refactor(core): derive draw_mode/is_won/move_count/is_auto_completable from session

Remove the draw_mode, move_count, is_won, and is_auto_completable fields
from GameState; they are now &self methods deriving from the underlying
card_game session (draw_mode from session config, move_count from history
length, is_won/is_auto_completable from check_win/check_auto_complete).

Tests previously fabricated these via direct field writes, which is no
longer possible. Add gated test-support overrides on TestPileState
(won/auto_completable/move_count) plus setters set_test_won,
set_test_auto_completable, set_test_move_count, and set_test_draw_mode
(re-deals the seed). All compiled out in production builds.

Fix the field->method ripple across solitaire_data, solitaire_wasm, and
solitaire_engine. Add a test-support dev-dependency to solitaire_data for
the won-game storage test.

cargo test --workspace and cargo clippy --workspace -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-10 09:24:03 -07:00
parent 1438fd6265
commit 056459619b
20 changed files with 187 additions and 120 deletions
+17 -17
View File
@@ -154,7 +154,7 @@ impl Plugin for GamePlugin {
let saved = path.as_deref().and_then(load_game_state_from);
let prompt_worthy = saved
.as_ref()
.is_some_and(|g| g.move_count > 0 && !g.is_won);
.is_some_and(|g| g.move_count() > 0 && !g.is_won());
let (initial_state, pending_restore) = if prompt_worthy {
(
GameState::new(seed_from_system_time(), DrawMode::DrawOne),
@@ -302,7 +302,7 @@ fn tick_elapsed_time(
*skip_next_delta = false;
return;
}
let is_won = game.0.is_won;
let is_won = game.0.is_won();
advance_elapsed(
&mut game.0.elapsed_seconds,
&mut accumulator,
@@ -424,7 +424,7 @@ fn handle_new_game(
for ev in new_game.read() {
// If an active game is in progress, intercept and show a confirm dialog.
// A game is "active" when moves have been made and it is not yet won.
let needs_confirm = game.0.move_count > 0 && !game.0.is_won;
let needs_confirm = game.0.move_count() > 0 && !game.0.is_won();
// Skip confirmation if a ConfirmNewGameScreen already exists (prevents
// duplicates) or if the event itself was already confirmed by the
// player pressing Y on the modal — without the `confirmed` check the
@@ -464,7 +464,7 @@ fn handle_new_game(
// where SettingsPlugin is not installed.
let draw_mode = settings
.as_ref()
.map_or_else(|| game.0.draw_mode, |s| s.0.draw_mode);
.map_or_else(|| game.0.draw_mode(), |s| s.0.draw_mode);
let mode = ev.mode.unwrap_or(game.0.mode);
// Solver-backed retry: when the player has opted in to
@@ -823,7 +823,7 @@ fn handle_draw(
if stock.is_empty() {
Vec::new()
} else {
let draw_count = match game.0.draw_mode {
let draw_count = match game.0.draw_mode() {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
@@ -865,7 +865,7 @@ fn handle_move(
path: Option<Res<GameStatePath>>,
) {
for ev in moves.read() {
let was_won = game.0.is_won;
let was_won = game.0.is_won();
// Identify the card that will be exposed (and may flip face-up) by the move.
// It's the card just below the bottom of the moving stack in the source pile.
let source_cards = pile_cards(&game.0, &ev.from);
@@ -910,7 +910,7 @@ fn handle_move(
foundation_done.write(FoundationCompletedEvent { slot, suit });
}
changed.write(StateChangedEvent);
if !was_won && game.0.is_won {
if !was_won && game.0.is_won() {
won.write(GameWonEvent {
score: game.0.score,
time_seconds: game.0.elapsed_seconds,
@@ -986,7 +986,7 @@ pub fn record_replay_on_win(
let win_move_index = recording.moves.len().checked_sub(1);
let replay = Replay::new(
game.0.seed,
game.0.draw_mode,
game.0.draw_mode(),
game.0.mode,
ev.time_seconds,
ev.score,
@@ -1093,13 +1093,13 @@ fn check_no_moves(
// Despawn game-over overlay whenever moves become available again or game is won.
let moves_ok = has_legal_moves(&game.0);
if moves_ok || game.0.is_won {
if moves_ok || game.0.is_won() {
for entity in &game_over_screens {
commands.entity(entity).despawn();
}
}
if game.0.is_won {
if game.0.is_won() {
return;
}
@@ -1248,7 +1248,7 @@ fn auto_save_game_state(
// or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at
// startup would clobber the real saved game on disk.
if paused.is_some_and(|p| p.0) || game.0.is_won || game.0.move_count == 0 || pending.0.is_some()
if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some()
{
return;
}
@@ -1596,7 +1596,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 1;
.set_test_move_count(1);
// Re-arm the timer past the threshold every frame and pump
// updates until the save fires. Caps at 16 iterations — a
@@ -1845,7 +1845,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 5;
.set_test_move_count(5);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
@@ -1869,7 +1869,7 @@ mod tests {
let mut app = test_app_with_input(42);
// move_count stays at 0 (fresh game).
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count,
app.world().resource::<GameStateResource>().0.move_count(),
0,
"test assumes a fresh game with no moves"
);
@@ -2362,7 +2362,7 @@ mod tests {
app.update();
// Game state was reseeded — move_count is 0 on the new game.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
}
#[test]
@@ -2436,7 +2436,7 @@ mod tests {
// The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly:
// move_count back to 0, undo stack empty.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
assert_eq!(
app.world()
.resource::<GameStateResource>()
@@ -2496,7 +2496,7 @@ mod tests {
);
// New game completed: a fresh deal carries 0 moves.
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count,
app.world().resource::<GameStateResource>().0.move_count(),
0,
"completed new game must be in fresh-deal state",
);