games: fail closed on unset turn; SPEC covers claim and turn validation

This commit is contained in:
DeFiDude 2026-06-10 05:01:38 -06:00
parent 221af53678
commit 2b9aba5e48
3 changed files with 65 additions and 5 deletions

10
SPEC.md
View file

@ -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

View file

@ -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]

View file

@ -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"));
}
}