Branching zones G9b: stop persisting current_room / room_seq_json

The two legacy columns are no longer needed at runtime. CurrentRoom is
derived from len(VisitedNodes)-1 on read (lockstep with what the
dual-write era stored), and RoomSeq is a transient in-memory field
populated only when a fresh run is started.

- INSERT, SELECT, and UPDATE statements drop the two columns. The
  schema still carries them — they retire in G9c.
- scanZoneRun derives CurrentRoom and falls back to a linear-derived
  CurrentNode only if a row predates the G4 dual-write deploy.
- markRoomCleared rewritten to walk the registered graph (first
  outgoing edge / dead-end completion). Tests that use it as an
  end-to-end run driver continue to pass.
- advanceZoneRunNode drops its current_room dual-write.
- adventure_activity_unified reads visited_nodes for the daily
  "X/N rooms" progress fragment.
- Camp boss-room test pivots to setting current_node directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 17:21:33 -07:00
parent 103cf30987
commit a5c0a83e2a
4 changed files with 69 additions and 51 deletions

View File

@@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -78,9 +79,10 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error)
} }
rows.Close() rows.Close()
// 2. dnd_zone_run — rows touched today. // 2. dnd_zone_run — rows touched today. Progress count is derived
// from len(visited_nodes) — current_room retired in G9.
rows, err = d.Query(` rows, err = d.Query(`
SELECT user_id, zone_id, current_room, total_rooms, abandoned, SELECT user_id, zone_id, visited_nodes, total_rooms, abandoned,
boss_defeated, completed_at, last_action_at boss_defeated, completed_at, last_action_at
FROM dnd_zone_run FROM dnd_zone_run
WHERE last_action_at >= ? AND last_action_at < DATE(?, '+1 day') WHERE last_action_at >= ? AND last_action_at < DATE(?, '+1 day')
@@ -91,16 +93,23 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error)
for rows.Next() { for rows.Next() {
var ( var (
uid, zoneID string uid, zoneID string
currentRoom, totalRooms int visitedJSON string
totalRooms int
abandoned, bossDefeated int abandoned, bossDefeated int
completedAt *time.Time completedAt *time.Time
lastAction time.Time lastAction time.Time
) )
if err := rows.Scan(&uid, &zoneID, &currentRoom, &totalRooms, if err := rows.Scan(&uid, &zoneID, &visitedJSON, &totalRooms,
&abandoned, &bossDefeated, &completedAt, &lastAction); err != nil { &abandoned, &bossDefeated, &completedAt, &lastAction); err != nil {
rows.Close() rows.Close()
return nil, fmt.Errorf("zone run scan: %w", err) return nil, fmt.Errorf("zone run scan: %w", err)
} }
var visited []string
_ = json.Unmarshal([]byte(visitedJSON), &visited)
currentRoom := len(visited) - 1
if currentRoom < 0 {
currentRoom = 0
}
userID := id.UserID(uid) userID := id.UserID(uid)
zoneDef := zoneOrFallback(ZoneID(zoneID)) zoneDef := zoneOrFallback(ZoneID(zoneID))

View File

@@ -267,24 +267,27 @@ func TestCampLocationCheck_BossRoomBlocks(t *testing.T) {
defer cleanupZoneRuns(uid) defer cleanupZoneRuns(uid)
defer cleanupExpeditions(uid) defer cleanupExpeditions(uid)
// Pre-create a zone run, then advance current_room to the boss room. // Pre-create a zone run, then warp current_node to the graph's boss.
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Walk to the boss room directly (last index). g, ok := loadZoneGraph(run.ZoneID)
bossIdx := -1 if !ok {
for i, rt := range run.RoomSeq { t.Fatalf("no graph for zone %q", run.ZoneID)
if rt == RoomBoss { }
bossIdx = i bossNodeID := ""
for nid, n := range g.Nodes {
if n.IsBoss {
bossNodeID = nid
break
} }
} }
if bossIdx < 0 { if bossNodeID == "" {
t.Fatal("no boss room in seq") t.Fatal("no boss node in graph")
} }
exp := &Expedition{RunID: run.RunID} exp := &Expedition{RunID: run.RunID}
// Force current_room to bossIdx. if _, err := db.Get().Exec(`UPDATE dnd_zone_run SET current_node = ? WHERE run_id = ?`, bossNodeID, run.RunID); err != nil {
if _, err := db.Get().Exec(`UPDATE dnd_zone_run SET current_room = ? WHERE run_id = ?`, bossIdx, run.RunID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
cleared, problem := campLocationCheck(exp) cleared, problem := campLocationCheck(exp)

View File

@@ -204,7 +204,6 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro()))) rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
} }
seq := generateRoomSequence(zone, rng) seq := generateRoomSequence(zone, rng)
seqJSON, _ := json.Marshal(seq)
startMood := 50 startMood := 50
if isHol, _ := isHolidayToday(); isHol { if isHol, _ := isHolidayToday(); isHol {
@@ -237,11 +236,11 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
run.NodeChoices = map[string]any{} run.NodeChoices = map[string]any{}
if _, err := db.Get().Exec(` if _, err := db.Get().Exec(`
INSERT INTO dnd_zone_run INSERT INTO dnd_zone_run
(run_id, user_id, zone_id, current_room, total_rooms, (run_id, user_id, zone_id, total_rooms,
room_seq_json, rooms_cleared, gm_mood, rooms_cleared, gm_mood,
current_node, visited_nodes, node_choices) current_node, visited_nodes, node_choices)
VALUES (?, ?, ?, 0, ?, ?, '[]', ?, ?, ?, '{}')`, VALUES (?, ?, ?, ?, '[]', ?, ?, ?, '{}')`,
run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON), startMood, run.RunID, run.UserID, string(zoneID), run.TotalRooms, startMood,
entryNode, string(visitedJSON), entryNode, string(visitedJSON),
); err != nil { ); err != nil {
return nil, fmt.Errorf("insert zone run: %w", err) return nil, fmt.Errorf("insert zone run: %w", err)
@@ -260,8 +259,8 @@ const zoneRunInactivityTimeout = 24 * time.Hour
// function returns (nil, nil) — the caller sees a clean slate. // function returns (nil, nil) — the caller sees a clean slate.
func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) { func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
row := db.Get().QueryRow(` row := db.Get().QueryRow(`
SELECT run_id, user_id, zone_id, current_room, total_rooms, SELECT run_id, user_id, zone_id, total_rooms,
room_seq_json, rooms_cleared, boss_defeated, abandoned, rooms_cleared, boss_defeated, abandoned,
loot_collected, gm_mood, started_at, last_action_at, completed_at, loot_collected, gm_mood, started_at, last_action_at, completed_at,
current_node, visited_nodes, node_choices current_node, visited_nodes, node_choices
FROM dnd_zone_run FROM dnd_zone_run
@@ -289,8 +288,8 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
// getZoneRun fetches by RunID regardless of completion state. Test/admin use. // getZoneRun fetches by RunID regardless of completion state. Test/admin use.
func getZoneRun(runID string) (*DungeonRun, error) { func getZoneRun(runID string) (*DungeonRun, error) {
row := db.Get().QueryRow(` row := db.Get().QueryRow(`
SELECT run_id, user_id, zone_id, current_room, total_rooms, SELECT run_id, user_id, zone_id, total_rooms,
room_seq_json, rooms_cleared, boss_defeated, abandoned, rooms_cleared, boss_defeated, abandoned,
loot_collected, gm_mood, started_at, last_action_at, completed_at, loot_collected, gm_mood, started_at, last_action_at, completed_at,
current_node, visited_nodes, node_choices current_node, visited_nodes, node_choices
FROM dnd_zone_run WHERE run_id = ?`, runID) FROM dnd_zone_run WHERE run_id = ?`, runID)
@@ -310,7 +309,6 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
var ( var (
r DungeonRun r DungeonRun
zoneID string zoneID string
seqJSON string
clearedJSON string clearedJSON string
lootJSON string lootJSON string
bossDefeatedI int bossDefeatedI int
@@ -321,8 +319,8 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
choicesJSON string choicesJSON string
) )
if err := row.Scan( if err := row.Scan(
&r.RunID, &r.UserID, &zoneID, &r.CurrentRoom, &r.TotalRooms, &r.RunID, &r.UserID, &zoneID, &r.TotalRooms,
&seqJSON, &clearedJSON, &bossDefeatedI, &abandonedI, &clearedJSON, &bossDefeatedI, &abandonedI,
&lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw, &lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw,
&currentNode, &visitedJSON, &choicesJSON, &currentNode, &visitedJSON, &choicesJSON,
); err != nil { ); err != nil {
@@ -335,9 +333,6 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
t := completedAtRaw.Time t := completedAtRaw.Time
r.CompletedAt = &t 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 clearedJSON != "" {
if err := json.Unmarshal([]byte(clearedJSON), &r.RoomsCleared); err != nil { if err := json.Unmarshal([]byte(clearedJSON), &r.RoomsCleared); err != nil {
return nil, fmt.Errorf("decode rooms_cleared: %w", err) return nil, fmt.Errorf("decode rooms_cleared: %w", err)
@@ -374,7 +369,18 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
r.NodeChoices = map[string]any{} r.NodeChoices = map[string]any{}
} }
r.CurrentNode = currentNode r.CurrentNode = currentNode
if r.CurrentNode == "" && len(r.RoomSeq) > 0 { // 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) r.CurrentNode = deriveLegacyNodeID(r.ZoneID, r.CurrentRoom)
} }
return &r, nil return &r, nil
@@ -392,8 +398,11 @@ func deriveLegacyNodeID(zoneID ZoneID, roomIdx int) string {
} }
// markRoomCleared records that the current room has been resolved and // markRoomCleared records that the current room has been resolved and
// advances the player to the next room. Returns the new current room // advances the player along the first outgoing graph edge. Returns the
// type (or "" if the run completed via boss kill / final room). // 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) { func markRoomCleared(runID string) (RoomType, error) {
r, err := getZoneRun(runID) r, err := getZoneRun(runID)
if err != nil { if err != nil {
@@ -405,13 +414,17 @@ func markRoomCleared(runID string) (RoomType, error) {
if !r.IsActive() { if !r.IsActive() {
return "", ErrNoActiveRun 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) cleared := append(r.RoomsCleared, r.CurrentRoom)
clearedJSON, _ := json.Marshal(cleared) clearedJSON, _ := json.Marshal(cleared)
wasBossRoom := r.CurrentRoomType() == RoomBoss edges := g.outgoingEdges(r.CurrentNode)
next := r.CurrentRoom + 1 if len(edges) == 0 {
if next >= r.TotalRooms || wasBossRoom { // Dead-end or boss — run completes here.
// Boss room cleared = run complete via boss defeat. isBoss := g.Nodes[r.CurrentNode].IsBoss
now := time.Now().UTC() now := time.Now().UTC()
if _, err := db.Get().Exec(` if _, err := db.Get().Exec(`
UPDATE dnd_zone_run UPDATE dnd_zone_run
@@ -420,33 +433,29 @@ func markRoomCleared(runID string) (RoomType, error) {
completed_at = ?, completed_at = ?,
last_action_at = ? last_action_at = ?
WHERE run_id = ?`, WHERE run_id = ?`,
string(clearedJSON), boolToInt(wasBossRoom), now, now, runID, string(clearedJSON), boolToInt(isBoss), now, now, runID,
); err != nil { ); err != nil {
return "", err return "", err
} }
return "", nil return "", nil
} }
// G4 dual-write: advance current_node + append to visited_nodes nextNode := edges[0].To
// alongside the legacy current_room bump.
nextNode := deriveLegacyNodeID(r.ZoneID, next)
visited := append(r.VisitedNodes, nextNode) visited := append(r.VisitedNodes, nextNode)
visitedJSON, _ := json.Marshal(visited) visitedJSON, _ := json.Marshal(visited)
if _, err := db.Get().Exec(` if _, err := db.Get().Exec(`
UPDATE dnd_zone_run UPDATE dnd_zone_run
SET rooms_cleared = ?, SET rooms_cleared = ?,
current_room = ?,
current_node = ?, current_node = ?,
visited_nodes = ?, visited_nodes = ?,
last_action_at = CURRENT_TIMESTAMP last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, WHERE run_id = ?`,
string(clearedJSON), next, nextNode, string(visitedJSON), runID, string(clearedJSON), nextNode, string(visitedJSON), runID,
); err != nil { ); err != nil {
return "", err return "", err
} }
r.CurrentRoom = next
r.CurrentNode = nextNode r.CurrentNode = nextNode
r.VisitedNodes = visited r.VisitedNodes = visited
return r.RoomSeq[next], nil return nodeKindToRoomType(g.Nodes[nextNode].Kind), nil
} }
// abandonZoneRun flags the active run as abandoned. Idempotent: returns // abandonZoneRun flags the active run as abandoned. Idempotent: returns

View File

@@ -300,7 +300,7 @@ func renderForkPrompt(zone ZoneDefinition, pf pendingFork) string {
} }
// recordRoomCleared appends the current room to rooms_cleared and // recordRoomCleared appends the current room to rooms_cleared and
// bumps last_action_at, without advancing current_room/current_node. // bumps last_action_at, without advancing current_node.
// Used by the graph-mode fork path: clearing the room is a separate // Used by the graph-mode fork path: clearing the room is a separate
// step from choosing where to go next. Returns the updated DungeonRun // step from choosing where to go next. Returns the updated DungeonRun
// snapshot reloaded post-write so callers see fresh fields. // snapshot reloaded post-write so callers see fresh fields.
@@ -377,9 +377,8 @@ func completeRunAtNode(runID string, boss bool) error {
} }
// advanceZoneRunNode moves a run to nextNode: appends to visited_nodes, // advanceZoneRunNode moves a run to nextNode: appends to visited_nodes,
// sets current_node, bumps current_room (legacy dual-write), and // sets current_node, and clears any pending fork prompt. Caller is
// clears any pending fork prompt. Caller is expected to have already // expected to have already called recordRoomCleared for the prior node.
// called recordRoomCleared for the prior node.
func advanceZoneRunNode(runID, nextNode string) error { func advanceZoneRunNode(runID, nextNode string) error {
r, err := getZoneRun(runID) r, err := getZoneRun(runID)
if err != nil { if err != nil {
@@ -390,16 +389,14 @@ func advanceZoneRunNode(runID, nextNode string) error {
} }
visited := append(r.VisitedNodes, nextNode) visited := append(r.VisitedNodes, nextNode)
visitedJSON, _ := json.Marshal(visited) visitedJSON, _ := json.Marshal(visited)
nextRoom := r.CurrentRoom + 1
_, err = db.Get().Exec(` _, err = db.Get().Exec(`
UPDATE dnd_zone_run UPDATE dnd_zone_run
SET current_node = ?, SET current_node = ?,
visited_nodes = ?, visited_nodes = ?,
current_room = ?,
node_choices = '{}', node_choices = '{}',
last_action_at = CURRENT_TIMESTAMP last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, WHERE run_id = ?`,
nextNode, string(visitedJSON), nextRoom, runID) nextNode, string(visitedJSON), runID)
return err return err
} }