diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index ebcd7a2..9a50285 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -266,9 +266,39 @@ type RosterDetail struct { Modifiers [6]int `json:"modifiers"` // matching ability modifiers Gear []GearItem `json:"gear,omitempty"` // Expedition context, present only while on a run. - Supplies int `json:"supplies,omitempty"` - ThreatLevel int `json:"threat_level,omitempty"` - Room string `json:"room,omitempty"` + Supplies int `json:"supplies,omitempty"` + ThreatLevel int `json:"threat_level,omitempty"` + Room string `json:"room,omitempty"` + Map *RosterMap `json:"map,omitempty"` +} + +// RosterMap is the fog-of-war cut of an adventurer's zone graph: every node +// they have visited, plus the one-hop frontier of doors leading out of visited +// nodes, with the rooms behind those doors withheld. It is per-adventurer and +// rides the roster push beside Room. Only ids and kinds cross the wire — a +// ZoneNode's Label and Content (encounter, loot bias, narration) are spoilers +// and never leave the game box. Frontier nodes carry kind "unknown". +type RosterMap struct { + ZoneID string `json:"zone_id"` + CurrentNode string `json:"current_node"` + Visited []string `json:"visited"` + Nodes []RosterMapNode `json:"nodes"` + Edges []RosterMapEdge `json:"edges"` +} + +// RosterMapNode is one room reduced to what a public map may show. +type RosterMapNode struct { + ID string `json:"id"` + Kind string `json:"kind"` // ZoneNodeKind, or "unknown" for an unreached frontier room +} + +// RosterMapEdge is one directed passage. Lock names the gate kind +// (perception_check, key_required, ...) so the map can mark a door as barred; +// LockData and Hint stay behind on the game box. +type RosterMapEdge struct { + From string `json:"from"` + To string `json:"to"` + Lock string `json:"lock,omitempty"` } // GearItem is one equipped piece for the armor/gear panel. diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index cb9361e..c37d5e8 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -400,6 +400,7 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap if exp.RunID != "" { if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 { e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms) + e.Detail.Map = buildRosterMap(run) } } } @@ -413,6 +414,71 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap return snap, nil } +// buildRosterMap computes the fog-of-war cut of a run's zone graph for the +// public roster. It sends every visited node with its true kind, plus the +// one-hop frontier — the destinations of edges leading out of visited nodes, +// with their kind withheld as "unknown". Edges are directed and stored by +// from-node, so "one hop out of a visited node" is exactly g.Edges[visited]. +// A frontier node's edges are NOT walked, so nothing past the first closed +// door reaches the wire — "view source to find the boss room" is not fog of +// war. Node Label/Content never leave the game box. +// +// Output order is deterministic (visited-path order, then frontier in +// discovery order) so an unchanged run produces a byte-identical snapshot and +// the roster push does not churn. +func buildRosterMap(run *DungeonRun) *peteclient.RosterMap { + g, ok := loadZoneGraph(run.ZoneID) + if !ok || len(run.VisitedNodes) == 0 { + return nil + } + + // Unique visited nodes in path order — VisitedNodes repeats on backtrack. + orderedVisited := make([]string, 0, len(run.VisitedNodes)) + seen := make(map[string]bool, len(run.VisitedNodes)) + for _, id := range run.VisitedNodes { + if !seen[id] { + seen[id] = true + orderedVisited = append(orderedVisited, id) + } + } + + m := &peteclient.RosterMap{ + ZoneID: string(run.ZoneID), + CurrentNode: run.CurrentNode, + Visited: orderedVisited, + } + + // Visited nodes first, in path order, with their real kind. + emitted := make(map[string]bool, len(orderedVisited)) + for _, id := range orderedVisited { + emitted[id] = true + if n, ok := g.Nodes[id]; ok { + m.Nodes = append(m.Nodes, peteclient.RosterMapNode{ID: id, Kind: string(n.Kind)}) + } + } + + // Frontier: destinations of edges out of visited nodes, kind withheld. + // Walk visited in path order so the frontier order is stable. + for _, from := range orderedVisited { + for _, edge := range g.Edges[from] { + lock := edge.Lock + if lock == LockNone { + lock = "" // an open door needs no mark; omitempty drops it + } + m.Edges = append(m.Edges, peteclient.RosterMapEdge{ + From: edge.From, + To: edge.To, + Lock: string(lock), + }) + if !seen[edge.To] && !emitted[edge.To] { + emitted[edge.To] = true + m.Nodes = append(m.Nodes, peteclient.RosterMapNode{ID: edge.To, Kind: "unknown"}) + } + } + } + return m +} + // resolveRosterToken maps a board token back to the adventurer it names. The // token is a one-way HMAC (eventToken), so it can't be inverted — instead we // recompute every live player's token and match. The salt is DB-persisted, so a diff --git a/internal/plugin/pete_roster_map_test.go b/internal/plugin/pete_roster_map_test.go new file mode 100644 index 0000000..cef3215 --- /dev/null +++ b/internal/plugin/pete_roster_map_test.go @@ -0,0 +1,137 @@ +package plugin + +import ( + "testing" +) + +// mapFixtureGraph registers a small branching graph and returns a cleanup. +// +// n1(entry) --none--------> n2(exploration) --key_required--> n4(boss) +// \--perception_check--> n3(trap) ------------------------/ +// +// n4 is reachable two ways; the validator wants a single entry and a reachable +// boss, which this satisfies. +func mapFixtureGraph(t *testing.T) ZoneID { + t.Helper() + const zid ZoneID = "map_test_zone" + g := ZoneGraph{ + ZoneID: zid, + Entry: "n1", + Boss: "n4", + Nodes: map[string]ZoneNode{ + "n1": {NodeID: "n1", ZoneID: zid, Kind: NodeKindEntry, IsEntry: true, Label: "The Gate"}, + "n2": {NodeID: "n2", ZoneID: zid, Kind: NodeKindExploration, Label: "Dusty Hall"}, + "n3": {NodeID: "n3", ZoneID: zid, Kind: NodeKindTrap, Label: "Spiked Pit"}, + "n4": {NodeID: "n4", ZoneID: zid, Kind: NodeKindBoss, IsBoss: true, Label: "Throne of Bone"}, + }, + Edges: map[string][]ZoneEdge{ + "n1": { + {From: "n1", To: "n2", Lock: LockNone, Weight: 1}, + {From: "n1", To: "n3", Lock: LockPerception, Weight: 1}, + }, + "n2": {{From: "n2", To: "n4", Lock: LockKey, Weight: 1}}, + "n3": {{From: "n3", To: "n4", Lock: LockNone, Weight: 1}}, + }, + } + registerZoneGraph(g) + t.Cleanup(func() { delete(zoneGraphRegistry, zid) }) + return zid +} + +func TestBuildRosterMap_FogOfWar(t *testing.T) { + zid := mapFixtureGraph(t) + run := &DungeonRun{ + ZoneID: zid, + CurrentNode: "n2", + VisitedNodes: []string{"n1", "n2"}, + TotalRooms: 4, + } + m := buildRosterMap(run) + if m == nil { + t.Fatal("buildRosterMap returned nil for a visited run") + } + if m.ZoneID != string(zid) || m.CurrentNode != "n2" { + t.Fatalf("header wrong: %+v", m) + } + + kinds := map[string]string{} + for _, n := range m.Nodes { + if _, dup := kinds[n.ID]; dup { + t.Errorf("node %q emitted twice", n.ID) + } + kinds[n.ID] = n.Kind + } + + // Visited nodes carry their true kind. + if kinds["n1"] != "entry" || kinds["n2"] != "exploration" { + t.Errorf("visited kinds wrong: %v", kinds) + } + // Frontier nodes are present but their kind is withheld. + if kinds["n3"] != "unknown" { + t.Errorf("n3 is one hop out of n1 and must be unknown, got %q", kinds["n3"]) + } + if kinds["n4"] != "unknown" { + t.Errorf("n4 (the boss) is one hop out of n2 and must be unknown, got %q", kinds["n4"]) + } + + // The map must never leak a room the player has not reached a door to. + // n4 is a real boss node, but reachable only as frontier — its kind stays + // hidden. A node past a frontier door (there is none deeper here) must not + // appear at all; assert exactly four nodes. + if len(m.Nodes) != 4 { + t.Fatalf("want 4 nodes (2 visited + 2 frontier), got %d: %+v", len(m.Nodes), m.Nodes) + } + + // Edges out of visited nodes only. n3->n4 must NOT appear: n3 is frontier, + // not visited, so walking its doors would leak structure past the fog. + var edgeKeys []string + for _, e := range m.Edges { + edgeKeys = append(edgeKeys, e.From+"->"+e.To+":"+e.Lock) + } + want := map[string]bool{ + "n1->n2:": true, // LockNone dropped to "" + "n1->n3:perception_check": true, + "n2->n4:key_required": true, + } + if len(edgeKeys) != len(want) { + t.Fatalf("want %d edges, got %v", len(want), edgeKeys) + } + for _, k := range edgeKeys { + if !want[k] { + t.Errorf("unexpected edge %q (n3->n4 would be a fog leak)", k) + } + } +} + +func TestBuildRosterMap_EmptyRunIsNil(t *testing.T) { + zid := mapFixtureGraph(t) + // A run that has visited nothing yet has no map to show. + if m := buildRosterMap(&DungeonRun{ZoneID: zid}); m != nil { + t.Errorf("empty VisitedNodes should yield nil, got %+v", m) + } + // An unknown zone (no graph, no legacy fallback row) yields nil, not a panic. + if m := buildRosterMap(&DungeonRun{ZoneID: "no_such_zone", VisitedNodes: []string{"x"}}); m != nil { + t.Errorf("unknown zone should yield nil, got %+v", m) + } +} + +func TestBuildRosterMap_BacktrackDedup(t *testing.T) { + zid := mapFixtureGraph(t) + // VisitedNodes is an ordered set that repeats on backtrack: n1,n2,n1. + run := &DungeonRun{ + ZoneID: zid, + CurrentNode: "n1", + VisitedNodes: []string{"n1", "n2", "n1"}, + } + m := buildRosterMap(run) + seen := map[string]int{} + for _, n := range m.Nodes { + seen[n.ID]++ + } + if seen["n1"] != 1 { + t.Errorf("backtracked node n1 emitted %d times, want 1", seen["n1"]) + } + if len(m.Visited) != 2 { + t.Errorf("Visited should dedup to [n1 n2], got %v", m.Visited) + } +}