Files
gogobee/internal/plugin/combat_engine.go
prosolis 055f07d3c0 Combat engine §2(a): the enemy charges for what a seat brings
Both scaling levers counted seats. partyEnemyHPScale gave +15% boss HP for any
roster >= 2, and partyActionExpectation lifted the enemy from 1 to 2.4 attacks a
round. A seat COUNT charges the same for an under-levelled friend, a hired NPC,
and a true peer — so a below-median body cost a full seat's worth of boss and did
not give a full seat's worth back.

Measured, once the companion's free full-heal was taken away and he became honest:
hiring him was WORSE than going alone (66.1% against solo's 69.0%). That is this
bug, and it has been live for every under-levelled friend anyone has ever invited.

Seats now carry a SeatWeight, and both levers scale on the summed weight of the
LIVING seats rather than on a head count. The weight is level-based, priced against
the leader, times a discount for a hireling (no subclass, no magic items, gear that
is never Masterwork — the layers a player accrues and a hireling never will).

Level, and deliberately not a power score: an HP-x-damage proxy would rank a cleric
below a fighter and quietly make every mixed HUMAN party easier, which is a
difficulty regression smuggled in under a bug fix.

The safety argument is one property: **a peer weighs exactly 1.0**. So the curves
interpolate between the integer knots the P8 sweep tuned — (1, 1.0), (2, 2.4),
2n-1 from 3 up — and every integer input returns exactly what it always returned.
Solo is byte-identical, a party of same-level humans is byte-identical, both
goldens hold unmoved, and only an UNEQUAL roster lands between the knots. That is
the entire point of the change.

It also finishes §2(b): a seat that is down now buys the enemy nothing. §2(b) fixed
the head-count half; a corpse still carried its full weight until this.

Measured, 640 runs/arm, same grid:

  solo                    69.0%   (unchanged — corpus intact)
  + Pete                  76.8%   (+7.8pp)
  + a human cleric peer   77.6%   (+8.6pp)

  band                 solo    +Pete     lift
  trailing (<40%)     10.0%    31.0%   +21.0pp
  middle              58.9%    76.8%   +17.9pp
  leading (>=70%)     93.5%    99.2%    +5.7pp

Help, never a carry: he rescues the players who were drowning and barely moves the
ones who were already fine — and he stays below a real human of the leader's level,
which is the invariant a hireling must never break.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 15:20:49 -07:00

1326 lines
54 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import "math/rand/v2"
// ── Core Types ───────────────────────────────────────────────────────────────
type CombatStats struct {
MaxHP int
// StartHP is the HP the combatant *enters* this fight at. Zero means
// "use MaxHP" (full health). Wounded carry-over uses this so MaxHP
// stays stable across fights — display reads "100/123", not "100/100".
StartHP int
// HPBonus is the absolute HP players gain from equipment / arena sets /
// housing on top of their D&D character sheet HP. DerivePlayerStats
// computes this; applyDnDPlayerLayer adds it to c.HPMax to form the
// final combat MaxHP. Kept separate so persistence to dnd_character can
// simply clamp endHP to c.HPMax — gear cushion doesn't carry over.
HPBonus int
Attack int
Defense int
Speed int
CritRate float64 // superseded by nat-20 crits but still consumed by autoCrit logic & equipment scaling.
DodgeRate float64 // not queried by hit resolution but still computed for narrative scaling.
BlockRate float64 // still used to halve damage on a successful hit.
// D&D layer. AC and AttackBonus drive d20-vs-AC hit resolution.
// Set via dnd_combat.go's applyDnDPlayerLayer / applyDnDArenaMonsterLayer / applyDnDDungeonMonsterLayer.
AC int
AttackBonus int
// Phase 8 — equipment-driven damage. When Weapon is non-nil, the d20
// attack path rolls weapon damage dice + AbilityModForDamage instead of
// the legacy calcDamage penetration formula. When nil, legacy math
// applies (used by monsters and any combatant without a D&D weapon).
Weapon *WeaponProfile
AbilityModForDamage int
WeaponProficient bool // false → -4 attack penalty (appendix §8 implementation note)
TwoHandedMode bool // true + versatile weapon → use larger versatile die
// FireAttacker tags monsters whose signature damage is fire (red dragons,
// fire elementals, hell hounds, magmin, balor, …). The enemy-attack path
// halves incoming damage when this is set on the enemy and FireResist is
// set on the player. Carried via toCombatStats; tuned generator derives it
// from SRD attack DamageType; hand-authored entries set it explicitly.
FireAttacker bool
}
type CombatModifiers struct {
DamageBonus float64 // additive: 0 = neutral, 0.25 = +25% damage. Applied as (1 + DamageBonus).
DamageReduct float64 // multiplicative damage-taken reduction, 1.0 = neutral
DeathSave bool // Sovereign reprieve — survive one lethal hit
PetAttackProc float64
PetAttackDmg int
PetDeflectProc float64
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
// Spiritual Weapon — separate channel from the pet so the spectral mace
// gets its own narration when a cleric without a companion casts it.
// Damage formula mirrors PetAttack (Dmg + d5), proc rolls per round.
SpiritWeaponProc float64
SpiritWeaponDmg int
SniperKillProc float64 // Arina instant-kill
MistyHealProc float64
MistyHealAmt int
CrowdRevengeProc float64 // Misty debuff: chance per round of crowd damage
CrowdRevengeDmg int // Misty debuff: damage per proc
HealItem int // consumable: HP restored per heal trigger
// HealItemCharges is the number of times the heal-at-<50%-HP trigger
// can fire. 1 = legacy one-shot. Boss fights set this from inventory
// healing-item count so a stocked-up player can sustain through long
// fights instead of the heal becoming a single weak trigger.
HealItemCharges int
// InitiativeBias adds to the player's initiative roll each round.
// Used by the DM mood system to tilt who-goes-first: positive favors
// the player (Effusive mood), negative favors the enemy (Hostile).
InitiativeBias float64
WardCharges int // consumable: hits fully absorbed
SporeCloud int // consumable: rounds of 15% enemy miss chance
ReflectNext float64 // consumable: fraction of next hit reflected
AutoCritFirst bool // consumable: first player hit is auto-crit
FlatDmgStart int // consumable: flat damage to enemy pre-combat
// D&D race passives (Phase 3 — race traits with combat hooks).
LuckyReroll bool // Halfling: reroll the first nat 1 of the fight
RageReady bool // Orc: when HP first drops <50%, next attack deals +50% damage
PoisonResist bool // Dwarf: poison tick damage halved
FireResist bool // Tiefling: incoming damage from fire-tagged sources halved
// Phase 10 SUB2a — subclass combat hooks.
// CritThreshold: lowest d20 roll that crits. 0 = use default (20).
// Champion L5 Improved Critical sets this to 19; Champion L15
// Superior Critical (SUB3) will set 18.
CritThreshold int
// BerserkerRage: while true, +RageMeleeDmg flat damage per hit and
// incoming weapon damage halved (PhysicalResistRage). Frenzy also
// adds FrenzyDmgBonus on top to model the bonus-attack-per-turn that
// one-shot combat can't represent literally. Set by armed `rage`
// ability for Berserker subclass.
BerserkerRage bool
RageMeleeDmg int // flat damage per hit while raging (5: +2)
PhysicalResistRage bool // halve incoming physical damage while raging
FrenzyDmgBonus float64 // multiplicative dmg bump while raging (Frenzy approximation)
// Phase 10 SUB2a-ii — Battle Master + Assassin.
// FirstAttackBonus: flat bonus added to the player's first d20 attack
// roll only (Battle Master Precision Attack ≈ +d8). Consumed on first
// roll regardless of hit/miss (5e: "before or after rolling").
// AssassinateAdvantage: re-roll the player's first miss (better of two
// d20s on the first attack) — proxy for "advantage vs. creatures that
// haven't acted yet".
// AssassinateBonusDmg: flat extra damage stacked on the first hit (rides
// the existing AutoCritFirst doubling — proxy for Death Strike's
// "crits vs. surprised").
FirstAttackBonus int
AssassinateAdvantage bool
AssassinateBonusDmg int
// Phase 10 SUB3c — Cleric Divine Strike. Flat bonus damage on every
// weapon hit (5e: "once per turn", which lands ~per round in our model).
// Damage type (radiant/weapon/poison) varies by Cleric subclass but is
// not tracked in our engine. Only fires on the weapon-dice damage path —
// no Weapon → no Divine Strike.
DivineStrikePerHit int
// Class-identity audit (2026-05-16) — Fighter Extra Attack as actual
// additional swings per round. 0 = legacy single swing. Each extra is
// a full resolvePlayerAttack call (own d20, own damage roll). 5e
// progression for Fighter: 1 at L5, 2 at L11, 3 at L20. Other martials
// (Paladin/Ranger/Barbarian/Bard-Valor) cap at 1 at L5 — wire as needed.
// Once-per-fight effects (AutoCritFirst, FirstAttackBonus, Assassinate
// reroll, SneakAttackDie applies every hit but Rogue has no extras) are
// guarded by st flags, so they correctly fire only on the first swing.
ExtraAttacks int
// Class-identity audit (2026-05-16) — Rogue Sneak Attack as actual
// per-hit Nd6 bonus damage. Number of d6 to roll on every player hit.
// 5e gates this on "once per turn given advantage/ally adjacent"; our
// 1v1 engine has no turn-boundary concept, so the same "lands every
// hit" convention as DivineStrikePerHit applies — and since Rogues
// only get one swing per round, this naturally caps at once/round.
// Set in applyClassPassives based on Rogue level (1d6 at L1-2 → 4d6
// at L7-8 → 10d6 at L19-20, per 5e progression).
SneakAttackDie int
// Class-identity audit (2026-05-16) — Ranger Hunter's Mark as actual
// Nd6 bonus damage per hit. 5e: bonus action to mark a target, then
// +1d6 to every weapon hit vs. that target until concentration drops.
// In 1v1 there's effectively always a marked target (the only foe),
// so we model the mark as "always-on" — applies to every player hit.
// Number of d6 to roll: scales with class level (1 at L1, +1 every
// 5 levels per the 5e upcast progression, capped at 4d6).
HuntersMarkDie int
// Class-identity audit (2026-05-16) — Druid thorn-lash. Flat damage
// returned to the enemy whenever the enemy lands a hit on the player.
// 5e analogue: Spike Growth / Thorn Whip / the various druidic
// retaliation cantrips collapsed into a passive aura. Fires after
// damage is applied (and after ward absorption), so a "blocked but
// touched" hit still pricks back. Independent of crit doubling.
ThornLashDmg int
// Phase 10 SUB2b — Mage subclasses.
// ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the
// start of each combat by Abjuration L5+ (2× Mage level, +prof at L7).
// Persists across rounds within a single combat; not refunded between fights.
// GrimHarvestSlot/Necrotic: snapshot of the damaging spell stashed for the
// post-combat Grim Harvest hook (Necromancy L5+) — by applyPendingCast on
// the auto-resolve path, and by seatFightStartMods reading the seat's
// statuses on the turn-based one. Heal fires only if that spell's event is
// what dropped the enemy to 0.
ArcaneWardHP int
GrimHarvestSlot int
GrimHarvestNecrotic bool
// Phase 9 — pending spell resolution. Set by applyPendingCast in
// dnd_spell_combat.go before SimulateCombat runs. SpellPreDamage is
// dealt as a pre-combat event with SpellPreDamageDesc as the narrative
// hook (spell name + flavor). SpellEnemySkipFirst causes the enemy to
// skip its attack on the first round (Hold Person, Sleep, etc.).
// Buffs (Bless, Mage Armor, Hunter's Mark) are folded into stats/mods
// directly and don't surface here.
SpellPreDamage int
SpellPreDamageDesc string
SpellEnemySkipFirst bool
}
type Combatant struct {
Name string
Stats CombatStats
Mods CombatModifiers
IsPlayer bool
Ability *MonsterAbility // non-nil for monsters with a special ability
// SeatWeight is what this seat costs the enemy: 1.0 is a full peer of the
// leader, and less than that is a seat that brings less to the fight. Zero
// means "unset" and reads as 1.0, so every existing call site — and every solo
// fight — is unchanged.
//
// The enemy's HP bump and its action economy scale on the SUM of these rather
// than on a seat count. A seat count charges the boss the same for an
// under-levelled friend, a hired NPC, and a peer, which is why hiring a
// below-median body was measurably worse than going alone: he cost a full
// seat's worth of boss and did not give a full seat's worth back.
//
// It is derived from the seat's identity (level, and whether it is a hireling),
// NOT from fight state — so every per-round rebuild recomputes the same number
// and there is nothing to persist. See seatWeight.
SeatWeight float64
}
type CombatPhase struct {
Name string
Rounds int
AttackWeight float64
DefenseWeight float64
SpeedWeight float64
EnvironmentProc float64
}
type CombatEvent struct {
Round int
Phase string
Actor string // "player", "enemy", "pet", "environment", "npc", "consumable"
Action string
Damage int
PlayerHP int
EnemyHP int
Desc string // optional flavor (item name, ability name)
// D&D layer fields. Set only on events from the d20-vs-AC resolution path.
// Roll is the raw d20 (1..20); RollAgainst is the target AC.
Roll int
RollAgainst int
// Seat is the roster index of the character this event is about: the one
// who swung, whose pet struck, who the enemy hit, whose poison ticked. The
// turn engine stamps it (turnEngine.stampSeat); the auto-resolve engine
// leaves it 0, which is correct for its one and only combatant.
//
// It exists so a party's play-by-play can name the right person. Solo events
// are all seat 0, and the omitempty tag keeps the field out of every solo
// turn_log_json — rows written before N3/P5 decode unchanged, and a fight in
// flight across a deploy resumes byte-identically (TestP5Fields_StayOffSoloRows).
//
// The omitempty makes seat 0 and "no seat" identical on the wire, which is
// fine for persistence and actively misleading in a diagnostic trace — it hid
// a companion who never swung, making the fight look like it had one seat.
// Do NOT fix that here; the wire format is load-bearing. The sim's trace
// serializes through simTraceEvent, which always emits the seat.
Seat int `json:"Seat,omitempty"`
}
type CombatResult struct {
PlayerWon bool
// TimedOut is true when the fight ran out the phase clock without a
// kill on either side and was decided by the HP-percentage tiebreak.
// Callers should treat a timeout loss as a retreat / escape — the
// player didn't actually take a fatal blow, so character-death side
// effects (markAdventureDead, respawn timer) should NOT fire.
TimedOut bool
Events []CombatEvent
PlayerStartHP int // MaxHP — display denominator
// PlayerEntryHP is the actual HP the player entered combat with (== MaxHP
// at full health, less when wounded carry-over via applyDnDHPScaling).
// Used by injectConsumableEvents so pre-combat narration shows the
// wounded entry state instead of lying "47/47" on the consumable line.
PlayerEntryHP int
EnemyStartHP int
EnemyEntryHP int
PlayerEndHP int
EnemyEndHP int
TotalRounds int
Closeness float64
NearDeath bool
PetAttacked bool
PetDeflected bool
SniperKilled bool
MistyHealed bool
}
// ── Monster Abilities ────────────────────────────────────────────────────────
type MonsterAbility struct {
Name string
Phase string // "opening", "clash", "decisive", "any"
ProcChance float64
// Effect — the mechanic the ability applies. Implemented in applyAbility:
// stand-in attacks (cleave, lifesteal); riders on the normal attack (poison,
// enrage, armor_break, stun, bonus_damage, aoe, aoe_fire, death_aoe, execute,
// self_heal, max_hp_drain); stateful effects armed here and read by the
// shared resolution primitives (evade, block, advantage, retaliate,
// regenerate, survive_at_1, stat_drain, debuff, spell_resist, reveal_action,
// fear_immune, ally_buff). Anything else is a silent no-op.
Effect string
}
// ── Default Phase Definitions ────────────────────────────────────────────────
// Sudden Death is a fallback phase that runs only when none of the earlier
// phases produced a kill. Adds enough rounds to push total combat length
// to ~10 in the worst case, so the absolute-HP tiebreaker below is rarely
// hit. Players reported the prior 6-round insta-timeout felt arbitrary
// when both sides still had plenty of HP.
var defaultCombatPhases = []CombatPhase{
{"Opening", 2, 0.6, 0.8, 1.5, 0.15},
{"Clash", 3, 1.2, 1.0, 0.8, 0.08},
{"Decisive", 1, 1.0, 0.7, 1.0, 0.05},
{"Sudden Death", 3, 1.1, 0.6, 1.0, 0.05},
}
var dungeonCombatPhases = []CombatPhase{
{"Opening", 1, 0.8, 0.8, 1.2, 0.10},
{"Clash", 2, 1.0, 1.0, 0.8, 0.08},
{"Decisive", 1, 1.0, 0.7, 1.0, 0.05},
{"Sudden Death", 4, 1.1, 0.6, 1.0, 0.05},
}
// bossCombatPhases extends the regular dungeon phases for boss encounters.
// Boss HP pools (97546) are too large for auto-resolve to close in the
// 8-round dungeon budget — players hit a wall and time out. Doubling the
// Sudden Death budget gives sustained pressure a chance to close the gap.
// Real fix is the turn-based-bosses refactor; this is the bridge tuning.
var bossCombatPhases = []CombatPhase{
{"Opening", 1, 0.8, 0.8, 1.2, 0.10},
{"Clash", 3, 1.0, 1.0, 0.8, 0.08},
{"Decisive", 2, 1.0, 0.7, 1.0, 0.05},
{"Sudden Death", 10, 1.1, 0.6, 1.0, 0.05},
}
// ── Simulation ───────────────────────────────────────────────────────────────
// actor is the per-combatant half of the simulation state — everything that
// belongs to one player character rather than to the fight as a whole.
//
// It is embedded into combatState as a *pointer*, which promotes its fields:
// `st.playerHP` still resolves, now to `st.actor.playerHP`. The embedded
// pointer is a cursor naming whoever is currently resolving. Solo combat has a
// one-element roster and never moves the cursor, so every read and every RNG
// draw is bit-identical to the pre-roster engine — which is what keeps the
// solo balance corpus (sim_results/d8prereq_corpus.jsonl lineage) valid.
//
// Field names are deliberately unchanged from when they lived on combatState.
type actor struct {
// c is the Combatant this state belongs to. Nil only in tests that drive
// a primitive directly without a roster.
c *Combatant
playerHP int
// hpMax is this actor's HP ceiling for in-fight healing. It tracks
// c.Stats.MaxHP except in the turn-based engine, where seat 0 restores the
// session's persisted player_hp_max — that snapshot comes from
// dndHPSnapshot and can differ from the rebuilt combatant's MaxHP.
hpMax int
// Consumable one-shots
healChargesLeft int // remaining heal-at-<50% triggers
wardCharges int
sporeRounds int
reflectFrac float64
autoCrit bool
// Monster ability effects landing on this actor
poisonTicks int
poisonDmg int
stunPlayer bool
// Sovereign reprieve
deathSaveUsed bool
// D&D race-passive state
luckyUsed bool // Halfling Lucky reroll consumed
raged bool // Orc Rage already triggered this fight
pendingRageAttack bool // next player attack gets +50% damage
// Phase 10 SUB2a-ii first-attack one-shots.
firstAttackBonusUsed bool
assassinateRerollUsed bool
assassinateBonusUsed bool
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
arcaneWardHP int
// Debuffs the enemy has stacked onto this actor specifically.
playerAtkDrain int // stat_drain: flat reduction to this actor's hit damage
playerACDebuff int // debuff: flat reduction to this actor's effective AC
maxHPDrain int // max_hp_drain: reduction to this actor's effective MaxHP
// concentrationDmg — per-round damage of an active concentration AOE
// (Spirit Guardians et al.). Concentration is per-caster, so it lives
// here rather than on the fight.
concentrationDmg int
}
// combatState tracks mutable state during the simulation. The embedded *actor
// is the cursor: the player character currently resolving. Everything declared
// directly on combatState is shared by the whole fight — the enemy, the round
// counter, the event log, and the RNG stream.
type combatState struct {
*actor // cursor into actors; promotes the per-actor fields
actors []*actor // the player roster, in seating order. len == 1 for solo.
// seatIdx is the roster index the cursor points at. Kept in step with the
// embedded *actor by seat(); read only by the turn engine, to attribute the
// events a phase emitted to the character they happened to.
seatIdx int
enemyHP int
// Monster ability effects (enemy-side stance — shared across the roster)
enraged bool
armorBroken bool
armorBreakAmt float64
// Phase 9 spell — enemy skip-first-attack (consumed on the first round
// the enemy would otherwise attack). Holding the enemy holds it for
// everyone, so this is fight-scoped, not per-caster.
enemySkipFirst bool
// Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is
// armed by applyAbility and read by the shared resolution primitives, so
// both engines honour them; the turn-based engine additionally round-trips
// them through CombatStatuses so they survive a suspend/resume.
//
// These describe the *enemy's* stance, so they are fight-scoped: an enemy
// holding a parry stance parries the next swing from anyone. The debuffs
// it stacks onto a specific character (stat_drain / debuff / max_hp_drain)
// live on actor instead.
enemyEvadeNext bool // evade: next player weapon attack auto-misses
enemyBlockUp bool // block: enemy holds a parry stance (~50% block on player hits)
enemyAdvantage bool // advantage: enemy rolls its attacks with advantage
enemyRetaliateFrac float64 // retaliate: fraction of a player hit reflected back
enemyRegen int // regenerate: enemy heals this much each round end
enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
// backed by real state.
enemySpellResist bool // spell_resist: player spell damage against this enemy is halved
enemyRevealNext bool // reveal_action: the player's next weapon attack is rolled at disadvantage
enemyFearImmune bool // fear_immune: player control spells (enemy-skip) fizzle against this enemy
enemyAtkBuff int // ally_buff: flat, accumulating bonus to the enemy's attack damage
round int
events []CombatEvent
// rng, when non-nil, is the deterministic source the turn-based engine
// and the timeout reaper seed per session so a fight can be resumed and
// replayed reproducibly. Auto-resolve (SimulateCombat called without a
// session) leaves this nil and the engine falls back to the package
// global — behaviorally identical to the pre-injection code.
rng *rand.Rand
}
// roll / randFloat draw from the session's injected rng when present,
// else the package global (see rngIntN / rngFloat in dnd_zone_loot.go).
// Auto-resolve leaves st.rng nil — behaviorally identical to the
// pre-injection code; the turn-based engine and the timeout reaper seed
// it per session so a fight can be resumed and replayed reproducibly.
// newActor seats one player character, deriving its opening per-fight state
// from that character's modifiers.
func newActor(c *Combatant) *actor {
startHP := c.Stats.MaxHP
if c.Stats.StartHP > 0 && c.Stats.StartHP < c.Stats.MaxHP {
startHP = c.Stats.StartHP
}
a := &actor{
c: c,
playerHP: startHP,
hpMax: c.Stats.MaxHP,
wardCharges: c.Mods.WardCharges,
sporeRounds: c.Mods.SporeCloud,
reflectFrac: c.Mods.ReflectNext,
autoCrit: c.Mods.AutoCritFirst,
arcaneWardHP: c.Mods.ArcaneWardHP,
}
// HealItemCharges: explicit count overrides legacy one-shot. Backfill
// to 1 charge if the caller set a HealItem amount but no count.
a.healChargesLeft = c.Mods.HealItemCharges
if a.healChargesLeft == 0 && c.Mods.HealItem > 0 {
a.healChargesLeft = 1
}
return a
}
// seat points the cursor at roster index i. Every per-actor read in the
// resolution primitives (st.playerHP, st.wardCharges, …) follows the cursor.
// Solo combat seats index 0 once and never moves it.
// seat moves the cursor to a roster index. seatIdx trails it so the turn engine
// can tag the events a phase emits with the character they are about.
func (st *combatState) seat(i int) { st.actor, st.seatIdx = st.actors[i], i }
// anyAlive reports whether at least one seated character is still standing.
// Solo fights read this as "the player is alive".
func (st *combatState) anyAlive() bool {
for _, a := range st.actors {
if a.playerHP > 0 {
return true
}
}
return false
}
func (st *combatState) roll(n int) int { return rngIntN(st.rng, n) }
func (st *combatState) randFloat() float64 { return rngFloat(st.rng) }
func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult {
return simulateCombatWithRNG(player, enemy, phases, nil)
}
// simulateCombatWithRNG is the deterministic core of the auto-resolve engine.
// SimulateCombat passes nil (package-global rand — production auto-resolve).
// The characterization test and the turn-based engine pass a seeded *rand.Rand
// so a fight is fully reproducible. Passing nil is behaviorally identical to
// the pre-injection code.
//
// This is the one-seat case of the N-body engine in combat_engine_party.go.
// Every roster short-circuit in there collapses for a single player, so the
// draw order — and therefore TestCombatCharacterization — is unchanged.
func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *rand.Rand) CombatResult {
return simulatePartyWithRNG([]Combatant{player}, enemy, phases, rng).Seats[0]
}
// maybeTriggerOrcRage emits the "rage" event and arms pendingRageAttack
// the first time the player's HP crosses below 50% while alive. Idempotent
// via st.raged. Shared by the player-attack and end-of-round trigger sites.
func maybeTriggerOrcRage(st *combatState, player *Combatant, phaseName string) {
if !player.Mods.RageReady || st.raged || st.playerHP <= 0 {
return
}
if st.playerHP*2 >= player.Stats.MaxHP {
return
}
st.raged = true
st.pendingRageAttack = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "rage",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: "Orc Rage",
})
}
// ── Attack Resolution ────────────────────────────────────────────────────────
// resolvePlayerSwings calls resolvePlayerAttack once, then up to
// player.Mods.ExtraAttacks more times (5e Extra Attack). Each extra swing is
// a full attack roll + damage roll. Stops early on enemy KO. The first swing
// consumes once-per-fight openers (AutoCritFirst, FirstAttackBonus,
// AssassinateAdvantage) via st flags — extras roll vanilla.
func resolvePlayerSwings(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
if resolvePlayerAttack(st, player, enemy, phase, result) {
return true
}
for i := 0; i < player.Mods.ExtraAttacks; i++ {
if st.enemyHP <= 0 || st.playerHP <= 0 {
return false
}
if resolvePlayerAttack(st, player, enemy, phase, result) {
return true
}
}
return false
}
// resolvePlayerAttack — d20 + AttackBonus vs enemy AC.
// Nat 20 = auto-hit + crit. Nat 1 = auto-miss tagged "fumble".
// Block (on hit) halves damage. autoCrit consumable forces a crit on hit.
func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
phaseName := phase.Name
// Stun: player skips attack
if st.stunPlayer {
st.stunPlayer = false
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "stunned",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return false
}
// Evade (monster ability): the enemy slipped out of reach — this swing
// finds nothing. Consumed here, armed by applyAbility's "evade" case.
if st.enemyEvadeNext {
st.enemyEvadeNext = false
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "evade",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return false
}
// Orc Rage: trigger on the first attack after dropping below 50% HP.
// Threshold logic lives in maybeTriggerOrcRage; this site keeps the
// fire-on-next-swing UX so rage applies to the *same round* the player
// reacts to a threshold-crossing hit (when they swing after the enemy).
// An end-of-round backstop in runRound covers the "enemy two-shotted
// me across rounds" case so the event never silently vanishes.
maybeTriggerOrcRage(st, player, phaseName)
roll := 1 + st.roll(20)
// Reveal action (monster ability): the enemy read this swing coming — it's
// rolled at disadvantage (2d20, keep the lower). One-shot, consumed here.
if st.enemyRevealNext {
st.enemyRevealNext = false
if alt := 1 + st.roll(20); alt < roll {
roll = alt
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "revealed",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
// Halfling Lucky: reroll the first nat 1 of the fight.
if roll == 1 && player.Mods.LuckyReroll && !st.luckyUsed {
st.luckyUsed = true
newRoll := 1 + st.roll(20)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "lucky_reroll",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: newRoll, RollAgainst: enemy.Stats.AC, Desc: "Halfling Lucky",
})
roll = newRoll
}
// Phase 10 SUB2a-ii — Assassin advantage: re-roll the first attack of
// the fight, take the better of two d20s. Models 5e's "advantage vs.
// creatures that haven't acted yet" applied to the opening strike only.
if player.Mods.AssassinateAdvantage && !st.assassinateRerollUsed {
st.assassinateRerollUsed = true
alt := 1 + st.roll(20)
if alt > roll {
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "assassinate_advantage",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: alt, RollAgainst: enemy.Stats.AC, Desc: "Assassinate",
})
roll = alt
}
}
isFumble := roll == 1
critFloor := attackCritFloor(player.Mods)
isCritRoll := roll >= critFloor
attackBonus := effectiveAttackBonus(player.Stats)
// Phase 10 SUB2a-ii — Battle Master Precision Attack: +d8 (modeled as
// flat +4) to the first attack roll only. Consumed even on miss.
if player.Mods.FirstAttackBonus > 0 && !st.firstAttackBonusUsed {
attackBonus += player.Mods.FirstAttackBonus
st.firstAttackBonusUsed = true
}
total := roll + attackBonus
if !attackConnects(roll, total, enemy.Stats.AC, critFloor) {
desc := ""
if isFumble {
desc = "fumble"
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "miss",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: enemy.Stats.AC, Desc: desc,
})
return false
}
// Damage roll: weapon dice path (Phase 8) or legacy penetration formula.
var dmg int
if player.Stats.Weapon != nil {
// Unproficient wielders don't add their ability mod to damage.
mod := player.Stats.AbilityModForDamage
if !player.Stats.WeaponProficient {
mod = 0
}
total, _ := rollWeaponDamage(st.rng, player.Stats.Weapon, mod, player.Stats.TwoHandedMode)
dmg = total
// Class damage bonus / streak bonus / etc. layered on top via DamageBonus.
if player.Mods.DamageBonus > 0 {
dmg = int(float64(dmg) * (1 + player.Mods.DamageBonus))
}
// Apply enemy damage reduction (consumables, sets) the same way calcDamage does.
if enemy.Mods.DamageReduct > 0 && enemy.Mods.DamageReduct != 1.0 {
dmg = int(float64(dmg) * enemy.Mods.DamageReduct)
}
} else {
dmg = calcDamage(st.rng, player.Stats.Attack, phase.AttackWeight, player.Mods.DamageBonus,
enemy.Stats.Defense, phase.DefenseWeight, enemy.Mods.DamageReduct)
}
// Stat drain (monster ability): the player's strength has been sapped — a
// flat, accumulating reduction to the damage every hit deals.
if st.playerAtkDrain > 0 {
dmg = max(1, dmg-st.playerAtkDrain)
}
blocked := enemy.Stats.BlockRate > 0 && st.randFloat() < enemy.Stats.BlockRate
// Parry stance (monster ability): an enemy holding a block stance rolls a
// further ~50% chance to halve the hit. Guarded by enemyBlockUp so the
// extra randFloat is only drawn once the ability has actually fired.
if !blocked && st.enemyBlockUp && st.randFloat() < 0.5 {
blocked = true
}
if blocked {
dmg = max(1, dmg/2)
}
dmg, action, desc := applyPlayerHitDamageMods(st, player, dmg, isCritRoll, blocked)
st.enemyHP = max(0, st.enemyHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: action,
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: enemy.Stats.AC, Desc: desc,
})
// Retaliate (monster ability): a damaging aura reflects a fraction of the
// hit straight back. Resolved per player hit; can drop the player, so this
// path can return true with the enemy still standing — callers disambiguate
// the outcome by inspecting HP (see stepPlayerTurn).
if st.enemyRetaliateFrac > 0 {
retal := max(1, int(float64(dmg)*st.enemyRetaliateFrac))
st.playerHP = max(0, st.playerHP-retal)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "retaliate",
Damage: retal, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 && !trySave(st, player, phaseName) {
return true
}
}
return enemyDown(st, phaseName)
}
// resolveEnemyAttack — enemy rolls d20 + AttackBonus vs player AC.
// Pet whiff and spore-cloud miss preempt the roll. Ward absorbs a hit fully.
// Block halves damage. Reflect bounces a fraction back. Death save fires if HP hits 0.
func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult, petWhiff, petDeflect, sporeMiss bool) bool {
phaseName := phase.Name
if st.sporeRounds > 0 {
st.sporeRounds--
}
if petWhiff {
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_whiff",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return false
}
if sporeMiss {
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "spore_miss",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return false
}
roll := 1 + st.roll(20)
// Advantage (monster ability): the enemy rolls a second d20 and keeps the
// better. Guarded by enemyAdvantage so the extra roll is only drawn once the
// ability has fired.
if st.enemyAdvantage {
if alt := 1 + st.roll(20); alt > roll {
roll = alt
}
}
isFumble := roll == 1
isNat20 := roll == 20
total := roll + effectiveAttackBonus(enemy.Stats)
// Debuff (monster ability): a flat, accumulating reduction to the player's
// effective AC, so the enemy's attacks land more often. Floored at 1.
// Guarded so an undebuffed player's AC passes through untouched.
targetAC := player.Stats.AC
if st.playerACDebuff > 0 {
targetAC = max(1, player.Stats.AC-st.playerACDebuff)
}
if !attackConnects(roll, total, targetAC, 20) {
desc := ""
if isFumble {
desc = "fumble"
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "miss",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: targetAC, Desc: desc,
})
return false
}
if st.wardCharges > 0 {
st.wardCharges--
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "ward_absorb",
Damage: 0, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return false
}
atkMult := 1.0
if st.enraged {
atkMult = 1.5
}
dmg := calcDamage(st.rng, enemyAttackStat(enemy, st, atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
blocked := player.Stats.BlockRate > 0 && st.randFloat() < player.Stats.BlockRate
if blocked {
dmg = max(1, dmg/2)
}
isCrit := isNat20
if isCrit {
dmg *= 2
}
// Phase 10 SUB2a — Berserker rage halves incoming weapon damage.
// Applied AFTER crit-doubling so the resistance survives crits.
if player.Mods.BerserkerRage && player.Mods.PhysicalResistRage {
dmg = max(1, dmg/2)
}
// Tiefling fire resistance — halve the enemy's primary attack when the
// monster's signature damage is fire (FireAttacker, set on the template).
// Aligned with the Berserker pattern: applied after crit-doubling so the
// resistance survives a nat 20.
if player.Mods.FireResist && enemy.Stats.FireAttacker {
dmg = max(1, dmg/2)
}
dmg = max(1, dmg)
if petDeflect {
dmg = max(1, dmg/2)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_deflect",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
action := "hit"
if isCrit {
action = "crit"
} else if blocked {
action = "block"
}
// Phase 10 SUB2b — Abjuration Arcane Ward absorbs incoming damage before
// it hits player HP. Only wired into the standard enemy attack path; tick
// effects (poison, environment, lifesteal, cleave) bypass the ward in this
// model — those represent damage-over-time / tactical hits where the
// abstraction "magical aegis" reads thinner narratively.
if st.arcaneWardHP > 0 && dmg > 0 {
absorbed := dmg
if absorbed > st.arcaneWardHP {
absorbed = st.arcaneWardHP
}
st.arcaneWardHP -= absorbed
dmg -= absorbed
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "arcane_ward",
Damage: absorbed, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Arcane Ward",
})
}
st.playerHP = max(0, st.playerHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: action,
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: targetAC,
})
if st.reflectFrac > 0 {
reflected := max(1, int(float64(dmg)*st.reflectFrac))
st.reflectFrac = 0
st.enemyHP = max(0, st.enemyHP-reflected)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "reflect_damage",
Damage: reflected, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if enemyDown(st, phaseName) {
return true
}
}
// Class-identity audit (2026-05-16) — Druid thorn lash. Fires on any
// landed enemy hit (dmg>0 after ward/block); independent flat-damage
// counter, not a fraction. Can drop the enemy.
if player.Mods.ThornLashDmg > 0 && dmg > 0 {
lash := player.Mods.ThornLashDmg
st.enemyHP = max(0, st.enemyHP-lash)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "thorn_lash",
Damage: lash, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Wild Resilience",
})
if enemyDown(st, phaseName) {
return true
}
}
if st.playerHP <= 0 {
return !trySave(st, player, phaseName)
}
return false
}
// ── Monster Ability Logic ────────────────────────────────────────────────────
func abilityFires(ability *MonsterAbility, phaseName string, st *combatState) bool {
phaseMatch := ability.Phase == "any" ||
(ability.Phase == "opening" && phaseName == "Opening") ||
(ability.Phase == "clash" && phaseName == "Clash") ||
(ability.Phase == "decisive" && phaseName == "Decisive")
if !phaseMatch {
return false
}
return st.randFloat() < ability.ProcChance
}
func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
ab := enemy.Ability
phaseName := phase.Name
switch ab.Effect {
case "poison":
st.poisonTicks = 2
st.poisonDmg = 3 + st.roll(3)
if player.Mods.PoisonResist {
st.poisonDmg = max(1, st.poisonDmg/2)
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
case "enrage":
if !st.enraged && st.enemyHP < int(float64(enemy.Stats.MaxHP)*0.4) {
st.enraged = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "enrage",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "armor_break":
if !st.armorBroken {
st.armorBroken = true
st.armorBreakAmt = 0.30
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "armor_break",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "stun":
st.stunPlayer = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "stun",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
case "lifesteal":
atkMult := 1.0
if st.enraged {
atkMult = 1.5
}
dmg := calcDamage(st.rng, enemyAttackStat(enemy, st, atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
dmg = max(1, dmg)
st.playerHP = max(0, st.playerHP-dmg)
heal := dmg / 2
st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+heal)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "lifesteal",
Damage: dmg, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 {
return !trySave(st, player, phaseName)
}
case "cleave":
atkMult := 1.0
if st.enraged {
atkMult = 1.5
}
dmg1 := calcDamage(st.rng, enemyAttackStat(enemy, st, atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
dmg1 = max(1, dmg1)
st.playerHP = max(0, st.playerHP-dmg1)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "cleave",
Damage: dmg1, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 {
if !trySave(st, player, phaseName) {
return true
}
// Death save fired — skip follow-up hit (survived the first blow barely)
} else {
dmg2 := max(1, dmg1/2)
st.playerHP = max(0, st.playerHP-dmg2)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "cleave",
Damage: dmg2, Desc: ab.Name + " (follow-up)", PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 {
return !trySave(st, player, phaseName)
}
}
case "bonus_damage":
// A single extra strike riding on top of the round's normal attack —
// the multiattack below is left intact (this is a rider, not a stand-in).
dmg := abilityHitDamage(st, player, enemy, phase, 0.6, 1.0)
st.playerHP = max(0, st.playerHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "bonus_damage",
Damage: dmg, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 {
return !trySave(st, player, phaseName)
}
case "aoe", "aoe_fire", "death_aoe":
// An area burst that partly bypasses armor (defWeight cut to 0.4), so a
// high-defense build still feels it. Rider on top of the normal attack.
dmg := abilityHitDamage(st, player, enemy, phase, 0.7, 0.4)
// Tiefling fire resistance: the aoe_fire effect name encodes a fire
// burst (Burning Hands, Fireball, Fire Breath). Halve for FireResist.
// Generic "aoe" / "death_aoe" don't always carry fire damage, so we
// gate on the effect string — the FireAttacker flag on the monster
// covers fire-themed creatures whose death/aura uses a non-aoe_fire
// effect (e.g., magmin's death_aoe burst rides their FireAttacker tag
// via the primary-attack halving path).
if ab.Effect == "aoe_fire" && player.Mods.FireResist {
dmg = max(1, dmg/2)
}
st.playerHP = max(0, st.playerHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "aoe",
Damage: dmg, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 {
return !trySave(st, player, phaseName)
}
case "execute":
// Finishing blow: lands hard once the player is under 30% HP, a glancing
// hit otherwise so the ability isn't dead weight at full health.
mult := 0.5
if st.playerHP*100 < player.Stats.MaxHP*30 {
mult = 1.3
}
dmg := abilityHitDamage(st, player, enemy, phase, mult, 0.7)
st.playerHP = max(0, st.playerHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "execute",
Damage: dmg, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 {
return !trySave(st, player, phaseName)
}
case "self_heal":
heal := enemy.Stats.MaxHP/5 + st.roll(max(1, enemy.Stats.MaxHP/10))
st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+heal)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "self_heal",
Damage: heal, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
case "evade":
// The enemy slips out of reach — its next-attacked-against weapon swing
// finds nothing. The flag is consumed (and the event emitted) by
// resolvePlayerAttack, so a fight log between here and there reads as the
// enemy turning evasive and the strike whiffing on the following beat.
st.enemyEvadeNext = true
case "block":
// The enemy settles into a parry stance for the rest of the fight: every
// player hit from here on rolls a ~50% chance to be halved (on top of any
// innate BlockRate). resolvePlayerAttack reads st.enemyBlockUp.
if !st.enemyBlockUp {
st.enemyBlockUp = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "parry_stance",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "advantage":
// The enemy gains the upper hand — it rolls its attacks with advantage
// (best of two d20s) for the rest of the fight. resolveEnemyAttack reads
// st.enemyAdvantage.
if !st.enemyAdvantage {
st.enemyAdvantage = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "advantage",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "retaliate":
// A damaging aura: from now on a fraction of every player hit is reflected
// straight back. resolvePlayerAttack applies the reflected damage per hit.
if st.enemyRetaliateFrac < 0.5 {
st.enemyRetaliateFrac = 0.5
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "retaliate_aura",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "regenerate":
// The enemy starts knitting its wounds every round. The actual healing
// ticks in the round_end phase (turn-based) / round start (auto-resolve);
// here we just arm it.
if st.enemyRegen == 0 {
st.enemyRegen = max(1, enemy.Stats.MaxHP/12)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regenerate",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "survive_at_1":
// The enemy cheats death once: the next blow that would drop it to 0
// leaves it at 1 HP instead (see enemyDown). Arm it the first time the
// ability procs; it stays armed until spent.
if !st.enemySurviveArmed {
st.enemySurviveArmed = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "survive_armed",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "stat_drain":
// Saps the player's strength — a flat, accumulating reduction to the
// damage their hits deal. Capped so a long fight can't zero them out.
drain := 2 + st.roll(3)
st.playerAtkDrain = min(12, st.playerAtkDrain+drain)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "stat_drain",
Damage: drain, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
case "debuff":
// Fouls the player's defence — a flat, accumulating reduction to their
// effective AC, so the enemy's attacks land more often. Capped.
drain := 1 + st.roll(2)
st.playerACDebuff = min(6, st.playerACDebuff+drain)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "debuff",
Damage: drain, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
case "max_hp_drain":
// Drains life force: lowers the player's effective MaxHP and deals that
// much immediate damage (the drain hits current HP too). Effective MaxHP
// is floored at 1 via the accumulating cap.
headroom := max(0, player.Stats.MaxHP-1-st.maxHPDrain)
drain := min(headroom, player.Stats.MaxHP/10+st.roll(max(1, player.Stats.MaxHP/20)))
st.maxHPDrain += drain
st.playerHP = max(0, st.playerHP-drain)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "max_hp_drain",
Damage: drain, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 {
return !trySave(st, player, phaseName)
}
case "spell_resist":
// Spell Immunity: the enemy is wrapped in anti-magic. Player spell damage
// (the pre-combat cast in auto-resolve, mid-fight !cast in the turn engine)
// is halved — read by enemyResistsSpells. Arm once; it's a passive, so a
// repeat proc is a silent no-op.
if !st.enemySpellResist {
st.enemySpellResist = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_resist",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "reveal_action":
// The enemy reads the player's intent — their next weapon swing comes in
// at disadvantage (2d20 keep-lower). One-shot, consumed by the next
// resolvePlayerAttack. Re-armed (and re-announced) on every proc.
st.enemyRevealNext = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "reveal_armed",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
case "fear_immune":
// The enemy's resolve can't be broken: player control spells (Hold Person,
// Sleep, Command — the enemy-skip mechanic) fizzle against it. Read by
// enemyImmuneToControl. Arm once; a passive, so a repeat proc is a no-op.
if !st.enemyFearImmune {
st.enemyFearImmune = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_immune",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "ally_buff":
// The enemy rallies — a flat, accumulating bonus to the damage its attacks
// deal (read by enemyAttackStat). Capped so a long fight can't run away.
buff := 2 + st.roll(3)
st.enemyAtkBuff = min(15, st.enemyAtkBuff+buff)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "ally_buff",
Damage: buff, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
return false
}
// abilityHitDamage resolves a monster-ability damage rider through the shared
// penetration formula (calcDamage), so a rider scales identically to a normal
// hit. atkFrac scales the enemy's Attack stat; defFrac scales how much of the
// player's Defense applies (1.0 = a normal hit, lower = an armor-piercing
// burst). Enrage's 1.5x attack multiplier is honored, matching cleave/lifesteal.
func abilityHitDamage(st *combatState, player, enemy *Combatant, phase *CombatPhase, atkFrac, defFrac float64) int {
mult := atkFrac
if st.enraged {
mult *= 1.5
}
dmg := calcDamage(st.rng, enemyAttackStat(enemy, st, mult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight*defFrac, player.Mods.DamageReduct)
return max(1, dmg)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
// calcDamage uses a penetration model: defense provides diminishing returns
// reduction rather than flat subtraction, so damage is never fully negated.
// Formula: rawAtk * reduction where reduction = K / (K + effectiveDef).
// K=40 means 40 defense halves incoming damage; 80 defense reduces by 67%.
//
// A ±15% per-hit jitter is applied so successive hits in the same phase don't
// produce identical numbers — the previous flat-damage output read as scripted.
func calcDamage(rng *rand.Rand, attack int, atkWeight, dmgBonus float64, defense int, defWeight, dmgReduct float64) int {
const K = 40.0
rawAtk := float64(attack) * atkWeight * (1 + dmgBonus)
effectiveDef := float64(defense) * defWeight * dmgReduct
reduction := K / (K + effectiveDef)
dmg := rawAtk * reduction
jitter := 0.85 + rngFloat(rng)*0.30 // 0.85 .. 1.15
dmg *= jitter
if dmg < 1 {
return 1
}
return int(dmg)
}
func playerDefense(player *Combatant, st *combatState) int {
def := player.Stats.Defense
if st.armorBroken {
def = int(float64(def) * (1 - st.armorBreakAmt))
}
return def
}
// enemyAttackStat is the enemy's effective Attack for a damage roll: the base
// stat scaled by mult (1.0 = normal, 1.5 = enraged, <1 = a partial-power rider)
// plus any accumulated ally_buff bonus. With no buff (every characterization
// scenario) it collapses to the pre-slice-4 expression.
func enemyAttackStat(enemy *Combatant, st *combatState, mult float64) int {
return max(1, int(float64(enemy.Stats.Attack)*mult)+st.enemyAtkBuff)
}
// enemyResistsSpells reports whether the enemy's spell_resist (Spell Immunity)
// ability is in play. It's a passive — the per-round proc may not have armed
// st.enemySpellResist yet, so the ability profile is checked too, letting the
// pre-combat spell and a round-1 mid-fight cast be resisted all the same.
func enemyResistsSpells(enemy *Combatant, st *combatState) bool {
return st.enemySpellResist || (enemy.Ability != nil && enemy.Ability.Effect == "spell_resist")
}
// enemyImmuneToControl reports whether the enemy's fear_immune ability is in
// play. Like spell_resist it's a passive, so the ability profile is checked
// alongside the armed flag.
func enemyImmuneToControl(enemy *Combatant, st *combatState) bool {
return st.enemyFearImmune || (enemy.Ability != nil && enemy.Ability.Effect == "fear_immune")
}
// effPlayerMaxHP is the player's MaxHP after any max_hp_drain monster ability,
// floored at 1. Heal clamps use this so a drained player can't be topped back
// up past the lowered ceiling. With no drain (every characterization scenario)
// it returns player.Stats.MaxHP unchanged.
func effPlayerMaxHP(player *Combatant, st *combatState) int {
return max(1, player.Stats.MaxHP-st.maxHPDrain)
}
// enemyDown reports whether the enemy is actually dead. It is the single choke
// point every "did this drop the enemy" check routes through, so the
// survive_at_1 ability can cheat death exactly once: an armed enemy at/below 0
// HP is restored to 1, the flag is spent, and the fight continues. With no
// ability armed (every auto-resolve characterization scenario), this is a plain
// st.enemyHP <= 0 with no side effects.
func enemyDown(st *combatState, phaseName string) bool {
if st.enemyHP > 0 {
return false
}
if st.enemySurviveArmed {
st.enemySurviveArmed = false
st.enemyHP = 1
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "survive_at_1",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return false
}
return true
}
func trySave(st *combatState, player *Combatant, phaseName string) bool {
if player.Mods.DeathSave && !st.deathSaveUsed {
st.deathSaveUsed = true
st.playerHP = 1
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "death_save",
PlayerHP: 1, EnemyHP: st.enemyHP, Desc: "Sovereign",
})
return true
}
return false
}