Files
gogobee/internal/plugin/dnd_zone_narration.go
prosolis a063e0ccd0 N3/P6d: a party where every member can see the dungeon
Every ownership lookup in the adventure module keys on a user id, and a
party member owns neither the expedition row nor the zone run. So each
player-facing read quietly told them they were not playing.

Rewire them. Reads a member should see resolve through activeExpeditionFor
/ activeZoneRunFor. Leader-only actions answer with copy that names the
leader instead of denying the expedition. Three busy-guards had to start
refusing a member outright: !zone enter, !expedition start and !sell all
keyed on the sender's own row, so a seated member could open a private
dungeon, outfit a rival expedition, or run a shop from the boss room.

Four things the rewire itself exposed:

!resources looks like a read but seed-persists harvest nodes, and
saveHarvestNodes rewrites the entire region_state blob — kills, event
gates, temporal stack — last-write-wins. Reaching it as a member would
revert the leader's walk from a stale snapshot. Persist only for the owner;
seedRoomNodes is pure, so a member re-derives the same nodes.

!zone taunt moves the party's shared mood, which is intended and safe:
applyMoodEvent lands an atomic delta. Its neighbour applyMoodDecayIfStale
writes an absolute gm_mood from the caller's snapshot, and every command
takes the *sender's* lock — a member running it against the leader's run
holds the wrong mutex. The owner check now lives on that function.

A seat outlives status='active'. releaseParty deliberately skips the
seven-day 'extracting' limbo, so the roster persists while
activeExpeditionFor goes blind — long enough for a member to open a run
that wins every lookup once the leader !resumes. seatedExpeditionFor spans
both statuses; it is what the busy-guards ask.

!expedition run was still member-blind. It is the same walk as !zone
advance, reached by its other name.

isPartyMember replaces `run != nil && !isLeader`: activeZoneRunFor reports
isLeader=false for a player with no run anywhere, so the bare test sends a
solo player to go ask their leader.

Golden byte-identical; solo T1 expedition clears end-to-end.
2026-07-09 23:56:16 -07:00

589 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"hash/fnv"
"math"
"time"
"gogobee/internal/db"
"gogobee/internal/flavor"
)
// Phase 11 D1d — TwinBee narration + DM 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.
// DMNarrationType — see design doc §7.
type DMNarrationType string
const (
DMRoomEntry DMNarrationType = "room_entry"
DMCombatStart DMNarrationType = "combat_start"
DMCombatEnd DMNarrationType = "combat_end"
DMNat20 DMNarrationType = "nat_20"
DMNat1 DMNarrationType = "nat_1"
DMBossEntry DMNarrationType = "boss_entry"
DMBossDeath DMNarrationType = "boss_death"
DMPlayerDeath DMNarrationType = "player_death"
DMZoneComplete DMNarrationType = "zone_complete"
DMTrapDetected DMNarrationType = "trap_detected"
DMTrapTripped DMNarrationType = "trap_tripped"
DMLore DMNarrationType = "lore"
)
// MoodBand — five-state band derived from the 0100 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 ZoneSunkenTemple:
return append(append([]string{}, flavor.RoomEntrySunkenTemple...), flavor.RoomEntryGeneric...)
case ZoneManorBlackspire:
return append(append([]string{}, flavor.RoomEntryHauntedManor...), flavor.RoomEntryGeneric...)
case ZoneUnderforge:
return append(append([]string{}, flavor.RoomEntryUnderforge...), flavor.RoomEntryGeneric...)
case ZoneUnderdark:
return append(append([]string{}, flavor.RoomEntryUnderdark...), flavor.RoomEntryGeneric...)
case ZoneFeywildCrossing:
return append(append([]string{}, flavor.RoomEntryFeywildCrossing...), flavor.RoomEntryGeneric...)
case ZoneDragonsLair:
return append(append([]string{}, flavor.RoomEntryDragonsLair...), flavor.RoomEntryGeneric...)
case ZoneAbyssPortal:
return append(append([]string{}, flavor.RoomEntryAbyssPortal...), 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 ZoneSunkenTemple:
return flavor.BossEntryDreamingAboleth
case ZoneManorBlackspire:
return flavor.BossEntryAldricBlackspire
case ZoneUnderforge:
return flavor.BossEntryEmberlordThyrak
case ZoneUnderdark:
return flavor.BossEntryIlvaras
case ZoneFeywildCrossing:
return flavor.BossEntryThornmother
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 DMNarrationType) []string {
switch kind {
case DMRoomEntry:
return zoneRoomEntryPool(zoneID)
case DMBossEntry:
return bossEntryPool(zoneID)
case DMCombatStart:
return flavor.CombatStart
case DMCombatEnd:
return flavor.CombatVictory
case DMNat20:
return flavor.Nat20
case DMNat1:
return flavor.Nat1
case DMBossDeath:
return flavor.BossDeath
case DMPlayerDeath:
return flavor.PlayerDeath
case DMZoneComplete:
return flavor.ZoneComplete
case DMTrapDetected:
return flavor.TrapDetected
case DMTrapTripped:
return flavor.TrapTriggered
case DMLore:
return zoneLorePool(zoneID)
}
return nil
}
// zoneLorePool returns the zone-specific lore pool prepended to the
// generic LoreLines pool. Tier 1 zones ship dedicated lore (D2b); other
// tiers fall back to the generic pool until their flavor files land.
func zoneLorePool(zoneID ZoneID) []string {
switch zoneID {
case ZoneGoblinWarrens:
return append(append([]string{}, flavor.LoreLinesWarrens...), flavor.LoreLines...)
case ZoneCryptValdris:
return append(append([]string{}, flavor.LoreLinesCrypt...), flavor.LoreLines...)
case ZoneForestShadows:
return append(append([]string{}, flavor.LoreLinesForestShadows...), flavor.LoreLines...)
case ZoneSunkenTemple:
return append(append([]string{}, flavor.LoreLinesSunkenTemple...), flavor.LoreLines...)
case ZoneManorBlackspire:
return append(append([]string{}, flavor.LoreLinesManorBlackspire...), flavor.LoreLines...)
case ZoneUnderforge:
return append(append([]string{}, flavor.LoreLinesUnderforge...), flavor.LoreLines...)
case ZoneUnderdark:
return append(append([]string{}, flavor.LoreLinesUnderdark...), flavor.LoreLines...)
case ZoneFeywildCrossing:
return append(append([]string{}, flavor.LoreLinesFeywildCrossing...), flavor.LoreLines...)
case ZoneDragonsLair:
return append(append([]string{}, flavor.LoreLinesDragonsLair...), flavor.LoreLines...)
case ZoneAbyssPortal:
return append(append([]string{}, flavor.LoreLinesAbyssPortal...), flavor.LoreLines...)
}
return flavor.LoreLines
}
// bossSignaturePool returns the zone-boss ability-callout pool (one-line
// cinematic suffix sampled at boss-entry render). Returns nil when the
// zone has no signature pool yet — caller skips the suffix.
func bossSignaturePool(zoneID ZoneID) []string {
switch zoneID {
case ZoneGoblinWarrens:
return flavor.GrolSignatureCallouts
case ZoneCryptValdris:
return flavor.ValdrisSignatureCallouts
case ZoneForestShadows:
return flavor.HollowKingSignatureCallouts
case ZoneSunkenTemple:
return flavor.AbolethSignatureCallouts
case ZoneManorBlackspire:
return flavor.AldricSignatureCallouts
case ZoneUnderforge:
return flavor.ThyrakSignatureCallouts
case ZoneUnderdark:
return flavor.IlvarasSignatureCallouts
case ZoneFeywildCrossing:
return flavor.ThornmotherSignatureCallouts
case ZoneDragonsLair:
return flavor.InfernaxSignatureCallouts
case ZoneAbyssPortal:
return flavor.BelaxathSignatureCallouts
}
return nil
}
// bossPhaseTwoPool returns the zone-boss phase-two callout pool — the
// line surfaced when the boss crosses its PhaseTwoAt HP threshold mid-fight.
// Returns nil for bosses with no phase-two transition (Grol, Aboleth).
func bossPhaseTwoPool(zoneID ZoneID) []string {
switch zoneID {
case ZoneCryptValdris:
return flavor.ValdrisPhaseTwoLines
case ZoneForestShadows:
return flavor.HollowKingPhaseTwoLines
case ZoneManorBlackspire:
return flavor.AldricPhaseTwoLines
case ZoneUnderforge:
return flavor.ThyrakPhaseTwoLines
case ZoneUnderdark:
return flavor.IlvarasPhaseTwoLines
case ZoneFeywildCrossing:
return flavor.ThornmotherPhaseTwoLines
case ZoneDragonsLair:
return flavor.InfernaxPhaseTwoLines
case ZoneAbyssPortal:
return flavor.BelaxathPhaseTwoLines
}
return nil
}
// bossPhaseTwoLine renders the zone-specific phase-two transition line.
// Empty string when the zone has no phase-two pool.
func bossPhaseTwoLine(zoneID ZoneID, runID string, roomIdx int) string {
pool := bossPhaseTwoPool(zoneID)
if len(pool) == 0 {
return ""
}
line := pickLineDeterministic(pool, runID, roomIdx^0x71F4A2D3)
if line == "" {
return ""
}
return "🎭 **TwinBee:** " + line
}
// phaseTwoCrossedInEvents reports whether enemy HP crossed the
// PhaseTwoAt fraction of startHP at any point in the events log. Returns
// false when threshold <= 0 (no phase 2) or startHP <= 0.
func phaseTwoCrossedInEvents(events []CombatEvent, startHP int, threshold float64) bool {
if threshold <= 0 || startHP <= 0 {
return false
}
cutoff := int(float64(startHP) * threshold)
for _, ev := range events {
if ev.EnemyHP <= cutoff {
return true
}
}
return false
}
// eliteRoomEntryPool returns the zone-specific elite-room atmospheric
// pool. Returns nil when the zone has no dedicated elite prose.
func eliteRoomEntryPool(zoneID ZoneID) []string {
switch zoneID {
case ZoneGoblinWarrens:
return flavor.EliteRoomEntryWarrens
case ZoneCryptValdris:
return flavor.EliteRoomEntryCrypt
case ZoneForestShadows:
return flavor.EliteRoomEntryForestShadows
case ZoneSunkenTemple:
return flavor.EliteRoomEntrySunkenTemple
case ZoneManorBlackspire:
return flavor.EliteRoomEntryManorBlackspire
case ZoneUnderforge:
return flavor.EliteRoomEntryUnderforge
case ZoneUnderdark:
return flavor.EliteRoomEntryUnderdark
case ZoneFeywildCrossing:
return flavor.EliteRoomEntryFeywildCrossing
case ZoneDragonsLair:
return flavor.EliteRoomEntryDragonsLair
case ZoneAbyssPortal:
return flavor.EliteRoomEntryAbyssPortal
}
return nil
}
// composeBossEntry renders the boss-entry narration plus, when the zone
// has a signature pool, a single ability callout on the line below.
// Falls back to the bare boss-entry line when no signature pool exists.
func composeBossEntry(zoneID ZoneID, runID string, roomIdx int) string {
entry := twinBeeLine(zoneID, DMBossEntry, runID, roomIdx)
pool := bossSignaturePool(zoneID)
if entry == "" || len(pool) == 0 {
return entry
}
// Salt the suffix selector with a constant so the entry line and the
// callout line vary independently across rooms.
callout := pickLineDeterministic(pool, runID, roomIdx^0x5BD17EF1)
if callout == "" {
return entry
}
return entry + "\n🎭 **TwinBee:** " + callout
}
// eliteRoomEntryLine renders the zone-specific elite atmospheric line
// for an elite room. Empty string when the zone has no elite pool yet.
func eliteRoomEntryLine(zoneID ZoneID, runID string, roomIdx int) string {
pool := eliteRoomEntryPool(zoneID)
if len(pool) == 0 {
return ""
}
line := pickLineDeterministic(pool, runID, roomIdx^0x2C9E1A37)
if line == "" {
return ""
}
return "🎭 **TwinBee:** " + line
}
// narrationCadence returns the salt index used to seed narration pickers —
// the number of rooms walked so far, 0-based. This is a *progress* read, not
// a position read (revisit R1's audit split): flavor should keep moving
// forward as the player does, so a backtrack draws fresh lines rather than
// replaying the ones they already read on the way in.
//
// Forward-only runs have RoomsTraversed == len(VisitedNodes), so this stays
// numerically identical to the pre-R1 derivation and every existing pick
// keeps landing on the same line. It is stable while the player stands
// still, which is what lets a re-read of `!status` print the same prose.
//
// The len(VisitedNodes) fallback covers rows that predate the R1 column and
// have yet to be swept by bootstrapRoomsTraversed.
func narrationCadence(run *DungeonRun) int {
if run == nil {
return 0
}
if run.RoomsTraversed > 0 {
return run.RoomsTraversed - 1
}
if n := len(run.VisitedNodes); n > 0 {
return n - 1
}
return run.CurrentRoom
}
// 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 DMNarrationType, runID string, roomIdx int) string {
pool := pickPool(zoneID, kind)
line := pickLineDeterministic(pool, runID, roomIdx)
if line == "" {
return ""
}
return "🎭 **TwinBee:** " + line
}
// tauntResponseLine renders a deterministic TwinBee reply to !zone taunt
// from the prewritten flavor.TauntResponses pool. roomIdx is folded so
// repeated taunts in the same room are stable but cross-room taunts vary.
func tauntResponseLine(runID string, roomIdx int) string {
line := pickLineDeterministic(flavor.TauntResponses, runID, roomIdx^0x4A8D9F25)
if line == "" {
return ""
}
return "🎭 **TwinBee:** " + line
}
// complimentResponseLine renders a deterministic TwinBee reply to
// !zone compliment from the prewritten flavor.ComplimentResponses pool.
func complimentResponseLine(runID string, roomIdx int) string {
line := pickLineDeterministic(flavor.ComplimentResponses, runID, roomIdx^0x3E172B6C)
if line == "" {
return ""
}
return "🎭 **TwinBee:** " + line
}
// moodAsidePool returns the mood-banded aside pool. Every band except
// strict Neutral (4059) surfaces flavor — Hostile/Effusive at the
// extremes, Grumpy/Friendly in the mid-bands. Neutral stays silent.
func moodAsidePool(mood int) []string {
switch moodBand(mood) {
case MoodBandHostile:
return flavor.MoodAsidesHostile
case MoodBandGrumpy:
return flavor.MoodAsidesGrumpy
case MoodBandFriendly:
return flavor.MoodAsidesFriendly
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 ───────────────────────────────────────────
// DMMoodEvent enumerates the mood-modifier triggers from design doc §3.2.
type DMMoodEvent string
const (
MoodEventZoneComplete DMMoodEvent = "zone_complete"
MoodEventPlayerDeath DMMoodEvent = "player_death"
MoodEventNat20 DMMoodEvent = "nat_20"
MoodEventNat1 DMMoodEvent = "nat_1"
MoodEventCommunityMilestone DMMoodEvent = "community_milestone"
MoodEventDowntime DMMoodEvent = "downtime"
MoodEventTaunt DMMoodEvent = "taunt"
MoodEventCompliment DMMoodEvent = "compliment"
)
// moodEventDelta — design doc §3.2 mood-modifier table.
var moodEventDelta = map[DMMoodEvent]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 DMMoodEvent) 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 DMMoodEvent) (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.DMMood, 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 {
elapsedHours := now.Sub(lastTouched).Hours()
if elapsedHours <= 0 {
return clampMood(score)
}
const ratePerHour = 2.0
// Round to nearest unit so a 30-minute gap decays 1, a 12-minute
// gap decays 0. Avoids the truncation bug where any sub-hour gap
// produced no decay.
drift := int(math.Round(elapsedHours * ratePerHour))
if drift <= 0 {
return clampMood(score)
}
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
}
// DMMoodCombatTilt — what the DM's mood does to combat dials.
//
// Effusive: monsters miss more, hit softer; player goes first more often.
// Hostile : monsters meaner, faster off the line.
// Mid-bands: small or zero effects so the DM's voice still matters
// without trivializing math at each step.
type DMMoodCombatTilt struct {
EnemyAttackDelta int // added to monster.Stats.Attack pre-fight
InitiativeBias float64 // added to player initiative roll each round
LootQualityMod float64 // multiplied into AdvBonusSummary.LootQuality
}
// dmMoodCombatTilt maps the run's DM mood score to the combat dials.
func dmMoodCombatTilt(mood int) DMMoodCombatTilt {
switch moodBand(mood) {
case MoodBandEffusive:
return DMMoodCombatTilt{EnemyAttackDelta: -1, InitiativeBias: +5, LootQualityMod: 1.20}
case MoodBandFriendly:
return DMMoodCombatTilt{EnemyAttackDelta: 0, InitiativeBias: +2, LootQualityMod: 1.10}
case MoodBandGrumpy:
return DMMoodCombatTilt{EnemyAttackDelta: +1, InitiativeBias: -2, LootQualityMod: 0.90}
case MoodBandHostile:
return DMMoodCombatTilt{EnemyAttackDelta: +2, InitiativeBias: -5, LootQualityMod: 0.75}
}
return DMMoodCombatTilt{LootQualityMod: 1.0} // Neutral
}
// applyMoodDecayIfStale persists a passive-decay correction when the
// run has been idle long enough to drift. Cheap no-op on fresh runs.
//
// N3/P6d: owner-only, and the `owner` argument is not a convenience — the
// UPDATE below writes gm_mood as an *absolute* value derived from the snapshot
// the caller read, while adjustGMMood writes an atomic `gm_mood + delta`. Every
// command in the module takes the *sender's* advUserLock, so a party member
// running this against the leader's run holds the wrong mutex and would drop
// whatever the leader's concurrent walk banked between the read and this write.
// Decay is time-based maintenance: skipping it on a member's read loses nothing,
// because the owner's next command applies exactly the same correction.
func applyMoodDecayIfStale(r *DungeonRun, owner bool) error {
if r == nil || !owner {
return nil
}
newMood := passiveDecayMood(r.DMMood, r.LastActionAt, time.Now().UTC())
if newMood == r.DMMood {
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.DMMood = newMood
return nil
}