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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
// `<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
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,94 @@ import (
|
||||
|
||||
// ── 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) {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <id>`.")
|
||||
}
|
||||
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 <id>`.")
|
||||
}
|
||||
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.")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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