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-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>
|
||||
{{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"}}
|
||||
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
|
||||
<nav class="mb-4">
|
||||
@@ -96,6 +111,28 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</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}}
|
||||
<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>
|
||||
|
||||
@@ -33,6 +33,30 @@ type whoDetail struct {
|
||||
Supplies int `json:"supplies"`
|
||||
ThreatLevel int `json:"threat_level"`
|
||||
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 {
|
||||
@@ -58,6 +82,7 @@ type whoPage struct {
|
||||
HasDetail bool
|
||||
Detail whoDetail
|
||||
Abilities []abilityRow
|
||||
MapView *mapView // laid-out dungeon map, nil when not on a run or no graph
|
||||
HasSelf bool
|
||||
Self storage.PlayerDetail
|
||||
// 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.Detail = d
|
||||
page.Abilities = abil
|
||||
page.MapView = buildMapView(d.Map)
|
||||
}
|
||||
|
||||
// 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