From 62d11885987f8028143b42d4392d6a1febe94149 Mon Sep 17 00:00:00 2001 From: folkengine Date: Mon, 11 May 2026 19:00:21 -0700 Subject: [PATCH] fix(game): form book on non-matching draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_draw only checked for a completed book when the drawn card matched the asked rank. A non-matching draw can still complete a book of the drawn card's rank, leaving the player stuck holding all four cards for the rest of the game. Fix by checking the drawn card's rank in both branches. Summary The defect is in gfcore, not gfarena. gfarena's src/lib.rs is just a WASM shim re-exporting gfcore::wasm_api. Root cause: Game::handle_draw in src/game/state.rs only checked for a completed book when the drawn card matched the asked rank. A non-matching draw can still complete a book of the drawn card's own rank (player held three of X, asked for Y, drew the fourth X). Evidence in the recorded game: Player 0 ("You") held 4♦, 4♣ from the deal, picked up 4♥ from Lucky on turn 9, then on turn 17 asked Lucky for a 9 → Go Fish → drew 4♠ from the pile. All four 4s were in hand but books_after_turn stayed at [1,0,0,0], and P0 wasted turns 25 and 33 asking for 4s that no one else could possibly have. Fix: Capture the drawn card's rank and always pass it to check_and_collect_book regardless of matched. When matched is true and a book forms, the player goes again (BookCompleted); when matched is false, the turn still advances (preserves the standard "no match ends turn" rule) but the book is recorded. Files changed: - src/game/state.rs — Game::handle_draw rewritten around the draw/book branch - src/game/state.rs — added test_handle_draw_forms_book_on_non_matching_draw regression test next to the existing matched: false test --- Cargo.lock | 2 +- Cargo.toml | 2 +- data/gf-history-1778474562.yaml | 2010 +++++++++++++++++++++++++++++++ src/game/state.rs | 100 +- 4 files changed, 2090 insertions(+), 24 deletions(-) create mode 100644 data/gf-history-1778474562.yaml diff --git a/Cargo.lock b/Cargo.lock index 0effa14..0aa5fd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,7 +303,7 @@ dependencies = [ [[package]] name = "gfcore" -version = "0.0.4" +version = "0.0.5" dependencies = [ "cardpack", "console_error_panic_hook", diff --git a/Cargo.toml b/Cargo.toml index 7914d23..5646015 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "gfcore" description = "Go Fish card game engine" -version = "0.0.4" +version = "0.0.5" edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" diff --git a/data/gf-history-1778474562.yaml b/data/gf-history-1778474562.yaml new file mode 100644 index 0000000..5202bc5 --- /dev/null +++ b/data/gf-history-1778474562.yaml @@ -0,0 +1,2010 @@ +gfcore_version: 0.0.4 +format_version: 1 +games: +- id: a3a88149-e43c-4c6f-b73b-36b6c1d3fc2a + variant: Standard Go Fish + timestamp: '0' + players: + - You + - Harriet + - Bertram + - Lucky + turns: + - player: 0 + events: + - !Asked + asker: 0 + target: 1 + rank: T + - !GoFish + player: 0 + - !Drew + player: 0 + matched: false + books_after_turn: + - 0 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 1 + rank: + weight: 8 + pip_type: Rank + index: 'T' + symbol: 'T' + value: 10 + - Draw + - player: 1 + events: + - !Asked + asker: 1 + target: 0 + rank: K + - !GoFish + player: 1 + - !Drew + player: 1 + matched: false + books_after_turn: + - 0 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 0 + rank: + weight: 11 + pip_type: Rank + index: 'K' + symbol: 'K' + value: 10 + - Draw + - player: 2 + events: + - !Asked + asker: 2 + target: 1 + rank: J + - !Gave + from: 1 + to: 2 + rank: J + count: 1 + - !Asked + asker: 2 + target: 1 + rank: J + - !GoFish + player: 2 + - !Drew + player: 2 + matched: false + books_after_turn: + - 0 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 1 + rank: + weight: 9 + pip_type: Rank + index: 'J' + symbol: 'J' + value: 10 + - !Ask + target: 1 + rank: + weight: 9 + pip_type: Rank + index: 'J' + symbol: 'J' + value: 10 + - Draw + - player: 3 + events: + - !Asked + asker: 3 + target: 1 + rank: '8' + - !Gave + from: 1 + to: 3 + rank: '8' + count: 1 + - !Asked + asker: 3 + target: 1 + rank: '4' + - !GoFish + player: 3 + - !Drew + player: 3 + matched: false + books_after_turn: + - 0 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 1 + rank: + weight: 6 + pip_type: Rank + index: '8' + symbol: '8' + value: 8 + - !Ask + target: 1 + rank: + weight: 2 + pip_type: Rank + index: '4' + symbol: '4' + value: 4 + - Draw + - player: 0 + events: + - !Asked + asker: 0 + target: 2 + rank: J + - !Gave + from: 2 + to: 0 + rank: J + count: 2 + - !Asked + asker: 0 + target: 3 + rank: '9' + - !GoFish + player: 0 + - !Drew + player: 0 + matched: false + books_after_turn: + - 0 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 2 + rank: + weight: 9 + pip_type: Rank + index: 'J' + symbol: 'J' + value: 10 + - !Ask + target: 3 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - Draw + - player: 1 + events: + - !Asked + asker: 1 + target: 0 + rank: K + - !GoFish + player: 1 + - !Drew + player: 1 + matched: false + books_after_turn: + - 0 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 0 + rank: + weight: 11 + pip_type: Rank + index: 'K' + symbol: 'K' + value: 10 + - Draw + - player: 2 + events: + - !Asked + asker: 2 + target: 1 + rank: '5' + - !GoFish + player: 2 + - !Drew + player: 2 + matched: true + - !Asked + asker: 2 + target: 1 + rank: '5' + - !GoFish + player: 2 + - !Drew + player: 2 + matched: false + books_after_turn: + - 0 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 1 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - Draw + - !Ask + target: 1 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - Draw + - player: 3 + events: + - !Asked + asker: 3 + target: 1 + rank: A + - !Gave + from: 1 + to: 3 + rank: A + count: 1 + - !Asked + asker: 3 + target: 0 + rank: '2' + - !GoFish + player: 3 + - !Drew + player: 3 + matched: true + - !Asked + asker: 3 + target: 0 + rank: T + - !Gave + from: 0 + to: 3 + rank: T + count: 2 + - !Asked + asker: 3 + target: 2 + rank: '2' + - !GoFish + player: 3 + - !Drew + player: 3 + matched: false + books_after_turn: + - 0 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 1 + rank: + weight: 12 + pip_type: Rank + index: 'A' + symbol: 'A' + value: 11 + - !Ask + target: 0 + rank: + weight: 0 + pip_type: Rank + index: '2' + symbol: '2' + value: 2 + - Draw + - !Ask + target: 0 + rank: + weight: 8 + pip_type: Rank + index: 'T' + symbol: 'T' + value: 10 + - !Ask + target: 2 + rank: + weight: 0 + pip_type: Rank + index: '2' + symbol: '2' + value: 2 + - Draw + - player: 0 + events: + - !Asked + asker: 0 + target: 3 + rank: J + - !Gave + from: 3 + to: 0 + rank: J + count: 1 + - !Book + player: 0 + rank: J + - !Asked + asker: 0 + target: 3 + rank: '4' + - !Gave + from: 3 + to: 0 + rank: '4' + count: 1 + - !Asked + asker: 0 + target: 3 + rank: '3' + - !GoFish + player: 0 + - !Drew + player: 0 + matched: false + books_after_turn: + - 1 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 3 + rank: + weight: 9 + pip_type: Rank + index: 'J' + symbol: 'J' + value: 10 + - !Ask + target: 3 + rank: + weight: 2 + pip_type: Rank + index: '4' + symbol: '4' + value: 4 + - !Ask + target: 3 + rank: + weight: 1 + pip_type: Rank + index: '3' + symbol: '3' + value: 3 + - Draw + - player: 1 + events: + - !Asked + asker: 1 + target: 0 + rank: K + - !GoFish + player: 1 + - !Drew + player: 1 + matched: false + books_after_turn: + - 1 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 0 + rank: + weight: 11 + pip_type: Rank + index: 'K' + symbol: 'K' + value: 10 + - Draw + - player: 2 + events: + - !Asked + asker: 2 + target: 1 + rank: '5' + - !GoFish + player: 2 + - !Drew + player: 2 + matched: false + books_after_turn: + - 1 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 1 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - Draw + - player: 3 + events: + - !Asked + asker: 3 + target: 1 + rank: T + - !GoFish + player: 3 + - !Drew + player: 3 + matched: false + books_after_turn: + - 1 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 1 + rank: + weight: 8 + pip_type: Rank + index: 'T' + symbol: 'T' + value: 10 + - Draw + - player: 0 + events: + - !Asked + asker: 0 + target: 3 + rank: Q + - !GoFish + player: 0 + - !Drew + player: 0 + matched: false + books_after_turn: + - 1 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 3 + rank: + weight: 10 + pip_type: Rank + index: 'Q' + symbol: 'Q' + value: 10 + - Draw + - player: 1 + events: + - !Asked + asker: 1 + target: 0 + rank: K + - !Gave + from: 0 + to: 1 + rank: K + count: 1 + - !Asked + asker: 1 + target: 0 + rank: K + - !GoFish + player: 1 + - !Drew + player: 1 + matched: false + books_after_turn: + - 1 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 0 + rank: + weight: 11 + pip_type: Rank + index: 'K' + symbol: 'K' + value: 10 + - !Ask + target: 0 + rank: + weight: 11 + pip_type: Rank + index: 'K' + symbol: 'K' + value: 10 + - Draw + - player: 2 + events: + - !Asked + asker: 2 + target: 1 + rank: '5' + - !GoFish + player: 2 + - !Drew + player: 2 + matched: false + books_after_turn: + - 1 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 1 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - Draw + - player: 3 + events: + - !Asked + asker: 3 + target: 0 + rank: '7' + - !GoFish + player: 3 + - !Drew + player: 3 + matched: false + books_after_turn: + - 1 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 0 + rank: + weight: 5 + pip_type: Rank + index: '7' + symbol: '7' + value: 7 + - Draw + - player: 0 + events: + - !Asked + asker: 0 + target: 3 + rank: '9' + - !GoFish + player: 0 + - !Drew + player: 0 + matched: false + books_after_turn: + - 1 + - 0 + - 0 + - 0 + actions: + - !Ask + target: 3 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - Draw + - player: 1 + events: + - !Asked + asker: 1 + target: 0 + rank: K + - !GoFish + player: 1 + - !Drew + player: 1 + matched: true + - !Book + player: 1 + rank: K + - !Asked + asker: 1 + target: 0 + rank: '9' + - !Gave + from: 0 + to: 1 + rank: '9' + count: 1 + - !Asked + asker: 1 + target: 0 + rank: '9' + - !GoFish + player: 1 + - !Drew + player: 1 + matched: false + books_after_turn: + - 1 + - 1 + - 0 + - 0 + actions: + - !Ask + target: 0 + rank: + weight: 11 + pip_type: Rank + index: 'K' + symbol: 'K' + value: 10 + - Draw + - !Ask + target: 0 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - !Ask + target: 0 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - Draw + - player: 2 + events: + - !Asked + asker: 2 + target: 1 + rank: '5' + - !GoFish + player: 2 + - !Drew + player: 2 + matched: false + books_after_turn: + - 1 + - 1 + - 0 + - 0 + actions: + - !Ask + target: 1 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - Draw + - player: 3 + events: + - !Asked + asker: 3 + target: 2 + rank: '7' + - !Gave + from: 2 + to: 3 + rank: '7' + count: 1 + - !Asked + asker: 3 + target: 2 + rank: '7' + - !GoFish + player: 3 + - !Drew + player: 3 + matched: false + books_after_turn: + - 1 + - 1 + - 0 + - 0 + actions: + - !Ask + target: 2 + rank: + weight: 5 + pip_type: Rank + index: '7' + symbol: '7' + value: 7 + - !Ask + target: 2 + rank: + weight: 5 + pip_type: Rank + index: '7' + symbol: '7' + value: 7 + - Draw + - player: 0 + events: + - !Asked + asker: 0 + target: 2 + rank: '8' + - !Gave + from: 2 + to: 0 + rank: '8' + count: 1 + - !Asked + asker: 0 + target: 3 + rank: '8' + - !Gave + from: 3 + to: 0 + rank: '8' + count: 2 + - !Book + player: 0 + rank: '8' + - !Asked + asker: 0 + target: 3 + rank: Q + - !GoFish + player: 0 + - !Drew + player: 0 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 0 + actions: + - !Ask + target: 2 + rank: + weight: 6 + pip_type: Rank + index: '8' + symbol: '8' + value: 8 + - !Ask + target: 3 + rank: + weight: 6 + pip_type: Rank + index: '8' + symbol: '8' + value: 8 + - !Ask + target: 3 + rank: + weight: 10 + pip_type: Rank + index: 'Q' + symbol: 'Q' + value: 10 + - Draw + - player: 1 + events: + - !Asked + asker: 1 + target: 0 + rank: '9' + - !GoFish + player: 1 + - !Drew + player: 1 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 0 + actions: + - !Ask + target: 0 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - Draw + - player: 2 + events: + - !Asked + asker: 2 + target: 1 + rank: '5' + - !GoFish + player: 2 + - !Drew + player: 2 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 0 + actions: + - !Ask + target: 1 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - Draw + - player: 3 + events: + - !Asked + asker: 3 + target: 1 + rank: '2' + - !Gave + from: 1 + to: 3 + rank: '2' + count: 1 + - !Book + player: 3 + rank: '2' + - !Asked + asker: 3 + target: 0 + rank: '7' + - !GoFish + player: 3 + - !Drew + player: 3 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 1 + actions: + - !Ask + target: 1 + rank: + weight: 0 + pip_type: Rank + index: '2' + symbol: '2' + value: 2 + - !Ask + target: 0 + rank: + weight: 5 + pip_type: Rank + index: '7' + symbol: '7' + value: 7 + - Draw + - player: 0 + events: + - !Asked + asker: 0 + target: 3 + rank: '4' + - !GoFish + player: 0 + - !Drew + player: 0 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 1 + actions: + - !Ask + target: 3 + rank: + weight: 2 + pip_type: Rank + index: '4' + symbol: '4' + value: 4 + - Draw + - player: 1 + events: + - !Asked + asker: 1 + target: 0 + rank: '9' + - !GoFish + player: 1 + - !Drew + player: 1 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 1 + actions: + - !Ask + target: 0 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - Draw + - player: 2 + events: + - !Asked + asker: 2 + target: 1 + rank: '5' + - !GoFish + player: 2 + - !Drew + player: 2 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 1 + actions: + - !Ask + target: 1 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - Draw + - player: 3 + events: + - !Asked + asker: 3 + target: 2 + rank: T + - !Gave + from: 2 + to: 3 + rank: T + count: 1 + - !Book + player: 3 + rank: T + - !Asked + asker: 3 + target: 0 + rank: '6' + - !Gave + from: 0 + to: 3 + rank: '6' + count: 1 + - !Asked + asker: 3 + target: 1 + rank: '7' + - !Gave + from: 1 + to: 3 + rank: '7' + count: 1 + - !Book + player: 3 + rank: '7' + - !Asked + asker: 3 + target: 2 + rank: A + - !Gave + from: 2 + to: 3 + rank: A + count: 1 + - !Book + player: 3 + rank: A + - !Asked + asker: 3 + target: 2 + rank: '6' + - !Gave + from: 2 + to: 3 + rank: '6' + count: 2 + - !Book + player: 3 + rank: '6' + - !Asked + asker: 3 + target: 0 + rank: '5' + - !GoFish + player: 3 + - !Drew + player: 3 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 5 + actions: + - !Ask + target: 2 + rank: + weight: 8 + pip_type: Rank + index: 'T' + symbol: 'T' + value: 10 + - !Ask + target: 0 + rank: + weight: 4 + pip_type: Rank + index: '6' + symbol: '6' + value: 6 + - !Ask + target: 1 + rank: + weight: 5 + pip_type: Rank + index: '7' + symbol: '7' + value: 7 + - !Ask + target: 2 + rank: + weight: 12 + pip_type: Rank + index: 'A' + symbol: 'A' + value: 11 + - !Ask + target: 2 + rank: + weight: 4 + pip_type: Rank + index: '6' + symbol: '6' + value: 6 + - !Ask + target: 0 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - Draw + - player: 0 + events: + - !Asked + asker: 0 + target: 2 + rank: Q + - !Gave + from: 2 + to: 0 + rank: Q + count: 1 + - !Asked + asker: 0 + target: 3 + rank: Q + - !GoFish + player: 0 + - !Drew + player: 0 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 5 + actions: + - !Ask + target: 2 + rank: + weight: 10 + pip_type: Rank + index: 'Q' + symbol: 'Q' + value: 10 + - !Ask + target: 3 + rank: + weight: 10 + pip_type: Rank + index: 'Q' + symbol: 'Q' + value: 10 + - Draw + - player: 1 + events: + - !Asked + asker: 1 + target: 0 + rank: '9' + - !GoFish + player: 1 + - !Drew + player: 1 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 5 + actions: + - !Ask + target: 0 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - Draw + - player: 2 + events: + - !Asked + asker: 2 + target: 1 + rank: '5' + - !GoFish + player: 2 + - !Drew + player: 2 + matched: false + books_after_turn: + - 2 + - 1 + - 0 + - 5 + actions: + - !Ask + target: 1 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - Draw + - player: 3 + events: + - !Asked + asker: 3 + target: 2 + rank: '5' + - !Gave + from: 2 + to: 3 + rank: '5' + count: 3 + - !Book + player: 3 + rank: '5' + books_after_turn: + - 2 + - 1 + - 0 + - 6 + actions: + - !Ask + target: 2 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - player: 0 + events: + - !Asked + asker: 0 + target: 1 + rank: Q + - !Gave + from: 1 + to: 0 + rank: Q + count: 1 + - !Book + player: 0 + rank: Q + - !Asked + asker: 0 + target: 2 + rank: '3' + - !Gave + from: 2 + to: 0 + rank: '3' + count: 1 + - !Asked + asker: 0 + target: 1 + rank: '3' + - !Gave + from: 1 + to: 0 + rank: '3' + count: 2 + - !Book + player: 0 + rank: '3' + - !Asked + asker: 0 + target: 1 + rank: '4' + - !GoFish + player: 0 + - !Drew + player: 0 + matched: false + books_after_turn: + - 4 + - 1 + - 0 + - 6 + actions: + - !Ask + target: 1 + rank: + weight: 10 + pip_type: Rank + index: 'Q' + symbol: 'Q' + value: 10 + - !Ask + target: 2 + rank: + weight: 1 + pip_type: Rank + index: '3' + symbol: '3' + value: 3 + - !Ask + target: 1 + rank: + weight: 1 + pip_type: Rank + index: '3' + symbol: '3' + value: 3 + - !Ask + target: 1 + rank: + weight: 2 + pip_type: Rank + index: '4' + symbol: '4' + value: 4 + - Draw + - player: 1 + events: + - !Asked + asker: 1 + target: 0 + rank: '9' + - !GoFish + player: 1 + - !Drew + player: 1 + matched: false + books_after_turn: + - 4 + - 1 + - 0 + - 6 + actions: + - !Ask + target: 0 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - Draw + - player: 2 + events: + - !Asked + asker: 2 + target: 1 + rank: '9' + - !Gave + from: 1 + to: 2 + rank: '9' + count: 3 + - !Book + player: 2 + rank: '9' + books_after_turn: + - 4 + - 1 + - 1 + - 6 + actions: + - !Ask + target: 1 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + winner: 3 + initial_draw_pile: + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 2 + pip_type: Rank + index: '4' + symbol: '4' + value: 4 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 12 + pip_type: Rank + index: 'A' + symbol: 'A' + value: 11 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 5 + pip_type: Rank + index: '7' + symbol: '7' + value: 7 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 9 + pip_type: Rank + index: 'J' + symbol: 'J' + value: 10 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 1 + pip_type: Rank + index: '3' + symbol: '3' + value: 3 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 6 + pip_type: Rank + index: '8' + symbol: '8' + value: 8 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 0 + pip_type: Rank + index: '2' + symbol: '2' + value: 2 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 5 + pip_type: Rank + index: '7' + symbol: '7' + value: 7 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 5 + pip_type: Rank + index: '7' + symbol: '7' + value: 7 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 8 + pip_type: Rank + index: 'T' + symbol: 'T' + value: 10 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 9 + pip_type: Rank + index: 'J' + symbol: 'J' + value: 10 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 6 + pip_type: Rank + index: '8' + symbol: '8' + value: 8 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 6 + pip_type: Rank + index: '8' + symbol: '8' + value: 8 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 2 + pip_type: Rank + index: '4' + symbol: '4' + value: 4 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 11 + pip_type: Rank + index: 'K' + symbol: 'K' + value: 10 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 12 + pip_type: Rank + index: 'A' + symbol: 'A' + value: 11 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 8 + pip_type: Rank + index: 'T' + symbol: 'T' + value: 10 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 11 + pip_type: Rank + index: 'K' + symbol: 'K' + value: 10 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 1 + pip_type: Rank + index: '3' + symbol: '3' + value: 3 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 8 + pip_type: Rank + index: 'T' + symbol: 'T' + value: 10 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 9 + pip_type: Rank + index: 'J' + symbol: 'J' + value: 10 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 0 + pip_type: Rank + index: '2' + symbol: '2' + value: 2 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 9 + pip_type: Rank + index: 'J' + symbol: 'J' + value: 10 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 2 + pip_type: Rank + index: '4' + symbol: '4' + value: 4 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 10 + pip_type: Rank + index: 'Q' + symbol: 'Q' + value: 10 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 10 + pip_type: Rank + index: 'Q' + symbol: 'Q' + value: 10 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 12 + pip_type: Rank + index: 'A' + symbol: 'A' + value: 11 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 6 + pip_type: Rank + index: '8' + symbol: '8' + value: 8 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 1 + pip_type: Rank + index: '3' + symbol: '3' + value: 3 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 3 + pip_type: Rank + index: '5' + symbol: '5' + value: 5 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 8 + pip_type: Rank + index: 'T' + symbol: 'T' + value: 10 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 0 + pip_type: Rank + index: '2' + symbol: '2' + value: 2 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 5 + pip_type: Rank + index: '7' + symbol: '7' + value: 7 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 10 + pip_type: Rank + index: 'Q' + symbol: 'Q' + value: 10 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 10 + pip_type: Rank + index: 'Q' + symbol: 'Q' + value: 10 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 4 + pip_type: Rank + index: '6' + symbol: '6' + value: 6 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 11 + pip_type: Rank + index: 'K' + symbol: 'K' + value: 10 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 7 + pip_type: Rank + index: '9' + symbol: '9' + value: 9 + - suit: + weight: 1 + pip_type: Suit + index: 'D' + symbol: '♦' + value: 2 + rank: + weight: 4 + pip_type: Rank + index: '6' + symbol: '6' + value: 6 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 0 + pip_type: Rank + index: '2' + symbol: '2' + value: 2 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 2 + pip_type: Rank + index: '4' + symbol: '4' + value: 4 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 11 + pip_type: Rank + index: 'K' + symbol: 'K' + value: 10 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 1 + pip_type: Rank + index: '3' + symbol: '3' + value: 3 + - suit: + weight: 3 + pip_type: Suit + index: 'S' + symbol: '♠' + value: 4 + rank: + weight: 4 + pip_type: Rank + index: '6' + symbol: '6' + value: 6 + - suit: + weight: 2 + pip_type: Suit + index: 'H' + symbol: '♥' + value: 3 + rank: + weight: 12 + pip_type: Rank + index: 'A' + symbol: 'A' + value: 11 + - suit: + weight: 0 + pip_type: Suit + index: 'C' + symbol: '♣' + value: 1 + rank: + weight: 4 + pip_type: Rank + index: '6' + symbol: '6' + value: 6 diff --git a/src/game/state.rs b/src/game/state.rs index f5a0f4e..a55388a 100644 --- a/src/game/state.rs +++ b/src/game/state.rs @@ -699,7 +699,8 @@ impl Game { return Ok(drew_event); }; - let matched = asked_rank == Some(card.rank); + let drawn_rank = card.rank; + let matched = asked_rank == Some(drawn_rank); self.players[cp].receive_card(card); @@ -711,30 +712,33 @@ impl Game { #[cfg(feature = "history")] self.push_event(&drew_event); - let last_event = if matched { - // Check whether the drawn card completes a book. - if let Some(ref r) = asked_rank { - let rules = self.variant.rules(); - if Self::check_and_collect_book(&mut self.players, cp, r, rules) { - let book_event = GameEvent::Book { - player: cp, - rank: r.index.to_string(), - }; - self.last_event = Some(book_event.clone()); - #[cfg(feature = "history")] - self.push_event(&book_event); - // Signal callers that a book was just completed; same player acts again. - self.phase = GamePhase::BookCompleted; - book_event - } else { - // Drew the asked rank but no book yet — same player keeps turn. - self.phase = GamePhase::WaitingForAsk; - drew_event - } + // Always check the drawn card's rank for book completion — a + // non-matching draw can still complete a book of its own rank + // (player held three of X, asked for Y, drew the fourth X). + let rules = self.variant.rules(); + let book_formed = Self::check_and_collect_book(&mut self.players, cp, &drawn_rank, rules); + + let last_event = if book_formed { + let book_event = GameEvent::Book { + player: cp, + rank: drawn_rank.index.to_string(), + }; + self.last_event = Some(book_event.clone()); + #[cfg(feature = "history")] + self.push_event(&book_event); + if matched { + // Drew the asked rank and completed a book — same player acts again. + self.phase = GamePhase::BookCompleted; } else { + // Drew a non-asked rank that completed a book — turn still ends. + self.advance_turn(); self.phase = GamePhase::WaitingForAsk; - drew_event } + book_event + } else if matched { + // Drew the asked rank but no book yet — same player keeps turn. + self.phase = GamePhase::WaitingForAsk; + drew_event } else { // Didn't match — next player's turn. self.advance_turn(); @@ -1269,6 +1273,58 @@ mod tests { ); } + #[test] + fn test_handle_draw_forms_book_on_non_matching_draw() { + // Regression test for gf-history-1778474562.yaml: a non-matching draw + // can still complete a book of the drawn card's rank. Player 0 holds + // three 4s plus a 9, asks for 9 (Go Fish), draws the fourth 4. The + // book of 4s must form even though matched=false; the turn still + // advances to player 1 per the standard "no match ends turn" rule. + use cardpack::prelude::FrenchBasicCard; + let mut game = two_player_game(); + clear_hand(&mut game.players[0]); + clear_hand(&mut game.players[1]); + // P0: three 4s + one 9. + game.players[0].receive_card(FrenchBasicCard::FOUR_SPADES); + game.players[0].receive_card(FrenchBasicCard::FOUR_HEARTS); + game.players[0].receive_card(FrenchBasicCard::FOUR_DIAMONDS); + game.players[0].receive_card(FrenchBasicCard::NINE_SPADES); + // P1: one Ace + one 9 (sharing the 9 rank with P0 keeps the game + // out of deadlock-triggered GameOver after the draw). + game.players[1].receive_card(FrenchBasicCard::ACE_SPADES); + game.players[1].receive_card(FrenchBasicCard::NINE_HEARTS); + + game.phase = GamePhase::WaitingForDraw; + game.last_asked_rank = Some(FrenchBasicCard::NINE_SPADES.rank); + // Pile has the fourth 4 on top, plus a non-book filler so the pile + // is non-empty after the draw and the win-condition check is a no-op. + game.draw_pile = BasicPile::from(vec![ + FrenchBasicCard::FOUR_CLUBS, + FrenchBasicCard::KING_SPADES, + ]); + + let result = game.act(PlayerAction::Draw).unwrap(); + + match result { + GameEvent::Book { player, ref rank } => { + assert_eq!(player, 0, "book must be credited to player 0"); + assert_eq!(rank, "4", "book must be of rank 4"); + } + other => panic!("expected Book event, got {other:?}"), + } + assert_eq!( + game.players[0].book_count(), + 1, + "player 0 should hold 1 book" + ); + assert_eq!( + game.current_player(), + 1, + "turn must advance on non-matching draw even when a book forms" + ); + assert_eq!(*game.phase(), GamePhase::WaitingForAsk); + } + // --- replenish_until_has_cards (lines 702-723) --- #[test]