# 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 1–6) 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 1–6 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.