mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +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") {
|
||||
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"))
|
||||
}
|
||||
|
||||
@@ -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 (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 ──────────────────────────────────────────────────────
|
||||
|
||||
func TestRenderCombatRound(t *testing.T) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user