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/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¶
- 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).
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:
- If
supply_level < 25:morale = max(0, morale - 1). - Apply supply-driven attack/defense penalties at read time (not stored — applied during round resolution):
- 25 ≤ supply < 50: attack ×0.95, defense ×0.95.
- supply < 25: attack ×0.85, defense ×0.85.
- 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):
- 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¶
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¶
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. |
| 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 |
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.