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

@@ -330,6 +330,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "flee") {
return p.handleFleeCmd(ctx)
}
if p.IsCommand(ctx.Body, "consume") {
return p.handleConsumeCmd(ctx, p.GetArgs(ctx.Body, "consume"))
}
if p.IsCommand(ctx.Body, "expedition") {
return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition"))
}

View File

@@ -3,6 +3,7 @@ package plugin
import (
"fmt"
"log/slog"
"strconv"
"strings"
"maunium.net/go/mautrix/id"
@@ -138,21 +139,25 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
}
// renderRoundResult turns a resolved round into the player-facing block: the
// play-by-play, then either the HP/turn-prompt footer (fight continues) or the
// close-out block (terminal status). Shared by !attack/!flee, !cast, !consume.
func (p *AdventurePlugin) renderRoundResult(userID id.UserID, sess *CombatSession, events []CombatEvent, playerName string, enemy Combatant) string {
var b strings.Builder
b.WriteString(renderCombatRound(events, player.Name, enemy.Name))
b.WriteString(renderCombatRound(events, playerName, enemy.Name))
if sess.IsActive() {
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("You: **%d/%d** · %s: **%d/%d**\n",
sess.PlayerHP, sess.PlayerHPMax, enemy.Name, sess.EnemyHP, sess.EnemyHPMax))
b.WriteString(combatTurnPrompt(sess))
return p.SendDM(ctx.Sender, b.String())
return b.String()
}
b.WriteString("\n\n")
b.WriteString(p.finishCombatSession(ctx.Sender, sess, enemy))
return p.SendDM(ctx.Sender, b.String())
b.WriteString(p.finishCombatSession(userID, sess, enemy))
return b.String()
}
// runCombatRound resolves one full round: the player's chosen action, then the
@@ -178,7 +183,7 @@ func runCombatRound(sess *CombatSession, player, enemy *Combatant, action Player
// combatTurnPrompt is the "your move" footer shown after every non-terminal
// round and on the opening !fight message.
func combatTurnPrompt(sess *CombatSession) string {
return fmt.Sprintf("**Round %d.** Your move — `!attack` or `!flee`.", sess.Round)
return fmt.Sprintf("**Round %d.** Your move — `!attack`, `!cast <spell>`, `!consume <item>`, or `!flee`.", sess.Round)
}
// ── close-out ───────────────────────────────────────────────────────────────
@@ -303,6 +308,16 @@ func renderCombatRoundEvent(ev CombatEvent, playerName, enemyName string) string
return fmt.Sprintf("✨ %s should be down — and isn't.", playerName)
case "flee":
return fmt.Sprintf("🏃 %s breaks off and runs.", playerName)
case "spell_cast":
if ev.Desc != "" {
return "✨ " + ev.Desc
}
return fmt.Sprintf("✨ %s casts a spell.", playerName)
case "use_consumable":
if ev.Desc != "" {
return "🧪 " + ev.Desc
}
return fmt.Sprintf("🧪 %s uses an item.", playerName)
}
case "enemy":
switch ev.Action {
@@ -326,6 +341,8 @@ func renderCombatRoundEvent(ev CombatEvent, playerName, enemyName string) string
return fmt.Sprintf("🧛 %s drains life from the wound.", enemyName)
case "cleave":
return fmt.Sprintf("🪓 %s cleaves into %s for **%d**.", enemyName, playerName, ev.Damage)
case "spell_held":
return fmt.Sprintf("🌀 %s is held fast — no attack this round.", enemyName)
}
}
if ev.Damage > 0 {
@@ -333,3 +350,265 @@ func renderCombatRoundEvent(ev CombatEvent, playerName, enemyName string) string
}
return ""
}
// ── !cast (in-combat) ───────────────────────────────────────────────────────
//
// handleDnDCastCmd routes here when the player has an active CombatSession:
// !cast resolves as the player's turn for the round instead of queuing for
// "next combat". Out-of-combat !cast is unchanged.
// parseCombatCast validates a !cast invocation for a caster mid-fight and
// returns the spell plus the resolved slot level. errMsg is non-empty and
// player-facing on any validation failure. It performs NO resource spend —
// the caller debits the slot only once the round is about to resolve.
func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
tokens := strings.Fields(args)
upcast := 0
var spellTokens []string
for i := 0; i < len(tokens); i++ {
switch tokens[i] {
case "--upcast":
if i+1 < len(tokens) {
if n, err := strconv.Atoi(tokens[i+1]); err == nil {
upcast = n
}
i++
}
case "--target":
if i+1 < len(tokens) {
i++
}
default:
spellTokens = append(spellTokens, tokens[i])
}
}
if len(spellTokens) == 0 {
return SpellDefinition{}, 0, "Usage: `!cast <spell> [--upcast N]` — casts as your turn this round. `!spells` lists options."
}
spell, ok := parseSpell(strings.Join(spellTokens, " "))
if !ok {
return SpellDefinition{}, 0, fmt.Sprintf("Unknown spell %q. Run `!spells` to list what you know.", strings.Join(spellTokens, " "))
}
// Class gate — Arcane Trickster Rogues use the Mage list.
effectiveClass := c.Class
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
effectiveClass = ClassMage
}
classOK := false
for _, cl := range spell.Classes {
if cl == effectiveClass {
classOK = true
break
}
}
if !classOK {
return SpellDefinition{}, 0, fmt.Sprintf("%s is not on the %s spell list.", spell.Name, titleClass(c.Class))
}
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
if mx := highestAvailableSlotForChar(c); spell.Level > mx {
return SpellDefinition{}, 0, fmt.Sprintf("Arcane Trickster L%d only has up to L%d slots.", c.Level, mx)
}
}
known, prepared, err := playerKnowsSpell(userID, spell.ID)
if err != nil {
return SpellDefinition{}, 0, "Couldn't check your spell list."
}
if !known {
return SpellDefinition{}, 0, fmt.Sprintf("You don't know %s yet.", spell.Name)
}
if !prepared {
return SpellDefinition{}, 0, fmt.Sprintf("%s isn't prepared today. Run `!prepare %s` first.", spell.Name, spell.ID)
}
slotLevel := spell.Level
if upcast > slotLevel && spell.Level > 0 {
slotLevel = upcast
}
if slotLevel < 0 || slotLevel > 5 {
return SpellDefinition{}, 0, "Slot level out of range (15)."
}
return spell, slotLevel, ""
}
func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
}
if sess == nil {
// Race: the fight closed between the route check and the lock.
return p.SendDM(ctx.Sender, "You're not in a fight anymore.")
}
advChar, _ := loadAdvCharacter(ctx.Sender)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
if err != nil || c == nil {
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
}
if !isSpellcaster(c) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
}
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
if errMsg != "" {
return p.SendDM(ctx.Sender, errMsg)
}
if spell.Effect == EffectReaction {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name))
}
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
}
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
if !supported {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s is a buff/utility spell — those aren't usable in turn-based fights yet (they need cross-round tracking). Try a damage, healing, or control spell.", spell.Name))
}
// Material component cost (Revivify, Raise Dead — rare in a fight).
if spell.MaterialCost > 0 {
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost))
}
}
// Slot spend — audit pattern: debit, run the round, refund on failure.
if spell.Level > 0 {
ok, serr := consumeSpellSlot(ctx.Sender, slotLevel)
if serr != nil {
return p.SendDM(ctx.Sender, "Couldn't consume slot: "+serr.Error())
}
if !ok {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"No L%d slot available. %s", slotLevel, renderSlotsBrief(ctx.Sender)))
}
}
eff := &turnActionEffect{
Label: out.Desc,
Action: "spell_cast",
EnemyDamage: out.EnemyDamage,
PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip,
}
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
if err != nil {
if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel)
}
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
}
// ── !consume (in-combat) ────────────────────────────────────────────────────
//
// !consume <item> spends one combat consumable as the player's turn. Heal and
// flat-damage items resolve fully within the round; buff-type items (ward,
// atk/def boost, spore, reflect, auto-crit) need cross-round stat tracking and
// are refused for now.
// matchConsumable resolves a player-typed name against the player's consumable
// inventory: case-insensitive exact match first, then a unique prefix match.
func matchConsumable(inv []ConsumableItem, query string) (*ConsumableItem, string) {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return nil, ""
}
var prefix []*ConsumableItem
for i := range inv {
name := strings.ToLower(inv[i].Def.Name)
if name == q {
return &inv[i], ""
}
if strings.HasPrefix(name, q) {
prefix = append(prefix, &inv[i])
}
}
switch len(prefix) {
case 0:
return nil, ""
case 1:
return prefix[0], ""
default:
names := make([]string, len(prefix))
for i, c := range prefix {
names[i] = c.Def.Name
}
return nil, "Ambiguous — matches: " + strings.Join(names, ", ") + "."
}
}
func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
}
if sess == nil {
return p.SendDM(ctx.Sender, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
}
inv := p.loadConsumableInventory(ctx.Sender)
args = strings.TrimSpace(args)
if args == "" {
if len(inv) == 0 {
return p.SendDM(ctx.Sender, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
}
names := make([]string, len(inv))
for i, c := range inv {
names[i] = c.Def.Name
}
return p.SendDM(ctx.Sender, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
}
item, ambig := matchConsumable(inv, args)
if ambig != "" {
return p.SendDM(ctx.Sender, ambig)
}
if item == nil {
return p.SendDM(ctx.Sender, fmt.Sprintf("No consumable matching %q in your inventory.", args))
}
def := item.Def
eff := &turnActionEffect{Action: "use_consumable"}
switch def.Effect {
case EffectHeal:
eff.PlayerHeal = int(def.Value)
eff.Label = fmt.Sprintf("%s — +%d HP", def.Name, eff.PlayerHeal)
case EffectFlatDmg:
eff.EnemyDamage = int(def.Value)
eff.Label = fmt.Sprintf("%s — %d damage", def.Name, eff.EnemyDamage)
default:
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** is a buff-type consumable — those aren't usable in turn-based fights yet. Healing and damage items (Berry Poultice, Coal Bomb, …) work.", def.Name))
}
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
}
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
}
// Round resolved and persisted — now burn the item. A removal failure here
// leaves the player a free use (logged, rare); better than charging them
// for a round that errored out.
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr)
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
}

View File

@@ -141,6 +141,100 @@ func TestRunCombatRound_PlayerWinsTerminates(t *testing.T) {
}
}
// ── runCombatRound: !cast / !consume turn effects ─────────────────────────
func TestRunCombatRound_ConsumeHealClampsToMax(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@combat-heal:example.org")
defer cleanupCombatSessions(uid)
// Player at 10/50, big enemy pool so the round can't end the fight.
sess, err := startCombatSession(uid, "r", "room0", "goblin", 10, 50, 100000, 100000)
if err != nil {
t.Fatal(err)
}
player, enemy := basePlayer(), baseEnemy()
eff := &turnActionEffect{Action: "use_consumable", Label: "Spirit Tonic", PlayerHeal: 1000}
if _, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff}); err != nil {
t.Fatal(err)
}
// Heal of 1000 must clamp to PlayerHPMax, even after the enemy's swing this
// round chipped some HP back off.
if sess.PlayerHP > sess.PlayerHPMax {
t.Errorf("playerHP = %d exceeds max %d", sess.PlayerHP, sess.PlayerHPMax)
}
if sess.PlayerHP <= 10 {
t.Errorf("playerHP = %d, expected the heal to raise it above the 10 it started at", sess.PlayerHP)
}
}
func TestRunCombatRound_CastDamageKillsEnemy(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@combat-cast-kill:example.org")
defer cleanupCombatSessions(uid)
sess, err := startCombatSession(uid, "r", "room0", "goblin", 100000, 100000, 30, 30)
if err != nil {
t.Fatal(err)
}
player, enemy := basePlayer(), baseEnemy()
eff := &turnActionEffect{Action: "spell_cast", Label: "Fireball — 99 dmg", EnemyDamage: 99}
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
if err != nil {
t.Fatal(err)
}
if sess.Status != CombatStatusWon || sess.Phase != CombatPhaseOver {
t.Errorf("after lethal cast: status=%q phase=%q", sess.Status, sess.Phase)
}
if sess.EnemyHP > 0 {
t.Errorf("enemyHP = %d, want 0", sess.EnemyHP)
}
if len(events) != 1 || events[0].Action != "spell_cast" {
t.Errorf("expected a single spell_cast event, got %+v", events)
}
}
func TestRunCombatRound_CastControlSkipsEnemyTurn(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@combat-control:example.org")
defer cleanupCombatSessions(uid)
sess, err := startCombatSession(uid, "r", "room0", "goblin", 200, 200, 100000, 100000)
if err != nil {
t.Fatal(err)
}
player, enemy := basePlayer(), baseEnemy()
hpBefore := sess.PlayerHP
eff := &turnActionEffect{Action: "spell_cast", Label: "Hold Person — controlled", EnemySkip: true}
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
if err != nil {
t.Fatal(err)
}
// The enemy forfeits its swing this round, so the player takes no damage.
if sess.PlayerHP != hpBefore {
t.Errorf("playerHP = %d, want unchanged %d (enemy was held)", sess.PlayerHP, hpBefore)
}
held := false
for _, ev := range events {
if ev.Action == "spell_held" {
held = true
}
}
if !held {
t.Errorf("expected a spell_held event, got %+v", events)
}
// The skip is a one-round effect — it must not persist into the next round.
if sess.Statuses.EnemySkipNext {
t.Error("EnemySkipNext should be cleared once the enemy turn consumed it")
}
if !sess.IsActive() || sess.Phase != CombatPhasePlayerTurn {
t.Errorf("fight should continue: status=%q phase=%q", sess.Status, sess.Phase)
}
}
// ── renderCombatRound ──────────────────────────────────────────────────────
func TestRenderCombatRound(t *testing.T) {

View File

@@ -55,6 +55,11 @@ type CombatStatuses struct {
Enraged bool `json:"enraged,omitempty"`
ArmorBroken bool `json:"armor_broken,omitempty"`
ArmorBreakAmt float64 `json:"armor_break_amt,omitempty"`
// EnemySkipNext carries a control-spell skip (Hold Person, Sleep) from the
// player_turn phase it was cast in to the enemy_turn phase that consumes it
// — those phases resolve as separate engine steps with separate in-memory
// combatState, so the flag must survive the commit between them.
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
}
// CombatSession is the in-memory shape of a combat_session row.

View File

@@ -39,14 +39,31 @@ var turnCombatPhase = CombatPhase{
// Player action kinds accepted by the state machine on a player_turn step.
const (
ActionAttack = "attack"
ActionFlee = "flee"
ActionAttack = "attack"
ActionFlee = "flee"
ActionCast = "cast"
ActionConsume = "consume"
)
// PlayerAction is the player's choice for a player_turn step. It is ignored on
// enemy_turn / round_end steps (pass the zero value).
type PlayerAction struct {
Kind string
// Effect is set for ActionCast / ActionConsume: a pre-resolved outcome
// handed in by the command handler. The handler owns spell/item lookup,
// dice rolls, and the resource spend; the engine just applies the HP
// deltas and emits the event so the round flows on into the enemy turn.
Effect *turnActionEffect
}
// turnActionEffect is the resolved payload of a !cast / !consume player turn.
// Damage, heal, and the one-round enemy-skip all land within the casting round.
type turnActionEffect struct {
Label string // "Fireball — crit!", "Berry Poultice"
Action string // CombatEvent.Action: "spell_cast" or "use_consumable"
EnemyDamage int
PlayerHeal int
EnemySkip bool // control spell: enemy forfeits its attack this round
}
// turnEngine wraps a combatState reconstructed from a persisted CombatSession
@@ -89,16 +106,17 @@ func phaseOrdinal(phase string) uint64 {
// rng is the deterministic source for this step (see combatSessionRNG).
func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.Rand) *turnEngine {
st := &combatState{
playerHP: sess.PlayerHP,
enemyHP: sess.EnemyHP,
round: sess.Round,
poisonTicks: sess.Statuses.PoisonTicks,
poisonDmg: sess.Statuses.PoisonDmg,
stunPlayer: sess.Statuses.StunPlayer,
enraged: sess.Statuses.Enraged,
armorBroken: sess.Statuses.ArmorBroken,
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
rng: rng,
playerHP: sess.PlayerHP,
enemyHP: sess.EnemyHP,
round: sess.Round,
poisonTicks: sess.Statuses.PoisonTicks,
poisonDmg: sess.Statuses.PoisonDmg,
stunPlayer: sess.Statuses.StunPlayer,
enraged: sess.Statuses.Enraged,
armorBroken: sess.Statuses.ArmorBroken,
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
enemySkipFirst: sess.Statuses.EnemySkipNext,
rng: rng,
}
return &turnEngine{
sess: sess,
@@ -134,15 +152,20 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
}
func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
if action.Kind == ActionFlee {
switch action.Kind {
case ActionFlee:
te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "flee",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
te.finish(CombatStatusFled)
return
case ActionCast, ActionConsume:
te.stepPlayerActionEffect(action.Effect)
return
}
// resolvePlayerAttack returns true once the enemy is down.
// Default: weapon attack. resolvePlayerAttack returns true once the
// enemy is down.
if resolvePlayerAttack(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
te.finish(CombatStatusWon)
return
@@ -150,7 +173,53 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
te.sess.Phase = CombatPhaseEnemyTurn
}
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
// has already rolled the spell / picked the item and spent the resource, so the
// engine only applies the HP deltas and emits the event before handing off to
// the enemy turn. A control-spell skip is parked in st.enemySkipFirst, which
// commit() persists as Statuses.EnemySkipNext for the enemy_turn step to read.
func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
st := te.st
if eff == nil {
// Defensive: a kind with no payload just passes the turn.
te.sess.Phase = CombatPhaseEnemyTurn
return
}
if eff.EnemyDamage > 0 {
st.enemyHP = max(0, st.enemyHP-eff.EnemyDamage)
}
if eff.PlayerHeal > 0 {
st.playerHP = min(te.sess.PlayerHPMax, st.playerHP+eff.PlayerHeal)
}
action := eff.Action
if action == "" {
action = "spell_cast"
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action,
Damage: eff.EnemyDamage, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label,
})
if st.enemyHP <= 0 {
te.finish(CombatStatusWon)
return
}
if eff.EnemySkip {
st.enemySkipFirst = true
}
te.sess.Phase = CombatPhaseEnemyTurn
}
func (te *turnEngine) stepEnemyTurn() {
// A control spell cast last phase forfeits the enemy's attack this round.
if te.st.enemySkipFirst {
te.st.enemySkipFirst = false
te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
te.sess.Phase = CombatPhaseRoundEnd
return
}
// resolveEnemyAttack returns true when the fight is decided — either the
// player went down without a death save, or a reflect consumable killed
// the enemy. Disambiguate by inspecting HP.
@@ -208,6 +277,7 @@ func (te *turnEngine) commit() {
Enraged: st.enraged,
ArmorBroken: st.armorBroken,
ArmorBreakAmt: st.armorBreakAmt,
EnemySkipNext: st.enemySkipFirst,
}
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
}

View File

@@ -61,6 +61,13 @@ func decodePendingCast(s string) (PendingCast, bool) {
// ── !cast command ────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) error {
// In a turn-based Elite/Boss fight, !cast resolves as the player's turn
// for the round rather than queuing for "next combat". handleCombatCastCmd
// takes the per-user lock itself and re-checks the session under it.
if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil {
return p.handleCombatCastCmd(ctx, args)
}
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()

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).