refactor(core): derive score/undo/recycle from upstream session stats

Replace the bespoke WXP scoring engine with the upstream
card_game/klondike session stats, eliminating duplicated state that
could drift from the single source of truth.

score()/undo_count()/recycle_count() now read session.stats(); the -15
undo penalty is configured as SessionConfig::undo_penalty and applied by
the upstream score formula. Save schema bumped v4 -> v5 (the three
counters are no longer persisted -- they are rebuilt by replaying the
forward instruction history on load).

- Remove GameState fields score, undo_count, recycle_count (#87)
- Remove score_history / is_recycle_history undo journal (#86)
- Remove KlondikeAdapter::apply_undo_score and the score_for_* helpers,
  plus pre_instruction_score_delta / will_flip_tableau_source (#84)

These three issues are a single atomic change: each removed field/helper
is consumed by the same draw/apply_instruction/undo/serde/PartialEq
paths, so they cannot compile or pass tests in isolation.

Behaviour changes (intentional): the escalating recycle penalty and
per-step score floor are gone (upstream linear scoring, floored once at
0); recycle_count is now cumulative; undo_count resets across save/load.

Refs #84, #86, #87

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-11 10:36:31 -07:00
parent 9e3c6b06b0
commit 372b6423d8
10 changed files with 237 additions and 381 deletions
+17 -18
View File
@@ -1818,7 +1818,7 @@ fn detect_score_change(
score_q: Query<Entity, With<HudScore>>,
mut commands: Commands,
) {
let current = game.0.score;
let current = game.0.score();
let delta = current - prev.0;
prev.0 = current;
if delta <= 0 {
@@ -2275,7 +2275,7 @@ fn update_hud(
**t = if is_zen {
String::new()
} else {
format!("Score: {}", g.score)
format!("Score: {}", g.score())
};
}
if let Ok(mut t) = moves_q.single_mut() {
@@ -2311,7 +2311,7 @@ fn update_hud(
// --- Undo count ---
if let Ok((mut t, mut color)) = undos_q.single_mut() {
let count = g.undo_count;
let count = g.undo_count();
if count == 0 {
**t = String::new();
*color = TextColor(TEXT_PRIMARY);
@@ -2325,8 +2325,8 @@ fn update_hud(
// --- Recycle counter (both modes, hidden until first recycle) ---
if let Ok(mut t) = recycles_q.single_mut() {
**t = if g.recycle_count > 0 {
format!("Recycles: {}", g.recycle_count)
**t = if g.recycle_count() > 0 {
format!("Recycles: {}", g.recycle_count())
} else {
String::new()
};
@@ -2763,9 +2763,9 @@ mod tests {
#[test]
fn score_reflects_game_state() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 750;
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
app.update();
assert_eq!(read_hud_text::<HudScore>(&mut app), "Score: 750");
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
}
#[test]
@@ -2795,7 +2795,6 @@ mod tests {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
app.world_mut().resource_mut::<GameStateResource>().0.score = 999;
app.update();
// Zen mode spec: "No score display" → text must be empty.
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
@@ -2916,7 +2915,7 @@ mod tests {
fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
@@ -2931,7 +2930,7 @@ mod tests {
target_score: None,
max_time_secs: Some(300),
});
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
}
@@ -2946,7 +2945,7 @@ mod tests {
target_score: Some(4000),
max_time_secs: None,
});
app.world_mut().resource_mut::<GameStateResource>().0.score = 1;
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
}
@@ -2984,7 +2983,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 3;
.force_test_undos(3);
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
}
@@ -3057,7 +3056,7 @@ mod tests {
fn recycles_hud_shows_count_draw_three() {
let mut app = headless_app();
let mut gs = GameState::new(42, DrawMode::DrawThree);
gs.recycle_count = 3;
gs.force_test_recycles(3);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
@@ -3068,7 +3067,7 @@ mod tests {
let mut app = headless_app();
// Draw-One with recycle_count > 0 must now show the counter too.
let mut gs = GameState::new(42, DrawMode::DrawOne);
gs.recycle_count = 2;
gs.force_test_recycles(2);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
@@ -3108,7 +3107,7 @@ mod tests {
set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater.
app.world_mut().resource_mut::<GameStateResource>().0.score = 50;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
// One floater should now exist.
@@ -3129,7 +3128,7 @@ mod tests {
#[test]
fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
@@ -3155,7 +3154,7 @@ mod tests {
let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant.
app.world_mut().resource_mut::<GameStateResource>().0.score = 5;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
app.update();
assert_eq!(
count_with::<ScoreFloater>(&mut app),
@@ -3231,7 +3230,7 @@ mod tests {
..Settings::default()
}));
// +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
assert_eq!(
count_with::<ScorePulse>(&mut app),