Compare commits
2 Commits
d44cedbea0
...
9564f54fc0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9564f54fc0 | |||
| b4ada2a07e |
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-12 — Password reset CLI shipped (`7514684`). HEAD locally: `7514684`. Push pending.
|
**Last updated:** 2026-05-12 — WASM winning-sequence test shipped (`b4ada2a`). HEAD locally: `b4ada2a`. Push pending.
|
||||||
|
|
||||||
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
|
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
|
||||||
modal, re-auth on token expiry, account deletion flow, server deployment
|
modal, re-auth on token expiry, account deletion flow, server deployment
|
||||||
@@ -12,8 +12,8 @@ and full server integration tests.
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **HEAD locally:** `7514684` (feat: --reset-password subcommand).
|
- **HEAD locally:** `b4ada2a` (test: WASM winning-sequence step-through).
|
||||||
- **HEAD on origin:** `566b112` (pushed — 3 commits ahead).
|
- **HEAD on origin:** `d44cedb` (pushed — 2 commits ahead).
|
||||||
- **Working tree:** `SESSION_HANDOFF.md` modified, uncommitted.
|
- **Working tree:** `SESSION_HANDOFF.md` modified, uncommitted.
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||||
- **Tests:** **1300+ passing / 0 failing** across the workspace.
|
- **Tests:** **1300+ passing / 0 failing** across the workspace.
|
||||||
@@ -95,9 +95,9 @@ Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
|
|||||||
`jwt_refresh_on_401_succeeds` (pull) and
|
`jwt_refresh_on_401_succeeds` (pull) and
|
||||||
`push_retries_after_401_on_expired_access_token` (push) in
|
`push_retries_after_401_on_expired_access_token` (push) in
|
||||||
`solitaire_data/tests/sync_round_trip.rs`.
|
`solitaire_data/tests/sync_round_trip.rs`.
|
||||||
- **WASM winning-replay step-through** — current tests cover 2 stock clicks;
|
- [x] **WASM winning-replay step-through.** Done (`b4ada2a`): greedy solver
|
||||||
a test stepping through a full winning sequence would catch
|
searches seeds 1–200 at test time; steps every move through `ReplayPlayer`;
|
||||||
`GameState`/`ReplayMove` compatibility regressions.
|
asserts `is_won = true` on the final `StateSnapshot`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -269,4 +269,205 @@ mod tests {
|
|||||||
let result = ReplayPlayer::from_json("not valid json");
|
let result = ReplayPlayer::from_json("not valid json");
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Winning-sequence step-through
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Greedy Klondike solver for DrawOne Classic.
|
||||||
|
///
|
||||||
|
/// Returns a `ReplayMove` list that wins the game from `seed`, or `None`
|
||||||
|
/// when the greedy heuristic gets stuck within the move budget.
|
||||||
|
///
|
||||||
|
/// Priority order (highest first):
|
||||||
|
/// 1. Waste → Foundation
|
||||||
|
/// 2. Tableau top → Foundation
|
||||||
|
/// 3. Tableau stack → Tableau, only if the move uncovers a face-down card
|
||||||
|
/// 4. Waste → Tableau
|
||||||
|
/// 5. Draw from stock (recycle is automatic inside `GameState::draw`)
|
||||||
|
fn greedy_solve(seed: u64) -> Option<Vec<ReplayMove>> {
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
|
let mut game = GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic);
|
||||||
|
let mut moves: Vec<ReplayMove> = Vec::new();
|
||||||
|
const MAX_MOVES: usize = 10_000;
|
||||||
|
|
||||||
|
'outer: loop {
|
||||||
|
if game.is_won {
|
||||||
|
return Some(moves);
|
||||||
|
}
|
||||||
|
if moves.len() >= MAX_MOVES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-complete: drive to win without further player input.
|
||||||
|
if game.is_auto_completable {
|
||||||
|
while let Some((from, to)) = game.next_auto_complete_move() {
|
||||||
|
if game.move_cards(from.clone(), to.clone(), 1).is_err() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
moves.push(ReplayMove::Move { from, to, count: 1 });
|
||||||
|
}
|
||||||
|
return if game.is_won { Some(moves) } else { None };
|
||||||
|
}
|
||||||
|
|
||||||
|
// P1: Waste → Foundation.
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
if game
|
||||||
|
.move_cards(PileType::Waste, PileType::Foundation(slot), 1)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
moves.push(ReplayMove::Move {
|
||||||
|
from: PileType::Waste,
|
||||||
|
to: PileType::Foundation(slot),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2: Tableau top → Foundation.
|
||||||
|
for i in 0..7_usize {
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
if game
|
||||||
|
.move_cards(PileType::Tableau(i), PileType::Foundation(slot), 1)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
moves.push(ReplayMove::Move {
|
||||||
|
from: PileType::Tableau(i),
|
||||||
|
to: PileType::Foundation(slot),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P3: Tableau stack → Tableau only when it uncovers a face-down card.
|
||||||
|
let mut made_move = false;
|
||||||
|
'p3: for i in 0..7_usize {
|
||||||
|
let pile_len = game.piles[&PileType::Tableau(i)].cards.len();
|
||||||
|
for count in 1..=pile_len {
|
||||||
|
let start = pile_len - count;
|
||||||
|
// Only worth moving if a face-down card sits just below.
|
||||||
|
let would_uncover =
|
||||||
|
start > 0 && !game.piles[&PileType::Tableau(i)].cards[start - 1].face_up;
|
||||||
|
if !would_uncover {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for j in 0..7_usize {
|
||||||
|
if i == j {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if game
|
||||||
|
.move_cards(PileType::Tableau(i), PileType::Tableau(j), count)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
moves.push(ReplayMove::Move {
|
||||||
|
from: PileType::Tableau(i),
|
||||||
|
to: PileType::Tableau(j),
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
made_move = true;
|
||||||
|
break 'p3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if made_move {
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// P4: Waste → Tableau.
|
||||||
|
for j in 0..7_usize {
|
||||||
|
if game
|
||||||
|
.move_cards(PileType::Waste, PileType::Tableau(j), 1)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
moves.push(ReplayMove::Move {
|
||||||
|
from: PileType::Waste,
|
||||||
|
to: PileType::Tableau(j),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P5: Draw from stock (handles recycle automatically).
|
||||||
|
if game.draw().is_ok() {
|
||||||
|
moves.push(ReplayMove::StockClick);
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No moves available — greedy solver is stuck on this seed.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full end-to-end winning-sequence regression test.
|
||||||
|
///
|
||||||
|
/// 1. Runs the greedy solver on seeds 1–200 to find the first
|
||||||
|
/// deterministically winnable game.
|
||||||
|
/// 2. Serialises the winning move list as a `Replay` JSON string.
|
||||||
|
/// 3. Feeds the JSON to `ReplayPlayer::from_json`.
|
||||||
|
/// 4. Steps through every move via `step_native` and asserts `is_won`
|
||||||
|
/// on the final snapshot.
|
||||||
|
///
|
||||||
|
/// Regression target: a `GameState` or `ReplayMove` change that breaks
|
||||||
|
/// an historically valid move sequence will cause `is_won` to be `false`
|
||||||
|
/// at the end of the replay, failing this test before any release.
|
||||||
|
#[test]
|
||||||
|
fn replay_player_completes_full_winning_sequence() {
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
|
|
||||||
|
let (seed, winning_moves) = (1_u64..=200)
|
||||||
|
.find_map(|s| greedy_solve(s).map(|m| (s, m)))
|
||||||
|
.expect("at least one seed in 1..=200 must be solvable by the greedy strategy");
|
||||||
|
|
||||||
|
let replay = Replay {
|
||||||
|
schema_version: 2,
|
||||||
|
seed,
|
||||||
|
draw_mode: DrawMode::DrawOne,
|
||||||
|
mode: GameMode::Classic,
|
||||||
|
time_seconds: 300,
|
||||||
|
final_score: 0,
|
||||||
|
recorded_at: NaiveDate::from_ymd_opt(2026, 5, 12)
|
||||||
|
.expect("2026-05-12 is a valid date"),
|
||||||
|
moves: winning_moves.clone(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&replay).expect("replay serialises to JSON cleanly");
|
||||||
|
|
||||||
|
let mut player =
|
||||||
|
ReplayPlayer::from_json(&json).expect("solver-generated replay JSON must be valid");
|
||||||
|
assert_eq!(player.step_idx, 0, "player must start at step 0");
|
||||||
|
assert_eq!(
|
||||||
|
player.moves.len(),
|
||||||
|
winning_moves.len(),
|
||||||
|
"player must hold the complete move list"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut last_snap: Option<StateSnapshot> = None;
|
||||||
|
while let Some(snap) = player.step_native() {
|
||||||
|
last_snap = Some(snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
let snap = last_snap.expect("winning sequence must contain at least one move");
|
||||||
|
assert!(
|
||||||
|
snap.is_won,
|
||||||
|
"seed {seed}: final snapshot after full replay must have is_won = true \
|
||||||
|
({} moves applied)",
|
||||||
|
winning_moves.len()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
snap.step_idx,
|
||||||
|
winning_moves.len(),
|
||||||
|
"step_idx after the last move must equal the total move count"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
player.step_native().is_none(),
|
||||||
|
"step_native must return None once all moves are exhausted"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user