fix(engine): start new game when player confirms abandon-current-game modal
Reported during 2026-04-29 smoke test: pressing Y on the
ConfirmNewGameScreen modal closed nothing and didn't start a new game.
Trace:
Frame N: handle_confirm_input despawns the modal entity (deferred),
writes NewGameRequestEvent.
End of N: command flush — modal gone.
Frame N+1: handle_new_game reads the event. needs_confirm is still
true (game state unchanged). confirm_already_open is now
false (modal flushed). Condition matches → spawn_confirm_
dialog runs again, the modal reappears, and the new game
never starts.
Add a `confirmed: bool` field to NewGameRequestEvent. handle_confirm_
input writes it as true on Y/Enter so handle_new_game's dialog-spawn
guard short-circuits and the existing despawn-and-start branch runs.
All other writers (button click, N hotkey, mode hotkeys, daily/
challenge/time-attack auto-deal, tests) stay at `confirmed: false`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,7 @@ fn handle_start_challenge_request(
|
|||||||
new_game.write(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: Some(seed),
|
seed: Some(seed),
|
||||||
mode: Some(GameMode::Challenge),
|
mode: Some(GameMode::Challenge),
|
||||||
|
confirmed: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ fn handle_start_daily_request(
|
|||||||
new_game.write(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: Some(daily.seed),
|
seed: Some(daily.seed),
|
||||||
mode: None,
|
mode: None,
|
||||||
|
confirmed: false,
|
||||||
});
|
});
|
||||||
let desc = daily
|
let desc = daily
|
||||||
.goal_description
|
.goal_description
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ pub struct UndoRequestEvent;
|
|||||||
pub struct NewGameRequestEvent {
|
pub struct NewGameRequestEvent {
|
||||||
pub seed: Option<u64>,
|
pub seed: Option<u64>,
|
||||||
pub mode: Option<GameMode>,
|
pub mode: Option<GameMode>,
|
||||||
|
/// `true` when this request originated from the user confirming the
|
||||||
|
/// abandon-current-game modal (Y / Enter on `ConfirmNewGameScreen`).
|
||||||
|
/// `handle_new_game` skips spawning the dialog when this is set,
|
||||||
|
/// otherwise it would respawn the modal in the frame after the player
|
||||||
|
/// presses Y (the despawn-on-Y has flushed by then) and the new game
|
||||||
|
/// would never actually start.
|
||||||
|
pub confirmed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
|
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
|
||||||
|
|||||||
@@ -164,9 +164,12 @@ fn handle_new_game(
|
|||||||
// If an active game is in progress, intercept and show a confirm dialog.
|
// 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.
|
// 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).
|
// 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
|
||||||
|
// modal would be respawned the frame after the despawn flushes.
|
||||||
let confirm_already_open = !confirm_screens.is_empty();
|
let confirm_already_open = !confirm_screens.is_empty();
|
||||||
if needs_confirm && !confirm_already_open {
|
if needs_confirm && !confirm_already_open && !ev.confirmed {
|
||||||
// Despawn any stale game-over overlay before showing confirm dialog.
|
// Despawn any stale game-over overlay before showing confirm dialog.
|
||||||
for entity in &game_over_screens {
|
for entity in &game_over_screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
@@ -300,12 +303,15 @@ fn handle_confirm_input(
|
|||||||
|
|
||||||
if confirmed {
|
if confirmed {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
// Re-send with move_count already 0 would bypass the dialog next time.
|
// Set `confirmed: true` so handle_new_game skips the dialog spawn
|
||||||
// We fire the event — handle_new_game will skip the dialog because
|
// and goes straight to the start-game branch. Without this flag the
|
||||||
// the screen is despawned before the next read.
|
// modal would respawn the frame after the despawn flushes (because
|
||||||
|
// confirm_screens is empty by then) and the new game would never
|
||||||
|
// actually start.
|
||||||
new_game.write(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: original.0.seed,
|
seed: original.0.seed,
|
||||||
mode: original.0.mode,
|
mode: original.0.mode,
|
||||||
|
confirmed: true,
|
||||||
});
|
});
|
||||||
} else if cancelled {
|
} else if cancelled {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
@@ -790,7 +796,7 @@ mod tests {
|
|||||||
.map(|c| c.id)
|
.map(|c| c.id)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
app.world_mut().write_message(NewGameRequestEvent { seed: Some(999), mode: None });
|
app.world_mut().write_message(NewGameRequestEvent { seed: Some(999), mode: None, confirmed: false });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let after: Vec<u32> = app
|
let after: Vec<u32> = app
|
||||||
@@ -908,7 +914,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut app = test_app(1);
|
let mut app = test_app(1);
|
||||||
app.insert_resource(GameStatePath(Some(path.clone())));
|
app.insert_resource(GameStatePath(Some(path.clone())));
|
||||||
app.world_mut().write_message(NewGameRequestEvent { seed: Some(2), mode: None });
|
app.world_mut().write_message(NewGameRequestEvent { seed: Some(2), mode: None, confirmed: false });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
assert!(!path.exists(), "saved file should be deleted after new game");
|
assert!(!path.exists(), "saved file should be deleted after new game");
|
||||||
@@ -1120,7 +1126,7 @@ mod tests {
|
|||||||
// Simulate an active game with moves made.
|
// Simulate an active game with moves made.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 5;
|
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 5;
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.write_message(NewGameRequestEvent { seed: None, mode: None });
|
.write_message(NewGameRequestEvent { seed: None, mode: None, confirmed: false });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let count = app
|
let count = app
|
||||||
@@ -1141,7 +1147,7 @@ mod tests {
|
|||||||
"test assumes a fresh game with no moves"
|
"test assumes a fresh game with no moves"
|
||||||
);
|
);
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.write_message(NewGameRequestEvent { seed: None, mode: None });
|
.write_message(NewGameRequestEvent { seed: None, mode: None, confirmed: false });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let count = app
|
let count = app
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ fn handle_keyboard_core(
|
|||||||
ev.new_game.write(NewGameRequestEvent {
|
ev.new_game.write(NewGameRequestEvent {
|
||||||
seed: None,
|
seed: None,
|
||||||
mode: Some(solitaire_core::game_state::GameMode::Classic),
|
mode: Some(solitaire_core::game_state::GameMode::Classic),
|
||||||
|
confirmed: false,
|
||||||
});
|
});
|
||||||
confirm.new_game_countdown = 0.0;
|
confirm.new_game_countdown = 0.0;
|
||||||
return;
|
return;
|
||||||
@@ -218,6 +219,7 @@ fn handle_keyboard_core(
|
|||||||
ev.new_game.write(NewGameRequestEvent {
|
ev.new_game.write(NewGameRequestEvent {
|
||||||
seed: None,
|
seed: None,
|
||||||
mode: Some(solitaire_core::game_state::GameMode::Zen),
|
mode: Some(solitaire_core::game_state::GameMode::Zen),
|
||||||
|
confirmed: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ev.info_toast.write(InfoToastEvent(format!(
|
ev.info_toast.write(InfoToastEvent(format!(
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ mod tests {
|
|||||||
.move_count = 3;
|
.move_count = 3;
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.write_message(NewGameRequestEvent { seed: Some(999), mode: None });
|
.write_message(NewGameRequestEvent { seed: Some(999), mode: None, confirmed: false });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let stats = &app.world().resource::<StatsResource>().0;
|
let stats = &app.world().resource::<StatsResource>().0;
|
||||||
@@ -567,7 +567,7 @@ mod tests {
|
|||||||
fn new_game_without_moves_does_not_record_abandoned() {
|
fn new_game_without_moves_does_not_record_abandoned() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.write_message(NewGameRequestEvent { seed: Some(42), mode: None });
|
.write_message(NewGameRequestEvent { seed: Some(42), mode: None, confirmed: false });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let stats = &app.world().resource::<StatsResource>().0;
|
let stats = &app.world().resource::<StatsResource>().0;
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ fn handle_start_time_attack_request(
|
|||||||
new_game.write(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: None,
|
seed: None,
|
||||||
mode: Some(GameMode::TimeAttack),
|
mode: Some(GameMode::TimeAttack),
|
||||||
|
confirmed: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ fn auto_deal_on_time_attack_win(
|
|||||||
new_game.write(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: None,
|
seed: None,
|
||||||
mode: Some(GameMode::TimeAttack),
|
mode: Some(GameMode::TimeAttack),
|
||||||
|
confirmed: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -742,6 +742,7 @@ mod tests {
|
|||||||
app.world_mut().write_message(NewGameRequestEvent {
|
app.world_mut().write_message(NewGameRequestEvent {
|
||||||
seed: None,
|
seed: None,
|
||||||
mode: Some(GameMode::Zen),
|
mode: Some(GameMode::Zen),
|
||||||
|
confirmed: false,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user