Compare commits

2 Commits
Author SHA1 Message Date
prosolis 2593b11112 adventure: fix owner gear regression and drop em-dashes from equip copy
Show the public Gear panel whenever the owner's Equipment panel won't
render (no pushed Slots), so an owner whose detail predates ask 7 still
sees their standard gear instead of a blank. Replace em-dashes in the
equip feature's user-facing strings with plain commas.
2026-07-17 20:48:02 -07:00
prosolis b0aeffd218 adventure: ask 7 — full equipment management from the web
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.
2026-07-17 20:34:24 -07:00
10 changed files with 723 additions and 52 deletions
+347
View File
@@ -0,0 +1,347 @@
# Adventure ask 7 — full equipment management from the web (HANDOFF SPEC)
Status: **BUILT + TESTED + SCREENSHOT-VERIFIED, DEPLOY PENDING** (as of 2026-07-17).
Both sides compile, vet clean, and pass their suites. The owner Equipment panel was
rendered headless (Chrome) in BOTH day and night phases, and the euro confirm dialog
was exercised (upgrade €25,000 and repair €40) — cost + balance math and thousands
separators read right, no purple-on-night contrast issue, public Gear panel correctly
hidden for the owner. Next: commit Pete, commit gogobee, deploy Pete first, then
gogobee (see Build order step 8), then run the live prosolis probe. This doc is the
complete contract + file:line map. Companion to `adventure_expansion_spec.md`
(asks 16) and the memory `project_adventure_expansion.md`.
## What got built (deviations from the spec below, all intentional)
- Pete: `EquipOrder.Tier` + `tier` column (schema + migration); new actions
`upgrade`/`repair`; new verdicts `rejected_downgrade`/`rejected_insufficient_funds`/
`rejected_max_tier`. `EquipSlotView` + `Slots`/`Balance` on `PlayerDetail`.
`handleEquipOrder` resolves take-off/upgrade/repair from `pd.Slots` server-side and
rejects a client-forged tier (409). who.html gained an owner "Equipment" panel with
an in-DOM confirm (cost + balance) for the money actions; public Gear panel hidden
for the owner. output.css rebuilt. Tests in equip_test.go / who exercised via the
real template.
- gogobee: `peteclient` mirror types. New file `pete_equip_manage.go` holds the
headless mutators: `applyMasterworkEquip`/`applyMasterworkUnequip` (free funcs,
sentinel `errEquipDowngrade`), `purchaseEquipmentTier`/`repairSlot` (methods on
`*AdventurePlugin`, euro-idempotent), `buildEquipSlotViews`, `isEquipmentSlot`.
`applyEquipOrder` routes on item Type / slot vocabulary. `itemViews` now gives
masterwork/arena backpack rows an equip id. `buildDetailSnapshot` is now a METHOD
(`p.buildDetailSnapshot`) so it can read the euro balance (nil-guarded for tests).
Tests in pete_equip_manage_test.go.
- **DEVIATION 1 — downgrade block placement:** the masterwork-equip downgrade check
lives INSIDE `applyMasterworkEquip` (returns `errEquipDowngrade`), not in the
router. Behavior/verdict identical; keeps the rule next to the mutation and unit-
testable. Router maps the sentinel to `rejected_downgrade`.
- **DEVIATION 2 — no refund on save fault (both euro mutators):** the spec suggested
`CreditIdem` refund on a later DB error. That is UNSAFE with guid-idempotent retry:
a refund on a fresh id followed by a guid-guarded retry that no longer re-debits
hands the player both gear and money. Instead we return `retry=true` and let the
next poll re-run — the debit is guid-idempotent (skipped) and the slot write is
idempotent. This matches the casino escrow precedent exactly. Do NOT "fix" this by
adding a refund.
- **DEVIATION 3 — upgrades only over PLAIN slots:** `buildEquipSlotViews` offers
`NextTier` only when the slot is plain shop-tier (not masterwork/arena) and sub-max,
and `purchaseEquipmentTier` rejects an upgrade over special gear as
`rejected_downgrade` ("take it off first"). This honors "block downgrades" (buying a
plain tier over special strips its bonus) AND keeps the upgrade path free of the
non-idempotent eviction step, so the retry-safety above holds with no eviction to
reconcile.
Repos: Pete at `/home/reala-misaki/git/pete` (web mirror, deploys to parodia).
gogobee at `/home/reala-misaki/git/gogobee` (game engine, deploys to millenia
`reala@192.168.1.212`). One-way data flow gogobee→Pete; the only route back is the
poll-queue (Pete records intent, gogobee polls + applies + files a verdict).
## Why this exists
Ask 5 built "equip from the web" but scoped it to **magic items only**. The user's
worn gear is almost all the OTHER equipment systems, so the feature touched almost
nothing they own. Diagnosis of user "prosolis" / character "Rurina" (live prod):
- Worn (the 5 `adventure_equipment` slots): weapon **Vorpal Sword** T5 (shop, not mw),
armor **The Deepforged Carapace** T5 **masterwork**, helmet **Crown of the Fallen**
T5 (shop), boots **Ranger's Boots** T4 (shop), tool **Mithril Pickaxe** T4 (shop).
- Backpack (250 items): 1 `MasterworkGear` **The Wandering Sole** (boots, T3), 3 slotted
magic items (Wand of the War Mage off_hand, 2 Weapons main_hand), rest consumables/
materials. `equipped` magic count = 0 (magic slots empty).
So today prosolis gets 3 buried Equip buttons (magic) and nothing else. They want to
manage ALL five slots.
## The three equipment subsystems (do not conflate — this is where it breaks)
1. **Standard tiered gear** — the 5 `EquipmentSlot`s (weapon/armor/helmet/boots/tool),
power = integer `Tier` 0..5, raised by BUYING a tier in the shop (euros). No
inventory item; the slot's tier IS the gear. This is 4 of Rurina's 5 worn pieces.
2. **Masterwork / Arena gear** — special items that live in the backpack
(`adventure_inventory`, `item_type` = `MasterworkGear` / `ArenaGear`), equipped INTO
an `EquipmentSlot` (a swap), round-trippable to the pack.
3. **Magic items** — backpack `item_type='magic_item'`, equipped into DnD slots
(off_hand/main_hand/ring_1…), a DISJOINT slot namespace. Already handled by ask 5.
## User decisions (locked)
- **Scope:** full management incl. shop-tier gear (not just inventory items).
- **Sequencing:** build BOTH phases, deploy together (one drop).
- **Euro spend:** yes, upgrade/repair debit euros from the web, but behind a **confirm
step** showing cost + balance.
- **Downgrades:** BLOCK them (equip and upgrade).
---
# HANDOFF STATE (2026-07-17 — resume here next session)
Both repos build, vet clean, full suites green. **Uncommitted on purpose** — screenshot-
verify the Equipment panel FIRST, then commit each side, then deploy Pete→gogobee, then
the live prosolis probe.
**Order for next session:**
1. **Screenshot-verify** the owner Equipment panel (never rendered in a browser yet; the
`TestEquipPanelRenders` test drives the real template but is not a visual check).
Recipe (same as prior asks): a throwaway test calling `seedEquip`/`getWho` (both in
`internal/web/equip_test.go` / `who_test.go`) that writes the rendered body to an HTML
file, served with `python3 -m http.server` from a dir with a `static` symlink into
`internal/web/static`. `seedEquip` already seeds `Slots` (a masterwork weapon with
Take off + Repair, plain boots with an Upgrade offer) + `Balance` 100000, so the panel
and the confirm dialog both render. Check day AND night phase; confirm the euro
confirm box (cost + balance) pops on Upgrade/Repair click, and the `€` amounts read
right. Watch the [[pete_theme_contrast]] purple-on-night hazard.
2. **Commit Pete** — every changed file below is ask 7 (incl. this doc):
`internal/storage/{db,detail,equip,schema,equip_test}.go`,
`internal/web/{equip,equip_test}.go`, `internal/web/templates/who.html`,
`internal/web/static/css/output.css`, `adventure_ask7_equipment_mgmt.md`.
3. **Commit gogobee** — stage ONLY the ask-7 files (the `gogobee_*.md` plan files are
unrelated mid-flight postgame work — leave them):
`git add internal/peteclient/client.go internal/plugin/pete_detail_test.go internal/plugin/pete_equip.go internal/plugin/pete_roster.go internal/plugin/pete_equip_manage.go internal/plugin/pete_equip_manage_test.go`
4. **Deploy Pete first** (build-on-server-with-cgo per deploy_topology), then gogobee on
millenia. A gogobee verdict string Pete's `validEquipVerdict` rejected would 400+park
the order, so Pete's ingest must accept the new verdicts before gogobee emits them.
5. **Live prosolis probe** (see the Verification section at the bottom). Expected: Take
off on armor (Deepforged Carapace T5 masterwork), Upgrade offers on boots + tool
(T4→T5, €25000), Repair where condition<100, Equip on The Wandering Sole BLOCKED as a
downgrade vs worn T4 boots, + the 3 magic items.
Baseline unchanged: Pete tip `1159e64`, gogobee tip `b29dcf4`. Nothing committed yet.
---
# THE WIRE CONTRACT (both repos must agree)
## equip_orders (Pete `internal/storage/equip.go` + gogobee `peteclient.EquipOrder`)
- **New column** `tier INTEGER NOT NULL DEFAULT 0` on `equip_orders`
(Pete `schema.go` CREATE at :176-191 + `addColumnIfMissing(d,"equip_orders","tier",...)`
in `db.go`, following the existing pattern at db.go:81+). Add `Tier int` to the
`EquipOrder` struct (Pete storage + gogobee `peteclient/client.go:689-698`), plumb
through Insert/scan/Pending/ByOwner and the JSON.
- **Actions** (`Action` field): existing `equip`, `unequip`; NEW `upgrade`, `repair`.
- **Field use per action:**
- `equip` (magic OR masterwork/arena): `ItemID` = `adventure_inventory` row id,
`Slot` = the item's slot (DnD slot for magic, EquipmentSlot for masterwork).
- `unequip` / take-off: `Slot` only (DnD slot → magic path; EquipmentSlot → masterwork).
- `upgrade`: `Slot` = EquipmentSlot, `Tier` = target tier. `ItemID` unused.
- `repair`: `Slot` = EquipmentSlot. `Tier`/`ItemID` unused.
- **Verdicts** (add to Pete `validEquipVerdict` + gogobee return strings): existing
`applied`, `rejected_not_owned`, `rejected_not_worn`, `rejected_not_equippable`;
NEW `rejected_downgrade`, `rejected_insufficient_funds`, `rejected_max_tier`.
Give each a friendly message in Pete's who.html JS `verdict` map (who.html ~:430).
## Detail push (Pete `internal/storage/detail.go` PlayerDetail + gogobee peteclient)
Add to `PlayerDetail`:
- `Slots []EquipSlotView` — the 5 standard slots, owner-only, for the management panel.
- `Balance float64` (`json:"balance,omitempty"`) — the owner's euro balance, for the
confirm dialogs.
New type (both repos):
```go
type EquipSlotView struct {
Slot string `json:"slot"` // weapon|armor|helmet|boots|tool
Name string `json:"name"`
Tier int `json:"tier"`
Condition int `json:"condition"`
Masterwork bool `json:"masterwork,omitempty"`
ArenaTier int `json:"arena_tier,omitempty"`
CanTakeOff bool `json:"can_take_off,omitempty"` // masterwork/arena → round-trippable
NextTier int `json:"next_tier,omitempty"` // 0 = at max tier (5)
NextName string `json:"next_name,omitempty"`
NextPrice float64 `json:"next_price,omitempty"`
RepairCost int `json:"repair_cost,omitempty"` // 0 = full condition
}
```
Worn masterwork/arena pieces are represented HERE (via `CanTakeOff`), NOT duplicated
into `Equipped`. `Equipped` stays magic-only (the DnD slots). Backpack items
(`Inventory`) keep the ItemView shape; masterwork/arena backpack rows now also get an
equip `ID` (see gogobee itemViews change) so they render Equip buttons.
---
# PHASE A — equip / take off inventory gear (masterwork + arena + magic). No money.
### gogobee changes
1. **`itemViews`** (`pete_roster.go:222-263`): currently sets `ItemView.ID = it.ID`
only for slotted magic items (:242-255); masterwork/arena backpack rows fall to the
`else if it.Slot != ""` branch (:256-259) with no id. ALSO set `v.ID = it.ID` when
`it.Type == "MasterworkGear" || it.Type == "ArenaGear"` (they carry a slot). This is
the whole reason a masterwork backpack item currently has no Equip button.
2. **`attachInventoryCompares`** (`pete_roster.go:269-280`): it decorates any row with
`ID != 0` by calling `magicItemCompare`. Now that masterwork rows have ids, GUARD it
to magic-only (skip rows where `magicItemFromAdvItem` fails). Masterwork gets no
compare card for now (fine).
3. **`equippedViews`** stays magic-only (`pete_roster.go:408-431`). Worn masterwork/arena
are surfaced via `Slots`/`EquipSlotView` instead (see Phase-common detail build).
4. **Extract headless mutators** mirroring `applyMagicEquip`/`applyMagicUnequip`
(`magic_items_gameplay.go:592-654` / `:665-688`) and the DM confirm logic
(`adventure_masterwork.go:487-587`):
- `applyMasterworkEquip(uid id.UserID, it AdvItem) (mwEquipOutcome, error)`:
require `it.Slot != ""` and Type MasterworkGear/ArenaGear else `errItemNotEquippable`
(reuse the sentinel at `magic_items_gameplay.go:573`). Load `loadAdvEquipment`; if the
current occupant is special (`Masterwork || ArenaTier>0`) evict it back to inventory
as a MasterworkGear/ArenaGear `AdvItem` (see the confirm handler :530-545 for the
exact reconstruction, incl. `arenaGearByName(name).SetKey` at :563). **Anti-dup
ordering = magic's**: `removeAdvInventoryItem(it.ID)` FIRST, then `saveAdvEquipment`,
restore the inventory row on save failure. Set the new row fields exactly like the
confirm handler :547-571 (Tier, Condition=100, Name, ActionsUsed=0, and Masterwork+
SkillSource OR ArenaTier+ArenaSet).
- `applyMasterworkUnequip(uid id.UserID, slot EquipmentSlot) (mwUnequipOutcome, error)`:
load equip; if the slot is NOT special (`!Masterwork && ArenaTier==0`) → `errSlotEmpty`
(`:576`) → `rejected_not_worn` (there is nothing round-trippable to take off; plain
shop-tier reverts via Phase B, not here). Otherwise move the piece to inventory
(MasterworkGear/ArenaGear AdvItem) and RESET the slot row to its tier-0 default:
`tier=0, condition=100, name = equipmentTiers[slot][0].Name, actions_used=0,
arena_tier=0, arena_set='', masterwork=0, skill_source=''` (matches the creation
seed at `adventure_character.go:528-537`). Keep the row (do NOT delete — the 5 rows
are an invariant; PK user_id+slot).
5. **`applyEquipOrder`** (`pete_equip.go:124-173`) routing:
- `equip`: load the AdvItem by `order.ItemID` (as today, :126-152). Branch on Type:
`MasterworkGear`/`ArenaGear``applyMasterworkEquip` (with downgrade block, below);
else → `applyMagicEquip` (unchanged). Miss → `rejected_not_owned`.
- `unequip`: branch on `order.Slot`: if it's an EquipmentSlot value
(weapon/armor/helmet/boots/tool) → `applyMasterworkUnequip(EquipmentSlot)`;
else → `applyMagicUnequip(DnDSlot)`. (Slot vocabularies are DISJOINT —
`EquipmentSlot` vs `DnDSlot` — confirmed, so the string alone disambiguates.)
6. **Downgrade block** (masterwork equip): before applying, compare
`advEffectiveTier(incoming)` vs `advEffectiveTier(currentOccupant)`
(`adventure_character.go:421-432`: arena ×1.5, masterwork ×1.25, else ×1). If
`incoming <= current` return `rejected_downgrade`. Magic equip is NOT downgrade-blocked
(its target DnD slots are usually empty and the compare card already informs).
### Pete changes (Phase A)
- Buttons already render off `ID`/worn in `who.go itemRows` (:123-145) + the `itemrow`
template (who.html :8-47). Take-off for masterwork slots is rendered from `Slots`
(see Pete Phase-common UI). Add `rejected_downgrade` to `validEquipVerdict`
(`storage/equip.go:63`) + the JS verdict map.
- `handleEquipOrder` (`web/equip.go:47-134`) already resolves an equip item from
`pd.Inventory` by id and an unequip from `pd.Equipped` by slot. Take-off of a masterwork
slot comes from `Slots`, so add resolution of a take-off/upgrade/repair against
`pd.Slots` (see Phase B handler notes — same code path).
---
# PHASE B — upgrade / repair the 5 standard slots (web shop). Spends euros (confirm-gated).
### gogobee changes
1. **Extract `purchaseEquipmentTier(uid id.UserID, slot EquipmentSlot, tier int, guid string) (outcome, error)`**
from the body of `advBuyEquipment` (`adventure_shop.go:742-829`), MINUS flavor text:
- `def := equipmentTiers[slot][tier]` (`adventure_character.go:179-220`; 6 tiers 0..5;
`EquipmentDef{Name,Tier,Description,Price}` at :172-177). Guard `tier` in range;
`tier >= len``rejected_max_tier`.
- **Downgrade block** = existing shop rule: block if `float64(def.Tier) <= advEffectiveTier(current)`
for masterwork, `def.Tier <= current.ArenaTier` for arena, `current.Tier >= def.Tier`
for plain (see `adventure_shop.go:744` / :750-757) → `rejected_downgrade`.
- **Idempotent euro**: `if !p.euro.HasExternalTx(guid)` gate the affordability check,
then `p.euro.DebitIdem(uid, def.Price, "adventure_equip_upgrade", guid)`
(`euro.go:452-461`; balance/ok/err). Insufficient → `rejected_insufficient_funds`.
Refund on later DB error via `CreditIdem` (`:465-475`). DO NOT use `Debit`/`Credit`
(non-idempotent) — the euro header at `euro.go:434-446` says web-initiated MUST use
the Idem variants. Precedent: casino escrow `pete_games.go:101-130`.
- Move old special gear to inventory (like the shop does), then `saveAdvEquipment`
with the new tier row (Tier, Condition=100, Name=def.Name, ActionsUsed=0,
Masterwork=false, ArenaTier=0). Community-pot 5% cut is OPTIONAL for web — decide;
simplest to skip it or mirror `communityPotAdd` (`adventure_shop.go:564-567`).
- NOTE: shop charges FULL `def.Price` for the chosen tier (not incremental). The web
UI should offer upgrading to the NEXT tier only (NextTier/NextPrice in EquipSlotView)
to keep it simple; the order carries the explicit target `Tier`.
2. **Extract headless repair** from `executeRepair` (`adventure_blacksmith.go:262-333`)
— it already takes only `userID` + a confirm struct and is Matrix-free except the
trailing `SendDM`. `repair(uid, slot, guid)`: recompute `blacksmithRepairCost(eq)`
(`:17-40`, base rates `:15`), `HasExternalTx`-gate + `DebitIdem(uid, cost,
"adventure_repair", guid)`, set `eq.Condition=100`, `saveAdvEquipment`, refund on error.
Condition already full → `rejected_no_change` (or just `applied` no-op; pick one and add
to the verdict set if used).
3. **Poller routing** in `applyEquipOrder`: `upgrade` → `purchaseEquipmentTier(owner,
order.Slot, order.Tier, order.GUID)`; `repair` → `repair(owner, order.Slot, order.GUID)`.
The GUID is the idempotency key for BOTH the euro move (DebitIdem externalID) AND the
existing `equip_applied_orders` ledger (`pete_equip.go:213-240`) — belt and suspenders.
4. **Build `Slots` + `Balance` in the detail push** (`buildDetailSnapshot` /
PlayerDetail assembly `pete_roster.go:190-211`). For each `allSlots` slot read
`loadAdvEquipment` (already used for public gear at rosterDetail :136): fill Name/Tier/
Condition/Masterwork/ArenaTier; `CanTakeOff = Masterwork || ArenaTier>0`;
`NextTier/NextName/NextPrice` from `equipmentTiers[slot][Tier+1]` if `Tier < 5` and it
isn't a downgrade; `RepairCost = blacksmithRepairCost(eq)` if `Condition < 100`.
`Balance = p.euro.GetBalance(uid)` (`euro.go:408-417`).
### Pete changes (Phase B)
- `storage/equip.go`: add `Tier` to EquipOrder + Insert/scan/queries; add the three new
verdicts to `validEquipVerdict`; add `upgrade`/`repair` to `validEquipAction`.
- `web/equip.go handleEquipOrder`: accept `upgrade`/`repair` actions. Resolve the slot
from `pd.Slots` (verify it exists and, for upgrade, that `req.Tier == slot.NextTier`
and `NextTier != 0`; for repair that `RepairCost > 0`). Reject client-forged tiers —
trust only the pushed `EquipSlotView`, exactly as ask 5 resolves item facts server-side.
- `web/who.html` + `who.go`: build an owner "Equipment" panel from `.Slots` — per slot a
card showing Name (T{Tier}, {Condition}%), and buttons: **Take off** if `CanTakeOff`,
**Upgrade to {NextName} · €{NextPrice}** if `NextTier>0`, **Repair · €{RepairCost}** if
`RepairCost>0`. Magic worn + backpack panels stay as they are. **Hide the public "Gear"
panel (who.html :120-135) for the owner** (`{{if not .HasSelf}}`) since this panel
supersedes it. New CSS classes → rebuild + commit `output.css`
(`npx tailwindcss -i internal/web/static/css/input.css -o …/output.css --minify`).
- **Confirm step**: for `upgrade`/`repair` (euro-spending) the JS must pop a confirm
showing cost + `page.Balance` before POSTing the order (per user decision). `equip`/
`unequip`/take-off place directly (no money). Reuse the equip JS at who.html ~:416-490.
---
# Cross-cutting / gotchas
- **Deploy order**: Pete ingest + verdict handlers accept the new actions/verdicts BEFORE
gogobee emits them. New order actions are additive on Pete's side. New DETAIL fields are
`omitempty` → safe either order. But a new gogobee VERDICT string that Pete's
`validEquipVerdict` rejects would 400 and park the order — so ship Pete first. (Same rule
as ask 1's event_type.)
- **Pete builds WITH cgo ON the server** (sqlite). See `deploy_topology` memory. gogobee
builds on millenia. Deploy = push to gitea → server `git pull --ff-only` +
`CGO_ENABLED=1 go build -o pete.new .` → swap → restart screen `pete`.
- **output.css is a committed build artifact** — new Tailwind classes silently no-op in
prod if not rebuilt+committed (bit us on `sm:grid-cols-5`).
- **Euro debt limit** applies (`BLACKJACK_DEBT_LIMIT` default 1000). A web upgrade that
would breach it is refused by `DebitIdem` → `rejected_insufficient_funds`.
- **Idempotency is doubled**: the order GUID keys BOTH `DebitIdem`'s externalID AND the
`equip_applied_orders` ledger. A retried poll re-files the stored verdict and moves no
money. Verify a mid-apply crash can't double-charge (DebitIdem is the guard; record the
applied-order ledger AFTER a successful apply, as fulfilEquipOrder already does :104).
# Testing (both repos)
- gogobee: unit-test each headless mutator; assert downgrade block, max-tier, insufficient-
funds, idempotent replay (same guid twice → one debit), masterwork equip evicts special
occupant / overwrites plain, take-off resets to tier-0.
- Pete: seed a PlayerDetail with `Slots` + a masterwork worn piece + masterwork backpack
item and assert the who page renders Take off / Upgrade / Repair buttons and the confirm
data. Use the `seedWho`/`getWho` throwaway render pattern (see
`project_adventure_expansion` memory + prior sessions' scratch test). Assert
`handleEquipOrder` rejects a client-forged tier and a non-owner.
- Verify verdict strings round-trip; `TestClearCookie…`-style table tests fit.
# Verification against live data (prod probe scripts were in this session's scratchpad,
# which is EPHEMERAL — re-create as needed). Read prod detail via
# `ssh reala@www.parodia.dev 'cd /opt/pete && python3 -'` piping a small script that opens
# data/pete.db and json-loads player_self_detail.detail_json (localpart 'prosolis') or
# adventure_roster.detail_json (name 'rurina'). Expected post-ship for prosolis: Take off
# on armor (Deepforged Carapace), Upgrade offered on weapon→? (already T5 max → none),
# boots Upgrade T4→T5 (€25000) etc., Repair where condition<100, Equip on The Wandering
# Sole (BLOCKED as downgrade vs worn T4 boots) + 3 magic items.
# Deployed baseline at handoff
- Pete tip `1159e64` live on parodia. gogobee tip `b29dcf4` live on millenia (contains all
ask 16 commits). Nothing for ask 7 written yet. gogobee has unrelated uncommitted
postgame-zone work in its tree — keep ask-7 edits in separate commits, stage by name.
# Build order (tasks)
1. Wire contract types both repos (EquipOrder.Tier, EquipSlotView, verdicts, actions).
2. gogobee headless mutators (masterwork equip/unequip, purchaseEquipmentTier, repair).
3. gogobee detail push (Slots + Balance; itemViews masterwork id; compare guard).
4. gogobee poller routing + downgrade block.
5. Pete storage (tier column, actions, verdicts) + handlers.
6. Pete who.html/who.go equipment panel + confirm JS + hide public Gear for owner.
7. Tests both sides; rebuild+commit output.css; gofmt.
8. Deploy Pete first, then gogobee; verify live with prosolis.
+2
View File
@@ -105,6 +105,8 @@ func runMigrations(d *sql.DB) error {
// recorded before the treasure_found event existed carry NULL, which is right:
// they had no such noun to keep.
addColumnIfMissing(d, "adventure_events", "stakes", "TEXT")
// Ask 7: upgrade orders carry a target tier for the 5 standard equipment slots.
addColumnIfMissing(d, "equip_orders", "tier", "INTEGER NOT NULL DEFAULT 0")
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
// Check sqlite_master before creating.
+27
View File
@@ -18,6 +18,33 @@ type PlayerDetail struct {
Equipped []ItemView `json:"equipped,omitempty"`
House HouseView `json:"house"`
Pets []PetView `json:"pets,omitempty"`
// Slots is the 5 standard equipment slots (weapon/armor/helmet/boots/tool) —
// owner-only, the input to the web equipment-management panel. Worn
// masterwork/arena pieces surface HERE (via CanTakeOff), not in Equipped, which
// stays magic-only (the DnD slots). See EquipSlotView.
Slots []EquipSlotView `json:"slots,omitempty"`
// Balance is the owner's euro balance, for the upgrade/repair confirm dialogs.
Balance float64 `json:"balance,omitempty"`
}
// EquipSlotView is one of the 5 standard equipment slots as gogobee pushed it,
// carrying everything the management panel needs to render its controls: what is
// worn now, whether it can be taken off (masterwork/arena round-trip to the pack),
// the next tier's name and price for an upgrade offer, and a repair cost when the
// piece is damaged. Pete renders it verbatim and trusts only these facts — a
// client-forged tier or price is ignored, resolved back against this view.
type EquipSlotView struct {
Slot string `json:"slot"` // weapon|armor|helmet|boots|tool
Name string `json:"name"`
Tier int `json:"tier"`
Condition int `json:"condition"`
Masterwork bool `json:"masterwork,omitempty"`
ArenaTier int `json:"arena_tier,omitempty"`
CanTakeOff bool `json:"can_take_off,omitempty"` // masterwork/arena → round-trippable to the pack
NextTier int `json:"next_tier,omitempty"` // 0 = at max tier (5), no upgrade offered
NextName string `json:"next_name,omitempty"`
NextPrice float64 `json:"next_price,omitempty"`
RepairCost int `json:"repair_cost,omitempty"` // 0 = full condition, nothing to repair
}
// ItemView is one item in a private panel — backpack, vault, or worn.
+29 -12
View File
@@ -32,7 +32,8 @@ type EquipOrder struct {
ItemID int64 `json:"item_id,omitempty"` // adventure_inventory row id, for an equip; unused for unequip
ItemName string `json:"item_name"` // display copy
Slot string `json:"slot"` // the magic-item slot to fill or clear
Action string `json:"action"` // equip / unequip
Action string `json:"action"` // equip / unequip / upgrade / repair
Tier int `json:"tier,omitempty"` // upgrade target tier (an EquipmentSlot tier); unused by the other actions
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
CreatedAt int64 `json:"created_at"`
@@ -40,9 +41,15 @@ type EquipOrder struct {
}
// Actions. These cross the wire to gogobee, so they are part of the contract.
// equip/unequip move an inventory item (magic) or a masterwork/arena piece; a
// take-off of a standard slot rides unequip too (the slot vocabularies are
// disjoint, so the string alone tells gogobee which path to run). upgrade and
// repair act on the 5 standard EquipmentSlots and spend euros on the game box.
const (
EquipActionEquip = "equip"
EquipActionUnequip = "unequip"
EquipActionUpgrade = "upgrade"
EquipActionRepair = "repair"
)
// Order states. Terminal states are enumerated, not free-text, so the page can
@@ -57,19 +64,29 @@ const (
EquipRejectedNotOwned = "rejected_not_owned" // the item is no longer in the pack (stale page)
EquipRejectedNotWorn = "rejected_not_worn" // unequip of a slot that's already empty
EquipRejectedNotEquipp = "rejected_not_equippable" // the item has no slot to fill
// Ask 7 additions. A downgrade equip/upgrade is blocked by user decision; the
// euro-spending actions can bounce on funds or top out at the max tier.
EquipRejectedDowngrade = "rejected_downgrade" // equipping/upgrading to something no better than what's worn
EquipRejectedNoFunds = "rejected_insufficient_funds" // the euro debit would breach the debt limit
EquipRejectedMaxTier = "rejected_max_tier" // already at the top standard tier, nothing to buy
)
// validEquipVerdict is the set of terminal states gogobee may hand back.
func validEquipVerdict(status string) bool {
switch status {
case EquipApplied, EquipRejectedNotOwned, EquipRejectedNotWorn, EquipRejectedNotEquipp:
case EquipApplied, EquipRejectedNotOwned, EquipRejectedNotWorn, EquipRejectedNotEquipp,
EquipRejectedDowngrade, EquipRejectedNoFunds, EquipRejectedMaxTier:
return true
}
return false
}
func validEquipAction(action string) bool {
return action == EquipActionEquip || action == EquipActionUnequip
switch action {
case EquipActionEquip, EquipActionUnequip, EquipActionUpgrade, EquipActionRepair:
return true
}
return false
}
var ErrNoSuchEquipOrder = errors.New("equip: no such order")
@@ -79,7 +96,7 @@ var ErrNoSuchEquipOrder = errors.New("equip: no such order")
// click, before gogobee has heard of it. The caller has already checked the owner
// is signed in and owns the page; eligibility (still-owned, wearable, bond cap) is
// gogobee's, at verdict time.
func InsertEquipOrder(ownerSub, ownerLocalpart, characterName string, itemID int64, itemName, slot, action string) (EquipOrder, error) {
func InsertEquipOrder(ownerSub, ownerLocalpart, characterName string, itemID int64, itemName, slot, action string, tier int) (EquipOrder, error) {
if !validEquipAction(action) {
return EquipOrder{}, fmt.Errorf("equip: bad action %q", action)
}
@@ -90,16 +107,16 @@ func InsertEquipOrder(ownerSub, ownerLocalpart, characterName string, itemID int
now := nowUnix()
if _, err := Get().Exec(
`INSERT INTO equip_orders
(guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
guid, ownerSub, ownerLocalpart, characterName, itemID, itemName, slot, action, EquipPending, now, now,
(guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, tier, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
guid, ownerSub, ownerLocalpart, characterName, itemID, itemName, slot, action, tier, EquipPending, now, now,
); err != nil {
return EquipOrder{}, fmt.Errorf("equip: insert order: %w", err)
}
return EquipOrder{
GUID: guid, OwnerSub: ownerSub, OwnerLocalpart: ownerLocalpart,
CharacterName: characterName, ItemID: itemID, ItemName: itemName,
Slot: slot, Action: action, Status: EquipPending, CreatedAt: now, UpdatedAt: now,
Slot: slot, Action: action, Tier: tier, Status: EquipPending, CreatedAt: now, UpdatedAt: now,
}, nil
}
@@ -112,7 +129,7 @@ func PendingEquipOrders(limit int) ([]EquipOrder, error) {
limit = 100
}
rows, err := Get().Query(
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, COALESCE(detail, ''), created_at, updated_at
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, tier, status, COALESCE(detail, ''), created_at, updated_at
FROM equip_orders
WHERE status = ?
ORDER BY created_at
@@ -148,7 +165,7 @@ func ResolveEquipOrder(guid, status, detail string) (EquipOrder, error) {
// EquipOrderByGUID reads one order.
func EquipOrderByGUID(guid string) (EquipOrder, error) {
rows, err := Get().Query(
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, COALESCE(detail, ''), created_at, updated_at
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, tier, status, COALESCE(detail, ''), created_at, updated_at
FROM equip_orders WHERE guid = ?`, guid,
)
if err != nil {
@@ -173,7 +190,7 @@ func EquipOrdersByOwner(ownerSub string, limit int) ([]EquipOrder, error) {
limit = 20
}
rows, err := Get().Query(
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, COALESCE(detail, ''), created_at, updated_at
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, tier, status, COALESCE(detail, ''), created_at, updated_at
FROM equip_orders
WHERE owner_sub = ?
ORDER BY created_at DESC
@@ -206,7 +223,7 @@ func scanEquipOrders(rows *sql.Rows) ([]EquipOrder, error) {
for rows.Next() {
var o EquipOrder
if err := rows.Scan(&o.GUID, &o.OwnerSub, &o.OwnerLocalpart, &o.CharacterName,
&o.ItemID, &o.ItemName, &o.Slot, &o.Action, &o.Status, &o.Detail,
&o.ItemID, &o.ItemName, &o.Slot, &o.Action, &o.Tier, &o.Status, &o.Detail,
&o.CreatedAt, &o.UpdatedAt); err != nil {
return nil, fmt.Errorf("equip: scan order: %w", err)
}
+7 -7
View File
@@ -9,7 +9,7 @@ import (
func TestEquipOrderLifecycle(t *testing.T) {
setupTestDB(t)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 42, "Cloak of Elvenkind", "cloak", EquipActionEquip)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 42, "Cloak of Elvenkind", "cloak", EquipActionEquip, 0)
if err != nil {
t.Fatal(err)
}
@@ -47,7 +47,7 @@ func TestEquipOrderLifecycle(t *testing.T) {
func TestEquipResolveIsIdempotent(t *testing.T) {
setupTestDB(t)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 7, "Ring of Protection", "ring_1", EquipActionEquip)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 7, "Ring of Protection", "ring_1", EquipActionEquip, 0)
if err != nil {
t.Fatal(err)
}
@@ -70,7 +70,7 @@ func TestEquipResolveUnknownAndBadVerdict(t *testing.T) {
t.Fatalf("unknown guid err = %v, want ErrNoSuchEquipOrder", err)
}
o, _ := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Boots", "feet", EquipActionEquip)
o, _ := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Boots", "feet", EquipActionEquip, 0)
if _, err := ResolveEquipOrder(o.GUID, "exploded", ""); err == nil {
t.Error("a bogus verdict status was accepted")
}
@@ -83,7 +83,7 @@ func TestEquipResolveUnknownAndBadVerdict(t *testing.T) {
// that isn't equip/unequip must not reach the table.
func TestEquipInsertRejectsBadAction(t *testing.T) {
setupTestDB(t)
if _, err := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Thing", "cloak", "wield"); err == nil {
if _, err := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Thing", "cloak", "wield", 0); err == nil {
t.Fatal("a bogus action was accepted")
}
}
@@ -92,7 +92,7 @@ func TestEquipInsertRejectsBadAction(t *testing.T) {
// so it rides on the slot alone — item_id 0 is expected, not a bug.
func TestEquipUnequipCarriesSlotNotItem(t *testing.T) {
setupTestDB(t)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 0, "Cloak of Elvenkind", "cloak", EquipActionUnequip)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 0, "Cloak of Elvenkind", "cloak", EquipActionUnequip, 0)
if err != nil {
t.Fatal(err)
}
@@ -106,11 +106,11 @@ func TestEquipOrdersByOwnerAndCount(t *testing.T) {
setupTestDB(t)
for i := 0; i < 3; i++ {
if _, err := InsertEquipOrder("sub-A", "alice", "Alice", int64(i+1), "Item", "cloak", EquipActionEquip); err != nil {
if _, err := InsertEquipOrder("sub-A", "alice", "Alice", int64(i+1), "Item", "cloak", EquipActionEquip, 0); err != nil {
t.Fatal(err)
}
}
if _, err := InsertEquipOrder("sub-B", "bob", "Bob", 9, "Item", "cloak", EquipActionEquip); err != nil {
if _, err := InsertEquipOrder("sub-B", "bob", "Bob", 9, "Item", "cloak", EquipActionEquip, 0); err != nil {
t.Fatal(err)
}
+2 -1
View File
@@ -181,7 +181,8 @@ CREATE TABLE IF NOT EXISTS equip_orders (
item_id INTEGER NOT NULL DEFAULT 0,
item_name TEXT NOT NULL DEFAULT '',
slot TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL, -- equip / unequip
action TEXT NOT NULL, -- equip / unequip / upgrade / repair
tier INTEGER NOT NULL DEFAULT 0, -- upgrade target tier; unused by the other actions
status TEXT NOT NULL, -- see the ladder above
detail TEXT, -- gogobee's human note on the verdict
created_at INTEGER NOT NULL,
+70 -19
View File
@@ -37,6 +37,7 @@ type equipOrderReq struct {
Action string `json:"action"`
ItemID int64 `json:"item_id"`
Slot string `json:"slot"`
Tier int `json:"tier"` // upgrade only: the target standard tier; verified against the pushed slot view
}
// handleEquipOrder places a pending equip/unequip for the signed-in owner. It
@@ -63,7 +64,10 @@ func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
writeEquipError(w, http.StatusBadRequest, "no character")
return
}
if req.Action != storage.EquipActionEquip && req.Action != storage.EquipActionUnequip {
switch req.Action {
case storage.EquipActionEquip, storage.EquipActionUnequip,
storage.EquipActionUpgrade, storage.EquipActionRepair:
default:
writeEquipError(w, http.StatusBadRequest, "bad action")
return
}
@@ -82,25 +86,60 @@ func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
return
}
// Resolve the item from the owner's own panels — never from the client — so a
// forged name or slot can't ride into the order. An equip names a backpack item
// by its row id (present only on wearable magic items); an unequip names a worn
// slot.
var itemName, slot string
if req.Action == storage.EquipActionEquip {
// Resolve every fact of the order from the owner's own pushed detail — never
// from the client — so a forged name, slot, tier, or price can't ride in. An
// equip names a backpack item by its row id; an unequip/take-off names a worn
// slot (a magic DnD slot in Equipped, or a masterwork/arena standard slot in
// Slots); upgrade and repair name a standard slot in Slots and move money, so
// the target tier is trusted only when it matches the slot's pushed NextTier.
var (
itemName string
slot string
itemID int64
tier int
)
switch req.Action {
case storage.EquipActionEquip:
it, found := findBackpackItem(pd.Inventory, req.ItemID)
if !found {
writeEquipError(w, http.StatusBadRequest, "that item isn't in your pack")
return
}
itemName, slot = it.Name, it.Slot
} else {
it, found := findWornSlot(pd.Equipped, req.Slot)
if !found {
writeEquipError(w, http.StatusBadRequest, "nothing's in that slot")
itemName, slot, itemID = it.Name, it.Slot, req.ItemID
case storage.EquipActionUnequip:
// Magic take-off keys on a DnD slot in Equipped; masterwork/arena take-off
// keys on a standard slot in Slots (CanTakeOff). The vocabularies are disjoint,
// so try each — gogobee disambiguates the same way. A plain shop-tier slot has
// nothing to round-trip, so it never resolves here (revert is an upgrade path).
if it, found := findWornSlot(pd.Equipped, req.Slot); found {
itemName, slot = it.Name, it.Slot
} else if sv, found := findSlotView(pd.Slots, req.Slot); found && sv.CanTakeOff {
itemName, slot = sv.Name, sv.Slot
} else {
writeEquipError(w, http.StatusBadRequest, "nothing to take off there")
return
}
itemName, slot = it.Name, it.Slot
case storage.EquipActionUpgrade:
sv, found := findSlotView(pd.Slots, req.Slot)
if !found || sv.NextTier == 0 {
writeEquipError(w, http.StatusBadRequest, "no upgrade available for that slot")
return
}
if req.Tier != sv.NextTier {
// The web offers the next tier only; a request for anything else is a stale
// page or a forged jump. Refuse rather than debit for a tier the owner never
// saw priced.
writeEquipError(w, http.StatusConflict, "that upgrade is out of date, reload the page")
return
}
itemName, slot, tier = sv.NextName, sv.Slot, sv.NextTier
case storage.EquipActionRepair:
sv, found := findSlotView(pd.Slots, req.Slot)
if !found || sv.RepairCost == 0 {
writeEquipError(w, http.StatusBadRequest, "nothing to repair there")
return
}
itemName, slot = sv.Name, sv.Slot
}
since := time.Now().Add(-equipBurstWindow).Unix()
@@ -109,7 +148,7 @@ func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
writeEquipError(w, http.StatusInternalServerError, "internal error")
return
} else if n >= equipBurstMax {
writeEquipError(w, http.StatusTooManyRequests, "slow down too many changes in a short while")
writeEquipError(w, http.StatusTooManyRequests, "slow down, too many changes in a short while")
return
}
@@ -118,11 +157,7 @@ func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
characterName = entry.Name
}
itemID := req.ItemID
if req.Action == storage.EquipActionUnequip {
itemID = 0 // a worn item's inventory row is gone; the slot is the handle
}
order, err := storage.InsertEquipOrder(u.Sub, owner, characterName, itemID, itemName, slot, req.Action)
order, err := storage.InsertEquipOrder(u.Sub, owner, characterName, itemID, itemName, slot, req.Action, tier)
if err != nil {
slog.Error("equip: insert order", "err", err)
writeEquipError(w, http.StatusInternalServerError, "internal error")
@@ -161,6 +196,22 @@ func findWornSlot(items []storage.ItemView, slot string) (storage.ItemView, bool
return storage.ItemView{}, false
}
// findSlotView finds one of the 5 standard equipment slots by name. It is the
// server-side source of truth for a take-off / upgrade / repair: the request
// names a slot, and every other fact (name, next tier, price, repair cost) is
// read from here rather than trusted from the client.
func findSlotView(slots []storage.EquipSlotView, slot string) (storage.EquipSlotView, bool) {
if slot == "" {
return storage.EquipSlotView{}, false
}
for _, sv := range slots {
if sv.Slot == slot {
return sv, true
}
}
return storage.EquipSlotView{}, false
}
// handleEquipOrders returns the signed-in owner's own recent equip orders for the
// status strip, newest first. Scoped to their OIDC subject.
func (s *Server) handleEquipOrders(w http.ResponseWriter, r *http.Request) {
+115
View File
@@ -3,6 +3,7 @@ package web
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -41,6 +42,16 @@ func seedEquip(t *testing.T, owner string) *Server {
{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
Effect: "faster to act", Attunement: true, Attuned: true},
},
// The 5 standard slots (ask 7). weapon is a worn masterwork (round-trippable,
// at max tier, damaged → repairable); boots is plain shop-tier at T3 with a
// T4 upgrade offered and nothing to take off or repair.
Slots: []storage.EquipSlotView{
{Slot: "weapon", Name: "Deepforged Blade", Tier: 5, Condition: 80,
Masterwork: true, CanTakeOff: true, RepairCost: 40},
{Slot: "boots", Name: "Leather Boots", Tier: 3, Condition: 100,
NextTier: 4, NextName: "Sturdy Boots", NextPrice: 25000},
},
Balance: 100000,
}}}); w.Code != 200 {
t.Fatalf("seed detail = %d", w.Code)
}
@@ -122,6 +133,110 @@ func TestEquipOrderRejections(t *testing.T) {
}
}
// TestEquipTakeOffMasterwork: a worn masterwork piece in a standard slot rides the
// unequip action, resolved from Slots (CanTakeOff), keyed on the slot with no item id.
func TestEquipTakeOffMasterwork(t *testing.T) {
s := seedEquip(t, "reala")
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "weapon"})
if w.Code != 200 {
t.Fatalf("take off = %d body=%s", w.Code, w.Body.String())
}
var o storage.EquipOrder
_ = json.Unmarshal(w.Body.Bytes(), &o)
if o.Action != "unequip" || o.Slot != "weapon" || o.ItemName != "Deepforged Blade" || o.ItemID != 0 {
t.Fatalf("take-off order = %+v", o)
}
}
// TestEquipUpgradeHappy: upgrading the boots to their next tier queues an upgrade
// order carrying the target tier and the tier's name — both resolved from the
// pushed slot view, not the request.
func TestEquipUpgradeHappy(t *testing.T) {
s := seedEquip(t, "reala")
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "boots", Tier: 4})
if w.Code != 200 {
t.Fatalf("upgrade = %d body=%s", w.Code, w.Body.String())
}
var o storage.EquipOrder
_ = json.Unmarshal(w.Body.Bytes(), &o)
if o.Action != "upgrade" || o.Slot != "boots" || o.Tier != 4 || o.ItemName != "Sturdy Boots" || o.ItemID != 0 {
t.Fatalf("upgrade order = %+v", o)
}
}
// TestEquipRepairHappy: repairing a damaged slot queues a repair order keyed on the
// slot, no money in the request — the cost is gogobee's at apply time.
func TestEquipRepairHappy(t *testing.T) {
s := seedEquip(t, "reala")
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "repair", Slot: "weapon"})
if w.Code != 200 {
t.Fatalf("repair = %d body=%s", w.Code, w.Body.String())
}
var o storage.EquipOrder
_ = json.Unmarshal(w.Body.Bytes(), &o)
if o.Action != "repair" || o.Slot != "weapon" || o.ItemName != "Deepforged Blade" {
t.Fatalf("repair order = %+v", o)
}
}
// TestEquipUpgradeRepairRejections: the money-spending actions trust only the
// pushed slot view. A forged tier, a slot with no upgrade offered, a repair of a
// full-condition slot, and a non-owner all bounce before any order is placed.
func TestEquipUpgradeRepairRejections(t *testing.T) {
s := seedEquip(t, "reala")
// A tier that isn't the slot's pushed NextTier: a stale page or a forged jump.
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "boots", Tier: 5}); w.Code != 409 {
t.Errorf("forged upgrade tier = %d, want 409", w.Code)
}
// weapon is at max tier (NextTier 0): no upgrade to offer.
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "weapon", Tier: 6}); w.Code != 400 {
t.Errorf("upgrade with no offer = %d, want 400", w.Code)
}
// boots are at full condition: nothing to repair.
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "repair", Slot: "boots"}); w.Code != 400 {
t.Errorf("repair of full-condition slot = %d, want 400", w.Code)
}
// A non-owner can't spend someone else's euros.
if w := placeEquip(t, s, "mallory", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "boots", Tier: 4}); w.Code != 403 {
t.Errorf("non-owner upgrade = %d, want 403", w.Code)
}
// No order should have survived any of those.
if pending, _ := storage.PendingEquipOrders(10); len(pending) != 0 {
t.Fatalf("a rejected money action still queued an order: %+v", pending)
}
}
// TestEquipPanelRenders: the owner's who page renders the Equipment panel with the
// three controls and the confirm data (balance) the money actions need.
func TestEquipPanelRenders(t *testing.T) {
s := seedEquip(t, "reala")
body := getWho(t, s, "tok-josie", "reala").Body.String()
for _, want := range []string{
"Equipment",
"Deepforged Blade",
"Take off", // the masterwork weapon is round-trippable
"Upgrade to Sturdy Boots", // the boots offer the next tier
"Repair", // the damaged weapon can be mended
"data-balance=\"100000.00\"", // the confirm dialog needs the balance
} {
if !strings.Contains(body, want) {
t.Errorf("equipment panel missing %q", want)
}
}
// The public Gear panel is suppressed for the owner (the Equipment panel
// supersedes it), so its heading must not appear on the owner render.
// A non-owner still sees the public sheet unchanged.
anon := getWho(t, s, "tok-josie", "").Body.String()
if strings.Contains(anon, "Deepforged Blade") {
t.Error("the owner-only equipment panel leaked onto the public page")
}
}
// TestEquipWireIdempotentAndAuthed: gogobee's pending/verdict pair is bearer-only,
// never nulls, and files a verdict once.
func TestEquipWireIdempotentAndAuthed(t *testing.T) {
File diff suppressed because one or more lines are too long
+123 -12
View File
@@ -117,6 +117,10 @@
{{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}}
@@ -133,6 +137,7 @@
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
{{end}}
</div>
{{end}}
</section>
{{if .MapView}}
@@ -290,6 +295,46 @@
<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>
@@ -417,23 +462,38 @@
// 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 () {
var panel = document.getElementById('gear-panel');
if (!panel) return; // only the owner gets this panel at all
var token = panel.getAttribute('data-token');
// 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_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) {
@@ -445,7 +505,7 @@
if (o.status === 'pending') anyPending = true;
var li = document.createElement('li');
li.className = 'flex items-baseline justify-between gap-3';
var verb = o.action === 'equip' ? 'Equip' : 'Take off';
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');
@@ -473,9 +533,9 @@
.catch(function () { /* transient — a later tick will do */ });
}
panel.addEventListener('click', function (e) {
var btn = e.target.closest('.equip-btn');
if (!btn || btn.disabled) return;
// 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…';
@@ -486,7 +546,8 @@
token: token,
action: btn.getAttribute('data-action'),
item_id: parseInt(btn.getAttribute('data-item-id') || '0', 10),
slot: btn.getAttribute('data-slot') || ''
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 }; }); })
@@ -505,7 +566,57 @@
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();
})();