Files
gogobee/internal/plugin/dnd_zone_run.go
prosolis 4999368031 Long expeditions D1-a: graph-driven TotalRooms; goblin_warrens 7→16
Length is now sourced from the zone graph's longest entry→boss path,
not the dice roll, so "Room X/Y" matches what the player walks. The
dice fallback stays for graphless zones.

Goblin Warrens grows to the T1 12–14 band (13-node traversal): adds the
missing Trap anchor and deepens both fork branches + the post-merge
approach. Pattern reference for the remaining zones in D1-b…d.
2026-05-27 17:24:36 -07:00

540 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. 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 in-flight runs
// from before the G4 dual-write deploy that lack a CurrentNode entry.
// Returns "" if no resolution is possible.
func (r *DungeonRun) CurrentRoomType() RoomType {
if r.CurrentNode != "" {
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 tracks the zone's *graph* longest entry→boss path so the
// "Room X/Y" display lines up with what the player actually walks. If
// the graph hasn't been authored / can't be loaded, we fall back to a
// dice roll within [zone.MinRooms, zone.MaxRooms] (the pre-graph shape).
func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType {
total := 0
if g, ok := loadZoneGraph(zone.ID); ok {
total = graphLongestPath(g)
}
if total == 0 {
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)
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. New runs always start at the registered graph's
// Entry node; only zones that lack a registered graph (none, post-G8)
// would fall back to the legacy linear `<zone>.r1` namespace.
entryNode := deriveLegacyNodeID(zoneID, 0)
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, total_rooms,
rooms_cleared, gm_mood,
current_node, visited_nodes, node_choices)
VALUES (?, ?, ?, ?, '[]', ?, ?, ?, '{}')`,
run.RunID, run.UserID, string(zoneID), run.TotalRooms, 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, total_rooms,
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, total_rooms,
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
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.TotalRooms,
&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 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
// G9b: CurrentRoom is now derived from VisitedNodes — no longer
// persisted in current_room. The downstream callers that key on
// CurrentRoom (combat enemy/trap salts, status renders, harvest
// keys) stay numerically identical to the dual-write era because
// VisitedNodes was bumped in lockstep with current_room.
if n := len(r.VisitedNodes); n > 0 {
r.CurrentRoom = n - 1
}
if r.CurrentNode == "" {
// Defensive: a row from before the G4 dual-write deploy that
// somehow survived 24h inactivity timeouts. Pin to the linear
// entry node so resolveRoom doesn't crash.
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 along the first outgoing graph edge. Returns the
// new current room type (or "" if the run completed via boss kill /
// dead-end). Used by tests that drive a run end-to-end without going
// through the !zone advance command surface; the runtime command path
// uses advanceTransitionGraph directly so it can render fork prompts.
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
}
g, ok := loadZoneGraph(r.ZoneID)
if !ok {
return "", fmt.Errorf("no graph for zone %q", r.ZoneID)
}
cleared := append(r.RoomsCleared, r.CurrentRoom)
clearedJSON, _ := json.Marshal(cleared)
edges := g.outgoingEdges(r.CurrentNode)
if len(edges) == 0 {
// Dead-end or boss — run completes here.
isBoss := g.Nodes[r.CurrentNode].IsBoss
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(isBoss), now, now, runID,
); err != nil {
return "", err
}
return "", nil
}
nextNode := edges[0].To
visited := append(r.VisitedNodes, nextNode)
visitedJSON, _ := json.Marshal(visited)
if _, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET rooms_cleared = ?,
current_node = ?,
visited_nodes = ?,
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`,
string(clearedJSON), nextNode, string(visitedJSON), runID,
); err != nil {
return "", err
}
r.CurrentNode = nextNode
r.VisitedNodes = visited
return nodeKindToRoomType(g.Nodes[nextNode].Kind), 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
}