The equip queue proved the reverse pipe works. This gives it verbs that play the game: pull out of a run from the adventurer page, take today's bout from the war room. Its own table and its own poll, not more actions on equip_orders. Every column of that table is equip vocabulary (item, slot, tier) and these verbs act on the character rather than on something it is carrying. Nothing in a request names an adventurer. The session maps to one localpart and a localpart to one adventurer, so Pete resolves the character itself and there is no id on the wire to forge. The panel's copy is kept honest by the verdict: an applied action hides the offer it has just spent, a refusal puts the button back. Watching it run is what put that there, along with the strip's layout — gogobee answers a bout with a whole sentence of damage, which the equip strip's two-column row squeezed into a column and wrapped the verb. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
755 lines
40 KiB
HTML
755 lines
40 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}}
|
||
</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">
|
||
<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. */}}
|
||
<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>
|
||
|
||
<!-- 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>
|
||
|
||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Pets</h3>
|
||
{{if .Self.Pets}}
|
||
<ul class="space-y-2 text-sm">
|
||
{{range .Self.Pets}}
|
||
<li 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>
|
||
</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}}
|