Combat: per-round !cast / !consume in turn-based fights

Turn-based Elite/Boss fights gain !cast and !consume as player-turn
actions, so casters and item-users make decisions per round instead of
pre-queuing a single effect. The command handler validates and resolves
the spell/item into a pre-rolled turnActionEffect; the engine just
applies the HP deltas and flows on into the enemy turn.

Scoped to effects that resolve within the casting round: damage, heal,
and control spells, plus heal/flat-damage consumables. Buff and utility
spells and buff-type consumables are refused without spending the
resource — they need cross-round stat persistence, a later sub-phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-14 06:49:14 -07:00
parent a0961fee8a
commit 5cd343af0c
7 changed files with 563 additions and 21 deletions

View File

@@ -290,6 +290,90 @@ func applySpellBuff(spell SpellDefinition, c *DnDCharacter, stats *CombatStats,
}
}
// ── Phase 13 — per-round turn-based casting ──────────────────────────────────
// turnSpellOutcome is the resolved effect of a spell cast as a player turn in a
// turn-based fight. Damage, healing, and the one-round enemy skip all land
// within the casting round, so no cross-round persistence is needed.
type turnSpellOutcome struct {
EnemyDamage int
PlayerHeal int
EnemySkip bool
Desc string
}
// resolveTurnSpell resolves a spell as a turn-based player action, reusing the
// auto-resolve spell math against a throwaway modifier set. supported is false
// for buff and utility spells: those mutate stats for the rest of the fight,
// which the turn engine can't carry yet (it rebuilds combatants each round) —
// the caller should refuse them without spending the slot. Reaction spells are
// likewise out of scope here and should be filtered by the caller.
func resolveTurnSpell(c *DnDCharacter, spell SpellDefinition, slotLevel int, enemy *CombatStats) (out turnSpellOutcome, supported bool) {
switch spell.Effect {
case EffectSpellHeal:
out.PlayerHeal = rollTurnSpellHeal(c, spell, slotLevel)
out.Desc = fmt.Sprintf("%s — +%d HP", spell.Name, out.PlayerHeal)
return out, true
case EffectDamageAttack, EffectDamageSave, EffectDamageAuto, EffectControl:
var mods CombatModifiers
dc := spellSaveDC(c)
atk := spellAttackBonus(c)
switch spell.Effect {
case EffectDamageAttack:
applySpellDamageAttack(spell, atk, &mods, enemy, slotLevel, c.Level)
case EffectDamageSave:
applySpellDamageSave(spell, dc, c, &mods, enemy, slotLevel)
case EffectDamageAuto:
applySpellDamageAuto(spell, &mods, slotLevel, c.Level)
case EffectControl:
applySpellControl(spell, dc, &mods, enemy, slotLevel)
}
if mods.SpellPreDamage > 0 {
applyMageSubclassSpellHooks(c, spell, slotLevel, &mods)
}
out.EnemyDamage = mods.SpellPreDamage
out.EnemySkip = mods.SpellEnemySkipFirst
out.Desc = mods.SpellPreDamageDesc
if out.Desc == "" {
out.Desc = spell.Name
}
return out, true
default:
// EffectBuffSelf / EffectBuffAlly / EffectUtility — deferred.
return out, false
}
}
// rollTurnSpellHeal rolls an in-combat healing cast. Mirrors
// resolveHealOutOfCombat's formula but returns the amount instead of writing
// to the character sheet — in a turn-based fight, HP lives on the session row.
func rollTurnSpellHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) int {
dice, faces, _ := parseDamageDice(spell.DamageDice)
if dice == 0 {
dice, faces = 1, 8
}
extra := slotLevel - spell.Level
if extra < 0 {
extra = 0
}
totalDice := dice + extra
supreme := lifeDomainSupremeHealing(c)
heal := 0
for i := 0; i < totalDice; i++ {
if supreme {
heal += faces
} else {
heal += 1 + rand.IntN(faces)
}
}
heal += abilityModifier(c.WIS)
heal += lifeDomainHealBonus(c, spell, slotLevel)
if heal < 1 {
heal = 1
}
return heal
}
// rollSpellDamageDice rolls a spell's damage dice with cantrip and upcast
// scaling. Returns at least 1 if the spell has any dice; 0 if no dice are
// listed (utility/buff spells).