Skip to content

Fleet Coordination

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 × morale × supply modifiers 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. - POST /api/v1/fleets/{id}/movemove_fleet(fleet_id, sector_id). - 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).

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

Both modifiers are scaled by morale:

attack_modifier  = formation_attack  * (morale / 100)
defense_modifier = formation_defense * (morale / 100)

Coordination bonus (target spec, fleet ≥ 3 ships):

coord_bonus = min(0.20, max(0, (ships - 2) * 0.025))
attack_modifier += coord_bonus

Morale and supply tick

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

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

Morale never auto-recovers from time alone. It recovers via: - Successful battle (+10). - Reaching a friendly station with full supply (+5 per tick, capped at 100).

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

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
    target.is_destroyed = true
    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

Termination

Battle ends if any is true:

Condition Source
One side has 0 active ships _get_active_fleet_ships empty
Either fleet morale < 20 Fleet.morale
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. - Both fleets' morale −20 (post-battle exhaustion). - 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.
Morale collapse (< 20) Battle ends immediately; both sides stop firing.

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