From 886eb5a75bdb58f9f6758a22681098e08705ac29 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 06:16:45 -0700 Subject: [PATCH] Combat: wire turn-based elite/boss fights to the command surface Routes Elite/Boss rooms off the auto-resolve SimulateCombat path and onto the persisted turn-based engine. !zone advance now stops at an Elite/Boss doorway; the player engages with !fight, then resolves one full round per !attack / !flee. A won CombatSession is the record that the room's combat is done, so a fresh !zone advance clears the room and advances the graph. - buildZoneCombatants: shared player/enemy Combatant builder extracted from runZoneCombat; combatantsForSession rebuilds the pair from a session row. - runCombatRound loops the phase state machine through a whole round; finishCombatSession runs HP/XP/loot/kill/threat/mood close-out. - getCombatSessionForEncounter lets the room resolver tell "already won" apart from "not yet fought". - !zone advance/enter/go blocked while a session is active. - resolveBossRoom deleted (dead after the reroute). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/adventure.go | 9 + internal/plugin/combat_cmd.go | 335 ++++++++++++++++++++++++ internal/plugin/combat_cmd_test.go | 169 ++++++++++++ internal/plugin/combat_session.go | 17 ++ internal/plugin/combat_session_build.go | 125 +++++++++ internal/plugin/dnd_zone_cmd.go | 103 +++----- internal/plugin/dnd_zone_cmd_graph.go | 3 + internal/plugin/dnd_zone_combat.go | 80 ++---- 8 files changed, 711 insertions(+), 130 deletions(-) create mode 100644 internal/plugin/combat_cmd.go create mode 100644 internal/plugin/combat_cmd_test.go create mode 100644 internal/plugin/combat_session_build.go diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 1a020d7..78ee01e 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -321,6 +321,15 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "zone") { return p.handleDnDZoneCmd(ctx, p.GetArgs(ctx.Body, "zone")) } + if p.IsCommand(ctx.Body, "fight") { + return p.handleFightCmd(ctx) + } + if p.IsCommand(ctx.Body, "attack") { + return p.handleAttackCmd(ctx) + } + if p.IsCommand(ctx.Body, "flee") { + return p.handleFleeCmd(ctx) + } if p.IsCommand(ctx.Body, "expedition") { return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition")) } diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go new file mode 100644 index 0000000..27b502b --- /dev/null +++ b/internal/plugin/combat_cmd.go @@ -0,0 +1,335 @@ +package plugin + +import ( + "fmt" + "log/slog" + "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.)_") + } + + _, 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()) + } + + 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()) + } + + var b strings.Builder + b.WriteString(renderCombatRound(events, player.Name, enemy.Name)) + + if sess.IsActive() { + b.WriteString("\n\n") + b.WriteString(fmt.Sprintf("You: **%d/%d** · %s: **%d/%d**\n", + sess.PlayerHP, sess.PlayerHPMax, enemy.Name, sess.EnemyHP, sess.EnemyHPMax)) + b.WriteString(combatTurnPrompt(sess)) + return p.SendDM(ctx.Sender, b.String()) + } + + b.WriteString("\n\n") + b.WriteString(p.finishCombatSession(ctx.Sender, sess, enemy)) + return p.SendDM(ctx.Sender, 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` 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() +} + +// ── rendering ─────────────────────────────────────────────────────────────── + +// renderCombatRound turns one round's events into a compact play-by-play +// block. Full TwinBee per-round narration is a later sub-phase; this is the +// functional MVP renderer. +func renderCombatRound(events []CombatEvent, playerName, enemyName string) string { + var lines []string + for _, ev := range events { + if line := renderCombatRoundEvent(ev, playerName, enemyName); line != "" { + lines = append(lines, line) + } + } + if len(lines) == 0 { + return "_The round passes without a clean blow landed._" + } + return strings.Join(lines, "\n") +} + +func renderCombatRoundEvent(ev CombatEvent, playerName, enemyName string) string { + switch ev.Actor { + case "player": + switch ev.Action { + case "hit": + return fmt.Sprintf("⚔️ %s lands a hit for **%d**.", playerName, ev.Damage) + case "crit": + return fmt.Sprintf("💥 %s **crits** for **%d**!", playerName, ev.Damage) + case "block": + return fmt.Sprintf("⚔️ %s pushes through the guard for **%d**.", playerName, ev.Damage) + case "miss": + return fmt.Sprintf("… %s swings and misses.", playerName) + case "stunned": + return fmt.Sprintf("😵 %s is stunned and loses the turn.", playerName) + case "rage": + return fmt.Sprintf("🔥 %s's blood is up — rage takes hold.", playerName) + case "death_save": + return fmt.Sprintf("✨ %s should be down — and isn't.", playerName) + case "flee": + return fmt.Sprintf("🏃 %s breaks off and runs.", playerName) + } + case "enemy": + switch ev.Action { + case "hit": + return fmt.Sprintf("🩸 %s hits %s for **%d**.", enemyName, playerName, ev.Damage) + case "crit": + return fmt.Sprintf("🩸 %s **crits** %s for **%d**!", enemyName, playerName, ev.Damage) + case "miss": + return fmt.Sprintf("… %s attacks, but misses.", enemyName) + case "poison_tick": + return fmt.Sprintf("☠️ Poison saps **%d** from %s.", ev.Damage, playerName) + case "poison": + return fmt.Sprintf("☠️ %s's strike leaves a poison festering.", enemyName) + case "enrage": + return fmt.Sprintf("😡 %s flies into a rage.", enemyName) + case "armor_break": + return fmt.Sprintf("🛡️ %s cracks %s's armor.", enemyName, playerName) + case "stun": + return fmt.Sprintf("💫 %s lands a stunning blow.", enemyName) + case "lifesteal": + return fmt.Sprintf("🧛 %s drains life from the wound.", enemyName) + case "cleave": + return fmt.Sprintf("🪓 %s cleaves into %s for **%d**.", enemyName, playerName, ev.Damage) + } + } + if ev.Damage > 0 { + return fmt.Sprintf("• %s — %s (%d)", ev.Actor, ev.Action, ev.Damage) + } + return "" +} diff --git a/internal/plugin/combat_cmd_test.go b/internal/plugin/combat_cmd_test.go new file mode 100644 index 0000000..b05a9f6 --- /dev/null +++ b/internal/plugin/combat_cmd_test.go @@ -0,0 +1,169 @@ +package plugin + +import ( + "strings" + "testing" + + "maunium.net/go/mautrix/id" +) + +func TestEncounterIDForRoom(t *testing.T) { + if got := encounterIDForRoom(0); got != "room0" { + t.Errorf("encounterIDForRoom(0) = %q, want room0", got) + } + if encounterIDForRoom(3) == encounterIDForRoom(4) { + t.Error("distinct rooms must produce distinct encounter ids") + } +} + +// ── getCombatSessionForEncounter ─────────────────────────────────────────── + +func TestGetCombatSessionForEncounter(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@combat-enc:example.org") + defer cleanupCombatSessions(uid) + + if got, err := getCombatSessionForEncounter("run-enc", "room3"); err != nil || got != nil { + t.Fatalf("unknown encounter: got %v / %v, want nil/nil", got, err) + } + + s, err := startCombatSession(uid, "run-enc", "room3", "goblin", 40, 40, 12, 12) + if err != nil { + t.Fatal(err) + } + got, err := getCombatSessionForEncounter("run-enc", "room3") + if err != nil || got == nil { + t.Fatalf("getCombatSessionForEncounter: %v / %v", got, err) + } + if got.SessionID != s.SessionID { + t.Errorf("id mismatch: %q vs %q", got.SessionID, s.SessionID) + } + if got, _ := getCombatSessionForEncounter("run-enc", "room9"); got != nil { + t.Errorf("wrong room should not match: %+v", got) + } + + // A terminal session is still returned — the room resolver relies on this + // to tell "already won" apart from "not yet fought". + s.Status = CombatStatusWon + s.Phase = CombatPhaseOver + if err := saveCombatSession(s); err != nil { + t.Fatal(err) + } + got, err = getCombatSessionForEncounter("run-enc", "room3") + if err != nil || got == nil || got.Status != CombatStatusWon { + t.Errorf("terminal session lookup: %+v / %v", got, err) + } +} + +// ── runCombatRound ───────────────────────────────────────────────────────── + +func TestRunCombatRound_FullRoundReturnsToPlayerTurn(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@combat-round:example.org") + defer cleanupCombatSessions(uid) + + // HP pools large enough that one round can't end the fight. + sess, err := startCombatSession(uid, "r", "room0", "goblin", 100000, 100000, 100000, 100000) + if err != nil { + t.Fatal(err) + } + player, enemy := basePlayer(), baseEnemy() + + events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}) + if err != nil { + t.Fatalf("runCombatRound: %v", err) + } + if !sess.IsActive() { + t.Fatalf("status = %q, want active after one non-lethal round", sess.Status) + } + if sess.Phase != CombatPhasePlayerTurn { + t.Errorf("phase = %q, want player_turn (round resolved fully)", sess.Phase) + } + if sess.Round != 2 { + t.Errorf("round = %d, want 2 after one full round", sess.Round) + } + if len(events) == 0 { + t.Error("expected the round to produce at least one event") + } + // The persisted row should match the in-memory session. + reloaded, _ := getCombatSession(sess.SessionID) + if reloaded == nil || reloaded.Round != 2 || reloaded.Phase != CombatPhasePlayerTurn { + t.Errorf("round not persisted: %+v", reloaded) + } +} + +func TestRunCombatRound_FleeEndsFight(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@combat-flee:example.org") + defer cleanupCombatSessions(uid) + + sess, err := startCombatSession(uid, "r", "room0", "goblin", 50, 50, 50, 50) + if err != nil { + t.Fatal(err) + } + player, enemy := basePlayer(), baseEnemy() + + events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionFlee}) + if err != nil { + t.Fatal(err) + } + if sess.Status != CombatStatusFled || sess.Phase != CombatPhaseOver { + t.Errorf("after flee: status=%q phase=%q", sess.Status, sess.Phase) + } + if len(events) != 1 || events[0].Action != "flee" { + t.Errorf("expected one flee event, got %+v", events) + } +} + +func TestRunCombatRound_PlayerWinsTerminates(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@combat-win:example.org") + defer cleanupCombatSessions(uid) + + // Enemy on 1 HP, player with a huge pool: a connecting hit ends it, and + // the player can't be dropped first. A few rounds covers attack misses. + sess, err := startCombatSession(uid, "r", "room0", "goblin", 100000, 100000, 1, 1) + if err != nil { + t.Fatal(err) + } + player, enemy := basePlayer(), baseEnemy() + + for i := 0; i < 15 && sess.IsActive(); i++ { + if _, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); err != nil { + t.Fatal(err) + } + } + if sess.Status != CombatStatusWon { + t.Fatalf("status = %q, want won", sess.Status) + } + if sess.Phase != CombatPhaseOver || sess.EnemyHP > 0 { + t.Errorf("terminal state wrong: phase=%q enemyHP=%d", sess.Phase, sess.EnemyHP) + } +} + +// ── renderCombatRound ────────────────────────────────────────────────────── + +func TestRenderCombatRound(t *testing.T) { + if got := renderCombatRound(nil, "Hero", "Goblin"); !strings.Contains(got, "without a clean blow") { + t.Errorf("empty events render = %q", got) + } + events := []CombatEvent{ + {Actor: "player", Action: "hit", Damage: 9}, + {Actor: "enemy", Action: "miss"}, + {Actor: "enemy", Action: "poison_tick", Damage: 3}, + } + got := renderCombatRound(events, "Hero", "Goblin") + if !strings.Contains(got, "Hero") || !strings.Contains(got, "9") { + t.Errorf("player hit not rendered: %q", got) + } + if !strings.Contains(got, "misses") { + t.Errorf("enemy miss not rendered: %q", got) + } + if !strings.Contains(got, "Poison") { + t.Errorf("poison tick not rendered: %q", got) + } + // Unknown actor/action with no damage renders nothing (not a blank line). + if line := renderCombatRoundEvent(CombatEvent{Actor: "system", Action: "timeout"}, "Hero", "Goblin"); line != "" { + t.Errorf("unknown zero-damage event should render empty, got %q", line) + } +} diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 4654913..6bd2119 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -179,6 +179,23 @@ func getCombatSession(sessionID string) (*CombatSession, error) { return s, err } +// getCombatSessionForEncounter returns the most recent session for a +// (run, encounter) pair regardless of status, or (nil, nil) if none exists. +// The room-resolution path uses it to tell "elite not yet fought" (nil) apart +// from "elite already won" (a terminal session) without an active-only filter. +func getCombatSessionForEncounter(runID, encounterID string) (*CombatSession, error) { + row := db.Get().QueryRow(`SELECT `+combatSessionCols+` + FROM combat_session + WHERE run_id = ? AND encounter_id = ? + ORDER BY started_at DESC + LIMIT 1`, runID, encounterID) + s, err := scanCombatSession(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return s, err +} + func scanCombatSession(row scanner) (*CombatSession, error) { var ( s CombatSession diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go new file mode 100644 index 0000000..cfd87bf --- /dev/null +++ b/internal/plugin/combat_session_build.go @@ -0,0 +1,125 @@ +package plugin + +import ( + "fmt" + "log/slog" + + "maunium.net/go/mautrix/id" +) + +// Phase 13 — Combatant reconstruction for turn-based fights. +// +// The auto-resolve path (runZoneCombat) builds a player/enemy Combatant pair, +// runs SimulateCombat, and discards them. The turn-based path needs the same +// pair, but rebuilt fresh on every player command from persisted session +// state — so the build is extracted here as buildZoneCombatants, and +// combatantsForSession wraps it for the resume case. +// +// HP is deliberately NOT carried by the Combatant pair: the turn engine reads +// live HP from the CombatSession row (sess.PlayerHP / sess.EnemyHP), so the +// rebuilt Combatants only need correct Stats/Mods/Ability. Re-deriving from +// current character state each round is safe because zone equipment, streaks, +// and bonuses don't change mid-fight. +// +// applyPendingCast and the auto-heal consumable setup are intentionally left +// out of the shared builder — those are one-shot mutations that runZoneCombat +// applies once before SimulateCombat. Re-applying them on every turn-based +// round would double-consume. Per-round !cast / !consume is a later sub-phase. + +// buildZoneCombatants derives the player/enemy Combatant pair for a zone +// encounter: the player's full D&D layer (stats + class/race/subclass passives +// + armed ability) and the monster's bestiary stat block with tier scaling and +// the DM-mood combat tilt folded in. The returned dndChar is handed back so +// callers can run post-combat subclass persistence without reloading it. +func (p *AdventurePlugin) buildZoneCombatants( + userID id.UserID, + monster DnDMonsterTemplate, + tier int, + dmMood int, +) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) { + tilt := dmMoodCombatTilt(dmMood) + char, err := loadAdvCharacter(userID) + if err != nil || char == nil { + return Combatant{}, Combatant{}, nil, fmt.Errorf("load adv character: %w", err) + } + equip, err := loadAdvEquipment(userID) + if err != nil { + return Combatant{}, Combatant{}, nil, fmt.Errorf("load equipment: %w", err) + } + bonuses := p.loadCombatBonuses(userID, char) + chatLvl := p.chatLevel(userID) + + playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, false) + + dndChar, _, err = ensureDnDCharacterForCombat(userID, char) + if err != nil { + return Combatant{}, Combatant{}, nil, fmt.Errorf("ensure dnd character: %w", err) + } + applyDnDPlayerLayer(&playerStats, dndChar) + applyDnDEquipmentLayer(&playerStats, dndChar, equip) + applyDnDHPScaling(&playerStats, dndChar) + applyClassPassives(&playerStats, &playerMods, dndChar) + applyRacePassives(&playerStats, &playerMods, dndChar) + applySubclassPassives(&playerStats, &playerMods, dndChar) + if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired { + slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName) + } + + enemyStats, enemyMods := monster.toCombatStats() + // Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a + // tier floor — boss/elite stat blocks already encode their challenge, so + // we never double-scale a block that's already above the floor. + if tier > 1 { + floorAC := dndDungeonACBase + tier + if enemyStats.AC < floorAC { + enemyStats.AC = floorAC + } + floorAB := dndDungeonAtkBase + tier + if enemyStats.AttackBonus < floorAB { + enemyStats.AttackBonus = floorAB + } + } + + // DM mood tilts: monster Attack delta + player initiative bias. + enemyStats.Attack += tilt.EnemyAttackDelta + if enemyStats.Attack < 1 { + enemyStats.Attack = 1 + } + playerMods.InitiativeBias += tilt.InitiativeBias + + displayName, _ := loadDisplayName(userID) + player = Combatant{ + Name: displayName, + Stats: playerStats, + Mods: playerMods, + IsPlayer: true, + } + enemy = Combatant{ + Name: monster.Name, + Stats: enemyStats, + Mods: enemyMods, + Ability: monster.Ability, + } + return player, enemy, dndChar, nil +} + +// combatantsForSession reconstructs the Combatant pair for an in-flight +// CombatSession. It reloads the zone run for the DM mood + tier and looks the +// enemy up in the bestiary by the session's EnemyID. The pair carries no HP — +// the turn engine reads that from the session row. +func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Combatant, enemy Combatant, err error) { + run, rerr := getZoneRun(sess.RunID) + if rerr != nil { + return Combatant{}, Combatant{}, fmt.Errorf("load zone run: %w", rerr) + } + if run == nil { + return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID) + } + monster, ok := dndBestiary[sess.EnemyID] + if !ok { + return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID) + } + zone := zoneOrFallback(run.ZoneID) + player, enemy, _, err = p.buildZoneCombatants(id.UserID(sess.UserID), monster, int(zone.Tier), run.DMMood) + return player, enemy, err +} diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index b14333d..29628a5 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -171,6 +171,9 @@ func atoiSafe(s string) int { } func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest string) error { + if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil { + return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.") + } if rest == "" { return p.SendDM(ctx.Sender, "`!zone enter ` — pick from `!zone list`. Example: `!zone enter goblin_warrens` or `!zone enter 1`.") @@ -379,11 +382,36 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error { if run == nil { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") } + if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil { + return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.") + } _ = applyMoodDecayIfStale(run) zone := zoneOrFallback(run.ZoneID) prev := run.CurrentRoomType() prevIdx := run.CurrentRoom + // Elite/Boss rooms are manual: !zone advance stops at the doorway and the + // player engages with !fight. Patrols don't roll while standing at the + // door — only on the advance that walked the player here, which already + // fired below on the previous room. A won CombatSession for the encounter + // means the fight's done; fall through so the graph clears the room. + if prev == RoomElite || prev == RoomBoss { + encID := encounterIDForRoom(prevIdx) + sess, serr := getCombatSessionForEncounter(run.RunID, encID) + if serr != nil { + return p.SendDM(ctx.Sender, "Couldn't read combat state: "+serr.Error()) + } + if sess == nil || sess.Status != CombatStatusWon { + kind := "Elite" + if prev == RoomBoss { + kind = "Boss" + } + return p.SendDM(ctx.Sender, fmt.Sprintf( + "**Room %d/%d — %s.** Type `!fight` to engage.", + prevIdx+1, run.TotalRooms, kind)) + } + } + // §4.1 Patrol Encounter: at Threat-Clock Alert+, patrols may move // through cleared rooms. Roll on `!advance` *before* the next room's // own resolution. Player KO ends the run. @@ -562,10 +590,12 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo return case RoomExploration: return p.resolveCombatRoom(userID, run, zone, false) - case RoomElite: - return p.resolveCombatRoom(userID, run, zone, true) - case RoomBoss: - return p.resolveBossRoom(userID, run, zone) + case RoomElite, RoomBoss: + // Manual turn-based combat (!fight / !attack). zoneCmdAdvance only + // reaches resolveRoom for an Elite/Boss room once a won CombatSession + // exists for it — so there's nothing to resolve here. Return empty + // and let the caller advance the graph past the now-cleared room. + return } return } @@ -681,71 +711,6 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z return } -// resolveBossRoom runs the zone-boss bestiary entry through the same -// combat path as room combat. Win → caller drops zone loot. -// -// Returns staged messages — see resolveCombatRoom for the contract. -func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (intro string, phases []string, outcome string, ended bool, err error) { - monster, ok := dndBestiary[zone.Boss.BestiaryID] - if !ok { - outcome = fmt.Sprintf("_(Boss %s not in bestiary — skipping.)_", zone.Boss.Name) - return - } - preHP, _ := dndHPSnapshot(userID) - // Bosses use bossCombatPhases — wider Sudden Death budget so a player - // grinding the boss down has time to actually close the HP gap. - result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), bossCombatPhases, run.DMMood) - if rerr != nil { - err = rerr - return - } - postHP, maxHP := dndHPSnapshot(userID) - nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result) - - intro = fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)", monster.Name, monster.HP, monster.AC) - - playerName := "You" - if name, _ := loadDisplayName(userID); name != "" { - playerName = name - } - phases = RenderCombatLog(result, playerName, monster.Name) - - outcome = renderBossOutcome(BossOutcomeInputs{ - ZoneID: zone.ID, - RunID: run.RunID, - RoomIdx: run.CurrentRoom, - Monster: monster, - Result: result, - PreHP: preHP, - PostHP: postHP, - MaxHP: maxHP, - PhaseTwoAt: zone.Boss.PhaseTwoAt, - Nat20s: nat20s, - Nat1s: nat1s, - DefeatHeadline: fmt.Sprintf("💀 **%s** stands over your body. Run ended.", monster.Name), - VictoryHeadline: fmt.Sprintf("🏆 **%s** falls. You finished at **%d/%d HP**.", monster.Name, postHP, maxHP), - }) - if !result.PlayerWon { - _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) - _ = abandonZoneRun(userID) - if !result.TimedOut { - markAdventureDead(userID, "zone", zone.Display) - } - ended = true - return - } - recordZoneKillForUser(userID, monster.ID) - // §8.1: zone boss defeat drops expedition threat by 20. Silent - // no-op for standalone zone runs (no active expedition). - if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil { - _ = applyBossDefeatThreat(exp.ID) - } - if drop := p.dropZoneLoot(userID, zone.ID, monster, true); drop != "" { - outcome = outcome + "\n" + drop - } - return -} - // BossOutcomeInputs is the carrier struct for renderBossOutcome — the // shared staged-narration body used by zone bosses (resolveBossRoom) // and arena bosses (resolveArenaBoss, post-L2). Pure inputs: every diff --git a/internal/plugin/dnd_zone_cmd_graph.go b/internal/plugin/dnd_zone_cmd_graph.go index 0134ce0..3d2e600 100644 --- a/internal/plugin/dnd_zone_cmd_graph.go +++ b/internal/plugin/dnd_zone_cmd_graph.go @@ -161,6 +161,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { if run == nil { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") } + if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil { + return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.") + } pf, derr := decodePendingFork(run.NodeChoices) if derr != nil { return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error()) diff --git a/internal/plugin/dnd_zone_combat.go b/internal/plugin/dnd_zone_combat.go index 030d10b..c9874ea 100644 --- a/internal/plugin/dnd_zone_combat.go +++ b/internal/plugin/dnd_zone_combat.go @@ -127,7 +127,6 @@ func (p *AdventurePlugin) runZoneCombat( if phases == nil { phases = dungeonCombatPhases } - tilt := dmMoodCombatTilt(dmMood) char, err := loadAdvCharacter(userID) if err != nil || char == nil { return CombatResult{}, fmt.Errorf("load adv character: %w", err) @@ -136,71 +135,23 @@ func (p *AdventurePlugin) runZoneCombat( if err != nil { return CombatResult{}, fmt.Errorf("load equipment: %w", err) } - bonuses := p.loadCombatBonuses(userID, char) - chatLvl := p.chatLevel(userID) - playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, false) - - dndChar, _, err := ensureDnDCharacterForCombat(userID, char) + // Player/enemy Combatant pair — shared with the turn-based engine. The + // builder folds in the D&D layer, tier scaling, and the DM-mood tilt. + player, enemy, dndChar, err := p.buildZoneCombatants(userID, monster, tier, dmMood) if err != nil { - return CombatResult{}, fmt.Errorf("ensure dnd character: %w", err) - } - applyDnDPlayerLayer(&playerStats, dndChar) - applyDnDEquipmentLayer(&playerStats, dndChar, equip) - applyDnDHPScaling(&playerStats, dndChar) - applyClassPassives(&playerStats, &playerMods, dndChar) - applyRacePassives(&playerStats, &playerMods, dndChar) - applySubclassPassives(&playerStats, &playerMods, dndChar) - if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired { - slog.Info("dnd: armed ability fired (zone)", "user", userID, "ability", firedName) + return CombatResult{}, err } - enemyStats, enemyMods := monster.toCombatStats() - // Tier scaling matches applyDnDDungeonMonsterLayer's intent: keep AC - // floor reasonable for higher-tier zones, but only bump if the bestiary - // stat block is below the floor — boss/elite stat blocks already encode - // their challenge, so we don't double-scale them. - if tier > 1 { - floorAC := dndDungeonACBase + tier - if enemyStats.AC < floorAC { - enemyStats.AC = floorAC - } - floorAB := dndDungeonAtkBase + tier - if enemyStats.AttackBonus < floorAB { - enemyStats.AttackBonus = floorAB - } - } - applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats) - - // Auto-consumable: panic heal only. Inventory healing items wire up - // the heal-at-<60%-HP trigger; offensive / buff consumables are NOT - // auto-burned (see dnd_boss_consumables.go for the rationale). + // Pre-combat one-shots that the turn-based path does NOT share: a queued + // spell and the panic-heal consumable trigger. Both mutate the Combatant + // pair once, before the fight runs. + applyPendingCast(userID, dndChar, &player.Stats, &player.Mods, &enemy.Stats) consumables := p.loadConsumableInventory(userID) - setupAutoHealFromInventory(consumables, &playerMods) - - // DM mood tilts: monster Attack delta + player initiative bias. - enemyStats.Attack += tilt.EnemyAttackDelta - if enemyStats.Attack < 1 { - enemyStats.Attack = 1 // floor — keep some bite even for Elated DM - } - playerMods.InitiativeBias += tilt.InitiativeBias - - displayName, _ := loadDisplayName(userID) - player := Combatant{ - Name: displayName, - Stats: playerStats, - Mods: playerMods, - IsPlayer: true, - } - enemy := Combatant{ - Name: monster.Name, - Stats: enemyStats, - Mods: enemyMods, - Ability: monster.Ability, - } + setupAutoHealFromInventory(consumables, &player.Mods) result := SimulateCombat(player, enemy, phases) - dumpCombatEventsIfDebug(fmt.Sprintf("zone:%s vs %s", monster.ID, displayName), result) + dumpCombatEventsIfDebug(fmt.Sprintf("zone:%s vs %s", monster.ID, player.Name), result) // Remove the actual heal items consumed during combat (one inventory // item per heal_item event fired). Cheapest-tier first. @@ -217,7 +168,7 @@ func (p *AdventurePlugin) runZoneCombat( p.grantCombatAchievements(userID, result) persistDnDHPAfterCombat(userID, result.PlayerEndHP) - if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil { + if err := persistDnDPostCombatSubclass(dndChar, player.Mods.BerserkerRage, result, player.Mods); err != nil { slog.Error("dnd: post-combat subclass persist (zone)", "user", userID, "err", err) } @@ -258,7 +209,14 @@ func zoneCombatXP(result CombatResult, cr float32, tier int) int { // triggers for nat-20s and nat-1s. Returns the count of each so the // caller can include the deltas in the rendered status line. func scanMoodEventsFromCombat(runID string, result CombatResult) (nat20s, nat1s int) { - for _, ev := range result.Events { + return scanMoodEventsFromEvents(runID, result.Events) +} + +// scanMoodEventsFromEvents is the event-slice core of scanMoodEventsFromCombat +// — used by the turn-based close-out, which has a CombatEvent log (the +// session's accumulated TurnLog) rather than a one-shot CombatResult. +func scanMoodEventsFromEvents(runID string, events []CombatEvent) (nat20s, nat1s int) { + for _, ev := range events { if ev.Roll == 20 && ev.Actor == "player" { nat20s++ }