mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -321,6 +321,15 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
if p.IsCommand(ctx.Body, "zone") {
|
if p.IsCommand(ctx.Body, "zone") {
|
||||||
return p.handleDnDZoneCmd(ctx, p.GetArgs(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") {
|
if p.IsCommand(ctx.Body, "expedition") {
|
||||||
return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition"))
|
return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition"))
|
||||||
}
|
}
|
||||||
|
|||||||
335
internal/plugin/combat_cmd.go
Normal file
335
internal/plugin/combat_cmd.go
Normal file
@@ -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 <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.)_")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, 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 ""
|
||||||
|
}
|
||||||
169
internal/plugin/combat_cmd_test.go
Normal file
169
internal/plugin/combat_cmd_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,6 +179,23 @@ func getCombatSession(sessionID string) (*CombatSession, error) {
|
|||||||
return s, err
|
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) {
|
func scanCombatSession(row scanner) (*CombatSession, error) {
|
||||||
var (
|
var (
|
||||||
s CombatSession
|
s CombatSession
|
||||||
|
|||||||
125
internal/plugin/combat_session_build.go
Normal file
125
internal/plugin/combat_session_build.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -171,6 +171,9 @@ func atoiSafe(s string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest string) error {
|
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 == "" {
|
if rest == "" {
|
||||||
return p.SendDM(ctx.Sender,
|
return p.SendDM(ctx.Sender,
|
||||||
"`!zone enter <id|#>` — pick from `!zone list`. Example: `!zone enter goblin_warrens` or `!zone enter 1`.")
|
"`!zone enter <id|#>` — 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 {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||||
}
|
}
|
||||||
|
if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
|
||||||
|
}
|
||||||
_ = applyMoodDecayIfStale(run)
|
_ = applyMoodDecayIfStale(run)
|
||||||
zone := zoneOrFallback(run.ZoneID)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
prev := run.CurrentRoomType()
|
prev := run.CurrentRoomType()
|
||||||
prevIdx := run.CurrentRoom
|
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
|
// §4.1 Patrol Encounter: at Threat-Clock Alert+, patrols may move
|
||||||
// through cleared rooms. Roll on `!advance` *before* the next room's
|
// through cleared rooms. Roll on `!advance` *before* the next room's
|
||||||
// own resolution. Player KO ends the run.
|
// own resolution. Player KO ends the run.
|
||||||
@@ -562,10 +590,12 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
|
|||||||
return
|
return
|
||||||
case RoomExploration:
|
case RoomExploration:
|
||||||
return p.resolveCombatRoom(userID, run, zone, false)
|
return p.resolveCombatRoom(userID, run, zone, false)
|
||||||
case RoomElite:
|
case RoomElite, RoomBoss:
|
||||||
return p.resolveCombatRoom(userID, run, zone, true)
|
// Manual turn-based combat (!fight / !attack). zoneCmdAdvance only
|
||||||
case RoomBoss:
|
// reaches resolveRoom for an Elite/Boss room once a won CombatSession
|
||||||
return p.resolveBossRoom(userID, run, zone)
|
// 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
|
return
|
||||||
}
|
}
|
||||||
@@ -681,71 +711,6 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
return
|
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
|
// BossOutcomeInputs is the carrier struct for renderBossOutcome — the
|
||||||
// shared staged-narration body used by zone bosses (resolveBossRoom)
|
// shared staged-narration body used by zone bosses (resolveBossRoom)
|
||||||
// and arena bosses (resolveArenaBoss, post-L2). Pure inputs: every
|
// and arena bosses (resolveArenaBoss, post-L2). Pure inputs: every
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
|
|||||||
if run == nil {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||||
}
|
}
|
||||||
|
if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
|
||||||
|
}
|
||||||
pf, derr := decodePendingFork(run.NodeChoices)
|
pf, derr := decodePendingFork(run.NodeChoices)
|
||||||
if derr != nil {
|
if derr != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error())
|
return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error())
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ func (p *AdventurePlugin) runZoneCombat(
|
|||||||
if phases == nil {
|
if phases == nil {
|
||||||
phases = dungeonCombatPhases
|
phases = dungeonCombatPhases
|
||||||
}
|
}
|
||||||
tilt := dmMoodCombatTilt(dmMood)
|
|
||||||
char, err := loadAdvCharacter(userID)
|
char, err := loadAdvCharacter(userID)
|
||||||
if err != nil || char == nil {
|
if err != nil || char == nil {
|
||||||
return CombatResult{}, fmt.Errorf("load adv character: %w", err)
|
return CombatResult{}, fmt.Errorf("load adv character: %w", err)
|
||||||
@@ -136,71 +135,23 @@ func (p *AdventurePlugin) runZoneCombat(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return CombatResult{}, fmt.Errorf("load equipment: %w", err)
|
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)
|
// 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.
|
||||||
dndChar, _, err := ensureDnDCharacterForCombat(userID, char)
|
player, enemy, dndChar, err := p.buildZoneCombatants(userID, monster, tier, dmMood)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CombatResult{}, fmt.Errorf("ensure dnd character: %w", err)
|
return CombatResult{}, 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enemyStats, enemyMods := monster.toCombatStats()
|
// Pre-combat one-shots that the turn-based path does NOT share: a queued
|
||||||
// Tier scaling matches applyDnDDungeonMonsterLayer's intent: keep AC
|
// spell and the panic-heal consumable trigger. Both mutate the Combatant
|
||||||
// floor reasonable for higher-tier zones, but only bump if the bestiary
|
// pair once, before the fight runs.
|
||||||
// stat block is below the floor — boss/elite stat blocks already encode
|
applyPendingCast(userID, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
|
||||||
// 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).
|
|
||||||
consumables := p.loadConsumableInventory(userID)
|
consumables := p.loadConsumableInventory(userID)
|
||||||
setupAutoHealFromInventory(consumables, &playerMods)
|
setupAutoHealFromInventory(consumables, &player.Mods)
|
||||||
|
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := SimulateCombat(player, enemy, phases)
|
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
|
// Remove the actual heal items consumed during combat (one inventory
|
||||||
// item per heal_item event fired). Cheapest-tier first.
|
// item per heal_item event fired). Cheapest-tier first.
|
||||||
@@ -217,7 +168,7 @@ func (p *AdventurePlugin) runZoneCombat(
|
|||||||
|
|
||||||
p.grantCombatAchievements(userID, result)
|
p.grantCombatAchievements(userID, result)
|
||||||
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
|
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)
|
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
|
// triggers for nat-20s and nat-1s. Returns the count of each so the
|
||||||
// caller can include the deltas in the rendered status line.
|
// caller can include the deltas in the rendered status line.
|
||||||
func scanMoodEventsFromCombat(runID string, result CombatResult) (nat20s, nat1s int) {
|
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" {
|
if ev.Roll == 20 && ev.Actor == "player" {
|
||||||
nat20s++
|
nat20s++
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user