mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Drops D&D-handbook syntax from the player-facing caster UX in favor of
verbs and feelings (per feedback_accessibility_over_dnd_crunch).
R12 — Drop "Spell DC: N / Spell Atk: +N" line from the spellbook view.
The numbers are pure handbook noise; the engine still computes them.
R13 — renderSlotLine swaps "L1 3/4 · L2 1/3" for "Level 1 ●●●○ · Level 2
●○○○". Filled bullets = energy left, hollow = spent.
R14 — Caster passive Description strings rewritten outcome-first: no
"+5%", no "scaled by your Charisma". Verbs and texture instead. All ten
class passives reworded; mechanics in applyClassPassives unchanged.
R18 — Class menu drops the "(d8, INT/WIS)" suffix → "**Mage** — INT &
WIS". The HP-die number was leaking implementation. !setup class confirm
line gets the same treatment.
P5 — Spellbook headers: "Cantrip" → "Cantrips", "L1" → "Level 1".
P6 — Cast queue line: "_(upcast to L2)_" → "_(empowered)_". Queued-line
in the spellbook view follows suit — drops "(L2 slot)" for "(empowered)"
when the slot is above the spell's base level.
P8 — Comment-convention marker added to dnd_passives.go and
dnd_subclass_combat.go file headers: `// internal note (not user-facing)`
flags Phase 2/3 tuning history so future codegen doesn't lift it into
Description / Flavor strings.
P9 — Spellbook line drops the duplicate "(can learn N more)" trailer —
the "%d / %d" already conveys remaining capacity.
Bonus cleanup. The two Arcane-Trickster slot-ceiling errors ("Arcane
Trickster L5 only has up to L1 slots.") and the "No L1 slot available"
out-of-energy messages get the same jargon scrub: "At level 5, your
Arcane Trickster magic only reaches level-1 spells." / "You're out of
level-1 energy."
Tests. Full plugin suite green; no test pinned the old strings.
601 lines
22 KiB
Go
601 lines
22 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)
|
||
}
|
||
|
||
// ── !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.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
||
}
|
||
if run == nil {
|
||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>` first.")
|
||
}
|
||
roomType := run.CurrentRoomType()
|
||
if roomType != RoomElite && roomType != RoomBoss {
|
||
return p.SendDM(ctx.Sender, "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.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.")
|
||
case CombatStatusWon:
|
||
return p.SendDM(ctx.Sender, "You've already cleared this room. `!zone advance` to move on.")
|
||
default:
|
||
return p.SendDM(ctx.Sender, "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 {
|
||
return p.SendDM(ctx.Sender, "_(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.SendDM(ctx.Sender, "Couldn't set up the fight: "+err.Error())
|
||
}
|
||
|
||
playerHP, playerMax := dndHPSnapshot(ctx.Sender)
|
||
if playerHP <= 0 {
|
||
return p.SendDM(ctx.Sender, "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.SendDM(ctx.Sender, "You're already in a fight. Finish it with `!attack` / `!flee`.")
|
||
}
|
||
return p.SendDM(ctx.Sender, "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, and
|
||
// make the one-and-only per-fight pet-attack roll.
|
||
seeded := seedCombatSessionOneShots(sess, player.Mods)
|
||
pet := rollCombatSessionPetProc(sess, player.Mods)
|
||
if seeded || pet {
|
||
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\n", playerHP, playerMax))
|
||
b.WriteString(combatTurnPrompt(sess))
|
||
return p.SendDM(ctx.Sender, 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})
|
||
}
|
||
|
||
func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action PlayerAction) 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, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
|
||
}
|
||
|
||
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, action)
|
||
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(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: 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 advanceCombatSession call 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) {
|
||
events, err := advanceCombatSession(sess, player, enemy, action)
|
||
if err != nil {
|
||
return events, err
|
||
}
|
||
for sess.IsActive() && sess.Phase != CombatPhasePlayerTurn {
|
||
more, merr := advanceCombatSession(sess, player, enemy, PlayerAction{})
|
||
if merr != nil {
|
||
return events, merr
|
||
}
|
||
events = append(events, more...)
|
||
}
|
||
return events, nil
|
||
}
|
||
|
||
// 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 ───────────────────────────────────────────────────────────────
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
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); drop != "" {
|
||
b.WriteString(drop + "\n")
|
||
}
|
||
b.WriteString("`!zone advance` to move on.")
|
||
|
||
case CombatStatusLost:
|
||
if run != nil {
|
||
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
||
}
|
||
_ = abandonZoneRun(userID)
|
||
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)
|
||
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 {
|
||
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())
|
||
}
|
||
|
||
var eff *turnActionEffect
|
||
if spell.Effect == EffectBuffSelf || spell.Effect == EffectBuffAlly {
|
||
// Buff path — resolve the buff against a throwaway combatant, fold the
|
||
// marginal effect into the session's persisted state, then rebuild the
|
||
// pair so the buff is live for this round's enemy turn.
|
||
as, am := player.Stats, player.Mods
|
||
applySpellBuff(spell, c, &as, &am, slotLevel)
|
||
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
||
if !d.any() {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"%s has no effect the turn-based engine can apply yet.", spell.Name))
|
||
}
|
||
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
|
||
return p.SendDM(ctx.Sender, msg)
|
||
}
|
||
sess.Statuses.applyBuffDelta(d)
|
||
player, enemy, err = p.combatantsForSession(sess)
|
||
if err != nil {
|
||
if spell.Level > 0 {
|
||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||
}
|
||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.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, &enemy.Stats)
|
||
if !supported {
|
||
return p.SendDM(ctx.Sender, 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(ctx.Sender, spell, slotLevel); msg != "" {
|
||
return p.SendDM(ctx.Sender, msg)
|
||
}
|
||
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))
|
||
}
|
||
|
||
// 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 {
|
||
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
|
||
player, enemy, err := p.combatantsForSession(sess)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
||
}
|
||
|
||
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 the session's persisted state, and
|
||
// rebuild the pair so the buff is live for this round's enemy turn.
|
||
as, am := player.Stats, player.Mods
|
||
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
|
||
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
||
if !d.any() {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"**%s** has no effect the turn-based engine can apply yet.", def.Name))
|
||
}
|
||
sess.Statuses.applyBuffDelta(d)
|
||
player, enemy, err = p.combatantsForSession(sess)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
||
}
|
||
eff.Label = def.Name + " — active"
|
||
eff.PlayerHeal = d.heal
|
||
eff.EnemySkip = d.enemySkip
|
||
}
|
||
|
||
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))
|
||
}
|