mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
N3 widened the combat *roster* from 1 to N but never widened the action model,
the scaling model, or the test net to match. Building the hireable companion
walked into all three. Only one of the four defects found was the companion's;
the rest have been live in prod for every human party since N3.
The party golden did not exist (§5)
Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had
nothing. That is why a healer class that cannot heal shipped without a test
going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl.
weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the
N-body path can never quietly stop being a superset of the balance corpus.
Regenerate only on purpose: -update-party.
No action could target another seat (§1)
Every heal in the engine was self-scoped. A party cleric could not put one hit
point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast <spell>
@user` and `--target @user` -- the latter has been advertised in !help and
silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto
picker uses it too (simPickAllyHeal), so away-players and engine-driven healers
behave like competent ones. It will not raise the dead.
Corpses kept buffing the boss (§2b)
enemyActionsThisRound counted len(st.actors), dead included -- so a party that
lost a member kept paying for them, and the survivor faced a boss still swinging
at two-player cadence, alone. A death spiral with the arrow pointing the wrong
way. Now counts livingActors(). Party golden moved deliberately for this.
An engine-driven seat was a bool any command could clear (§3)
autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so
a companion's own auto-played move arrived at beginCombatTurn looking like a
player returning to the keyboard and cleared the latch that was moving him. He
then stood in the fight doing nothing while the boss he had inflated killed
everyone. ActorStatuses.EngineDriven is now a persisted seat property that no
command clears, and the driver calls driveEngineSeat instead of impersonating.
"The party" could be empty (§4)
A solo expedition has no expedition_party rows, so asking the roster who was in
the party answered "nobody" -- and every caller fell back to something plausible.
That is how the companion got hired at level 1 for exactly the player the feature
exists for. expeditionParty()/partyHumans() always include the owner.
The companion himself (!expedition hire [class] / !expedition dismiss)
Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an
NPC seat and must never become a player: no player_meta, no dnd_character, no
inventory, no DM room -- mint him a player_meta row and
ensureDnDCharacterForCombat will auto-build the news bot a real character, and
he starts appearing in the graveyard and filing death notices about himself.
Mail and seats are different sets: he fights, he does not get written to.
Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no
companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for
trailing players and +2.0pp for leaders. Help, never a carry.
The solo golden is byte-identical throughout: solo combat provably did not move,
and the balance corpus is intact.
Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB
and he has none by design), so a hired Cleric is still just a bad fighter.
Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
898 lines
36 KiB
Go
898 lines
36 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 {
|
||
// An empty body means the caller already answered the player another way —
|
||
// a party fan-out, say. Sending it would post a blank DM.
|
||
if ctx.Silent || text == "" {
|
||
return nil
|
||
}
|
||
return p.SendDM(ctx.Sender, text)
|
||
}
|
||
|
||
// ── !fight ──────────────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||
// Resolve the roster before locking — a member's `!fight` opens the leader's
|
||
// fight, under the leader's lock, and seat 0 names that leader. fightRoster
|
||
// deliberately does not touch getActiveZoneRun: that lookup carries the §4.3
|
||
// idle reap, and it must only ever fire under the lock, on its owner's behalf.
|
||
roster := fightRoster(ctx.Sender)
|
||
release := p.lockCombatFight(roster[0], ctx.Sender)
|
||
defer release()
|
||
|
||
run, _, err := activeZoneRunFor(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.)_")
|
||
}
|
||
|
||
// Seat the whole party, leader first. A solo player is a one-seat roster and
|
||
// takes the path they always took: one build, one INSERT, no participant rows.
|
||
seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood)
|
||
if refusal != "" {
|
||
return p.replyDM(ctx, refusal)
|
||
}
|
||
// The persisted session scales enemy HP for a party (solo scales by 1.0, so
|
||
// this is a no-op there); mirror it here so the entry banner and the opening
|
||
// round resolve against the same ceiling startPartyCombatSession persisted and
|
||
// the rebuilt rounds use.
|
||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
||
|
||
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
|
||
// per seat onto the session and its participant rows, so they survive the
|
||
// turn engine's resume/commit cycle. The pet rolls per-turn inside the
|
||
// engine, so there's no fight-start roll.
|
||
sess, err := p.startPartyCombatSession(run.RunID, encID, monster.ID, enemy, seats)
|
||
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())
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
if sess.IsParty() {
|
||
players := seatCombatants(seats)
|
||
// Align the in-memory template with the scaled HP persisted above, so the
|
||
// opening-round settle resolves the enemy against the same MaxHP the
|
||
// rebuilt rounds do (regen clamp, bloodied-ability threshold). The persist
|
||
// already happened off the unscaled value, so this does not double-scale.
|
||
enemy.Stats.MaxHP = enemyHP
|
||
// The enemy may have won initiative. Resolve everything the round owes
|
||
// before anyone is asked to act, so the opening block narrates the hit
|
||
// rather than showing its damage with no explanation.
|
||
opening, serr := settleCombatSession(sess, players, enemy)
|
||
if serr != nil {
|
||
return p.replyDM(ctx, "Couldn't resolve the round: "+serr.Error())
|
||
}
|
||
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: 0, uid: roster[0]}
|
||
outcomes := p.closePartyRound(ct)
|
||
if !ctx.Silent {
|
||
// The opening block is per-reader for the same reason a round's
|
||
// narration is: "You: 40/40 HP" has to be the reader's own pool.
|
||
p.announcePartyFightStart(sess, players, enemy, b.String(), opening, outcomes)
|
||
}
|
||
// A member the roster left behind is owed an answer of their own: nothing
|
||
// above was addressed to them, because they are not seated.
|
||
if senderSkip != "" {
|
||
return p.replyDM(ctx, senderSkip)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// One seat. Usually that is a solo player fighting their own fight, and this
|
||
// is the block they have always been sent. It can also be a leader whose only
|
||
// companion was left behind — in which case the block is the leader's and the
|
||
// sender is owed the reason they are not in it.
|
||
owner := id.UserID(sess.UserID)
|
||
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.PlayerHP, sess.PlayerHPMax))
|
||
if curios := activeMagicItemsLine(owner); curios != "" {
|
||
b.WriteString(curios)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString("\n")
|
||
b.WriteString(combatTurnPrompt(sess))
|
||
if senderSkip != "" {
|
||
if !ctx.Silent {
|
||
if err := p.SendDM(owner, b.String()); err != nil {
|
||
slog.Error("combat: fight-start DM to leader failed", "user", owner, "err", err)
|
||
}
|
||
}
|
||
return p.replyDM(ctx, senderSkip)
|
||
}
|
||
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 {
|
||
exp, isLeader, err := activeExpeditionFor(userID)
|
||
switch {
|
||
case err != nil || exp == nil:
|
||
return "`!zone advance` to move on."
|
||
case !isLeader:
|
||
return "Your leader marches the party on."
|
||
}
|
||
return "`!expedition run` to keep going."
|
||
}
|
||
|
||
// 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)
|
||
// Achievements and post-combat subclass state are owed on every terminal
|
||
// status, not just a win — a Berserker who rages and loses still comes out
|
||
// of it exhausted. The auto-resolve close-outs have always done this.
|
||
p.postCombatBookkeepingForSeat(sess, 0)
|
||
|
||
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:
|
||
tier := 1
|
||
if run != nil {
|
||
tier = int(zone.Tier)
|
||
}
|
||
bossOnExpedition := p.applyOwnerWinEffects(userID, sess.EnemyID, elite)
|
||
p.grantSeatWinXP(userID, sess.PlayerHP, sess.PlayerHPMax, monster, tier)
|
||
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 !elite {
|
||
writeBossEpilogue(&b, zone.ID)
|
||
}
|
||
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:
|
||
endRunOnLoss(userID, sess.RunID, true)
|
||
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.
|
||
endRunOnLoss(userID, sess.RunID, false)
|
||
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.
|
||
// splitCastTarget peels an optional ally target off the end of a `!cast` argument
|
||
// — `cure wounds @alex`, or just `cure wounds alex` — and resolves it to a seat.
|
||
//
|
||
// It resolves against the people *in this fight* rather than the room, which is
|
||
// both cheaper (no ResolveUser round-trip, no RoomID to thread down here) and
|
||
// more correct: the only legal target of a combat heal is somebody sitting in the
|
||
// combat. Anything it does not recognise is left on the string for the spell
|
||
// parser, so `!cast cure wounds` and `!cast fireball 3` behave exactly as before.
|
||
//
|
||
// Both spellings work: the `--target @alex` flag that `!help` has advertised all
|
||
// along (and that parseCombatCast has been silently swallowing since SP2 —
|
||
// "reserved for SP3, accept and ignore"), and a plain trailing `@alex`.
|
||
//
|
||
// Returns (remainingArgs, seat, errMsg). seat is -1 when no target was named.
|
||
func splitCastTarget(ct *combatTurn, caster int, args string) (string, int, string) {
|
||
args = strings.TrimSpace(args)
|
||
if args == "" || !ct.isParty() {
|
||
return args, -1, ""
|
||
}
|
||
fields := strings.Fields(args)
|
||
|
||
// `--target <who>` anywhere in the string.
|
||
explicit, name := false, ""
|
||
for i := 0; i < len(fields); i++ {
|
||
if !strings.EqualFold(fields[i], "--target") {
|
||
continue
|
||
}
|
||
if i+1 >= len(fields) {
|
||
return args, -1, "`--target` needs a name: `!cast cure wounds --target @alex`."
|
||
}
|
||
explicit, name = true, strings.TrimPrefix(fields[i+1], "@")
|
||
fields = append(fields[:i], fields[i+2:]...)
|
||
break
|
||
}
|
||
|
||
if name == "" {
|
||
last := fields[len(fields)-1]
|
||
// A bare number is a slot level (`!cast fireball 3`), never a target.
|
||
if _, err := strconv.Atoi(last); err == nil {
|
||
return args, -1, ""
|
||
}
|
||
explicit = strings.HasPrefix(last, "@")
|
||
name = strings.TrimPrefix(last, "@")
|
||
if name == "" {
|
||
return args, -1, ""
|
||
}
|
||
fields = fields[:len(fields)-1]
|
||
}
|
||
// From here `fields` is the spell tokens with the target removed.
|
||
rest := strings.Join(fields, " ")
|
||
|
||
for i, c := range ct.players {
|
||
uid := ct.sess.seatUserID(i)
|
||
if !strings.EqualFold(c.Name, name) &&
|
||
!strings.EqualFold(uid, name) &&
|
||
!strings.EqualFold(id.UserID(uid).Localpart(), name) {
|
||
continue
|
||
}
|
||
if i == caster {
|
||
// Targeting yourself is just casting it on yourself, which is what the
|
||
// engine does by default. Drop the target and carry on.
|
||
return rest, -1, ""
|
||
}
|
||
return rest, i, ""
|
||
}
|
||
// An explicit @mention that matches nobody in the fight is a mistake worth
|
||
// naming — silently casting it on yourself would waste the slot.
|
||
if explicit {
|
||
return args, -1, fmt.Sprintf("**%s** isn't in this fight. Cast it on someone who is.", name)
|
||
}
|
||
return args, -1, ""
|
||
}
|
||
|
||
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
||
noop := func(bool) {}
|
||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||
|
||
// §1 — a heal may name somebody else in the fight: `!cast cure wounds @alex`.
|
||
// Split the target off before the spell is parsed, so the spell parser sees
|
||
// the same string it always has.
|
||
args, targetSeat, targetErr := splitCastTarget(ct, seat, args)
|
||
if targetErr != "" {
|
||
return PlayerAction{}, noop, targetErr
|
||
}
|
||
|
||
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
|
||
}
|
||
// Park the Necromancy kill-heal stash on the casting seat. The
|
||
// auto-resolve path keeps it on the fight-start CombatModifiers, which
|
||
// a turn-based fight has nowhere to hold — it rebuilds its combatants
|
||
// every round. Only a damaging cast stashes (a miss leaves the slot at
|
||
// 0), and each one overwrites the last, so the stash always describes
|
||
// the seat's most recent landed spell — which is the only one that can
|
||
// have been lethal by the time the close-out reads it.
|
||
if out.GrimHarvestSlot > 0 {
|
||
as := ct.sess.actorStatusesPtr(seat)
|
||
as.GrimHarvestSlot = out.GrimHarvestSlot
|
||
as.GrimHarvestNecrotic = out.GrimHarvestNecrotic
|
||
}
|
||
eff = &turnActionEffect{
|
||
Label: out.Desc,
|
||
Action: "spell_cast",
|
||
EnemyDamage: out.EnemyDamage,
|
||
PlayerHeal: out.PlayerHeal,
|
||
EnemySkip: out.EnemySkip,
|
||
}
|
||
// §1 — redirect the heal onto the named ally. The roll is the same; only
|
||
// the body it lands on changes. This is the line that makes a cleric a
|
||
// cleric: until it existed, every heal in the engine was a self-heal, and
|
||
// the class whose whole job is keeping other people upright could not put
|
||
// a single hit point on a friend.
|
||
if targetSeat >= 0 && targetSeat != seat {
|
||
if out.PlayerHeal <= 0 {
|
||
return PlayerAction{}, refund, fmt.Sprintf(
|
||
"%s isn't something you can cast on someone else. Drop the target to cast it yourself.", spell.Name)
|
||
}
|
||
eff.AllyHeal, eff.AllySeat = out.PlayerHeal, targetSeat
|
||
eff.PlayerHeal = 0
|
||
eff.Label = fmt.Sprintf("%s → %s (+%d HP)", spell.Name, ct.players[targetSeat].Name, out.PlayerHeal)
|
||
}
|
||
// 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, ""
|
||
}
|