Files
eagle0/docs/ALLIED_VICTORY_BATTLE_RESOLUTION.md
T
11430be186 Filter destroyed battalion IDs from aftermath claimants and update doc (#6437)
Captured units' battalions are destroyed during BattleEnded processing,
but the multi-victor branch was storing those destroyed IDs in
AftermathClaimantUnit. This caused invalid battalion references when
claimants withdrew. Filter them out when building claimants and
notFledDefenders, and exclude them from armySize calculation.

Also update ALLIED_VICTORY_BATTLE_RESOLUTION.md to reflect PR 6429
changes (LastAllianceStanding for attackers, allied co-attacker added
to winners for solo castle holder) and document the dead-ally
GameOverResponsePopulator bug fixed in PR 6435.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:15:35 -07:00

13 KiB

Allied Victory: Battle Resolution Flow

How allied victories work end-to-end, from Shardok tactical resolution through Eagle strategic processing to player-facing aftermath decisions.

1. Battle Setup (Eagle -> Shardok)

When Eagle sends a battle to Shardok via RequestBattlesAction, each player gets specific victory conditions:

Player Victory Conditions
Attacker(s) HoldsCriticalTiles, LastPlayerStanding, LastAllianceStanding
Defender LastPlayerStanding, WinAfterMaxRounds

This is set in RequestBattlesAction.shardokPlayers.

Alliance information is sent separately in PlayerSetupInfo.allies. Eagle reads each player's factionRelationships and includes all non-HOSTILE factions as allies (ShardokInterfaceGrpcClient.scala:75-80).

2. Victory Condition Evaluation (Shardok)

Victory conditions are checked in UpdateGameStatusAction.cpp in priority order:

Check 1: LastPlayerStanding (any time)

If exactly one player has surviving units and has LastPlayerStanding:

  • That player is the sole winner.
  • This applies to solo victories only (1 survivor).

Check 2: LastAllianceStanding (any time)

If multiple players survive, check if they form a winning alliance:

  • ALL survivors must have LastAllianceStanding condition
  • ALL survivors must be mutually allied (every pair checks both directions)
  • If yes: all survivors are winners

This triggers for AssaultProvince battles when allied attackers eliminate all defenders. Since the defender does not have LastAllianceStanding, the check correctly requires that only the allied attackers remain alive.

Check 3: WinAfterMaxRounds (end-of-round only)

If the round counter exceeds max_rounds:

  • Find the player with WinAfterMaxRounds (always the defender)
  • That player wins, even if they have no surviving units
  • Eagle validates exactly 1 player has this condition (internalRequire in RequestBattlesAction)

Check 4: HoldsCriticalTiles (end-of-round only)

Single-player castle control: If one player with HoldsCriticalTiles occupies ALL critical tiles with hero-bearing units, that player wins. Additionally, any other player who:

  • Has HoldsCriticalTiles
  • Is mutually allied with the castle holder

is also added to winning_shardok_ids as a co-winner.

Allied castle control: If multiple players collectively occupy all critical tiles with hero-bearing units, AND:

  • All occupants have HoldsCriticalTiles
  • All occupants are mutually allied

Then all occupants are winners together.

3. EndGameCondition Assignment Per Player (Shardok -> Eagle)

Shardok assigns each player a Victory or Loss. All winners receive Victory; there is no AllyVictory distinction. The EndGameCondition proto reserves fields 2 (ally_victory) and 3 (draw) for backwards compatibility but Eagle rejects both.

GameOverResponsePopulator validates that any player allied to a winner is also in winning_shardok_ids. If an ally is missing from the winners list, it throws an internal error.

Known issue: This validation is too strict for LastPlayerStanding. When one allied attacker is the sole survivor, their dead co-attacker is allied to the winner but legitimately not in winning_shardok_ids (they're dead). The throw should be changed to Loss for dead allies, or the check should only apply when the ally has surviving units.

Condition Criterion
Victory(type) Player is in winning_shardok_ids
Loss(type) Player is not in winning_shardok_ids

The type is the VictoryCondition that caused the game to end (e.g., HoldsCriticalTiles).

When do multiple players get Victory?

In standard AssaultProvince battles:

  1. Allied attackers jointly hold all castles -> both get Victory(HoldsCriticalTiles).

  2. One attacker holds all castles alone, ally has HoldsCriticalTiles and is mutually allied -> both get Victory(HoldsCriticalTiles). The ally is added to winning_shardok_ids by UpdateGameStatusAction.

  3. Allied attackers eliminate all defenders -> both get Victory(LastAllianceStanding). This triggers because both have LastAllianceStanding and are mutually allied.

  4. One attacker holds all castles, co-attacker is NOT allied -> only the castle holder gets Victory(HoldsCriticalTiles); the non-allied co-attacker gets Loss.

4. Eagle Processes Battle Results

ResolveBattleAction.scala receives BattleResolution containing each player's EndGameCondition and resolved units.

Winner/Loser Partition

Players are split by isVictory (line 383-386):

  • winningResolvedPlayers: Victory
  • losingResolvedPlayers: Loss

The unitReturned Function (line 765-788)

Determines which units go home vs stay at the battle province:

Unit Status Returned? Notes
Fled Always Sent to their army's flee province
NeverEntered Conditional Only if: attacker AND has flee province AND did NOT win (!isVictory)
Captured Never
Normal Never
Retreated Never
Outlawed Never Becomes unaffiliated hero

Key point: NeverEntered units from winning factions stay at the battle province rather than being sent home. This is important for bounced co-attackers who are allied with the actual winner.

Withdrawn/Fled Unit Routing (line 690-706)

For units that ARE returned, armyFromResolvedArmy builds a returning army with fleeProvinceId = None. If the army has no fleeProvinceId, it becomes "shattered" (units become outlaws in the battle province). If the original army had a fleeProvinceId set, returned units are sent there as incoming armies.

Battle Resolution Branching (line 397-552)

Three branches based on who won:

Branch A: Defender Won

Triggered when: winningShardokPlayers.exists(_.isDefender)

  • Executes ProvinceHeldAction
  • All non-defender, non-returned, non-outlawed units are captured
  • Province stays with the defending faction

Branch B: Single Attacker Won

Triggered when: exactly 1 attacking winner

  • Executes ProvinceConqueredAction immediately
  • Winning attacker's non-fled units occupy the province
  • Losing defenders' non-fled units are captured
  • Province transfers to the attacker

Branch C: Multiple Allied Attackers Won

Triggered when: 2+ attacking winners

  • Executes MultiVictorBattleSetupAction
  • Creates PendingConquestInfo with aftermath claimants
  • Province enters the Battle Aftermath phase

5. Battle Aftermath (Multi-Victor Only)

When multiple allied attackers win, the province enters an aftermath decision phase where players decide who keeps it.

Claimant Setup

Each attacking winner becomes an AftermathClaimant with:

  • units: their non-fled, non-outlawed units still at the battle province
  • armySize: total troop count of those units
  • broughtGold / broughtFood: supplies their armies carried

Claimants are sorted by army size descending (largest army decides first).

Decision Phase

Claimants are presented with a choice one at a time, in order:

If no one has chosen "Keep" yet:

  • Keep Province: claim the province for your faction
  • Withdraw To: pick an adjacent province to march your army to, optionally carrying up to the gold/food you brought

If someone already chose "Keep":

  • Withdraw To is the only option (can't have two keepers)

Last undecided claimant:

  • If no one has chosen "Keep" yet, the last claimant automatically keeps (no command shown, resolved by AutoResolveBattleAftermathAction)
  • This guarantees someone always claims the province

Finalization

Once all claimants have decided (FinalizeAftermathAction):

  1. The keeper's units run through ProvinceConqueredAction -- province transfers to their faction
  2. Withdrawing claimants' units become incoming armies to their chosen adjacent province, arriving next round, carrying the specified supplies
  3. PendingConquestInfo is cleared from the province

6. Summary: Who Gets What

Two allied attackers jointly hold all castles

  • Shardok: Both get Victory(HoldsCriticalTiles) (both in winning_shardok_ids)
  • Eagle: Both are attacking winners -> MultiVictorBattleSetupAction
  • Players: Largest army picks first: Keep or Withdraw. Last player auto-keeps if no one chose Keep.

One attacker holds all castles, allied attacker doesn't

  • Shardok: Castle-holder gets Victory(HoldsCriticalTiles). Mutually-allied co-attacker with HoldsCriticalTiles is added to winning_shardok_ids -> also gets Victory(HoldsCriticalTiles).
  • Eagle: Both are attacking winners -> MultiVictorBattleSetupAction -> Battle Aftermath.
  • Players: Largest army picks first: Keep or Withdraw.

Allied attackers eliminate all defenders

  • Shardok: Both attackers have LastAllianceStanding and are mutually allied -> both get Victory(LastAllianceStanding).
  • Eagle: Both are attacking winners -> MultiVictorBattleSetupAction -> Battle Aftermath.
  • Players: Largest army picks first: Keep or Withdraw.

Non-allied co-attackers: one holds all castles

  • Shardok: Castle-holder gets Victory(HoldsCriticalTiles), non-allied co-attacker gets Loss(HoldsCriticalTiles).
  • Eagle: Single attacker won -> ProvinceConqueredAction. Non-allied co-attacker's units are captured along with the defender's.

One allied attacker is sole survivor (LastPlayerStanding)

  • Shardok: Sole survivor gets Victory(LastPlayerStanding). Dead ally should get Loss.
  • BUG: GameOverResponsePopulator currently throws because dead ally is allied to winner but not in winning_shardok_ids. Needs fix — see known issue in Section 3.
  • Eagle (once fixed): Single attacker won -> ProvinceConqueredAction. Dead ally's units were already destroyed in battle.

Single attacker wins (any condition, no co-attackers)

  • No alliance considerations. ProvinceConqueredAction immediately.

Defender wins (WinAfterMaxRounds or LastPlayerStanding)

  • ProvinceHeldAction. All non-returned attacker units are captured.

7. Edge Cases and Known Issues

BUG: Dead ally causes crash via LastPlayerStanding

When one allied attacker is the sole survivor (both the co-attacker and defender are dead), LastPlayerStanding fires and only the survivor is in winning_shardok_ids. The GameOverResponsePopulator then sees the dead co-attacker is allied to the winner but not in winning_shardok_ids, and throws ShardokInternalErrorException. Fix: dead allies should receive Loss, not trigger a validation error.

Non-allied co-attackers eliminate all defenders

Two non-allied attackers who both survive after eliminating the defender cannot win via LastAllianceStanding (they aren't mutually allied). The game continues until max rounds, at which point the defender wins via WinAfterMaxRounds. They must capture all critical tiles.

Non-allied co-attackers: one holds all castles

Only the castle holder wins. The non-allied co-attacker gets Loss and their units are captured along with the defender's. The mutual-alliance check in UpdateGameStatusAction prevents non-allied players from being added to winning_shardok_ids.

Key File References

Component File
Victory condition checking src/main/cpp/net/eagle0/shardok/library/actions/UpdateGameStatusAction.cpp
Per-player EndGameCondition src/main/cpp/net/eagle0/shardok/server/GameOverResponsePopulator.cpp
Battle setup (victory conditions) src/main/scala/.../library/actions/impl/action/RequestBattlesAction.scala
Alliance communication src/main/scala/.../shardok_interface/ShardokInterfaceGrpcClient.scala:75-80
Battle result processing src/main/scala/.../library/actions/impl/action/ResolveBattleAction.scala
Unit return logic ResolveBattleAction.scala:765-788 (unitReturned)
Multi-victor setup src/main/scala/.../library/actions/impl/action/MultiVictorBattleSetupAction.scala
Aftermath availability src/main/scala/.../library/actions/availability/AvailableBattleAftermathDecisionCommandFactory.scala
Aftermath command src/main/scala/.../library/actions/impl/command/BattleAftermathDecisionCommand.scala
Auto-resolve last claimant src/main/scala/.../library/actions/impl/action/AutoResolveBattleAftermathAction.scala
Finalize aftermath src/main/scala/.../library/actions/impl/action/FinalizeAftermathAction.scala
EndGameCondition enum src/main/scala/.../model/state/shardok_battle/EndGameCondition.scala