fix(multi): resolve 16 bugs from comprehensive rules and code review
Build and Deploy / build-and-push (push) Successful in 4m12s
Build and Deploy / build-and-push (push) Successful in 4m12s
Core (solitaire_core): - fix(core): auto-complete now requires waste empty to prevent deadlock - fix(core): reject multi-card moves from waste pile (Klondike rule) - fix(core): reject foundation-to-foundation moves (score farming exploit) - fix(core): undo restores score from snapshot baseline, not live score - feat(scoring): add +5 flip bonus when face-down tableau card is exposed - feat(scoring): add recycle penalty (Draw-1: -100/pass, Draw-3: -20/pass) Engine (solitaire_engine): - fix(engine): remove TokioRuntimeResource::default() panic; degrade gracefully - fix(engine): add ModalScrim guard to handle_new_game spawn site - fix(engine): add ModalScrim guard to spawn_restore_prompt spawn site - fix(engine): add ModalScrim guard to check_no_moves spawn site Server / Web (solitaire_server): - fix(web): correct draw_mode casing in replay submission (DrawOne/DrawThree) - fix(web): correct mode casing in replay submission (Classic) for leaderboard - fix(web): trim recorded_at to YYYY-MM-DD for NaiveDate deserialization - fix(server): move /avatars route outside auth middleware (was always 401) Data / Sync (solitaire_data, solitaire_sync): - fix(data): namespace Android token file under APP_DIR_NAME with migration - fix(data): Android token store now multi-user (HashMap); no silent overwrite - fix(sync): draw_one_wins + draw_three_wins invariant preserved after merge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -135,8 +135,21 @@ fn merge_stats(
|
||||
fastest_win_seconds: local.fastest_win_seconds.min(remote.fastest_win_seconds),
|
||||
lifetime_score: local.lifetime_score.max(remote.lifetime_score),
|
||||
best_single_score: local.best_single_score.max(remote.best_single_score),
|
||||
draw_one_wins: local.draw_one_wins.max(remote.draw_one_wins),
|
||||
draw_three_wins: local.draw_three_wins.max(remote.draw_three_wins),
|
||||
// Take per-mode win counts from whichever side contributed `games_won`
|
||||
// (the side with the higher total). Independent max() calls can push
|
||||
// draw_one_wins + draw_three_wins above games_won when the two sides
|
||||
// have complementary win histories (e.g. local has 20 draw-one wins,
|
||||
// remote has 20 draw-three wins, each with games_won = 20).
|
||||
draw_one_wins: if local.games_won >= remote.games_won {
|
||||
local.draw_one_wins
|
||||
} else {
|
||||
remote.draw_one_wins
|
||||
},
|
||||
draw_three_wins: if local.games_won >= remote.games_won {
|
||||
local.draw_three_wins
|
||||
} else {
|
||||
remote.draw_three_wins
|
||||
},
|
||||
// Per-mode bests. Bests take max; fastest times take a *zero-aware*
|
||||
// min — see [`min_ignore_zero`] for the rationale (0 means "no win
|
||||
// yet" for these fields, unlike the lifetime `fastest_win_seconds`
|
||||
@@ -505,17 +518,55 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_draw_mode_wins_take_max() {
|
||||
fn stats_draw_mode_wins_taken_from_winning_side() {
|
||||
// Both sides have equal games_won (default 0), so local is chosen (>=).
|
||||
// Per-mode counts come entirely from that one side — no cross-side max.
|
||||
let mut local = default_payload();
|
||||
local.stats.games_won = 25;
|
||||
local.stats.draw_one_wins = 20;
|
||||
local.stats.draw_three_wins = 5;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.games_won = 15;
|
||||
remote.stats.draw_one_wins = 15;
|
||||
remote.stats.draw_three_wins = 8;
|
||||
|
||||
// local has more wins, so local's per-mode counts are used.
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.games_won, 25);
|
||||
assert_eq!(merged.stats.draw_one_wins, 20);
|
||||
assert_eq!(merged.stats.draw_three_wins, 8);
|
||||
assert_eq!(merged.stats.draw_three_wins, 5);
|
||||
assert!(
|
||||
merged.stats.draw_one_wins + merged.stats.draw_three_wins
|
||||
<= merged.stats.games_won,
|
||||
"draw-mode win counts must not exceed total wins"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_stats_draw_mode_wins_do_not_exceed_total() {
|
||||
// local: 20 draw-one wins, 0 draw-three, games_won = 20
|
||||
// remote: 0 draw-one wins, 20 draw-three, games_won = 20
|
||||
// Without the fix, independent max() calls yield draw_one=20, draw_three=20,
|
||||
// games_won=20 — the breakdown sums to 40, double the actual total.
|
||||
let mut local = default_payload();
|
||||
local.stats.games_won = 20;
|
||||
local.stats.draw_one_wins = 20;
|
||||
local.stats.draw_three_wins = 0;
|
||||
|
||||
let mut remote = default_payload();
|
||||
remote.stats.games_won = 20;
|
||||
remote.stats.draw_one_wins = 0;
|
||||
remote.stats.draw_three_wins = 20;
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert!(
|
||||
merged.stats.draw_one_wins + merged.stats.draw_three_wins <= merged.stats.games_won,
|
||||
"draw-mode win counts must not exceed total wins after merge: \
|
||||
draw_one={}, draw_three={}, games_won={}",
|
||||
merged.stats.draw_one_wins,
|
||||
merged.stats.draw_three_wins,
|
||||
merged.stats.games_won,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user