feat(@projects/@magic-civilization): update end-game event logic and victory conditions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-08 21:05:26 -07:00
parent 9ddc350a94
commit 04fba899e1
5 changed files with 47 additions and 17 deletions

View file

@ -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<ClanId>`. 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<ClanId>`. 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<u32>` added (`#[serde(default)]`). `GameState::pending_resignations: BTreeSet<u8>` 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<GameOver>` 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<AwardWinner { award_id, clan, value }>`. 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.

View file

@ -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,
}

View file

@ -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
}

View file

@ -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 },
});
}

View file

@ -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"),
}