diff --git a/Cargo.lock b/Cargo.lock index 07b0694..652076d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" name = "ai_nft" version = "0.1.0" dependencies = [ + "emergency_circuit_breaker", "soroban-sdk", ] @@ -507,6 +508,7 @@ name = "game_contract" version = "0.1.0" dependencies = [ "ed25519-dalek", + "emergency_circuit_breaker", "rand", "soroban-sdk", ] diff --git a/backend/modules/api/src/games.rs b/backend/modules/api/src/games.rs index 222e26a..1142b93 100644 --- a/backend/modules/api/src/games.rs +++ b/backend/modules/api/src/games.rs @@ -5,8 +5,8 @@ use actix_web::{ }; use db::DbPool; use dto::games::{ - CompleteGameRequest, CompleteGameResponse, CreateGameRequest, GameStatus, ImportGameRequest, - ImportGameResponse, JoinGameRequest, ListGamesQuery, MakeMoveRequest, + CompleteGameRequest, CompleteGameResponse, CreateGameRequest, ExportPgnQuery, GameStatus, + ImportGameRequest, ImportGameResponse, JoinGameRequest, ListGamesQuery, MakeMoveRequest, }; use error::error::ApiError; use security::jwt::Claims; @@ -59,12 +59,12 @@ pub async fn create_game( Err(resp) => return resp, }; - match GameService::create_game(db.get_ref(), creator_id, payload.0).await { + match GameService::create_game(pool.get_ref(), creator_id, payload.0).await { Ok(game_dto) => { // Track game creation metric increment_active_games(); increment_game_events("created"); - + HttpResponse::Created().json(json!({ "message": "Game created successfully", "data": { "game": game_dto } @@ -365,12 +365,12 @@ pub async fn abandon_game( let game_id = id.into_inner(); - match GameService::abandon_game(db.get_ref(), game_id, player_id).await { + match GameService::abandon_game(pool.get_ref(), game_id, player_id).await { Ok(_) => { // Track game abandonment metric decrement_active_games(); increment_game_events("abandoned"); - + HttpResponse::Ok().json(json!({ "message": "Game abandoned successfully", "data": {} @@ -482,6 +482,56 @@ pub async fn import_game( } } +// --------------------------------------------------------------------------- +// GET /v1/games/{id}/pgn — READ → replica pool +// --------------------------------------------------------------------------- +#[utoipa::path( + get, + path = "/v1/games/{id}/pgn", + params( + ("id" = Uuid, Path, description = "Game ID in UUID format", format = "uuid"), + ("include_analysis" = Option, Query, description = "Include %clk and %eval annotations in the movetext") + ), + responses( + (status = 200, description = "PGN file for the game (text/plain, application/x-chess-pgn)"), + (status = 404, description = "Game not found", body = NotFoundResponse), + (status = 422, description = "Stored game history could not be formatted as valid PGN", body = InvalidCredentialsResponse) + ), + security(("jwt_auth" = [])), + tag = "Games" +)] +#[get("/{id}/pgn")] +pub async fn export_game_pgn( + id: Path, + query: Query, + pool: web::Data, +) -> HttpResponse { + let game_id = id.into_inner(); + let include_analysis = query.include_analysis.unwrap_or(false); + + match GameService::export_pgn(pool.get_ref(), game_id, include_analysis).await { + Ok(pgn_text) => HttpResponse::Ok() + .content_type("application/x-chess-pgn; charset=utf-8") + .insert_header(( + "Content-Disposition", + format!("attachment; filename=\"game-{}.pgn\"", game_id), + )) + .body(pgn_text), + Err(ApiError::NotFound(_)) => HttpResponse::NotFound().json(json!({ + "message": "Game not found" + })), + Err(ApiError::BadRequest(msg)) => HttpResponse::UnprocessableEntity().json(json!({ + "message": msg + })), + Err(e) => { + eprintln!("export_game_pgn error: {e}"); + HttpResponse::InternalServerError().json(json!({ + "message": "Failed to export PGN" + })) + } + } +} + // --------------------------------------------------------------------------- // PUT /v1/games/{id}/complete — WRITE (transaction) → primary pool // --------------------------------------------------------------------------- @@ -570,7 +620,7 @@ pub async fn complete_game( // Track game completion metric decrement_active_games(); increment_game_events("completed"); - + let white_change = white_new_rating - white_old_rating; let black_change = black_new_rating - black_old_rating; @@ -619,4 +669,4 @@ pub async fn complete_game( }) } } -} +} \ No newline at end of file diff --git a/backend/modules/chess/src/pgn.rs b/backend/modules/chess/src/pgn.rs index 1785e1c..e96728b 100644 --- a/backend/modules/chess/src/pgn.rs +++ b/backend/modules/chess/src/pgn.rs @@ -1,10 +1,11 @@ //! PGN (Portable Game Notation) Parser Module //! //! This module provides functionality to parse and validate PGN strings, -//! enabling users to import games from other chess platforms. +//! enabling users to import games from other chess platforms, and to build +//! PGN strings from stored game data for export. use regex::Regex; -use shakmaty::{san::San, Chess, Position}; +use shakmaty::{san::San, Chess, Move, Position}; use std::collections::HashMap; use thiserror::Error; @@ -168,7 +169,7 @@ fn parse_moves(move_text: &str) -> Vec { // Split into tokens let tokens: Vec<&str> = without_variations.split_whitespace().collect(); - + // Strip a leading move-number prefix from each token, then drop results and // empty tokens. The prefix regex has no trailing `$` anchor, so it also handles // the compact form where the number is glued to the move (e.g. `1.e4`, @@ -249,6 +250,268 @@ pub fn validate_game(parsed: &ParsedGame) -> Result { }) } +// --------------------------------------------------------------------------- +// Export support +// --------------------------------------------------------------------------- + +/// Headers required to export a game as PGN. Distinct from `PgnHeaders` +/// (which is populated while *parsing* an incoming PGN) because export needs +/// FIDE-recommended supplemental tags that we don't require on import. +#[derive(Debug, Clone)] +pub struct ExportHeaders { + pub event: String, + pub site: String, + /// "YYYY.MM.DD" per the PGN spec, or "????.??.??" if unknown. + pub date: String, + /// Round number, or "?" if not applicable. + pub round: String, + pub white: String, + pub black: String, + pub result: GameResult, + pub white_elo: Option, + pub black_elo: Option, + /// e.g. "300+3" (300s base + 3s increment), or "-" if untimed. + pub time_control: Option, + /// e.g. "Normal", "Time forfeit", "Abandoned". + pub termination: Option, +} + +impl Default for ExportHeaders { + fn default() -> Self { + Self { + event: "?".to_string(), + site: "?".to_string(), + date: "????.??.??".to_string(), + round: "?".to_string(), + white: String::new(), + black: String::new(), + result: GameResult::default(), + white_elo: None, + black_elo: None, + time_control: None, + termination: None, + } + } +} + +/// A single played move plus whatever analysis annotations are available for +/// it. `san` should be the *bare* SAN (no check/mate suffix) — the builder +/// recomputes `+`/`#` itself from the replayed position so we never emit an +/// incorrect suffix regardless of what was originally stored. +#[derive(Debug, Clone, Default)] +pub struct MoveAnnotation { + pub san: String, + /// Clock remaining *after* this move, formatted "H:MM:SS" (PGN `%clk`). + pub clock: Option, + /// Engine evaluation in centipawns from White's perspective. + pub eval_centipawns: Option, + /// Mate-in-N eval; takes precedence over `eval_centipawns` when present. + pub eval_mate: Option, + /// Move classification annotation glyph, e.g. "?!", "??", "!!". + pub classification: Option, +} + +/// Builds a spec-compliant PGN string from headers + annotated moves. +pub struct PgnBuilder { + headers: ExportHeaders, + moves: Vec, + include_analysis: bool, +} + +const MOVETEXT_LINE_WIDTH: usize = 80; + +impl PgnBuilder { + pub fn new(headers: ExportHeaders, moves: Vec, include_analysis: bool) -> Self { + Self { + headers, + moves, + include_analysis, + } + } + + /// Escapes `"` and `\` inside a tag value, per the PGN spec. + fn escape_tag_value(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") + } + + fn tag(name: &str, value: &str) -> String { + format!("[{} \"{}\"]\n", name, Self::escape_tag_value(value)) + } + + /// Seven Tag Roster first, then FIDE-recommended supplemental tags. + fn format_headers(&self) -> String { + let mut out = String::new(); + out.push_str(&Self::tag("Event", &self.headers.event)); + out.push_str(&Self::tag("Site", &self.headers.site)); + out.push_str(&Self::tag("Date", &self.headers.date)); + out.push_str(&Self::tag("Round", &self.headers.round)); + out.push_str(&Self::tag("White", &self.headers.white)); + out.push_str(&Self::tag("Black", &self.headers.black)); + out.push_str(&Self::tag("Result", self.headers.result.to_pgn_string())); + + if let Some(elo) = self.headers.white_elo { + out.push_str(&Self::tag("WhiteElo", &elo.to_string())); + } + if let Some(elo) = self.headers.black_elo { + out.push_str(&Self::tag("BlackElo", &elo.to_string())); + } + if let Some(tc) = &self.headers.time_control { + out.push_str(&Self::tag("TimeControl", tc)); + } + if let Some(term) = &self.headers.termination { + out.push_str(&Self::tag("Termination", term)); + } + + out + } + + /// Replays every move from the start position, recomputing the correct + /// check (`+`) / checkmate (`#`) suffix instead of trusting whatever + /// suffix (if any) was stored, so export can never emit invalid SAN. + fn recompute_check_suffixes(moves: &[MoveAnnotation]) -> Result, PgnError> { + let mut position: Chess = Chess::default(); + let mut out = Vec::with_capacity(moves.len()); + + for (idx, ann) in moves.iter().enumerate() { + let move_number = (idx / 2) + 1; + + let san: San = ann.san.parse().map_err(|_| PgnError::IllegalMove { + move_number, + move_text: ann.san.clone(), + reason: "Invalid move notation".to_string(), + })?; + + let chess_move: Move = san.to_move(&position).map_err(|_| PgnError::IllegalMove { + move_number, + move_text: ann.san.clone(), + reason: "Move is not legal in this position".to_string(), + })?; + + position = position + .play(&chess_move) + .map_err(|_| PgnError::IllegalMove { + move_number, + move_text: ann.san.clone(), + reason: "Move leaves king in check".to_string(), + })?; + + let suffix = if position.is_checkmate() { + "#" + } else if position.is_check() { + "+" + } else { + "" + }; + + out.push(format!("{}{}", San::from(&chess_move), suffix)); + } + + Ok(out) + } + + fn format_eval_tag(ann: &MoveAnnotation) -> Option { + if let Some(mate) = ann.eval_mate { + Some(format!("[%eval #{}]", mate)) + } else { + ann.eval_centipawns + .map(|cp| format!("[%eval {:.2}]", cp as f64 / 100.0)) + } + } + + fn format_clk_tag(clock: &str) -> String { + format!("[%clk {}]", clock) + } + + /// Builds the movetext, wrapping at `MOVETEXT_LINE_WIDTH` columns as + /// recommended by the PGN spec. + fn format_movetext(&self) -> Result { + let sans = Self::recompute_check_suffixes(&self.moves)?; + + let mut tokens: Vec = Vec::new(); + + for (idx, (san, ann)) in sans.iter().zip(self.moves.iter()).enumerate() { + let is_white = idx % 2 == 0; + let move_number = (idx / 2) + 1; + + if is_white { + tokens.push(format!("{}.", move_number)); + } else if idx == 0 { + // Game starting on a black move (rare, but handle it). + tokens.push(format!("{}...", move_number)); + } + + let mut move_token = san.clone(); + if let Some(class) = &ann.classification { + move_token.push_str(class); + } + tokens.push(move_token); + + if self.include_analysis { + let mut annotation_parts = Vec::new(); + if let Some(eval_tag) = Self::format_eval_tag(ann) { + annotation_parts.push(eval_tag); + } + if let Some(clk) = &ann.clock { + annotation_parts.push(Self::format_clk_tag(clk)); + } + if !annotation_parts.is_empty() { + tokens.push(format!("{{ {} }}", annotation_parts.join(" "))); + } + } + } + + tokens.push(self.headers.result.to_pgn_string().to_string()); + + // Wrap tokens into lines no wider than MOVETEXT_LINE_WIDTH. + let mut lines = Vec::new(); + let mut current_line = String::new(); + for token in tokens { + if current_line.is_empty() { + current_line = token; + } else if current_line.len() + 1 + token.len() <= MOVETEXT_LINE_WIDTH { + current_line.push(' '); + current_line.push_str(&token); + } else { + lines.push(current_line); + current_line = token; + } + } + if !current_line.is_empty() { + lines.push(current_line); + } + + Ok(lines.join("\n")) + } + + pub fn build(&self) -> Result { + let headers = self.format_headers(); + let movetext = self.format_movetext()?; + // Blank line separates the tag section from the movetext, per spec. + Ok(format!("{}\n{}\n", headers, movetext)) + } +} + +/// Formats a full PGN export string from headers + annotated moves. +/// +/// This is the pure, DB-free formatting entry point — `GameService` +/// (service crate) is responsible for loading the game and its move +/// history and converting them into `ExportHeaders` / `MoveAnnotation` +/// before calling this. +pub fn export_pgn( + headers: ExportHeaders, + moves: Vec, + include_analysis: bool, +) -> Result { + if headers.white.is_empty() { + return Err(PgnError::MissingHeader("White".to_string())); + } + if headers.black.is_empty() { + return Err(PgnError::MissingHeader("Black".to_string())); + } + + PgnBuilder::new(headers, moves, include_analysis).build() +} + #[cfg(test)] mod tests { use super::*; @@ -391,4 +654,170 @@ mod tests { GameResult::Ongoing ); } -} + + // ----------------------------------------------------------------- + // Export tests + // ----------------------------------------------------------------- + + fn sample_export_headers() -> ExportHeaders { + ExportHeaders { + event: "Rated Blitz Game".to_string(), + site: "chess.example.com".to_string(), + date: "2026.08.27".to_string(), + round: "1".to_string(), + white: "Magnus Carlsen".to_string(), + black: "Hikaru Nakamura".to_string(), + result: GameResult::WhiteWins, + white_elo: Some(2839), + black_elo: Some(2802), + time_control: Some("300+3".to_string()), + termination: Some("Normal".to_string()), + } + } + + fn sample_moves() -> Vec { + vec![ + MoveAnnotation { + san: "e4".to_string(), + clock: Some("0:04:58".to_string()), + ..Default::default() + }, + MoveAnnotation { + san: "e5".to_string(), + clock: Some("0:04:57".to_string()), + ..Default::default() + }, + MoveAnnotation { + san: "Nf3".to_string(), + clock: Some("0:04:55".to_string()), + eval_centipawns: Some(32), + ..Default::default() + }, + MoveAnnotation { + san: "Nc6".to_string(), + clock: Some("0:04:53".to_string()), + eval_centipawns: Some(28), + ..Default::default() + }, + MoveAnnotation { + san: "Bb5".to_string(), + clock: Some("0:04:50".to_string()), + classification: Some("!".to_string()), + ..Default::default() + }, + ] + } + + #[test] + fn test_export_pgn_headers_present() { + let pgn = export_pgn(sample_export_headers(), sample_moves(), false).unwrap(); + assert!(pgn.contains("[Event \"Rated Blitz Game\"]")); + assert!(pgn.contains("[Site \"chess.example.com\"]")); + assert!(pgn.contains("[Date \"2026.08.27\"]")); + assert!(pgn.contains("[Round \"1\"]")); + assert!(pgn.contains("[White \"Magnus Carlsen\"]")); + assert!(pgn.contains("[Black \"Hikaru Nakamura\"]")); + assert!(pgn.contains("[WhiteElo \"2839\"]")); + assert!(pgn.contains("[BlackElo \"2802\"]")); + assert!(pgn.contains("[TimeControl \"300+3\"]")); + assert!(pgn.contains("[Termination \"Normal\"]")); + assert!(pgn.contains("[Result \"1-0\"]")); + } + + #[test] + fn test_export_pgn_roundtrips_through_parser() { + let pgn = export_pgn(sample_export_headers(), sample_moves(), false).unwrap(); + let parsed = parse_pgn(&pgn).unwrap(); + let validated = validate_game(&parsed).unwrap(); + assert!(validated.is_valid); + assert_eq!(validated.moves, vec!["e4", "e5", "Nf3", "Nc6", "Bb5"]); + } + + #[test] + fn test_export_pgn_includes_clock_and_eval_when_requested() { + let pgn = export_pgn(sample_export_headers(), sample_moves(), true).unwrap(); + assert!(pgn.contains("[%clk 0:04:58]")); + assert!(pgn.contains("[%eval 0.32]")); + // Result string still parses cleanly with annotations present. + let parsed = parse_pgn(&pgn).unwrap(); + assert!(validate_game(&parsed).is_ok()); + } + + #[test] + fn test_export_pgn_omits_analysis_when_not_requested() { + let pgn = export_pgn(sample_export_headers(), sample_moves(), false).unwrap(); + assert!(!pgn.contains("%clk")); + assert!(!pgn.contains("%eval")); + } + + #[test] + fn test_export_pgn_recomputes_check_suffix() { + // Scholar's mate — bare SANs supplied with no +/# suffix; the + // builder must recompute "Qxf7#" itself. + let moves = vec![ + MoveAnnotation { san: "e4".to_string(), ..Default::default() }, + MoveAnnotation { san: "e5".to_string(), ..Default::default() }, + MoveAnnotation { san: "Bc4".to_string(), ..Default::default() }, + MoveAnnotation { san: "Nc6".to_string(), ..Default::default() }, + MoveAnnotation { san: "Qh5".to_string(), ..Default::default() }, + MoveAnnotation { san: "Nf6".to_string(), ..Default::default() }, + MoveAnnotation { san: "Qxf7".to_string(), ..Default::default() }, + ]; + let mut headers = sample_export_headers(); + headers.result = GameResult::WhiteWins; + + let pgn = export_pgn(headers, moves, false).unwrap(); + assert!(pgn.contains("Qxf7#")); + } + + #[test] + fn test_export_pgn_check_suffix_non_mating() { + // A mid-game check that is not mate should get "+", never "#". + let moves = vec![ + MoveAnnotation { san: "e4".to_string(), ..Default::default() }, + MoveAnnotation { san: "e6".to_string(), ..Default::default() }, + MoveAnnotation { san: "Bb5".to_string(), ..Default::default() }, + ]; + let mut headers = sample_export_headers(); + headers.result = GameResult::Ongoing; + + let pgn = export_pgn(headers, moves, false).unwrap(); + assert!(pgn.contains("Bb5+")); + assert!(!pgn.contains("Bb5#")); + } + + #[test] + fn test_export_pgn_rejects_missing_white() { + let mut headers = sample_export_headers(); + headers.white = String::new(); + let result = export_pgn(headers, sample_moves(), false); + assert!(matches!(result, Err(PgnError::MissingHeader(_)))); + } + + #[test] + fn test_export_pgn_rejects_missing_black() { + let mut headers = sample_export_headers(); + headers.black = String::new(); + let result = export_pgn(headers, sample_moves(), false); + assert!(matches!(result, Err(PgnError::MissingHeader(_)))); + } + + #[test] + fn test_export_pgn_rejects_illegal_move_sequence() { + let moves = vec![MoveAnnotation { + san: "Ke3".to_string(), + ..Default::default() + }]; + let result = export_pgn(sample_export_headers(), moves, false); + assert!(matches!(result, Err(PgnError::IllegalMove { .. }))); + } + + #[test] + fn test_export_pgn_handles_no_moves() { + let mut headers = sample_export_headers(); + headers.result = GameResult::Ongoing; + let pgn = export_pgn(headers, vec![], false).unwrap(); + assert!(pgn.contains("[Result \"*\"]")); + assert!(pgn.trim_end().ends_with('*')); + } +} \ No newline at end of file