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.
This commit is contained in:
@@ -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 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+69
-18
@@ -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()
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
+120
-10
@@ -117,6 +117,9 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* Public gear list. Hidden for the owner: their own "Equipment" panel below
|
||||
supersedes it with live tiers, conditions, and management controls. */}}
|
||||
{{if not .HasSelf}}
|
||||
<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 +136,7 @@
|
||||
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .MapView}}
|
||||
@@ -290,6 +294,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,12 +461,22 @@
|
||||
// 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.
|
||||
@@ -431,9 +485,14 @@
|
||||
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_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 +504,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 +532,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 +545,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 +565,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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user