mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
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:
@@ -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
|
||||
|
||||
315
internal/plugin/adv2_scenario_test.go
Normal file
315
internal/plugin/adv2_scenario_test.go
Normal 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' L1–L3 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) // 5–8s
|
||||
}
|
||||
|
||||
// sendZoneCombatMessages is the zone/expedition variant — same staging as
|
||||
// arena, but tighter pacing (2–3s) 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) // 2–3s
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ®ionJSON,
|
||||
&e.XPEarned, &e.CoinsEarned, &e.GMMood,
|
||||
&e.XPEarned, &e.CoinsEarned, &e.DMMood,
|
||||
&lastBriefingRaw, &lastRecapRaw, &e.LastActivity, &completedRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 2–3s 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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 (D1b–D1d).
|
||||
// TwinBee DM mood land in subsequent D1 sub-phases (D1b–D1d).
|
||||
//
|
||||
// Tier 1 ships first (Goblin Warrens, Crypt of Valdris). Tiers 2–5 are
|
||||
// added in D2/D3/D4/D5. Loot table item IDs reference equipment/treasure
|
||||
|
||||
@@ -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, 60–79 friendly, 40–59 neutral,
|
||||
// 20–39 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 2–3s-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
|
||||
// 2–3s 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 2–3s 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 2–3s 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 5–8s 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.")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 0–100 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user