From 5cd343af0c123ef03e0c79ae07ea2c13d6fd263e Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 06:49:14 -0700 Subject: [PATCH] Combat: per-round !cast / !consume in turn-based fights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/plugin/adventure.go | 3 + internal/plugin/combat_cmd.go | 293 +++++++++++++++++++++++++- internal/plugin/combat_cmd_test.go | 94 +++++++++ internal/plugin/combat_session.go | 5 + internal/plugin/combat_turn_engine.go | 98 +++++++-- internal/plugin/dnd_cast.go | 7 + internal/plugin/dnd_spell_combat.go | 84 ++++++++ 7 files changed, 563 insertions(+), 21 deletions(-) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 78ee01e..ff1e4e5 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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")) } diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index 27b502b..5e033a4 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -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 `, `!consume `, 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 [--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 (1–5)." + } + 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 ` 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 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 ` 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 `. 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)) +} diff --git a/internal/plugin/combat_cmd_test.go b/internal/plugin/combat_cmd_test.go index b05a9f6..bfb872d 100644 --- a/internal/plugin/combat_cmd_test.go +++ b/internal/plugin/combat_cmd_test.go @@ -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) { diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 827f802..758f515 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -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. diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 0ee8338..aa5de34 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -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...) } diff --git a/internal/plugin/dnd_cast.go b/internal/plugin/dnd_cast.go index c5b9e6d..560a479 100644 --- a/internal/plugin/dnd_cast.go +++ b/internal/plugin/dnd_cast.go @@ -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() diff --git a/internal/plugin/dnd_spell_combat.go b/internal/plugin/dnd_spell_combat.go index 7fea19f..b26eac9 100644 --- a/internal/plugin/dnd_spell_combat.go +++ b/internal/plugin/dnd_spell_combat.go @@ -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).