adventure: show the item, not just its name and price

Renders what gogobee now sends: descriptions, the engine's own effect
summary, slot and skill tags, and a Worn panel with the bond count. One row
template for all three panels — worn, backpack, vault show the same facts and
only the frame differs.

The one distinction worth the wrapper struct: "inert" is a real problem state
— the item is on you doing nothing because all three bonds are spoken for.
The same item in a backpack isn't inert, it's just not worn yet. Rendering
both the same would invent a problem the player doesn't have, on the exact
panel they'd go to to fix it. An ItemView can't tell you which panel it's in,
so itemRow carries it, and TestWhoInertOnlyWhenWorn pins it.

Effect arrives resolved and Pete renders it as given. If it ever disagrees
with what an item does in a fight, that's a gogobee bug — deriving it here
from tier and slot would just be a second opinion that drifts.

No migration: detail rides as a JSON blob.
This commit is contained in:
prosolis
2026-07-17 06:45:06 -07:00
parent cd84f64a22
commit 4ce025a82c
5 changed files with 157 additions and 25 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,28 @@
{{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/15 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-amber-700 dark:text-amber-400 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}}
</li>
{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
<nav class="mb-4">
@@ -215,29 +238,28 @@
</div>
<div 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>
{{if .Self.Inventory}}
<ul class="space-y-1.5 text-sm max-h-72 overflow-y-auto pr-1">
{{range .Self.Inventory}}
<li class="flex items-baseline justify-between gap-3">
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
</li>
{{end}}
{{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 .Self.Vault}}
{{if .VaultRows}}
<h3 class="font-display text-lg font-bold mt-6 mb-3">Vault</h3>
<ul class="space-y-1.5 text-sm max-h-52 overflow-y-auto pr-1">
{{range .Self.Vault}}
<li class="flex items-baseline justify-between gap-3">
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
</li>
{{end}}
<ul class="space-y-2.5 text-sm max-h-52 overflow-y-auto pr-1">
{{range .VaultRows}}{{template "itemrow" .}}{{end}}
</ul>
{{end}}
</div>

View File

@@ -60,6 +60,12 @@ type whoPage struct {
Abilities []abilityRow
HasSelf bool
Self storage.PlayerDetail
// The private panels, wrapped so a row knows where it is sitting. Bond state
// only means something on a worn item — see itemRow.
Worn []itemRow
Backpack []itemRow
VaultRows []itemRow
BondsUsed int
// History. Unlike everything above, these are not a gogobee snapshot — they
// are counted from the facts Pete has been keeping since adventure_events
// landed. HasHistory is false for an adventurer who hasn't done anything since
@@ -71,6 +77,28 @@ type whoPage struct {
MoreHistory bool // the trail was capped; there is older history than this
}
// itemRow is one item plus the one thing the item itself can't tell you: which
// panel it's in. It matters for bond state. An attunement item that is worn
// without a bond is *inert* — on you, doing nothing, and worth shouting about.
// The same item in a backpack isn't inert, it's just not worn yet; gogobee only
// tracks bonds on equipped rows, so its Attuned is undefined rather than false.
// Rendering both as "inert" would invent a problem the player doesn't have.
type itemRow struct {
storage.ItemView
Worn bool
}
func itemRows(items []storage.ItemView, worn bool) []itemRow {
if len(items) == 0 {
return nil
}
out := make([]itemRow, 0, len(items))
for _, it := range items {
out = append(out, itemRow{ItemView: it, Worn: worn})
}
return out
}
// timelineEntry is one line of an adventurer's trail — a fact, rendered short,
// pointing at the dispatch that told it.
type timelineEntry struct {
@@ -150,6 +178,14 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
page.HasSelf = true
page.Self = self
page.Worn = itemRows(self.Equipped, true)
page.Backpack = itemRows(self.Inventory, false)
page.VaultRows = itemRows(self.Vault, false)
for _, it := range self.Equipped {
if it.Attuned {
page.BondsUsed++
}
}
}
}
}

View File

@@ -71,8 +71,23 @@ func seedWho(t *testing.T, owner string) *Server {
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: owner,
Token: "tok-josie",
Inventory: []storage.ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
Inventory: []storage.ItemView{
{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10},
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
Slot: "weapon", SkillSource: "mining", Desc: "A pick balanced for a long seam."},
// Wants a bond, but it's in the backpack: not worn, so not inert.
{Name: "Ring of Protection", Type: "ring", Tier: 4, Value: 900,
Slot: "ring", Attunement: true, Effect: "-8% damage taken"},
},
Equipped: []storage.ItemView{
{Name: "Flame Tongue", Type: "weapon", Value: 5000, Temper: 1, Slot: "weapon",
Desc: "A sword that ignites on command.", Effect: "+15% damage",
Attunement: true, Attuned: true},
// Worn while the bond cap is full: on her, doing nothing.
{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
Effect: "faster to act, +4 HP", Attunement: true, Attuned: false},
},
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
}}}); w.Code != 200 {
@@ -161,6 +176,48 @@ func TestWhoSelfUnlock(t *testing.T) {
}
}
// TestWhoItemDetail: the item panels carry the facts a player decides on —
// descriptions, the engine's effect summary, slot and skill tags — and the worn
// set renders with its bond count.
func TestWhoItemDetail(t *testing.T) {
s := seedWho(t, "josie")
body := getWho(t, s, "tok-josie", "josie").Body.String()
for _, want := range []string{
// "15% damage", not "+15%": html/template escapes a leading + to &#43;.
"Worn", "Flame Tongue", "A sword that ignites on command.", "15% damage",
"1 of 3 bonds in use", // Flame Tongue is bonded; the Cloak is not
"A pick balanced for a long seam.", "mining",
} {
if !strings.Contains(body, want) {
t.Errorf("owner page missing item detail %q", want)
}
}
}
// TestWhoInertOnlyWhenWorn pins the one distinction the panels can get wrong.
// "Inert" is a real problem state — the item is on you and doing nothing because
// every bond slot is full. An unworn attunement item is not inert, it's just not
// worn; gogobee tracks bonds only on equipped rows, so its Attuned is undefined
// rather than false. Rendering both the same would invent a problem the player
// does not have, on the exact panel they'd use to fix it.
func TestWhoInertOnlyWhenWorn(t *testing.T) {
s := seedWho(t, "josie")
body := getWho(t, s, "tok-josie", "josie").Body.String()
// The Cloak is worn with no bond free.
if !strings.Contains(body, "inert") {
t.Error("a worn item with no bond should be called out as inert")
}
if strings.Count(body, "inert") != 1 {
t.Errorf("only the worn unbonded item is inert, got %d mentions", strings.Count(body, "inert"))
}
// The backpack Ring of Protection wants a bond but isn't worn.
if !strings.Contains(body, "needs a bond") {
t.Error("an unworn attunement item should say it needs a bond, not that it is inert")
}
}
// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
// username; the localpart it maps to is lowercase. The owner must still unlock.
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {