Adv 2.0 D&D Phase 11 D2b: Tier 1 zone flavor files

New additive flavor files (zone_goblin_warrens_flavor.go,
zone_crypt_valdris_flavor.go) with elite-room intros, boss ability
callouts (Grol: Surprise Attack / Heart of Hruggek / Terrifying Roar;
Valdris: Corrupting Touch / Legendary Resistance / Call of the Grave /
Phase Two), and zone-specific lore deep-dives. Existing
twinbee_gm_flavor.go is untouched per the protected-files contract.

Wires three narration helpers in dnd_zone_narration.go:
  composeBossEntry — boss-entry render plus a one-line ability callout
  eliteRoomEntryLine — elite-room atmospheric prefix
  zoneLorePool — zone-specific !lore pool with generic fallback

Boss-entry callsite in dnd_zone_cmd.go now uses composeBossEntry; the
elite branch of resolveCombatRoom prefixes with the zone's elite line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 12:54:13 -07:00
parent 98a9c930fa
commit 4a6ddf45ae
5 changed files with 314 additions and 2 deletions

View File

@@ -349,7 +349,7 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
}
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
if next == RoomBoss {
if line := twinBeeLine(zone.ID, GMBossEntry, run.RunID, nextIdx); line != "" {
if line := composeBossEntry(zone.ID, run.RunID, nextIdx); line != "" {
b.WriteString(line)
b.WriteString("\n\n")
}
@@ -400,6 +400,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
scanMoodEventsFromCombat(run.RunID, result)
var b strings.Builder
if elite {
if line := eliteRoomEntryLine(zone.ID, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
}
if line := twinBeeLine(zone.ID, GMCombatStart, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")

View File

@@ -133,11 +133,81 @@ func pickPool(zoneID ZoneID, kind GMNarrationType) []string {
case GMTrapTripped:
return flavor.TrapTriggered
case GMLore:
return flavor.LoreLines
return zoneLorePool(zoneID)
}
return nil
}
// zoneLorePool returns the zone-specific lore pool prepended to the
// generic LoreLines pool. Tier 1 zones ship dedicated lore (D2b); other
// tiers fall back to the generic pool until their flavor files land.
func zoneLorePool(zoneID ZoneID) []string {
switch zoneID {
case ZoneGoblinWarrens:
return append(append([]string{}, flavor.LoreLinesWarrens...), flavor.LoreLines...)
case ZoneCryptValdris:
return append(append([]string{}, flavor.LoreLinesCrypt...), flavor.LoreLines...)
}
return flavor.LoreLines
}
// bossSignaturePool returns the zone-boss ability-callout pool (one-line
// cinematic suffix sampled at boss-entry render). Returns nil when the
// zone has no signature pool yet — caller skips the suffix.
func bossSignaturePool(zoneID ZoneID) []string {
switch zoneID {
case ZoneGoblinWarrens:
return flavor.GrolSignatureCallouts
case ZoneCryptValdris:
return flavor.ValdrisSignatureCallouts
}
return nil
}
// eliteRoomEntryPool returns the zone-specific elite-room atmospheric
// pool. Returns nil when the zone has no dedicated elite prose.
func eliteRoomEntryPool(zoneID ZoneID) []string {
switch zoneID {
case ZoneGoblinWarrens:
return flavor.EliteRoomEntryWarrens
case ZoneCryptValdris:
return flavor.EliteRoomEntryCrypt
}
return nil
}
// composeBossEntry renders the boss-entry narration plus, when the zone
// 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)
pool := bossSignaturePool(zoneID)
if entry == "" || len(pool) == 0 {
return entry
}
// Salt the suffix selector with a constant so the entry line and the
// callout line vary independently across rooms.
callout := pickLineDeterministic(pool, runID, roomIdx^0x5BD17EF1)
if callout == "" {
return entry
}
return entry + "\n🎭 **TwinBee:** " + callout
}
// eliteRoomEntryLine renders the zone-specific elite atmospheric line
// for an elite room. Empty string when the zone has no elite pool yet.
func eliteRoomEntryLine(zoneID ZoneID, runID string, roomIdx int) string {
pool := eliteRoomEntryPool(zoneID)
if len(pool) == 0 {
return ""
}
line := pickLineDeterministic(pool, runID, roomIdx^0x2C9E1A37)
if line == "" {
return ""
}
return "🎭 **TwinBee:** " + line
}
// pickLineDeterministic — stable selection across (runID, salt). Same
// inputs always yield the same line, so a player who re-reads status
// sees the same prose; different rooms / different runs vary.

View File

@@ -5,6 +5,8 @@ import (
"testing"
"time"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
@@ -162,6 +164,78 @@ func TestApplyMoodEvent_UnknownEventErrors(t *testing.T) {
}
}
// ── 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)
}
}
func TestComposeBossEntry_NoCalloutForUnflavoredZone(t *testing.T) {
// Forest of Shadows has a named boss-entry pool (Hollow King) but no
// signature-callout pool yet — composer should return the bare entry.
got := composeBossEntry(ZoneForestShadows, "run-d2b", 1)
if got == "" {
t.Fatal("forest boss-entry should still render")
}
if n := strings.Count(got, "🎭 **TwinBee:**"); n != 1 {
t.Errorf("Forest boss-entry expected 1 TwinBee block (no callout pool), got %d:\n%s", n, got)
}
}
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")
}
// Unflavored zone returns empty so the caller skips the prefix.
if got := eliteRoomEntryLine(ZoneDragonsLair, "run-elite", 3); got != "" {
t.Errorf("unflavored zone should return empty, got %q", got)
}
}
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))
}
// Tier 2+ zone with no dedicated lore must fall back to the generic pool.
if got := zoneLorePool(ZoneForestShadows); len(got) != len(flavor.LoreLines) {
t.Errorf("Forest lore pool should fall back to generic; got %d, want %d",
len(got), len(flavor.LoreLines))
}
}
func TestZoneCmd_AdvanceFinalRoomBumpsMood(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@zone-mood-complete:example")