Three verbs to match gogobee's: call off an expedition, turn back out of somebody else's party, send the pet sitter home. Which one this page offers is derived here rather than pushed — leadership is already legible in the party seats and the sitter's standing is already in the babysit offer, so nothing new crosses the wire. Two things running it turned up that no test would have. An applied abandon left "Pull out of the run" sitting under a verdict saying the expedition was over, so an applied verb now also hides the other verbs it just made untrue. And a party member was being offered that same button in the first place, beside the one that actually works — Pete knows from the seat it just read that gogobee would refuse it, so it is withheld. Also: heal the Matrix handle onto push rows stored before the column existed, on its own endpoint rather than through the subscribe upsert, which resets both watermarks and would have silenced the digest for anybody who reads the site regularly. And stack the board row below sm — four flex columns that wrapped to six lines on a phone, pre-existing.
958 lines
52 KiB
HTML
958 lines
52 KiB
HTML
{{define "title"}}{{.Mark.Name}} — {{.SiteTitle}}{{end}}
|
||
|
||
{{/* One item, wherever it's sitting. Worn, backpack, and vault all show the
|
||
same facts; only the panel around them differs. The description and the
|
||
effect line come from gogobee already resolved — Pete renders them, it does
|
||
not compute them. "inert" is the honest word for a worn item that wants a
|
||
bond and hasn't got one: it's on you and doing nothing. */}}
|
||
{{define "itemrow"}}
|
||
<li>
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<span class="flex-1 font-semibold">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">{{if .Tier}}T{{.Tier}}{{end}}{{if .Value}}{{if .Tier}} · {{end}}{{.Value}}g{{end}}</span>
|
||
</div>
|
||
{{if or .Slot .SkillSource .Attunement}}
|
||
<div class="flex flex-wrap gap-1.5 mt-1">
|
||
{{if .Slot}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/5 px-2 py-0.5 text-[color:var(--ink)]/60">{{.Slot}}</span>{{end}}
|
||
{{if .SkillSource}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/5 px-2 py-0.5 text-[color:var(--ink)]/60">{{.SkillSource}}</span>{{end}}
|
||
{{if .Attunement}}{{if .Attuned}}<span class="text-[11px] rounded-full bg-theme-adventure/20 px-2 py-0.5 text-theme-adventure font-semibold">bonded</span>{{else if .Worn}}<span class="text-[11px] rounded-full bg-amber-400/20 px-2 py-0.5 text-[color:var(--warn)] font-semibold">inert · no bond free</span>{{else}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/10 px-2 py-0.5 text-[color:var(--ink)]/60">needs a bond</span>{{end}}{{end}}
|
||
</div>
|
||
{{end}}
|
||
{{if .Desc}}<p class="text-xs text-[color:var(--ink)]/55 mt-1 leading-snug">{{.Desc}}</p>{{end}}
|
||
{{if .Effect}}<p class="text-xs text-theme-adventure/80 mt-0.5">{{.Effect}}</p>{{end}}
|
||
{{/* The compare card: how this backpack item stacks up against what's worn in
|
||
the slot it would equip into. gogobee decided the verdict and the per-stat
|
||
deltas (it holds the tempering and bond math); Pete only colours them. The
|
||
verdict chip always shows — a phone has no hover to lean on — with the
|
||
deltas beside it, each already flagged better/worse. */}}
|
||
{{with .Compare}}
|
||
<div class="mt-1 flex flex-wrap items-center gap-1.5">
|
||
{{if eq .Verdict "upgrade"}}<span class="cmp-chip cmp-up">↑ upgrade</span>
|
||
{{else if eq .Verdict "downgrade"}}<span class="cmp-chip cmp-down">↓ downgrade</span>
|
||
{{else if eq .Verdict "sidegrade"}}<span class="cmp-chip cmp-side">⇄ sidegrade</span>
|
||
{{else if eq .Verdict "new"}}<span class="cmp-chip cmp-new">✦ fills {{.VsSlot}}</span>
|
||
{{else if eq .Verdict "inert"}}<span class="cmp-chip cmp-inert">⚠ would sit inert</span>
|
||
{{else if eq .Verdict "same"}}<span class="cmp-chip cmp-same">= no change</span>{{end}}
|
||
{{range .Deltas}}<span class="cmp-delta {{if .Better}}cmp-delta-up{{else}}cmp-delta-down{{end}}">{{.Text}}</span>{{end}}
|
||
</div>
|
||
{{if .VsName}}<p class="text-[11px] text-[color:var(--ink)]/45 mt-0.5">vs worn {{.VsName}}</p>{{end}}
|
||
{{end}}
|
||
{{if .EquipAction}}
|
||
<div class="mt-1.5">
|
||
<button type="button" class="equip-btn text-[11px] rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-2.5 py-0.5 font-semibold transition-colors"
|
||
data-action="{{.EquipAction}}" data-item-id="{{.ID}}" data-slot="{{.Slot}}" data-item-name="{{.Name}}">{{if eq .EquipAction "equip"}}Equip{{else}}Take off{{end}}</button>
|
||
</div>
|
||
{{end}}
|
||
</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">
|
||
<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> The board
|
||
</a>
|
||
</nav>
|
||
|
||
<header class="rounded-3xl bg-theme-adventure 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">{{if .Mark.OnRun}}⚔{{else}}🏠{{end}}</div>
|
||
<div class="relative">
|
||
<p class="text-sm uppercase tracking-[0.2em] opacity-80">adventurer</p>
|
||
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Mark.Name}}</h1>
|
||
<p class="mt-2 opacity-90">Level {{.Mark.Level}} {{.Mark.ClassRace}}</p>
|
||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75" id="who-where">
|
||
{{if .Mark.OnRun}}{{.Mark.Where}}{{else}}In town{{if .Mark.Idle}} · {{.Mark.Idle}}{{end}}{{end}}
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
{{if .HasDetail}}
|
||
<!-- Stats + gear: public, the same anonymity model as the board. -->
|
||
<section class="mt-8 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">
|
||
<h2 class="font-display text-xl font-bold mb-4">Vitals</h2>
|
||
<div class="flex items-baseline justify-between">
|
||
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Hit points</span>
|
||
<span class="font-semibold" id="who-hp">{{.Detail.HPCurrent}} / {{.Detail.HPMax}}{{if .Detail.TempHP}} <span class="text-theme-adventure">+{{.Detail.TempHP}}</span>{{end}}</span>
|
||
</div>
|
||
<div class="mt-2 h-2 rounded-full bg-[color:var(--ink)]/10 overflow-hidden">
|
||
<div class="h-full rounded-full bg-theme-adventure transition-all" id="who-hp-bar" style="width:0%"></div>
|
||
</div>
|
||
<div class="mt-4 flex items-baseline justify-between">
|
||
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Armor class</span>
|
||
<span class="font-semibold" id="who-ac">{{.Detail.ArmorClass}}</span>
|
||
</div>
|
||
|
||
<div class="mt-5 grid grid-cols-3 gap-2">
|
||
{{range .Abilities}}
|
||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50">{{.Label}}</div>
|
||
<div class="font-display text-lg font-bold leading-none mt-1">{{.Score}}</div>
|
||
<div class="text-xs text-[color:var(--ink)]/60">{{.ModStr}}</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
|
||
{{if .Mark.OnRun}}
|
||
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 space-y-1.5 text-sm" id="who-expedition">
|
||
{{if .Detail.Room}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Room</span><span class="font-semibold" id="who-room">{{.Detail.Room}}</span></div>{{end}}
|
||
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Supplies</span><span class="font-semibold" id="who-supplies">{{.Detail.Supplies}}</span></div>
|
||
{{if .Detail.ThreatLevel}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Threat</span><span class="font-semibold" id="who-threat">{{.Detail.ThreatLevel}}</span></div>{{end}}
|
||
</div>
|
||
{{end}}
|
||
|
||
{{/* The party. Absent on a solo run, so this block answers "is anybody else
|
||
down there" rather than drawing a chair count. Server-rendered only: a
|
||
roster is settled before a party leaves town, so unlike the HP and
|
||
supply numbers above it there is nothing here for the poll to move.
|
||
An unnamed seat is an opted-out player, kept on purpose — see
|
||
partySeat.Anonymous for why it is not simply dropped. */}}
|
||
{{if .Detail.Party}}
|
||
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10">
|
||
<h3 class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50 mb-1">Down there together</h3>
|
||
<ul>
|
||
{{range .Detail.Party}}
|
||
<li class="party-seat">
|
||
<span class="party-seat-role{{if eq .Kind "leader"}} party-seat-leader{{end}}">{{.Kind}}</span>
|
||
{{if .Anonymous}}
|
||
<span class="party-seat-anon flex-1 text-sm">an adventurer who keeps out of the news</span>
|
||
{{else if .Token}}
|
||
<a href="/adventure/who/{{.Token}}" class="flex-1 text-sm font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
|
||
{{else}}
|
||
<span class="flex-1 text-sm font-semibold">{{.Name}}</span>
|
||
{{end}}
|
||
{{if .Level}}<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}</span>{{end}}
|
||
</li>
|
||
{{end}}
|
||
</ul>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
|
||
{{/* Public gear list. Hidden only when the owner's "Equipment" panel below will
|
||
actually render (it needs pushed Slots) — otherwise an owner whose detail
|
||
predates ask 7's Slots would see no standard gear at all. */}}
|
||
{{if not .Self.Slots}}
|
||
<div class="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">Gear</h2>
|
||
{{if .Detail.Gear}}
|
||
<ul class="space-y-2">
|
||
{{range .Detail.Gear}}
|
||
<li class="flex items-baseline justify-between gap-3">
|
||
<span class="text-xs uppercase tracking-wider text-[color:var(--ink)]/45 w-16 shrink-0">{{.Slot}}</span>
|
||
<span class="font-semibold flex-1">{{.Name}}{{if .Masterwork}} <span title="masterwork">★</span>{{end}}</span>
|
||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
|
||
</li>
|
||
{{end}}
|
||
</ul>
|
||
{{else}}
|
||
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
</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>
|
||
</section>
|
||
{{end}}
|
||
|
||
<!-- The liveblog. The map above says where they are; this says what happened
|
||
there. Sits outside the HasDetail gate on purpose: the log arrives on its
|
||
own channel and can outlive the snapshot that drew the map — a run that
|
||
just ended still has a story after the board has moved the mark back to
|
||
town. Rebuilt in place by the same poll that moves the HP bar. -->
|
||
<section id="who-runlog-section" class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete{{if not .RunLog.Has}} hidden{{end}}">
|
||
<div class="flex items-baseline justify-between mb-4 gap-3">
|
||
<h2 class="font-display text-xl font-bold">The run</h2>
|
||
<span id="who-runlog-status" class="text-sm text-[color:var(--ink)]/50">{{if .RunLog.Live}}under way{{else if .RunLog.Outcome}}{{.RunLog.Outcome}}{{else}}finished{{end}}{{if .RunLog.Rooms}} · room {{.RunLog.Rooms}}{{end}}</span>
|
||
</div>
|
||
<ol id="who-runlog" class="runlog">
|
||
{{range .RunLog.Lines}}
|
||
<li class="runlog-line{{if .Hurt}} runlog-hurt{{end}}{{if .Good}} runlog-good{{end}}">
|
||
<span class="runlog-emoji" aria-hidden="true">{{.Emoji}}</span>
|
||
<span class="runlog-text">{{.Text}}</span>
|
||
<span class="runlog-meta">{{if .Room}}{{.Room}} · {{end}}{{.When}}</span>
|
||
</li>
|
||
{{end}}
|
||
</ol>
|
||
<!-- Only once the run is over: while it's still walking, this column IS the
|
||
report, and a link to a second copy of it is just a way to lose the
|
||
reader. Revealed in place when a run ends under an open tab. -->
|
||
<a id="who-runlog-report" href="{{.RunLog.ReportURL}}"
|
||
class="mt-4 inline-flex items-center gap-1.5 text-sm font-semibold text-theme-adventure hover:opacity-80 transition{{if not .RunLog.ReportURL}} hidden{{end}}">
|
||
<span aria-hidden="true">📜</span> The full report <span aria-hidden="true">→</span>
|
||
</a>
|
||
</section>
|
||
|
||
{{if .HasSelf}}
|
||
<!-- Your call. The panels above are a spectator view of a run going well or
|
||
badly; this is the one thing the watcher can do about it. Owner-only, and
|
||
shown whether or not the mark is currently on a run — the board is up to
|
||
two minutes stale, so hiding the button on a snapshot that says "in town"
|
||
would be the page refusing an action the game would have allowed. gogobee
|
||
answers rejected_not_running if it really has ended, and that answer shows
|
||
up in the strip below. -->
|
||
<section id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete"
|
||
data-balance="{{.Self.Balance}}">
|
||
<h2 class="font-display text-xl font-bold mb-1">Your call</h2>
|
||
<p class="text-sm text-[color:var(--ink)]/60 mb-4">
|
||
Asked for here, done on the game box. It picks these up within a few seconds.
|
||
</p>
|
||
{{/* data-offer marks what stops being true once the extraction lands: there
|
||
is no run left to pull out of. Hidden by the script on an applied
|
||
verdict, restored on a refusal, which is the one case where the reader
|
||
still needs the retry.
|
||
|
||
W9: withheld from a party MEMBER, and only from them. W5a's rule is that
|
||
an idle-looking mark still gets this button, because the board is two
|
||
minutes stale and refusing a live extraction is worse than a no-op — but
|
||
a member is not a staleness question. Pete knows from the same seat it
|
||
just read that gogobee will answer rejected_not_leader, and offering a
|
||
certain refusal directly above "Turn back alone", which is the thing that
|
||
does work, is the page contradicting itself. CanLeave is only ever true
|
||
for a seat that says "member". */}}
|
||
{{if not .CanLeave}}
|
||
<div data-offer>
|
||
<button type="button"
|
||
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-4 py-1.5 text-sm font-semibold transition-colors"
|
||
data-action="extract"
|
||
data-label="Pull out of the run"
|
||
data-confirm-label="Yes, pull out"
|
||
data-confirm="Pull out of the dungeon now? You keep the loot, XP and coins you're carrying, and the run waits where you left it: you have seven days to go back in. If you're leading a party, it ends the day for all of you.">Pull out of the run</button>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{/* W9. The two ways out that are not an extraction, each in its own
|
||
data-offer wrapper rather than sharing the extract one above — an
|
||
extracted run is still abandonable, so hiding "Call it off" when the
|
||
extraction lands would take away the very button that case needs.
|
||
|
||
They are mutually exclusive by construction (offersToUndo reads the
|
||
viewer's own party seat: a leader is offered one, a member the other),
|
||
which is why neither mentions the other. */}}
|
||
{{if .CanAbandon}}
|
||
<div data-offer class="mt-3">
|
||
<button type="button"
|
||
class="adv-action-btn rounded-full border border-[color:var(--warn)]/40 text-[color:var(--warn)] hover:bg-[color:var(--warn)]/10 px-4 py-1.5 text-sm font-semibold transition-colors"
|
||
data-action="expedition_abandon"
|
||
data-label="Call the whole thing off"
|
||
data-confirm-tone="warn"
|
||
data-confirm-label="Yes, call it off"
|
||
data-confirm="End this expedition for good? Whatever is left of the supplies is forfeit and there is no way back into the run afterwards. If you have a party with you, it ends for all of them too, and they will be told. This is not the same as pulling out: pulling out keeps the way back open for seven days.">Call the whole thing off</button>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .CanLeave}}
|
||
<div data-offer class="mt-3">
|
||
<button type="button"
|
||
class="adv-action-btn rounded-full border border-[color:var(--ink)]/25 text-[color:var(--ink)]/70 hover:bg-[color:var(--ink)]/10 px-4 py-1.5 text-sm font-semibold transition-colors"
|
||
data-action="expedition_leave"
|
||
data-label="Turn back alone"
|
||
data-confirm-label="Yes, turn back"
|
||
data-confirm="Walk out of this party and head for town on your own? The rest of them carry on without you, and the supplies you bought stay in their pool: they were spent on the expedition, not lent to it. Your leader will be told.">Turn back alone</button>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{/* W5b. Everything below is server-rendered from the offer list gogobee
|
||
pushed onto this owner's own private row, so the page can only ever
|
||
propose a zone, a loadout and a price the game itself quoted. The order
|
||
carries those keys back and gogobee re-resolves all of them — an offer is
|
||
a quote, never a permission. */}}
|
||
|
||
{{if .Self.Resume}}
|
||
{{/* The way back in, first: it is the only offer here with a deadline on it,
|
||
and it is the answer to the verdict "Pull out" leaves behind, which until
|
||
now told a web player to go and type !resume in Matrix. */}}
|
||
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
|
||
<h3 class="font-display text-base font-bold">Go back in</h3>
|
||
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
|
||
{{.Self.Resume.Display}}, day {{.Self.Resume.Day}} — waiting where you left it.
|
||
{{with untilUnix .Self.Resume.ExpiresAt}}<span class="text-[color:var(--warn)] font-semibold">{{.}}</span>.{{end}}
|
||
You re-stock before you go, so pick a pack.
|
||
</p>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
{{$rz := .Self.Resume}}
|
||
{{range $rz.Loadouts}}
|
||
<button type="button"
|
||
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
|
||
data-action="expedition_resume"
|
||
data-loadout="{{.Key}}"
|
||
data-cost="{{.Cost}}"
|
||
data-label="{{.Name}} · €{{euro .Cost}}"
|
||
data-confirm-label="Go back in"
|
||
data-confirm="Walk back into {{$rz.Display}} on day {{$rz.Day}} with a {{.Name}} pack: {{.Blurb}}. About {{.Days}} days of provisions.">{{.Name}} · €{{euro .Cost}}</button>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .Self.Zones}}
|
||
{{/* Set out. The zone list is already level-gated and postgame-gated by
|
||
gogobee, and it arrives EMPTY while this adventurer is already out —
|
||
which is why there is no "you're on an expedition" branch here. */}}
|
||
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
|
||
<h3 class="font-display text-base font-bold">Set out</h3>
|
||
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
|
||
Pick where, then pick how much to carry. The pack is what you pay for.
|
||
</p>
|
||
<label class="block">
|
||
<span class="sr-only">Zone</span>
|
||
<select id="adv-zone-pick"
|
||
class="w-full rounded-xl bg-[color:var(--ink)]/5 border border-[color:var(--ink)]/15 px-3 py-2 text-sm font-semibold">
|
||
{{range .Self.Zones}}
|
||
<option value="{{.ID}}">{{.Display}} · T{{.Tier}}{{if .Postgame}} · mythic{{end}}</option>
|
||
{{end}}
|
||
</select>
|
||
</label>
|
||
{{/* One group per zone, all rendered, one shown. The costs differ by tier,
|
||
so a single shared row of buttons would have to be repriced in the
|
||
browser — and Pete does no arithmetic on the game's money. */}}
|
||
{{range .Self.Zones}}
|
||
{{$z := .}}
|
||
<div class="adv-zone-loadouts mt-2.5 hidden" data-zone="{{$z.ID}}">
|
||
{{with $z.Hook}}<p class="text-xs italic text-[color:var(--ink)]/50 mb-2">{{.}}</p>{{end}}
|
||
<div class="flex flex-wrap gap-1.5">
|
||
{{range $z.Loadouts}}
|
||
<button type="button"
|
||
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
|
||
data-action="expedition_start"
|
||
data-zone="{{$z.ID}}"
|
||
data-loadout="{{.Key}}"
|
||
data-cost="{{.Cost}}"
|
||
data-label="{{.Name}} · €{{euro .Cost}}"
|
||
data-confirm-label="Set out"
|
||
data-confirm="Head for {{$z.Display}} with a {{.Name}} pack: {{.Blurb}}. About {{.Days}} days of provisions.">{{.Name}} · €{{euro .Cost}}</button>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .Self.Babysit}}
|
||
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
|
||
<h3 class="font-display text-base font-bold">The sitter</h3>
|
||
{{if .Self.Babysit.Active}}
|
||
{{/* Engaged: say so, and offer only the way back out. Buying a second one
|
||
is refused by the game anyway, and a buy button under "already engaged"
|
||
reads as a page that has not noticed. */}}
|
||
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5">
|
||
Somebody is looking after the camp{{with untilUnix .Self.Babysit.ExpiresAt}} — {{.}}{{end}}.
|
||
Your pet is being tended daily and standard camps rest like fortified ones.
|
||
</p>
|
||
{{if .CanCancelSitter}}
|
||
<div class="flex flex-wrap gap-1.5 mt-2.5">
|
||
<button type="button"
|
||
class="adv-action-btn rounded-full border border-[color:var(--ink)]/25 text-[color:var(--ink)]/70 hover:bg-[color:var(--ink)]/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
|
||
data-action="babysit_cancel"
|
||
data-label="Send them home"
|
||
data-confirm-label="Yes, send them home"
|
||
data-confirm="Send the sitter home now? There is no refund: you paid for the days you booked and the rest of them go with the sitter. Your pet stops being tended and standard camps go back to resting like standard camps.">Send them home</button>
|
||
</div>
|
||
{{end}}
|
||
{{else}}
|
||
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
|
||
Pet tended daily, standard camps rest like fortified ones, rival duels declined for you.
|
||
</p>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
<button type="button"
|
||
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
|
||
data-action="babysit" data-days="7" data-cost="{{.Self.Babysit.WeekCost}}"
|
||
data-label="A week · €{{euro .Self.Babysit.WeekCost}}"
|
||
data-confirm-label="Hire for a week"
|
||
data-confirm="Engage the sitter for seven days. No refund if you cancel early.">A week · €{{euro .Self.Babysit.WeekCost}}</button>
|
||
<button type="button"
|
||
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
|
||
data-action="babysit" data-days="30" data-cost="{{.Self.Babysit.MonthCost}}"
|
||
data-label="A month · €{{euro .Self.Babysit.MonthCost}}"
|
||
data-confirm-label="Hire for a month"
|
||
data-confirm="Engage the sitter for thirty days. No refund if you cancel early.">A month · €{{euro .Self.Babysit.MonthCost}}</button>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
|
||
<!-- The queue is honest: an action lands on the game box's next poll, so a
|
||
fresh one reads "asked for", never "done". JS fills this from
|
||
/api/adventure/orders. -->
|
||
<div id="adv-action-orders-box" class="mt-5 hidden">
|
||
<h3 class="font-display text-base font-bold mb-2">What you've asked for</h3>
|
||
<ul id="adv-action-orders" class="space-y-1.5 text-xs"></ul>
|
||
</div>
|
||
</section>
|
||
{{end}}
|
||
|
||
{{if .HasHistory}}
|
||
<!-- The record. Public, like the dispatches it's counted from — this is the
|
||
same information the /adventure feed already printed, only as numbers
|
||
instead of as forty separate sentences. Not a gogobee snapshot: Pete
|
||
counted these itself out of the facts it kept. -->
|
||
<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">The record</h2>
|
||
|
||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.BossKills}}</div>
|
||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">bosses down</div>
|
||
</div>
|
||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.ZoneClears}}</div>
|
||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">zones cleared</div>
|
||
</div>
|
||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.TreasuresFound}}</div>
|
||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">treasure{{if ne .Trophies.TreasuresFound 1}}s{{end}}</div>
|
||
</div>
|
||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Deaths}}</div>
|
||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">death{{if ne .Trophies.Deaths 1}}s{{end}}</div>
|
||
</div>
|
||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Retreats}}</div>
|
||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">walked out</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{if or .Trophies.BossFirsts .Trophies.ZoneFirsts .Trophies.TreasureFirsts}}
|
||
<p class="mt-3 text-sm text-theme-adventure font-semibold">
|
||
★ {{if .Trophies.BossFirsts}}{{.Trophies.BossFirsts}} realm-first boss{{if ne .Trophies.BossFirsts 1}}es{{end}}{{end}}{{if and .Trophies.BossFirsts (or .Trophies.ZoneFirsts .Trophies.TreasureFirsts)}}, {{end}}{{if .Trophies.ZoneFirsts}}{{.Trophies.ZoneFirsts}} first clear{{if ne .Trophies.ZoneFirsts 1}}s{{end}}{{end}}{{if and .Trophies.ZoneFirsts .Trophies.TreasureFirsts}}, {{end}}{{if .Trophies.TreasureFirsts}}{{.Trophies.TreasureFirsts}} first hoard{{if ne .Trophies.TreasureFirsts 1}}s{{end}}{{end}}. Nobody had done it before.
|
||
</p>
|
||
{{end}}
|
||
|
||
<div class="mt-6 grid gap-6 sm:grid-cols-2">
|
||
{{if .Trophies.Bosses}}
|
||
<div>
|
||
<h3 class="font-display text-lg font-bold mb-3">Bosses fought</h3>
|
||
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||
{{range .Trophies.Bosses}}
|
||
<li class="flex items-baseline justify-between gap-3">
|
||
<span class="flex-1">{{.Boss}}{{if .First}} <span class="text-theme-adventure" title="first in the realm to clear it">★</span>{{end}}</span>
|
||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">×{{.Kills}}</span>
|
||
</li>
|
||
{{end}}
|
||
</ul>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .Trophies.Zones}}
|
||
<div>
|
||
<h3 class="font-display text-lg font-bold mb-3">Ground covered</h3>
|
||
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||
{{range .Trophies.Zones}}
|
||
<li class="flex items-baseline justify-between gap-3">
|
||
<span class="flex-1">{{.Zone}}{{if .First}} <span class="text-theme-adventure" title="first clear in the realm">★</span>{{end}}{{if .Region}} <span class="text-[color:var(--ink)]/45">{{.Region}}</span>{{end}}</span>
|
||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">×{{.Clears}}</span>
|
||
</li>
|
||
{{end}}
|
||
</ul>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
|
||
{{if .Trophies.Treasures}}
|
||
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
|
||
<h3 class="font-display text-lg font-bold mb-3">Treasures found</h3>
|
||
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||
{{range .Trophies.Treasures}}
|
||
<li class="flex items-baseline justify-between gap-3">
|
||
<span class="flex-1">💎 {{.Item}}{{if .First}} <span class="text-theme-adventure" title="first in the realm to pull this hoard">★</span>{{end}}</span>
|
||
{{if .Zone}}<span class="text-xs text-[color:var(--ink)]/45 shrink-0">{{.Zone}}</span>{{end}}
|
||
</li>
|
||
{{end}}
|
||
</ul>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .Trophies.Milestones}}
|
||
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
|
||
<h3 class="font-display text-lg font-bold mb-3">Milestones</h3>
|
||
<div class="flex flex-wrap gap-2">
|
||
{{range .Trophies.Milestones}}
|
||
<span class="rounded-full bg-theme-adventure/10 text-theme-adventure text-xs font-semibold px-3 py-1">🏅 {{.}}</span>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</section>
|
||
|
||
<!-- The trail: every fact we have, newest first, each linking to the dispatch
|
||
that told it. -->
|
||
<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">The trail</h2>
|
||
<ol class="relative border-l-2 border-[color:var(--ink)]/10 ml-3 space-y-4">
|
||
{{range .Timeline}}
|
||
<li class="relative pl-6">
|
||
<span class="absolute -left-[13px] top-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-[color:var(--card)] border-2 {{if .Notable}}border-theme-adventure{{else}}border-[color:var(--ink)]/10{{end}} text-xs" aria-hidden="true">{{.Emoji}}</span>
|
||
<a href="{{.Permalink}}" class="group block">
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<span class="font-semibold group-hover:text-theme-adventure transition {{if .Notable}}text-theme-adventure{{end}}">{{.Label}}</span>
|
||
<span class="text-xs text-[color:var(--ink)]/45 shrink-0">{{.When}}</span>
|
||
</div>
|
||
{{if .Line}}<p class="text-sm text-[color:var(--ink)]/60 mt-0.5">{{.Line}}</p>{{end}}
|
||
</a>
|
||
</li>
|
||
{{end}}
|
||
</ol>
|
||
{{if .MoreHistory}}
|
||
<p class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 text-xs text-[color:var(--ink)]/45">
|
||
Showing the most recent {{len .Timeline}}. The counts above cover everything.
|
||
</p>
|
||
{{end}}
|
||
</section>
|
||
{{end}}
|
||
|
||
{{if .HasSelf}}
|
||
<!-- Owner-only: this is you. Inventory, vault, house, pets — private, served
|
||
only because your signed-in localpart owns this page's token. -->
|
||
<section class="mt-8">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="rounded-full bg-theme-adventure text-white text-xs font-semibold px-3 py-1">Your adventurer</span>
|
||
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
|
||
</div>
|
||
|
||
{{/* Equipment: the 5 standard slots, owner-only management. Take off round-trips
|
||
a masterwork/arena piece to the pack (no money); Upgrade buys the next tier
|
||
and Repair mends condition, both spending euros behind a confirm that shows
|
||
the cost and the resulting balance. Every price and tier here is gogobee's,
|
||
resolved server-side — the buttons only carry what was pushed. */}}
|
||
{{if .Self.Slots}}
|
||
<div id="equip-panel" data-token="{{.Mark.Token}}" data-balance="{{printf "%.2f" .Self.Balance}}"
|
||
class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete mb-6">
|
||
<div class="flex items-baseline justify-between mb-4">
|
||
<h2 class="font-display text-xl font-bold">Equipment</h2>
|
||
<span class="text-xs text-[color:var(--ink)]/50">Balance <span class="font-semibold text-[color:var(--ink)]/70">€{{printf "%.2f" .Self.Balance}}</span></span>
|
||
</div>
|
||
<ul class="grid gap-3 sm:grid-cols-2">
|
||
{{range .Self.Slots}}
|
||
<li class="slot-card rounded-2xl bg-[color:var(--ink)]/5 p-4">
|
||
<div class="flex items-baseline justify-between gap-2">
|
||
<span class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/45">{{.Slot}}</span>
|
||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
|
||
</div>
|
||
<div class="font-semibold mt-0.5 flex items-center flex-wrap gap-1.5">
|
||
<span>{{.Name}}</span>
|
||
{{if .Masterwork}}<span class="text-theme-adventure" title="masterwork">★</span>{{end}}
|
||
{{if .ArenaTier}}<span class="text-[11px] rounded-full bg-theme-adventure/20 px-2 py-0.5 text-theme-adventure font-semibold">arena T{{.ArenaTier}}</span>{{end}}
|
||
</div>
|
||
{{if or .CanTakeOff .NextTier .RepairCost}}
|
||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||
{{if .CanTakeOff}}<button type="button" class="equip-btn text-[11px] rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-2.5 py-0.5 font-semibold transition-colors"
|
||
data-action="unequip" data-slot="{{.Slot}}" data-item-name="{{.Name}}">Take off</button>{{end}}
|
||
{{if .NextTier}}<button type="button" class="equip-btn text-[11px] rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-2.5 py-0.5 font-semibold transition-colors"
|
||
data-action="upgrade" data-slot="{{.Slot}}" data-tier="{{.NextTier}}" data-cost="{{printf "%.2f" .NextPrice}}" data-item-name="{{.NextName}}">Upgrade to {{.NextName}} · €{{printf "%.0f" .NextPrice}}</button>{{end}}
|
||
{{if .RepairCost}}<button type="button" class="equip-btn text-[11px] rounded-full border border-[color:var(--warn)]/40 text-[color:var(--warn)] hover:bg-amber-400/10 px-2.5 py-0.5 font-semibold transition-colors"
|
||
data-action="repair" data-slot="{{.Slot}}" data-cost="{{.RepairCost}}" data-item-name="{{.Name}}">Repair · €{{.RepairCost}}</button>{{end}}
|
||
</div>
|
||
{{end}}
|
||
</li>
|
||
{{end}}
|
||
</ul>
|
||
</div>
|
||
{{end}}
|
||
|
||
<div class="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">
|
||
<h2 class="font-display text-xl font-bold mb-4">Home</h2>
|
||
<div class="space-y-1.5 text-sm">
|
||
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">House tier</span><span class="font-semibold">{{if .Self.House.Tier}}{{.Self.House.Tier}}{{else}}no house{{end}}</span></div>
|
||
{{if .Self.House.LoanBalance}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Loan balance</span><span class="font-semibold">{{.Self.House.LoanBalance}}</span></div>{{end}}
|
||
{{if .Self.House.Rate}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Rate</span><span class="font-semibold">{{.Self.House.Rate}}</span></div>{{end}}
|
||
{{if .Self.House.Autopay}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Autopay</span><span class="font-semibold">on</span></div>{{end}}
|
||
</div>
|
||
|
||
{{/* Pets, with their levelling shown. They have been earning XP from every
|
||
won fight since that wiring was fixed, and until now the only place a
|
||
level appeared was a Matrix line that scrolled away. Ranges over
|
||
.PetRows, not .Self.Pets: the progress figures are worked out in Go
|
||
from the centi-XP gogobee sends, because the curve behind the target
|
||
is the engine's and there is no copy of it here. */}}
|
||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Pets</h3>
|
||
{{if .PetRows}}
|
||
<ul class="space-y-3 text-sm">
|
||
{{range .PetRows}}
|
||
<li>
|
||
<div class="flex items-baseline justify-between gap-3">
|
||
<span class="font-semibold flex-1">{{if .Name}}{{.Name}}{{else}}your {{.Type}}{{end}} <span class="text-[color:var(--ink)]/45 font-normal">{{.Type}}</span></span>
|
||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}{{if .ArmorTier}} · barding T{{.ArmorTier}}{{end}}</span>
|
||
</div>
|
||
<div class="pet-xp-track mt-1.5">
|
||
<div class="pet-xp-fill{{if .Capped}} pet-xp-capped{{end}}" style="width: {{if .Capped}}100{{else}}{{.Percent}}{{end}}%"></div>
|
||
</div>
|
||
<p class="mt-1 text-xs text-[color:var(--ink)]/45">
|
||
{{if .Capped}}fully grown{{else}}{{.Progress}} to level {{.NextLevel}}{{end}}
|
||
</p>
|
||
</li>
|
||
{{end}}
|
||
</ul>
|
||
{{else}}
|
||
<p class="text-sm text-[color:var(--ink)]/50">No pets right now.</p>
|
||
{{end}}
|
||
</div>
|
||
|
||
<div id="gear-panel" data-token="{{.Mark.Token}}" class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||
{{if .Worn}}
|
||
<h2 class="font-display text-xl font-bold mb-1">Worn</h2>
|
||
<p class="text-xs text-[color:var(--ink)]/45 mb-3">{{.BondsUsed}} of 3 bonds in use</p>
|
||
<ul class="space-y-2.5 text-sm">
|
||
{{range .Worn}}{{template "itemrow" .}}{{end}}
|
||
</ul>
|
||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Backpack</h3>
|
||
{{else}}
|
||
<h2 class="font-display text-xl font-bold mb-4">Backpack</h2>
|
||
{{end}}
|
||
{{if .Backpack}}
|
||
<ul class="space-y-2.5 text-sm max-h-72 overflow-y-auto pr-1">
|
||
{{range .Backpack}}{{template "itemrow" .}}{{end}}
|
||
</ul>
|
||
{{else}}
|
||
<p class="text-sm text-[color:var(--ink)]/50">Backpack's empty.</p>
|
||
{{end}}
|
||
|
||
{{if .VaultRows}}
|
||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Vault</h3>
|
||
<ul class="space-y-2.5 text-sm max-h-52 overflow-y-auto pr-1">
|
||
{{range .VaultRows}}{{template "itemrow" .}}{{end}}
|
||
</ul>
|
||
{{end}}
|
||
|
||
<!-- Changes you've asked for. The queue is honest: an equip lands on the
|
||
game box's next poll, so a fresh order reads "queued", never "done".
|
||
JS fills this from /api/equip/orders. -->
|
||
<div id="equip-orders" class="mt-6 hidden">
|
||
<h3 class="font-display text-lg font-bold mb-2">Pending changes</h3>
|
||
<ul id="equip-orders-list" class="space-y-1.5 text-xs"></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{{end}}
|
||
</article>
|
||
|
||
<script>
|
||
// The sheet is state, like the board: keep an open tab honest without a reload.
|
||
// Public detail only (HP, room, supplies) — the private panels stay as rendered.
|
||
(function () {
|
||
var root = document.getElementById('who');
|
||
if (!root) return;
|
||
var token = root.getAttribute('data-token');
|
||
|
||
function setBar() {
|
||
var el = document.getElementById('who-hp-bar');
|
||
var hp = document.getElementById('who-hp');
|
||
if (!el || !hp) return;
|
||
var m = hp.textContent.match(/(-?\d+)\s*\/\s*(\d+)/);
|
||
if (!m) return;
|
||
var cur = parseInt(m[1], 10), max = parseInt(m[2], 10);
|
||
var pct = max > 0 ? Math.max(0, Math.min(100, Math.round(cur / max * 100))) : 0;
|
||
el.style.width = pct + '%';
|
||
}
|
||
setBar();
|
||
|
||
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
|
||
|
||
// The liveblog is rebuilt from the JSON rather than patched: beats only ever
|
||
// arrive at the end, but a run can also END between polls, which changes the
|
||
// header and can drop the section entirely. Redrawing forty short lines is
|
||
// cheaper than getting the incremental case wrong.
|
||
//
|
||
// Built with textContent throughout — a beat carries a monster name that came
|
||
// off the wire, and innerHTML here would make the game box able to inject
|
||
// markup into a public page.
|
||
function drawRunLog(log) {
|
||
var section = document.getElementById('who-runlog-section');
|
||
var list = document.getElementById('who-runlog');
|
||
if (!section || !list) return;
|
||
if (!log || !log.Has || !log.Lines || !log.Lines.length) {
|
||
section.classList.add('hidden');
|
||
return;
|
||
}
|
||
// Only scroll to the newest beat when the reader was already at the bottom.
|
||
// Yanking them back down while they're reading four rooms ago is worse than
|
||
// making them scroll.
|
||
var pinned = list.scrollHeight - list.scrollTop - list.clientHeight < 24;
|
||
|
||
section.classList.remove('hidden');
|
||
txt('who-runlog-status',
|
||
(log.Live ? 'under way' : (log.Outcome || 'finished')) +
|
||
(log.Rooms ? ' · room ' + log.Rooms : ''));
|
||
|
||
var frag = document.createDocumentFragment();
|
||
log.Lines.forEach(function (ln) {
|
||
var li = document.createElement('li');
|
||
li.className = 'runlog-line' + (ln.Hurt ? ' runlog-hurt' : '') + (ln.Good ? ' runlog-good' : '');
|
||
var e = document.createElement('span');
|
||
e.className = 'runlog-emoji'; e.setAttribute('aria-hidden', 'true'); e.textContent = ln.Emoji || '';
|
||
var t = document.createElement('span');
|
||
t.className = 'runlog-text'; t.textContent = ln.Text || '';
|
||
var m = document.createElement('span');
|
||
m.className = 'runlog-meta'; m.textContent = (ln.Room ? ln.Room + ' · ' : '') + (ln.When || '');
|
||
li.appendChild(e); li.appendChild(t); li.appendChild(m);
|
||
frag.appendChild(li);
|
||
});
|
||
list.replaceChildren(frag);
|
||
if (pinned) list.scrollTop = list.scrollHeight;
|
||
|
||
// The run can end between two polls, which is exactly the moment the report
|
||
// becomes worth offering. href is set from the payload rather than built here
|
||
// so the page never invents a URL for a run Pete won't serve.
|
||
var report = document.getElementById('who-runlog-report');
|
||
if (report) {
|
||
if (log.ReportURL) {
|
||
report.href = log.ReportURL;
|
||
report.classList.remove('hidden');
|
||
} else {
|
||
report.classList.add('hidden');
|
||
}
|
||
}
|
||
}
|
||
(function () {
|
||
var list = document.getElementById('who-runlog');
|
||
if (list) list.scrollTop = list.scrollHeight;
|
||
})();
|
||
|
||
var timer = null;
|
||
function refresh() {
|
||
fetch('/api/adventure/who/' + encodeURIComponent(token), { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (data) {
|
||
if (!data) return;
|
||
if (!data.live) {
|
||
// Off the board entirely (expedition ended, opted out): nothing more to
|
||
// poll for, so stop the timer rather than hammer a token that's gone.
|
||
// The log goes with them: off the board is off the page, and the API
|
||
// sends none in this branch.
|
||
txt('who-where', 'Back in town');
|
||
drawRunLog(null);
|
||
if (timer) clearInterval(timer);
|
||
return;
|
||
}
|
||
var d = data.detail || {};
|
||
var mark = data.mark || {};
|
||
if (data.has_detail) {
|
||
var temp = d.temp_hp ? ' +' + d.temp_hp : '';
|
||
txt('who-hp', d.hp_current + ' / ' + d.hp_max + temp);
|
||
txt('who-ac', d.armor_class);
|
||
txt('who-room', d.room);
|
||
txt('who-supplies', d.supplies);
|
||
txt('who-threat', d.threat_level);
|
||
setBar();
|
||
}
|
||
// Keep the location line honest in both directions: a mark that came back
|
||
// to town while still on the board must stop showing its old expedition.
|
||
if (mark.OnRun) {
|
||
txt('who-where', mark.Where);
|
||
} else {
|
||
txt('who-where', 'In town' + (mark.Idle ? ' · ' + mark.Idle : ''));
|
||
}
|
||
drawRunLog(data.run_log);
|
||
})
|
||
.catch(function () { /* transient — next tick will do */ });
|
||
}
|
||
timer = setInterval(refresh, 60000);
|
||
})();
|
||
|
||
// The equip queue, owner side. Clicking Equip / Take off records the intent; the
|
||
// item actually moves on the game box's next poll. So the UI never claims a change
|
||
// landed — it shows "queued" and lets the order's own status be the truth.
|
||
(function () {
|
||
// Two owner panels can carry equip controls: the magic Worn/Backpack panel
|
||
// (#gear-panel) and the standard-slot management panel (#equip-panel). Either
|
||
// may be absent, so wire whichever exist and share one handler.
|
||
var gearPanel = document.getElementById('gear-panel');
|
||
var equipPanel = document.getElementById('equip-panel');
|
||
var panels = [gearPanel, equipPanel].filter(Boolean);
|
||
if (!panels.length) return; // only the owner gets these panels at all
|
||
var token = (equipPanel || gearPanel).getAttribute('data-token');
|
||
var balance = equipPanel ? parseFloat(equipPanel.getAttribute('data-balance') || '0') : 0;
|
||
var box = document.getElementById('equip-orders');
|
||
var list = document.getElementById('equip-orders-list');
|
||
|
||
function euroFmt(n) {
|
||
return (Math.round(n * 100) / 100).toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||
}
|
||
|
||
// How each terminal status reads to the owner. gogobee only rejects when the
|
||
// item slipped out from under the order or can't be worn — a bond-cap "inert"
|
||
// is still an applied change, and its detail line says so.
|
||
var STATUS = {
|
||
pending: 'queued',
|
||
applied: 'done',
|
||
rejected_not_owned: "couldn't, that item had already moved",
|
||
rejected_not_worn: "couldn't, that slot was already empty",
|
||
rejected_not_equippable: "couldn't, that item can't be worn",
|
||
rejected_downgrade: "couldn't, that wouldn't be an upgrade",
|
||
rejected_insufficient_funds: "couldn't, not enough euros",
|
||
rejected_max_tier: "couldn't, already at the top tier"
|
||
};
|
||
|
||
var VERB = { equip: 'Equip', unequip: 'Take off', upgrade: 'Upgrade', repair: 'Repair' };
|
||
|
||
var pollTimer = null;
|
||
|
||
function render(orders) {
|
||
list.innerHTML = '';
|
||
if (!orders || !orders.length) { box.classList.add('hidden'); return; }
|
||
box.classList.remove('hidden');
|
||
var anyPending = false;
|
||
orders.forEach(function (o) {
|
||
if (o.status === 'pending') anyPending = true;
|
||
var li = document.createElement('li');
|
||
li.className = 'flex items-baseline justify-between gap-3';
|
||
var verb = VERB[o.action] || o.action;
|
||
var left = document.createElement('span');
|
||
left.className = 'flex-1';
|
||
left.textContent = verb + ' ' + (o.item_name || o.slot || 'item');
|
||
var right = document.createElement('span');
|
||
right.className = 'shrink-0 ' + (o.status === 'pending'
|
||
? 'text-[color:var(--ink)]/45'
|
||
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
|
||
right.textContent = o.detail || STATUS[o.status] || o.status;
|
||
li.appendChild(left); li.appendChild(right);
|
||
list.appendChild(li);
|
||
});
|
||
// While anything is still queued, keep refreshing so the verdict lands without
|
||
// a reload; stop once everything is terminal.
|
||
if (anyPending && !pollTimer) {
|
||
pollTimer = setInterval(loadOrders, 15000);
|
||
} else if (!anyPending && pollTimer) {
|
||
clearInterval(pollTimer); pollTimer = null;
|
||
}
|
||
}
|
||
|
||
function loadOrders() {
|
||
fetch('/api/equip/orders', { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (o) { if (o) render(o); })
|
||
.catch(function () { /* transient — a later tick will do */ });
|
||
}
|
||
|
||
// placeOrder records the intent. equip / unequip / take-off go straight here;
|
||
// upgrade / repair pass through askConfirm first (they spend euros).
|
||
function placeOrder(btn) {
|
||
btn.disabled = true;
|
||
btn.classList.add('opacity-50');
|
||
btn.textContent = 'queuing…';
|
||
fetch('/api/equip/order', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
token: token,
|
||
action: btn.getAttribute('data-action'),
|
||
item_id: parseInt(btn.getAttribute('data-item-id') || '0', 10),
|
||
slot: btn.getAttribute('data-slot') || '',
|
||
tier: parseInt(btn.getAttribute('data-tier') || '0', 10)
|
||
})
|
||
})
|
||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
||
.then(function (res) {
|
||
if (!res.ok) {
|
||
btn.disabled = false;
|
||
btn.classList.remove('opacity-50');
|
||
btn.textContent = (res.body && res.body.error) || 'try again';
|
||
return;
|
||
}
|
||
btn.textContent = 'queued';
|
||
loadOrders();
|
||
})
|
||
.catch(function () {
|
||
btn.disabled = false;
|
||
btn.classList.remove('opacity-50');
|
||
btn.textContent = 'try again';
|
||
});
|
||
}
|
||
|
||
// askConfirm is the money gate: an upgrade or repair debits euros on the game
|
||
// box, so show the cost and the resulting balance before anything is queued.
|
||
// Placing it here in the DOM (rather than a native dialog) keeps the page's look
|
||
// and never blocks the browser event loop.
|
||
function askConfirm(btn) {
|
||
var card = btn.closest('.slot-card') || btn.parentElement;
|
||
var existing = card.querySelector('.equip-confirm');
|
||
if (existing) existing.remove();
|
||
var cost = parseFloat(btn.getAttribute('data-cost') || '0');
|
||
var action = btn.getAttribute('data-action');
|
||
var name = btn.getAttribute('data-item-name') || 'this slot';
|
||
var head = action === 'upgrade' ? ('Upgrade to ' + name) : ('Repair ' + name);
|
||
|
||
var boxEl = document.createElement('div');
|
||
boxEl.className = 'equip-confirm mt-2 rounded-xl bg-[color:var(--ink)]/5 p-2.5 text-xs';
|
||
var p = document.createElement('p');
|
||
p.className = 'text-[color:var(--ink)]/70';
|
||
p.textContent = head + ' for €' + euroFmt(cost) + '? Balance €' + euroFmt(balance) + ' → €' + euroFmt(balance - cost) + '.';
|
||
var row = document.createElement('div');
|
||
row.className = 'mt-2 flex gap-1.5';
|
||
var yes = document.createElement('button');
|
||
yes.type = 'button';
|
||
yes.className = 'rounded-full bg-theme-adventure text-white px-2.5 py-0.5 font-semibold';
|
||
yes.textContent = 'Confirm · €' + euroFmt(cost);
|
||
yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); });
|
||
var no = document.createElement('button');
|
||
no.type = 'button';
|
||
no.className = 'rounded-full border border-[color:var(--ink)]/20 text-[color:var(--ink)]/60 px-2.5 py-0.5';
|
||
no.textContent = 'Cancel';
|
||
no.addEventListener('click', function () { boxEl.remove(); });
|
||
row.appendChild(yes);
|
||
row.appendChild(no);
|
||
boxEl.appendChild(p);
|
||
boxEl.appendChild(row);
|
||
card.appendChild(boxEl);
|
||
}
|
||
|
||
function onClick(e) {
|
||
var btn = e.target.closest('.equip-btn');
|
||
if (!btn || btn.disabled) return;
|
||
var action = btn.getAttribute('data-action');
|
||
if (action === 'upgrade' || action === 'repair') {
|
||
askConfirm(btn); // money moves — confirm cost + balance first
|
||
return;
|
||
}
|
||
placeOrder(btn);
|
||
}
|
||
|
||
panels.forEach(function (p) { p.addEventListener('click', onClick); });
|
||
|
||
loadOrders();
|
||
})();
|
||
</script>
|
||
{{end}}
|
||
|
||
{{define "scripts"}}<script src="/static/js/adventure-actions.js" defer></script>{{end}}
|