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

@@ -489,3 +489,37 @@ var SaveFailed = []string{
"Like the NES game over screen — inevitable in this moment, fixable in the next. The save failed. The dungeon continues. So do you.",
"The effect takes hold and TwinBee is already calculating how you get out of it, because that's TwinBee's job: keep you oriented toward solutions even when the immediate situation is a problem.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MOOD ASIDES — Hostile band (mood 019, "Wrathful")
// Short room-entry asides surfaced only when TwinBee's mood is at the
// hostile extreme. Cryptic, withholding, no hints. Per design doc §3.2.
// ─────────────────────────────────────────────────────────────────────────────
var MoodAsidesHostile = []string{
"TwinBee is not narrating this one in detail. You can read the room. Read it.",
"The dungeon offers TwinBee something to mention. TwinBee declines. You're on your own for color commentary.",
"TwinBee is here. TwinBee is watching. TwinBee is not, currently, helping. There is a difference and you will feel it.",
"In the bad ending of every Castlevania, the protagonist gets less guidance than they did at the start. TwinBee has reached approximately that part of the playthrough.",
"TwinBee keeps several details to itself. The details would have been useful. TwinBee does not consider this its problem right now.",
"Whatever's in the next part of the room, TwinBee saw it and chose not to flag it. The mood is what it is.",
"TwinBee mutters something. You don't catch it. TwinBee does not repeat it.",
"The narration is sparse here. TwinBee is sparing it on purpose. Adjust accordingly.",
}
// ─────────────────────────────────────────────────────────────────────────────
// MOOD ASIDES — Effusive band (mood 80100, "Elated")
// Generous, warm asides surfaced when TwinBee is delighted with the run.
// Hint-friendly, fond. Per design doc §3.2.
// ─────────────────────────────────────────────────────────────────────────────
var MoodAsidesEffusive = []string{
"TwinBee is, not to put too fine a point on it, having a wonderful time. The next bit might come with bonus context.",
"TwinBee leans in. The mood is good. Good moods, in TwinBee's experience, lead to slightly more generous descriptions and slightly better odds of catching the small details.",
"This is the part of the run TwinBee will tell other GMs about later. TwinBee makes a small mental note and continues with visible enthusiasm.",
"TwinBee is delighted. You can hear it in the pacing. You can hear it in the choice of adjectives. The dungeon is, briefly, on your side.",
"In the good ending of every JRPG, the world feels slightly warmer in the late game. TwinBee is at that part of the playthrough and it shows.",
"TwinBee, not normally given to footnotes, is about to add a footnote. It will probably be useful. TwinBee is in that kind of mood.",
"The mood is high. TwinBee is, for the next stretch, more likely to mention the loose flagstone, the suspicious tapestry, the thing on the ceiling. Take advantage.",
"TwinBee hums a victory fanfare softly to itself. It is not earned yet. TwinBee is being optimistic on your behalf.",
}

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
}