Fleet Coordination¶
Status: 🚧 Partial — Fleet roster management, formation + coordination + supply modifiers, full battle-round simulation, winner/loot are all built and route-wired. Traced and resolved the previously-vague "⚠︎ contains code↔spec divergence (impl audit 2026-06-16)" tag: the real mismatch was the "Supply tick" section below describing a periodic auto-restore job that does not exist — see that section for the corrected mechanic (purchase-only resupply, no decay, no scheduler). (Re-verified 2026-08-21 vs Sectorwars2102 HEAD
46bce720; trace pass 2026-08-04.)
Purpose¶
The fleet-coordination subsystem turns a roster of individual ships into a single combat actor. It maintains a fleet's state machine (forming → ready → in_battle → disbanded), aggregates per-ship combat stats into fleet totals, applies formation × coordination modifiers (with supply-banded penalties) to attack and defense, runs round-by-round battle simulation when two fleets engage, records casualties, and divides loot. The contract is: a fleet at any state is internally consistent — totals match its members, status reflects what it can do.
Inputs¶
What triggers this:
- POST /api/v1/fleets — create_fleet(team_id, name, commander_id, formation).
- POST /api/v1/fleets/{id}/ships — add_ship_to_fleet(fleet_id, ship_id, role).
- DELETE /api/v1/fleets/{id}/ships/{ship_id} — remove_ship_from_fleet.
- Fleet position is not a travel-as-a-unit API. FleetService.move_fleet and POST /api/v1/fleets/{id}/move are retired. Fleet.sector_id is derived from the flagship's live Ship.sector_id on roster/stat recompute (fleet_service.py; see ../FEATURES/gameplay/fleet-tactics.md).
- POST /api/v1/fleets/{id}/formation — set_fleet_formation.
- POST /api/v1/fleets/{attacker_id}/attack/{defender_id} — initiate_battle.
- POST /api/v1/fleets/battles/{id}/round — simulate_battle_round.
State read:
- Fleet.status, Fleet.formation, Fleet.morale, Fleet.supply_level.
- Fleet.members[].role, .ship, .player_id.
- Ship.combat JSONB (attack_rating, shields, hull, max_hull).
- Ship.is_destroyed, Ship.current_speed.
- FleetBattle.battle_log, .phase, .attacker_* / .defender_* damage/destroyed/retreated counters.
- Owning Team.treasury_credits for loot.
Process¶
State machine¶
+------------+
| FORMING |
+------------+
|
(ship added,
commander set)
v
+------------+
| READY |<-----------------+
+------------+ |
| |
(initiate_battle) |
v |
+------------+ (battle ends) |
| IN_BATTLE |------------------+
+------------+
|
(last ship removed
OR explicit disband)
v
+------------+
| DISBANDED |
+------------+
RETREATING is a transient sub-state during the pursuit phase. Disbanded fleets are tombstones — kept for audit, never reactivated.
Add ship¶
- Verify fleet exists and
status ∈ {forming, ready}. - Verify ship exists and ship.owner.team_id == fleet.team_id.
- Verify ship not already in any fleet (FleetMember uniqueness).
- Insert FleetMember(fleet_id, ship_id, player_id, role, position=fleet.total_ships).
- Recalculate fleet stats from members.
- Commit.
Remove ship¶
- Find FleetMember(fleet_id, ship_id).
- Delete the row.
- Recalculate fleet stats.
- If
total_ships == 0: set status = DISBANDED, set disbanded_at = now. - Commit.
Recalculate fleet stats¶
fleet.total_ships = len(members)
fleet.total_firepower = Σ ship.combat["attack_rating"]
fleet.total_shields = Σ ship.combat["shields"]
fleet.total_hull = Σ ship.combat["hull"]
fleet.average_speed = mean(ship.current_speed)
Read combat values via JSONB safely (missing keys default to 0).
Coordination-bonus timing (per ADR-0061 S-I3): the fleet coordination_bonus is static — computed at fleet init and recomputed only on roster change events (member added, removed, or KIA). The combat resolver reads the cached value during the damage-stack composition (per ./combat-resolver.md#damage-stack-order-of-operations). A roster change mid-combat causes recompute at the next round boundary, not mid-round — combat math within a round is internally consistent. Fleet morale is a cosmetic/display column and does not enter the damage stack.
Formation modifier table¶
Per FEATURES/gameplay/fleet-tactics.md:
| Formation | Attack | Defense |
|---|---|---|
| standard | 1.00 | 1.00 |
| aggressive (Wedge) | 1.15 | 0.85 |
| defensive | 0.85 | 1.15 |
| flanking (Offensive) | 1.10 | 0.90 |
| turtle (Scatter) | 0.60 | 1.40 |
Combat damage is driven by formation × coordination, with the supply band applied as a read-time penalty (see Supply tick below). Fleet morale is a cosmetic/display column and is not a combat factor — it does not scale the formation modifier.
Coordination bonus (fleet ≥ 3 ships):
coordination_bonus = min(0.20, max(0, (ships - 2) * 0.025))
The coordination_bonus is not part of the formation attack_modifier. It enters combat as an outer multiplier in the damage stack — × (1 + coordination_bonus) — applied per ADR-0061 S-I3 alongside the rank multiplier (see ./combat-resolver.md#damage-stack-order-of-operations). At ≤ 2 ships the bonus is 0, so it is identity (no change to baseline damage).
Supply — read-time penalty + purchase-only restore (no periodic tick)¶
Corrected 2026-08-04 — there is no periodic "supply tick" scheduler job. Grep-verified against fleet_service.py (resupply_fleet's own code comment, WO-FLEET-CASUALTY-SUCCESSION correction): Fleet.supply_level is written nowhere else in the codebase — no scheduler job, no per-round combat write, no admin override, no auto-restore-at-dock. It starts at the model default (100) and moves only via a player-initiated resupply purchase. Supply never decays on its own; whether it should is an open design question tracked separately as WO-FLEET-SUPPLY-SINK, not part of today's shipped mechanic.
- Attack/defense penalty — applied at read time, during round resolution, not stored:
- 25 ≤ supply < 50: attack ×0.95, defense ×0.95.
- supply < 25: attack ×0.85, defense ×0.85.
- Restore — purchase-only, via
POST /fleets/{id}/resupply(FleetService.resupply_fleet, the sole writer ofsupply_levelin either direction — see../FEATURES/gameplay/fleet-tactics.mdfor the full requirement/cost breakdown): the fleet must be docked, not in battle, the requesting player a fleet member with sufficient credits; cost + per-visit restore ceiling scale with station class (RESUPPLY_COST_PER_POINT,RESUPPLY_BASE_RESTORE,RESUPPLY_RESTORE_PER_CLASS— a NO-CANON kernel). No tick, no cadence — it fires exactly once per resupply call.
Fleet morale is a cosmetic/display column — it does not auto-decay from supply, does not gate combat, and contributes no damage factor.
Battle round resolution¶
For simulate_battle_round(battle_id):
- Lock battle row.
- Get attacker/defender active ships (
hull > 0, not destroyed). - If either side empty → end battle.
- Compute formation bonuses for both fleets.
- Determine round number from log length.
- Attacker fire:
for each ship in attacker_ships: if random() < 0.7 and defender_ships: damage = attack_rating × 10 × attacker_attack_bonus × U(0.8, 1.2) target = random.choice(defender_ships) apply_damage(target, damage, defender_defense_bonus, battle, results) - Defender return fire: symmetric.
- Append round summary to
battle.battle_log(usingflag_modified). - Update battle damage counters.
- Check end conditions (see termination).
- Phase transition by round count (1–5 engagement, 6–15 main_battle, 16+ pursuit).
- Commit.
Damage application¶
Per-target damage in a fleet round uses the same shield-then-hull arithmetic as solo combat (see ./combat-resolver.md#damage-stack-order-of-operations) reduced by the formation's defense bonus. On a kill, fleet round-resolution delegates to the canonical destruction handler — CombatService._handle_ship_destruction (services/gameserver/src/services/combat_service.py, corrected citation 2026-08-04; see ./combat-resolver.md#destruction-transaction for the full 9-step transaction) — rather than just flipping is_destroyed. This keeps insurance, Cargo Wreck spawns, escape-pod ejection, reputation hooks, and audit logging on a single code path.
damage = max(1, int(damage / target_defense_bonus))
absorbed = min(damage, target.shields)
target.shields -= absorbed
remaining = damage - absorbed
target.hull -= remaining
if target.hull <= 0:
target.hull = 0
# Atomic transaction: status flip + destruction_cause + Cargo Wreck +
# insurance payout + escape-pod eject + log + reputation hooks.
combat_resolver._handle_ship_destroyed(
ship=target,
cause=DestructionCause.COMBAT,
killer_ship=attacker,
killer_player=attacker.current_pilot,
)
record FleetBattleCasualty(destroyed=true)
remove from fleet
elif target.hull < 0.3 * target.max_hull:
if random() < 0.3:
record FleetBattleCasualty(retreated=true)
remove from fleet
The destruction sub-routine is the single source of truth for what happens when a ship dies — fleet round-resolution must not duplicate or partially re-implement those side effects. Per-ship destruction commits inside the round transaction so a partial fleet wipe stays consistent under rollback.
Termination¶
Battle ends if any is true:
| Condition | Source |
|---|---|
| One side has 0 active ships | _get_active_fleet_ships empty |
| Either fleet > 70% losses (destroyed + retreated of initial) | attacker_ships_destroyed/retreated |
| 30 rounds elapsed | len(battle_log) > 30 |
Winner determination¶
After end:
attacker_strength = Σ (hull + shields) of attacker_active
defender_strength = Σ (hull + shields) of defender_active
if attacker_strength > defender_strength * 1.5: winner = "attacker"
elif defender_strength > attacker_strength * 1.5: winner = "defender"
elif len(attacker) > 0 and len(defender) == 0: winner = "attacker"
elif len(defender) > 0 and len(attacker) == 0: winner = "defender"
else: winner = "draw"
Loot¶
if winner == "attacker" and defender.team:
loot = defender.team.treasury_credits // 10
attacker.team.treasury_credits += loot
defender.team.treasury_credits -= loot
battle.credits_looted = loot
10% of loser's team treasury, capped at the available balance.
Outputs / state changes¶
Per add/remove:
- FleetMember row inserted/deleted.
- Fleet aggregated stats recomputed.
- fleet_status_changed event on transition to DISBANDED.
Per roster recompute (flagship position; no POST /move):
- Fleet.sector_id is set from the flagship member's ship sector (Sector UUID), best-effort if flagship/sector resolve.
- Member ships are not bulk-moved by a fleet relocate call — they move as individual ships.
- There is no live fleet_moved origin/dest event from a retired move_fleet.
Per round:
- Ship.combat JSONB updated (shields/hull on damaged ships).
- Ship.is_destroyed = true for kills.
- FleetMember rows deleted for destroyed/retreated ships.
- FleetBattleCasualty rows inserted.
- FleetBattle.battle_log appended.
- Damage counters updated.
- battle_round_complete event.
Per battle end:
- FleetBattle.ended_at, .winner, .credits_looted set.
- Both fleets' status returned to READY.
- Team.treasury_credits adjusted for loot.
- battle_ended event with full summary.
Invariants¶
fleet.total_ships == len(fleet.members)always.fleet.status == DISBANDED ⟺ fleet.disbanded_at IS NOT NULL.- A ship belongs to at most one fleet at a time (FleetMember.ship_id unique in active rows).
- While
fleet.status == IN_BATTLE, fleet.move and fleet.disband are rejected. - While a battle is open (
ended_at IS NULL), only one round simulator can run at a time (row lock). fleet.moraleandfleet.supply_levelclamped to[0, 100].- Coordination bonus is non-negative and capped at +0.20.
- After battle end, both fleets' status is READY (not IN_BATTLE).
FleetBattleCasualty.destroyed XOR FleetBattleCasualty.retreated(exactly one true per row).FleetBattle.credits_looted≤ original loser team treasury.
Failure modes¶
| Mode | Target handling |
|---|---|
| Add ship while fleet IN_BATTLE | Service rejects with ValueError. |
| Two players try to add same ship simultaneously | Unique constraint on FleetMember(ship_id) raises; second add fails cleanly. |
| Initiate battle when fleets are in different sectors | Service rejects: "Fleets must be in the same sector". |
| Initiate battle when either fleet is already in battle | Service rejects with ValueError. |
| Round simulator concurrent invocation | Row lock on FleetBattle row serializes; second waits. |
| Ship's combat JSONB missing keys | _get_ship_combat_stat returns 0 default; ship contributes nothing but doesn't crash. |
| All ships destroyed mid-round | End check fires after the round; battle.winner determined from strengths (one side may be 0). |
| Loser team has 0 treasury | loot = 0, no transfer. |
| Battle exceeds 30 rounds | Forced end via timeout condition; winner determined by strength. |
| Fleet member disconnects mid-battle | No special handling — ship remains and continues to be targeted; AI does not pilot it differently. |
Source map¶
| Concern | Path (target) |
|---|---|
| Fleet service | services/gameserver/src/services/fleet_service.py |
| Models | services/gameserver/src/models/fleet.py (Fleet, FleetMember, FleetBattle, FleetBattleCasualty) |
| Enums | same file (FleetRole, FleetStatus, BattlePhase) |
| Ship combat JSONB | services/gameserver/src/models/ship.py |
| Casualty processing | FleetService._record_ship_casualty |
| Loot transfer | FleetService._end_battle |
| Realtime events | services/gameserver/src/services/websocket_service.py |
| Routes | services/gameserver/src/api/routes/fleets.py |
Related¶
combat-resolver.md— single-ship resolution composed by the round simulator.bounty-and-reputation.md— fleet kills feed reputation hooks per kill.realtime-bus.md— fleet events surface here.../FEATURES/gameplay/fleet-tactics.md— player-facing fleet behavior.../FEATURES/gameplay/factions-and-teams.md— Team that owns fleets.