Skip to content

Combat Resolver

Status: 🚧 Partial — The single-ship combat pipeline is genuinely live end-to-end (validation, drone screen, shield/hull/critical stack, rank multiplier, escape-pod gating … · ⚠︎ contains code↔spec divergence (impl audit 2026-06-16)

Purpose

The combat resolver is the deterministic-but-stochastic pipeline that runs whenever an attack is initiated. It validates the attack, drains drones, exchanges damage between ships, applies destruction effects, and fires post-combat hooks (ranking, ARIA consciousness, medals, reputation, bounty). It is a single transaction so a fight either fully resolves or fully rolls back — there is no half-applied combat state.

Inputs

The pipeline reads: - Attacker Player row (locked) — current_ship, turns, current_sector_id, team_id, personal_reputation, is_docked, is_landed, aria_* fields. - Defender Player row (locked) for ship-vs-ship; or a Sector / Planet / Station for non-PvP variants. - The defender's Ship and ShipSpecificationattack_turn_cost, hull, shields, armor, type. - SHIP_COMBAT_MODIFIERS, WEAPON_TYPES, SHIP_DEFAULT_WEAPONS, FAST_ESCAPE_SHIP_TYPES constants. - Drone counts (Player.attack_drones, Player.defense_drones) and any DroneDeployment rows in the sector for sector combat. - Sector flags (combat-allowed, faction zone, region governance settings).

The system fires when an attack endpoint is invoked: POST /api/v1/combat/attack/player, .../drones, .../planet, .../port — or via fleet orders.

Process

Phases

┌─────────────────────────────────────────────┐
│ 1. Initiation                               │
│    - lock attacker + defender rows          │
│    - regenerate attacker turn pool          │
│    - validate same sector, no docking,      │
│      attacker has ship, defender exists     │
│    - look up turn_cost (defender ship spec) │
│    - assert combat allowed in sector        │
└──────────────┬──────────────────────────────┘
               ▼
┌─────────────────────────────────────────────┐
│ 2. Drone screen                             │
│    - exchange drones round-by-round         │
│    - winner keeps survivors                 │
│    - if attacker drones wiped + ship-v-ship │
│      continues, attacker takes hull damage  │
│      bonus from defender drones             │
└──────────────┬──────────────────────────────┘
               ▼
┌─────────────────────────────────────────────┐
│ 3. Ship-vs-ship exchange                    │
│    - select weapons (default by type)       │
│    - apply ship-matchup multiplier          │
│    - resolve N rounds (cap ~10) until one   │
│      side reaches hull <= 0 or escapes      │
└──────────────┬──────────────────────────────┘
               ▼
┌─────────────────────────────────────────────┐
│ 4. Cleanup                                  │
│    - apply ship destruction (escape pod)    │
│    - cargo theft / loss                     │
│    - update sector last_combat              │
│    - persist CombatLog                      │
│    - charge attacker turns                  │
└──────────────┬──────────────────────────────┘
               ▼
┌─────────────────────────────────────────────┐
│ 5. Post-combat hooks                        │
│    - rank points (winner)                   │
│    - ARIA consciousness +1 (winner)         │
│    - medal check (winner)                   │
│    - personal reputation deltas             │
│    - bounty collection                      │
│    - realtime broadcast                     │
└─────────────────────────────────────────────┘

Damage stack — order of operations

For each weapon attack against a target (per round):

1. base_damage  = attacker.weapon.base_damage
                  * attacker.attack_drones_modifier   (+5% per 10 drones)
                  * SHIP_COMBAT_MODIFIERS.get((attacker.type, defender.type), 1.0)
                  * sector.modifier                    (nebula, radiation, etc.)
                  * (1 + rank.combat_bonus / 100)        # ADR-0061 S-D2 — snapshotted at combat init
                  * (1 + fleet.coordination_bonus)        # ADR-0061 S-I3 — static, recomputed on roster events

2. shield_hit   = min(base_damage, defender.shields)
                  × (1 - defender.shield_resistance)
                  × weapon.shield_effectiveness

3. residual     = base_damage - min(base_damage, defender.shields)
   hull_hit     = residual
                  × (1 - defender.armor_rating)
                  × weapon.hull_effectiveness
                  × (1 - defender.defense_drones_modifier)   (-5% per 10 drones)

4. critical     = (RNG < 0.05) ? hull_hit * 0.5 : 0

5. defender.shields = max(0, defender.shields - shield_hit)
   defender.hull    = max(0, defender.hull    - hull_hit - critical)

The order is fixed: shields absorb first, then armor + drone defense apply to the residual, then a critical-hit bonus is added directly to hull. Drones act as both first-line absorbers (in phase 2) and a passive damage-reduction modifier (in phase 3).

Multiplicative composition order (per ADR-0061 S-D2 + S-I3): the rank combat_bonus and the fleet coordination_bonus stack multiplicatively in step 1. Each multiplier is independently snapshotted at appropriate boundaries — rank at combat init, coordination on fleet roster events. A roster change mid-combat causes the resolver to recompute coordination_bonus at the next round boundary (not mid-round); combat math within a round is internally consistent. Fleet morale is a cosmetic/display stat and is not a damage factor.

Target validation — escape pods (per ADR-0061 S-V1): the resolver rejects an escape pod as a fresh target at the validation step before any damage roll. target.type == 'escape_pod' raises ERR_INVALID_TARGET with escape_pods_are_indestructible, so a player cannot initiate an attack on someone already in a pod — that pod's hull and shield values are flavor-only.

The one path that still reaches a pod is mid-combat: a defender whose ship is destroyed is auto-ejected into a pod within the same fight, and a finishing blow that lands on that just-ejected pod applies the kill_escape_pod −500 reputation penalty to the attacker. The validation gate closes the "pick on someone already down" vector; the −500 penalty deters finishing a kill through the ejection. The two are consistent: fresh pods are un-targetable, and the only way to hit a pod (finishing one created this fight) is reputation-penalized.

Weapon profile

Weapon Base vs Shields vs Hull
laser 1.0 0.8 1.0
plasma 1.2 1.2 0.9
missile 1.5 0.6 1.5
emp 0.5 2.0 0.3

Default weapon by ship: scout = emp, defender / warp jumper = plasma, carrier = missile, others = laser.

Escape mechanic (defender side)

After each round, the defender may attempt to flee. Success probability:

escape_chance = 0.15
              + (FAST_ESCAPE_SHIP_TYPES ? 0.20 : 0.0)
              + (1 - defender.hull / defender.max_hull) * 0.30
              + sector_edge_proximity * 0.10
              - pursuer_class_factor * 0.10

A successful escape ends combat with result = DRAW and no destruction.

Fleet-coordinated extension

When the attacker (or defender) is acting under fleet orders: 1. Pre-resolution: aggregate stats from all fleet member ships (formation modifies offence/defence). See ../FEATURES/gameplay/combat.md for formations. 2. The fleet commander's ship is the "primary" for damage stack purposes; member ships contribute pooled hull / shields / drones. 3. On loss, casualties are recorded as FleetBattleCasualty rows per member ship; on win, rank/reputation hooks fan out to all participants by participation share. 4. Status machine: forming → ready → in_battle → retreating → disbanded (canonical FleetStatus values per ../DATA_MODELS/combat.md; retreating is a transient sub-state during pursuit per fleet-coordination.md:62).

Outputs / state changes

Per combat: - Player.turns — attacker decremented by turn_cost. - Player.attack_drones, Player.defense_drones — both sides decremented per phase 2. - Ship.hull, Ship.shields — both sides updated after each round. - Ship.is_destroyed — set if hull reaches 0; the player is auto-ejected to escape pod. - Player.current_ship_id — flipped to escape pod on destruction. - Player.credits — bounty collected (winner) on a kill where defender had bounties. - Player.personal_reputation — adjusted per the trigger table (see bounty-and-reputation.md). - Player.aria_total_interactions — winner +1; consciousness level promoted at 50/150/400/1000 thresholds. - Player.rank_points — winner gains points; promotion side-effect rolls into Player.military_rank. - Sector.last_combat — set to now. - CombatLog — one row per fight, with full combat_details JSON. - FleetBattle / FleetBattleCasualty — fleet variant only.

Events emitted: - combat_started — broadcast to both participants and the sector. - combat_round — round-by-round delta (for spectators in the sector). - combat_resolved — final result; piggybacks rank/medal/bounty payloads. - ship_destroyed — global broadcast; nudges sector listeners. - bounty_collected — to collector and (anonymized) bounty board.

Downstream systems notified: ranking, medals, bounty, personal reputation, ARIA consciousness, realtime bus.

Invariants

  1. The fight is one DB transaction. Either every mutation lands or none do.
  2. Both player rows are locked (SELECT … FOR UPDATE) before any state read.
  3. attacker.turns is only decremented after combat resolves successfully (preflight failures don't burn turns).
  4. Ship.hull ≥ 0 and Ship.shields ≥ 0 always — no negative values.
  5. attacker.id != defender.id (no self-attack).
  6. Defenders never spend turns to defend (defend turn_cost = 0).
  7. Hooks (rank, medal, reputation, bounty) are best-effort: each runs in its own try/except so a failure in one does not roll back the fight, but the failure is logged.
  8. A fight's CombatLog is written before any post-combat hook fires — the audit trail exists even if hooks fail.

Failure modes

Mode Target handling
Defender already destroyed mid-flight (race) Lock acquisition serializes; second attacker sees ship destroyed and gets a 409.
Attacker not in same sector by the time lock is held Pre-flight rechecks current_sector_id after the lock; mismatch → 400 Target not in your sector.
Sector flagged combat-disallowed (Federation, sanctuary, region policy) Pre-flight rejects with 400 Combat not allowed.
Hooks raise (e.g. medal service down) Each hook in its own try/except; combat result still committed; error logged.
Insurance payout missing Skip payout; combat log still written; player surfaced an alert.
Excessive round count (stalemate) Hard cap at 10 rounds; if still alive on both sides, result = DRAW.
Escape pod targeting A pod is rejected as a fresh target (ERR_INVALID_TARGET / escape_pods_are_indestructible). The only pod a fight can hit is one created this fight by ejecting the defender; a finishing blow on that just-ejected pod applies the −500 kill_escape_pod reputation penalty to the attacker.
Concurrent bounty collection Both players locked; BountyService.collect_bounty uses SELECT … FOR UPDATE; only the winning attacker collects.
Fleet member disconnect mid-fight Fleet aggregate stats are computed once at phase 1; later disconnects do not change resolution. Disconnected members still get casualty records.

Source map

Concern Path (target)
Resolver entry points services/gameserver/src/services/combat_service.py (attack_player, attack_sector_drones, attack_planet, attack_port)
Ship-vs-ship resolver services/gameserver/src/services/combat_service.py:_resolve_ship_combat
Drone resolver services/gameserver/src/services/combat_service.py:_resolve_drone_combat
Planet resolver services/gameserver/src/services/combat_service.py:_resolve_planet_combat
Port resolver services/gameserver/src/services/combat_service.py:_resolve_port_combat
Fleet orchestration services/gameserver/src/services/fleet_service.py
Ranking hook services/gameserver/src/services/ranking_service.py:award_rank_points
ARIA consciousness write inline in combat_service.py (target: aria_consciousness_service.py)
Medal hook services/gameserver/src/services/medal_service.py:check_combat_medals
Reputation hook services/gameserver/src/services/personal_reputation_service.py:adjust_reputation
Bounty hook services/gameserver/src/services/bounty_service.py:collect_bounty
Realtime broadcast services/gameserver/src/services/websocket_service.py:send_combat_update
Combat log model services/gameserver/src/models/combat_log.py
Combat REST routes services/gameserver/src/api/routes/combat.py, player_combat.py