Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2593b11112 | ||
|
|
b0aeffd218 | ||
|
|
1159e64505 | ||
|
|
9d9cfd9f9a | ||
|
|
8a5fea78ba | ||
|
|
6c6de56539 | ||
|
|
3230939c51 | ||
|
|
4f4bd9fbc1 | ||
|
|
eeeac08db7 | ||
|
|
6219224ea9 | ||
|
|
1589c36e96 | ||
|
|
e90deda498 | ||
|
|
dcd68ebdcd | ||
|
|
7d2d9910cf | ||
|
|
1425033047 | ||
|
|
4ce025a82c | ||
|
|
cd84f64a22 | ||
|
|
5fac1630f6 | ||
|
|
cbe9e67b3e | ||
|
|
b814f936a8 | ||
|
|
8504c4f47a | ||
|
|
e0d90ff7cc | ||
|
|
790a118273 | ||
|
|
8df2212aad | ||
|
|
1ca794ea1a | ||
|
|
30b0e8debb |
@@ -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.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Adventure expansion — progress & handoff
|
||||||
|
|
||||||
|
Last updated 2026-07-17. Companion to `adventure_expansion_spec.md` (the wire
|
||||||
|
contract). This file is the state of the work: what's done, what's next, and
|
||||||
|
what will bite whoever picks it up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read this first
|
||||||
|
|
||||||
|
1. `adventure_expansion_spec.md` — payloads, endpoints, file:line refs into both
|
||||||
|
repos. It supersedes any older sketch of these asks.
|
||||||
|
2. This file — where the work actually stands.
|
||||||
|
|
||||||
|
The gogobee repo is at `/home/reala-misaki/git/gogobee`. It runs on millenia
|
||||||
|
(`reala@192.168.1.212`). Pete runs on `reala@www.parodia.dev` (bare `./pete` in
|
||||||
|
a screen session, built **on the server, with cgo**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status of the five asks
|
||||||
|
|
||||||
|
| # | Ask | State |
|
||||||
|
|---|---|---|
|
||||||
|
| — | Trophy case / timeline ("The record" / "The trail") | **Built, tests green, committed, NOT pushed, NOT deployed** |
|
||||||
|
| — | Contract spec for the five | **Written** (`adventure_expansion_spec.md`) |
|
||||||
|
| 4 | Richer item view | **Next up.** Spec'd. Partly buildable — see below |
|
||||||
|
| 3 | Room graph → dungeon map + fog of war | Spec'd. Graph already exists in gogobee |
|
||||||
|
| 5 | `equip_orders` queue | Spec'd. Copies mischief |
|
||||||
|
| 1 | `treasure_found` → treasures | Spec'd, but **blocked on a gogobee feature** — no loot fact exists |
|
||||||
|
| 2 | LLM-authored dispatches | Spec'd. **Do last** — inverts a security guarantee |
|
||||||
|
|
||||||
|
Build order and the reasoning behind it are in the spec's last section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Unpushed and undeployed
|
||||||
|
|
||||||
|
Pete has **two unpushed commits on `main`**:
|
||||||
|
|
||||||
|
- `cbe9e67` — adventure: keep the facts, not just the sentence we made of them
|
||||||
|
(the trophy case / timeline feature — `adventure_events` table,
|
||||||
|
`internal/storage/adventure.go`, the who-page sections)
|
||||||
|
- `5fac163` — adventure: pin the gogobee contract before Pete assumes it
|
||||||
|
(the spec)
|
||||||
|
|
||||||
|
**Nothing is deployed.** The record/trail sections are live nowhere.
|
||||||
|
|
||||||
|
This is not a neutral delay. **Trophies are only countable going forward** —
|
||||||
|
they're built from `adventure_events`, which only starts filling once the
|
||||||
|
feature is deployed. Every fact gogobee emits between now and the deploy is
|
||||||
|
counted by nobody and cannot be counted back out later (the past is prose in the
|
||||||
|
story feed). The longer it sits, the more of every adventurer's history is
|
||||||
|
permanently uncountable. Deploy it soon, independent of where the expansion goes.
|
||||||
|
|
||||||
|
gogobee's tree has **unrelated uncommitted postgame-zone work** (combat engine,
|
||||||
|
expedition sim, plan docs). It's mid-flight and not part of this project — leave
|
||||||
|
it alone, and keep adventure-expansion edits in separate files and commits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next: ask 4, richer items
|
||||||
|
|
||||||
|
Chosen because it's additive-only, has real sources, carries no deploy-order
|
||||||
|
hazard and no security surface, and is the smallest change that visibly improves
|
||||||
|
the who page.
|
||||||
|
|
||||||
|
**gogobee:** `itemViews` (`internal/plugin/pete_roster.go:210-225`) currently
|
||||||
|
sends five of `AdvItem`'s fields and drops two that already exist:
|
||||||
|
|
||||||
|
- `Slot` (`EquipmentSlot`, non-empty for MasterworkGear)
|
||||||
|
- `SkillSource` (string, non-empty for MasterworkGear)
|
||||||
|
|
||||||
|
Also worth sending, from adjacent structs:
|
||||||
|
|
||||||
|
- `Desc` — resolve **at the push site** from `MagicItem.Desc`
|
||||||
|
(`magic_items.go:35-46`) or `EquipmentDef.Description`
|
||||||
|
(`adventure_character.go:172-177`). `AdvItem` rows carry no description of
|
||||||
|
their own, which is why this is a push-site join and not a field copy.
|
||||||
|
- `Attunement` (does it need a bond) and `Attuned` (does it have one). Distinct,
|
||||||
|
and both matter to a player deciding what to wear — the bond cap is 3.
|
||||||
|
|
||||||
|
**Pete:** extend `ItemView` (`internal/storage/detail.go:23-29`) to match
|
||||||
|
`peteclient/client.go:356-362`, then surface it on the who page.
|
||||||
|
|
||||||
|
Rides the private `/api/ingest/detail` push, so **either side can deploy first**.
|
||||||
|
|
||||||
|
**Not buildable — do not spec it in:** stat modifiers and requirements are *not
|
||||||
|
modeled anywhere*, not merely unsent. No attack/AC/ability deltas exist on
|
||||||
|
`AdvItem` or `MagicItem`; effects derive from Tier/Slot/SkillSource in
|
||||||
|
`combat_stats.go:36` and `combat_bridge.go:396,489`. "+2 to hit" needs a gogobee
|
||||||
|
engine change first. Deriving a display-only approximation from Tier/Slot at the
|
||||||
|
push site is a lie the first time the engine and the display disagree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Landmines
|
||||||
|
|
||||||
|
Things that cost money to learn, or that will cost money if forgotten.
|
||||||
|
|
||||||
|
**Deploy order is a data-loss rule, not a preference.** An unknown `event_type`
|
||||||
|
is a 400 on Pete; gogobee retries with backoff to `maxAttempts=8` and then
|
||||||
|
**parks the bulletin forever** (`peteclient/client.go:79-86`,
|
||||||
|
`plugin/pete.go:279-281`). Pete's handler ships *before* gogobee emits
|
||||||
|
`treasure_found`, or the first treasures are gone permanently. Additive *fields*
|
||||||
|
on existing event types are safe in either order.
|
||||||
|
|
||||||
|
**A limit on the fetch truncates a tally, not a list.** `EventsBySubject(name, 0)`
|
||||||
|
means unlimited, and anything that *counts* must pass 0 and cap in the caller
|
||||||
|
(`storage/adventure.go:108-114`). A capped read would freeze a veteran's kill
|
||||||
|
count at 40 forever, reading as a fact rather than a missing page.
|
||||||
|
|
||||||
|
**`INSERT OR IGNORE` on the guid is load-bearing**, not defensive habit
|
||||||
|
(`storage/adventure.go:45-58`). gogobee retries facts whose ack it lost. This is
|
||||||
|
the only adventure store where a duplicate is *permanently* wrong — the roster
|
||||||
|
forgives one by replacing itself; a double-counted boss kill is in the tally
|
||||||
|
forever.
|
||||||
|
|
||||||
|
**Only `subject` earns a trophy, never `opponent`.** A duel Josie *lost* still
|
||||||
|
names her (as the opponent in the winner's dispatch). It belongs on her trail,
|
||||||
|
but crediting it would score a loss as a win. Pinned by
|
||||||
|
`TestTrophyCaseIgnoresOpponentCredit`.
|
||||||
|
|
||||||
|
**Events key on character name**, not roster token — that's what a fact carries;
|
||||||
|
gogobee puts no stable character id on the wire. A rename takes the history with
|
||||||
|
it.
|
||||||
|
|
||||||
|
**Snapshots are dropped on failure, never queued** (`peteclient/client.go:320-327`).
|
||||||
|
A retried snapshot is a lie about a moment that has passed, and the silence is
|
||||||
|
what makes Pete's 12-minute staleness timer honest. Asks 3 and 4 ride snapshots:
|
||||||
|
a dropped push means a stale map, not a wrong one.
|
||||||
|
|
||||||
|
**The who page's tests render the real template**, so a field slip 500s in the
|
||||||
|
suite — but only on paths the test data reaches. `HasHistory` false skips the
|
||||||
|
whole history block, which is why `TestWhoHistoryPanels` seeds facts through the
|
||||||
|
real ingest handler.
|
||||||
|
|
||||||
|
**`/api/mischief/claim` is misnamed.** It's a *verdict* endpoint; mischief has no
|
||||||
|
`claimed` state (`storage/mischief.go:37-42`). Don't repeat the name in
|
||||||
|
`equip_orders` — the spec calls it `/api/equip/verdict`.
|
||||||
|
|
||||||
|
**The LLM dispatch guard inversion (ask 2).** `factGuard` only checks the
|
||||||
|
*structured* `Subject`/`Opponent` fields, which is safe today only because Pete's
|
||||||
|
templates can print nothing Pete didn't interpolate. The moment gogobee's LLM
|
||||||
|
authors the prose, the guard is checking fields that are **no longer the thing
|
||||||
|
being rendered**, and character names are player-chosen. A prose-level guard is
|
||||||
|
required *in the same change* that accepts `headline`/`lede`, not as a
|
||||||
|
follow-up. **Templates stop being the renderer and become the safety net — do not
|
||||||
|
delete them.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions already made (don't relitigate)
|
||||||
|
|
||||||
|
- **Room graph, not coordinates or a bare trail.** Rooms with exits, a real
|
||||||
|
graph. It already exists in gogobee (`zone_graph.go`); the wire throws it away.
|
||||||
|
- **Fog of war is a server-side cut**, not a CSS style. Send visited nodes plus a
|
||||||
|
one-hop ring with `kind: "unknown"`. The map is a public page; "view source to
|
||||||
|
find the boss room" is not fog of war.
|
||||||
|
- **`equip_orders` copies mischief, not escrow.** An equip is a desired end state,
|
||||||
|
not a delta, so a replay converges — mischief's precondition exactly. No
|
||||||
|
`claimed` state, no stale-reoffer window; the poll loop is its own retry.
|
||||||
|
- **Equip UI says "queued", never claims it landed.** It lands on gogobee's next
|
||||||
|
poll tick, up to 30s out. The order row's status is the truth.
|
||||||
|
- **"Treasures found" won't be faked from vault contents.** A *bought* sword is
|
||||||
|
not a trophy. Needs a real `treasure_found` fact.
|
||||||
|
- **Blank state over a wall of zeroes.** Adventurers predating `adventure_events`
|
||||||
|
render no record section at all. A clean absence, deliberate.
|
||||||
@@ -0,0 +1,732 @@
|
|||||||
|
# Adventure expansion — gogobee↔Pete contract spec
|
||||||
|
|
||||||
|
Status: **proposed**, nothing implemented. Written 2026-07-17.
|
||||||
|
|
||||||
|
Covers the five gogobee-blocked asks behind the Adventure expansion:
|
||||||
|
|
||||||
|
1. `treasure_found` fact → treasures on the trophy case
|
||||||
|
2. LLM-authored dispatches (`headline`/`lede` on the fact)
|
||||||
|
3. Room graph + current room → dungeon map with fog of war
|
||||||
|
4. Richer item view → item inspection
|
||||||
|
5. `equip_orders` queue → equipment management from the web
|
||||||
|
|
||||||
|
Pete is a read-only mirror of gogobee and stays one. Nothing here opens a route
|
||||||
|
from Pete into the game box's network; ask 5 uses the poll-queue pattern that
|
||||||
|
mischief and casino escrow already established, so the direction of travel
|
||||||
|
remains gogobee→Pete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The constraints that shape all five
|
||||||
|
|
||||||
|
**Deploy Pete first, always.** An unknown `event_type` is a 400 on Pete
|
||||||
|
(`internal/web/adventure.go:83`, via `renderAdventure` returning `ok=false`).
|
||||||
|
gogobee's sender retries a 400 with backoff to `maxAttempts=8` and then **parks
|
||||||
|
the bulletin forever** (`internal/peteclient/client.go:79-86`, and the warning at
|
||||||
|
`internal/plugin/pete.go:279-281`). So for ask 1 the first treasures are lost
|
||||||
|
permanently if the order is reversed. This is not a style preference; it is the
|
||||||
|
one sequencing rule in this document that silently destroys data.
|
||||||
|
|
||||||
|
Additive *fields* on an existing fact type are safe in either order — Pete's
|
||||||
|
`AdvFact` decode ignores unknown JSON keys, and gogobee omits empty ones. Only
|
||||||
|
new `event_type` values carry the parking hazard.
|
||||||
|
|
||||||
|
**Snapshots are dropped on failure, never queued** (`client.go:320-327`). A
|
||||||
|
retried snapshot is a lie about a moment that has passed, and the silence is
|
||||||
|
what makes Pete's `rosterStaleAfter = 12 * time.Minute` timer honest. Asks 3
|
||||||
|
and 4 ride snapshots, so they inherit this: a dropped push means a stale map,
|
||||||
|
not a wrong one.
|
||||||
|
|
||||||
|
**Facts are a log; snapshots are current state.** `adventure_events` is the one
|
||||||
|
adventure table that is a log (`internal/storage/schema.go:75-93`). Anything
|
||||||
|
that needs to be *counted* must arrive as a fact. Anything that describes *now*
|
||||||
|
belongs on a snapshot. Ask 1 is a fact because "treasures found" is a tally;
|
||||||
|
asks 3 and 4 are snapshots because a map and an item sheet describe the present.
|
||||||
|
|
||||||
|
**`INSERT OR IGNORE` on the guid is the durable idempotency guarantee**
|
||||||
|
(`internal/storage/adventure.go:45-58`). The `IsGUIDSeen` check ahead of it is a
|
||||||
|
courtesy that can race. Any new fact type inherits both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `treasure_found` fact
|
||||||
|
|
||||||
|
**This is a gogobee feature, not a contract gap.** No loot fact is emitted
|
||||||
|
anywhere today; loot is room-local narration. The contract below is the easy
|
||||||
|
half. The work is in gogobee.
|
||||||
|
|
||||||
|
### Emit site
|
||||||
|
|
||||||
|
`dropZoneLoot` (`internal/plugin/dnd_zone_loot.go:492`) is the single grant point
|
||||||
|
for monster/boss/elite drops and already has `userID`, `zoneID`, `monster`, and
|
||||||
|
`isBoss`/`isElite` in hand. `dropMagicItemLoot` (`:590`) is the magic-item branch
|
||||||
|
and additionally has the `MagicItem` and its `LootTier`.
|
||||||
|
|
||||||
|
Route it through `emitFact` (`pete.go:169-187`), **not** `peteclient.Emit`
|
||||||
|
directly — `emitFact` is what enforces the opt-out anonymization and derives
|
||||||
|
`Actors` from the final names. Pete's `factGuard` rejects a `Subject` absent
|
||||||
|
from `Actors`, so bypassing it produces a silent 400.
|
||||||
|
|
||||||
|
### Which finds are newsworthy
|
||||||
|
|
||||||
|
Not every copper piece is a bulletin. **The filter already exists**: the tier-5
|
||||||
|
treasure `RoomAnnounce` path (`internal/plugin/adventure.go:1377-1390`, strings
|
||||||
|
in `adventure_flavor_treasure.go:276-279`) fires only when a treasure def is
|
||||||
|
flagged story-grade. Reuse that flag as the emit condition rather than inventing
|
||||||
|
a second notion of "notable".
|
||||||
|
|
||||||
|
Suggested tiering, matching the existing `zone_first`/`zone_clear` pattern:
|
||||||
|
|
||||||
|
- `tier: "bulletin"` — a story-grade find.
|
||||||
|
- `tier: "priority"` — a realm-first hoard, via `claimRealmFirst(kind, target)`
|
||||||
|
(`pete.go:249-258`). The flavor file already exists
|
||||||
|
(`internal/flavor/zone_first_hoard_flavor.go`).
|
||||||
|
|
||||||
|
### Payload
|
||||||
|
|
||||||
|
New `event_type` on the existing `Fact` struct (`peteclient/client.go:33-51`).
|
||||||
|
No new fields — the existing ones carry it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"guid": "treasure_found:<token>:<ts>",
|
||||||
|
"event_type": "treasure_found",
|
||||||
|
"tier": "bulletin",
|
||||||
|
"actors": ["Josie"],
|
||||||
|
"subject": "Josie",
|
||||||
|
"zone": "The Ossuary",
|
||||||
|
"region": "...",
|
||||||
|
"level": 7,
|
||||||
|
"stakes": "Crown of the Drowned King",
|
||||||
|
"outcome": "legendary",
|
||||||
|
"occurred_at": 1752710400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `guid` prefix **must** equal `event_type` (`client.go:34`) — it becomes a
|
||||||
|
public permalink path on Pete (`advPermalink`, `internal/web/adventure.go:351`).
|
||||||
|
- `subject` is the finder. Never populate `opponent` — Pete only credits
|
||||||
|
trophies where `Subject == name` (`storage/adventure.go:178`), pinned by
|
||||||
|
`TestTrophyCaseIgnoresOpponentCredit`.
|
||||||
|
- `stakes` carries the item name. This is a reuse of an existing free-text field
|
||||||
|
rather than a new `item` field; if that reads as a stretch, add `item` instead
|
||||||
|
and treat it as an additive field (safe in either deploy order).
|
||||||
|
- `outcome` carries the rarity/loot tier, so Pete can weight a legendary find
|
||||||
|
above a common one without parsing the name.
|
||||||
|
|
||||||
|
### Pete side
|
||||||
|
|
||||||
|
- `renderAdventure` gains a `treasure_found` case (`internal/web/adventure.go:377-491`).
|
||||||
|
- `storage.TrophyCase` (`storage/adventure.go:84`) gains a treasure tally. Note
|
||||||
|
the standing caveat at `storage/adventure.go:108-114`: **a limit on the fetch
|
||||||
|
truncates a tally, not a list.** The counter must read `EventsBySubject(name, 0)`.
|
||||||
|
- The who template's "The record" section (`templates/who.html:83-155`) gains a
|
||||||
|
fourth stat tile alongside BossKills/ZoneClears/Deaths/Retreats.
|
||||||
|
- The `storage/adventure.go:75-83` comment saying no loot fact exists on the
|
||||||
|
wire gets deleted, since it will no longer be true.
|
||||||
|
|
||||||
|
### Deliberately not doing
|
||||||
|
|
||||||
|
Counting the vault. A *bought* sword is not a trophy, and inventory is
|
||||||
|
current-state with no "found it in X on day 3". Treasures are only countable
|
||||||
|
going forward, same as every other trophy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. LLM-authored dispatches (`headline` / `lede`)
|
||||||
|
|
||||||
|
**SHIPPED 2026-07-17: Pete `eeeac08`, gogobee `22b7949`. All tests green both
|
||||||
|
sides, gofmt-clean, screenshot-verified (realistic + max-length prose both sit
|
||||||
|
cleanly on the card), NOT deployed.** Built as written below (additive
|
||||||
|
`headline`/`lede`, prose-guard, template fallback), with one architecture
|
||||||
|
decision the spec did not surface:
|
||||||
|
|
||||||
|
> **This section contradicts `pete_adventure_news_voice.md`**, the older
|
||||||
|
> foundational doc, which says *Pete* owns the voice and gogobee is "compute,
|
||||||
|
> not ghostwriter" — the flow there is Pete builds a voiced prompt and calls a
|
||||||
|
> generic gogobee inference endpoint. That needs a **Pete→gogobee route**, which
|
||||||
|
> `roster.go:23-25` forbids ("no route back into the game box's network"). The
|
||||||
|
> network constraint kills the voice-doc design, so §2's gogobee-authors-and-
|
||||||
|
> pushes model is the only one that fits one-way delivery. Confirmed with the
|
||||||
|
> owner before building. Cost: the warm-reporter voice now lives in gogobee's
|
||||||
|
> prompt (`pete_dispatch_voice.go`), softening "gogobee never sees Pete" — a
|
||||||
|
> deliberate, owner-approved trade, not an oversight.
|
||||||
|
|
||||||
|
The template-rendered dispatches are all identical and read as boilerplate.
|
||||||
|
gogobee's LLM writes the prose instead; Pete's templates stop being the renderer
|
||||||
|
and **become the safety net**.
|
||||||
|
|
||||||
|
### Payload
|
||||||
|
|
||||||
|
Two additive, optional fields on `Fact` (`peteclient/client.go:33-51`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"headline": "Josie went into the Ossuary alone and came back with the crown.",
|
||||||
|
"lede": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `omitempty`. Additive fields on existing event types, so deploy order does
|
||||||
|
not matter.
|
||||||
|
|
||||||
|
### The security inversion — do not miss this
|
||||||
|
|
||||||
|
`internal/web/adventure.go:374-376` claims template-only output is "safe and
|
||||||
|
reproducible", and `factGuard` (`:358-372`) is what makes that true. **factGuard
|
||||||
|
only checks the structured `Subject`/`Opponent` fields.** That is safe today
|
||||||
|
only because Pete's own templates can print nothing Pete did not interpolate.
|
||||||
|
|
||||||
|
The moment gogobee's LLM authors the prose, factGuard is validating fields that
|
||||||
|
are **no longer the thing being rendered**. Character names are player-chosen,
|
||||||
|
so a hallucinated or injected name walks onto a public page. This is a live
|
||||||
|
injection surface, not a theoretical one.
|
||||||
|
|
||||||
|
**Required, in the same change that accepts `headline`/`lede` — not a
|
||||||
|
follow-up:**
|
||||||
|
|
||||||
|
- A **prose-level guard**. Pete holds the full roster. Reject any prose
|
||||||
|
containing a known character name that is absent from `Actors`, and fall back
|
||||||
|
to `renderAdventure` for that fact.
|
||||||
|
- The fallback is why **the templates must not be deleted**. They are the
|
||||||
|
degraded path for every fact the guard rejects, plus every fact from a gogobee
|
||||||
|
that sends no prose.
|
||||||
|
- Length caps on both fields, enforced before render. The 64 KiB body cap
|
||||||
|
(`adventure.go:79`) is not a prose cap.
|
||||||
|
- The guard runs at ingest, not render, so a rejected dispatch is rejected once
|
||||||
|
rather than on every page view.
|
||||||
|
|
||||||
|
A rejected dispatch should log loudly. It means either gogobee's LLM
|
||||||
|
hallucinated a name or someone found an injection path, and both are worth
|
||||||
|
seeing.
|
||||||
|
|
||||||
|
### Open question
|
||||||
|
|
||||||
|
Whether the LLM prose is persisted alongside the template output or replaces it
|
||||||
|
in `stories`. Persisting both costs a column and buys the ability to A/B the
|
||||||
|
voice and to re-render if the guard later tightens. Recommend persisting both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Room graph + current room
|
||||||
|
|
||||||
|
**The graph already exists and is richer than the ask assumed.** This is mostly
|
||||||
|
"stop throwing the structure away."
|
||||||
|
|
||||||
|
### What exists in gogobee today
|
||||||
|
|
||||||
|
- `ZoneGraph` / `ZoneNode` / `ZoneEdge` — `internal/plugin/zone_graph.go:84,47,71`.
|
||||||
|
- `ZoneNodeKind` (`:17-29`): entry, exploration, trap, elite, boss, harvest,
|
||||||
|
rest_camp, secret, fork, merge.
|
||||||
|
- `ZoneEdge` (`:71-74`) carries `From`/`To`/`Lock`/`Weight`, with
|
||||||
|
`ZoneEdgeLockKind` (`:60-68`): none, perception_check, key_required,
|
||||||
|
level_min, region_clear, stat_check.
|
||||||
|
- `DungeonRun` (`dnd_zone_run.go:58-85`) tracks `CurrentNode` and
|
||||||
|
**`VisitedNodes`** — which is exactly the fog-of-war mask, already computed.
|
||||||
|
- Per-zone graphs in `zone_graph_*.go` (~14 zones), nav in `zone_graph_nav.go`.
|
||||||
|
|
||||||
|
### What the wire drops
|
||||||
|
|
||||||
|
`pete_roster.go:343-349` flattens all of it into a display string at the last
|
||||||
|
moment:
|
||||||
|
|
||||||
|
```go
|
||||||
|
Room: fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
|
||||||
|
```
|
||||||
|
|
||||||
|
Worse, `CurrentRoom` is a **legacy linear path index derived from
|
||||||
|
`VisitedNodes`** and is no longer persisted (`dnd_zone_run.go:50-52, 433-442`).
|
||||||
|
`RoomsTraversed != CurrentRoom+1` once backtracking is involved (`:81-85`). So
|
||||||
|
the current string is a lossy projection of a graph onto a line that no longer
|
||||||
|
exists.
|
||||||
|
|
||||||
|
### Payload
|
||||||
|
|
||||||
|
Extend `RosterDetail` (`peteclient/client.go:260-272`), which lands in Pete's
|
||||||
|
`whoDetail` (`internal/web/who.go:25-44`). Keep the existing `room` string for
|
||||||
|
back-compat and add structure beside it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room": "4 / 9",
|
||||||
|
"map": {
|
||||||
|
"zone_id": "ossuary",
|
||||||
|
"current_node": "n7",
|
||||||
|
"visited": ["n1", "n3", "n7"],
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "kind": "entry"},
|
||||||
|
{"id": "n3", "kind": "trap"},
|
||||||
|
{"id": "n7", "kind": "elite"},
|
||||||
|
{"id": "n9", "kind": "boss"}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{"from": "n1", "to": "n3", "lock": "none"},
|
||||||
|
{"from": "n3", "to": "n7", "lock": "perception_check"},
|
||||||
|
{"from": "n7", "to": "n9", "lock": "key_required"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fog of war is a server-side cut, not a client-side style
|
||||||
|
|
||||||
|
**Send only what `VisitedNodes` justifies.** A node the adventurer has not
|
||||||
|
reached, plus edges leading out of visited nodes with the destination's `kind`
|
||||||
|
withheld. Do not send the full graph and grey it out in CSS — the map is a
|
||||||
|
public page, and "view source to find the boss room" is not fog of war.
|
||||||
|
|
||||||
|
Concretely: include a node if it is visited, or if it is one hop from a visited
|
||||||
|
node. For the one-hop ring, send `{"id": "n9", "kind": "unknown"}` — the player
|
||||||
|
knows a door is there, not what is behind it.
|
||||||
|
|
||||||
|
This means the payload is per-adventurer and cannot be shared or cached across
|
||||||
|
players, which is already true of `RosterDetail`.
|
||||||
|
|
||||||
|
### Cost
|
||||||
|
|
||||||
|
A zone graph is small (tens of nodes), and this rides the existing 2-minute
|
||||||
|
roster push (`pete_roster.go:32`), so no new request. The 1 MiB roster cap and
|
||||||
|
500-entry limit (`internal/web/roster.go:40`) are worth re-checking against 500
|
||||||
|
adventurers each carrying a subgraph — that is the one real risk here, and it
|
||||||
|
argues for the one-hop cut on size grounds as well as secrecy.
|
||||||
|
|
||||||
|
### Pete side
|
||||||
|
|
||||||
|
- `whoDetail` gains the `map` field; `decodeWhoDetail` (`who.go:252`) handles it.
|
||||||
|
- New map rendering on the who page. The 60s live poll
|
||||||
|
(`templates/who.html:305`) patches `#who-room` today; it would also patch the
|
||||||
|
map. Note the poll patches **public detail only** by design (`who.go:160-163`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Richer item view
|
||||||
|
|
||||||
|
**SHIPPED 2026-07-17** — gogobee `b6d4e4c`, Pete `4ce025a`. Neither deployed.
|
||||||
|
|
||||||
|
The section below is kept as written, because three of its claims were wrong
|
||||||
|
and the corrections are the useful part. What actually shipped:
|
||||||
|
|
||||||
|
- **`Equipped []ItemView` on `PlayerDetail`, which this section never asked
|
||||||
|
for.** Equipping *moves* the row from `adventure_inventory` into
|
||||||
|
`magic_item_equipped` (`magic_items_gameplay.go:679-690`) — the two sets are
|
||||||
|
disjoint. So `attuned` on a backpack item, below, can never be true: bond
|
||||||
|
state there isn't false, it's *undefined*. The real gap was that worn items
|
||||||
|
weren't sent at all. `equippedViews` is where `Attuned` means something.
|
||||||
|
- **Stat modifiers ARE modeled** — see "What does not exist", which is wrong.
|
||||||
|
`magicItemEffectFor`/`magicItemEffectSummary` (`magic_items_gameplay.go:211`,
|
||||||
|
`:538`) produce a player-facing delta ("+15% damage, -8% damage taken"). The
|
||||||
|
fear below — that a display-only approximation lies the first time it and the
|
||||||
|
engine disagree — doesn't apply: this *is* the engine's summary, the same
|
||||||
|
function the game speaks with, so there's nothing to drift from. Sent as
|
||||||
|
`effect`. Raw per-stat numbers still aren't modeled and still aren't sent.
|
||||||
|
- **`skill_source` must be filtered, not forwarded.** The column is dual-use:
|
||||||
|
`"mining"` on masterwork gear, and the internal `"magic_item:<id>"` registry
|
||||||
|
pointer on magic-item rows (`magic_items_gameplay.go:521-533`). Sending it raw
|
||||||
|
puts gogobee IDs on a page, and Pete can't tell the two apart to filter them.
|
||||||
|
The push site sends only the skill name.
|
||||||
|
|
||||||
|
Pete-side note: an ItemView can't tell you which panel it's in, and that decides
|
||||||
|
whether an unbonded attunement item reads as "inert" (worn, doing nothing) or
|
||||||
|
"needs a bond" (just not worn yet). `internal/web/who.go`'s `itemRow` carries it.
|
||||||
|
|
||||||
|
Still open from this ask: **equipping from the web** is ask 5, not this one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Partly buildable now, partly not. Being precise about which is which.
|
||||||
|
|
||||||
|
### What exists and is being dropped
|
||||||
|
|
||||||
|
`itemViews` (`pete_roster.go:210-225`) sends five of `AdvItem`'s fields
|
||||||
|
(`internal/plugin/adventure_character.go:150-159`) and drops two:
|
||||||
|
|
||||||
|
- **`Slot`** (`EquipmentSlot`, non-empty for MasterworkGear)
|
||||||
|
- **`SkillSource`** (string, non-empty for MasterworkGear)
|
||||||
|
|
||||||
|
Descriptions exist, on other structs:
|
||||||
|
|
||||||
|
- **`MagicItem.Desc`** (`magic_items.go:35-46`) — first-sentence SRD summary.
|
||||||
|
`MagicItem` also has `Kind`, `Rarity`, and **`Attunement bool`**.
|
||||||
|
- **`EquipmentDef.Description`** (`adventure_character.go:172-177`) — shop
|
||||||
|
equipment.
|
||||||
|
|
||||||
|
### What does not exist
|
||||||
|
|
||||||
|
**Stat modifiers and requirements are not modeled.** Not "not sent" — not
|
||||||
|
modeled. There are no attack/AC/ability deltas on `AdvItem` or `MagicItem`.
|
||||||
|
Effects are keyed off Tier/Slot/SkillSource and resolved at
|
||||||
|
`combat_stats.go:36` and `combat_bridge.go:396,489`.
|
||||||
|
|
||||||
|
So "+2 to hit" cannot be sent, because nothing computes it. Shipping it means
|
||||||
|
either inventing a modifier model in gogobee's engine first, or deriving a
|
||||||
|
display-only approximation from Tier/Slot at the push site — the latter is a
|
||||||
|
lie the first time the engine and the display disagree, and is not recommended.
|
||||||
|
|
||||||
|
**Requirements** likewise do not exist as data. Attunement is the closest real
|
||||||
|
thing (`MagicItem.Attunement`, with a bond cap of 3), and it is worth surfacing
|
||||||
|
on its own terms rather than dressed up as a generic requirement.
|
||||||
|
|
||||||
|
### Payload
|
||||||
|
|
||||||
|
Extend `ItemView` (`peteclient/client.go:356-362` → Pete's
|
||||||
|
`internal/storage/detail.go:23-29`) with fields that have a real source today:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Crown of the Drowned King",
|
||||||
|
"type": "MasterworkGear",
|
||||||
|
"tier": 5,
|
||||||
|
"value": 4200,
|
||||||
|
"temper": "...",
|
||||||
|
"slot": "helmet",
|
||||||
|
"skill_source": "...",
|
||||||
|
"desc": "...",
|
||||||
|
"attunement": true,
|
||||||
|
"attuned": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All additive and `omitempty`; rides the private `/api/ingest/detail` push
|
||||||
|
(`client.go:393-402`), so deploy order does not matter. `desc` is populated from
|
||||||
|
`MagicItem.Desc` or `EquipmentDef.Description` depending on the item's origin —
|
||||||
|
the push site resolves it, since `AdvItem` rows carry no description of their own.
|
||||||
|
|
||||||
|
`attunement` (does it need a bond) and `attuned` (does it have one) are distinct
|
||||||
|
and both matter to a player deciding what to wear, given the cap of 3.
|
||||||
|
|
||||||
|
### Deferred
|
||||||
|
|
||||||
|
Stat modifiers, until gogobee models them. Tracked as a gogobee engine change,
|
||||||
|
not a contract change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `equip_orders` queue
|
||||||
|
|
||||||
|
The one ask that needs a write path. **No new network route**: Pete grows a
|
||||||
|
queue table and a pending/verdict endpoint pair, gogobee grows a poller. Same
|
||||||
|
shape as mischief.
|
||||||
|
|
||||||
|
### Which existing queue to copy: mischief, not escrow
|
||||||
|
|
||||||
|
The two existing queues solve the ladder **differently**, and the difference is
|
||||||
|
load-bearing.
|
||||||
|
|
||||||
|
**Escrow** (`storage/games.go:54-58`) has a real `claimed` state, a `claimed_at`,
|
||||||
|
and a stale-reoffer window (`PendingEscrow`, `:209`). It needs them because real
|
||||||
|
money moves, the claim response is the authoritative amount to move against, and
|
||||||
|
a player is watching a spinner (hence `Flush` at `pete_games.go:97` and a 3s
|
||||||
|
poll).
|
||||||
|
|
||||||
|
**Mischief** (`storage/mischief.go:37-42`) has **no `claimed` state at all**.
|
||||||
|
`/api/mischief/claim` is misleadingly named — it is a *verdict* endpoint. A
|
||||||
|
gogobee that dies mid-work leaves the row `pending`; it is re-offered next poll;
|
||||||
|
the guid makes the replay a no-op. **The poll loop is its own retry.** No
|
||||||
|
`claimed_at`, no stale window, no reconciliation.
|
||||||
|
|
||||||
|
An equip order has neither of escrow's forcing properties. Nobody watches a
|
||||||
|
spinner (the UI must say "queued" regardless), and no money moves. More
|
||||||
|
importantly an equip is **naturally idempotent** — "sword in weapon slot" is a
|
||||||
|
desired end state, not a delta, so a replay converges rather than double-applies.
|
||||||
|
That is exactly mischief's precondition.
|
||||||
|
|
||||||
|
**Copy mischief.** Do not inherit the misleading name: call the endpoint
|
||||||
|
`verdict`.
|
||||||
|
|
||||||
|
### Ladder
|
||||||
|
|
||||||
|
```
|
||||||
|
pending -> applied
|
||||||
|
-> rejected_slot_taken
|
||||||
|
-> rejected_not_owned
|
||||||
|
-> rejected_requirements
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal reasons are enumerated rather than free-text so the web UI can say
|
||||||
|
something specific. `detail` carries the prose.
|
||||||
|
|
||||||
|
### Table
|
||||||
|
|
||||||
|
`equip_orders`, modeled on `mischief_orders` (`internal/storage/schema.go:125-139`):
|
||||||
|
|
||||||
|
| column | notes |
|
||||||
|
|---|---|
|
||||||
|
| `guid` | PK. Pete mints it at insert, so the player sees a reference instantly. |
|
||||||
|
| `owner_sub` | OIDC subject. `json:"-"` — never crosses to gogobee, same as `MischiefOrder.BuyerSub`. |
|
||||||
|
| `owner_localpart` | Matrix localpart, via `buyerLocalpart` (`web/mischief.go:21-23`). |
|
||||||
|
| `character_name` | Who is being dressed. |
|
||||||
|
| `item_id` | `AdvItem.ID`. |
|
||||||
|
| `slot` | Target slot, or empty for unequip. |
|
||||||
|
| `action` | `equip` / `unequip`. |
|
||||||
|
| `status` | The ladder above. |
|
||||||
|
| `detail` | Verdict prose. |
|
||||||
|
| `created_at`, `updated_at` | |
|
||||||
|
|
||||||
|
Indexes `(status, created_at)` and `(owner_sub, created_at DESC)`, matching
|
||||||
|
mischief.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
Bearer-authed, outside the sign-in block, beside the mischief pair
|
||||||
|
(`server.go:255-256`):
|
||||||
|
|
||||||
|
- **`GET /api/equip/pending`** → `[]storage.EquipOrder`. Never `null` — return
|
||||||
|
`[]` (`web/mischief.go:204-205`). Cap at 50 (`mischiefPollLimit`).
|
||||||
|
- **`POST /api/equip/verdict`** → `{"guid":..., "status":..., "detail":...}`,
|
||||||
|
16 KiB cap. Response: the resolved row.
|
||||||
|
- Missing guid → 400. Unknown guid → 400 + loud `slog.Error`. Bad status → 400.
|
||||||
|
- **400 is contractual** (`web/mischief.go:222-225`): it parks the row on
|
||||||
|
gogobee's side rather than retrying forever.
|
||||||
|
|
||||||
|
Buyer-side (OIDC, registered only when `adv.Enabled`): an order endpoint plus a
|
||||||
|
"my orders" list, mirroring `handleMischiefOrder` / `handleMischiefOrders`.
|
||||||
|
Burst guard 20/hour keyed on OIDC sub, explicitly anti-spam only — the real
|
||||||
|
eligibility check is gogobee's at verdict time.
|
||||||
|
|
||||||
|
### The idempotency mechanic to copy verbatim
|
||||||
|
|
||||||
|
`ResolveMischiefOrder` (`storage/mischief.go:115-132`): the UPDATE is guarded
|
||||||
|
`WHERE guid = ? AND status = 'pending'`, then it **unconditionally reads the row
|
||||||
|
back**. A first verdict, a retried verdict, and a missing row all take one path,
|
||||||
|
and the read-back is the authoritative answer. This is the whole reason mischief
|
||||||
|
can skip the `claimed` state.
|
||||||
|
|
||||||
|
### gogobee side
|
||||||
|
|
||||||
|
A poller modeled on `pete_mischief.go`: 30s interval, 20s timeout, poll errors
|
||||||
|
at `Debug` (a Pete predating the feature 404s here, which is not an error).
|
||||||
|
Apply through the existing equip path so `reconcileMagicAttunements`
|
||||||
|
(`magic_items_gameplay.go`) still runs — bond capacity and the cap of 3 are that
|
||||||
|
code's business, not the queue's. Short-circuit on the stamped order guid the
|
||||||
|
way `placeWebMischief` does.
|
||||||
|
|
||||||
|
### Honest UI
|
||||||
|
|
||||||
|
An equip lands on gogobee's next poll tick, up to 30s out. **The UI shows
|
||||||
|
"queued" and never claims it landed.** The order row's status is the truth; the
|
||||||
|
page reflects the row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order of work
|
||||||
|
|
||||||
|
1. ~~**Ask 4 (items)**~~ — **done** (gogobee `b6d4e4c`, Pete `4ce025a`). Read the
|
||||||
|
corrections at the top of §4 before trusting any other section here: this spec
|
||||||
|
was written from one side at a time, and every claim it got wrong was one that
|
||||||
|
only breaks when you read both sides together. Assume the same of §§1-3, 5.
|
||||||
|
2. **Ask 3 (map)** — the graph exists; the work is the one-hop cut and the
|
||||||
|
rendering. Check the roster size cap.
|
||||||
|
3. **Ask 5 (equip)** — the architectural step, but a well-trodden one now.
|
||||||
|
4. **Ask 1 (treasures)** — gated on gogobee emitting loot at all. Pete's handler
|
||||||
|
deploys first.
|
||||||
|
5. **Ask 2 (LLM dispatches)** — last, because the prose guard is the only piece
|
||||||
|
here that fails *publicly* if it is wrong.
|
||||||
|
|
||||||
|
## 6. Item comparison — "is this backpack item an upgrade?" (SHIPPED 2026-07-17)
|
||||||
|
|
||||||
|
**SHIPPED 2026-07-17 (session 5):** gogobee `7e59697`, Pete `6c6de56`. All tests
|
||||||
|
green both sides, gofmt-clean, screenshot-verified day + night (all six verdict
|
||||||
|
chips, including the purple `new` chip the theme-contrast history warned about).
|
||||||
|
NOT deployed. Additive private `Compare` field → safe deploy order either way.
|
||||||
|
|
||||||
|
**The one both-sides error, and it overturned a settled design decision: the game
|
||||||
|
only ever wears one ring.** `DnDSlotRing2` is declared and looped over as a valid
|
||||||
|
slot, but nothing in live code assigns to it — every ring in the registry is
|
||||||
|
`Slot: ring_1`, and the only equip path (`applyMagicEquip`, the same one ask 5's
|
||||||
|
web button drives) equips to `mi.Slot`. So "weaker of the two worn rings" (the
|
||||||
|
verified-at-scope proposal below) describes a trade that can't happen. The shipped
|
||||||
|
behaviour: a backpack ring compares against the `ring_1` occupant — which collapses
|
||||||
|
the ring special-case entirely, since **every** item's compare target is just
|
||||||
|
`mi.Slot`. Even a section written after verifying both repos carried the class of
|
||||||
|
error the rest of the spec did; the ring-slot subtlety was flagged "confirm before
|
||||||
|
building" and the confirmation is what caught it. (Owner was asked; chose ring_1.)
|
||||||
|
|
||||||
|
Everything else below shipped as written: strict-dominance verdict, engine-computed
|
||||||
|
deltas over tempered effects (reusing `magicItemEffectFor`, no Pete-side math), the
|
||||||
|
inert override that counts bonds *after* the slot's occupant is evicted, always-
|
||||||
|
visible verdict chip (no hover — phones don't have one) with deltas beside it. Chip
|
||||||
|
colours mix a fixed hue into `--ink`/`--card` so they survive all four phases by
|
||||||
|
construction (the map's trick), NOT a `dark:` variant. The mobile hover→tap
|
||||||
|
disclosure the spec proposed was dropped: deltas are short (≤3 chips), so showing
|
||||||
|
them inline is simpler and needs no JS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
A sixth ask, scoped after all five shipped. On the owner's own page, hovering a
|
||||||
|
backpack magic item shows a **compare card**: the item currently worn in that
|
||||||
|
same slot and the per-stat deltas, so the player can answer "is this better than
|
||||||
|
what I've got on?" without scrolling between two panels and eyeballing two
|
||||||
|
opaque effect strings.
|
||||||
|
|
||||||
|
**This section was written AFTER verifying both repos** (unlike §§1-5, which were
|
||||||
|
written a side at a time and were wrong wherever the two sides disagreed). The
|
||||||
|
findings below are checked against the gogobee engine, not assumed — but treat
|
||||||
|
the *design decisions* (verdict semantics, ring handling) as proposals, not
|
||||||
|
settled, and re-confirm the file refs at build time.
|
||||||
|
|
||||||
|
### The load-bearing constraint (same one as §4)
|
||||||
|
|
||||||
|
**Pete must not compute the comparison.** The ask-4 lesson: a display-only power
|
||||||
|
approximation *lies the moment it disagrees with the engine*, and character math
|
||||||
|
lives in gogobee. That is doubly true here — the comparison depends on three
|
||||||
|
things Pete does not hold:
|
||||||
|
|
||||||
|
- **Tempering.** An item's effective power folds in its per-instance temper
|
||||||
|
(`EquippedMagicItem.Effective()` → `temperedItem`, `magic_items_gameplay.go:252`).
|
||||||
|
The worn item and the backpack item each temper differently; the diff must be
|
||||||
|
over *tempered* effects.
|
||||||
|
- **Bond availability.** An attunement item worn with no free bond is **inert** —
|
||||||
|
equipping it changes nothing. So the honest verdict for such an item is "would
|
||||||
|
sit inert until you free a bond," not "downgrade." Same distinction §4/ask 4
|
||||||
|
drew, resurfacing on the compare card. Bond state is engine-side.
|
||||||
|
- **Which slot it lands in** (rings — see below).
|
||||||
|
|
||||||
|
So gogobee computes the comparison and pushes it; Pete renders it. **Simple diff
|
||||||
|
arithmetic, engine-side inputs.**
|
||||||
|
|
||||||
|
### What the comparison is built from — this is the good news
|
||||||
|
|
||||||
|
The Worn-panel items are **magic items**, and their power is a *structured
|
||||||
|
numeric struct*, not just the opaque `Effect` string:
|
||||||
|
`magicItemEffect{DamageBonus float64, DamageReductMult float64, FlatDmgStart int,
|
||||||
|
InitiativeBias float64, MaxHP int}` (`magic_items_gameplay.go:179`), derived by
|
||||||
|
`magicItemEffectFor(mi)` (`:212`) keyed on Kind+Rarity with a per-item overlay.
|
||||||
|
So a real field-by-field diff exists. **Diff the structs, never parse the
|
||||||
|
`Effect` summary string** — the summary drops any field that is zero, so parsing
|
||||||
|
it back loses deltas.
|
||||||
|
|
||||||
|
Direction of "better" per field (the verdict logic needs this):
|
||||||
|
`DamageBonus` ↑, `FlatDmgStart` ↑, `InitiativeBias` ↑, `MaxHP` ↑ are gains;
|
||||||
|
`DamageReductMult` is a multiplier on damage taken in `(0,1]`, so **lower is
|
||||||
|
better** (0.90 = −10% damage taken).
|
||||||
|
|
||||||
|
### NOT the masterwork gear
|
||||||
|
|
||||||
|
There are two equip systems. This ask is the **magic-item** Worn/backpack panels
|
||||||
|
(`MagicItem` / `DnDSlot`, effects via `magicItemEffectFor`). The other is
|
||||||
|
masterwork `EquipmentSlot`/`AdvEquipment` gear feeding `DerivePlayerStats` →
|
||||||
|
`CombatStats` (`combat_stats.go`). Do **not** cross the streams — different
|
||||||
|
slots, different power model, scoped out in ask 5. A compare only ever pairs a
|
||||||
|
magic item against a magic item.
|
||||||
|
|
||||||
|
### Payload
|
||||||
|
|
||||||
|
Additive `Compare` sub-object on the backpack `ItemView` (`peteclient.ItemView`,
|
||||||
|
mirrored in Pete `storage.ItemView`). Owner-private — it rides the PlayerDetail
|
||||||
|
`detail_json` blob (**no migration**, no new endpoint, no public exposure), same
|
||||||
|
channel as §4. `omitempty` → safe deploy order either way. Item names are
|
||||||
|
game-authored (not player names), so **no prose-guard / injection surface** like
|
||||||
|
§2.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"compare": {
|
||||||
|
"verdict": "upgrade | downgrade | sidegrade | same | new | inert",
|
||||||
|
"vs_name": "Ring of Protection", // worn item being replaced; "" when verdict=new
|
||||||
|
"vs_slot": "ring_2", // the slot it would land in (see rings)
|
||||||
|
"deltas": [
|
||||||
|
{"label": "damage", "better": true, "text": "+3% damage"},
|
||||||
|
{"label": "hp", "better": false, "text": "-4 HP"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`deltas` is engine-rendered player-facing text (like `magicItemEffectSummary`),
|
||||||
|
one entry per changed field, each flagged `better`. Pete renders chips/arrows off
|
||||||
|
`better`; it does no math.
|
||||||
|
|
||||||
|
### Verdict semantics (proposal — confirm)
|
||||||
|
|
||||||
|
Different stats are **not fungible** — the engine cannot say +3% damage beats
|
||||||
|
−4 HP. So use **strict dominance**, and let the player judge the mixed case:
|
||||||
|
|
||||||
|
- **upgrade** — every delta a gain (≥), at least one strict.
|
||||||
|
- **downgrade** — every delta a loss (≤), at least one strict.
|
||||||
|
- **sidegrade** — mixed. *This is the case the string-only view could never
|
||||||
|
show, and the reason this ask exists.* Show all deltas; claim no winner.
|
||||||
|
- **same** — no field differs.
|
||||||
|
- **new** — the target slot is empty; frame as "equips into an empty <slot>,"
|
||||||
|
all-gain but labelled a fill, not a replacement.
|
||||||
|
- **inert** — attunement item, no free bond: overrides the stat verdict, because
|
||||||
|
wearing it does nothing until a bond frees.
|
||||||
|
|
||||||
|
### Rings are double-slot (the subtlety to decide)
|
||||||
|
|
||||||
|
`DnDSlotRing1` / `DnDSlotRing2` (`dnd_equipment.go:24-25`) — two ring slots. A
|
||||||
|
backpack ring has to compare against *something*. Proposal: compare against the
|
||||||
|
**weaker of the two worn rings** (the one a sensible player would replace); if
|
||||||
|
either ring slot is empty, verdict `new` (fills the empty slot). Flag: this is a
|
||||||
|
judgement call, not derivable — confirm before building.
|
||||||
|
|
||||||
|
### Integration points
|
||||||
|
|
||||||
|
- **gogobee:** `itemViews` (`pete_roster.go:219`) builds the backpack ItemViews
|
||||||
|
but does **not** currently know the equipped set (it is shared with the vault
|
||||||
|
call). The compare needs the equipped magic items in scope — pass them into a
|
||||||
|
backpack-only post-pass, or a new `itemViewsWithCompare(inv, equipped)`. Attach
|
||||||
|
`Compare` for backpack magic items only (vault rows carry an id today but no
|
||||||
|
button; compare follows the same "backpack only" rule). Reuse
|
||||||
|
`magicItemEffectFor` on `temperedItem(...)` for both sides; read bonds from
|
||||||
|
`loadEquippedMagicItems`/the attune cap.
|
||||||
|
- **Pete:** `itemRow` (`internal/web/who.go:111`) already carries per-panel state;
|
||||||
|
add the compare card to the backpack row template in `who.html`. Hover-tooltip
|
||||||
|
on desktop. **Mobile has no hover** — render an always-visible verdict chip
|
||||||
|
(⬆ upgrade / ⬇ downgrade / ⇄ sidegrade / ✦ new / ⚠ inert) and put the full
|
||||||
|
deltas behind a tap. New Tailwind classes → rebuild+commit `output.css` (the
|
||||||
|
committed-artifact trap from every prior ask). Screenshot-verify day + night.
|
||||||
|
|
||||||
|
### Cost / honest scope
|
||||||
|
|
||||||
|
The engine work is small (one diff function over an existing struct, plus the
|
||||||
|
ring/bond decisions). The UI is the bulk: a tooltip that also degrades to a
|
||||||
|
tap-target on touch, styled for both phases. No new table, no migration, no new
|
||||||
|
endpoint, no public surface. Deploy order free (additive private field).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-ups, not covered by any of the five
|
||||||
|
|
||||||
|
Both Pete-only.
|
||||||
|
|
||||||
|
### `text-[color:var(--ink)]/NN` may be a no-op — unresolved, verify first
|
||||||
|
|
||||||
|
Found while fixing the night-phase contrast (`7d2d991`), **not** fixed there.
|
||||||
|
The evidence points two ways and I could not reconcile it:
|
||||||
|
|
||||||
|
- No such rule exists in the built `output.css` — `grep 'text-\[color:var(--ink)\]/55'`
|
||||||
|
finds nothing, and all 21 `var(--ink)` references in the output come from
|
||||||
|
hand-written component CSS, not from these classes.
|
||||||
|
- The item description in the Worn panel computes to a **fixed `rgb(74,46,42)`
|
||||||
|
that does not change with the phase** — that is dawn's `--ink` as a literal,
|
||||||
|
from a source I never located.
|
||||||
|
- Tailwind v3 cannot apply an opacity modifier to an arbitrary `var()` colour,
|
||||||
|
which would explain all of the above.
|
||||||
|
- **But** a night-phase screenshot showed that same text rendering as readable
|
||||||
|
cream, which contradicts the computed value outright.
|
||||||
|
|
||||||
|
So: probably a pre-existing site-wide no-op, silently dropping the muted-text
|
||||||
|
styling wherever it appears — `who.html` uses `/45`, `/50`, `/55` throughout,
|
||||||
|
and it is not confined to the new item panels. Treat the above as a lead, not a
|
||||||
|
finding. **Start by explaining the screenshot/computed contradiction** — one of
|
||||||
|
the two observations is measuring the wrong thing, and which one decides whether
|
||||||
|
there is a bug here at all.
|
||||||
|
|
||||||
|
Measuring this in the live page is booby-trapped, and each of these cost time:
|
||||||
|
reading an element's own background returns its translucent chip fill rather
|
||||||
|
than its backdrop (walk up from `parentElement`); a page reload resets
|
||||||
|
`data-phase` to the server-rendered value (`data-phase-lock` in `layout.html:2`
|
||||||
|
stops the clock script, not a reload); and reading the card colour and the text
|
||||||
|
colour in different ticks silently mixes two phases. A screenshot was ground
|
||||||
|
truth every time a computed value was not.
|
||||||
|
|
||||||
|
If it is real, the fix is the same shape as `--warn`: a phase variable, or a
|
||||||
|
hand-written utility — not a Tailwind arbitrary-value class with an opacity
|
||||||
|
modifier. See the `pete_theme_contrast` note for why `dark:` is never the answer
|
||||||
|
here (`darkMode` is unconfigured, so it follows the OS, not Pete's phase —
|
||||||
|
`status.html` and `channel.html` still have that bug).
|
||||||
|
|
||||||
|
### Richer live sheet
|
||||||
|
|
||||||
|
The live sheet poll patches only HP/AC/room/supplies/threat
|
||||||
|
(`templates/who.html:287-290`).
|
||||||
@@ -620,6 +620,31 @@ func (s State) Net() int64 {
|
|||||||
// cleared reports whether every card is home.
|
// cleared reports whether every card is home.
|
||||||
func (s State) cleared() bool { return s.Home() == FullDeck }
|
func (s State) cleared() bool { return s.Home() == FullDeck }
|
||||||
|
|
||||||
|
// Won reports that the board is a guaranteed clear: nothing left in the stock or
|
||||||
|
// waste, and not a single face-down card under any column. From here every card
|
||||||
|
// is a face-up run and a single auto() drains the whole board to the foundations
|
||||||
|
// without a choice left to make — which is exactly the tedious tail the felt
|
||||||
|
// should offer to finish in one gesture instead of thirty double-clicks.
|
||||||
|
//
|
||||||
|
// It's deliberately narrower than "solvable": a draw-one board with cards still
|
||||||
|
// in the stock is winnable too, but auto() won't turn the stock over, so calling
|
||||||
|
// that won would light a finish button that then stalls. This is the state the
|
||||||
|
// finish button actually finishes.
|
||||||
|
func (s State) Won() bool {
|
||||||
|
if s.Phase == PhaseDone || s.cleared() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(s.Stock) > 0 || len(s.Waste) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range s.Table {
|
||||||
|
if len(p.Down) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// CanAuto reports whether anything can go home at all — which is what greys the
|
// CanAuto reports whether anything can go home at all — which is what greys the
|
||||||
// finish button out rather than letting it be pressed at a board that has nothing
|
// finish button out rather than letting it be pressed at a board that has nothing
|
||||||
// for it.
|
// for it.
|
||||||
|
|||||||
@@ -423,6 +423,52 @@ func TestAutoSendsEverythingItCanHome(t *testing.T) {
|
|||||||
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
|
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Won is the state the finish button finishes: every card face up, the stock and
|
||||||
|
// waste drained, so a single auto sweeps the lot home. The button is only honest
|
||||||
|
// if these two agree — Won lighting up on a board auto can't clear would stall.
|
||||||
|
func TestWonIsExactlyWhatAutoCanFinish(t *testing.T) {
|
||||||
|
s := board(patient(), 5200)
|
||||||
|
// A won board: three short face-up runs across the columns, nothing face down,
|
||||||
|
// nothing in the stock or waste. Every card is reachable.
|
||||||
|
s.Table[0].Up = []cards.Card{card(2, cards.Spades), card(cards.Ace, cards.Hearts)}
|
||||||
|
s.Table[1].Up = []cards.Card{card(2, cards.Hearts), card(cards.Ace, cards.Spades)}
|
||||||
|
s.Table[2].Up = []cards.Card{card(2, cards.Diamonds), card(cards.Ace, cards.Clubs)}
|
||||||
|
s.Table[3].Up = []cards.Card{card(2, cards.Clubs), card(cards.Ace, cards.Diamonds)}
|
||||||
|
|
||||||
|
if !s.Won() {
|
||||||
|
t.Fatalf("a board with everything face up and the piles empty is won")
|
||||||
|
}
|
||||||
|
|
||||||
|
next, _ := apply(t, s, Move{Kind: "auto"})
|
||||||
|
if next.Home() != 8 { // the eight cards laid out above
|
||||||
|
t.Fatalf("auto homed %d cards, want 8 — the whole won board", next.Home())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything still hidden or still in a pile is not won: auto would stall on it.
|
||||||
|
withStock := s.clone()
|
||||||
|
withStock.Stock = cards.Deck{card(5, cards.Spades)}
|
||||||
|
if withStock.Won() {
|
||||||
|
t.Errorf("a board with a card still in the stock is not won — auto won't turn it over")
|
||||||
|
}
|
||||||
|
withWaste := s.clone()
|
||||||
|
withWaste.Waste = []cards.Card{card(5, cards.Spades)}
|
||||||
|
if withWaste.Won() {
|
||||||
|
t.Errorf("a board with a card still in the waste is not won")
|
||||||
|
}
|
||||||
|
withDown := s.clone()
|
||||||
|
withDown.Table[5].Down = []cards.Card{card(5, cards.Spades)}
|
||||||
|
if withDown.Won() {
|
||||||
|
t.Errorf("a board with a face-down card is not won")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A finished board is never won: the button belongs to a game still in play.
|
||||||
|
cleared := s.clone()
|
||||||
|
cleared.Phase = PhaseDone
|
||||||
|
if cleared.Won() {
|
||||||
|
t.Errorf("a settled board reports won")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- the money -------------------------------------------------------------
|
// ---- the money -------------------------------------------------------------
|
||||||
|
|
||||||
// The number the felt quotes while you play and the number settle() lands on are
|
// The number the felt quotes while you play and the number settle() lands on are
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The durable record of what has actually happened in the realm.
|
||||||
|
//
|
||||||
|
// Every other adventure table is a snapshot gogobee replaces wholesale. This one
|
||||||
|
// accumulates, because the questions it answers are historical: what has this
|
||||||
|
// adventurer killed, where have they been, how many times have they died. The
|
||||||
|
// story feed technically holds the same information — but as English, inside a
|
||||||
|
// headline, which you cannot count.
|
||||||
|
//
|
||||||
|
// Pete still computes nothing about the *game*. It only counts facts gogobee
|
||||||
|
// already told it. No row here is ever authored by Pete or edited after insert.
|
||||||
|
|
||||||
|
// AdvEvent is one game fact, kept as fact rather than as the sentence it was
|
||||||
|
// rendered into. Mirrors web.AdvFact minus the transport-only fields (no_push,
|
||||||
|
// class_race) that describe delivery rather than the event. Stakes is the one
|
||||||
|
// former transport field kept here: for a treasure_found it carries the item's
|
||||||
|
// name, which is the fact, not the delivery.
|
||||||
|
type AdvEvent struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Opponent string `json:"opponent"`
|
||||||
|
Boss string `json:"boss"`
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
Tally int `json:"tally"`
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
Milestone string `json:"milestone"`
|
||||||
|
Stakes string `json:"stakes"`
|
||||||
|
Actors []string `json:"actors"`
|
||||||
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertAdventureEvent records a fact. Idempotent on guid via INSERT OR IGNORE:
|
||||||
|
// gogobee retries a fact whose ack it lost, and a retried siege must not add a
|
||||||
|
// second kill to anybody's tally. The story insert upstream is guarded by
|
||||||
|
// IsGUIDSeen for the same reason; this is the same guarantee enforced by the
|
||||||
|
// table rather than by a check-then-act that a concurrent retry could race.
|
||||||
|
func InsertAdventureEvent(e *AdvEvent) error {
|
||||||
|
actors, err := json.Marshal(e.Actors)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = Get().Exec(`
|
||||||
|
INSERT OR IGNORE INTO adventure_events
|
||||||
|
(guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||||
|
level, tally, outcome, milestone, stakes, actors, occurred_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone,
|
||||||
|
e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors), e.OccurredAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BossTally is one monster and how many times this adventurer has put it down.
|
||||||
|
type BossTally struct {
|
||||||
|
Boss string `json:"boss"`
|
||||||
|
Kills int `json:"kills"`
|
||||||
|
First bool `json:"first"` // they were the first in the realm to ever clear it
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoneTally is one zone and how many times this adventurer has cleared it.
|
||||||
|
type ZoneTally struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Clears int `json:"clears"`
|
||||||
|
First bool `json:"first"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrophyCase is an adventurer's whole history, counted.
|
||||||
|
//
|
||||||
|
// Treasure is counted only from the treasure_found fact, never from the vault
|
||||||
|
// snapshot: a bought sword is not a trophy, and the snapshot is current-state
|
||||||
|
// with no "found it in X on day 3". So a treasure tally that predates the first
|
||||||
|
// treasure_found is a clean zero, not a back-derivation.
|
||||||
|
type TrophyCase struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Bosses []BossTally `json:"bosses,omitempty"`
|
||||||
|
Zones []ZoneTally `json:"zones,omitempty"`
|
||||||
|
Treasures []TreasureTally `json:"treasures,omitempty"`
|
||||||
|
BossKills int `json:"boss_kills"` // total, including firsts
|
||||||
|
BossFirsts int `json:"boss_firsts"` // realm-firsts among them
|
||||||
|
ZoneClears int `json:"zone_clears"`
|
||||||
|
ZoneFirsts int `json:"zone_firsts"`
|
||||||
|
TreasuresFound int `json:"treasures_found"` // story-grade finds, total
|
||||||
|
TreasureFirsts int `json:"treasure_firsts"` // realm-first hoards among them
|
||||||
|
Deaths int `json:"deaths"`
|
||||||
|
Retreats int `json:"retreats"`
|
||||||
|
RivalWins int `json:"rival_wins"`
|
||||||
|
Milestones []string `json:"milestones,omitempty"`
|
||||||
|
Survived int `json:"survived"` // mischief contracts walked away from
|
||||||
|
Downed int `json:"downed"` // mischief contracts that landed
|
||||||
|
Events int `json:"events"` // total facts on file
|
||||||
|
FirstSeen int64 `json:"first_seen"`
|
||||||
|
LastSeen int64 `json:"last_seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TreasureTally is one story-grade find: the item's name, the zone it came out
|
||||||
|
// of, and whether it was a realm-first hoard. Unlike bosses and zones there is no
|
||||||
|
// repeat count — a named treasure is found once, so each is its own row.
|
||||||
|
type TreasureTally struct {
|
||||||
|
Item string `json:"item"`
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
First bool `json:"first"` // first in the realm to pull this hoard
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventsBySubject returns every fact about a character, newest first. This is the
|
||||||
|
// timeline *and* the trophy source: one read, aggregated in Go, because the pool
|
||||||
|
// is MaxOpenConns(1) and six COUNT queries against it would serialize behind each
|
||||||
|
// other for an answer that fits comfortably in memory (a busy adventurer
|
||||||
|
// accumulates tens of facts, not millions).
|
||||||
|
//
|
||||||
|
// limit <= 0 means no limit, and callers who count should use it. A limit here
|
||||||
|
// silently truncates a *tally*, not just a list: read 40 facts for a display cap
|
||||||
|
// and a veteran's kill count quietly stops at 40 and stays wrong forever. Cap the
|
||||||
|
// trail in the caller, after the counting is done.
|
||||||
|
//
|
||||||
|
// Facts where the character is the *opponent* are included: a duel they lost is
|
||||||
|
// part of their history, and only the subject field would otherwise carry it.
|
||||||
|
func EventsBySubject(name string, limit int) ([]AdvEvent, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
q := `
|
||||||
|
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||||
|
level, tally, outcome, milestone, stakes, actors, occurred_at
|
||||||
|
FROM adventure_events
|
||||||
|
WHERE subject = ? OR opponent = ?
|
||||||
|
ORDER BY occurred_at DESC`
|
||||||
|
args := []any{name, name}
|
||||||
|
if limit > 0 {
|
||||||
|
q += ` LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []AdvEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var e AdvEvent
|
||||||
|
var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors sql.NullString
|
||||||
|
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent,
|
||||||
|
&boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone,
|
||||||
|
&stakes, &actors, &e.OccurredAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String
|
||||||
|
e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String
|
||||||
|
e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
|
||||||
|
if actors.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(actors.String), &e.Actors)
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTrophyCase counts a character's history out of their facts. Pure: it takes
|
||||||
|
// the rows EventsBySubject already read rather than querying again, so the who
|
||||||
|
// page pays for one database read and gets both the timeline and the trophies.
|
||||||
|
//
|
||||||
|
// Only facts where the character is the *subject* count toward trophies. They
|
||||||
|
// appear in an ally's dispatch as an opponent too, and crediting those would let
|
||||||
|
// a duel someone lost show up as a kill in their own case.
|
||||||
|
func BuildTrophyCase(name string, events []AdvEvent) TrophyCase {
|
||||||
|
tc := TrophyCase{Name: name}
|
||||||
|
bosses := map[string]*BossTally{}
|
||||||
|
zones := map[string]*ZoneTally{}
|
||||||
|
|
||||||
|
for _, e := range events {
|
||||||
|
if tc.LastSeen == 0 || e.OccurredAt > tc.LastSeen {
|
||||||
|
tc.LastSeen = e.OccurredAt
|
||||||
|
}
|
||||||
|
if tc.FirstSeen == 0 || e.OccurredAt < tc.FirstSeen {
|
||||||
|
tc.FirstSeen = e.OccurredAt
|
||||||
|
}
|
||||||
|
tc.Events++
|
||||||
|
|
||||||
|
if e.Subject != name {
|
||||||
|
continue // they're the other party in someone else's dispatch
|
||||||
|
}
|
||||||
|
switch e.EventType {
|
||||||
|
case "boss_kill", "boss_first":
|
||||||
|
tc.BossKills++
|
||||||
|
if e.Boss != "" {
|
||||||
|
b := bosses[e.Boss]
|
||||||
|
if b == nil {
|
||||||
|
b = &BossTally{Boss: e.Boss}
|
||||||
|
bosses[e.Boss] = b
|
||||||
|
}
|
||||||
|
b.Kills++
|
||||||
|
if e.EventType == "boss_first" {
|
||||||
|
b.First = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.EventType == "boss_first" {
|
||||||
|
tc.BossFirsts++
|
||||||
|
}
|
||||||
|
case "zone_clear", "zone_first":
|
||||||
|
tc.ZoneClears++
|
||||||
|
if e.Zone != "" {
|
||||||
|
z := zones[e.Zone]
|
||||||
|
if z == nil {
|
||||||
|
z = &ZoneTally{Zone: e.Zone, Region: e.Region}
|
||||||
|
zones[e.Zone] = z
|
||||||
|
}
|
||||||
|
z.Clears++
|
||||||
|
if e.EventType == "zone_first" {
|
||||||
|
z.First = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.EventType == "zone_first" {
|
||||||
|
tc.ZoneFirsts++
|
||||||
|
}
|
||||||
|
case "treasure_found":
|
||||||
|
tc.TreasuresFound++
|
||||||
|
// A realm-first hoard rides the priority tier, the same way zone_first
|
||||||
|
// does; a plain story-grade find is a bulletin.
|
||||||
|
first := e.Tier == "priority"
|
||||||
|
if first {
|
||||||
|
tc.TreasureFirsts++
|
||||||
|
}
|
||||||
|
if e.Stakes != "" {
|
||||||
|
tc.Treasures = append(tc.Treasures, TreasureTally{Item: e.Stakes, Zone: e.Zone, First: first})
|
||||||
|
}
|
||||||
|
case "death":
|
||||||
|
tc.Deaths++
|
||||||
|
case "retreat":
|
||||||
|
tc.Retreats++
|
||||||
|
case "rival_result":
|
||||||
|
tc.RivalWins++ // the fact's subject is the winner; see renderAdventure
|
||||||
|
case "milestone":
|
||||||
|
if e.Milestone != "" {
|
||||||
|
tc.Milestones = append(tc.Milestones, e.Milestone)
|
||||||
|
}
|
||||||
|
case "mischief_survived":
|
||||||
|
tc.Survived++
|
||||||
|
case "mischief_downed":
|
||||||
|
tc.Downed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range bosses {
|
||||||
|
tc.Bosses = append(tc.Bosses, *b)
|
||||||
|
}
|
||||||
|
for _, z := range zones {
|
||||||
|
tc.Zones = append(tc.Zones, *z)
|
||||||
|
}
|
||||||
|
// Deterministic order: most-fought first, then alphabetical. The tiebreak is
|
||||||
|
// not cosmetic — map iteration is randomized in Go, so without it the panel
|
||||||
|
// reshuffles on every request and a cached page and a live one disagree.
|
||||||
|
sort.Slice(tc.Bosses, func(i, j int) bool {
|
||||||
|
if tc.Bosses[i].Kills != tc.Bosses[j].Kills {
|
||||||
|
return tc.Bosses[i].Kills > tc.Bosses[j].Kills
|
||||||
|
}
|
||||||
|
return tc.Bosses[i].Boss < tc.Bosses[j].Boss
|
||||||
|
})
|
||||||
|
sort.Slice(tc.Zones, func(i, j int) bool {
|
||||||
|
if tc.Zones[i].Clears != tc.Zones[j].Clears {
|
||||||
|
return tc.Zones[i].Clears > tc.Zones[j].Clears
|
||||||
|
}
|
||||||
|
return tc.Zones[i].Zone < tc.Zones[j].Zone
|
||||||
|
})
|
||||||
|
return tc
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// adventure_events is the only adventure store that accumulates, so it is the
|
||||||
|
// only one where a duplicate delivery is permanently wrong: the roster forgives
|
||||||
|
// a double push by replacing itself, a double-counted boss kill is in the tally
|
||||||
|
// forever. These tests pin that, and pin what the trophy case will and won't
|
||||||
|
// credit an adventurer for.
|
||||||
|
|
||||||
|
func ev(guid, typ, subject string, at int64) *AdvEvent {
|
||||||
|
return &AdvEvent{GUID: guid, EventType: typ, Subject: subject, OccurredAt: at}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInsertAdventureEventIdempotent: gogobee retries a fact whose ack it lost.
|
||||||
|
// The retry must be a no-op, not a second kill.
|
||||||
|
func TestInsertAdventureEventIdempotent(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
e := ev("boss_kill:abc:1", "boss_kill", "Josie", 1000)
|
||||||
|
e.Boss = "The Rotmother"
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := InsertAdventureEvent(e); err != nil {
|
||||||
|
t.Fatalf("insert %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events, err := EventsBySubject("Josie", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EventsBySubject: %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("after 3 deliveries of one fact: got %d rows, want 1", len(events))
|
||||||
|
}
|
||||||
|
if tc := BuildTrophyCase("Josie", events); tc.BossKills != 1 {
|
||||||
|
t.Fatalf("boss kills: got %d, want 1 — a retry inflated the tally", tc.BossKills)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrophyCaseCounts walks a full history and checks each tally.
|
||||||
|
func TestTrophyCaseCounts(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
first := ev("boss_first:1:1", "boss_first", "Josie", 100)
|
||||||
|
first.Boss = "The Rotmother"
|
||||||
|
repeat := ev("boss_kill:2:2", "boss_kill", "Josie", 200)
|
||||||
|
repeat.Boss = "The Rotmother"
|
||||||
|
other := ev("boss_kill:3:3", "boss_kill", "Josie", 300)
|
||||||
|
other.Boss = "Gravebloom"
|
||||||
|
zone := ev("zone_clear:4:4", "zone_clear", "Josie", 400)
|
||||||
|
zone.Zone, zone.Region = "holymachina", "the Reach"
|
||||||
|
death := ev("death:5:5", "death", "Josie", 500)
|
||||||
|
mile := ev("milestone:6:6", "milestone", "Josie", 600)
|
||||||
|
mile.Milestone = "level 20"
|
||||||
|
|
||||||
|
for _, e := range []*AdvEvent{first, repeat, other, zone, death, mile} {
|
||||||
|
if err := InsertAdventureEvent(e); err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", e.GUID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := EventsBySubject("Josie", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EventsBySubject: %v", err)
|
||||||
|
}
|
||||||
|
tc := BuildTrophyCase("Josie", events)
|
||||||
|
|
||||||
|
if tc.BossKills != 3 {
|
||||||
|
t.Errorf("boss kills: got %d, want 3 (a first is still a kill)", tc.BossKills)
|
||||||
|
}
|
||||||
|
if tc.BossFirsts != 1 {
|
||||||
|
t.Errorf("boss firsts: got %d, want 1", tc.BossFirsts)
|
||||||
|
}
|
||||||
|
if tc.ZoneClears != 1 || tc.Deaths != 1 {
|
||||||
|
t.Errorf("zones/deaths: got %d/%d, want 1/1", tc.ZoneClears, tc.Deaths)
|
||||||
|
}
|
||||||
|
if len(tc.Milestones) != 1 || tc.Milestones[0] != "level 20" {
|
||||||
|
t.Errorf("milestones: got %v, want [level 20]", tc.Milestones)
|
||||||
|
}
|
||||||
|
// Ordering is by kills desc: the twice-killed Rotmother outranks Gravebloom.
|
||||||
|
if len(tc.Bosses) != 2 || tc.Bosses[0].Boss != "The Rotmother" || tc.Bosses[0].Kills != 2 {
|
||||||
|
t.Fatalf("boss tallies: got %+v, want Rotmother×2 first", tc.Bosses)
|
||||||
|
}
|
||||||
|
if !tc.Bosses[0].First {
|
||||||
|
t.Error("Rotmother should be flagged as a realm-first")
|
||||||
|
}
|
||||||
|
if tc.FirstSeen != 100 || tc.LastSeen != 600 {
|
||||||
|
t.Errorf("span: got %d..%d, want 100..600", tc.FirstSeen, tc.LastSeen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrophyCaseTreasures: story-grade finds count out of the treasure_found
|
||||||
|
// fact, the priority tier marks a realm-first hoard, and the item name rides the
|
||||||
|
// stakes field into a per-find row. A find with no name still counts but earns no
|
||||||
|
// showcase row.
|
||||||
|
func TestTrophyCaseTreasures(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
hoard := ev("treasure_found:1:1", "treasure_found", "Josie", 100)
|
||||||
|
hoard.Tier, hoard.Stakes, hoard.Zone = "priority", "Crown of the Drowned King", "The Ossuary"
|
||||||
|
find := ev("treasure_found:2:2", "treasure_found", "Josie", 200)
|
||||||
|
find.Tier, find.Stakes, find.Zone = "bulletin", "Ring of Nine Sorrows", "The Sump"
|
||||||
|
nameless := ev("treasure_found:3:3", "treasure_found", "Josie", 300)
|
||||||
|
nameless.Tier = "bulletin" // a find gogobee sent without a stakes noun
|
||||||
|
|
||||||
|
for _, e := range []*AdvEvent{hoard, find, nameless} {
|
||||||
|
if err := InsertAdventureEvent(e); err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", e.GUID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := EventsBySubject("Josie", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EventsBySubject: %v", err)
|
||||||
|
}
|
||||||
|
tc := BuildTrophyCase("Josie", events)
|
||||||
|
|
||||||
|
if tc.TreasuresFound != 3 {
|
||||||
|
t.Errorf("treasures found: got %d, want 3 (a nameless find still happened)", tc.TreasuresFound)
|
||||||
|
}
|
||||||
|
if tc.TreasureFirsts != 1 {
|
||||||
|
t.Errorf("treasure firsts: got %d, want 1 (only the priority hoard)", tc.TreasureFirsts)
|
||||||
|
}
|
||||||
|
// Only the two named finds earn a showcase row, newest first.
|
||||||
|
if len(tc.Treasures) != 2 {
|
||||||
|
t.Fatalf("treasure rows: got %d, want 2 (nameless earns no row)", len(tc.Treasures))
|
||||||
|
}
|
||||||
|
if tc.Treasures[0].Item != "Ring of Nine Sorrows" || tc.Treasures[0].First {
|
||||||
|
t.Errorf("newest row wrong: %+v", tc.Treasures[0])
|
||||||
|
}
|
||||||
|
if tc.Treasures[1].Item != "Crown of the Drowned King" || !tc.Treasures[1].First || tc.Treasures[1].Zone != "The Ossuary" {
|
||||||
|
t.Errorf("hoard row wrong: %+v", tc.Treasures[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrophyCaseIgnoresOpponentCredit is the one that matters for honesty. A
|
||||||
|
// duel Josie *lost* still names her, as the opponent in Quack's dispatch. It
|
||||||
|
// belongs on her timeline but must never be credited to her trophy case.
|
||||||
|
func TestTrophyCaseIgnoresOpponentCredit(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
lost := ev("rival_result:1:1", "rival_result", "Quack", 100)
|
||||||
|
lost.Opponent = "Josie" // Quack won; Josie is the one who got beaten
|
||||||
|
won := ev("rival_result:2:2", "rival_result", "Josie", 200)
|
||||||
|
won.Opponent = "Quack"
|
||||||
|
|
||||||
|
for _, e := range []*AdvEvent{lost, won} {
|
||||||
|
if err := InsertAdventureEvent(e); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := EventsBySubject("Josie", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EventsBySubject: %v", err)
|
||||||
|
}
|
||||||
|
// Both facts are hers to *see* — the loss is part of her story.
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("timeline: got %d events, want 2 (a loss is still history)", len(events))
|
||||||
|
}
|
||||||
|
// But only the one she won is hers to *claim*.
|
||||||
|
if tc := BuildTrophyCase("Josie", events); tc.RivalWins != 1 {
|
||||||
|
t.Fatalf("rival wins: got %d, want 1 — a loss was credited as a win", tc.RivalWins)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,12 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
|
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
|
||||||
// gogobee build that predates the detail page.
|
// gogobee build that predates the detail page.
|
||||||
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
|
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
|
||||||
|
// The noun a fact is about (a mischief bounty, a found treasure's name). Facts
|
||||||
|
// 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.
|
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
||||||
// Check sqlite_master before creating.
|
// Check sqlite_master before creating.
|
||||||
|
|||||||
@@ -15,17 +15,93 @@ type PlayerDetail struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Inventory []ItemView `json:"inventory,omitempty"`
|
Inventory []ItemView `json:"inventory,omitempty"`
|
||||||
Vault []ItemView `json:"vault,omitempty"`
|
Vault []ItemView `json:"vault,omitempty"`
|
||||||
|
Equipped []ItemView `json:"equipped,omitempty"`
|
||||||
House HouseView `json:"house"`
|
House HouseView `json:"house"`
|
||||||
Pets []PetView `json:"pets,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemView is one backpack or vault item.
|
// 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.
|
||||||
|
//
|
||||||
|
// Desc and Effect arrive already resolved: gogobee's inventory rows carry no
|
||||||
|
// description, and the combat delta is computed from the item rather than
|
||||||
|
// stored. Effect is the game engine's own summary, not Pete's guess at one — if
|
||||||
|
// it ever disagrees with what the item does in a fight, that is a gogobee bug
|
||||||
|
// and not something Pete can paper over here.
|
||||||
|
//
|
||||||
|
// Attunement means the item wants a bond; Attuned means it has one. Only worn
|
||||||
|
// items can be Attuned — equipping moves the row out of gogobee's inventory
|
||||||
|
// table entirely, so a backpack item's bond state isn't false, it's undefined.
|
||||||
type ItemView struct {
|
type ItemView struct {
|
||||||
Name string `json:"name"`
|
// ID is the adventure_inventory row id, sent only for a backpack item that can
|
||||||
Type string `json:"type"`
|
// be worn through the magic-item path — so a non-zero ID doubles as "this item
|
||||||
Tier int `json:"tier"`
|
// has an Equip button." Worn items carry none: unequip keys on Slot. The id is
|
||||||
Value int64 `json:"value"`
|
// the handle an equip order round-trips back to gogobee to name the item.
|
||||||
Temper int `json:"temper,omitempty"`
|
ID int64 `json:"id,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
Temper int `json:"temper,omitempty"`
|
||||||
|
Slot string `json:"slot,omitempty"`
|
||||||
|
SkillSource string `json:"skill_source,omitempty"`
|
||||||
|
Desc string `json:"desc,omitempty"`
|
||||||
|
Effect string `json:"effect,omitempty"`
|
||||||
|
Attunement bool `json:"attunement,omitempty"`
|
||||||
|
Attuned bool `json:"attuned,omitempty"`
|
||||||
|
// Compare, set only on backpack magic items (the ones carrying an equip ID),
|
||||||
|
// pairs this item against what is worn in the slot it would equip into. gogobee
|
||||||
|
// computes the verdict and per-stat deltas (the power math needs tempering and
|
||||||
|
// bond state, which live in the engine); Pete only renders it. Owner-private,
|
||||||
|
// rides detail_json — no public exposure. Item names here are game-authored, so
|
||||||
|
// there is no injection surface like the LLM dispatch prose.
|
||||||
|
Compare *ItemCompare `json:"compare,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemCompare is gogobee's verdict for equipping a backpack magic item over what
|
||||||
|
// is currently worn in its slot. Pete renders it verbatim and does no arithmetic.
|
||||||
|
type ItemCompare struct {
|
||||||
|
// Verdict: upgrade, downgrade, sidegrade, same, new, or inert.
|
||||||
|
Verdict string `json:"verdict"`
|
||||||
|
// VsName is the worn item being replaced; "" when Verdict is new (empty slot).
|
||||||
|
VsName string `json:"vs_name,omitempty"`
|
||||||
|
// VsSlot is the slot the item would land in (e.g. "ring_1").
|
||||||
|
VsSlot string `json:"vs_slot,omitempty"`
|
||||||
|
// Deltas is one entry per changed stat, each pre-flagged better/worse.
|
||||||
|
Deltas []ItemDelta `json:"deltas,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemDelta is one stat's change between the candidate and the worn item.
|
||||||
|
type ItemDelta struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Better bool `json:"better"`
|
||||||
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HouseView is the owner's housing summary.
|
// HouseView is the owner's housing summary.
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The equip queue: the one adventure feature that carries intent *back* to the
|
||||||
|
// game box, and it does it the same way mischief does — no new network route.
|
||||||
|
//
|
||||||
|
// A signed-in owner, on their own detail page, asks to wear an item they own or
|
||||||
|
// take one off. Pete records only the *intent*; it never touches the game's
|
||||||
|
// equipment tables. gogobee's poll loop drains the pending orders, runs the real
|
||||||
|
// equip through its own rules (slot eviction, the 3-bond attunement cap,
|
||||||
|
// reconcile), and hands back a verdict Pete files against the order. The guid is
|
||||||
|
// the idempotency key end to end.
|
||||||
|
//
|
||||||
|
// Unlike mischief the underlying game action is NOT naturally idempotent —
|
||||||
|
// equipping consumes an inventory row and regenerates it on unequip, so replaying
|
||||||
|
// the flow would double-move items. gogobee therefore short-circuits on the order
|
||||||
|
// guid before it mutates anything (see its poller). On Pete's side the mechanic
|
||||||
|
// is mischief's exactly: a verdict only moves a still-pending order, so a retried
|
||||||
|
// verdict is a no-op.
|
||||||
|
|
||||||
|
// EquipOrder is one wear/remove request and its current standing.
|
||||||
|
type EquipOrder struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
OwnerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee
|
||||||
|
OwnerLocalpart string `json:"owner_localpart"` // Matrix localpart gogobee turns into an MXID — the character to dress
|
||||||
|
CharacterName string `json:"character_name,omitempty"` // display copy, frozen at order time; gogobee ignores it
|
||||||
|
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 / 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"`
|
||||||
|
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// say something specific; detail carries the prose. The rejection set is honest
|
||||||
|
// to what gogobee's equip path can actually return: it auto-evicts a slot's
|
||||||
|
// current occupant (so there is no "slot taken") and equips over the bond cap as
|
||||||
|
// inert rather than refusing (so there is no "requirements" bounce). What is left
|
||||||
|
// is the item having moved out from under the order, or not being wearable.
|
||||||
|
const (
|
||||||
|
EquipPending = "pending" // placed; gogobee hasn't acted yet
|
||||||
|
EquipApplied = "applied" // worn/removed; detail says how (bonded, inert, ...)
|
||||||
|
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,
|
||||||
|
EquipRejectedDowngrade, EquipRejectedNoFunds, EquipRejectedMaxTier:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validEquipAction(action string) bool {
|
||||||
|
switch action {
|
||||||
|
case EquipActionEquip, EquipActionUnequip, EquipActionUpgrade, EquipActionRepair:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNoSuchEquipOrder = errors.New("equip: no such order")
|
||||||
|
|
||||||
|
// InsertEquipOrder records a fresh, pending order and returns it with a new guid.
|
||||||
|
// The guid is minted here so the owner sees a stable reference the instant they
|
||||||
|
// 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, tier int) (EquipOrder, error) {
|
||||||
|
if !validEquipAction(action) {
|
||||||
|
return EquipOrder{}, fmt.Errorf("equip: bad action %q", action)
|
||||||
|
}
|
||||||
|
guid, err := newGUID()
|
||||||
|
if err != nil {
|
||||||
|
return EquipOrder{}, err
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`INSERT INTO equip_orders
|
||||||
|
(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, Tier: tier, Status: EquipPending, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingEquipOrders is gogobee's poll: every order still waiting. Like mischief
|
||||||
|
// there is no claimed-but-stale window — a gogobee that dies mid-apply leaves the
|
||||||
|
// order pending to be offered again, and gogobee's own guid guard makes the replay
|
||||||
|
// a no-op.
|
||||||
|
func PendingEquipOrders(limit int) ([]EquipOrder, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`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
|
||||||
|
LIMIT ?`,
|
||||||
|
EquipPending, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("equip: pending orders: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanEquipOrders(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveEquipOrder files gogobee's verdict against a pending order. Idempotent by
|
||||||
|
// exactly mischief's mechanic: the UPDATE only moves a still-pending row, and the
|
||||||
|
// row is read back unconditionally so a first verdict, a retried verdict, and a
|
||||||
|
// missing row all take one path.
|
||||||
|
func ResolveEquipOrder(guid, status, detail string) (EquipOrder, error) {
|
||||||
|
if !validEquipVerdict(status) {
|
||||||
|
return EquipOrder{}, fmt.Errorf("equip: bad verdict %q", status)
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
if _, err := Get().Exec(
|
||||||
|
`UPDATE equip_orders SET status = ?, detail = ?, updated_at = ?
|
||||||
|
WHERE guid = ? AND status = ?`,
|
||||||
|
status, detail, now, guid, EquipPending,
|
||||||
|
); err != nil {
|
||||||
|
return EquipOrder{}, fmt.Errorf("equip: resolve order: %w", err)
|
||||||
|
}
|
||||||
|
return EquipOrderByGUID(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, tier, status, COALESCE(detail, ''), created_at, updated_at
|
||||||
|
FROM equip_orders WHERE guid = ?`, guid,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return EquipOrder{}, fmt.Errorf("equip: read order: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out, err := scanEquipOrders(rows)
|
||||||
|
if err != nil {
|
||||||
|
return EquipOrder{}, err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return EquipOrder{}, ErrNoSuchEquipOrder
|
||||||
|
}
|
||||||
|
return out[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EquipOrdersByOwner returns an owner's own recent orders, newest first, for the
|
||||||
|
// status strip on the detail page. Keyed on the OIDC subject so a username change
|
||||||
|
// doesn't strand history.
|
||||||
|
func EquipOrdersByOwner(ownerSub string, limit int) ([]EquipOrder, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(
|
||||||
|
`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
|
||||||
|
LIMIT ?`,
|
||||||
|
ownerSub, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("equip: orders by owner: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanEquipOrders(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountEquipOrdersSince backs the web anti-spam guard — the real eligibility check
|
||||||
|
// is gogobee's at verdict time; this only blunts a stuck mouse button.
|
||||||
|
func CountEquipOrdersSince(ownerSub string, since int64) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM equip_orders WHERE owner_sub = ? AND created_at >= ?`,
|
||||||
|
ownerSub, since,
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("equip: count recent orders: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanEquipOrders(rows *sql.Rows) ([]EquipOrder, error) {
|
||||||
|
var out []EquipOrder
|
||||||
|
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.Tier, &o.Status, &o.Detail,
|
||||||
|
&o.CreatedAt, &o.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("equip: scan order: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEquipOrderLifecycle(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 42, "Cloak of Elvenkind", "cloak", EquipActionEquip, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o.Status != EquipPending {
|
||||||
|
t.Fatalf("fresh order status = %q, want pending", o.Status)
|
||||||
|
}
|
||||||
|
if o.ItemID != 42 || o.Slot != "cloak" || o.Action != EquipActionEquip {
|
||||||
|
t.Fatalf("order fields lost through insert: %+v", o)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := PendingEquipOrders(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 || pending[0].GUID != o.GUID {
|
||||||
|
t.Fatalf("pending = %+v, want the one order we just placed", pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ResolveEquipOrder(o.GUID, EquipApplied, "worn and bonded")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Status != EquipApplied || got.Detail != "worn and bonded" {
|
||||||
|
t.Fatalf("resolved order = %+v, want applied with detail", got)
|
||||||
|
}
|
||||||
|
if pending, _ := PendingEquipOrders(10); len(pending) != 0 {
|
||||||
|
t.Fatalf("applied order still pending: %+v", pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEquipResolveIsIdempotent: gogobee's poll loop retries, so a verdict can
|
||||||
|
// arrive twice — the second must not overwrite the first. This is the whole
|
||||||
|
// reason Pete can copy mischief's mechanic even though the game action underneath
|
||||||
|
// is not itself idempotent (gogobee guards that separately, on the order guid).
|
||||||
|
func TestEquipResolveIsIdempotent(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 7, "Ring of Protection", "ring_1", EquipActionEquip, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ResolveEquipOrder(o.GUID, EquipApplied, "first"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := ResolveEquipOrder(o.GUID, EquipRejectedNotOwned, "second")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-resolve errored: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != EquipApplied || got.Detail != "first" {
|
||||||
|
t.Fatalf("idempotency broken: order became %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEquipResolveUnknownAndBadVerdict(t *testing.T) {
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
if _, err := ResolveEquipOrder("nope", EquipApplied, ""); !errors.Is(err, ErrNoSuchEquipOrder) {
|
||||||
|
t.Fatalf("unknown guid err = %v, want ErrNoSuchEquipOrder", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if got, _ := EquipOrderByGUID(o.GUID); got.Status != EquipPending {
|
||||||
|
t.Fatalf("order moved off pending on a bad verdict: %q", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEquipInsertRejectsBadAction: the action is part of the contract, so a value
|
||||||
|
// 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", 0); err == nil {
|
||||||
|
t.Fatal("a bogus action was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEquipUnequipCarriesSlotNotItem: an unequip has no live inventory row to name,
|
||||||
|
// 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, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := EquipOrderByGUID(o.GUID)
|
||||||
|
if got.Action != EquipActionUnequip || got.Slot != "cloak" || got.ItemID != 0 {
|
||||||
|
t.Fatalf("unequip order = %+v, want slot-keyed with no item id", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := InsertEquipOrder("sub-B", "bob", "Bob", 9, "Item", "cloak", EquipActionEquip, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mine, err := EquipOrdersByOwner("sub-A", 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(mine) != 3 {
|
||||||
|
t.Fatalf("alice sees %d orders, want 3 (and none of bob's)", len(mine))
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := CountEquipOrdersSince("sub-A", time.Now().Add(-time.Hour).Unix())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 3 {
|
||||||
|
t.Fatalf("count since an hour ago = %d, want 3", n)
|
||||||
|
}
|
||||||
|
if n, _ := CountEquipOrdersSince("sub-A", time.Now().Add(time.Hour).Unix()); n != 0 {
|
||||||
|
t.Fatalf("count since the future = %d, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
|
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
|
||||||
@@ -140,3 +141,31 @@ func RosterSnapshotAt() int64 {
|
|||||||
}
|
}
|
||||||
return at.Int64
|
return at.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KnownCharacterNames returns the set of character names on the current board,
|
||||||
|
// lowercased, for the prose-guard. It is the answer to "is this a name of a real
|
||||||
|
// adventurer other than the one the fact is about" — a name Pete knows but that
|
||||||
|
// the fact did not authorize walking onto a public page.
|
||||||
|
//
|
||||||
|
// Best-effort: an empty set (query error, or gogobee has never pushed a board)
|
||||||
|
// disables only the name half of the guard, never the length caps. The board is
|
||||||
|
// a snapshot, so a character who has dropped off it is not covered — the guard's
|
||||||
|
// job is protecting people who are currently in the realm, not auditing history.
|
||||||
|
func KnownCharacterNames() map[string]bool {
|
||||||
|
rows, err := Get().Query(`SELECT name FROM adventure_roster WHERE name <> ''`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("KnownCharacterNames query failed", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
names := make(map[string]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names[strings.ToLower(name)] = true
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,48 @@ CREATE TABLE IF NOT EXISTS adventure_roster_meta (
|
|||||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- adventure_events is the structured residue of a dispatch, and it is the one
|
||||||
|
-- adventure table that is a *log* rather than a snapshot. That inversion is the
|
||||||
|
-- point. The roster answers "where is Josie now" and is replaced every tick;
|
||||||
|
-- this answers "what has Josie ever done", which no snapshot can, because each
|
||||||
|
-- push throws the last one away.
|
||||||
|
--
|
||||||
|
-- It exists because the facts were already arriving and we were burning them.
|
||||||
|
-- gogobee sends boss/opponent/zone/outcome on every fact; renderAdventure melted
|
||||||
|
-- them into a sentence and only the sentence was kept, so "how many bosses has
|
||||||
|
-- she downed" was answerable only by parsing English back out of a headline.
|
||||||
|
-- These rows are that fact, kept as fact. The story row remains the thing people
|
||||||
|
-- read; this is the thing we can count.
|
||||||
|
--
|
||||||
|
-- Keyed on guid, the same idempotency key as stories: a gogobee retry must not
|
||||||
|
-- double-count a kill. subject/opponent are *character names*, not roster tokens,
|
||||||
|
-- because that is what a fact carries and what the feed already prints in public.
|
||||||
|
-- Names are therefore the join back to a board row (roster.name -> token), which
|
||||||
|
-- is weaker than an id: a renamed or recycled character takes their history with
|
||||||
|
-- them. gogobee doesn't put a stable character id on the wire today, and inventing
|
||||||
|
-- one Pete-side would only be a guess at which two names were the same person.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_events (
|
||||||
|
guid TEXT PRIMARY KEY, -- == stories.guid; the dispatch this fact rendered into
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
tier TEXT, -- "priority" | "bulletin"
|
||||||
|
subject TEXT, -- character name: the one it happened to
|
||||||
|
opponent TEXT, -- character name: the other player, when there is one
|
||||||
|
boss TEXT, -- game-authored monster name, not player-controlled
|
||||||
|
zone TEXT,
|
||||||
|
region TEXT,
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tally INTEGER NOT NULL DEFAULT 0, -- the fact's Count: defenders, days in, ...
|
||||||
|
outcome TEXT,
|
||||||
|
milestone TEXT,
|
||||||
|
stakes TEXT, -- free-text noun the fact is about: a bounty, a found treasure's name
|
||||||
|
|
||||||
|
actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit
|
||||||
|
occurred_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, occurred_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adv_events_opponent ON adventure_events(opponent, occurred_at DESC) WHERE opponent IS NOT NULL AND opponent <> '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adv_events_type ON adventure_events(event_type, occurred_at DESC);
|
||||||
|
|
||||||
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
|
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
|
||||||
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
|
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
|
||||||
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
||||||
@@ -111,6 +153,44 @@ CREATE TABLE IF NOT EXISTS mischief_tiers (
|
|||||||
ordinal INTEGER NOT NULL DEFAULT 0
|
ordinal INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- An equip/unequip an owner asked for from their own detail page, on its way to
|
||||||
|
-- gogobee. This is the one adventure feature that carries intent *back* to the
|
||||||
|
-- game box; it does it the mischief way, with no new network route — Pete records
|
||||||
|
-- the intent, gogobee polls and applies it against its own equipment tables, and
|
||||||
|
-- pushes back a verdict. The status ladder:
|
||||||
|
--
|
||||||
|
-- pending -> applied (worn or removed; detail says how)
|
||||||
|
-- -> rejected_not_owned (the item left the pack before gogobee got here)
|
||||||
|
-- -> rejected_not_worn (unequip of an already-empty slot)
|
||||||
|
-- -> rejected_not_equippable (the item has no slot to fill)
|
||||||
|
--
|
||||||
|
-- guid is the idempotency key end to end. Note the game action is NOT naturally
|
||||||
|
-- idempotent (equipping consumes an inventory row), so gogobee short-circuits on
|
||||||
|
-- this guid before it mutates — unlike mischief, whose action converges on its
|
||||||
|
-- own. owner_sub is the OIDC subject (stable across renames) and keys "my orders";
|
||||||
|
-- owner_localpart is the Matrix localpart gogobee turns into the MXID of the
|
||||||
|
-- character to dress. item_id is the adventure_inventory row id for an equip (the
|
||||||
|
-- table is AUTOINCREMENT, so a stale id misses cleanly rather than hitting the
|
||||||
|
-- wrong item); an unequip keys on slot alone. character_name and item_name are
|
||||||
|
-- frozen display copy gogobee ignores.
|
||||||
|
CREATE TABLE IF NOT EXISTS equip_orders (
|
||||||
|
guid TEXT PRIMARY KEY,
|
||||||
|
owner_sub TEXT NOT NULL,
|
||||||
|
owner_localpart TEXT NOT NULL,
|
||||||
|
character_name TEXT NOT NULL DEFAULT '',
|
||||||
|
item_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
item_name TEXT NOT NULL DEFAULT '',
|
||||||
|
slot TEXT NOT NULL DEFAULT '',
|
||||||
|
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,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_equip_orders_pending ON equip_orders(status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, created_at DESC);
|
||||||
|
|
||||||
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
||||||
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
||||||
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
||||||
|
|||||||
+171
-2
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -37,6 +38,13 @@ type AdvFact struct {
|
|||||||
Milestone string `json:"milestone"`
|
Milestone string `json:"milestone"`
|
||||||
OccurredAt int64 `json:"occurred_at"`
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
NoPush bool `json:"no_push"`
|
NoPush bool `json:"no_push"`
|
||||||
|
// Headline/Lede are gogobee's LLM-authored prose, both optional. When present
|
||||||
|
// and past the prose-guard they replace the template render; otherwise Pete
|
||||||
|
// falls back to renderAdventure. gogobee is compute here, Pete is the editor:
|
||||||
|
// the templates are no longer the renderer, they are the safety net. See
|
||||||
|
// proseGuard and adventure_expansion_spec.md §2.
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
Lede string `json:"lede,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
|
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
|
||||||
@@ -93,19 +101,36 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render the template first. It validates the event type, and it is the
|
||||||
|
// fallback for every fact whose LLM prose is absent or fails the guard.
|
||||||
headline, lede, ok := renderAdventure(f)
|
headline, lede, ok := renderAdventure(f)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success. Checked
|
||||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
|
// before the prose-guard because the guard runs a board query (KnownCharacterNames);
|
||||||
|
// a retried dispatch we already have a story for should cost nothing.
|
||||||
if storage.IsGUIDSeen(f.GUID) {
|
if storage.IsGUIDSeen(f.GUID) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("duplicate"))
|
_, _ = w.Write([]byte("duplicate"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer gogobee's LLM prose when it is present and safe. Both fields must be
|
||||||
|
// supplied — a half-authored dispatch is not a voice, and mixing an LLM
|
||||||
|
// headline with a template lede reads as two writers. The guard is what makes
|
||||||
|
// the untrusted prose safe to print; a rejection is worth seeing loudly, since
|
||||||
|
// it is either a hallucinated name or someone who found an injection path.
|
||||||
|
if f.Headline != "" && f.Lede != "" {
|
||||||
|
if proseGuard(f.Headline, f.Lede, f.Actors) {
|
||||||
|
headline, lede = f.Headline, f.Lede
|
||||||
|
} else {
|
||||||
|
slog.Warn("adventure ingest: prose-guard rejected LLM dispatch, using template",
|
||||||
|
"guid", f.GUID, "event_type", f.EventType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
|
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
|
||||||
// dated 1970 on the permalink, pinned to the bottom of the section, and
|
// dated 1970 on the permalink, pinned to the bottom of the section, and
|
||||||
// outside every digest window. Treat "missing" as "now".
|
// outside every digest window. Treat "missing" as "now".
|
||||||
@@ -132,6 +157,31 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "insert failed", http.StatusInternalServerError)
|
http.Error(w, "insert failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Keep the fact itself, not just the sentence we made out of it. The story row
|
||||||
|
// above is what people read; this is what the trophy case and timeline can
|
||||||
|
// count. Best-effort on purpose: a dispatch that published is published, and
|
||||||
|
// losing its structured twin costs a tally, not the news. Failing the request
|
||||||
|
// here would make gogobee retry a fact we already have a story for.
|
||||||
|
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
|
||||||
|
GUID: f.GUID,
|
||||||
|
EventType: f.EventType,
|
||||||
|
Tier: f.Tier,
|
||||||
|
Subject: f.Subject,
|
||||||
|
Opponent: f.Opponent,
|
||||||
|
Boss: f.Boss,
|
||||||
|
Zone: f.Zone,
|
||||||
|
Region: f.Region,
|
||||||
|
Level: f.Level,
|
||||||
|
Tally: f.Count,
|
||||||
|
Outcome: f.Outcome,
|
||||||
|
Milestone: f.Milestone,
|
||||||
|
Stakes: f.Stakes,
|
||||||
|
Actors: f.Actors,
|
||||||
|
OccurredAt: occurredAt,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("adventure ingest: event record failed", "guid", f.GUID, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
||||||
|
|
||||||
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
|
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
|
||||||
@@ -180,6 +230,8 @@ func advEventMeta(eventType string) (label, emoji string) {
|
|||||||
return "First clear", "🗺️"
|
return "First clear", "🗺️"
|
||||||
case "zone_clear":
|
case "zone_clear":
|
||||||
return "Zone cleared", "🗺️"
|
return "Zone cleared", "🗺️"
|
||||||
|
case "treasure_found":
|
||||||
|
return "Treasure", "💎"
|
||||||
case "death":
|
case "death":
|
||||||
return "In memoriam", "🪦"
|
return "In memoriam", "🪦"
|
||||||
case "arrival":
|
case "arrival":
|
||||||
@@ -347,6 +399,105 @@ func factGuard(f AdvFact) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Length caps for LLM-authored prose, enforced before render. The 64 KiB body
|
||||||
|
// cap on the ingest request is a transport limit, not a prose limit — a
|
||||||
|
// dispatch is a headline and a short paragraph, and anything past these is
|
||||||
|
// malformed, not a valid long story. Over the cap falls back to the template.
|
||||||
|
const (
|
||||||
|
maxDispatchHeadline = 200
|
||||||
|
maxDispatchLede = 800
|
||||||
|
)
|
||||||
|
|
||||||
|
// proseGuard decides whether gogobee's LLM-authored headline+lede is safe to
|
||||||
|
// print. factGuard checks the STRUCTURED Subject/Opponent fields; that was the
|
||||||
|
// whole safety story while Pete's own templates were the renderer, because a
|
||||||
|
// template can print nothing Pete did not interpolate. LLM prose breaks that
|
||||||
|
// assumption — the guard would be validating fields that are no longer the thing
|
||||||
|
// being rendered — so this checks the RENDERED TEXT itself.
|
||||||
|
//
|
||||||
|
// Two rejections, both falling back to the template:
|
||||||
|
// - Over the length caps: a runaway or padded generation, not a dispatch.
|
||||||
|
// - Naming a known adventurer the fact did not authorize: any character name
|
||||||
|
// Pete holds on the current board that is absent from the fact's Actors
|
||||||
|
// allow-list. Character names are player-chosen, so a hallucinated or
|
||||||
|
// injected name is a live way to put words in a real person's mouth on a
|
||||||
|
// public page. Boss/zone/region are game-authored, never on the board, so
|
||||||
|
// they never trip this.
|
||||||
|
//
|
||||||
|
// The name half is best-effort: an empty board (KnownCharacterNames nil) leaves
|
||||||
|
// only the length caps, which is the correct degraded behaviour — with no known
|
||||||
|
// names there is nothing to impersonate that Pete could recognise anyway.
|
||||||
|
func proseGuard(headline, lede string, actors []string) bool {
|
||||||
|
if len(headline) > maxDispatchHeadline || len(lede) > maxDispatchLede {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
allow := make(map[string]bool, len(actors))
|
||||||
|
for _, a := range actors {
|
||||||
|
if a != "" {
|
||||||
|
allow[strings.ToLower(a)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text := strings.ToLower(headline + "\n" + lede)
|
||||||
|
for name := range storage.KnownCharacterNames() {
|
||||||
|
if allow[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if containsWholeWord(text, name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsWholeWord reports whether needle appears in haystack bounded by
|
||||||
|
// non-letter/digit runes (or the string edges). Both are already lowercased.
|
||||||
|
// Bounding avoids a short character name ("Al") matching inside an unrelated
|
||||||
|
// word ("Alabama") while still catching it as a standalone name; it is
|
||||||
|
// deliberately rune-aware so a non-ASCII player name still bounds correctly,
|
||||||
|
// where a stdlib \b would not.
|
||||||
|
func containsWholeWord(haystack, needle string) bool {
|
||||||
|
if needle == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
from := 0
|
||||||
|
for {
|
||||||
|
i := strings.Index(haystack[from:], needle)
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
start := from + i
|
||||||
|
end := start + len(needle)
|
||||||
|
beforeOK := start == 0 || !isWordRune(lastRune(haystack[:start]))
|
||||||
|
afterOK := end == len(haystack) || !isWordRune(firstRune(haystack[end:]))
|
||||||
|
if beforeOK && afterOK {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
from = start + 1
|
||||||
|
if from >= len(haystack) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWordRune(r rune) bool {
|
||||||
|
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstRune(s string) rune {
|
||||||
|
for _, r := range s {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastRune(s string) rune {
|
||||||
|
var last rune
|
||||||
|
for _, r := range s {
|
||||||
|
last = r
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
// renderAdventure returns the deterministic headline + lede for a fact. Copied
|
// renderAdventure returns the deterministic headline + lede for a fact. Copied
|
||||||
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
|
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
|
||||||
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
|
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
|
||||||
@@ -386,6 +537,24 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
|||||||
inRegion = " in " + f.Region
|
inRegion = " in " + f.Region
|
||||||
}
|
}
|
||||||
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
|
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
|
||||||
|
case "treasure_found":
|
||||||
|
// A story-grade find pulled from a dungeon. stakes is the item's name,
|
||||||
|
// outcome its rarity, and the priority tier marks a realm-first hoard
|
||||||
|
// nobody had ever pulled before — the same split zone_first uses.
|
||||||
|
inZone := f.Zone
|
||||||
|
if inZone == "" {
|
||||||
|
inZone = "the dungeon"
|
||||||
|
}
|
||||||
|
rarity := ""
|
||||||
|
if f.Outcome != "" {
|
||||||
|
rarity = strings.ToLower(f.Outcome) + " "
|
||||||
|
}
|
||||||
|
if f.Tier == "priority" {
|
||||||
|
return fmt.Sprintf("First ever: %s pulls %s out of %s.", f.Subject, f.Stakes, inZone),
|
||||||
|
fmt.Sprintf("Nobody had laid hands on %s before today. %s found the %shoard deep in %s%s, first in the realm to do it. Some haul.", f.Stakes, f.Subject, rarity, inZone, atLevel), true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s turned up %s in %s.", f.Subject, f.Stakes, inZone),
|
||||||
|
fmt.Sprintf("%s came back from %s with %s to show for it%s. A %sfind like that is worth a mention. Nice one.", f.Subject, inZone, f.Stakes, atLevel, rarity), true
|
||||||
case "death":
|
case "death":
|
||||||
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
||||||
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProseAcceptedWhenClean: gogobee's LLM prose replaces the template render
|
||||||
|
// (on the site row AND the live Matrix post) when both fields are present and
|
||||||
|
// name nobody the fact did not authorize.
|
||||||
|
func TestProseAcceptedWhenClean(t *testing.T) {
|
||||||
|
const token = "t"
|
||||||
|
s, posted := newAdvServer(t, token)
|
||||||
|
|
||||||
|
const hl = "Josie went into the Ossuary alone and came back with the crown."
|
||||||
|
const lede = "No fanfare, no party — just Josie, a locked door, and a very bad afternoon for whatever was guarding it. She walked back out at level 14 with the thing everyone else left behind."
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "boss_kill:j:1", EventType: "boss_kill", Tier: "priority",
|
||||||
|
Actors: []string{"Josie"}, Subject: "Josie", Boss: "the Bone Warden",
|
||||||
|
Zone: "the Ossuary", Level: 14, OccurredAt: 1,
|
||||||
|
Headline: hl, Lede: lede,
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
|
||||||
|
}
|
||||||
|
got, err := storage.GetStoryByGUID(f.GUID)
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("story not stored: %v", err)
|
||||||
|
}
|
||||||
|
if got.Headline != hl {
|
||||||
|
t.Errorf("stored headline = %q, want the LLM headline", got.Headline)
|
||||||
|
}
|
||||||
|
if got.Lede != lede {
|
||||||
|
t.Errorf("stored lede = %q, want the LLM lede", got.Lede)
|
||||||
|
}
|
||||||
|
if len(*posted) != 1 || (*posted)[0].Headline != hl {
|
||||||
|
t.Fatalf("live post did not carry LLM headline: %+v", *posted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProseRejectedNamesBystander: prose that names a real adventurer on the
|
||||||
|
// board who is NOT in the fact's Actors is the injection this guard exists for.
|
||||||
|
// It must fall back to the template, not print the name.
|
||||||
|
func TestProseRejectedNamesBystander(t *testing.T) {
|
||||||
|
const token = "t"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
// Kif is a real, current adventurer — but this fact is about Josie.
|
||||||
|
if err := storage.ReplaceRoster([]storage.RosterEntry{
|
||||||
|
{Token: "tk", Name: "Kif", Level: 9, Status: "idle"},
|
||||||
|
}, 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "boss_kill:j:2", EventType: "boss_kill", Tier: "priority",
|
||||||
|
Actors: []string{"Josie"}, Subject: "Josie", Boss: "the Bone Warden",
|
||||||
|
Zone: "the Ossuary", Level: 14, OccurredAt: 1,
|
||||||
|
Headline: "Josie and Kif split the Ossuary hoard.",
|
||||||
|
Lede: "A tidy bit of teamwork down in the dark today.",
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("ingest status = %d", rw.Code)
|
||||||
|
}
|
||||||
|
got, err := storage.GetStoryByGUID(f.GUID)
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("story not stored: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(got.Headline+got.Lede, "Kif") {
|
||||||
|
t.Errorf("bystander name leaked past the guard: %q / %q", got.Headline, got.Lede)
|
||||||
|
}
|
||||||
|
// The template render is what should have published instead.
|
||||||
|
tHl, _, _ := renderAdventure(f)
|
||||||
|
if got.Headline != tHl {
|
||||||
|
t.Errorf("did not fall back to template: headline = %q, want %q", got.Headline, tHl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProseRejectedTooLong: a runaway generation past the length caps is not a
|
||||||
|
// dispatch. Falls back to the template.
|
||||||
|
func TestProseRejectedTooLong(t *testing.T) {
|
||||||
|
const token = "t"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "arrival:z:1", EventType: "arrival", Tier: "bulletin",
|
||||||
|
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "human fighter",
|
||||||
|
OccurredAt: 1,
|
||||||
|
Headline: "Welcome, Zapp.",
|
||||||
|
Lede: strings.Repeat("very long ", maxDispatchLede),
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("ingest status = %d", rw.Code)
|
||||||
|
}
|
||||||
|
got, err := storage.GetStoryByGUID(f.GUID)
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("story not stored: %v", err)
|
||||||
|
}
|
||||||
|
tHl, _, _ := renderAdventure(f)
|
||||||
|
if got.Headline != tHl {
|
||||||
|
t.Errorf("over-length prose was not rejected: headline = %q", got.Headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProseNeedsBothFields: a headline with no lede is half a voice. The guard
|
||||||
|
// path is only taken when both are present; otherwise the whole template renders.
|
||||||
|
func TestProseNeedsBothFields(t *testing.T) {
|
||||||
|
const token = "t"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "arrival:z:2", EventType: "arrival", Tier: "bulletin",
|
||||||
|
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "human fighter",
|
||||||
|
OccurredAt: 1,
|
||||||
|
Headline: "Welcome, Zapp — a headline with no body.",
|
||||||
|
// Lede intentionally empty.
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("ingest status = %d", rw.Code)
|
||||||
|
}
|
||||||
|
got, _ := storage.GetStoryByGUID(f.GUID)
|
||||||
|
tHl, _, _ := renderAdventure(f)
|
||||||
|
if got == nil || got.Headline != tHl {
|
||||||
|
t.Errorf("half-authored prose was used; want template headline %q", tHl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainsWholeWord(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
hay, needle string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"josie and kif split it", "kif", true},
|
||||||
|
{"the kiffish blade", "kif", false}, // substring, not a word
|
||||||
|
{"al arrived", "al", true}, // short name, still bounded
|
||||||
|
{"alabama arrived", "al", false}, // bounded off
|
||||||
|
{"ended with kif.", "kif", true}, // trailing punctuation is a boundary
|
||||||
|
{"名月 cleared it", "名月", true}, // non-ASCII name, bounded by space
|
||||||
|
{"the 名月光 shard", "名月", false}, // non-ASCII substring, not a word
|
||||||
|
{"nobody here", "kif", false}, // absent
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := containsWholeWord(c.hay, c.needle); got != c.want {
|
||||||
|
t.Errorf("containsWholeWord(%q,%q) = %v, want %v", c.hay, c.needle, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,6 +218,33 @@ func TestRenderZoneTaxonomy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRenderTreasure: a story-grade find names the item and its zone; a
|
||||||
|
// priority find is billed as a realm-first, and the rarity from outcome rides
|
||||||
|
// into the lede.
|
||||||
|
func TestRenderTreasure(t *testing.T) {
|
||||||
|
hoard := AdvFact{EventType: "treasure_found", Tier: "priority", Subject: "Josie",
|
||||||
|
Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", Level: 7}
|
||||||
|
hl, lede, ok := renderAdventure(hoard)
|
||||||
|
if !ok || !strings.Contains(hl, "First ever") || !strings.Contains(hl, "Crown of the Drowned King") {
|
||||||
|
t.Errorf("hoard headline = %q (ok=%v)", hl, ok)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lede, "legendary") {
|
||||||
|
t.Errorf("hoard lede dropped the rarity: %q", lede)
|
||||||
|
}
|
||||||
|
|
||||||
|
find := AdvFact{EventType: "treasure_found", Tier: "bulletin", Subject: "Josie",
|
||||||
|
Zone: "The Sump", Stakes: "Ring of Nine Sorrows"}
|
||||||
|
hl2, _, ok := renderAdventure(find)
|
||||||
|
if !ok || !strings.Contains(hl2, "Josie") || !strings.Contains(hl2, "Ring of Nine Sorrows") ||
|
||||||
|
!strings.Contains(hl2, "The Sump") || strings.Contains(hl2, "First ever") {
|
||||||
|
t.Errorf("plain find headline = %q (ok=%v)", hl2, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lbl, emoji := advEventMeta("treasure_found"); lbl != "Treasure" || emoji == "" {
|
||||||
|
t.Errorf("treasure meta = %q/%q", lbl, emoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdventureArtAndMeta(t *testing.T) {
|
func TestAdventureArtAndMeta(t *testing.T) {
|
||||||
const token = "t"
|
const token = "t"
|
||||||
s, _ := newAdvServer(t, token)
|
s, _ := newAdvServer(t, token)
|
||||||
|
|||||||
+15
-3
@@ -195,11 +195,23 @@ func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) {
|
func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
// A cookie delete only lands when its Domain matches the one the cookie was
|
||||||
|
// set with. The session cookie's scope has changed over Pete's life: host-only
|
||||||
|
// before the games site, then widened to the parent domain so news and games
|
||||||
|
// could share one login. A browser may still hold it under the older scope, and
|
||||||
|
// a clear under only the current scope leaves the other in place — a stranded
|
||||||
|
// cookie that keeps someone signed in with a session logout can't reach. So
|
||||||
|
// clear both: always host-only, plus the parent domain when one is configured.
|
||||||
|
base := http.Cookie{
|
||||||
Name: name, Value: "", Path: "/", MaxAge: -1,
|
Name: name, Value: "", Path: "/", MaxAge: -1,
|
||||||
Domain: a.cookieDomain(name),
|
|
||||||
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
||||||
})
|
}
|
||||||
|
http.SetCookie(w, &base) // host-only (no Domain attribute)
|
||||||
|
if d := a.cookieDomain(name); d != "" {
|
||||||
|
scoped := base
|
||||||
|
scoped.Domain = d
|
||||||
|
http.SetCookie(w, &scoped) // parent-domain scope
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// oauthFor returns the OAuth config to use for this request. The configured
|
// oauthFor returns the OAuth config to use for this request. The configured
|
||||||
|
|||||||
@@ -8,6 +8,56 @@ import (
|
|||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestClearCookieClearsBothScopes: when a parent cookie domain is configured,
|
||||||
|
// clearCookie must emit a delete for BOTH the parent-domain scope and the
|
||||||
|
// host-only scope. A browser holding a session under the older host-only scope
|
||||||
|
// (from before the cookie domain widened for the games site) would otherwise
|
||||||
|
// survive logout and keep the user signed in with a session logout can't reach.
|
||||||
|
func TestClearCookieClearsBothScopes(t *testing.T) {
|
||||||
|
a := &Authenticator{domain: "parodia.dev"}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.clearCookie(rec, sessionCookie)
|
||||||
|
|
||||||
|
var hostOnly, scoped bool
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name != sessionCookie {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.MaxAge >= 0 {
|
||||||
|
t.Errorf("clear cookie should expire the session, got MaxAge=%d", c.MaxAge)
|
||||||
|
}
|
||||||
|
switch c.Domain {
|
||||||
|
case "":
|
||||||
|
hostOnly = true
|
||||||
|
case "parodia.dev":
|
||||||
|
scoped = true
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected clear Domain %q", c.Domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hostOnly {
|
||||||
|
t.Error("missing host-only clear (no Domain) — stale host-only sessions stay stranded")
|
||||||
|
}
|
||||||
|
if !scoped {
|
||||||
|
t.Error("missing parent-domain clear (Domain=parodia.dev)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no cookie domain configured, only the host-only clear is emitted.
|
||||||
|
func TestClearCookieHostOnlyWhenNoDomain(t *testing.T) {
|
||||||
|
a := &Authenticator{}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.clearCookie(rec, sessionCookie)
|
||||||
|
|
||||||
|
got := rec.Result().Cookies()
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("want exactly one clear cookie, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Domain != "" {
|
||||||
|
t.Errorf("want host-only clear, got Domain=%q", got[0].Domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||||
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
payload := []byte(`{"sub":"abc","exp":123}`)
|
payload := []byte(`{"sub":"abc","exp":123}`)
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The equip queue's web seam.
|
||||||
|
//
|
||||||
|
// Two audiences, same shape as mischief. A signed-in owner, on their own detail
|
||||||
|
// page, clicks Equip or Unequip; the OIDC-gated buy half records the intent after
|
||||||
|
// proving they own the page and the item. gogobee hits the bearer-authed pair: it
|
||||||
|
// polls pending orders and pushes a verdict. Pete never runs an equip rule — it
|
||||||
|
// records intent and files the verdict; the item actually moves on the game box,
|
||||||
|
// on gogobee's next poll tick. The UI says "queued" and never claims it landed.
|
||||||
|
|
||||||
|
// equipBurstWindow / equipBurstMax are Pete's own anti-spam guard, nothing more.
|
||||||
|
// The real eligibility — still-owned, wearable, the 3-bond cap — is gogobee's, at
|
||||||
|
// verdict time. This only stops a stuck mouse button from spooling the table.
|
||||||
|
const (
|
||||||
|
equipBurstWindow = time.Hour
|
||||||
|
equipBurstMax = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
// equipOrderReq is the browser's request. The owner names the page they're on
|
||||||
|
// (proving ownership), what they're doing, and the item — by its inventory row id
|
||||||
|
// for an equip, or by slot for an unequip. Pete resolves the display facts itself
|
||||||
|
// from the owner's own detail, never trusting the client for name or slot.
|
||||||
|
type equipOrderReq struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
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
|
||||||
|
// asserts what Pete can honestly know: the viewer is signed in, owns this exact
|
||||||
|
// page (proven by a row gogobee pushed, never by the token alone), and the item
|
||||||
|
// is actually in the panel they claim. Bond caps and the rest of the rulebook are
|
||||||
|
// gogobee's, checked when it drains the order.
|
||||||
|
func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.requireUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
owner := buyerLocalpart(u)
|
||||||
|
if owner == "" {
|
||||||
|
writeEquipError(w, http.StatusConflict, "please sign in again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req equipOrderReq
|
||||||
|
if !decodeStateBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Token == "" {
|
||||||
|
writeEquipError(w, http.StatusBadRequest, "no character")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch req.Action {
|
||||||
|
case storage.EquipActionEquip, storage.EquipActionUnequip,
|
||||||
|
storage.EquipActionUpgrade, storage.EquipActionRepair:
|
||||||
|
default:
|
||||||
|
writeEquipError(w, http.StatusBadRequest, "bad action")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership: only the localpart that owns this exact page token may dress it.
|
||||||
|
// The detail row is gogobee's own proof of owner<->page; a token alone proves
|
||||||
|
// nothing.
|
||||||
|
pd, ok, err := storage.PlayerDetailByOwner(owner, req.Token)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("equip: owner lookup", "err", err)
|
||||||
|
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeEquipError(w, http.StatusForbidden, "that's not your adventurer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 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
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
if n, err := storage.CountEquipOrdersSince(u.Sub, since); err != nil {
|
||||||
|
slog.Error("equip: burst count", "err", err)
|
||||||
|
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
} else if n >= equipBurstMax {
|
||||||
|
writeEquipError(w, http.StatusTooManyRequests, "slow down, too many changes in a short while")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
characterName := ""
|
||||||
|
if entry, ok, err := storage.RosterEntryByToken(req.Token); err == nil && ok {
|
||||||
|
characterName = entry.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("equip: order placed", "guid", order.GUID, "owner", owner, "action", req.Action, "slot", slot)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBackpackItem finds a wearable backpack item by its row id. Only magic items
|
||||||
|
// carry a non-zero ID, so a zero id can never match — the id both names the item
|
||||||
|
// and gates the action to the magic-item equip path.
|
||||||
|
func findBackpackItem(items []storage.ItemView, id int64) (storage.ItemView, bool) {
|
||||||
|
if id == 0 {
|
||||||
|
return storage.ItemView{}, false
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
if it.ID == id {
|
||||||
|
return it, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storage.ItemView{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// findWornSlot finds a worn item by the slot it fills.
|
||||||
|
func findWornSlot(items []storage.ItemView, slot string) (storage.ItemView, bool) {
|
||||||
|
if slot == "" {
|
||||||
|
return storage.ItemView{}, false
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Slot == slot {
|
||||||
|
return it, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
u := s.requireUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orders, err := storage.EquipOrdersByOwner(u.Sub, 20)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("equip: orders by owner", "err", err)
|
||||||
|
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if orders == nil {
|
||||||
|
orders = []storage.EquipOrder{}
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
|
||||||
|
|
||||||
|
// equipPollLimit caps one poll, matching the mischief seam.
|
||||||
|
const equipPollLimit = 50
|
||||||
|
|
||||||
|
// handleEquipPending is gogobee's poll: every order still waiting. Like mischief
|
||||||
|
// there is no stale-reoffer window — a gogobee that dies mid-apply leaves the order
|
||||||
|
// pending to be offered again, and gogobee's guid guard makes the replay a no-op.
|
||||||
|
func (s *Server) handleEquipPending(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orders, err := storage.PendingEquipOrders(equipPollLimit)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("equip: pending", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if orders == nil {
|
||||||
|
orders = []storage.EquipOrder{}
|
||||||
|
}
|
||||||
|
writeJSON(w, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// equipVerdict is gogobee's answer on an order: the terminal status and a human
|
||||||
|
// note to render.
|
||||||
|
type equipVerdict struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEquipVerdict files gogobee's verdict against a pending order. Idempotent:
|
||||||
|
// gogobee's poll loop retries, so the same verdict can arrive more than once and
|
||||||
|
// only the first moves the order. An unknown guid is a 400 — under the seam's
|
||||||
|
// contract that parks the row for a human rather than retrying forever against a
|
||||||
|
// row that will never exist.
|
||||||
|
func (s *Server) handleEquipVerdict(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v equipVerdict
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.GUID == "" {
|
||||||
|
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := storage.ResolveEquipOrder(v.GUID, v.Status, v.Detail)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchEquipOrder) {
|
||||||
|
slog.Error("equip: verdict for an order we've never heard of", "guid", v.GUID, "status", v.Status)
|
||||||
|
http.Error(w, "no such order", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("equip: resolve", "guid", v.GUID, "status", v.Status, "err", err)
|
||||||
|
http.Error(w, "bad verdict", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("equip: order resolved", "guid", order.GUID, "status", order.Status)
|
||||||
|
writeJSON(w, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeEquipError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The equip queue's web seam. Two contracts: the owner half proves ownership and
|
||||||
|
// resolves the item from Pete's own record (never the client), and the gogobee
|
||||||
|
// half is a bearer-authed, idempotent pending/verdict pair.
|
||||||
|
|
||||||
|
// seedEquip stands up a board + a private detail set owned by `owner`, with a
|
||||||
|
// wearable backpack magic item (ID != 0, the equip handle) and a worn item in a
|
||||||
|
// slot. Mirrors seedWho but pins the fields the equip path keys on.
|
||||||
|
func seedEquip(t *testing.T, owner string) *Server {
|
||||||
|
t.Helper()
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed roster = %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||||
|
Localpart: owner,
|
||||||
|
Token: "tok-josie",
|
||||||
|
Inventory: []storage.ItemView{
|
||||||
|
// A wearable magic item: carries an ID, so it can be equipped.
|
||||||
|
{ID: 501, Name: "Ring of Protection", Type: "ring", Tier: 4, Value: 900,
|
||||||
|
Slot: "ring_1", Attunement: true, Effect: "-8% damage taken"},
|
||||||
|
// Mundane gear: no ID, so no equip handle even though it has a slot.
|
||||||
|
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
|
||||||
|
Slot: "weapon", SkillSource: "mining"},
|
||||||
|
},
|
||||||
|
Equipped: []storage.ItemView{
|
||||||
|
{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)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeEquip(t *testing.T, s *Server, username string, req equipOrderReq) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := as(t, s, username, "POST", "/api/equip/order", req)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEquipOrder(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEquipOrderHappyEquip: the owner equips a backpack magic item; Pete resolves
|
||||||
|
// the item name and slot from its own record and queues a pending order.
|
||||||
|
func TestEquipOrderHappyEquip(t *testing.T) {
|
||||||
|
s := seedEquip(t, "reala")
|
||||||
|
|
||||||
|
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501})
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("equip = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var o storage.EquipOrder
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o.Status != storage.EquipPending || o.Action != "equip" || o.ItemID != 501 {
|
||||||
|
t.Fatalf("order = %+v", o)
|
||||||
|
}
|
||||||
|
// Name and slot come from Pete's own detail, not the request.
|
||||||
|
if o.ItemName != "Ring of Protection" || o.Slot != "ring_1" || o.CharacterName != "Josie" {
|
||||||
|
t.Fatalf("order didn't resolve from the owner's record: %+v", o)
|
||||||
|
}
|
||||||
|
if pending, _ := storage.PendingEquipOrders(10); len(pending) != 1 {
|
||||||
|
t.Fatal("order didn't land in the pending set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEquipOrderHappyUnequip: taking off a worn item rides on the slot; item_id 0.
|
||||||
|
func TestEquipOrderHappyUnequip(t *testing.T) {
|
||||||
|
s := seedEquip(t, "reala")
|
||||||
|
|
||||||
|
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "cloak"})
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("unequip = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var o storage.EquipOrder
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &o)
|
||||||
|
if o.Action != "unequip" || o.Slot != "cloak" || o.ItemName != "Cloak of Elvenkind" || o.ItemID != 0 {
|
||||||
|
t.Fatalf("unequip order = %+v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEquipOrderRejections: the honest failure surface — not your page, item not
|
||||||
|
// in the pack, empty slot, unequippable mundane gear, bad action.
|
||||||
|
func TestEquipOrderRejections(t *testing.T) {
|
||||||
|
s := seedEquip(t, "reala")
|
||||||
|
|
||||||
|
// A different signed-in user does not own Josie's page.
|
||||||
|
if w := placeEquip(t, s, "mallory", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501}); w.Code != 403 {
|
||||||
|
t.Errorf("non-owner equip = %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
// An item id that isn't in the pack.
|
||||||
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 999}); w.Code != 400 {
|
||||||
|
t.Errorf("unknown item = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// Mundane gear has a slot but no id, so it can never be named for equip.
|
||||||
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 0}); w.Code != 400 {
|
||||||
|
t.Errorf("no-id equip = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// Unequip of a slot nothing is in.
|
||||||
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "boots"}); w.Code != 400 {
|
||||||
|
t.Errorf("empty-slot unequip = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// A bogus action.
|
||||||
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "wield", ItemID: 501}); w.Code != 400 {
|
||||||
|
t.Errorf("bad action = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
s := seedEquip(t, "reala")
|
||||||
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed order = %d", w.Code)
|
||||||
|
}
|
||||||
|
// The owner sees their own order in the "my orders" strip.
|
||||||
|
if rows := mustEquipOrders(t, s, "reala"); len(rows) != 1 {
|
||||||
|
t.Fatalf("owner sees %d orders, want 1", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// No bearer → 401 on both machine endpoints.
|
||||||
|
if w := httptest.NewRecorder(); func() bool {
|
||||||
|
s.handleEquipPending(w, jsonReq(t, "GET", "/api/equip/pending", "", nil))
|
||||||
|
return w.Code == 401
|
||||||
|
}() == false {
|
||||||
|
t.Error("pending without bearer should be 401")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending returns the order (bearer-authed), never null.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEquipPending(w, jsonReq(t, "GET", "/api/equip/pending", "tok", nil))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("pending = %d", w.Code)
|
||||||
|
}
|
||||||
|
var pending []storage.EquipOrder
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil || len(pending) != 1 {
|
||||||
|
t.Fatalf("pending body = %s err=%v", w.Body.String(), err)
|
||||||
|
}
|
||||||
|
guid := pending[0].GUID
|
||||||
|
|
||||||
|
// A verdict resolves it; a replay is a no-op.
|
||||||
|
verdict := func(status, detail string) *httptest.ResponseRecorder {
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
s.handleEquipVerdict(rw, jsonReq(t, "POST", "/api/equip/verdict", "tok",
|
||||||
|
equipVerdict{GUID: guid, Status: status, Detail: detail}))
|
||||||
|
return rw
|
||||||
|
}
|
||||||
|
if w := verdict("applied", "worn"); w.Code != 200 {
|
||||||
|
t.Fatalf("verdict = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := verdict("rejected_not_owned", "too late"); w.Code != 200 {
|
||||||
|
t.Fatalf("replay verdict = %d", w.Code)
|
||||||
|
}
|
||||||
|
got, _ := storage.EquipOrderByGUID(guid)
|
||||||
|
if got.Status != storage.EquipApplied || got.Detail != "worn" {
|
||||||
|
t.Fatalf("replay overwrote the first verdict: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown guid parks with a 400, not a silent retry.
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
s.handleEquipVerdict(rw, jsonReq(t, "POST", "/api/equip/verdict", "tok",
|
||||||
|
equipVerdict{GUID: "ghost", Status: "applied"}))
|
||||||
|
if rw.Code != 400 {
|
||||||
|
t.Errorf("unknown guid = %d, want 400", rw.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustEquipOrders returns the raw "my orders" JSON rows for a user. Small helper
|
||||||
|
// so the wire test can pull the guid it just created without reaching into storage.
|
||||||
|
func mustEquipOrders(t *testing.T, s *Server, username string) []json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
r := as(t, s, username, "GET", "/api/equip/orders", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEquipOrders(w, r)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("orders = %d", w.Code)
|
||||||
|
}
|
||||||
|
var rows []json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ type solitaireView struct {
|
|||||||
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
|
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
|
||||||
Moves int `json:"moves"`
|
Moves int `json:"moves"`
|
||||||
CanAuto bool `json:"can_auto"`
|
CanAuto bool `json:"can_auto"`
|
||||||
|
Won bool `json:"won"` // every card face up, stock and waste empty: one press finishes it
|
||||||
|
|
||||||
Home int `json:"home"` // cards on the foundations
|
Home int `json:"home"` // cards on the foundations
|
||||||
PerCard float64 `json:"per_card"` // what one more is worth
|
PerCard float64 `json:"per_card"` // what one more is worth
|
||||||
@@ -86,6 +87,7 @@ func viewSolitaire(g klondike.State) solitaireView {
|
|||||||
Passes: g.PassesLeft(),
|
Passes: g.PassesLeft(),
|
||||||
Moves: g.Moves,
|
Moves: g.Moves,
|
||||||
CanAuto: g.CanAuto(),
|
CanAuto: g.CanAuto(),
|
||||||
|
Won: g.Won(),
|
||||||
Home: g.Home(),
|
Home: g.Home(),
|
||||||
PerCard: g.PerCard(),
|
PerCard: g.PerCard(),
|
||||||
BreakEven: g.Tier.BreakEven(),
|
BreakEven: g.Tier.BreakEven(),
|
||||||
|
|||||||
@@ -255,6 +255,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
|
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
|
||||||
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
|
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
|
||||||
|
|
||||||
|
// The equip queue's game-box wire: gogobee polls pending equip/unequip orders
|
||||||
|
// and pushes a verdict. Same bearer token and same reason as the pair above —
|
||||||
|
// the caller is a machine on the tailnet. The owner-facing half hangs off the
|
||||||
|
// auth block below.
|
||||||
|
mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending)
|
||||||
|
mux.HandleFunc("POST /api/equip/verdict", s.handleEquipVerdict)
|
||||||
|
|
||||||
// The casino. Signed-in only — there is money in it — so these hang off the
|
// The casino. Signed-in only — there is money in it — so these hang off the
|
||||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||||
@@ -282,6 +289,12 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
|
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
|
||||||
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
|
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
|
||||||
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
|
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
|
||||||
|
|
||||||
|
// The equip queue, owner side. Signed-in only — an order dresses a
|
||||||
|
// specific owner's character — and gated on the adventure seam like the
|
||||||
|
// storefront, since without a board there is no detail page to equip from.
|
||||||
|
mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder)
|
||||||
|
mux.HandleFunc("GET /api/equip/orders", s.handleEquipOrders)
|
||||||
}
|
}
|
||||||
if s.cfg.Push.Enabled {
|
if s.cfg.Push.Enabled {
|
||||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
|
||||||
|
Casino Audio
|
||||||
|
|
||||||
|
by Kenney Vleugels (Kenney.nl)
|
||||||
|
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
License (Creative Commons Zero, CC0)
|
||||||
|
http://creativecommons.org/publicdomain/zero/1.0/
|
||||||
|
|
||||||
|
You may use these assets in personal and commercial projects.
|
||||||
|
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
|
||||||
|
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Donate: http://support.kenney.nl
|
||||||
|
Request: http://request.kenney.nl
|
||||||
|
|
||||||
|
Follow on Twitter for updates:
|
||||||
|
@KenneyNL
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,15 @@
|
|||||||
thanks to the `transition-colors duration-1000` on <body>.
|
thanks to the `transition-colors duration-1000` on <body>.
|
||||||
---------------------------------------------------------------------------- */
|
---------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* --warn is the "you should look at this" amber, and it is a phase variable for
|
||||||
|
the same reason --ink is: the phase decides how dark the card underneath is.
|
||||||
|
Tailwind's dark: variant cannot do this job here — darkMode is unconfigured,
|
||||||
|
so dark: follows the OS's prefers-color-scheme, which has nothing to do with
|
||||||
|
which phase Pete is showing. A dark phase under a light OS renders amber-700
|
||||||
|
on the night card at 2.34:1, and a light phase under a dark OS renders
|
||||||
|
amber-400 on cream at 1.55:1 — half of the four combinations unreadable, and
|
||||||
|
which half depends on a setting outside the page. Only night has a dark card;
|
||||||
|
dusk and dawn are lit despite their names. */
|
||||||
:root,
|
:root,
|
||||||
html[data-phase="day"] {
|
html[data-phase="day"] {
|
||||||
--bg: #fff7e4; /* warm cream */
|
--bg: #fff7e4; /* warm cream */
|
||||||
@@ -15,6 +24,7 @@ html[data-phase="day"] {
|
|||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--ink: #3a2e1f;
|
--ink: #3a2e1f;
|
||||||
--accent: #f2a541; /* sunshine yellow */
|
--accent: #f2a541; /* sunshine yellow */
|
||||||
|
--warn: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-phase="dawn"] {
|
html[data-phase="dawn"] {
|
||||||
@@ -23,6 +33,7 @@ html[data-phase="dawn"] {
|
|||||||
--card: #fff4ea;
|
--card: #fff4ea;
|
||||||
--ink: #4a2e2a;
|
--ink: #4a2e2a;
|
||||||
--accent: #ff8a65;
|
--accent: #ff8a65;
|
||||||
|
--warn: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-phase="dusk"] {
|
html[data-phase="dusk"] {
|
||||||
@@ -31,6 +42,7 @@ html[data-phase="dusk"] {
|
|||||||
--card: #fff1de;
|
--card: #fff1de;
|
||||||
--ink: #3d2417;
|
--ink: #3d2417;
|
||||||
--accent: #e6553a;
|
--accent: #e6553a;
|
||||||
|
--warn: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-phase="night"] {
|
html[data-phase="night"] {
|
||||||
@@ -39,6 +51,7 @@ html[data-phase="night"] {
|
|||||||
--card: #2d365a;
|
--card: #2d365a;
|
||||||
--ink: #f1ecd8; /* moonlight */
|
--ink: #f1ecd8; /* moonlight */
|
||||||
--accent: #f9d976; /* lantern */
|
--accent: #f9d976; /* lantern */
|
||||||
|
--warn: #fbbf24; /* the only dark card, so the only light amber */
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -93,6 +106,33 @@ html[data-phase="night"] {
|
|||||||
.text-theme-lego { color: #b00d0e; }
|
.text-theme-lego { color: #b00d0e; }
|
||||||
.text-theme-adventure { color: #5836b8; }
|
.text-theme-adventure { color: #5836b8; }
|
||||||
|
|
||||||
|
/* Night repaint. The colours above are all tuned to sit on a light card, and
|
||||||
|
nobody checked them against a dark one when the phases were built: on
|
||||||
|
night's #2d365a every single one lands between 1.08:1 (eu) and 3.12:1
|
||||||
|
(finance), i.e. the whole family is below AA and eu is very nearly
|
||||||
|
invisible. Same hue, lifted lightness, saturation floored at 0.62 so the
|
||||||
|
dulled ones stay a colour instead of going grey — all now ≥5.5:1.
|
||||||
|
|
||||||
|
Only night gets this. Dusk and dawn are lit cards despite the names, so
|
||||||
|
they keep the originals exactly. Lego cannot stay pillar-box red and be
|
||||||
|
readable on navy — a red light enough to pass reads as salmon, and that is
|
||||||
|
the honest trade rather than a red nobody can see.
|
||||||
|
|
||||||
|
Deliberately NOT Tailwind's dark: variant: darkMode is unconfigured, so
|
||||||
|
dark: follows the OS's prefers-color-scheme, which knows nothing about
|
||||||
|
which phase Pete is showing. Keyed off the phase, like --ink is. */
|
||||||
|
html[data-phase="night"] .text-theme-gaming { color: #30cb7b; }
|
||||||
|
html[data-phase="night"] .text-theme-tech { color: #7fb8e1; }
|
||||||
|
html[data-phase="night"] .text-theme-politics { color: #e5a191; }
|
||||||
|
html[data-phase="night"] .text-theme-eu { color: #8bb1ff; }
|
||||||
|
html[data-phase="night"] .text-theme-music { color: #caa2e9; }
|
||||||
|
html[data-phase="night"] .text-theme-anime { color: #e89cb6; }
|
||||||
|
html[data-phase="night"] .text-theme-foss { color: #f69d5d; }
|
||||||
|
html[data-phase="night"] .text-theme-kids { color: #19c7ba; }
|
||||||
|
html[data-phase="night"] .text-theme-finance { color: #07cb8e; }
|
||||||
|
html[data-phase="night"] .text-theme-lego { color: #f79898; }
|
||||||
|
html[data-phase="night"] .text-theme-adventure { color: #baa9eb; }
|
||||||
|
|
||||||
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
|
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
|
||||||
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
|
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
|
||||||
.decoration-theme-politics { text-decoration-color: #e07a5f; }
|
.decoration-theme-politics { text-decoration-color: #e07a5f; }
|
||||||
@@ -940,6 +980,18 @@ html[data-phase="night"] {
|
|||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Falling money — the win curtain. Positioned entirely by the transform WAAPI
|
||||||
|
drives (see moneyRain in casino-fx.js), so it starts pinned to the top-left. */
|
||||||
|
.pete-money {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 2px rgba(0,0,0,0.35));
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
/* The dealer's beat before they draw out. */
|
/* The dealer's beat before they draw out. */
|
||||||
.pete-dealer-think {
|
.pete-dealer-think {
|
||||||
animation: pete-think 0.9s ease-in-out infinite;
|
animation: pete-think 0.9s ease-in-out infinite;
|
||||||
@@ -1444,6 +1496,14 @@ html[data-phase="night"] {
|
|||||||
100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
|
100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The finish button on a won board breathes, so the one press left to make is
|
||||||
|
the one the eye lands on. */
|
||||||
|
.pete-finish { animation: pete-finish-pulse 1.6s ease-in-out infinite; }
|
||||||
|
@keyframes pete-finish-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(242, 181, 61, 0.55); }
|
||||||
|
50% { box-shadow: 0 0 0 0.9rem rgba(242, 181, 61, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* The rail: the house's rack, what you've banked, the meter. On a wide screen
|
/* The rail: the house's rack, what you've banked, the meter. On a wide screen
|
||||||
it's a column down the right of the felt; on a narrow one it lies down and
|
it's a column down the right of the felt; on a narrow one it lies down and
|
||||||
sits under the board. */
|
sits under the board. */
|
||||||
@@ -1978,6 +2038,93 @@ html[data-phase="night"] {
|
|||||||
}
|
}
|
||||||
.pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; }
|
.pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; }
|
||||||
|
|
||||||
|
/* The send-off. When the mercy rule buries a seat, uno.js rolls one of four
|
||||||
|
of these and drops it over that seat — or over your hand, if it's you going
|
||||||
|
down. Every piece is built fresh and clears itself, so it can't outlive the
|
||||||
|
hand. The wrap is a zero-size anchor at the seat's middle; the pieces hang
|
||||||
|
off it. */
|
||||||
|
.pete-uno-bury {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 45%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: 40;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rockslide: a handful of boulders come down and pile where the seat was. */
|
||||||
|
.pete-uno-rock {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 1px rgba(0,0,0,0.4));
|
||||||
|
animation: pete-bury-rock 0.6s cubic-bezier(0.5, 0, 0.7, 1) both;
|
||||||
|
animation-delay: var(--d, 0ms);
|
||||||
|
}
|
||||||
|
@keyframes pete-bury-rock {
|
||||||
|
0% { transform: translate(calc(-50% + var(--dx)), -3.4rem) rotate(0deg); opacity: 0; }
|
||||||
|
20% { opacity: 1; }
|
||||||
|
75% { transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot)); }
|
||||||
|
85% { transform: translate(calc(-50% + var(--dx)), calc(var(--dy) - 0.22rem)) rotate(var(--rot)); }
|
||||||
|
100% { transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot)); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tombstone and coffin: one heavy thing drops in and sets, with a small bounce. */
|
||||||
|
.pete-uno-tomb,
|
||||||
|
.pete-uno-coffin {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 3px 2px rgba(0,0,0,0.45));
|
||||||
|
animation: pete-bury-drop 0.5s cubic-bezier(0.34, 1.3, 0.64, 1) both;
|
||||||
|
}
|
||||||
|
.pete-uno-tomb { font-size: 2.4rem; }
|
||||||
|
.pete-uno-coffin { font-size: 2.5rem; }
|
||||||
|
@keyframes pete-bury-drop {
|
||||||
|
0% { transform: translate(-50%, -3.2rem) scale(0.7); opacity: 0; }
|
||||||
|
55% { opacity: 1; }
|
||||||
|
70% { transform: translate(-50%, -50%) scale(1.06); }
|
||||||
|
82% { transform: translate(-50%, -44%) scale(1); }
|
||||||
|
100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Mega Man death: a flash, then eight pieces fire out and wink off. */
|
||||||
|
.pete-uno-zap-flash {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
margin: -0.75rem 0 0 -0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: radial-gradient(circle, #fff 0%, rgba(180,220,255,0) 70%);
|
||||||
|
animation: pete-bury-flash 0.35s ease-out both;
|
||||||
|
}
|
||||||
|
@keyframes pete-bury-flash {
|
||||||
|
0% { transform: scale(0.3); opacity: 0.95; }
|
||||||
|
100% { transform: scale(1.9); opacity: 0; }
|
||||||
|
}
|
||||||
|
.pete-uno-zap-bit {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
margin: -0.25rem 0 0 -0.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #cfeaff;
|
||||||
|
box-shadow: 0 0 6px 1px rgba(150,210,255,0.9);
|
||||||
|
animation: pete-bury-zap 0.5s ease-out both;
|
||||||
|
}
|
||||||
|
@keyframes pete-bury-zap {
|
||||||
|
0% { transform: rotate(var(--ang)) translateX(0.2rem); opacity: 1; }
|
||||||
|
100% { transform: rotate(var(--ang)) translateX(3.4rem); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* The rules switch: two dials, and this is the one that isn't the table size. */
|
/* The rules switch: two dials, and this is the one that isn't the table size. */
|
||||||
.pete-seg {
|
.pete-seg {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -2031,7 +2178,8 @@ html[data-phase="night"] {
|
|||||||
.pete-tile-hit,
|
.pete-tile-hit,
|
||||||
.pete-meter[data-hit="1"] { animation: none; }
|
.pete-meter[data-hit="1"] { animation: none; }
|
||||||
.pete-nope,
|
.pete-nope,
|
||||||
.pete-home-flash { animation: none; }
|
.pete-home-flash,
|
||||||
|
.pete-finish { animation: none; }
|
||||||
.pete-card[data-held="1"] { transition: none; }
|
.pete-card[data-held="1"] { transition: none; }
|
||||||
/* The clock still drains — it is information, not decoration — but it stops
|
/* The clock still drains — it is information, not decoration — but it stops
|
||||||
pulsing at you, and a wrong answer stops shaking. */
|
pulsing at you, and a wrong answer stops shaking. */
|
||||||
@@ -2043,6 +2191,9 @@ html[data-phase="night"] {
|
|||||||
.pete-uno-card[data-glow="1"]::before { animation: none; }
|
.pete-uno-card[data-glow="1"]::before { animation: none; }
|
||||||
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; }
|
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; }
|
||||||
.pete-uno-pending { animation: none; }
|
.pete-uno-pending { animation: none; }
|
||||||
|
/* The grave still gets marked — that's information — it just doesn't fall in.
|
||||||
|
uno.js drops a single static headstone here rather than the full rockslide. */
|
||||||
|
.pete-uno-tomb { animation: none; transform: translate(-50%, -50%); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2437,3 +2588,116 @@ html[data-room] .pete-felt {
|
|||||||
.pete-poker-you .pete-seat-cards { --card-h: 6rem; --card-w: 4.3rem; min-height: 6rem; }
|
.pete-poker-you .pete-seat-cards { --card-h: 6rem; --card-w: 4.3rem; min-height: 6rem; }
|
||||||
.pete-poker-pot-total { font-size: 1.25rem; }
|
.pete-poker-pot-total { font-size: 1.25rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Dungeon map (who page). The graph arrives already cut to the fog-of-war
|
||||||
|
frontier; who_map.go lays it out and who.html draws it as inline SVG. Node
|
||||||
|
colours ride the phase through --ink/--card like every card does, with a
|
||||||
|
per-kind tint carried in --map-fill so the SVG discs and the legend dots
|
||||||
|
read from one source. The adventure purple marks the room you're in. */
|
||||||
|
.map-svg { display: block; overflow: visible; }
|
||||||
|
|
||||||
|
.map-edge {
|
||||||
|
stroke: color-mix(in srgb, var(--ink) 26%, transparent);
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.map-edge-locked {
|
||||||
|
stroke: #c98a2b; /* a barred door reads amber, not ink */
|
||||||
|
stroke-dasharray: 3 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-disc {
|
||||||
|
fill: var(--map-fill, color-mix(in srgb, var(--ink) 9%, var(--card)));
|
||||||
|
stroke: color-mix(in srgb, var(--map-stroke, var(--ink)) 55%, transparent);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.map-glyph {
|
||||||
|
fill: color-mix(in srgb, var(--map-stroke, var(--ink)) 85%, var(--ink));
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.map-node-current .map-disc { stroke: #6d4bd8; stroke-width: 2.5; }
|
||||||
|
.map-ring { fill: none; stroke: #6d4bd8; stroke-width: 2; opacity: 0.5; }
|
||||||
|
|
||||||
|
/* Per-kind tint. Set on the node group (and the legend dot); both the disc
|
||||||
|
fill and the glyph colour derive from it. */
|
||||||
|
.map-node-entry { --map-fill: color-mix(in srgb, #3fa66a 22%, var(--card)); --map-stroke: #2f8a54; }
|
||||||
|
.map-node-boss { --map-fill: color-mix(in srgb, #c0392b 22%, var(--card)); --map-stroke: #a52f22; }
|
||||||
|
.map-node-trap { --map-fill: color-mix(in srgb, #d98324 22%, var(--card)); --map-stroke: #b56a17; }
|
||||||
|
.map-node-elite { --map-fill: color-mix(in srgb, #6d4bd8 20%, var(--card)); --map-stroke: #5836b8; }
|
||||||
|
.map-node-secret { --map-fill: color-mix(in srgb, #b08d2e 22%, var(--card)); --map-stroke: #8f7018; }
|
||||||
|
.map-node-harvest { --map-fill: color-mix(in srgb, #3f8f6a 18%, var(--card)); --map-stroke: #2f7355; }
|
||||||
|
.map-node-rest { --map-fill: color-mix(in srgb, #3f83a6 18%, var(--card)); --map-stroke: #2f6a88; }
|
||||||
|
.map-node-plain { --map-fill: color-mix(in srgb, var(--ink) 9%, var(--card)); --map-stroke: var(--ink); }
|
||||||
|
.map-node-unknown { --map-fill: color-mix(in srgb, var(--ink) 5%, var(--card)); --map-stroke: color-mix(in srgb, var(--ink) 40%, transparent); }
|
||||||
|
.map-node-unknown .map-disc { stroke-dasharray: 3 4; }
|
||||||
|
|
||||||
|
/* Legend swatches. Same --map-fill source as the nodes. */
|
||||||
|
.map-dot {
|
||||||
|
width: 11px; height: 11px; border-radius: 9999px;
|
||||||
|
background: var(--map-fill, color-mix(in srgb, var(--ink) 9%, var(--card)));
|
||||||
|
border: 1.5px solid color-mix(in srgb, var(--map-stroke, var(--ink)) 55%, transparent);
|
||||||
|
}
|
||||||
|
.map-node-unknown.map-dot { border-style: dashed; }
|
||||||
|
.map-door-legend {
|
||||||
|
width: 16px; height: 0;
|
||||||
|
border-top: 2.5px dashed #c98a2b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Item compare chips (who page, owner's backpack). gogobee decides the verdict
|
||||||
|
and per-stat deltas; Pete only colours them. Every colour mixes a fixed hue
|
||||||
|
into --ink for the text and --card for the fill, so it lands on the readable
|
||||||
|
side of the card in all four phases — the same by-construction contrast trick
|
||||||
|
the dungeon map uses, not a Tailwind dark: variant (which follows the OS, not
|
||||||
|
Pete's phase). The verdict chip is the anchor and always renders; a phone has
|
||||||
|
no hover, so nothing hides behind one. */
|
||||||
|
.cmp-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||||
|
font-size: 11px; font-weight: 600; line-height: 1;
|
||||||
|
border-radius: 9999px; padding: 0.18rem 0.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.cmp-up {
|
||||||
|
color: color-mix(in srgb, #3fa66a 65%, var(--ink));
|
||||||
|
background: color-mix(in srgb, #3fa66a 16%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, #3fa66a 38%, transparent);
|
||||||
|
}
|
||||||
|
.cmp-down {
|
||||||
|
color: color-mix(in srgb, #c0392b 60%, var(--ink));
|
||||||
|
background: color-mix(in srgb, #c0392b 15%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, #c0392b 36%, transparent);
|
||||||
|
}
|
||||||
|
.cmp-side {
|
||||||
|
color: color-mix(in srgb, var(--ink) 80%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--ink) 8%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, var(--ink) 22%, transparent);
|
||||||
|
}
|
||||||
|
.cmp-new {
|
||||||
|
color: color-mix(in srgb, #6d4bd8 62%, var(--ink));
|
||||||
|
background: color-mix(in srgb, #6d4bd8 15%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, #6d4bd8 36%, transparent);
|
||||||
|
}
|
||||||
|
.cmp-inert {
|
||||||
|
color: var(--warn); /* --warn is already a phase variable — safe on night */
|
||||||
|
background: color-mix(in srgb, var(--warn) 16%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 36%, transparent);
|
||||||
|
}
|
||||||
|
.cmp-same {
|
||||||
|
color: color-mix(in srgb, var(--ink) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--ink) 6%, var(--card));
|
||||||
|
border-color: color-mix(in srgb, var(--ink) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-stat deltas — tint-only, lighter than the verdict chip so it stays the
|
||||||
|
anchor. Green reads as a gain, red as a loss; the engine set the flag. */
|
||||||
|
.cmp-delta {
|
||||||
|
font-size: 11px; font-weight: 500; line-height: 1;
|
||||||
|
border-radius: 9999px; padding: 0.15rem 0.45rem;
|
||||||
|
}
|
||||||
|
.cmp-delta-up { color: color-mix(in srgb, #3fa66a 65%, var(--ink)); background: color-mix(in srgb, #3fa66a 13%, var(--card)); }
|
||||||
|
.cmp-delta-down { color: color-mix(in srgb, #c0392b 60%, var(--ink)); background: color-mix(in srgb, #c0392b 12%, var(--card)); }
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -265,13 +265,10 @@
|
|||||||
verdictEl.textContent = text;
|
verdictEl.textContent = text;
|
||||||
verdictEl.classList.remove("hidden");
|
verdictEl.classList.remove("hidden");
|
||||||
|
|
||||||
// The one thing in this room that gets confetti. A natural is rare, it pays
|
// Every win gets it to rain, because a win should feel like one. A natural is
|
||||||
// 3:2, and if everything celebrated then nothing would.
|
// rarer and pays 3:2, so it keeps the confetti on top of the money as well.
|
||||||
//
|
if (v.outcome === "blackjack") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
|
||||||
// The *sound* is not so precious: a win is a win and you should hear it. So
|
else if (v.net > 0) FX.moneyRain();
|
||||||
// the fanfare rides on the money, not on the confetti.
|
|
||||||
if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 });
|
|
||||||
else if (v.net > 0) FX.sfx("win");
|
|
||||||
else if (v.net < 0) FX.sfx("lose");
|
else if (v.net < 0) FX.sfx("lose");
|
||||||
else FX.sfx("push");
|
else FX.sfx("push");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,65 @@
|
|||||||
return Promise.all(done);
|
return Promise.all(done);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// moneyRain: it rains money. The big finish on a win — bills and coins tumbling
|
||||||
|
// the whole height of the window, swaying and spinning as they fall. Louder than
|
||||||
|
// confetti, and it carries its own sound (a coin cascade) so it can stand in for
|
||||||
|
// the plain win fanfare wherever a table decides a win is worth the theatre.
|
||||||
|
//
|
||||||
|
// Like burst, the sound comes before the reduced-motion bail: no rain still
|
||||||
|
// deserves to be heard.
|
||||||
|
function moneyRain(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
if (opts.sound !== false) sfx(opts.sound || "jackpot");
|
||||||
|
if (reduced) return Promise.resolve();
|
||||||
|
|
||||||
|
var n = opts.count || 22;
|
||||||
|
var glyphs = opts.glyphs || ["💶", "💰", "🪙", "💵", "💴", "🤑"];
|
||||||
|
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
var done = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
var bit = document.createElement("div");
|
||||||
|
bit.className = "pete-money";
|
||||||
|
bit.textContent = glyphs[i % glyphs.length];
|
||||||
|
stage().appendChild(bit);
|
||||||
|
|
||||||
|
// Spread across the width in even lanes, each nudged off its lane so the
|
||||||
|
// curtain doesn't read as a grid. It sways sideways and spins on the way down.
|
||||||
|
var x = ((i + 0.5) / n) * vw + jitter(i, vw / n / 2);
|
||||||
|
var drift = jitter(i + 5, 80);
|
||||||
|
var startY = -50 - Math.abs(jitter(i + 2, 180));
|
||||||
|
var spin = jitter(i, 300);
|
||||||
|
var scale = 0.85 + Math.abs(jitter(i + 3, 0.55));
|
||||||
|
|
||||||
|
done.push(
|
||||||
|
bit
|
||||||
|
.animate(
|
||||||
|
[
|
||||||
|
{ transform: t(x, startY, scale, 0), opacity: 0, offset: 0 },
|
||||||
|
{ transform: t(x + drift, startY + 40, scale, spin * 0.2), opacity: 1, offset: 0.1 },
|
||||||
|
{ transform: t(x - drift, vh * 0.55, scale, spin * 0.6), opacity: 1, offset: 0.6 },
|
||||||
|
{ transform: t(x + drift * 0.5, vh + 60, scale, spin), opacity: 1, offset: 1 },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 1600 + Math.abs(jitter(i + 9, 1)) * 900,
|
||||||
|
easing: "cubic-bezier(0.35, 0.15, 0.55, 1)",
|
||||||
|
fill: "both",
|
||||||
|
delay: Math.abs(jitter(i, 320)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.finished.catch(function () {})
|
||||||
|
.then(
|
||||||
|
(function (b) {
|
||||||
|
return function () { b.remove(); };
|
||||||
|
})(bit)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.all(done);
|
||||||
|
}
|
||||||
|
|
||||||
// count rolls a number to a new value instead of swapping it. A chip count that
|
// count rolls a number to a new value instead of swapping it. A chip count that
|
||||||
// jumps is a variable; one that climbs is a payout.
|
// jumps is a variable; one that climbs is a payout.
|
||||||
function count(el, to, opts) {
|
function count(el, to, opts) {
|
||||||
@@ -359,6 +418,7 @@
|
|||||||
flyMany: flyMany,
|
flyMany: flyMany,
|
||||||
spot: spot,
|
spot: spot,
|
||||||
burst: burst,
|
burst: burst,
|
||||||
|
moneyRain: moneyRain,
|
||||||
count: count,
|
count: count,
|
||||||
centre: centre,
|
centre: centre,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
// The noise the room makes.
|
// The noise the room makes.
|
||||||
//
|
//
|
||||||
// There are no audio files here and there is nothing to download. Every sound in
|
// The room speaks in two voices. The cards and chips are *recorded* — real clay
|
||||||
// this casino is *made* — an oscillator, a burst of filtered noise, an envelope —
|
// and real card stock, CC0 foley from Kenney's casino pack, sitting as small ogg
|
||||||
// the same bargain the weather engine takes with its clouds. A card is a short
|
// files under /static/audio/casino. Synthesis never quite caught the grain of a
|
||||||
// slap of noise through a bandpass; a chip is two detuned sines with a click on
|
// chip landing on a chip, so for those we stopped trying. Everything melodic —
|
||||||
// the front; a win is four notes going up. It costs about six kilobytes and no
|
// the wins, the losses, the little ticks — is still *made*: an oscillator, a
|
||||||
// round trips, and it means a sound can be pitched, stretched and detuned per
|
// burst of filtered noise, an envelope, the same bargain the weather engine takes
|
||||||
// call instead of being the same wav 300 times.
|
// with its clouds. A win is four notes going up; a chip is a recording of a chip.
|
||||||
|
//
|
||||||
|
// A name is a recording if it appears in SAMPLES and synthesised if it appears in
|
||||||
|
// SOUNDS. The recordings guard against the one weakness of a sample — the same wav
|
||||||
|
// 300 times is a machine gun — by keeping several takes per sound and rotating
|
||||||
|
// through them, and by nudging every play a few percent off pitch. The synthesised
|
||||||
|
// half was always immune to that; it varies itself.
|
||||||
//
|
//
|
||||||
// Two rules hold the whole file up.
|
// Two rules hold the whole file up.
|
||||||
//
|
//
|
||||||
@@ -21,6 +27,10 @@
|
|||||||
// default-off switch for the entire file, checked before anything else happens.
|
// default-off switch for the entire file, checked before anything else happens.
|
||||||
//
|
//
|
||||||
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
|
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
|
||||||
|
//
|
||||||
|
// If a recording hasn't finished decoding on the frame it's first needed, that one
|
||||||
|
// call falls through to the synthesised version — so the table is never silent
|
||||||
|
// while the ogg loads, and by the second card everything is real.
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -108,6 +118,95 @@
|
|||||||
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
|
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the recordings --------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The foley. Each name maps to a handful of takes; `v` (the index of the card or
|
||||||
|
// chip in a run) picks which one and how far off pitch it lands, so a dealt hand
|
||||||
|
// is four different cards rather than one card four times. `gain` is per-sound
|
||||||
|
// and multiplies the master, exactly like the synthesised sounds' peak does.
|
||||||
|
|
||||||
|
var SAMPLE_BASE = "/static/audio/casino/";
|
||||||
|
var SAMPLES = {
|
||||||
|
// A card thrown down onto the table. `vary` is the largest fraction a take is
|
||||||
|
// ever pitched by, up or down — 0.20 means a play can land anywhere from a fifth
|
||||||
|
// slower (deeper) to a fifth faster (brighter).
|
||||||
|
card: { files: ["cardPlace1", "cardPlace2", "cardPlace3", "cardPlace4"], gain: 0.6, vary: 0.20 },
|
||||||
|
// A card slid into place — softer, longer than a throw.
|
||||||
|
deal: { files: ["cardSlide1", "cardSlide2", "cardSlide3", "cardSlide4", "cardSlide5", "cardSlide6"], gain: 0.5, vary: 0.20 },
|
||||||
|
// A card flicked over.
|
||||||
|
flip: { files: ["cardFan1", "cardFan2"], gain: 0.5, vary: 0.18 },
|
||||||
|
// The riffle — one take, so the pitch wobble is all that keeps two shuffles apart.
|
||||||
|
shuffle: { files: ["cardShuffle"], gain: 0.6, vary: 0.10 },
|
||||||
|
// A clay chip set down on a stack.
|
||||||
|
chip: { files: ["chipLay1", "chipLay2"], gain: 0.6, vary: 0.22 },
|
||||||
|
// Chips gathered and slid away.
|
||||||
|
sweep: { files: ["chipsHandle2", "chipsHandle4", "chipsHandle6"], gain: 0.5, vary: 0.14 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decoded buffers by filename. A value of null means "claimed, in flight or
|
||||||
|
// decoding"; false means "we tried and it failed, never bother again"; an
|
||||||
|
// AudioBuffer means ready.
|
||||||
|
var buffers = {};
|
||||||
|
var warmed = false;
|
||||||
|
|
||||||
|
// A rotating cursor per sound, so that even a run of identical calls walks
|
||||||
|
// through the takes rather than replaying the first one. Callers pass `v` to
|
||||||
|
// separate simultaneous sounds (the cards of one deal), but many pass the same
|
||||||
|
// constant every time — the cursor is what keeps a stack of chips from being the
|
||||||
|
// same click over and over.
|
||||||
|
var turn = {};
|
||||||
|
|
||||||
|
// decode fetches a file and turns it into a buffer, exactly once per filename.
|
||||||
|
// decodeAudioData exists in a modern promise flavour and an old callback one;
|
||||||
|
// this handles both so the foley works on older Safari too.
|
||||||
|
function decode(file) {
|
||||||
|
if (file in buffers) return; // ready, in flight, or known-bad
|
||||||
|
buffers[file] = null;
|
||||||
|
fetch(SAMPLE_BASE + file + ".ogg")
|
||||||
|
.then(function (r) { return r.ok ? r.arrayBuffer() : Promise.reject(); })
|
||||||
|
.then(function (ab) {
|
||||||
|
return new Promise(function (res, rej) {
|
||||||
|
var p = ctx.decodeAudioData(ab, res, rej);
|
||||||
|
if (p && p.then) p.then(res, rej);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (buf) { buffers[file] = buf; })
|
||||||
|
.catch(function () { buffers[file] = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// warm pulls every take down once the context is awake, so that after the first
|
||||||
|
// click the whole set is decoded and ready and nothing has to fall back.
|
||||||
|
function warm() {
|
||||||
|
if (warmed || !ctx) return;
|
||||||
|
warmed = true;
|
||||||
|
for (var name in SAMPLES) SAMPLES[name].files.forEach(decode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sample plays one recorded take. It returns false — rather than making a noise —
|
||||||
|
// when the name isn't a recording or its buffer isn't decoded yet, which is the
|
||||||
|
// caller's signal to reach for the synthesised version instead.
|
||||||
|
function sample(name, t0, v) {
|
||||||
|
var cfg = SAMPLES[name];
|
||||||
|
if (!cfg) return false;
|
||||||
|
// Advance the cursor, then offset it by v. The cursor guarantees consecutive
|
||||||
|
// calls move to the next take; v spreads apart sounds fired together (one
|
||||||
|
// deal's cards) so they don't all land on the same take at once.
|
||||||
|
var k = (turn[name] = (turn[name] || 0) + 1) + Math.abs(Math.round(v));
|
||||||
|
var buf = buffers[cfg.files[k % cfg.files.length]];
|
||||||
|
if (!buf) return false; // not decoded yet, or failed: let synth cover it
|
||||||
|
var src = ctx.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
// Pitch, walking with the same cursor across seven steps between -vary and
|
||||||
|
// +vary, so even a single-take sound like the shuffle isn't identical twice in
|
||||||
|
// a row. Seven steps rather than a couple means the runs don't read as a loop.
|
||||||
|
if (cfg.vary) src.playbackRate.value = 1 + (((k % 7) - 3) / 3) * cfg.vary;
|
||||||
|
var g = ctx.createGain();
|
||||||
|
g.gain.value = cfg.gain == null ? 0.6 : cfg.gain;
|
||||||
|
src.connect(g).connect(master);
|
||||||
|
src.start(t0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- the sounds ------------------------------------------------------------
|
// ---- the sounds ------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Each one takes the time it starts at, and a `v` — a small per-call variation,
|
// Each one takes the time it starts at, and a `v` — a small per-call variation,
|
||||||
@@ -165,6 +264,17 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// It rains money. A bright run of coins spilling out over the win fanfare —
|
||||||
|
// ten metallic pings climbing a pentatonic scale, each with a tick on the
|
||||||
|
// front of it so it lands as coin rather than as bell.
|
||||||
|
jackpot: function (t) {
|
||||||
|
var notes = [784, 988, 1175, 1319, 1568, 1760];
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
tone(t + i * 0.05, notes[i % notes.length], { type: "triangle", decay: 0.13, gain: 0.09 });
|
||||||
|
hiss(t + i * 0.05, { freq: 5200, q: 3, decay: 0.02, gain: 0.05 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Two notes down, and the second one is flat. Nobody needs telling twice.
|
// Two notes down, and the second one is flat. Nobody needs telling twice.
|
||||||
lose: function (t) {
|
lose: function (t) {
|
||||||
tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 });
|
tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 });
|
||||||
@@ -208,6 +318,43 @@
|
|||||||
tick: function (t) {
|
tick: function (t) {
|
||||||
hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 });
|
hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The mercy rule buries someone. Four ways to go, one sound each — uno.js
|
||||||
|
// rolls which animation plays and asks for the sound that matches it.
|
||||||
|
//
|
||||||
|
// Rocks coming down: a low rumble with tumbling knocks over the top, and a
|
||||||
|
// second slump as the pile settles.
|
||||||
|
rockslide: function (t) {
|
||||||
|
hiss(t, { filter: "lowpass", freq: 320, sweepTo: 140, decay: 0.5, gain: 0.34, q: 0.6 });
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
tone(t + i * 0.055, 128 - i * 9, { type: "square", decay: 0.1, gain: 0.09 });
|
||||||
|
}
|
||||||
|
hiss(t + 0.3, { filter: "lowpass", freq: 200, decay: 0.32, gain: 0.3, q: 0.5 });
|
||||||
|
},
|
||||||
|
|
||||||
|
// A headstone dropping in: one heavy stone thud that rings a little as it sets.
|
||||||
|
tombstone: function (t) {
|
||||||
|
tone(t, 92, { type: "sine", to: 58, glide: 0.16, decay: 0.42, gain: 0.32 });
|
||||||
|
hiss(t, { filter: "lowpass", freq: 520, decay: 0.12, gain: 0.34, q: 0.6 });
|
||||||
|
tone(t + 0.02, 184, { type: "triangle", decay: 0.5, gain: 0.08 });
|
||||||
|
},
|
||||||
|
|
||||||
|
// The Mega Man death: the pieces zip outward and the pitch falls away as they go.
|
||||||
|
zap: function (t) {
|
||||||
|
tone(t, 1250, { type: "sawtooth", to: 170, glide: 0.34, decay: 0.36, gain: 0.12 });
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
tone(t + i * 0.02, 940 - i * 130, { type: "square", decay: 0.06, gain: 0.06 });
|
||||||
|
}
|
||||||
|
hiss(t, { freq: 3000, sweepTo: 600, decay: 0.3, gain: 0.1, q: 0.8 });
|
||||||
|
},
|
||||||
|
|
||||||
|
// A coffin lid: two flat wooden knocks, the second lower and heavier.
|
||||||
|
coffin: function (t) {
|
||||||
|
tone(t, 150, { type: "triangle", decay: 0.14, gain: 0.26 });
|
||||||
|
hiss(t, { filter: "lowpass", freq: 820, decay: 0.05, gain: 0.24, q: 0.7 });
|
||||||
|
tone(t + 0.17, 108, { type: "triangle", decay: 0.22, gain: 0.28 });
|
||||||
|
hiss(t + 0.17, { filter: "lowpass", freq: 600, decay: 0.06, gain: 0.24, q: 0.7 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- the door --------------------------------------------------------------
|
// ---- the door --------------------------------------------------------------
|
||||||
@@ -217,6 +364,7 @@
|
|||||||
// gesture — after which play() can schedule freely.
|
// gesture — after which play() can schedule freely.
|
||||||
function wake() {
|
function wake() {
|
||||||
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
|
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
|
||||||
|
warm();
|
||||||
}
|
}
|
||||||
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
|
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
|
||||||
window.addEventListener(ev, wake, { passive: true });
|
window.addEventListener(ev, wake, { passive: true });
|
||||||
@@ -231,13 +379,18 @@
|
|||||||
// lands in 400ms can say so rather than sleeping.
|
// lands in 400ms can say so rather than sleeping.
|
||||||
function play(name, opts) {
|
function play(name, opts) {
|
||||||
if (muted) return;
|
if (muted) return;
|
||||||
var s = SOUNDS[name];
|
if (!SOUNDS[name] && !SAMPLES[name]) return; // known to neither voice
|
||||||
if (!s) return;
|
|
||||||
if (!boot()) return;
|
if (!boot()) return;
|
||||||
wake();
|
wake();
|
||||||
if (ctx.state !== "running") return; // not yet touched: no sound, and no error
|
if (ctx.state !== "running") return; // not yet touched: no sound, and no error
|
||||||
|
var t0 = ctx.currentTime + ((opts && opts.delay) || 0);
|
||||||
|
var v = (opts && opts.v) || 0;
|
||||||
try {
|
try {
|
||||||
s(ctx.currentTime + ((opts && opts.delay) || 0), ((opts && opts.v) || 0));
|
// A recording if we have one decoded; the synthesised take otherwise — both
|
||||||
|
// for names that are only ever synthesised, and for the first call of a
|
||||||
|
// recorded name while its ogg is still loading.
|
||||||
|
if (sample(name, t0, v)) return;
|
||||||
|
if (SOUNDS[name]) SOUNDS[name](t0, v);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* a sound is never worth throwing over */
|
/* a sound is never worth throwing over */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,10 +293,10 @@
|
|||||||
verdictEl.textContent = text;
|
verdictEl.textContent = text;
|
||||||
verdictEl.classList.remove("hidden");
|
verdictEl.classList.remove("hidden");
|
||||||
|
|
||||||
// Confetti for a phrase guessed outright — the one call you make on your own
|
// A phrase guessed outright keeps the confetti — the one call you make on your
|
||||||
// rather than by grinding the alphabet.
|
// own rather than by grinding the alphabet — and any win at all makes it rain.
|
||||||
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 });
|
if (v.outcome === "solved" && v.net > 0) { FX.burst(verdictEl, { count: 30 }); FX.moneyRain({ count: 28 }); }
|
||||||
else if (v.net > 0) FX.sfx("win");
|
else if (v.net > 0) FX.moneyRain();
|
||||||
else if (v.net < 0) FX.sfx("lose");
|
else if (v.net < 0) FX.sfx("lose");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -370,7 +370,12 @@
|
|||||||
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
|
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
|
||||||
potTotal.textContent = money(pot.amount);
|
potTotal.textContent = money(pot.amount);
|
||||||
moveStack(e.seat, e.amount);
|
moveStack(e.seat, e.amount);
|
||||||
if (e.seat === me && e.amount > 0) FX.burst(s.plate, { count: 18 });
|
if (e.seat === me && e.amount > 0) {
|
||||||
|
FX.burst(s.plate, { count: 18 });
|
||||||
|
// A real haul makes it rain; a blind-steal doesn't, or poker would be
|
||||||
|
// raining money every thirty seconds. Twenty big blinds is a pot.
|
||||||
|
if (view && view.tier && e.amount >= 20 * view.tier.bb) FX.moneyRain({ count: 24 });
|
||||||
|
}
|
||||||
return wait(260);
|
return wait(260);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,10 @@
|
|||||||
|
|
||||||
var playing = root.querySelector("[data-playing]");
|
var playing = root.querySelector("[data-playing]");
|
||||||
var betting = root.querySelector("[data-betting]");
|
var betting = root.querySelector("[data-betting]");
|
||||||
|
var playControls = root.querySelector("[data-play-controls]");
|
||||||
|
var wonControls = root.querySelector("[data-won-controls]");
|
||||||
var autoBtn = root.querySelector("[data-auto]");
|
var autoBtn = root.querySelector("[data-auto]");
|
||||||
|
var finishBtn = root.querySelector("[data-finish]");
|
||||||
var cashBtn = root.querySelector("[data-cash]");
|
var cashBtn = root.querySelector("[data-cash]");
|
||||||
var cashAmountEl = root.querySelector("[data-cash-amount]");
|
var cashAmountEl = root.querySelector("[data-cash-amount]");
|
||||||
var startBtn = root.querySelector("[data-start]");
|
var startBtn = root.querySelector("[data-start]");
|
||||||
@@ -258,6 +261,11 @@
|
|||||||
playing.classList.toggle("hidden", !live);
|
playing.classList.toggle("hidden", !live);
|
||||||
betting.classList.toggle("hidden", live);
|
betting.classList.toggle("hidden", live);
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
|
// A won board has nothing left to decide, so the ordinary controls step aside
|
||||||
|
// for the one button that finishes it.
|
||||||
|
var won = !!v.won;
|
||||||
|
wonControls.classList.toggle("hidden", !won);
|
||||||
|
playControls.classList.toggle("hidden", won);
|
||||||
autoBtn.disabled = !v.can_auto;
|
autoBtn.disabled = !v.can_auto;
|
||||||
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
|
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
|
||||||
}
|
}
|
||||||
@@ -545,6 +553,10 @@
|
|||||||
|
|
||||||
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
||||||
|
|
||||||
|
// The finish button. On a won board a single auto drains every card home in one
|
||||||
|
// cascade, and the board settles cleared on the far end of it.
|
||||||
|
finishBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
||||||
|
|
||||||
cashBtn.addEventListener("click", function () {
|
cashBtn.addEventListener("click", function () {
|
||||||
drop();
|
drop();
|
||||||
send({ kind: "concede" });
|
send({ kind: "concede" });
|
||||||
@@ -576,9 +588,9 @@
|
|||||||
verdictEl.textContent = text;
|
verdictEl.textContent = text;
|
||||||
verdictEl.classList.remove("hidden");
|
verdictEl.classList.remove("hidden");
|
||||||
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
|
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
|
||||||
// this room, so it's the one that gets the confetti.
|
// this room, so it keeps the confetti — but any win now makes it rain.
|
||||||
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 });
|
if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 40 }); FX.moneyRain({ count: 34 }); }
|
||||||
else if (v.net > 0) FX.sfx("win");
|
else if (v.net > 0) FX.moneyRain();
|
||||||
else if (v.net < 0) FX.sfx("lose");
|
else if (v.net < 0) FX.sfx("lose");
|
||||||
else FX.sfx("push");
|
else FX.sfx("push");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,8 +307,9 @@
|
|||||||
verdictEl.textContent = text;
|
verdictEl.textContent = text;
|
||||||
verdictEl.classList.remove("hidden");
|
verdictEl.classList.remove("hidden");
|
||||||
|
|
||||||
// Confetti only for clearing all twelve — the one thing in here worth it.
|
// Clearing all twelve keeps the confetti on top; any win at all makes it rain.
|
||||||
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 });
|
if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
|
||||||
|
else if (v.net > 0) FX.moneyRain();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPhase(v) {
|
function setPhase(v) {
|
||||||
|
|||||||
@@ -304,12 +304,22 @@
|
|||||||
colourEl.textContent = "";
|
colourEl.textContent = "";
|
||||||
if (feltEl) feltEl.dataset.c = "";
|
if (feltEl) feltEl.dataset.c = "";
|
||||||
}
|
}
|
||||||
pending(live(v) ? (v.pending || 0) : 0);
|
pending(live(v) ? (v.pending || 0) : 0, v);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pending(n) {
|
// Who the running stack lands on next: during a stack phase that's whoever's
|
||||||
|
// turn it is. Fall back to the view the caller passed, then to the live game.
|
||||||
|
function pendingTarget(v) {
|
||||||
|
var src = v || game;
|
||||||
|
if (!src) return "you";
|
||||||
|
if (src.turn === me) return "you";
|
||||||
|
var s = (src.seats && src.seats[src.turn]) || null;
|
||||||
|
return (s && s.name) || "them";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pending(n, v) {
|
||||||
if (billEl) {
|
if (billEl) {
|
||||||
billEl.textContent = n > 0 ? "+" + n + " on you" : "";
|
billEl.textContent = n > 0 ? "+" + n + " on " + pendingTarget(v) : "";
|
||||||
billEl.classList.toggle("hidden", n <= 0);
|
billEl.classList.toggle("hidden", n <= 0);
|
||||||
}
|
}
|
||||||
if (takeN) takeN.textContent = String(n);
|
if (takeN) takeN.textContent = String(n);
|
||||||
@@ -355,7 +365,7 @@
|
|||||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||||
verdictEl.textContent = text;
|
verdictEl.textContent = text;
|
||||||
verdictEl.classList.remove("hidden");
|
verdictEl.classList.remove("hidden");
|
||||||
if (v.winner === me && v.outcome !== "tie") FX.burst(verdictEl, { count: 34 });
|
if (v.winner === me && v.outcome !== "tie") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
|
||||||
else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose");
|
else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +485,80 @@
|
|||||||
return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); });
|
return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bury gives the mercy rule some ceremony. It rolls one of four send-offs and
|
||||||
|
// drops it over the buried seat — or over your hand, if it's you going down —
|
||||||
|
// with the noise that goes with it. Every piece is built from scratch and
|
||||||
|
// clears itself, so a repaint that lands mid-fall just cuts it short. Resolves
|
||||||
|
// on the dramatic beat, not on cleanup: the hand shouldn't wait for the dust.
|
||||||
|
var BURIALS = ["rocks", "tomb", "zap", "coffin"];
|
||||||
|
|
||||||
|
function bury(seat) {
|
||||||
|
var host = seat === me ? handEl : seatEl(seat);
|
||||||
|
if (!host) return Promise.resolve();
|
||||||
|
|
||||||
|
var wrap = document.createElement("div");
|
||||||
|
wrap.className = "pete-uno-bury";
|
||||||
|
host.appendChild(wrap);
|
||||||
|
|
||||||
|
// No theatre for reduced motion: a single static headstone marks the grave,
|
||||||
|
// with the stone-drop thud so it isn't silent either.
|
||||||
|
if (reduced) {
|
||||||
|
FX.sfx("tombstone");
|
||||||
|
var stone = document.createElement("div");
|
||||||
|
stone.className = "pete-uno-tomb";
|
||||||
|
stone.textContent = "🪦";
|
||||||
|
wrap.appendChild(stone);
|
||||||
|
setTimeout(function () { wrap.remove(); }, 1400);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
var kind = BURIALS[Math.floor(Math.random() * BURIALS.length)];
|
||||||
|
wrap.dataset.kind = kind;
|
||||||
|
|
||||||
|
if (kind === "rocks") {
|
||||||
|
FX.sfx("rockslide");
|
||||||
|
[
|
||||||
|
{ dx: "-1.15rem", dy: "0.55rem", rot: "-20deg", d: 0 },
|
||||||
|
{ dx: "0.95rem", dy: "0.7rem", rot: "24deg", d: 70 },
|
||||||
|
{ dx: "-0.15rem", dy: "0.15rem", rot: "8deg", d: 150 },
|
||||||
|
{ dx: "-1.5rem", dy: "1.0rem", rot: "-34deg", d: 220 },
|
||||||
|
{ dx: "1.45rem", dy: "0.95rem", rot: "16deg", d: 300 },
|
||||||
|
{ dx: "0.35rem", dy: "1.05rem", rot: "-10deg", d: 360 },
|
||||||
|
].forEach(function (r) {
|
||||||
|
var el = document.createElement("div");
|
||||||
|
el.className = "pete-uno-rock";
|
||||||
|
el.textContent = "🪨";
|
||||||
|
el.style.setProperty("--dx", r.dx);
|
||||||
|
el.style.setProperty("--dy", r.dy);
|
||||||
|
el.style.setProperty("--rot", r.rot);
|
||||||
|
el.style.setProperty("--d", r.d + "ms");
|
||||||
|
wrap.appendChild(el);
|
||||||
|
});
|
||||||
|
} else if (kind === "zap") {
|
||||||
|
FX.sfx("zap");
|
||||||
|
var flash = document.createElement("div");
|
||||||
|
flash.className = "pete-uno-zap-flash";
|
||||||
|
wrap.appendChild(flash);
|
||||||
|
for (var a = 0; a < 8; a++) {
|
||||||
|
var bit = document.createElement("div");
|
||||||
|
bit.className = "pete-uno-zap-bit";
|
||||||
|
bit.style.setProperty("--ang", (a * 45) + "deg");
|
||||||
|
wrap.appendChild(bit);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// tomb or coffin: one heavy thing drops in and sets.
|
||||||
|
FX.sfx(kind === "tomb" ? "tombstone" : "coffin");
|
||||||
|
var mark = document.createElement("div");
|
||||||
|
mark.className = kind === "tomb" ? "pete-uno-tomb" : "pete-uno-coffin";
|
||||||
|
mark.textContent = kind === "tomb" ? "🪦" : "⚰️";
|
||||||
|
wrap.appendChild(mark);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zap is gone in half a second; the graves linger before they clear.
|
||||||
|
setTimeout(function () { wrap.remove(); }, kind === "zap" ? 650 : 1500);
|
||||||
|
return new Promise(function (r) { setTimeout(r, kind === "zap" ? 480 : 640); });
|
||||||
|
}
|
||||||
|
|
||||||
// play walks the server's script for a move the acting player just made.
|
// play walks the server's script for a move the acting player just made.
|
||||||
function play(view) {
|
function play(view) {
|
||||||
var events = view.uno_events || [];
|
var events = view.uno_events || [];
|
||||||
@@ -570,7 +654,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "stack":
|
case "stack":
|
||||||
pending(e.n);
|
pending(e.n, final);
|
||||||
spotlight(e.seat);
|
spotlight(e.seat);
|
||||||
FX.sfx("bad");
|
FX.sfx("bad");
|
||||||
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
|
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
|
||||||
@@ -614,8 +698,12 @@
|
|||||||
bump(e.seat, 0);
|
bump(e.seat, 0);
|
||||||
showHand(e.hand);
|
showHand(e.hand);
|
||||||
pending(0);
|
pending(0);
|
||||||
FX.sfx("bad");
|
// bury() rolls the send-off and makes its own noise; the badge rides
|
||||||
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
|
// alongside it so you still see what count did them in.
|
||||||
|
return Promise.all([
|
||||||
|
bury(e.seat),
|
||||||
|
badge(e.seat, "Buried on " + e.n, "bad"),
|
||||||
|
]).then(function () { return wait(300); });
|
||||||
}
|
}
|
||||||
|
|
||||||
case "skip":
|
case "skip":
|
||||||
|
|||||||
+17
-11
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
||||||
// drops every cache that doesn't match the current version.
|
// drops every cache that doesn't match the current version.
|
||||||
var CACHE_VERSION = "v4";
|
var CACHE_VERSION = "v5";
|
||||||
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
||||||
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||||
|
|
||||||
@@ -117,18 +117,24 @@ self.addEventListener("fetch", function (event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static assets: cache-first (they're versioned by deploy), fill the cache on
|
// Static assets: network-first. Our asset URLs are NOT content-hashed
|
||||||
// first miss so a later offline visit has them.
|
// (/static/js/weather-gl.js stays the same URL across deploys), so a
|
||||||
|
// cache-first strategy would pin whatever bytes were first cached and never
|
||||||
|
// notice a redeployed file changed — leaving stale JS/CSS in place until the
|
||||||
|
// CACHE_VERSION bump below. Go's FileServer sets ETag/Last-Modified, so an
|
||||||
|
// online refetch is a cheap 304 when nothing changed. We still fill the cache
|
||||||
|
// on every success and fall back to it when the network is unreachable, so
|
||||||
|
// offline reading keeps the shell it needs.
|
||||||
if (url.pathname.indexOf("/static/") === 0) {
|
if (url.pathname.indexOf("/static/") === 0) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(req).then(function (hit) {
|
fetch(req).then(function (res) {
|
||||||
return hit || fetch(req).then(function (res) {
|
if (res && res.ok) {
|
||||||
if (res && res.ok) {
|
var copy = res.clone();
|
||||||
var copy = res.clone();
|
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
|
||||||
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
|
}
|
||||||
}
|
return res;
|
||||||
return res;
|
}).catch(function () {
|
||||||
});
|
return caches.match(req);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -86,7 +86,9 @@
|
|||||||
|
|
||||||
<!-- Playing: shown while a board is live. -->
|
<!-- Playing: shown while a board is live. -->
|
||||||
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
|
<!-- The ordinary controls, while there's still a game to play. -->
|
||||||
|
<div data-play-controls class="flex flex-wrap items-center gap-3">
|
||||||
<button type="button" data-auto
|
<button type="button" data-auto
|
||||||
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
|
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
|
||||||
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
@@ -103,6 +105,22 @@
|
|||||||
Cash the board · <span data-cash-amount class="tabular-nums">0</span>
|
Cash the board · <span data-cash-amount class="tabular-nums">0</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- The board's won: every card's face up and the piles are drained, so
|
||||||
|
there's nothing left to decide. One press sends the lot home. -->
|
||||||
|
<div data-won-controls class="hidden">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" data-finish
|
||||||
|
class="pete-finish rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:pointer-events-none transition">
|
||||||
|
You've cracked it. Send them all home 🎉
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-[color:var(--ink)]/45">
|
||||||
|
The whole board's face up now, so the rest plays itself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+434
-17
@@ -1,5 +1,66 @@
|
|||||||
{{define "title"}}{{.Mark.Name}} — {{.SiteTitle}}{{end}}
|
{{define "title"}}{{.Mark.Name}} — {{.SiteTitle}}{{end}}
|
||||||
|
|
||||||
|
{{/* One item, wherever it's sitting. Worn, backpack, and vault all show the
|
||||||
|
same facts; only the panel around them differs. The description and the
|
||||||
|
effect line come from gogobee already resolved — Pete renders them, it does
|
||||||
|
not compute them. "inert" is the honest word for a worn item that wants a
|
||||||
|
bond and hasn't got one: it's on you and doing nothing. */}}
|
||||||
|
{{define "itemrow"}}
|
||||||
|
<li>
|
||||||
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="flex-1 font-semibold">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">{{if .Tier}}T{{.Tier}}{{end}}{{if .Value}}{{if .Tier}} · {{end}}{{.Value}}g{{end}}</span>
|
||||||
|
</div>
|
||||||
|
{{if or .Slot .SkillSource .Attunement}}
|
||||||
|
<div class="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
{{if .Slot}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/5 px-2 py-0.5 text-[color:var(--ink)]/60">{{.Slot}}</span>{{end}}
|
||||||
|
{{if .SkillSource}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/5 px-2 py-0.5 text-[color:var(--ink)]/60">{{.SkillSource}}</span>{{end}}
|
||||||
|
{{if .Attunement}}{{if .Attuned}}<span class="text-[11px] rounded-full bg-theme-adventure/20 px-2 py-0.5 text-theme-adventure font-semibold">bonded</span>{{else if .Worn}}<span class="text-[11px] rounded-full bg-amber-400/20 px-2 py-0.5 text-[color:var(--warn)] font-semibold">inert · no bond free</span>{{else}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/10 px-2 py-0.5 text-[color:var(--ink)]/60">needs a bond</span>{{end}}{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Desc}}<p class="text-xs text-[color:var(--ink)]/55 mt-1 leading-snug">{{.Desc}}</p>{{end}}
|
||||||
|
{{if .Effect}}<p class="text-xs text-theme-adventure/80 mt-0.5">{{.Effect}}</p>{{end}}
|
||||||
|
{{/* The compare card: how this backpack item stacks up against what's worn in
|
||||||
|
the slot it would equip into. gogobee decided the verdict and the per-stat
|
||||||
|
deltas (it holds the tempering and bond math); Pete only colours them. The
|
||||||
|
verdict chip always shows — a phone has no hover to lean on — with the
|
||||||
|
deltas beside it, each already flagged better/worse. */}}
|
||||||
|
{{with .Compare}}
|
||||||
|
<div class="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
|
{{if eq .Verdict "upgrade"}}<span class="cmp-chip cmp-up">↑ upgrade</span>
|
||||||
|
{{else if eq .Verdict "downgrade"}}<span class="cmp-chip cmp-down">↓ downgrade</span>
|
||||||
|
{{else if eq .Verdict "sidegrade"}}<span class="cmp-chip cmp-side">⇄ sidegrade</span>
|
||||||
|
{{else if eq .Verdict "new"}}<span class="cmp-chip cmp-new">✦ fills {{.VsSlot}}</span>
|
||||||
|
{{else if eq .Verdict "inert"}}<span class="cmp-chip cmp-inert">⚠ would sit inert</span>
|
||||||
|
{{else if eq .Verdict "same"}}<span class="cmp-chip cmp-same">= no change</span>{{end}}
|
||||||
|
{{range .Deltas}}<span class="cmp-delta {{if .Better}}cmp-delta-up{{else}}cmp-delta-down{{end}}">{{.Text}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .VsName}}<p class="text-[11px] text-[color:var(--ink)]/45 mt-0.5">vs worn {{.VsName}}</p>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .EquipAction}}
|
||||||
|
<div class="mt-1.5">
|
||||||
|
<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="{{.EquipAction}}" data-item-id="{{.ID}}" data-slot="{{.Slot}}" data-item-name="{{.Name}}">{{if eq .EquipAction "equip"}}Equip{{else}}Take off{{end}}</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* The dungeon map. Laid out server-side (who_map.go) into an x/y node field;
|
||||||
|
this just draws it. Edges first so nodes sit on top of the lines. Nothing
|
||||||
|
here is a player-authored string — node kinds are a fixed vocabulary and
|
||||||
|
ids are zone-prefixed — so the SVG carries no injection surface. */}}
|
||||||
|
{{define "dungeonmap"}}
|
||||||
|
<svg viewBox="0 0 {{.W}} {{.H}}" width="{{.W}}" height="{{.H}}" role="img" aria-label="Dungeon map" class="map-svg max-w-full h-auto">
|
||||||
|
<g>
|
||||||
|
{{range .Edges}}<line x1="{{.X1}}" y1="{{.Y1}}" x2="{{.X2}}" y2="{{.Y2}}" class="map-edge{{if .Locked}} map-edge-locked{{end}}">{{if .Lock}}<title>{{.Lock}}</title>{{end}}</line>{{end}}
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
{{range .Nodes}}<g class="map-node {{.Class}}{{if .Current}} map-node-current{{end}}" transform="translate({{.X}},{{.Y}})">{{if .Current}}<circle r="21" class="map-ring"></circle>{{end}}<circle r="15" class="map-disc"></circle><text text-anchor="middle" dominant-baseline="central" class="map-glyph">{{.Glyph}}</text><title>{{.Label}}{{if .Current}} — you are here{{end}}</title></g>{{end}}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
|
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
|
||||||
<nav class="mb-4">
|
<nav class="mb-4">
|
||||||
@@ -56,6 +117,10 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* Public gear list. Hidden only when the owner's "Equipment" panel below will
|
||||||
|
actually render (it needs pushed Slots) — otherwise an owner whose detail
|
||||||
|
predates ask 7's Slots would see no standard gear at all. */}}
|
||||||
|
{{if not .Self.Slots}}
|
||||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
<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>
|
<h2 class="font-display text-xl font-bold mb-4">Gear</h2>
|
||||||
{{if .Detail.Gear}}
|
{{if .Detail.Gear}}
|
||||||
@@ -72,13 +137,155 @@
|
|||||||
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
|
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{{if .MapView}}
|
||||||
|
<!-- The dungeon map: the fog-of-war cut gogobee sent. Visited rooms drawn in
|
||||||
|
full, the doors leading out of them, and the unopened rooms behind those
|
||||||
|
doors as question marks. Pete lays it out; it is never told what a room
|
||||||
|
holds, only that a door is there. -->
|
||||||
|
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
<div class="flex items-baseline justify-between mb-4">
|
||||||
|
<h2 class="font-display text-xl font-bold">The map</h2>
|
||||||
|
{{if .Detail.Room}}<span class="text-sm text-[color:var(--ink)]/50">Room <span class="font-semibold text-[color:var(--ink)]/70">{{.Detail.Room}}</span></span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
{{template "dungeonmap" .MapView}}
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-[color:var(--ink)]/55">
|
||||||
|
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-entry"></span>entrance</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-boss"></span>boss</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-unknown"></span>unexplored</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5"><span class="map-door-legend"></span>locked door</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
|
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .HasHistory}}
|
||||||
|
<!-- The record. Public, like the dispatches it's counted from — this is the
|
||||||
|
same information the /adventure feed already printed, only as numbers
|
||||||
|
instead of as forty separate sentences. Not a gogobee snapshot: Pete
|
||||||
|
counted these itself out of the facts it kept. -->
|
||||||
|
<section class="mt-8 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">The record</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||||
|
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||||
|
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.BossKills}}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">bosses down</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||||
|
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.ZoneClears}}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">zones cleared</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||||
|
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.TreasuresFound}}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">treasure{{if ne .Trophies.TreasuresFound 1}}s{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||||
|
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Deaths}}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">death{{if ne .Trophies.Deaths 1}}s{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||||
|
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Retreats}}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">walked out</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if or .Trophies.BossFirsts .Trophies.ZoneFirsts .Trophies.TreasureFirsts}}
|
||||||
|
<p class="mt-3 text-sm text-theme-adventure font-semibold">
|
||||||
|
★ {{if .Trophies.BossFirsts}}{{.Trophies.BossFirsts}} realm-first boss{{if ne .Trophies.BossFirsts 1}}es{{end}}{{end}}{{if and .Trophies.BossFirsts (or .Trophies.ZoneFirsts .Trophies.TreasureFirsts)}}, {{end}}{{if .Trophies.ZoneFirsts}}{{.Trophies.ZoneFirsts}} first clear{{if ne .Trophies.ZoneFirsts 1}}s{{end}}{{end}}{{if and .Trophies.ZoneFirsts .Trophies.TreasureFirsts}}, {{end}}{{if .Trophies.TreasureFirsts}}{{.Trophies.TreasureFirsts}} first hoard{{if ne .Trophies.TreasureFirsts 1}}s{{end}}{{end}}. Nobody had done it before.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-6 sm:grid-cols-2">
|
||||||
|
{{if .Trophies.Bosses}}
|
||||||
|
<div>
|
||||||
|
<h3 class="font-display text-lg font-bold mb-3">Bosses fought</h3>
|
||||||
|
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||||||
|
{{range .Trophies.Bosses}}
|
||||||
|
<li class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="flex-1">{{.Boss}}{{if .First}} <span class="text-theme-adventure" title="first in the realm to clear it">★</span>{{end}}</span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">×{{.Kills}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Trophies.Zones}}
|
||||||
|
<div>
|
||||||
|
<h3 class="font-display text-lg font-bold mb-3">Ground covered</h3>
|
||||||
|
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||||||
|
{{range .Trophies.Zones}}
|
||||||
|
<li class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="flex-1">{{.Zone}}{{if .First}} <span class="text-theme-adventure" title="first clear in the realm">★</span>{{end}}{{if .Region}} <span class="text-[color:var(--ink)]/45">{{.Region}}</span>{{end}}</span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">×{{.Clears}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Trophies.Treasures}}
|
||||||
|
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
|
||||||
|
<h3 class="font-display text-lg font-bold mb-3">Treasures found</h3>
|
||||||
|
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||||||
|
{{range .Trophies.Treasures}}
|
||||||
|
<li class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="flex-1">💎 {{.Item}}{{if .First}} <span class="text-theme-adventure" title="first in the realm to pull this hoard">★</span>{{end}}</span>
|
||||||
|
{{if .Zone}}<span class="text-xs text-[color:var(--ink)]/45 shrink-0">{{.Zone}}</span>{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Trophies.Milestones}}
|
||||||
|
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
|
||||||
|
<h3 class="font-display text-lg font-bold mb-3">Milestones</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{{range .Trophies.Milestones}}
|
||||||
|
<span class="rounded-full bg-theme-adventure/10 text-theme-adventure text-xs font-semibold px-3 py-1">🏅 {{.}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- The trail: every fact we have, newest first, each linking to the dispatch
|
||||||
|
that told it. -->
|
||||||
|
<section class="mt-8 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">The trail</h2>
|
||||||
|
<ol class="relative border-l-2 border-[color:var(--ink)]/10 ml-3 space-y-4">
|
||||||
|
{{range .Timeline}}
|
||||||
|
<li class="relative pl-6">
|
||||||
|
<span class="absolute -left-[13px] top-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-[color:var(--card)] border-2 {{if .Notable}}border-theme-adventure{{else}}border-[color:var(--ink)]/10{{end}} text-xs" aria-hidden="true">{{.Emoji}}</span>
|
||||||
|
<a href="{{.Permalink}}" class="group block">
|
||||||
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="font-semibold group-hover:text-theme-adventure transition {{if .Notable}}text-theme-adventure{{end}}">{{.Label}}</span>
|
||||||
|
<span class="text-xs text-[color:var(--ink)]/45 shrink-0">{{.When}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .Line}}<p class="text-sm text-[color:var(--ink)]/60 mt-0.5">{{.Line}}</p>{{end}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
{{if .MoreHistory}}
|
||||||
|
<p class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 text-xs text-[color:var(--ink)]/45">
|
||||||
|
Showing the most recent {{len .Timeline}}. The counts above cover everything.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .HasSelf}}
|
{{if .HasSelf}}
|
||||||
<!-- Owner-only: this is you. Inventory, vault, house, pets — private, served
|
<!-- Owner-only: this is you. Inventory, vault, house, pets — private, served
|
||||||
only because your signed-in localpart owns this page's token. -->
|
only because your signed-in localpart owns this page's token. -->
|
||||||
@@ -88,6 +295,46 @@
|
|||||||
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
|
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
|
||||||
</div>
|
</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="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">
|
<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>
|
<h2 class="font-display text-xl font-bold mb-4">Home</h2>
|
||||||
@@ -113,32 +360,39 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
<div id="gear-panel" data-token="{{.Mark.Token}}" class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
|
{{if .Worn}}
|
||||||
|
<h2 class="font-display text-xl font-bold mb-1">Worn</h2>
|
||||||
|
<p class="text-xs text-[color:var(--ink)]/45 mb-3">{{.BondsUsed}} of 3 bonds in use</p>
|
||||||
|
<ul class="space-y-2.5 text-sm">
|
||||||
|
{{range .Worn}}{{template "itemrow" .}}{{end}}
|
||||||
|
</ul>
|
||||||
|
<h3 class="font-display text-lg font-bold mt-6 mb-3">Backpack</h3>
|
||||||
|
{{else}}
|
||||||
<h2 class="font-display text-xl font-bold mb-4">Backpack</h2>
|
<h2 class="font-display text-xl font-bold mb-4">Backpack</h2>
|
||||||
{{if .Self.Inventory}}
|
{{end}}
|
||||||
<ul class="space-y-1.5 text-sm max-h-72 overflow-y-auto pr-1">
|
{{if .Backpack}}
|
||||||
{{range .Self.Inventory}}
|
<ul class="space-y-2.5 text-sm max-h-72 overflow-y-auto pr-1">
|
||||||
<li class="flex items-baseline justify-between gap-3">
|
{{range .Backpack}}{{template "itemrow" .}}{{end}}
|
||||||
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
|
||||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
</ul>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="text-sm text-[color:var(--ink)]/50">Backpack's empty.</p>
|
<p class="text-sm text-[color:var(--ink)]/50">Backpack's empty.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Self.Vault}}
|
{{if .VaultRows}}
|
||||||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Vault</h3>
|
<h3 class="font-display text-lg font-bold mt-6 mb-3">Vault</h3>
|
||||||
<ul class="space-y-1.5 text-sm max-h-52 overflow-y-auto pr-1">
|
<ul class="space-y-2.5 text-sm max-h-52 overflow-y-auto pr-1">
|
||||||
{{range .Self.Vault}}
|
{{range .VaultRows}}{{template "itemrow" .}}{{end}}
|
||||||
<li class="flex items-baseline justify-between gap-3">
|
|
||||||
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
|
||||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Changes you've asked for. The queue is honest: an equip lands on the
|
||||||
|
game box's next poll, so a fresh order reads "queued", never "done".
|
||||||
|
JS fills this from /api/equip/orders. -->
|
||||||
|
<div id="equip-orders" class="mt-6 hidden">
|
||||||
|
<h3 class="font-display text-lg font-bold mb-2">Pending changes</h3>
|
||||||
|
<ul id="equip-orders-list" class="space-y-1.5 text-xs"></ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -203,5 +457,168 @@
|
|||||||
}
|
}
|
||||||
timer = setInterval(refresh, 60000);
|
timer = setInterval(refresh, 60000);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// The equip queue, owner side. Clicking Equip / Take off records the intent; the
|
||||||
|
// 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 () {
|
||||||
|
// Two owner panels can carry equip controls: the magic Worn/Backpack panel
|
||||||
|
// (#gear-panel) and the standard-slot management panel (#equip-panel). Either
|
||||||
|
// may be absent, so wire whichever exist and share one handler.
|
||||||
|
var gearPanel = document.getElementById('gear-panel');
|
||||||
|
var equipPanel = document.getElementById('equip-panel');
|
||||||
|
var panels = [gearPanel, equipPanel].filter(Boolean);
|
||||||
|
if (!panels.length) return; // only the owner gets these panels at all
|
||||||
|
var token = (equipPanel || gearPanel).getAttribute('data-token');
|
||||||
|
var balance = equipPanel ? parseFloat(equipPanel.getAttribute('data-balance') || '0') : 0;
|
||||||
|
var box = document.getElementById('equip-orders');
|
||||||
|
var list = document.getElementById('equip-orders-list');
|
||||||
|
|
||||||
|
function euroFmt(n) {
|
||||||
|
return (Math.round(n * 100) / 100).toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// How each terminal status reads to the owner. gogobee only rejects when the
|
||||||
|
// item slipped out from under the order or can't be worn — a bond-cap "inert"
|
||||||
|
// is still an applied change, and its detail line says so.
|
||||||
|
var STATUS = {
|
||||||
|
pending: 'queued',
|
||||||
|
applied: 'done',
|
||||||
|
rejected_not_owned: "couldn't, that item had already moved",
|
||||||
|
rejected_not_worn: "couldn't, that slot was already empty",
|
||||||
|
rejected_not_equippable: "couldn't, that item can't be worn",
|
||||||
|
rejected_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) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!orders || !orders.length) { box.classList.add('hidden'); return; }
|
||||||
|
box.classList.remove('hidden');
|
||||||
|
var anyPending = false;
|
||||||
|
orders.forEach(function (o) {
|
||||||
|
if (o.status === 'pending') anyPending = true;
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'flex items-baseline justify-between gap-3';
|
||||||
|
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');
|
||||||
|
var right = document.createElement('span');
|
||||||
|
right.className = 'shrink-0 ' + (o.status === 'pending'
|
||||||
|
? 'text-[color:var(--ink)]/45'
|
||||||
|
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
|
||||||
|
right.textContent = o.detail || STATUS[o.status] || o.status;
|
||||||
|
li.appendChild(left); li.appendChild(right);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
// While anything is still queued, keep refreshing so the verdict lands without
|
||||||
|
// a reload; stop once everything is terminal.
|
||||||
|
if (anyPending && !pollTimer) {
|
||||||
|
pollTimer = setInterval(loadOrders, 15000);
|
||||||
|
} else if (!anyPending && pollTimer) {
|
||||||
|
clearInterval(pollTimer); pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOrders() {
|
||||||
|
fetch('/api/equip/orders', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (o) { if (o) render(o); })
|
||||||
|
.catch(function () { /* transient — a later tick will do */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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…';
|
||||||
|
fetch('/api/equip/order', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: token,
|
||||||
|
action: btn.getAttribute('data-action'),
|
||||||
|
item_id: parseInt(btn.getAttribute('data-item-id') || '0', 10),
|
||||||
|
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 }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove('opacity-50');
|
||||||
|
btn.textContent = (res.body && res.body.error) || 'try again';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.textContent = 'queued';
|
||||||
|
loadOrders();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
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();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
+210
-1
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -32,6 +33,30 @@ type whoDetail struct {
|
|||||||
Supplies int `json:"supplies"`
|
Supplies int `json:"supplies"`
|
||||||
ThreatLevel int `json:"threat_level"`
|
ThreatLevel int `json:"threat_level"`
|
||||||
Room string `json:"room"`
|
Room string `json:"room"`
|
||||||
|
Map *whoMap `json:"map"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with
|
||||||
|
// their true kind, plus the one-hop frontier of doors whose rooms are withheld
|
||||||
|
// (kind "unknown"). Pete lays it out and draws it; it never receives node
|
||||||
|
// labels or contents, only ids and kinds. See who_map.go.
|
||||||
|
type whoMap struct {
|
||||||
|
ZoneID string `json:"zone_id"`
|
||||||
|
CurrentNode string `json:"current_node"`
|
||||||
|
Visited []string `json:"visited"`
|
||||||
|
Nodes []whoMapNode `json:"nodes"`
|
||||||
|
Edges []whoMapEdge `json:"edges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type whoMapNode struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type whoMapEdge struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Lock string `json:"lock"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type whoGear struct {
|
type whoGear struct {
|
||||||
@@ -57,10 +82,86 @@ type whoPage struct {
|
|||||||
HasDetail bool
|
HasDetail bool
|
||||||
Detail whoDetail
|
Detail whoDetail
|
||||||
Abilities []abilityRow
|
Abilities []abilityRow
|
||||||
|
MapView *mapView // laid-out dungeon map, nil when not on a run or no graph
|
||||||
HasSelf bool
|
HasSelf bool
|
||||||
Self storage.PlayerDetail
|
Self storage.PlayerDetail
|
||||||
|
// The private panels, wrapped so a row knows where it is sitting. Bond state
|
||||||
|
// only means something on a worn item — see itemRow.
|
||||||
|
Worn []itemRow
|
||||||
|
Backpack []itemRow
|
||||||
|
VaultRows []itemRow
|
||||||
|
BondsUsed int
|
||||||
|
// History. Unlike everything above, these are not a gogobee snapshot — they
|
||||||
|
// are counted from the facts Pete has been keeping since adventure_events
|
||||||
|
// landed. HasHistory is false for an adventurer who hasn't done anything since
|
||||||
|
// then, which includes every veteran on the day this shipped: their past is in
|
||||||
|
// the story feed as prose and cannot be counted back out.
|
||||||
|
Trophies storage.TrophyCase
|
||||||
|
HasHistory bool
|
||||||
|
Timeline []timelineEntry
|
||||||
|
MoreHistory bool // the trail was capped; there is older history than this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// itemRow is one item plus the one thing the item itself can't tell you: which
|
||||||
|
// panel it's in. It matters for bond state. An attunement item that is worn
|
||||||
|
// without a bond is *inert* — on you, doing nothing, and worth shouting about.
|
||||||
|
// The same item in a backpack isn't inert, it's just not worn yet; gogobee only
|
||||||
|
// tracks bonds on equipped rows, so its Attuned is undefined rather than false.
|
||||||
|
// Rendering both as "inert" would invent a problem the player doesn't have.
|
||||||
|
type itemRow struct {
|
||||||
|
storage.ItemView
|
||||||
|
Worn bool
|
||||||
|
// EquipAction is the control this row offers its owner: "unequip" on anything
|
||||||
|
// worn, "equip" on a backpack item the magic-item path will accept, "" on
|
||||||
|
// everything else (vault items, mundane backpack gear). Empty means no button.
|
||||||
|
EquipAction string
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemRows wraps gogobee's item views for one panel. panel is "worn", "backpack",
|
||||||
|
// or "vault"; it decides both the bond wording (only a worn item can be inert) and
|
||||||
|
// which equip control, if any, the row offers.
|
||||||
|
func itemRows(items []storage.ItemView, panel string) []itemRow {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
worn := panel == "worn"
|
||||||
|
out := make([]itemRow, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
row := itemRow{ItemView: it, Worn: worn}
|
||||||
|
switch {
|
||||||
|
case worn:
|
||||||
|
// Everything in magic_item_equipped is a magic item and can come off; the
|
||||||
|
// slot is the handle gogobee unequips by.
|
||||||
|
row.EquipAction = storage.EquipActionUnequip
|
||||||
|
case panel == "backpack" && it.ID != 0:
|
||||||
|
// Only a wearable magic item carries a row id (gogobee sets it just in the
|
||||||
|
// magic-item branch), so the id gates Equip to exactly the items the
|
||||||
|
// magic-item path accepts — never mundane gear, never a vault item.
|
||||||
|
row.EquipAction = storage.EquipActionEquip
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// timelineEntry is one line of an adventurer's trail — a fact, rendered short,
|
||||||
|
// pointing at the dispatch that told it.
|
||||||
|
type timelineEntry struct {
|
||||||
|
Emoji string
|
||||||
|
Label string
|
||||||
|
Line string // "The Rotmother, in holymachina"
|
||||||
|
When string
|
||||||
|
Permalink string
|
||||||
|
Notable bool // a realm-first or a death: the lines worth an eye
|
||||||
|
}
|
||||||
|
|
||||||
|
// timelineCap bounds the trail. An adventurer accrues facts for as long as they
|
||||||
|
// play, and the page renders the whole list server-side — this is the point where
|
||||||
|
// "their whole history" stops being a page and starts being a scroll nobody
|
||||||
|
// reads. The count in the trophy case stays honest either way; only the trail is
|
||||||
|
// clipped.
|
||||||
|
const timelineCap = 40
|
||||||
|
|
||||||
// handleAdventureWho serves one adventurer's detail page. Public; 404s when the
|
// handleAdventureWho serves one adventurer's detail page. Public; 404s when the
|
||||||
// token isn't on the current board — the same liveness gate the storefront uses,
|
// token isn't on the current board — the same liveness gate the storefront uses,
|
||||||
// so a stale or guessed token never resolves to a page.
|
// so a stale or guessed token never resolves to a page.
|
||||||
@@ -94,6 +195,25 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
|||||||
page.HasDetail = true
|
page.HasDetail = true
|
||||||
page.Detail = d
|
page.Detail = d
|
||||||
page.Abilities = abil
|
page.Abilities = abil
|
||||||
|
page.MapView = buildMapView(d.Map)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History: one read feeds both the trophy case and the trail. Keyed on the
|
||||||
|
// character *name* rather than the page token, because that is what a fact
|
||||||
|
// carries — see the adventure_events schema note on why there is no id to use.
|
||||||
|
// Best-effort: a page that can't count trophies is still a page.
|
||||||
|
// Unlimited on purpose: the trophy case counts these, and a limit would cap the
|
||||||
|
// tally rather than the list. The trail is clipped below, after the counting.
|
||||||
|
if events, err := storage.EventsBySubject(entry.Name, 0); err != nil {
|
||||||
|
slog.Error("who: history lookup failed", "token", token, "err", err)
|
||||||
|
} else if len(events) > 0 {
|
||||||
|
page.HasHistory = true
|
||||||
|
page.Trophies = storage.BuildTrophyCase(entry.Name, events)
|
||||||
|
page.MoreHistory = len(events) > timelineCap
|
||||||
|
if page.MoreHistory {
|
||||||
|
events = events[:timelineCap]
|
||||||
|
}
|
||||||
|
page.Timeline = buildTimeline(s, entry.Name, events)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Owner enrichment: only when the signed-in user's localpart owns this exact
|
// Owner enrichment: only when the signed-in user's localpart owns this exact
|
||||||
@@ -101,9 +221,28 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
|||||||
// reversing the token, so no visitor can unlock another player's self extras.
|
// reversing the token, so no visitor can unlock another player's self extras.
|
||||||
if s.auth != nil {
|
if s.auth != nil {
|
||||||
if u := s.auth.userFromRequest(r); u != nil {
|
if u := s.auth.userFromRequest(r); u != nil {
|
||||||
if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
|
// A signed-in visitor viewing someone else's page legitimately fails the
|
||||||
|
// ownership join (ok=false), so that alone is not worth a log. Two other
|
||||||
|
// misses are: a genuine lookup/decode error, and a session that carries no
|
||||||
|
// username (minted before the game economy existed) — for the latter the
|
||||||
|
// join can never match because buyerLocalpart is empty. Both used to fail
|
||||||
|
// silently here; surface them so a stuck owner is diagnosable.
|
||||||
|
lp := buyerLocalpart(u)
|
||||||
|
if lp == "" {
|
||||||
|
slog.Warn("who: signed-in session has no username; owner unlock skipped", "sub", u.Sub)
|
||||||
|
} else if self, ok, err := storage.PlayerDetailByOwner(lp, token); err != nil {
|
||||||
|
slog.Error("who: owner detail lookup failed", "localpart", lp, "token", token, "err", err)
|
||||||
|
} else if ok {
|
||||||
page.HasSelf = true
|
page.HasSelf = true
|
||||||
page.Self = self
|
page.Self = self
|
||||||
|
page.Worn = itemRows(self.Equipped, "worn")
|
||||||
|
page.Backpack = itemRows(self.Inventory, "backpack")
|
||||||
|
page.VaultRows = itemRows(self.Vault, "vault")
|
||||||
|
for _, it := range self.Equipped {
|
||||||
|
if it.Attuned {
|
||||||
|
page.BondsUsed++
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +283,76 @@ func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildTimeline renders facts into trail lines.
|
||||||
|
//
|
||||||
|
// The line is built from the fact, not from the dispatch's headline, on purpose:
|
||||||
|
// the headline is a *news* sentence written for the moment it landed ("First
|
||||||
|
// ever: Josie brings down the Rotmother"), and forty of those stacked in a column
|
||||||
|
// read as a wall of shouting. The trail wants the noun, not the announcement. It
|
||||||
|
// also saves a join back to stories for every row.
|
||||||
|
func buildTimeline(s *Server, subject string, events []storage.AdvEvent) []timelineEntry {
|
||||||
|
out := make([]timelineEntry, 0, len(events))
|
||||||
|
for _, e := range events {
|
||||||
|
label, emoji := advEventMeta(e.EventType)
|
||||||
|
out = append(out, timelineEntry{
|
||||||
|
Emoji: emoji,
|
||||||
|
Label: label,
|
||||||
|
Line: timelineLine(subject, e),
|
||||||
|
When: time.Unix(e.OccurredAt, 0).UTC().Format("Jan 2, 2006"),
|
||||||
|
Permalink: s.advPermalink(e.GUID),
|
||||||
|
// A realm-first hoard is the treasure worth an eye; a plain find rides
|
||||||
|
// the quiet border like any other bulletin.
|
||||||
|
Notable: e.EventType == "boss_first" || e.EventType == "zone_first" || e.EventType == "death" ||
|
||||||
|
(e.EventType == "treasure_found" && e.Tier == "priority"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// timelineLine is the short "what" of a fact: the monster, the zone, the rival.
|
||||||
|
// Empty is fine — the label and emoji already carry the event, and inventing
|
||||||
|
// filler for a fact that carries no nouns would just be noise.
|
||||||
|
func timelineLine(subject string, e storage.AdvEvent) string {
|
||||||
|
inZone := func(s string) string {
|
||||||
|
if e.Zone == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if s == "" {
|
||||||
|
return e.Zone
|
||||||
|
}
|
||||||
|
return s + ", in " + e.Zone
|
||||||
|
}
|
||||||
|
switch e.EventType {
|
||||||
|
case "boss_first", "boss_kill", "siege_start", "siege_win", "siege_loss",
|
||||||
|
"mischief_survived", "mischief_downed":
|
||||||
|
return inZone(e.Boss)
|
||||||
|
case "zone_first", "zone_clear", "retreat", "departure", "death":
|
||||||
|
if e.Region != "" && e.Zone != "" {
|
||||||
|
return e.Zone + " — " + e.Region
|
||||||
|
}
|
||||||
|
return e.Zone
|
||||||
|
case "rival_result", "pete_duel_win", "pete_duel_loss":
|
||||||
|
// Name the *other* party. On the winner's page the rival is the opponent;
|
||||||
|
// on the loser's page this fact arrives via the opponent column, so the
|
||||||
|
// rival is the subject — naming e.Opponent there would point the viewer at
|
||||||
|
// themselves. Pete duels carry no character opponent, so this stays empty.
|
||||||
|
other := e.Opponent
|
||||||
|
if e.Opponent == subject {
|
||||||
|
other = e.Subject
|
||||||
|
}
|
||||||
|
if other != "" && other != subject {
|
||||||
|
return "vs " + other
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
case "milestone":
|
||||||
|
return e.Milestone
|
||||||
|
case "treasure_found":
|
||||||
|
// The item is the point; name it, and say where it came out of.
|
||||||
|
return inZone(e.Stakes)
|
||||||
|
}
|
||||||
|
return inZone("")
|
||||||
|
}
|
||||||
|
|
||||||
// decodeWhoDetail unpacks the public detail blob and builds the ability rows.
|
// decodeWhoDetail unpacks the public detail blob and builds the ability rows.
|
||||||
// Returns ok=false when there is no detail (a snapshot from before the detail
|
// Returns ok=false when there is no detail (a snapshot from before the detail
|
||||||
// push), so the page can fall back to the summary the board already has.
|
// push), so the page can fall back to the summary the board already has.
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
// Dungeon map rendering.
|
||||||
|
//
|
||||||
|
// gogobee sends the fog-of-war cut of an adventurer's zone graph (whoMap):
|
||||||
|
// every room they have visited with its true kind, plus the one-hop frontier of
|
||||||
|
// doors leading out of visited rooms, with the rooms behind them withheld as
|
||||||
|
// kind "unknown". Pete lays that graph out and draws it as an inline SVG on the
|
||||||
|
// who page. No node labels or contents ever cross the wire — the map knows a
|
||||||
|
// room is a trap, never what the trap is.
|
||||||
|
//
|
||||||
|
// Layout is a left-to-right layering: column = BFS depth from the entry along
|
||||||
|
// the edges we were given, row = order the node first appears in the payload.
|
||||||
|
// It is computed server-side and fully deterministic, so the same run draws the
|
||||||
|
// same map every render.
|
||||||
|
|
||||||
|
// Layout geometry. The node/ring radii live in who.html and the map CSS; only
|
||||||
|
// the spacing the layout math needs is here.
|
||||||
|
const (
|
||||||
|
mapColGap = 84 // horizontal spacing between depth columns
|
||||||
|
mapRowGap = 60 // vertical spacing between nodes sharing a column
|
||||||
|
mapMargin = 26 // padding around the node field
|
||||||
|
)
|
||||||
|
|
||||||
|
// mapView is the laid-out map, ready for the template's SVG.
|
||||||
|
type mapView struct {
|
||||||
|
Nodes []mapNode
|
||||||
|
Edges []mapEdge
|
||||||
|
W int // svg viewBox width
|
||||||
|
H int // svg viewBox height
|
||||||
|
}
|
||||||
|
|
||||||
|
type mapNode struct {
|
||||||
|
X, Y int
|
||||||
|
Kind string // ZoneNodeKind, or "unknown" for an unreached frontier room
|
||||||
|
Glyph string // the single character drawn inside the node
|
||||||
|
Class string // CSS class picking the node's colour family
|
||||||
|
Label string // accessible title, e.g. "Trap" or "Unexplored"
|
||||||
|
Current bool // the room the adventurer is standing in
|
||||||
|
}
|
||||||
|
|
||||||
|
type mapEdge struct {
|
||||||
|
X1, Y1, X2, Y2 int
|
||||||
|
Locked bool
|
||||||
|
Lock string // the gate's kind, for the door's title text
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeStyle maps a room kind to how it draws. Kept small and total: an unknown
|
||||||
|
// kind (a room type Pete hasn't been taught yet) falls through to a neutral dot
|
||||||
|
// rather than vanishing.
|
||||||
|
type nodeStyle struct {
|
||||||
|
glyph string
|
||||||
|
class string
|
||||||
|
label string
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapNodeStyles = map[string]nodeStyle{
|
||||||
|
"entry": {"⌂", "map-node-entry", "Entrance"},
|
||||||
|
"exploration": {"·", "map-node-plain", "Room"},
|
||||||
|
"trap": {"!", "map-node-trap", "Trap"},
|
||||||
|
"elite": {"◆", "map-node-elite", "Elite"},
|
||||||
|
"boss": {"♛", "map-node-boss", "Boss"}, // text dingbats only — U+26CF/U+2620 fall back to emoji or tofu
|
||||||
|
"harvest": {"❖", "map-node-harvest", "Harvest"},
|
||||||
|
"rest_camp": {"✚", "map-node-rest", "Rest camp"},
|
||||||
|
"secret": {"✦", "map-node-secret", "Secret"},
|
||||||
|
"fork": {"⋔", "map-node-plain", "Fork"},
|
||||||
|
"merge": {"⋏", "map-node-plain", "Merge"},
|
||||||
|
"unknown": {"?", "map-node-unknown", "Unexplored"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func styleFor(kind string) nodeStyle {
|
||||||
|
if s, ok := mapNodeStyles[kind]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return nodeStyle{"·", "map-node-plain", "Room"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMapView lays out a fog-of-war graph. Returns nil when there is nothing
|
||||||
|
// worth drawing (no nodes) so the template can skip the panel entirely.
|
||||||
|
func buildMapView(m *whoMap) *mapView {
|
||||||
|
if m == nil || len(m.Nodes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjacency for the depth walk, and appearance order for stable rows.
|
||||||
|
order := make(map[string]int, len(m.Nodes))
|
||||||
|
for i, n := range m.Nodes {
|
||||||
|
if _, dup := order[n.ID]; !dup {
|
||||||
|
order[n.ID] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adj := make(map[string][]string, len(m.Nodes))
|
||||||
|
for _, e := range m.Edges {
|
||||||
|
// Only lay out edges whose endpoints are both nodes we were given; a
|
||||||
|
// half-edge would point at nothing.
|
||||||
|
if _, ok := order[e.From]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := order[e.To]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
adj[e.From] = append(adj[e.From], e.To)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth = shortest hop count from the entry. The first visited node is the
|
||||||
|
// entry (gogobee sends Visited in path order); fall back to appearance
|
||||||
|
// order 0 if Visited is somehow empty.
|
||||||
|
start := ""
|
||||||
|
if len(m.Visited) > 0 {
|
||||||
|
start = m.Visited[0]
|
||||||
|
} else {
|
||||||
|
start = m.Nodes[0].ID
|
||||||
|
}
|
||||||
|
depth := bfsDepth(start, adj, order)
|
||||||
|
|
||||||
|
// Group nodes by depth column, each column ordered by first appearance.
|
||||||
|
maxDepth := 0
|
||||||
|
byCol := map[int][]string{}
|
||||||
|
placed := make(map[string]bool, len(m.Nodes))
|
||||||
|
for _, n := range m.Nodes {
|
||||||
|
// Two visited rooms can both list a door to the same withheld frontier
|
||||||
|
// room, so the same id can appear twice in Nodes. Place it once, or it
|
||||||
|
// draws as two overlapping discs and its edges resolve to whichever copy
|
||||||
|
// landed last.
|
||||||
|
if placed[n.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placed[n.ID] = true
|
||||||
|
d := depth[n.ID]
|
||||||
|
byCol[d] = append(byCol[d], n.ID)
|
||||||
|
if d > maxDepth {
|
||||||
|
maxDepth = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxRows := 0
|
||||||
|
for d := 0; d <= maxDepth; d++ {
|
||||||
|
sortByOrder(byCol[d], order)
|
||||||
|
if len(byCol[d]) > maxRows {
|
||||||
|
maxRows = len(byCol[d])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kindByID := make(map[string]string, len(m.Nodes))
|
||||||
|
for _, n := range m.Nodes {
|
||||||
|
kindByID[n.ID] = n.Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place nodes. Each column is vertically centred against the tallest column
|
||||||
|
// so the map reads as balanced rather than top-aligned.
|
||||||
|
pos := make(map[string][2]int, len(m.Nodes))
|
||||||
|
view := &mapView{
|
||||||
|
W: mapMargin*2 + maxDepth*mapColGap,
|
||||||
|
H: mapMargin*2 + max(0, maxRows-1)*mapRowGap,
|
||||||
|
}
|
||||||
|
if view.W < mapMargin*2 {
|
||||||
|
view.W = mapMargin * 2
|
||||||
|
}
|
||||||
|
for d := 0; d <= maxDepth; d++ {
|
||||||
|
col := byCol[d]
|
||||||
|
x := mapMargin + d*mapColGap
|
||||||
|
offset := (maxRows - len(col)) * mapRowGap / 2
|
||||||
|
for i, id := range col {
|
||||||
|
y := mapMargin + offset + i*mapRowGap
|
||||||
|
pos[id] = [2]int{x, y}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for d := 0; d <= maxDepth; d++ {
|
||||||
|
for _, id := range byCol[d] {
|
||||||
|
p := pos[id]
|
||||||
|
st := styleFor(kindByID[id])
|
||||||
|
view.Nodes = append(view.Nodes, mapNode{
|
||||||
|
X: p[0], Y: p[1],
|
||||||
|
Kind: kindByID[id],
|
||||||
|
Glyph: st.glyph,
|
||||||
|
Class: st.class,
|
||||||
|
Label: st.label,
|
||||||
|
Current: id == m.CurrentNode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range m.Edges {
|
||||||
|
a, okA := pos[e.From]
|
||||||
|
b, okB := pos[e.To]
|
||||||
|
if !okA || !okB {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
view.Edges = append(view.Edges, mapEdge{
|
||||||
|
X1: a[0], Y1: a[1], X2: b[0], Y2: b[1],
|
||||||
|
Locked: e.Lock != "" && e.Lock != "none",
|
||||||
|
Lock: e.Lock,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
// bfsDepth returns the hop distance from start to every node reachable along
|
||||||
|
// adj. Nodes never reached (there should be none in a well-formed cut) default
|
||||||
|
// to 0, so a stray node still lands in the first column rather than off-canvas.
|
||||||
|
func bfsDepth(start string, adj map[string][]string, order map[string]int) map[string]int {
|
||||||
|
depth := map[string]int{start: 0}
|
||||||
|
queue := []string{start}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
u := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
for _, v := range adj[u] {
|
||||||
|
if _, seen := depth[v]; !seen {
|
||||||
|
depth[v] = depth[u] + 1
|
||||||
|
queue = append(queue, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Any node with an appearance order but no depth (unreachable via the cut)
|
||||||
|
// gets 0 so it is still placed.
|
||||||
|
for id := range order {
|
||||||
|
if _, ok := depth[id]; !ok {
|
||||||
|
depth[id] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return depth
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortByOrder sorts ids in place by their first-appearance index. Insertion
|
||||||
|
// sort — columns hold a handful of nodes, and it keeps the ordering stable
|
||||||
|
// without pulling in a comparator closure.
|
||||||
|
func sortByOrder(ids []string, order map[string]int) {
|
||||||
|
for i := 1; i < len(ids); i++ {
|
||||||
|
for j := i; j > 0 && order[ids[j]] < order[ids[j-1]]; j-- {
|
||||||
|
ids[j], ids[j-1] = ids[j-1], ids[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fogMap is the shape gogobee sends: two visited rooms (entry, elite), and two
|
||||||
|
// frontier rooms reachable one hop out — n4 is really the boss but arrives with
|
||||||
|
// its kind withheld as "unknown". n3 sits behind a perception-locked door, n4
|
||||||
|
// behind a key-locked one.
|
||||||
|
func fogMap() *whoMap {
|
||||||
|
return &whoMap{
|
||||||
|
ZoneID: "ossuary",
|
||||||
|
CurrentNode: "n2",
|
||||||
|
Visited: []string{"n1", "n2"},
|
||||||
|
Nodes: []whoMapNode{
|
||||||
|
{ID: "n1", Kind: "entry"},
|
||||||
|
{ID: "n2", Kind: "elite"},
|
||||||
|
{ID: "n3", Kind: "unknown"},
|
||||||
|
{ID: "n4", Kind: "unknown"},
|
||||||
|
},
|
||||||
|
Edges: []whoMapEdge{
|
||||||
|
{From: "n1", To: "n2", Lock: ""},
|
||||||
|
{From: "n1", To: "n3", Lock: "perception_check"},
|
||||||
|
{From: "n2", To: "n4", Lock: "key_required"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMapView_Layout(t *testing.T) {
|
||||||
|
v := buildMapView(fogMap())
|
||||||
|
if v == nil {
|
||||||
|
t.Fatal("nil view for a non-empty map")
|
||||||
|
}
|
||||||
|
if len(v.Nodes) != 4 {
|
||||||
|
t.Fatalf("want 4 nodes, got %d", len(v.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth places entry in column 0, its two successors in column 1, the boss
|
||||||
|
// frontier in column 2 — a left-to-right dungeon.
|
||||||
|
var n1, n2, n4 mapNode
|
||||||
|
for _, n := range v.Nodes {
|
||||||
|
switch {
|
||||||
|
case n.Kind == "entry":
|
||||||
|
n1 = n
|
||||||
|
case n.Kind == "elite":
|
||||||
|
n2 = n
|
||||||
|
case n.Kind == "unknown" && n.X > n1.X+mapColGap:
|
||||||
|
n4 = n // the deepest unknown is n4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !(n1.X < n2.X && n2.X < n4.X) {
|
||||||
|
t.Errorf("columns not left-to-right: entry=%d elite=%d frontier=%d", n1.X, n2.X, n4.X)
|
||||||
|
}
|
||||||
|
// The boss frontier must NOT reveal its kind or its glyph.
|
||||||
|
if n4.Kind != "unknown" || n4.Glyph != "?" {
|
||||||
|
t.Errorf("frontier boss leaked: kind=%q glyph=%q", n4.Kind, n4.Glyph)
|
||||||
|
}
|
||||||
|
if n2.Current != true {
|
||||||
|
t.Errorf("current node n2 (elite) should be marked current")
|
||||||
|
}
|
||||||
|
if n1.Current {
|
||||||
|
t.Errorf("entry is not the current node")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edges: three total, the two locked ones dashed, the visited-visited one open.
|
||||||
|
if len(v.Edges) != 3 {
|
||||||
|
t.Fatalf("want 3 edges, got %d", len(v.Edges))
|
||||||
|
}
|
||||||
|
locked := 0
|
||||||
|
for _, e := range v.Edges {
|
||||||
|
if e.Locked {
|
||||||
|
locked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if locked != 2 {
|
||||||
|
t.Errorf("want 2 locked doors (perception, key), got %d", locked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMapView_EmptyIsNil(t *testing.T) {
|
||||||
|
if buildMapView(nil) != nil {
|
||||||
|
t.Error("nil map should give nil view")
|
||||||
|
}
|
||||||
|
if buildMapView(&whoMap{ZoneID: "x"}) != nil {
|
||||||
|
t.Error("map with no nodes should give nil view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicDetailWithMap is publicDetail plus a fog-of-war map, the way the roster
|
||||||
|
// push carries both.
|
||||||
|
func publicDetailWithMap(t *testing.T) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(map[string]any{
|
||||||
|
"hp_current": 30,
|
||||||
|
"hp_max": 42,
|
||||||
|
"armor_class": 17,
|
||||||
|
"abilities": [6]int{16, 14, 15, 10, 12, 8},
|
||||||
|
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
|
||||||
|
"supplies": 8,
|
||||||
|
"room": "2 / 4",
|
||||||
|
"map": fogMap(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoMapRenders drives the real template with a map present and asserts the
|
||||||
|
// SVG lands, the current room is marked, and the fog holds: the frontier boss
|
||||||
|
// is drawn as an unexplored room, never as a boss.
|
||||||
|
func TestWhoMapRenders(t *testing.T) {
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||||
|
e.Detail = publicDetailWithMap(t)
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed roster = %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := getWho(t, s, "tok-josie", "")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("who = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{"The map", "map-svg", "map-node-current", "map-edge-locked"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("map render missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The elite room glyph shows (visited); the boss glyph must not (the boss is
|
||||||
|
// only reachable as an unrevealed frontier door).
|
||||||
|
if !strings.Contains(body, "◆") {
|
||||||
|
t.Error("visited elite room should show its glyph")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "♛") {
|
||||||
|
t.Error("fog leak: the boss glyph rendered for an unexplored frontier room")
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
-4
@@ -71,16 +71,71 @@ func seedWho(t *testing.T, owner string) *Server {
|
|||||||
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||||
Localpart: owner,
|
Localpart: owner,
|
||||||
Token: "tok-josie",
|
Token: "tok-josie",
|
||||||
Inventory: []storage.ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
|
Inventory: []storage.ItemView{
|
||||||
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10},
|
||||||
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
|
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
|
||||||
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
Slot: "weapon", SkillSource: "mining", Desc: "A pick balanced for a long seam."},
|
||||||
|
// Wants a bond, but it's in the backpack: not worn, so not inert.
|
||||||
|
{Name: "Ring of Protection", Type: "ring", Tier: 4, Value: 900,
|
||||||
|
Slot: "ring", Attunement: true, Effect: "-8% damage taken"},
|
||||||
|
// A wearable magic item (carries an equip id) with gogobee's compare
|
||||||
|
// card attached: a sidegrade against the worn Flame Tongue.
|
||||||
|
{Name: "Blazing Brand", Type: "weapon", Tier: 5, Value: 4000, ID: 77,
|
||||||
|
Slot: "main_hand", Desc: "A blade that hums.", Effect: "+20% damage",
|
||||||
|
Compare: &storage.ItemCompare{
|
||||||
|
Verdict: "sidegrade", VsName: "Flame Tongue", VsSlot: "main_hand",
|
||||||
|
Deltas: []storage.ItemDelta{
|
||||||
|
{Label: "damage", Better: true, Text: "+5% damage"},
|
||||||
|
{Label: "hp", Better: false, Text: "-4 HP"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Equipped: []storage.ItemView{
|
||||||
|
{Name: "Flame Tongue", Type: "weapon", Value: 5000, Temper: 1, Slot: "weapon",
|
||||||
|
Desc: "A sword that ignites on command.", Effect: "+15% damage",
|
||||||
|
Attunement: true, Attuned: true},
|
||||||
|
// Worn while the bond cap is full: on her, doing nothing.
|
||||||
|
{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
|
||||||
|
Effect: "faster to act, +4 HP", Attunement: true, Attuned: false},
|
||||||
|
},
|
||||||
|
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||||
|
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
|
||||||
|
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||||
}}}); w.Code != 200 {
|
}}}); w.Code != 200 {
|
||||||
t.Fatalf("seed detail = %d", w.Code)
|
t.Fatalf("seed detail = %d", w.Code)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedHistory pushes facts about Josie through the real ingest path, so the
|
||||||
|
// history panels are driven by rows that arrived the way gogobee sends them
|
||||||
|
// rather than by a hand-built struct that could drift from the contract.
|
||||||
|
func seedHistory(t *testing.T, s *Server) {
|
||||||
|
t.Helper()
|
||||||
|
facts := []AdvFact{
|
||||||
|
{GUID: "boss_first:a:1", EventType: "boss_first", Tier: "priority", Actors: []string{"Josie"},
|
||||||
|
Subject: "Josie", Boss: "The Rotmother", Zone: "holymachina", Region: "the Reach", OccurredAt: 100},
|
||||||
|
{GUID: "boss_kill:b:2", EventType: "boss_kill", Tier: "bulletin", Actors: []string{"Josie"},
|
||||||
|
Subject: "Josie", Boss: "The Rotmother", Zone: "holymachina", OccurredAt: 200},
|
||||||
|
{GUID: "death:c:3", EventType: "death", Tier: "priority", Actors: []string{"Josie"},
|
||||||
|
Subject: "Josie", Zone: "holymachina", Level: 14, OccurredAt: 300},
|
||||||
|
{GUID: "milestone:d:4", EventType: "milestone", Tier: "bulletin", Actors: []string{"Josie"},
|
||||||
|
Subject: "Josie", Milestone: "level 14", OccurredAt: 400},
|
||||||
|
{GUID: "treasure_found:e:5", EventType: "treasure_found", Tier: "priority", Actors: []string{"Josie"},
|
||||||
|
Subject: "Josie", Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", OccurredAt: 500},
|
||||||
|
}
|
||||||
|
for _, f := range facts {
|
||||||
|
body, _ := json.Marshal(f)
|
||||||
|
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer tok")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleAdventureIngest(w, req)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("seed fact %s = %d: %s", f.GUID, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder {
|
func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil)
|
var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil)
|
||||||
@@ -134,6 +189,76 @@ func TestWhoSelfUnlock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWhoItemDetail: the item panels carry the facts a player decides on —
|
||||||
|
// descriptions, the engine's effect summary, slot and skill tags — and the worn
|
||||||
|
// set renders with its bond count.
|
||||||
|
func TestWhoItemDetail(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
|
||||||
|
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
// "15% damage", not "+15%": html/template escapes a leading + to +.
|
||||||
|
"Worn", "Flame Tongue", "A sword that ignites on command.", "15% damage",
|
||||||
|
"1 of 3 bonds in use", // Flame Tongue is bonded; the Cloak is not
|
||||||
|
"A pick balanced for a long seam.", "mining",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("owner page missing item detail %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoInertOnlyWhenWorn pins the one distinction the panels can get wrong.
|
||||||
|
// "Inert" is a real problem state — the item is on you and doing nothing because
|
||||||
|
// every bond slot is full. An unworn attunement item is not inert, it's just not
|
||||||
|
// worn; gogobee tracks bonds only on equipped rows, so its Attuned is undefined
|
||||||
|
// rather than false. Rendering both the same would invent a problem the player
|
||||||
|
// does not have, on the exact panel they'd use to fix it.
|
||||||
|
func TestWhoInertOnlyWhenWorn(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
|
||||||
|
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||||
|
// The Cloak is worn with no bond free.
|
||||||
|
if !strings.Contains(body, "inert") {
|
||||||
|
t.Error("a worn item with no bond should be called out as inert")
|
||||||
|
}
|
||||||
|
if strings.Count(body, "inert") != 1 {
|
||||||
|
t.Errorf("only the worn unbonded item is inert, got %d mentions", strings.Count(body, "inert"))
|
||||||
|
}
|
||||||
|
// The backpack Ring of Protection wants a bond but isn't worn.
|
||||||
|
if !strings.Contains(body, "needs a bond") {
|
||||||
|
t.Error("an unworn attunement item should say it needs a bond, not that it is inert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoItemCompare: the compare card that gogobee attaches to a backpack magic
|
||||||
|
// item renders on the owner's page — verdict chip, per-stat deltas, and the name
|
||||||
|
// of the worn item it's measured against — and stays off the public page, since
|
||||||
|
// it rides the owner-private inventory.
|
||||||
|
func TestWhoItemCompare(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
|
||||||
|
owner := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"sidegrade", // the verdict chip
|
||||||
|
"vs worn Flame Tongue", // what it's measured against
|
||||||
|
"5% damage", // the better delta ("+5%" — leading + is escaped to +)
|
||||||
|
"-4 HP", // the worse delta (minus is not escaped)
|
||||||
|
"cmp-delta-up", // the gain is coloured as a gain
|
||||||
|
"cmp-delta-down", // the loss as a loss
|
||||||
|
} {
|
||||||
|
if !strings.Contains(owner, want) {
|
||||||
|
t.Errorf("owner compare card missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The compare rides the private inventory, so an anonymous visitor never sees it.
|
||||||
|
anon := getWho(t, s, "tok-josie", "").Body.String()
|
||||||
|
if strings.Contains(anon, "sidegrade") || strings.Contains(anon, "Blazing Brand") {
|
||||||
|
t.Error("compare card leaked onto the public page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
|
// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
|
||||||
// username; the localpart it maps to is lowercase. The owner must still unlock.
|
// username; the localpart it maps to is lowercase. The owner must still unlock.
|
||||||
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {
|
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {
|
||||||
@@ -197,3 +322,49 @@ func TestDetailIngestReplacesAndAuth(t *testing.T) {
|
|||||||
t.Error("public page vanished when only the private detail was dropped")
|
t.Error("public page vanished when only the private detail was dropped")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWhoHistoryPanels: the record and the trail render for an adventurer with
|
||||||
|
// facts on file. Driven through the real template and the real ingest path, so a
|
||||||
|
// field slip in either 500s here rather than in prod.
|
||||||
|
func TestWhoHistoryPanels(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
seedHistory(t, s)
|
||||||
|
|
||||||
|
w := getWho(t, s, "tok-josie", "")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("who = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"The record",
|
||||||
|
"The Rotmother", // the boss tally
|
||||||
|
"bosses down",
|
||||||
|
"realm-first", // the boss_first got flagged
|
||||||
|
"level 14", // the milestone chip
|
||||||
|
"treasure", // the treasure stat tile
|
||||||
|
"Treasures found", // the showcase panel
|
||||||
|
"Crown of the Drowned King", // the named hoard
|
||||||
|
"first hoard", // the realm-first callout picked it up
|
||||||
|
"The trail",
|
||||||
|
"/adventure/treasure_found:e:5", // the trail links back to the treasure dispatch
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("history render missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoHistoryAbsent: an adventurer with no facts on file gets no empty
|
||||||
|
// scaffolding. Every veteran looks like this the day this ships — their past is
|
||||||
|
// prose in the feed and was never counted — so the blank state has to be a clean
|
||||||
|
// absence, not a wall of zeroes.
|
||||||
|
func TestWhoHistoryAbsent(t *testing.T) {
|
||||||
|
s := seedWho(t, "josie")
|
||||||
|
|
||||||
|
body := getWho(t, s, "tok-josie", "").Body.String()
|
||||||
|
for _, leak := range []string{"The record", "The trail", "bosses down"} {
|
||||||
|
if strings.Contains(body, leak) {
|
||||||
|
t.Errorf("no-history page should not render %q", leak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user