diff --git a/internal/flavor/twinbee_gm_flavor.go b/internal/flavor/twinbee_gm_flavor.go index ae4de70..6e45648 100644 --- a/internal/flavor/twinbee_gm_flavor.go +++ b/internal/flavor/twinbee_gm_flavor.go @@ -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 0–19, "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 80–100, "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.", +} diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 9e04409..f276277 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -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()) diff --git a/internal/plugin/dnd_zone_narration.go b/internal/plugin/dnd_zone_narration.go index 3b59bba..8828375 100644 --- a/internal/plugin/dnd_zone_narration.go +++ b/internal/plugin/dnd_zone_narration.go @@ -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. diff --git a/internal/plugin/dnd_zone_narration_test.go b/internal/plugin/dnd_zone_narration_test.go index a7a9008..dcc10ef 100644 --- a/internal/plugin/dnd_zone_narration_test.go +++ b/internal/plugin/dnd_zone_narration_test.go @@ -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 +}