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 HEAD46bce720; 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 ShipSpecification — attack_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):
- Mark ship destroyed (
Ship.status = DESTROYED,destruction_cause,destroyed_at). - Auto-eject pilot to an escape pod (preserves
Player.current_ship_idcontinuity). - Wreck-suppression check — no wreck for
WARP_GATE_ANCHOR/SELF_DESTRUCTcauses (per ADR-0052 SK36). - Insurance payout.
- Cargo-wreck creation from the lost (non-rescued) cargo.
- Bounty collection — pays active
BountyClaimrows for the destroyed pilot,collector_player_id = killing_blow_pilot_id, row-locked (SELECT ... FOR UPDATE) inside the same tx. - Stolen-report resolution, if the ship carried
stolen_status = True. - Kill-log insert (
PirateKillLogif pirate; combat audit row otherwise). - 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_idunique-index design does not match shipped schema — bounties live inPlayer.settings["bounties"]JSONB (placed_by,amount, stringid), 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_MATEis live:report_stolen()(services/gameserver/src/services/ship_registry_service.py:207-214, comment cites "ADR-0055 S-F1" directly) checks the thief's liveteam_idagainst the owner's team before allowing the report, wired throughPOST /{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_pilotattribution 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) returnswith_bountyfor insurable hulls /no_bountyfor non-insurable hulls when the request omitsrecovery_mode, looked up viaShipSpecification.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¶
- The fight is one DB transaction. Either every mutation lands or none do.
- Both player rows are locked (
SELECT … FOR UPDATE) before any state read. attacker.turnsis only decremented after combat resolves successfully (preflight failures don't burn turns).Ship.hull ≥ 0andShip.shields ≥ 0always — no negative values.attacker.id != defender.id(no self-attack).- Defenders never spend turns to defend (
defend turn_cost = 0). - 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.
- A fight's
CombatLogis 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 |
Related¶
- DATA_MODELS:
../DATA_MODELS/combat.md,../DATA_MODELS/player.md. - FEATURES:
../FEATURES/gameplay/combat.md. - SYSTEMS: turn-regeneration.md, bounty-and-reputation.md, realtime-bus.md.
- REST API: combat & player_combat routes auto-published at
<api-host>/docs.