Files
Pete/adventure_ask7_equipment_mgmt.md
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

23 KiB
Raw Permalink Blame History

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 EquipmentSlots (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):

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/ArenaGearapplyMasterworkEquip (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 >= lenrejected_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: upgradepurchaseEquipmentTier(owner, order.Slot, order.Tier, order.GUID); repairrepair(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 DebitIdemrejected_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.