mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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, ¤tRoom, &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))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
¤tNode, &visitedJSON, &choicesJSON,
|
¤tNode, &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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user