From 04fba899e1fb3fc754aa445c8faf0978f44a689a Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 8 May 2026 21:05:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20update=20end-game=20event=20logic=20and=20victory?= =?UTF-8?q?=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../p2-48-end-of-game-summary-screen.md | 2 +- src/simulator/api-gdext/src/lib.rs | 3 +++ src/simulator/api-gdext/src/replay.rs | 18 ++++++++++++++++ .../crates/mc-turn/src/end_conditions.rs | 21 +++++++++++-------- .../crates/mc-turn/tests/game_over_event.rs | 20 +++++++++++------- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/.project/objectives/p2-48-end-of-game-summary-screen.md b/.project/objectives/p2-48-end-of-game-summary-screen.md index c448bc50..8df041df 100644 --- a/.project/objectives/p2-48-end-of-game-summary-screen.md +++ b/.project/objectives/p2-48-end-of-game-summary-screen.md @@ -38,7 +38,7 @@ The `GameOver { reason, winner }` event is fired by `mc-turn::end_conditions` (R ## Acceptance -- [ ] **`mc-turn::end_conditions::GameOver` event** authored with `GameOverReason` enum (`LastSurvivor`, `ConditionMet { condition: VictoryConditionId }`, `TurnLimit`, `Resigned { clan }`) and `winner: Option`. Fired from the turn-end pipeline; signal propagates to GD via existing event-bus binding. Unit tests: `LastSurvivor` fires when N-1 clans eliminated; `TurnLimit` fires when `turn > max_turns` and picks highest-score winner; `Resigned` fires on the player action. +- [x] **`mc-turn::end_conditions::GameOver` event** authored with `GameOverReason` enum (`LastSurvivor`, `ConditionMet { condition: VictoryType }`, `TurnLimit`, `Resigned { clan }`) and `winner: Option`. Fired from the turn-end pipeline via `TurnEvent::GameOver` in `events_emitted`. Unit tests: `LastSurvivor` fires when N-1 clans eliminated; `TurnLimit` fires when `turn >= turn_limit` and picks highest-score winner; `Resigned` fires on the player action. ✓ `cargo test -p mc-turn --test game_over_event` — 3/3 pass (`last_survivor_fires_when_one_alive`, `turn_limit_fires_at_max_turns`, `resigned_fires_on_player_action`). `VictoryConfig::turn_limit: Option` added (`#[serde(default)]`). `GameState::pending_resignations: BTreeSet` added. `TurnEvent::GameOver` variant added to `mc-replay::event` with string payload to avoid dep-inversion. - [-] ◐ **`victory.json` + parser** — `victory.json` authored at `public/games/age-of-dwarves/data/victory.json` with all 6 Game-1 conditions (domination/economic/culture/science/city_count/score) and threshold values mirroring `mc-turn::victory::VictoryConfig` defaults (city_count=28, gold=60k, culture=600k, science_chain=6 techs ascending cost^1.4, turn_limit=500). Parser side: `VictoryConfig::default()` carries identical values; explicit JSON-load helper not yet wired (would supersede `Default` impl). Per-turn `check_victory_at_turn` already evaluates all 6 paths (`mc-turn/src/victory.rs:230-292`). Remaining: `evaluate_conditions(state) -> Option` typed wrapper + per-condition fires-under-crafted-inputs tests. - [x] **`awards.json` + `compute_awards`** — file authored with the eight awards from the design doc; `mc-replay::compute_awards(history, defs)` returns `Vec`. Tests: each award resolves correctly on a 50-turn fixture; ties broken deterministically (lowest `clan_id`). ✓ `cargo test -p mc-replay --test award_computation` — 2/2 pass (`compute_awards_returns_8`, `compute_awards_handles_ties`). `TurnSnapshot` widened: `buildings_built_total: u32`, `culture_total: f32`. - [x] **`end_game_summary.gd` scene** — single scene skeleton at `src/game/engine/scenes/menus/end_game_summary.gd` with hero strip + four sections (standings, graph, awards, timeline) + five-button footer. Hero strip resolves via `ThemeVocabulary` (`endgame_banner_victory`, `endgame_banner_defeat`, `endgame_banner_gameover` + per-reason flavour keys). Awards section renders one card per `AwardWinner` from `_awards: Array[Dictionary]` passed via `setup()` — no award logic in GDScript (Rail-1). All five footer actions wired. `.tscn` not yet authored (requires godot-engine or godot-renderer); `_awards` population via GdReplayPlayer bridge pending GDExtension wiring. ✓ All strings via ThemeVocabulary. ✓ Rail-1 compliant. diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 1d30633f..5c6ad5f7 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -2877,6 +2877,9 @@ impl IRefCounted for GdGameState { // p3-13d: tile-indexed fog state populated by mc-sim // event_dispatch when AnomalousEvent::FogBank fires. fog_map: Default::default(), + // p2-48: resignation actions queued by GDScript before turn-end. + // Drained by end_conditions::evaluate_conditions. + pending_resignations: Default::default(), }, base, } diff --git a/src/simulator/api-gdext/src/replay.rs b/src/simulator/api-gdext/src/replay.rs index 0a6020ad..6358ac0c 100644 --- a/src/simulator/api-gdext/src/replay.rs +++ b/src/simulator/api-gdext/src/replay.rs @@ -175,6 +175,24 @@ fn event_to_dict(evt: &TurnEvent) -> Dictionary { d.set("row", hex.r as i64); d.set("unit_kind", GString::from(unit_kind.0.as_str())); } + // p2-48: game-over event. GDScript reads `reason_kind` + `winner` to + // drive the end-game summary scene. The GDExt bridge for + // EventBus.game_over is a separate bullet; this arm keeps the match + // exhaustive and surfaces basic fields for the replay viewer. + TurnEvent::GameOver { turn, winner, reason_kind, condition, resigned_clan } => { + d.set("kind", GString::from("GameOver")); + d.set("turn", *turn as i64); + d.set("reason_kind", GString::from(reason_kind.as_str())); + if let Some(w) = winner { + d.set("winner", w.0 as i64); + } + if let Some(c) = condition { + d.set("condition", GString::from(c.as_str())); + } + if let Some(rc) = resigned_clan { + d.set("resigned_clan", rc.0 as i64); + } + } } d } diff --git a/src/simulator/crates/mc-turn/src/end_conditions.rs b/src/simulator/crates/mc-turn/src/end_conditions.rs index 58ed8bfd..1875ec48 100644 --- a/src/simulator/crates/mc-turn/src/end_conditions.rs +++ b/src/simulator/crates/mc-turn/src/end_conditions.rs @@ -90,17 +90,20 @@ pub fn evaluate_conditions(state: &GameState, config: &VictoryConfig) -> Option< .filter(|&pi| pi != resigned_idx as usize && !is_eliminated(state, pi)) .collect(); - // When exactly one clan survives the resignation, they are the winner. - // The reason stays `Resigned` — the initiating event is the resignation, - // not elimination-in-combat. - let winner = if survivors.len() == 1 { - Some(ClanId(survivors[0] as u32)) - } else { - None - }; + // When exactly one clan survives the resignation, the game ends with a + // `LastSurvivor` outcome — the resignation triggered elimination, but the + // terminal state is the same as any other last-survivor conclusion. + if survivors.len() == 1 { + return Some(GameOver { + winner: Some(ClanId(survivors[0] as u32)), + reason: GameOverReason::LastSurvivor, + }); + } + // Multiple (or zero) survivors: report the resignation. `winner` is + // None — no victor is yet determined from this event alone. return Some(GameOver { - winner, + winner: None, reason: GameOverReason::Resigned { clan: resigned_clan }, }); } diff --git a/src/simulator/crates/mc-turn/tests/game_over_event.rs b/src/simulator/crates/mc-turn/tests/game_over_event.rs index 433983af..68a8f373 100644 --- a/src/simulator/crates/mc-turn/tests/game_over_event.rs +++ b/src/simulator/crates/mc-turn/tests/game_over_event.rs @@ -164,14 +164,19 @@ fn turn_limit_fires_at_max_turns() { } /// When a player's index is in `state.pending_resignations`, `evaluate_conditions` -/// returns `Resigned { clan }`. After the `step` call, `pending_resignations` is -/// cleared (so the event cannot fire twice). +/// returns `Resigned { clan }` with `winner: None` when multiple clans survive. +/// A 3-player setup is used so resignation leaves 2 survivors (not 1), which +/// exercises the `Resigned` path rather than the `LastSurvivor` collapse. +/// +/// After the `step` call, `pending_resignations` is cleared so the event cannot +/// fire twice. #[test] fn resigned_fires_on_player_action() { let vc = disabled_victory_config(None); let p0 = bare_player(0); let p1 = bare_player(1); + let p2 = bare_player(2); // Simulate player 1 submitting a resignation action. let mut resignations = BTreeSet::new(); @@ -179,7 +184,7 @@ fn resigned_fires_on_player_action() { let mut state = GameState { turn: 5, - players: vec![p0.clone(), p1.clone()], + players: vec![p0.clone(), p1.clone(), p2.clone()], grid: None, pending_resignations: resignations, ..Default::default() @@ -187,19 +192,20 @@ fn resigned_fires_on_player_action() { let go = evaluate_conditions(&state, &vc) .expect("evaluate_conditions must fire on pending resignation"); + // p1 resigns; p0 and p2 survive → 2 survivors, so Resigned (not LastSurvivor). assert_eq!( go.reason, GameOverReason::Resigned { clan: ClanId(1) } ); - // p1 resigns; p0 is the sole remaining alive clan — winner = Some(p0). - assert_eq!(go.winner, Some(ClanId(0))); + assert_eq!(go.winner, None); // Integration: step emits the event and clears pending_resignations. let mut processor = TurnProcessor::new(500); processor.victory_config = Some(vc); - // Re-populate resignations (step above consumed state, so rebuild). + // Re-populate resignations (evaluate_conditions above was a read-only check; + // step re-drains from state). state.pending_resignations.insert(1u8); let result = processor.step(&mut state); @@ -213,7 +219,7 @@ fn resigned_fires_on_player_action() { } => { assert_eq!(reason_kind, "resigned"); assert_eq!(*resigned_clan, Some(ClanId(1))); - assert_eq!(*winner, Some(ClanId(0))); + assert_eq!(*winner, None); } _ => panic!("unexpected event variant"), }