mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
A solo fight is a conversation: the player types, the engine answers, and
nothing happens in between. A party fight is a queue, and three things follow.
Turn ownership. Only the seat on the clock may act, so beginCombatTurn resolves
the sender's seat and refuses the rest before anyone spends a slot or burns an
item. A fight lock, because three members typing !attack at once took three
different user locks and the check would have passed for all three: it takes the
fight's lock (keyed on seat 0) then the member's own, always in that order, and
a solo fight -- whose owner is the sender, and sync.Mutex is not reentrant --
takes exactly the one lock it always took. And a turn deadline, because one
member who wanders off must not freeze the other two for the hour it takes the
session reaper to wake up.
The deadline is three minutes, not the plan's sixty seconds. The sweep rides the
existing one-minute ticker, so any deadline really fires in [d, d+1m); and
expeditions here run for days, so the asymmetry favours patience over robbing
someone of their boss turn while they read the room on their phone. A lapse
latches that seat onto the auto-picker for the rest of the fight, so an absent
member costs the party one wait rather than one per round. Typing anything hands
the wheel back. Solo is never swept.
Three seat-0 leaks fixed on the way past, all of which would have surfaced as
the leader quietly doing everyone's business:
- mid-fight buffs folded into the session's embedded ActorStatuses, so a
member casting Shield on themselves would have armoured the leader;
- pickAutoCombatAction read sess.PlayerHP and Statuses.ConcentrationDmg, so
playing an away member's turn would have healed the wrong person and
re-armed the wrong aura;
- runCombatRound rested on any player_turn, and a downed seat still holds one
-- the round would have come to rest on a corpse and waited for a dead
member to type !attack. settleCombatSession drains it. beginCombatTurn
settles before reading the clock, which also fixes a latent solo bug: a
fight interrupted mid enemy_turn resumed parked there and silently ate the
player's next !attack.
The narration turned out to be written in the second person -- "You score 9
damage", "A hit gets through your guard" -- so swapping a name per seat would
have told three people they each landed the same blow. A round is rendered once
per reader instead: your own events go through the untouched flavor pool, your
allies' through a terse third-person summary. CombatEvent carries the seat to
make that possible, stamped once per phase step rather than at the twenty-odd
append sites in the primitives, which emit against the cursor and know nothing
of seats.
Closing out fans along the seam the data model already cut. Threat, the
zone-kill record, the boss-defeat drop and the run teardown all resolve through
getActiveExpedition or getActiveZoneRun, and a member owns neither row -- so
they fire once, for the owner. Fanning them out would have tripled the threat a
single kill costs. HP, XP, loot and death are the character's, and every seat
gets their own. A member can be dead in a fight the party won, so death is read
per seat off HP, not off the session's status.
The reaper stays attack-only. Finishing an abandoned fight should not quietly
burn the player's spell slots and potions; the deadline latch does use the
picker, because that member is mid-fight with a party waiting on them.
startPartyCombatSession has no production caller yet -- handleFightCmd still
opens a solo session. P6 seats the party.
TestCombatCharacterization is byte-identical: solo balance did not move.
756 lines
29 KiB
Go
756 lines
29 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// Phase 13 — turn-based combat command surface.
|
||
//
|
||
// !fight — engage the Elite/Boss room the player is standing at, opening a
|
||
// persisted CombatSession.
|
||
// !attack — resolve one full round (player turn → enemy turn → round end).
|
||
// !flee — break off; the run ends with a light penalty.
|
||
//
|
||
// !zone advance stops at an Elite/Boss doorway (see zoneCmdAdvance); the
|
||
// player explicitly opts into the fight here. While a session is active,
|
||
// !zone advance / enter / go are blocked — one fight locks the run.
|
||
|
||
// encounterIDForRoom is the stable per-room key tying a CombatSession to the
|
||
// room it was opened in, so a won session can be recognised by the room
|
||
// resolver. Unique within a run; combined with run_id it's globally unique.
|
||
func encounterIDForRoom(roomIdx int) string {
|
||
return fmt.Sprintf("room%d", roomIdx)
|
||
}
|
||
|
||
// replyDM sends a player-facing combat reply unless ctx.Silent is set. The
|
||
// turn-engine combat commands route all their DMs through here so the
|
||
// background autopilot can drive a boss/elite fight on the real engine
|
||
// (long-expedition D8-f) without spamming the player a DM per round — the
|
||
// state mutations (HP, XP, threat, run-clear) still happen; only the
|
||
// narration is dropped. Non-silent callers (manual !fight) are unchanged.
|
||
func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error {
|
||
if ctx.Silent {
|
||
return nil
|
||
}
|
||
return p.SendDM(ctx.Sender, text)
|
||
}
|
||
|
||
// ── !fight ──────────────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||
userMu := p.advUserLock(ctx.Sender)
|
||
userMu.Lock()
|
||
defer userMu.Unlock()
|
||
|
||
run, err := getActiveZoneRun(ctx.Sender)
|
||
if err != nil {
|
||
return p.replyDM(ctx, "Couldn't read run state: "+err.Error())
|
||
}
|
||
if run == nil {
|
||
return p.replyDM(ctx, "No active zone run. Use `!zone enter <id>` first.")
|
||
}
|
||
roomType := run.CurrentRoomType()
|
||
if roomType != RoomElite && roomType != RoomBoss {
|
||
return p.replyDM(ctx, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.")
|
||
}
|
||
|
||
encID := encounterIDForRoom(run.CurrentRoom)
|
||
if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil {
|
||
switch existing.Status {
|
||
case CombatStatusActive:
|
||
return p.replyDM(ctx, "You're already in this fight — `!attack` or `!flee`.")
|
||
case CombatStatusWon:
|
||
return p.replyDM(ctx, "You've already cleared this room. "+continueHint(ctx.Sender))
|
||
default:
|
||
return p.replyDM(ctx, "This fight is already over. `!zone status` for where you stand.")
|
||
}
|
||
}
|
||
|
||
zone := zoneOrFallback(run.ZoneID)
|
||
isBoss := roomType == RoomBoss
|
||
var monster DnDMonsterTemplate
|
||
var ok bool
|
||
if isBoss {
|
||
monster, ok = dndBestiary[zone.Boss.BestiaryID]
|
||
} else {
|
||
monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true)
|
||
}
|
||
if !ok || monster.ID == "" {
|
||
// monster.ID == "" guards a malformed bestiary entry (e.g. one whose
|
||
// ID field was dropped): startCombatSession would otherwise persist a
|
||
// session with an empty EnemyID, and the turn engine — having no enemy
|
||
// to resolve — spins inertly until autoDriveCombat's round cap. Fail
|
||
// loudly instead of stalling.
|
||
return p.replyDM(ctx, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
|
||
}
|
||
|
||
player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
|
||
if err != nil {
|
||
return p.replyDM(ctx, "Couldn't set up the fight: "+err.Error())
|
||
}
|
||
|
||
playerHP, playerMax := dndHPSnapshot(ctx.Sender)
|
||
if playerHP <= 0 {
|
||
return p.replyDM(ctx, "You're in no shape to fight. `!rest` first.")
|
||
}
|
||
enemyHP := enemy.Stats.MaxHP
|
||
|
||
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
|
||
if err != nil {
|
||
if err == ErrCombatSessionAlreadyActive {
|
||
return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.")
|
||
}
|
||
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
|
||
}
|
||
|
||
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
|
||
// the session so they survive the turn engine's resume/commit cycle. The
|
||
// pet now rolls per-turn inside the engine, so there's no fight-start roll.
|
||
if seedCombatSessionOneShots(sess, player.Mods) {
|
||
if err := saveCombatSession(sess); err != nil {
|
||
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
|
||
}
|
||
}
|
||
|
||
var b strings.Builder
|
||
if isBoss {
|
||
if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
||
b.WriteString(line + "\n\n")
|
||
}
|
||
b.WriteString(fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)\n", monster.Name, enemyHP, enemy.Stats.AC))
|
||
} else {
|
||
if line := eliteRoomEntryLine(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
||
b.WriteString(line + "\n\n")
|
||
}
|
||
b.WriteString(fmt.Sprintf("⚔️ **Elite — %s** (HP %d, AC %d)\n", monster.Name, enemyHP, enemy.Stats.AC))
|
||
}
|
||
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", playerHP, playerMax))
|
||
if curios := activeMagicItemsLine(ctx.Sender); curios != "" {
|
||
b.WriteString(curios)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString("\n")
|
||
b.WriteString(combatTurnPrompt(sess))
|
||
return p.replyDM(ctx, b.String())
|
||
}
|
||
|
||
// ── !attack / !flee ─────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) handleAttackCmd(ctx MessageContext) error {
|
||
return p.handleCombatActionCmd(ctx, PlayerAction{Kind: ActionAttack})
|
||
}
|
||
|
||
func (p *AdventurePlugin) handleFleeCmd(ctx MessageContext) error {
|
||
return p.handleCombatActionCmd(ctx, PlayerAction{Kind: ActionFlee})
|
||
}
|
||
|
||
const noFightMsg = "You're not in a fight. `!fight` at an Elite or Boss room to start one."
|
||
|
||
func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action PlayerAction) error {
|
||
ct, release, msg := p.beginCombatTurn(ctx.Sender, noFightMsg)
|
||
if ct == nil {
|
||
return p.replyDM(ctx, msg)
|
||
}
|
||
defer release()
|
||
|
||
// Fleeing ends the run for the whole party, so it is the leader's call —
|
||
// the same reasoning that makes `!extract` leader-only.
|
||
if action.Kind == ActionFlee && ct.isParty() && ct.seat != 0 {
|
||
return p.replyDM(ctx, "Only your party leader can break off a fight.")
|
||
}
|
||
|
||
events, err := p.driveCombatRound(ct, action)
|
||
if err != nil {
|
||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||
}
|
||
return p.replyCombatRound(ctx, ct, events)
|
||
}
|
||
|
||
// replyCombatRound narrates a resolved round. A solo fight answers the one
|
||
// player who typed, exactly as it always has. A party fight fans out: every
|
||
// member gets the play-by-play with the right names on it, and their own footer
|
||
// or close-out. Terminal side effects run either way — a silent autopilot round
|
||
// still owes the party its XP and loot.
|
||
func (p *AdventurePlugin) replyCombatRound(ctx MessageContext, ct *combatTurn, events []CombatEvent) error {
|
||
if !ct.isParty() {
|
||
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, ct.sess, events, ct.players[0].Name, *ct.enemy))
|
||
}
|
||
outcomes := p.closePartyRound(ct)
|
||
if ctx.Silent {
|
||
return nil
|
||
}
|
||
p.announcePartyRound(ct, events, "", outcomes)
|
||
return nil
|
||
}
|
||
|
||
// 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(RenderTurnRound(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 b.String()
|
||
}
|
||
b.WriteString("\n\n")
|
||
b.WriteString(p.finishCombatSession(userID, sess, enemy))
|
||
return b.String()
|
||
}
|
||
|
||
// runCombatRound resolves one full round of a solo fight: the player's chosen
|
||
// action, then the enemy turn and the round-end status tick, advancing the
|
||
// session until it is back at a player_turn or has reached a terminal status.
|
||
// Returns every event the round produced. Each advance persists the session, so
|
||
// a crash mid-round resumes cleanly from the last phase.
|
||
func runCombatRound(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||
return runPartyCombatRound(sess, []*Combatant{player}, enemy, action)
|
||
}
|
||
|
||
// partyRoundStepCap bounds the drain loop below. A round is at most one step per
|
||
// seat plus the enemy turn and the round-end tick, so a 3-player party settles
|
||
// in 5; the cap only turns a hypothetical non-advancing phase into a loud error
|
||
// instead of a hung goroutine holding the fight's lock.
|
||
const partyRoundStepCap = 64
|
||
|
||
// runPartyCombatRound resolves the acting seat's action and then drains every
|
||
// phase after it that needs no human: the enemy turn, the round-end status tick,
|
||
// and any seat that is down (which forfeits its turn silently). It comes to rest
|
||
// on a standing player's turn, or on a terminal status.
|
||
//
|
||
// A latched-onto-autopilot seat is *not* drained here — resolving its turn means
|
||
// running the picker, which needs the plugin to reach the character's spells and
|
||
// inventory. driveCombatRound layers that on top.
|
||
//
|
||
// For a solo roster this is exactly the old loop: a solo player_turn always
|
||
// belongs to a standing player, since a downed one has already ended the fight.
|
||
func runPartyCombatRound(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||
events, err := advancePartyCombatSession(sess, players, enemy, action)
|
||
if err != nil {
|
||
return events, err
|
||
}
|
||
more, err := settleCombatSession(sess, players, enemy)
|
||
return append(events, more...), err
|
||
}
|
||
|
||
// settleCombatSession drains every phase the engine can resolve without a human:
|
||
// the enemy turn, the round-end status tick, and any seat that is down. It comes
|
||
// to rest on a standing player's turn, or on a terminal status.
|
||
//
|
||
// It is a no-op on a session already parked on a standing player's turn, which
|
||
// is where every solo fight sits between commands.
|
||
func settleCombatSession(sess *CombatSession, players []*Combatant, enemy *Combatant) ([]CombatEvent, error) {
|
||
var events []CombatEvent
|
||
for i := 0; i < partyRoundStepCap; i++ {
|
||
if !sess.IsActive() {
|
||
return events, nil
|
||
}
|
||
if seat, waiting := actingSeat(sess, players, enemy); waiting && sess.seatAlive(seat) {
|
||
return events, nil
|
||
}
|
||
more, merr := advancePartyCombatSession(sess, players, enemy, PlayerAction{})
|
||
if merr != nil {
|
||
return events, merr
|
||
}
|
||
events = append(events, more...)
|
||
}
|
||
return events, fmt.Errorf("combat session %s: round did not settle within %d steps",
|
||
sess.SessionID, partyRoundStepCap)
|
||
}
|
||
|
||
// 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`, `!cast <spell>`, `!consume <item>`, or `!flee`.", sess.Round)
|
||
}
|
||
|
||
// ── close-out ───────────────────────────────────────────────────────────────
|
||
|
||
// continueHint returns the verb the player uses to keep moving after a
|
||
// manual Elite/Boss fight, phrased for their current mode. On an
|
||
// expedition the autopilot drives the walk, so `!zone advance` is the
|
||
// wrong surface — point them at `!expedition run` instead. Standalone
|
||
// zone runs still advance with `!zone advance`.
|
||
func continueHint(userID id.UserID) string {
|
||
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
|
||
return "`!expedition run` to keep going."
|
||
}
|
||
return "`!zone advance` to move on."
|
||
}
|
||
|
||
// finishCombatSession runs the post-fight side effects once a CombatSession
|
||
// has reached a terminal status, and returns the player-facing outcome block.
|
||
// The graph is NOT advanced here: the terminal session row is the record that
|
||
// the room's manual combat is done, and a fresh !zone advance clears the room.
|
||
func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string {
|
||
persistDnDHPAfterCombat(userID, sess.PlayerHP)
|
||
|
||
run, _ := getZoneRun(sess.RunID)
|
||
var zone ZoneDefinition
|
||
elite := true
|
||
cadence := sess.Round
|
||
if run != nil {
|
||
zone = zoneOrFallback(run.ZoneID)
|
||
elite = run.CurrentRoomType() != RoomBoss
|
||
cadence = narrationCadence(run)
|
||
}
|
||
monster := dndBestiary[sess.EnemyID]
|
||
|
||
// nat20/nat1 mood deltas from the whole fight's event log.
|
||
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
|
||
|
||
var b strings.Builder
|
||
switch sess.Status {
|
||
case CombatStatusWon:
|
||
recordZoneKillForUser(userID, sess.EnemyID)
|
||
applyRoomCombatThreatForUser(userID, elite)
|
||
// zoneCombatXP only reads PlayerWon + NearDeath off the result.
|
||
nearDeath := sess.PlayerHPMax > 0 && sess.PlayerHP*5 < sess.PlayerHPMax
|
||
tier := 1
|
||
if run != nil {
|
||
tier = int(zone.Tier)
|
||
}
|
||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
|
||
}
|
||
}
|
||
bossOnExpedition := false
|
||
if !elite {
|
||
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
|
||
// for standalone zone runs (no active expedition).
|
||
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
|
||
_ = applyBossDefeatThreat(exp.ID)
|
||
bossOnExpedition = true
|
||
}
|
||
}
|
||
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
|
||
b.WriteString(line + "\n")
|
||
}
|
||
emoji := "✅"
|
||
if !elite {
|
||
emoji = "🏆"
|
||
}
|
||
b.WriteString(fmt.Sprintf("%s **%s** down. You finished at **%d/%d HP**.\n",
|
||
emoji, enemy.Name, sess.PlayerHP, sess.PlayerHPMax))
|
||
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite, elite); drop != "" {
|
||
b.WriteString(drop + "\n")
|
||
}
|
||
if bossOnExpedition {
|
||
// The boss is the expedition's climax. Frame the close-out as
|
||
// the win rather than a "keep walking" nudge. One more
|
||
// `!expedition run` walks out the cleared room and triggers
|
||
// finalizeExpeditionOnZoneClear (rewards + status flip).
|
||
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
|
||
} else {
|
||
b.WriteString(continueHint(userID))
|
||
}
|
||
|
||
case CombatStatusLost:
|
||
if run != nil {
|
||
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
||
}
|
||
_ = abandonZoneRun(userID)
|
||
forceExtractExpeditionForRunLoss(userID, "combat death")
|
||
markAdventureDead(userID, "zone", zone.Display)
|
||
if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" {
|
||
b.WriteString(line + "\n")
|
||
}
|
||
b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", enemy.Name))
|
||
|
||
case CombatStatusFled:
|
||
// Flee = run ends, light penalty: wounds persist (HP already saved),
|
||
// but no death timer. Chosen candidate from the migration plan's
|
||
// open question on flee outcome.
|
||
_ = abandonZoneRun(userID)
|
||
forceExtractExpeditionForRunLoss(userID, "combat flee")
|
||
b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
|
||
|
||
default:
|
||
b.WriteString("The fight is over.")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// ── !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("At level %d, your Arcane Trickster magic only reaches level-%d spells.", 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 {
|
||
// "not in a fight anymore" — this handler is only routed to when the caller
|
||
// already saw an active session, so a miss here means it closed under them.
|
||
ct, release, msg := p.beginCombatTurn(ctx.Sender, "You're not in a fight anymore.")
|
||
if ct == nil {
|
||
return p.replyDM(ctx, msg)
|
||
}
|
||
defer release()
|
||
|
||
action, settle, errMsg := p.castActionForSeat(ct, ct.seat, args)
|
||
if errMsg != "" {
|
||
return p.replyDM(ctx, errMsg)
|
||
}
|
||
events, err := p.driveCombatRound(ct, action)
|
||
settle(err == nil)
|
||
if err != nil {
|
||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||
}
|
||
return p.replyCombatRound(ctx, ct, events)
|
||
}
|
||
|
||
// castActionForSeat resolves a `!cast` for one seat into a PlayerAction, having
|
||
// already spent the slot and any material component. It is shared by the command
|
||
// handler and by the autopilot that plays an absent member's turn, so both spend
|
||
// resources through exactly one code path.
|
||
//
|
||
// The returned settle(ok) must be called once the round has been attempted: on
|
||
// failure it refunds the slot. On refusal (non-empty msg) nothing was spent.
|
||
//
|
||
// A buff spell folds its delta into *this seat's* persisted statuses and rebuilds
|
||
// the roster so the buff is live for the round's enemy turn. Before P5 that
|
||
// delta went to the session's embedded copy — seat 0 — so a party member
|
||
// buffing themselves would have buffed the leader.
|
||
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
||
noop := func(bool) {}
|
||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||
|
||
advChar, _ := loadAdvCharacter(uid)
|
||
c, err := p.ensureCharForDnDCmd(uid, advChar)
|
||
if err != nil || c == nil {
|
||
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
|
||
}
|
||
if !isSpellcaster(c) {
|
||
return PlayerAction{}, noop, fmt.Sprintf(
|
||
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
|
||
}
|
||
|
||
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
|
||
if errMsg != "" {
|
||
return PlayerAction{}, noop, errMsg
|
||
}
|
||
if spell.Effect == EffectReaction {
|
||
return PlayerAction{}, noop, fmt.Sprintf(
|
||
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name)
|
||
}
|
||
|
||
refund := func(ok bool) {
|
||
if !ok && spell.Level > 0 {
|
||
_ = refundSpellSlot(uid, slotLevel)
|
||
}
|
||
}
|
||
|
||
var eff *turnActionEffect
|
||
if spell.Effect == EffectBuffSelf || spell.Effect == EffectBuffAlly {
|
||
// Buff path — resolve the buff against a throwaway combatant, fold the
|
||
// marginal effect into that seat's persisted state, then rebuild the
|
||
// roster so the buff is live for this round's enemy turn.
|
||
player := ct.players[seat]
|
||
as, am := player.Stats, player.Mods
|
||
applySpellBuff(spell, c, &as, &am, slotLevel)
|
||
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
||
if !d.any() {
|
||
return PlayerAction{}, noop, fmt.Sprintf(
|
||
"%s has no effect the turn-based engine can apply yet.", spell.Name)
|
||
}
|
||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||
return PlayerAction{}, noop, msg
|
||
}
|
||
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
||
if rerr := p.rebuildRoster(ct); rerr != nil {
|
||
refund(false)
|
||
return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error()
|
||
}
|
||
label := spell.Name + " — active"
|
||
if d.heal > 0 {
|
||
label = fmt.Sprintf("%s — +%d HP", spell.Name, d.heal)
|
||
}
|
||
eff = &turnActionEffect{
|
||
Action: "spell_cast", Label: label,
|
||
PlayerHeal: d.heal, EnemySkip: d.enemySkip,
|
||
}
|
||
} else {
|
||
out, supported := resolveTurnSpell(c, spell, slotLevel, &ct.enemy.Stats)
|
||
if !supported {
|
||
return PlayerAction{}, noop, fmt.Sprintf(
|
||
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)
|
||
}
|
||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||
return PlayerAction{}, noop, msg
|
||
}
|
||
eff = &turnActionEffect{
|
||
Label: out.Desc,
|
||
Action: "spell_cast",
|
||
EnemyDamage: out.EnemyDamage,
|
||
PlayerHeal: out.PlayerHeal,
|
||
EnemySkip: out.EnemySkip,
|
||
}
|
||
// Concentration AOE damage spells linger: the burst lands this round
|
||
// (EnemyDamage) and the same value re-ticks every round_end after, via
|
||
// the engine's concentration aura. spiritual_weapon already covers the
|
||
// cleric's bonus-action half of the combo; this restores the action half.
|
||
if spell.Concentration &&
|
||
(spell.Effect == EffectDamageAuto || spell.Effect == EffectDamageSave) {
|
||
eff.ConcentrationDmg = out.EnemyDamage
|
||
}
|
||
}
|
||
return PlayerAction{Kind: ActionCast, Effect: eff}, refund, ""
|
||
}
|
||
|
||
// rebuildRoster re-derives the seated combatants after a mid-fight buff changed
|
||
// a seat's persisted statuses, so the buff is live for the rest of the round.
|
||
func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error {
|
||
players, enemy, err := p.partyCombatantsForSession(ct.sess)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ct.players, ct.enemy = players, enemy
|
||
return nil
|
||
}
|
||
|
||
// chargeSpellCost debits a spell's material component and leveled slot for a
|
||
// turn-based cast. It returns a non-empty player-facing message on failure; on
|
||
// success the caller owns the slot and must refundSpellSlot if the round itself
|
||
// errors. Material components (rare in a fight) are not refunded if the slot
|
||
// debit then fails — matching the auto-resolve cast path.
|
||
func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
||
if spell.MaterialCost > 0 {
|
||
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
|
||
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
|
||
}
|
||
}
|
||
if spell.Level > 0 {
|
||
ok, serr := consumeSpellSlot(userID, slotLevel)
|
||
if serr != nil {
|
||
return "Couldn't consume slot: " + serr.Error()
|
||
}
|
||
if !ok {
|
||
return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ── !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) fold into the session's persisted
|
||
// fight-scoped state and carry for the rest of the fight.
|
||
|
||
// 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 {
|
||
const notFighting = "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically."
|
||
|
||
args = strings.TrimSpace(args)
|
||
if args == "" {
|
||
// Listing the pack reads no turn state, so it neither takes the fight's
|
||
// lock nor settles a phase — a player peeking at their options between
|
||
// rounds must not advance the fight.
|
||
probe, err := activeCombatSessionFor(ctx.Sender)
|
||
if err != nil {
|
||
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
|
||
}
|
||
if probe == nil {
|
||
return p.replyDM(ctx, notFighting)
|
||
}
|
||
inv := p.loadConsumableInventory(ctx.Sender)
|
||
if len(inv) == 0 {
|
||
return p.replyDM(ctx, "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.replyDM(ctx, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
|
||
}
|
||
|
||
ct, release, msg := p.beginCombatTurn(ctx.Sender, notFighting)
|
||
if ct == nil {
|
||
return p.replyDM(ctx, msg)
|
||
}
|
||
defer release()
|
||
|
||
action, settle, errMsg := p.consumeActionForSeat(ct, ct.seat, args)
|
||
if errMsg != "" {
|
||
return p.replyDM(ctx, errMsg)
|
||
}
|
||
events, err := p.driveCombatRound(ct, action)
|
||
settle(err == nil)
|
||
if err != nil {
|
||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||
}
|
||
return p.replyCombatRound(ctx, ct, events)
|
||
}
|
||
|
||
// consumeActionForSeat resolves a `!consume` for one seat into a PlayerAction.
|
||
// Shared by the command handler and by the autopilot that plays an absent
|
||
// member's turn.
|
||
//
|
||
// The returned settle(ok) burns the item only once the round has resolved: a
|
||
// removal failure leaves the player a free use (logged, rare), which beats
|
||
// charging them for a round that errored out.
|
||
func (p *AdventurePlugin) consumeActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
||
noop := func(bool) {}
|
||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||
|
||
inv := p.loadConsumableInventory(uid)
|
||
item, ambig := matchConsumable(inv, args)
|
||
if ambig != "" {
|
||
return PlayerAction{}, noop, ambig
|
||
}
|
||
if item == nil {
|
||
return PlayerAction{}, noop, 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:
|
||
// Buff-type consumable — resolve the marginal effect against a
|
||
// throwaway combatant, fold it into that seat's persisted state, and
|
||
// rebuild the roster so the buff is live for this round's enemy turn.
|
||
player := ct.players[seat]
|
||
as, am := player.Stats, player.Mods
|
||
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
|
||
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
||
if !d.any() {
|
||
return PlayerAction{}, noop, fmt.Sprintf(
|
||
"**%s** has no effect the turn-based engine can apply yet.", def.Name)
|
||
}
|
||
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
||
if rerr := p.rebuildRoster(ct); rerr != nil {
|
||
return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error()
|
||
}
|
||
eff.Label = def.Name + " — active"
|
||
eff.PlayerHeal = d.heal
|
||
eff.EnemySkip = d.enemySkip
|
||
}
|
||
|
||
burn := func(ok bool) {
|
||
if !ok {
|
||
return
|
||
}
|
||
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
|
||
slog.Error("combat: consume remove inventory item failed", "user", uid, "item", def.Name, "err", rerr)
|
||
}
|
||
}
|
||
return PlayerAction{Kind: ActionConsume, Effect: eff}, burn, ""
|
||
}
|