Skip to content

Add a terminal defeat state when the player loses every town - #6

Open
Dbhardwaj99 wants to merge 2 commits into
codex/campaign-integrityfrom
codex/que-5-terminal-defeat
Open

Add a terminal defeat state when the player loses every town#6
Dbhardwaj99 wants to merge 2 commits into
codex/campaign-integrityfrom
codex/que-5-terminal-defeat

Conversation

@Dbhardwaj99

Copy link
Copy Markdown
Owner

Closes QUE-5

The bug

Enemy realms capture player towns in runEnemyTurn, but losing the last one was never a state the game recognised. Two places fix up activeTownID after a capture — GameViewModel.sanitizeSelection() and GameRules.runEnemyTurn — and both look for the "next" player-controlled town and quietly do nothing when there is none.

activeTownID is then left pointing at a town the player no longer owns, and activeTown falls back to state.towns[0]. The player carries on building, training and taking turns in someone else's town, indefinitely. .victory was the only terminal phase.

The fix

The player can only lose towns through an enemy capture, and every capture reaches the view model via advanceDaysanitizeSelection(). That single funnel is where the check goes, so both the timer path (tick) and the manual path (advanceDayManually) are covered by one guard rather than one per caller.

  • GamePhase.defeat, and DefeatView mirroring VictoryView.
  • concedeCampaign() stops the clock and dismisses any open sheet — a day can roll over while the build menu is open, which is a real scenario for defeat (it never was for victory, which only fires from an explicit attack tap).
  • tick() stops advancing days once the phase leaves .town.

Testing

losingTheLastTownEndsTheCampaign drives a real GameRules.resolveAttack capture of the player's only town and asserts the phase turns over. Verified it fails without the guard (Expectation failed: (viewModel.phase → .town) == .defeat) and passes with it.

Test run with 3 tests in 1 suite passed
** TEST SUCCEEDED **

The test target previously synced only Models + Tests, so GameViewModel was out of scope; it now also syncs ViewModels, which depends on nothing beyond Foundation, Observation and Models.

Note on the base branch

Targets codex/campaign-integrity as requested. That branch is currently at the same commit as main, so this can be retargeted at main with no conflicts if that is preferred.

🤖 Generated with Claude Code

Dbhardwaj99 and others added 2 commits August 1, 2026 18:27
Enemy raids can capture the player's last town, but nothing noticed.
`sanitizeSelection()` and `runEnemyTurn` both look for the "next" player
town and quietly do nothing when there is none, leaving `activeTownID`
pointing at an enemy town — `activeTown` then falls back to `towns[0]`
and the player keeps building in a town they do not own, forever.

Every faction change funnels through `sanitizeSelection()`, so the check
goes there: no player towns means `.defeat`, which stops the clock and
dismisses any open sheet (a day can roll over while the build menu is up).
`tick()` stops advancing days once the phase leaves `.town`.

DefeatView mirrors VictoryView. The test target now also compiles
ViewModels so the phase transition can be covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three acceptance criteria were unmet:

- "Automatic ticks and manual Next cannot advance or mutate a terminal
  campaign" — tick() was guarded but advanceDayManually() was not, so the
  Next Day button still rolled days over on a finished campaign.
- "Present a clear loss state with a New Campaign action" — DefeatView now
  takes an onNewCampaign closure, wired to ContentView.startGame.
- "A deterministic test ... proves a later advanceDay attempt leaves state
  unchanged" — the test now snapshots GameState after defeat and asserts
  both advanceDayManually() and tick() leave it byte-identical.

Victory behavior is untouched, as the ticket requires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant