mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Adv 2.0 D&D Phase 11 D1d: TwinBee narration + mood triggers
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) <noreply@anthropic.com>
This commit is contained in:
@@ -185,6 +185,10 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
|
|||||||
var b strings.Builder
|
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(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms))
|
||||||
b.WriteString("_" + zone.Hook + "_\n\n")
|
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.",
|
b.WriteString(fmt.Sprintf("**Room 1/%d — %s.** Use `!zone advance` to proceed, `!zone map` for layout.",
|
||||||
run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
|
run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
@@ -200,6 +204,7 @@ func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error {
|
|||||||
if run == nil {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone list` then `!zone enter <id>`.")
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone list` then `!zone enter <id>`.")
|
||||||
}
|
}
|
||||||
|
_ = applyMoodDecayIfStale(run)
|
||||||
zone, _ := getZone(run.ZoneID)
|
zone, _ := getZone(run.ZoneID)
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
|
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
|
||||||
@@ -292,6 +297,7 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
|||||||
if run == nil {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||||
}
|
}
|
||||||
|
_ = applyMoodDecayIfStale(run)
|
||||||
zone, _ := getZone(run.ZoneID)
|
zone, _ := getZone(run.ZoneID)
|
||||||
prev := run.CurrentRoomType()
|
prev := run.CurrentRoomType()
|
||||||
prevIdx := run.CurrentRoom
|
prevIdx := run.CurrentRoom
|
||||||
@@ -300,15 +306,32 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
|||||||
return p.SendDM(ctx.Sender, "Couldn't advance: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't advance: "+err.Error())
|
||||||
}
|
}
|
||||||
if next == "" {
|
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
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Boss defeated. Run complete.\n\n", zone.Display))
|
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.)_")
|
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())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
nextIdx := run.CurrentRoom + 1 // markRoomCleared already advanced; reflect for narration salt
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n", prevIdx+1, prettyRoomType(prev)))
|
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
|
||||||
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", run.CurrentRoom+2, run.TotalRooms, prettyRoomType(next)))
|
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.")
|
b.WriteString("`!zone advance` to continue.")
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
|||||||
274
internal/plugin/dnd_zone_narration.go
Normal file
274
internal/plugin/dnd_zone_narration.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
193
internal/plugin/dnd_zone_narration_test.go
Normal file
193
internal/plugin/dnd_zone_narration_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user