From a5c0a83e2acd929081fbdbd20f7f3dc17276a244 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 17:21:33 -0700 Subject: [PATCH] Branching zones G9b: stop persisting current_room / room_seq_json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/plugin/adventure_activity_unified.go | 17 +++-- internal/plugin/dnd_expedition_camp_test.go | 23 ++++--- internal/plugin/dnd_zone_run.go | 69 +++++++++++-------- internal/plugin/zone_graph_nav.go | 11 ++- 4 files changed, 69 insertions(+), 51 deletions(-) diff --git a/internal/plugin/adventure_activity_unified.go b/internal/plugin/adventure_activity_unified.go index cc06cdb..48d3ca6 100644 --- a/internal/plugin/adventure_activity_unified.go +++ b/internal/plugin/adventure_activity_unified.go @@ -1,6 +1,7 @@ package plugin import ( + "encoding/json" "fmt" "time" @@ -78,9 +79,10 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error) } 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(` - 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 FROM dnd_zone_run 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() { var ( uid, zoneID string - currentRoom, totalRooms int + visitedJSON string + totalRooms int abandoned, bossDefeated int completedAt *time.Time lastAction time.Time ) - if err := rows.Scan(&uid, &zoneID, ¤tRoom, &totalRooms, + if err := rows.Scan(&uid, &zoneID, &visitedJSON, &totalRooms, &abandoned, &bossDefeated, &completedAt, &lastAction); err != nil { rows.Close() 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) zoneDef := zoneOrFallback(ZoneID(zoneID)) diff --git a/internal/plugin/dnd_expedition_camp_test.go b/internal/plugin/dnd_expedition_camp_test.go index 3b7aacb..34b324e 100644 --- a/internal/plugin/dnd_expedition_camp_test.go +++ b/internal/plugin/dnd_expedition_camp_test.go @@ -267,24 +267,27 @@ func TestCampLocationCheck_BossRoomBlocks(t *testing.T) { defer cleanupZoneRuns(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) if err != nil { t.Fatal(err) } - // Walk to the boss room directly (last index). - bossIdx := -1 - for i, rt := range run.RoomSeq { - if rt == RoomBoss { - bossIdx = i + g, ok := loadZoneGraph(run.ZoneID) + if !ok { + t.Fatalf("no graph for zone %q", run.ZoneID) + } + bossNodeID := "" + for nid, n := range g.Nodes { + if n.IsBoss { + bossNodeID = nid + break } } - if bossIdx < 0 { - t.Fatal("no boss room in seq") + if bossNodeID == "" { + t.Fatal("no boss node in graph") } exp := &Expedition{RunID: run.RunID} - // Force current_room to bossIdx. - if _, err := db.Get().Exec(`UPDATE dnd_zone_run SET current_room = ? WHERE run_id = ?`, bossIdx, run.RunID); err != nil { + if _, err := db.Get().Exec(`UPDATE dnd_zone_run SET current_node = ? WHERE run_id = ?`, bossNodeID, run.RunID); err != nil { t.Fatal(err) } cleared, problem := campLocationCheck(exp) diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index 6d7f840..55aa869 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -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()))) } seq := generateRoomSequence(zone, rng) - seqJSON, _ := json.Marshal(seq) startMood := 50 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{} 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, + (run_id, user_id, zone_id, total_rooms, + rooms_cleared, gm_mood, current_node, visited_nodes, node_choices) - VALUES (?, ?, ?, 0, ?, ?, '[]', ?, ?, ?, '{}')`, - run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON), startMood, + VALUES (?, ?, ?, ?, '[]', ?, ?, ?, '{}')`, + run.RunID, run.UserID, string(zoneID), run.TotalRooms, startMood, entryNode, string(visitedJSON), ); err != nil { 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. 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, + 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 @@ -289,8 +288,8 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) { // 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, + 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) @@ -310,7 +309,6 @@ func scanZoneRun(row scanner) (*DungeonRun, error) { var ( r DungeonRun zoneID string - seqJSON string clearedJSON string lootJSON string bossDefeatedI int @@ -321,8 +319,8 @@ func scanZoneRun(row scanner) (*DungeonRun, error) { choicesJSON string ) if err := row.Scan( - &r.RunID, &r.UserID, &zoneID, &r.CurrentRoom, &r.TotalRooms, - &seqJSON, &clearedJSON, &bossDefeatedI, &abandonedI, + &r.RunID, &r.UserID, &zoneID, &r.TotalRooms, + &clearedJSON, &bossDefeatedI, &abandonedI, &lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw, ¤tNode, &visitedJSON, &choicesJSON, ); err != nil { @@ -335,9 +333,6 @@ func scanZoneRun(row scanner) (*DungeonRun, error) { 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) @@ -374,7 +369,18 @@ func scanZoneRun(row scanner) (*DungeonRun, error) { r.NodeChoices = map[string]any{} } 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) } return &r, nil @@ -392,8 +398,11 @@ func deriveLegacyNodeID(zoneID ZoneID, roomIdx int) string { } // 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). +// 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 { @@ -405,13 +414,17 @@ func markRoomCleared(runID string) (RoomType, error) { 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) - wasBossRoom := r.CurrentRoomType() == RoomBoss - next := r.CurrentRoom + 1 - if next >= r.TotalRooms || wasBossRoom { - // Boss room cleared = run complete via boss defeat. + 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 @@ -420,33 +433,29 @@ func markRoomCleared(runID string) (RoomType, error) { completed_at = ?, last_action_at = ? WHERE run_id = ?`, - string(clearedJSON), boolToInt(wasBossRoom), now, now, runID, + string(clearedJSON), boolToInt(isBoss), 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) + 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_room = ?, current_node = ?, visited_nodes = ?, last_action_at = CURRENT_TIMESTAMP WHERE run_id = ?`, - string(clearedJSON), next, nextNode, string(visitedJSON), runID, + string(clearedJSON), nextNode, string(visitedJSON), runID, ); err != nil { return "", err } - r.CurrentRoom = next r.CurrentNode = nextNode r.VisitedNodes = visited - return r.RoomSeq[next], nil + return nodeKindToRoomType(g.Nodes[nextNode].Kind), nil } // abandonZoneRun flags the active run as abandoned. Idempotent: returns diff --git a/internal/plugin/zone_graph_nav.go b/internal/plugin/zone_graph_nav.go index 06685e7..9015436 100644 --- a/internal/plugin/zone_graph_nav.go +++ b/internal/plugin/zone_graph_nav.go @@ -300,7 +300,7 @@ func renderForkPrompt(zone ZoneDefinition, pf pendingFork) string { } // 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 // step from choosing where to go next. Returns the updated DungeonRun // 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, -// sets current_node, bumps current_room (legacy dual-write), and -// clears any pending fork prompt. Caller is expected to have already -// called recordRoomCleared for the prior node. +// sets current_node, and clears any pending fork prompt. Caller is +// expected to have already called recordRoomCleared for the prior node. func advanceZoneRunNode(runID, nextNode string) error { r, err := getZoneRun(runID) if err != nil { @@ -390,16 +389,14 @@ func advanceZoneRunNode(runID, nextNode string) error { } visited := append(r.VisitedNodes, nextNode) visitedJSON, _ := json.Marshal(visited) - nextRoom := r.CurrentRoom + 1 _, err = db.Get().Exec(` UPDATE dnd_zone_run SET current_node = ?, visited_nodes = ?, - current_room = ?, node_choices = '{}', last_action_at = CURRENT_TIMESTAMP WHERE run_id = ?`, - nextNode, string(visitedJSON), nextRoom, runID) + nextNode, string(visitedJSON), runID) return err }