adventure: give the realm a map, a board and a history

Everything Pete published so far was either the present moment (the roster,
the Siege bar) or one thing that happened (a dispatch, a run report). None of
it said what this place IS — how many zones there are, which are harder, or
whether anybody has ever actually beaten them.

Three pages off one snapshot, because they are one question and every number
on all three comes off the same scan of the same run history upstream:

  /adventure/realm      the world, in difficulty order, with who is inside it
  /adventure/standings  the board, plus Pete's own duel record
  /adventure/firsts     the hall of firsts, as a dated history

The map is deliberately not who_map.go's layout engine. That lays out a graph,
and the realm has no edges — zones aren't connected, you pick one and go.
Forcing a graph onto a set would imply a topology the game doesn't have. What
it has is an order, so tier bands are what get drawn.

A zone nobody has ever cleared looks different rather than saying so in small
text, and that styling keys off the clear count, never off whether a name is
attached — otherwise an opt-out would silently redraw a conquered place as one
nobody has come out of.

The per-kind first dot goes through a custom property instead of a
.firsts-entry-zone::before rule: input.css is in Tailwind's content glob, so a
hand-written class survives the purge only if its literal name can be lifted
out of the file, and a name glued to ::before cannot. It failed silently.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 17:54:49 -07:00
parent b4a276da36
commit 1dfd3ac9fb
12 changed files with 1745 additions and 7 deletions
+21
View File
@@ -0,0 +1,21 @@
{{/* The realm nav, shared by the map, the board, the hall of firsts and the war
room, so the four read as one place rather than four orphans hanging off the
dispatch feed.
It lives in its own partial rather than in realm.html because each page gets
its own parsed template set (see server.go): a {{define}} in one page's file
is invisible to the others, and the only way to share a block is to list it
as a shared file. Passed the whole pageData, so it can mark the current tab
off .Path. */}}
{{define "realmnav"}}
<nav class="mb-5 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<a href="/adventure" class="inline-flex items-center gap-1.5 font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<span aria-hidden="true"></span> All dispatches
</a>
<span class="text-[color:var(--ink)]/20" aria-hidden="true">·</span>
<a href="/adventure/realm" class="realm-tab{{if eq .Path "/adventure/realm"}} realm-tab-on{{end}}">The map</a>
<a href="/adventure/standings" class="realm-tab{{if eq .Path "/adventure/standings"}} realm-tab-on{{end}}">The board</a>
<a href="/adventure/firsts" class="realm-tab{{if eq .Path "/adventure/firsts"}} realm-tab-on{{end}}">Hall of firsts</a>
<a href="/adventure/siege" class="realm-tab{{if eq .Path "/adventure/siege"}} realm-tab-on{{end}}">The Siege</a>
</nav>
{{end}}
+76
View File
@@ -0,0 +1,76 @@
{{define "title"}}Hall of firsts — {{.SiteTitle}}{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto">
{{template "realmnav" .}}
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-10 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">📜</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">📜 Hall of firsts</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Everything that has only ever happened once.</h1>
<p class="mt-3 opacity-90 max-w-2xl">
The first time anyone walked out of a place alive. The first time a thing
came out of the ground. Each of these happened exactly once in the history
of the realm and can't happen again.
</p>
{{if .Firsts.Total}}
<div class="mt-6 flex flex-wrap gap-x-8 gap-y-3 text-xs uppercase tracking-wider opacity-85">
<span>Entries · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Firsts.Total}}</span></span>
<span>Places opened · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Firsts.Zones}}</span></span>
{{if .Firsts.Others}}<span>Things found · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Firsts.Others}}</span></span>{{end}}
</div>
{{end}}
{{if and .Firsts.Known .Firsts.Stale}}
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
Ledger as of {{.Firsts.LastSeenAgo}}. Nothing in a history book goes stale exactly, but a new entry might not be here yet.
</p>
{{end}}
</div>
</header>
{{if .Firsts.Years}}
{{range .Firsts.Years}}
<section class="mt-8">
<h2 class="font-display text-2xl font-bold mb-4 tabular-nums">
{{if .Year}}{{.Year}}{{else}}Before the records{{end}}
</h2>
<ol class="firsts-ledger">
{{range .Firsts}}
<li class="firsts-entry firsts-entry-{{.Kind}}">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/40 font-semibold">{{.Label}}</span>
<span class="font-display font-bold text-base">{{.Display}}</span>
{{if .Tier}}<span class="text-[11px] text-[color:var(--ink)]/35 tabular-nums">T{{.Tier}}</span>{{end}}
</div>
<p class="mt-0.5 text-sm text-[color:var(--ink)]/60">
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold text-[color:var(--ink)]/80 hover:text-theme-adventure transition">{{.Holder}}</a>
{{else if .Holder}}<span class="font-semibold text-[color:var(--ink)]/80">{{.Holder}}</span>
{{else}}<span class="italic text-[color:var(--ink)]/40">nobody left who'll admit to it</span>{{end}}
{{if .When}}<span class="text-[color:var(--ink)]/35"> · {{.When}}</span>{{end}}
</p>
</li>
{{end}}
</ol>
</section>
{{end}}
<p class="mt-8 text-xs text-[color:var(--ink)]/40 text-center max-w-xl mx-auto">
An entry with no name is one where the record has outlived the record-holder —
a thing found and long since given away, or somebody who'd rather I didn't say.
The claim still stands.
</p>
{{else}}
<div class="mt-6 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">
{{if .Firsts.Known}}
Nothing's happened for the first time yet. Everything is about to be a first.
{{else}}
Haven't had the ledger through from the field yet.
{{end}}
</p>
</div>
{{end}}
</article>
{{end}}
+113
View File
@@ -0,0 +1,113 @@
{{define "title"}}The realm — {{.SiteTitle}}{{end}}
{{/* One zone. The whole design problem of this page is in this block: a zone
nobody has ever beaten has to LOOK different, not just say so in small
text, because "nobody has ever done this" is the single most interesting
fact the realm has to offer. */}}
{{define "realmzone"}}
<li class="realm-zone{{if .Unbeaten}} realm-zone-unbeaten{{end}}{{if .Busy}} realm-zone-busy{{end}}">
<div class="flex items-baseline justify-between gap-3">
<h3 class="font-display font-bold text-base flex-1 min-w-0 truncate">{{.Display}}</h3>
{{if .Levels}}<span class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/40 shrink-0">{{.Levels}}</span>{{end}}
</div>
{{if .Atmosphere}}
<p class="mt-1 text-xs text-[color:var(--ink)]/55 leading-relaxed">{{.Atmosphere}}</p>
{{end}}
<div class="mt-2.5 text-xs">
{{if .Unbeaten}}
<p class="realm-unbeaten-line">Nobody has ever come back out of it.</p>
{{else}}
<p class="text-[color:var(--ink)]/60">
{{/* FirstClearBy empty with Clears > 0 is the anonymised case: the zone
has been beaten, the clearer opted out. It must not read as unbeaten. */}}
First through:
{{if .FirstClearToken}}<a href="/adventure/who/{{.FirstClearToken}}" class="font-semibold hover:text-theme-adventure transition">{{.FirstClearBy}}</a>
{{else if .FirstClearBy}}<span class="font-semibold">{{.FirstClearBy}}</span>
{{else}}<span class="italic text-[color:var(--ink)]/45">somebody who'd rather not say</span>{{end}}
{{if .FirstWhen}}<span class="text-[color:var(--ink)]/40"> · {{.FirstWhen}}</span>{{end}}
</p>
<p class="mt-0.5 text-[color:var(--ink)]/45 tabular-nums">
{{.Clears}} clear{{if ne .Clears 1}}s{{end}} by {{.Clearers}} adventurer{{if ne .Clearers 1}}s{{end}}
</p>
{{end}}
</div>
{{if .Occupants}}
<div class="mt-2.5 pt-2.5 border-t border-[color:var(--ink)]/10">
<p class="text-[11px] uppercase tracking-wider text-theme-adventure font-semibold">In there now</p>
<p class="mt-1 text-xs">
{{range $i, $o := .Occupants}}{{if $i}}<span class="text-[color:var(--ink)]/30">, </span>{{end}}<!--
-->{{if $o.Token}}<a href="/adventure/who/{{$o.Token}}" class="font-semibold hover:text-theme-adventure transition">{{$o.Name}}</a>{{else}}<span class="font-semibold">{{$o.Name}}</span>{{end}}<!--
-->{{if $o.Day}}<span class="text-[color:var(--ink)]/40"> (day {{$o.Day}})</span>{{end}}{{end}}
</p>
</div>
{{end}}
</li>
{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-5xl mx-auto">
{{template "realmnav" .}}
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-10 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">🗺️</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">🗺️ The realm</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Everywhere you can go, and what came back.</h1>
<p class="mt-3 opacity-90 max-w-2xl">
Every place in the world, in the order it gets harder. Some of these have been
walked a hundred times. Some of them have never been walked at all.
</p>
{{if .Realm.Known}}
<div class="mt-6 flex flex-wrap gap-x-8 gap-y-3 text-xs uppercase tracking-wider opacity-85">
<span>Places · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.ZoneCount}}</span></span>
<span>Beaten · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.ClearedZones}}</span></span>
<span>Never beaten · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.Unbeaten}}</span></span>
<span>Out there now · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.OutThere}}</span></span>
</div>
{{end}}
{{if and .Realm.Known .Realm.Stale}}
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
The wire's been quiet — this is the realm as of {{.Realm.LastSeenAgo}}. The places
haven't moved, but who's out in them might have.
</p>
{{end}}
</div>
</header>
{{if not .Realm.Known}}
<div class="mt-6 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">
I haven't had the survey through from the field yet. When it lands, the whole
world lives on this page.
</p>
</div>
{{end}}
{{range .Realm.Tiers}}
<section class="mt-8{{if .Postgame}} realm-band-postgame rounded-3xl p-5 sm:p-6{{end}}">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 mb-1">
<h2 class="font-display text-xl sm:text-2xl font-bold">{{.Label}}</h2>
<span class="text-xs text-[color:var(--ink)]/45 tabular-nums shrink-0">
{{.Cleared}} of {{len .Zones}} beaten
</span>
</div>
{{if .Blurb}}<p class="text-sm text-[color:var(--ink)]/55 mb-4 max-w-2xl">{{.Blurb}}</p>{{end}}
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{{range .Zones}}{{template "realmzone" .}}{{end}}
</ul>
</section>
{{end}}
{{if .Realm.Known}}
<p class="mt-8 text-xs text-[color:var(--ink)]/40 text-center">
Say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono">!expedition start</code>
to me in Matrix to pick one and go.
</p>
{{end}}
</article>
{{end}}
+3 -5
View File
@@ -22,11 +22,9 @@
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="siege"
data-active="{{if .Siege.Active}}1{{else}}0{{end}}" data-ends-at="{{.Siege.EndsAt}}">
<nav class="mb-4">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<span aria-hidden="true"></span> All dispatches
</a>
</nav>
{{/* The war room joined the realm nav when the realm pages landed: it is one
of the four standing pages about this place, not a one-off. */}}
{{template "realmnav" .}}
{{if .Siege.Active}}
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden {{if not .Siege.Stale}}siege-live{{end}}">
+107
View File
@@ -0,0 +1,107 @@
{{define "title"}}The board — {{.SiteTitle}}{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-4xl mx-auto">
{{template "realmnav" .}}
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-10 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">🏆</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">🏆 The board</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Who's actually been getting on with it.</h1>
<p class="mt-3 opacity-90 max-w-2xl">
Ranked by how deep anyone has got, then by how much of the realm they've
put behind them. Lifetime totals — nothing here decays, and nothing here
moves while you're not playing.
</p>
{{if and .Standings.Known .Standings.Stale}}
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
Last count came in {{.Standings.LastSeenAgo}}. Nothing on this board moves fast, but it isn't live either.
</p>
{{end}}
</div>
</header>
{{if .Standings.Rows}}
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/45 border-b-2 border-[color:var(--ink)]/10">
<th class="text-left font-semibold px-4 py-3 w-10">#</th>
<th class="text-left font-semibold px-2 py-3">Adventurer</th>
<th class="text-right font-semibold px-2 py-3" title="The deepest tier they have actually beaten a boss in">Deepest</th>
<th class="text-right font-semibold px-2 py-3" title="Distinct zones cleared">Zones</th>
<th class="text-right font-semibold px-2 py-3" title="Total successful clears, repeats included">Clears</th>
<th class="text-right font-semibold px-2 py-3" title="Things nobody in the realm had ever done before">Firsts</th>
<th class="text-right font-semibold px-2 py-3" title="Total damage dealt to every Siege boss, all time">Siege</th>
<th class="text-right font-semibold px-4 py-3" title="Deaths I've reported">Deaths</th>
</tr>
</thead>
<tbody>
{{range .Standings.Rows}}
<tr class="border-b border-[color:var(--ink)]/5 last:border-0 hover:bg-[color:var(--ink)]/[0.03] transition">
<td class="px-4 py-3 tabular-nums text-[color:var(--ink)]/35 font-semibold">{{.Rank}}</td>
<td class="px-2 py-3 min-w-0">
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure transition">{{.Name}}</a>
{{else}}<span class="font-semibold">{{.Name}}</span>{{end}}
<span class="block text-xs text-[color:var(--ink)]/40">
lv {{.Level}}{{if .ClassRace}} · {{.ClassRace}}{{end}}
</span>
</td>
<td class="px-2 py-3 text-right tabular-nums">
{{if .DeepestTier}}<span class="standings-tier standings-tier-{{.DeepestTier}}">T{{.DeepestTier}}</span>
{{else}}<span class="text-[color:var(--ink)]/25"></span>{{end}}
</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .Zones}} text-[color:var(--ink)]/25{{end}}">{{if .Zones}}{{.Zones}}{{else}}—{{end}}</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .Clears}} text-[color:var(--ink)]/25{{end}}">{{if .Clears}}{{.Clears}}{{else}}—{{end}}</td>
<td class="px-2 py-3 text-right tabular-nums">
{{if .Firsts}}<span class="standings-firsts">{{.Firsts}}</span>{{else}}<span class="text-[color:var(--ink)]/25"></span>{{end}}
</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .SiegeDamage}} text-[color:var(--ink)]/25{{end}}">
{{if .SiegeDamage}}{{.SiegeDamage}}{{else}}—{{end}}
</td>
<td class="px-4 py-3 text-right tabular-nums{{if not .Deaths}} text-[color:var(--ink)]/25{{end}}">{{if .Deaths}}{{.Deaths}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<p class="mt-3 text-xs text-[color:var(--ink)]/40 px-1">
Deepest is the hardest tier they've actually put a boss down in, not the hardest
one they've walked into. Deaths is my own count, off the dispatches I've filed.
</p>
{{else}}
<div class="mt-6 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">
{{if .Standings.Known}}
Nobody on the board yet. Make a character and it fills up.
{{else}}
Haven't had the count through from the field yet.
{{end}}
</p>
</div>
{{end}}
{{/* Pete keeps score on himself. He can be hired onto an expedition and he
duels; a paper that ranks everybody else and quietly leaves itself off the
table is doing something slightly dishonest. */}}
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold">And my own record, since you asked</h2>
{{if .Standings.PeteFought}}
<p class="mt-2 text-sm text-[color:var(--ink)]/70">
<span class="font-semibold text-theme-adventure tabular-nums text-lg">{{.Standings.PeteWins}}</span> won,
<span class="font-semibold tabular-nums text-lg">{{.Standings.PeteLosses}}</span> lost.
I file those myself, which you're welcome to hold against me.
</p>
{{else}}
<p class="mt-2 text-sm text-[color:var(--ink)]/60">
No bouts yet. I'll report them when there are, wins and the other kind.
</p>
{{end}}
</section>
</article>
{{end}}