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 … Β· ⚠︎ contains code↔spec divergence (impl audit 2026-06-16)

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. - POST /api/v1/fleets/{id}/move β€” move_fleet(fleet_id, sector_id). - 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

  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 tick

Per game tick (default 5 minutes), for each fleet not disbanded:

  1. Apply supply-driven attack/defense penalties at read time (not stored β€” applied during round resolution):
  2. 25 ≀ supply < 50: attack Γ—0.95, defense Γ—0.95.
  3. supply < 25: attack Γ—0.85, defense Γ—0.85.
  4. If fleet is docked at a friendly station: supply_level = min(100, supply_level + station_class) per tick.

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 handler in combat-resolver.py:_handle_ship_destroyed 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 move: - Fleet.sector_id updated. - All member ships' sector_id updated to match. - fleet_moved event with origin and destination.

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