diff --git a/gogobee_branching_zones_plan.md b/gogobee_branching_zones_plan.md index e1e7583..3cb2abc 100644 --- a/gogobee_branching_zones_plan.md +++ b/gogobee_branching_zones_plan.md @@ -378,5 +378,6 @@ When the new session starts: 3. Read `gogobee_dungeon_zones.md` (existing zone design) + `internal/plugin/dnd_zone.go` for current shape. 4. Run G1 (schema) + G2 (types) + G3 (legacy compiler) + G4 (run state). All four are infra; ship together with no behavior change. 5. Land G5 navigation behind `GOGOBEE_BRANCHING_ZONES=1` env gate. -6. POC G7 with Crypt of Valdris. -7. Pause for playtest before G8 mass migration. +6. **G6 (done)** — dependent surfaces: harvest re-keyed on `node_id` (with legacy `room_idx` read-fallback + drop-on-save migration), narration cadence salted on `len(visited_nodes)-1` via `narrationCadence(run)`, `!zone map` graph render gated by `GOGOBEE_BRANCHING_ZONES=1` (locked edges → `╳`, secrets hidden until visited), and a region-boundary hook in graph advance/`!zone go` that mirrors `!region travel` flavor + log without burning supplies (graph is run-state authoritative). +7. POC G7 with Crypt of Valdris. +8. Pause for playtest before G8 mass migration. diff --git a/internal/plugin/adv2_scenario_test.go b/internal/plugin/adv2_scenario_test.go index 5528d70..235dd05 100644 --- a/internal/plugin/adv2_scenario_test.go +++ b/internal/plugin/adv2_scenario_test.go @@ -284,7 +284,8 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) { // Seed harvest nodes for the entry room (they're auto-seeded by // loadHarvestNodes on first read, but confirm we get at least one). roomIdx := currentRoomIndexFor(exp) - nodes := loadHarvestNodes(exp, roomIdx) + nodeID := currentNodeIDFor(exp) + nodes := loadHarvestNodes(exp, nodeID) t.Logf("room %d has %d harvest nodes", roomIdx, len(nodes)) if len(nodes) == 0 { t.Error("expected at least one harvest node seeded") diff --git a/internal/plugin/dnd_expedition_harvest.go b/internal/plugin/dnd_expedition_harvest.go index d92ddb8..1dd5513 100644 --- a/internal/plugin/dnd_expedition_harvest.go +++ b/internal/plugin/dnd_expedition_harvest.go @@ -117,13 +117,40 @@ func harvestActionAbility(c *DnDCharacter, action HarvestAction) int { // Single-region zones store under "". func regionHarvestKey(e *Expedition) string { return e.CurrentRegion } -// roomHarvestKey returns the JSON-safe key for a 0-based room index. -func roomHarvestKey(roomIdx int) string { return strconv.Itoa(roomIdx) } +// nodeHarvestKey returns the JSON-safe storage key for a graph node id. +// Phase G6: harvest tables are keyed by node id (e.g. "crypt_valdris.r3"), +// not by raw room index. Linear-zone runs derive a node id with the +// `.r` scheme (deriveLegacyNodeID), so this collapses to a +// stable key for the existing room-walk model. Branching zones use the +// hand-authored node ids and get distinct keys per branch. +func nodeHarvestKey(nodeID string) string { return nodeID } -// loadHarvestNodes returns the (region, room)'s nodes, lazy-seeding from +// legacyRoomIdxFromNodeID parses a derived node id back to its 0-based +// room index. Returns ok=false for hand-authored ids that don't match +// the `.r` scheme — there's no legacy storage to migrate +// for those, so the caller skips the fallback. +func legacyRoomIdxFromNodeID(nodeID string, zoneID ZoneID) (int, bool) { + prefix := string(zoneID) + ".r" + if !strings.HasPrefix(nodeID, prefix) { + return 0, false + } + n, err := strconv.Atoi(strings.TrimPrefix(nodeID, prefix)) + if err != nil || n < 1 { + return 0, false + } + return n - 1, true +} + +// loadHarvestNodes returns the (region, node)'s nodes, lazy-seeding from // the §3 registry on first access. Caller must persist via // saveHarvestNodes after mutation. -func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode { +// +// Migration: rows written before G6 used the room-index integer as the +// storage key. When a node-id-keyed entry is missing and the id maps +// back to a legacy room index, we read from the old key so existing +// expeditions don't lose harvest state mid-flight. The next save writes +// under the new key. +func loadHarvestNodes(e *Expedition, nodeID string) []HarvestNode { if e == nil { return nil } @@ -132,7 +159,7 @@ func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode { } table := loadHarvestTable(e) regionKey := regionHarvestKey(e) - roomKey := roomHarvestKey(roomIdx) + roomKey := nodeHarvestKey(nodeID) regionMap, ok := table[regionKey] if !ok { @@ -142,6 +169,11 @@ func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode { if existing, ok := regionMap[roomKey]; ok { return existing } + if idx, ok := legacyRoomIdxFromNodeID(nodeID, e.ZoneID); ok { + if existing, ok := regionMap[strconv.Itoa(idx)]; ok { + return existing + } + } seeded := seedRoomNodes(e.ZoneID) regionMap[roomKey] = seeded table[regionKey] = regionMap @@ -149,20 +181,25 @@ func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode { return seeded } -// saveHarvestNodes writes the supplied node slice into the (region, room) +// saveHarvestNodes writes the supplied node slice into the (region, node) // slot and persists the expedition state. -func saveHarvestNodes(e *Expedition, roomIdx int, nodes []HarvestNode) error { +func saveHarvestNodes(e *Expedition, nodeID string, nodes []HarvestNode) error { if e.RegionState == nil { e.RegionState = map[string]any{} } table := loadHarvestTable(e) regionKey := regionHarvestKey(e) - roomKey := roomHarvestKey(roomIdx) + roomKey := nodeHarvestKey(nodeID) regionMap, ok := table[regionKey] if !ok { regionMap = map[string][]HarvestNode{} } regionMap[roomKey] = nodes + if idx, ok := legacyRoomIdxFromNodeID(nodeID, e.ZoneID); ok { + // Drop the legacy room-idx entry once the node-id key is live so + // the table doesn't carry parallel state forever. + delete(regionMap, strconv.Itoa(idx)) + } table[regionKey] = regionMap e.RegionState[regionStateHarvestKey] = table return persistRegionState(e) @@ -378,8 +415,8 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct return p.SendDM(ctx.Sender, blockReason) } - roomIdx := run.CurrentRoom - nodes := loadHarvestNodes(exp, roomIdx) + nodeID := harvestNodeIDFor(run) + nodes := loadHarvestNodes(exp, nodeID) idx := pickAvailableNode(nodes, exp, char, action) if idx < 0 { return p.SendDM(ctx.Sender, fmt.Sprintf( @@ -413,7 +450,7 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct pre.WriteString("\n\n_The harvest is forfeit._") } // Persist node state untouched (no charge spent). - _ = saveHarvestNodes(exp, roomIdx, nodes) + _ = saveHarvestNodes(exp, nodeID, nodes) _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt", fmt.Sprintf("%s %s kind=%d total=%d", string(action), res.ID, int(interrupt), intTotal), "") return p.SendDM(ctx.Sender, pre.String()) @@ -499,7 +536,7 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct } } - if err := saveHarvestNodes(exp, roomIdx, nodes); err != nil { + if err := saveHarvestNodes(exp, nodeID, nodes); err != nil { _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest", fmt.Sprintf("persistence error: %v", err), "") } @@ -640,9 +677,9 @@ func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error { if err != nil || run == nil { return p.SendDM(ctx.Sender, "Couldn't load region state.") } - roomIdx := run.CurrentRoom - nodes := loadHarvestNodes(exp, roomIdx) - _ = saveHarvestNodes(exp, roomIdx, nodes) // persist seed if first touch + nodeID := harvestNodeIDFor(run) + nodes := loadHarvestNodes(exp, nodeID) + _ = saveHarvestNodes(exp, nodeID, nodes) // persist seed if first touch zone, _ := getZone(exp.ZoneID) regionLabel := exp.CurrentRegion @@ -894,14 +931,15 @@ func rollManorCursedTrinket(zoneID ZoneID, action HarvestAction, rng func(int) i return roll < manorCursedTrinketChance } -// restoreHarvestNodesInRoom resets every node in (region, room) to its +// restoreHarvestNodesInRoom resets every node in (region, node) to its // MaxCharges. Used by the Feywild Time Loop event (§7.4) — "previous -// harvest results in that room are nullified." -func restoreHarvestNodesInRoom(exp *Expedition, roomIdx int) bool { - if roomIdx < 0 { +// harvest results in that room are nullified." Empty nodeID is a no-op +// (caller had no active run/node to target). +func restoreHarvestNodesInRoom(exp *Expedition, nodeID string) bool { + if nodeID == "" { return false } - nodes := loadHarvestNodes(exp, roomIdx) + nodes := loadHarvestNodes(exp, nodeID) if len(nodes) == 0 { return false } @@ -919,7 +957,7 @@ func restoreHarvestNodesInRoom(exp *Expedition, roomIdx int) bool { // briefing/cycle flow persists RegionState downstream. Avoiding the // persist call here keeps the helper test-friendly without a DB. regionKey := regionHarvestKey(exp) - roomKey := roomHarvestKey(roomIdx) + roomKey := nodeHarvestKey(nodeID) table := loadHarvestTable(exp) regionMap := table[regionKey] if regionMap == nil { @@ -943,3 +981,32 @@ func currentRoomIndexFor(e *Expedition) int { } return run.CurrentRoom } + +// currentNodeIDFor returns the active region run's current graph node +// id, or "" if no run is linked / loadable. Used by harvest call sites +// that need to address the node-id-keyed harvest table from event +// handlers (no DungeonRun on hand). +func currentNodeIDFor(e *Expedition) string { + if e == nil || e.RunID == "" { + return "" + } + run, err := getZoneRun(e.RunID) + if err != nil || run == nil { + return "" + } + return harvestNodeIDFor(run) +} + +// harvestNodeIDFor resolves the storage-key node id for a run. Prefers +// the dual-written CurrentNode; falls back to the legacy linear +// derivation when a row predates G4. Never returns "" for an active +// run with a populated RoomSeq. +func harvestNodeIDFor(run *DungeonRun) string { + if run == nil { + return "" + } + if run.CurrentNode != "" { + return run.CurrentNode + } + return deriveLegacyNodeID(run.ZoneID, run.CurrentRoom) +} diff --git a/internal/plugin/dnd_expedition_harvest_test.go b/internal/plugin/dnd_expedition_harvest_test.go index 1f7083c..7e541c2 100644 --- a/internal/plugin/dnd_expedition_harvest_test.go +++ b/internal/plugin/dnd_expedition_harvest_test.go @@ -9,6 +9,94 @@ import ( // ── pure-function tests (no DB) ──────────────────────────────────────────── +// TestLegacyRoomIdxFromNodeID exercises the G6 migration parser: +// `.r` round-trips, hand-authored ids return ok=false. +func TestLegacyRoomIdxFromNodeID(t *testing.T) { + cases := []struct { + nodeID string + zone ZoneID + want int + ok bool + }{ + {"goblin_warrens.r1", ZoneGoblinWarrens, 0, true}, + {"crypt_valdris.r5", ZoneCryptValdris, 4, true}, + {"crypt_valdris.fork_a", ZoneCryptValdris, 0, false}, + {"goblin_warrens.r1", ZoneCryptValdris, 0, false}, // wrong zone prefix + {"goblin_warrens.r0", ZoneGoblinWarrens, 0, false}, + {"", ZoneGoblinWarrens, 0, false}, + } + for _, c := range cases { + got, ok := legacyRoomIdxFromNodeID(c.nodeID, c.zone) + if ok != c.ok || (ok && got != c.want) { + t.Errorf("legacyRoomIdxFromNodeID(%q, %s) = (%d, %t), want (%d, %t)", + c.nodeID, c.zone, got, ok, c.want, c.ok) + } + } +} + +// TestLoadHarvestNodes_LegacyRoomKeyFallback confirms a row with the +// pre-G6 `strconv.Itoa(roomIdx)` storage key is read via the fallback +// when the new node-id key is queried. +func TestLoadHarvestNodes_LegacyRoomKeyFallback(t *testing.T) { + exp := &Expedition{ + ZoneID: ZoneGoblinWarrens, + CurrentRegion: "", + RegionState: map[string]any{ + regionStateHarvestKey: map[string]map[string][]HarvestNode{ + "": { + "3": {{ResourceID: "iron_ore", CurrentCharges: 0, MaxCharges: 2}}, + }, + }, + }, + } + nodeID := deriveLegacyNodeID(ZoneGoblinWarrens, 3) + got := loadHarvestNodes(exp, nodeID) + if len(got) != 1 || got[0].ResourceID != "iron_ore" || got[0].CurrentCharges != 0 { + t.Fatalf("legacy fallback didn't return depleted node, got %+v", got) + } + // Authored (non-legacy) node id with no entry should lazy-seed fresh, + // not pick up the legacy "3" entry. + fresh := loadHarvestNodes(exp, "goblin_warrens.fork_a") + for _, n := range fresh { + if n.ResourceID == "iron_ore" && n.CurrentCharges == 0 { + t.Errorf("fork_a leaked depleted state from legacy room 3 entry") + } + } +} + +// TestSaveHarvestNodes_DropsLegacyRoomKey verifies the migration-on-save +// drops the legacy `strconv.Itoa(roomIdx)` entry after persistence runs. +func TestSaveHarvestNodes_DropsLegacyRoomKey(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@harvest-migrate:example.org") + defer cleanupHarvestState(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + // Seed the legacy "3" key directly so the load fallback fires. + exp.RegionState[regionStateHarvestKey] = map[string]map[string][]HarvestNode{ + "": { + "3": {{ResourceID: "iron_ore", CurrentCharges: 0, MaxCharges: 2}}, + }, + } + nodeID := deriveLegacyNodeID(ZoneGoblinWarrens, 3) + got := loadHarvestNodes(exp, nodeID) + if err := saveHarvestNodes(exp, nodeID, got); err != nil { + t.Fatalf("saveHarvestNodes: %v", err) + } + table := loadHarvestTable(exp) + if _, exists := table[""]["3"]; exists { + t.Errorf("legacy room-idx key '3' should be dropped after migration save") + } + if _, exists := table[""][nodeID]; !exists { + t.Errorf("expected node-id key %q after migration save", nodeID) + } +} + + func TestResolveHarvestOutcome_Brackets(t *testing.T) { cases := []struct { roll, dc int @@ -241,14 +329,16 @@ func TestReplenishHarvestNodes_RestoresAllRooms(t *testing.T) { } // Seed two rooms and drain one node in each. - r0 := loadHarvestNodes(exp, 0) + n0 := deriveLegacyNodeID(ZoneGoblinWarrens, 0) + n1 := deriveLegacyNodeID(ZoneGoblinWarrens, 1) + r0 := loadHarvestNodes(exp, n0) r0[0].CurrentCharges = 0 - if err := saveHarvestNodes(exp, 0, r0); err != nil { + if err := saveHarvestNodes(exp, n0, r0); err != nil { t.Fatal(err) } - r1 := loadHarvestNodes(exp, 1) + r1 := loadHarvestNodes(exp, n1) r1[0].CurrentCharges = 0 - if err := saveHarvestNodes(exp, 1, r1); err != nil { + if err := saveHarvestNodes(exp, n1, r1); err != nil { t.Fatal(err) } @@ -256,7 +346,7 @@ func TestReplenishHarvestNodes_RestoresAllRooms(t *testing.T) { t.Fatal(err) } for _, idx := range []int{0, 1} { - nodes := loadHarvestNodes(exp, idx) + nodes := loadHarvestNodes(exp, deriveLegacyNodeID(ZoneGoblinWarrens, idx)) for i, n := range nodes { if n.CurrentCharges != n.MaxCharges { t.Errorf("room %d node %d not replenished: %d/%d", @@ -276,9 +366,11 @@ func TestSaveHarvestNodes_PersistsAcrossLoad(t *testing.T) { if err != nil { t.Fatal(err) } - nodes := loadHarvestNodes(exp, 2) + n2 := deriveLegacyNodeID(ZoneGoblinWarrens, 2) + n5 := deriveLegacyNodeID(ZoneGoblinWarrens, 5) + nodes := loadHarvestNodes(exp, n2) nodes[0].CurrentCharges = 0 - if err := saveHarvestNodes(exp, 2, nodes); err != nil { + if err := saveHarvestNodes(exp, n2, nodes); err != nil { t.Fatal(err) } // Reload from DB. @@ -286,12 +378,12 @@ func TestSaveHarvestNodes_PersistsAcrossLoad(t *testing.T) { if err != nil { t.Fatal(err) } - got := loadHarvestNodes(reloaded, 2) + got := loadHarvestNodes(reloaded, n2) if got[0].CurrentCharges != 0 { t.Errorf("after reload, room 2 node 0 charges = %d, want 0", got[0].CurrentCharges) } // Other rooms should still be untouched (lazy-seeded fresh). - other := loadHarvestNodes(reloaded, 5) + other := loadHarvestNodes(reloaded, n5) for _, n := range other { if n.CurrentCharges != n.MaxCharges { t.Errorf("room 5 leaked depletion: %+v", n) diff --git a/internal/plugin/dnd_expedition_temporal.go b/internal/plugin/dnd_expedition_temporal.go index 4d35d0c..eb764b1 100644 --- a/internal/plugin/dnd_expedition_temporal.go +++ b/internal/plugin/dnd_expedition_temporal.go @@ -376,7 +376,7 @@ func feywildTemporalPostRollover(e *Expedition) []string { // to the current room's nodes. Combat loot stays gone; only the // renewable harvest nodes loop back. out := []string{line} - if restored := restoreHarvestNodesInRoom(e, currentRoomIndexFor(e)); restored { + if restored := restoreHarvestNodesInRoom(e, currentNodeIDFor(e)); restored { out = append(out, "_Harvest nodes in this room have re-emerged — the timeline isn't sure they were ever taken._") } return out diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 682dc77..3b2b7ff 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -203,11 +203,11 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest var b strings.Builder b.WriteString(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms)) b.WriteString("_" + zone.Hook + "_\n\n") - if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, run.CurrentRoom); line != "" { + if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, narrationCadence(run)); line != "" { b.WriteString(line) b.WriteString("\n\n") } - if aside := moodAsideLine(run.DMMood, run.RunID, run.CurrentRoom); aside != "" { + if aside := moodAsideLine(run.DMMood, run.RunID, narrationCadence(run)); aside != "" { b.WriteString(aside) b.WriteString("\n\n") } @@ -286,12 +286,20 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") } zone := zoneOrFallback(run.ZoneID) + var b strings.Builder + b.WriteString(fmt.Sprintf("**%s — Map**\n```\n", zone.Display)) + if branchingZonesEnabled() { + if g, ok := loadZoneGraph(run.ZoneID); ok { + b.WriteString(renderZoneGraphMap(g, run)) + b.WriteString("\n```\n") + b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_") + return p.SendDM(ctx.Sender, b.String()) + } + } cleared := map[int]bool{} for _, r := range run.RoomsCleared { cleared[r] = true } - var b strings.Builder - b.WriteString(fmt.Sprintf("**%s — Map**\n```\n", zone.Display)) b.WriteString(renderZoneMap(run.RoomSeq, run.CurrentRoom, cleared)) b.WriteString("\n```\n") b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss · ✓ cleared ▶ here · pending_") @@ -614,12 +622,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z // before the 5–8s phase pacing kicks in. var ib strings.Builder if elite { - if line := eliteRoomEntryLine(zone.ID, run.RunID, run.CurrentRoom); line != "" { + if line := eliteRoomEntryLine(zone.ID, run.RunID, narrationCadence(run)); line != "" { ib.WriteString(line) ib.WriteString("\n") } } - if line := twinBeeLine(zone.ID, DMCombatStart, run.RunID, run.CurrentRoom); line != "" { + if line := twinBeeLine(zone.ID, DMCombatStart, run.RunID, narrationCadence(run)); line != "" { ib.WriteString(line) ib.WriteString("\n") } @@ -642,12 +650,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z // lands *after* the play-by-play, where it has dramatic context. var ob strings.Builder if nat20s > 0 { - if line := twinBeeLine(zone.ID, DMNat20, run.RunID, run.CurrentRoom); line != "" { + if line := twinBeeLine(zone.ID, DMNat20, run.RunID, narrationCadence(run)); line != "" { ob.WriteString(line) ob.WriteString("\n") } } else if nat1s > 0 { - if line := twinBeeLine(zone.ID, DMNat1, run.RunID, run.CurrentRoom); line != "" { + if line := twinBeeLine(zone.ID, DMNat1, run.RunID, narrationCadence(run)); line != "" { ob.WriteString(line) ob.WriteString("\n") } @@ -656,7 +664,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) markAdventureDead(userID, "zone", zone.Display) - if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, run.CurrentRoom); line != "" { + if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" { ob.WriteString(line) ob.WriteString("\n") } @@ -669,7 +677,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z ended = true return } - if line := twinBeeLine(zone.ID, DMCombatEnd, run.RunID, run.CurrentRoom); line != "" { + if line := twinBeeLine(zone.ID, DMCombatEnd, run.RunID, narrationCadence(run)); line != "" { ob.WriteString(line) ob.WriteString("\n") } @@ -853,7 +861,7 @@ func (p *AdventurePlugin) zoneMoodInteraction( return p.SendDM(ctx.Sender, "Couldn't apply mood event: "+err.Error()) } var b strings.Builder - if line := render(run.RunID, run.CurrentRoom); line != "" { + if line := render(run.RunID, narrationCadence(run)); line != "" { b.WriteString(line) b.WriteString("\n\n") } @@ -885,7 +893,7 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") } zone := zoneOrFallback(run.ZoneID) - line := twinBeeLine(zone.ID, DMLore, run.RunID, run.CurrentRoom) + line := twinBeeLine(zone.ID, DMLore, run.RunID, narrationCadence(run)) if line == "" { return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.") } diff --git a/internal/plugin/dnd_zone_cmd_graph.go b/internal/plugin/dnd_zone_cmd_graph.go index 2016855..0134ce0 100644 --- a/internal/plugin/dnd_zone_cmd_graph.go +++ b/internal/plugin/dnd_zone_cmd_graph.go @@ -6,6 +6,8 @@ package plugin import ( "fmt" "strings" + + "maunium.net/go/mautrix/id" ) // advanceTransitionGraph is the graph-mode replacement for @@ -64,6 +66,13 @@ func (p *AdventurePlugin) advanceTransitionGraph(run *DungeonRun, zone ZoneDefin err = aerr return } + // G6d — region boundary hook. Multi-region branching graphs + // can cross a region edge mid-run; emit the existing transit + // flavor + log so the player gets the same "you've crossed + // into X" beat the !region travel command produces. Doesn't + // retire the run (the graph is the authoritative state) — it + // just narrates the crossing. + fireGraphRegionTransition(run.UserID, g.Nodes[currentNode], g.Nodes[chosen.To]) next = nodeKindToRoomType(g.Nodes[chosen.To].Kind) return } @@ -80,6 +89,36 @@ func (p *AdventurePlugin) advanceTransitionGraph(run *DungeonRun, zone ZoneDefin return } +// fireGraphRegionTransition compares the from/to graph node region ids +// and, when they differ, mirrors the existing !region travel surface: +// updates Expedition.CurrentRegion + visited list and writes a transit +// log entry. Unlike full !region travel, it does NOT burn supplies, +// advance the day, or retire/spawn DungeonRuns — the graph is the +// authoritative run state, and the player crossed via the graph edge, +// not a separate transit day. Standalone (no expedition) is a no-op. +func fireGraphRegionTransition(userID string, from, to ZoneNode) { + if from.RegionID == to.RegionID { + return + } + if to.RegionID == "" { + return + } + exp, err := getActiveExpedition(id.UserID(userID)) + if err != nil || exp == nil { + return + } + if exp.CurrentRegion == to.RegionID { + return + } + if err := setCurrentRegion(exp, to.RegionID); err != nil { + return + } + _, _ = MarkRegionVisited(exp, to.RegionID) + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", + fmt.Sprintf("graph region transit: %s → %s (node %s → %s)", + from.RegionID, to.RegionID, from.NodeID, to.NodeID), "") +} + func unlockedChoices(cs []pendingChoice) []pendingChoice { out := make([]pendingChoice, 0, len(cs)) for _, c := range cs { @@ -147,7 +186,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { } g, _ := loadZoneGraph(run.ZoneID) zone := zoneOrFallback(run.ZoneID) + fromNode := g.Nodes[run.CurrentNode] nextNode := g.Nodes[chosen.To] + fireGraphRegionTransition(run.UserID, fromNode, nextNode) nextRoom := nodeKindToRoomType(nextNode.Kind) nextIdx := run.CurrentRoom + 1 var b strings.Builder diff --git a/internal/plugin/dnd_zone_combat.go b/internal/plugin/dnd_zone_combat.go index 3d8db2f..9032076 100644 --- a/internal/plugin/dnd_zone_combat.go +++ b/internal/plugin/dnd_zone_combat.go @@ -299,7 +299,7 @@ func (p *AdventurePlugin) applyTrapEffectWithDetect( // failed detection no longer contradicts the next line. The mismatch // where "Perception roll pays off" preceded a tripped trap was // caused by this line firing unconditionally. - if line := twinBeeLine(zone.ID, DMTrapDetected, run.RunID, run.CurrentRoom); line != "" { + if line := twinBeeLine(zone.ID, DMTrapDetected, run.RunID, narrationCadence(run)); line != "" { b.WriteString(line) b.WriteString("\n") } diff --git a/internal/plugin/dnd_zone_conditions_test.go b/internal/plugin/dnd_zone_conditions_test.go index e90ff35..81fb7c4 100644 --- a/internal/plugin/dnd_zone_conditions_test.go +++ b/internal/plugin/dnd_zone_conditions_test.go @@ -145,13 +145,14 @@ func TestRollManorCursedTrinket(t *testing.T) { } func TestRestoreHarvestNodesInRoom(t *testing.T) { + nodeID := deriveLegacyNodeID(ZoneFeywildCrossing, 0) exp := &Expedition{ ZoneID: ZoneFeywildCrossing, CurrentRegion: "", RegionState: map[string]any{ regionStateHarvestKey: map[string]map[string][]HarvestNode{ "": { - "0": { + nodeID: { {ResourceID: "fey_dust", CurrentCharges: 0, MaxCharges: 2}, {ResourceID: "enchanted_petal", CurrentCharges: 1, MaxCharges: 2}, }, @@ -159,11 +160,11 @@ func TestRestoreHarvestNodesInRoom(t *testing.T) { }, }, } - if !restoreHarvestNodesInRoom(exp, 0) { + if !restoreHarvestNodesInRoom(exp, nodeID) { t.Fatalf("restore should report changes") } table := loadHarvestTable(exp) - got := table[""]["0"] + got := table[""][nodeID] for _, n := range got { if n.CurrentCharges != n.MaxCharges { t.Errorf("node %s: charges=%d max=%d (expected restored)", n.ResourceID, n.CurrentCharges, n.MaxCharges) @@ -173,8 +174,8 @@ func TestRestoreHarvestNodesInRoom(t *testing.T) { func TestRestoreHarvestNodesInRoom_NoOpEmpty(t *testing.T) { exp := &Expedition{ZoneID: ZoneFeywildCrossing, RegionState: map[string]any{}} - if got := restoreHarvestNodesInRoom(exp, -1); got { - t.Errorf("negative roomIdx should no-op") + if got := restoreHarvestNodesInRoom(exp, ""); got { + t.Errorf("empty nodeID should no-op") } } diff --git a/internal/plugin/dnd_zone_harvest.go b/internal/plugin/dnd_zone_harvest.go index 6f8a41c..3464d44 100644 --- a/internal/plugin/dnd_zone_harvest.go +++ b/internal/plugin/dnd_zone_harvest.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "math/rand/v2" + "strconv" "strings" "gogobee/internal/db" @@ -25,28 +26,42 @@ import ( // belong to the expedition system. Standalone harvest is the casual // flow: roll d20 vs DC, get yield, decrement node, save. -// loadStandaloneHarvestNodes reads the run's harvest map and returns the -// nodes for the given room. Lazy-seeds from the zone registry on first -// access. Caller persists via saveStandaloneHarvestNodes. -func loadStandaloneHarvestNodes(runID string, zoneID ZoneID, roomIdx int) ([]HarvestNode, error) { +// loadStandaloneHarvestNodes reads the run's harvest map and returns +// the nodes for the given graph node. Lazy-seeds from the zone registry +// on first access. Caller persists via saveStandaloneHarvestNodes. +// +// G6 migration: rows written before the re-key used the room-index +// integer as the storage key. When the node-id key is absent and the +// id maps back to a legacy room index, we read from the old key so +// in-flight runs don't lose harvest state. The next save writes under +// the new key (and drops the old one). +func loadStandaloneHarvestNodes(runID string, zoneID ZoneID, nodeID string) ([]HarvestNode, error) { table, err := loadStandaloneHarvestTable(runID) if err != nil { return nil, err } - roomKey := roomHarvestKey(roomIdx) + roomKey := nodeHarvestKey(nodeID) if existing, ok := table[roomKey]; ok { return existing, nil } + if idx, ok := legacyRoomIdxFromNodeID(nodeID, zoneID); ok { + if existing, ok := table[strconv.Itoa(idx)]; ok { + return existing, nil + } + } return seedRoomNodes(zoneID), nil } // saveStandaloneHarvestNodes writes nodes back into the run's harvest map. -func saveStandaloneHarvestNodes(runID string, roomIdx int, nodes []HarvestNode) error { +func saveStandaloneHarvestNodes(runID string, zoneID ZoneID, nodeID string, nodes []HarvestNode) error { table, err := loadStandaloneHarvestTable(runID) if err != nil { return err } - table[roomHarvestKey(roomIdx)] = nodes + table[nodeHarvestKey(nodeID)] = nodes + if idx, ok := legacyRoomIdxFromNodeID(nodeID, zoneID); ok { + delete(table, strconv.Itoa(idx)) + } raw, err := json.Marshal(table) if err != nil { return fmt.Errorf("marshal harvest table: %w", err) @@ -93,8 +108,8 @@ func (p *AdventurePlugin) handleStandaloneHarvest(ctx MessageContext, char *DnDC prettyRoomType(run.CurrentRoomType()))) } - roomIdx := run.CurrentRoom - nodes, err := loadStandaloneHarvestNodes(run.RunID, run.ZoneID, roomIdx) + nodeID := harvestNodeIDFor(run) + nodes, err := loadStandaloneHarvestNodes(run.RunID, run.ZoneID, nodeID) if err != nil { return p.SendDM(ctx.Sender, "Couldn't load harvest state.") } @@ -155,7 +170,7 @@ func (p *AdventurePlugin) handleStandaloneHarvest(ctx MessageContext, char *DnDC b.WriteString(flavor.Pick(flavor.NodeDepleted)) } - if err := saveStandaloneHarvestNodes(run.RunID, roomIdx, nodes); err != nil { + if err := saveStandaloneHarvestNodes(run.RunID, run.ZoneID, nodeID, nodes); err != nil { slog.Error("standalone harvest: save nodes", "user", ctx.Sender, "run", run.RunID, "err", err) } diff --git a/internal/plugin/dnd_zone_harvest_test.go b/internal/plugin/dnd_zone_harvest_test.go index ec2aad4..565c18f 100644 --- a/internal/plugin/dnd_zone_harvest_test.go +++ b/internal/plugin/dnd_zone_harvest_test.go @@ -48,8 +48,9 @@ func TestStandaloneHarvest_RoutesToZoneRun(t *testing.T) { if raw == "" || raw == "{}" { t.Errorf("expected harvest_nodes_json populated after standalone harvest, got %q", raw) } - if !strings.Contains(raw, "\"0\":") { - t.Errorf("expected room 0 entry in harvest table, got %q", raw) + wantKey := "\"" + deriveLegacyNodeID(run.ZoneID, 0) + "\":" + if !strings.Contains(raw, wantKey) { + t.Errorf("expected entry node %s in harvest table, got %q", wantKey, raw) } } diff --git a/internal/plugin/dnd_zone_narration.go b/internal/plugin/dnd_zone_narration.go index 24fbe42..14b179f 100644 --- a/internal/plugin/dnd_zone_narration.go +++ b/internal/plugin/dnd_zone_narration.go @@ -330,6 +330,22 @@ func eliteRoomEntryLine(zoneID ZoneID, runID string, roomIdx int) string { return "🎭 **TwinBee:** " + line } +// narrationCadence returns the salt index used to seed narration +// pickers — the count of nodes visited so far (0-based, matching the +// pre-G6 CurrentRoom semantics). Both linear-mode and graph-mode +// advance bump len(VisitedNodes) in lockstep with CurrentRoom, so this +// preserves existing pick determinism while letting G9 drop the legacy +// current_room column without re-keying every flavor pool seed. +func narrationCadence(run *DungeonRun) int { + if run == nil { + return 0 + } + if n := len(run.VisitedNodes); n > 0 { + return n - 1 + } + return run.CurrentRoom +} + // pickLineDeterministic — stable selection across (runID, salt). Same // inputs always yield the same line, so a player who re-reads status // sees the same prose; different rooms / different runs vary. diff --git a/internal/plugin/zone_graph_map.go b/internal/plugin/zone_graph_map.go new file mode 100644 index 0000000..b9b5762 --- /dev/null +++ b/internal/plugin/zone_graph_map.go @@ -0,0 +1,174 @@ +package plugin + +// Phase G6 — graph-aware !zone map renderer. +// +// Replaces the linear renderZoneMap() output when GOGOBEE_BRANCHING_ZONES +// is on. Walks the registered (or legacy-compiled) ZoneGraph in BFS +// order rooted at Entry, lays nodes out by PosX/PosY, and prints a +// horizontal glyph row per layer with a status row underneath. Locked +// outgoing edges show as `╳`; secret nodes stay hidden until the +// player has visited them (prevents spoilers from `!zone map`). + +import ( + "fmt" + "sort" + "strings" +) + +type laidNode struct { + ID string + Node ZoneNode +} + +// renderZoneGraphMap is the graph-mode replacement for renderZoneMap. +// Output stays inside the same ``` block in zoneCmdMap. +func renderZoneGraphMap(g ZoneGraph, run *DungeonRun) string { + if len(g.Nodes) == 0 { + return "(no graph)" + } + visited := map[string]bool{} + for _, n := range run.VisitedNodes { + visited[n] = true + } + current := run.CurrentNode + + // Group nodes by PosX, drop secret nodes the player hasn't reached. + cols := map[int][]laidNode{} + maxX := 0 + for id, n := range g.Nodes { + if n.Kind == NodeKindSecret && !visited[id] { + continue + } + cols[n.PosX] = append(cols[n.PosX], laidNode{ID: id, Node: n}) + if n.PosX > maxX { + maxX = n.PosX + } + } + if len(cols) == 0 { + return "(no nodes)" + } + for x := range cols { + sort.Slice(cols[x], func(i, j int) bool { + a, b := cols[x][i].Node, cols[x][j].Node + if a.PosY != b.PosY { + return a.PosY < b.PosY + } + return cols[x][i].ID < cols[x][j].ID + }) + } + + // Find max stack height per column for vertical alignment. + maxRows := 0 + for x := 0; x <= maxX; x++ { + if len(cols[x]) > maxRows { + maxRows = len(cols[x]) + } + } + if maxRows == 0 { + maxRows = 1 + } + + // Render row-by-row. Each "row" is a top (glyph) + bot (status) + // pair. Empty cells get spaces so columns stay aligned. + var sb strings.Builder + for row := 0; row < maxRows; row++ { + var top, bot strings.Builder + for x := 0; x <= maxX; x++ { + if x > 0 { + connector, statusGap := graphConnector(g, cols[x-1], cols[x], row, visited) + top.WriteString(connector) + bot.WriteString(statusGap) + } + if row < len(cols[x]) { + ln := cols[x][row] + top.WriteString(nodeGlyph(ln.Node)) + bot.WriteString(nodeStatusMark(ln.ID, current, visited)) + } else { + top.WriteString(" ") + bot.WriteString(" ") + } + } + sb.WriteString(strings.TrimRight(top.String(), " ")) + sb.WriteString("\n") + sb.WriteString(strings.TrimRight(bot.String(), " ")) + if row < maxRows-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + +// graphConnector returns the 2-char top/bot strings drawn between +// columns for a given row. Defaults to `──`/` `; if the only edge +// reaching this row's right-column node is locked, swaps to `╳ ` to +// signal a gate. Hidden secrets aren't connectable so they get blank. +func graphConnector(g ZoneGraph, leftCol, rightCol []laidNode, row int, visited map[string]bool) (string, string) { + _ = visited + if row >= len(rightCol) { + return " ", " " + } + target := rightCol[row].ID + hasUnlocked, hasAny := false, false + for _, ln := range leftCol { + for _, e := range g.Edges[ln.ID] { + if e.To != target { + continue + } + hasAny = true + if e.Lock == "" || e.Lock == LockNone { + hasUnlocked = true + } + } + } + if !hasAny { + return " ", " " + } + if !hasUnlocked { + return "╳─", " " + } + return "──", " " +} + +func nodeStatusMark(id, current string, visited map[string]bool) string { + switch { + case id == current: + return "▶" + case visited[id]: + return "✓" + default: + return "·" + } +} + +func nodeGlyph(n ZoneNode) string { + switch n.Kind { + case NodeKindEntry: + return "E" + case NodeKindExploration, NodeKindFork, NodeKindMerge: + return "?" + case NodeKindTrap: + return "T" + case NodeKindElite: + return "★" + case NodeKindBoss: + return "☠" + case NodeKindHarvest: + return "H" + case NodeKindRestCamp: + return "⛺" + case NodeKindSecret: + return "⚿" + } + return "·" +} + +// debugDump (test-only crutch) prints the raw column layout. Currently +// unused outside potential debugging — kept off the unused-warning list +// by routing fmt through it. +func debugDumpGraphMap(g ZoneGraph) string { + var b strings.Builder + for id, n := range g.Nodes { + b.WriteString(fmt.Sprintf("%s @ (%d,%d) %s\n", id, n.PosX, n.PosY, n.Kind)) + } + return b.String() +} diff --git a/internal/plugin/zone_graph_map_test.go b/internal/plugin/zone_graph_map_test.go new file mode 100644 index 0000000..311da21 --- /dev/null +++ b/internal/plugin/zone_graph_map_test.go @@ -0,0 +1,81 @@ +package plugin + +import ( + "strings" + "testing" +) + +// TestRenderZoneGraphMap_LinearLooksFamiliar exercises the linear-graph +// case so the new renderer doesn't regress the legacy single-row look. +func TestRenderZoneGraphMap_LinearLooksFamiliar(t *testing.T) { + g := BuildLinearGraph(ZoneGoblinWarrens, []ZoneNodeKind{ + NodeKindEntry, NodeKindExploration, NodeKindTrap, NodeKindBoss, + }) + run := &DungeonRun{ + ZoneID: ZoneGoblinWarrens, + CurrentNode: "goblin_warrens.r2", + VisitedNodes: []string{"goblin_warrens.r1", "goblin_warrens.r2"}, + } + got := renderZoneGraphMap(g, run) + // Top row should contain glyphs in order. + if !strings.Contains(got, "E──?──T──☠") { + t.Errorf("missing linear glyph row in:\n%s", got) + } + // Status row: first cleared, second current, rest pending. + if !strings.Contains(got, "✓") || !strings.Contains(got, "▶") || !strings.Contains(got, "·") { + t.Errorf("missing status markers in:\n%s", got) + } +} + +// TestRenderZoneGraphMap_HidesUnvisitedSecrets confirms the spoiler +// guard: secret nodes don't render until the player has reached them. +func TestRenderZoneGraphMap_HidesUnvisitedSecrets(t *testing.T) { + nodes := []ZoneNode{ + {NodeID: "z.entry", Kind: NodeKindEntry, IsEntry: true, PosX: 0}, + {NodeID: "z.boss", Kind: NodeKindBoss, IsBoss: true, PosX: 1}, + {NodeID: "z.secret", Kind: NodeKindSecret, PosX: 1, PosY: 1}, + } + edges := []ZoneEdge{ + {From: "z.entry", To: "z.boss"}, + {From: "z.entry", To: "z.secret", Lock: LockPerception, LockData: map[string]any{"dc": 12}}, + } + g := BuildGraph("test_zone", nodes, edges) + + hidden := renderZoneGraphMap(g, &DungeonRun{ + CurrentNode: "z.entry", + VisitedNodes: []string{"z.entry"}, + }) + if strings.Contains(hidden, "⚿") { + t.Errorf("unvisited secret should not render, got:\n%s", hidden) + } + // Once visited, the glyph appears. + revealed := renderZoneGraphMap(g, &DungeonRun{ + CurrentNode: "z.secret", + VisitedNodes: []string{"z.entry", "z.secret"}, + }) + if !strings.Contains(revealed, "⚿") { + t.Errorf("visited secret should render, got:\n%s", revealed) + } +} + +// TestRenderZoneGraphMap_LockedEdgeRenderedAsCross verifies locked-only +// approaches show ╳ instead of ──. +func TestRenderZoneGraphMap_LockedEdgeRenderedAsCross(t *testing.T) { + nodes := []ZoneNode{ + {NodeID: "z.entry", Kind: NodeKindEntry, IsEntry: true, PosX: 0}, + {NodeID: "z.gate", Kind: NodeKindExploration, PosX: 1}, + {NodeID: "z.boss", Kind: NodeKindBoss, IsBoss: true, PosX: 2}, + } + edges := []ZoneEdge{ + {From: "z.entry", To: "z.gate", Lock: LockKey, LockData: map[string]any{"key_id": "rune"}}, + {From: "z.gate", To: "z.boss"}, + } + g := BuildGraph("test_zone_locked", nodes, edges) + got := renderZoneGraphMap(g, &DungeonRun{ + CurrentNode: "z.entry", + VisitedNodes: []string{"z.entry"}, + }) + if !strings.Contains(got, "╳") { + t.Errorf("locked edge should render as ╳, got:\n%s", got) + } +}