Adv 2.0 D&D polish: GM→DM rename, streamed zone combat, end-to-end scenario test

GM→DM rename across docs and code (GMNarrationType→DMNarrationType,
GMState→DMState, narration constants, comments) so the system reads as
"Dungeon Master" everywhere. Player-visible "GM mood" wording stays
where it appears in flavor.

Streamed zone/expedition combat: zone advance now stages patrol →
patrol play-by-play → patrol resolution → room intro → room play-by-play
→ final outcome through sendZoneCombatMessages with 2–3s pacing
(arena keeps its 5–8s window). Combat narrative lines pick up a compact
d20-vs-AC roll annotation for hit/crit/miss/block events.

Combat outcome polish: dndHPSnapshot lets narration show sheet HP
rather than legacy combat-engine HP, and markAdventureDead clears the
zombie state where hp_current was 0 but the legacy alive flag stayed
true after a D&D-layer KO.

Adv 2.0 announcement (ADVENTURE_2.0_ANNOUNCEMENT.md), README rewrite
covering the new layer, and adv2_scenario_test.go — a full
zone-run + expedition + harvest playthrough against a copy of the prod
DB asserting persisted state end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 07:43:11 -07:00
parent 83a71173b1
commit dfa7beeb96
32 changed files with 1262 additions and 334 deletions

View File

@@ -1,7 +1,7 @@
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
// twinbee_gm_flavor.go
// TwinBee GM Dialogue — All narration lines for the GogoBee dungeon system.
// Organized by GMNarrationType. Each slice is randomly sampled at runtime.
// Organized by DMNarrationType. Each slice is randomly sampled at runtime.
// Add new entries freely. Never remove or alter existing entries.
package flavor

View File

@@ -0,0 +1,315 @@
package plugin
import (
"math/rand/v2"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// Adv 2.0 scenario playthrough: drives a realistic player session against
// a copy of the prod DB, verifying happy-path behavior end-to-end across
// the !zone state machine, the !expedition multi-day system, and the
// !harvest economy. SendDM is a no-op without a Matrix client, so the
// asserts target persisted state. Logs are verbose (t.Logf) so the run
// reads like a transcript.
// ── Scenario A — single-session zone run ────────────────────────────────────
func TestAdv2Scenario_ZoneRunGoblinWarrens(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@adv2-scenA:example")
t.Cleanup(func() { cleanupZoneRuns(uid); cleanupExpeditions(uid) })
// Beefy L5 fighter so combat doesn't trivially TPK (still RNG, but 5
// is well past goblin warrens' L1L3 band).
if err := createAdvCharacter(uid, "scenA"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
HPMax: 60, HPCurrent: 60, ArmorClass: 18,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
euro := &EuroPlugin{}
euro.ensureBalance(uid)
euro.Credit(uid, 500, "scenA bankroll")
p := &AdventurePlugin{euro: euro}
// !zone (list) — should not error, no run created.
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, ""); err != nil {
t.Fatalf("zone list: %v", err)
}
if got, _ := getActiveZoneRun(uid); got != nil {
t.Fatal("zone list should not create a run")
}
// !zone enter goblin_warrens
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
t.Fatalf("zone enter: %v", err)
}
run, err := getActiveZoneRun(uid)
if err != nil || run == nil {
t.Fatalf("expected active run after enter: %v", err)
}
t.Logf("entered %s — %d rooms, mood=%d, first room=%s",
run.ZoneID, run.TotalRooms, run.DMMood, run.CurrentRoomType())
if run.DMMood != 50 {
t.Errorf("starting mood = %d, want 50", run.DMMood)
}
if run.CurrentRoom != 0 || run.CurrentRoomType() != RoomEntry {
t.Errorf("first room not entry: idx=%d type=%s", run.CurrentRoom, run.CurrentRoomType())
}
// !zone status (cheap smoke)
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "status"); err != nil {
t.Fatalf("zone status: %v", err)
}
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "map"); err != nil {
t.Fatalf("zone map: %v", err)
}
// Drive !zone advance until the run terminates (cleared, died, or
// abandoned). Cap iterations as a safety net in case advance becomes
// a no-op due to a regression.
maxSteps := run.TotalRooms + 4
clearedRoomTypes := []RoomType{}
for step := 0; step < maxSteps; step++ {
before, _ := getActiveZoneRun(uid)
if before == nil {
t.Logf("step %d: run terminated (cleared/died/abandoned)", step)
break
}
prevType := before.CurrentRoomType()
prevHP := c.HPCurrent
if cur, _ := LoadDnDCharacter(uid); cur != nil {
prevHP = cur.HPCurrent
}
t.Logf("step %d: in room %d/%d (%s), mood=%d, HP=%d",
step, before.CurrentRoom+1, before.TotalRooms, prevType, before.DMMood, prevHP)
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "advance"); err != nil {
t.Fatalf("zone advance step %d: %v", step, err)
}
clearedRoomTypes = append(clearedRoomTypes, prevType)
}
final, _ := getActiveZoneRun(uid)
raw := getZoneRunByUser(t, uid) // helper below — most-recent row
if final != nil {
t.Logf("final state: still active at room %d/%d — likely advance regression",
final.CurrentRoom+1, final.TotalRooms)
t.Errorf("run did not terminate within %d steps", maxSteps)
} else if raw != nil {
boss := raw.BossDefeated
t.Logf("run %s ended: bossDefeated=%v abandoned=%v rooms_cleared=%d/%d loot=%d mood=%d",
raw.RunID, boss, raw.Abandoned, len(raw.RoomsCleared), raw.TotalRooms,
len(raw.LootCollected), raw.DMMood)
t.Logf("clearedRoomTypes: %v", clearedRoomTypes)
if !boss && !raw.Abandoned && raw.CompletedAt == nil {
t.Errorf("run row in indeterminate state: %+v", raw)
}
}
}
// getZoneRunByUser fetches the most-recent zone-run row for a user
// regardless of active/abandoned/cleared. Helper for the scenario test.
func getZoneRunByUser(t *testing.T, uid id.UserID) *DungeonRun {
t.Helper()
row := db.Get().QueryRow(`
SELECT run_id FROM dnd_zone_run
WHERE user_id = ?
ORDER BY started_at DESC LIMIT 1
`, string(uid))
var runID string
if err := row.Scan(&runID); err != nil {
return nil
}
r, _ := getZoneRun(runID)
return r
}
// ── Scenario B — multi-day expedition ───────────────────────────────────────
func TestAdv2Scenario_ExpeditionCryptValdris(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@adv2-scenB:example")
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
if err := createAdvCharacter(uid, "scenB"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassRanger, Level: 5,
STR: 14, DEX: 18, CON: 14, INT: 10, WIS: 14, CHA: 10,
HPMax: 50, HPCurrent: 50, ArmorClass: 16,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
euro := &EuroPlugin{}
euro.ensureBalance(uid)
euro.Credit(uid, 1000, "scenB bankroll")
p := &AdventurePlugin{euro: euro}
// !expedition list
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "list"); err != nil {
t.Fatalf("expedition list: %v", err)
}
// !expedition start crypt_valdris with 2 standard packs
balBefore := euro.GetBalance(uid)
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start crypt_valdris 2s"); err != nil {
t.Fatalf("expedition start: %v", err)
}
exp, err := getActiveExpedition(uid)
if err != nil || exp == nil {
t.Fatalf("expected active expedition: %v", err)
}
balAfter := euro.GetBalance(uid)
t.Logf("expedition %s started: zone=%s supplies=%d/%d threat=%d cost=%.0f→%.0f",
exp.ID, exp.ZoneID, int(exp.Supplies.Current), int(exp.Supplies.Max),
exp.ThreatLevel, balBefore, balAfter)
if exp.ZoneID != ZoneCryptValdris {
t.Errorf("zone = %s, want crypt_valdris", exp.ZoneID)
}
if balAfter >= balBefore {
t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter)
}
// Backdate start so deliverBriefing's same-day guard passes.
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
t.Fatalf("backdate: %v", err)
}
// Drive 3 daily briefings — verify supply burn + day advance.
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
for day := 1; day <= 3; day++ {
fresh, _ := getExpedition(exp.ID)
if fresh == nil {
t.Logf("expedition ended before day %d", day)
break
}
preDay := fresh.CurrentDay
preSupplies := fresh.Supplies.Current
preThreat := fresh.ThreatLevel
if err := p.deliverBriefing(fresh, now); err != nil {
t.Fatalf("deliverBriefing day %d: %v", day, err)
}
// Advance the wallclock + roll the start back another 24h so the
// next briefing's day-guard fires.
now = now.Add(24 * time.Hour)
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
t.Fatalf("backdate day %d: %v", day, err)
}
post, _ := getExpedition(exp.ID)
t.Logf("day %d: day=%d→%d supplies=%v→%v threat=%d→%d",
day, preDay, post.CurrentDay, preSupplies, post.Supplies.Current, preThreat, post.ThreatLevel)
if post.CurrentDay <= preDay {
t.Errorf("day did not advance: %d → %d", preDay, post.CurrentDay)
}
}
// !expedition status / log
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "status"); err != nil {
t.Fatalf("expedition status: %v", err)
}
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "log"); err != nil {
t.Fatalf("expedition log: %v", err)
}
entries, _ := recentExpeditionLog(exp.ID, 10)
t.Logf("expedition log has %d entries", len(entries))
for i, e := range entries {
t.Logf(" [%d] day=%d type=%s summary=%q", i, e.Day, e.Type, e.Summary)
}
if len(entries) == 0 {
t.Error("expected at least one log entry")
}
// !expedition abandon — clean state for next test runs.
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "abandon"); err != nil {
t.Fatalf("expedition abandon: %v", err)
}
if got, _ := getActiveExpedition(uid); got != nil {
t.Error("expected no active expedition after abandon")
}
}
// ── Scenario C — harvest in an active expedition ────────────────────────────
func TestAdv2Scenario_HarvestForestShadows(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@adv2-scenC:example")
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
if err := createAdvCharacter(uid, "scenC"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceElf, Class: ClassRanger, Level: 4,
STR: 12, DEX: 18, CON: 14, INT: 10, WIS: 16, CHA: 10,
HPMax: 40, HPCurrent: 40, ArmorClass: 15,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
euro := &EuroPlugin{}
euro.ensureBalance(uid)
euro.Credit(uid, 500, "scenC bankroll")
p := &AdventurePlugin{euro: euro}
// Forage-friendly expedition.
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows"); err != nil {
t.Fatalf("expedition start: %v", err)
}
exp, _ := getActiveExpedition(uid)
if exp == nil {
t.Fatal("no expedition")
}
// Drive a region run so harvest has a current room context.
run, err := ensureRegionRun(exp, c.Level)
if err != nil {
t.Fatalf("ensureRegionRun: %v", err)
}
t.Logf("region run %s in zone %s (%d rooms)", run.RunID, run.ZoneID, run.TotalRooms)
// Seed harvest nodes for the entry room (they're auto-seeded by
// loadHarvestNodes on first read, but confirm we get at least one).
roomIdx := currentRoomIndexFor(exp)
nodes := loadHarvestNodes(exp, roomIdx)
t.Logf("room %d has %d harvest nodes", roomIdx, len(nodes))
if len(nodes) == 0 {
t.Error("expected at least one harvest node seeded")
}
// Try each harvest action a couple of times and watch for crashes.
// Outcomes are RNG-driven; we mainly want no panics + coherent state.
rng := rand.New(rand.NewPCG(7, 13))
_ = rng
for _, action := range []HarvestAction{HarvestForage, HarvestMine} {
for i := 0; i < 3; i++ {
if err := p.handleHarvestCmd(MessageContext{Sender: uid}, action); err != nil {
t.Fatalf("handleHarvestCmd(%s) attempt %d: %v", action, i, err)
}
fresh, _ := getExpedition(exp.ID)
if fresh == nil {
t.Logf("expedition ended during harvest %s #%d", action, i)
break
}
t.Logf("after %s #%d: supplies=%v threat=%d", action, i, fresh.Supplies.Current, fresh.ThreatLevel)
}
}
// !resources output path (no-op SendDM but should not error).
if err := p.handleResourcesCmd(MessageContext{Sender: uid}); err != nil {
t.Fatalf("handleResourcesCmd: %v", err)
}
}

View File

@@ -192,14 +192,14 @@ func (p *AdventurePlugin) Init() error {
if err := lockCoopCombatActions(); err != nil {
slog.Error("adventure: startup coop combat lock failed", "err", err)
}
// Phase R1 — archive any active dnd_zone_run rows that aren't linked to
// an active expedition. Idempotent: re-running it does nothing once the
// orphan set is empty.
if n, err := archiveOrphanZoneRuns(); err != nil {
slog.Error("adventure: R1 orphan zone-run archive failed", "err", err)
} else if n > 0 {
slog.Info("adventure: R1 archived orphan zone runs", "count", n)
}
// Phase R1 orphan-archive used to run here on every Init, but it
// over-archived: it treats any active dnd_zone_run row not linked to
// an active expedition as a legacy `!adventure dungeon` orphan, which
// also kills legitimate `!zone enter` single-session runs every time
// the bot restarts. That migration's job was done on its first
// execution; leaving it on Init was eating live runs. The function is
// preserved for one-off invocation if a future schema change ever
// needs it again.
// Revive any characters whose DeadUntil has expired
p.catchUpRespawns(chars)

View File

@@ -258,9 +258,9 @@ func TestFixedHolidays_NoDuplicateMonthDay(t *testing.T) {
func TestDailySummary_HolidayBlock(t *testing.T) {
players := []AdvPlayerDaySummary{
{DisplayName: "Alice", Activity: "dungeon", Location: "Cellar", Outcome: "success", LootValue: 500, HolidayActions: 2},
{DisplayName: "Bob", Activity: "mine", Location: "Quarry", Outcome: "success", LootValue: 300, HolidayActions: 1},
{DisplayName: "Carol", IsDead: true, DeadUntil: "14:00 UTC", Activity: "dungeon", Location: "Cave", Outcome: "death", HolidayActions: 1},
{DisplayName: "Alice", Activity: "dungeon", Location: "Cellar", Outcome: "success", LootValue: 500},
{DisplayName: "Bob", Activity: "mine", Location: "Quarry", Outcome: "success", LootValue: 300},
{DisplayName: "Carol", IsDead: true, DeadUntil: "14:00 UTC", Activity: "dungeon", Location: "Cave", Outcome: "death"},
}
text := renderAdvDailySummary("2026-12-25", nil, TwinBeeRewardSummary{}, players, "Christmas")
@@ -268,17 +268,19 @@ func TestDailySummary_HolidayBlock(t *testing.T) {
if !strings.Contains(text, "Christmas") {
t.Error("summary should contain holiday name")
}
if !strings.Contains(text, "two actions today") {
t.Error("summary should mention two actions on holidays")
// Adv 2.0 — holiday recap now advertises the +5 mood / free pack /
// +1 yield perks instead of the retired double-action mechanic.
if !strings.Contains(text, "TwinBee's blessing") {
t.Error("summary should mention TwinBee's blessing on holidays")
}
if !strings.Contains(text, "before their second action") {
t.Error("summary should note Carol died before second action")
if !strings.Contains(text, "free standard pack") {
t.Error("summary should mention the complimentary standard pack")
}
// Without holiday
textNoHol := renderAdvDailySummary("2026-12-26", nil, TwinBeeRewardSummary{}, players, "")
if strings.Contains(textNoHol, "two actions") {
t.Error("non-holiday summary should NOT mention two actions")
if strings.Contains(textNoHol, "TwinBee's blessing") {
t.Error("non-holiday summary should NOT mention TwinBee's blessing")
}
}

View File

@@ -328,12 +328,27 @@ func (p *AdventurePlugin) prependCraftNarrative(userID id.UserID, messages []str
// Runs in a goroutine so it doesn't block the message handler.
// Returns a channel that is closed when all messages have been sent.
func (p *AdventurePlugin) sendCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
return p.sendCombatMessagesWithDelay(userID, phaseMessages, finalMessage, 5, 4) // 58s
}
// sendZoneCombatMessages is the zone/expedition variant — same staging as
// arena, but tighter pacing (23s) so dungeon advances feel snappier.
func (p *AdventurePlugin) sendZoneCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
return p.sendCombatMessagesWithDelay(userID, phaseMessages, finalMessage, 2, 2) // 23s
}
// sendCombatMessagesWithDelay is the shared streamer. base/jitter define
// the per-message delay window in seconds: delay = base + rand.IntN(jitter).
func (p *AdventurePlugin) sendCombatMessagesWithDelay(userID id.UserID, phaseMessages []string, finalMessage string, base, jitter int) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
for _, msg := range phaseMessages {
_ = p.SendDM(userID, msg)
delay := 5 + rand.IntN(4) // 5-8 seconds
delay := base
if jitter > 0 {
delay += rand.IntN(jitter)
}
time.Sleep(time.Duration(delay) * time.Second)
}
_ = p.SendDM(userID, finalMessage)

View File

@@ -103,6 +103,9 @@ func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result Combat
for _, e := range pg.Events {
line := renderEvent(e, playerName, enemyName, result, picker)
if line != "" {
if roll := rollAnnotation(e); roll != "" {
line = line + " " + roll
}
sb.WriteString(line + "\n")
}
}
@@ -125,6 +128,26 @@ func clampHP(hp int) int {
return hp
}
// rollAnnotation returns a compact d20 tag for events that came from the
// d20-vs-AC resolution path. Empty for ability/heal/consumable events
// where Roll is unset. Format: "_(🎲 14 vs AC 13)_" — italicized so the
// dice fact reads as a sidebar to the narrative line.
func rollAnnotation(e CombatEvent) string {
if e.Roll <= 0 {
return ""
}
switch e.Action {
case "hit", "crit", "miss", "block":
// d20 contests — annotate with roll vs target AC.
default:
return ""
}
if e.RollAgainst > 0 {
return fmt.Sprintf("_(🎲 %d vs AC %d)_", e.Roll, e.RollAgainst)
}
return fmt.Sprintf("_(🎲 %d)_", e.Roll)
}
func phaseHeader(name string) string {
switch name {
case "Opening", "opening":

View File

@@ -57,7 +57,7 @@ type DnDClassInfo struct {
}
var dndRaces = []DnDRaceInfo{
{RaceHuman, "Human", [6]int{0, 0, 0, 0, 0, 0}, "Versatile (floating +1 bonus deferred to Phase 2)"},
{RaceHuman, "Human", [6]int{0, 0, 0, 0, 0, 0}, "Versatile (floating +1 bonus not yet implemented)"},
{RaceElf, "Elf", [6]int{0, 2, -1, 1, 1, 0}, "Darkvision; immune to sleep effects"},
{RaceDwarf, "Dwarf", [6]int{1, -1, 2, 0, 1, -1}, "Poison resistance; bonus vs. underground enemies"},
{RaceHalfling, "Halfling", [6]int{0, 2, 0, 0, 1, 0}, "Lucky: once per combat, reroll a natural 1"},

View File

@@ -145,10 +145,12 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
}
}
// Reaction spells deferred.
// Reaction spells deferred — combat is one-shot SimulateCombat, no
// per-turn windows where a reaction could trigger. Will land alongside
// turn-based combat when that arrives.
if spell.Effect == EffectReaction {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s is a reaction spell — those land in Phase 11 alongside turn-based boss combat.", spell.Name))
"%s is a reaction spell — those aren't usable yet (combat resolves in one beat, no reaction window). Pick a different spell.", spell.Name))
}
// Known + prepared check.

View File

@@ -487,3 +487,36 @@ func persistDnDHPAfterCombat(userID id.UserID, legacyStartHP, legacyEndHP int) {
slog.Error("dnd: persist hp after combat", "user", userID, "err", err)
}
}
// dndHPSnapshot returns the user's D&D-scale (current, max) HP. Caller
// captures pre-combat values via this helper, runs combat (which calls
// persistDnDHPAfterCombat internally), then re-snapshots for the post
// values. Used so combat outcome narration shows sheet HP rather than
// the legacy combat-engine HP scale.
func dndHPSnapshot(userID id.UserID) (cur, max int) {
c, err := LoadDnDCharacter(userID)
if err != nil || c == nil {
return 0, 0
}
return c.HPCurrent, c.HPMax
}
// markAdventureDead flips the legacy adventure_characters.alive flag and
// starts the 6h respawn timer for a player who went down in a D&D-layer
// combat. Without this, hp_current persists as 0 but the legacy alive
// flag stays true — the "zombie" state where !hospital says "you're
// alive!" while the sheet shows 0/33. Idempotent: bails if already dead.
//
// source is "zone" / "expedition" / "patrol"; location is human-readable
// (e.g. "Forest of Shadows") and surfaces in the daily report and
// standout-loss flavor.
func markAdventureDead(userID id.UserID, source, location string) {
char, err := loadAdvCharacter(userID)
if err != nil || char == nil || !char.Alive {
return
}
char.Kill(source, location)
if err := saveAdvCharacter(char); err != nil {
slog.Error("dnd: kill on combat loss", "user", userID, "err", err)
}
}

View File

@@ -82,7 +82,7 @@ type Expedition struct {
RegionState map[string]any
XPEarned int
CoinsEarned int
GMMood int
DMMood int
LastBriefingAt *time.Time
LastRecapAt *time.Time
LastActivity time.Time
@@ -143,6 +143,10 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
if currentRegion != "" {
regionState[regionStateVisitedKey] = []string{currentRegion}
}
startMood := 50
if isHol, _ := isHolidayToday(); isHol {
startMood = 55
}
exp := &Expedition{
ID: newExpeditionID(),
UserID: string(userID),
@@ -155,7 +159,7 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
Supplies: supplies,
ThreatEvents: []ThreatEvent{},
RegionState: regionState,
GMMood: 50,
DMMood: startMood,
LastActivity: now,
}
supJSON, _ := json.Marshal(supplies)
@@ -167,9 +171,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
(expedition_id, user_id, zone_id, run_id, status,
start_date, current_day, current_region, supplies_json,
threat_events, region_state, gm_mood, last_activity)
VALUES (?, ?, ?, ?, 'active', ?, 1, ?, ?, ?, ?, 50, ?)`,
VALUES (?, ?, ?, ?, 'active', ?, 1, ?, ?, ?, ?, ?, ?)`,
exp.ID, exp.UserID, string(zoneID), nullableString(runID),
now, currentRegion, string(supJSON), string(threatJSON), string(regJSON), now,
now, currentRegion, string(supJSON), string(threatJSON), string(regJSON), startMood, now,
); err != nil {
return nil, fmt.Errorf("insert expedition: %w", err)
}
@@ -235,7 +239,7 @@ func scanExpedition(row scanner) (*Expedition, error) {
&e.StartDate, &e.CurrentDay, &e.CurrentRegion, &bossI,
&suppliesJSON, &campJSON, &e.ThreatLevel, &siegeI,
&threatJSON, &e.TemporalStack, &regionJSON,
&e.XPEarned, &e.CoinsEarned, &e.GMMood,
&e.XPEarned, &e.CoinsEarned, &e.DMMood,
&lastBriefingRaw, &lastRecapRaw, &e.LastActivity, &completedRaw,
); err != nil {
return nil, err

View File

@@ -182,7 +182,13 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
}
zone, _ := getZone(zoneID)
supplies := makeSupplies(zone.Tier, purchase)
// Holiday perk: a complimentary standard pack is added to the supplies
// snapshot without inflating the coin cost.
suppliesPurchase := purchase
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
supplies := makeSupplies(zone.Tier, suppliesPurchase)
// Debit coins; bail on debit failure (race / cap).
if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(zoneID)) {

View File

@@ -118,10 +118,12 @@ func (p *AdventurePlugin) runHarvestInterrupt(
_ = SaveDnDCharacter(dndChar)
}
preCombatHP, _ := dndHPSnapshot(userID)
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
if err != nil {
return fmt.Sprintf("_(Interrupt combat error: %v.)_", err), false
}
postHP, maxHP := dndHPSnapshot(userID)
scanMoodEventsFromCombat(run.RunID, result)
var b strings.Builder
@@ -147,6 +149,7 @@ func (p *AdventurePlugin) runHarvestInterrupt(
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID)
_ = retireAllRegionRuns(exp)
markAdventureDead(userID, "expedition", zone.Display)
if line := flavor.Pick(flavor.PlayerDeath); line != "" {
b.WriteString(line)
b.WriteString("\n")
@@ -161,8 +164,8 @@ func (p *AdventurePlugin) runHarvestInterrupt(
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d).",
monster.Name, result.PlayerStartHP, result.PlayerEndHP))
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).",
monster.Name, preCombatHP, postHP, maxHP))
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
b.WriteString("\n")
b.WriteString(drop)
@@ -332,56 +335,84 @@ func rollPatrolChance(threatLevel int) float64 {
// tryPatrolEncounter is called from zoneCmdAdvance *before* the next room
// resolves. If the player's active expedition is at Alert+, we roll for a
// patrol; on hit, we run a single combat against a non-elite roster entry.
// Returns narration (empty if no patrol), endedRun (player KO), and any
// error to surface.
//
// Returns staged messages so the patrol fight gets the same 23s pacing
// as room/boss combat — see resolveCombatRoom for the contract. When no
// patrol fires, intro/phases/outcome are all empty and ended is false.
func (p *AdventurePlugin) tryPatrolEncounter(
userID id.UserID,
run *DungeonRun,
zone ZoneDefinition,
) (string, bool, error) {
exp, err := getActiveExpedition(userID)
if err != nil || exp == nil {
return "", false, nil
) (intro string, phases []string, outcome string, ended bool, err error) {
exp, gerr := getActiveExpedition(userID)
if gerr != nil || exp == nil {
return
}
if exp.RunID != run.RunID {
return "", false, nil
return
}
chance := rollPatrolChance(exp.ThreatLevel)
if chance <= 0 || rand.Float64() > chance {
return "", false, nil
return
}
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)
if !ok {
return "", false, nil
return
}
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
if err != nil {
return "", false, err
preHP, _ := dndHPSnapshot(userID)
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier))
if rerr != nil {
err = rerr
return
}
postHP, maxHP := dndHPSnapshot(userID)
scanMoodEventsFromCombat(run.RunID, result)
var b strings.Builder
b.WriteString(flavor.Pick(flavor.PatrolEncounter))
b.WriteString("\n")
b.WriteString(fmt.Sprintf("🛡 **Patrol — %s** (HP %d, AC %d)\n",
// Intro: patrol-encounter flavor + creature stat block.
var ib strings.Builder
ib.WriteString(flavor.Pick(flavor.PatrolEncounter))
ib.WriteString("\n")
ib.WriteString(fmt.Sprintf("🛡 **Patrol — %s** (HP %d, AC %d)",
monster.Name, monster.HP, monster.AC))
intro = ib.String()
// Phases: forward-simulating engine play-by-play.
playerName := "You"
if char, _ := loadAdvCharacter(userID); char != nil && char.DisplayName != "" {
playerName = char.DisplayName
}
phases = RenderCombatLog(result, playerName, monster.Name)
var ob strings.Builder
if !result.PlayerWon {
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID)
_ = retireAllRegionRuns(exp)
markAdventureDead(userID, "patrol", zone.Display)
if line := flavor.Pick(flavor.PlayerDeath); line != "" {
b.WriteString(line)
b.WriteString("\n")
ob.WriteString(line)
ob.WriteString("\n")
}
b.WriteString(fmt.Sprintf("💀 The patrol takes you down. Run ended."))
return b.String(), true, nil
ob.WriteString("💀 The patrol takes you down. Run ended.")
if rollLine := dndRollSummaryLine(result); rollLine != "" {
ob.WriteString("\n")
ob.WriteString(rollLine)
}
outcome = ob.String()
ended = true
return
}
_ = recordZoneKill(exp, monster.ID)
b.WriteString(fmt.Sprintf("✅ Patrol dispatched (HP %d→%d).",
result.PlayerStartHP, result.PlayerEndHP))
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
b.WriteString("\n")
b.WriteString(drop)
ob.WriteString(fmt.Sprintf("✅ Patrol dispatched (HP %d→%d / %d).",
preHP, postHP, maxHP))
if rollLine := dndRollSummaryLine(result); rollLine != "" {
ob.WriteString("\n")
ob.WriteString(rollLine)
}
return b.String(), false, nil
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
ob.WriteString("\n")
ob.WriteString(drop)
}
outcome = ob.String()
return
}

View File

@@ -339,16 +339,24 @@ func currentRoomCleared(run *DungeonRun) bool {
// handleHarvestCmd is the shared dispatcher for !forage/!mine/!scavenge/
// !essence/!commune. Resolves a single attempt and DMs the outcome.
func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAction) error {
char, err := LoadDnDCharacter(ctx.Sender)
if err != nil || char == nil {
return p.SendDM(ctx.Sender, "No D&D character found. Run `!setup` first.")
}
exp, err := getActiveExpedition(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load expedition state.")
}
if exp == nil {
return p.SendDM(ctx.Sender, "You're not on an expedition. Start one with `!expedition start <zone>`.")
}
char, err := LoadDnDCharacter(ctx.Sender)
if err != nil || char == nil {
return p.SendDM(ctx.Sender, "No D&D character found. Run `!setup` first.")
// No expedition: fall through to single-session harvest if the
// player has an active !zone enter run. Single-session harvest
// lives in dnd_zone_harvest.go and stores nodes per-run rather
// than per-region.
run, _ := getActiveZoneRun(ctx.Sender)
if run == nil {
return p.SendDM(ctx.Sender, "You're not in a zone or expedition. Use `!zone enter <id>` or `!expedition start <zone>`.")
}
return p.handleStandaloneHarvest(ctx, char, action, run)
}
if action == HarvestFish && !isFishingZone(exp.ZoneID) {
return p.SendDM(ctx.Sender, "There's no water to fish in here. `!fish` works only in Forest of Shadows, Sunken Temple, Underdark, or Feywild Crossing.")
@@ -582,6 +590,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
if qty <= 0 {
return nil
}
if isHol, _ := isHolidayToday(); isHol {
qty++
}
tier := zoneTierFromID(res.ZoneID)
for i := 0; i < qty; i++ {
item := AdvItem{

View File

@@ -553,7 +553,7 @@ func TestDragonsLair_AwarenessPulseFiresEveryThreeDays(t *testing.T) {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
// +10 awareness pulse + ~3 daily threat drift = 13 (mod GMMood 50 = neutral).
// +10 awareness pulse + ~3 daily threat drift = 13 (mod DMMood 50 = neutral).
if got.ThreatLevel < prevThreat+10 {
t.Errorf("threat = %d, want ≥ %d (awareness pulse +10)", got.ThreatLevel, prevThreat+10)
}

View File

@@ -53,8 +53,8 @@ func TestStartExpedition_RoundTrip(t *testing.T) {
if got.Supplies.Current != 10 || got.Supplies.DailyBurn != 1 {
t.Errorf("supplies round-trip wrong: %+v", got.Supplies)
}
if got.GMMood != 50 {
t.Errorf("gm_mood = %d", got.GMMood)
if got.DMMood != 50 {
t.Errorf("gm_mood = %d", got.DMMood)
}
if got.Camp != nil {
t.Errorf("expected no camp at start")

View File

@@ -81,18 +81,18 @@ func threatBandInfo(b ThreatBand) ThreatBandInfo {
return ThreatBandInfo{Band: ThreatBandQuiet, Label: "Quiet", SupplyMult: 1}
}
// dailyThreatDrift is the +3/day base (§8.1) plus the GMMood-driven mod
// dailyThreatDrift is the +3/day base (§8.1) plus the DMMood-driven mod
// (Wrathful +5, Elated -3). Mood bands map: effusive (≥80) → Elated,
// hostile (<20) → Wrathful, the middle three are neutral.
func dailyThreatDrift(gmMood int) (int, string) {
func dailyThreatDrift(dmMood int) (int, string) {
base := 3
mod := 0
tag := ""
switch {
case gmMood >= 80:
case dmMood >= 80:
mod = -3
tag = "elated"
case gmMood < 20:
case dmMood < 20:
mod = 5
tag = "wrathful"
}
@@ -111,7 +111,7 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
if e.BossDefeated {
return 0, "", nil
}
delta, reason := dailyThreatDrift(e.GMMood)
delta, reason := dailyThreatDrift(e.DMMood)
if delta == 0 {
return 0, reason, nil
}

View File

@@ -632,7 +632,7 @@ func renderSetupComplete(c *DnDCharacter) string {
" HP %d/%d AC %d\n"+
" STR %d DEX %d CON %d INT %d WIS %d CHA %d\n\n"+
"_%s_\n\n"+
"Use `!sheet` anytime to review. Combat, abilities, and rest mechanics arrive in the next phases.",
"Use `!sheet` anytime to review. `!zone list` to head out, or `!expedition list` for a longer run.",
c.Level, ri.Display, ci.Display,
c.HPCurrent, c.HPMax, c.ArmorClass,
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA,

View File

@@ -174,6 +174,5 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, equip map[Equipmen
}
}
b.WriteString("\n_Combat, abilities, and rest mechanics arrive in upcoming phases._")
return b.String()
}

View File

@@ -150,7 +150,6 @@ func (p *AdventurePlugin) applySubclassChoice(
b.WriteString(fmt.Sprintf("_%d euros spent. Cooldown: %dd before next change._\n\n",
dndSubclassRespecCost, int(dndSubclassRespecCooldown/(24*time.Hour))))
}
b.WriteString("_" + info.Flavor + "_\n\n")
b.WriteString("_(Mechanical L5/7/10/15 subclass abilities land in upcoming phases.)_")
b.WriteString("_" + info.Flavor + "_")
return p.SendDM(ctx.Sender, b.String())
}

View File

@@ -10,7 +10,7 @@ import (
// D1a (this file) ships zone *definitions* only — names, tiers, level
// ranges, enemy rosters by bestiary ID, boss stat block, and loot stubs.
// State machine (DungeonRun), commands (!zone enter/advance/etc), and
// TwinBee GM mood land in subsequent D1 sub-phases (D1bD1d).
// TwinBee DM mood land in subsequent D1 sub-phases (D1bD1d).
//
// Tier 1 ships first (Goblin Warrens, Crypt of Valdris). Tiers 25 are
// added in D2/D3/D4/D5. Loot table item IDs reference equipment/treasure

View File

@@ -197,11 +197,11 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
var b strings.Builder
b.WriteString(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms))
b.WriteString("_" + zone.Hook + "_\n\n")
if line := twinBeeLine(zone.ID, GMRoomEntry, run.RunID, run.CurrentRoom); line != "" {
if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n\n")
}
if aside := moodAsideLine(run.GMMood, run.RunID, run.CurrentRoom); aside != "" {
if aside := moodAsideLine(run.DMMood, run.RunID, run.CurrentRoom); aside != "" {
b.WriteString(aside)
b.WriteString("\n\n")
}
@@ -225,18 +225,18 @@ func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error {
var b strings.Builder
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
zone.Display, run.CurrentRoom+1, run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
b.WriteString(fmt.Sprintf("Cleared: %d Loot: %d GM mood: %d/100 (%s)\n",
len(run.RoomsCleared), len(run.LootCollected), run.GMMood, gmMoodLabel(run.GMMood)))
b.WriteString(fmt.Sprintf("Cleared: %d Loot: %d DM mood: %d/100 (%s)\n",
len(run.RoomsCleared), len(run.LootCollected), run.DMMood, dmMoodLabel(run.DMMood)))
b.WriteString(fmt.Sprintf("Started: %s Last action: %s",
run.StartedAt.Format("2006-01-02 15:04"),
run.LastActionAt.Format("2006-01-02 15:04")))
return p.SendDM(ctx.Sender, b.String())
}
// gmMoodLabel returns a human-friendly label for the mood gauge per
// dmMoodLabel returns a human-friendly label for the mood gauge per
// design doc §3.2 mood bands (≥80 effusive, 6079 friendly, 4059 neutral,
// 2039 grumpy, <20 hostile).
func gmMoodLabel(mood int) string {
func dmMoodLabel(mood int) string {
switch {
case mood >= 80:
return "effusive"
@@ -361,22 +361,44 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
// §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.
patrolNarr, patrolEnded, perr := p.tryPatrolEncounter(ctx.Sender, run, zone)
//
// Patrol-then-room is chained into a single 23s-paced stream so the
// player reads patrol → patrol play-by-play → patrol resolution →
// room intro → room play-by-play → final, in one continuous beat.
patrolIntro, patrolPhases, patrolOutcome, patrolEnded, perr := p.tryPatrolEncounter(ctx.Sender, run, zone)
if perr != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve patrol: "+perr.Error())
}
var preStream []string
if patrolPhases != nil {
if patrolIntro != "" {
preStream = append(preStream, patrolIntro)
}
preStream = append(preStream, patrolPhases...)
}
if patrolEnded {
return p.SendDM(ctx.Sender, patrolNarr)
// Patrol dropped the player; run is over.
return p.streamFlow(ctx.Sender, preStream, patrolOutcome)
}
// Patrol survived (or didn't fire). If it fired, patrolOutcome becomes
// a streamed midpoint between the patrol fight and the room resolver.
if patrolPhases != nil && patrolOutcome != "" {
preStream = append(preStream, patrolOutcome)
}
// Resolve the current room *before* clearing it, so combat results
// can decide whether the player advances or the run ends.
resolution, ended, err := p.resolveRoom(ctx.Sender, run, zone)
// can decide whether the player advances or the run ends. Combat
// rooms return a non-nil phases slice — those get streamed with
// 23s delays for suspense; non-combat rooms collapse into a single
// SendDM as before.
intro, phases, outcome, ended, err := p.resolveRoom(ctx.Sender, run, zone)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve room: "+err.Error())
}
if ended {
return p.SendDM(ctx.Sender, resolution)
// Death (combat or otherwise). Stream phases if present, then the
// death narration as the final message.
return p.streamOrSend(ctx.Sender, preStream, intro, phases, outcome)
}
next, err := markRoomCleared(run.RunID)
@@ -386,37 +408,28 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
if next == "" {
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
var b strings.Builder
if patrolNarr != "" {
b.WriteString(patrolNarr)
b.WriteString("\n\n")
}
if resolution != "" {
b.WriteString(resolution)
if outcome != "" {
b.WriteString(outcome)
b.WriteString("\n\n")
}
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
if line := twinBeeLine(zone.ID, GMZoneComplete, run.RunID, prevIdx); line != "" {
if line := twinBeeLine(zone.ID, DMZoneComplete, run.RunID, prevIdx); line != "" {
b.WriteString(line)
b.WriteString("\n\n")
}
// Drop the zone loot table on boss kill.
if granted := p.rollZoneLoot(ctx.Sender, run, zone); len(granted) > 0 {
b.WriteString("**Loot:**\n")
for _, id := range granted {
b.WriteString("• " + id + "\n")
}
}
return p.SendDM(ctx.Sender, b.String())
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
}
nextIdx := run.CurrentRoom + 1
var b strings.Builder
if patrolNarr != "" {
b.WriteString(patrolNarr)
b.WriteString("\n\n")
}
if resolution != "" {
b.WriteString(resolution)
if outcome != "" {
b.WriteString(outcome)
b.WriteString("\n\n")
}
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
@@ -426,16 +439,16 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
b.WriteString("\n\n")
}
} else {
if line := twinBeeLine(zone.ID, GMRoomEntry, run.RunID, nextIdx); line != "" {
if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, nextIdx); line != "" {
b.WriteString(line)
b.WriteString("\n\n")
}
}
// Reload mood — combat-event nat20/nat1 deltas have been persisted via
// scanMoodEventsFromCombat but not reflected on the in-memory run.
freshMood := run.GMMood
freshMood := run.DMMood
if fresh, _ := getZoneRun(run.RunID); fresh != nil {
freshMood = fresh.GMMood
freshMood = fresh.DMMood
}
if aside := moodAsideLine(freshMood, run.RunID, nextIdx); aside != "" {
b.WriteString(aside)
@@ -443,20 +456,58 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
}
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", nextIdx+1, run.TotalRooms, prettyRoomType(next)))
b.WriteString("`!zone advance` to continue.")
return p.SendDM(ctx.Sender, b.String())
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
}
// resolveRoom dispatches to the per-room-type resolver. Returns the
// resolution narration, an `ended` flag (true when the run ended due to
// player death — caller should send the narration and stop), and any
// error encountered. Entry rooms are pure flavor and resolve trivially.
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (string, bool, error) {
// streamOrSend dispatches a staged room resolution. When the room produced
// combat phases (or there was a paced patrol pre-stream), the messages get
// streamed with 23s delays. Otherwise everything collapses into a single
// SendDM. preStream is non-empty when a patrol fight ran ahead of the
// current room and its play-by-play needs to lead the dispatch.
func (p *AdventurePlugin) streamOrSend(userID id.UserID, preStream []string, intro string, phases []string, final string) error {
if phases == nil && len(preStream) == 0 {
var b strings.Builder
if intro != "" {
b.WriteString(intro)
b.WriteString("\n\n")
}
b.WriteString(final)
return p.SendDM(userID, b.String())
}
msgs := append([]string{}, preStream...)
if intro != "" {
msgs = append(msgs, intro)
}
if phases != nil {
msgs = append(msgs, phases...)
}
return p.streamFlow(userID, msgs, final)
}
// streamFlow ships a list of phase messages followed by a final message,
// with the zone-combat 23s delay pacing. Single entry point for both the
// patrol-and-die path and the patrol-leading-into-room path.
func (p *AdventurePlugin) streamFlow(userID id.UserID, phaseMessages []string, finalMessage string) error {
if len(phaseMessages) == 0 {
return p.SendDM(userID, finalMessage)
}
p.sendZoneCombatMessages(userID, phaseMessages, finalMessage)
return nil
}
// resolveRoom dispatches to the per-room-type resolver. Returns staged
// messages (intro, phases, outcome) so combat rooms can be paced with
// inter-phase delays — see resolveCombatRoom for the contract. For
// non-combat rooms (entry, trap), phases is nil and outcome carries the
// resolution narration.
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (intro string, phases []string, outcome string, ended bool, err error) {
switch run.CurrentRoomType() {
case RoomEntry:
return "", false, nil
return
case RoomTrap:
_, narration := p.resolveTrapRoom(userID, run, zone)
return narration, false, nil
outcome = narration
return
case RoomExploration:
return p.resolveCombatRoom(userID, run, zone, false)
case RoomElite:
@@ -464,135 +515,197 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
case RoomBoss:
return p.resolveBossRoom(userID, run, zone)
}
return "", false, nil
return
}
// resolveCombatRoom spawns one roster enemy (elite filter optional),
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
// and renders the narration block. Returns ended=true on player loss.
func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, elite bool) (string, bool, error) {
// and renders the staged narration. Returns:
// intro — pre-combat block (TwinBee combat-start + monster stat block)
// phases — RenderCombatLog output, streamed with delays by the caller
// outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary
// ended — true when the player went down (caller skips next-room teaser)
//
// Phases will be nil only on a "no roster" skip — caller treats that as a
// non-paced fallthrough.
func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, elite bool) (intro string, phases []string, outcome string, ended bool, err error) {
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, elite)
if !ok {
return fmt.Sprintf("_(No %s roster entry — skipping.)_", map[bool]string{true: "elite", false: "exploration"}[elite]), false, nil
outcome = fmt.Sprintf("_(No %s roster entry — skipping.)_", map[bool]string{true: "elite", false: "exploration"}[elite])
return
}
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
if err != nil {
return "", false, err
// Capture D&D-scale HP before combat so the outcome line can show
// sheet HP rather than the engine's legacy-scale numbers.
preHP, _ := dndHPSnapshot(userID)
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier))
if rerr != nil {
err = rerr
return
}
postHP, maxHP := dndHPSnapshot(userID)
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result)
var b strings.Builder
// Intro: pre-combat narration + creature stat block. This lands first,
// before the 58s phase pacing kicks in.
var ib strings.Builder
if elite {
if line := eliteRoomEntryLine(zone.ID, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
ib.WriteString(line)
ib.WriteString("\n")
}
}
if line := twinBeeLine(zone.ID, GMCombatStart, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
// Surface the most-impactful crit/fumble as TwinBee narration. A run
// with both gets the nat-20 line — players already see the fumble in
// the combat log, but the gloat lands harder than the pity.
if nat20s > 0 {
if line := twinBeeLine(zone.ID, GMNat20, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
} else if nat1s > 0 {
if line := twinBeeLine(zone.ID, GMNat1, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
if line := twinBeeLine(zone.ID, DMCombatStart, run.RunID, run.CurrentRoom); line != "" {
ib.WriteString(line)
ib.WriteString("\n")
}
if elite {
b.WriteString(fmt.Sprintf("⚔️ **Elite encounter — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
ib.WriteString(fmt.Sprintf("⚔️ **Elite encounter — %s** (HP %d, AC %d)", monster.Name, monster.HP, monster.AC))
} else {
b.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
ib.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)", monster.Name, monster.HP, monster.AC))
}
intro = ib.String()
// Phases: forward-simulating engine play-by-play. Use the player's
// display name when available so narrative lines read naturally.
playerName := "You"
if char, _ := loadAdvCharacter(userID); char != nil && char.DisplayName != "" {
playerName = char.DisplayName
}
phases = RenderCombatLog(result, playerName, monster.Name)
// Outcome: post-combat block. Nat20/nat1 narration goes here so it
// lands *after* the play-by-play, where it has dramatic context.
var ob strings.Builder
if nat20s > 0 {
if line := twinBeeLine(zone.ID, DMNat20, run.RunID, run.CurrentRoom); line != "" {
ob.WriteString(line)
ob.WriteString("\n")
}
} else if nat1s > 0 {
if line := twinBeeLine(zone.ID, DMNat1, run.RunID, run.CurrentRoom); line != "" {
ob.WriteString(line)
ob.WriteString("\n")
}
}
if !result.PlayerWon {
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID)
if line := twinBeeLine(zone.ID, GMPlayerDeath, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
markAdventureDead(userID, "zone", zone.Display)
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, run.CurrentRoom); line != "" {
ob.WriteString(line)
ob.WriteString("\n")
}
b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name))
return b.String(), true, nil
ob.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name))
if rollLine := dndRollSummaryLine(result); rollLine != "" {
ob.WriteString("\n")
ob.WriteString(rollLine)
}
outcome = ob.String()
ended = true
return
}
if line := twinBeeLine(zone.ID, GMCombatEnd, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
if line := twinBeeLine(zone.ID, DMCombatEnd, run.RunID, run.CurrentRoom); line != "" {
ob.WriteString(line)
ob.WriteString("\n")
}
ob.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).", monster.Name, preHP, postHP, maxHP))
if rollLine := dndRollSummaryLine(result); rollLine != "" {
ob.WriteString("\n")
ob.WriteString(rollLine)
}
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP))
recordZoneKillForUser(userID, monster.ID)
applyRoomCombatThreatForUser(userID, elite)
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
b.WriteString("\n")
b.WriteString(drop)
ob.WriteString("\n")
ob.WriteString(drop)
}
return b.String(), false, nil
outcome = ob.String()
return
}
// resolveBossRoom runs the zone-boss bestiary entry through the same
// combat path as room combat. Win → caller drops zone loot.
func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (string, bool, error) {
//
// 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 {
return fmt.Sprintf("_(Boss %s not in bestiary — skipping.)_", zone.Boss.Name), false, nil
outcome = fmt.Sprintf("_(Boss %s not in bestiary — skipping.)_", zone.Boss.Name)
return
}
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
if err != nil {
return "", false, err
preHP, _ := dndHPSnapshot(userID)
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier))
if rerr != nil {
err = rerr
return
}
postHP, maxHP := dndHPSnapshot(userID)
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result)
var b strings.Builder
b.WriteString(fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
intro = fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)", monster.Name, monster.HP, monster.AC)
playerName := "You"
if char, _ := loadAdvCharacter(userID); char != nil && char.DisplayName != "" {
playerName = char.DisplayName
}
phases = RenderCombatLog(result, playerName, monster.Name)
var ob strings.Builder
if nat20s > 0 {
if line := twinBeeLine(zone.ID, GMNat20, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
if line := twinBeeLine(zone.ID, DMNat20, run.RunID, run.CurrentRoom); line != "" {
ob.WriteString(line)
ob.WriteString("\n")
}
} else if nat1s > 0 {
if line := twinBeeLine(zone.ID, GMNat1, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
if line := twinBeeLine(zone.ID, DMNat1, run.RunID, run.CurrentRoom); line != "" {
ob.WriteString(line)
ob.WriteString("\n")
}
}
if phaseTwoCrossedInEvents(result.Events, result.EnemyStartHP, zone.Boss.PhaseTwoAt) {
if line := bossPhaseTwoLine(zone.ID, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
ob.WriteString(line)
ob.WriteString("\n")
}
}
if !result.PlayerWon {
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID)
if line := twinBeeLine(zone.ID, GMPlayerDeath, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
markAdventureDead(userID, "zone", zone.Display)
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, run.CurrentRoom); line != "" {
ob.WriteString(line)
ob.WriteString("\n")
}
b.WriteString(fmt.Sprintf("💀 **%s** stands over your body. Run ended.", monster.Name))
return b.String(), true, nil
ob.WriteString(fmt.Sprintf("💀 **%s** stands over your body. Run ended.", monster.Name))
if rollLine := dndRollSummaryLine(result); rollLine != "" {
ob.WriteString("\n")
ob.WriteString(rollLine)
}
outcome = ob.String()
ended = true
return
}
if line := twinBeeLine(zone.ID, GMBossDeath, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
if line := twinBeeLine(zone.ID, DMBossDeath, run.RunID, run.CurrentRoom); line != "" {
ob.WriteString(line)
ob.WriteString("\n")
}
ob.WriteString(fmt.Sprintf("🏆 **%s** falls (HP %d→%d / %d).", monster.Name, preHP, postHP, maxHP))
if rollLine := dndRollSummaryLine(result); rollLine != "" {
ob.WriteString("\n")
ob.WriteString(rollLine)
}
b.WriteString(fmt.Sprintf("🏆 **%s** falls (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP))
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, err := getActiveExpedition(userID); err == nil && exp != nil {
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID)
}
if drop := p.dropZoneLoot(userID, zone.ID, monster, true); drop != "" {
b.WriteString("\n")
b.WriteString(drop)
ob.WriteString("\n")
ob.WriteString(drop)
}
return b.String(), false, nil
outcome = ob.String()
return
}
// ── taunt / compliment ──────────────────────────────────────────────────────
@@ -610,7 +723,7 @@ func (p *AdventurePlugin) zoneCmdCompliment(ctx MessageContext) error {
// from the prewritten pool, and report the new mood band.
func (p *AdventurePlugin) zoneMoodInteraction(
ctx MessageContext,
ev GMMoodEvent,
ev DMMoodEvent,
render func(runID string, roomIdx int) string,
icon string,
) error {
@@ -637,8 +750,8 @@ func (p *AdventurePlugin) zoneMoodInteraction(
sign = ""
delta = -delta
}
b.WriteString(fmt.Sprintf("%s GM mood: %d/100 (%s) _(%s%d)_",
icon, newMood, gmMoodLabel(newMood), sign, delta))
b.WriteString(fmt.Sprintf("%s DM mood: %d/100 (%s) _(%s%d)_",
icon, newMood, dmMoodLabel(newMood), sign, delta))
return p.SendDM(ctx.Sender, b.String())
}
@@ -659,7 +772,7 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
}
zone := zoneOrFallback(run.ZoneID)
line := twinBeeLine(zone.ID, GMLore, run.RunID, run.CurrentRoom)
line := twinBeeLine(zone.ID, DMLore, run.RunID, run.CurrentRoom)
if line == "" {
return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.")
}

View File

@@ -239,8 +239,8 @@ func TestGMMoodLabel_Bands(t *testing.T) {
0: "hostile",
}
for mood, want := range cases {
if got := gmMoodLabel(mood); got != want {
t.Errorf("gmMoodLabel(%d) = %s, want %s", mood, got, want)
if got := dmMoodLabel(mood); got != want {
t.Errorf("dmMoodLabel(%d) = %s, want %s", mood, got, want)
}
}
}
@@ -260,7 +260,7 @@ func TestZoneCmd_TauntAppliesMoodPenalty(t *testing.T) {
if err != nil || before == nil {
t.Fatalf("active run after enter: %v", err)
}
startMood := before.GMMood
startMood := before.DMMood
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "taunt"); err != nil {
t.Fatalf("taunt: %v", err)
}
@@ -269,9 +269,9 @@ func TestZoneCmd_TauntAppliesMoodPenalty(t *testing.T) {
t.Fatalf("active run after taunt: %v", err)
}
want := clampMood(startMood + MoodEventDelta(MoodEventTaunt))
if after.GMMood != want {
if after.DMMood != want {
t.Errorf("mood after taunt = %d, want %d (start %d, delta %d)",
after.GMMood, want, startMood, MoodEventDelta(MoodEventTaunt))
after.DMMood, want, startMood, MoodEventDelta(MoodEventTaunt))
}
}
@@ -288,7 +288,7 @@ func TestZoneCmd_ComplimentAppliesMoodBonus(t *testing.T) {
if err != nil || before == nil {
t.Fatalf("active run after enter: %v", err)
}
startMood := before.GMMood
startMood := before.DMMood
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "compliment"); err != nil {
t.Fatalf("compliment: %v", err)
}
@@ -297,9 +297,9 @@ func TestZoneCmd_ComplimentAppliesMoodBonus(t *testing.T) {
t.Fatalf("active run after compliment: %v", err)
}
want := clampMood(startMood + MoodEventDelta(MoodEventCompliment))
if after.GMMood != want {
if after.DMMood != want {
t.Errorf("mood after compliment = %d, want %d (start %d, delta %d)",
after.GMMood, want, startMood, MoodEventDelta(MoodEventCompliment))
after.DMMood, want, startMood, MoodEventDelta(MoodEventCompliment))
}
}
@@ -351,8 +351,8 @@ func TestZoneCmd_LoreWithActiveRunNoSideEffects(t *testing.T) {
if err != nil || after == nil {
t.Fatalf("active run after lore: %v", err)
}
if after.GMMood != before.GMMood {
t.Errorf("lore should not move mood: before %d, after %d", before.GMMood, after.GMMood)
if after.DMMood != before.DMMood {
t.Errorf("lore should not move mood: before %d, after %d", before.DMMood, after.DMMood)
}
if after.CurrentRoom != before.CurrentRoom {
t.Errorf("lore should not advance room: before %d, after %d", before.CurrentRoom, after.CurrentRoom)
@@ -393,14 +393,14 @@ func TestRenderZoneMap_Empty(t *testing.T) {
}
// TestNarrationCoverage_AllZonesAllTypes — D6 polish guarantee. Locks in
// that every GMNarrationType resolves to a non-empty pool for every
// registered zone. Adding a new zone or new GMNarrationType without
// that every DMNarrationType resolves to a non-empty pool for every
// registered zone. Adding a new zone or new DMNarrationType without
// wiring its pool will fail this test.
func TestNarrationCoverage_AllZonesAllTypes(t *testing.T) {
types := []GMNarrationType{
GMRoomEntry, GMCombatStart, GMCombatEnd, GMNat20, GMNat1,
GMBossEntry, GMBossDeath, GMPlayerDeath, GMZoneComplete,
GMTrapDetected, GMTrapTripped, GMLore,
types := []DMNarrationType{
DMRoomEntry, DMCombatStart, DMCombatEnd, DMNat20, DMNat1,
DMBossEntry, DMBossDeath, DMPlayerDeath, DMZoneComplete,
DMTrapDetected, DMTrapTripped, DMLore,
}
for _, z := range allZones() {
for _, k := range types {

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
"math/rand/v2"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
@@ -17,7 +18,7 @@ import (
// into a fight" — spawning enemies from the zone roster, running them
// through the existing combat engine with the player's full D&D layer,
// awarding XP per kill and zone loot on boss defeat, and ending the run
// (mood penalty + GMPlayerDeath narration) if the player goes down.
// (mood penalty + DMPlayerDeath narration) if the player goes down.
//
// Per-room behavior:
// Entry → no combat (already narrated on enter)
@@ -178,6 +179,15 @@ func (p *AdventurePlugin) runZoneCombat(
result := SimulateCombat(player, enemy, dungeonCombatPhases)
// Misty condition repair (post-combat, same 20% chance as arena/encounter
// paths in combat_bridge.go). Mirrors the buff's intent — gourmet food
// keeps her gear in shape on long expeditions, not just in the arena.
if char.MistyBuffExpires != nil && time.Now().UTC().Before(*char.MistyBuffExpires) {
if rand.Float64() < 0.20 {
npcRepairMostDamaged(userID, equip, 5)
}
}
p.grantCombatAchievements(userID, result)
persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP)
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
@@ -286,11 +296,15 @@ func (p *AdventurePlugin) applyTrapEffectWithDetect(
detect SkillCheckResult,
) (int, string) {
var b strings.Builder
if line := twinBeeLine(zone.ID, GMTrapDetected, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
if detect.Success {
// Detected: the TwinBee "you spotted it" line only fires here, so a
// failed detection no longer contradicts the next line. The mismatch
// where "Perception roll pays off" preceded a tripped trap was
// caused by this line firing unconditionally.
if line := twinBeeLine(zone.ID, DMTrapDetected, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(trapSpottedHeader(trap, detect))
return 0, b.String()
}
@@ -308,10 +322,10 @@ func (p *AdventurePlugin) applyTrapEffectWithDetect(
slog.Error("zone: trap HP persist", "user", userID, "err", err)
}
}
if line := twinBeeLine(zone.ID, GMTrapTripped, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
// Trip flavor lives in trap.Trigger (mechanism-specific) which the
// damage header now surfaces. The generic DMTrapTripped pool is no
// longer pulled here — it mixed dart/ceiling/pit/glyph lines that
// frequently mismatched the actual trap.
b.WriteString(trapDamageHeader(trap, dmg, dndChar.HPCurrent, dndChar.HPMax))
return dmg, b.String()
}
@@ -336,15 +350,12 @@ func (p *AdventurePlugin) resolveTrapRoomLegacy(userID id.UserID, run *DungeonRu
if err := SaveDnDCharacter(dndChar); err != nil {
slog.Error("zone: trap HP persist (legacy)", "user", userID, "err", err)
}
// Legacy path always trips — no detection check. Drop the TwinBee pool
// picks here for the same reason as applyTrapEffectWithDetect: the
// detected line shouldn't fire when nothing was detected, and the
// generic tripped pool routinely mismatches the actual trap. Plain
// damage line carries the result.
var b strings.Builder
if line := twinBeeLine(zone.ID, GMTrapDetected, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
if line := twinBeeLine(zone.ID, GMTrapTripped, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("💢 The trap takes **%d HP** (%d/%d remaining).", dmg, dndChar.HPCurrent, dndChar.HPMax))
return dmg, b.String()
}

View File

@@ -10,7 +10,7 @@ import (
"gogobee/internal/flavor"
)
// Phase 11 D1d — TwinBee narration + GM mood triggers. Implements
// Phase 11 D1d — TwinBee narration + DM mood triggers. Implements
// `gogobee_dungeon_zones.md` §3 (TwinBee GM System) on top of the D1b
// state machine.
//
@@ -23,22 +23,22 @@ import (
// surfaces as the human label in `!zone status` and gates downstream
// generosity (loot rolls, hint drops) once those land.
// GMNarrationType — see design doc §7.
type GMNarrationType string
// DMNarrationType — see design doc §7.
type DMNarrationType string
const (
GMRoomEntry GMNarrationType = "room_entry"
GMCombatStart GMNarrationType = "combat_start"
GMCombatEnd GMNarrationType = "combat_end"
GMNat20 GMNarrationType = "nat_20"
GMNat1 GMNarrationType = "nat_1"
GMBossEntry GMNarrationType = "boss_entry"
GMBossDeath GMNarrationType = "boss_death"
GMPlayerDeath GMNarrationType = "player_death"
GMZoneComplete GMNarrationType = "zone_complete"
GMTrapDetected GMNarrationType = "trap_detected"
GMTrapTripped GMNarrationType = "trap_tripped"
GMLore GMNarrationType = "lore"
DMRoomEntry DMNarrationType = "room_entry"
DMCombatStart DMNarrationType = "combat_start"
DMCombatEnd DMNarrationType = "combat_end"
DMNat20 DMNarrationType = "nat_20"
DMNat1 DMNarrationType = "nat_1"
DMBossEntry DMNarrationType = "boss_entry"
DMBossDeath DMNarrationType = "boss_death"
DMPlayerDeath DMNarrationType = "player_death"
DMZoneComplete DMNarrationType = "zone_complete"
DMTrapDetected DMNarrationType = "trap_detected"
DMTrapTripped DMNarrationType = "trap_tripped"
DMLore DMNarrationType = "lore"
)
// MoodBand — five-state band derived from the 0100 score.
@@ -127,31 +127,31 @@ func bossEntryPool(zoneID ZoneID) []string {
// pickPool routes a narration type to the right []string pool from
// internal/flavor. Returns nil for kinds that have no pool yet (caller
// is expected to skip rendering or fall back to a static line).
func pickPool(zoneID ZoneID, kind GMNarrationType) []string {
func pickPool(zoneID ZoneID, kind DMNarrationType) []string {
switch kind {
case GMRoomEntry:
case DMRoomEntry:
return zoneRoomEntryPool(zoneID)
case GMBossEntry:
case DMBossEntry:
return bossEntryPool(zoneID)
case GMCombatStart:
case DMCombatStart:
return flavor.CombatStart
case GMCombatEnd:
case DMCombatEnd:
return flavor.CombatVictory
case GMNat20:
case DMNat20:
return flavor.Nat20
case GMNat1:
case DMNat1:
return flavor.Nat1
case GMBossDeath:
case DMBossDeath:
return flavor.BossDeath
case GMPlayerDeath:
case DMPlayerDeath:
return flavor.PlayerDeath
case GMZoneComplete:
case DMZoneComplete:
return flavor.ZoneComplete
case GMTrapDetected:
case DMTrapDetected:
return flavor.TrapDetected
case GMTrapTripped:
case DMTrapTripped:
return flavor.TrapTriggered
case GMLore:
case DMLore:
return zoneLorePool(zoneID)
}
return nil
@@ -302,7 +302,7 @@ func eliteRoomEntryPool(zoneID ZoneID) []string {
// has a signature pool, a single ability callout on the line below.
// Falls back to the bare boss-entry line when no signature pool exists.
func composeBossEntry(zoneID ZoneID, runID string, roomIdx int) string {
entry := twinBeeLine(zoneID, GMBossEntry, runID, roomIdx)
entry := twinBeeLine(zoneID, DMBossEntry, runID, roomIdx)
pool := bossSignaturePool(zoneID)
if entry == "" || len(pool) == 0 {
return entry
@@ -352,7 +352,7 @@ func pickLineDeterministic(lines []string, runID string, salt int) string {
//
// roomIdx is folded into the deterministic selector so the same room
// in the same run always renders the same prose (idempotent reads).
func twinBeeLine(zoneID ZoneID, kind GMNarrationType, runID string, roomIdx int) string {
func twinBeeLine(zoneID ZoneID, kind DMNarrationType, runID string, roomIdx int) string {
pool := pickPool(zoneID, kind)
line := pickLineDeterministic(pool, runID, roomIdx)
if line == "" {
@@ -414,22 +414,22 @@ func moodAsideLine(mood int, runID string, roomIdx int) string {
// ── Mood event table & application ───────────────────────────────────────────
// GMMoodEvent enumerates the mood-modifier triggers from design doc §3.2.
type GMMoodEvent string
// DMMoodEvent enumerates the mood-modifier triggers from design doc §3.2.
type DMMoodEvent string
const (
MoodEventZoneComplete GMMoodEvent = "zone_complete"
MoodEventPlayerDeath GMMoodEvent = "player_death"
MoodEventNat20 GMMoodEvent = "nat_20"
MoodEventNat1 GMMoodEvent = "nat_1"
MoodEventCommunityMilestone GMMoodEvent = "community_milestone"
MoodEventDowntime GMMoodEvent = "downtime"
MoodEventTaunt GMMoodEvent = "taunt"
MoodEventCompliment GMMoodEvent = "compliment"
MoodEventZoneComplete DMMoodEvent = "zone_complete"
MoodEventPlayerDeath DMMoodEvent = "player_death"
MoodEventNat20 DMMoodEvent = "nat_20"
MoodEventNat1 DMMoodEvent = "nat_1"
MoodEventCommunityMilestone DMMoodEvent = "community_milestone"
MoodEventDowntime DMMoodEvent = "downtime"
MoodEventTaunt DMMoodEvent = "taunt"
MoodEventCompliment DMMoodEvent = "compliment"
)
// moodEventDelta — design doc §3.2 mood-modifier table.
var moodEventDelta = map[GMMoodEvent]int{
var moodEventDelta = map[DMMoodEvent]int{
MoodEventZoneComplete: +10,
MoodEventPlayerDeath: -5,
MoodEventNat20: +3,
@@ -441,11 +441,11 @@ var moodEventDelta = map[GMMoodEvent]int{
}
// MoodEventDelta exposes the static delta for an event (test use).
func MoodEventDelta(ev GMMoodEvent) int { return moodEventDelta[ev] }
func MoodEventDelta(ev DMMoodEvent) int { return moodEventDelta[ev] }
// applyMoodEvent updates the run's mood by the event's delta. Returns
// the new mood score after clamping.
func applyMoodEvent(runID string, ev GMMoodEvent) (int, error) {
func applyMoodEvent(runID string, ev DMMoodEvent) (int, error) {
delta, ok := moodEventDelta[ev]
if !ok {
return 0, fmt.Errorf("unknown mood event: %s", ev)
@@ -457,7 +457,7 @@ func applyMoodEvent(runID string, ev GMMoodEvent) (int, error) {
if err != nil || r == nil {
return 0, err
}
return r.GMMood, nil
return r.DMMood, nil
}
// passiveDecayMood drifts the mood toward 50 at ±2/hour, scaled by
@@ -509,8 +509,8 @@ func applyMoodDecayIfStale(r *DungeonRun) error {
if r == nil {
return nil
}
newMood := passiveDecayMood(r.GMMood, r.LastActionAt, time.Now().UTC())
if newMood == r.GMMood {
newMood := passiveDecayMood(r.DMMood, r.LastActionAt, time.Now().UTC())
if newMood == r.DMMood {
return nil
}
if _, err := db.Get().Exec(`
@@ -518,6 +518,6 @@ func applyMoodDecayIfStale(r *DungeonRun) error {
newMood, r.RunID); err != nil {
return err
}
r.GMMood = newMood
r.DMMood = newMood
return nil
}

View File

@@ -31,7 +31,7 @@ func TestMoodBand_Boundaries(t *testing.T) {
}
func TestMoodEventDelta_DesignDocTable(t *testing.T) {
want := map[GMMoodEvent]int{
want := map[DMMoodEvent]int{
MoodEventZoneComplete: +10,
MoodEventPlayerDeath: -5,
MoodEventNat20: +3,
@@ -75,8 +75,8 @@ func TestPassiveDecayMood_DriftsTowardFifty(t *testing.T) {
}
func TestTwinBeeLine_DeterministicAcrossCalls(t *testing.T) {
a := twinBeeLine(ZoneGoblinWarrens, GMRoomEntry, "abc123", 2)
b := twinBeeLine(ZoneGoblinWarrens, GMRoomEntry, "abc123", 2)
a := twinBeeLine(ZoneGoblinWarrens, DMRoomEntry, "abc123", 2)
b := twinBeeLine(ZoneGoblinWarrens, DMRoomEntry, "abc123", 2)
if a != b {
t.Errorf("non-deterministic:\n a=%q\n b=%q", a, b)
}
@@ -86,7 +86,7 @@ func TestTwinBeeLine_DeterministicAcrossCalls(t *testing.T) {
// Different room index should typically yield a different line
// (chance of accidental collision is 1/N; with the Goblin+Generic
// pool that's tiny but theoretically possible — only assert prefix).
c := twinBeeLine(ZoneGoblinWarrens, GMRoomEntry, "abc123", 3)
c := twinBeeLine(ZoneGoblinWarrens, DMRoomEntry, "abc123", 3)
if !strings.HasPrefix(c, "🎭 **TwinBee:**") {
t.Errorf("missing prefix on alt salt: %q", c)
}
@@ -94,18 +94,18 @@ func TestTwinBeeLine_DeterministicAcrossCalls(t *testing.T) {
func TestTwinBeeLine_BossEntryUsesNamedPool(t *testing.T) {
// Grol's pool is a single hand-crafted line.
got := twinBeeLine(ZoneGoblinWarrens, GMBossEntry, "any", 0)
got := twinBeeLine(ZoneGoblinWarrens, DMBossEntry, "any", 0)
if !strings.Contains(got, "Grol") {
t.Errorf("Goblin Warrens boss entry should mention Grol; got %q", got)
}
got = twinBeeLine(ZoneCryptValdris, GMBossEntry, "any", 0)
got = twinBeeLine(ZoneCryptValdris, DMBossEntry, "any", 0)
if !strings.Contains(got, "Valdris") {
t.Errorf("Crypt boss entry should mention Valdris; got %q", got)
}
}
func TestTwinBeeLine_UnknownKindReturnsEmpty(t *testing.T) {
if got := twinBeeLine(ZoneGoblinWarrens, GMNarrationType("not_a_real_kind"), "x", 0); got != "" {
if got := twinBeeLine(ZoneGoblinWarrens, DMNarrationType("not_a_real_kind"), "x", 0); got != "" {
t.Errorf("expected empty for unknown kind, got %q", got)
}
}
@@ -120,8 +120,8 @@ func TestApplyMoodEvent_PersistsClampedDelta(t *testing.T) {
if err != nil {
t.Fatalf("start: %v", err)
}
if run.GMMood != 50 {
t.Fatalf("starting mood = %d, want 50", run.GMMood)
if run.DMMood != 50 {
t.Fatalf("starting mood = %d, want 50", run.DMMood)
}
// +10 → 60
@@ -144,8 +144,8 @@ func TestApplyMoodEvent_PersistsClampedDelta(t *testing.T) {
_, _ = applyMoodEvent(run.RunID, MoodEventDowntime)
}
r, _ := getZoneRun(run.RunID)
if r.GMMood != 0 {
t.Errorf("after extreme negative stack: mood = %d, want 0 (clamped)", r.GMMood)
if r.DMMood != 0 {
t.Errorf("after extreme negative stack: mood = %d, want 0 (clamped)", r.DMMood)
}
// Clamp upward
@@ -153,13 +153,13 @@ func TestApplyMoodEvent_PersistsClampedDelta(t *testing.T) {
_, _ = applyMoodEvent(run.RunID, MoodEventCommunityMilestone)
}
r, _ = getZoneRun(run.RunID)
if r.GMMood != 100 {
t.Errorf("after extreme positive stack: mood = %d, want 100 (clamped)", r.GMMood)
if r.DMMood != 100 {
t.Errorf("after extreme positive stack: mood = %d, want 100 (clamped)", r.DMMood)
}
}
func TestApplyMoodEvent_UnknownEventErrors(t *testing.T) {
if _, err := applyMoodEvent("fake-run", GMMoodEvent("ghosts")); err == nil {
if _, err := applyMoodEvent("fake-run", DMMoodEvent("ghosts")); err == nil {
t.Error("expected error on unknown mood event")
}
}
@@ -363,8 +363,8 @@ func TestZoneCmd_AdvanceFinalRoomBumpsMood(t *testing.T) {
if !final.BossDefeated {
t.Error("boss should be defeated after final markRoomCleared")
}
if final.GMMood != 60 {
t.Errorf("post-completion mood = %d, want 60 (50 + zone_complete +10)", final.GMMood)
if final.DMMood != 60 {
t.Errorf("post-completion mood = %d, want 60 (50 + zone_complete +10)", final.DMMood)
}
}

View File

@@ -56,7 +56,7 @@ type DungeonRun struct {
BossDefeated bool
Abandoned bool
LootCollected []string
GMMood int
DMMood int
StartedAt time.Time
LastActionAt time.Time
CompletedAt *time.Time
@@ -178,6 +178,10 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
seq := generateRoomSequence(zone, rng)
seqJSON, _ := json.Marshal(seq)
startMood := 50
if isHol, _ := isHolidayToday(); isHol {
startMood = 55
}
run := &DungeonRun{
RunID: newRunID(),
UserID: string(userID),
@@ -186,7 +190,7 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
TotalRooms: len(seq),
RoomSeq: seq,
RoomsCleared: []int{},
GMMood: 50,
DMMood: startMood,
StartedAt: time.Now().UTC(),
LastActionAt: time.Now().UTC(),
}
@@ -194,8 +198,8 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
INSERT INTO dnd_zone_run
(run_id, user_id, zone_id, current_room, total_rooms,
room_seq_json, rooms_cleared, gm_mood)
VALUES (?, ?, ?, 0, ?, ?, '[]', 50)`,
run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON),
VALUES (?, ?, ?, 0, ?, ?, '[]', ?)`,
run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON), startMood,
); err != nil {
return nil, fmt.Errorf("insert zone run: %w", err)
}
@@ -271,7 +275,7 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
if err := row.Scan(
&r.RunID, &r.UserID, &zoneID, &r.CurrentRoom, &r.TotalRooms,
&seqJSON, &clearedJSON, &bossDefeatedI, &abandonedI,
&lootJSON, &r.GMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw,
&lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw,
); err != nil {
return nil, err
}

View File

@@ -83,8 +83,8 @@ func TestStartZoneRun_HappyPath(t *testing.T) {
if run.CurrentRoom != 0 {
t.Errorf("current room = %d", run.CurrentRoom)
}
if run.GMMood != 50 {
t.Errorf("gm mood = %d", run.GMMood)
if run.DMMood != 50 {
t.Errorf("gm mood = %d", run.DMMood)
}
if !run.IsActive() {
t.Error("expected run active")
@@ -201,15 +201,15 @@ func TestAdjustGMMoodClampsBounds(t *testing.T) {
t.Fatal(err)
}
r, _ := getZoneRun(run.RunID)
if r.GMMood != 100 {
t.Errorf("upper clamp: mood = %d", r.GMMood)
if r.DMMood != 100 {
t.Errorf("upper clamp: mood = %d", r.DMMood)
}
if err := adjustGMMood(run.RunID, -250); err != nil {
t.Fatal(err)
}
r, _ = getZoneRun(run.RunID)
if r.GMMood != 0 {
t.Errorf("lower clamp: mood = %d", r.GMMood)
if r.DMMood != 0 {
t.Errorf("lower clamp: mood = %d", r.DMMood)
}
}

View File

@@ -285,8 +285,11 @@ func trapDamageHeader(tr zoneTrapDef, dmg int, hpCur, hpMax int) string {
return fmt.Sprintf("🚫 **%s** — %s. Spells fizzle and enchanted gear sleeps until you leave the room.", tr.Display, tr.Trigger)
}
dice := fmt.Sprintf("%dd%d", tr.DamageDiceN, tr.DamageDiceD)
return fmt.Sprintf("💢 **%s** (%s %s) — **%d HP** (%d/%d remaining).",
tr.Display, dice, tr.DamageType, dmg, hpCur, hpMax)
// Lead with the trap's own mechanism-specific Trigger so the narrative
// matches the trap kind (e.g. a Collapsing Ceiling no longer reads as
// a dart in the air). Trigger is required on every catalog entry.
return fmt.Sprintf("💢 **%s** — %s. (%s %s) — **%d HP** (%d/%d remaining).",
tr.Display, tr.Trigger, dice, tr.DamageType, dmg, hpCur, hpMax)
}
// trapSpottedHeader — narration for a successful detection roll. No