The Siege is the one mechanic where the whole town works on a single object, and it existed exclusively in Matrix — so anybody not in the room at the time never knew it happened. A communal event nobody can see is a communal event that fails. gogobee now pushes the war room on the roster tick and Pete replaces its copy, the same snapshot contract the board has and for the same reason: the shared pool is state, not history, and a retried snapshot would be a lie about how much HP is left. /adventure/siege draws it. The load-bearing detail is one CSS line — the health bar has a width transition, so a poll that lands a lower pool slides the bar down instead of snapping it. That is the difference between watching the town chip a boss down and reading a report about it. Alongside: a countdown to the window's close, past sieges with the bar each one ended on, and the muster split into who took today's bout and who still has one going spare — the mechanic is one fight per person per day, so that second column is a hit the town hasn't taken yet. The opt-out rule here deliberately differs from the board's. The board omits an opted-out player outright, because class + level + zone re-identifies them. A Siege contributor is anonymised instead: their damage is part of what the town did to the boss, and deleting it would understate the shared effort and stop the totals adding up. They keep their rank, lose their name, and carry no token — so there is no link back to a page that names them. An opted-out player who never fought is still omitted; there is nothing to account for. siege_start / siege_win / siege_loss are wired on the gogobee side; the templates for all three have been sitting unused in renderAdventure since the section shipped.
335 lines
17 KiB
HTML
335 lines
17 KiB
HTML
{{define "title"}}{{.Channel.Title}} — {{.SiteTitle}}{{end}}
|
||
|
||
{{define "main"}}
|
||
<section class="mt-2 mb-8">
|
||
<div class="rounded-3xl bg-theme-{{.Channel.Theme}} text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Channel.Emoji}}</div>
|
||
<div class="relative">
|
||
<p class="text-sm uppercase tracking-[0.2em] opacity-80">section</p>
|
||
<h1 class="font-display text-4xl sm:text-5xl font-bold mt-1">{{.Channel.Title}}</h1>
|
||
<p class="mt-2 max-w-xl text-white/85">{{.Channel.Blurb}}</p>
|
||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">{{.Total}} stories archived</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{{if .ShowRoster}}
|
||
{{/* The Siege strip. Above the board on purpose: the board is where everyone is,
|
||
the Siege is where everyone should be. When one is camped this is a live bar
|
||
and a door into the war room; when none is, it stays as the quiet doorway to
|
||
the history, which is the other half of making the next one feel like it
|
||
counts. */}}
|
||
{{if .Siege.Active}}
|
||
<a href="/adventure/siege" class="block mb-6 rounded-3xl bg-theme-adventure text-white p-5 sm:p-6 shadow-pete relative overflow-hidden group {{if not .Siege.Stale}}siege-live{{end}}">
|
||
<div class="absolute -top-6 -right-4 text-[8rem] opacity-20 select-none" aria-hidden="true">🏰</div>
|
||
<div class="relative">
|
||
<p class="text-xs uppercase tracking-[0.2em] opacity-80">🏰 The Siege · now</p>
|
||
<h2 class="font-display text-2xl font-bold mt-1 group-hover:underline">{{.Siege.BossName}} is at the gates</h2>
|
||
<div class="mt-3 siege-track">
|
||
<div class="siege-fill" style="width: {{.Siege.HPPercent}}%"></div>
|
||
</div>
|
||
<p class="mt-2 text-sm opacity-90 tabular-nums">{{.Siege.HPCurrent}} / {{.Siege.HPMax}} HP · {{.Siege.HPPercent}}% standing · {{len .Siege.Waiting}} bout{{if ne (len .Siege.Waiting) 1}}s{{end}} still going spare</p>
|
||
</div>
|
||
</a>
|
||
{{else if .Siege.History}}
|
||
<a href="/adventure/siege" class="flex items-center gap-3 mb-6 rounded-2xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-5 py-3 shadow-pete hover:border-theme-adventure/40 transition">
|
||
<span class="text-lg" aria-hidden="true">🏰</span>
|
||
<span class="text-sm text-[color:var(--ink)]/70">Nothing camped outside town right now — <span class="font-semibold text-theme-adventure">the sieges we've fought</span></span>
|
||
</a>
|
||
{{end}}
|
||
|
||
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
|
||
<div class="flex items-baseline justify-between mb-3">
|
||
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
|
||
<p class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50" id="roster-status">
|
||
{{if .RosterStale}}last known — the wire's gone quiet{{else}}live{{end}}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
||
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
||
{{range .Roster}}
|
||
<li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}">
|
||
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||
<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
|
||
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
||
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
||
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||
</span>
|
||
{{if and $.User .OnRun}}
|
||
<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
|
||
{{end}}
|
||
</li>
|
||
{{else}}
|
||
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
|
||
{{end}}
|
||
</ul>
|
||
</div>
|
||
|
||
{{if not .User}}
|
||
<p class="mt-3 text-xs text-[color:var(--ink)]/50">
|
||
<a href="/auth/login?next={{.Path}}" class="underline hover:text-red-500">Sign in</a> to send something unpleasant their way.
|
||
</p>
|
||
{{end}}
|
||
|
||
{{if .User}}
|
||
<!-- Your open contracts. Rendered from filed facts, never from live game state:
|
||
Pete only knows what gogobee told it happened. -->
|
||
<div id="mischief-orders" class="mt-4 hidden">
|
||
<h3 class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50 mb-2">Trouble you've paid for</h3>
|
||
<ul id="mischief-orders-list" class="space-y-2 text-sm"></ul>
|
||
</div>
|
||
|
||
<!-- The buy dialog. Prices are gogobee's, pulled live from /api/mischief/catalog;
|
||
nothing here asserts a number of its own. -->
|
||
<div id="mischief-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50 p-4">
|
||
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
|
||
<div class="flex items-center justify-between px-5 py-4 border-b border-[color:var(--ink)]/10">
|
||
<h3 class="font-display text-xl font-bold">Send trouble to <span id="mischief-target">—</span></h3>
|
||
<button type="button" id="mischief-close" class="text-2xl leading-none text-[color:var(--ink)]/50 hover:text-[color:var(--ink)]" aria-label="close">×</button>
|
||
</div>
|
||
<div class="px-5 py-4 space-y-3">
|
||
<p class="text-sm text-[color:var(--ink)]/60">Pick how bad. They won't know who sent it — unless you sign it, or they live to read the receipt.</p>
|
||
<div id="mischief-tiers" class="space-y-2"></div>
|
||
<label class="flex items-center gap-2 text-sm">
|
||
<input type="checkbox" id="mischief-signed" class="rounded border-[color:var(--ink)]/30">
|
||
<span>Sign my name to it <span class="text-[color:var(--ink)]/45">(+25%, and they'll know)</span></span>
|
||
</label>
|
||
<p id="mischief-balance" class="text-xs text-[color:var(--ink)]/50"></p>
|
||
<p id="mischief-result" class="text-sm hidden"></p>
|
||
</div>
|
||
<div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-[color:var(--ink)]/10">
|
||
<button type="button" id="mischief-cancel" class="rounded-full px-4 py-2 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">Never mind</button>
|
||
<button type="button" id="mischief-confirm" class="rounded-full bg-red-500 text-white px-5 py-2 text-sm font-semibold shadow-pete hover:-translate-y-0.5 transition disabled:opacity-40 disabled:translate-y-0" disabled>Put the word out</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</section>
|
||
|
||
<script>
|
||
// The board is state, not a story: an open tab should keep telling the truth
|
||
// without a reload. Cheap poll — a handful of rows, no auth, no cache.
|
||
(function () {
|
||
var list = document.getElementById('roster-list');
|
||
var status = document.getElementById('roster-status');
|
||
var card = document.getElementById('roster-card');
|
||
if (!list) return;
|
||
|
||
var signedIn = {{if .User}}true{{else}}false{{end}};
|
||
|
||
function esc(s) {
|
||
var d = document.createElement('div');
|
||
d.textContent = s == null ? '' : String(s);
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function row(a) {
|
||
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
||
var button = (signedIn && a.OnRun)
|
||
? '<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
|
||
: '';
|
||
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
|
||
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||
'<a href="/adventure/who/' + esc(a.Token) + '" class="font-semibold hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
|
||
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
||
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
||
esc(a.Where) + idle + '</span>' + button + '</li>';
|
||
}
|
||
|
||
function refresh() {
|
||
fetch('/api/roster', { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (data) {
|
||
if (!data) return;
|
||
var rows = data.adventurers || [];
|
||
list.innerHTML = rows.length
|
||
? rows.map(row).join('')
|
||
: '<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody\'s out at the moment. Quiet week in the realm.</li>';
|
||
status.textContent = data.stale ? "last known — the wire's gone quiet" : 'live';
|
||
card.classList.toggle('opacity-60', !!data.stale);
|
||
})
|
||
.catch(function () { /* transient — the next tick will do */ });
|
||
}
|
||
|
||
setInterval(refresh, 60000);
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (!document.hidden) refresh();
|
||
});
|
||
|
||
if (signedIn) initMischief(list, esc);
|
||
})();
|
||
|
||
// The storefront: pick a mark, pick how bad, pay. The only thing this code is
|
||
// authoritative about is the buyer's intent — every price, every yes/no, and the
|
||
// buyer's actual balance are gogobee's, reached through the API.
|
||
function initMischief(list, esc) {
|
||
var modal = document.getElementById('mischief-modal');
|
||
if (!modal) return;
|
||
var targetEl = document.getElementById('mischief-target');
|
||
var tiersEl = document.getElementById('mischief-tiers');
|
||
var signedEl = document.getElementById('mischief-signed');
|
||
var balanceEl = document.getElementById('mischief-balance');
|
||
var resultEl = document.getElementById('mischief-result');
|
||
var confirmEl = document.getElementById('mischief-confirm');
|
||
var ordersWrap = document.getElementById('mischief-orders');
|
||
var ordersList = document.getElementById('mischief-orders-list');
|
||
|
||
var catalog = null; // {tiers, euro, has_balance}
|
||
var chosen = null; // selected tier key
|
||
var current = null; // {token, name}
|
||
|
||
function euros(n) { return '€' + Number(n).toLocaleString(); }
|
||
|
||
function loadCatalog() {
|
||
return fetch('/api/mischief/catalog', { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (c) { catalog = c; return c; });
|
||
}
|
||
|
||
function drawTiers() {
|
||
if (!catalog || !catalog.tiers || !catalog.tiers.length) {
|
||
tiersEl.innerHTML = '<p class="text-sm text-[color:var(--ink)]/50">The catalogue is between snapshots. Try again in a moment.</p>';
|
||
return;
|
||
}
|
||
var signed = signedEl.checked;
|
||
var known = catalog.has_balance;
|
||
var euro = catalog.euro;
|
||
tiersEl.innerHTML = catalog.tiers.map(function (t) {
|
||
var price = signed ? t.signed_fee : t.fee;
|
||
var tooDear = known && euro < price;
|
||
return '<button type="button" class="mischief-tier w-full text-left rounded-2xl border-2 px-4 py-2 transition ' +
|
||
(chosen === t.key ? 'border-red-500 bg-red-500/10' : 'border-[color:var(--ink)]/10 hover:border-[color:var(--ink)]/25') +
|
||
(tooDear ? ' opacity-40 cursor-not-allowed' : '') + '" data-key="' + esc(t.key) + '"' + (tooDear ? ' disabled' : '') + '>' +
|
||
'<span class="flex items-baseline justify-between"><span class="font-semibold">' + esc(t.display) + '</span>' +
|
||
'<span class="font-semibold">' + euros(price) + '</span></span>' +
|
||
(t.blurb ? '<span class="block text-xs text-[color:var(--ink)]/55 mt-0.5">' + esc(t.blurb) + '</span>' : '') +
|
||
'</button>';
|
||
}).join('');
|
||
balanceEl.textContent = known ? 'You have about ' + euros(euro) + ' to spend.' : '';
|
||
Array.prototype.forEach.call(tiersEl.querySelectorAll('.mischief-tier'), function (b) {
|
||
b.addEventListener('click', function () {
|
||
chosen = b.getAttribute('data-key');
|
||
confirmEl.disabled = false;
|
||
drawTiers();
|
||
});
|
||
});
|
||
}
|
||
|
||
function open(token, name) {
|
||
current = { token: token, name: name };
|
||
chosen = null;
|
||
confirmEl.disabled = true;
|
||
resultEl.classList.add('hidden');
|
||
resultEl.textContent = '';
|
||
signedEl.checked = false;
|
||
targetEl.textContent = name;
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
(catalog ? Promise.resolve() : loadCatalog()).then(drawTiers);
|
||
}
|
||
|
||
function close() {
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('flex');
|
||
}
|
||
|
||
function badge(statusText) {
|
||
var map = {
|
||
pending: ['on its way', 'bg-amber-500/15 text-amber-700'],
|
||
placed: ['out for delivery', 'bg-red-500/15 text-red-600'],
|
||
bounced_funds: ["couldn't afford it", 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'],
|
||
bounced_ineligible: ['no longer a mark', 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60']
|
||
};
|
||
var m = map[statusText] || [statusText, 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'];
|
||
return '<span class="rounded-full px-2 py-0.5 text-xs font-semibold ' + m[1] + '">' + esc(m[0]) + '</span>';
|
||
}
|
||
|
||
function loadOrders() {
|
||
fetch('/api/mischief/orders', { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (orders) {
|
||
if (!orders || !orders.length) { ordersWrap.classList.add('hidden'); return; }
|
||
ordersWrap.classList.remove('hidden');
|
||
ordersList.innerHTML = orders.map(function (o) {
|
||
var detail = o.detail ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(o.detail) + '</span>' : '';
|
||
return '<li class="flex items-center gap-3">' +
|
||
'<span class="font-semibold">' + esc(o.target_name) + '</span>' +
|
||
'<span class="text-[color:var(--ink)]/55">' + esc(o.tier) + (o.signed ? ', signed' : '') + '</span>' +
|
||
'<span class="ml-auto">' + badge(o.status) + '</span>' + detail + '</li>';
|
||
}).join('');
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
|
||
list.addEventListener('click', function (e) {
|
||
var b = e.target.closest('.mischief-send');
|
||
if (!b) return;
|
||
open(b.getAttribute('data-token'), b.getAttribute('data-name'));
|
||
});
|
||
signedEl.addEventListener('change', drawTiers);
|
||
document.getElementById('mischief-close').addEventListener('click', close);
|
||
document.getElementById('mischief-cancel').addEventListener('click', close);
|
||
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
|
||
|
||
confirmEl.addEventListener('click', function () {
|
||
if (!current || !chosen) return;
|
||
confirmEl.disabled = true;
|
||
resultEl.classList.add('hidden');
|
||
fetch('/api/mischief/order', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ target_token: current.token, tier: chosen, signed: signedEl.checked })
|
||
})
|
||
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
||
.then(function (res) {
|
||
resultEl.classList.remove('hidden');
|
||
if (res.ok) {
|
||
resultEl.className = 'text-sm text-red-600 font-semibold';
|
||
resultEl.textContent = 'The word is out. Watch the board.';
|
||
catalog = null; // balance moved; refetch next open
|
||
loadOrders();
|
||
setTimeout(close, 1200);
|
||
} else {
|
||
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
|
||
resultEl.textContent = (res.body && res.body.error) || 'That didn\'t take. Try again.';
|
||
confirmEl.disabled = false;
|
||
}
|
||
})
|
||
.catch(function () {
|
||
resultEl.classList.remove('hidden');
|
||
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
|
||
resultEl.textContent = 'The line dropped. Try again.';
|
||
confirmEl.disabled = false;
|
||
});
|
||
});
|
||
|
||
loadOrders();
|
||
setInterval(loadOrders, 60000);
|
||
}
|
||
</script>
|
||
{{end}}
|
||
|
||
{{if .Stories}}
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||
{{range .Stories}}
|
||
{{template "card" dict "Story" . "Theme" $.Channel.Theme}}
|
||
{{end}}
|
||
</div>
|
||
|
||
<nav class="mt-10 flex items-center justify-between">
|
||
{{if .HasPrev}}
|
||
<a href="{{.PrevURL}}" class="rounded-full bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">← newer</a>
|
||
{{else}}<span></span>{{end}}
|
||
<span class="text-sm text-[color:var(--ink)]/60">page {{.Page}}</span>
|
||
{{if .HasNext}}
|
||
<a href="{{.NextURL}}" class="rounded-full bg-theme-{{.Channel.Theme}} text-white px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">older →</a>
|
||
{{else}}<span></span>{{end}}
|
||
</nav>
|
||
{{else}}
|
||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-10 text-center text-[color:var(--ink)]/60">
|
||
Pete hasn't catalogued anything for {{.Channel.Title}} yet.
|
||
</div>
|
||
{{end}}
|
||
{{end}}
|