adventure: draw the dungeon as a fog-of-war map, not a room tally

This commit is contained in:
prosolis
2026-07-17 08:18:17 -07:00
parent dcd68ebdcd
commit e90deda498
6 changed files with 493 additions and 1 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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
View 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]
}
}
}

View 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")
}
}