From 9dc0e37340b3a4cff423ef4da2838b6e6f4aa8ab Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 12:16:42 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 11 D1d: TwinBee narration + mood triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the existing internal/flavor TwinBee GM pools into the !zone state machine. Adds GMNarrationType, mood band derivation, the ยง3.2 mood-event delta table, and ยฑ2/hr passive decay toward 50. Zone enter narrates the entry room; advance narrates each subsequent room (boss rooms use the named BossEntry* pools, completion uses ZoneComplete and applies MoodEventZoneComplete). Line selection is deterministic on (runID, roomIdx) so repeat reads render the same prose. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/dnd_zone_cmd.go | 29 ++- internal/plugin/dnd_zone_narration.go | 274 +++++++++++++++++++++ internal/plugin/dnd_zone_narration_test.go | 193 +++++++++++++++ 3 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 internal/plugin/dnd_zone_narration.go create mode 100644 internal/plugin/dnd_zone_narration_test.go diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 99145fb..b65f200 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -185,6 +185,10 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest var b strings.Builder b.WriteString(fmt.Sprintf("๐Ÿ— **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms)) b.WriteString("_" + zone.Hook + "_\n\n") + if line := twinBeeLine(zone.ID, GMRoomEntry, run.RunID, run.CurrentRoom); line != "" { + b.WriteString(line) + 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()) @@ -200,6 +204,7 @@ func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error { if run == nil { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone list` then `!zone enter `.") } + _ = applyMoodDecayIfStale(run) zone, _ := getZone(run.ZoneID) var b strings.Builder b.WriteString(fmt.Sprintf("**%s** โ€” room %d/%d (%s)\n", @@ -292,6 +297,7 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error { if run == nil { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") } + _ = applyMoodDecayIfStale(run) zone, _ := getZone(run.ZoneID) prev := run.CurrentRoomType() prevIdx := run.CurrentRoom @@ -300,15 +306,32 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Couldn't advance: "+err.Error()) } if next == "" { - // Run completed via boss room. + // Boss room cleared โ†’ zone complete. Bump mood per design ยง3.2. + _, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete) var b strings.Builder b.WriteString(fmt.Sprintf("๐Ÿ† **Cleared %s.** Boss defeated. Run complete.\n\n", zone.Display)) + if line := twinBeeLine(zone.ID, GMZoneComplete, run.RunID, prevIdx); line != "" { + b.WriteString(line) + b.WriteString("\n\n") + } b.WriteString("_(Combat resolution + loot rolls land in D1e โ€” for now this is a clean state-machine win.)_") return p.SendDM(ctx.Sender, b.String()) } + nextIdx := run.CurrentRoom + 1 // markRoomCleared already advanced; reflect for narration salt var b strings.Builder - b.WriteString(fmt.Sprintf("โœ“ Cleared room %d (%s).\n", prevIdx+1, prettyRoomType(prev))) - b.WriteString(fmt.Sprintf("**Room %d/%d โ€” %s.** ", run.CurrentRoom+2, run.TotalRooms, prettyRoomType(next))) + 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 != "" { + b.WriteString(line) + b.WriteString("\n\n") + } + } else { + if line := twinBeeLine(zone.ID, GMRoomEntry, run.RunID, nextIdx); line != "" { + b.WriteString(line) + 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 new file mode 100644 index 0000000..8c4cdff --- /dev/null +++ b/internal/plugin/dnd_zone_narration.go @@ -0,0 +1,274 @@ +package plugin + +import ( + "fmt" + "hash/fnv" + "time" + + "gogobee/internal/db" + "gogobee/internal/flavor" +) + +// Phase 11 D1d โ€” TwinBee narration + GM mood triggers. Implements +// `gogobee_dungeon_zones.md` ยง3 (TwinBee GM System) on top of the D1b +// state machine. +// +// Flavor lines come from the canonical pools in internal/flavor +// (twinbee_gm_flavor.go) โ€” this file does not author new prose. +// Per-zone overrides are routed through zoneRoomEntryPool / bossEntryPool. +// +// Mood-banded *flavor* selection is a D6 polish item. D1d wires the +// score, the event triggers, and the passive decay; mood currently +// surfaces as the human label in `!zone status` and gates downstream +// generosity (loot rolls, hint drops) once those land. + +// GMNarrationType โ€” see design doc ยง7. +type GMNarrationType string + +const ( + GMRoomEntry GMNarrationType = "room_entry" + GMCombatStart GMNarrationType = "combat_start" + GMCombatEnd GMNarrationType = "combat_end" + GMNat20 GMNarrationType = "nat_20" + GMNat1 GMNarrationType = "nat_1" + GMBossEntry GMNarrationType = "boss_entry" + GMBossDeath GMNarrationType = "boss_death" + GMPlayerDeath GMNarrationType = "player_death" + GMZoneComplete GMNarrationType = "zone_complete" + GMTrapDetected GMNarrationType = "trap_detected" + GMTrapTripped GMNarrationType = "trap_tripped" + GMLore GMNarrationType = "lore" +) + +// MoodBand โ€” five-state band derived from the 0โ€“100 score. +type MoodBand int + +const ( + MoodBandHostile MoodBand = iota + MoodBandGrumpy + MoodBandNeutral + MoodBandFriendly + MoodBandEffusive +) + +// moodBand maps a raw score to its band per design doc ยง3.2. +func moodBand(score int) MoodBand { + switch { + case score >= 80: + return MoodBandEffusive + case score >= 60: + return MoodBandFriendly + case score >= 40: + return MoodBandNeutral + case score >= 20: + return MoodBandGrumpy + default: + return MoodBandHostile + } +} + +// zoneRoomEntryPool returns the zone-specific RoomEntry pool when one +// exists, else the generic pool. Pools live in internal/flavor. +func zoneRoomEntryPool(zoneID ZoneID) []string { + switch zoneID { + case ZoneGoblinWarrens: + return append(append([]string{}, flavor.RoomEntryGoblinWarrens...), flavor.RoomEntryGeneric...) + case ZoneCryptValdris: + return append(append([]string{}, flavor.RoomEntryCryptValdris...), flavor.RoomEntryGeneric...) + case ZoneForestShadows: + return append(append([]string{}, flavor.RoomEntryForestShadows...), flavor.RoomEntryGeneric...) + case ZoneManorBlackspire: + return append(append([]string{}, flavor.RoomEntryHauntedManor...), flavor.RoomEntryGeneric...) + case ZoneUnderdark: + return append(append([]string{}, flavor.RoomEntryUnderdark...), flavor.RoomEntryGeneric...) + case ZoneDragonsLair: + return append(append([]string{}, flavor.RoomEntryDragonsLair...), flavor.RoomEntryGeneric...) + } + return flavor.RoomEntryGeneric +} + +// bossEntryPool returns the boss-specific pool when one exists, else +// the generic boss-entry pool. +func bossEntryPool(zoneID ZoneID) []string { + switch zoneID { + case ZoneGoblinWarrens: + return flavor.BossEntryGrol + case ZoneCryptValdris: + return flavor.BossEntryValdris + case ZoneForestShadows: + return flavor.BossEntryHollowKing + case ZoneDragonsLair: + return flavor.BossEntryInfernax + case ZoneAbyssPortal: + return flavor.BossEntryBelaxath + } + return flavor.BossEntryGeneric +} + +// pickPool routes a narration type to the right []string pool from +// internal/flavor. Returns nil for kinds that have no pool yet (caller +// is expected to skip rendering or fall back to a static line). +func pickPool(zoneID ZoneID, kind GMNarrationType) []string { + switch kind { + case GMRoomEntry: + return zoneRoomEntryPool(zoneID) + case GMBossEntry: + return bossEntryPool(zoneID) + case GMCombatStart: + return flavor.CombatStart + case GMCombatEnd: + return flavor.CombatVictory + case GMNat20: + return flavor.Nat20 + case GMNat1: + return flavor.Nat1 + case GMBossDeath: + return flavor.BossDeath + case GMPlayerDeath: + return flavor.PlayerDeath + case GMZoneComplete: + return flavor.ZoneComplete + case GMTrapDetected: + return flavor.TrapDetected + case GMTrapTripped: + return flavor.TrapTriggered + case GMLore: + return flavor.LoreLines + } + return nil +} + +// 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. +func pickLineDeterministic(lines []string, runID string, salt int) string { + if len(lines) == 0 { + return "" + } + h := fnv.New64a() + h.Write([]byte(runID)) + var sb [8]byte + for i := 0; i < 8; i++ { + sb[i] = byte(salt >> (8 * i)) + } + h.Write(sb[:]) + return lines[int(h.Sum64()%uint64(len(lines)))] +} + +// twinBeeLine renders the TwinBee narration block for a given event, +// drawing from the canonical internal/flavor pools. +// +// roomIdx is folded into the deterministic selector so the same room +// in the same run always renders the same prose (idempotent reads). +func twinBeeLine(zoneID ZoneID, kind GMNarrationType, runID string, roomIdx int) string { + pool := pickPool(zoneID, kind) + line := pickLineDeterministic(pool, runID, roomIdx) + if line == "" { + return "" + } + return "๐ŸŽญ **TwinBee:** " + line +} + +// โ”€โ”€ Mood event table & application โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// GMMoodEvent enumerates the mood-modifier triggers from design doc ยง3.2. +type GMMoodEvent string + +const ( + MoodEventZoneComplete GMMoodEvent = "zone_complete" + MoodEventPlayerDeath GMMoodEvent = "player_death" + MoodEventNat20 GMMoodEvent = "nat_20" + MoodEventNat1 GMMoodEvent = "nat_1" + MoodEventCommunityMilestone GMMoodEvent = "community_milestone" + MoodEventDowntime GMMoodEvent = "downtime" + MoodEventTaunt GMMoodEvent = "taunt" + MoodEventCompliment GMMoodEvent = "compliment" +) + +// moodEventDelta โ€” design doc ยง3.2 mood-modifier table. +var moodEventDelta = map[GMMoodEvent]int{ + MoodEventZoneComplete: +10, + MoodEventPlayerDeath: -5, + MoodEventNat20: +3, + MoodEventNat1: -2, + MoodEventCommunityMilestone: +15, + MoodEventDowntime: -20, + MoodEventTaunt: -10, + MoodEventCompliment: +5, +} + +// MoodEventDelta exposes the static delta for an event (test use). +func MoodEventDelta(ev GMMoodEvent) int { return moodEventDelta[ev] } + +// applyMoodEvent updates the run's mood by the event's delta. Returns +// the new mood score after clamping. +func applyMoodEvent(runID string, ev GMMoodEvent) (int, error) { + delta, ok := moodEventDelta[ev] + if !ok { + return 0, fmt.Errorf("unknown mood event: %s", ev) + } + if err := adjustGMMood(runID, delta); err != nil { + return 0, err + } + r, err := getZoneRun(runID) + if err != nil || r == nil { + return 0, err + } + return r.GMMood, nil +} + +// passiveDecayMood drifts the mood toward 50 at ยฑ2/hour, scaled by +// hours elapsed since lastTouched. Pure function; caller persists. +// +// Design doc ยง3.2: "Mood decays toward 50 at a rate of ยฑ2 per hour +// (passive rebalancing)." Long-idle runs reset toward neutral. +func passiveDecayMood(score int, lastTouched, now time.Time) int { + hours := int(now.Sub(lastTouched).Hours()) + if hours <= 0 { + return clampMood(score) + } + const ratePerHour = 2 + drift := hours * ratePerHour + switch { + case score > 50: + score -= drift + if score < 50 { + score = 50 + } + case score < 50: + score += drift + if score > 50 { + score = 50 + } + } + return clampMood(score) +} + +func clampMood(s int) int { + if s < 0 { + return 0 + } + if s > 100 { + return 100 + } + return s +} + +// applyMoodDecayIfStale persists a passive-decay correction when the +// run has been idle long enough to drift. Cheap no-op on fresh runs. +func applyMoodDecayIfStale(r *DungeonRun) error { + if r == nil { + return nil + } + newMood := passiveDecayMood(r.GMMood, r.LastActionAt, time.Now().UTC()) + if newMood == r.GMMood { + return nil + } + if _, err := db.Get().Exec(` + UPDATE dnd_zone_run SET gm_mood = ? WHERE run_id = ?`, + newMood, r.RunID); err != nil { + return err + } + r.GMMood = newMood + return nil +} diff --git a/internal/plugin/dnd_zone_narration_test.go b/internal/plugin/dnd_zone_narration_test.go new file mode 100644 index 0000000..c3cbe8b --- /dev/null +++ b/internal/plugin/dnd_zone_narration_test.go @@ -0,0 +1,193 @@ +package plugin + +import ( + "strings" + "testing" + "time" + + "maunium.net/go/mautrix/id" +) + +// Phase 11 D1d โ€” TwinBee narration + mood event tests. + +func TestMoodBand_Boundaries(t *testing.T) { + cases := []struct { + score int + want MoodBand + }{ + {0, MoodBandHostile}, {19, MoodBandHostile}, + {20, MoodBandGrumpy}, {39, MoodBandGrumpy}, + {40, MoodBandNeutral}, {59, MoodBandNeutral}, + {60, MoodBandFriendly}, {79, MoodBandFriendly}, + {80, MoodBandEffusive}, {100, MoodBandEffusive}, + } + for _, c := range cases { + if got := moodBand(c.score); got != c.want { + t.Errorf("moodBand(%d) = %d, want %d", c.score, got, c.want) + } + } +} + +func TestMoodEventDelta_DesignDocTable(t *testing.T) { + want := map[GMMoodEvent]int{ + MoodEventZoneComplete: +10, + MoodEventPlayerDeath: -5, + MoodEventNat20: +3, + MoodEventNat1: -2, + MoodEventCommunityMilestone: +15, + MoodEventDowntime: -20, + MoodEventTaunt: -10, + MoodEventCompliment: +5, + } + for ev, delta := range want { + if got := MoodEventDelta(ev); got != delta { + t.Errorf("delta(%s) = %d, want %d", ev, got, delta) + } + } +} + +func TestPassiveDecayMood_DriftsTowardFifty(t *testing.T) { + now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC) + cases := []struct { + name string + score int + hoursAgo int + want int + }{ + {"high-score-decays-down", 80, 5, 70}, // -2/hr * 5 + {"high-clamps-at-50", 80, 100, 50}, // would overshoot + {"low-score-decays-up", 10, 5, 20}, // +2/hr * 5 + {"low-clamps-at-50", 10, 100, 50}, // would overshoot + {"already-50-no-change", 50, 100, 50}, // neutral stays neutral + {"fresh-no-decay", 80, 0, 80}, // no time elapsed + {"sub-hour-no-decay", 80, 0, 80}, // hours==0 โ†’ no drift + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + last := now.Add(-time.Duration(c.hoursAgo) * time.Hour) + if got := passiveDecayMood(c.score, last, now); got != c.want { + t.Errorf("decay(%d, %dh ago) = %d, want %d", c.score, c.hoursAgo, got, c.want) + } + }) + } +} + +func TestTwinBeeLine_DeterministicAcrossCalls(t *testing.T) { + a := twinBeeLine(ZoneGoblinWarrens, GMRoomEntry, "abc123", 2) + b := twinBeeLine(ZoneGoblinWarrens, GMRoomEntry, "abc123", 2) + if a != b { + t.Errorf("non-deterministic:\n a=%q\n b=%q", a, b) + } + if !strings.HasPrefix(a, "๐ŸŽญ **TwinBee:**") { + t.Errorf("missing TwinBee prefix: %q", a) + } + // Different room index should typically yield a different line + // (chance of accidental collision is 1/N; with the Goblin+Generic + // pool that's tiny but theoretically possible โ€” only assert prefix). + c := twinBeeLine(ZoneGoblinWarrens, GMRoomEntry, "abc123", 3) + if !strings.HasPrefix(c, "๐ŸŽญ **TwinBee:**") { + t.Errorf("missing prefix on alt salt: %q", c) + } +} + +func TestTwinBeeLine_BossEntryUsesNamedPool(t *testing.T) { + // Grol's pool is a single hand-crafted line. + got := twinBeeLine(ZoneGoblinWarrens, GMBossEntry, "any", 0) + if !strings.Contains(got, "Grol") { + t.Errorf("Goblin Warrens boss entry should mention Grol; got %q", got) + } + got = twinBeeLine(ZoneCryptValdris, GMBossEntry, "any", 0) + if !strings.Contains(got, "Valdris") { + t.Errorf("Crypt boss entry should mention Valdris; got %q", got) + } +} + +func TestTwinBeeLine_UnknownKindReturnsEmpty(t *testing.T) { + if got := twinBeeLine(ZoneGoblinWarrens, GMNarrationType("not_a_real_kind"), "x", 0); got != "" { + t.Errorf("expected empty for unknown kind, got %q", got) + } +} + +func TestApplyMoodEvent_PersistsClampedDelta(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@zone-mood-evt:example") + zoneCmdTestCharacter(t, uid, 1) + defer cleanupZoneRuns(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("start: %v", err) + } + if run.GMMood != 50 { + t.Fatalf("starting mood = %d, want 50", run.GMMood) + } + + // +10 โ†’ 60 + got, err := applyMoodEvent(run.RunID, MoodEventZoneComplete) + if err != nil { + t.Fatalf("apply zone_complete: %v", err) + } + if got != 60 { + t.Errorf("after zone_complete: mood = %d, want 60", got) + } + + // -20 โ†’ 40 + got, _ = applyMoodEvent(run.RunID, MoodEventDowntime) + if got != 40 { + t.Errorf("after downtime: mood = %d, want 40", got) + } + + // Clamp test: huge negative stack โ†’ 0 + for i := 0; i < 20; i++ { + _, _ = applyMoodEvent(run.RunID, MoodEventDowntime) + } + r, _ := getZoneRun(run.RunID) + if r.GMMood != 0 { + t.Errorf("after extreme negative stack: mood = %d, want 0 (clamped)", r.GMMood) + } + + // Clamp upward + for i := 0; i < 20; i++ { + _, _ = applyMoodEvent(run.RunID, MoodEventCommunityMilestone) + } + r, _ = getZoneRun(run.RunID) + if r.GMMood != 100 { + t.Errorf("after extreme positive stack: mood = %d, want 100 (clamped)", r.GMMood) + } +} + +func TestApplyMoodEvent_UnknownEventErrors(t *testing.T) { + if _, err := applyMoodEvent("fake-run", GMMoodEvent("ghosts")); err == nil { + t.Error("expected error on unknown mood event") + } +} + +func TestZoneCmd_AdvanceFinalRoomBumpsMood(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@zone-mood-complete:example") + zoneCmdTestCharacter(t, uid, 1) + defer cleanupZoneRuns(uid) + + p := &AdventurePlugin{} + if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil { + t.Fatal(err) + } + run, _ := getActiveZoneRun(uid) + // Advance through every room. zone_complete fires on the boss-room + // advance and bumps mood by +10 (50 โ†’ 60). + for i := 0; i < run.TotalRooms; i++ { + if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "advance"); err != nil { + t.Fatalf("advance %d: %v", i, err) + } + } + final, err := getZoneRun(run.RunID) + if err != nil || final == nil { + t.Fatal("could not fetch completed run") + } + if !final.BossDefeated { + t.Error("boss should be defeated after final advance") + } + if final.GMMood != 60 { + t.Errorf("post-completion mood = %d, want 60 (50 + zone_complete +10)", final.GMMood) + } +}