0061 — Combat correctness and rank wiring (Group C)¶
Status¶
Accepted.
Context¶
Nine audit findings cluster around the combat resolver and its feeders. Two are critical (rank combat-bonus not wired, escape pod indestructibility contradiction); the rest are correctness fixes around insurance math, cargo-wreck rounding, fleet-bonus timing, mid-combat rank promotion, nested-pod cascades, and team-snapshot consistency on suspect status.
The cluster is mostly mechanical. The picks below ratify single canonical answers and document them in the right places.
Decision¶
S-D2 — Rank combat bonus wired into the damage stack¶
The combat_bonus value computed from Player.military_rank per ../FEATURES/gameplay/ranking.md is read at the damage-roll step in combat_service.attack_player and applied as a multiplier on the attacker's outgoing damage. The bug-acknowledged "stored but never read" gap closes.
# combat_service.attack_player — damage-roll step
damage_base = roll_weapon_damage(attacker.equipped_weapons, defender.armor)
damage_after_rank = damage_base * (1 + attacker.player.combat_bonus)
final_damage = damage_after_rank
The bonus stacks multiplicatively with the fleet coordination bonus (per S-I3 below). The bonus value is the rank-snapshot used per S-I4 — the rank in effect at combat start — not the rank the player would hit mid-combat.
S-V1 — Escape pods are truly indestructible (excluded from combat targeting)¶
The combat resolver rejects escape pods as valid targets. The targeting validator gates:
def validate_target(attacker, target):
if target.type == 'escape_pod':
raise InvalidTarget('escape_pods_are_indestructible', target.id)
...
The hull and shield values on EscapePod (200/150 per design target) become flavor-only — they are not consulted by combat math. The pod's role as the unconditional safety net is preserved; the kick-them-while-they're-down attack vector is closed at the validator.
This makes S-D1 (hull/shield values) cosmetic. The doc-target values (200/150) stay documented; whether the shipped values match (currently 150/100) is a code-vs-doc concern for the gameserver repo per the repo's standard "code-vs-doc mismatches: leave alone" policy.
S-D3 — Insurance payout formula reconciled¶
The insurance payout doc gains one canonical formula and one worked example using consistent inputs and outputs:
payout = ship.insured_value * payout_fraction(insurance_tier, destruction_cause)
Where payout_fraction is the matrix of (tier × cause) combinations already in ../FEATURES/gameplay/ship-insurance.md but currently surfaces with two slightly different framings. The reconciled doc states the formula once and walks one example end-to-end (e.g., Cargo Hauler insured at 80,000 cr, Premium tier, killed in PvE → 80,000 × 0.85 = 68,000 cr payout).
S-I1 — Cargo-wreck recovery roll granularity¶
The recovery roll is per-commodity, ceil-rounded for fractional units. Aligns with ADR-0007 design intent; the rounding rule is documented explicitly:
def roll_recovery_per_commodity(cargo, damage_band):
out = {}
for commodity, units in cargo.items():
fraction = uniform(damage_band.min, damage_band.max)
recovered = ceil(units * fraction) # ceil per ADR-0061 S-I1
if recovered > 0:
out[commodity] = recovered
return out
Ceil-rounding (rather than round/floor) is generous to salvagers — partial-unit losses don't disappear. The example walks a missile-killed Cargo Hauler with {ore: 1000, organics: 500, exotic_tech: 100} rolling 28% → {ore: 280, organics: 140, exotic_tech: 28}.
S-I2 — Escape pod in destroyed Carrier hangar auto-ejects¶
When a Carrier with at least one docked escape pod is destroyed, the destruction handler auto-ejects each docked pod to the sector at the Carrier's last position. The pod retains its state — turns, inventory, pilot identity — and arrives in drifting posture at the Carrier's sector.
# combat_service._handle_ship_destruction — within step 2 (auto-eject)
if ship.ship_type == 'carrier':
for docked_pod in ship.hangar.docked_escape_pods:
eject_pod_to_sector(docked_pod, ship.current_sector_id, posture='drifting')
outbox.queue('escape_pod.auto_ejected', {
'pod_id': docked_pod.id,
'pilot_id': docked_pod.pilot_id,
'host_carrier_id': ship.id,
'sector_id': ship.current_sector_id,
'at': utcnow(),
})
The cascade is consistent with the safety-net story: the Carrier blew up around them but they survived.
S-I3 — Fleet coordination bonus timing; morale is cosmetic¶
The fleet coordination bonus is computed once at fleet init from the member roster and recomputed on roster change events (member leaves, joins, or dies). Between roster changes, the bonus is static — round-by-round combat reads the cached value.
Fleet morale is a cosmetic column: it surfaces in the UI and narrative but is not a combat factor. Combat damage is base × (1 + combat) × (1 + coordination) with no morale factor. Damage-stack composition:
final_damage = base_damage
× (1 + attacker.combat_bonus) # rank, S-D2
× (1 + fleet.coordination_bonus) # static, S-I3
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.
S-I4 — Rank promotion deferred until combat ends¶
When Player.rank_points crosses a rank threshold during combat, the promotion is queued and fires when combat resolves. No mid-combat stat changes; the combat_bonus value used throughout the fight is the pre-combat rank's bonus (snapshot at combat init).
# end-of-combat handler
if player.rank_points >= next_rank_threshold(player.military_rank):
promote_rank(player)
aria.narrate_rank_up(player)
outbox.queue('player.rank_promoted', { ... })
This is consistent with the dialogue-context snapshot pattern from ADR-0058 A-F1 and the bonus-snapshot pattern from S-D2 above. Combat is internally consistent; rank progression notifications fire at predictable boundaries.
S-V4 — Suspect-status team-membership snapshot¶
When suspect status fires (per the cargo-wreck early-salvage mechanic in ../FEATURES/gameplay/ships.md), the player's current team-mate set is captured as Player.suspect_team_snapshot UUID[]. Team-mate exemptions during the suspect grace window are evaluated against the snapshot, not live team membership.
A player joining the suspect's team mid-grace is not added to the snapshot and does not gain the team-mate exemption. A player leaving the suspect's team mid-grace stays exempt for the remainder of the window — the snapshot was taken at acquisition, not maintained live.
Pattern is consistent with the team-lock snapshot from ADR-0060 G-F2. Snapshots clear when the suspect grace expires; a subsequent suspect event rebuilds a fresh snapshot.
Consequences¶
- S-D2 is a one-line code change with a multi-line doc clarification. The combat-resolver's
damagecalculation gains one multiplier; behaviour previously documented but never implemented becomes live. - S-V1 changes the threat model: pods are now genuinely safe. Pirates lose the "destroy fleeing pilots" tactic; the game's loss model treats death as ship-destruction, not pilot-destruction. Doc and code converge.
- S-I2 requires a hangar-cascade enumeration in the destruction handler. Carriers are the only ship type that hangars pods; the loop is bounded by Carrier hangar capacity.
- S-I3 + S-D2 + S-I4 establish a clear damage-stack composition order: base × rank × coordination. The order is multiplicative; each multiplier is independently snapshotted at appropriate boundaries (rank at combat init, coordination at fleet roster events). Fleet
moraleis a cosmetic column and contributes no damage factor. - S-V4 adds
Player.suspect_team_snapshot UUID[](nullable). Cleared on grace expiry. The snapshot pattern is now used in three places: pirate-holding raid lock, suspect grace, and (potentially future) any other team-mate exemption window. - S-D1 ratifies the doc target without resolving the code-vs-doc gap; the gameserver repo handles the shipped-values fix.
Alternatives considered¶
- Destructible escape pods (S-V1). Rejected per user pick — the kick-them-while-they're-down vector is the wrong shape for the game's intended loss model.
- Pod cascade-destroyed with Carrier (S-I2). Rejected per user pick — too punitive; defeats the safety-net purpose for nested pilots.
- Mid-combat rank promotion (S-I4). Rejected per user pick — combat math becomes round-by-round inconsistent; debugging combat outcomes becomes harder.
- Live team membership for suspect-grace exemptions (S-V4). Rejected — opens a mid-grace-join exploit symmetric to the one closed in pirate-holding raids.
- Floor-rounding on cargo-wreck recovery (S-I1). Rejected — punishes salvagers on small-quantity commodities; ceil is generous and stays consistent with the design intent.
Related¶
- ADR-0007 — cargo-wreck mechanic.
- ADR-0058 A-F1 — dialogue-context snapshot pattern (analogous to combat-bonus snapshot here).
- ADR-0060 G-F2 — team-lock snapshot pattern (S-V4 mirrors).
../SYSTEMS/combat-resolver.md— damage-stack composition.../SYSTEMS/fleet-coordination.md— coordination bonus.../FEATURES/gameplay/combat.md— combat surfaces.../FEATURES/gameplay/ranking.md—combat_bonus, rank thresholds, mid-combat deferral.../FEATURES/gameplay/ships.md— escape pods (now indestructible), Carrier hangar cascade, suspect status.../FEATURES/gameplay/ship-insurance.md— payout formula + worked example.../DATA_MODELS/player.md—suspect_team_snapshot.