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>
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
LastAllianceStandingcondition - 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 (
internalRequireinRequestBattlesAction)
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:
-
Allied attackers jointly hold all castles -> both get
Victory(HoldsCriticalTiles). -
One attacker holds all castles alone, ally has
HoldsCriticalTilesand is mutually allied -> both getVictory(HoldsCriticalTiles). The ally is added towinning_shardok_idsbyUpdateGameStatusAction. -
Allied attackers eliminate all defenders -> both get
Victory(LastAllianceStanding). This triggers because both haveLastAllianceStandingand are mutually allied. -
One attacker holds all castles, co-attacker is NOT allied -> only the castle holder gets
Victory(HoldsCriticalTiles); the non-allied co-attacker getsLoss.
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: VictorylosingResolvedPlayers: 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
ProvinceConqueredActionimmediately - 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
PendingConquestInfowith 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 provincearmySize: total troop count of those unitsbroughtGold/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):
- The keeper's units run through
ProvinceConqueredAction-- province transfers to their faction - Withdrawing claimants' units become incoming armies to their chosen adjacent province, arriving next round, carrying the specified supplies
PendingConquestInfois cleared from the province
6. Summary: Who Gets What
Two allied attackers jointly hold all castles
- Shardok: Both get
Victory(HoldsCriticalTiles)(both inwinning_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 withHoldsCriticalTilesis added towinning_shardok_ids-> also getsVictory(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
LastAllianceStandingand are mutually allied -> both getVictory(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 getsLoss(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 getLoss. - BUG:
GameOverResponsePopulatorcurrently throws because dead ally is allied to winner but not inwinning_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 |