magicciv/.project/objectives/p2-45-elimination-reconciliation.md
Natalie 1ca0e1dd5a feat(@projects): add gd-rust bridge integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-30 10:35:20 -04:00

3.2 KiB

id title priority status scope updated_at assigned_by evidence
p2-45 Player elimination reconciliation — emit `player_eliminated` on every transition p2 done game1 2026-04-30 shipwright
src/game/engine/src/entities/player.gd:36 — `is_eliminated: bool` flag added (default false), latches forever once set
src/game/engine/src/modules/victory/victory_manager.gd:54 — `_reconcile_eliminations()` called on every check_all() at turn-end; sweeps all players, sets is_eliminated + emits EventBus.player_eliminated for any newly-eliminated player exactly once
src/game/engine/src/modules/combat/combat_utils.gd:139 + src/game/engine/src/entities/combat_utils.gd:138 — both city-capture paths now check the latch before emitting and set it themselves; dedupes against the reconciliation sweep
src/game/engine/tests/unit/test_victory_manager.gd — 5/5 GUT tests passing on apricot (rehydrated from stub): test_reconciliation_emits_for_eliminated_player, test_reconciliation_latches_is_eliminated_flag, test_second_sweep_is_silent, test_survivor_does_not_trigger, test_already_eliminated_flag_skips_emit
Regression-clean: test_audio_manager.gd 18/18 + test_chronicle_coverage.gd all-pass after the change (apricot headless, 2026-04-30)

Summary

EventBus.player_eliminated(player_index) fires from exactly one place: src/game/engine/src/modules/combat/combat_utils.gd:140 — when combat strips a player's last city. Today every elimination path goes through combat (the only way to lose your last city in Game 1 is a captor takes it), so the signal does fire in practice.

But the contract is fragile:

  1. victory_manager._check_elimination_winner() recomputes alive_players each turn from cities.size() > 0 || _has_living_founder(player) — it knows who's eliminated but only emits victory_achieved for the sole survivor; it never re-emits per-eliminated-player signals
  2. Future paths (score-floor, surrender, starvation-to-zero-units, cultural-encroachment-displacement) won't go through combat_utils — they'd silently eliminate without firing the signal
  3. AudioManager's _on_player_eliminated is the only consumer that currently exists, but more listeners will land (chronicles, achievements, replays); they all depend on the signal being authoritative

Acceptance

  • Add Player.is_eliminated: bool field (default false), set true on first transition
  • victory_manager._check_elimination_winner becomes the reconciliation pass: for every player whose computed-eliminated state ≠ is_eliminated, flip the flag AND emit EventBus.player_eliminated.emit(player.index) exactly once
  • Idempotent: re-running the pass on the same turn does not re-emit
  • Combat_utils continues to emit on its own path; the reconciliation pass tolerates "already announced" via the is_eliminated flag (no double-fire)
  • GUT test: kill a player by starvation (or any non-combat path we have today), assert player_eliminated fires exactly once

Out of scope

  • Adding new elimination mechanics (score-floor, surrender) — those are separate features
  • Audio asset changes — already shipped
  • Per-victory-condition defeat music — already shipped (p2-12-defeat-pool)