mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -330,6 +330,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
if p.IsCommand(ctx.Body, "flee") {
|
if p.IsCommand(ctx.Body, "flee") {
|
||||||
return p.handleFleeCmd(ctx)
|
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") {
|
if p.IsCommand(ctx.Body, "expedition") {
|
||||||
return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition"))
|
return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -138,21 +139,25 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
|
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
|
var b strings.Builder
|
||||||
b.WriteString(renderCombatRound(events, player.Name, enemy.Name))
|
b.WriteString(renderCombatRound(events, playerName, enemy.Name))
|
||||||
|
|
||||||
if sess.IsActive() {
|
if sess.IsActive() {
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
b.WriteString(fmt.Sprintf("You: **%d/%d** · %s: **%d/%d**\n",
|
b.WriteString(fmt.Sprintf("You: **%d/%d** · %s: **%d/%d**\n",
|
||||||
sess.PlayerHP, sess.PlayerHPMax, enemy.Name, sess.EnemyHP, sess.EnemyHPMax))
|
sess.PlayerHP, sess.PlayerHPMax, enemy.Name, sess.EnemyHP, sess.EnemyHPMax))
|
||||||
b.WriteString(combatTurnPrompt(sess))
|
b.WriteString(combatTurnPrompt(sess))
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
b.WriteString(p.finishCombatSession(ctx.Sender, sess, enemy))
|
b.WriteString(p.finishCombatSession(userID, sess, enemy))
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCombatRound resolves one full round: the player's chosen action, then the
|
// 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
|
// combatTurnPrompt is the "your move" footer shown after every non-terminal
|
||||||
// round and on the opening !fight message.
|
// round and on the opening !fight message.
|
||||||
func combatTurnPrompt(sess *CombatSession) string {
|
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 ───────────────────────────────────────────────────────────────
|
// ── 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)
|
return fmt.Sprintf("✨ %s should be down — and isn't.", playerName)
|
||||||
case "flee":
|
case "flee":
|
||||||
return fmt.Sprintf("🏃 %s breaks off and runs.", playerName)
|
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":
|
case "enemy":
|
||||||
switch ev.Action {
|
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)
|
return fmt.Sprintf("🧛 %s drains life from the wound.", enemyName)
|
||||||
case "cleave":
|
case "cleave":
|
||||||
return fmt.Sprintf("🪓 %s cleaves into %s for **%d**.", enemyName, playerName, ev.Damage)
|
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 {
|
if ev.Damage > 0 {
|
||||||
@@ -333,3 +350,265 @@ func renderCombatRoundEvent(ev CombatEvent, playerName, enemyName string) string
|
|||||||
}
|
}
|
||||||
return ""
|
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 (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 <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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────
|
// ── renderCombatRound ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestRenderCombatRound(t *testing.T) {
|
func TestRenderCombatRound(t *testing.T) {
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ type CombatStatuses struct {
|
|||||||
Enraged bool `json:"enraged,omitempty"`
|
Enraged bool `json:"enraged,omitempty"`
|
||||||
ArmorBroken bool `json:"armor_broken,omitempty"`
|
ArmorBroken bool `json:"armor_broken,omitempty"`
|
||||||
ArmorBreakAmt float64 `json:"armor_break_amt,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.
|
// CombatSession is the in-memory shape of a combat_session row.
|
||||||
|
|||||||
@@ -41,12 +41,29 @@ var turnCombatPhase = CombatPhase{
|
|||||||
const (
|
const (
|
||||||
ActionAttack = "attack"
|
ActionAttack = "attack"
|
||||||
ActionFlee = "flee"
|
ActionFlee = "flee"
|
||||||
|
ActionCast = "cast"
|
||||||
|
ActionConsume = "consume"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PlayerAction is the player's choice for a player_turn step. It is ignored on
|
// PlayerAction is the player's choice for a player_turn step. It is ignored on
|
||||||
// enemy_turn / round_end steps (pass the zero value).
|
// enemy_turn / round_end steps (pass the zero value).
|
||||||
type PlayerAction struct {
|
type PlayerAction struct {
|
||||||
Kind string
|
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
|
// turnEngine wraps a combatState reconstructed from a persisted CombatSession
|
||||||
@@ -98,6 +115,7 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
|||||||
enraged: sess.Statuses.Enraged,
|
enraged: sess.Statuses.Enraged,
|
||||||
armorBroken: sess.Statuses.ArmorBroken,
|
armorBroken: sess.Statuses.ArmorBroken,
|
||||||
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
|
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
|
||||||
|
enemySkipFirst: sess.Statuses.EnemySkipNext,
|
||||||
rng: rng,
|
rng: rng,
|
||||||
}
|
}
|
||||||
return &turnEngine{
|
return &turnEngine{
|
||||||
@@ -134,15 +152,20 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
||||||
if action.Kind == ActionFlee {
|
switch action.Kind {
|
||||||
|
case ActionFlee:
|
||||||
te.st.events = append(te.st.events, CombatEvent{
|
te.st.events = append(te.st.events, CombatEvent{
|
||||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "flee",
|
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "flee",
|
||||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||||
})
|
})
|
||||||
te.finish(CombatStatusFled)
|
te.finish(CombatStatusFled)
|
||||||
return
|
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) {
|
if resolvePlayerAttack(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
|
||||||
te.finish(CombatStatusWon)
|
te.finish(CombatStatusWon)
|
||||||
return
|
return
|
||||||
@@ -150,7 +173,53 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
|||||||
te.sess.Phase = CombatPhaseEnemyTurn
|
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() {
|
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
|
// resolveEnemyAttack returns true when the fight is decided — either the
|
||||||
// player went down without a death save, or a reflect consumable killed
|
// player went down without a death save, or a reflect consumable killed
|
||||||
// the enemy. Disambiguate by inspecting HP.
|
// the enemy. Disambiguate by inspecting HP.
|
||||||
@@ -208,6 +277,7 @@ func (te *turnEngine) commit() {
|
|||||||
Enraged: st.enraged,
|
Enraged: st.enraged,
|
||||||
ArmorBroken: st.armorBroken,
|
ArmorBroken: st.armorBroken,
|
||||||
ArmorBreakAmt: st.armorBreakAmt,
|
ArmorBreakAmt: st.armorBreakAmt,
|
||||||
|
EnemySkipNext: st.enemySkipFirst,
|
||||||
}
|
}
|
||||||
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ func decodePendingCast(s string) (PendingCast, bool) {
|
|||||||
// ── !cast command ────────────────────────────────────────────────────────────
|
// ── !cast command ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) error {
|
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 := p.advUserLock(ctx.Sender)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
|
|||||||
@@ -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
|
// 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
|
// scaling. Returns at least 1 if the spell has any dice; 0 if no dice are
|
||||||
// listed (utility/buff spells).
|
// listed (utility/buff spells).
|
||||||
|
|||||||
Reference in New Issue
Block a user