Files
gogobee/internal/plugin/dnd_zone_run.go
prosolis 90ff7666bd Branching zones G7: Crypt of Valdris POC graph
Hand-authored zoneCryptValdrisGraph() (8 nodes incl. fork +
Perception DC 15 secret reliquary) self-registers at init. Two
surgical bridges so new runs traverse the authored graph when
GOGOBEE_BRANCHING_ZONES=1: startZoneRun seeds current_node from
g.Entry, and DungeonRun.CurrentRoomType resolves via the live
node's kind so divergent paths (secret_chamber) dispatch through
the right resolver. Gate off is bit-identical to pre-G7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:28:58 -07:00

528 lines
17 KiB
Go
Raw 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 (
cryptorand "crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/rand/v2"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// Phase 11 D1b — DungeonRun state machine. Implements `gogobee_dungeon_zones.md`
// §4 (Dungeon Structure) and the DungeonRun model in §7. Persists a single
// zone run per player to dnd_zone_run.
//
// Room sequencing follows the design doc fixed pattern:
// Entry → Exploration ×N₁ → Trap → Exploration ×N₂ → Elite → Boss
// where N₁ + N₂ scales with zone tier so total rooms lands in the
// zone's [MinRooms, MaxRooms] window. Boss is always last.
//
// State machine:
// start → advance × R → boss → complete
// ↓
// abandon (manual or 24h-idle, D1c)
//
// D1b ships persistence and pure logic only. !zone enter/advance/etc
// commands wire to this in D1c; combat resolution per room is wired in
// D1e onwards. Trap/elite/boss room *behavior* is not implemented yet —
// advancing through them currently just records them as cleared.
// RoomType enumerates the room categories per design doc §4.2.
type RoomType string
const (
RoomEntry RoomType = "entry"
RoomExploration RoomType = "exploration"
RoomTrap RoomType = "trap"
RoomElite RoomType = "elite"
RoomBoss RoomType = "boss"
)
// DungeonRun is the in-memory shape of a dnd_zone_run row.
//
// Phase G4 adds CurrentNode / VisitedNodes / NodeChoices alongside the
// legacy CurrentRoom / RoomSeq fields. The graph columns dual-write
// during the migration; readers prefer CurrentNode but fall back to
// deriving it from CurrentRoom + RoomSeq (compileRunGraph) when a row
// predates the migration. The legacy fields retire in G9.
type DungeonRun struct {
RunID string
UserID string
ZoneID ZoneID
CurrentRoom int
TotalRooms int
RoomSeq []RoomType
RoomsCleared []int
BossDefeated bool
Abandoned bool
LootCollected []string
DMMood int
StartedAt time.Time
LastActionAt time.Time
CompletedAt *time.Time
// Phase G4 — branching zone graph run state. CurrentNode is the
// authoritative position once the graph rollout is complete; until
// then it dual-writes with CurrentRoom. VisitedNodes is the ordered
// path of node_ids the player has resolved. NodeChoices stores
// pending fork-prompt state (G5 surface) — populated when the player
// arrives at a fork with 2+ unlocked outgoing edges.
CurrentNode string
VisitedNodes []string
NodeChoices map[string]any
}
// IsActive reports whether this run is still ongoing (not boss-defeated,
// not abandoned, not otherwise completed).
func (r *DungeonRun) IsActive() bool {
return !r.BossDefeated && !r.Abandoned && r.CompletedAt == nil
}
// CurrentRoomType returns the type of the room the player is currently
// standing in. In gated graph mode (GOGOBEE_BRANCHING_ZONES=1) with a
// hand-authored ZoneGraph registered for the run's zone, the live
// CurrentNode's kind is authoritative — that's the only way side paths
// (e.g. the Crypt of Valdris secret chamber) resolve to the right
// room-type when the player diverges from the canonical RoomSeq. The
// legacy RoomSeq fallback covers gate-off runs and runs in zones whose
// graph is still the linear-compiled one. Returns "" if no resolution
// is possible.
func (r *DungeonRun) CurrentRoomType() RoomType {
if branchingZonesEnabled() && r.CurrentNode != "" {
if _, authored := zoneGraphRegistry[r.ZoneID]; authored {
if g, ok := loadZoneGraph(r.ZoneID); ok {
if n, exists := g.Nodes[r.CurrentNode]; exists {
return nodeKindToRoomType(n.Kind)
}
}
}
}
if r.CurrentRoom < 0 || r.CurrentRoom >= len(r.RoomSeq) {
return ""
}
return r.RoomSeq[r.CurrentRoom]
}
// generateRoomSequence builds the deterministic-but-seeded room layout
// for a run of the given zone. The boss room is always last; one Entry
// is always first; one Trap and one Elite room sit between explorations.
// Total length is sampled in [zone.MinRooms, zone.MaxRooms].
func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType {
total := zone.MinRooms
if zone.MaxRooms > zone.MinRooms {
total += rng.IntN(zone.MaxRooms - zone.MinRooms + 1)
}
// Fixed slots: Entry + Trap + Elite + Boss = 4. Remaining = explorations.
const fixed = 4
if total < fixed+2 {
total = fixed + 2 // at least 2 exploration rooms
}
explorations := total - fixed
// Split exploration rooms before/after the trap. Bias toward 12 on each side.
preTrap := 1
if explorations >= 3 {
preTrap = 1 + rng.IntN(explorations-1)
} else if explorations == 2 {
preTrap = 1
}
postTrap := explorations - preTrap
seq := make([]RoomType, 0, total)
seq = append(seq, RoomEntry)
for i := 0; i < preTrap; i++ {
seq = append(seq, RoomExploration)
}
seq = append(seq, RoomTrap)
for i := 0; i < postTrap; i++ {
seq = append(seq, RoomExploration)
}
seq = append(seq, RoomElite)
seq = append(seq, RoomBoss)
return seq
}
// newRunID — 16-char hex token. Crypto-random; collision-resistant.
func newRunID() string {
var b [8]byte
if _, err := cryptorand.Read(b[:]); err != nil {
// Fall back to math/rand if /dev/urandom is unavailable.
// Run IDs are not security-sensitive, just unique.
v := rng2.Uint64()
for i := range b {
b[i] = byte(v >> (8 * i))
}
}
return hex.EncodeToString(b[:])
}
// rng2 is a seeded math/rand fallback for newRunID. Test isolation isn't
// needed — collisions are still vanishingly unlikely.
var rng2 = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0xC0FFEE))
// ---- Persistence ------------------------------------------------------------
var (
// ErrRunAlreadyActive — player tried to start a run while another is in flight.
ErrRunAlreadyActive = errors.New("zone run already active for player")
// ErrNoActiveRun — player tried to advance/retreat with no run in flight.
ErrNoActiveRun = errors.New("no active zone run for player")
// ErrUnknownZone — start called with an unregistered ZoneID.
ErrUnknownZone = errors.New("unknown zone")
// ErrZoneTierLocked — player level too low for the zone.
ErrZoneTierLocked = errors.New("zone tier above player ceiling")
)
// startZoneRun creates a new run for the player. Fails if the player has
// an active run, the zone is unknown, or the player's level is too far
// below the zone's tier (per zonesForLevel's gate).
func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand) (*DungeonRun, error) {
zone, ok := getZone(zoneID)
if !ok {
return nil, ErrUnknownZone
}
allowed := false
for _, z := range zonesForLevel(dndLevel) {
if z.ID == zoneID {
allowed = true
break
}
}
if !allowed {
return nil, ErrZoneTierLocked
}
existing, err := getActiveZoneRun(userID)
if err != nil {
return nil, err
}
if existing != nil {
return nil, ErrRunAlreadyActive
}
if rng == nil {
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
}
seq := generateRoomSequence(zone, rng)
seqJSON, _ := json.Marshal(seq)
startMood := 50
if isHol, _ := isHolidayToday(); isHol {
startMood = 55
}
run := &DungeonRun{
RunID: newRunID(),
UserID: string(userID),
ZoneID: zoneID,
CurrentRoom: 0,
TotalRooms: len(seq),
RoomSeq: seq,
RoomsCleared: []int{},
DMMood: startMood,
StartedAt: time.Now().UTC(),
LastActionAt: time.Now().UTC(),
}
// G4 dual-write: persist the entry node id and seed visited_nodes
// with it, so navigation surfaces in G5 can read graph state without
// further migration. G7: when graph mode is on AND the zone has a
// hand-authored graph, start at that graph's Entry node so the player
// actually traverses the authored topology rather than falling off
// into the legacy `<zone>.r1` namespace.
entryNode := deriveLegacyNodeID(zoneID, 0)
if branchingZonesEnabled() {
if g, ok := zoneGraphRegistry[zoneID]; ok {
entryNode = g.Entry
}
}
visitedJSON, _ := json.Marshal([]string{entryNode})
run.CurrentNode = entryNode
run.VisitedNodes = []string{entryNode}
run.NodeChoices = map[string]any{}
if _, err := db.Get().Exec(`
INSERT INTO dnd_zone_run
(run_id, user_id, zone_id, current_room, total_rooms,
room_seq_json, rooms_cleared, gm_mood,
current_node, visited_nodes, node_choices)
VALUES (?, ?, ?, 0, ?, ?, '[]', ?, ?, ?, '{}')`,
run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON), startMood,
entryNode, string(visitedJSON),
); err != nil {
return nil, fmt.Errorf("insert zone run: %w", err)
}
return run, nil
}
// zoneRunInactivityTimeout is the §4.3 stale-run threshold: a run that
// has gone untouched for this long is auto-abandoned the next time
// anyone looks at it.
const zoneRunInactivityTimeout = 24 * time.Hour
// getActiveZoneRun returns the player's in-flight run, or (nil, nil) if
// none. If the most-recent active run has been idle longer than
// zoneRunInactivityTimeout, it's auto-abandoned (§4.3) and the
// function returns (nil, nil) — the caller sees a clean slate.
func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
row := db.Get().QueryRow(`
SELECT run_id, user_id, zone_id, current_room, total_rooms,
room_seq_json, rooms_cleared, boss_defeated, abandoned,
loot_collected, gm_mood, started_at, last_action_at, completed_at,
current_node, visited_nodes, node_choices
FROM dnd_zone_run
WHERE user_id = ?
AND completed_at IS NULL
AND abandoned = 0
AND boss_defeated = 0
ORDER BY started_at DESC
LIMIT 1`,
string(userID))
r, err := scanZoneRun(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if time.Since(r.LastActionAt) > zoneRunInactivityTimeout {
_ = abandonZoneRunByID(r.RunID)
return nil, nil
}
return r, nil
}
// getZoneRun fetches by RunID regardless of completion state. Test/admin use.
func getZoneRun(runID string) (*DungeonRun, error) {
row := db.Get().QueryRow(`
SELECT run_id, user_id, zone_id, current_room, total_rooms,
room_seq_json, rooms_cleared, boss_defeated, abandoned,
loot_collected, gm_mood, started_at, last_action_at, completed_at,
current_node, visited_nodes, node_choices
FROM dnd_zone_run WHERE run_id = ?`, runID)
r, err := scanZoneRun(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return r, err
}
// scanZoneRun reads one row into a DungeonRun (used by both fetchers).
type scanner interface {
Scan(dest ...any) error
}
func scanZoneRun(row scanner) (*DungeonRun, error) {
var (
r DungeonRun
zoneID string
seqJSON string
clearedJSON string
lootJSON string
bossDefeatedI int
abandonedI int
completedAtRaw sql.NullTime
currentNode string
visitedJSON string
choicesJSON string
)
if err := row.Scan(
&r.RunID, &r.UserID, &zoneID, &r.CurrentRoom, &r.TotalRooms,
&seqJSON, &clearedJSON, &bossDefeatedI, &abandonedI,
&lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw,
&currentNode, &visitedJSON, &choicesJSON,
); err != nil {
return nil, err
}
r.ZoneID = ZoneID(zoneID)
r.BossDefeated = bossDefeatedI != 0
r.Abandoned = abandonedI != 0
if completedAtRaw.Valid {
t := completedAtRaw.Time
r.CompletedAt = &t
}
if err := json.Unmarshal([]byte(seqJSON), &r.RoomSeq); err != nil {
return nil, fmt.Errorf("decode room_seq_json: %w", err)
}
if clearedJSON != "" {
if err := json.Unmarshal([]byte(clearedJSON), &r.RoomsCleared); err != nil {
return nil, fmt.Errorf("decode rooms_cleared: %w", err)
}
}
if r.RoomsCleared == nil {
r.RoomsCleared = []int{}
}
if lootJSON != "" {
if err := json.Unmarshal([]byte(lootJSON), &r.LootCollected); err != nil {
return nil, fmt.Errorf("decode loot_collected: %w", err)
}
}
if r.LootCollected == nil {
r.LootCollected = []string{}
}
// G4 graph run state. visited_nodes / node_choices come straight
// from JSON; current_node is hot-swap-derived from current_room
// when empty (rows that predate the migration).
if visitedJSON != "" && visitedJSON != "[]" {
if err := json.Unmarshal([]byte(visitedJSON), &r.VisitedNodes); err != nil {
return nil, fmt.Errorf("decode visited_nodes: %w", err)
}
}
if r.VisitedNodes == nil {
r.VisitedNodes = []string{}
}
if choicesJSON != "" && choicesJSON != "{}" {
if err := json.Unmarshal([]byte(choicesJSON), &r.NodeChoices); err != nil {
return nil, fmt.Errorf("decode node_choices: %w", err)
}
}
if r.NodeChoices == nil {
r.NodeChoices = map[string]any{}
}
r.CurrentNode = currentNode
if r.CurrentNode == "" && len(r.RoomSeq) > 0 {
r.CurrentNode = deriveLegacyNodeID(r.ZoneID, r.CurrentRoom)
}
return &r, nil
}
// deriveLegacyNodeID returns the node id a linear graph would assign
// to position roomIdx (0-based) for the given zone. Mirrors
// BuildLinearGraph's "<zone>.r<n>" scheme so hot-swapped rows align
// with newly-started runs.
func deriveLegacyNodeID(zoneID ZoneID, roomIdx int) string {
if roomIdx < 0 {
roomIdx = 0
}
return fmt.Sprintf("%s.r%d", zoneID, roomIdx+1)
}
// markRoomCleared records that the current room has been resolved and
// advances the player to the next room. Returns the new current room
// type (or "" if the run completed via boss kill / final room).
func markRoomCleared(runID string) (RoomType, error) {
r, err := getZoneRun(runID)
if err != nil {
return "", err
}
if r == nil {
return "", ErrNoActiveRun
}
if !r.IsActive() {
return "", ErrNoActiveRun
}
cleared := append(r.RoomsCleared, r.CurrentRoom)
clearedJSON, _ := json.Marshal(cleared)
wasBossRoom := r.CurrentRoomType() == RoomBoss
next := r.CurrentRoom + 1
if next >= r.TotalRooms || wasBossRoom {
// Boss room cleared = run complete via boss defeat.
now := time.Now().UTC()
if _, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET rooms_cleared = ?,
boss_defeated = ?,
completed_at = ?,
last_action_at = ?
WHERE run_id = ?`,
string(clearedJSON), boolToInt(wasBossRoom), now, now, runID,
); err != nil {
return "", err
}
return "", nil
}
// G4 dual-write: advance current_node + append to visited_nodes
// alongside the legacy current_room bump.
nextNode := deriveLegacyNodeID(r.ZoneID, next)
visited := append(r.VisitedNodes, nextNode)
visitedJSON, _ := json.Marshal(visited)
if _, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET rooms_cleared = ?,
current_room = ?,
current_node = ?,
visited_nodes = ?,
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`,
string(clearedJSON), next, nextNode, string(visitedJSON), runID,
); err != nil {
return "", err
}
r.CurrentRoom = next
r.CurrentNode = nextNode
r.VisitedNodes = visited
return r.RoomSeq[next], nil
}
// abandonZoneRun flags the active run as abandoned. Idempotent: returns
// ErrNoActiveRun if there is nothing to abandon.
func abandonZoneRun(userID id.UserID) error {
r, err := getActiveZoneRun(userID)
if err != nil {
return err
}
if r == nil {
return ErrNoActiveRun
}
_, err = db.Get().Exec(`
UPDATE dnd_zone_run
SET abandoned = 1,
completed_at = CURRENT_TIMESTAMP,
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, r.RunID)
return err
}
// abandonZoneRunByID abandons a specific run regardless of its active
// status. Idempotent — exits cleanly if the row is already terminal.
// Used by the expedition layer to retire a region's run when the player
// travels onward, since the user-keyed abandonZoneRun would refuse to
// fire when the run is no longer "active" (e.g. boss defeated).
func abandonZoneRunByID(runID string) error {
if runID == "" {
return nil
}
_, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET abandoned = 1,
completed_at = COALESCE(completed_at, CURRENT_TIMESTAMP),
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, runID)
return err
}
// adjustGMMood clamps mood to [0, 100] and persists. Used by D1d when
// nat-1/nat-20/zone-completion events fire. delta may be negative.
func adjustGMMood(runID string, delta int) error {
_, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET gm_mood = MAX(0, MIN(100, gm_mood + ?)),
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, delta, runID)
return err
}
// addLoot appends an item ID to the run's loot manifest. Caller is
// responsible for actually granting the item to the player's inventory;
// this is the audit trail of what dropped during the run.
func addLoot(runID string, itemID string) error {
r, err := getZoneRun(runID)
if err != nil {
return err
}
if r == nil {
return ErrNoActiveRun
}
loot := append(r.LootCollected, itemID)
lootJSON, _ := json.Marshal(loot)
_, err = db.Get().Exec(`
UPDATE dnd_zone_run
SET loot_collected = ?,
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, string(lootJSON), runID)
return err
}