diff --git a/SPEC.md b/SPEC.md index 03f3f32..3fdaad7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -161,6 +161,12 @@ challenge --> accept --> action* --> end | `receiver` | Receiver validates on receipt; rejects invalid | Sends `error` action | | `both` | Both sides validate independently | Receiver sends `error` if validation disagrees | +For turn-based games, a move against an `active` session whose stored `turn` +is unset MUST be rejected: every accept handler assigns `turn`, so an empty +value means corrupted or desynchronized state, and validators fail closed +rather than guess. Claimed terminal state (`x`/`r`/`w`) is never trusted — +receivers recompute it from their replayed local state and reject mismatches. + --- ## 9. Error Actions @@ -339,7 +345,7 @@ Chess (`chess.1`) is the built-in chess implementation. App ID `"chess"`, versio - **UCI moves only.** Every move is a UCI string (`e2e4`, `e7e8q`). FEN, SAN, and board snapshots are never transmitted. - **State by replay.** Each peer reconstructs the current position by replaying the UCI history on the starting FEN. Both peers do this independently (validation = `both`); a divergence is a protocol error. - **Terminal reasons are 2-3 char codes.** Keeps move envelopes well under the 200-byte budget. -- **Threefold repetition and the fifty-move rule are claim-based.** A peer must explicitly send `draw_offer` with the appropriate reason; the rule is not auto-detected mid-game. +- **Threefold repetition and the fifty-move rule are claim-based.** A peer must explicitly send `draw_offer` with the appropriate reason (`3fr`/`50m`); the rule is not auto-detected mid-game. The receiver verifies the claim against its replayed position: a valid claim terminates the game as a draw immediately (FIDE semantics — no `draw_accept` round-trip), while an invalid claim degrades to a plain draw offer. The claimant pre-terminates its local session on a valid claim. ### Payload Schema @@ -365,7 +371,7 @@ The `w` key reuses the same character in two payload contexts. Receivers MUST di | `rsn` | Resignation | | `agr` | Draw by agreement | -A move that delivers checkmate carries `x="win"`, `r="cm"`, and `w` = the mating player's hash. A claim-based draw is sent as `draw_offer` with `r` set to the claim reason; the opponent responds with `draw_accept` (which transitions the session to `completed` with terminal=`draw`). +A move that delivers checkmate carries `x="win"`, `r="cm"`, and `w` = the mating player's hash. A claim-based draw is sent as `draw_offer` with `r` set to the claim reason; a receiver that verifies the claim against its replayed position transitions directly to `completed` with terminal=`draw` (no `draw_accept` round-trip). A plain `draw_offer` (no claim reason, or an invalid claim) still requires `draw_accept`. ### Engine Notes diff --git a/src/apps/chess.rs b/src/apps/chess.rs index b824e9c..e76b93c 100644 --- a/src/apps/chess.rs +++ b/src/apps/chess.rs @@ -1125,8 +1125,13 @@ impl ChessApp { ); } + // Empty turn on an active session is invalid state — fail closed + // (canonical per SPEC; matches lrgp-py). let turn = meta_str(meta, "turn"); - if !turn.is_empty() && turn != sender_hash { + if turn.is_empty() { + return (false, Some("Turn is required before moves".into())); + } + if turn != sender_hash { return (false, Some("Not your turn".into())); } @@ -2047,6 +2052,27 @@ mod tests { assert_eq!(value_as_u64(out.payload.get(KEY_PLY).unwrap()).unwrap(), 1); } + /// T1-13: an active session with an empty `turn` is invalid state; a + /// move against it must fail closed (canonical per SPEC, matches lrgp-py). + #[test] + fn test_validate_move_rejects_empty_turn() { + let _coin = pin_coin(true); + let app = ChessApp::new(); + setup_active(&app, "alice", "bob"); + + let mut session = app.get_session("g1", "bob").unwrap(); + session + .metadata + .insert("turn".into(), JsonValue::String("".into())); + + let mut p = HashMap::new(); + p.insert(KEY_MOVE.to_string(), rmpv::Value::String("e2e4".into())); + p.insert(KEY_PLY.to_string(), rmpv::Value::Integer(0.into())); + let (valid, err) = app.validate_move(&session, &p, "alice"); + assert!(!valid); + assert!(err.unwrap().contains("Turn is required")); + } + /// T1-5: a 1-based first move (the old rs emission) must be rejected, /// a 0-based one accepted. #[test] diff --git a/src/apps/tictactoe.rs b/src/apps/tictactoe.rs index 86ecd29..891c07f 100644 --- a/src/apps/tictactoe.rs +++ b/src/apps/tictactoe.rs @@ -672,9 +672,13 @@ impl TicTacToeApp { ); } - // 2. Must be sender's turn + // 2. Must be sender's turn. Empty turn on an active session is + // invalid state — fail closed (canonical per SPEC; matches lrgp-py). let turn = meta_str(meta, "turn"); - if !turn.is_empty() && turn != sender_hash { + if turn.is_empty() { + return (false, Some("Turn is required before moves".into())); + } + if turn != sender_hash { return (false, Some("Not your turn".into())); } @@ -1486,4 +1490,28 @@ mod tests { assert!(!valid); assert!(msg.unwrap().contains("not found")); } + + /// T1-13: an active session with an empty `turn` is invalid state; a + /// move against it must fail closed (canonical per SPEC, matches lrgp-py). + #[test] + fn test_validate_move_rejects_empty_turn() { + let app = TicTacToeApp::new(); + app.handle_outgoing("s1", CMD_CHALLENGE, &HashMap::new(), "alice"); + app.handle_incoming("s1", CMD_CHALLENGE, &HashMap::new(), "alice", "bob"); + let accept = app.handle_outgoing("s1", CMD_ACCEPT, &HashMap::new(), "bob"); + app.handle_incoming("s1", CMD_ACCEPT, &accept.payload, "bob", "alice"); + + let mut session = app.get_session("s1", "alice").unwrap(); + session + .metadata + .insert("turn".into(), JsonValue::String("".into())); + + let mut p = HashMap::new(); + p.insert("i".into(), rmpv::Value::Integer(0.into())); + p.insert("b".into(), rmpv::Value::String("X________".into())); + p.insert("n".into(), rmpv::Value::Integer(1.into())); + let (valid, msg) = app.validate_move(&session, &p, "bob"); + assert!(!valid); + assert!(msg.unwrap().contains("Turn is required")); + } }