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 } 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 (97–546) 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 }