mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
G1 — schema. Adds zone_node + zone_edge tables and three columns to dnd_zone_run (current_node, visited_nodes, node_choices). Linear columns stay during the migration; G9 retires them. G2 — types + validator. New internal/plugin/zone_graph.go defines ZoneNode/ZoneEdge/ZoneGraph + ZoneNodeKind/ZoneEdgeLockKind. BuildGraph enforces: exactly one entry, exactly one boss, boss reachable via BFS, no orphan nodes, no self-loops without explicit opt-in. BuildLinearGraph synthesizes a chain for legacy zones. G3 — legacy compiler + dual-mode loader. compileLegacyZoneGraph turns a ZoneDefinition into a representative linear graph (MaxRooms shape). loadZoneGraph returns the registered graph if hand-authored (G7+), else the legacy fallback. compileRunGraph mirrors a per-run RoomSeq exactly for hot-swap derivations. G4 — run-state dual-write. DungeonRun gains CurrentNode / VisitedNodes / NodeChoices. scanZoneRun reads them and hot-swaps current_node from current_room when a row predates the migration (deriveLegacyNodeID matches BuildLinearGraph's "<zone>.r<n>" scheme). startZoneRun and markRoomCleared write both columns. No behavior change yet — navigation surface (forks, locked edges, !zone go) lands in G5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
504 lines
16 KiB
Go
504 lines
16 KiB
Go
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 (CurrentRoom is 0-indexed). Returns "" if out of range.
|
||
func (r *DungeonRun) CurrentRoomType() RoomType {
|
||
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 1–2 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.
|
||
entryNode := deriveLegacyNodeID(zoneID, 0)
|
||
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,
|
||
¤tNode, &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
|
||
}
|
||
|