mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
The doc's item M claimed all three mage subclass spell hooks were dead on the
turn path because applyMageSubclassSpellHooks had one caller. It has three.
resolveTurnSpell has called it since 5cd343a, so Empowered Evocation and
Overchannel always worked -- they only move mods.SpellPreDamage, which
resolveTurnSpell returns as EnemyDamage.
Grim Harvest was the real defect, with a narrower cause: the hook wrote
mods.GrimHarvestSlot into a local CombatModifiers that resolveTurnSpell
discarded, because turnSpellOutcome had no field to carry it out. A Necromancy
Mage who killed with a spell in a manual fight never healed.
The stash can't ride on fight-start mods the way auto-resolve's does -- the
spell is cast mid-fight and the turn engine rebuilds combatants every round --
so it rides on the casting seat's ActorStatuses, like ArmedAbility. Each
damaging cast overwrites it; snapshotActor carries it across commit().
grimHarvestHeal also scanned for the *first* spell_cast event to ask whether
the spell landed the killing blow. Auto-resolve casts once, pre-combat, so
first == last there. A turn-based mage casts every round, so a non-lethal
opening cantrip vetoed the heal the killing spell had earned. Now scans for the
last spell_cast -- provably identical on the auto-resolve path, so the golden
corpus does not move.
Balance: a caster buff on the manual surface only, and the one the subclass was
written to have. Auto-resolve already paid it out.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
1303 lines
53 KiB
Go
1303 lines
53 KiB
Go
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.
|
||
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
|
||
}
|