Extends equip-from-the-web (ask 5, magic-only) to all five standard gear slots. Owners get an Equipment panel on their own who page with: - Take off for worn masterwork/arena pieces (round-trippable to pack) - Upgrade to the next shop tier (spends euros, confirm-gated) - Repair a damaged slot (spends euros, confirm-gated) The public Gear panel is hidden for the owner since this supersedes it. Wire: equip_orders gains a tier column; new actions upgrade/repair; new verdicts rejected_downgrade / rejected_insufficient_funds / rejected_max_tier. PlayerDetail carries Slots (EquipSlotView x5) + Balance for the confirm dialogs. handleEquipOrder resolves take-off/upgrade/repair from pd.Slots server-side and rejects a client-forged tier (409), same as ask 5 trusts only Pete's own record. Verified: full suite green, headless render of the panel + confirm dialog in both day and night phases. gogobee ships the poll-apply half separately; Pete deploys first so its ingest accepts the new verdicts before gogobee emits them.
624 lines
34 KiB
HTML
624 lines
34 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 for the owner: their own "Equipment" panel below
|
||
supersedes it with live tiers, conditions, and management controls. */}}
|
||
{{if not .HasSelf}}
|
||
<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}}
|
||
|
||
{{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; }
|
||
|
||
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.
|
||
txt('who-where', 'Back in town');
|
||
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 : ''));
|
||
}
|
||
})
|
||
.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}}
|