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