From 3eabc149a84140cd52d2a4971c0f9cdfee3802d4 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 5 May 2026 17:26:14 +0000 Subject: [PATCH] fix(engine): hide previous-game positions during new-game deal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported leak: when a new game starts, every card sprite tweens from its previous-game Transform to its new dealt position. A careful observer can track those origin points and deduce face-down cards in the new layout — the tween's start frame literally renders the prior game's geometry. Fix: in handle_new_game, after replacing the GameState, snap every existing card Transform to the stock pile's position before writing StateChangedEvent. The downstream slide tween in card_plugin then reads the stock position as its source, so all 52 cards animate out from a single point — reads as "dealing from the deck" with no information leak. No layout reach in headless test contexts so the snap is gated on Option>. Reported by Quat. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_engine/src/game_plugin.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index f83f800..b1295b6 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -168,6 +168,8 @@ fn handle_new_game( font_res: Option>, confirm_screens: Query>, game_over_screens: Query>, + layout: Option>, + mut card_transforms: Query<&mut Transform, With>, ) { for ev in new_game.read() { // If an active game is in progress, intercept and show a confirm dialog. @@ -209,6 +211,24 @@ fn handle_new_game( && let Err(e) = delete_game_state_at(p) { warn!("game_state: failed to delete saved game: {e}"); } + // Snap every existing card sprite to the stock position before the + // deal animation starts. Without this the per-card slide tween reads + // each card's previous-game Transform as its source, which lets a + // careful observer track origin points to deduce where face-down + // cards came from. Funnelling all sprites through the deck position + // hides that information and reads naturally as "dealt from the + // deck." Skipped when LayoutResource isn't present (headless tests). + if let Some(layout) = layout.as_ref() + && let Some(stock) = layout + .0 + .pile_positions + .get(&solitaire_core::pile::PileType::Stock) + { + for mut tx in &mut card_transforms { + tx.translation.x = stock.x; + tx.translation.y = stock.y; + } + } changed.write(StateChangedEvent); } }