mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
adventure: send Pete the fog-of-war zone map beside the room count
This commit is contained in:
@@ -266,9 +266,39 @@ type RosterDetail struct {
|
|||||||
Modifiers [6]int `json:"modifiers"` // matching ability modifiers
|
Modifiers [6]int `json:"modifiers"` // matching ability modifiers
|
||||||
Gear []GearItem `json:"gear,omitempty"`
|
Gear []GearItem `json:"gear,omitempty"`
|
||||||
// Expedition context, present only while on a run.
|
// Expedition context, present only while on a run.
|
||||||
Supplies int `json:"supplies,omitempty"`
|
Supplies int `json:"supplies,omitempty"`
|
||||||
ThreatLevel int `json:"threat_level,omitempty"`
|
ThreatLevel int `json:"threat_level,omitempty"`
|
||||||
Room string `json:"room,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.
|
// GearItem is one equipped piece for the armor/gear panel.
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
|
|||||||
if exp.RunID != "" {
|
if exp.RunID != "" {
|
||||||
if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 {
|
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.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
|
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
|
// 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
|
// 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
|
// recompute every live player's token and match. The salt is DB-persisted, so a
|
||||||
|
|||||||
137
internal/plugin/pete_roster_map_test.go
Normal file
137
internal/plugin/pete_roster_map_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user