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
+2 -2
View File
@@ -819,7 +819,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::DrawMode::DrawThree;
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -868,7 +868,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::DrawMode::DrawThree;
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
+6 -6
View File
@@ -76,14 +76,14 @@ fn detect_auto_complete(
}
changed.clear();
if game.0.is_won {
if game.0.is_won() {
state.active = false;
return;
}
if game.0.is_auto_completable && !state.active {
if game.0.is_auto_completable() && !state.active {
state.active = true;
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
} else if !game.0.is_auto_completable && state.active {
} else if !game.0.is_auto_completable() && state.active {
// `is_auto_completable` only becomes false after an explicit undo
// (which puts a card back on the tableau or re-fills the stock/waste)
// or a new-game reset — never as a transient gap during a normal
@@ -209,7 +209,7 @@ mod tests {
Tableau::Tableau1,
vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
g.is_auto_completable = true;
g.set_test_auto_completable(true);
let expected = (
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Foundation(Foundation::Foundation1),
@@ -228,7 +228,7 @@ mod tests {
fn detect_activates_when_auto_completable() {
let mut app = headless_app();
let mut g = GameState::new(42, DrawMode::DrawOne);
g.is_auto_completable = true;
g.set_test_auto_completable(true);
app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().write_message(StateChangedEvent);
app.update();
@@ -263,7 +263,7 @@ mod tests {
let mut app = headless_app();
// Inject a won game state — active should not be set.
let (mut gs, _) = seeded_state_with_auto_move();
gs.is_won = true;
gs.set_test_won(true);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().write_message(StateChangedEvent);
app.update();
+3 -3
View File
@@ -734,7 +734,7 @@ fn sync_cards(
// Without this, the buffer sits at waste_base uncovered during the animation
// and its rank/suit peek behind the incoming card.
let waste_buffer_id: Option<Card> = {
let visible = match game.draw_mode {
let visible = match game.draw_mode() {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
@@ -903,7 +903,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three
// shows up to 3 fanned in X (matching the standard Klondike presentation).
let render_start = if is_waste {
let visible = match game.draw_mode {
let visible = match game.draw_mode() {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
@@ -918,7 +918,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
let mut y_offset = 0.0_f32;
let rendered_len = cards[render_start..].len();
for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) {
let x_offset = if is_waste && matches!(game.draw_mode(), DrawMode::DrawThree) {
// When len > visible, slot 0 is a hidden buffer card kept at
// x=0 to prevent a flash during the draw tween. When len ≤
// visible (small pile), every card is visible and should fan
+1 -1
View File
@@ -437,7 +437,7 @@ fn tableau_or_stack_pos(
base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree {
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree {
let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32;
+1 -1
View File
@@ -408,7 +408,7 @@ fn start_deal_anim(
return;
}
// Only animate a fresh deal (no moves made yet).
if game.0.move_count != 0 {
if game.0.move_count() != 0 {
return;
}
let Some(layout) = layout else { return };
+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",
);
+11 -11
View File
@@ -1154,7 +1154,7 @@ fn handle_hint_button(
return;
}
let Some(ref g) = game else { return };
if g.0.is_won {
if g.0.is_won() {
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
return;
}
@@ -2106,10 +2106,10 @@ fn update_won_previously(
let Ok(mut text) = q.single_mut() else {
return;
};
let won_before = !game.0.is_won
let won_before = !game.0.is_won()
&& history.as_ref().is_some_and(|h| {
h.0.replays.iter().any(|r| {
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode && r.mode == game.0.mode
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
})
});
let next = if won_before {
@@ -2279,11 +2279,11 @@ fn update_hud(
};
}
if let Ok(mut t) = moves_q.single_mut() {
**t = format!("Moves: {}", g.move_count);
**t = format!("Moves: {}", g.move_count());
}
if let Ok(mut t) = mode_q.single_mut() {
**t = match g.mode {
GameMode::Classic => match g.draw_mode {
GameMode::Classic => match g.draw_mode() {
DrawMode::DrawOne => String::new(),
DrawMode::DrawThree => "Draw 3".to_string(),
},
@@ -2296,7 +2296,7 @@ fn update_hud(
// --- Daily challenge constraint (with time-low colour warning) ---
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
if g.is_won {
if g.is_won() {
**t = String::new();
} else if let Some(dc) = daily.as_deref() {
**t = challenge_hud_text(dc);
@@ -2334,7 +2334,7 @@ fn update_hud(
// --- Draw-cycle indicator (Draw-Three mode only) ---
if let Ok(mut t) = draw_cycle_q.single_mut() {
**t = if g.is_won || g.draw_mode != DrawMode::DrawThree {
**t = if g.is_won() || g.draw_mode() != DrawMode::DrawThree {
// Hide when not in Draw-Three or after the game is won.
String::new()
} else {
@@ -2774,7 +2774,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 42;
.set_test_move_count(42);
app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
}
@@ -2962,7 +2962,7 @@ mod tests {
max_time_secs: Some(300),
});
// Mark the game as won — HudChallenge should be empty.
app.world_mut().resource_mut::<GameStateResource>().0.is_won = true;
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
@@ -3012,7 +3012,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count += 1;
.set_test_move_count(1);
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
}
@@ -3024,7 +3024,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count += 1;
.set_test_move_count(1);
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
}
+2 -2
View File
@@ -305,7 +305,7 @@ fn handle_keyboard_hint(
let Some(ref g) = game else { return };
if g.0.is_won {
if g.0.is_won() {
info_toast.write(InfoToastEvent(
"Game won! Press N for a new game".to_string(),
));
@@ -1153,7 +1153,7 @@ fn card_position(
y_offset -= layout.card_size.y * step;
}
Vec2::new(base.x, base.y + y_offset)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree {
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree {
// In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing must use the same offsets
// so clicking the visually rightmost (top) card actually registers.
+2 -2
View File
@@ -340,7 +340,7 @@ fn handle_forfeit_request(
if !forfeit_screens.is_empty() {
return;
}
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won);
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won());
if !game_in_progress {
toast.write(InfoToastEvent("No game to forfeit".to_string()));
return;
@@ -1025,7 +1025,7 @@ mod tests {
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
let mut game = GameState::new(1, DrawMode::DrawOne);
game.is_won = true;
game.set_test_won(true);
app.insert_resource(GameStateResource(game));
app.update();
+2 -2
View File
@@ -63,7 +63,7 @@ impl PendingHintTask {
/// Spawn a new solver task for `state` with `config`. Drops any
/// previously in-flight task first (cancel-on-replace).
pub fn spawn(&mut self, state: GameState, config: SolverConfig) {
let move_count_at_spawn = state.move_count;
let move_count_at_spawn = state.move_count();
let handle = AsyncComputeTaskPool::get().spawn(async move {
let outcome = try_solve_from_state(&state, &config);
match outcome.result {
@@ -156,7 +156,7 @@ pub fn poll_pending_hint_task(
pending.inner = None;
let Some(g) = game else { return };
if g.0.move_count != move_count_at_spawn {
if g.0.move_count() != move_count_at_spawn {
return;
}
+7 -7
View File
@@ -534,7 +534,7 @@ fn update_stats_on_win(
let prev_streak = stats.0.win_streak_current;
stats
.0
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode());
// Per-mode best score / fastest win — additive on top of the
// lifetime totals tracked by `update_on_win`. TimeAttack is a
// no-op inside the helper because it has its own session-level
@@ -588,7 +588,7 @@ fn update_stats_on_new_game(
mut toast: MessageWriter<InfoToastEvent>,
) {
for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won {
if game.0.move_count() > 0 && !game.0.is_won() {
let streak = stats.0.win_streak_current;
stats.0.record_abandoned();
persist(&path, &stats.0, "abandoned game");
@@ -614,7 +614,7 @@ fn handle_forfeit(
mut auto_complete: Option<ResMut<AutoCompleteState>>,
) {
for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won {
if game.0.move_count() > 0 && !game.0.is_won() {
let streak = stats.0.win_streak_current;
stats.0.record_abandoned();
persist(&path, &stats.0, "forfeit");
@@ -1327,7 +1327,7 @@ mod tests {
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.draw_mode = solitaire_core::DrawMode::DrawThree;
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -1373,7 +1373,7 @@ mod tests {
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.move_count = 3;
.set_test_move_count(3);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999),
@@ -1699,7 +1699,7 @@ mod tests {
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.move_count = 1;
.set_test_move_count(1);
app.world_mut().write_message(ForfeitEvent);
app.update();
@@ -1725,7 +1725,7 @@ mod tests {
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.move_count = 1;
.set_test_move_count(1);
app.world_mut().write_message(ForfeitEvent);
app.update();
+1 -1
View File
@@ -331,7 +331,7 @@ fn push_replay_on_win(
}
let replay = Replay::new(
game.0.seed,
game.0.draw_mode,
game.0.draw_mode(),
game.0.mode,
ev.time_seconds,
ev.score,
+1 -1
View File
@@ -182,7 +182,7 @@ fn advance_time_attack(
// No shared screen-state enum currently covers every overlay. Pause the
// countdown whenever gameplay is blocked by a modal, the pause flag, or a
// just-won board state.
if paused.is_some_and(|p| p.0) || game.0.is_won || !modal_scrims.is_empty() {
if paused.is_some_and(|p| p.0) || game.0.is_won() || !modal_scrims.is_empty() {
return;
}
session.remaining_secs = (session.remaining_secs - time.delta_secs()).max(0.0);
+1 -1
View File
@@ -83,7 +83,7 @@ fn evaluate_weekly_goals(
let ctx = WeeklyGoalContext {
time_seconds: ev.time_seconds,
used_undo: game.0.undo_count > 0,
draw_mode: game.0.draw_mode,
draw_mode: game.0.draw_mode(),
};
for def in WEEKLY_GOALS {
if !def.matches(&ctx) {