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). Traced and resolved the previously-vague "⚠︎ contains code↔spec divergence (impl audit 2026-06-16)" tag: the one real mismatch is in "Events emitted" below (wire-level combat_update, not three distinct event types) — see that section for detail. (Re-verified 2026-08-21 vs Sectorwars2102 HEAD 46bce720; trace pass 2026-08-04.)

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).

Destruction transaction

Folded in from ADR-0055 (2026-08-04) — the destruction handler's transaction composition and ordering, previously only in the ADR.

Real entry point (corrected citation — the ADR and this page previously cited a nonexistent combat_resolver.py:_handle_ship_destroyed): CombatService._handle_ship_destruction (combat_service.py:4988), which calls ship_service.destroy_ship for the core status flip + escape-pod ejection, then _spawn_cargo_wreck for the lost-cargo wreck. Fleet-coordinated kills delegate to this same handler (see "Fleet-coordinated extension" above) so there is one code path for what happens when a ship dies.

Fixed ordering, one transaction — the destruction handler runs the following steps inside a single DB transaction (a partial failure rolls back the whole thing; the destruction is then re-attempted by the resolver, never left half-finished):

  1. Mark ship destroyed (Ship.status = DESTROYED, destruction_cause, destroyed_at).
  2. Auto-eject pilot to an escape pod (preserves Player.current_ship_id continuity).
  3. Wreck-suppression check — no wreck for WARP_GATE_ANCHOR / SELF_DESTRUCT causes (per ADR-0052 SK36).
  4. Insurance payout.
  5. Cargo-wreck creation from the lost (non-rescued) cargo.
  6. Bounty collection — pays active BountyClaim rows for the destroyed pilot, collector_player_id = killing_blow_pilot_id, row-locked (SELECT ... FOR UPDATE) inside the same tx.
  7. Stolen-report resolution, if the ship carried stolen_status = True.
  8. Kill-log insert (PirateKillLog if pirate; combat audit row otherwise).
  9. Combat log + reputation hooks.

Realtime events for these steps are queued via the transactional outbox (per ADR-0054) and flushed post-commit — same pattern as the "Transactional outbox" section in realtime-bus.md.

Concurrency and anti-collusion rules layered on the handler:

  • Concurrent-attacker serialization (S-V2) — closed transitively by this page's own Phase 1 row-lock (Initiation, above): the defender row-lock serializes concurrent combat calls on the same target, so only one destruction transaction ever commits per ship; a second attacker whose damage lands on an already-destroyed target no-ops.
  • Bounty uniqueness (S-V3) — ✅ shipped, corrected 2026-08-07 (supersedes the 2026-08-04 Design-only note). The ADR's BountyClaim.placer_player_id/collector_player_id unique-index design does not match shipped schema — bounties live in Player.settings["bounties"] JSONB (placed_by, amount, string id), not a DB unique index — but the equivalent guard IS enforced at the JSONB/service level: place_bounty() (services/gameserver/src/services/bounty_service.py:553-567) rejects a second active bounty from the same placer on the same target (if any(str(b.get("placed_by")) == str(placer_id) for b in existing_bounties), ERR-style "You already have an active bounty on this target") — a single placer cannot stack duplicate bounties on one target; distinct placers each keep their own entry.
  • Same-team collusion block (S-F1) — ✅ shipped, corrected 2026-08-07 (supersedes the 2026-08-04 Design-only note). ERR_THIEF_IS_TEAM_MATE is live: report_stolen() (services/gameserver/src/services/ship_registry_service.py:207-214, comment cites "ADR-0055 S-F1" directly) checks the thief's live team_id against the owner's team before allowing the report, wired through POST /{ship_id}/report-stolen (api/routes/ship_registry_behaviors.py:84-110). ERR_COLLECTOR_SAME_TEAM_AS_PLACER (the bounty-collection side) was not re-checked this pass — flag as open if revisiting.
  • Killer first-scoop on cargo wrecks (S-F2) — during the wreck's 1-hour grace window, the killing-blow pilot (individually, not their team) may salvage free alongside the original owner + original owner's team; verified live via killing_blow_pilot attribution in _spawn_cargo_wreck (combat_service.py), COMBAT-cause only.
  • Non-insurable default recovery_mode (S-F4) — ✅ shipped, corrected 2026-08-07 (supersedes the 2026-08-04 Design-only note). _default_recovery_mode() (services/gameserver/src/services/ship_registry_service.py:172-180, comment cites "ADR-0055 S-F4" directly) returns with_bounty for insurable hulls / no_bounty for non-insurable hulls when the request omits recovery_mode, looked up via ShipSpecification.type.

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 — three phases, one wire type (traced 2026-08-04, matches realtime-bus.md's "Current wire deviation"): the resolver dispatches a started phase, one round phase per resolved round, and a resolved closing phase (combat_service.py:_combat_round_deltas + the phase-dispatch block around line 464), but every frame is sent with wire type: "combat_update" (connection_manager.send_combat_update, websocket_service.py:604) — the phase is only distinguishable via the envelope's deltas.phase field ("started" / "resolved") or, for round frames, the presence of a round number with round-shaped deltas and no phase key. There are no separate combat_started/combat_round/combat_resolved wire event types today, despite in-code comments describing the three phases using those names — this is a code-comment-vs-actual-wire-type gap, not a functional bug (all three phases genuinely fire, correctly scoped to participants + sector spectators). combat_started/combat_round/combat_resolved remain the target vocabulary per realtime-bus.md's taxonomy table; adopting them is the same tracked publisher-refactor tech debt, not a new gap this page introduces. - 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