mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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.",
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user