package plugin import ( "strings" "testing" "time" "gogobee/internal/flavor" "maunium.net/go/mautrix/id" ) // Phase 11 D1d — TwinBee narration + mood event tests. func TestMoodBand_Boundaries(t *testing.T) { cases := []struct { score int want MoodBand }{ {0, MoodBandHostile}, {19, MoodBandHostile}, {20, MoodBandGrumpy}, {39, MoodBandGrumpy}, {40, MoodBandNeutral}, {59, MoodBandNeutral}, {60, MoodBandFriendly}, {79, MoodBandFriendly}, {80, MoodBandEffusive}, {100, MoodBandEffusive}, } for _, c := range cases { if got := moodBand(c.score); got != c.want { t.Errorf("moodBand(%d) = %d, want %d", c.score, got, c.want) } } } func TestMoodEventDelta_DesignDocTable(t *testing.T) { want := map[DMMoodEvent]int{ MoodEventZoneComplete: +10, MoodEventPlayerDeath: -5, MoodEventNat20: +3, MoodEventNat1: -2, MoodEventCommunityMilestone: +15, MoodEventDowntime: -20, MoodEventTaunt: -10, MoodEventCompliment: +5, } for ev, delta := range want { if got := MoodEventDelta(ev); got != delta { t.Errorf("delta(%s) = %d, want %d", ev, got, delta) } } } func TestPassiveDecayMood_DriftsTowardFifty(t *testing.T) { now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC) cases := []struct { name string score int hoursAgo int want int }{ {"high-score-decays-down", 80, 5, 70}, // -2/hr * 5 {"high-clamps-at-50", 80, 100, 50}, // would overshoot {"low-score-decays-up", 10, 5, 20}, // +2/hr * 5 {"low-clamps-at-50", 10, 100, 50}, // would overshoot {"already-50-no-change", 50, 100, 50}, // neutral stays neutral {"fresh-no-decay", 80, 0, 80}, // no time elapsed {"sub-hour-no-decay", 80, 0, 80}, // hours==0 → no drift } for _, c := range cases { t.Run(c.name, func(t *testing.T) { last := now.Add(-time.Duration(c.hoursAgo) * time.Hour) if got := passiveDecayMood(c.score, last, now); got != c.want { t.Errorf("decay(%d, %dh ago) = %d, want %d", c.score, c.hoursAgo, got, c.want) } }) } } func TestTwinBeeLine_DeterministicAcrossCalls(t *testing.T) { 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) } if !strings.HasPrefix(a, "🎭 **TwinBee:**") { t.Errorf("missing TwinBee prefix: %q", a) } // 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, DMRoomEntry, "abc123", 3) if !strings.HasPrefix(c, "🎭 **TwinBee:**") { t.Errorf("missing prefix on alt salt: %q", c) } } func TestTwinBeeLine_BossEntryUsesNamedPool(t *testing.T) { // Grol's pool is a single hand-crafted line. 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, 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, DMNarrationType("not_a_real_kind"), "x", 0); got != "" { t.Errorf("expected empty for unknown kind, got %q", got) } } func TestApplyMoodEvent_PersistsClampedDelta(t *testing.T) { setupAuditTestDB(t) uid := id.UserID("@zone-mood-evt:example") zoneCmdTestCharacter(t, uid, 1) defer cleanupZoneRuns(uid) run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) if err != nil { t.Fatalf("start: %v", err) } if run.DMMood != 50 { t.Fatalf("starting mood = %d, want 50", run.DMMood) } // +10 → 60 got, err := applyMoodEvent(run.RunID, MoodEventZoneComplete) if err != nil { t.Fatalf("apply zone_complete: %v", err) } if got != 60 { t.Errorf("after zone_complete: mood = %d, want 60", got) } // -20 → 40 got, _ = applyMoodEvent(run.RunID, MoodEventDowntime) if got != 40 { t.Errorf("after downtime: mood = %d, want 40", got) } // Clamp test: huge negative stack → 0 for i := 0; i < 20; i++ { _, _ = applyMoodEvent(run.RunID, MoodEventDowntime) } r, _ := getZoneRun(run.RunID) if r.DMMood != 0 { t.Errorf("after extreme negative stack: mood = %d, want 0 (clamped)", r.DMMood) } // Clamp upward for i := 0; i < 20; i++ { _, _ = applyMoodEvent(run.RunID, MoodEventCommunityMilestone) } r, _ = getZoneRun(run.RunID) 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", DMMoodEvent("ghosts")); err == nil { t.Error("expected error on unknown mood event") } } // ── Phase 11 D2b — Tier 1 zone flavor file routing ────────────────────────── func TestComposeBossEntry_AppendsAbilityCalloutForTier1(t *testing.T) { got := composeBossEntry(ZoneGoblinWarrens, "run-d2b", 7) if !strings.Contains(got, "Grol") { t.Errorf("Warrens boss-entry should mention Grol: %q", got) } // Two TwinBee prefixes = entry + callout suffix. if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { t.Errorf("Warrens boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) } got = composeBossEntry(ZoneCryptValdris, "run-d2b", 7) if !strings.Contains(got, "Valdris") { t.Errorf("Crypt boss-entry should mention Valdris: %q", got) } if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { t.Errorf("Crypt boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) } } // (Dragon's Lair gained signature callouts and an elite pool in D5b; see // the Tier 4-5 routing tests below for its current behavior.) func TestComposeBossEntry_DeterministicAcrossCalls(t *testing.T) { a := composeBossEntry(ZoneGoblinWarrens, "stable-run", 4) b := composeBossEntry(ZoneGoblinWarrens, "stable-run", 4) if a != b { t.Errorf("compose not deterministic:\n a=%q\n b=%q", a, b) } } func TestEliteRoomEntryLine_RoutesPerZone(t *testing.T) { w := eliteRoomEntryLine(ZoneGoblinWarrens, "run-elite", 3) if w == "" || !strings.HasPrefix(w, "🎭 **TwinBee:**") { t.Errorf("Warrens elite line missing/no prefix: %q", w) } c := eliteRoomEntryLine(ZoneCryptValdris, "run-elite", 3) if c == "" || !strings.HasPrefix(c, "🎭 **TwinBee:**") { t.Errorf("Crypt elite line missing/no prefix: %q", c) } if w == c { t.Errorf("Warrens and Crypt elite lines collided on same salt — pools shouldn't overlap") } } func TestZoneLorePool_PrependsZoneSpecific(t *testing.T) { w := zoneLorePool(ZoneGoblinWarrens) if len(w) <= len(flavor.LoreLines) { t.Errorf("Warrens lore pool should be larger than generic; got %d vs generic %d", len(w), len(flavor.LoreLines)) } } // ── Phase 11 D3c — Tier 2 zone flavor file routing ────────────────────────── func TestComposeBossEntry_AppendsAbilityCalloutForTier2(t *testing.T) { got := composeBossEntry(ZoneForestShadows, "run-d3c", 5) if !strings.Contains(got, "Hollow King") { t.Errorf("Forest boss-entry should mention Hollow King: %q", got) } if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { t.Errorf("Forest boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) } got = composeBossEntry(ZoneSunkenTemple, "run-d3c", 5) if !strings.Contains(got, "Aboleth") { t.Errorf("Sunken Temple boss-entry should mention Aboleth: %q", got) } if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { t.Errorf("Sunken Temple boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) } } func TestEliteRoomEntryLine_Tier2Routes(t *testing.T) { f := eliteRoomEntryLine(ZoneForestShadows, "run-elite-t2", 3) if f == "" || !strings.HasPrefix(f, "🎭 **TwinBee:**") { t.Errorf("Forest elite line missing/no prefix: %q", f) } s := eliteRoomEntryLine(ZoneSunkenTemple, "run-elite-t2", 3) if s == "" || !strings.HasPrefix(s, "🎭 **TwinBee:**") { t.Errorf("Sunken Temple elite line missing/no prefix: %q", s) } if f == s { t.Errorf("Forest and Sunken Temple elite lines collided on same salt") } } func TestZoneLorePool_Tier2PrependsZoneSpecific(t *testing.T) { f := zoneLorePool(ZoneForestShadows) if len(f) <= len(flavor.LoreLines) { t.Errorf("Forest lore pool should be larger than generic; got %d vs generic %d", len(f), len(flavor.LoreLines)) } s := zoneLorePool(ZoneSunkenTemple) if len(s) <= len(flavor.LoreLines) { t.Errorf("Sunken Temple lore pool should be larger than generic; got %d vs generic %d", len(s), len(flavor.LoreLines)) } } func TestRoomEntryPool_Tier2PrependsZoneSpecific(t *testing.T) { f := zoneRoomEntryPool(ZoneForestShadows) if len(f) <= len(flavor.RoomEntryGeneric) { t.Errorf("Forest room-entry pool should overlay zone-specific lines; got %d", len(f)) } s := zoneRoomEntryPool(ZoneSunkenTemple) if len(s) <= len(flavor.RoomEntryGeneric) { t.Errorf("Sunken Temple room-entry pool should overlay zone-specific lines; got %d", len(s)) } } // ── Phase 11 D4c — Tier 3 zone flavor file routing ────────────────────────── func TestComposeBossEntry_AppendsAbilityCalloutForTier3(t *testing.T) { got := composeBossEntry(ZoneManorBlackspire, "run-d4c", 5) if !strings.Contains(got, "Aldric") { t.Errorf("Manor boss-entry should mention Aldric: %q", got) } if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { t.Errorf("Manor boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) } got = composeBossEntry(ZoneUnderforge, "run-d4c", 5) if !strings.Contains(got, "Thyrak") { t.Errorf("Underforge boss-entry should mention Thyrak: %q", got) } if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { t.Errorf("Underforge boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) } } func TestEliteRoomEntryLine_Tier3Routes(t *testing.T) { m := eliteRoomEntryLine(ZoneManorBlackspire, "run-elite-t3", 3) if m == "" || !strings.HasPrefix(m, "🎭 **TwinBee:**") { t.Errorf("Manor elite line missing/no prefix: %q", m) } u := eliteRoomEntryLine(ZoneUnderforge, "run-elite-t3", 3) if u == "" || !strings.HasPrefix(u, "🎭 **TwinBee:**") { t.Errorf("Underforge elite line missing/no prefix: %q", u) } if m == u { t.Errorf("Manor and Underforge elite lines collided on same salt") } } func TestZoneLorePool_Tier3PrependsZoneSpecific(t *testing.T) { m := zoneLorePool(ZoneManorBlackspire) if len(m) <= len(flavor.LoreLines) { t.Errorf("Manor lore pool should be larger than generic; got %d vs generic %d", len(m), len(flavor.LoreLines)) } u := zoneLorePool(ZoneUnderforge) if len(u) <= len(flavor.LoreLines) { t.Errorf("Underforge lore pool should be larger than generic; got %d vs generic %d", len(u), len(flavor.LoreLines)) } } func TestRoomEntryPool_Tier3PrependsZoneSpecific(t *testing.T) { m := zoneRoomEntryPool(ZoneManorBlackspire) if len(m) <= len(flavor.RoomEntryGeneric) { t.Errorf("Manor room-entry pool should overlay zone-specific lines; got %d", len(m)) } u := zoneRoomEntryPool(ZoneUnderforge) if len(u) <= len(flavor.RoomEntryGeneric) { t.Errorf("Underforge room-entry pool should overlay zone-specific lines; got %d", len(u)) } } func TestZoneCmd_AdvanceFinalRoomBumpsMood(t *testing.T) { setupAuditTestDB(t) uid := id.UserID("@zone-mood-complete:example") zoneCmdTestCharacter(t, uid, 1) defer cleanupZoneRuns(uid) // D1e wired real combat into !zone advance, so the cmd-level path // is no longer deterministic for an L1 fighter against the Goblin // Warrens roster. Drive the persistence layer directly to assert // the mood-on-completion contract. run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) if err != nil { t.Fatal(err) } for i := 0; i < run.TotalRooms; i++ { if _, err := markRoomCleared(run.RunID); err != nil { t.Fatalf("markRoomCleared %d: %v", i, err) } } if _, err := applyMoodEvent(run.RunID, MoodEventZoneComplete); err != nil { t.Fatal(err) } final, err := getZoneRun(run.RunID) if err != nil || final == nil { t.Fatal("could not fetch completed run") } if !final.BossDefeated { t.Error("boss should be defeated after final markRoomCleared") } if final.DMMood != 60 { t.Errorf("post-completion mood = %d, want 60 (50 + zone_complete +10)", final.DMMood) } } // ── Phase 11 D5b — Tier 4-5 zone flavor file routing ──────────────────────── func TestComposeBossEntry_AppendsAbilityCalloutForTier4(t *testing.T) { got := composeBossEntry(ZoneUnderdark, "run-d5b", 5) if !strings.Contains(got, "Ilvaras") { t.Errorf("Underdark boss-entry should mention Ilvaras: %q", got) } if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { t.Errorf("Underdark boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) } got = composeBossEntry(ZoneFeywildCrossing, "run-d5b", 5) if !strings.Contains(got, "Thornmother") { t.Errorf("Feywild boss-entry should mention Thornmother: %q", got) } if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { t.Errorf("Feywild boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) } } func TestComposeBossEntry_AppendsAbilityCalloutForTier5(t *testing.T) { got := composeBossEntry(ZoneDragonsLair, "run-d5b", 5) if !strings.Contains(got, "Infernax") { t.Errorf("Dragon's Lair boss-entry should mention Infernax: %q", got) } if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { t.Errorf("Dragon's Lair boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) } got = composeBossEntry(ZoneAbyssPortal, "run-d5b", 5) if !strings.Contains(got, "Belaxath") { t.Errorf("Abyss boss-entry should mention Belaxath: %q", got) } if n := strings.Count(got, "🎭 **TwinBee:**"); n != 2 { t.Errorf("Abyss boss-entry expected 2 TwinBee blocks (entry+callout), got %d:\n%s", n, got) } } func TestEliteRoomEntryLine_Tier4And5Routes(t *testing.T) { u := eliteRoomEntryLine(ZoneUnderdark, "run-elite-t45", 3) if u == "" || !strings.HasPrefix(u, "🎭 **TwinBee:**") { t.Errorf("Underdark elite line missing/no prefix: %q", u) } f := eliteRoomEntryLine(ZoneFeywildCrossing, "run-elite-t45", 3) if f == "" || !strings.HasPrefix(f, "🎭 **TwinBee:**") { t.Errorf("Feywild elite line missing/no prefix: %q", f) } d := eliteRoomEntryLine(ZoneDragonsLair, "run-elite-t45", 3) if d == "" || !strings.HasPrefix(d, "🎭 **TwinBee:**") { t.Errorf("Dragon's Lair elite line missing/no prefix: %q", d) } a := eliteRoomEntryLine(ZoneAbyssPortal, "run-elite-t45", 3) if a == "" || !strings.HasPrefix(a, "🎭 **TwinBee:**") { t.Errorf("Abyss elite line missing/no prefix: %q", a) } if u == f || u == d || u == a || f == d || f == a || d == a { t.Errorf("Tier 4-5 elite lines collided on same salt — pools shouldn't overlap") } } func TestZoneLorePool_Tier4And5PrependsZoneSpecific(t *testing.T) { for _, z := range []ZoneID{ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal} { if got := zoneLorePool(z); len(got) <= len(flavor.LoreLines) { t.Errorf("%s lore pool should be larger than generic; got %d vs generic %d", z, len(got), len(flavor.LoreLines)) } } } func TestRoomEntryPool_Tier4And5PrependsZoneSpecific(t *testing.T) { // All four T4-5 zones should now overlay zone-specific room entries. for _, z := range []ZoneID{ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal} { if got := zoneRoomEntryPool(z); len(got) <= len(flavor.RoomEntryGeneric) { t.Errorf("%s room-entry pool should overlay zone-specific lines; got %d", z, len(got)) } } } // ── Phase 11 D6 — boss phase-two narration ────────────────────────────────── func TestBossPhaseTwoPool_RoutesEightZones(t *testing.T) { cases := map[ZoneID]int{ ZoneCryptValdris: len(flavor.ValdrisPhaseTwoLines), ZoneForestShadows: len(flavor.HollowKingPhaseTwoLines), ZoneManorBlackspire: len(flavor.AldricPhaseTwoLines), ZoneUnderforge: len(flavor.ThyrakPhaseTwoLines), ZoneUnderdark: len(flavor.IlvarasPhaseTwoLines), ZoneFeywildCrossing: len(flavor.ThornmotherPhaseTwoLines), ZoneDragonsLair: len(flavor.InfernaxPhaseTwoLines), ZoneAbyssPortal: len(flavor.BelaxathPhaseTwoLines), } for z, want := range cases { got := bossPhaseTwoPool(z) if len(got) == 0 || len(got) != want { t.Errorf("%s phase-two pool len mismatch: got %d, want %d", z, len(got), want) } } // Bosses without a phase-two transition return nil. for _, z := range []ZoneID{ZoneGoblinWarrens, ZoneSunkenTemple} { if got := bossPhaseTwoPool(z); got != nil { t.Errorf("%s should not have a phase-two pool (no PhaseTwoAt)", z) } } } func TestBossPhaseTwoLine_PrependsTwinBee(t *testing.T) { line := bossPhaseTwoLine(ZoneCryptValdris, "run-d6", 4) if !strings.HasPrefix(line, "🎭 **TwinBee:**") { t.Errorf("phase-two line missing TwinBee prefix: %q", line) } if bossPhaseTwoLine(ZoneGoblinWarrens, "run-d6", 4) != "" { t.Errorf("Grol has no phase-two pool — expected empty string") } } func TestPhaseTwoCrossedInEvents(t *testing.T) { startHP := 100 threshold := 0.5 events := []CombatEvent{{EnemyHP: 80}, {EnemyHP: 60}, {EnemyHP: 40}} if !phaseTwoCrossedInEvents(events, startHP, threshold) { t.Errorf("expected crossing at EnemyHP=40 (cutoff=50)") } if phaseTwoCrossedInEvents(events[:2], startHP, threshold) { t.Errorf("did not cross 50%% by event 2 (EnemyHP=60); should be false") } if phaseTwoCrossedInEvents(events, startHP, 0) { t.Errorf("threshold 0 means no phase 2 — should be false") } if phaseTwoCrossedInEvents(events, 0, threshold) { t.Errorf("startHP 0 — should be false (degenerate)") } } // ── Phase 11 D8 — mood-banded TwinBee aside ───────────────────────────────── func TestMoodAsideLine_OnlyAtExtremes(t *testing.T) { // Mid-bands return empty — flavor stays strictly extra. for _, mood := range []int{20, 39, 40, 50, 59, 60, 79} { if got := moodAsideLine(mood, "run-aside", 3); got != "" { t.Errorf("mood=%d (mid-band) should produce no aside; got %q", mood, got) } } // Extremes always produce a line (pools are non-empty). for _, mood := range []int{0, 19} { got := moodAsideLine(mood, "run-aside", 3) if got == "" || !strings.HasPrefix(got, "🎭 **TwinBee:**") { t.Errorf("hostile mood=%d should produce aside; got %q", mood, got) } } for _, mood := range []int{80, 100} { got := moodAsideLine(mood, "run-aside", 3) if got == "" || !strings.HasPrefix(got, "🎭 **TwinBee:**") { t.Errorf("effusive mood=%d should produce aside; got %q", mood, got) } } } func TestMoodAsideLine_DrawsFromCorrectPool(t *testing.T) { hostile := moodAsideLine(5, "run-aside", 1) effusive := moodAsideLine(95, "run-aside", 1) // Each line must come from its band's pool. if !lineInPool(hostile, flavor.MoodAsidesHostile) { t.Errorf("hostile aside %q not in MoodAsidesHostile", hostile) } if !lineInPool(effusive, flavor.MoodAsidesEffusive) { t.Errorf("effusive aside %q not in MoodAsidesEffusive", effusive) } } func TestMoodAsideLine_DeterministicPerRoom(t *testing.T) { a := moodAsideLine(10, "run-D8", 5) b := moodAsideLine(10, "run-D8", 5) if a != b { t.Errorf("same (mood, runID, roomIdx) should produce same aside: %q vs %q", a, b) } } // lineInPool returns true if rendered (with TwinBee prefix) corresponds to // any line in pool. func lineInPool(rendered string, pool []string) bool { const prefix = "🎭 **TwinBee:** " if !strings.HasPrefix(rendered, prefix) { return false } body := strings.TrimPrefix(rendered, prefix) for _, line := range pool { if line == body { return true } } return false }