Adv 2.0 D&D Phase 11 D8: mood-banded TwinBee aside

Adds a TwinBee aside on room entry when GM mood sits at the extreme
bands (≤19 hostile, ≥80 effusive). Mid-bands stay silent — the aside is
strictly extra flavor, never replacing core narration. Two new pools
(MoodAsidesHostile, MoodAsidesEffusive) live alongside the existing
TwinBee voice in twinbee_gm_flavor.go. Wired into zoneCmdEnter and
zoneCmdAdvance; the advance path reloads the run so post-combat
nat20/nat1 deltas are reflected before band lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 14:25:04 -07:00
parent 71594a0d89
commit 7bb922ae62
4 changed files with 138 additions and 0 deletions

View File

@@ -198,6 +198,10 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
b.WriteString(line)
b.WriteString("\n\n")
}
if aside := moodAsideLine(run.GMMood, run.RunID, run.CurrentRoom); aside != "" {
b.WriteString(aside)
b.WriteString("\n\n")
}
b.WriteString(fmt.Sprintf("**Room 1/%d — %s.** Use `!zone advance` to proceed, `!zone map` for layout.",
run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
return p.SendDM(ctx.Sender, b.String())
@@ -367,6 +371,16 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
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
if fresh, _ := getZoneRun(run.RunID); fresh != nil {
freshMood = fresh.GMMood
}
if aside := moodAsideLine(freshMood, run.RunID, nextIdx); aside != "" {
b.WriteString(aside)
b.WriteString("\n\n")
}
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())

View File

@@ -381,6 +381,36 @@ func complimentResponseLine(runID string, roomIdx int) string {
return "🎭 **TwinBee:** " + line
}
// moodAsidePool returns the mood-banded aside pool when the mood sits at
// an extreme band (hostile or effusive), else nil. Mid-bands (grumpy /
// neutral / friendly) intentionally surface no aside — flavor stays
// strictly extra, never replacing the core narration.
func moodAsidePool(mood int) []string {
switch moodBand(mood) {
case MoodBandHostile:
return flavor.MoodAsidesHostile
case MoodBandEffusive:
return flavor.MoodAsidesEffusive
}
return nil
}
// moodAsideLine renders a deterministic TwinBee aside reflecting the
// run's mood at the extreme bands. roomIdx is folded so the same room
// in the same run is stable; cross-room asides vary. Returns "" for
// mid-band moods.
func moodAsideLine(mood int, runID string, roomIdx int) string {
pool := moodAsidePool(mood)
if len(pool) == 0 {
return ""
}
line := pickLineDeterministic(pool, runID, roomIdx^0x6F2D8B14)
if line == "" {
return ""
}
return "🎭 **TwinBee:** " + line
}
// ── Mood event table & application ───────────────────────────────────────────
// GMMoodEvent enumerates the mood-modifier triggers from design doc §3.2.

View File

@@ -500,3 +500,63 @@ func TestPhaseTwoCrossedInEvents(t *testing.T) {
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
}