mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Branching zones G6: dependent surfaces re-keyed on graph nodes
- Harvest tables (expedition + standalone) now keyed by node_id; legacy room-idx entries auto-migrate via read-fallback + drop-on-save. - Narration salts swapped from run.CurrentRoom to narrationCadence(run) (= len(visited_nodes)-1) so flavor pickers survive G9 column drop. - !zone map renders the graph (BFS by PosX/PosY) when the gate is on: ✓/▶/· status, ╳ for locked-only edges, secrets hidden until visited. - Region-boundary hook in graph advance/!zone go updates expedition CurrentRegion + visited list when from/to nodes differ — without burning supplies or retiring runs (the graph IS the run state). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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.
|
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.
|
5. Land G5 navigation behind `GOGOBEE_BRANCHING_ZONES=1` env gate.
|
||||||
6. POC G7 with Crypt of Valdris.
|
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. Pause for playtest before G8 mass migration.
|
7. POC G7 with Crypt of Valdris.
|
||||||
|
8. Pause for playtest before G8 mass migration.
|
||||||
|
|||||||
@@ -284,7 +284,8 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) {
|
|||||||
// Seed harvest nodes for the entry room (they're auto-seeded by
|
// Seed harvest nodes for the entry room (they're auto-seeded by
|
||||||
// loadHarvestNodes on first read, but confirm we get at least one).
|
// loadHarvestNodes on first read, but confirm we get at least one).
|
||||||
roomIdx := currentRoomIndexFor(exp)
|
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))
|
t.Logf("room %d has %d harvest nodes", roomIdx, len(nodes))
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
t.Error("expected at least one harvest node seeded")
|
t.Error("expected at least one harvest node seeded")
|
||||||
|
|||||||
@@ -117,13 +117,40 @@ func harvestActionAbility(c *DnDCharacter, action HarvestAction) int {
|
|||||||
// Single-region zones store under "".
|
// Single-region zones store under "".
|
||||||
func regionHarvestKey(e *Expedition) string { return e.CurrentRegion }
|
func regionHarvestKey(e *Expedition) string { return e.CurrentRegion }
|
||||||
|
|
||||||
// roomHarvestKey returns the JSON-safe key for a 0-based room index.
|
// nodeHarvestKey returns the JSON-safe storage key for a graph node id.
|
||||||
func roomHarvestKey(roomIdx int) string { return strconv.Itoa(roomIdx) }
|
// 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
|
||||||
|
// `<zone>.r<N+1>` 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 `<zone>.r<N+1>` 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
|
// the §3 registry on first access. Caller must persist via
|
||||||
// saveHarvestNodes after mutation.
|
// 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 {
|
if e == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -132,7 +159,7 @@ func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode {
|
|||||||
}
|
}
|
||||||
table := loadHarvestTable(e)
|
table := loadHarvestTable(e)
|
||||||
regionKey := regionHarvestKey(e)
|
regionKey := regionHarvestKey(e)
|
||||||
roomKey := roomHarvestKey(roomIdx)
|
roomKey := nodeHarvestKey(nodeID)
|
||||||
|
|
||||||
regionMap, ok := table[regionKey]
|
regionMap, ok := table[regionKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -142,6 +169,11 @@ func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode {
|
|||||||
if existing, ok := regionMap[roomKey]; ok {
|
if existing, ok := regionMap[roomKey]; ok {
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
if idx, ok := legacyRoomIdxFromNodeID(nodeID, e.ZoneID); ok {
|
||||||
|
if existing, ok := regionMap[strconv.Itoa(idx)]; ok {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
}
|
||||||
seeded := seedRoomNodes(e.ZoneID)
|
seeded := seedRoomNodes(e.ZoneID)
|
||||||
regionMap[roomKey] = seeded
|
regionMap[roomKey] = seeded
|
||||||
table[regionKey] = regionMap
|
table[regionKey] = regionMap
|
||||||
@@ -149,20 +181,25 @@ func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode {
|
|||||||
return seeded
|
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.
|
// 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 {
|
if e.RegionState == nil {
|
||||||
e.RegionState = map[string]any{}
|
e.RegionState = map[string]any{}
|
||||||
}
|
}
|
||||||
table := loadHarvestTable(e)
|
table := loadHarvestTable(e)
|
||||||
regionKey := regionHarvestKey(e)
|
regionKey := regionHarvestKey(e)
|
||||||
roomKey := roomHarvestKey(roomIdx)
|
roomKey := nodeHarvestKey(nodeID)
|
||||||
regionMap, ok := table[regionKey]
|
regionMap, ok := table[regionKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
regionMap = map[string][]HarvestNode{}
|
regionMap = map[string][]HarvestNode{}
|
||||||
}
|
}
|
||||||
regionMap[roomKey] = nodes
|
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
|
table[regionKey] = regionMap
|
||||||
e.RegionState[regionStateHarvestKey] = table
|
e.RegionState[regionStateHarvestKey] = table
|
||||||
return persistRegionState(e)
|
return persistRegionState(e)
|
||||||
@@ -378,8 +415,8 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct
|
|||||||
return p.SendDM(ctx.Sender, blockReason)
|
return p.SendDM(ctx.Sender, blockReason)
|
||||||
}
|
}
|
||||||
|
|
||||||
roomIdx := run.CurrentRoom
|
nodeID := harvestNodeIDFor(run)
|
||||||
nodes := loadHarvestNodes(exp, roomIdx)
|
nodes := loadHarvestNodes(exp, nodeID)
|
||||||
idx := pickAvailableNode(nodes, exp, char, action)
|
idx := pickAvailableNode(nodes, exp, char, action)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
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._")
|
pre.WriteString("\n\n_The harvest is forfeit._")
|
||||||
}
|
}
|
||||||
// Persist node state untouched (no charge spent).
|
// Persist node state untouched (no charge spent).
|
||||||
_ = saveHarvestNodes(exp, roomIdx, nodes)
|
_ = saveHarvestNodes(exp, nodeID, nodes)
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
||||||
fmt.Sprintf("%s %s kind=%d total=%d", string(action), res.ID, int(interrupt), intTotal), "")
|
fmt.Sprintf("%s %s kind=%d total=%d", string(action), res.ID, int(interrupt), intTotal), "")
|
||||||
return p.SendDM(ctx.Sender, pre.String())
|
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",
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||||
fmt.Sprintf("persistence error: %v", err), "")
|
fmt.Sprintf("persistence error: %v", err), "")
|
||||||
}
|
}
|
||||||
@@ -640,9 +677,9 @@ func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
|
|||||||
if err != nil || run == nil {
|
if err != nil || run == nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't load region state.")
|
return p.SendDM(ctx.Sender, "Couldn't load region state.")
|
||||||
}
|
}
|
||||||
roomIdx := run.CurrentRoom
|
nodeID := harvestNodeIDFor(run)
|
||||||
nodes := loadHarvestNodes(exp, roomIdx)
|
nodes := loadHarvestNodes(exp, nodeID)
|
||||||
_ = saveHarvestNodes(exp, roomIdx, nodes) // persist seed if first touch
|
_ = saveHarvestNodes(exp, nodeID, nodes) // persist seed if first touch
|
||||||
|
|
||||||
zone, _ := getZone(exp.ZoneID)
|
zone, _ := getZone(exp.ZoneID)
|
||||||
regionLabel := exp.CurrentRegion
|
regionLabel := exp.CurrentRegion
|
||||||
@@ -894,14 +931,15 @@ func rollManorCursedTrinket(zoneID ZoneID, action HarvestAction, rng func(int) i
|
|||||||
return roll < manorCursedTrinketChance
|
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
|
// MaxCharges. Used by the Feywild Time Loop event (§7.4) — "previous
|
||||||
// harvest results in that room are nullified."
|
// harvest results in that room are nullified." Empty nodeID is a no-op
|
||||||
func restoreHarvestNodesInRoom(exp *Expedition, roomIdx int) bool {
|
// (caller had no active run/node to target).
|
||||||
if roomIdx < 0 {
|
func restoreHarvestNodesInRoom(exp *Expedition, nodeID string) bool {
|
||||||
|
if nodeID == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
nodes := loadHarvestNodes(exp, roomIdx)
|
nodes := loadHarvestNodes(exp, nodeID)
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -919,7 +957,7 @@ func restoreHarvestNodesInRoom(exp *Expedition, roomIdx int) bool {
|
|||||||
// briefing/cycle flow persists RegionState downstream. Avoiding the
|
// briefing/cycle flow persists RegionState downstream. Avoiding the
|
||||||
// persist call here keeps the helper test-friendly without a DB.
|
// persist call here keeps the helper test-friendly without a DB.
|
||||||
regionKey := regionHarvestKey(exp)
|
regionKey := regionHarvestKey(exp)
|
||||||
roomKey := roomHarvestKey(roomIdx)
|
roomKey := nodeHarvestKey(nodeID)
|
||||||
table := loadHarvestTable(exp)
|
table := loadHarvestTable(exp)
|
||||||
regionMap := table[regionKey]
|
regionMap := table[regionKey]
|
||||||
if regionMap == nil {
|
if regionMap == nil {
|
||||||
@@ -943,3 +981,32 @@ func currentRoomIndexFor(e *Expedition) int {
|
|||||||
}
|
}
|
||||||
return run.CurrentRoom
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,94 @@ import (
|
|||||||
|
|
||||||
// ── pure-function tests (no DB) ────────────────────────────────────────────
|
// ── pure-function tests (no DB) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestLegacyRoomIdxFromNodeID exercises the G6 migration parser:
|
||||||
|
// `<zone>.r<N+1>` 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) {
|
func TestResolveHarvestOutcome_Brackets(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
roll, dc int
|
roll, dc int
|
||||||
@@ -241,14 +329,16 @@ func TestReplenishHarvestNodes_RestoresAllRooms(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Seed two rooms and drain one node in each.
|
// 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
|
r0[0].CurrentCharges = 0
|
||||||
if err := saveHarvestNodes(exp, 0, r0); err != nil {
|
if err := saveHarvestNodes(exp, n0, r0); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r1 := loadHarvestNodes(exp, 1)
|
r1 := loadHarvestNodes(exp, n1)
|
||||||
r1[0].CurrentCharges = 0
|
r1[0].CurrentCharges = 0
|
||||||
if err := saveHarvestNodes(exp, 1, r1); err != nil {
|
if err := saveHarvestNodes(exp, n1, r1); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +346,7 @@ func TestReplenishHarvestNodes_RestoresAllRooms(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, idx := range []int{0, 1} {
|
for _, idx := range []int{0, 1} {
|
||||||
nodes := loadHarvestNodes(exp, idx)
|
nodes := loadHarvestNodes(exp, deriveLegacyNodeID(ZoneGoblinWarrens, idx))
|
||||||
for i, n := range nodes {
|
for i, n := range nodes {
|
||||||
if n.CurrentCharges != n.MaxCharges {
|
if n.CurrentCharges != n.MaxCharges {
|
||||||
t.Errorf("room %d node %d not replenished: %d/%d",
|
t.Errorf("room %d node %d not replenished: %d/%d",
|
||||||
@@ -276,9 +366,11 @@ func TestSaveHarvestNodes_PersistsAcrossLoad(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
nodes := loadHarvestNodes(exp, 2)
|
n2 := deriveLegacyNodeID(ZoneGoblinWarrens, 2)
|
||||||
|
n5 := deriveLegacyNodeID(ZoneGoblinWarrens, 5)
|
||||||
|
nodes := loadHarvestNodes(exp, n2)
|
||||||
nodes[0].CurrentCharges = 0
|
nodes[0].CurrentCharges = 0
|
||||||
if err := saveHarvestNodes(exp, 2, nodes); err != nil {
|
if err := saveHarvestNodes(exp, n2, nodes); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Reload from DB.
|
// Reload from DB.
|
||||||
@@ -286,12 +378,12 @@ func TestSaveHarvestNodes_PersistsAcrossLoad(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got := loadHarvestNodes(reloaded, 2)
|
got := loadHarvestNodes(reloaded, n2)
|
||||||
if got[0].CurrentCharges != 0 {
|
if got[0].CurrentCharges != 0 {
|
||||||
t.Errorf("after reload, room 2 node 0 charges = %d, want 0", got[0].CurrentCharges)
|
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 rooms should still be untouched (lazy-seeded fresh).
|
||||||
other := loadHarvestNodes(reloaded, 5)
|
other := loadHarvestNodes(reloaded, n5)
|
||||||
for _, n := range other {
|
for _, n := range other {
|
||||||
if n.CurrentCharges != n.MaxCharges {
|
if n.CurrentCharges != n.MaxCharges {
|
||||||
t.Errorf("room 5 leaked depletion: %+v", n)
|
t.Errorf("room 5 leaked depletion: %+v", n)
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ func feywildTemporalPostRollover(e *Expedition) []string {
|
|||||||
// to the current room's nodes. Combat loot stays gone; only the
|
// to the current room's nodes. Combat loot stays gone; only the
|
||||||
// renewable harvest nodes loop back.
|
// renewable harvest nodes loop back.
|
||||||
out := []string{line}
|
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._")
|
out = append(out, "_Harvest nodes in this room have re-emerged — the timeline isn't sure they were ever taken._")
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -203,11 +203,11 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
|
|||||||
var b strings.Builder
|
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(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms))
|
||||||
b.WriteString("_" + zone.Hook + "_\n\n")
|
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(line)
|
||||||
b.WriteString("\n\n")
|
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(aside)
|
||||||
b.WriteString("\n\n")
|
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 <id>`.")
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||||
}
|
}
|
||||||
zone := zoneOrFallback(run.ZoneID)
|
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{}
|
cleared := map[int]bool{}
|
||||||
for _, r := range run.RoomsCleared {
|
for _, r := range run.RoomsCleared {
|
||||||
cleared[r] = true
|
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(renderZoneMap(run.RoomSeq, run.CurrentRoom, cleared))
|
||||||
b.WriteString("\n```\n")
|
b.WriteString("\n```\n")
|
||||||
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss · ✓ cleared ▶ here · pending_")
|
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.
|
// before the 5–8s phase pacing kicks in.
|
||||||
var ib strings.Builder
|
var ib strings.Builder
|
||||||
if elite {
|
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(line)
|
||||||
ib.WriteString("\n")
|
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(line)
|
||||||
ib.WriteString("\n")
|
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.
|
// lands *after* the play-by-play, where it has dramatic context.
|
||||||
var ob strings.Builder
|
var ob strings.Builder
|
||||||
if nat20s > 0 {
|
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(line)
|
||||||
ob.WriteString("\n")
|
ob.WriteString("\n")
|
||||||
}
|
}
|
||||||
} else if nat1s > 0 {
|
} 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(line)
|
||||||
ob.WriteString("\n")
|
ob.WriteString("\n")
|
||||||
}
|
}
|
||||||
@@ -656,7 +664,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||||
_ = abandonZoneRun(userID)
|
_ = abandonZoneRun(userID)
|
||||||
markAdventureDead(userID, "zone", zone.Display)
|
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(line)
|
||||||
ob.WriteString("\n")
|
ob.WriteString("\n")
|
||||||
}
|
}
|
||||||
@@ -669,7 +677,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
ended = true
|
ended = true
|
||||||
return
|
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(line)
|
||||||
ob.WriteString("\n")
|
ob.WriteString("\n")
|
||||||
}
|
}
|
||||||
@@ -853,7 +861,7 @@ func (p *AdventurePlugin) zoneMoodInteraction(
|
|||||||
return p.SendDM(ctx.Sender, "Couldn't apply mood event: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't apply mood event: "+err.Error())
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
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(line)
|
||||||
b.WriteString("\n\n")
|
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 <id>`.")
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||||
}
|
}
|
||||||
zone := zoneOrFallback(run.ZoneID)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
line := twinBeeLine(zone.ID, DMLore, run.RunID, run.CurrentRoom)
|
line := twinBeeLine(zone.ID, DMLore, run.RunID, narrationCadence(run))
|
||||||
if line == "" {
|
if line == "" {
|
||||||
return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.")
|
return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
// advanceTransitionGraph is the graph-mode replacement for
|
// advanceTransitionGraph is the graph-mode replacement for
|
||||||
@@ -64,6 +66,13 @@ func (p *AdventurePlugin) advanceTransitionGraph(run *DungeonRun, zone ZoneDefin
|
|||||||
err = aerr
|
err = aerr
|
||||||
return
|
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)
|
next = nodeKindToRoomType(g.Nodes[chosen.To].Kind)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -80,6 +89,36 @@ func (p *AdventurePlugin) advanceTransitionGraph(run *DungeonRun, zone ZoneDefin
|
|||||||
return
|
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 {
|
func unlockedChoices(cs []pendingChoice) []pendingChoice {
|
||||||
out := make([]pendingChoice, 0, len(cs))
|
out := make([]pendingChoice, 0, len(cs))
|
||||||
for _, c := range cs {
|
for _, c := range cs {
|
||||||
@@ -147,7 +186,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
|
|||||||
}
|
}
|
||||||
g, _ := loadZoneGraph(run.ZoneID)
|
g, _ := loadZoneGraph(run.ZoneID)
|
||||||
zone := zoneOrFallback(run.ZoneID)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
|
fromNode := g.Nodes[run.CurrentNode]
|
||||||
nextNode := g.Nodes[chosen.To]
|
nextNode := g.Nodes[chosen.To]
|
||||||
|
fireGraphRegionTransition(run.UserID, fromNode, nextNode)
|
||||||
nextRoom := nodeKindToRoomType(nextNode.Kind)
|
nextRoom := nodeKindToRoomType(nextNode.Kind)
|
||||||
nextIdx := run.CurrentRoom + 1
|
nextIdx := run.CurrentRoom + 1
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ func (p *AdventurePlugin) applyTrapEffectWithDetect(
|
|||||||
// failed detection no longer contradicts the next line. The mismatch
|
// failed detection no longer contradicts the next line. The mismatch
|
||||||
// where "Perception roll pays off" preceded a tripped trap was
|
// where "Perception roll pays off" preceded a tripped trap was
|
||||||
// caused by this line firing unconditionally.
|
// 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(line)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,13 +145,14 @@ func TestRollManorCursedTrinket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRestoreHarvestNodesInRoom(t *testing.T) {
|
func TestRestoreHarvestNodesInRoom(t *testing.T) {
|
||||||
|
nodeID := deriveLegacyNodeID(ZoneFeywildCrossing, 0)
|
||||||
exp := &Expedition{
|
exp := &Expedition{
|
||||||
ZoneID: ZoneFeywildCrossing,
|
ZoneID: ZoneFeywildCrossing,
|
||||||
CurrentRegion: "",
|
CurrentRegion: "",
|
||||||
RegionState: map[string]any{
|
RegionState: map[string]any{
|
||||||
regionStateHarvestKey: map[string]map[string][]HarvestNode{
|
regionStateHarvestKey: map[string]map[string][]HarvestNode{
|
||||||
"": {
|
"": {
|
||||||
"0": {
|
nodeID: {
|
||||||
{ResourceID: "fey_dust", CurrentCharges: 0, MaxCharges: 2},
|
{ResourceID: "fey_dust", CurrentCharges: 0, MaxCharges: 2},
|
||||||
{ResourceID: "enchanted_petal", CurrentCharges: 1, 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")
|
t.Fatalf("restore should report changes")
|
||||||
}
|
}
|
||||||
table := loadHarvestTable(exp)
|
table := loadHarvestTable(exp)
|
||||||
got := table[""]["0"]
|
got := table[""][nodeID]
|
||||||
for _, n := range got {
|
for _, n := range got {
|
||||||
if n.CurrentCharges != n.MaxCharges {
|
if n.CurrentCharges != n.MaxCharges {
|
||||||
t.Errorf("node %s: charges=%d max=%d (expected restored)", n.ResourceID, 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) {
|
func TestRestoreHarvestNodesInRoom_NoOpEmpty(t *testing.T) {
|
||||||
exp := &Expedition{ZoneID: ZoneFeywildCrossing, RegionState: map[string]any{}}
|
exp := &Expedition{ZoneID: ZoneFeywildCrossing, RegionState: map[string]any{}}
|
||||||
if got := restoreHarvestNodesInRoom(exp, -1); got {
|
if got := restoreHarvestNodesInRoom(exp, ""); got {
|
||||||
t.Errorf("negative roomIdx should no-op")
|
t.Errorf("empty nodeID should no-op")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -25,28 +26,42 @@ import (
|
|||||||
// belong to the expedition system. Standalone harvest is the casual
|
// belong to the expedition system. Standalone harvest is the casual
|
||||||
// flow: roll d20 vs DC, get yield, decrement node, save.
|
// flow: roll d20 vs DC, get yield, decrement node, save.
|
||||||
|
|
||||||
// loadStandaloneHarvestNodes reads the run's harvest map and returns the
|
// loadStandaloneHarvestNodes reads the run's harvest map and returns
|
||||||
// nodes for the given room. Lazy-seeds from the zone registry on first
|
// the nodes for the given graph node. Lazy-seeds from the zone registry
|
||||||
// access. Caller persists via saveStandaloneHarvestNodes.
|
// on first access. Caller persists via saveStandaloneHarvestNodes.
|
||||||
func loadStandaloneHarvestNodes(runID string, zoneID ZoneID, roomIdx int) ([]HarvestNode, error) {
|
//
|
||||||
|
// 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)
|
table, err := loadStandaloneHarvestTable(runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
roomKey := roomHarvestKey(roomIdx)
|
roomKey := nodeHarvestKey(nodeID)
|
||||||
if existing, ok := table[roomKey]; ok {
|
if existing, ok := table[roomKey]; ok {
|
||||||
return existing, nil
|
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
|
return seedRoomNodes(zoneID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveStandaloneHarvestNodes writes nodes back into the run's harvest map.
|
// 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)
|
table, err := loadStandaloneHarvestTable(runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
raw, err := json.Marshal(table)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal harvest table: %w", err)
|
return fmt.Errorf("marshal harvest table: %w", err)
|
||||||
@@ -93,8 +108,8 @@ func (p *AdventurePlugin) handleStandaloneHarvest(ctx MessageContext, char *DnDC
|
|||||||
prettyRoomType(run.CurrentRoomType())))
|
prettyRoomType(run.CurrentRoomType())))
|
||||||
}
|
}
|
||||||
|
|
||||||
roomIdx := run.CurrentRoom
|
nodeID := harvestNodeIDFor(run)
|
||||||
nodes, err := loadStandaloneHarvestNodes(run.RunID, run.ZoneID, roomIdx)
|
nodes, err := loadStandaloneHarvestNodes(run.RunID, run.ZoneID, nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't load harvest state.")
|
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))
|
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)
|
slog.Error("standalone harvest: save nodes", "user", ctx.Sender, "run", run.RunID, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ func TestStandaloneHarvest_RoutesToZoneRun(t *testing.T) {
|
|||||||
if raw == "" || raw == "{}" {
|
if raw == "" || raw == "{}" {
|
||||||
t.Errorf("expected harvest_nodes_json populated after standalone harvest, got %q", raw)
|
t.Errorf("expected harvest_nodes_json populated after standalone harvest, got %q", raw)
|
||||||
}
|
}
|
||||||
if !strings.Contains(raw, "\"0\":") {
|
wantKey := "\"" + deriveLegacyNodeID(run.ZoneID, 0) + "\":"
|
||||||
t.Errorf("expected room 0 entry in harvest table, got %q", raw)
|
if !strings.Contains(raw, wantKey) {
|
||||||
|
t.Errorf("expected entry node %s in harvest table, got %q", wantKey, raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -330,6 +330,22 @@ func eliteRoomEntryLine(zoneID ZoneID, runID string, roomIdx int) string {
|
|||||||
return "🎭 **TwinBee:** " + line
|
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
|
// pickLineDeterministic — stable selection across (runID, salt). Same
|
||||||
// inputs always yield the same line, so a player who re-reads status
|
// inputs always yield the same line, so a player who re-reads status
|
||||||
// sees the same prose; different rooms / different runs vary.
|
// sees the same prose; different rooms / different runs vary.
|
||||||
|
|||||||
174
internal/plugin/zone_graph_map.go
Normal file
174
internal/plugin/zone_graph_map.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
81
internal/plugin/zone_graph_map_test.go
Normal file
81
internal/plugin/zone_graph_map_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user