diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 6eb93c9..c0b9571 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -181,6 +181,20 @@ type CombatModifiers struct { SpellPreDamage int SpellPreDamageDesc string SpellEnemySkipFirst bool + + // At-will cantrip channel. Arcane blasters (Mage/Sorcerer/Warlock) throw a + // scaling damage cantrip EVERY round — 5e cantrips are the caster's at-will + // floor and scale to 4 dice at L17 (Fire Bolt 4d10, Eldritch Blast 4 beams). + // The pre-combat one-shot SpellPreDamage modelled a single leveled cast and + // left the caster swinging a stick for the rest of the fight; that is the + // whole of the caster T5-room wall (one burst can't finish a 65-HP monster + // and the quarterstaff floor does nothing). CantripPerRound is dealt as flat + // magic damage at the top of resolvePlayerSwings each round — no dice roll, + // so the RNG stream is stable and variance stays low. 0 for non-casters, so + // martial combat is byte-identical. Halved by enemy spell_resist like any + // spell. CantripDesc is the narration hook (spell name). + CantripPerRound int + CantripDesc string } type Combatant struct { @@ -568,6 +582,31 @@ func maybeTriggerOrcRage(st *combatState, player *Combatant, phaseName string) { // 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 { + // At-will cantrip: fires once per round before the weapon swing, independent + // of whether the swing connects (a caster who whiffs the stick still throws + // its Fire Bolt). Flat magic damage, halved by spell_resist. 0 for + // non-casters → skipped entirely, so martial combat draws no extra events + // and no RNG. See CantripPerRound in CombatModifiers. + if player.Mods.CantripPerRound > 0 && st.enemyHP > 0 { + dmg := player.Mods.CantripPerRound + if enemyResistsSpells(enemy, st) { + dmg = max(1, dmg/2) + } + st.enemyHP = max(0, st.enemyHP-dmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phase.Name, Actor: "player", Action: "cantrip", + Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + Desc: player.Mods.CantripDesc, + }) + // Route the kill through enemyDown, not a raw HP read: a boss that cheats + // death (survive_at_1) or holds a phylactery rebirth (T6 Valdris) must get + // that chance even when the lethal blow is the at-will cantrip. enemyDown + // restores its HP and returns false, so the weapon swing below resolves + // against the revived pool. Mirrors resolvePlayerAttack's own kill routing. + if enemyDown(st, phase.Name) { + return true + } + } if resolvePlayerAttack(st, player, enemy, phase, result) { return true } diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 7bc466c..7693646 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -242,6 +242,10 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "concentration_tick": return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage) + case "cantrip": + // e.Desc is the spell name (Fire Bolt / Eldritch Blast); e.Damage the hit. + return fmt.Sprintf(pickRand(narrativeCantrip), e.Desc, e.Damage) + case "pet_deflect": return pickRand(narrativePetDeflect) @@ -558,6 +562,15 @@ var narrativeConcentrationTick = []string{ "🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.", } +// narrativeCantrip fires each round an arcane blaster throws its at-will cantrip +// (Fire Bolt / Eldritch Blast) before the weapon swing. %s is the spell name, +// %d the damage — a caster's sustained floor, so it lands every round. +var narrativeCantrip = []string{ + "✨ %s streaks out and burns home — %d damage. The at-will floor never stops.", + "✨ A bolt of %s answers before the staff even moves, scorching for %d.", + "✨ %s lances the enemy for %d. No incantation, no wind-up — just the steady arcane drum.", +} + var narrativePetDeflect = []string{ "🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.", "🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",