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
209 lines
11 KiB
HTML
209 lines
11 KiB
HTML
{{define "title"}}{{if .Siege.Active}}The Siege — {{.Siege.BossName}}{{else}}The Siege{{end}} — {{.SiteTitle}}{{end}}
|
|
|
|
{{/* One row of the defender board. Same shape whether they fought today or are
|
|
still to go; the column they're in is the message. An opted-out defender
|
|
carries no token, so they get no link — their damage still counts and still
|
|
holds its rank, they just aren't named. */}}
|
|
{{define "defenderrow"}}
|
|
<li class="flex items-baseline gap-3 py-1.5 border-b border-[color:var(--ink)]/5 last:border-0">
|
|
<span class="flex-1 min-w-0">
|
|
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure transition truncate">{{.Name}}</a>
|
|
{{else}}<span class="font-semibold text-[color:var(--ink)]/55 italic truncate">{{.Name}}</span>{{end}}
|
|
{{if .Level}}<span class="text-xs text-[color:var(--ink)]/40 ml-1.5">lv {{.Level}}</span>{{end}}
|
|
</span>
|
|
{{if .Fights}}
|
|
<span class="text-xs text-[color:var(--ink)]/50 shrink-0 tabular-nums">{{.Damage}} dmg · {{.Fights}} bout{{if ne .Fights 1}}s{{end}}</span>
|
|
{{else}}
|
|
<span class="text-xs text-[color:var(--ink)]/35 shrink-0">not yet in it</span>
|
|
{{end}}
|
|
</li>
|
|
{{end}}
|
|
|
|
{{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}}">
|
|
{{/* 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}}">
|
|
<div class="absolute -top-8 -right-4 text-[12rem] opacity-20 select-none" aria-hidden="true">🏰</div>
|
|
<div class="relative">
|
|
<p class="text-sm uppercase tracking-[0.2em] opacity-80">🏰 The Siege · Tier {{.Siege.Tier}}</p>
|
|
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Siege.BossName}}</h1>
|
|
<p class="mt-2 opacity-90">Camped outside town. One bout each, every day, until it falls or the window closes.</p>
|
|
|
|
<!-- The bar. This is the page. -->
|
|
<div class="mt-6 siege-track" role="progressbar" aria-label="Siege boss health"
|
|
aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{.Siege.HPPercent}}" id="siege-bar-track">
|
|
<div class="siege-fill" id="siege-bar" style="width: {{.Siege.HPPercent}}%"></div>
|
|
</div>
|
|
<div class="mt-2 flex items-baseline justify-between text-sm">
|
|
<span class="font-semibold tabular-nums"><span id="siege-hp">{{.Siege.HPCurrent}}</span> / <span id="siege-hpmax">{{.Siege.HPMax}}</span> HP</span>
|
|
<span class="opacity-80 tabular-nums"><span id="siege-pct">{{.Siege.HPPercent}}</span>% standing</span>
|
|
</div>
|
|
|
|
<div class="mt-5 flex flex-wrap gap-x-6 gap-y-2 text-xs uppercase tracking-wider opacity-85">
|
|
<span>Time left · <span class="font-semibold normal-case tracking-normal" id="siege-countdown">—</span></span>
|
|
<span>Bouts today · <span class="font-semibold tabular-nums" id="siege-bouts">{{.Siege.BoutsToday}}</span></span>
|
|
<span>Mustered · <span class="font-semibold tabular-nums" id="siege-mustered">{{.Siege.Mustered}}</span></span>
|
|
</div>
|
|
|
|
{{if .Siege.Stale}}
|
|
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
|
|
We've lost the wire to the field — this is where the pool stood {{.Siege.LastSeenAgo}}. Treat the number as history until it comes back.
|
|
</p>
|
|
{{end}}
|
|
</div>
|
|
</header>
|
|
|
|
<!-- How to actually join in. The bout is a Matrix command today; saying so
|
|
plainly beats a page that shows a fight nobody can tell how to enter. -->
|
|
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 shadow-pete">
|
|
<p class="text-sm text-[color:var(--ink)]/75">
|
|
<span class="font-semibold text-theme-adventure">Taking your bout:</span>
|
|
say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono text-xs">!adventure siege fight</code>
|
|
to me in Matrix. One a day, each. Damage counts whether you win the fight or not — turning up is the mechanic.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- The muster. Two columns, and the right-hand one is the point: a bout not
|
|
taken today is damage the pool never sees. -->
|
|
<section class="mt-6 grid gap-6 sm:grid-cols-2">
|
|
<div class="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-3">
|
|
<h2 class="font-display text-xl font-bold">In it today</h2>
|
|
<span class="siege-chip siege-chip-fought">{{len .Siege.Fought}}</span>
|
|
</div>
|
|
{{if .Siege.Fought}}
|
|
<ul class="text-sm">{{range .Siege.Fought}}{{template "defenderrow" .}}{{end}}</ul>
|
|
{{else}}
|
|
<p class="text-sm text-[color:var(--ink)]/50">Nobody's swung at it yet today. The pool doesn't move on its own.</p>
|
|
{{end}}
|
|
</div>
|
|
|
|
<div class="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-3">
|
|
<h2 class="font-display text-xl font-bold">Bout still going spare</h2>
|
|
<span class="siege-chip siege-chip-waiting">{{len .Siege.Waiting}}</span>
|
|
</div>
|
|
{{if .Siege.Waiting}}
|
|
<ul class="text-sm">{{range .Siege.Waiting}}{{template "defenderrow" .}}{{end}}</ul>
|
|
<p class="mt-3 text-xs text-[color:var(--ink)]/45">Each of these is a free hit the town hasn't taken.</p>
|
|
{{else}}
|
|
<p class="text-sm text-[color:var(--ink)]/50">Everyone's been out. Good turnout.</p>
|
|
{{end}}
|
|
</div>
|
|
</section>
|
|
|
|
{{else}}
|
|
<header class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
|
<div class="absolute -top-8 -right-4 text-[12rem] opacity-10 select-none" aria-hidden="true">🏰</div>
|
|
<div class="relative">
|
|
<p class="text-sm uppercase tracking-[0.2em] text-[color:var(--ink)]/50">🏰 The Siege</p>
|
|
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Nothing's camped outside town.</h1>
|
|
<p class="mt-3 text-[color:var(--ink)]/70">
|
|
{{if .Siege.Known}}
|
|
Quiet month so far. One comes for the town every month — a named thing with a shared health pool, and everybody gets a swing a day at it.
|
|
{{else}}
|
|
I haven't heard from the field about a Siege yet. When one turns up, the bar lives here.
|
|
{{end}}
|
|
</p>
|
|
</div>
|
|
</header>
|
|
{{end}}
|
|
|
|
{{if .Siege.History}}
|
|
<!-- The history is what makes the live bar mean something. A won siege ends
|
|
at zero and draws as an empty track; a lost one shows exactly how much
|
|
was left standing when the window shut. -->
|
|
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
|
<h2 class="font-display text-xl font-bold mb-4">Sieges past</h2>
|
|
<ul class="space-y-4">
|
|
{{range .Siege.History}}
|
|
<li>
|
|
<div class="flex items-baseline justify-between gap-3">
|
|
<span class="font-semibold flex-1 min-w-0 truncate">{{.BossName}} <span class="text-xs font-normal text-[color:var(--ink)]/40">T{{.Tier}}</span></span>
|
|
<span class="siege-chip {{if .Won}}siege-chip-held{{else}}siege-chip-fell{{end}} shrink-0">{{if .Won}}town held{{else}}broke through{{end}}</span>
|
|
</div>
|
|
<div class="mt-1.5 siege-track siege-track-sm">
|
|
<div class="siege-fill {{if .Won}}siege-fill-spent{{end}}" style="width: {{.HPPercent}}%"></div>
|
|
</div>
|
|
<div class="mt-1.5 flex flex-wrap items-baseline justify-between gap-x-3 text-xs text-[color:var(--ink)]/50">
|
|
<span>
|
|
{{if .Won}}Felled by {{.Defenders}} defender{{if ne .Defenders 1}}s{{end}}{{else}}{{.HPRemaining}} HP still standing when the window shut{{end}}{{if .MVP}} · most bouts: <span class="font-semibold text-[color:var(--ink)]/70">{{.MVP}}</span>{{if .MVPFights}} ({{.MVPFights}}){{end}}{{end}}
|
|
</span>
|
|
{{if .When}}<span class="shrink-0">{{.When}}</span>{{end}}
|
|
</div>
|
|
</li>
|
|
{{end}}
|
|
</ul>
|
|
</section>
|
|
{{end}}
|
|
</article>
|
|
|
|
<script>
|
|
// The war room is state, like the board — an open tab should stay honest without
|
|
// a reload. Two moving parts:
|
|
//
|
|
// 1. The countdown, which ticks locally every second off the pushed ends_at.
|
|
// No network for that; the deadline is fixed the moment the Siege spawns.
|
|
// 2. The pool, re-polled every 15s. The bar's width is set here and CSS does
|
|
// the animating (a width transition), so a poll that lands a lower pool
|
|
// *slides* rather than snapping. That slide is the whole reason this page
|
|
// exists: it is what turns a number into the town chipping something down.
|
|
(function () {
|
|
var root = document.getElementById('siege');
|
|
if (!root) return;
|
|
var active = root.getAttribute('data-active') === '1';
|
|
var endsAt = parseInt(root.getAttribute('data-ends-at') || '0', 10);
|
|
|
|
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
|
|
|
|
function tickCountdown() {
|
|
var el = document.getElementById('siege-countdown');
|
|
if (!el) return;
|
|
if (!endsAt) { el.textContent = '—'; return; }
|
|
var left = endsAt - Math.floor(Date.now() / 1000);
|
|
if (left <= 0) { el.textContent = 'window closed'; return; }
|
|
var h = Math.floor(left / 3600), m = Math.floor((left % 3600) / 60), s = left % 60;
|
|
el.textContent = h > 0 ? (h + 'h ' + m + 'm') : (m + 'm ' + s + 's');
|
|
}
|
|
|
|
if (!active) return; // nothing camped: no countdown, no poll, nothing to move
|
|
tickCountdown();
|
|
setInterval(tickCountdown, 1000);
|
|
|
|
var pollTimer = null;
|
|
function refresh() {
|
|
fetch('/api/adventure/siege', { headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (data) {
|
|
if (!data) return;
|
|
if (!data.active) {
|
|
// It resolved while we were watching. The page we're holding is now a
|
|
// different page — reload rather than fake an ending, so the result and
|
|
// the fresh history come from the server that knows them.
|
|
if (pollTimer) clearInterval(pollTimer);
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
var bar = document.getElementById('siege-bar');
|
|
if (bar) bar.style.width = data.hp_percent + '%';
|
|
var track = document.getElementById('siege-bar-track');
|
|
if (track) track.setAttribute('aria-valuenow', data.hp_percent);
|
|
txt('siege-hp', data.hp_current);
|
|
txt('siege-hpmax', data.hp_max);
|
|
txt('siege-pct', data.hp_percent);
|
|
txt('siege-bouts', data.bouts_today);
|
|
txt('siege-mustered', data.mustered);
|
|
if (data.ends_at) endsAt = data.ends_at;
|
|
})
|
|
.catch(function () { /* transient — the next tick will do */ });
|
|
}
|
|
pollTimer = setInterval(refresh, 15000);
|
|
})();
|
|
</script>
|
|
{{end}}
|