adventure: draw the dungeon as a fog-of-war map, not a room tally
This commit is contained in:
@@ -2588,3 +2588,61 @@ html[data-room] .pete-felt {
|
|||||||
.pete-poker-you .pete-seat-cards { --card-h: 6rem; --card-w: 4.3rem; min-height: 6rem; }
|
.pete-poker-you .pete-seat-cards { --card-h: 6rem; --card-w: 4.3rem; min-height: 6rem; }
|
||||||
.pete-poker-pot-total { font-size: 1.25rem; }
|
.pete-poker-pot-total { font-size: 1.25rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Dungeon map (who page). The graph arrives already cut to the fog-of-war
|
||||||
|
frontier; who_map.go lays it out and who.html draws it as inline SVG. Node
|
||||||
|
colours ride the phase through --ink/--card like every card does, with a
|
||||||
|
per-kind tint carried in --map-fill so the SVG discs and the legend dots
|
||||||
|
read from one source. The adventure purple marks the room you're in. */
|
||||||
|
.map-svg { display: block; overflow: visible; }
|
||||||
|
|
||||||
|
.map-edge {
|
||||||
|
stroke: color-mix(in srgb, var(--ink) 26%, transparent);
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.map-edge-locked {
|
||||||
|
stroke: #c98a2b; /* a barred door reads amber, not ink */
|
||||||
|
stroke-dasharray: 3 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-disc {
|
||||||
|
fill: var(--map-fill, color-mix(in srgb, var(--ink) 9%, var(--card)));
|
||||||
|
stroke: color-mix(in srgb, var(--map-stroke, var(--ink)) 55%, transparent);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.map-glyph {
|
||||||
|
fill: color-mix(in srgb, var(--map-stroke, var(--ink)) 85%, var(--ink));
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.map-node-current .map-disc { stroke: #6d4bd8; stroke-width: 2.5; }
|
||||||
|
.map-ring { fill: none; stroke: #6d4bd8; stroke-width: 2; opacity: 0.5; }
|
||||||
|
|
||||||
|
/* Per-kind tint. Set on the node group (and the legend dot); both the disc
|
||||||
|
fill and the glyph colour derive from it. */
|
||||||
|
.map-node-entry { --map-fill: color-mix(in srgb, #3fa66a 22%, var(--card)); --map-stroke: #2f8a54; }
|
||||||
|
.map-node-boss { --map-fill: color-mix(in srgb, #c0392b 22%, var(--card)); --map-stroke: #a52f22; }
|
||||||
|
.map-node-trap { --map-fill: color-mix(in srgb, #d98324 22%, var(--card)); --map-stroke: #b56a17; }
|
||||||
|
.map-node-elite { --map-fill: color-mix(in srgb, #6d4bd8 20%, var(--card)); --map-stroke: #5836b8; }
|
||||||
|
.map-node-secret { --map-fill: color-mix(in srgb, #b08d2e 22%, var(--card)); --map-stroke: #8f7018; }
|
||||||
|
.map-node-harvest { --map-fill: color-mix(in srgb, #3f8f6a 18%, var(--card)); --map-stroke: #2f7355; }
|
||||||
|
.map-node-rest { --map-fill: color-mix(in srgb, #3f83a6 18%, var(--card)); --map-stroke: #2f6a88; }
|
||||||
|
.map-node-plain { --map-fill: color-mix(in srgb, var(--ink) 9%, var(--card)); --map-stroke: var(--ink); }
|
||||||
|
.map-node-unknown { --map-fill: color-mix(in srgb, var(--ink) 5%, var(--card)); --map-stroke: color-mix(in srgb, var(--ink) 40%, transparent); }
|
||||||
|
.map-node-unknown .map-disc { stroke-dasharray: 3 4; }
|
||||||
|
|
||||||
|
/* Legend swatches. Same --map-fill source as the nodes. */
|
||||||
|
.map-dot {
|
||||||
|
width: 11px; height: 11px; border-radius: 9999px;
|
||||||
|
background: var(--map-fill, color-mix(in srgb, var(--ink) 9%, var(--card)));
|
||||||
|
border: 1.5px solid color-mix(in srgb, var(--map-stroke, var(--ink)) 55%, transparent);
|
||||||
|
}
|
||||||
|
.map-node-unknown.map-dot { border-style: dashed; }
|
||||||
|
.map-door-legend {
|
||||||
|
width: 16px; height: 0;
|
||||||
|
border-top: 2.5px dashed #c98a2b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -23,6 +23,21 @@
|
|||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* The dungeon map. Laid out server-side (who_map.go) into an x/y node field;
|
||||||
|
this just draws it. Edges first so nodes sit on top of the lines. Nothing
|
||||||
|
here is a player-authored string — node kinds are a fixed vocabulary and
|
||||||
|
ids are zone-prefixed — so the SVG carries no injection surface. */}}
|
||||||
|
{{define "dungeonmap"}}
|
||||||
|
<svg viewBox="0 0 {{.W}} {{.H}}" width="{{.W}}" height="{{.H}}" role="img" aria-label="Dungeon map" class="map-svg max-w-full h-auto">
|
||||||
|
<g>
|
||||||
|
{{range .Edges}}<line x1="{{.X1}}" y1="{{.Y1}}" x2="{{.X2}}" y2="{{.Y2}}" class="map-edge{{if .Locked}} map-edge-locked{{end}}">{{if .Lock}}<title>{{.Lock}}</title>{{end}}</line>{{end}}
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
{{range .Nodes}}<g class="map-node {{.Class}}{{if .Current}} map-node-current{{end}}" transform="translate({{.X}},{{.Y}})">{{if .Current}}<circle r="21" class="map-ring"></circle>{{end}}<circle r="15" class="map-disc"></circle><text text-anchor="middle" dominant-baseline="central" class="map-glyph">{{.Glyph}}</text><title>{{.Label}}{{if .Current}} — you are here{{end}}</title></g>{{end}}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
|
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
|
||||||
<nav class="mb-4">
|
<nav class="mb-4">
|
||||||
@@ -96,6 +111,28 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{{if .MapView}}
|
||||||
|
<!-- The dungeon map: the fog-of-war cut gogobee sent. Visited rooms drawn in
|
||||||
|
full, the doors leading out of them, and the unopened rooms behind those
|
||||||
|
doors as question marks. Pete lays it out; it is never told what a room
|
||||||
|
holds, only that a door is there. -->
|
||||||
|
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<div class="flex items-baseline justify-between mb-4">
|
||||||
|
<h2 class="font-display text-xl font-bold">The map</h2>
|
||||||
|
{{if .Detail.Room}}<span class="text-sm text-[color:var(--ink)]/50">Room <span class="font-semibold text-[color:var(--ink)]/70">{{.Detail.Room}}</span></span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
{{template "dungeonmap" .MapView}}
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-[color:var(--ink)]/55">
|
||||||
|
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-entry"></span>entrance</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-boss"></span>boss</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-unknown"></span>unexplored</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5"><span class="map-door-legend"></span>locked door</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
|
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
|
||||||
|
|||||||
@@ -33,6 +33,30 @@ type whoDetail struct {
|
|||||||
Supplies int `json:"supplies"`
|
Supplies int `json:"supplies"`
|
||||||
ThreatLevel int `json:"threat_level"`
|
ThreatLevel int `json:"threat_level"`
|
||||||
Room string `json:"room"`
|
Room string `json:"room"`
|
||||||
|
Map *whoMap `json:"map"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with
|
||||||
|
// their true kind, plus the one-hop frontier of doors whose rooms are withheld
|
||||||
|
// (kind "unknown"). Pete lays it out and draws it; it never receives node
|
||||||
|
// labels or contents, only ids and kinds. See who_map.go.
|
||||||
|
type whoMap struct {
|
||||||
|
ZoneID string `json:"zone_id"`
|
||||||
|
CurrentNode string `json:"current_node"`
|
||||||
|
Visited []string `json:"visited"`
|
||||||
|
Nodes []whoMapNode `json:"nodes"`
|
||||||
|
Edges []whoMapEdge `json:"edges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type whoMapNode struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type whoMapEdge struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Lock string `json:"lock"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type whoGear struct {
|
type whoGear struct {
|
||||||
@@ -58,6 +82,7 @@ type whoPage struct {
|
|||||||
HasDetail bool
|
HasDetail bool
|
||||||
Detail whoDetail
|
Detail whoDetail
|
||||||
Abilities []abilityRow
|
Abilities []abilityRow
|
||||||
|
MapView *mapView // laid-out dungeon map, nil when not on a run or no graph
|
||||||
HasSelf bool
|
HasSelf bool
|
||||||
Self storage.PlayerDetail
|
Self storage.PlayerDetail
|
||||||
// The private panels, wrapped so a row knows where it is sitting. Bond state
|
// The private panels, wrapped so a row knows where it is sitting. Bond state
|
||||||
@@ -150,6 +175,7 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
|||||||
page.HasDetail = true
|
page.HasDetail = true
|
||||||
page.Detail = d
|
page.Detail = d
|
||||||
page.Abilities = abil
|
page.Abilities = abil
|
||||||
|
page.MapView = buildMapView(d.Map)
|
||||||
}
|
}
|
||||||
|
|
||||||
// History: one read feeds both the trophy case and the trail. Keyed on the
|
// History: one read feeds both the trophy case and the trail. Keyed on the
|
||||||
|
|||||||
223
internal/web/who_map.go
Normal file
223
internal/web/who_map.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
// Dungeon map rendering.
|
||||||
|
//
|
||||||
|
// gogobee sends the fog-of-war cut of an adventurer's zone graph (whoMap):
|
||||||
|
// every room they have visited with its true kind, plus the one-hop frontier of
|
||||||
|
// doors leading out of visited rooms, with the rooms behind them withheld as
|
||||||
|
// kind "unknown". Pete lays that graph out and draws it as an inline SVG on the
|
||||||
|
// who page. No node labels or contents ever cross the wire — the map knows a
|
||||||
|
// room is a trap, never what the trap is.
|
||||||
|
//
|
||||||
|
// Layout is a left-to-right layering: column = BFS depth from the entry along
|
||||||
|
// the edges we were given, row = order the node first appears in the payload.
|
||||||
|
// It is computed server-side and fully deterministic, so the same run draws the
|
||||||
|
// same map every render.
|
||||||
|
|
||||||
|
// Layout geometry. The node/ring radii live in who.html and the map CSS; only
|
||||||
|
// the spacing the layout math needs is here.
|
||||||
|
const (
|
||||||
|
mapColGap = 84 // horizontal spacing between depth columns
|
||||||
|
mapRowGap = 60 // vertical spacing between nodes sharing a column
|
||||||
|
mapMargin = 26 // padding around the node field
|
||||||
|
)
|
||||||
|
|
||||||
|
// mapView is the laid-out map, ready for the template's SVG.
|
||||||
|
type mapView struct {
|
||||||
|
Nodes []mapNode
|
||||||
|
Edges []mapEdge
|
||||||
|
W int // svg viewBox width
|
||||||
|
H int // svg viewBox height
|
||||||
|
}
|
||||||
|
|
||||||
|
type mapNode struct {
|
||||||
|
X, Y int
|
||||||
|
Kind string // ZoneNodeKind, or "unknown" for an unreached frontier room
|
||||||
|
Glyph string // the single character drawn inside the node
|
||||||
|
Class string // CSS class picking the node's colour family
|
||||||
|
Label string // accessible title, e.g. "Trap" or "Unexplored"
|
||||||
|
Current bool // the room the adventurer is standing in
|
||||||
|
}
|
||||||
|
|
||||||
|
type mapEdge struct {
|
||||||
|
X1, Y1, X2, Y2 int
|
||||||
|
Locked bool
|
||||||
|
Lock string // the gate's kind, for the door's title text
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeStyle maps a room kind to how it draws. Kept small and total: an unknown
|
||||||
|
// kind (a room type Pete hasn't been taught yet) falls through to a neutral dot
|
||||||
|
// rather than vanishing.
|
||||||
|
type nodeStyle struct {
|
||||||
|
glyph string
|
||||||
|
class string
|
||||||
|
label string
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapNodeStyles = map[string]nodeStyle{
|
||||||
|
"entry": {"⌂", "map-node-entry", "Entrance"},
|
||||||
|
"exploration": {"·", "map-node-plain", "Room"},
|
||||||
|
"trap": {"!", "map-node-trap", "Trap"},
|
||||||
|
"elite": {"◆", "map-node-elite", "Elite"},
|
||||||
|
"boss": {"♛", "map-node-boss", "Boss"}, // text dingbats only — U+26CF/U+2620 fall back to emoji or tofu
|
||||||
|
"harvest": {"❖", "map-node-harvest", "Harvest"},
|
||||||
|
"rest_camp": {"✚", "map-node-rest", "Rest camp"},
|
||||||
|
"secret": {"✦", "map-node-secret", "Secret"},
|
||||||
|
"fork": {"⋔", "map-node-plain", "Fork"},
|
||||||
|
"merge": {"⋏", "map-node-plain", "Merge"},
|
||||||
|
"unknown": {"?", "map-node-unknown", "Unexplored"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func styleFor(kind string) nodeStyle {
|
||||||
|
if s, ok := mapNodeStyles[kind]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return nodeStyle{"·", "map-node-plain", "Room"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMapView lays out a fog-of-war graph. Returns nil when there is nothing
|
||||||
|
// worth drawing (no nodes) so the template can skip the panel entirely.
|
||||||
|
func buildMapView(m *whoMap) *mapView {
|
||||||
|
if m == nil || len(m.Nodes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjacency for the depth walk, and appearance order for stable rows.
|
||||||
|
order := make(map[string]int, len(m.Nodes))
|
||||||
|
for i, n := range m.Nodes {
|
||||||
|
if _, dup := order[n.ID]; !dup {
|
||||||
|
order[n.ID] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adj := make(map[string][]string, len(m.Nodes))
|
||||||
|
for _, e := range m.Edges {
|
||||||
|
// Only lay out edges whose endpoints are both nodes we were given; a
|
||||||
|
// half-edge would point at nothing.
|
||||||
|
if _, ok := order[e.From]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := order[e.To]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
adj[e.From] = append(adj[e.From], e.To)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth = shortest hop count from the entry. The first visited node is the
|
||||||
|
// entry (gogobee sends Visited in path order); fall back to appearance
|
||||||
|
// order 0 if Visited is somehow empty.
|
||||||
|
start := ""
|
||||||
|
if len(m.Visited) > 0 {
|
||||||
|
start = m.Visited[0]
|
||||||
|
} else {
|
||||||
|
start = m.Nodes[0].ID
|
||||||
|
}
|
||||||
|
depth := bfsDepth(start, adj, order)
|
||||||
|
|
||||||
|
// Group nodes by depth column, each column ordered by first appearance.
|
||||||
|
maxDepth := 0
|
||||||
|
byCol := map[int][]string{}
|
||||||
|
for _, n := range m.Nodes {
|
||||||
|
d := depth[n.ID]
|
||||||
|
byCol[d] = append(byCol[d], n.ID)
|
||||||
|
if d > maxDepth {
|
||||||
|
maxDepth = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxRows := 0
|
||||||
|
for d := 0; d <= maxDepth; d++ {
|
||||||
|
sortByOrder(byCol[d], order)
|
||||||
|
if len(byCol[d]) > maxRows {
|
||||||
|
maxRows = len(byCol[d])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kindByID := make(map[string]string, len(m.Nodes))
|
||||||
|
for _, n := range m.Nodes {
|
||||||
|
kindByID[n.ID] = n.Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place nodes. Each column is vertically centred against the tallest column
|
||||||
|
// so the map reads as balanced rather than top-aligned.
|
||||||
|
pos := make(map[string][2]int, len(m.Nodes))
|
||||||
|
view := &mapView{
|
||||||
|
W: mapMargin*2 + maxDepth*mapColGap,
|
||||||
|
H: mapMargin*2 + max(0, maxRows-1)*mapRowGap,
|
||||||
|
}
|
||||||
|
if view.W < mapMargin*2 {
|
||||||
|
view.W = mapMargin * 2
|
||||||
|
}
|
||||||
|
for d := 0; d <= maxDepth; d++ {
|
||||||
|
col := byCol[d]
|
||||||
|
x := mapMargin + d*mapColGap
|
||||||
|
offset := (maxRows - len(col)) * mapRowGap / 2
|
||||||
|
for i, id := range col {
|
||||||
|
y := mapMargin + offset + i*mapRowGap
|
||||||
|
pos[id] = [2]int{x, y}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for d := 0; d <= maxDepth; d++ {
|
||||||
|
for _, id := range byCol[d] {
|
||||||
|
p := pos[id]
|
||||||
|
st := styleFor(kindByID[id])
|
||||||
|
view.Nodes = append(view.Nodes, mapNode{
|
||||||
|
X: p[0], Y: p[1],
|
||||||
|
Kind: kindByID[id],
|
||||||
|
Glyph: st.glyph,
|
||||||
|
Class: st.class,
|
||||||
|
Label: st.label,
|
||||||
|
Current: id == m.CurrentNode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range m.Edges {
|
||||||
|
a, okA := pos[e.From]
|
||||||
|
b, okB := pos[e.To]
|
||||||
|
if !okA || !okB {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
view.Edges = append(view.Edges, mapEdge{
|
||||||
|
X1: a[0], Y1: a[1], X2: b[0], Y2: b[1],
|
||||||
|
Locked: e.Lock != "" && e.Lock != "none",
|
||||||
|
Lock: e.Lock,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
// bfsDepth returns the hop distance from start to every node reachable along
|
||||||
|
// adj. Nodes never reached (there should be none in a well-formed cut) default
|
||||||
|
// to 0, so a stray node still lands in the first column rather than off-canvas.
|
||||||
|
func bfsDepth(start string, adj map[string][]string, order map[string]int) map[string]int {
|
||||||
|
depth := map[string]int{start: 0}
|
||||||
|
queue := []string{start}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
u := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
for _, v := range adj[u] {
|
||||||
|
if _, seen := depth[v]; !seen {
|
||||||
|
depth[v] = depth[u] + 1
|
||||||
|
queue = append(queue, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Any node with an appearance order but no depth (unreachable via the cut)
|
||||||
|
// gets 0 so it is still placed.
|
||||||
|
for id := range order {
|
||||||
|
if _, ok := depth[id]; !ok {
|
||||||
|
depth[id] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return depth
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortByOrder sorts ids in place by their first-appearance index. Insertion
|
||||||
|
// sort — columns hold a handful of nodes, and it keeps the ordering stable
|
||||||
|
// without pulling in a comparator closure.
|
||||||
|
func sortByOrder(ids []string, order map[string]int) {
|
||||||
|
for i := 1; i < len(ids); i++ {
|
||||||
|
for j := i; j > 0 && order[ids[j]] < order[ids[j-1]]; j-- {
|
||||||
|
ids[j], ids[j-1] = ids[j-1], ids[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
internal/web/who_map_test.go
Normal file
148
internal/web/who_map_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fogMap is the shape gogobee sends: two visited rooms (entry, elite), and two
|
||||||
|
// frontier rooms reachable one hop out — n4 is really the boss but arrives with
|
||||||
|
// its kind withheld as "unknown". n3 sits behind a perception-locked door, n4
|
||||||
|
// behind a key-locked one.
|
||||||
|
func fogMap() *whoMap {
|
||||||
|
return &whoMap{
|
||||||
|
ZoneID: "ossuary",
|
||||||
|
CurrentNode: "n2",
|
||||||
|
Visited: []string{"n1", "n2"},
|
||||||
|
Nodes: []whoMapNode{
|
||||||
|
{ID: "n1", Kind: "entry"},
|
||||||
|
{ID: "n2", Kind: "elite"},
|
||||||
|
{ID: "n3", Kind: "unknown"},
|
||||||
|
{ID: "n4", Kind: "unknown"},
|
||||||
|
},
|
||||||
|
Edges: []whoMapEdge{
|
||||||
|
{From: "n1", To: "n2", Lock: ""},
|
||||||
|
{From: "n1", To: "n3", Lock: "perception_check"},
|
||||||
|
{From: "n2", To: "n4", Lock: "key_required"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMapView_Layout(t *testing.T) {
|
||||||
|
v := buildMapView(fogMap())
|
||||||
|
if v == nil {
|
||||||
|
t.Fatal("nil view for a non-empty map")
|
||||||
|
}
|
||||||
|
if len(v.Nodes) != 4 {
|
||||||
|
t.Fatalf("want 4 nodes, got %d", len(v.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth places entry in column 0, its two successors in column 1, the boss
|
||||||
|
// frontier in column 2 — a left-to-right dungeon.
|
||||||
|
var n1, n2, n4 mapNode
|
||||||
|
for _, n := range v.Nodes {
|
||||||
|
switch {
|
||||||
|
case n.Kind == "entry":
|
||||||
|
n1 = n
|
||||||
|
case n.Kind == "elite":
|
||||||
|
n2 = n
|
||||||
|
case n.Kind == "unknown" && n.X > n1.X+mapColGap:
|
||||||
|
n4 = n // the deepest unknown is n4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !(n1.X < n2.X && n2.X < n4.X) {
|
||||||
|
t.Errorf("columns not left-to-right: entry=%d elite=%d frontier=%d", n1.X, n2.X, n4.X)
|
||||||
|
}
|
||||||
|
// The boss frontier must NOT reveal its kind or its glyph.
|
||||||
|
if n4.Kind != "unknown" || n4.Glyph != "?" {
|
||||||
|
t.Errorf("frontier boss leaked: kind=%q glyph=%q", n4.Kind, n4.Glyph)
|
||||||
|
}
|
||||||
|
if n2.Current != true {
|
||||||
|
t.Errorf("current node n2 (elite) should be marked current")
|
||||||
|
}
|
||||||
|
if n1.Current {
|
||||||
|
t.Errorf("entry is not the current node")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edges: three total, the two locked ones dashed, the visited-visited one open.
|
||||||
|
if len(v.Edges) != 3 {
|
||||||
|
t.Fatalf("want 3 edges, got %d", len(v.Edges))
|
||||||
|
}
|
||||||
|
locked := 0
|
||||||
|
for _, e := range v.Edges {
|
||||||
|
if e.Locked {
|
||||||
|
locked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if locked != 2 {
|
||||||
|
t.Errorf("want 2 locked doors (perception, key), got %d", locked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMapView_EmptyIsNil(t *testing.T) {
|
||||||
|
if buildMapView(nil) != nil {
|
||||||
|
t.Error("nil map should give nil view")
|
||||||
|
}
|
||||||
|
if buildMapView(&whoMap{ZoneID: "x"}) != nil {
|
||||||
|
t.Error("map with no nodes should give nil view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicDetailWithMap is publicDetail plus a fog-of-war map, the way the roster
|
||||||
|
// push carries both.
|
||||||
|
func publicDetailWithMap(t *testing.T) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(map[string]any{
|
||||||
|
"hp_current": 30,
|
||||||
|
"hp_max": 42,
|
||||||
|
"armor_class": 17,
|
||||||
|
"abilities": [6]int{16, 14, 15, 10, 12, 8},
|
||||||
|
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
|
||||||
|
"supplies": 8,
|
||||||
|
"room": "2 / 4",
|
||||||
|
"map": fogMap(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoMapRenders drives the real template with a map present and asserts the
|
||||||
|
// SVG lands, the current room is marked, and the fog holds: the frontier boss
|
||||||
|
// is drawn as an unexplored room, never as a boss.
|
||||||
|
func TestWhoMapRenders(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||||
|
e.Detail = publicDetailWithMap(t)
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed roster = %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := getWho(t, s, "tok-josie", "")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("who = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{"The map", "map-svg", "map-node-current", "map-edge-locked"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("map render missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The elite room glyph shows (visited); the boss glyph must not (the boss is
|
||||||
|
// only reachable as an unrevealed frontier door).
|
||||||
|
if !strings.Contains(body, "◆") {
|
||||||
|
t.Error("visited elite room should show its glyph")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "♛") {
|
||||||
|
t.Error("fog leak: the boss glyph rendered for an unexplored frontier room")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user