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 ` 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 `, `!consume `, 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 [--upcast N]` — casts as your turn this round. `!spells` lists options." } spell, ok := parseSpell(strings.Join(spellTokens, " ")) if !ok { return SpellDefinition{}, 0, fmt.Sprintf("Unknown spell %q. Run `!spells` to list what you know.", strings.Join(spellTokens, " ")) } // Class gate — Arcane Trickster Rogues use the Mage list. effectiveClass := c.Class if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster { effectiveClass = ClassMage } classOK := false for _, cl := range spell.Classes { if cl == effectiveClass { classOK = true break } } if !classOK { return SpellDefinition{}, 0, fmt.Sprintf("%s is not on the %s spell list.", spell.Name, titleClass(c.Class)) } if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster { if mx := highestAvailableSlotForChar(c); spell.Level > mx { return SpellDefinition{}, 0, fmt.Sprintf("Arcane Trickster L%d only has up to L%d slots.", c.Level, mx) } } known, prepared, err := playerKnowsSpell(userID, spell.ID) if err != nil { return SpellDefinition{}, 0, "Couldn't check your spell list." } if !known { return SpellDefinition{}, 0, fmt.Sprintf("You don't know %s yet.", spell.Name) } if !prepared { return SpellDefinition{}, 0, fmt.Sprintf("%s isn't prepared today. Run `!prepare %s` first.", spell.Name, spell.ID) } slotLevel := spell.Level if upcast > slotLevel && spell.Level > 0 { slotLevel = upcast } if slotLevel < 0 || slotLevel > 5 { return SpellDefinition{}, 0, "Slot level out of range (1–5)." } return spell, slotLevel, "" } func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() sess, err := getActiveCombatSession(ctx.Sender) if err != nil { return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error()) } if sess == nil { // Race: the fight closed between the route check and the lock. return p.SendDM(ctx.Sender, "You're not in a fight anymore.") } advChar, _ := loadAdvCharacter(ctx.Sender) c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar) if err != nil || c == nil { return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.") } if !isSpellcaster(c) { return p.SendDM(ctx.Sender, fmt.Sprintf( "%s isn't a caster class. `!attack` or `!consume ` 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("No L%d slot available. %s", slotLevel, renderSlotsBrief(userID)) } } return "" } // ── !consume (in-combat) ──────────────────────────────────────────────────── // // !consume 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 ` 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 `. 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)) }