formation-chess-web 0.2.0

Rules engine and text notation for Formation Chess (阵棋), a strategy board game where piece formations reshape nearby abilities
use formation_chess_core::action::Action;
use formation_chess_core::action::GameResult;
use formation_chess_core::action::Move;
use formation_chess_core::action::Place;
use formation_chess_core::board::Board;
use formation_chess_core::game::Game;
use formation_chess_core::game::GameConfig;
use formation_chess_core::piece::Color;
use formation_chess_core::piece::Piece;
use formation_chess_core::piece::Player;
use serde::Deserialize;
use serde::Serialize;

#[derive(Debug, Clone, Deserialize)]
pub struct ApiPieceRef {
    pub name: char,
    pub color: String,
}

impl ApiPieceRef {
    pub fn to_piece(&self) -> Result<Piece, String> {
        let color = parse_color(&self.color)?;
        Piece::lookup(self.name, color)
            .ok_or_else(|| format!("unknown piece: {} {:?}", self.name, self.color))
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct ApiPiece {
    pub name: char,
    pub color: String,
    pub formation: u8,
}

impl ApiPiece {
    pub fn from_piece(piece: Piece) -> Self {
        ApiPiece {
            name: piece.name,
            color: color_to_str(piece.color),
            formation: piece.formation.points,
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct ApiBoard {
    pub width: u8,
    pub height: u8,
    pub cells: Vec<Vec<Option<ApiPiece>>>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ApiState {
    pub player: String,
    pub result: String,
    pub board: ApiBoard,
    pub red_pool: Vec<ApiPiece>,
    pub black_pool: Vec<ApiPiece>,
    pub white_pool: u8,
}

impl ApiState {
    pub fn from_game(game: &Game) -> Self {
        let board = game.board();
        let cells: Vec<Vec<Option<ApiPiece>>> = (0 .. board.height())
            .map(|y| {
                (0 .. board.width()).map(|x| board.get((x, y)).map(ApiPiece::from_piece)).collect()
            })
            .collect();

        ApiState {
            player: player_to_str(game.player()),
            result: result_to_str(game.result()),
            board: ApiBoard { width: board.width(), height: board.height(), cells },
            red_pool: game.red_pool().iter().copied().map(ApiPiece::from_piece).collect(),
            black_pool: game.black_pool().iter().copied().map(ApiPiece::from_piece).collect(),
            white_pool: game.white_pool(),
        }
    }
}

#[derive(Debug, Serialize)]
pub struct ApiActionResponse {
    #[serde(flatten)]
    pub state: ApiState,
    pub error: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ApiActionRequest {
    Place { piece: ApiPieceRef, to: [u8; 2] },
    Move { from: [u8; 2], to: [u8; 2] },
    Capture { from: [u8; 2], to: [u8; 2] },
    Push { from: [u8; 2], to: [u8; 2] },
    Pass,
    Resign,
}

impl ApiActionRequest {
    pub fn to_action(&self, current_player: Player) -> Result<Action, String> {
        match self {
            ApiActionRequest::Place { piece, to } => {
                Ok(Action::Place(Place { piece: piece.to_piece()?, to: (to[0], to[1]) }))
            },
            ApiActionRequest::Move { from, to } => {
                Ok(Action::Move(Move { from: (from[0], from[1]), to: (to[0], to[1]) }))
            },
            ApiActionRequest::Capture { from, to } => {
                Ok(Action::Capture(Move { from: (from[0], from[1]), to: (to[0], to[1]) }))
            },
            ApiActionRequest::Push { from, to } => {
                Ok(Action::Push(Move { from: (from[0], from[1]), to: (to[0], to[1]) }))
            },
            ApiActionRequest::Pass => Ok(Action::Pass(current_player)),
            ApiActionRequest::Resign => Ok(Action::Resign(current_player)),
        }
    }
}

#[derive(Debug, Serialize)]
pub struct ApiMoveHint {
    #[serde(rename = "type")]
    pub action_type: String,
    pub to: [u8; 2],
}

#[derive(Debug, Serialize)]
pub struct ApiHintsResponse {
    pub moves: Option<Vec<ApiMoveHint>>,
    pub placements: Option<Vec<[u8; 2]>>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum ApiHintsRequest {
    Piece {
        x: u8,
        y: u8,
    },
    White {
        #[expect(dead_code)]
        white: bool,
    },
}

#[derive(Debug, Deserialize)]
pub struct ApiNewBoard {
    pub width: u8,
    pub height: u8,
    #[serde(default)]
    pub cells: Option<Vec<Vec<Option<ApiPieceRef>>>>,
}

#[derive(Debug, Deserialize)]
pub struct ApiNewRequest {
    #[serde(default)]
    pub notation: Option<String>,
    #[serde(default)]
    pub board: Option<ApiNewBoard>,
    #[serde(default)]
    pub red_pool: Vec<String>,
    #[serde(default)]
    pub black_pool: Vec<String>,
    #[serde(default)]
    pub white_pool: u8,
    #[serde(default)]
    pub player: Option<String>,
}

impl ApiNewRequest {
    pub fn to_game_config(&self) -> Result<GameConfig, String> {
        if let Some(notation) = &self.notation {
            let config: GameConfig =
                notation.parse().map_err(|e: String| format!("invalid notation: {e}"))?;
            return Ok(config);
        }

        let api_board = self
            .board
            .as_ref()
            .ok_or_else(|| "board is required when notation is not provided".to_string())?;

        if api_board.width == 0 || api_board.width > 16 {
            return Err(format!("board width must be 1..=16, got {}", api_board.width));
        }
        if api_board.height == 0 || api_board.height > 16 {
            return Err(format!("board height must be 1..=16, got {}", api_board.height));
        }

        let mut board = Board::new(api_board.width, api_board.height);

        let has_cells = api_board.cells.is_some();
        if let Some(cells) = &api_board.cells {
            for (y, row) in cells.iter().enumerate() {
                let y = y as u8;
                if y >= api_board.height {
                    break;
                }
                for (x, cell) in row.iter().enumerate() {
                    let x = x as u8;
                    if x >= api_board.width {
                        break;
                    }
                    if let Some(piece_ref) = cell {
                        board[(x, y)] = Some(piece_ref.to_piece()?);
                    }
                }
            }
        }

        let red_pool = if !has_cells && self.red_pool.is_empty() && self.black_pool.is_empty() {
            Piece::RED_PLAYER_PIECES.to_vec()
        } else {
            self.red_pool
                .iter()
                .map(|name| {
                    let c =
                        name.chars().next().ok_or_else(|| format!("empty piece name: {name}"))?;
                    Piece::lookup(c, Color::Red).ok_or_else(|| format!("unknown red piece: {name}"))
                })
                .collect::<Result<Vec<_>, _>>()?
        };

        let black_pool = if !has_cells && self.red_pool.is_empty() && self.black_pool.is_empty() {
            Piece::BLACK_PLAYER_PIECES.to_vec()
        } else {
            self.black_pool
                .iter()
                .map(|name| {
                    let c =
                        name.chars().next().ok_or_else(|| format!("empty piece name: {name}"))?;
                    Piece::lookup(c, Color::Black)
                        .ok_or_else(|| format!("unknown black piece: {name}"))
                })
                .collect::<Result<Vec<_>, _>>()?
        };

        let player = match self.player.as_deref() {
            None | Some("Red") => Player::Red,
            Some("Black") => Player::Black,
            Some(s) => return Err(format!("invalid player: {s}")),
        };

        Ok(GameConfig {
            player,
            board,
            red_pool,
            black_pool,
            white: Piece::WHITE,
            white_pool: self.white_pool,
            result: GameResult::Unfinished,
        })
    }
}

#[derive(Debug, Serialize)]
pub struct ApiRulesResponse {
    pub text: String,
}

#[derive(Debug, Serialize)]
pub struct ApiError {
    pub error: String,
}

pub fn color_to_str(color: Color) -> String {
    match color {
        Color::Red => "Red".into(),
        Color::Black => "Black".into(),
        Color::White => "White".into(),
    }
}

pub fn parse_color(s: &str) -> Result<Color, String> {
    match s {
        "Red" => Ok(Color::Red),
        "Black" => Ok(Color::Black),
        "White" => Ok(Color::White),
        _ => Err(format!("unknown color: {s}")),
    }
}

pub fn player_to_str(player: Player) -> String {
    match player {
        Player::Red => "Red".into(),
        Player::Black => "Black".into(),
    }
}

pub fn result_to_str(result: GameResult) -> String {
    match result {
        GameResult::Unfinished => "Unfinished".into(),
        GameResult::RedWin => "RedWin".into(),
        GameResult::BlackWin => "BlackWin".into(),
        GameResult::Draw => "Draw".into(),
    }
}

pub fn action_type_str(action: &Action) -> &'static str {
    match action {
        Action::Move(_) => "move",
        Action::Capture(_) => "capture",
        Action::Push(_) => "push",
        _ => "move", // valid_moves never returns other variants; safe fallback
    }
}