Skip to content

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/fleetscreate_fleet(team_id, name, commander_id, formation). - POST /api/v1/fleets/{id}/shipsadd_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}/formationset_fleet_formation. - POST /api/v1/fleets/{attacker_id}/attack/{defender_id}initiate_battle. - POST /api/v1/fleets/battles/{id}/roundsimulate_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

  1. Verify fleet exists and status ∈ {forming, ready}.
  2. Verify ship exists and ship.owner.team_id == fleet.team_id.
  3. Verify ship not already in any fleet (FleetMember uniqueness).
  4. Insert FleetMember(fleet_id, ship_id, player_id, role, position=fleet.total_ships).
  5. Recalculate fleet stats from members.
  6. Commit.

Remove ship

  1. Find FleetMember(fleet_id, ship_id).
  2. Delete the row.
  3. Recalculate fleet stats.
  4. If total_ships == 0: set status = DISBANDED, set disbanded_at = now.
  5. 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.

  1. Attack/defense penalty — applied at read time, during round resolution, not stored:
  2. 25 ≤ supply < 50: attack ×0.95, defense ×0.95.
  3. supply < 25: attack ×0.85, defense ×0.85.
  4. Restore — purchase-only, via POST /fleets/{id}/resupply (FleetService.resupply_fleet, the sole writer of supply_level in either direction — see ../FEATURES/gameplay/fleet-tactics.md for 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):

  1. Lock battle row.
  2. Get attacker/defender active ships (hull > 0, not destroyed).
  3. If either side empty → end battle.
  4. Compute formation bonuses for both fleets.
  5. Determine round number from log length.
  6. 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)
    
  7. Defender return fire: symmetric.
  8. Append round summary to battle.battle_log (using flag_modified).
  9. Update battle damage counters.
  10. Check end conditions (see termination).
  11. Phase transition by round count (1–5 engagement, 6–15 main_battle, 16+ pursuit).
  12. 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 handlerCombatService._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

  1. fleet.total_ships == len(fleet.members) always.
  2. fleet.status == DISBANDED ⟺ fleet.disbanded_at IS NOT NULL.
  3. A ship belongs to at most one fleet at a time (FleetMember.ship_id unique in active rows).
  4. While fleet.status == IN_BATTLE, fleet.move and fleet.disband are rejected.
  5. While a battle is open (ended_at IS NULL), only one round simulator can run at a time (row lock).
  6. fleet.morale and fleet.supply_level clamped to [0, 100].
  7. Coordination bonus is non-negative and capped at +0.20.
  8. After battle end, both fleets' status is READY (not IN_BATTLE).
  9. FleetBattleCasualty.destroyed XOR FleetBattleCasualty.retreated (exactly one true per row).
  10. 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