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:
@@ -15,17 +15,34 @@ type PlayerDetail struct {
|
||||
Token string `json:"token"`
|
||||
Inventory []ItemView `json:"inventory,omitempty"`
|
||||
Vault []ItemView `json:"vault,omitempty"`
|
||||
Equipped []ItemView `json:"equipped,omitempty"`
|
||||
House HouseView `json:"house"`
|
||||
Pets []PetView `json:"pets,omitempty"`
|
||||
}
|
||||
|
||||
// ItemView is one backpack or vault item.
|
||||
// ItemView is one item in a private panel — backpack, vault, or worn.
|
||||
//
|
||||
// Desc and Effect arrive already resolved: gogobee's inventory rows carry no
|
||||
// description, and the combat delta is computed from the item rather than
|
||||
// stored. Effect is the game engine's own summary, not Pete's guess at one — if
|
||||
// it ever disagrees with what the item does in a fight, that is a gogobee bug
|
||||
// and not something Pete can paper over here.
|
||||
//
|
||||
// Attunement means the item wants a bond; Attuned means it has one. Only worn
|
||||
// items can be Attuned — equipping moves the row out of gogobee's inventory
|
||||
// table entirely, so a backpack item's bond state isn't false, it's undefined.
|
||||
type ItemView struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tier int `json:"tier"`
|
||||
Value int64 `json:"value"`
|
||||
Temper int `json:"temper,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tier int `json:"tier"`
|
||||
Value int64 `json:"value"`
|
||||
Temper int `json:"temper,omitempty"`
|
||||
Slot string `json:"slot,omitempty"`
|
||||
SkillSource string `json:"skill_source,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
Effect string `json:"effect,omitempty"`
|
||||
Attunement bool `json:"attunement,omitempty"`
|
||||
Attuned bool `json:"attuned,omitempty"`
|
||||
}
|
||||
|
||||
// HouseView is the owner's housing summary.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 +.
|
||||
"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) {
|
||||
|
||||
Reference in New Issue
Block a user