Compare commits
105
Commits
weather-fx
...
fcd4368631
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd4368631 | ||
|
|
15e229b6c3 | ||
|
|
5b07199631 | ||
|
|
a14859c5ee | ||
|
|
aac6c3e127 | ||
|
|
c40ac1e673 | ||
|
|
556b9440b8 | ||
|
|
b07abc1d13 | ||
|
|
0d8dba90df | ||
|
|
c2a40dad64 | ||
|
|
868a29e992 | ||
|
|
6b0aae9f4a | ||
|
|
b19ab5eff0 | ||
|
|
1dfd3ac9fb | ||
|
|
b4a276da36 | ||
|
|
8c3f2b0d07 | ||
|
|
7051e8ffff | ||
|
|
23563b6a6a | ||
|
|
91d25e9da1 | ||
|
|
8aa3e762ca | ||
|
|
19255d933a | ||
|
|
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 | ||
|
|
4189c03a82 | ||
|
|
16711e13e6 | ||
|
|
2ac6ec6b91 | ||
|
|
983748ea98 | ||
|
|
e5af5326d5 | ||
|
|
dbde827f75 | ||
|
|
18049f6f59 | ||
|
|
927ed84163 | ||
|
|
f8b07d8e6c | ||
|
|
4ad96dcb5e | ||
|
|
5139385350 | ||
|
|
5b381b03ff | ||
|
|
004fca3f25 | ||
|
|
4b3e5fe4c5 | ||
|
|
1f1a6cb6e8 | ||
|
|
a5b7e41929 | ||
|
|
57c445ff29 | ||
|
|
6f34a89622 | ||
|
|
7ca1f7a030 | ||
|
|
39ed293f4f | ||
|
|
a4666866a8 | ||
|
|
03524aefbc | ||
|
|
8db8845feb | ||
|
|
aca523e511 | ||
|
|
4bc38859d4 | ||
|
|
638e28263a | ||
|
|
b96879d25c | ||
|
|
903c5accdb | ||
|
|
e6c1bd3b54 | ||
|
|
6e20883e5d | ||
|
|
d7e63d86a6 | ||
|
|
79c857023f | ||
|
|
3e9b93af55 | ||
|
|
2d653bf439 | ||
|
|
c62d736223 | ||
|
|
feb353f789 | ||
|
|
5ca056bf20 | ||
|
|
fe2195e85f | ||
|
|
d29a311eff | ||
|
|
6961f90634 | ||
|
|
b00da21a47 | ||
|
|
8ec13eab5b | ||
|
|
c69fbb63db | ||
|
|
cb84e1d549 | ||
|
|
44613c4760 | ||
|
|
a442cfccaa | ||
|
|
f9a98f72a6 | ||
|
|
8310b30439 | ||
|
|
6ccd18452c | ||
|
|
e85ebe56f7 | ||
|
|
99574db3e9 | ||
|
|
8cb5b38599 | ||
|
|
a614077cff | ||
|
|
82d1c6ebeb | ||
|
|
10bcc78c51 | ||
|
|
8e0d6aff3e | ||
|
|
9bf56cbb4e | ||
|
|
4c671fb410 |
@@ -5,3 +5,4 @@ pete
|
||||
config.yaml
|
||||
config.toml
|
||||
node_modules/
|
||||
holdem-train
|
||||
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,89 @@
|
||||
// Command holdem-train trains the casino's poker bots and writes the policy the
|
||||
// table embeds.
|
||||
//
|
||||
// go run ./cmd/holdem-train -iterations 5000000 -out internal/games/holdem/policy.gob
|
||||
//
|
||||
// It is not part of Pete. It runs when the engine's rules change, takes half an
|
||||
// hour, and produces a file. Nothing at runtime imports it.
|
||||
//
|
||||
// The bots are trained heads-up, at every stack depth the table deals — that
|
||||
// range is the point, because poker at twenty big blinds and poker at a hundred
|
||||
// are different games and a bot that only knows one of them folds into the other.
|
||||
// A six-handed table then reuses the same policy, which is an approximation, and
|
||||
// a documented one.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/holdem"
|
||||
)
|
||||
|
||||
func main() {
|
||||
iterations := flag.Int("iterations", 5_000_000, "hands to train on")
|
||||
workers := flag.Int("workers", runtime.NumCPU(), "parallel workers")
|
||||
out := flag.String("out", "internal/games/holdem/policy.gob", "where to write the policy")
|
||||
stakes := flag.String("stakes", "low", "which table's blinds to train at")
|
||||
minBB := flag.Int64("min-bb", 20, "shallowest stack, in big blinds")
|
||||
maxBB := flag.Int64("max-bb", 100, "deepest stack, in big blinds")
|
||||
seed := flag.Uint64("seed", 20260714, "seed, so a run can be repeated")
|
||||
flag.Parse()
|
||||
|
||||
tier, err := holdem.TierBySlug(*stakes)
|
||||
if err != nil {
|
||||
log.Fatalf("no such table %q", *stakes)
|
||||
}
|
||||
|
||||
fmt.Printf("training %s hands on %d workers — %s blinds, %d–%d BB deep\n",
|
||||
commas(*iterations), *workers, tier.Name, *minBB, *maxBB)
|
||||
|
||||
started := time.Now()
|
||||
last := started
|
||||
policy := holdem.Train(*iterations, *workers, tier, *minBB, *maxBB, *seed, func(done int) {
|
||||
if time.Since(last) < 20*time.Second {
|
||||
return
|
||||
}
|
||||
last = time.Now()
|
||||
frac := float64(done) / float64(*iterations)
|
||||
if frac <= 0 {
|
||||
return
|
||||
}
|
||||
left := time.Duration(float64(time.Since(started)) * (1 - frac) / frac)
|
||||
fmt.Printf(" %s / %s hands (%.0f%%), about %s to go\n",
|
||||
commas(done), commas(*iterations), frac*100, left.Round(time.Second))
|
||||
})
|
||||
|
||||
f, err := os.Create(*out)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := holdem.Save(f, policy); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
info, _ := f.Stat()
|
||||
fmt.Printf("\ndone in %s: %s nodes, %.1f MB → %s\n",
|
||||
time.Since(started).Round(time.Second), commas(policy.Meta.Nodes),
|
||||
float64(info.Size())/(1<<20), *out)
|
||||
}
|
||||
|
||||
func commas(n int) string {
|
||||
s := fmt.Sprint(n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var out []byte
|
||||
for i, c := range []byte(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -58,6 +58,12 @@ client_secret = "${PETE_OIDC_CLIENT_SECRET}"
|
||||
redirect_url = "https://news.parodia.dev/auth/callback"
|
||||
# HMAC key that signs the session cookie. Generate with: openssl rand -hex 32
|
||||
session_secret = "${PETE_SESSION_SECRET}"
|
||||
# Share the session across sibling hosts, so signing in on news.parodia.dev also
|
||||
# signs you in on games.parodia.dev. This widens the cookie to every host under
|
||||
# the domain, so leave it empty to keep the session host-only. Each host that
|
||||
# starts a login also needs its own redirect URI registered in Authentik
|
||||
# (<host>/auth/callback) — the login round-trip returns to the host it began on.
|
||||
cookie_domain = ""
|
||||
|
||||
# Optional Web Push digests. When enabled, signed-in users can opt in (from the
|
||||
# feed-settings panel) to a periodic "N new stories" notification, delivered via
|
||||
@@ -96,6 +102,16 @@ label = "Amy (US, female)"
|
||||
id = "en_US-ryan-high"
|
||||
label = "Ryan (US, male, HQ)"
|
||||
|
||||
# The casino (games.parodia.dev). Signed-in only — there is real money in it — so
|
||||
# it needs [web.auth] above, and web.auth.cookie_domain if the games host is a
|
||||
# different subdomain from the news one. Chips are 1:1 with gogobee euros and
|
||||
# cross the border through the escrow endpoints, which gogobee polls; matrix_server
|
||||
# is how a player is named to that ledger (@<authentik username>:<matrix_server>).
|
||||
[web.games]
|
||||
enabled = false
|
||||
host = "games.parodia.dev"
|
||||
matrix_server = "parodia.dev"
|
||||
|
||||
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
||||
# There is no automatic classification — Pete posts each story to its configured channel.
|
||||
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/chehsunliu/poker v0.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
|
||||
@@ -10,6 +10,8 @@ github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/chehsunliu/poker v0.1.0 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI=
|
||||
github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -31,6 +33,7 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:76SAnflG7ZFhgtnaVCpP6A5Z1S/VMFzRBN7KGm5j4oc=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
@@ -48,6 +51,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:L0Sdr2nYdktjerdXpIn9wOCn+GebPs/nCL2qH6RTGa0=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -58,6 +62,7 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
@@ -157,6 +162,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
||||
|
||||
+124
-12
@@ -13,22 +13,96 @@ import (
|
||||
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||
|
||||
type Config struct {
|
||||
Matrix MatrixConfig `toml:"matrix"`
|
||||
Posting PostingConfig `toml:"posting"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Sources []SourceConfig `toml:"sources"`
|
||||
Matrix MatrixConfig `toml:"matrix"`
|
||||
Posting PostingConfig `toml:"posting"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Adventure AdventureConfig `toml:"adventure"`
|
||||
Sources []SourceConfig `toml:"sources"`
|
||||
}
|
||||
|
||||
// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
|
||||
// game-event facts to Pete's ingest endpoint, Pete templates them into stories
|
||||
// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
|
||||
// default — the endpoint 404s and no adventure channel appears until enabled.
|
||||
type AdventureConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// IngestToken is the shared bearer secret gogobee presents on
|
||||
// POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
|
||||
IngestToken string `toml:"ingest_token"`
|
||||
// Channel is the Matrix channel name (a key in [matrix.channels]) that
|
||||
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
|
||||
// channel, adventure runs website-only (stories still appear on /adventure).
|
||||
Channel string `toml:"channel"`
|
||||
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
|
||||
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
|
||||
// "unset" and doesn't get silently rewritten to the default.
|
||||
DigestHour *int `toml:"digest_hour"`
|
||||
// RoomSilentTypes lists event types gogobee already announces in the games
|
||||
// room in TwinBee's own voice. Pete stores them and publishes them on the
|
||||
// site, but never posts them to Matrix — neither the live priority beat nor
|
||||
// the daily digest — so the room hears each moment once.
|
||||
//
|
||||
// Unset falls back to defaultRoomSilentTypes; an explicit empty list turns
|
||||
// the suppression off and restores the double-post.
|
||||
RoomSilentTypes []string `toml:"room_silent_types"`
|
||||
}
|
||||
|
||||
// defaultRoomSilentTypes are the event types TwinBee announces to the games room
|
||||
// itself as of gogobee HEAD. Each has a matching room announce on the gogobee
|
||||
// side (announceTreasureToRoom, the duel broadcast, announceMischief*,
|
||||
// announceWorldBoss), so a Pete post is a second telling of the same beat.
|
||||
//
|
||||
// Types NOT listed here have no room announce behind them and are Pete's alone
|
||||
// to report: zone_clear, zone_first, boss_kill, arrival, departure, death,
|
||||
// retreat, milestone, companion_hire.
|
||||
var defaultRoomSilentTypes = []string{
|
||||
"treasure_found",
|
||||
"rival_result",
|
||||
"mischief_contract",
|
||||
"mischief_survived",
|
||||
"mischief_downed",
|
||||
"mischief_fizzled",
|
||||
"siege_start",
|
||||
"siege_win",
|
||||
"siege_loss",
|
||||
}
|
||||
|
||||
// RoomSilentSet returns the room-suppressed event types as a lookup set,
|
||||
// resolving the unset case. Safe on a zero-value AdventureConfig.
|
||||
func (a AdventureConfig) RoomSilentSet() map[string]bool {
|
||||
types := a.RoomSilentTypes
|
||||
if types == nil {
|
||||
types = defaultRoomSilentTypes
|
||||
}
|
||||
set := make(map[string]bool, len(types))
|
||||
for _, t := range types {
|
||||
set[t] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
|
||||
// unset case. Safe on a zero-value AdventureConfig.
|
||||
func (a AdventureConfig) DigestHourOrDefault() int {
|
||||
if a.DigestHour == nil {
|
||||
return defaultDigestHour
|
||||
}
|
||||
return *a.DigestHour
|
||||
}
|
||||
|
||||
const defaultDigestHour = 17
|
||||
|
||||
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
||||
type WebConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
||||
SiteTitle string `toml:"site_title"` // display name in the header
|
||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
||||
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
||||
Enabled bool `toml:"enabled"`
|
||||
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
||||
SiteTitle string `toml:"site_title"` // display name in the header
|
||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
||||
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
||||
Games GamesConfig `toml:"games"` // optional casino (games.parodia.dev)
|
||||
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||
@@ -77,6 +151,23 @@ type VoiceConfig struct {
|
||||
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
|
||||
}
|
||||
|
||||
// GamesConfig wires the casino. It is signed-in only — there is money in it —
|
||||
// so it does nothing without web.auth, and it needs the Matrix server name
|
||||
// because that is how a player's identity reaches gogobee's euro ledger: an
|
||||
// Authentik username is the Matrix localpart, and the Matrix id is the account.
|
||||
type GamesConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// Host is the public hostname the casino answers on, e.g.
|
||||
// "games.parodia.dev". Requests arriving on it are served the casino at "/";
|
||||
// everywhere else the same pages live under /games. Empty means no host
|
||||
// branching, which is the normal state of affairs in local development.
|
||||
Host string `toml:"host"`
|
||||
// MatrixServer is the server name half of a player's Matrix id
|
||||
// ("parodia.dev" -> @reala:parodia.dev). Without it, no player can be
|
||||
// identified in the economy and the casino stays shut.
|
||||
MatrixServer string `toml:"matrix_server"`
|
||||
}
|
||||
|
||||
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
||||
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
||||
@@ -87,6 +178,12 @@ type AuthConfig struct {
|
||||
ClientSecret string `toml:"client_secret"`
|
||||
RedirectURL string `toml:"redirect_url"` // e.g. https://news.parodia.dev/auth/callback
|
||||
SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie
|
||||
// CookieDomain widens the session cookie beyond the host that set it, so a
|
||||
// sign-in on news.parodia.dev is also a sign-in on games.parodia.dev. Set it
|
||||
// to ".parodia.dev" to share the session across every parodia.dev host —
|
||||
// which is every host, including the landing site, so it is opt-in rather
|
||||
// than the default. Empty keeps the cookie host-only.
|
||||
CookieDomain string `toml:"cookie_domain"`
|
||||
}
|
||||
|
||||
type MatrixConfig struct {
|
||||
@@ -228,6 +325,21 @@ func (c *Config) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.Adventure.Enabled {
|
||||
if c.Adventure.IngestToken == "" {
|
||||
return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
|
||||
}
|
||||
if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 {
|
||||
return fmt.Errorf("adventure.digest_hour must be 0-23")
|
||||
}
|
||||
if c.Adventure.Channel != "" {
|
||||
if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
|
||||
slog.Warn("adventure.channel is not a configured Matrix channel — adventure runs website-only",
|
||||
"channel", c.Adventure.Channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, s := range c.Sources {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("sources[%d].name is required", i)
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
// Package blackjack is a pure blackjack engine.
|
||||
//
|
||||
// It knows nothing about HTTP, sockets, timers, euros or players' names. You
|
||||
// hand it a state and a move, it hands you back a new state and the list of
|
||||
// things that just happened. Everything else — who is sitting there, what their
|
||||
// chips are, when their clock runs out — belongs to the shell in internal/games/table.
|
||||
//
|
||||
// That seam is the one thing gogobee's blackjack never had: there, the engine
|
||||
// *was* the message sender, so an "error" meant a Matrix send had failed rather
|
||||
// than that a player had tried something illegal. Here an error means exactly
|
||||
// one thing: the move was not legal in this state.
|
||||
//
|
||||
// The state is a plain value. It serializes, so a hand survives a redeploy, and
|
||||
// it replays, so a disputed hand can be dealt again from its seed.
|
||||
package blackjack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce. Callers can match on these to tell a
|
||||
// player "not now" rather than "something broke".
|
||||
var (
|
||||
ErrHandOver = errors.New("blackjack: the hand is already over")
|
||||
ErrNotYourTurn = errors.New("blackjack: it is not the player's turn to act")
|
||||
ErrUnknownMove = errors.New("blackjack: unknown move")
|
||||
ErrCantDouble = errors.New("blackjack: double is only allowed on the opening two cards")
|
||||
ErrCantSplit = errors.New("blackjack: split is only allowed on two cards of the same rank")
|
||||
ErrDeckExhausted = errors.New("blackjack: the shoe is empty")
|
||||
ErrBadBet = errors.New("blackjack: bet must be positive")
|
||||
)
|
||||
|
||||
// MaxHands is how many hands one deal can turn into: the opening hand, plus
|
||||
// three splits. Four is the usual house limit and it is also the point past
|
||||
// which the felt runs out of room.
|
||||
const MaxHands = 4
|
||||
|
||||
// Phase is whose turn it is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlayer Phase = "player" // the player is acting on the active hand
|
||||
PhaseDealer Phase = "dealer" // transient: the dealer is drawing out
|
||||
PhaseDone Phase = "done" // settled, Outcome and Payout are final
|
||||
)
|
||||
|
||||
// Outcome is how a finished hand finished, from the player's point of view.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeBlackjack Outcome = "blackjack" // natural 21, paid 3:2
|
||||
OutcomeWin Outcome = "win"
|
||||
OutcomeLose Outcome = "lose"
|
||||
OutcomePush Outcome = "push" // tie, stake returned
|
||||
OutcomeBust Outcome = "bust" // player went over 21
|
||||
OutcomeDealerBust Outcome = "dealer_bust"
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays the player more than their stake back.
|
||||
func (o Outcome) Won() bool {
|
||||
return o == OutcomeWin || o == OutcomeBlackjack || o == OutcomeDealerBust
|
||||
}
|
||||
|
||||
// Rules are the table's terms. They're part of the state rather than a global,
|
||||
// so a hand always settles under the rules it was dealt under — even if the
|
||||
// house changes them mid-session.
|
||||
type Rules struct {
|
||||
Decks int `json:"decks"` // shoe size
|
||||
BlackjackPays float64 `json:"blackjack_pays"` // 1.5 = the honest 3:2
|
||||
DealerHitsSoft17 bool `json:"dealer_hits_soft17"` // gogobee's dealer does
|
||||
RakePct float64 `json:"rake_pct"` // house cut, taken from winnings only
|
||||
}
|
||||
|
||||
// DefaultRules match the blackjack gogobee has been dealing in Matrix for years:
|
||||
// six decks, 3:2 on a natural, dealer hits soft 17. The rake is the one new term
|
||||
// — see settle for exactly what it touches.
|
||||
func DefaultRules() Rules {
|
||||
return Rules{Decks: 6, BlackjackPays: 1.5, DealerHitsSoft17: true, RakePct: 0.05}
|
||||
}
|
||||
|
||||
// Hand is one hand the player is holding, with the chips that are on it.
|
||||
//
|
||||
// A deal starts with one. A split turns one into two, and the new hand carries a
|
||||
// bet of its own — which is the whole reason split is not just a card trick: it
|
||||
// is the only move in the game that takes more chips out of a player's stack
|
||||
// *after* the cards are out.
|
||||
type Hand struct {
|
||||
Cards []cards.Card `json:"cards"`
|
||||
Bet int64 `json:"bet"`
|
||||
Doubled bool `json:"doubled"`
|
||||
|
||||
// Split marks a hand that came out of a split. It exists for one rule: 21 on
|
||||
// a split hand is twenty-one, not a natural, and is paid 1:1 like any other.
|
||||
// Otherwise splitting aces would print money.
|
||||
Split bool `json:"split"`
|
||||
|
||||
// Done means this hand will not be acted on again: it stood, it busted, it
|
||||
// doubled, or it is a split ace, which gets exactly one card and no say.
|
||||
Done bool `json:"done"`
|
||||
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"` // stake plus winnings, net of rake. Zero on a loss.
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Value totals the hand. See HandValue.
|
||||
func (h Hand) Value() (int, bool) { return HandValue(h.Cards) }
|
||||
|
||||
// Natural reports a blackjack: 21 on the opening two cards of a hand that was
|
||||
// dealt, not split.
|
||||
func (h Hand) Natural() bool { return !h.Split && IsBlackjack(h.Cards) }
|
||||
|
||||
// State is one deal of heads-up blackjack: the player's hands against the
|
||||
// dealer's one.
|
||||
type State struct {
|
||||
Rules Rules `json:"rules"`
|
||||
Deck cards.Deck `json:"deck"` // the shoe, top card first — never shown to the browser
|
||||
Dealer []cards.Card `json:"dealer"`
|
||||
|
||||
// Hands is always at least one, and Active indexes the one being played. The
|
||||
// player works left to right: a hand is finished before the next is looked at,
|
||||
// which is both how a real table does it and what keeps the felt legible.
|
||||
Hands []Hand `json:"hands"`
|
||||
Active int `json:"active"`
|
||||
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"` // the deal as a whole; per-hand outcomes live on the hands
|
||||
|
||||
// Bet, Payout and Rake are the totals across every hand: what the player has
|
||||
// staked, what comes back, and what the house kept. The ledger and the chip
|
||||
// stack only ever deal in these.
|
||||
Bet int64 `json:"bet"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table can narrate or animate. The engine emits them
|
||||
// instead of drawing anything itself.
|
||||
//
|
||||
// Hand is which of the player's hands an event landed on — meaningless for the
|
||||
// dealer's, and the reason it is here at all is that after a split the browser
|
||||
// has to know which fan a card is flying to.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "split" | "double" | "reveal" | "settle"
|
||||
Card *cards.Card `json:"card,omitempty"`
|
||||
Hand int `json:"hand"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Move is a player action.
|
||||
type Move string
|
||||
|
||||
const (
|
||||
Hit Move = "hit"
|
||||
Stand Move = "stand"
|
||||
Double Move = "double"
|
||||
Split Move = "split"
|
||||
)
|
||||
|
||||
// HandValue totals a hand, counting each ace as 11 until that would bust, then
|
||||
// demoting them one at a time. soft reports whether an ace is still counting as
|
||||
// 11 — which is what makes "soft 17" a different thing from 17.
|
||||
func HandValue(hand []cards.Card) (total int, soft bool) {
|
||||
aces := 0
|
||||
for _, c := range hand {
|
||||
switch {
|
||||
case c.Rank == cards.Ace:
|
||||
aces++
|
||||
total += 11
|
||||
case c.Rank >= 10:
|
||||
total += 10
|
||||
default:
|
||||
total += int(c.Rank)
|
||||
}
|
||||
}
|
||||
for total > 21 && aces > 0 {
|
||||
total -= 10 // demote an ace from 11 to 1
|
||||
aces--
|
||||
}
|
||||
return total, aces > 0
|
||||
}
|
||||
|
||||
// IsBlackjack reports a natural: 21 on the opening two cards. A 21 assembled
|
||||
// from three cards is not one, and does not get paid 3:2.
|
||||
func IsBlackjack(hand []cards.Card) bool {
|
||||
if len(hand) != 2 {
|
||||
return false
|
||||
}
|
||||
v, _ := HandValue(hand)
|
||||
return v == 21
|
||||
}
|
||||
|
||||
// New deals a fresh hand: two to the player, two to the dealer. If either side
|
||||
// has a natural the hand is already over and the returned State is settled — a
|
||||
// player with blackjack never gets asked whether they'd like to hit.
|
||||
func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if r.Decks < 1 {
|
||||
r.Decks = 1
|
||||
}
|
||||
deck := cards.NewDeck(r.Decks)
|
||||
deck.Shuffle(rng)
|
||||
|
||||
s := State{
|
||||
Rules: r,
|
||||
Deck: deck,
|
||||
Hands: []Hand{{Bet: bet}},
|
||||
Bet: bet,
|
||||
Phase: PhasePlayer,
|
||||
}
|
||||
evs := []Event{{Kind: "deal"}}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := s.hit(0, &evs); err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
if err := s.drawDealer(&evs); err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// A natural on either side ends it before the player ever acts.
|
||||
if s.Hands[0].Natural() || IsBlackjack(s.Dealer) {
|
||||
s.settle(&evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// hit puts one card on a player hand. Pointer receiver: it mutates the deck and
|
||||
// the hand together, and neither may end up applied to a stale copy of the state.
|
||||
func (s *State) hit(i int, evs *[]Event) error {
|
||||
c, ok := s.Deck.Draw()
|
||||
if !ok {
|
||||
return ErrDeckExhausted
|
||||
}
|
||||
s.Hands[i].Cards = append(s.Hands[i].Cards, c)
|
||||
card := c
|
||||
*evs = append(*evs, Event{Kind: "player_card", Card: &card, Hand: i})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) drawDealer(evs *[]Event) error {
|
||||
c, ok := s.Deck.Draw()
|
||||
if !ok {
|
||||
return ErrDeckExhausted
|
||||
}
|
||||
s.Dealer = append(s.Dealer, c)
|
||||
card := c
|
||||
*evs = append(*evs, Event{Kind: "dealer_card", Card: &card})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyMove is the whole engine: a legal move in, a new state and the events it
|
||||
// produced out. An error means the move was illegal and the state is unchanged.
|
||||
//
|
||||
// s is taken by value, so the caller's state is only replaced on success.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrHandOver
|
||||
}
|
||||
// A copied State still shares its slices' backing arrays with the original.
|
||||
// Two moves applied from the same starting state would then append cards over
|
||||
// each other. Clone first: the caller's state is genuinely untouched, and a
|
||||
// state can be replayed as many times as we like.
|
||||
s = s.clone()
|
||||
if s.Phase != PhasePlayer {
|
||||
return s, nil, ErrNotYourTurn
|
||||
}
|
||||
switch m {
|
||||
case Hit, Stand, Double, Split:
|
||||
default:
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
if m == Double && !s.CanDouble() {
|
||||
// Doubling means doubling the stake for exactly one more card. Only ever
|
||||
// legal on the opening two — after that you're just describing a hit.
|
||||
return s, nil, ErrCantDouble
|
||||
}
|
||||
if m == Split && !s.CanSplit() {
|
||||
return s, nil, ErrCantSplit
|
||||
}
|
||||
|
||||
i := s.Active
|
||||
evs := []Event{}
|
||||
|
||||
switch m {
|
||||
case Split:
|
||||
// The second card moves to a hand of its own, carrying a bet the same size
|
||||
// as the one it came from, and both hands are topped up to two cards.
|
||||
h := &s.Hands[i]
|
||||
moved := h.Cards[1]
|
||||
h.Cards = h.Cards[:1]
|
||||
h.Split = true
|
||||
|
||||
fresh := Hand{Cards: []cards.Card{moved}, Bet: h.Bet, Split: true}
|
||||
s.Hands = append(s.Hands, Hand{})
|
||||
copy(s.Hands[i+2:], s.Hands[i+1:]) // the new hand sits immediately to the right
|
||||
s.Hands[i+1] = fresh
|
||||
s.Bet += fresh.Bet
|
||||
|
||||
evs = append(evs, Event{Kind: "split", Hand: i})
|
||||
if err := s.hit(i, &evs); err != nil {
|
||||
return s, nil, err
|
||||
}
|
||||
if err := s.hit(i+1, &evs); err != nil {
|
||||
return s, nil, err
|
||||
}
|
||||
|
||||
// Split aces get one card each and no say in it. Without this rule a pair
|
||||
// of aces is the best hand in the game and everybody would split them
|
||||
// forever; with it, splitting aces is a gamble like everything else.
|
||||
if moved.Rank == cards.Ace {
|
||||
s.Hands[i].Done = true
|
||||
s.Hands[i+1].Done = true
|
||||
}
|
||||
// A hand that has just been dealt a card can still be sitting on 21, and a
|
||||
// 21 has nothing left to decide.
|
||||
s.finishIfDone(i)
|
||||
s.finishIfDone(i + 1)
|
||||
|
||||
case Double:
|
||||
h := &s.Hands[i]
|
||||
s.Bet += h.Bet
|
||||
h.Bet *= 2
|
||||
h.Doubled = true
|
||||
// Announced before the card, because that is the order it happens in: the
|
||||
// chips go down, and *then* you find out what you bought with them.
|
||||
evs = append(evs, Event{Kind: "double", Hand: i})
|
||||
if err := s.hit(i, &evs); err != nil {
|
||||
return s, nil, err
|
||||
}
|
||||
h.Done = true // one card, and that is the deal you made
|
||||
|
||||
case Hit:
|
||||
if err := s.hit(i, &evs); err != nil {
|
||||
return s, nil, err
|
||||
}
|
||||
s.finishIfDone(i)
|
||||
|
||||
case Stand:
|
||||
s.Hands[i].Done = true
|
||||
}
|
||||
|
||||
s.advance(&evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// finishIfDone closes a hand that has nothing left to decide: it busted, or it
|
||||
// is sitting on 21 and would only be hitting it to be polite.
|
||||
func (s *State) finishIfDone(i int) {
|
||||
if v, _ := s.Hands[i].Value(); v >= 21 {
|
||||
s.Hands[i].Done = true
|
||||
}
|
||||
}
|
||||
|
||||
// advance moves to the next hand still owed a decision. When there are none, the
|
||||
// dealer plays — unless every hand busted, in which case there is nothing to beat
|
||||
// and the dealer does not draw.
|
||||
func (s *State) advance(evs *[]Event) {
|
||||
for i := s.Active; i < len(s.Hands); i++ {
|
||||
if !s.Hands[i].Done {
|
||||
s.Active = i
|
||||
return
|
||||
}
|
||||
}
|
||||
s.Active = len(s.Hands) - 1
|
||||
|
||||
if s.allBust() {
|
||||
// The dealer draws no cards, but the hole card still turns over: the
|
||||
// browser has been showing a face-down card since the deal, and the settled
|
||||
// state it is about to be handed has the dealer's full total on it. Without
|
||||
// the reveal you get a nineteen printed under a card nobody has looked at.
|
||||
*evs = append(*evs, Event{Kind: "reveal"})
|
||||
s.settle(evs)
|
||||
return
|
||||
}
|
||||
s.Phase = PhaseDealer
|
||||
s.dealerPlay(evs)
|
||||
}
|
||||
|
||||
func (s *State) allBust() bool {
|
||||
for _, h := range s.Hands {
|
||||
if v, _ := h.Value(); v <= 21 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// dealerPlay draws the dealer out to the house rule, then settles. The dealer
|
||||
// has no choices to make — that's the game — so this needs no move.
|
||||
func (s *State) dealerPlay(evs *[]Event) {
|
||||
*evs = append(*evs, Event{Kind: "reveal"}) // the hole card turns over
|
||||
for {
|
||||
v, soft := HandValue(s.Dealer)
|
||||
hitSoft17 := s.Rules.DealerHitsSoft17 && v == 17 && soft
|
||||
if v >= 17 && !hitSoft17 {
|
||||
break
|
||||
}
|
||||
if err := s.drawDealer(evs); err != nil {
|
||||
break // shoe ran dry mid-draw; settle on what's on the table
|
||||
}
|
||||
}
|
||||
s.settle(evs)
|
||||
}
|
||||
|
||||
// settle decides every hand against the dealer and adds up what comes back. It
|
||||
// is the only place chips are computed.
|
||||
//
|
||||
// The rake comes off winnings, never off the stake: a player who pushes gets
|
||||
// exactly their bet back, and a player who loses is never charged for the
|
||||
// privilege. The house only takes a cut of money the house was going to hand
|
||||
// over anyway. That's a rake, as opposed to a fee for showing up.
|
||||
//
|
||||
// Each hand is raked on its own winnings. Netting the hands against each other
|
||||
// first would let a player who won one and lost one pay no rake at all, which is
|
||||
// not a rake, it's a discount for splitting.
|
||||
func (s *State) settle(evs *[]Event) {
|
||||
dealerVal, _ := HandValue(s.Dealer)
|
||||
dealerBJ := IsBlackjack(s.Dealer)
|
||||
|
||||
s.Payout, s.Rake = 0, 0
|
||||
for i := range s.Hands {
|
||||
h := &s.Hands[i]
|
||||
playerVal, _ := h.Value()
|
||||
|
||||
// profit is what this hand wins on top of its stake. Negative means the
|
||||
// stake is gone.
|
||||
var profit int64
|
||||
|
||||
switch {
|
||||
case playerVal > 21:
|
||||
h.Outcome = OutcomeBust
|
||||
profit = -h.Bet
|
||||
case h.Natural() && dealerBJ:
|
||||
h.Outcome = OutcomePush
|
||||
case h.Natural():
|
||||
h.Outcome = OutcomeBlackjack
|
||||
profit = int64(math.Floor(float64(h.Bet) * s.Rules.BlackjackPays))
|
||||
case dealerBJ:
|
||||
h.Outcome = OutcomeLose
|
||||
profit = -h.Bet
|
||||
case dealerVal > 21:
|
||||
h.Outcome = OutcomeDealerBust
|
||||
profit = h.Bet
|
||||
case playerVal > dealerVal:
|
||||
h.Outcome = OutcomeWin
|
||||
profit = h.Bet
|
||||
case playerVal == dealerVal:
|
||||
h.Outcome = OutcomePush
|
||||
default:
|
||||
h.Outcome = OutcomeLose
|
||||
profit = -h.Bet
|
||||
}
|
||||
|
||||
if profit > 0 {
|
||||
h.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
|
||||
if h.Rake < 0 {
|
||||
h.Rake = 0
|
||||
}
|
||||
profit -= h.Rake
|
||||
}
|
||||
if profit < 0 {
|
||||
h.Payout = 0 // stake is lost; nothing comes back
|
||||
} else {
|
||||
h.Payout = h.Bet + profit
|
||||
}
|
||||
|
||||
s.Payout += h.Payout
|
||||
s.Rake += h.Rake
|
||||
}
|
||||
|
||||
s.Outcome = s.overall()
|
||||
s.Phase = PhaseDone
|
||||
*evs = append(*evs, Event{Kind: "settle", Text: string(s.Outcome)})
|
||||
}
|
||||
|
||||
// overall is the deal's outcome as one word, which is what the ledger and the
|
||||
// history line want. With one hand it is simply that hand's. With several there
|
||||
// is no honest single word for "won one, lost one", so it reports what the deal
|
||||
// did to the player's chips, which is the thing anybody actually means.
|
||||
func (s State) overall() Outcome {
|
||||
if len(s.Hands) == 1 {
|
||||
return s.Hands[0].Outcome
|
||||
}
|
||||
switch net := s.Payout - s.Bet; {
|
||||
case net > 0:
|
||||
return OutcomeWin
|
||||
case net < 0:
|
||||
return OutcomeLose
|
||||
default:
|
||||
return OutcomePush
|
||||
}
|
||||
}
|
||||
|
||||
// Net is what the deal did to the player's chip stack: everything paid out minus
|
||||
// everything staked. Negative on a loss, zero on a push.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// Hand returns the hand being played. There is always one.
|
||||
func (s State) Hand() Hand {
|
||||
if s.Active < 0 || s.Active >= len(s.Hands) {
|
||||
return Hand{}
|
||||
}
|
||||
return s.Hands[s.Active]
|
||||
}
|
||||
|
||||
// CanDouble reports whether Double is legal right now — the shell asks this to
|
||||
// decide whether to light the button up.
|
||||
func (s State) CanDouble() bool {
|
||||
h := s.Hand()
|
||||
return s.Phase == PhasePlayer && !h.Done && len(h.Cards) == 2
|
||||
}
|
||||
|
||||
// CanSplit reports whether Split is legal: two cards of the same rank, and room
|
||||
// at the table for another hand.
|
||||
//
|
||||
// Same *rank*, not same value: a king and a queen are both worth ten and are not
|
||||
// a pair, which is the stricter of the two house rules and the one that doesn't
|
||||
// need explaining on the felt.
|
||||
func (s State) CanSplit() bool {
|
||||
h := s.Hand()
|
||||
if s.Phase != PhasePlayer || h.Done || len(h.Cards) != 2 || len(s.Hands) >= MaxHands {
|
||||
return false
|
||||
}
|
||||
return h.Cards[0].Rank == h.Cards[1].Rank
|
||||
}
|
||||
|
||||
// SplitCost is what splitting the active hand would take out of the player's
|
||||
// stack: another bet the same size as the one already on it. The shell has to
|
||||
// take these chips before the move, because a split the player cannot cover is
|
||||
// not a legal move.
|
||||
func (s State) SplitCost() int64 { return s.Hand().Bet }
|
||||
|
||||
// DoubleCost is the same idea for a double: the stake again.
|
||||
func (s State) DoubleCost() int64 { return s.Hand().Bet }
|
||||
|
||||
// clone deep-copies the slices so a derived state shares no backing array with
|
||||
// the one it came from.
|
||||
func (s State) clone() State {
|
||||
s.Deck = append(cards.Deck(nil), s.Deck...)
|
||||
s.Dealer = append([]cards.Card(nil), s.Dealer...)
|
||||
hands := make([]Hand, len(s.Hands))
|
||||
copy(hands, s.Hands)
|
||||
for i := range hands {
|
||||
hands[i].Cards = append([]cards.Card(nil), hands[i].Cards...)
|
||||
}
|
||||
s.Hands = hands
|
||||
return s
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a state, including one written before split existed.
|
||||
//
|
||||
// A live hand outlives a deploy: it is a blob in the database, and somebody is
|
||||
// mid-hand when the new binary starts. Those blobs have a "player" array and no
|
||||
// "hands", and without this they would come back as a state with no hands at all
|
||||
// — which is not a decoding error, it's a player whose cards vanished. So the old
|
||||
// shape is read as what it always was: one hand, holding the whole stake.
|
||||
func (s *State) UnmarshalJSON(b []byte) error {
|
||||
type state State // shed the method, or this recurses forever
|
||||
var v struct {
|
||||
state
|
||||
Player []cards.Card `json:"player"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = State(v.state)
|
||||
if len(s.Hands) == 0 && len(v.Player) > 0 {
|
||||
s.Hands = []Hand{{
|
||||
Cards: v.Player,
|
||||
Bet: s.Bet,
|
||||
Outcome: s.Outcome,
|
||||
Payout: s.Payout,
|
||||
Rake: s.Rake,
|
||||
Done: s.Phase == PhaseDone,
|
||||
}}
|
||||
s.Active = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package blackjack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// hand builds a hand from "A♠"-ish shorthand: rank letters/numbers only.
|
||||
func hand(ranks ...cards.Rank) []cards.Card {
|
||||
h := make([]cards.Card, len(ranks))
|
||||
for i, r := range ranks {
|
||||
h[i] = cards.Card{Rank: r, Suit: cards.Spades}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestHandValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hand []cards.Card
|
||||
want int
|
||||
soft bool
|
||||
}{
|
||||
{"two aces are 12, not 22", hand(cards.Ace, cards.Ace), 12, true},
|
||||
{"ace plus king is a soft 21", hand(cards.Ace, cards.King), 21, true},
|
||||
{"faces are all ten", hand(cards.Jack, cards.Queen), 20, false},
|
||||
{"ace demotes to save the hand", hand(cards.Ace, 9, 5), 15, false},
|
||||
{"three aces and an eight", hand(cards.Ace, cards.Ace, cards.Ace, 8), 21, true},
|
||||
{"soft 17 is an ace and a six", hand(cards.Ace, 6), 17, true},
|
||||
{"hard 17 has no ace", hand(cards.King, 7), 17, false},
|
||||
{"a bust stays busted", hand(cards.King, cards.Queen, 5), 25, false},
|
||||
{"empty hand", nil, 0, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, soft := HandValue(tc.hand)
|
||||
if got != tc.want || soft != tc.soft {
|
||||
t.Fatalf("HandValue = (%d, soft=%v), want (%d, soft=%v)", got, soft, tc.want, tc.soft)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlackjack_OnlyOnTwoCards(t *testing.T) {
|
||||
if !IsBlackjack(hand(cards.Ace, cards.King)) {
|
||||
t.Fatal("A+K is a natural")
|
||||
}
|
||||
// 21 built from three cards is not a natural and must not be paid 3:2.
|
||||
if IsBlackjack(hand(7, 7, 7)) {
|
||||
t.Fatal("7+7+7 is 21 but not a blackjack")
|
||||
}
|
||||
if IsBlackjack(hand(cards.Ace)) {
|
||||
t.Fatal("one card is not a blackjack")
|
||||
}
|
||||
}
|
||||
|
||||
// settleWith forces a finished hand and reads back the money, bypassing the
|
||||
// deal so the payout math can be checked case by case.
|
||||
func settleWith(t *testing.T, r Rules, bet int64, player, dealer []cards.Card) State {
|
||||
t.Helper()
|
||||
s := State{Rules: r, Bet: bet, Hands: []Hand{{Cards: player, Bet: bet}}, Dealer: dealer}
|
||||
evs := []Event{}
|
||||
s.settle(&evs)
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("settle left the hand unfinished")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSettle_PayoutsAndRake(t *testing.T) {
|
||||
r := DefaultRules() // 3:2, 5% rake
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
player []cards.Card
|
||||
dealer []cards.Card
|
||||
wantOutcome Outcome
|
||||
wantPayout int64 // chips returned to the stack
|
||||
wantRake int64
|
||||
}{
|
||||
{
|
||||
// 100 stake, 100 profit, 5 raked → 195 back, net +95.
|
||||
name: "a plain win is raked on the profit only",
|
||||
player: hand(cards.King, 9), dealer: hand(cards.King, 8),
|
||||
wantOutcome: OutcomeWin, wantPayout: 195, wantRake: 5,
|
||||
},
|
||||
{
|
||||
// 3:2 on 100 is 150 profit, 7 raked (floor of 7.5) → 243 back.
|
||||
name: "a natural pays 3:2 less rake",
|
||||
player: hand(cards.Ace, cards.King), dealer: hand(cards.King, 8),
|
||||
wantOutcome: OutcomeBlackjack, wantPayout: 243, wantRake: 7,
|
||||
},
|
||||
{
|
||||
name: "a push returns the stake untouched — the house takes nothing",
|
||||
player: hand(cards.King, 9), dealer: hand(cards.Queen, 9),
|
||||
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "two naturals push",
|
||||
player: hand(cards.Ace, cards.King), dealer: hand(cards.Ace, cards.Queen),
|
||||
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "a loss pays nothing and is not charged a rake",
|
||||
player: hand(cards.King, 8), dealer: hand(cards.King, 9),
|
||||
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "a bust pays nothing even if the dealer would have busted too",
|
||||
player: hand(cards.King, 8, 9), dealer: hand(cards.King, 6, 9),
|
||||
wantOutcome: OutcomeBust, wantPayout: 0, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "dealer blackjack beats the player's twenty",
|
||||
player: hand(cards.King, cards.Queen), dealer: hand(cards.Ace, cards.Jack),
|
||||
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "dealer bust pays even money less rake",
|
||||
player: hand(cards.King, 5), dealer: hand(cards.King, 6, 9),
|
||||
wantOutcome: OutcomeDealerBust, wantPayout: 195, wantRake: 5,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := settleWith(t, r, 100, tc.player, tc.dealer)
|
||||
if s.Outcome != tc.wantOutcome {
|
||||
t.Errorf("outcome = %q, want %q", s.Outcome, tc.wantOutcome)
|
||||
}
|
||||
if s.Payout != tc.wantPayout {
|
||||
t.Errorf("payout = %d, want %d", s.Payout, tc.wantPayout)
|
||||
}
|
||||
if s.Rake != tc.wantRake {
|
||||
t.Errorf("rake = %d, want %d", s.Rake, tc.wantRake)
|
||||
}
|
||||
// The invariant the ledger depends on: every chip the player staked
|
||||
// either comes back, goes to the house as rake, or is lost to the table.
|
||||
if s.Payout < 0 || s.Rake < 0 {
|
||||
t.Errorf("negative chips: payout=%d rake=%d", s.Payout, s.Rake)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettle_RakeNeverTouchesTheStake(t *testing.T) {
|
||||
// A 100% rake is absurd, but it must still never claw back a player's own
|
||||
// stake: the worst a rake can do is take all the winnings.
|
||||
r := Rules{Decks: 6, BlackjackPays: 1.5, RakePct: 1.0}
|
||||
s := settleWith(t, r, 100, hand(cards.King, 9), hand(cards.King, 8))
|
||||
if s.Payout != 100 {
|
||||
t.Fatalf("payout = %d, want the stake back (100)", s.Payout)
|
||||
}
|
||||
if s.Net() != 0 {
|
||||
t.Fatalf("net = %d, want 0", s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
|
||||
rng := cards.NewRNG(1, 2)
|
||||
s, evs, err := New(50, DefaultRules(), rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Hands[0].Cards) != 2 || len(s.Dealer) != 2 {
|
||||
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Hands[0].Cards), len(s.Dealer))
|
||||
}
|
||||
if len(s.Deck) != 6*52-4 {
|
||||
t.Fatalf("shoe has %d cards left, want %d", len(s.Deck), 6*52-4)
|
||||
}
|
||||
if len(evs) == 0 || evs[0].Kind != "deal" {
|
||||
t.Fatal("no deal event")
|
||||
}
|
||||
// Unless somebody was dealt a natural, it's the player's move.
|
||||
if !IsBlackjack(s.Hands[0].Cards) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
|
||||
t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_RejectsNonPositiveBet(t *testing.T) {
|
||||
for _, bet := range []int64{0, -100} {
|
||||
if _, _, err := New(bet, DefaultRules(), cards.NewRNG(1, 2)); err == nil {
|
||||
t.Fatalf("bet %d was accepted", bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_NaturalSettlesImmediately(t *testing.T) {
|
||||
// Search seeds for a deal that gives the player a natural, then assert the
|
||||
// hand is already over — a player holding blackjack is never asked to hit.
|
||||
for seed := uint64(1); seed < 200; seed++ {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(seed, seed))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsBlackjack(s.Hands[0].Cards) {
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Hit); err != ErrHandOver {
|
||||
t.Fatalf("seed %d: hitting a settled natural gave %v, want ErrHandOver", seed, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Skip("no natural dealt in 200 seeds")
|
||||
}
|
||||
|
||||
func TestApplyMove_HitUntilBustSettles(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(7, 7))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural; not the hand under test")
|
||||
}
|
||||
for i := 0; i < 12 && s.Phase == PhasePlayer; i++ {
|
||||
s, _, err = ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("hitting a dozen times never ended the hand")
|
||||
}
|
||||
if v, _ := HandValue(s.Hands[0].Cards); v <= 21 {
|
||||
t.Fatalf("player stopped at %d without busting — the loop should have gone over", v)
|
||||
}
|
||||
if s.Outcome != OutcomeBust || s.Payout != 0 {
|
||||
t.Fatalf("outcome=%q payout=%d, want bust/0", s.Outcome, s.Payout)
|
||||
}
|
||||
// A busted player must not have made the dealer draw.
|
||||
if len(s.Dealer) != 2 {
|
||||
t.Fatalf("dealer drew %d cards against a busted player", len(s.Dealer)-2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_StandRunsTheDealerOut(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(3, 9))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
s, evs, err := ApplyMove(s, Stand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q after stand, want done", s.Phase)
|
||||
}
|
||||
v, soft := HandValue(s.Dealer)
|
||||
if v < 17 {
|
||||
t.Fatalf("dealer stood on %d, must draw below 17", v)
|
||||
}
|
||||
if v == 17 && soft {
|
||||
t.Fatal("dealer stood on soft 17; the house rule says hit")
|
||||
}
|
||||
var reveal bool
|
||||
for _, e := range evs {
|
||||
if e.Kind == "reveal" {
|
||||
reveal = true
|
||||
}
|
||||
}
|
||||
if !reveal {
|
||||
t.Fatal("dealer played without a reveal event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_DoubleTakesOneCardThenStands(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(11, 4))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
if !s.CanDouble() {
|
||||
t.Fatal("double should be legal on the opening two cards")
|
||||
}
|
||||
s, _, err = ApplyMove(s, Double)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Hands[0].Doubled || s.Bet != 200 {
|
||||
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Hands[0].Doubled)
|
||||
}
|
||||
if len(s.Hands[0].Cards) != 3 {
|
||||
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Hands[0].Cards))
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("a double must end the player's turn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_DoubleIsIllegalAfterHitting(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(5, 5))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
s, _, err = ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhasePlayer {
|
||||
t.Skip("busted on the hit; not the hand under test")
|
||||
}
|
||||
before := s.Bet
|
||||
after, _, err := ApplyMove(s, Double)
|
||||
if err != ErrCantDouble {
|
||||
t.Fatalf("double after a hit gave %v, want ErrCantDouble", err)
|
||||
}
|
||||
if after.Bet != before {
|
||||
t.Fatalf("a rejected double still moved the bet: %d -> %d", before, after.Bet)
|
||||
}
|
||||
if s.CanDouble() {
|
||||
t.Fatal("CanDouble says yes on a three-card hand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_RejectsGarbage(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(2, 8))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move("surrender")); err != ErrUnknownMove {
|
||||
t.Fatalf("got %v, want ErrUnknownMove", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The engine's state has to survive a redeploy: no timers, no pointers, no
|
||||
// unexported fields that JSON would quietly drop.
|
||||
func TestState_RoundTripsThroughJSON(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(13, 21))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Play both forward identically; a state that survives the trip settles the same.
|
||||
live, _, err := ApplyMove(s, Stand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revived, _, err := ApplyMove(back, Stand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if live.Outcome != revived.Outcome || live.Payout != revived.Payout {
|
||||
t.Fatalf("revived hand settled differently: %q/%d vs %q/%d",
|
||||
revived.Outcome, revived.Payout, live.Outcome, live.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
// Same seed, same shoe — this is what lets a disputed hand be re-dealt.
|
||||
func TestNew_IsReproducibleFromItsSeed(t *testing.T) {
|
||||
a, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cards.Hand(a.Hands[0].Cards) != cards.Hand(b.Hands[0].Cards) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) {
|
||||
t.Fatalf("same seed dealt different hands: %s/%s vs %s/%s",
|
||||
cards.Hand(a.Hands[0].Cards), cards.Hand(a.Dealer), cards.Hand(b.Hands[0].Cards), cards.Hand(b.Dealer))
|
||||
}
|
||||
}
|
||||
|
||||
// A State handed to ApplyMove twice must produce two independent hands. If the
|
||||
// engine let derived states share a backing array, the second deal would scribble
|
||||
// over the first one's cards — and a player could watch a card change under them.
|
||||
func TestApplyMove_DerivedStatesDoNotShareCards(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(23, 5))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
before := cards.Hand(s.Hands[0].Cards)
|
||||
|
||||
a, _, err := ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aHand := cards.Hand(a.Hands[0].Cards)
|
||||
|
||||
if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cards.Hand(a.Hands[0].Cards); got != aHand {
|
||||
t.Fatalf("the first hand changed under us: %q became %q", aHand, got)
|
||||
}
|
||||
if got := cards.Hand(s.Hands[0].Cards); got != before {
|
||||
t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package blackjack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// Split is the only move in blackjack that takes chips out of a player's stack
|
||||
// after the cards are already out, so most of what can go wrong with it is money
|
||||
// rather than cards. These tests are mostly about the money.
|
||||
|
||||
// dealt builds a state mid-hand: the player holding `player`, the dealer showing
|
||||
// `dealer`, and a shoe stacked with `shoe` so the next cards are known. It skips
|
||||
// New() because a split needs a pair, and waiting for one out of a shuffled shoe
|
||||
// is not a test, it's a slot machine.
|
||||
func dealt(bet int64, player, dealer, shoe []cards.Card) State {
|
||||
return State{
|
||||
Rules: DefaultRules(),
|
||||
Deck: cards.Deck(shoe),
|
||||
Dealer: dealer,
|
||||
Hands: []Hand{{Cards: player, Bet: bet}},
|
||||
Bet: bet,
|
||||
Phase: PhasePlayer,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitDealsTwoHandsAndTakesASecondBet(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(8, 8),
|
||||
hand(cards.King, 6),
|
||||
hand(3, 9, 5, 5), // one card to each hand, then whatever the dealer needs
|
||||
)
|
||||
if !s.CanSplit() {
|
||||
t.Fatal("CanSplit says no to a pair of eights")
|
||||
}
|
||||
if s.SplitCost() != 100 {
|
||||
t.Fatalf("SplitCost = %d, want the same bet again (100)", s.SplitCost())
|
||||
}
|
||||
|
||||
s, evs, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Hands) != 2 {
|
||||
t.Fatalf("split made %d hands, want 2", len(s.Hands))
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if len(h.Cards) != 2 {
|
||||
t.Errorf("hand %d holds %d cards after the split, want 2", i, len(h.Cards))
|
||||
}
|
||||
if h.Cards[0].Rank != 8 {
|
||||
t.Errorf("hand %d didn't keep an eight: %s", i, cards.Hand(h.Cards))
|
||||
}
|
||||
if h.Bet != 100 {
|
||||
t.Errorf("hand %d carries a bet of %d, want 100", i, h.Bet)
|
||||
}
|
||||
if !h.Split {
|
||||
t.Errorf("hand %d isn't marked as split", i)
|
||||
}
|
||||
}
|
||||
// The whole point, in one line: there is twice as much on the table as there
|
||||
// was, and the shell has to have taken it.
|
||||
if s.Bet != 200 {
|
||||
t.Fatalf("total stake = %d after splitting a 100 hand, want 200", s.Bet)
|
||||
}
|
||||
if s.Active != 0 {
|
||||
t.Fatalf("active hand = %d, want the left one first", s.Active)
|
||||
}
|
||||
|
||||
var split, cardsDealt int
|
||||
for _, e := range evs {
|
||||
switch e.Kind {
|
||||
case "split":
|
||||
split++
|
||||
case "player_card":
|
||||
cardsDealt++
|
||||
}
|
||||
}
|
||||
if split != 1 || cardsDealt != 2 {
|
||||
t.Fatalf("the split emitted %d split and %d card events, want 1 and 2", split, cardsDealt)
|
||||
}
|
||||
}
|
||||
|
||||
// Split aces get one card each and no further say. Without that rule a pair of
|
||||
// aces is the best hand in the game every single time.
|
||||
func TestSplitAcesGetOneCardEachAndNoMore(t *testing.T) {
|
||||
s := dealt(50,
|
||||
hand(cards.Ace, cards.Ace),
|
||||
hand(9, 7), // dealer sits on 16, has to draw
|
||||
hand(cards.King, 9, 5),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if len(h.Cards) != 2 {
|
||||
t.Errorf("split ace %d holds %d cards, want exactly 2", i, len(h.Cards))
|
||||
}
|
||||
if !h.Done {
|
||||
t.Errorf("split ace %d is still being asked for a decision", i)
|
||||
}
|
||||
}
|
||||
// Both hands are finished the moment they're dealt, so the dealer plays and
|
||||
// the deal settles without the player ever acting again.
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q after splitting aces, want the deal to have run to the end", s.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
// An ace and a ten on a split hand is twenty-one. It is not a blackjack, and the
|
||||
// house does not pay 3:2 for it — which is the single most expensive rule in this
|
||||
// file, because splitting aces makes 21s for a living.
|
||||
func TestTwentyOneOnASplitHandIsNotANatural(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(cards.Ace, cards.Ace),
|
||||
hand(cards.King, 7), // dealer stands on 17
|
||||
hand(cards.King, cards.Queen),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q, want done", s.Phase)
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if v, _ := h.Value(); v != 21 {
|
||||
t.Fatalf("hand %d is %d, want the 21 this test is about", i, v)
|
||||
}
|
||||
if h.Natural() {
|
||||
t.Errorf("hand %d counts as a natural — a split 21 must not pay 3:2", i)
|
||||
}
|
||||
if h.Outcome != OutcomeWin {
|
||||
t.Errorf("hand %d settled as %q, want a plain win", i, h.Outcome)
|
||||
}
|
||||
// 100 staked, 100 profit, 5% rake off the profit: 195 back, not 245.
|
||||
if h.Payout != 195 {
|
||||
t.Errorf("hand %d paid %d, want 195 (a 1:1 win less the rake), not a 3:2 payout", i, h.Payout)
|
||||
}
|
||||
}
|
||||
if s.Payout != 390 {
|
||||
t.Fatalf("the deal paid %d, want 390", s.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
// Same rank, not same value. A king and a queen are both worth ten and are not a
|
||||
// pair.
|
||||
func TestSplitNeedsAPairOfTheSameRank(t *testing.T) {
|
||||
s := dealt(100, hand(cards.King, cards.Queen), hand(9, 9), hand(5))
|
||||
if s.CanSplit() {
|
||||
t.Error("CanSplit says a king and a queen are a pair")
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Split); err != ErrCantSplit {
|
||||
t.Errorf("splitting K+Q gave %v, want ErrCantSplit", err)
|
||||
}
|
||||
|
||||
// And you cannot split a hand you have already hit.
|
||||
s = dealt(100, hand(8, 8, 5), hand(9, 9), hand(5))
|
||||
if s.CanSplit() {
|
||||
t.Error("CanSplit says yes to a three-card hand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplittingStopsAtFourHands(t *testing.T) {
|
||||
// Every card an eight, so every hand splits again for as long as it's allowed.
|
||||
shoe := make([]cards.Card, 0, 12)
|
||||
for i := 0; i < 12; i++ {
|
||||
shoe = append(shoe, cards.Card{Rank: 8, Suit: cards.Spades})
|
||||
}
|
||||
s := dealt(100, hand(8, 8), hand(9, 7), shoe)
|
||||
|
||||
for i := 0; i < MaxHands-1; i++ {
|
||||
var err error
|
||||
if s, _, err = ApplyMove(s, Split); err != nil {
|
||||
t.Fatalf("split %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if len(s.Hands) != MaxHands {
|
||||
t.Fatalf("ended with %d hands, want %d", len(s.Hands), MaxHands)
|
||||
}
|
||||
if s.CanSplit() {
|
||||
t.Fatalf("CanSplit says yes at %d hands", MaxHands)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Split); err != ErrCantSplit {
|
||||
t.Errorf("the fifth split gave %v, want ErrCantSplit", err)
|
||||
}
|
||||
if s.Bet != 400 {
|
||||
t.Errorf("four hands of 100 = %d staked, want 400", s.Bet)
|
||||
}
|
||||
}
|
||||
|
||||
// Each hand is raked on its own winnings. Netting the hands against each other
|
||||
// first would mean a player who wins one and loses one pays no rake at all, which
|
||||
// is not a rake, it's a discount for splitting.
|
||||
func TestEachSplitHandIsSettledAndRakedOnItsOwn(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(8, 8),
|
||||
hand(cards.King, 9), // dealer stands on 19
|
||||
// left hand gets a 2 (10, then it hits to 20 and stands);
|
||||
// right hand gets a 3 (11) and will bust on a king.
|
||||
hand(2, 3, cards.King, cards.King),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // left: 8+2+K = 20
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, _ := s.Hands[0].Value(); v != 20 {
|
||||
t.Fatalf("left hand is %d, want 20", v)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Stand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Active != 1 {
|
||||
t.Fatalf("standing on the left hand left the active hand at %d, want 1", s.Active)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // right: 8+3+K = 21... no: 8+3=11, +K = 21
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q, want done", s.Phase)
|
||||
}
|
||||
|
||||
left, right := s.Hands[0], s.Hands[1]
|
||||
if left.Outcome != OutcomeWin { // 20 beats 19
|
||||
t.Errorf("left hand settled %q, want a win", left.Outcome)
|
||||
}
|
||||
if right.Outcome != OutcomeWin { // 21 beats 19
|
||||
t.Errorf("right hand settled %q, want a win", right.Outcome)
|
||||
}
|
||||
// Two 100 hands, each winning 100, each raked 5 on its own profit.
|
||||
if left.Rake != 5 || right.Rake != 5 || s.Rake != 10 {
|
||||
t.Errorf("rake = %d/%d (total %d), want 5/5 (10) — each hand raked on its own winnings", left.Rake, right.Rake, s.Rake)
|
||||
}
|
||||
if s.Payout != 390 || s.Net() != 190 {
|
||||
t.Errorf("payout = %d net = %d, want 390 and 190", s.Payout, s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
// A hand that busts loses its own bet and nothing else. The other hand is a
|
||||
// separate bet and settles on its own merits.
|
||||
func TestBustingOneSplitHandDoesNotTouchTheOther(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(9, 9),
|
||||
hand(cards.King, 8), // dealer stands on 18
|
||||
hand(5, 2, cards.King, cards.King),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split) // left: 9+5=14, right: 9+2=11
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // left: 14+K = 24, bust
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Hands[0].Outcome != "" && s.Hands[0].Outcome != OutcomeBust {
|
||||
t.Fatalf("left hand outcome %q before settling", s.Hands[0].Outcome)
|
||||
}
|
||||
if !s.Hands[0].Done || s.Active != 1 {
|
||||
t.Fatalf("a bust hand didn't hand over: done=%v active=%d", s.Hands[0].Done, s.Active)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // right: 11+K = 21
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if s.Hands[0].Outcome != OutcomeBust || s.Hands[0].Payout != 0 {
|
||||
t.Errorf("left hand = %q paying %d, want a bust paying nothing", s.Hands[0].Outcome, s.Hands[0].Payout)
|
||||
}
|
||||
if s.Hands[1].Outcome != OutcomeWin || s.Hands[1].Payout != 195 {
|
||||
t.Errorf("right hand = %q paying %d, want a win paying 195", s.Hands[1].Outcome, s.Hands[1].Payout)
|
||||
}
|
||||
// Staked 200, got 195 back: down 5 on the deal, and the word for that is lose.
|
||||
if s.Payout != 195 || s.Net() != -5 || s.Outcome != OutcomeLose {
|
||||
t.Errorf("deal settled %q paying %d (net %d), want lose/195/-5", s.Outcome, s.Payout, s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
// If every hand busts there is nothing left to beat, and the dealer does not turn
|
||||
// over — same as it always was with one hand.
|
||||
func TestTheDealerDoesNotPlayWhenEveryHandIsBust(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(9, 9),
|
||||
hand(cards.King, 6),
|
||||
hand(8, 8, cards.King, cards.King, 4), // both hands reach 17 then bust on a king
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // left 9+8+K = 27
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // right 9+8+K = 27
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q, want done", s.Phase)
|
||||
}
|
||||
if len(s.Dealer) != 2 {
|
||||
t.Errorf("the dealer drew to %d cards with every hand already bust", len(s.Dealer))
|
||||
}
|
||||
if s.Payout != 0 {
|
||||
t.Errorf("two bust hands paid %d, want nothing", s.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
// The dealer not drawing is not the same as the dealer not turning over. The
|
||||
// browser has been showing a face-down card since the deal, and the settled state
|
||||
// it gets handed has the dealer's whole total on it — so a bust-out still owes it
|
||||
// a reveal, or the felt prints a nineteen under a card nobody has looked at.
|
||||
func TestBustingOutStillTurnsTheHoleCardOver(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(cards.King, 6),
|
||||
hand(cards.King, 9),
|
||||
hand(cards.King), // 16 + K = 26
|
||||
)
|
||||
s, evs, err := ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone || s.Outcome != OutcomeBust {
|
||||
t.Fatalf("phase/outcome = %q/%q, want done/bust", s.Phase, s.Outcome)
|
||||
}
|
||||
var reveal bool
|
||||
for _, e := range evs {
|
||||
if e.Kind == "reveal" {
|
||||
reveal = true
|
||||
}
|
||||
}
|
||||
if !reveal {
|
||||
t.Error("the player busted out and the hole card was never revealed")
|
||||
}
|
||||
}
|
||||
|
||||
// Doubling after a split doubles *that hand's* bet, not the whole table's.
|
||||
func TestDoublingAfterASplitOnlyDoublesThatHand(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(5, 5),
|
||||
hand(cards.King, 7),
|
||||
hand(6, 4, 9, cards.King),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split) // left: 5+6=11, right: 5+4=9
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.DoubleCost() != 100 {
|
||||
t.Fatalf("DoubleCost = %d on a split hand of 100, want 100", s.DoubleCost())
|
||||
}
|
||||
s, _, err = ApplyMove(s, Double) // left doubles: 11 + 9 = 20
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Hands[0].Bet != 200 || s.Hands[1].Bet != 100 {
|
||||
t.Fatalf("bets are %d/%d after doubling the left hand, want 200/100", s.Hands[0].Bet, s.Hands[1].Bet)
|
||||
}
|
||||
if s.Bet != 300 {
|
||||
t.Fatalf("total staked = %d, want 300 (100 + a doubled 100 + 100)", s.Bet)
|
||||
}
|
||||
if !s.Hands[0].Done || s.Active != 1 {
|
||||
t.Fatal("a doubled hand takes one card and hands over")
|
||||
}
|
||||
}
|
||||
|
||||
// A live hand outlives a deploy. The blobs written before split existed have a
|
||||
// "player" array and no "hands", and a state that decodes to no hands at all is a
|
||||
// player whose cards vanished mid-deal.
|
||||
func TestALiveHandDealtBeforeSplitExistedStillLoads(t *testing.T) {
|
||||
legacy := []byte(`{
|
||||
"rules": {"decks": 6, "blackjack_pays": 1.5, "dealer_hits_soft17": true, "rake_pct": 0.05},
|
||||
"deck": [],
|
||||
"player": [{"r": 10, "s": 1}, {"r": 7, "s": 2}],
|
||||
"dealer": [{"r": 9, "s": 0}, {"r": 5, "s": 3}],
|
||||
"bet": 250,
|
||||
"doubled": false,
|
||||
"phase": "player"
|
||||
}`)
|
||||
|
||||
var s State
|
||||
if err := json.Unmarshal(legacy, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Hands) != 1 {
|
||||
t.Fatalf("an old live hand decoded to %d hands, want 1 — the player's cards went missing", len(s.Hands))
|
||||
}
|
||||
if len(s.Hands[0].Cards) != 2 {
|
||||
t.Fatalf("the revived hand holds %d cards, want 2", len(s.Hands[0].Cards))
|
||||
}
|
||||
if s.Hands[0].Bet != 250 || s.Bet != 250 {
|
||||
t.Fatalf("the revived hand carries %d of the %d staked, want all of it", s.Hands[0].Bet, s.Bet)
|
||||
}
|
||||
if v, _ := s.Hands[0].Value(); v != 17 {
|
||||
t.Fatalf("the revived hand is worth %d, want 17", v)
|
||||
}
|
||||
// And it can still be played to the end.
|
||||
if _, _, err := ApplyMove(s, Stand); err != nil {
|
||||
t.Fatalf("the revived hand could not be stood on: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Package cards holds the deck primitives every card game on Pete shares.
|
||||
//
|
||||
// gogobee never had this: blackjack carried its own deck, UNO carried another,
|
||||
// and hold'em leaned on a third-party one. Three shuffles, three bugs to fix
|
||||
// three times. The games ported over here consolidate onto this instead.
|
||||
//
|
||||
// Two rules hold throughout:
|
||||
//
|
||||
// The RNG is threaded, never global. Every shuffle takes an explicit *rand.Rand,
|
||||
// so a hand is reproducible from its seed — which is what makes the engines
|
||||
// testable, and what lets us re-deal a disputed hand and show the player exactly
|
||||
// what the shoe did.
|
||||
//
|
||||
// A Deck is a plain value. No pointers into it, no timers hanging off it, so a
|
||||
// game in progress serializes to JSON and survives a redeploy.
|
||||
package cards
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// Suit is one of the four French suits.
|
||||
type Suit uint8
|
||||
|
||||
const (
|
||||
Spades Suit = iota
|
||||
Hearts
|
||||
Diamonds
|
||||
Clubs
|
||||
)
|
||||
|
||||
// Rank runs Ace(1) through King(13). Ace is low here; games that want it high
|
||||
// (blackjack's soft 11, hold'em's wheel) say so themselves.
|
||||
type Rank uint8
|
||||
|
||||
const (
|
||||
Ace Rank = 1
|
||||
Jack Rank = 11
|
||||
Queen Rank = 12
|
||||
King Rank = 13
|
||||
)
|
||||
|
||||
var (
|
||||
suitGlyphs = [4]string{"♠", "♥", "♦", "♣"}
|
||||
rankNames = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
|
||||
)
|
||||
|
||||
// Card is one playing card. The short JSON keys keep a serialized shoe small —
|
||||
// a six-deck blackjack state is 312 of these.
|
||||
type Card struct {
|
||||
Rank Rank `json:"r"`
|
||||
Suit Suit `json:"s"`
|
||||
}
|
||||
|
||||
// String renders the card the way a table shows it: "A♠", "10♥".
|
||||
func (c Card) String() string {
|
||||
if c.Rank < Ace || c.Rank > King || c.Suit > Clubs {
|
||||
return "??"
|
||||
}
|
||||
return rankNames[c.Rank] + suitGlyphs[c.Suit]
|
||||
}
|
||||
|
||||
// Red reports whether the card is a red suit — the one thing every renderer
|
||||
// needs and nobody should re-derive.
|
||||
func (c Card) Red() bool { return c.Suit == Hearts || c.Suit == Diamonds }
|
||||
|
||||
// Deck is an ordered pile of cards. The next card to come off is at index 0.
|
||||
type Deck []Card
|
||||
|
||||
// NewDeck builds n standard 52-card decks in fixed order. Shuffle before use:
|
||||
// an unshuffled deck is a bug at a table, but it's exactly what a test wants.
|
||||
func NewDeck(n int) Deck {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
d := make(Deck, 0, 52*n)
|
||||
for i := 0; i < n; i++ {
|
||||
for s := Spades; s <= Clubs; s++ {
|
||||
for r := Ace; r <= King; r++ {
|
||||
d = append(d, Card{Rank: r, Suit: s})
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Shuffle permutes the deck in place using the supplied RNG. Passing a seeded
|
||||
// *rand.Rand gives the same shuffle every time, which is the whole point.
|
||||
func (d Deck) Shuffle(rng *rand.Rand) {
|
||||
rng.Shuffle(len(d), func(i, j int) { d[i], d[j] = d[j], d[i] })
|
||||
}
|
||||
|
||||
// Draw takes the top card. ok is false when the deck is spent; the caller
|
||||
// decides whether that means reshuffle or fold, because the two games that hit
|
||||
// it disagree.
|
||||
func (d *Deck) Draw() (c Card, ok bool) {
|
||||
if len(*d) == 0 {
|
||||
return Card{}, false
|
||||
}
|
||||
c = (*d)[0]
|
||||
*d = (*d)[1:]
|
||||
return c, true
|
||||
}
|
||||
|
||||
// Hand renders a run of cards for display: "A♠ 10♥".
|
||||
func Hand(cs []Card) string {
|
||||
s := ""
|
||||
for i, c := range cs {
|
||||
if i > 0 {
|
||||
s += " "
|
||||
}
|
||||
s += c.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// NewRNG seeds a generator from two uint64s. Games store the seed alongside the
|
||||
// hand so a finished hand can be replayed exactly as it was dealt.
|
||||
func NewRNG(seed1, seed2 uint64) *rand.Rand {
|
||||
return rand.New(rand.NewPCG(seed1, seed2))
|
||||
}
|
||||
|
||||
func (s Suit) String() string {
|
||||
if s > Clubs {
|
||||
return "?"
|
||||
}
|
||||
return suitGlyphs[s]
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package cards
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewDeck_IsAFullShoe(t *testing.T) {
|
||||
d := NewDeck(6)
|
||||
if len(d) != 312 {
|
||||
t.Fatalf("six decks hold %d cards, want 312", len(d))
|
||||
}
|
||||
seen := map[Card]int{}
|
||||
for _, c := range d {
|
||||
seen[c]++
|
||||
}
|
||||
if len(seen) != 52 {
|
||||
t.Fatalf("%d distinct cards, want 52", len(seen))
|
||||
}
|
||||
for c, n := range seen {
|
||||
if n != 6 {
|
||||
t.Fatalf("%s appears %d times in a six-deck shoe, want 6", c, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeck_ClampsToAtLeastOne(t *testing.T) {
|
||||
if len(NewDeck(0)) != 52 {
|
||||
t.Fatal("a zero-deck shoe should still hold one deck")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_SameSeedSameOrder(t *testing.T) {
|
||||
a, b := NewDeck(1), NewDeck(1)
|
||||
a.Shuffle(NewRNG(99, 1))
|
||||
b.Shuffle(NewRNG(99, 1))
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("same seed diverged at %d: %s vs %s", i, a[i], b[i])
|
||||
}
|
||||
}
|
||||
// And a different seed must not give the same order, or the RNG isn't wired up.
|
||||
c := NewDeck(1)
|
||||
c.Shuffle(NewRNG(100, 1))
|
||||
same := true
|
||||
for i := range a {
|
||||
if a[i] != c[i] {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
t.Fatal("a different seed produced an identical shuffle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_KeepsEveryCard(t *testing.T) {
|
||||
d := NewDeck(1)
|
||||
d.Shuffle(NewRNG(4, 4))
|
||||
seen := map[Card]bool{}
|
||||
for _, c := range d {
|
||||
seen[c] = true
|
||||
}
|
||||
if len(d) != 52 || len(seen) != 52 {
|
||||
t.Fatalf("shuffle lost cards: %d cards, %d distinct", len(d), len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_TakesFromTheTopAndRunsOut(t *testing.T) {
|
||||
d := NewDeck(1)
|
||||
top := d[0]
|
||||
c, ok := d.Draw()
|
||||
if !ok || c != top {
|
||||
t.Fatalf("drew %s (ok=%v), want the top card %s", c, ok, top)
|
||||
}
|
||||
if len(d) != 51 {
|
||||
t.Fatalf("deck has %d cards after one draw, want 51", len(d))
|
||||
}
|
||||
for len(d) > 0 {
|
||||
d.Draw()
|
||||
}
|
||||
if _, ok := d.Draw(); ok {
|
||||
t.Fatal("an empty deck kept dealing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCard_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
card Card
|
||||
want string
|
||||
}{
|
||||
{Card{Ace, Spades}, "A♠"},
|
||||
{Card{10, Hearts}, "10♥"},
|
||||
{Card{King, Clubs}, "K♣"},
|
||||
{Card{Rank: 99, Suit: Spades}, "??"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := tc.card.String(); got != tc.want {
|
||||
t.Errorf("String() = %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
if !(Card{Ace, Hearts}).Red() || (Card{Ace, Spades}).Red() {
|
||||
t.Error("Red() disagrees about which suits are red")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// Package hangman is a pure hangman engine, played for chips.
|
||||
//
|
||||
// Same seam as blackjack: ApplyMove(state, move) (state, events, error), where
|
||||
// an error means the move was illegal and nothing else. No HTTP, no timers, no
|
||||
// player names. The state is a plain value, so a game survives a redeploy and
|
||||
// replays from its seed.
|
||||
//
|
||||
// The casino version differs from the one gogobee plays in Matrix in one way
|
||||
// that matters: there is money on it. That makes the gallows a payout meter as
|
||||
// well as a death clock — every wrong guess takes a tenth off what a win is
|
||||
// worth. You can still win from five wrong, you just won't win much.
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("hangman: the game is already over")
|
||||
ErrNotALetter = errors.New("hangman: that is not a letter")
|
||||
ErrAlreadyTried = errors.New("hangman: that letter has been tried")
|
||||
ErrUnknownMove = errors.New("hangman: unknown move")
|
||||
ErrBadBet = errors.New("hangman: bet must be positive")
|
||||
ErrEmptySolution = errors.New("hangman: guess something")
|
||||
ErrUnknownTier = errors.New("hangman: no such tier")
|
||||
)
|
||||
|
||||
// MaxWrong is how many wrong guesses the gallows holds: head, body, two arms,
|
||||
// two legs. It is not a tier setting — the drawing has six parts, so the game
|
||||
// has six lives, and the tiers vary what a win pays instead.
|
||||
const MaxWrong = 6
|
||||
|
||||
// Decay is what one wrong guess costs, as a fraction of the tier's base
|
||||
// multiple. Six wrong is death, so the worst a living player can be is 50% off.
|
||||
const Decay = 0.10
|
||||
|
||||
// Tier is a difficulty, chosen before the bet. Short phrases pay the most:
|
||||
// there is less of them to read, and fewer distinct letters to hit by accident.
|
||||
// A long phrase mostly reveals itself.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Min int `json:"min"` // phrase length, in characters
|
||||
Max int `json:"max"` // inclusive
|
||||
Base float64 `json:"base"` // what a win pays, before any wrong guesses
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// Tiers are the three tables. The bank has 74 short phrases, 67 medium and 64
|
||||
// long, so none of them runs thin.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "short", Name: "Short", Min: 8, Max: 20, Base: 2.6,
|
||||
Blurb: "A handful of letters and nothing to go on."},
|
||||
{Slug: "medium", Name: "Medium", Min: 21, Max: 40, Base: 2.0,
|
||||
Blurb: "Long enough to read once it starts falling open."},
|
||||
{Slug: "long", Name: "Long", Min: 41, Max: 9999, Base: 1.6,
|
||||
Blurb: "It gives itself away. It also pays the least."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier by the name the browser sent.
|
||||
func TierBySlug(slug string) (Tier, error) {
|
||||
for _, t := range Tiers {
|
||||
if t.Slug == slug {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Tier{}, ErrUnknownTier
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlaying Phase = "playing"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeSolved Outcome = "solved" // guessed the phrase outright
|
||||
OutcomeFilled Outcome = "filled" // revealed the last letter
|
||||
OutcomeHung Outcome = "hung" // six wrong
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays.
|
||||
func (o Outcome) Won() bool { return o == OutcomeSolved || o == OutcomeFilled }
|
||||
|
||||
// State is one game. The phrase is in here, which is exactly why this value
|
||||
// never leaves the server — the browser gets a Masked() view of it instead.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Phrase string `json:"phrase"`
|
||||
Runes []rune `json:"runes"` // the phrase, indexable safely
|
||||
Shown []bool `json:"shown"` // one per rune: is it face up
|
||||
Tried []rune `json:"tried"` // every letter guessed, in order, right or wrong
|
||||
Wrong []rune `json:"wrong"` // just the ones that missed
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates: a letter turning over, a limb being
|
||||
// drawn. The engine emits them rather than drawing anything itself.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "start" | "hit" | "miss" | "solve" | "settle"
|
||||
Letter string `json:"letter,omitempty"` // the letter guessed
|
||||
At []int `json:"at,omitempty"` // which rune positions it turned over
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Move is a player action: a single letter, or the whole phrase.
|
||||
type Move struct {
|
||||
Letter string `json:"letter"`
|
||||
Solve string `json:"solve"`
|
||||
}
|
||||
|
||||
// New starts a game on a phrase drawn from the tier's shelf of the bank.
|
||||
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
phrase, err := drawPhrase(t, rng)
|
||||
if err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
return start(bet, t, phrase, rakePct)
|
||||
}
|
||||
|
||||
// start builds the opening state for a known phrase. Split out from New so a
|
||||
// test can pin the phrase instead of the seed.
|
||||
func start(bet int64, t Tier, phrase string, rakePct float64) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
rs := []rune(phrase)
|
||||
s := State{
|
||||
Tier: t, Phrase: phrase, Runes: rs,
|
||||
Shown: make([]bool, len(rs)),
|
||||
RakePct: rakePct,
|
||||
Bet: bet, Phase: PhasePlaying,
|
||||
}
|
||||
// Spaces and punctuation are never guessed — they start face up, because a
|
||||
// row of blanks with the word breaks hidden is a puzzle about typography.
|
||||
for i, r := range rs {
|
||||
if !Guessable(r) {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
}
|
||||
return s, []Event{{Kind: "start"}}, nil
|
||||
}
|
||||
|
||||
// Guessable reports whether a rune is one you'd guess: a letter or a digit.
|
||||
// Everything else is scaffolding and is shown from the start.
|
||||
//
|
||||
// Exported because the renderer needs the same answer — a rune you'd guess is a
|
||||
// rune that gets a tile to guess it into. Asking the drawing side to decide that
|
||||
// for itself is how you get a board with no tile for a letter the engine is
|
||||
// waiting on, and a phrase that cannot be finished.
|
||||
func Guessable(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. A legal move in, the new state and what happened out.
|
||||
// An error means the move was illegal and the caller's state is untouched.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
s = s.clone()
|
||||
|
||||
switch {
|
||||
case m.Solve != "":
|
||||
return applySolve(s, m.Solve)
|
||||
case m.Letter != "":
|
||||
return applyLetter(s, m.Letter)
|
||||
default:
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
}
|
||||
|
||||
// applyLetter guesses one letter.
|
||||
func applyLetter(s State, letter string) (State, []Event, error) {
|
||||
rs := []rune(strings.ToLower(strings.TrimSpace(letter)))
|
||||
if len(rs) != 1 || !Guessable(rs[0]) {
|
||||
return s, nil, ErrNotALetter
|
||||
}
|
||||
g := rs[0]
|
||||
if containsRune(s.Tried, g) {
|
||||
// Not a miss — a no-op. Charging a life for a letter the board already
|
||||
// shows you tried is punishing a mis-click, not a bad guess.
|
||||
return s, nil, ErrAlreadyTried
|
||||
}
|
||||
s.Tried = append(s.Tried, g)
|
||||
|
||||
var at []int
|
||||
for i, r := range s.Runes {
|
||||
if !s.Shown[i] && foldEq(r, g) {
|
||||
s.Shown[i] = true
|
||||
at = append(at, i)
|
||||
}
|
||||
}
|
||||
|
||||
evs := []Event{}
|
||||
if len(at) > 0 {
|
||||
evs = append(evs, Event{Kind: "hit", Letter: string(g), At: at})
|
||||
if s.filled() {
|
||||
s.settle(OutcomeFilled, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
s.Wrong = append(s.Wrong, g)
|
||||
evs = append(evs, Event{Kind: "miss", Letter: string(g)})
|
||||
if len(s.Wrong) >= MaxWrong {
|
||||
s.settle(OutcomeHung, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// applySolve guesses the whole phrase. Right, and it's over at the multiple you
|
||||
// still hold. Wrong, and it costs a life like any other bad guess — otherwise
|
||||
// solving would be a free roll you could spam until it landed.
|
||||
func applySolve(s State, attempt string) (State, []Event, error) {
|
||||
if strings.TrimSpace(attempt) == "" {
|
||||
return s, nil, ErrEmptySolution
|
||||
}
|
||||
evs := []Event{{Kind: "solve", Text: attempt}}
|
||||
|
||||
if normalize(attempt) == normalize(s.Phrase) {
|
||||
for i := range s.Shown {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
s.settle(OutcomeSolved, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// A wrong solve is a miss with no letter attached to it.
|
||||
s.Wrong = append(s.Wrong, '·')
|
||||
evs = append(evs, Event{Kind: "miss", Text: attempt})
|
||||
if len(s.Wrong) >= MaxWrong {
|
||||
s.settle(OutcomeHung, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Multiple is what a win is worth right now: the tier's base, less a tenth of
|
||||
// it for every wrong guess so far. Floored at 1, so a win never hands back less
|
||||
// than the stake — a player who fought through five wrong guesses and got there
|
||||
// has not earned a loss.
|
||||
func (s State) Multiple() float64 {
|
||||
m := s.Tier.Base * (1 - Decay*float64(len(s.Wrong)))
|
||||
if m < 1 {
|
||||
return 1
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Pays is what a win *right now* would actually put back on the player's stack:
|
||||
// the stake, plus the winnings, less the house's cut of the winnings.
|
||||
//
|
||||
// It exists because the felt shows this number while the game is still running,
|
||||
// and settle() is the only other thing that computes it. If the two ever
|
||||
// disagreed the table would be quoting a payout it doesn't honour — so settle
|
||||
// calls this rather than doing the sum a second time.
|
||||
func (s State) Pays() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
|
||||
if total < s.Bet {
|
||||
total = s.Bet // a win never hands back less than the stake
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
rake = 0
|
||||
}
|
||||
profit -= rake
|
||||
}
|
||||
return s.Bet + profit
|
||||
}
|
||||
|
||||
// Rake taken on a win right now — the other half of what Pays works out.
|
||||
func (s State) rakeNow() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
|
||||
if total < s.Bet {
|
||||
return 0
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit <= 0 {
|
||||
return 0
|
||||
}
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// Lives is how many wrong guesses are left.
|
||||
func (s State) Lives() int { return MaxWrong - len(s.Wrong) }
|
||||
|
||||
// filled reports whether every guessable rune is face up.
|
||||
func (s State) filled() bool {
|
||||
for _, up := range s.Shown {
|
||||
if !up {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// settle decides the payout. Same rule as blackjack: the rake comes out of
|
||||
// winnings, never out of the stake. A loss is never charged a fee.
|
||||
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||
s.Outcome = o
|
||||
s.Phase = PhaseDone
|
||||
|
||||
if o.Won() {
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
} else {
|
||||
s.Payout = 0
|
||||
}
|
||||
|
||||
// The phrase goes face up when it's over, win or lose. Losing without ever
|
||||
// being told the answer is the one thing hangman must never do.
|
||||
for i := range s.Shown {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "settle", Text: string(o)})
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// Masked is the phrase as the player may see it: revealed runes as themselves,
|
||||
// hidden ones as an underscore. This — not Phrase — is what crosses the wire.
|
||||
func (s State) Masked() string {
|
||||
var b strings.Builder
|
||||
for i, r := range s.Runes {
|
||||
if s.Shown[i] {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// clone deep-copies the slices, so a derived state shares no backing array with
|
||||
// the one it came from and a state can be replayed freely.
|
||||
func (s State) clone() State {
|
||||
s.Runes = append([]rune(nil), s.Runes...)
|
||||
s.Shown = append([]bool(nil), s.Shown...)
|
||||
s.Tried = append([]rune(nil), s.Tried...)
|
||||
s.Wrong = append([]rune(nil), s.Wrong...)
|
||||
return s
|
||||
}
|
||||
|
||||
// normalize flattens a phrase for comparison: case, spacing and punctuation all
|
||||
// stop mattering. "How are you gentlemen!!" is solved by "how are you gentlemen".
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if Guessable(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// foldEq compares two runes the way a guess should: case-insensitively.
|
||||
func foldEq(a, b rune) bool {
|
||||
return unicode.ToLower(a) == unicode.ToLower(b)
|
||||
}
|
||||
|
||||
func containsRune(rs []rune, r rune) bool {
|
||||
for _, x := range rs {
|
||||
if foldEq(x, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tierShort is the tier most tests play on: base 2.6.
|
||||
func tierShort(t *testing.T) Tier {
|
||||
t.Helper()
|
||||
tr, err := TierBySlug("short")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// play runs a sequence of single-letter guesses against a pinned phrase.
|
||||
func play(t *testing.T, phrase string, bet int64, rake float64, guesses ...string) State {
|
||||
t.Helper()
|
||||
s, _, err := start(bet, tierShort(t), phrase, rake)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, g := range guesses {
|
||||
next, _, err := ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatalf("guess %q: %v", g, err)
|
||||
}
|
||||
s = next
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestStartShowsScaffoldingOnly(t *testing.T) {
|
||||
s, evs, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := s.Masked(), "______ ____"; got != want {
|
||||
t.Errorf("masked = %q, want %q — the space is scaffolding and shows, the letters don't", got, want)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "start" {
|
||||
t.Errorf("events = %+v, want one start", evs)
|
||||
}
|
||||
if s.Lives() != MaxWrong {
|
||||
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPunctuationAndDigitsAreScaffoldingOrNot(t *testing.T) {
|
||||
// Punctuation shows from the start; a digit is guessable like a letter.
|
||||
s, _, err := start(100, tierShort(t), "Level 9!", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := s.Masked(), "_____ _!"; got != want {
|
||||
t.Fatalf("masked = %q, want %q", got, want)
|
||||
}
|
||||
next, _, err := ApplyMove(s, Move{Letter: "9"})
|
||||
if err != nil {
|
||||
t.Fatalf("guessing a digit: %v", err)
|
||||
}
|
||||
if got, want := next.Masked(), "_____ 9!"; got != want {
|
||||
t.Errorf("masked = %q, want %q — a digit is a guess", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHitRevealsEveryOccurrence(t *testing.T) {
|
||||
// "Blue Shell" has an l at 1, 8 and 9. One guess turns over all three.
|
||||
s := play(t, "Blue Shell", 100, 0.05, "l")
|
||||
if got, want := s.Masked(), "_l__ ___ll"; got != want {
|
||||
t.Errorf("masked = %q, want %q — every l turns over, not just the first", got, want)
|
||||
}
|
||||
if s.Lives() != MaxWrong {
|
||||
t.Errorf("a hit cost a life: lives = %d", s.Lives())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissCostsALifeAndTheMultiple(t *testing.T) {
|
||||
tr := tierShort(t)
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z")
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong-1)
|
||||
}
|
||||
want := tr.Base * 0.9
|
||||
if got := s.Multiple(); got != want {
|
||||
t.Errorf("multiple = %v, want %v — one wrong guess is a tenth of the base", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedLetterIsRefusedNotPunished(t *testing.T) {
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z")
|
||||
_, _, err := ApplyMove(s, Move{Letter: "z"})
|
||||
if err != ErrAlreadyTried {
|
||||
t.Fatalf("err = %v, want ErrAlreadyTried", err)
|
||||
}
|
||||
// And the state the caller holds is untouched: a mis-click is not a life.
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("the refused move moved the state: lives = %d", s.Lives())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixWrongHangsYouAndPaysNothing(t *testing.T) {
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z", "x", "q", "y", "w", "k")
|
||||
if s.Phase != PhaseDone || s.Outcome != OutcomeHung {
|
||||
t.Fatalf("phase/outcome = %s/%s, want done/hung", s.Phase, s.Outcome)
|
||||
}
|
||||
if s.Payout != 0 {
|
||||
t.Errorf("payout = %d, want 0", s.Payout)
|
||||
}
|
||||
if s.Net() != -100 {
|
||||
t.Errorf("net = %d, want -100", s.Net())
|
||||
}
|
||||
if strings.Contains(s.Masked(), "_") {
|
||||
t.Error("a lost game must still show the phrase — being hung without being told the answer is the one thing hangman can't do")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillingTheLastLetterWinsCleanAtFullMultiple(t *testing.T) {
|
||||
// "Blue Shell" — every distinct letter, no misses.
|
||||
s := play(t, "Blue Shell", 100, 0, "b", "l", "u", "e", "s", "h")
|
||||
if s.Outcome != OutcomeFilled {
|
||||
t.Fatalf("outcome = %s, want filled", s.Outcome)
|
||||
}
|
||||
// Base 2.6, no wrong guesses, no rake: 100 -> 260.
|
||||
if s.Payout != 260 {
|
||||
t.Errorf("payout = %d, want 260", s.Payout)
|
||||
}
|
||||
if s.Net() != 160 {
|
||||
t.Errorf("net = %d, want 160", s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveOutrightWinsAtTheMultipleYouStillHold(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two wrong first: 2.6 -> 2.08.
|
||||
for _, g := range []string{"z", "x"} {
|
||||
s, _, err = ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "insert coin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Outcome != OutcomeSolved {
|
||||
t.Fatalf("outcome = %s, want solved", s.Outcome)
|
||||
}
|
||||
if s.Payout != 208 {
|
||||
t.Errorf("payout = %d, want 208 — solving pays the multiple you still hold, not the base", s.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveIgnoresCaseAndPunctuation(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "How are you gentlemen!!", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "HOW ARE YOU GENTLEMEN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Outcome != OutcomeSolved {
|
||||
t.Errorf("outcome = %s — a solve shouldn't turn on shouting or the exclamation marks", s.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongSolveCostsALife(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "insert quarter"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("lives = %d, want %d — a free solve is a solve you spam until it lands", s.Lives(), MaxWrong-1)
|
||||
}
|
||||
if s.Phase != PhasePlaying {
|
||||
t.Errorf("phase = %s, want playing", s.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWinNeverReturnsLessThanTheStake(t *testing.T) {
|
||||
// Long pays 1.6, and five wrong guesses would take it to 0.8 — under water.
|
||||
long, err := TierBySlug("long")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err := start(100, long, "the quick brown fox jumps over the lazy dog and keeps going", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Five clean misses. Every letter in the pangram is in the pangram, so the
|
||||
// only things guaranteed to miss it are digits.
|
||||
for _, g := range []string{"1", "2", "3", "4", "5"} {
|
||||
s, _, err = ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := s.Multiple(); got != 1 {
|
||||
t.Fatalf("multiple = %v, want 1 — the floor", got)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "the quick brown fox jumps over the lazy dog and keeps going"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Payout != 100 {
|
||||
t.Errorf("payout = %d, want 100 — a win hands back the stake at worst, never less", s.Payout)
|
||||
}
|
||||
if s.Rake != 0 {
|
||||
t.Errorf("rake = %d, want 0 — there was no profit to take a cut of", s.Rake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRakeComesOutOfWinningsNeverTheStake(t *testing.T) {
|
||||
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
|
||||
// Total 260, profit 160, rake 5% = 8, so 252 comes back.
|
||||
if s.Rake != 8 {
|
||||
t.Errorf("rake = %d, want 8 (5%% of the 160 profit)", s.Rake)
|
||||
}
|
||||
if s.Payout != 252 {
|
||||
t.Errorf("payout = %d, want 252", s.Payout)
|
||||
}
|
||||
if s.Payout < s.Bet {
|
||||
t.Error("the rake ate into the stake")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheFeltQuotesIsWhatTheHousePays(t *testing.T) {
|
||||
// The table shows Pays() while the game is still running. If that number and
|
||||
// the one settle() lands on ever came apart, the felt would be advertising a
|
||||
// payout the house doesn't honour. Walk a game and check they agree at every
|
||||
// step — including after a miss, which is where they'd drift.
|
||||
for _, wrong := range []int{0, 1, 2, 3} {
|
||||
s, _, err := start(200, tierShort(t), "Blue Shell", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
misses := []string{"z", "x", "q", "y"}
|
||||
for i := 0; i < wrong; i++ {
|
||||
s, _, err = ApplyMove(s, Move{Letter: misses[i]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
quoted := s.Pays() // what the felt is telling the player right now
|
||||
|
||||
s, _, err = ApplyMove(s, Move{Solve: "blue shell"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Payout != quoted {
|
||||
t.Errorf("%d wrong: felt quoted %d, house paid %d", wrong, quoted, s.Payout)
|
||||
}
|
||||
if s.Payout != s.Bet+s.Net() {
|
||||
t.Errorf("%d wrong: payout %d doesn't square with net %d on a %d bet", wrong, s.Payout, s.Net(), s.Bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveOnAFinishedGameIsRefused(t *testing.T) {
|
||||
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
|
||||
if _, _, err := ApplyMove(s, Move{Letter: "z"}); err != ErrGameOver {
|
||||
t.Errorf("err = %v, want ErrGameOver", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageGuessesAreRefused(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []Move{{Letter: "ab"}, {Letter: "!"}, {Letter: " "}, {}} {
|
||||
if _, _, err := ApplyMove(s, m); err == nil {
|
||||
t.Errorf("move %+v was accepted", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMoveDoesNotTouchTheCallersState(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := s.Masked()
|
||||
if _, _, err := ApplyMove(s, Move{Letter: "i"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Masked() != before {
|
||||
t.Errorf("applying a move mutated the state it was given: %q -> %q", before, s.Masked())
|
||||
}
|
||||
if len(s.Tried) != 0 {
|
||||
t.Errorf("tried = %v, want empty — the caller's state was written through", s.Tried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateSurvivesASerializationRoundTrip(t *testing.T) {
|
||||
// A redeploy mid-game is a JSON round-trip. It has to come back playable.
|
||||
s := play(t, "Insert Coin", 100, 0.05, "i", "z")
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.Masked() != s.Masked() || back.Lives() != s.Lives() || back.Multiple() != s.Multiple() {
|
||||
t.Fatalf("round trip changed the game: %+v", back)
|
||||
}
|
||||
next, _, err := ApplyMove(back, Move{Solve: "insert coin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next.Outcome != OutcomeSolved {
|
||||
t.Errorf("a game restored from JSON couldn't be finished: %s", next.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIsReproducibleFromItsSeed(t *testing.T) {
|
||||
// The seed is in the audit log so a disputed game can be replayed. That is
|
||||
// only true if the phrase comes back the same.
|
||||
one, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
two, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if one.Phrase != two.Phrase {
|
||||
t.Errorf("same seed dealt different phrases: %q vs %q", one.Phrase, two.Phrase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDrawsFromTheRightShelf(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
for _, tr := range Tiers {
|
||||
for i := 0; i < 50; i++ {
|
||||
s, _, err := New(100, tr, 0.05, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tr.Slug, err)
|
||||
}
|
||||
if n := len([]rune(s.Phrase)); n < tr.Min || n > tr.Max {
|
||||
t.Fatalf("%s drew %q (%d chars), outside %d-%d", tr.Slug, s.Phrase, n, tr.Min, tr.Max)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryTierHasAShelfWorthPlaying(t *testing.T) {
|
||||
// If someone edits phrases.txt and empties a tier, the game 500s at the
|
||||
// table rather than here. Catch it here.
|
||||
for _, tr := range Tiers {
|
||||
if n := Shelf(tr.Slug); n < 20 {
|
||||
t.Errorf("tier %s has %d phrases — too few to not repeat", tr.Slug, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The bank. gogobee kept its phrases in a file it read at boot out of a path in
|
||||
// an env var, which meant the game was one missing file away from not existing.
|
||||
// Embedding it means the casino cannot start without its phrases, which is the
|
||||
// correct relationship between a game and the thing it is about.
|
||||
//
|
||||
//go:embed phrases.txt
|
||||
var phrasesTxt string
|
||||
|
||||
// ErrNoPhrases means the bank has nothing at this length. It can only happen if
|
||||
// someone edits phrases.txt down past a tier, and it is a programming error
|
||||
// rather than anything a player did — but it's an error, not a panic, because a
|
||||
// casino that won't boot is worse than one game being shut.
|
||||
var ErrNoPhrases = errors.New("hangman: no phrases in that tier")
|
||||
|
||||
var (
|
||||
shelvesOnce sync.Once
|
||||
shelves map[string][]string // tier slug -> the phrases that fit it
|
||||
)
|
||||
|
||||
// load sorts the bank onto one shelf per tier, once. Comments and blank lines
|
||||
// are dropped, and so is anything too short to be a game — the tiers' own Min
|
||||
// is the floor.
|
||||
func load() {
|
||||
shelves = make(map[string][]string, len(Tiers))
|
||||
sc := bufio.NewScanner(strings.NewReader(phrasesTxt))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
n := len([]rune(line))
|
||||
for _, t := range Tiers {
|
||||
if n >= t.Min && n <= t.Max {
|
||||
shelves[t.Slug] = append(shelves[t.Slug], line)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shelf is how many phrases a tier has. Exists so a test can assert the bank
|
||||
// hasn't been edited out from under a tier.
|
||||
func Shelf(slug string) int {
|
||||
shelvesOnce.Do(load)
|
||||
return len(shelves[slug])
|
||||
}
|
||||
|
||||
// drawPhrase picks one phrase from a tier's shelf. The rng is threaded, never
|
||||
// the package global, so a game replays exactly from the seed in its audit row.
|
||||
func drawPhrase(t Tier, rng *rand.Rand) (string, error) {
|
||||
shelvesOnce.Do(load)
|
||||
shelf := shelves[t.Slug]
|
||||
if len(shelf) == 0 {
|
||||
return "", ErrNoPhrases
|
||||
}
|
||||
return shelf[rng.IntN(len(shelf))], nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
# GogoBee Hangman Seed Phrases -- Video Game Edition
|
||||
#
|
||||
# Tiers are assigned automatically by character count at load time.
|
||||
# Section headers below are for human readability only -- the bot ignores them.
|
||||
#
|
||||
# Easy: 8-20 characters
|
||||
# Medium: 21-40 characters
|
||||
# Hard: 41-80 characters
|
||||
#
|
||||
# Add community phrases via: !hangman submit [phrase]
|
||||
# All submissions require LLM approval before entering the pool.
|
||||
# This file can be edited directly. Bot reloads on restart.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EASY (8-20 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Finish Him!
|
||||
Game Over
|
||||
Insert Coin
|
||||
Hadouken!
|
||||
Fatality!
|
||||
Big Boss
|
||||
Konami Code
|
||||
Warp Zone
|
||||
Blue Shell
|
||||
God Mode
|
||||
BFG 9000
|
||||
Cacodemon
|
||||
Quad Damage
|
||||
Samus Aran
|
||||
Morph Ball
|
||||
Mother Brain
|
||||
Dracula!
|
||||
Simon Belmont
|
||||
Ecclesia
|
||||
Outer Heaven
|
||||
Spread Gun
|
||||
Power Pellet
|
||||
Checkpoint
|
||||
Rocket Jump
|
||||
Mushroom Kingdom
|
||||
Princess Peach
|
||||
Bowser's Castle
|
||||
Fire Flower
|
||||
Varia Suit
|
||||
Space Jump
|
||||
Vampire Killer
|
||||
Holy Water
|
||||
Trevor Belmont
|
||||
Soma Cruz
|
||||
Julius Belmont
|
||||
Waluigi!
|
||||
Richter!
|
||||
Phantoon
|
||||
Speed Run
|
||||
High Score
|
||||
Continue?
|
||||
Press Start
|
||||
Ryu Hayabusa
|
||||
Plasma Gun
|
||||
What is a man?
|
||||
Serious Sam
|
||||
Shoryuken!
|
||||
Duck Hunt
|
||||
The cake is a lie
|
||||
War never changes
|
||||
Would you kindly?
|
||||
Do a barrel roll!
|
||||
Praise the Sun!
|
||||
A winner is you
|
||||
You're pretty good
|
||||
La-Li-Lu-Le-Lo
|
||||
I am error
|
||||
Leeroy Jenkins!
|
||||
For the Horde!
|
||||
For the Alliance!
|
||||
Frostmourne hungers
|
||||
Falcon Punch!
|
||||
Rip and tear!
|
||||
Vic Viper
|
||||
Salamander!
|
||||
Parodius Da!
|
||||
We'll bang, okay?
|
||||
What you say!!
|
||||
You spoony bard!
|
||||
One-Winged Angel
|
||||
Morning Star
|
||||
Glyph Union
|
||||
TwinBee, scramble!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEDIUM (21-40 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Stay a while and listen
|
||||
It's dangerous to go alone!
|
||||
Kept you waiting, huh?
|
||||
A man chooses, a slave obeys
|
||||
Metal Gear?! Metal Gear!!
|
||||
We're not tools of the government
|
||||
The winds of destruction
|
||||
Who are the Patriots?
|
||||
This is good, isn't it?
|
||||
You have died of dysentery
|
||||
The Triforce of Courage
|
||||
Ganon has broken the seal!
|
||||
May the wind guide you home
|
||||
Dodongo dislikes smoke
|
||||
The right man in the wrong place
|
||||
Nothing is true, everything is permitted
|
||||
I used to be an adventurer like you
|
||||
You can't hide from the Grim Reaper
|
||||
All your base are belong to us!
|
||||
Somebody set up us the bomb!
|
||||
For great justice, take off every Zig!
|
||||
How are you gentlemen!!
|
||||
Kain has betrayed us!
|
||||
Garland will knock you all down!
|
||||
You are not prepared!
|
||||
I am Uther the Lightbringer!
|
||||
Order of Ecclesia calls
|
||||
The Dark Lord rises again!
|
||||
Shanoa, bearer of glyphs
|
||||
In this world, it's kill or be killed
|
||||
Despite everything, it's still you
|
||||
The Underground is your home now
|
||||
Papyrus demands a battle!
|
||||
I'm going to make spaghetti!
|
||||
Toriel will protect you
|
||||
Estus Flask replenished
|
||||
The age of fire fades
|
||||
Prepare to die, undead one
|
||||
Can't let you do that, Star Fox!
|
||||
Andross' empire spans the Lylat system!
|
||||
Captain Falcon, show me your moves!
|
||||
OBJECTION! That testimony is a lie!
|
||||
Hold it! I have new evidence!
|
||||
Phoenix Wright, attorney at law!
|
||||
Does this unit have a soul?
|
||||
Shepard, the Reapers are coming!
|
||||
Tali'Zorah vas Normandy!
|
||||
Gruntilda shall not be defeated!
|
||||
K. Rool has stolen the banana hoard!
|
||||
Kirby, hero of Dream Land!
|
||||
Meta Knight awaits your challenge!
|
||||
Congraturation! This story is happy end.
|
||||
Cecil has become a Paladin
|
||||
Cloud Strife, SOLDIER First Class
|
||||
Time compression is inevitable
|
||||
You require more vespene gas
|
||||
Nuclear launch detected
|
||||
You must construct additional pylons
|
||||
I'm Commander Shepard!
|
||||
War... War has changed.
|
||||
Rip and tear until it is done!
|
||||
Dawn of Sorrow awaits
|
||||
Do you feel like a hero yet?
|
||||
You're a monster. You know that, Walker?
|
||||
Halo... it's not a natural formation.
|
||||
Whip it good, Belmont!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HARD (41-80 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
What is a man? A miserable pile of secrets!
|
||||
Die monster! You don't belong in this world!
|
||||
My name is Dracula, and I bid you welcome.
|
||||
You have no chance to survive make your time!
|
||||
You've met with a terrible fate, haven't you?
|
||||
Fear the old blood... and welcome, good hunter.
|
||||
Welcome to the Liandri Grand Tournament!
|
||||
I am the very model of a scientist Salarian!
|
||||
Metroid Prime has escaped into the impact crater!
|
||||
I want to be the very best, like no one ever was
|
||||
The world needs only one Big Boss... and one Snake.
|
||||
Snake, do you think love can bloom on a battlefield?
|
||||
Thank you Mario, but our princess is in another castle!
|
||||
You must gather your party before venturing forth
|
||||
Snake, we're not tools of the government, or anyone else
|
||||
Did I ever tell you what the definition of insanity is?
|
||||
They're everywhere! The demons... they won't stop coming!
|
||||
Dracula! Your time has come! The Vampire Killer strikes!
|
||||
Link... I'm Navi, your fairy companion! Listen!
|
||||
Hero of Time, your destiny awaits in the Sacred Realm
|
||||
Master Chief, finish the fight. Earth is counting on you.
|
||||
Outer Heaven... a place where warriors can find purpose
|
||||
We passed the point of no return a long time ago, Snake
|
||||
Liquid! I was the one who was meant to be the successor!
|
||||
Revolver Ocelot is a triple agent working for the Patriots
|
||||
Phazon corruption detected! Seek immediate medical attention!
|
||||
Dark Samus has absorbed the Phazon and grown more powerful!
|
||||
I'm the Doom Slayer, and I'm here to kill every last one of you!
|
||||
Praise the Chosen Undead, for they shall link the fire!
|
||||
Ganon is the evil king who stole the Triforce of Power!
|
||||
The legendary soldier who defied his genes... Big Boss
|
||||
Killing spree! Monster kill! Godlike! Unstoppable!
|
||||
Humanity restored! The bonfire blazes with newfound strength!
|
||||
You are the last line of defense against an infinite demonic army
|
||||
Liquid Snake... your dominant genes... give you the edge in battle!
|
||||
All we did was give meaning to the nuclear age by using nukes!
|
||||
I'm a soldier who's been betrayed, abandoned... I fight alone now
|
||||
Samus Aran, the last of the Chozo warriors, descends into the unknown
|
||||
You are the Chosen Undead, fated to succeed where so many have failed
|
||||
The price of living in the past is a slow death in the present
|
||||
A sword wields no strength unless the hands that hold it have courage
|
||||
The flow of time is always cruel, its speed seems different for each person
|
||||
Hey! Listen! There's something important you need to know!
|
||||
We are born of the blood, made men by the blood, undone by the blood
|
||||
War has changed. It's no longer about nations, ideologies, or ethnicity
|
||||
Mental has sent his armies, and I am all that stands between them and Earth!
|
||||
I'm no hero. Never was. Never will be. I'm just an old killer.
|
||||
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||
I used to be an adventurer like you, then I took an arrow in the knee
|
||||
I'm not the only one who's responsible. The humans are just as guilty!
|
||||
The Skaarj have invaded, and you must fight your way to freedom
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EXTREME (81+ characters) -- Full quotes. No mercy.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
For you, the day Bison graced your village was the most important day of your life. But for me... it was Tuesday.
|
||||
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||
Die monster! You don't belong in this world! It was not by my hand that I am once again given flesh!
|
||||
You can't keep a good man down, and that goes double for a soldier who's been fighting his whole life.
|
||||
They say the definition of insanity is doing the same thing over and over and expecting different results... did I ever tell you that?
|
||||
I've been waiting for this... a professional killer. I'm so excited I may be sick!
|
||||
Kept you waiting, huh? Don't worry though. I'll take you somewhere warm. Back to the battlefield.
|
||||
It's easy to forget what a sin is in the middle of a battlefield. Especially when you're killing to stay alive.
|
||||
We are not tools of the government or anyone else. Fighting was the only thing, the only thing I was good at. But at least I always fought for what I believed in.
|
||||
In the 21st century, the battlefield will once again be a symbol of the glory of nations. I am a weapon. A human weapon.
|
||||
A man's dreams can be his greatest asset... or his most dangerous enemy. The question is: what are you willing to sacrifice to see them through?
|
||||
I need scissors! 61!
|
||||
Outer Heaven, a place where soldiers need not justify their actions -- where warriors can be free of political manipulation.
|
||||
Nothing happened to me. I happened. I'm the one who knocks, the one who causes all the trouble... that is my purpose.
|
||||
Sun Tzu said that. I think he meant it as a metaphor, but he also said never leave home without a healthy supply of rations, so what does he know?
|
||||
@@ -0,0 +1,279 @@
|
||||
package holdem
|
||||
|
||||
import "sort"
|
||||
|
||||
// The betting rules. These are the fiddly ones — min-raise, short all-ins that
|
||||
// don't reopen the action, side pots — and they came over from gogobee, where
|
||||
// they had no tests at all. They have some now.
|
||||
|
||||
// blinds posts the small and the big. Heads-up is the exception every poker
|
||||
// implementation gets wrong once: with two players the button *is* the small
|
||||
// blind and acts first before the flop, and last after it.
|
||||
func (s *State) blinds(evs *[]Event) (bb int) {
|
||||
var sb int
|
||||
if s.dealt() == 2 {
|
||||
sb, bb = s.Button, s.nextIn(s.Button)
|
||||
} else {
|
||||
sb = s.nextIn(s.Button)
|
||||
bb = s.nextIn(sb)
|
||||
}
|
||||
|
||||
s.post(sb, s.Tier.SB, "small", evs)
|
||||
s.post(bb, s.Tier.BB, "big", evs)
|
||||
|
||||
s.Bet = s.Tier.BB
|
||||
s.MinRaise = s.Tier.BB
|
||||
s.Aggressor = bb // the big blind has the option to raise their own blind
|
||||
return bb
|
||||
}
|
||||
|
||||
// post puts a blind up. A player too short to cover it is all-in for what they
|
||||
// have, which is legal and is why the amount is clamped rather than refused.
|
||||
func (s *State) post(seat int, amount int64, which string, evs *[]Event) {
|
||||
p := &s.Seats[seat]
|
||||
if amount > p.Stack {
|
||||
amount = p.Stack
|
||||
}
|
||||
p.Stack -= amount
|
||||
p.Bet = amount
|
||||
p.Total = amount
|
||||
if p.Stack == 0 {
|
||||
p.State = AllIn
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "blind", Seat: seat, Amount: amount, Text: which})
|
||||
}
|
||||
|
||||
// firstPreFlop is under the gun: the seat after the big blind, or the button
|
||||
// itself when the table is heads-up.
|
||||
//
|
||||
// The button only gets it if the button can still act. A short stack can be
|
||||
// all-in on its own blind — post a small blind of 1 with 1 chip left and you are
|
||||
// in the hand with no chips and no say — and handing the action to a seat that
|
||||
// cannot act wedges the table.
|
||||
func (s *State) firstPreFlop(bb int) int {
|
||||
if s.dealt() != 2 {
|
||||
return s.nextCanAct(bb)
|
||||
}
|
||||
if s.Seats[s.Button].State == Active {
|
||||
return s.Button
|
||||
}
|
||||
return s.nextCanAct(s.Button)
|
||||
}
|
||||
|
||||
// firstPostFlop is the first seat left of the button, on every street after the
|
||||
// flop. The button acts last from here on, which is the whole point of it.
|
||||
func (s *State) firstPostFlop() int { return s.nextCanAct(s.Button) }
|
||||
|
||||
// ---- the five things a seat can do ----------------------------------------
|
||||
|
||||
func (s *State) fold(seat int, evs *[]Event) {
|
||||
p := &s.Seats[seat]
|
||||
p.State = Folded
|
||||
p.Acted = true
|
||||
s.History += "f"
|
||||
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "fold"})
|
||||
}
|
||||
|
||||
func (s *State) check(seat int, evs *[]Event) error {
|
||||
p := &s.Seats[seat]
|
||||
if p.Bet < s.Bet {
|
||||
return ErrCantCheck
|
||||
}
|
||||
p.Acted = true
|
||||
s.History += "c"
|
||||
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "check"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) call(seat int, evs *[]Event) error {
|
||||
p := &s.Seats[seat]
|
||||
owed := s.Bet - p.Bet
|
||||
if owed <= 0 {
|
||||
return ErrNothingToCall
|
||||
}
|
||||
if owed > p.Stack {
|
||||
owed = p.Stack // a call for less than the bet is a call all-in
|
||||
}
|
||||
|
||||
p.Stack -= owed
|
||||
p.Bet += owed
|
||||
p.Total += owed
|
||||
p.Acted = true
|
||||
|
||||
text := "call"
|
||||
if p.Stack == 0 {
|
||||
p.State = AllIn
|
||||
text = "allin"
|
||||
s.History += "a"
|
||||
} else {
|
||||
s.History += "c"
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: owed, Total: p.Bet})
|
||||
return nil
|
||||
}
|
||||
|
||||
// raise raises *to* a total, not *by* an amount. Every poker interface in the
|
||||
// world means the total, and a browser that means the other thing bets wrong.
|
||||
func (s *State) raise(seat int, to int64, evs *[]Event) error {
|
||||
p := &s.Seats[seat]
|
||||
most := p.Bet + p.Stack
|
||||
|
||||
if to > most {
|
||||
return ErrTooBig
|
||||
}
|
||||
if to < s.Bet+s.MinRaise && to < most {
|
||||
return ErrTooSmall // only a shove may be smaller than a legal raise
|
||||
}
|
||||
|
||||
added := to - p.Bet
|
||||
over := to - s.Bet
|
||||
|
||||
p.Stack -= added
|
||||
p.Bet = to
|
||||
p.Total += added
|
||||
p.Acted = true
|
||||
|
||||
if over > 0 {
|
||||
s.MinRaise = over
|
||||
}
|
||||
s.Bet = to
|
||||
s.Aggressor = seat
|
||||
|
||||
text := "raise"
|
||||
if p.Stack == 0 {
|
||||
p.State = AllIn
|
||||
text = "allin"
|
||||
s.History += "a"
|
||||
} else {
|
||||
// The policy was trained against a tree with two raise sizes in it, so the
|
||||
// history it reads has to say which one this was: R for a pot-sized raise
|
||||
// or bigger, r for anything smaller.
|
||||
if pot := s.inPlay(); pot > 0 && float64(over) >= float64(pot)*0.75 {
|
||||
s.History += "R"
|
||||
} else {
|
||||
s.History += "r"
|
||||
}
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: added, Total: to})
|
||||
return nil
|
||||
}
|
||||
|
||||
// allin pushes the lot.
|
||||
//
|
||||
// A short all-in does not reopen the betting. If a player shoves for less than
|
||||
// a full raise over the current bet, players who have already acted may call it
|
||||
// but may not raise again — otherwise a tiny stack could be used to reopen the
|
||||
// action for a partner, which is the oldest collusion trick there is.
|
||||
func (s *State) allin(seat int, evs *[]Event) error {
|
||||
p := &s.Seats[seat]
|
||||
if p.Stack <= 0 {
|
||||
return ErrNoChips
|
||||
}
|
||||
added := p.Stack
|
||||
to := p.Bet + added
|
||||
|
||||
p.Stack = 0
|
||||
p.Bet = to
|
||||
p.Total += added
|
||||
p.State = AllIn
|
||||
p.Acted = true
|
||||
|
||||
if to > s.Bet {
|
||||
if over := to - s.Bet; over >= s.MinRaise {
|
||||
s.MinRaise = over
|
||||
s.Aggressor = seat
|
||||
}
|
||||
s.Bet = to
|
||||
}
|
||||
|
||||
s.History += "a"
|
||||
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "allin", Amount: added, Total: to})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- when is a street over ------------------------------------------------
|
||||
|
||||
// streetDone reports whether the betting round is finished, given the seat the
|
||||
// action would pass to next.
|
||||
//
|
||||
// The "has acted" check is the load-bearing half. The big blind has money in
|
||||
// front of them without having chosen to put it there, so a round where
|
||||
// everybody merely limps in has all bets matched while the blind has never had
|
||||
// a say. Without this, they never get their option.
|
||||
func (s *State) streetDone(next int) bool {
|
||||
if s.canActCount() == 0 {
|
||||
return true
|
||||
}
|
||||
for i := range s.Seats {
|
||||
p := &s.Seats[i]
|
||||
if p.State != Active {
|
||||
continue
|
||||
}
|
||||
if p.Bet != s.Bet || !p.Acted {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// The last aggressor being all-in means the action can't get back to them:
|
||||
// everyone left has matched the bet above, so there is nothing more to do.
|
||||
if s.Seats[s.Aggressor].State == AllIn {
|
||||
return true
|
||||
}
|
||||
return next == s.Aggressor
|
||||
}
|
||||
|
||||
// ---- side pots -------------------------------------------------------------
|
||||
|
||||
// sidePots slices the pot into layers, one per distinct all-in level. A player
|
||||
// can only win the part of the pot they could have lost, so each layer is
|
||||
// contested by exactly the players who paid into it.
|
||||
//
|
||||
// Folded players' chips stay in the pot — they paid for the right to fold — but
|
||||
// they are eligible for nothing.
|
||||
func (s *State) sidePots() {
|
||||
s.collect()
|
||||
|
||||
var levels []int64
|
||||
for i := range s.Seats {
|
||||
p := &s.Seats[i]
|
||||
if p.State == Folded || p.State == Out || p.Total == 0 {
|
||||
continue
|
||||
}
|
||||
levels = append(levels, p.Total)
|
||||
}
|
||||
if len(levels) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(levels, func(i, j int) bool { return levels[i] < levels[j] })
|
||||
|
||||
var pots []Pot
|
||||
var prev int64
|
||||
for _, level := range levels {
|
||||
if level <= prev {
|
||||
continue
|
||||
}
|
||||
var amount int64
|
||||
var eligible []int
|
||||
for i := range s.Seats {
|
||||
p := &s.Seats[i]
|
||||
paid := p.Total - prev
|
||||
if paid > level-prev {
|
||||
paid = level - prev
|
||||
}
|
||||
if paid > 0 {
|
||||
amount += paid // folded money counts toward the pot...
|
||||
}
|
||||
if p.State != Folded && p.State != Out && p.Total >= level {
|
||||
eligible = append(eligible, i) // ...but wins no part of it
|
||||
}
|
||||
}
|
||||
if amount > 0 {
|
||||
pots = append(pots, Pot{Amount: amount, Eligible: eligible})
|
||||
}
|
||||
prev = level
|
||||
}
|
||||
|
||||
if len(pots) > 0 {
|
||||
s.Side = pots
|
||||
s.Pot = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// The bots' brain.
|
||||
//
|
||||
// gogobee ran counterfactual regret minimisation against this game for a very
|
||||
// long time, and policy.gob is what it converged on: a table from "the situation
|
||||
// I am in" to "how often I fold, call, raise small, raise big, or shove". It is
|
||||
// the single highest-value thing in either repository, and none of it is
|
||||
// re-derived here — this file is the *runtime*, the trainer stayed behind.
|
||||
//
|
||||
// A situation is squeezed down to six things, and this is the whole reason the
|
||||
// table fits in memory: the street, whether the bot is in position, which of
|
||||
// twelve equity buckets its hand falls in, which of five stack-to-pot buckets,
|
||||
// whether the board is dry, wet or paired, and the last six actions. Two hands
|
||||
// that hash to the same key get the same strategy, and that is the approximation
|
||||
// the whole thing is built on.
|
||||
//
|
||||
// The key has to be *exactly* the key the trainer wrote, character for
|
||||
// character. Change the bucket edges, the position label or the history encoding
|
||||
// and every lookup misses — silently, because a miss is not an error, it is a
|
||||
// fall back to the pot-odds rule below. The bots would get quietly, unaccountably
|
||||
// worse. So: don't touch these numbers.
|
||||
|
||||
//go:embed policy.gob
|
||||
var policyGob []byte
|
||||
|
||||
// The five things the trainer let a bot consider.
|
||||
const (
|
||||
actFold = iota
|
||||
actCallCheck
|
||||
actRaiseHalf
|
||||
actRaisePot
|
||||
actAllIn
|
||||
numActions
|
||||
)
|
||||
|
||||
// policyTable maps an info-set key to how often to take each action.
|
||||
type policyTable map[string][numActions]float64
|
||||
|
||||
var (
|
||||
policyOnce sync.Once
|
||||
policy policyTable
|
||||
)
|
||||
|
||||
// loadPolicy decodes the embedded table, once, on the first hand anybody plays.
|
||||
//
|
||||
// Not in an init(): it is megabytes of gob, and Pete is a news server that mostly
|
||||
// never deals a card. Every test in the repo and every cold start would pay for
|
||||
// it. The first player to sit down pays for it instead, and only they do.
|
||||
func loadPolicy() policyTable {
|
||||
policyOnce.Do(func() {
|
||||
t, err := loadTrained(policyGob)
|
||||
if err != nil {
|
||||
// The bots still play — on pot odds — rather than the table 500ing.
|
||||
slog.Error("holdem: cannot decode CFR policy, bots fall back to pot odds", "err", err)
|
||||
policy = policyTable{}
|
||||
return
|
||||
}
|
||||
policy = t.Strategy
|
||||
slog.Info("holdem: CFR policy loaded", "nodes", len(policy),
|
||||
"iterations", t.Meta.Iterations, "stakes", t.Meta.Stakes, "depths", t.Meta.Depths)
|
||||
})
|
||||
return policy
|
||||
}
|
||||
|
||||
// ---- the info-set key ------------------------------------------------------
|
||||
//
|
||||
// Every function below is a load-bearing copy of the trainer's. See the note at
|
||||
// the top of the file before changing a number in any of them.
|
||||
|
||||
// equityBucket puts a hand's strength in one of twelve boxes, from trash to
|
||||
// monster.
|
||||
func equityBucket(eq float64) int {
|
||||
switch {
|
||||
case eq < 0.08:
|
||||
return 0
|
||||
case eq < 0.17:
|
||||
return 1
|
||||
case eq < 0.25:
|
||||
return 2
|
||||
case eq < 0.33:
|
||||
return 3
|
||||
case eq < 0.42:
|
||||
return 4
|
||||
case eq < 0.50:
|
||||
return 5
|
||||
case eq < 0.58:
|
||||
return 6
|
||||
case eq < 0.67:
|
||||
return 7
|
||||
case eq < 0.75:
|
||||
return 8
|
||||
case eq < 0.83:
|
||||
return 9
|
||||
case eq < 0.92:
|
||||
return 10
|
||||
default:
|
||||
return 11
|
||||
}
|
||||
}
|
||||
|
||||
// sprBucket is the stack-to-pot ratio: how much room is left to play. Under 1 is
|
||||
// a pot that is already committed; over 12 is a pot you can still fold out of.
|
||||
func sprBucket(spr float64) int {
|
||||
switch {
|
||||
case spr < 1:
|
||||
return 0
|
||||
case spr < 3:
|
||||
return 1
|
||||
case spr < 6:
|
||||
return 2
|
||||
case spr < 12:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
// Board textures, which is what makes the same hand a bet or a check.
|
||||
const (
|
||||
boardDry = 0
|
||||
boardWet = 1
|
||||
boardPaired = 2
|
||||
)
|
||||
|
||||
// boardTexture classifies the community cards. A paired board is one somebody
|
||||
// might have trips on; a wet one has a flush or straight coming.
|
||||
// It is on the trainer's hot path — millions of calls — so it counts into arrays
|
||||
// rather than maps. A map here cost more than the poker did.
|
||||
func boardTexture(board []cards.Card) int {
|
||||
if len(board) < 3 {
|
||||
return boardDry
|
||||
}
|
||||
|
||||
var ranks [14]int8
|
||||
var suits [4]int8
|
||||
var vals [5]int
|
||||
for i, c := range board {
|
||||
ranks[c.Rank]++
|
||||
suits[c.Suit]++
|
||||
vals[i] = int(c.Rank)
|
||||
}
|
||||
for _, n := range ranks {
|
||||
if n >= 2 {
|
||||
return boardPaired
|
||||
}
|
||||
}
|
||||
for _, n := range suits {
|
||||
if n >= 3 {
|
||||
return boardWet
|
||||
}
|
||||
}
|
||||
|
||||
// Three cards inside a five-rank window is a straight waiting to happen.
|
||||
v := vals[:len(board)]
|
||||
for i := 1; i < len(v); i++ {
|
||||
for j := i; j > 0 && v[j] < v[j-1]; j-- {
|
||||
v[j], v[j-1] = v[j-1], v[j]
|
||||
}
|
||||
}
|
||||
for i := 0; i+2 < len(v); i++ {
|
||||
if v[i+2]-v[i] <= 4 {
|
||||
return boardWet
|
||||
}
|
||||
}
|
||||
return boardDry
|
||||
}
|
||||
|
||||
// infoSet builds the string the policy is keyed on. The format is the trainer's.
|
||||
//
|
||||
// Position is IP or OOP — in position or out of it — and *nothing else*. This is
|
||||
// the one thing gogobee got wrong, and it got it wrong invisibly for as long as
|
||||
// the game has existed: the trainer packed a single "am I last to act" bit and
|
||||
// wrote its keys as IP/OOP, while the runtime looked them up with the table
|
||||
// labels a player would recognise (BTN, SB, BB, UTG…). Not one key ever matched.
|
||||
// Every bot in every hand of hold'em gogobee ever dealt fell through to the
|
||||
// pot-odds rule, and the five million training iterations sitting in policy.gob
|
||||
// were never once read.
|
||||
//
|
||||
// Nothing about that looks broken from the outside. A missing key is not an
|
||||
// error, it's a fallback — the bots played, they just played a heuristic. This is
|
||||
// why the hit rate is now a test.
|
||||
func infoSet(street Street, inPosition bool, eq, spr, texture int, history string) string {
|
||||
pos := "OOP"
|
||||
if inPosition {
|
||||
pos = "IP"
|
||||
}
|
||||
return fmt.Sprintf("%d|%s|%d|%d|%d|%s", street, pos, eq, spr, texture, history)
|
||||
}
|
||||
|
||||
// recent keeps the last six actions, which is all the trainer's key had room for.
|
||||
func recent(h string) string {
|
||||
if len(h) > 6 {
|
||||
return h[len(h)-6:]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ---- where a seat stands ---------------------------------------------------
|
||||
|
||||
// mcIters is how many runouts a bot samples to judge its hand at the table.
|
||||
const mcIters = 1000
|
||||
|
||||
// spot is everything the policy knows about a seat's situation, and it is the
|
||||
// one function that builds it.
|
||||
//
|
||||
// The trainer calls this too. That is the point of it: the key the policy is
|
||||
// written under and the key it is read under come out of the same code, so they
|
||||
// cannot quietly stop matching — which is exactly what went wrong the first time
|
||||
// and went unnoticed for the life of the game.
|
||||
func (s State) spot(seat, iters int, rng *rand.Rand) (string, Equity) {
|
||||
eq := s.equityFor(seat, iters, rng)
|
||||
return s.spotKey(seat, eq), eq
|
||||
}
|
||||
|
||||
// equityFor measures how good a seat's hand is right now.
|
||||
//
|
||||
// It depends only on the cards — the hand, the board, how many opponents — and
|
||||
// not on a single thing that happened in the betting. Which is why the trainer
|
||||
// can measure it once per street and reuse it down every branch it explores, and
|
||||
// why doing that is most of the difference between a run that takes half an hour
|
||||
// and one that takes four.
|
||||
func (s State) equityFor(seat, iters int, rng *rand.Rand) Equity {
|
||||
opponents := s.liveCount() - 1
|
||||
if opponents < 1 {
|
||||
opponents = 1
|
||||
}
|
||||
// Preflop heads-up is a lookup, not a simulation: there are only 169 hands
|
||||
// that differ, they have been measured to death, and a sampled answer would
|
||||
// only add noise to a bucket boundary.
|
||||
if s.Street == PreFlop && opponents == 1 {
|
||||
return preflopEquity(s.Seats[seat].Hole)
|
||||
}
|
||||
return equityOf(s.Seats[seat].Hole, s.Community, opponents, iters, rng)
|
||||
}
|
||||
|
||||
// spotKey builds the key from an equity already measured.
|
||||
func (s State) spotKey(seat int, eq Equity) string {
|
||||
pot := s.inPlay()
|
||||
spr := 0.0
|
||||
if pot > 0 {
|
||||
spr = float64(s.Seats[seat].Stack) / float64(pot)
|
||||
}
|
||||
return infoSet(s.Street, s.InPosition(seat), equityBucket(eq.Strength()),
|
||||
sprBucket(spr), boardTexture(s.Community), recent(s.History))
|
||||
}
|
||||
|
||||
// ---- choosing --------------------------------------------------------------
|
||||
|
||||
// hits and misses count how often a bot finds itself in the trained policy.
|
||||
//
|
||||
// They exist because the way this can break is silently. A key the policy has
|
||||
// never seen is not an error — the bot shrugs and plays pot odds — so a policy
|
||||
// that has stopped matching the game looks exactly like a policy that is working.
|
||||
// gogobee's never matched once, for the whole life of the game, and nobody could
|
||||
// have known by watching it play. Now a test reads these and fails.
|
||||
var hits, misses atomic.Int64
|
||||
|
||||
// botActs plays one bot's turn: work out where it stands, look up what it does
|
||||
// there, throw out anything illegal, and roll for it.
|
||||
func (s *State) botActs(seat int, evs *[]Event, rng *rand.Rand) {
|
||||
key, eq := s.spot(seat, mcIters, rng)
|
||||
|
||||
probs, ok := loadPolicy()[key]
|
||||
if ok {
|
||||
hits.Add(1)
|
||||
} else {
|
||||
misses.Add(1)
|
||||
probs = potOdds(eq, s, seat)
|
||||
}
|
||||
probs = legal(probs, s, seat)
|
||||
|
||||
move := s.moveFor(pick(probs, rng), seat)
|
||||
if err := s.act(seat, move, evs); err != nil {
|
||||
// A bot cannot be allowed to wedge the table by choosing something the rules
|
||||
// then refuse: it checks if it can and folds if it can't, and the mismatch
|
||||
// is loud, because it means legal() and the betting rules disagree.
|
||||
slog.Error("holdem: bot chose an illegal move", "seat", seat, "move", move.Kind, "err", err)
|
||||
if s.Owed(seat) > 0 {
|
||||
s.fold(seat, evs)
|
||||
} else {
|
||||
_ = s.check(seat, evs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// potOdds is what a bot does when the trained table has never seen this spot: it
|
||||
// works out whether the price it is being offered beats its chance of winning,
|
||||
// and mixes in enough aggression not to be a calling station.
|
||||
func potOdds(eq Equity, s *State, seat int) [numActions]float64 {
|
||||
p := &s.Seats[seat]
|
||||
strength := eq.Strength()
|
||||
owed := s.Bet - p.Bet
|
||||
pot := s.inPlay()
|
||||
|
||||
price := 0.0
|
||||
if owed > 0 && pot+owed > 0 {
|
||||
price = float64(owed) / float64(pot+owed)
|
||||
}
|
||||
|
||||
var probs [numActions]float64
|
||||
switch {
|
||||
case strength > 0.8:
|
||||
probs[actRaisePot], probs[actAllIn], probs[actCallCheck] = 0.6, 0.2, 0.2
|
||||
case strength > 0.6:
|
||||
probs[actRaiseHalf], probs[actCallCheck], probs[actFold] = 0.4, 0.5, 0.1
|
||||
case owed > 0 && strength > price:
|
||||
probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.7, 0.2, 0.1
|
||||
case owed > 0:
|
||||
probs[actFold], probs[actCallCheck] = 0.7, 0.3
|
||||
default:
|
||||
probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.6, 0.3, 0.1
|
||||
}
|
||||
return probs
|
||||
}
|
||||
|
||||
// mask is what a seat may actually do here. The trainer explores exactly this
|
||||
// set, so it never learns a strategy the table would turn down.
|
||||
func (s State) mask(seat int) (m [numActions]bool) {
|
||||
owed := s.Bet - s.Seats[seat].Bet
|
||||
|
||||
// Folding a hand you could see for free is a bug, not a strategy.
|
||||
m[actFold] = owed > 0
|
||||
m[actCallCheck] = true
|
||||
|
||||
// A raise needs chips behind the call, and somebody left to bet into.
|
||||
raise := s.Seats[seat].Stack > owed && s.canBet()
|
||||
m[actRaiseHalf], m[actRaisePot], m[actAllIn] = raise, raise, raise
|
||||
return m
|
||||
}
|
||||
|
||||
// legal zeroes out what the seat cannot do and renormalises what's left.
|
||||
func legal(probs [numActions]float64, s *State, seat int) [numActions]float64 {
|
||||
m := s.mask(seat)
|
||||
var total float64
|
||||
for i := range probs {
|
||||
if !m[i] {
|
||||
probs[i] = 0
|
||||
}
|
||||
total += probs[i]
|
||||
}
|
||||
if total <= 0 {
|
||||
var only [numActions]float64
|
||||
only[actCallCheck] = 1
|
||||
return only
|
||||
}
|
||||
for i := range probs {
|
||||
probs[i] /= total
|
||||
}
|
||||
return probs
|
||||
}
|
||||
|
||||
// pick rolls against the distribution.
|
||||
func pick(probs [numActions]float64, rng *rand.Rand) int {
|
||||
r := rng.Float64()
|
||||
sum := 0.0
|
||||
for i, p := range probs {
|
||||
sum += p
|
||||
if r < sum {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return actCallCheck
|
||||
}
|
||||
|
||||
// moveFor turns an abstract action — "raise half the pot" — into a legal move at
|
||||
// the actual size the table is at. A raise that would cost the bot everything it
|
||||
// has is a shove, which is the same decision made honestly.
|
||||
func (s *State) moveFor(action, seat int) Move {
|
||||
p := &s.Seats[seat]
|
||||
owed := s.Bet - p.Bet
|
||||
pot := s.inPlay()
|
||||
most := p.Bet + p.Stack
|
||||
|
||||
sized := func(by int64) Move {
|
||||
if by < s.MinRaise {
|
||||
by = s.MinRaise
|
||||
}
|
||||
to := s.Bet + by
|
||||
if to >= most {
|
||||
return Move{Kind: Shove}
|
||||
}
|
||||
return Move{Kind: Raise, To: to}
|
||||
}
|
||||
|
||||
switch action {
|
||||
case actFold:
|
||||
if owed <= 0 {
|
||||
return Move{Kind: Check} // never fold for free
|
||||
}
|
||||
return Move{Kind: Fold}
|
||||
case actCallCheck:
|
||||
if owed > 0 {
|
||||
return Move{Kind: Call}
|
||||
}
|
||||
return Move{Kind: Check}
|
||||
case actRaiseHalf:
|
||||
return sized(pot / 2)
|
||||
case actRaisePot:
|
||||
return sized(pot)
|
||||
case actAllIn:
|
||||
return Move{Kind: Shove}
|
||||
}
|
||||
return Move{Kind: Check}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// How good is this hand, really?
|
||||
//
|
||||
// A bot's decision starts here: deal the cards it cannot see, a thousand times,
|
||||
// and count how often it wins. That number — plus the board's texture, the
|
||||
// stack-to-pot ratio and the action so far — is the key it looks its trained
|
||||
// strategy up under.
|
||||
//
|
||||
// Monte Carlo rather than exhaustive because exhaustive is 2.1 million river
|
||||
// runouts against one opponent and rather more against five, and a thousand
|
||||
// samples puts the estimate inside a percentage point or so. The bot does not
|
||||
// need the fourth decimal place; it needs to know whether it is ahead.
|
||||
|
||||
// Equity is the fraction of runouts a hand wins, ties and loses against the
|
||||
// given number of unknown opponents.
|
||||
type Equity struct {
|
||||
Win float64
|
||||
Tie float64
|
||||
Loss float64
|
||||
}
|
||||
|
||||
// Strength collapses a result into the one number the policy is keyed on: a tie
|
||||
// is worth half a win, because that is what half a pot is.
|
||||
func (e Equity) Strength() float64 { return e.Win + e.Tie*0.5 }
|
||||
|
||||
// deck52 is the evaluator's whole deck, built once.
|
||||
var deck52 = func() []poker.Card {
|
||||
d := make([]poker.Card, 0, 52)
|
||||
for s := cards.Spades; s <= cards.Clubs; s++ {
|
||||
for r := cards.Ace; r <= cards.King; r++ {
|
||||
d = append(d, pokerOf[s][r])
|
||||
}
|
||||
}
|
||||
return d
|
||||
}()
|
||||
|
||||
// equityOf runs the simulation. The RNG is threaded like everything else here,
|
||||
// so a bot's decision replays from the session's seed along with the deal.
|
||||
func equityOf(hole [2]cards.Card, board []cards.Card, opponents, iterations int, rng *rand.Rand) Equity {
|
||||
if opponents < 1 {
|
||||
opponents = 1
|
||||
}
|
||||
|
||||
h0, h1 := toPoker(hole[0]), toPoker(hole[1])
|
||||
|
||||
// Seven cards, checked by hand. A map here would be the most expensive thing
|
||||
// in the trainer, which calls this function millions of times.
|
||||
var known [7]poker.Card
|
||||
n := 2
|
||||
known[0], known[1] = h0, h1
|
||||
pb := make([]poker.Card, len(board))
|
||||
for i, c := range board {
|
||||
pb[i] = toPoker(c)
|
||||
known[n] = pb[i]
|
||||
n++
|
||||
}
|
||||
|
||||
rest := make([]poker.Card, 0, 52)
|
||||
for _, c := range deck52 {
|
||||
seen := false
|
||||
for _, k := range known[:n] {
|
||||
if k == c {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
rest = append(rest, c)
|
||||
}
|
||||
}
|
||||
|
||||
need := opponents*2 + (5 - len(pb))
|
||||
if need > len(rest) {
|
||||
return Equity{Tie: 1}
|
||||
}
|
||||
|
||||
hero := make([]poker.Card, 7)
|
||||
hero[0], hero[1] = h0, h1
|
||||
villain := make([]poker.Card, 7)
|
||||
full := make([]poker.Card, 5)
|
||||
|
||||
var wins, ties int
|
||||
for i := 0; i < iterations; i++ {
|
||||
// A partial Fisher-Yates: only the cards actually needed get shuffled into
|
||||
// place, which is the difference between this being cheap and being the
|
||||
// slowest thing in the request.
|
||||
for j := 0; j < need; j++ {
|
||||
k := j + rng.IntN(len(rest)-j)
|
||||
rest[j], rest[k] = rest[k], rest[j]
|
||||
}
|
||||
|
||||
copy(full, pb)
|
||||
at := opponents * 2
|
||||
for b := len(pb); b < 5; b++ {
|
||||
full[b] = rest[at]
|
||||
at++
|
||||
}
|
||||
|
||||
copy(hero[2:], full)
|
||||
mine := poker.Evaluate(hero)
|
||||
|
||||
best := int32(7463) // one worse than the worst real hand
|
||||
for o := 0; o < opponents; o++ {
|
||||
villain[0], villain[1] = rest[o*2], rest[o*2+1]
|
||||
copy(villain[2:], full)
|
||||
if r := poker.Evaluate(villain); r < best {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case mine < best:
|
||||
wins++
|
||||
case mine == best:
|
||||
ties++
|
||||
}
|
||||
}
|
||||
|
||||
total := float64(iterations)
|
||||
return Equity{
|
||||
Win: float64(wins) / total,
|
||||
Tie: float64(ties) / total,
|
||||
Loss: float64(iterations-wins-ties) / total,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// The bridge to the evaluator.
|
||||
//
|
||||
// The engine deals Pete's own cards.Card — the same one blackjack, solitaire and
|
||||
// the felt already speak — and converts at the door. Hand strength is the one
|
||||
// thing in this package that is genuinely hard to get right (7-card best-of-5,
|
||||
// 7,462 distinct hands), so it is not homegrown: github.com/chehsunliu/poker is
|
||||
// a lookup table and it is correct.
|
||||
//
|
||||
// The conversion is a table built once. Doing it per evaluation would matter:
|
||||
// a bot's equity estimate is a thousand seven-card evaluations, and it makes
|
||||
// several of those per hand.
|
||||
|
||||
var (
|
||||
pokerRanks = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K"}
|
||||
pokerSuits = [4]string{"s", "h", "d", "c"} // cards.Spades, Hearts, Diamonds, Clubs
|
||||
|
||||
// A var initializer, not an init(). Go builds package-level variables before
|
||||
// it runs init functions, so anything else in this package that is itself a
|
||||
// var built out of this table — equity.go's deck52 is — would otherwise be
|
||||
// built out of an empty one. It was, briefly: every card came out identical,
|
||||
// every showdown tied, and every bot believed it held exactly 50% equity.
|
||||
pokerOf = func() (t [4][14]poker.Card) {
|
||||
for s := cards.Spades; s <= cards.Clubs; s++ {
|
||||
for r := cards.Ace; r <= cards.King; r++ {
|
||||
t[s][r] = poker.NewCard(pokerRanks[r] + pokerSuits[s])
|
||||
}
|
||||
}
|
||||
return t
|
||||
}()
|
||||
)
|
||||
|
||||
// toPoker converts one card for the evaluator.
|
||||
func toPoker(c cards.Card) poker.Card { return pokerOf[c.Suit][c.Rank] }
|
||||
|
||||
func toPokerAll(cs []cards.Card) []poker.Card {
|
||||
out := make([]poker.Card, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = toPoker(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rankOf evaluates a seat's best five from its hole cards and the board. Lower
|
||||
// is better — 1 is a royal flush — which is the evaluator's convention and not
|
||||
// worth inverting, since nothing outside this file ever sees the number.
|
||||
func rankOf(hole [2]cards.Card, board []cards.Card) (int32, string) {
|
||||
seven := make([]poker.Card, 0, 7)
|
||||
seven = append(seven, toPoker(hole[0]), toPoker(hole[1]))
|
||||
seven = append(seven, toPokerAll(board)...)
|
||||
r := poker.Evaluate(seven)
|
||||
return r, strings.ToLower(poker.RankString(r))
|
||||
}
|
||||
|
||||
// ---- showdown -------------------------------------------------------------
|
||||
|
||||
type ranked struct {
|
||||
seat int
|
||||
rank int32
|
||||
desc string
|
||||
}
|
||||
|
||||
// showdown turns the cards over, splits the pots, and pays. Every player still
|
||||
// in the hand shows, in the order the felt should turn them over: best hand
|
||||
// first, so the winner is the first card face the player sees.
|
||||
func (s *State) showdown(evs *[]Event) {
|
||||
s.collect()
|
||||
s.Street = Showdown
|
||||
|
||||
// Cut the side pots, if nobody has cut them yet.
|
||||
//
|
||||
// runout() does this, but runout only happens when the betting stops because
|
||||
// there is nobody left able to bet. A hand can reach a showdown with an all-in
|
||||
// player in it and the betting having finished perfectly normally: a short stack
|
||||
// shoves, and two players who both have chips behind keep betting past them,
|
||||
// street after street, all the way to the river. Nothing has been cut, and the
|
||||
// short stack is sitting in a single pot marked eligible for all of it.
|
||||
//
|
||||
// Which means they can win every chip the deep players put in *after* they were
|
||||
// already all-in — money they could never have lost. All-in for 100 against two
|
||||
// players who each put in 500, and the best hand takes 1,100 instead of the 300
|
||||
// they were playing for. The chips still balance, so conservation says nothing;
|
||||
// they just go to the wrong seat.
|
||||
if len(s.Side) == 0 && s.anyAllIn() {
|
||||
s.sidePots()
|
||||
}
|
||||
|
||||
// Say so. The last street's bets are still sitting in front of the seats that
|
||||
// made them, as far as the felt knows, and nothing else in the script is going
|
||||
// to tell it they have been swept in.
|
||||
*evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()})
|
||||
|
||||
var live []ranked
|
||||
for i := range s.Seats {
|
||||
p := &s.Seats[i]
|
||||
if p.State == Folded || p.State == Out {
|
||||
continue
|
||||
}
|
||||
r, desc := rankOf(p.Hole, s.Community)
|
||||
live = append(live, ranked{seat: i, rank: r, desc: desc})
|
||||
}
|
||||
sort.Slice(live, func(i, j int) bool { return live[i].rank < live[j].rank })
|
||||
|
||||
for _, e := range live {
|
||||
*evs = append(*evs, Event{
|
||||
Kind: "show", Seat: e.seat,
|
||||
Cards: []cards.Card{s.Seats[e.seat].Hole[0], s.Seats[e.seat].Hole[1]},
|
||||
Text: e.desc,
|
||||
})
|
||||
}
|
||||
|
||||
pots := s.Side
|
||||
if len(pots) == 0 {
|
||||
all := make([]int, 0, len(live))
|
||||
for _, e := range live {
|
||||
all = append(all, e.seat)
|
||||
}
|
||||
pots = []Pot{{Amount: s.Pot, Eligible: all}}
|
||||
s.Pot = 0
|
||||
}
|
||||
s.Side = nil
|
||||
|
||||
for _, pot := range pots {
|
||||
s.payPot(pot, live, evs)
|
||||
}
|
||||
s.endHand(evs)
|
||||
}
|
||||
|
||||
// payPot rakes a pot and splits it between the best eligible hands.
|
||||
//
|
||||
// The rake comes out of the pot before it is split, which is what a cardroom
|
||||
// does and is also the only thing consistent with the rest of this casino: a
|
||||
// player pays it out of a pot they *win*, never out of a bet they lose. A hand
|
||||
// that dies before the flop is not raked at all — no flop, no drop — so folding
|
||||
// your blind round after round costs you exactly the blinds and no fee.
|
||||
func (s *State) payPot(pot Pot, live []ranked, evs *[]Event) {
|
||||
if pot.Amount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
eligible := make(map[int]bool, len(pot.Eligible))
|
||||
for _, seat := range pot.Eligible {
|
||||
eligible[seat] = true
|
||||
}
|
||||
|
||||
var winners []ranked
|
||||
best := int32(0)
|
||||
for _, e := range live {
|
||||
if !eligible[e.seat] {
|
||||
continue
|
||||
}
|
||||
if len(winners) == 0 || e.rank < best {
|
||||
best, winners = e.rank, []ranked{e}
|
||||
} else if e.rank == best {
|
||||
winners = append(winners, e)
|
||||
}
|
||||
}
|
||||
if len(winners) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
amount := pot.Amount
|
||||
if s.Flopped {
|
||||
rake := int64(math.Floor(float64(amount) * s.Tier.RakePct))
|
||||
if cap := s.Tier.BB * rakeCapBB; rake > cap {
|
||||
rake = cap
|
||||
}
|
||||
if rake > 0 {
|
||||
amount -= rake
|
||||
s.Rake += rake // every chip of it, so the table still balances
|
||||
|
||||
// But only the part that came out of a *human's* winnings is money the
|
||||
// house actually made, and it is the only part worth quoting. The bots'
|
||||
// chips are not real — the only real money at the table is the players' —
|
||||
// so raking a pot a bot won costs nobody anything, and a counter that
|
||||
// climbed while every human folded would be telling them it had.
|
||||
for _, w := range winners {
|
||||
if !s.Seats[w.seat].Bot {
|
||||
// The table total (for the audit's delta) and the winner's own
|
||||
// running tally (for the ledger line the felt shows them). At a
|
||||
// table with two humans these are different numbers: each player is
|
||||
// only ever quoted the rake that came out of a pot they won.
|
||||
s.Paid += rake / int64(len(winners))
|
||||
s.Seats[w.seat].Paid += rake / int64(len(winners))
|
||||
}
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "rake", Seat: -1, Amount: rake})
|
||||
}
|
||||
}
|
||||
|
||||
share := amount / int64(len(winners))
|
||||
odd := amount % int64(len(winners)) // the odd chip goes to the first seat left of the button
|
||||
for i, w := range winners {
|
||||
won := share
|
||||
if i == 0 {
|
||||
won += odd
|
||||
}
|
||||
s.Seats[w.seat].Stack += won
|
||||
s.Seats[w.seat].Won += won
|
||||
*evs = append(*evs, Event{Kind: "win", Seat: w.seat, Amount: won, Text: w.desc})
|
||||
}
|
||||
}
|
||||
|
||||
// takeit ends a hand nobody contested: everyone else folded, so the last player
|
||||
// standing takes the pot without showing. Their own uncalled bet comes back
|
||||
// first — it was never called, so it was never really in the pot.
|
||||
func (s *State) takeit(evs *[]Event) {
|
||||
s.uncalled(evs)
|
||||
s.collect()
|
||||
*evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()})
|
||||
|
||||
winner := -1
|
||||
for i := range s.Seats {
|
||||
if s.Seats[i].State != Folded && s.Seats[i].State != Out {
|
||||
winner = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if winner < 0 {
|
||||
s.endHand(evs)
|
||||
return
|
||||
}
|
||||
|
||||
// There are never side pots here: they are only cut once the betting is over
|
||||
// because everybody is all-in, and a table where everybody is all-in is a table
|
||||
// where nobody is left to fold.
|
||||
pot := Pot{Amount: s.Pot, Eligible: []int{winner}}
|
||||
s.Pot = 0
|
||||
s.payPot(pot, []ranked{{seat: winner, rank: 0}}, evs)
|
||||
s.endHand(evs)
|
||||
}
|
||||
|
||||
// uncalled returns the unmatched top of a bet. If you shove 500 into a player
|
||||
// with 200 behind, 300 of that was never contested and comes straight back.
|
||||
//
|
||||
// It must run *before* the bets are swept into the pot, and the matched level it
|
||||
// measures against counts the players who folded. Their chips are in the pot —
|
||||
// they paid to see the bet and then gave up — so the money they put in is money
|
||||
// that called. Miss that and a bet folded to on the river comes back whole,
|
||||
// including the part that was called on the flop, which mints chips out of air.
|
||||
//
|
||||
// The rake is the other reason this matters at all. When everybody folds, the
|
||||
// winner takes the pot back either way and the arithmetic looks the same — but a
|
||||
// pot with an uncalled bet still in it is a pot the house rakes, and it would be
|
||||
// raking the player on their own money that nobody ever contested.
|
||||
func (s *State) uncalled(evs *[]Event) {
|
||||
top, topSeat := int64(-1), -1
|
||||
for i := range s.Seats {
|
||||
p := &s.Seats[i]
|
||||
if p.State == Folded || p.State == Out {
|
||||
continue
|
||||
}
|
||||
if p.Total > top {
|
||||
top, topSeat = p.Total, i
|
||||
}
|
||||
}
|
||||
if topSeat < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var matched int64 // the most anybody else put in, whether or not they're still in
|
||||
for i := range s.Seats {
|
||||
if i == topSeat || s.Seats[i].State == Out {
|
||||
continue
|
||||
}
|
||||
if s.Seats[i].Total > matched {
|
||||
matched = s.Seats[i].Total
|
||||
}
|
||||
}
|
||||
|
||||
excess := top - matched
|
||||
p := &s.Seats[topSeat]
|
||||
if excess <= 0 || excess > p.Bet {
|
||||
// An uncalled bet is always part of the street it was made on, so it cannot
|
||||
// be bigger than what that seat has in front of them right now.
|
||||
return
|
||||
}
|
||||
|
||||
p.Stack += excess
|
||||
p.Total -= excess
|
||||
p.Bet -= excess
|
||||
if p.State == AllIn && p.Stack > 0 {
|
||||
p.State = Active // they were never really all-in against anybody
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "uncalled", Seat: topSeat, Amount: excess})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,818 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// The one that matters: no chip is ever created or destroyed.
|
||||
//
|
||||
// Everything else in this package is a rule you could argue about. This is the
|
||||
// one that would lose somebody money. Every chip at the table is in exactly one
|
||||
// place — a stack, a bet in front of a seat, a pot, or the house's rake — and
|
||||
// the only thing that ever adds to the total is a bot reloading. So: play a
|
||||
// hundred sessions of real hands, with the trained bots making real decisions,
|
||||
// and count the chips after every single move.
|
||||
func TestChipsAreConserved(t *testing.T) {
|
||||
for game := 0; game < 100; game++ {
|
||||
rng := rand.New(rand.NewPCG(uint64(game), 99))
|
||||
bots := 1 + game%MaxBots
|
||||
tier := Tiers[game%len(Tiers)]
|
||||
|
||||
s, _, err := New(tier, SoloSeats(tier, bots, tier.MaxBuy), tier.RakePct, uint64(game), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("new table: %v", err)
|
||||
}
|
||||
|
||||
want := chipsAt(s) // what the table started with
|
||||
|
||||
for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ {
|
||||
var evs []Event
|
||||
s, evs, err = apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
|
||||
}
|
||||
want += reloaded(evs) // a bot that rebought brought new chips with it
|
||||
check(t, s, want, game, hand, "deal")
|
||||
|
||||
for step := 0; s.Phase == PhaseBetting; step++ {
|
||||
if step > 200 {
|
||||
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
|
||||
}
|
||||
s, _, err = apply(s, randomMove(s, rng))
|
||||
if err != nil {
|
||||
t.Fatalf("game %d hand %d: %v", game, hand, err)
|
||||
}
|
||||
check(t, s, want, game, hand, "move")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// chipsAt totals every chip the table can see, plus every one the house has
|
||||
// already taken out of it.
|
||||
func chipsAt(s State) int64 {
|
||||
total := s.Rake + s.Pot
|
||||
for _, p := range s.Seats {
|
||||
total += p.Stack + p.Bet
|
||||
}
|
||||
for _, pot := range s.Side {
|
||||
total += pot.Amount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// reloaded is what the bots brought back to the table on this deal. It is the
|
||||
// only thing in the game that is allowed to make chips out of nothing, which is
|
||||
// exactly why the test has to know about it and nothing else does.
|
||||
func reloaded(evs []Event) int64 {
|
||||
var n int64
|
||||
for _, e := range evs {
|
||||
if e.Kind == "rebuy" {
|
||||
n += e.Amount
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func check(t *testing.T, s State, want int64, game, hand int, when string) {
|
||||
t.Helper()
|
||||
if got := chipsAt(s); got != want {
|
||||
t.Fatalf("game %d hand %d, after %s: %d chips on the table, want %d "+
|
||||
"(pot %d, rake %d, stacks %v)", game, hand, when, got, want, s.Pot, s.Rake, stacks(s))
|
||||
}
|
||||
for i, p := range s.Seats {
|
||||
if p.Stack < 0 {
|
||||
t.Fatalf("game %d hand %d: seat %d has a negative stack (%d)", game, hand, i, p.Stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stacks(s State) []int64 {
|
||||
out := make([]int64, len(s.Seats))
|
||||
for i, p := range s.Seats {
|
||||
out[i] = p.Stack
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// You is seat zero — the shape every test in this file is written against. The
|
||||
// engine no longer has this constant: a table is a list of seats and which are
|
||||
// human is a per-seat property, not a fixed index. But these tests all seat one
|
||||
// human at zero (the pre-multiplayer solo shape), so a test-local alias keeps
|
||||
// them readable as the regression guard they are. The multiway behaviour has its
|
||||
// own tests, which do not assume it.
|
||||
const You = 0
|
||||
|
||||
// apply plays a move as the seat whose turn it is at a solo table — always the
|
||||
// human at seat zero. It wraps the seat-parameterized ApplyMove so the solo tests
|
||||
// read as they did before the reshape.
|
||||
func apply(s State, m Move) (State, []Event, error) {
|
||||
return ApplyMove(s, You, m)
|
||||
}
|
||||
|
||||
// randomMove picks something legal for the player, without any thought at all.
|
||||
// A bad player is exactly what this test wants: it gets into all-ins, folds,
|
||||
// short stacks and split pots far faster than a good one would.
|
||||
func randomMove(s State, rng *rand.Rand) Move {
|
||||
owed := s.Owed(You)
|
||||
var legal []Move
|
||||
if owed > 0 {
|
||||
legal = append(legal, Move{Kind: Fold}, Move{Kind: Call})
|
||||
} else {
|
||||
legal = append(legal, Move{Kind: Check})
|
||||
}
|
||||
if s.Seats[You].Stack > owed && s.canBet() {
|
||||
legal = append(legal, Move{Kind: Shove})
|
||||
if to := s.MinRaiseTo(You); to < s.MaxRaiseTo(You) {
|
||||
legal = append(legal, Move{Kind: Raise, To: to})
|
||||
}
|
||||
}
|
||||
return legal[rng.IntN(len(legal))]
|
||||
}
|
||||
|
||||
// ---- the rules a poker player would notice were wrong -----------------------
|
||||
|
||||
func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s, evs, err := apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The button posted the small blind, not the big one.
|
||||
var small, big int
|
||||
for _, e := range evs {
|
||||
if e.Kind == "blind" && e.Text == "small" {
|
||||
small = e.Seat
|
||||
}
|
||||
if e.Kind == "blind" && e.Text == "big" {
|
||||
big = e.Seat
|
||||
}
|
||||
}
|
||||
if small != s.Button {
|
||||
t.Errorf("heads-up: seat %d posted the small blind, but the button is seat %d", small, s.Button)
|
||||
}
|
||||
if big == s.Button {
|
||||
t.Error("heads-up: the button posted the big blind too")
|
||||
}
|
||||
// And it is the first to act before the flop. (If the button is a bot it has
|
||||
// already acted, so what we can check is that the player didn't get skipped.)
|
||||
if s.Phase != PhaseBetting {
|
||||
t.Fatalf("phase %q: the hand should be waiting on somebody", s.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBigBlindGetsTheirOption(t *testing.T) {
|
||||
// A table where everyone just calls: the big blind has the bet matched without
|
||||
// ever having chosen anything, and the street must not end until they speak.
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
|
||||
// Find a hand where the player is the big blind. The button alternates, so at
|
||||
// most a couple of deals.
|
||||
for i := 0; i < 6 && s.Position(You) != "BB"; i++ {
|
||||
s = playOut(t, s)
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
}
|
||||
if s.Position(You) != "BB" {
|
||||
t.Skip("never dealt the big blind")
|
||||
}
|
||||
if s.Phase != PhaseBetting {
|
||||
return // the bot folded or raised; either way the option isn't the question
|
||||
}
|
||||
if s.ToAct == You && s.Owed(You) == 0 {
|
||||
// This is the option: nothing to call, but the hand is still ours to act on.
|
||||
if _, _, err := apply(s, Move{Kind: Check}); err != nil {
|
||||
t.Errorf("the big blind cannot check their option: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAShortAllInDoesNotReopenTheBetting(t *testing.T) {
|
||||
s := State{
|
||||
Tier: Tiers[1], // 5/10
|
||||
Seats: []Seat{{Name: "You", Stack: 1000}, {Name: "Bot", Bot: true, Stack: 1000}},
|
||||
Bet: 100,
|
||||
MinRaise: 100, // a full raise would be to 200
|
||||
Aggressor: You,
|
||||
Phase: PhaseBetting,
|
||||
}
|
||||
s.Seats[You].Bet = 100
|
||||
s.Seats[1].Bet = 0
|
||||
s.Seats[1].Stack = 150 // can only get to 150, which is a raise of 50: not a full one
|
||||
|
||||
var evs []Event
|
||||
if err := s.allin(1, &evs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Bet != 150 {
|
||||
t.Errorf("the bet to call is %d, want 150", s.Bet)
|
||||
}
|
||||
if s.MinRaise != 100 {
|
||||
t.Errorf("min raise moved to %d — a short all-in must not change it", s.MinRaise)
|
||||
}
|
||||
if s.Aggressor != You {
|
||||
t.Errorf("the aggressor moved to seat %d — a short all-in must not reopen the action", s.Aggressor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidePotsPayInLayers(t *testing.T) {
|
||||
// Three players all-in for different amounts. The short stack can only win
|
||||
// what everyone could have lost to them.
|
||||
s := State{
|
||||
Tier: Tiers[1],
|
||||
Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
|
||||
}
|
||||
s.Seats[0].Total, s.Seats[0].State = 100, AllIn // short
|
||||
s.Seats[1].Total, s.Seats[1].State = 500, AllIn // middle
|
||||
s.Seats[2].Total, s.Seats[2].State = 500, AllIn // covers
|
||||
|
||||
s.sidePots()
|
||||
|
||||
if len(s.Side) != 2 {
|
||||
t.Fatalf("got %d pots, want 2: %+v", len(s.Side), s.Side)
|
||||
}
|
||||
main, side := s.Side[0], s.Side[1]
|
||||
if main.Amount != 300 { // 100 from each of the three
|
||||
t.Errorf("main pot is %d, want 300", main.Amount)
|
||||
}
|
||||
if len(main.Eligible) != 3 {
|
||||
t.Errorf("main pot has %d eligible, want all 3", len(main.Eligible))
|
||||
}
|
||||
if side.Amount != 800 { // 400 more from each of the two who had it
|
||||
t.Errorf("side pot is %d, want 800", side.Amount)
|
||||
}
|
||||
if len(side.Eligible) != 2 {
|
||||
t.Errorf("side pot has %d eligible, want 2 — the short stack cannot win it", len(side.Eligible))
|
||||
}
|
||||
if total := main.Amount + side.Amount; total != 1100 {
|
||||
t.Errorf("the pots hold %d, but %d went in", total, 1100)
|
||||
}
|
||||
}
|
||||
|
||||
// A covered all-in player can only ever win what they matched, and the hand does
|
||||
// not have to end in a run-out for that to be true.
|
||||
//
|
||||
// This one got through everything. The side pots were only ever cut in runout(),
|
||||
// which happens when the betting stops because *nobody* can bet — so a short stack
|
||||
// who shoves and gets called by two players who still have chips behind, and who
|
||||
// then keep betting past them all the way to the river, reached a showdown with the
|
||||
// pots never cut. One pot, everybody eligible, and the short stack takes the lot.
|
||||
//
|
||||
// Chip conservation never saw it: the chips balance perfectly, they just land in
|
||||
// the wrong seat. And every browser session went through runout(), because the
|
||||
// player shoving is what ends the betting. It took reading the code.
|
||||
func TestACoveredAllInCannotWinTheSidePot(t *testing.T) {
|
||||
c := func(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
|
||||
|
||||
s := State{
|
||||
Tier: Tiers[1],
|
||||
Phase: PhaseBetting,
|
||||
Street: River,
|
||||
Community: []cards.Card{
|
||||
c(2, cards.Clubs), c(7, cards.Diamonds), c(9, cards.Spades),
|
||||
c(cards.Jack, cards.Hearts), c(4, cards.Clubs),
|
||||
},
|
||||
Seats: []Seat{
|
||||
{Name: "You", Stack: 500, Hole: [2]cards.Card{c(3, cards.Clubs), c(5, cards.Diamonds)}},
|
||||
// All-in for 100, and holding the best hand at the table.
|
||||
{Name: "Short", Bot: true, Hole: [2]cards.Card{c(cards.Ace, cards.Clubs), c(cards.Ace, cards.Diamonds)}},
|
||||
{Name: "Deep", Bot: true, Stack: 500, Hole: [2]cards.Card{c(cards.King, cards.Clubs), c(cards.Queen, cards.Diamonds)}},
|
||||
},
|
||||
}
|
||||
s.Seats[0].Total, s.Seats[0].State = 500, Active
|
||||
s.Seats[1].Total, s.Seats[1].State = 100, AllIn
|
||||
s.Seats[2].Total, s.Seats[2].State = 500, Active
|
||||
s.Pot = 1100 // 100 + 500 + 500
|
||||
|
||||
var evs []Event
|
||||
s.showdown(&evs)
|
||||
|
||||
// The main pot is 100 from each of the three. The other 800 is between the two
|
||||
// who were still betting, and the short stack cannot touch it.
|
||||
if s.Seats[1].Won != 300 {
|
||||
t.Errorf("all-in for 100 against two players, and won %d — the most that can ever "+
|
||||
"be won is the 300 main pot. The side pot was not cut.", s.Seats[1].Won)
|
||||
}
|
||||
if s.Seats[0].Won+s.Seats[2].Won != 800 {
|
||||
t.Errorf("the 800 side pot paid out %d between the two players who were "+
|
||||
"actually contesting it", s.Seats[0].Won+s.Seats[2].Won)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldedChipsStayInThePotButWinNothing(t *testing.T) {
|
||||
s := State{
|
||||
Tier: Tiers[1],
|
||||
Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
|
||||
}
|
||||
s.Seats[0].Total, s.Seats[0].State = 200, AllIn
|
||||
s.Seats[1].Total, s.Seats[1].State = 50, Folded // called 50 and gave up
|
||||
s.Seats[2].Total, s.Seats[2].State = 200, AllIn
|
||||
|
||||
s.sidePots()
|
||||
|
||||
var total int64
|
||||
for _, p := range s.Side {
|
||||
total += p.Amount
|
||||
for _, seat := range p.Eligible {
|
||||
if seat == 1 {
|
||||
t.Error("a folded seat is eligible to win a pot")
|
||||
}
|
||||
}
|
||||
}
|
||||
if total != 450 {
|
||||
t.Errorf("the pots hold %d, want 450 — the folder's 50 has to still be in there", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUncalledBetComesBack(t *testing.T) {
|
||||
s := State{
|
||||
Tier: Tiers[1],
|
||||
Seats: []Seat{{Name: "You", Stack: 0}, {Name: "A", Bot: true}},
|
||||
}
|
||||
s.Seats[0].Total, s.Seats[0].Bet, s.Seats[0].State = 500, 500, AllIn
|
||||
s.Seats[1].Total, s.Seats[1].State = 200, Folded
|
||||
|
||||
var evs []Event
|
||||
s.uncalled(&evs)
|
||||
|
||||
if s.Seats[You].Stack != 300 {
|
||||
t.Errorf("got %d back, want the 300 nobody called", s.Seats[You].Stack)
|
||||
}
|
||||
if s.Seats[You].Total != 200 {
|
||||
t.Errorf("still committed for %d, want 200 — the rest was never in the pot", s.Seats[You].Total)
|
||||
}
|
||||
if s.Seats[You].State != Active {
|
||||
t.Error("still marked all-in for chips that came back")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the rake --------------------------------------------------------------
|
||||
|
||||
func TestNoFlopNoDrop(t *testing.T) {
|
||||
s := State{Tier: Tiers[1], Flopped: false, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||
var evs []Event
|
||||
s.payPot(Pot{Amount: 1000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||
|
||||
if s.Rake != 0 {
|
||||
t.Errorf("raked %d off a pot that never saw a flop", s.Rake)
|
||||
}
|
||||
if s.Seats[You].Stack != 1000 {
|
||||
t.Errorf("paid %d of a 1000 pot", s.Seats[You].Stack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRakeIsCapped(t *testing.T) {
|
||||
s := State{Tier: Tiers[1], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||
var evs []Event
|
||||
// 5% of 10,000 is 500, but the cap is three big blinds — 30 at 5/10.
|
||||
s.payPot(Pot{Amount: 10000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||
|
||||
want := s.Tier.BB * rakeCapBB
|
||||
if s.Rake != want {
|
||||
t.Errorf("raked %d, want the %d cap", s.Rake, want)
|
||||
}
|
||||
if s.Seats[You].Stack != 10000-want {
|
||||
t.Errorf("paid %d, want %d", s.Seats[You].Stack, 10000-want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRakeIsFivePercentUnderTheCap(t *testing.T) {
|
||||
s := State{Tier: Tiers[0], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||
var evs []Event
|
||||
s.payPot(Pot{Amount: 100, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) // cap is 6 at 1/2
|
||||
|
||||
if s.Rake != 5 {
|
||||
t.Errorf("raked %d off a 100 pot, want 5", s.Rake)
|
||||
}
|
||||
if s.Seats[You].Stack != 95 {
|
||||
t.Errorf("paid %d, want 95", s.Seats[You].Stack)
|
||||
}
|
||||
}
|
||||
|
||||
// The rake has to survive the wiring, not only the arithmetic.
|
||||
//
|
||||
// This is the test that was missing, and a browser found what it would have
|
||||
// found: New() overwrites the tier's rake with the one the casino hands it, and
|
||||
// the casino hands it a *fraction* (blackjack's 0.05). The tier declared 5,
|
||||
// meaning percent. Every other rake test builds a State by hand and sets the tier
|
||||
// itself, so not one of them ever saw the number a real table runs on — and the
|
||||
// house quietly took nothing from every pot for an afternoon.
|
||||
func TestTheRakeSurvivesTheConstructor(t *testing.T) {
|
||||
tier := Tiers[1] // 5/10, so the cap is 30
|
||||
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), 0.05, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Flopped = true
|
||||
|
||||
var evs []Event
|
||||
s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||
|
||||
if s.Rake != 20 {
|
||||
t.Fatalf("the house took %d of a 400 pot, want 20 — five percent of it. "+
|
||||
"RakePct is a fraction (0.05), not a percentage (5): see the note on Tiers.", s.Rake)
|
||||
}
|
||||
if !has(evs, "rake") {
|
||||
t.Error("the rake was taken with no event to say so, so the felt cannot show it")
|
||||
}
|
||||
}
|
||||
|
||||
// The house only makes money off you. A pot a bot wins is raked — that is what a
|
||||
// pot is — but the chips it comes out of are not real, so it has cost you nothing
|
||||
// and the number the felt quotes you must not move.
|
||||
func TestYouOnlyPayRakeOnPotsYouWin(t *testing.T) {
|
||||
s := State{Tier: Tiers[1], Flopped: true,
|
||||
Seats: []Seat{{Name: "You"}, {Name: "Dice", Bot: true}}}
|
||||
var evs []Event
|
||||
|
||||
// A bot takes it.
|
||||
s.payPot(Pot{Amount: 400, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
|
||||
if s.Paid != 0 {
|
||||
t.Errorf("you paid %d in rake on a pot a bot won", s.Paid)
|
||||
}
|
||||
if s.Rake != 20 {
|
||||
t.Errorf("the table lifted %d off that pot, want 20 — the chips have to balance "+
|
||||
"whoever won it", s.Rake)
|
||||
}
|
||||
|
||||
// Now you take one.
|
||||
s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||
if s.Paid != 20 {
|
||||
t.Errorf("you paid %d in rake on a 400 pot you won, want 20", s.Paid)
|
||||
}
|
||||
if s.Rake != 40 {
|
||||
t.Errorf("the table has lifted %d in total, want 40", s.Rake)
|
||||
}
|
||||
|
||||
// And a chop costs you half of it.
|
||||
s.Paid, s.Rake = 0, 0
|
||||
s.payPot(Pot{Amount: 400, Eligible: []int{0, 1}},
|
||||
[]ranked{{seat: 0, rank: 9}, {seat: 1, rank: 9}}, &evs)
|
||||
if s.Paid != 10 {
|
||||
t.Errorf("you paid %d in rake on a chopped pot, want 10 — half the rake, "+
|
||||
"because you won half the pot", s.Paid)
|
||||
}
|
||||
}
|
||||
|
||||
// At a table with two humans the session-rake line each is shown is their own,
|
||||
// not the table's: the house took it out of a pot one of them won, and quoting it
|
||||
// to the other says the house has taken money off them that it has not. So the
|
||||
// per-seat tally only ever moves on the seat that won the pot.
|
||||
func TestRakeIsChargedToTheSeatThatWonIt(t *testing.T) {
|
||||
s := State{Tier: Tiers[1], Flopped: true,
|
||||
Seats: []Seat{{Name: "Reala"}, {Name: "Bob"}}}
|
||||
var evs []Event
|
||||
|
||||
// Reala wins a 400 pot. The rake on it (5%, 20) is Reala's to have paid.
|
||||
s.payPot(Pot{Amount: 400, Eligible: []int{0}}, []ranked{{seat: 0}}, &evs)
|
||||
if s.Seats[0].Paid != 20 {
|
||||
t.Errorf("the seat that won the pot paid %d in rake, want 20", s.Seats[0].Paid)
|
||||
}
|
||||
if s.Seats[1].Paid != 0 {
|
||||
t.Errorf("the other player was charged %d in rake for a pot they were not in", s.Seats[1].Paid)
|
||||
}
|
||||
if s.Paid != 20 {
|
||||
t.Errorf("the table total is %d, want 20 — it is still the sum for the audit", s.Paid)
|
||||
}
|
||||
|
||||
// Bob wins the next one. His tally moves; Reala's stays where it was.
|
||||
s.payPot(Pot{Amount: 200, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
|
||||
if s.Seats[0].Paid != 20 {
|
||||
t.Errorf("Reala's tally moved on a pot Bob won: %d, want 20", s.Seats[0].Paid)
|
||||
}
|
||||
if s.Seats[1].Paid != 10 {
|
||||
t.Errorf("Bob paid %d in rake on a 200 pot he won, want 10", s.Seats[1].Paid)
|
||||
}
|
||||
if s.Paid != 30 {
|
||||
t.Errorf("the table total is %d, want 30", s.Paid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASplitPotSplits(t *testing.T) {
|
||||
s := State{Tier: Tiers[1], Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||
var evs []Event
|
||||
// Same rank: they chop. The odd chip goes to one of them, not into the air.
|
||||
s.payPot(Pot{Amount: 101, Eligible: []int{0, 1}},
|
||||
[]ranked{{seat: 0, rank: 500}, {seat: 1, rank: 500}}, &evs)
|
||||
|
||||
if got := s.Seats[0].Stack + s.Seats[1].Stack; got != 101 {
|
||||
t.Errorf("paid out %d of a 101 pot", got)
|
||||
}
|
||||
if s.Seats[0].Stack != 51 || s.Seats[1].Stack != 50 {
|
||||
t.Errorf("split %d/%d, want 51/50", s.Seats[0].Stack, s.Seats[1].Stack)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the session -----------------------------------------------------------
|
||||
|
||||
func TestYouCannotWalkOutOfALiveHand(t *testing.T) {
|
||||
s := table(t, Tiers[0], 2, 200)
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
if s.Phase != PhaseBetting {
|
||||
t.Skip("the hand ended before the player could act")
|
||||
}
|
||||
if _, _, err := apply(s, Move{Kind: Leave}); err != ErrHandLive {
|
||||
t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
|
||||
}
|
||||
if _, _, err := apply(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive {
|
||||
t.Errorf("topping up mid-hand gave %v, want ErrHandLive", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeavingTakesTheStackHome(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
s = playOut(t, s)
|
||||
|
||||
stack := s.Seats[You].Stack
|
||||
s, _, err := apply(s, Move{Kind: Leave})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Errorf("phase %q after leaving, want done", s.Phase)
|
||||
}
|
||||
if s.Payout != stack {
|
||||
t.Errorf("payout %d, want the %d that was in front of us", s.Payout, stack)
|
||||
}
|
||||
if _, _, err := apply(s, Move{Kind: Deal}); err != ErrOver {
|
||||
t.Errorf("dealt a hand at a table we got up from: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBustingEndsTheSession(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s.Seats[You].Stack = 0
|
||||
var evs []Event
|
||||
s.endHand(&evs)
|
||||
|
||||
if s.Phase != PhaseDone {
|
||||
t.Errorf("phase %q with no chips left, want done", s.Phase)
|
||||
}
|
||||
if s.Payout != 0 {
|
||||
t.Errorf("payout %d for a busted player", s.Payout)
|
||||
}
|
||||
if !has(evs, "bust") {
|
||||
t.Error("no bust event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200) // max buy is 200, and we're at it
|
||||
if _, _, err := apply(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn {
|
||||
t.Errorf("topped up over the table maximum: %v", err)
|
||||
}
|
||||
|
||||
s = table(t, Tiers[0], 1, 100)
|
||||
s, _, err := apply(s, Move{Kind: TopUp, Amount: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Seats[You].Stack != 150 {
|
||||
t.Errorf("stack is %d, want 150", s.Seats[You].Stack)
|
||||
}
|
||||
if s.BoughtIn != 150 {
|
||||
t.Errorf("bought in for %d, want 150 — the top-up is real money too", s.BoughtIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuyInHasToBeInRange(t *testing.T) {
|
||||
tier := Tiers[0]
|
||||
for _, amount := range []int64{0, tier.MinBuy - 1, tier.MaxBuy + 1} {
|
||||
if _, _, err := New(tier, SoloSeats(tier, 1, amount), 5, 1, 2); err != ErrBadBuyIn {
|
||||
t.Errorf("buy-in of %d at a %d–%d table: %v", amount, tier.MinBuy, tier.MaxBuy, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- what the raw script carries ------------------------------------------
|
||||
|
||||
// The engine no longer redacts the event stream, and this pins why: a shared
|
||||
// table has more than one human, so the engine cannot know who a stream is for.
|
||||
// It emits every seat's hole cards, and the *view* layer builds each viewer's
|
||||
// redacted copy — that per-seat redaction is the security boundary now, and it
|
||||
// has its own test in the web package (TestHoldemViewNeverLeaksAnotherSeatsCards).
|
||||
//
|
||||
// So the engine-level contract flipped: the deal must carry a hole event for
|
||||
// every dealt seat, or a viewer would have no cards of their own to be shown.
|
||||
func TestTheDealScriptCarriesEverySeatsHole(t *testing.T) {
|
||||
s := table(t, Tiers[0], 3, 200) // four seats: one human, three bots
|
||||
s, evs, err := apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holes := map[int][]cards.Card{}
|
||||
for _, e := range evs {
|
||||
if e.Kind == "hole" {
|
||||
holes[e.Seat] = e.Cards
|
||||
}
|
||||
}
|
||||
for i := range s.Seats {
|
||||
if s.Seats[i].State == Out {
|
||||
continue
|
||||
}
|
||||
got := holes[i]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("seat %d got no hole event; the view has nothing to redact from", i)
|
||||
}
|
||||
if got[0] != s.Seats[i].Hole[0] || got[1] != s.Seats[i].Hole[1] {
|
||||
t.Errorf("seat %d hole event %v disagrees with state %v", i, got, s.Seats[i].Hole)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- hand strength ---------------------------------------------------------
|
||||
|
||||
func TestTheEvaluatorKnowsWhichHandIsBetter(t *testing.T) {
|
||||
board := []cards.Card{
|
||||
{Rank: 10, Suit: cards.Hearts}, {Rank: cards.Jack, Suit: cards.Hearts},
|
||||
{Rank: cards.Queen, Suit: cards.Hearts}, {Rank: 2, Suit: cards.Spades},
|
||||
{Rank: 7, Suit: cards.Clubs},
|
||||
}
|
||||
flush, _ := rankOf([2]cards.Card{{Rank: 3, Suit: cards.Hearts}, {Rank: 5, Suit: cards.Hearts}}, board)
|
||||
straight, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Clubs}}, board)
|
||||
royal, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Hearts}, {Rank: cards.Ace, Suit: cards.Hearts}}, board)
|
||||
pair, _ := rankOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 4, Suit: cards.Diamonds}}, board)
|
||||
|
||||
if !(royal < flush && flush < straight && straight < pair) {
|
||||
t.Errorf("hands rank royal=%d flush=%d straight=%d pair=%d — lower must be better, in that order",
|
||||
royal, flush, straight, pair)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquityKnowsAcesAreGood(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 1))
|
||||
aces := equityOf([2]cards.Card{{Rank: cards.Ace, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Hearts}}, nil, 1, 2000, rng)
|
||||
rags := equityOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 2, Suit: cards.Hearts}}, nil, 1, 2000, rng)
|
||||
|
||||
if aces.Strength() < 0.8 {
|
||||
t.Errorf("pocket aces are worth %.2f heads-up, want about 0.85", aces.Strength())
|
||||
}
|
||||
if rags.Strength() > 0.4 {
|
||||
t.Errorf("seven-deuce is worth %.2f heads-up, want about 0.35", rags.Strength())
|
||||
}
|
||||
if aces.Strength() <= rags.Strength() {
|
||||
t.Error("seven-deuce is not better than pocket aces")
|
||||
}
|
||||
}
|
||||
|
||||
// The policy loads, and every node in it is a probability distribution.
|
||||
func TestThePolicyLoads(t *testing.T) {
|
||||
p := loadPolicy()
|
||||
if len(p) < 1000 {
|
||||
t.Fatalf("the CFR policy has %d nodes in it — it did not load, or it was never trained", len(p))
|
||||
}
|
||||
for key, probs := range p {
|
||||
var sum float64
|
||||
for _, v := range probs {
|
||||
if v < 0 {
|
||||
t.Fatalf("%s: a negative probability (%v)", key, probs)
|
||||
}
|
||||
sum += v
|
||||
}
|
||||
if sum < 0.99 || sum > 1.01 {
|
||||
t.Fatalf("%s: the probabilities sum to %v, not 1", key, sum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheBotsAreActuallyTrained is the test this game most needed and did not
|
||||
// have.
|
||||
//
|
||||
// A bot that cannot find itself in the policy does not fail. It shrugs, plays the
|
||||
// pot-odds rule, and looks exactly like a bot that is working — which is how
|
||||
// gogobee shipped a trained poker AI whose policy was *never read once* for the
|
||||
// entire life of the game. The trainer wrote its keys under IP/OOP and the table
|
||||
// looked them up under BTN/SB/BB, and there was nothing anywhere that would have
|
||||
// said so.
|
||||
//
|
||||
// So: deal real hands, let the bots think, and count how often the thinking lands
|
||||
// in the table. Heads-up is the number that has to hold — that is what the policy
|
||||
// was trained on. A six-handed table is a documented approximation of it and
|
||||
// drops off as seats are added, which is why this only asserts on the duel.
|
||||
func TestTheBotsAreActuallyTrained(t *testing.T) {
|
||||
hits.Store(0)
|
||||
misses.Store(0)
|
||||
|
||||
rng := rand.New(rand.NewPCG(11, 12))
|
||||
for game := 0; game < 40; game++ {
|
||||
tier := Tiers[1]
|
||||
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), tier.RakePct, uint64(game), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for hand := 0; hand < 6 && s.Phase != PhaseDone; hand++ {
|
||||
s, _, err = apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for s.Phase == PhaseBetting {
|
||||
s, _, err = apply(s, randomMove(s, rng))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h, m := hits.Load(), misses.Load()
|
||||
if h+m < 100 {
|
||||
t.Fatalf("the bots only made %d decisions — this test isn't measuring anything", h+m)
|
||||
}
|
||||
rate := float64(h) / float64(h+m)
|
||||
if rate < 0.6 {
|
||||
t.Fatalf("heads-up, the bots found themselves in the trained policy %.0f%% of the time "+
|
||||
"(%d of %d decisions). They are playing the pot-odds fallback, which means the key the "+
|
||||
"trainer writes and the key the table reads have drifted apart. See infoSet.",
|
||||
rate*100, h, h+m)
|
||||
}
|
||||
t.Logf("heads-up policy hit rate: %.0f%% (%d of %d decisions)", rate*100, h, h+m)
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
func table(t *testing.T, tier Tier, bots int, buyIn int64) State {
|
||||
t.Helper()
|
||||
s, _, err := New(tier, SoloSeats(tier, bots, buyIn), tier.RakePct, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("new table: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// playOut folds every decision until the hand is over.
|
||||
func playOut(t *testing.T, s State) State {
|
||||
t.Helper()
|
||||
for i := 0; s.Phase == PhaseBetting; i++ {
|
||||
if i > 100 {
|
||||
t.Fatal("the hand will not end")
|
||||
}
|
||||
move := Move{Kind: Fold}
|
||||
if s.Owed(You) == 0 {
|
||||
move = Move{Kind: Check}
|
||||
}
|
||||
var err error
|
||||
s, _, err = apply(s, move)
|
||||
if err != nil {
|
||||
t.Fatalf("playing out: %v", err)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func has(evs []Event, kind string) bool {
|
||||
for _, e := range evs {
|
||||
if e.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Where you sit is decided when the button moves, and a fold does not move it.
|
||||
// Position walked the table with nextIn, which steps over folded seats while the
|
||||
// seat count still includes them — so as players mucked, the labels slid round
|
||||
// and a six-handed felt printed CO on three different seats at once. The badge is
|
||||
// the only thing that reads this, which is exactly why nothing caught it.
|
||||
func TestPositionsDoNotMoveWhenSeatsFold(t *testing.T) {
|
||||
s := table(t, Tiers[0], 5, 200) // six-handed
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
|
||||
before := make([]string, len(s.Seats))
|
||||
for i := range s.Seats {
|
||||
before[i] = s.Position(i)
|
||||
}
|
||||
|
||||
// Every seat has its own label, and the ones a six-max table prints are these.
|
||||
seen := map[string]int{}
|
||||
for _, p := range before {
|
||||
seen[p]++
|
||||
}
|
||||
for _, want := range []string{"BTN", "SB", "BB", "UTG", "MP", "CO"} {
|
||||
if seen[want] != 1 {
|
||||
t.Errorf("six-handed: %q appears %d times, want exactly once — got %v",
|
||||
want, seen[want], before)
|
||||
}
|
||||
}
|
||||
|
||||
// Now fold seats out of the hand, one at a time. Nobody's position changes by
|
||||
// mucking — folding is done to the state directly because a fold in the engine
|
||||
// belongs to whoever is to act, and what is under test is the label, not the turn.
|
||||
for i := range s.Seats {
|
||||
if i == You || s.Seats[i].State != Active {
|
||||
continue
|
||||
}
|
||||
s.Seats[i].State = Folded
|
||||
for j := range s.Seats {
|
||||
if got := s.Position(j); got != before[j] {
|
||||
t.Fatalf("seat %d was %q and is now %q after seat %d folded — position is "+
|
||||
"where you sit, not who is left", j, before[j], got, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The reshape's own guard: a table with more than one human actually plays, and
|
||||
// the chips still conserve when the person to act is not always seat zero.
|
||||
//
|
||||
// The solo suite proves the engine still behaves as it did; this proves the thing
|
||||
// that changed. Two humans and two bots sit down, and the driver plays whichever
|
||||
// human the action stops on — which is the whole point of the multiway advance:
|
||||
// it runs the bots itself and hands control back at every *human* seat, not just
|
||||
// at seat zero.
|
||||
|
||||
// randomMoveFor picks a legal move for a specific seat, the multiway sibling of
|
||||
// randomMove. It never folds when it can check, so hands actually develop.
|
||||
func randomMoveFor(s State, seat int, rng *rand.Rand) Move {
|
||||
owed := s.Owed(seat)
|
||||
var legal []Move
|
||||
if owed > 0 {
|
||||
legal = append(legal, Move{Kind: Fold}, Move{Kind: Call})
|
||||
} else {
|
||||
legal = append(legal, Move{Kind: Check})
|
||||
}
|
||||
if s.Seats[seat].Stack > owed && s.canBet() {
|
||||
if to := s.MinRaiseTo(seat); to < s.MaxRaiseTo(seat) {
|
||||
legal = append(legal, Move{Kind: Raise, To: to})
|
||||
}
|
||||
}
|
||||
return legal[rng.IntN(len(legal))]
|
||||
}
|
||||
|
||||
func TestMultiwayChipsAreConserved(t *testing.T) {
|
||||
for game := 0; game < 100; game++ {
|
||||
rng := rand.New(rand.NewPCG(uint64(game), 71))
|
||||
tier := Tiers[game%len(Tiers)]
|
||||
|
||||
// Two humans, two bots. The humans sit at 0 and 2 so the action genuinely
|
||||
// lands on a non-zero human seat, which is the case the old engine could not
|
||||
// have reached.
|
||||
seats := []SeatConfig{
|
||||
{Name: "Ana", Stack: tier.MaxBuy},
|
||||
{Name: "Bot A", Bot: true, Stack: tier.MaxBuy},
|
||||
{Name: "Bo", Stack: tier.MaxBuy},
|
||||
{Name: "Bot B", Bot: true, Stack: tier.MaxBuy},
|
||||
}
|
||||
s, _, err := New(tier, seats, tier.RakePct, uint64(game), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("new table: %v", err)
|
||||
}
|
||||
want := chipsAt(s)
|
||||
|
||||
for hand := 0; hand < 8 && s.Phase == PhaseHandOver; hand++ {
|
||||
var evs []Event
|
||||
s, evs, err = ApplyMove(s, 0, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
|
||||
}
|
||||
want += reloaded(evs)
|
||||
check(t, s, want, game, hand, "deal")
|
||||
|
||||
for step := 0; s.Phase == PhaseBetting; step++ {
|
||||
if step > 400 {
|
||||
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
|
||||
}
|
||||
seat := s.ToAct
|
||||
if s.Seats[seat].Bot {
|
||||
t.Fatalf("game %d: advance stopped on bot seat %d — it should run bots itself", game, seat)
|
||||
}
|
||||
s, _, err = ApplyMove(s, seat, randomMoveFor(s, seat, rng))
|
||||
if err != nil {
|
||||
t.Fatalf("game %d hand %d seat %d: %v", game, hand, seat, err)
|
||||
}
|
||||
check(t, s, want, game, hand, "move")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiwayRejectsOutOfTurnMoves pins that a human cannot act when it is
|
||||
// another human's turn — the betting move is legal only from the seat to act.
|
||||
func TestMultiwayRejectsOutOfTurnMoves(t *testing.T) {
|
||||
tier := Tiers[0]
|
||||
seats := []SeatConfig{
|
||||
{Name: "Ana", Stack: tier.MaxBuy},
|
||||
{Name: "Bo", Stack: tier.MaxBuy},
|
||||
}
|
||||
s, _, err := New(tier, seats, tier.RakePct, 3, 9)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err = ApplyMove(s, 0, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseBetting {
|
||||
t.Fatalf("want a live hand, got phase %s", s.Phase)
|
||||
}
|
||||
|
||||
// Whoever is not to act tries to move. It must be refused, and nothing must
|
||||
// change.
|
||||
other := 1 - s.ToAct
|
||||
before := chipsAt(s)
|
||||
if _, _, err := ApplyMove(s, other, Move{Kind: Call}); err != ErrNotYourTurn {
|
||||
t.Fatalf("want ErrNotYourTurn from the seat not to act, got %v", err)
|
||||
}
|
||||
if got := chipsAt(s); got != before {
|
||||
t.Errorf("a refused move moved chips: %d -> %d", before, got)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,431 @@
|
||||
package holdem
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// The trainer.
|
||||
//
|
||||
// This is counterfactual regret minimisation, and what it produces is policy.gob
|
||||
// — the table the bots read at the table. It is not on any request path; it runs
|
||||
// from cmd/holdem-train, for half an hour, and then it is a file.
|
||||
//
|
||||
// The one thing worth understanding about it: **it plays the real game.** Every
|
||||
// move it explores goes through Step, which is the same reducer the felt calls,
|
||||
// so the blinds, the min-raise, the street completion and the money are the ones
|
||||
// a player will actually meet. Its info-set key comes out of State.spot, which is
|
||||
// the same function the bots look themselves up with.
|
||||
//
|
||||
// That is not tidiness, it is the whole lesson of the policy this replaces. That
|
||||
// one was trained against a hand-written model of poker sitting beside the real
|
||||
// engine — a model where a call always ended the street, the big blind had no
|
||||
// option, and the payoff was half the pot no matter who had put what in. Then it
|
||||
// was looked up under a key the trainer never wrote. The result was a 3.4MB file
|
||||
// that had never once been read, and nobody could tell, because a policy miss is
|
||||
// not an error. It just quietly isn't there.
|
||||
//
|
||||
// So: one engine, one key function, and a test that fails if the bots stop
|
||||
// finding themselves in the table.
|
||||
|
||||
// How much of the game tree to explore. Two raises a street keeps the tree small
|
||||
// enough to converge; a third barely changes how anybody plays and multiplies the
|
||||
// nodes.
|
||||
const (
|
||||
maxRaisesPerStreet = 2
|
||||
maxDepth = 40
|
||||
trainMCIters = 60 // noisy, but it is only picking a bucket
|
||||
)
|
||||
|
||||
// regrets is what CFR accumulates: how much better each action would have been.
|
||||
type regrets map[string]*[numActions]float64
|
||||
|
||||
// Trained is the file the bots read.
|
||||
type Trained struct {
|
||||
Strategy map[string][numActions]float64
|
||||
Meta TrainMeta
|
||||
}
|
||||
|
||||
// TrainMeta is what the policy can say about itself. Worth having: a policy is
|
||||
// otherwise an opaque three megabytes and there is no way to tell a good one from
|
||||
// a stale one by looking.
|
||||
type TrainMeta struct {
|
||||
Iterations int
|
||||
Stakes string
|
||||
Depths string
|
||||
Nodes int
|
||||
}
|
||||
|
||||
// ---- preflop, measured once ------------------------------------------------
|
||||
|
||||
var (
|
||||
preflopOnce sync.Once
|
||||
preflopTable [13][13]Equity // [hi][lo] offsuit, [lo][hi] suited, diagonal pairs
|
||||
)
|
||||
|
||||
// preflopEquity is the equity of a starting hand heads-up. There are only 169
|
||||
// hands that differ from each other, so they are measured properly, once, and
|
||||
// then it is a lookup — which matters twice: it takes the noise out of a bucket
|
||||
// boundary, and the trainer visits preflop on every single iteration.
|
||||
func preflopEquity(hole [2]cards.Card) Equity {
|
||||
preflopOnce.Do(func() {
|
||||
rng := cards.NewRNG(20260714, 1)
|
||||
for a := cards.Ace; a <= cards.King; a++ {
|
||||
for b := a; b <= cards.King; b++ {
|
||||
lo, hi := rankIdx(a), rankIdx(b)
|
||||
|
||||
// Suited, and the pairs (which can only be offsuit) on the diagonal.
|
||||
s1 := cards.Card{Rank: a, Suit: cards.Spades}
|
||||
s2 := cards.Card{Rank: b, Suit: cards.Spades}
|
||||
if a == b {
|
||||
s2.Suit = cards.Hearts
|
||||
}
|
||||
preflopTable[lo][hi] = equityOf([2]cards.Card{s1, s2}, nil, 1, 10000, rng)
|
||||
|
||||
if a != b {
|
||||
o2 := cards.Card{Rank: b, Suit: cards.Hearts}
|
||||
preflopTable[hi][lo] = equityOf([2]cards.Card{s1, o2}, nil, 1, 10000, rng)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
lo, hi := rankIdx(hole[0].Rank), rankIdx(hole[1].Rank)
|
||||
if lo > hi {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
if hole[0].Suit == hole[1].Suit {
|
||||
return preflopTable[lo][hi] // suited, and the pairs sit here too
|
||||
}
|
||||
if lo == hi {
|
||||
return preflopTable[lo][hi]
|
||||
}
|
||||
return preflopTable[hi][lo] // offsuit
|
||||
}
|
||||
|
||||
// rankIdx maps a rank to 0–12, with the ace high — which is what it is, before
|
||||
// the flop.
|
||||
func rankIdx(r cards.Rank) int {
|
||||
if r == cards.Ace {
|
||||
return 12
|
||||
}
|
||||
return int(r) - 2
|
||||
}
|
||||
|
||||
// ---- the traversal ---------------------------------------------------------
|
||||
|
||||
// Train runs external-sampling MCCFR for n hands and returns the average
|
||||
// strategy. Each worker keeps its own tables and they are summed at the end,
|
||||
// which is what makes this embarrassingly parallel and is the only reason it
|
||||
// finishes in half an hour.
|
||||
func Train(n, workers int, t Tier, minBB, maxBB int64, seed uint64, progress func(done int)) *Trained {
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
type table struct {
|
||||
reg regrets
|
||||
avg regrets
|
||||
}
|
||||
out := make([]table, workers)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var done sync.Mutex
|
||||
completed := 0
|
||||
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
tr := &trainer{
|
||||
reg: regrets{},
|
||||
avg: regrets{},
|
||||
tier: t,
|
||||
minBB: minBB,
|
||||
maxBB: maxBB,
|
||||
rng: cards.NewRNG(seed, uint64(w)+1),
|
||||
}
|
||||
|
||||
share := n / workers
|
||||
if w < n%workers {
|
||||
share++
|
||||
}
|
||||
for i := 0; i < share; i++ {
|
||||
tr.iterate(uint64(w)<<40 | uint64(i))
|
||||
// i+1, not i: the check fired on the very first pass and credited two
|
||||
// thousand hands before a single one had been walked, which with thirty
|
||||
// workers made the first ETA sixty thousand hands optimistic.
|
||||
if progress != nil && (i+1)%2000 == 0 {
|
||||
done.Lock()
|
||||
completed += 2000
|
||||
c := completed
|
||||
done.Unlock()
|
||||
progress(c)
|
||||
}
|
||||
}
|
||||
out[w] = table{tr.reg, tr.avg}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Sum the workers' average-strategy tables, then normalise each node into the
|
||||
// probabilities a bot will actually play.
|
||||
total := regrets{}
|
||||
for _, tab := range out {
|
||||
for key, v := range tab.avg {
|
||||
acc, ok := total[key]
|
||||
if !ok {
|
||||
acc = &[numActions]float64{}
|
||||
total[key] = acc
|
||||
}
|
||||
for i, x := range v {
|
||||
acc[i] += x
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
strategy := make(map[string][numActions]float64, len(total))
|
||||
for key, v := range total {
|
||||
var sum float64
|
||||
for _, x := range v {
|
||||
sum += x
|
||||
}
|
||||
var probs [numActions]float64
|
||||
if sum > 0 {
|
||||
for i, x := range v {
|
||||
probs[i] = x / sum
|
||||
}
|
||||
} else {
|
||||
for i := range probs {
|
||||
probs[i] = 1.0 / numActions
|
||||
}
|
||||
}
|
||||
strategy[key] = probs
|
||||
}
|
||||
|
||||
return &Trained{
|
||||
Strategy: strategy,
|
||||
Meta: TrainMeta{
|
||||
Iterations: n,
|
||||
Stakes: fmt.Sprintf("%d/%d", t.SB, t.BB),
|
||||
Depths: fmt.Sprintf("%d–%d BB", minBB, maxBB),
|
||||
Nodes: len(strategy),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type trainer struct {
|
||||
reg regrets
|
||||
avg regrets
|
||||
tier Tier
|
||||
minBB int64
|
||||
maxBB int64
|
||||
rng *rand.Rand
|
||||
|
||||
// A hand's equity on a given street depends on the cards and nothing else —
|
||||
// not on how the betting went to get there. The deck is fixed for the whole
|
||||
// iteration, so the flop is the same flop down every branch, and this is
|
||||
// measured once per seat per street instead of once per node.
|
||||
eq [2][4]Equity
|
||||
have [2][4]bool
|
||||
}
|
||||
|
||||
// iterate deals one hand and walks it once for each player.
|
||||
//
|
||||
// The stack depth is drawn fresh every hand, across the whole range the table
|
||||
// allows. This is the fix for the policy that came before: it was trained at ten
|
||||
// big blinds and nothing else, so four out of five spots in a real cash game fell
|
||||
// outside anything it had ever seen. A hand of poker is a different game at 20
|
||||
// big blinds than at 100 — that is most of what makes it a game — and the bots
|
||||
// have to have played both.
|
||||
func (tr *trainer) iterate(id uint64) {
|
||||
depth := tr.minBB
|
||||
if tr.maxBB > tr.minBB {
|
||||
depth += tr.rng.Int64N(tr.maxBB - tr.minBB + 1)
|
||||
}
|
||||
stack := depth * tr.tier.BB
|
||||
|
||||
// No rake while learning. The bots should learn to play poker, not to beat a
|
||||
// fee, and the fee is the house's business.
|
||||
t := tr.tier
|
||||
t.RakePct = 0
|
||||
|
||||
s, err := open(t, stack, stack, id, tr.rng.Uint64())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
start := [2]int64{s.Seats[0].Stack + s.Seats[0].Bet, s.Seats[1].Stack + s.Seats[1].Bet}
|
||||
|
||||
tr.have = [2][4]bool{} // one deal, one set of boards, one set of equities
|
||||
|
||||
for me := 0; me < 2; me++ {
|
||||
tr.walk(s.clone(), me, start, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// equity is the cached measurement for this seat on this street.
|
||||
func (tr *trainer) equity(s State, seat int) Equity {
|
||||
st := s.Street
|
||||
if st > River {
|
||||
st = River
|
||||
}
|
||||
if !tr.have[seat][st] {
|
||||
tr.eq[seat][st] = s.equityFor(seat, trainMCIters, tr.rng)
|
||||
tr.have[seat][st] = true
|
||||
}
|
||||
return tr.eq[seat][st]
|
||||
}
|
||||
|
||||
// walk returns what the hand is worth to `me`, in chips, from here.
|
||||
func (tr *trainer) walk(s State, me int, start [2]int64, depth int) float64 {
|
||||
if s.Phase != PhaseBetting || depth > maxDepth {
|
||||
// The hand is over (or we have gone far enough to call it over). What it was
|
||||
// worth is simply what the player has now against what they sat down with —
|
||||
// the real number, out of the real engine, side pots and all.
|
||||
return float64(s.Seats[me].Stack - start[me])
|
||||
}
|
||||
|
||||
seat := s.ToAct
|
||||
key := s.spotKey(seat, tr.equity(s, seat))
|
||||
|
||||
mask := s.mask(seat)
|
||||
if raises(s.History) >= maxRaisesPerStreet {
|
||||
mask[actRaiseHalf], mask[actRaisePot] = false, false
|
||||
}
|
||||
|
||||
reg := tr.reg[key]
|
||||
if reg == nil {
|
||||
reg = &[numActions]float64{}
|
||||
tr.reg[key] = reg
|
||||
}
|
||||
strat := match(*reg, mask)
|
||||
|
||||
// The opponent's turn: sample one line and follow it. That is the "external
|
||||
// sampling" part, and it is what keeps a hand from costing 5^12 traversals.
|
||||
if seat != me {
|
||||
avg := tr.avg[key]
|
||||
if avg == nil {
|
||||
avg = &[numActions]float64{}
|
||||
tr.avg[key] = avg
|
||||
}
|
||||
for i, p := range strat {
|
||||
avg[i] += p
|
||||
}
|
||||
return tr.walk(tr.play(s, seat, sample(strat, tr.rng)), me, start, depth+1)
|
||||
}
|
||||
|
||||
// Our turn: try everything, and regret what we didn't do.
|
||||
var values [numActions]float64
|
||||
var node float64
|
||||
for a := 0; a < numActions; a++ {
|
||||
if !mask[a] {
|
||||
continue
|
||||
}
|
||||
values[a] = tr.walk(tr.play(s, seat, a), me, start, depth+1)
|
||||
node += strat[a] * values[a]
|
||||
}
|
||||
for a := 0; a < numActions; a++ {
|
||||
if mask[a] {
|
||||
reg[a] += values[a] - node
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// play applies one abstract action through the real reducer.
|
||||
func (tr *trainer) play(s State, seat, action int) State {
|
||||
next, _, err := step(s.clone(), s.moveFor(action, seat))
|
||||
if err != nil {
|
||||
// The mask and the rules disagreed, which is a bug in one of them. Fold and
|
||||
// carry on rather than poison the whole run.
|
||||
next, _, err = step(s.clone(), Move{Kind: Fold})
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// match is regret matching: play each action in proportion to how much you wish
|
||||
// you had played it. An action nobody regrets not taking gets played uniformly.
|
||||
func match(reg [numActions]float64, mask [numActions]bool) [numActions]float64 {
|
||||
var strat [numActions]float64
|
||||
var sum float64
|
||||
for i, r := range reg {
|
||||
if mask[i] && r > 0 {
|
||||
sum += r
|
||||
}
|
||||
}
|
||||
if sum > 0 {
|
||||
for i, r := range reg {
|
||||
if mask[i] && r > 0 {
|
||||
strat[i] = r / sum
|
||||
}
|
||||
}
|
||||
return strat
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, ok := range mask {
|
||||
if ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
strat[actCallCheck] = 1
|
||||
return strat
|
||||
}
|
||||
for i, ok := range mask {
|
||||
if ok {
|
||||
strat[i] = 1 / float64(n)
|
||||
}
|
||||
}
|
||||
return strat
|
||||
}
|
||||
|
||||
func sample(strat [numActions]float64, rng *rand.Rand) int {
|
||||
r := rng.Float64()
|
||||
var sum float64
|
||||
for i, p := range strat {
|
||||
sum += p
|
||||
if r < sum {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return actCallCheck
|
||||
}
|
||||
|
||||
// raises counts the bets and raises on this street, which is what the tree is
|
||||
// capped on.
|
||||
func raises(history string) int {
|
||||
n := 0
|
||||
for _, c := range history {
|
||||
if c == 'r' || c == 'R' {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ---- the file --------------------------------------------------------------
|
||||
|
||||
// Save writes a trained policy.
|
||||
func Save(w io.Writer, t *Trained) error { return gob.NewEncoder(w).Encode(t) }
|
||||
|
||||
// Load reads one. It is only used by the tests — the bots read the embedded copy.
|
||||
func Load(r io.Reader) (*Trained, error) {
|
||||
var t Trained
|
||||
if err := gob.NewDecoder(r).Decode(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// loadTrained decodes the embedded policy in the new format.
|
||||
func loadTrained(b []byte) (*Trained, error) { return Load(bytes.NewReader(b)) }
|
||||
@@ -0,0 +1,709 @@
|
||||
// Package klondike is a pure Klondike solitaire engine, played for chips.
|
||||
//
|
||||
// Same seam as blackjack and hangman: ApplyMove(state, move) (state, events,
|
||||
// error), where an error means the move was illegal and nothing else. The state
|
||||
// is a plain value, so a game survives a redeploy and replays from its seed.
|
||||
//
|
||||
// The casino version is Vegas scoring, which is the only way solitaire has ever
|
||||
// been a gambling game and the only shape that makes sense with money on it.
|
||||
// You do not win or lose the deal. You *buy the deck* for your stake, and every
|
||||
// card you get home to a foundation pays a slice of it back. Fifty-two cards
|
||||
// home pays the tier's full multiple; nothing home pays nothing. You can stop
|
||||
// whenever you like and keep what you have banked, which is what makes a game
|
||||
// that has gone dead a decision rather than a wall.
|
||||
//
|
||||
// There is no undo. The stake is spent the moment the deck is bought, so an undo
|
||||
// would be a way to walk a losing board backwards until it wins.
|
||||
package klondike
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("klondike: the game is already over")
|
||||
ErrUnknownMove = errors.New("klondike: unknown move")
|
||||
ErrBadBet = errors.New("klondike: bet must be positive")
|
||||
ErrUnknownTier = errors.New("klondike: no such tier")
|
||||
ErrBadPile = errors.New("klondike: no such pile")
|
||||
ErrEmptyPile = errors.New("klondike: there is nothing there to move")
|
||||
ErrNotASequence = errors.New("klondike: those cards aren't a run you can lift")
|
||||
ErrWontGo = errors.New("klondike: that card doesn't go there")
|
||||
ErrNoDraw = errors.New("klondike: there is nothing left to turn over")
|
||||
ErrNoPasses = errors.New("klondike: you've used your passes through the stock")
|
||||
ErrNothingHome = errors.New("klondike: nothing can go home right now")
|
||||
)
|
||||
|
||||
// Piles is the number of tableau columns. Foundations is one per suit.
|
||||
const (
|
||||
Piles = 7
|
||||
Foundations = 4
|
||||
FullDeck = 52
|
||||
)
|
||||
|
||||
// Tier is a difficulty, chosen with the bet. The two dials are how many cards
|
||||
// the stock turns over at a time and how many times you may go through it —
|
||||
// which between them are the whole difficulty of Klondike. Turning three at a
|
||||
// time hides two of every three cards behind a card you may never reach; a
|
||||
// single pass means the ones you leave behind are gone for good.
|
||||
//
|
||||
// The multiple pays for that. Cutthroat is the cruellest deal in the room and
|
||||
// pays 3.4×, which means you are ahead from sixteen cards home even though most
|
||||
// of those boards never clear.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Draw int `json:"draw"` // cards turned over per pull on the stock
|
||||
Passes int `json:"passes"` // times through the stock; 0 means unlimited
|
||||
Base float64 `json:"base"` // what a full 52 cards home pays, as a multiple of the stake
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// BreakEven is how many cards have to reach the foundations before the player is
|
||||
// square with the house. It's the number the felt actually quotes, because
|
||||
// "1.4×" tells a player nothing about a game where the multiple is paid per card.
|
||||
func (t Tier) BreakEven() int {
|
||||
if t.Base <= 0 {
|
||||
return FullDeck
|
||||
}
|
||||
n := int(math.Ceil(float64(FullDeck) / t.Base))
|
||||
if n > FullDeck {
|
||||
return FullDeck
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Tiers are the three deals.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "patient", Name: "Patient", Draw: 1, Passes: 0, Base: 1.4,
|
||||
Blurb: "One card at a time, through the stock as often as you like."},
|
||||
{Slug: "vegas", Name: "Vegas", Draw: 3, Passes: 3, Base: 2.2,
|
||||
Blurb: "Three at a time, three times round. The house game."},
|
||||
{Slug: "cutthroat", Name: "Cutthroat", Draw: 3, Passes: 1, Base: 3.4,
|
||||
Blurb: "Three at a time, one pass. What you leave behind is gone."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier by the name the browser sent.
|
||||
func TierBySlug(slug string) (Tier, error) {
|
||||
for _, t := range Tiers {
|
||||
if t.Slug == slug {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Tier{}, ErrUnknownTier
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlaying Phase = "playing"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended. Note there is no "lost": a board that goes dead is
|
||||
// cashed, for whatever it made. Solitaire's failure mode is a board you can't
|
||||
// improve, and the honest thing to do with one is pay out what's on it.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeCleared Outcome = "cleared" // all 52 home
|
||||
OutcomeCashed Outcome = "cashed" // the player stopped and took the board
|
||||
)
|
||||
|
||||
// Pile is one tableau column: a face-down stack with a face-up run on top of it.
|
||||
// Down is the part the browser never sees.
|
||||
type Pile struct {
|
||||
Down []cards.Card `json:"down"`
|
||||
Up []cards.Card `json:"up"`
|
||||
}
|
||||
|
||||
// State is one game. The stock and every Down card are in here, which is exactly
|
||||
// why this value never leaves the server.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Stock cards.Deck `json:"stock"`
|
||||
Waste []cards.Card `json:"waste"`
|
||||
Table [Piles]Pile `json:"table"`
|
||||
Found [Foundations][]cards.Card `json:"found"` // indexed by suit
|
||||
Recycles int `json:"recycles"` // times the waste has gone back under
|
||||
Moves int `json:"moves"`
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates. The engine emits them rather than
|
||||
// leaving the browser to diff two boards and guess what moved — a card that
|
||||
// slides from a column to a foundation and a card that was simply redrawn there
|
||||
// are the same diff and very different things to watch.
|
||||
//
|
||||
// Home and Pays ride on every event, so the meter on the felt is always quoting
|
||||
// a number the engine worked out. The browser never does this arithmetic: it did
|
||||
// once, and the felt advertised a payout the house didn't honour.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "deal" | "draw" | "recycle" | "move" | "home" | "flip" | "settle"
|
||||
Cards []cards.Card `json:"cards,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Home int `json:"home"`
|
||||
Pays int64 `json:"pays"`
|
||||
}
|
||||
|
||||
// Move is a player action.
|
||||
//
|
||||
// Home is its own kind rather than a Move To a foundation the player picked,
|
||||
// because there is only ever one foundation a card can go to and asking the
|
||||
// player to name it would be a quiz about suit ordering. The browser sends
|
||||
// "this card, home"; the engine finds the pile.
|
||||
type Move struct {
|
||||
Kind string `json:"kind"` // "draw" | "move" | "home" | "auto" | "concede"
|
||||
From string `json:"from"` // "waste" | "t0".."t6" | "f0".."f3"
|
||||
To string `json:"to"` // "t0".."t6" | "f0".."f3"
|
||||
Count int `json:"count"` // how many cards off the end of a tableau run; 0 means 1
|
||||
}
|
||||
|
||||
// New deals a game.
|
||||
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if t.Draw < 1 {
|
||||
return State{}, nil, ErrUnknownTier
|
||||
}
|
||||
d := cards.NewDeck(1)
|
||||
d.Shuffle(rng)
|
||||
return deal(bet, t, d, rakePct)
|
||||
}
|
||||
|
||||
// deal lays the board out. Split out from New so a test can pin the deck
|
||||
// instead of the seed.
|
||||
func deal(bet int64, t Tier, d cards.Deck, rakePct float64) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if len(d) != FullDeck {
|
||||
return State{}, nil, errors.New("klondike: a solitaire deck is 52 cards")
|
||||
}
|
||||
s := State{Tier: t, Bet: bet, RakePct: rakePct, Phase: PhasePlaying}
|
||||
|
||||
// The classic lay-out: column i gets i+1 cards, the last of them face up.
|
||||
for i := 0; i < Piles; i++ {
|
||||
for j := 0; j <= i; j++ {
|
||||
c, _ := d.Draw()
|
||||
if j == i {
|
||||
s.Table[i].Up = append(s.Table[i].Up, c)
|
||||
} else {
|
||||
s.Table[i].Down = append(s.Table[i].Down, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Stock = d
|
||||
return s, []Event{s.event("deal", nil, "", "")}, nil
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. A legal move in, the new board and what happened out.
|
||||
// An error means the move was illegal and the caller's state is untouched.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
// The move is played against a copy, and an illegal one hands the original
|
||||
// back untouched. Nothing below mutates before it has decided the move is
|
||||
// legal — but "nothing below mutates early" is an invariant seven functions
|
||||
// have to keep, and this is one line that doesn't need them to.
|
||||
orig := s
|
||||
s = s.clone()
|
||||
|
||||
var evs []Event
|
||||
var err error
|
||||
switch m.Kind {
|
||||
case "draw":
|
||||
evs, err = s.draw()
|
||||
case "move":
|
||||
evs, err = s.move(m.From, m.To, m.Count)
|
||||
case "home":
|
||||
evs, err = s.home(m.From)
|
||||
case "auto":
|
||||
evs, err = s.auto()
|
||||
case "concede":
|
||||
s.settle(OutcomeCashed, &evs)
|
||||
return s, evs, nil
|
||||
default:
|
||||
return orig, nil, ErrUnknownMove
|
||||
}
|
||||
if err != nil {
|
||||
return orig, nil, err
|
||||
}
|
||||
s.Moves++
|
||||
|
||||
// A cleared board settles itself. Nothing else does: a board with no move left
|
||||
// on it is not something the engine gets to decide, because "no move left" in
|
||||
// Klondike depends on cards nobody has turned over yet.
|
||||
if s.cleared() {
|
||||
s.settle(OutcomeCleared, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// ---- the moves -------------------------------------------------------------
|
||||
|
||||
// draw turns cards off the stock, or puts the waste back under it if the stock
|
||||
// is spent and the tier still owes a pass.
|
||||
func (s *State) draw() ([]Event, error) {
|
||||
if len(s.Stock) == 0 {
|
||||
if len(s.Waste) == 0 {
|
||||
return nil, ErrNoDraw
|
||||
}
|
||||
// Passes is how many times you may go *through* the stock, so the number of
|
||||
// times you may turn it back over is one less than that. Zero means unlimited.
|
||||
if s.Tier.Passes > 0 && s.Recycles >= s.Tier.Passes-1 {
|
||||
return nil, ErrNoPasses
|
||||
}
|
||||
// The waste is turned over as a block, not reshuffled — so the card that
|
||||
// comes out first on the next pass is the one that came out first on this
|
||||
// one. Which means no reversal: the waste's *bottom* card is the one your
|
||||
// hand lands on when you flip the pile, and the bottom card is the one that
|
||||
// was drawn first. Reversing here would deal a different game on every pass
|
||||
// and quietly break the seed in the audit log.
|
||||
s.Stock = cards.Deck(s.Waste)
|
||||
s.Waste = nil
|
||||
s.Recycles++
|
||||
return []Event{s.event("recycle", nil, "waste", "stock")}, nil
|
||||
}
|
||||
|
||||
n := s.Tier.Draw
|
||||
if n > len(s.Stock) {
|
||||
n = len(s.Stock)
|
||||
}
|
||||
drawn := make([]cards.Card, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
c, _ := s.Stock.Draw()
|
||||
drawn = append(drawn, c)
|
||||
s.Waste = append(s.Waste, c)
|
||||
}
|
||||
return []Event{s.event("draw", drawn, "stock", "waste")}, nil
|
||||
}
|
||||
|
||||
// move takes cards from one pile and puts them on another.
|
||||
func (s *State) move(from, to string, count int) ([]Event, error) {
|
||||
if count < 1 {
|
||||
count = 1
|
||||
}
|
||||
lifted, err := s.peek(from, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.accepts(to, lifted) {
|
||||
return nil, ErrWontGo
|
||||
}
|
||||
if err := s.take(from, count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.put(to, lifted)
|
||||
|
||||
kind := "move"
|
||||
if isFoundation(to) {
|
||||
kind = "home"
|
||||
}
|
||||
evs := []Event{s.event(kind, lifted, from, to)}
|
||||
return s.withFlip(from, evs), nil
|
||||
}
|
||||
|
||||
// home sends the top card of a pile to the foundation that will take it. There
|
||||
// is only ever one, so the player doesn't have to say which.
|
||||
func (s *State) home(from string) ([]Event, error) {
|
||||
top, err := s.peek(from, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := "f" + strconv.Itoa(int(top[0].Suit))
|
||||
if !s.accepts(to, top) {
|
||||
return nil, ErrWontGo
|
||||
}
|
||||
return s.move(from, to, 1)
|
||||
}
|
||||
|
||||
// auto sends everything that can go home, home, and keeps doing it until nothing
|
||||
// else can. It is the finish button, and it is also the shortcut for the tail of
|
||||
// a board that is already decided.
|
||||
//
|
||||
// It can cost you: a two you needed on the tableau is a two that has gone home.
|
||||
// That is the player's call to make by pressing it, and it is the same call the
|
||||
// button makes in every other solitaire ever written.
|
||||
func (s *State) auto() ([]Event, error) {
|
||||
var evs []Event
|
||||
for {
|
||||
moved := false
|
||||
for _, from := range sources() {
|
||||
top, err := s.peek(from, 1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
to := "f" + strconv.Itoa(int(top[0].Suit))
|
||||
if !s.accepts(to, top) {
|
||||
continue
|
||||
}
|
||||
one, err := s.move(from, to, 1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
evs = append(evs, one...)
|
||||
moved = true
|
||||
}
|
||||
if !moved {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(evs) == 0 {
|
||||
return nil, ErrNothingHome
|
||||
}
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// sources are the piles auto() will lift a card off, in the order it tries them.
|
||||
func sources() []string {
|
||||
out := make([]string, 0, Piles+1)
|
||||
out = append(out, "waste")
|
||||
for i := 0; i < Piles; i++ {
|
||||
out = append(out, "t"+strconv.Itoa(i))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withFlip turns up the card a tableau column was hiding, if taking from it left
|
||||
// its face-down stack exposed. This is the only thing in the game that reveals a
|
||||
// card the player hadn't earned yet, so it is the only place it can happen.
|
||||
func (s *State) withFlip(from string, evs []Event) []Event {
|
||||
i, ok := tableauIndex(from)
|
||||
if !ok {
|
||||
return evs
|
||||
}
|
||||
p := &s.Table[i]
|
||||
if len(p.Up) > 0 || len(p.Down) == 0 {
|
||||
return evs
|
||||
}
|
||||
c := p.Down[len(p.Down)-1]
|
||||
p.Down = p.Down[:len(p.Down)-1]
|
||||
p.Up = append(p.Up, c)
|
||||
return append(evs, s.event("flip", []cards.Card{c}, from, from))
|
||||
}
|
||||
|
||||
// ---- piles -----------------------------------------------------------------
|
||||
|
||||
// peek returns the top `count` cards of a pile without taking them, and refuses
|
||||
// a run that isn't one you could lift: a tableau run has to descend in rank and
|
||||
// alternate colour all the way down, exactly as it does on the felt.
|
||||
func (s *State) peek(name string, count int) ([]cards.Card, error) {
|
||||
switch {
|
||||
case name == "waste":
|
||||
if count != 1 {
|
||||
return nil, ErrNotASequence // the waste is a pile, not a run: one card, the top one
|
||||
}
|
||||
if len(s.Waste) == 0 {
|
||||
return nil, ErrEmptyPile
|
||||
}
|
||||
return []cards.Card{s.Waste[len(s.Waste)-1]}, nil
|
||||
|
||||
case isFoundation(name):
|
||||
i, ok := foundationIndex(name)
|
||||
if !ok {
|
||||
return nil, ErrBadPile
|
||||
}
|
||||
if count != 1 {
|
||||
return nil, ErrNotASequence
|
||||
}
|
||||
f := s.Found[i]
|
||||
if len(f) == 0 {
|
||||
return nil, ErrEmptyPile
|
||||
}
|
||||
return []cards.Card{f[len(f)-1]}, nil
|
||||
|
||||
default:
|
||||
i, ok := tableauIndex(name)
|
||||
if !ok {
|
||||
return nil, ErrBadPile
|
||||
}
|
||||
up := s.Table[i].Up
|
||||
if len(up) == 0 {
|
||||
return nil, ErrEmptyPile
|
||||
}
|
||||
if count > len(up) {
|
||||
return nil, ErrNotASequence
|
||||
}
|
||||
run := up[len(up)-count:]
|
||||
if !isRun(run) {
|
||||
return nil, ErrNotASequence
|
||||
}
|
||||
return append([]cards.Card(nil), run...), nil
|
||||
}
|
||||
}
|
||||
|
||||
// take removes the top `count` cards. peek has already vetted them.
|
||||
func (s *State) take(name string, count int) error {
|
||||
switch {
|
||||
case name == "waste":
|
||||
s.Waste = s.Waste[:len(s.Waste)-count]
|
||||
return nil
|
||||
case isFoundation(name):
|
||||
i, _ := foundationIndex(name)
|
||||
s.Found[i] = s.Found[i][:len(s.Found[i])-count]
|
||||
return nil
|
||||
default:
|
||||
i, ok := tableauIndex(name)
|
||||
if !ok {
|
||||
return ErrBadPile
|
||||
}
|
||||
s.Table[i].Up = s.Table[i].Up[:len(s.Table[i].Up)-count]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// put drops cards onto a pile. accepts has already vetted them.
|
||||
func (s *State) put(name string, cs []cards.Card) {
|
||||
if isFoundation(name) {
|
||||
i, _ := foundationIndex(name)
|
||||
s.Found[i] = append(s.Found[i], cs...)
|
||||
return
|
||||
}
|
||||
i, _ := tableauIndex(name)
|
||||
s.Table[i].Up = append(s.Table[i].Up, cs...)
|
||||
}
|
||||
|
||||
// accepts is the rule the whole game is made of: what may be put where.
|
||||
//
|
||||
// A foundation takes its own suit in order from the ace, one card at a time. A
|
||||
// tableau column takes a run that descends by one and alternates colour from its
|
||||
// top card, and an empty column takes a King and nothing else.
|
||||
func (s *State) accepts(name string, cs []cards.Card) bool {
|
||||
if len(cs) == 0 {
|
||||
return false
|
||||
}
|
||||
if isFoundation(name) {
|
||||
i, ok := foundationIndex(name)
|
||||
if !ok || len(cs) != 1 {
|
||||
return false
|
||||
}
|
||||
c := cs[0]
|
||||
return int(c.Suit) == i && int(c.Rank) == len(s.Found[i])+1
|
||||
}
|
||||
|
||||
i, ok := tableauIndex(name)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !isRun(cs) {
|
||||
return false
|
||||
}
|
||||
up := s.Table[i].Up
|
||||
if len(up) == 0 {
|
||||
// An empty column is the most valuable thing on the board, so it costs a
|
||||
// King to take one. A column with cards still face-down under it is not
|
||||
// empty, and Up being empty there can't happen: withFlip turns one over.
|
||||
return cs[0].Rank == cards.King && len(s.Table[i].Down) == 0
|
||||
}
|
||||
top := up[len(up)-1]
|
||||
return int(cs[0].Rank) == int(top.Rank)-1 && cs[0].Red() != top.Red()
|
||||
}
|
||||
|
||||
// isRun reports whether these cards, in this order, are a tableau sequence:
|
||||
// descending by one, alternating colour.
|
||||
func isRun(cs []cards.Card) bool {
|
||||
for i := 1; i < len(cs); i++ {
|
||||
if int(cs[i].Rank) != int(cs[i-1].Rank)-1 || cs[i].Red() == cs[i-1].Red() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isFoundation(name string) bool { return strings.HasPrefix(name, "f") }
|
||||
|
||||
func tableauIndex(name string) (int, bool) { return pileIndex(name, "t", Piles) }
|
||||
|
||||
func foundationIndex(name string) (int, bool) { return pileIndex(name, "f", Foundations) }
|
||||
|
||||
func pileIndex(name, prefix string, n int) (int, bool) {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
i, err := strconv.Atoi(name[len(prefix):])
|
||||
if err != nil || i < 0 || i >= n {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
// Home is how many cards have reached the foundations. It is the only number in
|
||||
// this game that the payout depends on.
|
||||
func (s State) Home() int {
|
||||
n := 0
|
||||
for _, f := range s.Found {
|
||||
n += len(f)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// PerCard is what one card home is worth, before the rake. The felt quotes this
|
||||
// because "2.2×" tells a player nothing about a game where the multiple is paid
|
||||
// out a fifty-second at a time.
|
||||
func (s State) PerCard() float64 {
|
||||
return float64(s.Bet) * s.Tier.Base / float64(FullDeck)
|
||||
}
|
||||
|
||||
// Earned is the gross: what the cards home have bought back, before the house
|
||||
// takes anything. Computed from the total rather than card by card, so 52 cards
|
||||
// home pays the tier's multiple exactly instead of the multiple less 52 roundings.
|
||||
func (s State) Earned() int64 {
|
||||
return int64(math.Floor(float64(s.Bet) * s.Tier.Base * float64(s.Home()) / float64(FullDeck)))
|
||||
}
|
||||
|
||||
// Pays is what stopping *right now* would actually put back on the player's
|
||||
// stack: the gross, less the house's cut of anything above the stake.
|
||||
//
|
||||
// The felt shows this number while the game is still running and settle() lands
|
||||
// on it, and they are the same function for the reason hangman's are: the moment
|
||||
// they are two sums, the table is quoting a payout it doesn't honour.
|
||||
//
|
||||
// Unlike the other games it can be less than the stake, and can be zero. That is
|
||||
// the game — you bought the deck, and a deck that gives you nothing owes you
|
||||
// nothing.
|
||||
func (s State) Pays() int64 {
|
||||
total := s.Earned()
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake > 0 {
|
||||
total -= rake
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// rakeNow is the house's cut if the board were cashed right now — the other half
|
||||
// of what Pays works out.
|
||||
func (s State) rakeNow() int64 {
|
||||
profit := s.Earned() - s.Bet
|
||||
if profit <= 0 {
|
||||
return 0
|
||||
}
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// cleared reports whether every card is home.
|
||||
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
|
||||
// finish button out rather than letting it be pressed at a board that has nothing
|
||||
// for it.
|
||||
func (s State) CanAuto() bool {
|
||||
for _, from := range sources() {
|
||||
top, err := (&s).peek(from, 1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if (&s).accepts("f"+strconv.Itoa(int(top[0].Suit)), top) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PassesLeft is how many more times the player may go through the stock,
|
||||
// counting the one they are in. -1 means unlimited.
|
||||
func (s State) PassesLeft() int {
|
||||
if s.Tier.Passes <= 0 {
|
||||
return -1
|
||||
}
|
||||
left := s.Tier.Passes - s.Recycles
|
||||
if left < 0 {
|
||||
return 0
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
// settle closes the game at whatever is on the board. Same rule as everywhere
|
||||
// else in the room: the rake comes out of winnings, never out of the stake.
|
||||
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||
s.Outcome = o
|
||||
s.Phase = PhaseDone
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
*evs = append(*evs, s.event("settle", nil, "", string(o)))
|
||||
}
|
||||
|
||||
// event stamps an event with the two numbers the felt's meter reads off it, so
|
||||
// the browser never has to work out what the board is worth.
|
||||
func (s State) event(kind string, cs []cards.Card, from, to string) Event {
|
||||
return Event{
|
||||
Kind: kind, Cards: cs, From: from, To: to,
|
||||
Home: s.Home(), Pays: s.Pays(),
|
||||
}
|
||||
}
|
||||
|
||||
// clone deep-copies everything with a backing array, so a derived state shares
|
||||
// none of it with the one it came from and a board can be replayed freely.
|
||||
func (s State) clone() State {
|
||||
s.Stock = append(cards.Deck(nil), s.Stock...)
|
||||
s.Waste = append([]cards.Card(nil), s.Waste...)
|
||||
for i := range s.Table {
|
||||
s.Table[i].Down = append([]cards.Card(nil), s.Table[i].Down...)
|
||||
s.Table[i].Up = append([]cards.Card(nil), s.Table[i].Up...)
|
||||
}
|
||||
for i := range s.Found {
|
||||
s.Found[i] = append([]cards.Card(nil), s.Found[i]...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
package klondike
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
const rake = 0.05
|
||||
|
||||
func vegas() Tier { t, _ := TierBySlug("vegas"); return t }
|
||||
func patient() Tier { t, _ := TierBySlug("patient"); return t }
|
||||
func cut() Tier { t, _ := TierBySlug("cutthroat"); return t }
|
||||
|
||||
func card(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
|
||||
|
||||
// ordered builds the 52 cards in a fixed order — the deck deal() would get if
|
||||
// the shuffle were the identity. Tests that care about the board build their own.
|
||||
func ordered() cards.Deck { return cards.NewDeck(1) }
|
||||
|
||||
func mustDeal(t *testing.T, bet int64, tier Tier, d cards.Deck) State {
|
||||
t.Helper()
|
||||
s, evs, err := deal(bet, tier, d, rake)
|
||||
if err != nil {
|
||||
t.Fatalf("deal: %v", err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "deal" {
|
||||
t.Fatalf("deal events = %+v, want one deal", evs)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func apply(t *testing.T, s State, m Move) (State, []Event) {
|
||||
t.Helper()
|
||||
next, evs, err := ApplyMove(s, m)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyMove(%+v): %v", m, err)
|
||||
}
|
||||
return next, evs
|
||||
}
|
||||
|
||||
func refuses(t *testing.T, s State, m Move, want error) {
|
||||
t.Helper()
|
||||
next, evs, err := ApplyMove(s, m)
|
||||
if err == nil {
|
||||
t.Fatalf("ApplyMove(%+v) was allowed, want %v", m, want)
|
||||
}
|
||||
if want != nil && err != want {
|
||||
t.Fatalf("ApplyMove(%+v) = %v, want %v", m, err, want)
|
||||
}
|
||||
if evs != nil {
|
||||
t.Errorf("an illegal move emitted events: %+v", evs)
|
||||
}
|
||||
// The board an illegal move hands back must be the one it was given. This is
|
||||
// the whole contract of the reducer, and it's cheap to check by value.
|
||||
if !sameBoard(next, s) {
|
||||
t.Errorf("an illegal move changed the board")
|
||||
}
|
||||
}
|
||||
|
||||
func sameBoard(a, b State) bool {
|
||||
x, _ := json.Marshal(a)
|
||||
y, _ := json.Marshal(b)
|
||||
return string(x) == string(y)
|
||||
}
|
||||
|
||||
// ---- the deal --------------------------------------------------------------
|
||||
|
||||
func TestDealLaysOutTheBoard(t *testing.T) {
|
||||
s := mustDeal(t, 520, vegas(), ordered())
|
||||
|
||||
seen := 0
|
||||
for i := 0; i < Piles; i++ {
|
||||
p := s.Table[i]
|
||||
if len(p.Up) != 1 {
|
||||
t.Errorf("column %d has %d face up, want 1", i, len(p.Up))
|
||||
}
|
||||
if len(p.Down) != i {
|
||||
t.Errorf("column %d has %d face down, want %d", i, len(p.Down), i)
|
||||
}
|
||||
seen += len(p.Up) + len(p.Down)
|
||||
}
|
||||
if seen != 28 {
|
||||
t.Errorf("tableau holds %d cards, want 28", seen)
|
||||
}
|
||||
if len(s.Stock) != 24 {
|
||||
t.Errorf("stock is %d, want 24", len(s.Stock))
|
||||
}
|
||||
if s.Home() != 0 || s.Pays() != 0 {
|
||||
t.Errorf("a fresh board is worth %d from %d home, want nothing", s.Pays(), s.Home())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDealRefusesABadStake(t *testing.T) {
|
||||
if _, _, err := deal(0, vegas(), ordered(), rake); err != ErrBadBet {
|
||||
t.Fatalf("deal(0) = %v, want ErrBadBet", err)
|
||||
}
|
||||
if _, _, err := New(-5, vegas(), rake, cards.NewRNG(1, 2)); err != ErrBadBet {
|
||||
t.Fatalf("New(-5) = %v, want ErrBadBet", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the stock -------------------------------------------------------------
|
||||
|
||||
func TestDrawTurnsTheTiersCount(t *testing.T) {
|
||||
for _, tier := range []Tier{patient(), vegas()} {
|
||||
s := mustDeal(t, 100, tier, ordered())
|
||||
next, evs := apply(t, s, Move{Kind: "draw"})
|
||||
if len(next.Waste) != tier.Draw {
|
||||
t.Errorf("%s: waste is %d after one draw, want %d", tier.Slug, len(next.Waste), tier.Draw)
|
||||
}
|
||||
if len(next.Stock) != 24-tier.Draw {
|
||||
t.Errorf("%s: stock is %d, want %d", tier.Slug, len(next.Stock), 24-tier.Draw)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "draw" || len(evs[0].Cards) != tier.Draw {
|
||||
t.Errorf("%s: draw events = %+v", tier.Slug, evs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The last pull off a short stock turns over what's left rather than refusing.
|
||||
func TestDrawTakesWhatIsLeft(t *testing.T) {
|
||||
s := mustDeal(t, 100, vegas(), ordered()) // 24 in the stock, drawing 3
|
||||
for i := 0; i < 7; i++ {
|
||||
s, _ = apply(t, s, Move{Kind: "draw"}) // 21 drawn, 3 left
|
||||
}
|
||||
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||
if len(s.Stock) != 0 || len(s.Waste) != 24 {
|
||||
t.Fatalf("stock %d waste %d, want 0 and 24", len(s.Stock), len(s.Waste))
|
||||
}
|
||||
refuses(t, drained(t, s), Move{Kind: "draw"}, ErrNoDraw)
|
||||
}
|
||||
|
||||
// drained empties the waste too, so there is genuinely nothing to turn over.
|
||||
func drained(t *testing.T, s State) State {
|
||||
t.Helper()
|
||||
s = s.clone()
|
||||
s.Waste = nil
|
||||
s.Stock = nil
|
||||
return s
|
||||
}
|
||||
|
||||
// The waste goes back under the stock in the order it came out — a recycle is a
|
||||
// pile being turned over, not reshuffled. If this ever reshuffled, the seed in
|
||||
// the audit log would stop replaying the game.
|
||||
func TestRecycleTurnsTheWasteOverInOrder(t *testing.T) {
|
||||
s := mustDeal(t, 100, patient(), ordered())
|
||||
want := append(cards.Deck(nil), s.Stock...)
|
||||
|
||||
for i := 0; i < 24; i++ {
|
||||
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||
}
|
||||
next, evs := apply(t, s, Move{Kind: "draw"})
|
||||
if len(evs) != 1 || evs[0].Kind != "recycle" {
|
||||
t.Fatalf("events = %+v, want a recycle", evs)
|
||||
}
|
||||
if len(next.Waste) != 0 {
|
||||
t.Errorf("waste is %d after a recycle, want empty", len(next.Waste))
|
||||
}
|
||||
for i := range want {
|
||||
if next.Stock[i] != want[i] {
|
||||
t.Fatalf("stock[%d] = %v after recycle, want %v — the pile was reshuffled",
|
||||
i, next.Stock[i], want[i])
|
||||
}
|
||||
}
|
||||
if next.Recycles != 1 {
|
||||
t.Errorf("recycles = %d, want 1", next.Recycles)
|
||||
}
|
||||
}
|
||||
|
||||
// Passes is how many times you may go *through* the stock, so it is one more
|
||||
// than the number of times you may turn it back over.
|
||||
func TestPassesRunOut(t *testing.T) {
|
||||
tests := []struct {
|
||||
tier Tier
|
||||
recycles int // how many turn-overs the tier should allow
|
||||
}{
|
||||
{cut(), 0}, // one pass: you never get to turn it back over
|
||||
{vegas(), 2}, // three passes: two turn-overs
|
||||
{patient(), -1}, // unlimited
|
||||
}
|
||||
for _, tc := range tests {
|
||||
s := mustDeal(t, 100, tc.tier, ordered())
|
||||
if got := s.PassesLeft(); tc.recycles < 0 && got != -1 {
|
||||
t.Errorf("%s: PassesLeft = %d, want -1 (unlimited)", tc.tier.Slug, got)
|
||||
}
|
||||
allowed := 0
|
||||
for i := 0; i < 5; i++ {
|
||||
// Empty the stock, then try to turn it over.
|
||||
for len(s.Stock) > 0 {
|
||||
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||
}
|
||||
next, _, err := ApplyMove(s, Move{Kind: "draw"})
|
||||
if err == ErrNoPasses {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.tier.Slug, err)
|
||||
}
|
||||
s = next
|
||||
allowed++
|
||||
}
|
||||
if tc.recycles < 0 {
|
||||
if allowed != 5 {
|
||||
t.Errorf("%s: only %d recycles allowed, want unlimited", tc.tier.Slug, allowed)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if allowed != tc.recycles {
|
||||
t.Errorf("%s: %d recycles allowed, want %d", tc.tier.Slug, allowed, tc.recycles)
|
||||
}
|
||||
if s.PassesLeft() != 1 {
|
||||
t.Errorf("%s: PassesLeft = %d on the last pass, want 1", tc.tier.Slug, s.PassesLeft())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the rules -------------------------------------------------------------
|
||||
|
||||
// board builds a State directly, so a rule can be tested against the position
|
||||
// that exercises it rather than against whatever a shuffle happened to deal.
|
||||
func board(tier Tier, bet int64) State {
|
||||
return State{Tier: tier, Bet: bet, RakePct: rake, Phase: PhasePlaying}
|
||||
}
|
||||
|
||||
func TestTableauTakesDescendingAlternatingColour(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{card(8, cards.Spades)} // black 8
|
||||
s.Table[1].Up = []cards.Card{card(7, cards.Hearts)} // red 7 — goes on the 8
|
||||
s.Table[2].Up = []cards.Card{card(7, cards.Clubs)} // black 7 — does not
|
||||
s.Table[3].Up = []cards.Card{card(6, cards.Hearts)} // red 6 — wrong rank for the 8
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
|
||||
if len(next.Table[0].Up) != 2 || next.Table[0].Up[1] != card(7, cards.Hearts) {
|
||||
t.Fatalf("the red seven didn't land on the black eight: %+v", next.Table[0].Up)
|
||||
}
|
||||
if len(next.Table[1].Up) != 0 {
|
||||
t.Errorf("the seven is still in its old column")
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "move" {
|
||||
t.Errorf("events = %+v, want one move", evs)
|
||||
}
|
||||
|
||||
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo) // same colour
|
||||
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrWontGo) // two below
|
||||
}
|
||||
|
||||
func TestOnlyAKingTakesAnEmptyColumn(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
// t0 is empty and has nothing under it.
|
||||
s.Table[1].Up = []cards.Card{card(cards.King, cards.Hearts)}
|
||||
s.Table[2].Up = []cards.Card{card(cards.Queen, cards.Spades)}
|
||||
|
||||
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo)
|
||||
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
|
||||
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0].Rank != cards.King {
|
||||
t.Fatalf("the king didn't take the empty column: %+v", next.Table[0].Up)
|
||||
}
|
||||
}
|
||||
|
||||
// A run comes off the tableau as a block, and only if it is a run.
|
||||
func TestLiftingARun(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{
|
||||
card(9, cards.Hearts), // red
|
||||
card(8, cards.Spades), // black
|
||||
card(7, cards.Diamonds), // red
|
||||
}
|
||||
s.Table[1].Up = []cards.Card{card(10, cards.Clubs)} // black 10 takes the red 9
|
||||
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "t0", To: "t1", Count: 3})
|
||||
if len(next.Table[1].Up) != 4 || len(next.Table[0].Up) != 0 {
|
||||
t.Fatalf("the run didn't move as a block: t0=%v t1=%v", next.Table[0].Up, next.Table[1].Up)
|
||||
}
|
||||
|
||||
// Not a run: same colour in the middle of it.
|
||||
bad := board(vegas(), 520)
|
||||
bad.Table[0].Up = []cards.Card{
|
||||
card(9, cards.Hearts),
|
||||
card(8, cards.Diamonds), // red on red
|
||||
}
|
||||
bad.Table[1].Up = []cards.Card{card(10, cards.Clubs)}
|
||||
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 2}, ErrNotASequence)
|
||||
|
||||
// And you can't lift more cards than the column has.
|
||||
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 9}, ErrNotASequence)
|
||||
}
|
||||
|
||||
// Taking the last face-up card off a column turns the next one over. This is the
|
||||
// only thing in the game that reveals a card, which is the point of the test.
|
||||
func TestTakingTheLastCardFlipsTheNextOne(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
hidden := card(cards.Queen, cards.Clubs)
|
||||
s.Table[0].Down = []cards.Card{card(2, cards.Spades), hidden}
|
||||
s.Table[0].Up = []cards.Card{card(7, cards.Hearts)}
|
||||
s.Table[1].Up = []cards.Card{card(8, cards.Spades)}
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "move", From: "t0", To: "t1"})
|
||||
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0] != hidden {
|
||||
t.Fatalf("the hidden card didn't turn over: %+v", next.Table[0].Up)
|
||||
}
|
||||
if len(next.Table[0].Down) != 1 {
|
||||
t.Errorf("face-down stack is %d, want 1", len(next.Table[0].Down))
|
||||
}
|
||||
if len(evs) != 2 || evs[1].Kind != "flip" || evs[1].Cards[0] != hidden {
|
||||
t.Fatalf("events = %+v, want a move then a flip carrying the card", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoundationsBuildUpBySuitFromTheAce(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
|
||||
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
|
||||
s.Table[2].Up = []cards.Card{card(2, cards.Spades)}
|
||||
s.Table[3].Up = []cards.Card{card(3, cards.Hearts)}
|
||||
|
||||
// A two can't start a foundation.
|
||||
refuses(t, s, Move{Kind: "home", From: "t1"}, ErrWontGo)
|
||||
|
||||
s, evs := apply(t, s, Move{Kind: "home", From: "t0"})
|
||||
if len(s.Found[cards.Hearts]) != 1 {
|
||||
t.Fatalf("the ace didn't go home: %+v", s.Found)
|
||||
}
|
||||
if evs[0].Kind != "home" || evs[0].To != "f"+strconv.Itoa(int(cards.Hearts)) {
|
||||
t.Fatalf("event = %+v, want a home to the hearts pile", evs[0])
|
||||
}
|
||||
if evs[0].Home != 1 {
|
||||
t.Errorf("event carries Home=%d, want 1", evs[0].Home)
|
||||
}
|
||||
|
||||
// The three can't jump the two, and the two of spades can't go on hearts.
|
||||
refuses(t, s, Move{Kind: "home", From: "t3"}, ErrWontGo)
|
||||
refuses(t, s, Move{Kind: "move", From: "t2", To: "f" + strconv.Itoa(int(cards.Hearts))}, ErrWontGo)
|
||||
|
||||
s, _ = apply(t, s, Move{Kind: "home", From: "t1"})
|
||||
if s.Home() != 2 {
|
||||
t.Errorf("Home = %d, want 2", s.Home())
|
||||
}
|
||||
}
|
||||
|
||||
// A card can come back off a foundation — a real rule, and one that matters when
|
||||
// you need a low card to move a column. The payout follows it back down, because
|
||||
// the payout reads the board rather than counting events.
|
||||
func TestACardComesBackOffAFoundation(t *testing.T) {
|
||||
s := board(vegas(), 5200)
|
||||
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
|
||||
s.Table[0].Up = []cards.Card{card(3, cards.Spades)}
|
||||
|
||||
before := s.Pays()
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "f" + strconv.Itoa(int(cards.Hearts)), To: "t0"})
|
||||
if len(next.Found[cards.Hearts]) != 1 || len(next.Table[0].Up) != 2 {
|
||||
t.Fatalf("the two didn't come back down: found=%v t0=%v", next.Found[cards.Hearts], next.Table[0].Up)
|
||||
}
|
||||
if next.Home() != 1 {
|
||||
t.Errorf("Home = %d after taking a card back, want 1", next.Home())
|
||||
}
|
||||
if next.Pays() >= before {
|
||||
t.Errorf("Pays = %d after taking a card back, want less than %d", next.Pays(), before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWasteGivesUpItsTopCardOnly(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Waste = []cards.Card{card(5, cards.Spades), card(7, cards.Hearts)}
|
||||
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
|
||||
|
||||
// The 5 is under the 7 and is not available, however much you'd like it.
|
||||
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0", Count: 2}, ErrNotASequence)
|
||||
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "waste", To: "t0"})
|
||||
if len(next.Waste) != 1 || next.Waste[0] != card(5, cards.Spades) {
|
||||
t.Fatalf("the wrong card left the waste: %+v", next.Waste)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyPilesAndNonsensePiles(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
|
||||
|
||||
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0"}, ErrEmptyPile)
|
||||
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrEmptyPile)
|
||||
refuses(t, s, Move{Kind: "move", From: "t9", To: "t0"}, ErrBadPile)
|
||||
refuses(t, s, Move{Kind: "move", From: "t0", To: "t9"}, ErrWontGo)
|
||||
refuses(t, s, Move{Kind: "move", From: "banana", To: "t0"}, ErrBadPile)
|
||||
refuses(t, s, Move{Kind: "sing"}, ErrUnknownMove)
|
||||
}
|
||||
|
||||
// ---- auto ------------------------------------------------------------------
|
||||
|
||||
func TestAutoSendsEverythingItCanHome(t *testing.T) {
|
||||
s := board(vegas(), 5200)
|
||||
// Two aces and the hearts two, sitting on top of three columns.
|
||||
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
|
||||
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
|
||||
s.Table[2].Up = []cards.Card{card(cards.Ace, cards.Spades)}
|
||||
s.Table[3].Up = []cards.Card{card(9, cards.Clubs)} // goes nowhere
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "auto"})
|
||||
if next.Home() != 3 {
|
||||
t.Fatalf("Home = %d after auto, want 3 (two aces and the two)", next.Home())
|
||||
}
|
||||
if len(next.Table[3].Up) != 1 {
|
||||
t.Errorf("the nine went somewhere it couldn't go")
|
||||
}
|
||||
homes := 0
|
||||
for _, e := range evs {
|
||||
if e.Kind == "home" {
|
||||
homes++
|
||||
}
|
||||
}
|
||||
if homes != 3 {
|
||||
t.Errorf("auto emitted %d home events, want 3 — the table has to animate each one", homes)
|
||||
}
|
||||
|
||||
// Nothing left to do: the button says so rather than doing nothing quietly.
|
||||
if next.CanAuto() {
|
||||
t.Errorf("CanAuto is true with only a nine on the board")
|
||||
}
|
||||
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 number the felt quotes while you play and the number settle() lands on are
|
||||
// the same function. Hangman had these as two sums once and the table advertised
|
||||
// a payout the house didn't honour; this asserts they can't drift here.
|
||||
func TestTheQuoteIsThePayout(t *testing.T) {
|
||||
s := board(vegas(), 1000)
|
||||
for home := 0; home <= FullDeck; home++ {
|
||||
s.Found = [Foundations][]cards.Card{}
|
||||
left := home
|
||||
for suit := 0; suit < Foundations && left > 0; suit++ {
|
||||
n := left
|
||||
if n > 13 {
|
||||
n = 13
|
||||
}
|
||||
for r := 1; r <= n; r++ {
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
left -= n
|
||||
}
|
||||
if s.Home() != home {
|
||||
t.Fatalf("built a board with %d home, wanted %d", s.Home(), home)
|
||||
}
|
||||
|
||||
quoted := s.Pays()
|
||||
var evs []Event
|
||||
done := s.clone()
|
||||
done.settle(OutcomeCashed, &evs)
|
||||
if done.Payout != quoted {
|
||||
t.Fatalf("%d home: the felt quoted %d and settle paid %d", home, quoted, done.Payout)
|
||||
}
|
||||
if done.Payout+done.Rake != done.Earned() {
|
||||
t.Fatalf("%d home: payout %d + rake %d != earned %d",
|
||||
home, done.Payout, done.Rake, done.Earned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFullBoardPaysTheTiersMultiple(t *testing.T) {
|
||||
for _, tier := range Tiers {
|
||||
s := board(tier, 1000)
|
||||
for suit := 0; suit < Foundations; suit++ {
|
||||
for r := 1; r <= 13; r++ {
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
}
|
||||
// Gross is the multiple exactly — computed from the total, not summed 52
|
||||
// times, so it doesn't bleed a rounding per card.
|
||||
want := int64(float64(s.Bet) * tier.Base)
|
||||
if s.Earned() != want {
|
||||
t.Errorf("%s: a cleared board earns %d, want %d", tier.Slug, s.Earned(), want)
|
||||
}
|
||||
// And the rake comes out of the winnings, never the stake.
|
||||
profit := want - s.Bet
|
||||
if s.Pays() != want-int64(float64(profit)*rake) {
|
||||
t.Errorf("%s: pays %d, want %d less %v%% of the profit", tier.Slug, s.Pays(), want, rake*100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty board owes nothing, and is not charged a fee for owing nothing.
|
||||
func TestNothingHomePaysNothing(t *testing.T) {
|
||||
s := board(cut(), 500)
|
||||
if s.Pays() != 0 || s.rakeNow() != 0 {
|
||||
t.Fatalf("an empty board pays %d and rakes %d, want nothing either way", s.Pays(), s.rakeNow())
|
||||
}
|
||||
var evs []Event
|
||||
s.settle(OutcomeCashed, &evs)
|
||||
if s.Payout != 0 || s.Net() != -500 {
|
||||
t.Errorf("payout %d net %d, want 0 and -500", s.Payout, s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
// Below break-even the player is down but is not raked: there is no profit to
|
||||
// take a cut of.
|
||||
func TestNoRakeBelowTheStake(t *testing.T) {
|
||||
tier := vegas()
|
||||
s := board(tier, 5200)
|
||||
for i := 0; i < tier.BreakEven()-1; i++ {
|
||||
suit, r := i/13, i%13+1
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
if s.Earned() > s.Bet {
|
||||
t.Fatalf("break-even is meant to be the first card that gets you square, but %d earns %d on a %d stake",
|
||||
s.Home(), s.Earned(), s.Bet)
|
||||
}
|
||||
if s.rakeNow() != 0 {
|
||||
t.Errorf("raked %d off a losing board", s.rakeNow())
|
||||
}
|
||||
if s.Pays() != s.Earned() {
|
||||
t.Errorf("pays %d, want the full %d — nothing to rake", s.Pays(), s.Earned())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakEvenIsTheCardThatGetsYouSquare(t *testing.T) {
|
||||
for _, tier := range Tiers {
|
||||
s := board(tier, 5200)
|
||||
for i := 0; i < tier.BreakEven(); i++ {
|
||||
suit, r := i/13, i%13+1
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
if s.Earned() < s.Bet {
|
||||
t.Errorf("%s: %d cards home earns %d on a %d stake — break-even is quoted too low",
|
||||
tier.Slug, s.Home(), s.Earned(), s.Bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- settling --------------------------------------------------------------
|
||||
|
||||
func TestConcedeCashesTheBoard(t *testing.T) {
|
||||
s := board(vegas(), 5200)
|
||||
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
|
||||
want := s.Pays()
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "concede"})
|
||||
if next.Phase != PhaseDone || next.Outcome != OutcomeCashed {
|
||||
t.Fatalf("phase %q outcome %q, want done/cashed", next.Phase, next.Outcome)
|
||||
}
|
||||
if next.Payout != want {
|
||||
t.Errorf("cashed for %d, want the %d the board was quoting", next.Payout, want)
|
||||
}
|
||||
if evs[len(evs)-1].Kind != "settle" {
|
||||
t.Errorf("no settle event: %+v", evs)
|
||||
}
|
||||
refuses(t, next, Move{Kind: "draw"}, ErrGameOver)
|
||||
}
|
||||
|
||||
// The last card home ends the game on its own — the player doesn't have to tell
|
||||
// the table they've won.
|
||||
func TestTheLastCardHomeClearsTheBoard(t *testing.T) {
|
||||
s := board(vegas(), 1000)
|
||||
for suit := 0; suit < Foundations; suit++ {
|
||||
top := 13
|
||||
if suit == int(cards.Clubs) {
|
||||
top = 12 // the king of clubs is the one card still out
|
||||
}
|
||||
for r := 1; r <= top; r++ {
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
}
|
||||
s.Table[0].Up = []cards.Card{card(cards.King, cards.Clubs)}
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "home", From: "t0"})
|
||||
if next.Phase != PhaseDone || next.Outcome != OutcomeCleared {
|
||||
t.Fatalf("phase %q outcome %q, want done/cleared", next.Phase, next.Outcome)
|
||||
}
|
||||
if next.Payout != int64(float64(1000)*vegas().Base)-int64(float64(int64(float64(1000)*vegas().Base)-1000)*rake) {
|
||||
t.Errorf("a cleared board paid %d", next.Payout)
|
||||
}
|
||||
if evs[len(evs)-1].Kind != "settle" {
|
||||
t.Errorf("the winning card didn't settle the game: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the shape of the thing ------------------------------------------------
|
||||
|
||||
// A game survives a redeploy: the whole state, shoe and face-down cards and all,
|
||||
// goes through JSON and comes back the same board.
|
||||
func TestAGameSurvivesJSON(t *testing.T) {
|
||||
s, _, err := New(500, cut(), rake, cards.NewRNG(7, 11))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
s, _, _ = ApplyMove(s, Move{Kind: "draw"})
|
||||
}
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameBoard(s, back) {
|
||||
t.Fatal("the board didn't come back the same")
|
||||
}
|
||||
}
|
||||
|
||||
// The same seed deals the same board. This is what lets a disputed game be dealt
|
||||
// again exactly as it fell, and it is why the RNG is threaded rather than global.
|
||||
func TestASeedDealsTheSameBoard(t *testing.T) {
|
||||
a, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameBoard(a, b) {
|
||||
t.Fatal("the same seed dealt two different boards")
|
||||
}
|
||||
|
||||
c, _, _ := New(100, vegas(), rake, cards.NewRNG(43, 99))
|
||||
if sameBoard(a, c) {
|
||||
t.Fatal("two seeds dealt the same board")
|
||||
}
|
||||
}
|
||||
|
||||
// Every card is on the board exactly once, whatever you do to it. A move that
|
||||
// duplicated a card would be a move that printed money.
|
||||
func TestNoCardIsEverLostOrDuplicated(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(3, 5))
|
||||
s, _, err := New(1000, patient(), rake, rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
countDeck(t, s, "the deal")
|
||||
|
||||
// Play a long random game: whatever the fuzzer stumbles into, the deck holds.
|
||||
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
|
||||
m := randomMove(rng)
|
||||
next, _, err := ApplyMove(s, m)
|
||||
if err != nil {
|
||||
continue // an illegal move is a fine thing for a fuzzer to find
|
||||
}
|
||||
s = next
|
||||
countDeck(t, s, "after "+m.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func randomMove(rng *rand.Rand) Move {
|
||||
pile := func() string {
|
||||
switch rng.IntN(3) {
|
||||
case 0:
|
||||
return "waste"
|
||||
case 1:
|
||||
return "t" + strconv.Itoa(rng.IntN(Piles))
|
||||
default:
|
||||
return "f" + strconv.Itoa(rng.IntN(Foundations))
|
||||
}
|
||||
}
|
||||
switch rng.IntN(10) {
|
||||
case 0, 1, 2, 3:
|
||||
return Move{Kind: "draw"}
|
||||
case 4:
|
||||
return Move{Kind: "home", From: pile()}
|
||||
case 5:
|
||||
return Move{Kind: "auto"}
|
||||
default:
|
||||
return Move{Kind: "move", From: pile(), To: pile(), Count: 1 + rng.IntN(4)}
|
||||
}
|
||||
}
|
||||
|
||||
func countDeck(t *testing.T, s State, when string) {
|
||||
t.Helper()
|
||||
seen := map[cards.Card]int{}
|
||||
add := func(cs []cards.Card) {
|
||||
for _, c := range cs {
|
||||
seen[c]++
|
||||
}
|
||||
}
|
||||
add(s.Stock)
|
||||
add(s.Waste)
|
||||
for _, p := range s.Table {
|
||||
add(p.Down)
|
||||
add(p.Up)
|
||||
}
|
||||
for _, f := range s.Found {
|
||||
add(f)
|
||||
}
|
||||
if len(seen) != FullDeck {
|
||||
t.Fatalf("%s: %d distinct cards on the board, want 52", when, len(seen))
|
||||
}
|
||||
for c, n := range seen {
|
||||
if n != 1 {
|
||||
t.Fatalf("%s: %v appears %d times", when, c, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The face-up run in every tableau column is always a legal run, and a column
|
||||
// with cards face-up never has an unturned card left under it. Both are things
|
||||
// the *rules* keep true, so a fuzzer that breaks them has found a real bug.
|
||||
func TestTheBoardStaysWellFormed(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(11, 13))
|
||||
s, _, err := New(1000, vegas(), rake, rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
|
||||
next, _, err := ApplyMove(s, randomMove(rng))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s = next
|
||||
for j, p := range s.Table {
|
||||
if !isRun(p.Up) {
|
||||
t.Fatalf("column %d holds a run that isn't one: %v", j, p.Up)
|
||||
}
|
||||
if len(p.Up) == 0 && len(p.Down) > 0 {
|
||||
t.Fatalf("column %d has %d cards face down and nothing turned over", j, len(p.Down))
|
||||
}
|
||||
}
|
||||
for suit, f := range s.Found {
|
||||
for r, c := range f {
|
||||
if int(c.Suit) != suit || int(c.Rank) != r+1 {
|
||||
t.Fatalf("foundation %d holds %v at position %d", suit, c, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// Package trivia is a pure trivia-ladder engine, played for chips.
|
||||
//
|
||||
// Same seam as blackjack and hangman: ApplyMove(state, move, now) (state,
|
||||
// events, error), where an error means the move was illegal and nothing else.
|
||||
// The one difference is that clock: trivia is the only game in the room where
|
||||
// *when* you move changes what it pays, and a pure reducer cannot own a timer.
|
||||
// So the time is an argument. The engine stays a value in, value out, and the
|
||||
// only thing that knows what o'clock it is remains the caller.
|
||||
//
|
||||
// The shape is a ladder. You stake once, and then answer a run of questions:
|
||||
// every right answer multiplies what the stake is worth, a wrong one loses the
|
||||
// lot, and you may walk with what you've built at any point after the first.
|
||||
// It is the oldest quiz-show bet there is — the tension is entirely in whether
|
||||
// you take the money.
|
||||
//
|
||||
// The reason for the clock is less pretty: trivia answers are googlable, and a
|
||||
// game that paid the same for a slow right answer as a fast one would be a game
|
||||
// about typing into another tab. So the multiple a question is worth decays
|
||||
// from Fast to Buzzer across the tier's time limit, and running out of time
|
||||
// loses exactly as much as being wrong. The countdown in the browser is
|
||||
// decoration; this is the clock that counts.
|
||||
package trivia
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("trivia: the game is already over")
|
||||
ErrUnknownMove = errors.New("trivia: unknown move")
|
||||
ErrBadBet = errors.New("trivia: bet must be positive")
|
||||
ErrUnknownTier = errors.New("trivia: no such tier")
|
||||
ErrShortLadder = errors.New("trivia: not enough questions to build a ladder")
|
||||
ErrNothingBanked = errors.New("trivia: answer one before you walk")
|
||||
)
|
||||
|
||||
// Rungs is how long the ladder is. Clearing it is a win in itself: the run ends
|
||||
// and banks, because a ladder with no top is just a slot machine you can't stop
|
||||
// playing, and eventually every player loses everything to one bad question.
|
||||
const Rungs = 12
|
||||
|
||||
// Tier is a difficulty, chosen before the bet. It sets three things that move
|
||||
// together: how hard the questions are, how long you get, and what a right
|
||||
// answer is worth. Hard questions pay more and give you less time to look them
|
||||
// up, which is the whole bargain.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Difficulty string `json:"difficulty"` // what OpenTDB calls it: easy | medium | hard
|
||||
Fast float64 `json:"fast"` // what a right answer multiplies by, answered instantly
|
||||
Buzzer float64 `json:"buzzer"` // ...and what it's worth answered on the last tick
|
||||
Limit int `json:"limit"` // seconds on the clock, per question
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// Tiers are the three tables.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "easy", Name: "Easy", Difficulty: "easy", Fast: 1.30, Buzzer: 1.10, Limit: 20,
|
||||
Blurb: "Things you know. The clock is the only thing in your way."},
|
||||
{Slug: "medium", Name: "Medium", Difficulty: "medium", Fast: 1.55, Buzzer: 1.20, Limit: 18,
|
||||
Blurb: "Things you nearly know."},
|
||||
{Slug: "hard", Name: "Hard", Difficulty: "hard", Fast: 1.90, Buzzer: 1.30, Limit: 15,
|
||||
Blurb: "Things you don't. Fifteen seconds is not enough to find out."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier by the name the browser sent.
|
||||
func TierBySlug(slug string) (Tier, error) {
|
||||
for _, t := range Tiers {
|
||||
if t.Slug == slug {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Tier{}, ErrUnknownTier
|
||||
}
|
||||
|
||||
// Step is what a right answer multiplies the running total by, given how long
|
||||
// it took. Fast at nought seconds, Buzzer at the limit, straight line between.
|
||||
//
|
||||
// Answering at the buzzer still pays *something* — the decay is a reason to be
|
||||
// quick, not a punishment for thinking. The punishment for thinking too long is
|
||||
// the timeout, and that one takes everything.
|
||||
func (t Tier) Step(elapsed time.Duration) float64 {
|
||||
limit := t.Clock()
|
||||
switch {
|
||||
case elapsed <= 0:
|
||||
return t.Fast
|
||||
case elapsed >= limit:
|
||||
return t.Buzzer
|
||||
}
|
||||
speed := 1 - float64(elapsed)/float64(limit) // 1 answering instantly, 0 at the buzzer
|
||||
return t.Buzzer + (t.Fast-t.Buzzer)*speed
|
||||
}
|
||||
|
||||
// Clock is the tier's time limit as a duration.
|
||||
func (t Tier) Clock() time.Duration { return time.Duration(t.Limit) * time.Second }
|
||||
|
||||
// Question is one rung. It carries its own correct index, which is exactly why
|
||||
// a State never crosses the wire — the browser is sent the answers and not
|
||||
// which of them is right.
|
||||
type Question struct {
|
||||
Category string `json:"category"`
|
||||
Text string `json:"text"`
|
||||
Answers []string `json:"answers"` // already shuffled: the right one is not always first
|
||||
Correct int `json:"correct"` // index into Answers
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlaying Phase = "playing"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeWalked Outcome = "walked" // took the money
|
||||
OutcomeCleared Outcome = "cleared" // answered all twelve
|
||||
OutcomeWrong Outcome = "wrong" // picked the wrong one
|
||||
OutcomeTimeout Outcome = "timeout" // ran out of clock
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays.
|
||||
func (o Outcome) Won() bool { return o == OutcomeWalked || o == OutcomeCleared }
|
||||
|
||||
// State is one game. The ladder — every question, and every right answer — is
|
||||
// in here, which is why this value stays on the server. The browser gets a view
|
||||
// of the current rung and nothing about the ones ahead of it.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Ladder []Question `json:"ladder"` // the whole run, drawn up front
|
||||
Rung int `json:"rung"` // how many answered right; also the index of the live question
|
||||
|
||||
// AskedAt is when the current question was *put to the player*, by the
|
||||
// server's clock. It is the only clock in the game. A reload does not reset
|
||||
// it: you cannot stop time by refreshing.
|
||||
AskedAt time.Time `json:"asked_at"`
|
||||
|
||||
Multiple float64 `json:"multiple"` // 1.0 at the start; the product of every step earned
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "ask" | "right" | "wrong" | "timeout" | "settle"
|
||||
Choice int `json:"choice"` // what the player picked (-1 when they didn't)
|
||||
Correct int `json:"correct"` // which one was right — sent only once it's decided
|
||||
Step float64 `json:"step,omitempty"` // what this answer multiplied by
|
||||
Multiple float64 `json:"multiple,omitempty"` // the running total, after
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Move is a player action: pick an answer, or take the money.
|
||||
type Move struct {
|
||||
Choice int `json:"choice"` // index into the live question's answers
|
||||
Walk bool `json:"walk"`
|
||||
}
|
||||
|
||||
// New starts a game on a ladder of questions the caller has already drawn. The
|
||||
// engine does not reach for a database any more than blackjack reaches for a
|
||||
// deck: the questions arrive as a value, so a game is reproducible and a test
|
||||
// can pin every rung.
|
||||
//
|
||||
// The answers are shuffled here, with the caller's seeded rng, because a bank
|
||||
// that always stores the right answer first would otherwise be a game about
|
||||
// clicking first.
|
||||
func New(bet int64, t Tier, rakePct float64, qs []Question, now time.Time, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if len(qs) < Rungs {
|
||||
return State{}, nil, ErrShortLadder
|
||||
}
|
||||
ladder := make([]Question, Rungs)
|
||||
for i := range ladder {
|
||||
ladder[i] = shuffleAnswers(qs[i], rng)
|
||||
}
|
||||
s := State{
|
||||
Tier: t, Ladder: ladder, RakePct: rakePct,
|
||||
Multiple: 1,
|
||||
AskedAt: now,
|
||||
Bet: bet, Phase: PhasePlaying,
|
||||
}
|
||||
return s, []Event{{Kind: "ask", Choice: -1, Correct: -1}}, nil
|
||||
}
|
||||
|
||||
// shuffleAnswers moves the right answer somewhere the player can't guess from
|
||||
// position, and keeps track of where it went.
|
||||
func shuffleAnswers(q Question, rng *rand.Rand) Question {
|
||||
answers := append([]string(nil), q.Answers...)
|
||||
correct := q.Answers[q.Correct]
|
||||
rng.Shuffle(len(answers), func(i, j int) { answers[i], answers[j] = answers[j], answers[i] })
|
||||
out := q
|
||||
out.Answers = answers
|
||||
for i, a := range answers {
|
||||
if a == correct {
|
||||
out.Correct = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Live is the question the player is looking at.
|
||||
func (s State) Live() Question {
|
||||
if s.Rung < 0 || s.Rung >= len(s.Ladder) {
|
||||
return Question{}
|
||||
}
|
||||
return s.Ladder[s.Rung]
|
||||
}
|
||||
|
||||
// Left is how much clock the live question has, at the given moment. It goes to
|
||||
// the browser so its countdown starts where the server's does — but the browser
|
||||
// is never asked what it says.
|
||||
func (s State) Left(now time.Time) time.Duration {
|
||||
d := s.Tier.Clock() - now.Sub(s.AskedAt)
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. now is the server's clock, and the only one that
|
||||
// counts toward the answer.
|
||||
func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
s = s.clone()
|
||||
|
||||
q := s.Live()
|
||||
if len(q.Answers) == 0 {
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
|
||||
elapsed := now.Sub(s.AskedAt)
|
||||
|
||||
// Out of time. This is a loss, and it has to be — a timeout that merely cost
|
||||
// you the speed bonus would make "leave it open in another tab and go and
|
||||
// look it up" the strongest way to play.
|
||||
//
|
||||
// It is checked before *everything*, walking included. A dead clock that you
|
||||
// could still walk away from would be no clock at all: you would sit on every
|
||||
// question for as long as you liked, answer the ones you found and walk off
|
||||
// the ones you didn't, and never once lose the ladder. The timeout has to be
|
||||
// the first thing that happens to a move, or it is not a deadline.
|
||||
if elapsed > s.Tier.Clock() {
|
||||
evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}}
|
||||
s.settle(OutcomeTimeout, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
if m.Walk {
|
||||
// You cannot walk off a rung you haven't climbed. If you could, seeing the
|
||||
// first question and walking away would be a free look: stake, peek, walk,
|
||||
// stake again, and keep reshuffling until the question is one you know.
|
||||
// The first question is therefore the price of sitting down.
|
||||
if s.Rung == 0 {
|
||||
return s, nil, ErrNothingBanked
|
||||
}
|
||||
evs := []Event{}
|
||||
s.settle(OutcomeWalked, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
if m.Choice < 0 || m.Choice >= len(q.Answers) {
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
|
||||
if m.Choice != q.Correct {
|
||||
evs := []Event{{Kind: "wrong", Choice: m.Choice, Correct: q.Correct}}
|
||||
s.settle(OutcomeWrong, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Right, and quick enough to be worth something.
|
||||
step := s.Tier.Step(elapsed)
|
||||
s.Multiple *= step
|
||||
s.Rung++
|
||||
evs := []Event{{
|
||||
Kind: "right", Choice: m.Choice, Correct: q.Correct,
|
||||
Step: step, Multiple: s.Multiple,
|
||||
}}
|
||||
|
||||
if s.Rung >= Rungs {
|
||||
s.settle(OutcomeCleared, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// The next question goes up, and its clock starts now.
|
||||
s.AskedAt = now
|
||||
evs = append(evs, Event{Kind: "ask", Choice: -1, Correct: -1})
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Pays is what banking *right now* would put back on the player's stack: the
|
||||
// stake, plus the winnings, less the house's cut of the winnings.
|
||||
//
|
||||
// It exists for the same reason hangman's does. The felt quotes this number
|
||||
// while the game is still running — it is the "take the money" button's label —
|
||||
// and settle() calls it rather than doing the sum a second time, so the table
|
||||
// can never advertise a payout the house doesn't hand over.
|
||||
func (s State) Pays() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
|
||||
if total < s.Bet {
|
||||
total = s.Bet // banking never hands back less than the stake
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake > 0 {
|
||||
profit -= rake
|
||||
}
|
||||
}
|
||||
return s.Bet + profit
|
||||
}
|
||||
|
||||
// rakeNow is the other half of what Pays works out: the house's cut of a win
|
||||
// banked at this moment. Never taken from the stake, so a player who walks
|
||||
// having answered nothing — which they can't — and one who loses, pay nothing.
|
||||
func (s State) rakeNow() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
|
||||
if total <= s.Bet {
|
||||
return 0
|
||||
}
|
||||
rake := int64(math.Floor(float64(total-s.Bet) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// settle decides the payout. Same rule as every other table in the room: the
|
||||
// rake comes out of winnings, never out of the stake, and a loss is never
|
||||
// charged a fee.
|
||||
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||
s.Outcome = o
|
||||
s.Phase = PhaseDone
|
||||
|
||||
if o.Won() {
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
} else {
|
||||
s.Payout = 0
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "settle", Choice: -1, Correct: -1, Text: string(o)})
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// clone deep-copies the ladder, so a derived state shares no backing array with
|
||||
// the one it came from and a game can be replayed freely.
|
||||
func (s State) clone() State {
|
||||
s.Ladder = append([]Question(nil), s.Ladder...)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package trivia
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rng() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) }
|
||||
|
||||
var epoch = time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// bank builds n questions whose right answer is always "right", so a test can
|
||||
// find it after the shuffle without caring where it landed.
|
||||
func bank(n int) []Question {
|
||||
qs := make([]Question, n)
|
||||
for i := range qs {
|
||||
qs[i] = Question{
|
||||
Category: "General",
|
||||
Text: "question?",
|
||||
Answers: []string{"right", "wrong1", "wrong2", "wrong3"},
|
||||
Correct: 0,
|
||||
}
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
func tier(slug string) Tier {
|
||||
t, err := TierBySlug(slug)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func newGame(t *testing.T, bet int64, slug string) State {
|
||||
t.Helper()
|
||||
s, evs, err := New(bet, tier(slug), 0.05, bank(Rungs), epoch, rng())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "ask" {
|
||||
t.Fatalf("New should open with one ask, got %+v", evs)
|
||||
}
|
||||
if s.Multiple != 1 {
|
||||
t.Fatalf("a fresh ladder is worth the stake, got multiple %v", s.Multiple)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// answerRight plays the live question correctly, after `took` on the clock.
|
||||
func answerRight(t *testing.T, s State, took time.Duration) (State, []Event) {
|
||||
t.Helper()
|
||||
q := s.Live()
|
||||
next, evs, err := ApplyMove(s, Move{Choice: q.Correct}, s.AskedAt.Add(took))
|
||||
if err != nil {
|
||||
t.Fatalf("right answer refused: %v", err)
|
||||
}
|
||||
return next, evs
|
||||
}
|
||||
|
||||
func TestNewShufflesButKeepsTheAnswer(t *testing.T) {
|
||||
s := newGame(t, 100, "medium")
|
||||
moved := 0
|
||||
for _, q := range s.Ladder {
|
||||
if q.Answers[q.Correct] != "right" {
|
||||
t.Fatalf("Correct points at %q, not the right answer", q.Answers[q.Correct])
|
||||
}
|
||||
if q.Correct != 0 {
|
||||
moved++
|
||||
}
|
||||
}
|
||||
// All twelve landing on index 0 would mean the shuffle isn't running, and the
|
||||
// game would be "always click the first one".
|
||||
if moved == 0 {
|
||||
t.Fatal("the right answer is first in every question — the shuffle did nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortBankIsRefused(t *testing.T) {
|
||||
if _, _, err := New(100, tier("easy"), 0.05, bank(Rungs-1), epoch, rng()); err != ErrShortLadder {
|
||||
t.Fatalf("a ladder with a missing rung should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The one that matters most: the number the felt quotes is the number the
|
||||
// player is actually paid, at every rung, exactly as in hangman.
|
||||
func TestTheQuoteIsThePayout(t *testing.T) {
|
||||
s := newGame(t, 200, "hard")
|
||||
for rung := 1; rung < Rungs; rung++ {
|
||||
s, _ = answerRight(t, s, 3*time.Second)
|
||||
|
||||
quoted := s.Pays() // what the "take the money" button says it's worth
|
||||
|
||||
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("rung %d: walk refused: %v", rung, err)
|
||||
}
|
||||
if banked.Payout != quoted {
|
||||
t.Fatalf("rung %d: the felt quoted %d and the house paid %d", rung, quoted, banked.Payout)
|
||||
}
|
||||
if banked.Phase != PhaseDone || banked.Outcome != OutcomeWalked {
|
||||
t.Fatalf("rung %d: walking should end the game, got %s/%s", rung, banked.Phase, banked.Outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walking before answering anything would be a free look at the first question:
|
||||
// stake, peek, walk, restake until the question is one you happen to know.
|
||||
func TestYouCannotWalkOffTheFirstRung(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
if _, _, err := ApplyMove(s, Move{Walk: true}, epoch); err != ErrNothingBanked {
|
||||
t.Fatalf("walking on rung 0 should be refused, got %v", err)
|
||||
}
|
||||
|
||||
// One right answer, and now you may.
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
if _, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt); err != nil {
|
||||
t.Fatalf("walking after a right answer should be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongAnswerLosesTheLot(t *testing.T) {
|
||||
s := newGame(t, 300, "medium")
|
||||
// Build a decent ladder first, so there is something real to lose.
|
||||
for i := 0; i < 4; i++ {
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
}
|
||||
if s.Pays() <= 300 {
|
||||
t.Fatalf("four right answers should be worth more than the stake, got %d", s.Pays())
|
||||
}
|
||||
|
||||
q := s.Live()
|
||||
wrong := (q.Correct + 1) % len(q.Answers)
|
||||
out, evs, err := ApplyMove(s, Move{Choice: wrong}, s.AskedAt.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("a wrong answer is a legal move: %v", err)
|
||||
}
|
||||
if out.Outcome != OutcomeWrong || out.Payout != 0 {
|
||||
t.Fatalf("a wrong answer should pay nothing, got %s/%d", out.Outcome, out.Payout)
|
||||
}
|
||||
if out.Rake != 0 {
|
||||
t.Fatalf("a loss must never be charged a rake, got %d", out.Rake)
|
||||
}
|
||||
if out.Net() != -300 {
|
||||
t.Fatalf("a wrong answer costs the stake and nothing more, got %d", out.Net())
|
||||
}
|
||||
// The player is told which one it was.
|
||||
if evs[0].Kind != "wrong" || evs[0].Correct != q.Correct {
|
||||
t.Fatalf("a wrong answer should reveal the right one, got %+v", evs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The clock is the whole anti-google mechanism: running out of it has to cost
|
||||
// as much as being wrong, or leaving the tab open and looking it up wins.
|
||||
func TestTheClockTakesEverything(t *testing.T) {
|
||||
s := newGame(t, 250, "hard")
|
||||
for i := 0; i < 3; i++ {
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
}
|
||||
banked := s.Pays()
|
||||
|
||||
q := s.Live()
|
||||
late := s.AskedAt.Add(s.Tier.Clock() + time.Millisecond)
|
||||
out, evs, err := ApplyMove(s, Move{Choice: q.Correct}, late) // the *right* answer, too late
|
||||
if err != nil {
|
||||
t.Fatalf("a late answer is a legal move: %v", err)
|
||||
}
|
||||
if out.Outcome != OutcomeTimeout {
|
||||
t.Fatalf("answering past the limit should time out, got %s", out.Outcome)
|
||||
}
|
||||
if out.Payout != 0 {
|
||||
t.Fatalf("a timeout pays nothing — it was worth %d a moment ago, and paid %d", banked, out.Payout)
|
||||
}
|
||||
if evs[0].Kind != "timeout" {
|
||||
t.Fatalf("expected a timeout event, got %+v", evs[0])
|
||||
}
|
||||
|
||||
// And answering on the final tick still counts.
|
||||
onTime := s.AskedAt.Add(s.Tier.Clock())
|
||||
if out, _, err = ApplyMove(s, Move{Choice: q.Correct}, onTime); err != nil {
|
||||
t.Fatalf("an answer on the buzzer is legal: %v", err)
|
||||
}
|
||||
if out.Rung != s.Rung+1 {
|
||||
t.Fatal("an answer on the final tick should still count")
|
||||
}
|
||||
}
|
||||
|
||||
// Speed is the only thing separating a slow right answer from a fast one.
|
||||
func TestFasterPaysMore(t *testing.T) {
|
||||
base := newGame(t, 1000, "hard")
|
||||
|
||||
quick, _ := answerRight(t, base, time.Second)
|
||||
slow, _ := answerRight(t, base, 14*time.Second)
|
||||
|
||||
if quick.Multiple <= slow.Multiple {
|
||||
t.Fatalf("a quick answer should be worth more: quick %v, slow %v", quick.Multiple, slow.Multiple)
|
||||
}
|
||||
if quick.Pays() <= slow.Pays() {
|
||||
t.Fatalf("a quick answer should pay more: quick %d, slow %d", quick.Pays(), slow.Pays())
|
||||
}
|
||||
|
||||
// The ends of the scale are the tier's own numbers, and nothing is outside them.
|
||||
instant, _ := answerRight(t, base, 0)
|
||||
buzzer, _ := answerRight(t, base, base.Tier.Clock())
|
||||
if instant.Multiple != base.Tier.Fast {
|
||||
t.Fatalf("an instant answer is worth Fast (%v), got %v", base.Tier.Fast, instant.Multiple)
|
||||
}
|
||||
if buzzer.Multiple != base.Tier.Buzzer {
|
||||
t.Fatalf("an answer on the buzzer is worth Buzzer (%v), got %v", base.Tier.Buzzer, buzzer.Multiple)
|
||||
}
|
||||
if quick.Multiple > base.Tier.Fast || slow.Multiple < base.Tier.Buzzer {
|
||||
t.Fatal("a step escaped the tier's range")
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing the ladder ends the run and banks it, rather than leaving the player
|
||||
// on a rung that doesn't exist.
|
||||
func TestClearingTheLadderBanks(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
for i := 0; i < Rungs; i++ {
|
||||
if s.Phase != PhasePlaying {
|
||||
t.Fatalf("the game ended early, on rung %d", i)
|
||||
}
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
}
|
||||
if s.Outcome != OutcomeCleared {
|
||||
t.Fatalf("twelve right answers should clear the ladder, got %s", s.Outcome)
|
||||
}
|
||||
if s.Rung != Rungs {
|
||||
t.Fatalf("expected to be on rung %d, got %d", Rungs, s.Rung)
|
||||
}
|
||||
if s.Payout != s.Pays() || s.Payout <= s.Bet {
|
||||
t.Fatalf("clearing should bank a win, got payout %d on a %d stake", s.Payout, s.Bet)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move{Choice: 0}, s.AskedAt); err != ErrGameOver {
|
||||
t.Fatalf("a cleared ladder takes no more moves, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The rake comes out of winnings, never out of the stake.
|
||||
func TestRakeOnlyBitesWinnings(t *testing.T) {
|
||||
s := newGame(t, 1000, "medium")
|
||||
s, _ = answerRight(t, s, 0) // instant: multiple is exactly Fast, so the sum is checkable by hand
|
||||
|
||||
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
|
||||
total := int64(float64(1000) * s.Tier.Fast) // 1550
|
||||
profit := total - 1000 // 550
|
||||
rake := int64(float64(profit) * 0.05) // 27
|
||||
want := 1000 + profit - rake // 1523
|
||||
|
||||
if banked.Payout != want {
|
||||
t.Fatalf("payout should be stake + winnings - 5%% of winnings = %d, got %d", want, banked.Payout)
|
||||
}
|
||||
if banked.Rake != rake {
|
||||
t.Fatalf("rake should be %d, got %d", rake, banked.Rake)
|
||||
}
|
||||
if banked.Payout < banked.Bet {
|
||||
t.Fatal("a win handed back less than the stake")
|
||||
}
|
||||
}
|
||||
|
||||
// A move must not scribble on the state it came from — a game has to replay.
|
||||
func TestApplyMoveDoesNotMutateItsInput(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
before := s.Live()
|
||||
|
||||
next, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("move: %v", err)
|
||||
}
|
||||
if s.Rung != 0 || s.Multiple != 1 || s.Phase != PhasePlaying {
|
||||
t.Fatalf("the original state moved underneath us: rung %d multiple %v", s.Rung, s.Multiple)
|
||||
}
|
||||
if next.Rung != 1 {
|
||||
t.Fatalf("the derived state should have climbed a rung, got %d", next.Rung)
|
||||
}
|
||||
// The same move replays to the same place.
|
||||
again, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
if again.Multiple != next.Multiple || again.Rung != next.Rung {
|
||||
t.Fatal("the same move from the same state landed somewhere else")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeftCountsDown(t *testing.T) {
|
||||
s := newGame(t, 100, "hard") // 15s
|
||||
if got := s.Left(epoch); got != 15*time.Second {
|
||||
t.Fatalf("a fresh question has the whole clock, got %v", got)
|
||||
}
|
||||
if got := s.Left(epoch.Add(10 * time.Second)); got != 5*time.Second {
|
||||
t.Fatalf("expected 5s left, got %v", got)
|
||||
}
|
||||
// It floors at nought rather than going negative, so a browser can render it.
|
||||
if got := s.Left(epoch.Add(time.Hour)); got != 0 {
|
||||
t.Fatalf("the clock should stop at zero, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageMovesAreRefused(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
for _, choice := range []int{-1, 4, 99} {
|
||||
if _, _, err := ApplyMove(s, Move{Choice: choice}, s.AskedAt); err != ErrUnknownMove {
|
||||
t.Fatalf("choice %d should be refused, got %v", choice, err)
|
||||
}
|
||||
}
|
||||
if s.Phase != PhasePlaying {
|
||||
t.Fatal("a refused move should leave the game alone")
|
||||
}
|
||||
}
|
||||
|
||||
// The clock has to beat the walk button, or it is not a deadline.
|
||||
//
|
||||
// If a dead clock could still be walked away from, the ladder would carry no
|
||||
// risk at all: sit on every question for as long as you like, answer the ones
|
||||
// you can look up, and walk off the ones you can't. The timeout has to be the
|
||||
// first thing that happens to a move.
|
||||
func TestWalkingOffADeadClockIsATimeout(t *testing.T) {
|
||||
s := newGame(t, 500, "hard")
|
||||
s, _ = answerRight(t, s, time.Second) // one rung banked, so a walk is otherwise legal
|
||||
|
||||
late := s.AskedAt.Add(s.Tier.Clock() + time.Second)
|
||||
out, evs, err := ApplyMove(s, Move{Walk: true}, late)
|
||||
if err != nil {
|
||||
t.Fatalf("walking after the clock died should resolve, not error: %v", err)
|
||||
}
|
||||
if out.Outcome != OutcomeTimeout {
|
||||
t.Fatalf("a walk after the clock ran out is a timeout, got %q", out.Outcome)
|
||||
}
|
||||
if out.Payout != 0 {
|
||||
t.Fatalf("a timeout pays nothing, got %d", out.Payout)
|
||||
}
|
||||
if len(evs) == 0 || evs[0].Kind != "timeout" {
|
||||
t.Fatalf("expected the timeout event first, got %+v", evs)
|
||||
}
|
||||
|
||||
// And the same walk, one tick inside the limit, still banks.
|
||||
intime := s.AskedAt.Add(s.Tier.Clock() - time.Millisecond)
|
||||
banked, _, err := ApplyMove(s, Move{Walk: true}, intime)
|
||||
if err != nil {
|
||||
t.Fatalf("walk with the clock still running: %v", err)
|
||||
}
|
||||
if banked.Outcome != OutcomeWalked || banked.Payout <= 0 {
|
||||
t.Fatalf("a walk inside the clock banks, got %q paying %d", banked.Outcome, banked.Payout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package uno
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// The bots.
|
||||
//
|
||||
// Lifted from the ones gogobee's UNO plays in Matrix, which are genuinely decent
|
||||
// company — they hold their wild draw fours back until you're close to going
|
||||
// out, they follow the colour in play when they can, and they get out of the way
|
||||
// of their own hand. Two things changed on the way over:
|
||||
//
|
||||
// The RNG is threaded. gogobee's bots reach for the package global, which is why
|
||||
// its own tests can only assert that a bot played *something* legal. These take
|
||||
// the game's generator, so a bot's choice is part of what a seed replays.
|
||||
//
|
||||
// They are not the same bot at every table. A single deterministic policy is a
|
||||
// puzzle: play round it once and it never surprises you again. So the bot takes
|
||||
// the best card it sees most of the time, and now and then takes the second best
|
||||
// — enough that you cannot count what it is holding by what it plays.
|
||||
|
||||
// botSlip is how often a bot takes its second choice instead of its first. Low
|
||||
// enough that it still plays well, high enough that it isn't a lookup table.
|
||||
const botSlip = 6 // one turn in six
|
||||
|
||||
// botPick chooses a card to play, or reports -1 when the bot has nothing legal.
|
||||
func botPick(hand []Card, top Card, topColor Color, minOpponent int, rng *rand.Rand) (Card, int) {
|
||||
var playable []int
|
||||
for i, c := range hand {
|
||||
if c.CanPlayOn(top, topColor) {
|
||||
playable = append(playable, i)
|
||||
}
|
||||
}
|
||||
if len(playable) == 0 {
|
||||
return Card{}, -1
|
||||
}
|
||||
|
||||
order := botRank(hand, topColor, playable, minOpponent)
|
||||
pick := order[0]
|
||||
if len(order) > 1 && rng.IntN(botSlip) == 0 {
|
||||
pick = order[1]
|
||||
}
|
||||
return hand[pick], pick
|
||||
}
|
||||
|
||||
// botRank sorts the playable cards best-first, by the bot's own priorities.
|
||||
//
|
||||
// The shape of it: hurt the leader if there is one, otherwise get rid of
|
||||
// something useful and keep the wilds back. A wild draw four spent early is a
|
||||
// wild draw four you don't have when somebody is sitting on one card.
|
||||
func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int {
|
||||
var wd4, wilds, actions, numbers []int
|
||||
for _, i := range playable {
|
||||
switch c := hand[i]; {
|
||||
case c.Value == WildDrawFour:
|
||||
wd4 = append(wd4, i)
|
||||
case c.Value == WildCard:
|
||||
wilds = append(wilds, i)
|
||||
case c.Value.Action():
|
||||
actions = append(actions, i)
|
||||
default:
|
||||
numbers = append(numbers, i)
|
||||
}
|
||||
}
|
||||
|
||||
// Following the colour in play is worth more than not, because it keeps the
|
||||
// bot's hand flexible — so within each group, the cards already in colour go
|
||||
// first.
|
||||
inColorFirst := func(idx []int) []int {
|
||||
var same, other []int
|
||||
for _, i := range idx {
|
||||
if hand[i].Color == topColor {
|
||||
same = append(same, i)
|
||||
} else {
|
||||
other = append(other, i)
|
||||
}
|
||||
}
|
||||
return append(same, other...)
|
||||
}
|
||||
|
||||
var out []int
|
||||
if minOpponent >= 0 && minOpponent <= 2 {
|
||||
// Somebody is about to go out. This is what the +4 was being saved for.
|
||||
out = append(out, wd4...)
|
||||
out = append(out, inColorFirst(actions)...)
|
||||
out = append(out, wilds...)
|
||||
out = append(out, inColorFirst(numbers)...)
|
||||
return out
|
||||
}
|
||||
out = append(out, inColorFirst(actions)...)
|
||||
out = append(out, inColorFirst(numbers)...)
|
||||
out = append(out, wilds...)
|
||||
out = append(out, wd4...)
|
||||
return out
|
||||
}
|
||||
|
||||
// botStack answers a stack, or reports -1 when the bot has nothing to answer it
|
||||
// with and has to eat the lot.
|
||||
//
|
||||
// It plays the *smallest* draw card it holds. The bill is passed on either way —
|
||||
// what it is passing on is the stack plus whatever it added — so the cheap card
|
||||
// does the same job as the expensive one, and keeps the +10 in hand for a turn
|
||||
// when the bot is the one choosing to hurt somebody rather than the one dodging.
|
||||
//
|
||||
// The slip is here too: one time in six it reaches for the second-smallest, so a
|
||||
// player can't read the stack it just passed as a complete inventory of what the
|
||||
// bot doesn't have.
|
||||
func botStack(hand []Card, topColor Color, rng *rand.Rand) (Card, int) {
|
||||
var can []int
|
||||
for i, c := range hand {
|
||||
if c.CanStackOn(topColor) {
|
||||
can = append(can, i)
|
||||
}
|
||||
}
|
||||
if len(can) == 0 {
|
||||
return Card{}, -1
|
||||
}
|
||||
// Smallest draw first. A stable insertion sort: there are never many.
|
||||
for i := 1; i < len(can); i++ {
|
||||
for j := i; j > 0 && hand[can[j]].Value.Draw() < hand[can[j-1]].Value.Draw(); j-- {
|
||||
can[j], can[j-1] = can[j-1], can[j]
|
||||
}
|
||||
}
|
||||
pick := can[0]
|
||||
if len(can) > 1 && rng.IntN(botSlip) == 0 {
|
||||
pick = can[1]
|
||||
}
|
||||
return hand[pick], pick
|
||||
}
|
||||
|
||||
// botRouletteColor names the colour for a roulette: whichever the bot holds
|
||||
// *least* of. The victim flips until that colour turns up, so the rarer the
|
||||
// colour, the longer they flip and the more they keep. Naming the colour you're
|
||||
// long in is naming the one that ends the flipping soonest, which is mercy — and
|
||||
// this is not that game.
|
||||
func botRouletteColor(hand []Card, rng *rand.Rand) Color {
|
||||
counts := [5]int{}
|
||||
for _, c := range hand {
|
||||
if c.Color.Playable() {
|
||||
counts[c.Color]++
|
||||
}
|
||||
}
|
||||
best, bestN := Wild, 1<<30
|
||||
for col := Red; col <= Green; col++ {
|
||||
if counts[col] < bestN {
|
||||
best, bestN = col, counts[col]
|
||||
}
|
||||
}
|
||||
if best == Wild {
|
||||
return Red + Color(rng.IntN(4))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// botColor names a colour for a wild: whichever the bot holds most of, so the
|
||||
// card it plays next is one it already has. A hand of nothing but wilds picks
|
||||
// at random rather than always saying red, which would be a tell.
|
||||
func botColor(hand []Card, rng *rand.Rand) Color {
|
||||
counts := [5]int{}
|
||||
for _, c := range hand {
|
||||
if c.Color.Playable() {
|
||||
counts[c.Color]++
|
||||
}
|
||||
}
|
||||
best, bestN := Wild, 0
|
||||
for col := Red; col <= Green; col++ {
|
||||
if counts[col] > bestN {
|
||||
best, bestN = col, counts[col]
|
||||
}
|
||||
}
|
||||
if bestN == 0 {
|
||||
return Red + Color(rng.IntN(4))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// botPool are the regulars a bot seat is named from. Flavour, and load-bearing
|
||||
// flavour: "Kiwi played a +4" is a table, "Bot 2 played a +4" is a test fixture.
|
||||
// More names than a full table, so no two chairs ever share one.
|
||||
var botPool = []string{
|
||||
"Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan",
|
||||
"Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine",
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package uno
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// Calling UNO, and catching the seat that didn't.
|
||||
//
|
||||
// This was the one rule on the box the table wasn't playing. A hand going down to
|
||||
// one card used to emit the "uno" event by itself, which made the call a thing
|
||||
// that *happened to you* rather than a thing you did — and a rule nobody can fail
|
||||
// is not a rule, it's an announcement.
|
||||
//
|
||||
// So now: play your second-to-last card and you owe the table a word. Say it
|
||||
// (Move.Uno) and you're safe. Stay quiet and the bots get one look at you, right
|
||||
// then, before any of them plays — because a bot that has moved on is a bot that
|
||||
// has stopped watching your hand. Miss it and you take two.
|
||||
//
|
||||
// It runs the other way too, and that half is the fun one. A bot forgets often
|
||||
// enough to be worth watching for, and when it does it says *nothing* — there is
|
||||
// no event, no badge, no tell on the felt except the count beside its fan reading
|
||||
// "1 card" with no UNO on it. Catch it (MoveCatch) and it takes two. Call a seat
|
||||
// that had nothing to hide and you take two yourself, which is what stops the
|
||||
// catch button from being a thing you simply mash every turn.
|
||||
//
|
||||
// The whole rule turns on one asymmetry, and it's deliberate: the bots get a
|
||||
// *roll* to notice you, and you get to actually look. Attention is the edge here,
|
||||
// and it's the player's to take.
|
||||
|
||||
const (
|
||||
// CatchPenalty is what silence costs, both ways. Two, as printed on the box.
|
||||
CatchPenalty = 2
|
||||
|
||||
// botForget is how often a bot goes down to one card without saying so. High
|
||||
// enough that watching the counts pays — at a full table you'll get a catch to
|
||||
// make every few games — and low enough that a quiet seat is still a thing you
|
||||
// have to notice rather than assume.
|
||||
botForget = 0.30
|
||||
|
||||
// botAlert is the chance a *single* bot notices that you didn't call. They each
|
||||
// get a look, so forgetting is punished about 75% of the time heads-up and 98%
|
||||
// at a full table: the more seats there are watching you, the less you get away
|
||||
// with, which is the right shape for it.
|
||||
botAlert = 0.75
|
||||
)
|
||||
|
||||
// botForgets rolls for whether a bot muffs its call.
|
||||
func botForgets(rng *rand.Rand) bool { return rng.Float64() < botForget }
|
||||
|
||||
// declare records whether a seat that is now on one card said so, and — if it did
|
||||
// — announces it. A seat on any other number of cards owes nothing and this does
|
||||
// nothing, which is what makes it safe to call after every play.
|
||||
func (s *State) declare(seat int, called bool, evs *[]Event) {
|
||||
if !s.playing() || len(s.Hands[seat]) != 1 {
|
||||
return
|
||||
}
|
||||
s.ensureCalled()
|
||||
s.Called[seat] = called
|
||||
if called {
|
||||
*evs = append(*evs, Event{Kind: EvUno, Seat: seat, Left: 1})
|
||||
}
|
||||
}
|
||||
|
||||
// botsCatch is the window. The seat that just played is holding one card and
|
||||
// didn't say the word — so every bot still in the hand gets one look, in seat
|
||||
// order, and the first to see it takes them for two.
|
||||
//
|
||||
// It runs before runBots on purpose. The catch has to land while the table is
|
||||
// still looking at the card that was played, not three turns later. Only the bots
|
||||
// catch on a roll here; a human polices the table with the catch button, which is
|
||||
// the asymmetry the whole rule turns on — attention is the player's edge.
|
||||
func (s *State) botsCatch(actor int, evs *[]Event, rng *rand.Rand) {
|
||||
if !s.playing() || len(s.Hands[actor]) != 1 || s.called(actor) {
|
||||
return
|
||||
}
|
||||
for _, seat := range s.alive() {
|
||||
if seat == actor || !s.Seats[seat].Bot {
|
||||
continue // a human catches with the button, not on a roll
|
||||
}
|
||||
if rng.Float64() >= botAlert {
|
||||
continue // this one wasn't looking
|
||||
}
|
||||
s.penalise(actor, seat, EvCaught, evs, rng)
|
||||
return // caught once is caught. They don't queue up to take turns at you
|
||||
}
|
||||
}
|
||||
|
||||
// seatCatches calls out a seat the caller thinks is holding one card in silence.
|
||||
//
|
||||
// It is not a turn: right or wrong, the turn stays where it was. What it costs is
|
||||
// the risk — a seat that did call, or isn't on one card at all, is a seat the
|
||||
// caller has accused of nothing, and that is two cards to them.
|
||||
func (s *State) seatCatches(caller int, m Move, rng *rand.Rand) ([]Event, error) {
|
||||
seat := m.Seat
|
||||
if seat == caller || seat < 0 || seat >= len(s.Hands) || !s.live(seat) {
|
||||
return nil, ErrNoCatch
|
||||
}
|
||||
var evs []Event
|
||||
if len(s.Hands[seat]) == 1 && !s.called(seat) {
|
||||
s.penalise(seat, caller, EvCaught, &evs, rng) // got them
|
||||
} else {
|
||||
s.penalise(caller, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking
|
||||
}
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// penalise makes a seat take the price of the call: cards off the deck, quietly —
|
||||
// no draw event, because what the table is being told about is the catch, and a
|
||||
// draw event alongside it would animate the same two cards twice.
|
||||
//
|
||||
// In No Mercy those two cards can be the two that bury them, which is a fine way
|
||||
// to go: caught on one card, dead on twenty-five.
|
||||
func (s *State) penalise(victim, by int, kind string, evs *[]Event, rng *rand.Rand) {
|
||||
got := s.drawCards(victim, CatchPenalty, evs, rng)
|
||||
if len(got) == 0 {
|
||||
return // the table has nothing left to punish anybody with
|
||||
}
|
||||
s.ensureCalled()
|
||||
s.Called[victim] = false
|
||||
*evs = append(*evs, s.mine(Event{
|
||||
Kind: kind, Seat: victim, By: by, N: len(got), Left: len(s.Hands[victim]),
|
||||
}))
|
||||
s.mercy(victim, evs, rng)
|
||||
}
|
||||
|
||||
// UnoAt is which cards in your hand would leave you holding exactly one, if you
|
||||
// played them. It is the table's cue to ask you for the call, and it comes from
|
||||
// here rather than from the browser counting your cards because No Mercy's
|
||||
// "discard all" doesn't take one card out of your hand — it takes every card of
|
||||
// its colour, and the browser guessing at that is the browser getting it wrong.
|
||||
//
|
||||
// It answers for every card, legal or not. The table only ever asks about a card
|
||||
// you were allowed to play anyway.
|
||||
func (s State) UnoAt(seat int) []int {
|
||||
if !s.playing() || s.Turn != seat {
|
||||
return nil
|
||||
}
|
||||
hand := s.Hands[seat]
|
||||
var out []int
|
||||
for i, c := range hand {
|
||||
left := len(hand) - 1
|
||||
if c.Value == DiscardAll {
|
||||
for j, other := range hand {
|
||||
if j != i && other.Color == c.Color && !other.IsWild() {
|
||||
left--
|
||||
}
|
||||
}
|
||||
}
|
||||
if left == 1 {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Catchable is which seats are, right now, sitting on one card they never
|
||||
// announced. It is what the browser puts a button on.
|
||||
//
|
||||
// This leaks nothing. The card counts are already on the felt and the UNO badge
|
||||
// already isn't — this is the same two facts, subtracted, and doing that
|
||||
// subtraction on the server is only so that the rule for what counts as catchable
|
||||
// lives in one place instead of two.
|
||||
func (s State) Catchable(viewer int) []int {
|
||||
if !s.playing() || s.Turn != viewer {
|
||||
return nil // you can only catch a seat on your own turn
|
||||
}
|
||||
var out []int
|
||||
for _, seat := range s.alive() {
|
||||
if seat != viewer && len(s.Hands[seat]) == 1 && !s.called(seat) {
|
||||
out = append(out, seat)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// called reports whether a seat holding one card announced it. Callers outside
|
||||
// the package read the Called slice, which is on the state they already hold.
|
||||
func (s State) called(seat int) bool {
|
||||
return seat < len(s.Called) && s.Called[seat]
|
||||
}
|
||||
|
||||
// ensureCalled grows the slice to fit the table. A game dealt before this rule
|
||||
// existed has no Called at all, and the seats in it are all — correctly —
|
||||
// uncalled: nobody in that game ever said the word, because there was no way to.
|
||||
func (s *State) ensureCalled() {
|
||||
for len(s.Called) < len(s.Hands) {
|
||||
s.Called = append(s.Called, false)
|
||||
}
|
||||
}
|
||||
|
||||
// tidyCalls forgets the calls that no longer mean anything. A seat's call is only
|
||||
// ever about the one card it is holding — draw into two and the word you said is
|
||||
// spent, and saying it again is a new thing you have to do.
|
||||
//
|
||||
// Without this, a seat that called on one card, was made to draw, and worked its
|
||||
// way back down to one would still be wearing the old call, and could never be
|
||||
// caught again for the rest of the game.
|
||||
func (s *State) tidyCalls() {
|
||||
s.ensureCalled()
|
||||
for seat := range s.Called {
|
||||
if len(s.Hands[seat]) != 1 {
|
||||
s.Called[seat] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package uno
|
||||
|
||||
import "testing"
|
||||
|
||||
// The UNO call, and the catch on the other side of it. See call.go.
|
||||
|
||||
// oneCardAway sets you up holding two cards, both of which go on the pile, so
|
||||
// playing either one takes you to UNO.
|
||||
//
|
||||
// The bot is given a hand that can't touch a red pile and a deck that can't help
|
||||
// it, so whatever it does on its turn, it does not make you draw. That matters:
|
||||
// a hand that grows spends the call (see tidyCalls), which is correct and would
|
||||
// otherwise make these tests flap on the seeds where the bot happens to turn up
|
||||
// a +2.
|
||||
func oneCardAway(t *testing.T, seed uint64) State {
|
||||
t.Helper()
|
||||
s := deal(t, duel(), seed)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
|
||||
s.Hands[1] = []Card{{Blue, Three}, {Green, Four}, {Yellow, Six}}
|
||||
s.Deck = make([]Card, 24)
|
||||
for i := range s.Deck {
|
||||
s.Deck[i] = Card{Blue, Nine} // nothing here plays on a red one, and nothing bites
|
||||
}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
return s
|
||||
}
|
||||
|
||||
// TestCallingUnoKeepsYouSafe — say the word and the table has nothing on you.
|
||||
// Across a spread of seeds, not one bot ever gets a catch.
|
||||
func TestCallingUnoKeepsYouSafe(t *testing.T) {
|
||||
for seed := uint64(0); seed < 200; seed++ {
|
||||
s := oneCardAway(t, seed)
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true})
|
||||
if err != nil {
|
||||
t.Fatalf("seed %d: play: %v", seed, err)
|
||||
}
|
||||
if hasKind(evs, EvCaught) {
|
||||
t.Fatalf("seed %d: caught after calling UNO", seed)
|
||||
}
|
||||
if !hasKind(evs, EvUno) {
|
||||
t.Fatalf("seed %d: called UNO and the table never said so", seed)
|
||||
}
|
||||
if !next.Called[You] {
|
||||
t.Fatalf("seed %d: the call wasn't recorded", seed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestForgettingUnoGetsYouCaught — stay quiet on one card and the bot takes you
|
||||
// for two. It gets one look, so it misses sometimes; over 400 games it should
|
||||
// land near botAlert, and the two cards should actually arrive.
|
||||
func TestForgettingUnoGetsYouCaught(t *testing.T) {
|
||||
caught, games := 0, 400
|
||||
for seed := uint64(0); seed < uint64(games); seed++ {
|
||||
s := oneCardAway(t, seed)
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("seed %d: play: %v", seed, err)
|
||||
}
|
||||
if !hasKind(evs, EvCaught) {
|
||||
continue
|
||||
}
|
||||
caught++
|
||||
// One card left after the play, plus the two the catch cost.
|
||||
if n := len(next.Hands[You]); n != 3 {
|
||||
t.Fatalf("seed %d: caught and holding %d, want 3", seed, n)
|
||||
}
|
||||
if hasKind(evs, EvUno) {
|
||||
t.Fatalf("seed %d: an UNO was announced by a seat that never called", seed)
|
||||
}
|
||||
}
|
||||
rate := float64(caught) / float64(games)
|
||||
if rate < botAlert-0.08 || rate > botAlert+0.08 {
|
||||
t.Errorf("one bot caught you %.0f%% of the time, want about %.0f%% (botAlert)",
|
||||
rate*100, botAlert*100)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoreBotsMeansLessGettingAwayWithIt — every seat gets its own look, so
|
||||
// forgetting at a full table is very nearly always punished.
|
||||
func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) {
|
||||
away := func(tier Tier, seed uint64) bool {
|
||||
s := deal(t, tier, seed)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
_, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play: %v", err)
|
||||
}
|
||||
return !hasKind(evs, EvCaught)
|
||||
}
|
||||
var got [2]float64
|
||||
for i, tier := range []Tier{duel(), table()} {
|
||||
escapes := 0
|
||||
for seed := uint64(0); seed < 400; seed++ {
|
||||
if away(tier, seed) {
|
||||
escapes++
|
||||
}
|
||||
}
|
||||
got[i] = float64(escapes) / 400
|
||||
}
|
||||
if got[1] >= got[0] {
|
||||
t.Errorf("you got away with it %.0f%% of the time against one bot and %.0f%% against two; "+
|
||||
"two pairs of eyes should catch you more often, not less", got[0]*100, got[1]*100)
|
||||
}
|
||||
}
|
||||
|
||||
// quietBot puts a bot on one card it never called, with the turn back on you.
|
||||
func quietBot(t *testing.T, called bool) State {
|
||||
t.Helper()
|
||||
s := deal(t, duel(), 21)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, One}, {Blue, Two}, {Green, Three}}
|
||||
s.Hands[1] = []Card{{Yellow, Nine}}
|
||||
s.Called = []bool{false, called}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
return s
|
||||
}
|
||||
|
||||
// TestCatchingAQuietBot — it's on one card and it never said so. Two cards to it,
|
||||
// and the turn is still yours: catching is not a move you spend a turn on.
|
||||
func TestCatchingAQuietBot(t *testing.T) {
|
||||
s := quietBot(t, false)
|
||||
before := total(census(s))
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("catch: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvCaught) {
|
||||
t.Fatal("no catch event")
|
||||
}
|
||||
if n := len(next.Hands[1]); n != 3 {
|
||||
t.Errorf("the bot holds %d, want 3: one card, plus the two it just took", n)
|
||||
}
|
||||
if n := len(next.Hands[You]); n != 3 {
|
||||
t.Errorf("your hand is %d, want 3: a catch costs you nothing", n)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the turn went to seat %d: a catch is not a turn", next.Turn)
|
||||
}
|
||||
if total(census(next)) != before {
|
||||
t.Error("the catch lost a card")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCatchingACleanBotCostsYou — it called, or it isn't on one card at all.
|
||||
// Either way you've accused it of nothing, and that is two cards to you.
|
||||
func TestCatchingACleanBotCostsYou(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
state State
|
||||
}{
|
||||
{"it called", quietBot(t, true)},
|
||||
{"it isn't even close", func() State {
|
||||
s := quietBot(t, false)
|
||||
s.Hands[1] = []Card{{Yellow, Nine}, {Yellow, Eight}}
|
||||
return s
|
||||
}()},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
next, evs, err := ApplyMove(tc.state, You, Move{Kind: MoveCatch, Seat: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("catch: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvMiscall) {
|
||||
t.Fatal("no miscall event")
|
||||
}
|
||||
if n := len(next.Hands[You]); n != 5 {
|
||||
t.Errorf("your hand is %d, want 5: three, plus the two a bad call cost", n)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the turn went to seat %d: even a bad catch isn't a turn", next.Turn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestYouCannotCatchYourself, or a seat that isn't at the table.
|
||||
func TestYouCannotCatchYourself(t *testing.T) {
|
||||
s := quietBot(t, false)
|
||||
for _, seat := range []int{You, -1, 9} {
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch {
|
||||
t.Errorf("catching seat %d: got %v, want ErrNoCatch", seat, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestACallIsSpentWhenTheHandGrows. Call on one card, get made to draw, and work
|
||||
// your way back down to one: that is a new call you owe, not the old one still
|
||||
// standing. Without this a seat could be caught out once and never again.
|
||||
func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
|
||||
s := deal(t, duel(), 5)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, One}}
|
||||
s.Called = []bool{true, false}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
// Draw, and the hand is two: the word you said was about a card you no longer
|
||||
// hold on its own.
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Called[You] {
|
||||
t.Error("the call survived the hand growing; it should be spent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCatchableIsWhatTheTableCanSee — a quiet bot on one card, and nobody else.
|
||||
func TestCatchable(t *testing.T) {
|
||||
s := quietBot(t, false)
|
||||
if got := s.Catchable(You); len(got) != 1 || got[0] != 1 {
|
||||
t.Errorf("Catchable() = %v, want [1]", got)
|
||||
}
|
||||
clean := quietBot(t, true)
|
||||
if got := clean.Catchable(You); len(got) != 0 {
|
||||
t.Errorf("Catchable() = %v on a bot that called, want none", got)
|
||||
}
|
||||
// And not on somebody else's turn: you can only call it out when it's on you.
|
||||
off := quietBot(t, false)
|
||||
off.Turn = 1
|
||||
if got := off.Catchable(You); len(got) != 0 {
|
||||
t.Errorf("Catchable() = %v off-turn, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnoAtSeesThroughDiscardAll — the whole reason the table asks the engine
|
||||
// which cards take you to one, rather than counting your hand itself. "Discard
|
||||
// all" takes every card of its colour with it, so a six-card hand can land on
|
||||
// one, and a browser subtracting one from six gets a player caught.
|
||||
func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 3)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Red, Seven}, {Blue, Two}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
// Index 0 dumps itself and the three other reds: five cards become one.
|
||||
// Index 4 is an ordinary play: five become four.
|
||||
got := s.UnoAt(You)
|
||||
if len(got) != 1 || got[0] != 0 {
|
||||
t.Errorf("UnoAt() = %v, want [0]: only the discard-all lands you on one card", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnoAtIsTheOrdinaryCaseToo — two cards in hand, and either of them is a call.
|
||||
func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) {
|
||||
s := oneCardAway(t, 1)
|
||||
got := s.UnoAt(You)
|
||||
if len(got) != 2 {
|
||||
t.Errorf("UnoAt() = %v, want both cards: either one leaves you holding one", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoingOutNeedsNoCall — your last card is not one card, it's none. Nobody
|
||||
// owes the table a word for winning.
|
||||
func TestGoingOutNeedsNoCall(t *testing.T) {
|
||||
s := deal(t, duel(), 9)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, One}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play the last card: %v", err)
|
||||
}
|
||||
if next.Winner != You {
|
||||
t.Fatalf("winner %d outcome %q, want a win for you", next.Winner, next.Outcome)
|
||||
}
|
||||
if hasKind(evs, EvCaught) {
|
||||
t.Error("caught for not calling UNO on the card that won the game")
|
||||
}
|
||||
}
|
||||
|
||||
// TestABotThatForgetsSaysNothing — the tell is the absence of the badge, and the
|
||||
// count beside the fan. If a quiet bot emitted anything at all there'd be nothing
|
||||
// to spot.
|
||||
func TestABotThatForgetsSaysNothing(t *testing.T) {
|
||||
quiet := 0
|
||||
for seed := uint64(0); seed < 300 && quiet < 1; seed++ {
|
||||
s := deal(t, duel(), seed)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Blue, Two}, {Blue, Three}, {Blue, Four}}
|
||||
s.Hands[1] = []Card{{Red, One}, {Red, Nine}}
|
||||
s.Turn = 1
|
||||
s.Phase = PhasePlay
|
||||
s.Turn = You // the bot plays on the back of your move
|
||||
|
||||
// Draw, handing the turn over: the bot plays a red and lands on one card.
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if len(next.Hands[1]) != 1 || next.Called[1] {
|
||||
continue // it either didn't get down to one, or it remembered
|
||||
}
|
||||
quiet++
|
||||
if hasKind(evs, EvUno) {
|
||||
t.Error("a bot that forgot to call still announced it")
|
||||
}
|
||||
if len(next.Catchable(You)) != 1 {
|
||||
t.Error("a quiet bot on one card isn't catchable")
|
||||
}
|
||||
}
|
||||
if quiet == 0 {
|
||||
t.Skip("no bot forgot in 300 games; botForget may have been turned down")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package uno
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// No Mercy.
|
||||
//
|
||||
// A rules dial, not a fourth table. The table size is still the tier — a duel is
|
||||
// a duel — and No Mercy is a switch you throw across all three of them. What it
|
||||
// changes is the game they play:
|
||||
//
|
||||
// - A 168-card deck, with faces the normal one doesn't print: a coloured +4, a
|
||||
// +6, a +10, a skip-everyone, a discard-all, a reverse-and-draw-four, and a
|
||||
// colour roulette.
|
||||
// - Draw cards stack. A +2 pointed at you can be answered with any draw card
|
||||
// you hold, and the bill goes to the next seat with the two added on. Whoever
|
||||
// runs out of draw cards eats the lot.
|
||||
// - You draw until you can play. There is no drawing one card and shrugging.
|
||||
// - And twenty-five cards in your hand kills you. That is the whole point of
|
||||
// the deck: it is built to bury somebody, and the mercy rule is what happens
|
||||
// when it does.
|
||||
//
|
||||
// Everything here is reached from uno.go behind `s.Tier.NoMercy`. A normal game
|
||||
// never runs a line of it.
|
||||
|
||||
// MercyLimit is the hand that ends you. Reach it and you are out of the game —
|
||||
// your cards go back in the deck and the table plays on without you.
|
||||
const MercyLimit = 25
|
||||
|
||||
// NewNoMercyDeck builds the 168.
|
||||
//
|
||||
// Per colour: two of each number, three skips, two skip-everyones, four
|
||||
// reverses, two +2s, two coloured +4s and three discard-alls — thirty-six cards,
|
||||
// times four colours. Then the wilds: eight reverse-draw-fours, four +6s, four
|
||||
// +10s and eight roulettes. Unshuffled, same as NewDeck, because New shuffles and
|
||||
// a test wants the order it was built in.
|
||||
func NewNoMercyDeck() []Card {
|
||||
d := make([]Card, 0, 168)
|
||||
for _, col := range []Color{Red, Blue, Yellow, Green} {
|
||||
for v := Zero; v <= Nine; v++ {
|
||||
d = append(d, Card{col, v}, Card{col, v})
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
d = append(d, Card{col, Skip})
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
d = append(d, Card{col, SkipAll})
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
d = append(d, Card{col, Reverse})
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
d = append(d, Card{col, DrawTwo})
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
d = append(d, Card{col, DrawFour})
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
d = append(d, Card{col, DiscardAll})
|
||||
}
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
d = append(d, Card{Wild, WildRevFour})
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
d = append(d, Card{Wild, WildDrawSix})
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
d = append(d, Card{Wild, WildDrawTen})
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
d = append(d, Card{Wild, WildRoulette})
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// CanStackOn reports whether a card can be thrown onto a stack that is already
|
||||
// building. Any draw card answers any other — there is no escalation rule, so a
|
||||
// +2 is a legal reply to a +10 — but a *coloured* draw card still has to follow
|
||||
// the colour in play. The wild draws always go.
|
||||
//
|
||||
// This is why the pending count is not a cap: what you are matching is the fact
|
||||
// of a draw card, not its size.
|
||||
func (c Card) CanStackOn(topColor Color) bool {
|
||||
if c.Value.Draw() == 0 {
|
||||
return false
|
||||
}
|
||||
if c.IsWild() {
|
||||
return true
|
||||
}
|
||||
return c.Color == topColor
|
||||
}
|
||||
|
||||
// canStack reports whether a seat holds anything at all it could answer with.
|
||||
func (s State) canStack(seat int) bool {
|
||||
for _, c := range s.Hands[seat] {
|
||||
if c.CanStackOn(s.Color) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// absorb is what happens when the stack stops with you: you take every card in
|
||||
// it, and you lose your turn. The pending count is cleared *before* the cards
|
||||
// land, because a mercy kill inside the draw ends the seat and there must be no
|
||||
// bill left standing against a seat that is no longer at the table.
|
||||
func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) {
|
||||
n := s.Pending
|
||||
s.Pending = 0
|
||||
s.deal(seat, n, true, evs, rng)
|
||||
|
||||
// The seat can die paying the bill, and a mercy kill can end the hand — the last
|
||||
// seat but one dying leaves somebody alone to take the pot. So the phase is only
|
||||
// reset if there is still a hand to have a phase.
|
||||
if s.mercy(seat, evs, rng) && !s.playing() {
|
||||
return
|
||||
}
|
||||
if !s.live(seat) {
|
||||
s.Phase = PhasePlay
|
||||
s.advance(1)
|
||||
return // it died, but the table plays on. Don't skip a seat that isn't there.
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: seat, Left: len(s.Hands[seat])})
|
||||
s.Phase = PhasePlay
|
||||
s.advance(1) // the turn is on the seat that just paid, so it moves one on
|
||||
}
|
||||
|
||||
// roulette is the colour roulette: the next seat turns cards over until the
|
||||
// named colour comes up, and keeps every card it turned. Then it loses its turn.
|
||||
//
|
||||
// The deck can run out mid-flip (the discard is reshuffled back under as usual,
|
||||
// and even that can be dry), so this is bounded by what there is to draw, not by
|
||||
// the colour ever actually appearing. A wild is not a colour and never ends it.
|
||||
func (s *State) roulette(victim int, color Color, evs *[]Event, rng *rand.Rand) {
|
||||
got := 0
|
||||
for {
|
||||
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
|
||||
break
|
||||
}
|
||||
c, ok := s.pop()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
s.Hands[victim] = append(s.Hands[victim], c)
|
||||
got++
|
||||
if c.Color == color {
|
||||
break
|
||||
}
|
||||
if len(s.Hands[victim]) >= MercyLimit {
|
||||
break // they are dead already; stop dealing cards to a corpse
|
||||
}
|
||||
}
|
||||
if got > 0 {
|
||||
e := Event{Kind: EvRoulette, Seat: victim, N: got, Color: color, Left: len(s.Hands[victim])}
|
||||
*evs = append(*evs, s.mine(e))
|
||||
}
|
||||
if s.mercy(victim, evs, rng) {
|
||||
return
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim, Left: len(s.Hands[victim])})
|
||||
s.advance(2)
|
||||
}
|
||||
|
||||
// discardAll dumps every remaining card of a colour out of a hand and buries it
|
||||
// under the card that was just played. The pile keeps its top: the played card
|
||||
// stays the card in play, and the rest go beneath it, where they are still in the
|
||||
// game (a reshuffle brings them back) and still count in a census.
|
||||
func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
|
||||
hand := s.Hands[seat]
|
||||
kept := make([]Card, 0, len(hand))
|
||||
var dumped []Card
|
||||
for _, c := range hand {
|
||||
if c.Color == color && !c.IsWild() {
|
||||
dumped = append(dumped, c)
|
||||
} else {
|
||||
kept = append(kept, c)
|
||||
}
|
||||
}
|
||||
s.Hands[seat] = kept
|
||||
if len(dumped) > 0 {
|
||||
top := s.Discard[len(s.Discard)-1]
|
||||
s.Discard = append(s.Discard[:len(s.Discard)-1], dumped...)
|
||||
s.Discard = append(s.Discard, top)
|
||||
*evs = append(*evs, s.mine(Event{Kind: EvDiscardAll, Seat: seat, N: len(dumped),
|
||||
Color: color, Left: len(kept)}))
|
||||
}
|
||||
return len(dumped)
|
||||
}
|
||||
|
||||
// mercy checks a seat against the limit and, if it has crossed it, takes it out
|
||||
// of the hand: its cards go back into the deck and it plays no more this hand. It
|
||||
// reports whether the seat died.
|
||||
//
|
||||
// A mercy kill is no longer game over for anyone — it is one seat out of the
|
||||
// current hand, human or bot alike, and the hand plays on among the living. When
|
||||
// it leaves exactly one seat alive, that seat has outlived the table and takes the
|
||||
// pot; whoever it is, they win it the same way going out first would.
|
||||
func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
|
||||
if !s.Tier.NoMercy || !s.live(seat) || len(s.Hands[seat]) < MercyLimit {
|
||||
return false
|
||||
}
|
||||
n := len(s.Hands[seat])
|
||||
s.Deck = append(s.Deck, s.Hands[seat]...)
|
||||
rng.Shuffle(len(s.Deck), func(i, j int) { s.Deck[i], s.Deck[j] = s.Deck[j], s.Deck[i] })
|
||||
s.Hands[seat] = nil
|
||||
s.Out[seat] = true
|
||||
s.Pending = 0 // a dead seat pays no bill, and leaves none behind
|
||||
*evs = append(*evs, s.mine(Event{Kind: EvMercy, Seat: seat, N: n, Left: 0}))
|
||||
|
||||
if alive := s.alive(); len(alive) == 1 {
|
||||
s.settle(alive[0], OutcomeWon, evs) // the last one standing takes the pot
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Live reports whether a seat is still in the game. The felt needs it: a seat the
|
||||
// mercy rule has buried holds no cards, and a seat holding no cards is otherwise
|
||||
// indistinguishable from the one that just went out and won.
|
||||
func (s State) Live(seat int) bool { return s.live(seat) }
|
||||
|
||||
// alive lists the seats still in the game.
|
||||
func (s State) alive() []int {
|
||||
var out []int
|
||||
for i := range s.Hands {
|
||||
if s.live(i) {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// live reports whether a seat is still in the current hand. Out is empty between
|
||||
// hands and in any game saved before No Mercy existed, so a missing entry is a
|
||||
// living seat.
|
||||
func (s State) live(seat int) bool {
|
||||
return seat >= len(s.Out) || !s.Out[seat]
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func nmDuel() Tier { t, _ := TierBySlug("nm-duel"); return t }
|
||||
func nmTable() Tier { t, _ := TierBySlug("nm-table"); return t }
|
||||
func nmFull() Tier { t, _ := TierBySlug("nm-full"); return t }
|
||||
|
||||
func TestNoMercyDeckIsADeck(t *testing.T) {
|
||||
m := census(State{Deck: NewNoMercyDeck()})
|
||||
if got := total(m); got != 168 {
|
||||
t.Fatalf("deck has %d cards, want 168", got)
|
||||
}
|
||||
want := map[Card]int{
|
||||
{Red, Zero}: 2, // two of every number, unlike the normal deck's single zero
|
||||
{Blue, Seven}: 2,
|
||||
{Green, Skip}: 3,
|
||||
{Yellow, SkipAll}: 2,
|
||||
{Red, Reverse}: 4,
|
||||
{Blue, DrawTwo}: 2,
|
||||
{Green, DrawFour}: 2, // the *coloured* +4
|
||||
{Yellow, DiscardAll}: 3,
|
||||
{Wild, WildRevFour}: 8,
|
||||
{Wild, WildDrawSix}: 4,
|
||||
{Wild, WildDrawTen}: 4,
|
||||
{Wild, WildRoulette}: 8,
|
||||
}
|
||||
for c, n := range want {
|
||||
if m[c] != n {
|
||||
t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n)
|
||||
}
|
||||
}
|
||||
if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 {
|
||||
t.Error("the No Mercy deck should print none of the normal wilds")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoMercyCensus is the load-bearing one: 168 cards, each in exactly one place,
|
||||
// checked after every move of a hundred hands played to the end.
|
||||
func TestNoMercyCensus(t *testing.T) {
|
||||
for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} {
|
||||
for seed := uint64(0); seed < 100; seed++ {
|
||||
s := deal(t, tier, seed)
|
||||
if got := total(census(s)); got != 168 {
|
||||
t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got)
|
||||
}
|
||||
rng := rand.New(rand.NewPCG(seed, 99))
|
||||
for moves := 0; s.playing() && moves < 800; moves++ {
|
||||
next, _, err := ApplyMove(s, You, naive(s, rng))
|
||||
if err != nil {
|
||||
t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase)
|
||||
}
|
||||
s = next
|
||||
if got := census(s); total(got) != 168 {
|
||||
t.Fatalf("%s seed %d: %d cards after a move, want 168",
|
||||
tier.Slug, seed, total(got))
|
||||
}
|
||||
}
|
||||
if s.playing() {
|
||||
t.Fatalf("%s seed %d: hand never ended", tier.Slug, seed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// naive is a bad-but-real strategy: play the first legal card, take a stack you
|
||||
// can't answer, and draw when you have nothing. Always played from the human seat.
|
||||
func naive(s State, rng *rand.Rand) Move {
|
||||
if s.Phase == PhaseStack {
|
||||
if p := s.Playable(You); len(p) > 0 {
|
||||
return playMove(s, p[0], rng)
|
||||
}
|
||||
return Move{Kind: MoveTake}
|
||||
}
|
||||
if p := s.Playable(You); len(p) > 0 {
|
||||
return playMove(s, p[0], rng)
|
||||
}
|
||||
return Move{Kind: MoveDraw}
|
||||
}
|
||||
|
||||
// stack loads a seat's hand up to n cards by taking them off the deck, so the
|
||||
// table still holds 168 of them.
|
||||
func stack(s *State, seat, n int) {
|
||||
s.Deck = append(s.Deck, s.Hands[seat]...)
|
||||
s.Hands[seat] = nil
|
||||
s.Color = s.top().Color
|
||||
|
||||
kept := make([]Card, 0, len(s.Deck))
|
||||
for _, c := range s.Deck {
|
||||
if len(s.Hands[seat]) < n {
|
||||
s.Hands[seat] = append(s.Hands[seat], c)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, c)
|
||||
}
|
||||
s.Deck = kept
|
||||
}
|
||||
|
||||
func playMove(s State, idx int, rng *rand.Rand) Move {
|
||||
m := Move{Kind: MovePlay, Index: idx}
|
||||
if s.Hands[You][idx].IsWild() {
|
||||
m.Color = Red + Color(rng.IntN(4))
|
||||
}
|
||||
for _, at := range s.UnoAt(You) {
|
||||
if at == idx {
|
||||
m.Uno = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestAStackIsPassedOnAndPaid walks the rule the whole mode turns on: a draw card
|
||||
// opens a bill, and the seat that can't answer pays the whole thing.
|
||||
func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 7)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||
s.Hands[1] = []Card{{Red, DrawTwo}, {Blue, Nine}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play +2: %v", err)
|
||||
}
|
||||
if next.Phase != PhaseStack {
|
||||
t.Fatalf("phase is %s, want stack: a +2 in No Mercy opens a stack", next.Phase)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Fatalf("the stack came back to seat %d, want you", next.Turn)
|
||||
}
|
||||
if next.Pending != 4 {
|
||||
t.Fatalf("the bill is %d, want 4 (your two, plus the bot's two)", next.Pending)
|
||||
}
|
||||
if !hasKind(evs, EvStack) {
|
||||
t.Error("no stack event: the felt has nothing to show the player")
|
||||
}
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustStack {
|
||||
t.Errorf("drawing out of a stack: %v, want ErrMustStack", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
|
||||
t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err)
|
||||
}
|
||||
|
||||
before := len(next.Hands[You])
|
||||
paid, evs, err := ApplyMove(next, You, Move{Kind: MoveTake})
|
||||
if err != nil {
|
||||
t.Fatalf("take: %v", err)
|
||||
}
|
||||
var forced int
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvForced && e.Seat == You {
|
||||
forced = e.N
|
||||
}
|
||||
}
|
||||
if forced != 4 {
|
||||
t.Errorf("the stack made you take %d cards, want 4", forced)
|
||||
}
|
||||
if len(paid.Hands[You]) < before+4 {
|
||||
t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You]))
|
||||
}
|
||||
if paid.Pending == 4 && paid.Phase == PhaseStack {
|
||||
t.Error("the bill you just paid is still standing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMercyKillsThePlayerButNotTheSession: the mercy limit takes the human out of
|
||||
// the *hand* — the pot goes to whoever is left, and the table plays on. It is no
|
||||
// longer game over.
|
||||
func TestMercyKillsThePlayerButNotTheSession(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 3)
|
||||
stack(&s, You, 24)
|
||||
s.Turn = You
|
||||
s.Phase = PhaseStack
|
||||
s.Pending = 10
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveTake})
|
||||
if err != nil {
|
||||
t.Fatalf("take: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvMercy) {
|
||||
t.Fatal("no mercy event: twenty-five cards should have killed the seat")
|
||||
}
|
||||
if next.playing() {
|
||||
t.Fatalf("the hand should be over once one seat is left: phase %s", next.Phase)
|
||||
}
|
||||
if next.live(You) || len(next.Hands[You]) != 0 {
|
||||
t.Error("a dead seat should hold no cards and be out of the hand")
|
||||
}
|
||||
// Heads-up, killing the human leaves the bot alone: it takes the pot.
|
||||
if next.Winner != 1 {
|
||||
t.Errorf("winner is seat %d, want the surviving bot", next.Winner)
|
||||
}
|
||||
if got := total(census(next)); got != 168 {
|
||||
t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutlivingTheTableWins: the deck buries bots too, and a table with every bot
|
||||
// dead is a pot you have taken.
|
||||
func TestOutlivingTheTableWins(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 11)
|
||||
pot := s.Pot
|
||||
before := s.Seats[You].Stack
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||
s.Hands[1] = make([]Card, 0, 24)
|
||||
for i := 0; i < 24; i++ {
|
||||
s.Hands[1] = append(s.Hands[1], Card{Blue, Nine}) // nothing it can answer with
|
||||
}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play +2: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvMercy) {
|
||||
t.Fatal("the bot should have died taking the stack")
|
||||
}
|
||||
if next.playing() || next.Winner != You {
|
||||
t.Fatalf("phase %s winner %d, want a finished hand won by you", next.Phase, next.Winner)
|
||||
}
|
||||
profit := pot - s.Tier.Ante
|
||||
wantWon := pot - int64(float64(profit)*rake)
|
||||
if next.Seats[You].Stack != before+wantWon {
|
||||
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouDrawUntilYouCanPlay(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 5)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Blue, One}} // nothing playable
|
||||
s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if len(next.Hands[You]) != 4 {
|
||||
t.Fatalf("hand is %d, want 4: you draw until something plays", len(next.Hands[You]))
|
||||
}
|
||||
if next.Phase != PhaseDrawn {
|
||||
t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play", next.Phase)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MovePass}); err != ErrMustPlayNow {
|
||||
t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipAllComesBackToYou(t *testing.T) {
|
||||
s := deal(t, nmFull(), 13)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play skip-all: %v", err)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("turn went to seat %d, want you: skip-all skips everyone else", next.Turn)
|
||||
}
|
||||
if !hasKind(evs, EvSkipAll) {
|
||||
t.Error("no skipall event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 17)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
before := total(census(s))
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true})
|
||||
if err != nil {
|
||||
t.Fatalf("play discard-all: %v", err)
|
||||
}
|
||||
if len(next.Hands[You]) != 1 {
|
||||
t.Fatalf("hand is %d, want 1: every red should have gone with it", len(next.Hands[You]))
|
||||
}
|
||||
if next.Hands[You][0] != (Card{Blue, Two}) {
|
||||
t.Errorf("kept %v, want the blue two", next.Hands[You][0])
|
||||
}
|
||||
// The discard-all was played, so it is on the pile — the bot may have played over
|
||||
// it since, which is why this looks for it rather than at the very top.
|
||||
found := false
|
||||
for _, c := range next.Discard {
|
||||
if c.Value == DiscardAll {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the discard-all isn't on the pile")
|
||||
}
|
||||
if !hasKind(evs, EvDiscardAll) {
|
||||
t.Error("no discard event")
|
||||
}
|
||||
if got := total(census(next)); got != before {
|
||||
t.Errorf("%d cards, want %d: a dumped colour is buried, not destroyed", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 19)
|
||||
s.Color = Blue
|
||||
s.Discard = []Card{{Blue, Five}}
|
||||
s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}}
|
||||
s.Hands[1] = []Card{{Green, Three}}
|
||||
s.Deck = []Card{{Blue, Two}, {Green, Four}, {Yellow, Six}, {Red, Seven}, {Blue, Eight}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Red})
|
||||
if err != nil {
|
||||
t.Fatalf("play roulette: %v", err)
|
||||
}
|
||||
var got int
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvRoulette {
|
||||
got = e.N
|
||||
}
|
||||
}
|
||||
if got != 4 {
|
||||
t.Errorf("flipped %d, want 4 — up to and including the first red", got)
|
||||
}
|
||||
if n := len(next.Hands[1]); n != 5 {
|
||||
t.Errorf("the bot holds %d, want 5", n)
|
||||
}
|
||||
if total(census(next)) != total(census(s)) {
|
||||
t.Error("the roulette lost a card")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,807 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const rake = 0.05
|
||||
|
||||
// You is the human's seat in the solo tests, a test-local alias for what the
|
||||
// engine no longer hardcodes: a table is a list of seats, and seat zero being the
|
||||
// human is a convention only these fixtures keep.
|
||||
const You = 0
|
||||
|
||||
func duel() Tier { t, _ := TierBySlug("duel"); return t }
|
||||
func full() Tier { t, _ := TierBySlug("full"); return t }
|
||||
func table() Tier { t, _ := TierBySlug("table"); return t }
|
||||
|
||||
// openSolo opens a solo table (one human, the tier's bots) without dealing.
|
||||
func openSolo(t *testing.T, tier Tier, seed uint64) State {
|
||||
t.Helper()
|
||||
s, _, err := New(tier, SoloSeats(tier, tier.Bots, 1000), rake, seed, 0xC0FFEE)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// deal opens a solo table and deals the first hand, so the human is to act.
|
||||
func deal(t *testing.T, tier Tier, seed uint64) State {
|
||||
t.Helper()
|
||||
s := openSolo(t, tier, seed)
|
||||
s, evs, err := ApplyMove(s, You, Move{Kind: MoveDeal})
|
||||
if err != nil {
|
||||
t.Fatalf("deal: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvDeal) {
|
||||
t.Fatalf("the deal emitted no deal event: %+v", evs)
|
||||
}
|
||||
if s.Turn != You {
|
||||
t.Fatalf("the human should act first at a solo table, turn is %d", s.Turn)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// census counts every card in the game, wherever it is. It is the invariant the
|
||||
// whole engine has to hold: 108 cards, each of them in exactly one place.
|
||||
func census(s State) map[Card]int {
|
||||
m := map[Card]int{}
|
||||
for _, h := range s.Hands {
|
||||
for _, c := range h {
|
||||
m[c]++
|
||||
}
|
||||
}
|
||||
for _, c := range s.Deck {
|
||||
m[c]++
|
||||
}
|
||||
for _, c := range s.Discard {
|
||||
if c.Value.Wild() {
|
||||
c.Color = Wild
|
||||
}
|
||||
m[c]++
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func total(m map[Card]int) int {
|
||||
n := 0
|
||||
for _, v := range m {
|
||||
n += v
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestNewDeckIsADeck(t *testing.T) {
|
||||
m := census(State{Deck: NewDeck()})
|
||||
if got := total(m); got != 108 {
|
||||
t.Fatalf("deck has %d cards, want 108", got)
|
||||
}
|
||||
if m[Card{Red, Zero}] != 1 {
|
||||
t.Errorf("want one red zero, got %d", m[Card{Red, Zero}])
|
||||
}
|
||||
if m[Card{Blue, Seven}] != 2 {
|
||||
t.Errorf("want two blue sevens, got %d", m[Card{Blue, Seven}])
|
||||
}
|
||||
if m[Card{Wild, WildDrawFour}] != 4 {
|
||||
t.Errorf("want four +4s, got %d", m[Card{Wild, WildDrawFour}])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeals(t *testing.T) {
|
||||
s := deal(t, full(), 7)
|
||||
if len(s.Hands) != 4 {
|
||||
t.Fatalf("full house is four seats, got %d", len(s.Hands))
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if len(h) != HandSize {
|
||||
t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize)
|
||||
}
|
||||
}
|
||||
bots := 0
|
||||
for _, seat := range s.Seats {
|
||||
if seat.Bot {
|
||||
bots++
|
||||
if seat.Name == "" {
|
||||
t.Error("a bot seat has no name")
|
||||
}
|
||||
}
|
||||
}
|
||||
if bots != 3 {
|
||||
t.Fatalf("want three bots, got %d", bots)
|
||||
}
|
||||
if got := total(census(s)); got != 108 {
|
||||
t.Fatalf("the deal lost cards: %d of 108", got)
|
||||
}
|
||||
// Every seat anted, so the pot is one ante per seat.
|
||||
if want := s.Tier.Ante * int64(len(s.Seats)); s.Pot != want {
|
||||
t.Errorf("pot is %d, want %d (one ante each)", s.Pot, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The card turned over to start is never an action card — see dealHand.
|
||||
func TestOpeningCardIsANumber(t *testing.T) {
|
||||
for seed := uint64(0); seed < 300; seed++ {
|
||||
s := deal(t, table(), seed)
|
||||
if s.Top().Value.Action() {
|
||||
t.Fatalf("seed %d opened on %v", seed, s.Top())
|
||||
}
|
||||
if s.Color != s.Top().Color {
|
||||
t.Fatalf("seed %d: colour in play is %v, top card is %v", seed, s.Color, s.Top())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the rules ------------------------------------------------------------
|
||||
|
||||
// rig builds a live hand by hand, so a rule can be tested without hunting a seed
|
||||
// that happens to deal it. Every seat has anted, so the pot is set and a win
|
||||
// settles for real.
|
||||
func rig(hands [][]Card, top Card, color Color) State {
|
||||
left := map[Card]int{}
|
||||
for _, c := range NewDeck() {
|
||||
left[c]++
|
||||
}
|
||||
take := func(c Card) {
|
||||
if c.IsWild() {
|
||||
c.Color = Wild
|
||||
}
|
||||
left[c]--
|
||||
}
|
||||
for _, h := range hands {
|
||||
for _, c := range h {
|
||||
take(c)
|
||||
}
|
||||
}
|
||||
take(top)
|
||||
|
||||
var deck []Card
|
||||
for _, c := range NewDeck() {
|
||||
if left[c] > 0 {
|
||||
left[c]--
|
||||
deck = append(deck, c)
|
||||
}
|
||||
}
|
||||
ante := full().Ante
|
||||
seats := make([]Seat, len(hands))
|
||||
for i := range seats {
|
||||
// The stack is what is left after this seat anted: a real deal moves the ante
|
||||
// out of the stack and into the pot, so a refund or a win returns it here.
|
||||
seats[i] = Seat{Name: botPool[i], Bot: i != You, Stack: 1000 - ante, Ante: ante}
|
||||
}
|
||||
seats[You].Name = "You"
|
||||
return State{
|
||||
Tier: full(), Seats: seats, Hands: hands, Discard: []Card{top}, Color: color,
|
||||
Deck: deck, Dir: 1, Turn: You, Dealer: len(hands) - 1, Phase: PhasePlay,
|
||||
Pot: ante * int64(len(hands)), Winner: -1,
|
||||
Out: make([]bool, len(hands)), Called: make([]bool, len(hands)),
|
||||
RakePct: rake, Seed1: 1, Seed2: 2,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayMustMatch(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
|
||||
t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayMatchesFaceOrColor(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("a blue 9 on a red 9 is legal: %v", err)
|
||||
}
|
||||
if next.Color != Blue {
|
||||
t.Errorf("colour in play should follow the card: %v", next.Color)
|
||||
}
|
||||
if evs[0].Kind != EvPlay || evs[0].Seat != You {
|
||||
t.Errorf("first event should be your play, got %+v", evs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildNeedsAColor(t *testing.T) {
|
||||
s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
|
||||
t.Fatalf("a wild with no colour should be refused, got %v", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
|
||||
t.Fatalf("naming 'wild' is not naming a colour, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildNamesTheColor(t *testing.T) {
|
||||
s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Green})
|
||||
if err != nil {
|
||||
t.Fatalf("play wild: %v", err)
|
||||
}
|
||||
top := next.Discard
|
||||
if len(top) < 2 {
|
||||
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
|
||||
}
|
||||
if top[1] != (Card{Green, WildCard}) {
|
||||
t.Errorf("the wild should sit on the pile as green, got %v", top[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawTwoHitsTheNextSeat(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play +2: %v", err)
|
||||
}
|
||||
if len(next.Hands[1]) != 4 {
|
||||
t.Errorf("the bot should hold 2 + 2 = 4 cards, got %d", len(next.Hands[1]))
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the bot was skipped, so it should be your turn: %d", next.Turn)
|
||||
}
|
||||
if !hasKind(evs, EvForced) || !hasKind(evs, EvSkip) {
|
||||
t.Errorf("a +2 is a forced draw and a skip: %+v", evs)
|
||||
}
|
||||
if got := total(census(next)); got != 108 {
|
||||
t.Fatalf("the +2 lost cards: %d of 108", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseIsASkipHeadsUp(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play reverse: %v", err)
|
||||
}
|
||||
if next.Dir != 1 {
|
||||
t.Errorf("with two at the table a reverse doesn't turn the table around: dir %d", next.Dir)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the bot should have been skipped, turn is %d", next.Turn)
|
||||
}
|
||||
if !hasKind(evs, EvSkip) || hasKind(evs, EvReverse) {
|
||||
t.Errorf("heads up, a reverse reads as a skip: %+v", evs)
|
||||
}
|
||||
if len(next.Hands[1]) != 1 {
|
||||
t.Errorf("the bot never played, so it still holds one card: %d", len(next.Hands[1]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseTurnsTheTableAround(t *testing.T) {
|
||||
s := rig([][]Card{
|
||||
{{Red, Reverse}, {Red, One}},
|
||||
{{Red, Five}, {Blue, Six}},
|
||||
{{Red, Six}, {Green, Six}},
|
||||
{{Red, Seven}, {Yellow, Six}},
|
||||
}, Card{Red, Nine}, Red)
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play reverse: %v", err)
|
||||
}
|
||||
if next.Dir != -1 {
|
||||
t.Errorf("four at the table: a reverse turns it around, dir %d", next.Dir)
|
||||
}
|
||||
if !hasKind(evs, EvReverse) {
|
||||
t.Errorf("want a reverse event: %+v", evs)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the bots should have played round to you, turn is %d", next.Turn)
|
||||
}
|
||||
var order []int
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvPlay && e.Seat != You {
|
||||
order = append(order, e.Seat)
|
||||
}
|
||||
}
|
||||
if len(order) != 3 || order[0] != 3 || order[1] != 2 || order[2] != 1 {
|
||||
t.Errorf("the bots played in the order %v, want [3 2 1]", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipSkips(t *testing.T) {
|
||||
s := rig([][]Card{
|
||||
{{Red, Skip}, {Red, One}},
|
||||
{{Red, Five}, {Blue, Six}},
|
||||
{{Red, Six}, {Green, Six}},
|
||||
}, Card{Red, Nine}, Red)
|
||||
s.Tier = table()
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play skip: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvSkip) {
|
||||
t.Errorf("want a skip event: %+v", evs)
|
||||
}
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvPlay && e.Seat == 1 {
|
||||
t.Errorf("seat 1 was skipped and should not have played: %+v", e)
|
||||
}
|
||||
}
|
||||
if len(next.Hands[1]) != 2 {
|
||||
t.Errorf("seat 1 was skipped and should still hold two: %d", len(next.Hands[1]))
|
||||
}
|
||||
if len(next.Hands[2]) != 1 {
|
||||
t.Errorf("seat 2 was not skipped and should have played: %d", len(next.Hands[2]))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- drawing --------------------------------------------------------------
|
||||
|
||||
func TestDrawnPlayableWaitsForYou(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Phase != PhaseDrawn {
|
||||
t.Fatalf("a playable draw should stop and ask, phase is %v", next.Phase)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Fatalf("the turn should still be yours: %d", next.Turn)
|
||||
}
|
||||
if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) {
|
||||
t.Fatalf("your own drawn card comes face up: %+v", evs[0])
|
||||
}
|
||||
if got := next.Playable(You); len(got) != 1 || got[0] != 1 {
|
||||
t.Errorf("the drawn card, and only it, is playable: %v", got)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
|
||||
t.Fatalf("only the drawn card may be played, got %v", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
|
||||
t.Fatalf("you can't draw twice, got %v", err)
|
||||
}
|
||||
after, _, err := ApplyMove(next, You, Move{Kind: MovePass})
|
||||
if err != nil {
|
||||
t.Fatalf("pass: %v", err)
|
||||
}
|
||||
if after.Phase != PhasePlay || after.Turn != You {
|
||||
t.Fatalf("after passing the bot plays and it comes back to you: phase %v turn %d", after.Phase, after.Turn)
|
||||
}
|
||||
if len(after.Hands[You]) != 2 {
|
||||
t.Errorf("you kept the card you drew: %d", len(after.Hands[You]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnplayableDrawPassesTheTurn(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Phase != PhasePlay {
|
||||
t.Errorf("nothing to decide, so no pause: %v", next.Phase)
|
||||
}
|
||||
if !hasKind(evs, EvPass) {
|
||||
t.Errorf("the turn passed, and the table should be told: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassOnlyAfterADraw(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePass}); err != ErrCantPass {
|
||||
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dead is a table nobody can move at: the deck is spent, the discard is one card
|
||||
// deep, and not a seat holds a card that goes on the pile.
|
||||
func dead(hands [][]Card) State {
|
||||
s := rig(hands, Card{Red, Nine}, Red)
|
||||
s.Deck = nil
|
||||
return s
|
||||
}
|
||||
|
||||
// A dead table ends the hand rather than passing the turn round forever. It no
|
||||
// longer ends the *session* — a shared table plays another hand — so it lands on
|
||||
// PhaseHandOver, not PhaseDone.
|
||||
func TestDeadTableEnds(t *testing.T) {
|
||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}}) // level: one card each
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.playing() {
|
||||
t.Fatalf("nobody can move and there is nothing to draw: the hand is over, not %q", next.Phase)
|
||||
}
|
||||
if next.Phase != PhaseHandOver {
|
||||
t.Fatalf("a dead hand at a shared table returns to handover, not %q", next.Phase)
|
||||
}
|
||||
if next.Outcome != OutcomeTie {
|
||||
t.Errorf("level on the shortest hand is a tie, got %q", next.Outcome)
|
||||
}
|
||||
// A tie hands the antes back: every seat is whole again.
|
||||
for i := range next.Seats {
|
||||
if next.Seats[i].Stack != 1000 {
|
||||
t.Errorf("seat %d wasn't refunded: stack %d, want 1000", i, next.Seats[i].Stack)
|
||||
}
|
||||
}
|
||||
if !hasKind(evs, EvSettle) {
|
||||
t.Errorf("the table has to be told the hand is over: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// And the shortest hand takes the pot, which is the one way a stuck table pays.
|
||||
func TestDeadTablePaysTheShortestHand(t *testing.T) {
|
||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
|
||||
pot := s.Pot
|
||||
before := s.Seats[You].Stack
|
||||
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Outcome != OutcomeStuck || next.Winner != You {
|
||||
t.Fatalf("one card against two is a win for you: outcome %q winner %d", next.Outcome, next.Winner)
|
||||
}
|
||||
profit := pot - s.Tier.Ante
|
||||
wantRake := int64(float64(profit) * rake)
|
||||
wantWon := pot - wantRake
|
||||
if next.Seats[You].Won != wantWon {
|
||||
t.Errorf("you took %d, want %d (pot %d less rake %d)", next.Seats[You].Won, wantWon, pot, wantRake)
|
||||
}
|
||||
if next.Seats[You].Stack != before+wantWon {
|
||||
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReshuffleRebuildsTheDeck(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Deck = nil
|
||||
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw on an empty deck: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvReshuffle) {
|
||||
t.Fatalf("want a reshuffle: %+v", evs)
|
||||
}
|
||||
if len(next.Discard) == 0 || next.Discard[0] != (Card{Red, Nine}) {
|
||||
t.Errorf("the card in play stays on the pile: %v", next.Discard)
|
||||
}
|
||||
for _, c := range next.Deck {
|
||||
if c.Value == WildCard && c.Color != Wild {
|
||||
t.Errorf("a wild went back into the deck stamped %v", c.Color)
|
||||
}
|
||||
}
|
||||
for _, h := range next.Hands {
|
||||
for _, c := range h {
|
||||
if c.Value == WildCard && c.Color != Wild {
|
||||
t.Errorf("a wild was dealt out stamped %v", c.Color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the pot --------------------------------------------------------------
|
||||
|
||||
// The winner takes the pot, and the house's rake comes out of the winnings, never
|
||||
// out of a seat's own ante.
|
||||
func TestWinnerTakesThePotLessRake(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}, {{Blue, One}, {Blue, Two}}}, Card{Red, Nine}, Red)
|
||||
pot := s.Pot
|
||||
before := s.Seats[You].Stack
|
||||
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) // your last card
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
if next.Outcome != OutcomeWon || next.Winner != You {
|
||||
t.Fatalf("playing your last card wins: outcome %q winner %d", next.Outcome, next.Winner)
|
||||
}
|
||||
profit := pot - s.Tier.Ante
|
||||
wantRake := int64(float64(profit) * rake)
|
||||
wantWon := pot - wantRake
|
||||
if next.Rake != wantRake {
|
||||
t.Errorf("rake %d, want %d — and never a penny of an ante", next.Rake, wantRake)
|
||||
}
|
||||
if next.LastPot != pot {
|
||||
t.Errorf("last pot %d, want %d", next.LastPot, pot)
|
||||
}
|
||||
if next.Seats[You].Stack != before+wantWon {
|
||||
t.Errorf("your stack is %d, want %d (+%d)", next.Seats[You].Stack, before+wantWon, wantWon)
|
||||
}
|
||||
if next.Paid != wantRake {
|
||||
t.Errorf("the session rake tally is %d, want %d", next.Paid, wantRake)
|
||||
}
|
||||
}
|
||||
|
||||
// A bot winning rakes nothing: the house already keeps the whole pot when its own
|
||||
// seat takes it, so there is nothing to charge.
|
||||
func TestABotWinningRakesNothing(t *testing.T) {
|
||||
// The bot at seat 1 holds one card that plays; it goes out the moment the turn
|
||||
// reaches it.
|
||||
s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
pot := s.Pot
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play: %v", err)
|
||||
}
|
||||
if next.Outcome != OutcomeWon || next.Winner != 1 {
|
||||
t.Fatalf("the bot went out: outcome %q winner %d", next.Outcome, next.Winner)
|
||||
}
|
||||
if next.Rake != 0 {
|
||||
t.Errorf("a bot winning rakes nothing, got %d", next.Rake)
|
||||
}
|
||||
if next.Seats[1].Won != pot {
|
||||
t.Errorf("the bot took %d, want the whole pot %d", next.Seats[1].Won, pot)
|
||||
}
|
||||
// You anted and lost it: your stack is down exactly one ante.
|
||||
if next.Seats[You].Stack != 1000-s.Tier.Ante {
|
||||
t.Errorf("your stack is %d, want %d (one ante gone)", next.Seats[You].Stack, 1000-s.Tier.Ante)
|
||||
}
|
||||
last := evs[len(evs)-1]
|
||||
if last.Kind != EvSettle || last.Seat != 1 {
|
||||
t.Errorf("the settle should name the winner: %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// A hand ending returns the table to handover, ready to deal again — it does not
|
||||
// take a hand move.
|
||||
func TestNoHandMoveBetweenHands(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
if over.Phase != PhaseHandOver {
|
||||
t.Fatalf("a finished hand returns to handover, got %q", over.Phase)
|
||||
}
|
||||
if _, _, err := ApplyMove(over, You, Move{Kind: MoveDraw}); err != ErrNoHand {
|
||||
t.Fatalf("no hand is in progress between hands, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// You can deal the next hand, ante again, and play on — the session shape.
|
||||
func TestDealTheNextHand(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
again, evs, err := ApplyMove(over, You, Move{Kind: MoveDeal})
|
||||
if err != nil {
|
||||
t.Fatalf("deal the next hand: %v", err)
|
||||
}
|
||||
if again.HandNo != over.HandNo+1 {
|
||||
t.Errorf("hand number didn't advance: %d then %d", over.HandNo, again.HandNo)
|
||||
}
|
||||
if !again.playing() {
|
||||
t.Fatalf("the next hand should be live, phase %q", again.Phase)
|
||||
}
|
||||
if !hasKind(evs, EvAnte) || !hasKind(evs, EvDeal) {
|
||||
t.Errorf("the deal antes and turns a card: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadBuyIn(t *testing.T) {
|
||||
if _, _, err := New(duel(), SoloSeats(duel(), 1, 10), rake, 1, 2); err != ErrBadBuyIn {
|
||||
t.Fatalf("a buy-in under the minimum should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the whole game -------------------------------------------------------
|
||||
|
||||
// playOut plays one hand to its end with a simple strategy: play the first legal
|
||||
// card, take a stack you can't answer, otherwise draw, otherwise pass.
|
||||
func playOut(t *testing.T, s State, maxTurns int) State {
|
||||
t.Helper()
|
||||
for turn := 0; s.playing(); turn++ {
|
||||
if turn > maxTurns {
|
||||
t.Fatalf("the hand never ended in %d turns", maxTurns)
|
||||
}
|
||||
if s.Turn != You {
|
||||
t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn)
|
||||
}
|
||||
|
||||
var m Move
|
||||
if p := s.Playable(You); len(p) > 0 {
|
||||
m = Move{Kind: MovePlay, Index: p[0]}
|
||||
if s.Hands[You][p[0]].IsWild() {
|
||||
m.Color = Green
|
||||
}
|
||||
} else if s.Phase == PhaseStack {
|
||||
m = Move{Kind: MoveTake}
|
||||
} else if s.Phase == PhaseDrawn {
|
||||
m = Move{Kind: MovePass}
|
||||
} else {
|
||||
m = Move{Kind: MoveDraw}
|
||||
}
|
||||
|
||||
next, evs, err := ApplyMove(s, You, m)
|
||||
if err != nil {
|
||||
t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase)
|
||||
}
|
||||
if len(evs) == 0 {
|
||||
t.Fatalf("turn %d: a move that happened emitted nothing", turn)
|
||||
}
|
||||
if got := total(census(next)); got != 108 {
|
||||
t.Fatalf("turn %d: %d cards of 108 — a card was lost or minted", turn, got)
|
||||
}
|
||||
for c, n := range census(next) {
|
||||
if want := deckCount(c); n != want {
|
||||
t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want)
|
||||
}
|
||||
}
|
||||
s = next
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// deckCount is how many of a given card a 108 deck holds.
|
||||
func deckCount(c Card) int {
|
||||
switch {
|
||||
case c.Color == Wild:
|
||||
return 4
|
||||
case c.Value == Zero:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// A hundred hands, played out, with the invariants checked at every step.
|
||||
func TestGamesPlayOut(t *testing.T) {
|
||||
yous, others, ties := 0, 0, 0
|
||||
for seed := uint64(0); seed < 100; seed++ {
|
||||
tier := Tiers[seed%3]
|
||||
end := playOut(t, deal(t, tier, seed), 500)
|
||||
if end.Phase != PhaseHandOver {
|
||||
t.Fatalf("seed %d ended in phase %q", seed, end.Phase)
|
||||
}
|
||||
switch {
|
||||
case end.Winner == You:
|
||||
yous++
|
||||
case end.Winner >= 0:
|
||||
others++
|
||||
case end.Outcome == OutcomeTie:
|
||||
ties++
|
||||
default:
|
||||
t.Fatalf("seed %d ended with winner %d outcome %q", seed, end.Winner, end.Outcome)
|
||||
}
|
||||
if end.Winner >= 0 && end.Outcome == OutcomeWon && len(end.Hands[end.Winner]) != 0 {
|
||||
t.Fatalf("seed %d: the winner is still holding cards", seed)
|
||||
}
|
||||
}
|
||||
if yous == 0 || others == 0 {
|
||||
t.Fatalf("100 hands gave %d to you, %d to others, %d tied — one side never happens", yous, others, ties)
|
||||
}
|
||||
t.Logf("100 hands: %d to you, %d to others, %d tied", yous, others, ties)
|
||||
}
|
||||
|
||||
// The same seed deals the same hand and the bots make the same choices.
|
||||
func TestReplaysFromTheSeed(t *testing.T) {
|
||||
a := playOut(t, deal(t, full(), 42), 500)
|
||||
b := playOut(t, deal(t, full(), 42), 500)
|
||||
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
if string(ja) != string(jb) {
|
||||
t.Fatal("the same seed played the same way gave two different games")
|
||||
}
|
||||
if a.Winner < 0 && a.Outcome != OutcomeTie {
|
||||
t.Fatal("the replay didn't finish")
|
||||
}
|
||||
}
|
||||
|
||||
// A game in progress survives a redeploy: it round-trips through its JSON.
|
||||
func TestStateSurvivesStorage(t *testing.T) {
|
||||
s := deal(t, table(), 9)
|
||||
s, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
again, _ := json.Marshal(back)
|
||||
if string(again) != string(blob) {
|
||||
t.Fatal("a stored game came back different")
|
||||
}
|
||||
playOut(t, back, 500)
|
||||
}
|
||||
|
||||
// A move the engine refuses leaves the caller's state exactly as it was.
|
||||
func TestARefusedMoveChangesNothing(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
before, _ := json.Marshal(s)
|
||||
|
||||
for _, m := range []Move{
|
||||
{Kind: MovePlay, Index: 0}, // doesn't match
|
||||
{Kind: MovePlay, Index: 1}, // wild with no colour
|
||||
{Kind: MovePlay, Index: 9}, // no such card
|
||||
{Kind: MovePass}, // nothing drawn
|
||||
{Kind: "shuffle-the-deck-in-my-favour"}, // no
|
||||
} {
|
||||
if _, _, err := ApplyMove(s, You, m); err == nil {
|
||||
t.Fatalf("%+v should have been refused", m)
|
||||
}
|
||||
}
|
||||
after, _ := json.Marshal(s)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("a refused move touched the state")
|
||||
}
|
||||
}
|
||||
|
||||
// The bots choose. Two different seeds should not play the same game.
|
||||
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
|
||||
same := 0
|
||||
for seed := uint64(0); seed < 20; seed++ {
|
||||
a := playOut(t, deal(t, duel(), seed), 500)
|
||||
b := playOut(t, deal(t, duel(), seed+1000), 500)
|
||||
if len(a.Discard) == len(b.Discard) {
|
||||
same++
|
||||
}
|
||||
}
|
||||
if same == 20 {
|
||||
t.Fatal("every seed played out to the same length — the bots aren't choosing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSavesTheDrawFour(t *testing.T) {
|
||||
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
|
||||
top, color := Card{Red, Nine}, Red
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
|
||||
held := 0
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, idx := botPick(hand, top, color, 5, rng); idx == 1 {
|
||||
held++
|
||||
}
|
||||
}
|
||||
if held < 30 {
|
||||
t.Errorf("with the table comfortable the bot should mostly play the red 5, held %d/50", held)
|
||||
}
|
||||
|
||||
reached := 0
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, idx := botPick(hand, top, color, 1, rng); idx == 0 {
|
||||
reached++
|
||||
}
|
||||
}
|
||||
if reached < 30 {
|
||||
t.Errorf("with a player on one card the bot should mostly play the +4, reached %d/50", reached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPicksItsBestColor(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(3, 4))
|
||||
hand := []Card{{Blue, One}, {Blue, Two}, {Green, Three}, {Wild, WildCard}}
|
||||
if got := botColor(hand, rng); got != Blue {
|
||||
t.Errorf("the bot holds two blues: it should call blue, got %v", got)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() {
|
||||
t.Fatalf("botColor named %v, which is not a colour", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotHasNothingToPlay(t *testing.T) {
|
||||
if _, idx := botPick([]Card{{Blue, Three}}, Card{Red, Nine}, Red, 3, rand.New(rand.NewPCG(1, 1))); idx != -1 {
|
||||
t.Errorf("a hand with nothing legal should report -1, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func hasKind(evs []Event, kind string) bool {
|
||||
for _, e := range evs {
|
||||
if e.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+22
-13
@@ -37,7 +37,9 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
||||
if s.Lede != "" {
|
||||
plainParts = append(plainParts, s.Lede)
|
||||
}
|
||||
plainParts = append(plainParts, formatSourceTag(s.Source, s.Platforms, false))
|
||||
if tag := formatSourceTag(s.Source, s.Platforms, false); tag != "" {
|
||||
plainParts = append(plainParts, tag)
|
||||
}
|
||||
plain = strings.Join(plainParts, "\n")
|
||||
|
||||
// HTML body
|
||||
@@ -55,25 +57,32 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
||||
if s.Lede != "" {
|
||||
htmlParts = append(htmlParts, html.EscapeString(s.Lede))
|
||||
}
|
||||
htmlParts = append(htmlParts, formatSourceTag(s.Source, s.Platforms, true))
|
||||
if tag := formatSourceTag(s.Source, s.Platforms, true); tag != "" {
|
||||
htmlParts = append(htmlParts, tag)
|
||||
}
|
||||
htmlBody = strings.Join(htmlParts, "<br/>")
|
||||
|
||||
return plain, htmlBody
|
||||
}
|
||||
|
||||
// formatSourceTag builds the source + platform tags line.
|
||||
// formatSourceTag builds the source + platform tags line. An empty source is
|
||||
// omitted rather than tagged: Pete's own reporting has no outlet to credit, and
|
||||
// an empty tag would read as him signing his own name.
|
||||
func formatSourceTag(source string, platforms []string, isHTML bool) string {
|
||||
if isHTML {
|
||||
parts := []string{fmt.Sprintf("<code>%s</code>", html.EscapeString(strings.ToLower(source)))}
|
||||
for _, p := range platforms {
|
||||
parts = append(parts, fmt.Sprintf("<code>%s</code>", html.EscapeString(p)))
|
||||
}
|
||||
return strings.Join(parts, " \u00b7 ")
|
||||
var parts []string
|
||||
if source != "" {
|
||||
parts = append(parts, strings.ToLower(source))
|
||||
}
|
||||
|
||||
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
|
||||
for _, p := range platforms {
|
||||
parts = append(parts, fmt.Sprintf("`%s`", p))
|
||||
parts = append(parts, platforms...)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
for i, p := range parts {
|
||||
if isHTML {
|
||||
parts[i] = fmt.Sprintf("<code>%s</code>", html.EscapeString(p))
|
||||
} else {
|
||||
parts[i] = fmt.Sprintf("`%s`", p)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " \u00b7 ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// Package opentdb fills the casino's trivia bank from the Open Trivia Database.
|
||||
//
|
||||
// The questions are *prefetched* into a local table, not fetched per question,
|
||||
// and that is a deliberate call rather than an optimisation. A trivia ladder
|
||||
// asks a question every fifteen seconds with money on the clock: a per-question
|
||||
// fetch would put somebody else's latency, rate limit and downtime inside a
|
||||
// timed round the player is being scored against. Pull the bank in the
|
||||
// background, and a round becomes a local read that either works or doesn't.
|
||||
//
|
||||
// OpenTDB allows one request every five seconds per IP and caps a batch at 50,
|
||||
// so the refill is a slow, polite drip, run in the background and never in the
|
||||
// path of anything a player is waiting for.
|
||||
package opentdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/safehttp"
|
||||
)
|
||||
|
||||
// endpoint is the API. It is the only host this package ever talks to, and it
|
||||
// goes through safehttp like every other outbound fetch in Pete.
|
||||
const endpoint = "https://opentdb.com/api.php"
|
||||
|
||||
// Batch is the most OpenTDB will hand over in one request.
|
||||
const Batch = 50
|
||||
|
||||
// Politeness is the gap the API asks for between requests. Going faster earns a
|
||||
// response_code 5 and nothing else.
|
||||
const Politeness = 6 * time.Second
|
||||
|
||||
// fetchTimeout bounds a single request. The refill runs in the background, so a
|
||||
// slow answer costs nothing but its own goroutine — but it must still end.
|
||||
const fetchTimeout = 20 * time.Second
|
||||
|
||||
// maxBody caps what we will read from the API, hostile or merely broken.
|
||||
const maxBody = 1 << 20
|
||||
|
||||
// apiResponse is OpenTDB's envelope. ResponseCode is the part that matters:
|
||||
// zero is the only one that means "here are your questions".
|
||||
type apiResponse struct {
|
||||
ResponseCode int `json:"response_code"`
|
||||
Results []struct {
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
Question string `json:"question"`
|
||||
Correct string `json:"correct_answer"`
|
||||
Incorrect []string `json:"incorrect_answers"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
// responseErr turns a non-zero code into something a log line can explain.
|
||||
func responseErr(code int) error {
|
||||
switch code {
|
||||
case 1:
|
||||
return fmt.Errorf("opentdb: no results for that query")
|
||||
case 2:
|
||||
return fmt.Errorf("opentdb: the query was invalid")
|
||||
case 3, 4:
|
||||
return fmt.Errorf("opentdb: session token expired or exhausted")
|
||||
case 5:
|
||||
return fmt.Errorf("opentdb: rate limited — slow down")
|
||||
default:
|
||||
return fmt.Errorf("opentdb: response code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Client fetches questions.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{http: safehttp.NewClient(fetchTimeout)}
|
||||
}
|
||||
|
||||
// Fetch pulls up to n multiple-choice questions of one difficulty.
|
||||
//
|
||||
// Only "multiple" questions are asked for: the ladder is four buttons, and a
|
||||
// true/false question on the same felt would be a coin flip dressed up as a
|
||||
// question — and a coin flip the player is being paid a difficulty multiple for.
|
||||
func (c *Client) Fetch(ctx context.Context, difficulty string, n int) ([]trivia.Question, error) {
|
||||
if n <= 0 || n > Batch {
|
||||
n = Batch
|
||||
}
|
||||
q := url.Values{
|
||||
"amount": {fmt.Sprint(n)},
|
||||
"difficulty": {difficulty},
|
||||
"type": {"multiple"},
|
||||
}
|
||||
raw := endpoint + "?" + q.Encode()
|
||||
if err := safehttp.ValidateURL(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "pete-games/1.0 (+https://games.parodia.dev)")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("opentdb: http %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(safehttp.LimitedBody(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out apiResponse
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("opentdb: %w", err)
|
||||
}
|
||||
if out.ResponseCode != 0 {
|
||||
return nil, responseErr(out.ResponseCode)
|
||||
}
|
||||
|
||||
qs := make([]trivia.Question, 0, len(out.Results))
|
||||
for _, r := range out.Results {
|
||||
// The API hands back HTML entities ("Who wrote "Dune"?"), which
|
||||
// would otherwise be drawn literally onto a button.
|
||||
text := clean(r.Question)
|
||||
correct := clean(r.Correct)
|
||||
if text == "" || correct == "" || len(r.Incorrect) != 3 {
|
||||
continue // a malformed question is one we simply don't take
|
||||
}
|
||||
|
||||
// Correct: 0 here is a convention, not a tell. The engine reshuffles every
|
||||
// question against the game's own seed as it builds the ladder, so where
|
||||
// the right answer sits in the bank never reaches a player.
|
||||
answers := make([]string, 0, 4)
|
||||
answers = append(answers, correct)
|
||||
dupe := false
|
||||
for _, w := range r.Incorrect {
|
||||
a := clean(w)
|
||||
// A wrong answer that reads the same as the right one — usually two
|
||||
// spellings that collapse once the entities are decoded — is a question
|
||||
// with two identical buttons on it, and the shuffle can only call one of
|
||||
// them correct. A player who clicked the right words and was told they
|
||||
// were wrong has lost the whole ladder to our typography. Drop it.
|
||||
if a == "" || a == correct {
|
||||
dupe = true
|
||||
break
|
||||
}
|
||||
answers = append(answers, a)
|
||||
}
|
||||
if dupe || len(answers) != 4 {
|
||||
continue
|
||||
}
|
||||
qs = append(qs, trivia.Question{
|
||||
Category: clean(r.Category),
|
||||
Text: text,
|
||||
Answers: answers,
|
||||
Correct: 0,
|
||||
})
|
||||
}
|
||||
return qs, nil
|
||||
}
|
||||
|
||||
// clean turns an API string into something you can put on a button: entities
|
||||
// decoded, whitespace tidied.
|
||||
func clean(s string) string {
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
// RunID is set on the dispatches that are the *ending* of an expedition — a
|
||||
// clear, a retreat, a death. It is the join from "how it went" to "what
|
||||
// happened", and it is empty on every other kind of fact.
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
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, run_id, 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.RunID, e.OccurredAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdventureEventByGUID returns the stored fact behind one dispatch, or nil when
|
||||
// there isn't one. Missing is normal, not an error: the story row is inserted
|
||||
// first and the fact record is best-effort (see handleAdventureIngest), and every
|
||||
// dispatch that predates the fact table has a story and no fact at all. Callers
|
||||
// render the thinner event_type-only view in that case.
|
||||
func AdventureEventByGUID(guid string) (*AdvEvent, error) {
|
||||
if guid == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var e AdvEvent
|
||||
var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors, runID sql.NullString
|
||||
err := Get().QueryRow(`
|
||||
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||
level, tally, outcome, milestone, stakes, actors, run_id, occurred_at
|
||||
FROM adventure_events WHERE guid = ?`, guid).Scan(
|
||||
&e.GUID, &e.EventType, &tier, &subject, &opponent, &boss, &zone, ®ion,
|
||||
&e.Level, &e.Tally, &outcome, &milestone, &stakes, &actors, &runID, &e.OccurredAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if 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
|
||||
e.RunID = runID.String
|
||||
if actors.String != "" {
|
||||
_ = json.Unmarshal([]byte(actors.String), &e.Actors)
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// AdventureEventFacets returns the (event_type, tier, outcome) of many dispatches
|
||||
// in one read, keyed by guid. It exists so a feed page can tint two dozen cards
|
||||
// by what they actually are without paying a query per card — the pool is
|
||||
// MaxOpenConns(1), so N round trips would serialize behind each other.
|
||||
//
|
||||
// Guids absent from the map are dispatches with no fact row; the caller leaves
|
||||
// those untinted rather than guessing.
|
||||
func AdventureEventFacets(guids []string) map[string]AdvEvent {
|
||||
out := make(map[string]AdvEvent, len(guids))
|
||||
if len(guids) == 0 {
|
||||
return out
|
||||
}
|
||||
q := `SELECT guid, event_type, tier, outcome FROM adventure_events WHERE guid IN (?` +
|
||||
strings.Repeat(`,?`, len(guids)-1) + `)`
|
||||
args := make([]any, len(guids))
|
||||
for i, g := range guids {
|
||||
args[i] = g
|
||||
}
|
||||
rows, err := Get().Query(q, args...)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e AdvEvent
|
||||
var tier, outcome sql.NullString
|
||||
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &outcome); err != nil {
|
||||
return out
|
||||
}
|
||||
e.Tier, e.Outcome = tier.String, outcome.String
|
||||
out[e.GUID] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,46 @@ func runMigrations(d *sql.DB) error {
|
||||
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
||||
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
||||
addColumnIfMissing(d, "round_robin_state", "last_channel", "TEXT")
|
||||
// Occupancy of a shared table. Rows written before the casino went multiplayer
|
||||
// are solo games and read as NULL, which is exactly what they are.
|
||||
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT")
|
||||
// The public detail sheet (stats + equipped gear) for an adventurer's
|
||||
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
|
||||
// gogobee build that predates the detail page.
|
||||
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")
|
||||
// The run behind a dispatch that *ended* one. NULL on every fact filed before
|
||||
// the run report existed and on every fact that isn't the end of an
|
||||
// expedition; both simply render without the "read the run" link.
|
||||
addColumnIfMissing(d, "adventure_events", "run_id", "TEXT")
|
||||
// The liveblog's late-arriving prose and the column that carries it. Both are
|
||||
// in their tables' CREATE TABLE — those tables have never shipped — so these
|
||||
// two adds exist only for a database that already ran an earlier build of the
|
||||
// run-liveblog branch.
|
||||
addColumnIfMissing(d, "adventure_run", "summary", "TEXT NOT NULL DEFAULT ''")
|
||||
addColumnIfMissing(d, "adventure_run_beat", "prose", "TEXT NOT NULL DEFAULT ''")
|
||||
// Ask 7: upgrade orders carry a target tier for the 5 standard equipment slots.
|
||||
addColumnIfMissing(d, "equip_orders", "tier", "INTEGER NOT NULL DEFAULT 0")
|
||||
// W5b: the three verbs that take arguments (which zone, which loadout, how
|
||||
// many days of sitting) carry them as one small JSON object. W5a's two verbs
|
||||
// take none, so an existing row gets '' and reads back as no params — which is
|
||||
// exactly what extract and siege_join mean.
|
||||
addColumnIfMissing(d, "adventure_orders", "params", "TEXT NOT NULL DEFAULT ''")
|
||||
// Adventure alerts. A subscription made before they existed knows only the OIDC
|
||||
// subject, and the adventure ownership join needs the Matrix localpart — so an
|
||||
// existing row gets "" here and is skipped for owner-scoped alerts until the
|
||||
// browser re-subscribes, which it does on every page load that has push on.
|
||||
// Realm-wide alerts (the Siege) need no localpart and work immediately.
|
||||
addColumnIfMissing(d, "push_subscriptions", "user_localpart", "TEXT NOT NULL DEFAULT ''")
|
||||
// The adventure watermark is deliberately separate from last_notified_at: the
|
||||
// digest and the alerts run on different clocks (6 hours vs 2 minutes), and
|
||||
// sharing one column would let whichever ran last decide what the other had
|
||||
// already seen. 0 on a pre-existing row is corrected to "now" on the first
|
||||
// pass rather than replaying every dispatch Pete has ever stored.
|
||||
addColumnIfMissing(d, "push_subscriptions", "last_adv_notified_at", "INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
||||
// Check sqlite_master before creating.
|
||||
@@ -139,6 +179,15 @@ func RunMaintenance() {
|
||||
exec("prune old daily_visitors",
|
||||
`DELETE FROM daily_visitors WHERE day < ?`, unixDay()-30)
|
||||
|
||||
// Finished expedition logs. Kept for longer than the page shows them (the
|
||||
// adventurer page hides a run six hours after it ends) because the dispatch
|
||||
// that announced the run outlives the run, and a dead link from a story to
|
||||
// its own log is worse than a log nobody reads. A run still walking is never
|
||||
// pruned however old it looks — see PruneRuns for why.
|
||||
if err := PruneRuns(nowUnix() - int64(14*86400)); err != nil {
|
||||
slog.Error("db exec failed", "op", "prune finished runs", "err", err)
|
||||
}
|
||||
|
||||
exec("wal checkpoint", "PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
exec("optimize", "PRAGMA optimize")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// PlayerDetail is one player's private, owner-only expansion — inventory, vault,
|
||||
// house, pets — pushed by gogobee keyed by localpart. Pete stores it in its own
|
||||
// keyspace (player_self_detail) and only ever serves it back to the one
|
||||
// authenticated user it belongs to. Token rides along so the detail page can
|
||||
// prove owner↔page without ever reversing the anonymous roster token.
|
||||
type PlayerDetail struct {
|
||||
Localpart string `json:"localpart"`
|
||||
Token string `json:"token"`
|
||||
Inventory []ItemView `json:"inventory,omitempty"`
|
||||
Vault []ItemView `json:"vault,omitempty"`
|
||||
Equipped []ItemView `json:"equipped,omitempty"`
|
||||
House HouseView `json:"house"`
|
||||
Pets []PetView `json:"pets,omitempty"`
|
||||
// Slots is the 5 standard equipment slots (weapon/armor/helmet/boots/tool) —
|
||||
// owner-only, the input to the web equipment-management panel. Worn
|
||||
// masterwork/arena pieces surface HERE (via CanTakeOff), not in Equipped, which
|
||||
// stays magic-only (the DnD slots). See EquipSlotView.
|
||||
Slots []EquipSlotView `json:"slots,omitempty"`
|
||||
// Balance is the owner's euro balance, for the upgrade/repair confirm dialogs.
|
||||
Balance float64 `json:"balance,omitempty"`
|
||||
// Zones / Resume / Babysit are the W5b action offers: what this owner may ask
|
||||
// for from the web right now, priced by gogobee. Pete renders these and does no
|
||||
// arithmetic — every price and every gate is the game's, quoted at push time.
|
||||
//
|
||||
// An offer is NOT a permission. It is up to two minutes stale, so gogobee
|
||||
// re-resolves the zone, the price and the fee when the order lands. What the
|
||||
// list buys is a page that does not offer a button certain to be refused.
|
||||
Zones []ZoneOffer `json:"zones,omitempty"`
|
||||
Resume *ResumeOffer `json:"resume,omitempty"`
|
||||
Babysit *BabysitOffer `json:"babysit,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneOffer is one place the owner may set out for. Absent entirely while they
|
||||
// are already out, so an empty list means "not right now" rather than "nowhere".
|
||||
type ZoneOffer struct {
|
||||
ID string `json:"id"`
|
||||
Display string `json:"display"`
|
||||
Tier int `json:"tier"`
|
||||
Hook string `json:"hook,omitempty"`
|
||||
Postgame bool `json:"postgame,omitempty"`
|
||||
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
|
||||
}
|
||||
|
||||
// LoadoutOffer is one supply preset: what it is called, what it costs, and how
|
||||
// many days of provisions it buys. Key is what the order carries back.
|
||||
type LoadoutOffer struct {
|
||||
Key string `json:"key"` // lean|balanced|heavy
|
||||
Name string `json:"name"`
|
||||
Blurb string `json:"blurb,omitempty"`
|
||||
Cost int `json:"cost"`
|
||||
Days int `json:"days"`
|
||||
}
|
||||
|
||||
// ResumeOffer is the extracted expedition still waiting to be walked back into.
|
||||
// ExpiresAt is the end of the seven-day window, so the page can say how long is
|
||||
// left rather than only that there is a way back.
|
||||
type ResumeOffer struct {
|
||||
ZoneID string `json:"zone_id"`
|
||||
Display string `json:"display"`
|
||||
Tier int `json:"tier"`
|
||||
Day int `json:"day"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
|
||||
}
|
||||
|
||||
// BabysitOffer is the pet sitter's standing and the two prices they charge. It
|
||||
// is pushed even when a sitter is engaged: "looked after until Tuesday" is what
|
||||
// the page should say instead of a buy button.
|
||||
type BabysitOffer struct {
|
||||
Active bool `json:"active"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
WeekCost int `json:"week_cost"`
|
||||
MonthCost int `json:"month_cost"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// ID is the adventure_inventory row id, sent only for a backpack item that can
|
||||
// be worn through the magic-item path — so a non-zero ID doubles as "this item
|
||||
// has an Equip button." Worn items carry none: unequip keys on Slot. The id is
|
||||
// the handle an equip order round-trips back to gogobee to name the item.
|
||||
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.
|
||||
type HouseView struct {
|
||||
Tier int `json:"tier"`
|
||||
LoanBalance int `json:"loan_balance,omitempty"`
|
||||
Autopay bool `json:"autopay,omitempty"`
|
||||
Rate float64 `json:"rate,omitempty"`
|
||||
}
|
||||
|
||||
// PetView is one pet slot.
|
||||
//
|
||||
// XP and XPNeeded are both **centi-XP**, the game's own unit: a pet earns 1.5
|
||||
// points per action and the stored ledger is an integer, so everything is kept
|
||||
// times a hundred. Divide by 100 to show a number to a human; do nothing else
|
||||
// with either. XPNeeded is the engine's per-band curve and is 0 at the level cap,
|
||||
// which is the only way to tell "full" from "nothing left to earn".
|
||||
type PetView struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
XP int `json:"xp,omitempty"`
|
||||
XPNeeded int `json:"xp_needed,omitempty"`
|
||||
ArmorTier int `json:"armor_tier,omitempty"`
|
||||
}
|
||||
|
||||
// ReplacePlayerDetail swaps the whole private-detail set in one transaction —
|
||||
// replace, never merge, the same contract as the roster: a player who dropped
|
||||
// out of gogobee's push must lose their stale self-view rather than have it
|
||||
// linger. localpart is lowercased upstream to match how a session Username reads.
|
||||
func ReplacePlayerDetail(players []PlayerDetail, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM player_self_detail`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO player_self_detail (localpart, token, detail_json, snapshot_at)
|
||||
VALUES (?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, p := range players {
|
||||
if p.Localpart == "" || p.Token == "" {
|
||||
continue // a self-view with no owner or no page to hang on is unusable
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := stmt.Exec(p.Localpart, p.Token, string(body), snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// PlayerDetailByOwner returns the private detail for localpart, but only when it
|
||||
// owns the given page token. This is the ownership join the detail page needs:
|
||||
// the signed-in user's localpart is trusted (it comes from their verified
|
||||
// session), and a row exists only if gogobee pushed that same (localpart, token)
|
||||
// pair — so a viewer can only ever unlock the self extras on their own page, and
|
||||
// Pete never has to turn a token back into a handle to decide it.
|
||||
func PlayerDetailByOwner(localpart, token string) (PlayerDetail, bool, error) {
|
||||
if localpart == "" || token == "" {
|
||||
return PlayerDetail{}, false, nil
|
||||
}
|
||||
var storedToken, detailJSON string
|
||||
err := Get().QueryRow(
|
||||
`SELECT token, detail_json FROM player_self_detail WHERE localpart = ?`, localpart).
|
||||
Scan(&storedToken, &detailJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return PlayerDetail{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PlayerDetail{}, false, err
|
||||
}
|
||||
if storedToken != token {
|
||||
return PlayerDetail{}, false, nil // signed in, but not the owner of this page
|
||||
}
|
||||
var pd PlayerDetail
|
||||
if err := json.Unmarshal([]byte(detailJSON), &pd); err != nil {
|
||||
return PlayerDetail{}, false, err
|
||||
}
|
||||
pd.Localpart = localpart
|
||||
pd.Token = storedToken
|
||||
return pd, true, nil
|
||||
}
|
||||
|
||||
// SelfToken returns the roster token owned by localpart, if gogobee's last push
|
||||
// carried one. Lets the board mark "your adventurer" without exposing the
|
||||
// localpart↔token map anywhere public.
|
||||
func SelfToken(localpart string) (string, bool) {
|
||||
if localpart == "" {
|
||||
return "", false
|
||||
}
|
||||
var token string
|
||||
err := Get().QueryRow(
|
||||
`SELECT token FROM player_self_detail WHERE localpart = ?`, localpart).Scan(&token)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// The private self-detail set is the one place Pete holds a localpart↔token
|
||||
// association. These tests pin the security contract of that store: the
|
||||
// ownership join only ever unlocks a page for the localpart that owns it, and a
|
||||
// replace wipes a departed player's stale self-view rather than leaving it to
|
||||
// linger.
|
||||
|
||||
func seedSelfDetail(t *testing.T, localpart, token string) PlayerDetail {
|
||||
t.Helper()
|
||||
return PlayerDetail{
|
||||
Localpart: localpart,
|
||||
Token: token,
|
||||
Inventory: []ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
|
||||
Vault: []ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||
House: HouseView{Tier: 2, LoanBalance: 1500},
|
||||
Pets: []PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayerDetailByOwnerMatrix is the ownership matrix: the self-view unlocks
|
||||
// only when the signed-in localpart owns the exact page token. A different
|
||||
// localpart, or the owner viewing someone else's page, gets nothing.
|
||||
func TestPlayerDetailByOwnerMatrix(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
seedSelfDetail(t, "quack", "tok-quack"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatalf("ReplacePlayerDetail: %v", err)
|
||||
}
|
||||
|
||||
// The owner, on their own page: unlocked, and the private goods come through.
|
||||
pd, ok, err := PlayerDetailByOwner("josie", "tok-josie")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("owner on own page: ok=%v err=%v, want unlocked", ok, err)
|
||||
}
|
||||
if len(pd.Inventory) != 1 || pd.House.Tier != 2 || len(pd.Pets) != 1 {
|
||||
t.Errorf("owner detail = %+v, want inventory+house+pet carried", pd)
|
||||
}
|
||||
|
||||
// Josie signed in, looking at Quack's page: her localpart doesn't own that
|
||||
// token, so the join must refuse — no peeking at another player's private set.
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-quack"); ok {
|
||||
t.Error("owner unlocked ANOTHER player's page — the token guard failed")
|
||||
}
|
||||
|
||||
// A signed-in stranger with no self-detail row at all.
|
||||
if _, ok, _ := PlayerDetailByOwner("nobody", "tok-josie"); ok {
|
||||
t.Error("a stranger unlocked a page they have no row for")
|
||||
}
|
||||
|
||||
// Empty inputs never unlock.
|
||||
if _, ok, _ := PlayerDetailByOwner("", "tok-josie"); ok {
|
||||
t.Error("empty localpart unlocked a page")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", ""); ok {
|
||||
t.Error("empty token unlocked a page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailReplaces: the set is swapped whole, never merged — a
|
||||
// player gogobee stops pushing (deleted character, dropped out) must lose their
|
||||
// stale self-view, the same complete-snapshot contract as the board.
|
||||
func TestReplacePlayerDetailReplaces(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
seedSelfDetail(t, "quack", "tok-quack"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Next push carries only Josie; Quack has left.
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
}, 1060); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("quack", "tok-quack"); ok {
|
||||
t.Error("a dropped player's self-view survived the replace")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-josie"); !ok {
|
||||
t.Error("the surviving player lost their self-view")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailTokenFollows: when a player's board token rotates
|
||||
// (re-derived each push), the ownership check must follow it. The stale token no
|
||||
// longer unlocks; the current one does.
|
||||
func TestReplacePlayerDetailTokenFollows(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-old")}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-new")}, 1060); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-old"); ok {
|
||||
t.Error("the stale token still unlocked the page after a rotation")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-new"); !ok {
|
||||
t.Error("the current token failed to unlock the page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailSkipsUnusable: a row with no owner or no page to hang
|
||||
// on is dropped at write time — it could never be served and would only be dead
|
||||
// weight.
|
||||
func TestReplacePlayerDetailSkipsUnusable(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
{Localpart: "", Token: "tok-orphan"},
|
||||
{Localpart: "ghost", Token: ""},
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok, ok := SelfToken("josie"); !ok || tok != "tok-josie" {
|
||||
t.Errorf("SelfToken(josie) = %q,%v, want tok-josie", tok, ok)
|
||||
}
|
||||
if _, ok := SelfToken("ghost"); ok {
|
||||
t.Error("a tokenless row was stored")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The chip ledger and the euro/chip border.
|
||||
//
|
||||
// Chips are euros that have crossed into the casino. They are 1:1 with euros and
|
||||
// they are not a second wallet: every chip that exists came from a euro gogobee
|
||||
// debited, and every chip destroyed becomes a euro gogobee credits back. Pete
|
||||
// never writes a euro balance. The border is crossed only by a game_escrow row,
|
||||
// whose guid is the idempotency key gogobee hands to DebitIdem/CreditIdem — so a
|
||||
// claim whose acknowledgement is lost on the wire can be retried without the
|
||||
// player paying for it twice.
|
||||
//
|
||||
// The whole reason for the border is latency. gogobee has no inbound API and is
|
||||
// not getting one, so it polls; a bet that round-tripped through a poll loop
|
||||
// would take seconds to be dealt. Instead the poll loop runs twice per *session*
|
||||
// — buy in, cash out — and every hand in between plays against chips held here,
|
||||
// at full speed, with no economy call in the hot path.
|
||||
|
||||
// MaxChipsOnTable caps how many chips a player can hold at once. A buy-in that
|
||||
// would push them over is refused before it ever reaches gogobee.
|
||||
//
|
||||
// This is the inflation brake. A web casino runs orders of magnitude more hands
|
||||
// per hour than a Matrix-paced one ever did, so whatever the house edge is, it
|
||||
// compounds far faster in both directions. The cap bounds the worst case for a
|
||||
// single sitting; the rake (see the blackjack engine) bleeds the rest back out.
|
||||
const MaxChipsOnTable int64 = 10_000
|
||||
|
||||
// EscrowStaleAfter is how long a claimed-but-unsettled escrow row waits before
|
||||
// the poll endpoint offers it again. gogobee can die between claiming a row and
|
||||
// pushing its result; without a re-offer, the player's money sits in limbo
|
||||
// forever. Re-claiming is safe precisely because the guid makes it idempotent.
|
||||
const EscrowStaleAfter = 90 * time.Second
|
||||
|
||||
// SessionIdleAfter is when the reaper decides a player has walked away and cashes
|
||||
// their chips back to euros on their behalf. Chips in an abandoned session are
|
||||
// euros in limbo, and limbo is not a state a player's money should be in.
|
||||
const SessionIdleAfter = 30 * time.Minute
|
||||
|
||||
// Escrow kinds and states. These strings cross the wire to gogobee, so they are
|
||||
// part of the contract — see §4 of pete_games_plan.md.
|
||||
const (
|
||||
KindBuyIn = "buyin"
|
||||
KindCashOut = "cashout"
|
||||
|
||||
EscrowRequested = "requested" // the player asked; gogobee hasn't seen it yet
|
||||
EscrowClaimed = "claimed" // gogobee has it and is moving the euros
|
||||
EscrowFunded = "funded" // buy-in landed; the chips are spendable
|
||||
EscrowRejected = "rejected" // buy-in refused; no chips, no euros moved
|
||||
EscrowSettled = "settled" // cash-out landed; chips destroyed, euros credited
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientChips = errors.New("games: not enough chips")
|
||||
ErrOverTableCap = errors.New("games: that would put more than the cap on the table")
|
||||
ErrBadAmount = errors.New("games: amount must be positive")
|
||||
ErrNoSuchEscrow = errors.New("games: no such escrow row")
|
||||
)
|
||||
|
||||
// Escrow is one crossing of the euro/chip border.
|
||||
type Escrow struct {
|
||||
GUID string `json:"guid"`
|
||||
MatrixUser string `json:"matrix_user"`
|
||||
Kind string `json:"kind"`
|
||||
Amount int64 `json:"amount"`
|
||||
State string `json:"state,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BalanceAfter float64 `json:"balance_after,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// ChipStack is what a player has on the table right now.
|
||||
type ChipStack struct {
|
||||
Chips int64 // spendable
|
||||
// Pending is chips asked for but not yet funded — a buy-in gogobee hasn't
|
||||
// claimed or settled. Shown as "buying chips…", never spendable.
|
||||
Pending int64
|
||||
EuroBalance float64 // advisory, from the last gogobee push; may be minutes stale
|
||||
LastPlayed int64
|
||||
}
|
||||
|
||||
// newGUID mints an escrow id. It's the idempotency key for a real money move, so
|
||||
// it comes from crypto/rand rather than anything a caller could collide with.
|
||||
func newGUID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("games: mint guid: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Chips reports a player's stack. A player who has never played has no row and
|
||||
// reads as an empty stack rather than an error.
|
||||
func Chips(user string) (ChipStack, error) {
|
||||
var st ChipStack
|
||||
var euro sql.NullFloat64
|
||||
err := Get().QueryRow(
|
||||
`SELECT chips, euro_balance, last_played FROM game_chips WHERE matrix_user = ?`, user,
|
||||
).Scan(&st.Chips, &euro, &st.LastPlayed)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return ChipStack{}, fmt.Errorf("games: read chips: %w", err)
|
||||
}
|
||||
st.EuroBalance = euro.Float64
|
||||
|
||||
if err := Get().QueryRow(
|
||||
`SELECT COALESCE(SUM(amount), 0) FROM game_escrow
|
||||
WHERE matrix_user = ? AND kind = ? AND state IN (?, ?)`,
|
||||
user, KindBuyIn, EscrowRequested, EscrowClaimed,
|
||||
).Scan(&st.Pending); err != nil {
|
||||
return ChipStack{}, fmt.Errorf("games: read pending buy-ins: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// RequestBuyIn opens a buy-in: the player wants `amount` euros turned into chips.
|
||||
// No chips exist yet — they appear only when gogobee confirms it took the euros.
|
||||
// The table cap is enforced here, against chips already held *plus* buy-ins still
|
||||
// in flight, so a player can't clear the cap by firing several at once.
|
||||
func RequestBuyIn(user string, amount int64) (Escrow, error) {
|
||||
if amount <= 0 {
|
||||
return Escrow{}, ErrBadAmount
|
||||
}
|
||||
st, err := Chips(user)
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
if st.Chips+st.Pending+amount > MaxChipsOnTable {
|
||||
return Escrow{}, ErrOverTableCap
|
||||
}
|
||||
|
||||
guid, err := newGUID()
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, user, KindBuyIn, amount, EscrowRequested, now, now,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: request buy-in: %w", err)
|
||||
}
|
||||
return Escrow{GUID: guid, MatrixUser: user, Kind: KindBuyIn, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// RequestCashOut opens a cash-out: chips are destroyed *now*, and the matching
|
||||
// euros arrive when gogobee claims the row.
|
||||
//
|
||||
// Destroying them up front is what keeps the invariant true. If the chips lingered
|
||||
// until gogobee confirmed, a player could bet them while the cash-out was in
|
||||
// flight and the same euro would exist on both sides of the border. If the credit
|
||||
// somehow fails, RefundCashOut puts the chips back.
|
||||
func RequestCashOut(user string, amount int64) (Escrow, error) {
|
||||
if amount <= 0 {
|
||||
return Escrow{}, ErrBadAmount
|
||||
}
|
||||
guid, err := newGUID()
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: begin cash-out: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
// Conditional update: the chips leave only if they're actually there.
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_chips SET chips = chips - ?, updated_at = ?
|
||||
WHERE matrix_user = ? AND chips >= ?`,
|
||||
amount, now, user, amount,
|
||||
)
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: debit chips: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return Escrow{}, ErrInsufficientChips
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, user, KindCashOut, amount, EscrowRequested, now, now,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: request cash-out: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: commit cash-out: %w", err)
|
||||
}
|
||||
return Escrow{GUID: guid, MatrixUser: user, Kind: KindCashOut, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// PendingEscrow is what gogobee's poll loop reads: everything waiting to be moved.
|
||||
//
|
||||
// It returns rows nobody has claimed, *and* rows claimed long enough ago that we
|
||||
// have to assume gogobee died holding them. Re-offering a claimed row is safe
|
||||
// because the guid is idempotent end to end: if gogobee already moved the euros,
|
||||
// the retry is a no-op that reports the same answer.
|
||||
func PendingEscrow(limit int) ([]Escrow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
stale := nowUnix() - int64(EscrowStaleAfter.Seconds())
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, matrix_user, kind, amount, state, created_at
|
||||
FROM game_escrow
|
||||
WHERE state = ?
|
||||
OR (state = ? AND COALESCE(claimed_at, 0) < ?)
|
||||
ORDER BY created_at
|
||||
LIMIT ?`,
|
||||
EscrowRequested, EscrowClaimed, stale, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: pending escrow: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Escrow
|
||||
for rows.Next() {
|
||||
var e Escrow
|
||||
if err := rows.Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("games: scan escrow: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ClaimEscrow marks a row as taken by gogobee. Claiming is idempotent and is not
|
||||
// a lock: a row already claimed can be claimed again (that's how a stale re-offer
|
||||
// works), but a row already *finished* cannot be, which is what stops a settled
|
||||
// cash-out from being paid a second time.
|
||||
func ClaimEscrow(guid string) (Escrow, error) {
|
||||
now := nowUnix()
|
||||
res, err := Get().Exec(
|
||||
`UPDATE game_escrow SET state = ?, claimed_at = ?, updated_at = ?
|
||||
WHERE guid = ? AND state IN (?, ?)`,
|
||||
EscrowClaimed, now, now, guid, EscrowRequested, EscrowClaimed,
|
||||
)
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: claim escrow: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
// Either it doesn't exist or it's already finished. Tell the caller which.
|
||||
e, err := EscrowByGUID(guid)
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
return EscrowByGUID(guid)
|
||||
}
|
||||
|
||||
// EscrowByGUID reads one row.
|
||||
func EscrowByGUID(guid string) (Escrow, error) {
|
||||
var e Escrow
|
||||
var reason sql.NullString
|
||||
var bal sql.NullFloat64
|
||||
err := Get().QueryRow(
|
||||
`SELECT guid, matrix_user, kind, amount, state, reason, balance_after, created_at
|
||||
FROM game_escrow WHERE guid = ?`, guid,
|
||||
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &reason, &bal, &e.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Escrow{}, ErrNoSuchEscrow
|
||||
}
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: read escrow: %w", err)
|
||||
}
|
||||
e.Reason, e.BalanceAfter = reason.String, bal.Float64
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// SettleEscrow applies gogobee's verdict on a claimed row, and is the only place
|
||||
// chips are created or finally destroyed.
|
||||
//
|
||||
// buy-in, ok -> chips appear (funded)
|
||||
// buy-in, !ok -> nothing happens, nothing moved (rejected)
|
||||
// cash-out, ok -> chips stay destroyed, euros paid (settled)
|
||||
// cash-out, !ok -> chips come back (funded — the player never lost them)
|
||||
//
|
||||
// It is idempotent: gogobee's push queue retries, so the same verdict can arrive
|
||||
// more than once and only the first one may move chips. A row that has already
|
||||
// reached a terminal state is a no-op, not an error.
|
||||
func SettleEscrow(guid string, ok bool, reason string, balanceAfter float64) (Escrow, error) {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: begin settle: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
var e Escrow
|
||||
var st string
|
||||
if err := tx.QueryRow(
|
||||
`SELECT guid, matrix_user, kind, amount, state FROM game_escrow WHERE guid = ?`, guid,
|
||||
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &st); errors.Is(err, sql.ErrNoRows) {
|
||||
return Escrow{}, ErrNoSuchEscrow
|
||||
} else if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: settle lookup: %w", err)
|
||||
}
|
||||
|
||||
// Terminal already — a retried push. Report what we decided the first time.
|
||||
if st == EscrowFunded || st == EscrowRejected || st == EscrowSettled {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
|
||||
}
|
||||
return EscrowByGUID(guid)
|
||||
}
|
||||
|
||||
final := EscrowFunded
|
||||
switch {
|
||||
case e.Kind == KindBuyIn && ok:
|
||||
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
case e.Kind == KindBuyIn && !ok:
|
||||
final = EscrowRejected // gogobee took nothing, so we create nothing
|
||||
case e.Kind == KindCashOut && ok:
|
||||
final = EscrowSettled // the chips were destroyed when the row was opened
|
||||
case e.Kind == KindCashOut && !ok:
|
||||
// gogobee couldn't pay. The chips were already destroyed on our side, so
|
||||
// give them back rather than vanishing the player's money.
|
||||
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE game_escrow SET state = ?, reason = ?, balance_after = ?, updated_at = ?
|
||||
WHERE guid = ?`,
|
||||
final, reason, balanceAfter, now, guid,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: settle update: %w", err)
|
||||
}
|
||||
|
||||
// The euro balance gogobee just reported is the freshest one we'll get.
|
||||
// Advisory only — we display it, we never decide anything with it.
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE game_chips SET euro_balance = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||
balanceAfter, now, e.MatrixUser,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: cache euro balance: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
|
||||
}
|
||||
return EscrowByGUID(guid)
|
||||
}
|
||||
|
||||
// addChips credits a stack inside an open transaction, creating the row if the
|
||||
// player has never held chips before.
|
||||
func addChips(tx *sql.Tx, user string, amount int64, now int64) error {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET chips = chips + excluded.chips, updated_at = excluded.updated_at`,
|
||||
user, amount, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: credit chips: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stake takes chips off a player's stack to put them at risk on a hand. It is the
|
||||
// conditional-update kind of debit: the chips leave in the same statement that
|
||||
// checks they're there, so two hands opened at once can't spend the same chip.
|
||||
func Stake(user string, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return ErrBadAmount
|
||||
}
|
||||
now := nowUnix()
|
||||
res, err := Get().Exec(
|
||||
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
|
||||
WHERE matrix_user = ? AND chips >= ?`,
|
||||
amount, now, now, user, amount,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: stake: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrInsufficientChips
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Award returns chips to a player when a hand settles: stake plus winnings, net
|
||||
// of rake, exactly as the engine computed it. A losing hand awards nothing and
|
||||
// should not call this.
|
||||
//
|
||||
// This is the standalone form, for a caller with no transaction of its own. A
|
||||
// settle must not use it — see award, and the warning on CommitHand.
|
||||
func Award(user string, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin award: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
if err := award(tx, user, amount, nowUnix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit award: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// award credits a stack inside an open transaction.
|
||||
//
|
||||
// It differs from addChips in one deliberate way: it moves last_played, because
|
||||
// being paid is something that happens at a table and the reaper should see it.
|
||||
// A buy-in is not — that is why addChips leaves the idle clock alone.
|
||||
func award(tx *sql.Tx, user string, amount int64, now int64) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||
chips = chips + excluded.chips, last_played = excluded.last_played, updated_at = excluded.updated_at`,
|
||||
user, amount, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: award chips: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Hand is one settled hand, as the audit log keeps it.
|
||||
type Hand struct {
|
||||
MatrixUser string
|
||||
Game string
|
||||
Bet int64
|
||||
Payout int64
|
||||
Rake int64
|
||||
Outcome string
|
||||
Seed1 uint64
|
||||
Seed2 uint64
|
||||
}
|
||||
|
||||
// RecordHand writes a finished hand to the audit trail. The seeds are the point:
|
||||
// with them, any hand in the log can be dealt again exactly as it fell, which is
|
||||
// how a dispute gets answered with a fact instead of an apology.
|
||||
func RecordHand(h Hand) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin record hand: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
if err := recordHand(tx, h, nowUnix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit record hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordHand writes the audit row inside an open transaction.
|
||||
func recordHand(tx *sql.Tx, h Hand, now int64) error {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome,
|
||||
int64(h.Seed1), int64(h.Seed2), now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: record hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IdleStacks lists players holding chips who stopped playing a while ago. The
|
||||
// reaper cashes these out on their behalf: chips in an abandoned session are
|
||||
// euros in limbo, and they should be back in the player's balance where they can
|
||||
// see them.
|
||||
func IdleStacks(idleFor time.Duration) ([]ChipStack, []string, error) {
|
||||
cutoff := nowUnix() - int64(idleFor.Seconds())
|
||||
rows, err := Get().Query(
|
||||
`SELECT matrix_user, chips, last_played FROM game_chips
|
||||
WHERE chips > 0 AND last_played > 0 AND last_played < ?`, cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("games: idle stacks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stacks []ChipStack
|
||||
var users []string
|
||||
for rows.Next() {
|
||||
var st ChipStack
|
||||
var user string
|
||||
if err := rows.Scan(&user, &st.Chips, &st.LastPlayed); err != nil {
|
||||
return nil, nil, fmt.Errorf("games: scan idle stack: %w", err)
|
||||
}
|
||||
stacks = append(stacks, st)
|
||||
users = append(users, user)
|
||||
}
|
||||
return stacks, users, rows.Err()
|
||||
}
|
||||
|
||||
// ReapIdleSessions cashes out everyone who walked away, and reports how many it
|
||||
// sent home. Safe to run on a timer: a player who comes back simply buys in again,
|
||||
// and a cash-out that's already in flight can't be opened twice because the chips
|
||||
// are gone from the stack the moment the first one is.
|
||||
func ReapIdleSessions(idleFor time.Duration) (int, error) {
|
||||
stacks, users, err := IdleStacks(idleFor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
reaped := 0
|
||||
for i, user := range users {
|
||||
if _, err := RequestCashOut(user, stacks[i].Chips); err != nil {
|
||||
// One player's stack failing to reap shouldn't strand everyone else's.
|
||||
if !errors.Is(err, ErrInsufficientChips) {
|
||||
return reaped, fmt.Errorf("games: reap %s: %w", user, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
reaped++
|
||||
}
|
||||
return reaped, nil
|
||||
}
|
||||
|
||||
// Touch marks a player as active, so the reaper leaves them alone. Called on any
|
||||
// deliberate action at a table — not on a page load, or an open tab would keep a
|
||||
// walked-away player's chips hostage forever.
|
||||
func Touch(user string) {
|
||||
exec("games: touch session",
|
||||
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||
nowUnix(), nowUnix(), user)
|
||||
}
|
||||
|
||||
// ---- the hand in progress -------------------------------------------------
|
||||
|
||||
var (
|
||||
// ErrNoLiveHand means the player isn't in a hand right now.
|
||||
ErrNoLiveHand = errors.New("games: no hand in progress")
|
||||
// ErrHandInProgress means they already are, and may not be dealt another.
|
||||
ErrHandInProgress = errors.New("games: already in a hand")
|
||||
)
|
||||
|
||||
// LiveHand is a hand a player is in the middle of. State is the engine's own
|
||||
// State, serialized whole — the shoe is in there, which is exactly why this row
|
||||
// never leaves the server.
|
||||
type LiveHand struct {
|
||||
Game string
|
||||
State []byte
|
||||
Seed1 uint64
|
||||
Seed2 uint64
|
||||
// TableID is set when the player is sitting at a shared table instead of playing
|
||||
// alone. The cards are then in game_tables and State here is empty: this row is
|
||||
// the occupancy claim and nothing else. One row per player either way, which is
|
||||
// the point — the primary key that stops a second solo hand is the same one that
|
||||
// stops a second seat.
|
||||
TableID string
|
||||
}
|
||||
|
||||
// StartLiveHand seats a *new* hand, and refuses if the player is already in one.
|
||||
// The plain INSERT is the point: it is the primary key, not a prior read, that
|
||||
// decides. Two Deal clicks racing each other would otherwise both see an empty
|
||||
// felt, both take a stake, and the second would overwrite the first — taking the
|
||||
// player's chips for a hand that no longer exists anywhere.
|
||||
func StartLiveHand(user string, h LiveHand) error {
|
||||
res, err := Get().Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), nowUnix(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: start live hand: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrHandInProgress
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveLiveHand stores the hand a player is in, replacing any earlier one. The
|
||||
// player's stake has already left their stack by the time this is called, so
|
||||
// the write is what makes the hand recoverable if Pete restarts mid-deal.
|
||||
func SaveLiveHand(user string, h LiveHand) error {
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||
game = excluded.game, state = excluded.state,
|
||||
seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
|
||||
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: save live hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadLiveHand returns the hand a player is in, or ErrNoLiveHand.
|
||||
func LoadLiveHand(user string) (LiveHand, error) {
|
||||
var h LiveHand
|
||||
var state string
|
||||
var s1, s2 int64
|
||||
var tableID sql.NullString
|
||||
err := Get().QueryRow(
|
||||
`SELECT game, state, seed1, seed2, table_id FROM game_live_hands WHERE matrix_user = ?`, user,
|
||||
).Scan(&h.Game, &state, &s1, &s2, &tableID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return LiveHand{}, ErrNoLiveHand
|
||||
}
|
||||
if err != nil {
|
||||
return LiveHand{}, fmt.Errorf("games: load live hand: %w", err)
|
||||
}
|
||||
h.State, h.Seed1, h.Seed2, h.TableID = []byte(state), uint64(s1), uint64(s2), tableID.String
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// ClearLiveHand ends a hand. Called when it settles — the audit log in
|
||||
// game_hands is what survives it.
|
||||
func ClearLiveHand(user string) error {
|
||||
if _, err := Get().Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
|
||||
return fmt.Errorf("games: clear live hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the settle ------------------------------------------------------------
|
||||
|
||||
// Commit is one write-back of a game: the state, and — if the game is over —
|
||||
// everything settling it takes.
|
||||
type Commit struct {
|
||||
Live LiveHand
|
||||
Fresh bool // a game just started, which is the one write that may be refused
|
||||
|
||||
// Stake is what the player put up to open this game. It is refunded, in this
|
||||
// same transaction, if the seat turns out to be taken. Only meaningful with
|
||||
// Fresh.
|
||||
Stake int64
|
||||
|
||||
Done bool
|
||||
Payout int64 // stake plus winnings, net of rake. Zero on a loss.
|
||||
Audit Hand // the audit row. Ignored unless Done.
|
||||
}
|
||||
|
||||
// CommitHand writes a game back and settles it if it is over — all of it in one
|
||||
// transaction.
|
||||
//
|
||||
// It used to be four separate autocommit statements (save, award, record,
|
||||
// clear), which was survivable while a game belonged to exactly one player: the
|
||||
// ordering paid first and cleared second, so a crash in between left a settled
|
||||
// game on the felt, which reads as done and can be cleared. It does not survive
|
||||
// a game with a pot in it. Pay the winner, die before the state write, and the
|
||||
// table still says the hand is live — so it settles a second time and the winner
|
||||
// is paid twice. Chips minted from nothing, and gogobee will happily turn them
|
||||
// into euros.
|
||||
//
|
||||
// So: one Begin, one Commit, and the money and the state move together or not at
|
||||
// all.
|
||||
//
|
||||
// The rule this enforces, and the reason award/recordHand exist in tx-taking
|
||||
// form at all: **nothing inside here may call Get().Exec**. The pool runs at
|
||||
// MaxOpenConns(1), so a bare Exec inside an open transaction waits for the one
|
||||
// connection that this transaction is holding — forever. It is not an error, it
|
||||
// is a hung process, and since the news app shares the pool it takes that down
|
||||
// too. The tx-taking helper is the pattern; addChips has done it this way since
|
||||
// the escrow ledger was written.
|
||||
func CommitHand(user string, c Commit) error {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin commit: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
// Seat the game first, even one that is already over — a blackjack natural
|
||||
// settles the instant it is dealt. The INSERT is what enforces one game at a
|
||||
// time, and it has to happen for *every* new one, or a natural dealt on top of
|
||||
// a game already in progress would settle, clear the felt, and take the other
|
||||
// game's stake with it.
|
||||
if c.Fresh {
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||
user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: start live hand: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
// Somebody is already sitting here. This game was never seated, so the
|
||||
// chips it staked go back — and they go back *in this transaction*, which
|
||||
// is the point. As two statements, a crash between the refusal and the
|
||||
// refund took the player's stake for a game that never existed anywhere.
|
||||
if err := award(tx, user, c.Stake, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit refund: %w", err)
|
||||
}
|
||||
return ErrHandInProgress
|
||||
}
|
||||
} else if _, err := tx.Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||
game = excluded.game, state = excluded.state,
|
||||
seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
|
||||
user, c.Live.Game, string(c.Live.State), int64(c.Live.Seed1), int64(c.Live.Seed2), now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: save live hand: %w", err)
|
||||
}
|
||||
|
||||
if c.Done {
|
||||
if err := award(tx, user, c.Payout, now); err != nil {
|
||||
return err
|
||||
}
|
||||
// The audit row is now inside the transaction with the payout, which means a
|
||||
// failure to write it rolls the payout back rather than paying quietly and
|
||||
// logging. That is a deliberate change: the two are the same fact, and a
|
||||
// payout nobody can account for is worse than a payout that didn't happen —
|
||||
// the game stays live and settles again on the next request.
|
||||
if err := recordHand(tx, c.Audit, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
|
||||
return fmt.Errorf("games: clear live hand: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Touch, folded in: a deliberate action at a table, so the reaper leaves them
|
||||
// alone. A player with no chip row yet has nothing to touch, and this is a
|
||||
// no-op for them.
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||
now, now, user,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: touch session: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HouseTake is the total rake collected since a given time — the number that
|
||||
// answers "is this economy inflating".
|
||||
func HouseTake(since int64) (int64, error) {
|
||||
var total int64
|
||||
if err := Get().QueryRow(
|
||||
`SELECT COALESCE(SUM(rake), 0) FROM game_hands WHERE played_at >= ?`, since,
|
||||
).Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("games: house take: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const player = "@reala:parodia.dev"
|
||||
|
||||
// fund runs a buy-in all the way through the happy path, so a test that needs
|
||||
// chips on the table can just say so.
|
||||
func fund(t *testing.T, user string, amount int64) {
|
||||
t.Helper()
|
||||
e, err := RequestBuyIn(user, amount)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func chipsOf(t *testing.T, user string) int64 {
|
||||
t.Helper()
|
||||
st, err := Chips(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st.Chips
|
||||
}
|
||||
|
||||
func TestBuyIn_ChipsOnlyExistOnceGogobeeConfirms(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.State != EscrowRequested {
|
||||
t.Fatalf("state = %q, want %q", e.State, EscrowRequested)
|
||||
}
|
||||
|
||||
// Requested is not funded. Nothing is spendable yet — gogobee hasn't taken
|
||||
// the euros, so creating chips here would mint money out of nothing.
|
||||
st, err := Chips(player)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Chips != 0 {
|
||||
t.Fatalf("chips = %d before settlement, want 0", st.Chips)
|
||||
}
|
||||
if st.Pending != 500 {
|
||||
t.Fatalf("pending = %d, want 500", st.Pending)
|
||||
}
|
||||
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st, err = Chips(player)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Chips != 500 {
|
||||
t.Fatalf("chips = %d after funding, want 500", st.Chips)
|
||||
}
|
||||
if st.Pending != 0 {
|
||||
t.Fatalf("pending = %d after funding, want 0", st.Pending)
|
||||
}
|
||||
if st.EuroBalance != 4500 {
|
||||
t.Fatalf("advisory euro balance = %v, want 4500", st.EuroBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyIn_RejectedCreatesNoChips(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowRejected {
|
||||
t.Fatalf("state = %q, want %q", got.State, EscrowRejected)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 0 {
|
||||
t.Fatalf("chips = %d after a rejected buy-in, want 0", c)
|
||||
}
|
||||
}
|
||||
|
||||
// The push queue retries. A verdict that lands twice must only move chips once.
|
||||
func TestSettle_IsIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||
t.Fatalf("settle %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if c := chipsOf(t, player); c != 500 {
|
||||
t.Fatalf("chips = %d after three identical pushes, want 500", c)
|
||||
}
|
||||
}
|
||||
|
||||
// A late, contradictory push must not overturn a settled row — otherwise a
|
||||
// delayed "rejected" could confiscate chips the player already won hands with.
|
||||
func TestSettle_TerminalStateWins(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowFunded {
|
||||
t.Fatalf("state = %q, want the original %q", got.State, EscrowFunded)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 500 {
|
||||
t.Fatalf("chips = %d, want 500 — a late rejection took funded chips", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashOut_ChipsLeaveImmediately(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
e, err := RequestCashOut(player, 400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The chips are gone the moment the row opens. If they lingered until gogobee
|
||||
// confirmed, the player could bet them while the euros were also in flight —
|
||||
// the same euro on both sides of the border.
|
||||
if c := chipsOf(t, player); c != 600 {
|
||||
t.Fatalf("chips = %d immediately after cash-out, want 600", c)
|
||||
}
|
||||
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SettleEscrow(e.GUID, true, "", 4400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowSettled {
|
||||
t.Fatalf("state = %q, want %q", got.State, EscrowSettled)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 600 {
|
||||
t.Fatalf("chips = %d after settlement, want 600", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashOut_FailedCreditGivesTheChipsBack(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
e, err := RequestCashOut(player, 400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// gogobee couldn't pay. The chips were already destroyed here, so they have to
|
||||
// come back — the player's money cannot simply evaporate at the border.
|
||||
if _, err := SettleEscrow(e.GUID, false, "ledger_error", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 1000 {
|
||||
t.Fatalf("chips = %d after a failed cash-out, want the full 1000 back", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashOut_CannotExceedTheStack(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 100)
|
||||
|
||||
if _, err := RequestCashOut(player, 500); !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 100 {
|
||||
t.Fatalf("chips = %d after a refused cash-out, want 100", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyIn_TableCapCountsChipsAndInFlightBuyIns(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if _, err := RequestBuyIn(player, MaxChipsOnTable+1); !errors.Is(err, ErrOverTableCap) {
|
||||
t.Fatalf("err = %v, want ErrOverTableCap", err)
|
||||
}
|
||||
|
||||
fund(t, player, MaxChipsOnTable-1000)
|
||||
|
||||
// A second buy-in that fits is fine.
|
||||
if _, err := RequestBuyIn(player, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A third, while the second is still in flight, must not clear the cap by
|
||||
// racing — pending buy-ins count against it.
|
||||
if _, err := RequestBuyIn(player, 1); !errors.Is(err, ErrOverTableCap) {
|
||||
t.Fatalf("err = %v, want ErrOverTableCap — in-flight buy-ins must count", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequest_RejectsNonPositiveAmounts(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 100)
|
||||
|
||||
for _, amount := range []int64{0, -50} {
|
||||
if _, err := RequestBuyIn(player, amount); !errors.Is(err, ErrBadAmount) {
|
||||
t.Errorf("buy-in %d: err = %v, want ErrBadAmount", amount, err)
|
||||
}
|
||||
if _, err := RequestCashOut(player, amount); !errors.Is(err, ErrBadAmount) {
|
||||
t.Errorf("cash-out %d: err = %v, want ErrBadAmount", amount, err)
|
||||
}
|
||||
}
|
||||
if c := chipsOf(t, player); c != 100 {
|
||||
t.Fatalf("chips = %d, want 100", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStakeAndAward(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 500)
|
||||
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 400 {
|
||||
t.Fatalf("chips = %d after staking 100, want 400", c)
|
||||
}
|
||||
|
||||
// A win pays the stake back plus the profit, net of rake.
|
||||
if err := Award(player, 195); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 595 {
|
||||
t.Fatalf("chips = %d after a 195 payout, want 595", c)
|
||||
}
|
||||
|
||||
// You cannot bet chips you don't have.
|
||||
if err := Stake(player, 10_000); !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 595 {
|
||||
t.Fatalf("chips = %d after a refused stake, want 595", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStake_UnknownPlayerHasNothingToBet(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := Stake("@stranger:parodia.dev", 10); !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingEscrow_OffersUnclaimedAndAbandonedRows(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
fresh, err := RequestBuyIn(player, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
abandoned, err := RequestBuyIn("@other:parodia.dev", 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pending, err := PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 2 {
|
||||
t.Fatalf("%d pending rows, want 2", len(pending))
|
||||
}
|
||||
|
||||
// Claim both: neither should be offered again while gogobee is working on them.
|
||||
for _, e := range []Escrow{fresh, abandoned} {
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
pending, err = PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("%d rows offered while claimed, want 0", len(pending))
|
||||
}
|
||||
|
||||
// Now pretend gogobee died holding one of them. A claim that never came back
|
||||
// must be re-offered, or the player's money sits in limbo forever.
|
||||
stale := nowUnix() - int64(EscrowStaleAfter.Seconds()) - 1
|
||||
if _, err := Get().Exec(`UPDATE game_escrow SET claimed_at = ? WHERE guid = ?`, stale, abandoned.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err = PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].GUID != abandoned.GUID {
|
||||
t.Fatalf("stale claim was not re-offered: got %d rows", len(pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaim_CannotReopenAFinishedRow(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 300)
|
||||
|
||||
e, err := RequestCashOut(player, 300)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Re-claiming a settled cash-out must not walk it back to claimed, or gogobee
|
||||
// would pay the same euros out twice.
|
||||
got, err := ClaimEscrow(e.GUID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowSettled {
|
||||
t.Fatalf("state = %q after re-claiming a settled row, want %q", got.State, EscrowSettled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscrow_UnknownGUID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if _, err := EscrowByGUID("nope"); !errors.Is(err, ErrNoSuchEscrow) {
|
||||
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
|
||||
}
|
||||
if _, err := SettleEscrow("nope", true, "", 0); !errors.Is(err, ErrNoSuchEscrow) {
|
||||
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaper_CashesOutTheWalkedAway(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 700)
|
||||
|
||||
// Still playing: the reaper must leave them alone.
|
||||
Touch(player)
|
||||
n, err := ReapIdleSessions(SessionIdleAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 || chipsOf(t, player) != 700 {
|
||||
t.Fatalf("reaped %d active players (chips now %d)", n, chipsOf(t, player))
|
||||
}
|
||||
|
||||
// Now they've been gone an hour.
|
||||
old := nowUnix() - int64((2 * time.Hour).Seconds())
|
||||
if _, err := Get().Exec(`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`, old, player); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err = ReapIdleSessions(SessionIdleAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("reaped %d, want 1", n)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 0 {
|
||||
t.Fatalf("chips = %d after the reaper ran, want 0 — they should be euros now", c)
|
||||
}
|
||||
|
||||
// And it must be a real cash-out, waiting for gogobee to pay it out.
|
||||
pending, err := PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].Kind != KindCashOut || pending[0].Amount != 700 {
|
||||
t.Fatalf("reaper did not queue a 700 cash-out: %+v", pending)
|
||||
}
|
||||
|
||||
// Running again finds nothing left to reap.
|
||||
if n, err := ReapIdleSessions(SessionIdleAfter); err != nil || n != 0 {
|
||||
t.Fatalf("second sweep reaped %d (err=%v), want 0", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordHand_AndHouseTake(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
hands := []Hand{
|
||||
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 195, Rake: 5, Outcome: "win", Seed1: 42, Seed2: 7},
|
||||
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 0, Rake: 0, Outcome: "bust", Seed1: 43, Seed2: 8},
|
||||
{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 486, Rake: 14, Outcome: "blackjack", Seed1: 44, Seed2: 9},
|
||||
}
|
||||
for _, h := range hands {
|
||||
if err := RecordHand(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
take, err := HouseTake(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if take != 19 {
|
||||
t.Fatalf("house take = %d, want 19", take)
|
||||
}
|
||||
|
||||
// The seeds have to survive the round trip, or a disputed hand can't be re-dealt.
|
||||
var s1, s2 int64
|
||||
if err := Get().QueryRow(
|
||||
`SELECT seed1, seed2 FROM game_hands WHERE outcome = 'blackjack'`,
|
||||
).Scan(&s1, &s2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s1 != 44 || s2 != 9 {
|
||||
t.Fatalf("seeds came back as (%d, %d), want (44, 9)", s1, s2)
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant, end to end: every euro that entered the casino is either a chip
|
||||
// on the table or a euro on its way home. None are minted, none evaporate.
|
||||
func TestBorder_ChipsAreConserved(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
// Play a losing hand and a winning one.
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
} // 900
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
} // 800
|
||||
if err := Award(player, 195); err != nil {
|
||||
t.Fatal(err)
|
||||
} // 995 — one loss, one win less rake
|
||||
|
||||
if c := chipsOf(t, player); c != 995 {
|
||||
t.Fatalf("chips = %d, want 995", c)
|
||||
}
|
||||
|
||||
e, err := RequestCashOut(player, 995)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4995); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c := chipsOf(t, player); c != 0 {
|
||||
t.Fatalf("chips = %d after cashing out everything, want 0", c)
|
||||
}
|
||||
// 1000 in, 995 out, 5 lost to the table and the rake. Nothing left stranded.
|
||||
st, err := Chips(player)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Pending != 0 {
|
||||
t.Fatalf("pending = %d, want 0", st.Pending)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The mischief storefront's half of the euro border.
|
||||
//
|
||||
// A buyer signs in, picks a mark off the anonymous roster board, and places a
|
||||
// hit. Pete records only the *intent*: it never moves money and never runs a
|
||||
// single game rule. gogobee's poll loop reads the pending orders, does the real
|
||||
// work against its own ledger (debit, eligibility, open the contract), and hands
|
||||
// back a verdict Pete files against the order. The guid is the idempotency key
|
||||
// end to end — gogobee passes it to DebitIdem and stamps it on the contract — so
|
||||
// a verdict whose ack is lost on the wire can be retried without the buyer paying
|
||||
// twice or the mark catching two hits for one order.
|
||||
|
||||
// MischiefOrder is one storefront order and its current standing.
|
||||
type MischiefOrder struct {
|
||||
GUID string `json:"guid"`
|
||||
BuyerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee
|
||||
BuyerUsername string `json:"buyer_username"` // localpart gogobee turns into @username:server
|
||||
TargetToken string `json:"target_token"`
|
||||
TargetName string `json:"target_name"`
|
||||
Tier string `json:"tier"`
|
||||
Signed bool `json:"signed"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// Order states. These strings cross the wire to gogobee (it POSTs the verdict),
|
||||
// so they are part of the contract — see the schema and gogobee_mischief_plan.md.
|
||||
const (
|
||||
MischiefPending = "pending" // placed; gogobee hasn't acted yet
|
||||
MischiefPlaced = "placed" // gogobee debited the buyer and opened a contract
|
||||
MischiefBouncedFunds = "bounced_funds" // buyer couldn't afford it after all
|
||||
MischiefBouncedIneligible = "bounced_ineligible" // target no longer a valid mark
|
||||
)
|
||||
|
||||
// validMischiefVerdict is the set of terminal states gogobee is allowed to hand
|
||||
// back. An unknown verdict is a contract mismatch, not something to file blindly.
|
||||
func validMischiefVerdict(status string) bool {
|
||||
switch status {
|
||||
case MischiefPlaced, MischiefBouncedFunds, MischiefBouncedIneligible:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var ErrNoSuchOrder = errors.New("mischief: no such order")
|
||||
|
||||
// InsertMischiefOrder records a fresh, pending order and returns it with a new
|
||||
// guid. Money and eligibility are gogobee's problem; all Pete asserts here is
|
||||
// that the buyer is signed in and the form was well formed (checked by the
|
||||
// caller). The guid is minted here so the buyer sees a stable reference the
|
||||
// instant they submit, before gogobee has ever heard of it.
|
||||
func InsertMischiefOrder(buyerSub, buyerUsername, targetToken, targetName, tier string, signed bool) (MischiefOrder, error) {
|
||||
guid, err := newGUID()
|
||||
if err != nil {
|
||||
return MischiefOrder{}, err
|
||||
}
|
||||
now := nowUnix()
|
||||
sig := 0
|
||||
if signed {
|
||||
sig = 1
|
||||
}
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO mischief_orders
|
||||
(guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, buyerSub, buyerUsername, targetToken, targetName, tier, sig, MischiefPending, now, now,
|
||||
); err != nil {
|
||||
return MischiefOrder{}, fmt.Errorf("mischief: insert order: %w", err)
|
||||
}
|
||||
return MischiefOrder{
|
||||
GUID: guid, BuyerSub: buyerSub, BuyerUsername: buyerUsername,
|
||||
TargetToken: targetToken, TargetName: targetName, Tier: tier,
|
||||
Signed: signed, Status: MischiefPending, CreatedAt: now, UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PendingMischiefOrders is what gogobee's poll loop reads: every order still
|
||||
// waiting to be acted on. There is no claimed-but-stale window like escrow has,
|
||||
// because a pending order carries no intermediate state — if gogobee dies partway
|
||||
// through, the order simply stays pending and is offered again next poll, and the
|
||||
// guid makes the replay a no-op.
|
||||
func PendingMischiefOrders(limit int) ([]MischiefOrder, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
||||
FROM mischief_orders
|
||||
WHERE status = ?
|
||||
ORDER BY created_at
|
||||
LIMIT ?`,
|
||||
MischiefPending, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mischief: pending orders: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMischiefOrders(rows)
|
||||
}
|
||||
|
||||
// ResolveMischiefOrder files gogobee's verdict against a pending order. It is
|
||||
// idempotent: gogobee's poll loop can re-offer and re-resolve the same order, so
|
||||
// a verdict that arrives twice is a no-op the second time. Only a pending order
|
||||
// moves; an order already in a terminal state reports what it already decided,
|
||||
// which is what stops a retried verdict from overwriting the first one.
|
||||
func ResolveMischiefOrder(guid, status, detail string) (MischiefOrder, error) {
|
||||
if !validMischiefVerdict(status) {
|
||||
return MischiefOrder{}, fmt.Errorf("mischief: bad verdict %q", status)
|
||||
}
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`UPDATE mischief_orders SET status = ?, detail = ?, updated_at = ?
|
||||
WHERE guid = ? AND status = ?`,
|
||||
status, detail, now, guid, MischiefPending,
|
||||
); err != nil {
|
||||
return MischiefOrder{}, fmt.Errorf("mischief: resolve order: %w", err)
|
||||
}
|
||||
// Whether the update moved a pending order or matched nothing (missing, or
|
||||
// already terminal from an earlier verdict), the current row is the
|
||||
// authoritative answer — reading it back is the single path either way, and
|
||||
// MischiefOrderByGUID turns a missing row into ErrNoSuchOrder for the caller.
|
||||
return MischiefOrderByGUID(guid)
|
||||
}
|
||||
|
||||
// MischiefOrderByGUID reads one order.
|
||||
func MischiefOrderByGUID(guid string) (MischiefOrder, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
||||
FROM mischief_orders WHERE guid = ?`, guid,
|
||||
)
|
||||
if err != nil {
|
||||
return MischiefOrder{}, fmt.Errorf("mischief: read order: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out, err := scanMischiefOrders(rows)
|
||||
if err != nil {
|
||||
return MischiefOrder{}, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return MischiefOrder{}, ErrNoSuchOrder
|
||||
}
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// MischiefOrdersByBuyer returns a buyer's own recent orders, newest first, for
|
||||
// the storefront status panel. Keyed on the OIDC subject so a username change
|
||||
// doesn't strand a buyer's history.
|
||||
func MischiefOrdersByBuyer(buyerSub string, limit int) ([]MischiefOrder, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
||||
FROM mischief_orders
|
||||
WHERE buyer_sub = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
buyerSub, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mischief: orders by buyer: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMischiefOrders(rows)
|
||||
}
|
||||
|
||||
// CountMischiefOrdersSince counts how many orders a buyer has placed since a unix
|
||||
// cutoff. It backs the storefront's burst guard — the real economic caps (per
|
||||
// day, per target, boss weekly) live on gogobee, this only blunts form-spam.
|
||||
func CountMischiefOrdersSince(buyerSub string, since int64) (int, error) {
|
||||
var n int
|
||||
err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM mischief_orders WHERE buyer_sub = ? AND created_at >= ?`,
|
||||
buyerSub, since,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("mischief: count recent orders: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func scanMischiefOrders(rows *sql.Rows) ([]MischiefOrder, error) {
|
||||
var out []MischiefOrder
|
||||
for rows.Next() {
|
||||
var o MischiefOrder
|
||||
var sig int
|
||||
if err := rows.Scan(&o.GUID, &o.BuyerSub, &o.BuyerUsername, &o.TargetToken,
|
||||
&o.TargetName, &o.Tier, &sig, &o.Status, &o.Detail, &o.CreatedAt, &o.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("mischief: scan order: %w", err)
|
||||
}
|
||||
o.Signed = sig != 0
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---- user_euro: the buyer's own advisory balance -------------------------------
|
||||
|
||||
// ReplaceUserEuro swaps the whole balance table for a new snapshot, in one
|
||||
// transaction, mirroring the roster: a buyer gogobee stopped reporting (opted
|
||||
// out, deleted character) must drop out rather than keep a frozen number forever.
|
||||
// Replace — never merge.
|
||||
func ReplaceUserEuro(balances []MischiefBalance, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM user_euro`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO user_euro (username, euro, snapshot_at) VALUES (?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, b := range balances {
|
||||
if b.Username == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(b.Username, b.Euro, snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// MischiefBalance is one buyer's advisory euro balance in the roster push.
|
||||
type MischiefBalance struct {
|
||||
Username string `json:"username"`
|
||||
Euro float64 `json:"euro"`
|
||||
}
|
||||
|
||||
// ---- mischief_tiers: the price catalog, pushed by gogobee -----------------------
|
||||
|
||||
// MischiefTier is one rung of the storefront's price list. gogobee is the sole
|
||||
// authority on prices — it pushes the whole catalog on the roster tick so a fee
|
||||
// retune reaches the storefront within a snapshot and Pete never hardcodes a
|
||||
// number that can silently drift. Pete uses it only to render and to validate a
|
||||
// submitted tier key; the real debit is always gogobee's, at its own price.
|
||||
type MischiefTier struct {
|
||||
Key string `json:"key"`
|
||||
Display string `json:"display"`
|
||||
Fee int `json:"fee"`
|
||||
SignedFee int `json:"signed_fee"`
|
||||
Blurb string `json:"blurb,omitempty"`
|
||||
}
|
||||
|
||||
// ReplaceMischiefTiers swaps the whole catalog for gogobee's latest, preserving
|
||||
// the push order (grunt→boss) via an ordinal column. Replace, never merge — a
|
||||
// tier gogobee dropped must disappear from the storefront.
|
||||
func ReplaceMischiefTiers(tiers []MischiefTier) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM mischief_tiers`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO mischief_tiers (key, display, fee, signed_fee, blurb, ordinal) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for i, t := range tiers {
|
||||
if t.Key == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(t.Key, t.Display, t.Fee, t.SignedFee, t.Blurb, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// MischiefTiers returns the catalog in push order. Empty (not an error) until
|
||||
// gogobee has pushed one — the storefront treats that as "catalog not ready yet".
|
||||
func MischiefTiers() ([]MischiefTier, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT key, display, fee, signed_fee, COALESCE(blurb, '') FROM mischief_tiers ORDER BY ordinal`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mischief: read tiers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MischiefTier
|
||||
for rows.Next() {
|
||||
var t MischiefTier
|
||||
if err := rows.Scan(&t.Key, &t.Display, &t.Fee, &t.SignedFee, &t.Blurb); err != nil {
|
||||
return nil, fmt.Errorf("mischief: scan tier: %w", err)
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MischiefTierByKey looks up one tier for validating a submitted order.
|
||||
func MischiefTierByKey(key string) (MischiefTier, bool, error) {
|
||||
tiers, err := MischiefTiers()
|
||||
if err != nil {
|
||||
return MischiefTier{}, false, err
|
||||
}
|
||||
for _, t := range tiers {
|
||||
if t.Key == key {
|
||||
return t, true, nil
|
||||
}
|
||||
}
|
||||
return MischiefTier{}, false, nil
|
||||
}
|
||||
|
||||
// UserEuro reads one buyer's advisory balance. The bool is false when gogobee has
|
||||
// never reported a balance for them (never played, opted out) — the storefront
|
||||
// then shows tiers without an affordability hint rather than a misleading €0.
|
||||
func UserEuro(username string) (float64, bool, error) {
|
||||
var euro float64
|
||||
err := Get().QueryRow(`SELECT euro FROM user_euro WHERE username = ?`, username).Scan(&euro)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("mischief: read user euro: %w", err)
|
||||
}
|
||||
return euro, true, nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMischiefOrderLifecycle(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
o, err := InsertMischiefOrder("sub-1", "reala", "tok-josie", "Josie", "elite", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.Status != MischiefPending {
|
||||
t.Fatalf("fresh order status = %q, want pending", o.Status)
|
||||
}
|
||||
if !o.Signed {
|
||||
t.Error("signed flag lost through insert")
|
||||
}
|
||||
|
||||
pending, err := PendingMischiefOrders(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)
|
||||
}
|
||||
|
||||
// gogobee places the contract.
|
||||
got, err := ResolveMischiefOrder(o.GUID, MischiefPlaced, "the word is out")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != MischiefPlaced || got.Detail != "the word is out" {
|
||||
t.Fatalf("resolved order = %+v, want placed with detail", got)
|
||||
}
|
||||
|
||||
// It must leave the pending set.
|
||||
if pending, _ := PendingMischiefOrders(10); len(pending) != 0 {
|
||||
t.Fatalf("placed order still pending: %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMischiefResolveIsIdempotent is the whole reason the guid is an end-to-end
|
||||
// key: gogobee's poll loop retries, so a verdict can arrive twice, and the second
|
||||
// arrival must not overwrite the first or error.
|
||||
func TestMischiefResolveIsIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
o, err := InsertMischiefOrder("sub-1", "reala", "tok", "Josie", "grunt", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ResolveMischiefOrder(o.GUID, MischiefPlaced, "first"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second, *different* verdict arrives. First one wins.
|
||||
got, err := ResolveMischiefOrder(o.GUID, MischiefBouncedFunds, "second")
|
||||
if err != nil {
|
||||
t.Fatalf("re-resolve errored: %v", err)
|
||||
}
|
||||
if got.Status != MischiefPlaced || got.Detail != "first" {
|
||||
t.Fatalf("idempotency broken: order became %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefResolveUnknownAndBadVerdict(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if _, err := ResolveMischiefOrder("nope", MischiefPlaced, ""); !errors.Is(err, ErrNoSuchOrder) {
|
||||
t.Fatalf("unknown guid err = %v, want ErrNoSuchOrder", err)
|
||||
}
|
||||
|
||||
o, _ := InsertMischiefOrder("sub-1", "reala", "tok", "Josie", "grunt", false)
|
||||
if _, err := ResolveMischiefOrder(o.GUID, "exploded", ""); err == nil {
|
||||
t.Error("a bogus verdict status was accepted")
|
||||
}
|
||||
// The order must survive a rejected verdict as still-pending.
|
||||
if got, _ := MischiefOrderByGUID(o.GUID); got.Status != MischiefPending {
|
||||
t.Fatalf("order moved off pending on a bad verdict: %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefOrdersByBuyerAndCount(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := InsertMischiefOrder("sub-A", "alice", "tok", "Josie", "grunt", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := InsertMischiefOrder("sub-B", "bob", "tok", "Josie", "grunt", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mine, err := MischiefOrdersByBuyer("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 := CountMischiefOrdersSince("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, _ := CountMischiefOrdersSince("sub-A", time.Now().Add(time.Hour).Unix()); n != 0 {
|
||||
t.Fatalf("count since the future = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefTiersReplaceAndLookup(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
tiers := []MischiefTier{
|
||||
{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50, Blurb: "theatre"},
|
||||
{Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500},
|
||||
}
|
||||
if err := ReplaceMischiefTiers(tiers); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := MischiefTiers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Key != "grunt" || got[1].Key != "boss" {
|
||||
t.Fatalf("catalog order not preserved: %+v", got)
|
||||
}
|
||||
|
||||
tier, ok, err := MischiefTierByKey("boss")
|
||||
if err != nil || !ok || tier.SignedFee != 1500 {
|
||||
t.Fatalf("lookup boss = %+v ok=%v err=%v", tier, ok, err)
|
||||
}
|
||||
if _, ok, _ := MischiefTierByKey("dragon"); ok {
|
||||
t.Error("lookup invented a tier that was never pushed")
|
||||
}
|
||||
|
||||
// Replace, never merge: a dropped tier vanishes.
|
||||
if err := ReplaceMischiefTiers([]MischiefTier{{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := MischiefTierByKey("boss"); ok {
|
||||
t.Error("a tier dropped from the push survived the replace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEuroReplaceAndRead(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if err := ReplaceUserEuro([]MischiefBalance{{Username: "reala", Euro: 820.5}}, time.Now().Unix()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
euro, has, err := UserEuro("reala")
|
||||
if err != nil || !has || euro != 820.5 {
|
||||
t.Fatalf("UserEuro(reala) = %v has=%v err=%v", euro, has, err)
|
||||
}
|
||||
|
||||
// A user gogobee has never reported reads as "unknown", not €0 — the
|
||||
// storefront shows no hint rather than a discouraging, wrong zero.
|
||||
if _, has, _ := UserEuro("stranger"); has {
|
||||
t.Error("UserEuro claimed to know a stranger's balance")
|
||||
}
|
||||
|
||||
// Replace drops anyone the new snapshot omits.
|
||||
if err := ReplaceUserEuro([]MischiefBalance{{Username: "bob", Euro: 10}}, time.Now().Unix()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has, _ := UserEuro("reala"); has {
|
||||
t.Error("a balance that fell out of the snapshot survived the replace")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The action queue: the second channel that carries intent back to the game box,
|
||||
// and the first one that acts on the adventurer rather than on their kit.
|
||||
//
|
||||
// Same shape as the equip queue, deliberately — a signed-in owner asks for
|
||||
// something on a page they own, Pete records only the intent, gogobee polls,
|
||||
// runs the real rule against its own tables, and files a verdict Pete renders.
|
||||
// Pete never ends an expedition and never swings at a boss; it records that
|
||||
// somebody asked to.
|
||||
//
|
||||
// The reason this is its own table rather than more actions on equip_orders is
|
||||
// vocabulary: an equip order is about an item in a slot at a tier, and none of
|
||||
// those columns mean anything to "leave the dungeon". See the schema comment.
|
||||
//
|
||||
// Neither verb is naturally idempotent — an extract ends a run, a bout spends
|
||||
// the day's only swing — so gogobee guards on the order guid before it mutates,
|
||||
// exactly as the equip poller does. On Pete's side the mechanic is the equip
|
||||
// queue's: a verdict only moves a still-pending row, so a retried verdict is a
|
||||
// no-op.
|
||||
|
||||
// AdvOrder is one requested action and its current standing.
|
||||
type AdvOrder 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 — whose adventurer acts
|
||||
Token string `json:"token,omitempty"` // the roster token ownership was proven against; display/audit only
|
||||
CharacterName string `json:"character_name,omitempty"` // display copy, frozen at order time; gogobee ignores it
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
// Params is the verb's arguments, and only the three verbs that take any carry
|
||||
// it. It never names an adventurer — that still comes from the session — and
|
||||
// nothing in it is trusted: Pete resolves every field against the owner's own
|
||||
// pushed offer list before storing it, and gogobee resolves it again against
|
||||
// the game's tables before it means anything.
|
||||
Params *AdvOrderParams `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// AdvOrderParams is the union of every verb's arguments, flat rather than
|
||||
// per-verb because there are three of them and each reads one or two fields.
|
||||
// A field a verb does not read is ignored rather than rejected.
|
||||
type AdvOrderParams struct {
|
||||
Zone string `json:"zone,omitempty"` // zone id, for expedition_start
|
||||
Loadout string `json:"loadout,omitempty"` // lean|balanced|heavy
|
||||
Days int `json:"days,omitempty"` // 7 or 30, for babysit
|
||||
}
|
||||
|
||||
// Actions. These cross the wire to gogobee, so they are part of the contract.
|
||||
//
|
||||
// extract pull out of a running expedition, keeping loot/XP, resumable for a
|
||||
// week — the game's `!extract`. Leader-only, which gogobee enforces.
|
||||
// siege_join take today's one bout against the world boss — `!adventure
|
||||
// worldboss fight`. The narration still lands in Matrix; the web gets
|
||||
// the damage line as the verdict.
|
||||
// W5b adds the three that take arguments and spend coins:
|
||||
//
|
||||
// expedition_start leave town for a zone with a supply loadout — `!expedition
|
||||
// start <zone> <loadout>`. The most common action in the game
|
||||
// and, until now, Matrix-only.
|
||||
// expedition_resume walk back into the run you extracted from, re-outfitted —
|
||||
// `!resume`. The other half of W5a's extract: that verb's own
|
||||
// verdict tells people to type !resume, and this is the door.
|
||||
// babysit engage the pet sitter for a week or a month — `!adventure
|
||||
// babysit week|month`.
|
||||
//
|
||||
// W9 adds the three that undo the ones above. Each was already named inside a
|
||||
// refusal or a confirm this page shows — "`!expedition abandon` first",
|
||||
// "`!expedition leave` to walk out alone", "no refund if you cancel early" — so
|
||||
// until now the web told people to go and type a command it could have offered.
|
||||
// None takes an argument and none spends a euro:
|
||||
//
|
||||
// expedition_abandon end the expedition outright, for the whole party. Leader
|
||||
// only, which gogobee enforces. Also the way to close an
|
||||
// extracted run without paying to walk back into it first.
|
||||
// expedition_leave walk out of somebody else's party alone, supplies left in
|
||||
// the pool. Member only — the leader's row IS the expedition.
|
||||
// babysit_cancel dismiss the sitter early. No refund, by the game's design.
|
||||
const (
|
||||
AdvActionExtract = "extract"
|
||||
AdvActionSiegeJoin = "siege_join"
|
||||
AdvActionExpedition = "expedition_start"
|
||||
AdvActionResume = "expedition_resume"
|
||||
AdvActionBabysit = "babysit"
|
||||
|
||||
AdvActionAbandon = "expedition_abandon"
|
||||
AdvActionLeave = "expedition_leave"
|
||||
AdvActionBabysitCancel = "babysit_cancel"
|
||||
)
|
||||
|
||||
// Order states. Terminal states are enumerated rather than free-text so the page
|
||||
// can say something specific about each; detail carries gogobee's prose. The
|
||||
// rejection set is honest to what the game paths can actually answer.
|
||||
const (
|
||||
AdvOrderPending = "pending" // placed; gogobee hasn't acted yet
|
||||
AdvOrderApplied = "applied" // it happened; detail says what
|
||||
|
||||
AdvRejectedNotRunning = "rejected_not_running" // extract: no active expedition
|
||||
AdvRejectedNotLeader = "rejected_not_leader" // extract: a party member can't call the extraction
|
||||
AdvRejectedNoSiege = "rejected_no_siege" // siege_join: nothing camped outside town
|
||||
AdvRejectedAlreadyFought = "rejected_already_fought" // siege_join: today's bout is spent
|
||||
AdvRejectedUnavailable = "rejected_unavailable" // no character, dead, or an argument the game does not sell
|
||||
|
||||
// W5b's three verbs.
|
||||
AdvRejectedBusy = "rejected_busy" // already out, already seated, or already has a sitter
|
||||
AdvRejectedInsufficientFunds = "rejected_insufficient_funds" // could not cover the cost
|
||||
AdvRejectedZoneLocked = "rejected_zone_locked" // that zone is not open at this level
|
||||
AdvRejectedNothingToResume = "rejected_nothing_to_resume" // nothing extracted, or its window closed
|
||||
|
||||
// W9's two. rejected_is_leader is deliberately not rejected_not_leader read
|
||||
// backwards: they are opposite facts about the same person, and collapsing
|
||||
// them would answer a leader who tried to walk out by telling them they are
|
||||
// not the leader.
|
||||
AdvRejectedIsLeader = "rejected_is_leader" // expedition_leave: the leader's row is the expedition
|
||||
AdvRejectedNothingToCancel = "rejected_nothing_to_cancel" // babysit_cancel: no sitter is engaged
|
||||
)
|
||||
|
||||
func validAdvAction(action string) bool {
|
||||
switch action {
|
||||
case AdvActionExtract, AdvActionSiegeJoin,
|
||||
AdvActionExpedition, AdvActionResume, AdvActionBabysit,
|
||||
AdvActionAbandon, AdvActionLeave, AdvActionBabysitCancel:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validAdvVerdict is the set of terminal states gogobee may hand back.
|
||||
func validAdvVerdict(status string) bool {
|
||||
switch status {
|
||||
case AdvOrderApplied, AdvRejectedNotRunning, AdvRejectedNotLeader,
|
||||
AdvRejectedNoSiege, AdvRejectedAlreadyFought, AdvRejectedUnavailable,
|
||||
AdvRejectedBusy, AdvRejectedInsufficientFunds, AdvRejectedZoneLocked,
|
||||
AdvRejectedNothingToResume, AdvRejectedIsLeader, AdvRejectedNothingToCancel:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var ErrNoSuchAdvOrder = errors.New("orders: no such order")
|
||||
|
||||
// ErrBadAdvVerdict is a verdict outside the terminal set. It is kept distinct
|
||||
// from a storage failure so the web seam can answer 400 (gogobee sent something
|
||||
// it will never be able to send successfully) rather than parking a perfectly
|
||||
// resolvable order on a transient database error.
|
||||
var ErrBadAdvVerdict = errors.New("orders: bad verdict")
|
||||
|
||||
// InsertAdvOrder records a fresh, pending order and returns it with a new guid.
|
||||
// The guid is minted here so the owner has a stable reference the instant they
|
||||
// click, before gogobee has heard of it. The caller has already proved the signed-
|
||||
// in viewer owns this adventurer; whether the action is *legal right now* is
|
||||
// gogobee's answer, at verdict time.
|
||||
func InsertAdvOrder(ownerSub, ownerLocalpart, token, characterName, action string, params *AdvOrderParams) (AdvOrder, error) {
|
||||
if !validAdvAction(action) {
|
||||
return AdvOrder{}, fmt.Errorf("orders: bad action %q", action)
|
||||
}
|
||||
// Store the canonical re-serialised form, never the client's bytes: the caller
|
||||
// has already resolved every field against the owner's own offer list, so what
|
||||
// goes in the row is Pete's understanding of the request rather than the
|
||||
// request itself.
|
||||
paramsJSON := ""
|
||||
if params != nil {
|
||||
b, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return AdvOrder{}, fmt.Errorf("orders: marshal params: %w", err)
|
||||
}
|
||||
paramsJSON = string(b)
|
||||
}
|
||||
guid, err := newGUID()
|
||||
if err != nil {
|
||||
return AdvOrder{}, err
|
||||
}
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO adventure_orders
|
||||
(guid, owner_sub, owner_localpart, token, character_name, action, status, params, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, ownerSub, ownerLocalpart, token, characterName, action, AdvOrderPending, paramsJSON, now, now,
|
||||
); err != nil {
|
||||
return AdvOrder{}, fmt.Errorf("orders: insert order: %w", err)
|
||||
}
|
||||
return AdvOrder{
|
||||
GUID: guid, OwnerSub: ownerSub, OwnerLocalpart: ownerLocalpart,
|
||||
Token: token, CharacterName: characterName, Action: action,
|
||||
Status: AdvOrderPending, Params: params, CreatedAt: now, UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PendingAdvOrders is gogobee's poll: every order still waiting. Like the equip
|
||||
// queue there is no claimed-but-stale window — a gogobee that dies mid-apply
|
||||
// leaves the order pending to be offered again, and its own guid ledger makes the
|
||||
// replay a no-op.
|
||||
func PendingAdvOrders(limit int) ([]AdvOrder, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), COALESCE(params, ''), created_at, updated_at
|
||||
FROM adventure_orders
|
||||
WHERE status = ?
|
||||
ORDER BY created_at
|
||||
LIMIT ?`,
|
||||
AdvOrderPending, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("orders: pending orders: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAdvOrders(rows)
|
||||
}
|
||||
|
||||
// ResolveAdvOrder files gogobee's verdict against a pending order. Idempotent by
|
||||
// the equip queue'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 ResolveAdvOrder(guid, status, detail string) (AdvOrder, error) {
|
||||
if !validAdvVerdict(status) {
|
||||
return AdvOrder{}, fmt.Errorf("%w %q", ErrBadAdvVerdict, status)
|
||||
}
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`UPDATE adventure_orders SET status = ?, detail = ?, updated_at = ?
|
||||
WHERE guid = ? AND status = ?`,
|
||||
status, detail, now, guid, AdvOrderPending,
|
||||
); err != nil {
|
||||
return AdvOrder{}, fmt.Errorf("orders: resolve order: %w", err)
|
||||
}
|
||||
return AdvOrderByGUID(guid)
|
||||
}
|
||||
|
||||
// AdvOrderByGUID reads one order.
|
||||
func AdvOrderByGUID(guid string) (AdvOrder, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), COALESCE(params, ''), created_at, updated_at
|
||||
FROM adventure_orders WHERE guid = ?`, guid,
|
||||
)
|
||||
if err != nil {
|
||||
return AdvOrder{}, fmt.Errorf("orders: read order: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out, err := scanAdvOrders(rows)
|
||||
if err != nil {
|
||||
return AdvOrder{}, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return AdvOrder{}, ErrNoSuchAdvOrder
|
||||
}
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// AdvOrdersByOwner returns an owner's own recent orders, newest first, for the
|
||||
// status strip. Keyed on the OIDC subject so a rename doesn't strand history.
|
||||
func AdvOrdersByOwner(ownerSub string, limit int) ([]AdvOrder, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), COALESCE(params, ''), created_at, updated_at
|
||||
FROM adventure_orders
|
||||
WHERE owner_sub = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
ownerSub, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("orders: orders by owner: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAdvOrders(rows)
|
||||
}
|
||||
|
||||
// HasPendingAdvOrder reports whether this owner already has an unanswered order
|
||||
// of this action outstanding. Unlike the equip queue's burst counter this is a
|
||||
// correctness guard, not anti-spam: two queued extracts would apply in sequence
|
||||
// and the second would come back "no expedition to leave", which reads as a
|
||||
// failure for something that in fact worked.
|
||||
func HasPendingAdvOrder(ownerSub, action string) (bool, error) {
|
||||
var n int
|
||||
err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM adventure_orders WHERE owner_sub = ? AND action = ? AND status = ?`,
|
||||
ownerSub, action, AdvOrderPending,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("orders: pending lookup: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// CountAdvOrdersSince backs the web anti-spam guard, same role as the equip
|
||||
// queue's: the real eligibility is gogobee's at verdict time, this only blunts a
|
||||
// stuck mouse button.
|
||||
func CountAdvOrdersSince(ownerSub string, since int64) (int, error) {
|
||||
var n int
|
||||
err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM adventure_orders WHERE owner_sub = ? AND created_at >= ?`,
|
||||
ownerSub, since,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("orders: count recent orders: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func scanAdvOrders(rows *sql.Rows) ([]AdvOrder, error) {
|
||||
var out []AdvOrder
|
||||
for rows.Next() {
|
||||
var o AdvOrder
|
||||
var params string
|
||||
if err := rows.Scan(&o.GUID, &o.OwnerSub, &o.OwnerLocalpart, &o.Token,
|
||||
&o.CharacterName, &o.Action, &o.Status, &o.Detail, ¶ms,
|
||||
&o.CreatedAt, &o.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("orders: scan order: %w", err)
|
||||
}
|
||||
// Unparseable params are dropped rather than failing the read. The row is
|
||||
// still a real order somebody placed, and a verb whose arguments went
|
||||
// missing is refused honestly by gogobee ("that order didn't say where
|
||||
// to") — which beats the whole poll erroring on one bad row.
|
||||
if params != "" {
|
||||
var pp AdvOrderParams
|
||||
if err := json.Unmarshal([]byte(params), &pp); err == nil {
|
||||
o.Params = &pp
|
||||
}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdvOrderRoundTrip(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
o, err := InsertAdvOrder("sub-1", "josie", "tok-josie", "Josie", AdvActionExtract, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if o.GUID == "" || o.Status != AdvOrderPending {
|
||||
t.Fatalf("order = %+v, want a guid and pending", o)
|
||||
}
|
||||
|
||||
pending, err := PendingAdvOrders(10)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].GUID != o.GUID || pending[0].Action != AdvActionExtract {
|
||||
t.Fatalf("pending = %+v", pending)
|
||||
}
|
||||
|
||||
got, err := ResolveAdvOrder(o.GUID, AdvOrderApplied, "out on day 3")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if got.Status != AdvOrderApplied || got.Detail != "out on day 3" {
|
||||
t.Fatalf("resolved = %+v", got)
|
||||
}
|
||||
if left, _ := PendingAdvOrders(10); len(left) != 0 {
|
||||
t.Fatalf("a resolved order is still pending: %+v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvOrderVerdictOnlyMovesAPendingRow is the idempotency mechanic: gogobee
|
||||
// retries its verdict push, so the second one must be a read, not a write.
|
||||
func TestAdvOrderVerdictOnlyMovesAPendingRow(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
o, _ := InsertAdvOrder("sub-1", "josie", "tok-josie", "Josie", AdvActionSiegeJoin, nil)
|
||||
if _, err := ResolveAdvOrder(o.GUID, AdvOrderApplied, "first"); err != nil {
|
||||
t.Fatalf("first verdict: %v", err)
|
||||
}
|
||||
got, err := ResolveAdvOrder(o.GUID, AdvRejectedNoSiege, "second")
|
||||
if err != nil {
|
||||
t.Fatalf("second verdict: %v", err)
|
||||
}
|
||||
if got.Status != AdvOrderApplied || got.Detail != "first" {
|
||||
t.Fatalf("order = %q/%q, want the first verdict to stand", got.Status, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvOrderRejectsBadInput(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if _, err := InsertAdvOrder("sub-1", "josie", "tok", "Josie", "sell_house", nil); err == nil {
|
||||
t.Fatal("an unknown action was accepted")
|
||||
}
|
||||
o, _ := InsertAdvOrder("sub-1", "josie", "tok", "Josie", AdvActionExtract, nil)
|
||||
if _, err := ResolveAdvOrder(o.GUID, "exploded", ""); err == nil {
|
||||
t.Fatal("an unknown verdict was accepted")
|
||||
}
|
||||
if _, err := AdvOrderByGUID("nope"); !errors.Is(err, ErrNoSuchAdvOrder) {
|
||||
t.Fatalf("unknown guid err = %v, want ErrNoSuchAdvOrder", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasPendingAdvOrderIsPerVerb: the guard stops a double-click on one button,
|
||||
// not the other button.
|
||||
func TestHasPendingAdvOrderIsPerVerb(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
o, _ := InsertAdvOrder("sub-1", "josie", "tok", "Josie", AdvActionExtract, nil)
|
||||
|
||||
if got, _ := HasPendingAdvOrder("sub-1", AdvActionExtract); !got {
|
||||
t.Fatal("a pending extract wasn't seen")
|
||||
}
|
||||
if got, _ := HasPendingAdvOrder("sub-1", AdvActionSiegeJoin); got {
|
||||
t.Fatal("a pending extract blocked a bout")
|
||||
}
|
||||
if got, _ := HasPendingAdvOrder("sub-2", AdvActionExtract); got {
|
||||
t.Fatal("one owner's pending order was seen for another")
|
||||
}
|
||||
// A resolved order stops holding the verb.
|
||||
if _, err := ResolveAdvOrder(o.GUID, AdvOrderApplied, ""); err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if got, _ := HasPendingAdvOrder("sub-1", AdvActionExtract); got {
|
||||
t.Fatal("a resolved order still holds its verb")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvOrdersByOwnerScopes(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if _, err := InsertAdvOrder("sub-A", "alice", "tok-a", "Alice", AdvActionExtract, nil); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if _, err := InsertAdvOrder("sub-B", "bob", "tok-b", "Bob", AdvActionExtract, nil); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
got, err := AdvOrdersByOwner("sub-A", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("by owner: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].OwnerLocalpart != "alice" {
|
||||
t.Fatalf("orders = %+v, want only alice's", got)
|
||||
}
|
||||
if n, _ := CountAdvOrdersSince("sub-A", 0); n != 1 {
|
||||
t.Fatalf("count = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
+68
-15
@@ -5,35 +5,73 @@ import "fmt"
|
||||
// PushSubscription is one browser/device endpoint a signed-in user has opted in
|
||||
// for Web Push digests. See the push_subscriptions schema for the field roles.
|
||||
type PushSubscription struct {
|
||||
Endpoint string
|
||||
UserSub string
|
||||
P256dh string
|
||||
Auth string
|
||||
CreatedAt int64
|
||||
LastNotifiedAt int64
|
||||
Endpoint string
|
||||
UserSub string
|
||||
Localpart string
|
||||
P256dh string
|
||||
Auth string
|
||||
CreatedAt int64
|
||||
LastNotifiedAt int64
|
||||
LastAdvNotifiedAt int64
|
||||
}
|
||||
|
||||
// AddPushSubscription records (or refreshes) a push endpoint for a user. The
|
||||
// endpoint is the primary key, so a re-subscribe from the same browser updates
|
||||
// the keys and resets the digest watermark to now — the user shouldn't be
|
||||
// paged for everything published before they opted in.
|
||||
func AddPushSubscription(sub, endpoint, p256dh, auth string) error {
|
||||
// the keys and resets both watermarks to now — the user shouldn't be paged for
|
||||
// everything published before they opted in.
|
||||
//
|
||||
// localpart is the session's Matrix handle, refreshed on every re-subscribe so a
|
||||
// row stored by a build that predated adventure alerts heals itself the first
|
||||
// time that browser subscribes again. It may legitimately be empty (a session
|
||||
// minted before the game economy existed carries no username); such a row simply
|
||||
// never matches an owner-scoped alert.
|
||||
func AddPushSubscription(sub, localpart, endpoint, p256dh, auth string) error {
|
||||
now := nowUnix()
|
||||
_, err := Get().Exec(`
|
||||
INSERT INTO push_subscriptions (endpoint, user_sub, p256dh, auth, created_at, last_notified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO push_subscriptions
|
||||
(endpoint, user_sub, user_localpart, p256dh, auth, created_at, last_notified_at, last_adv_notified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_sub = excluded.user_sub,
|
||||
user_localpart = excluded.user_localpart,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
last_notified_at = excluded.last_notified_at`,
|
||||
endpoint, sub, p256dh, auth, now, now)
|
||||
last_notified_at = excluded.last_notified_at,
|
||||
last_adv_notified_at = excluded.last_adv_notified_at`,
|
||||
endpoint, sub, localpart, p256dh, auth, now, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add push subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HealPushSubscriptionLocalpart fills in the Matrix handle on a row that was
|
||||
// stored before push_subscriptions had the column — the rows that can never match
|
||||
// an owner-scoped adventure alert, and whose owners have no way to notice.
|
||||
//
|
||||
// It is deliberately NOT AddPushSubscription with the same arguments. That upsert
|
||||
// resets both watermarks to now, which is right when somebody opts in and
|
||||
// catastrophic on a heal: the browser would call it on every page load, so a
|
||||
// reader who visits daily would silently never receive a digest or an alert
|
||||
// again. This touches one column and no clock.
|
||||
//
|
||||
// Scoped to user_sub so presenting somebody else's endpoint rewrites nothing, and
|
||||
// restricted to rows whose localpart is still empty — so it is a no-op after the
|
||||
// first success, and it can never overwrite a good handle with a stale one.
|
||||
func HealPushSubscriptionLocalpart(sub, endpoint, localpart string) error {
|
||||
if localpart == "" {
|
||||
return nil // nothing to heal with; see AddPushSubscription on empty handles
|
||||
}
|
||||
_, err := Get().Exec(
|
||||
`UPDATE push_subscriptions SET user_localpart = ?
|
||||
WHERE endpoint = ? AND user_sub = ? AND user_localpart = ''`,
|
||||
localpart, endpoint, sub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("heal push subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePushSubscription drops one endpoint regardless of owner. Reserved for
|
||||
// the digest sender's prune path, where a push service has reported the endpoint
|
||||
// gone (404/410) and there's no caller identity to scope by. User-initiated
|
||||
@@ -61,7 +99,8 @@ func RemovePushSubscriptionForUser(sub, endpoint string) error {
|
||||
// ListPushSubscriptions returns every stored subscription, for the digest sender.
|
||||
func ListPushSubscriptions() ([]PushSubscription, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT endpoint, user_sub, p256dh, auth, created_at, last_notified_at
|
||||
`SELECT endpoint, user_sub, user_localpart, p256dh, auth,
|
||||
created_at, last_notified_at, last_adv_notified_at
|
||||
FROM push_subscriptions`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,7 +109,8 @@ func ListPushSubscriptions() ([]PushSubscription, error) {
|
||||
var out []PushSubscription
|
||||
for rows.Next() {
|
||||
var p PushSubscription
|
||||
if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.P256dh, &p.Auth, &p.CreatedAt, &p.LastNotifiedAt); err != nil {
|
||||
if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.Localpart, &p.P256dh, &p.Auth,
|
||||
&p.CreatedAt, &p.LastNotifiedAt, &p.LastAdvNotifiedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
@@ -78,6 +118,19 @@ func ListPushSubscriptions() ([]PushSubscription, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TouchAdvPushSubscription advances an endpoint's *adventure alert* watermark, so
|
||||
// the next pass only considers dispatches that occurred after ts. Kept separate
|
||||
// from TouchPushSubscription for the reason the schema gives: the digest and the
|
||||
// alerts must not be able to consume each other's backlog.
|
||||
func TouchAdvPushSubscription(endpoint string, ts int64) error {
|
||||
_, err := Get().Exec(
|
||||
`UPDATE push_subscriptions SET last_adv_notified_at = ? WHERE endpoint = ?`, ts, endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch adventure push watermark: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchPushSubscription advances an endpoint's digest watermark so its next
|
||||
// digest only considers stories seen after ts.
|
||||
func TouchPushSubscription(endpoint string, ts int64) error {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// The reads behind adventure push alerts. The alert sender needs two things the
|
||||
// rest of the storage layer does not: dispatches ordered by when they *happened*
|
||||
// rather than by subject, and a way to turn a signed-in identity into the
|
||||
// character name a fact would carry.
|
||||
|
||||
// AdvEventsSince returns dispatches that occurred after sinceUnix, newest first,
|
||||
// capped at limit.
|
||||
//
|
||||
// The clock is occurred_at, not an arrival time, and that choice has a
|
||||
// consequence worth stating: a dispatch that reaches Pete late but describes
|
||||
// something old — a queue row unparked months after the fact, which W0's
|
||||
// inversion made possible — sorts behind the watermark and is never alerted on.
|
||||
// That is the outcome we want. An alert is a claim that something is happening
|
||||
// now, and a phone buzzing about a hire from March would be a lie told urgently.
|
||||
func AdvEventsSince(sinceUnix int64, limit int) ([]AdvEvent, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := Get().Query(`
|
||||
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||
level, tally, outcome, milestone, stakes, run_id, occurred_at
|
||||
FROM adventure_events
|
||||
WHERE occurred_at > ?
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT ?`, sinceUnix, limit)
|
||||
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 sql.NullString
|
||||
var outcome, milestone, stakes, runID sql.NullString
|
||||
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent,
|
||||
&boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone,
|
||||
&stakes, &runID, &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
|
||||
e.RunID = runID.String
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AdvCharacterForOwner returns the character name currently on the board for a
|
||||
// signed-in user's localpart, which is the join an owner-scoped alert needs: a
|
||||
// fact carries a character *name*, a session carries a localpart, and the only
|
||||
// thing that connects them is the owner-private (localpart -> token) row gogobee
|
||||
// pushes alongside the public (token -> name) board.
|
||||
//
|
||||
// It fails closed, and every way it can fail is a way it should:
|
||||
//
|
||||
// - no self-detail row: gogobee has stopped pushing for this player, so Pete
|
||||
// has no current basis to claim any name is theirs.
|
||||
// - no roster row for the token: the player is off the board — removed, or
|
||||
// opted out of the news entirely. An opted-out player's facts are anonymised
|
||||
// on the wire anyway, so there is no name left to match even in principle.
|
||||
//
|
||||
// Both cases mean the caller sends nothing, which is the correct answer to "is
|
||||
// this dispatch about you" when Pete cannot honestly tell.
|
||||
func AdvCharacterForOwner(localpart string) (string, bool) {
|
||||
if localpart == "" {
|
||||
return "", false
|
||||
}
|
||||
var name string
|
||||
err := Get().QueryRow(`
|
||||
SELECT r.name
|
||||
FROM player_self_detail d
|
||||
JOIN adventure_roster r ON r.token = d.token
|
||||
WHERE d.localpart = ?`, localpart).Scan(&name)
|
||||
if err != nil || name == "" {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// seedOwnedCharacter puts a player on the board and gives them an owner, which
|
||||
// is the two-row arrangement AdvCharacterForOwner has to walk: the public
|
||||
// (token -> name) board plus the owner-private (localpart -> token) detail row.
|
||||
func seedOwnedCharacter(t *testing.T, localpart, token, name string) {
|
||||
t.Helper()
|
||||
if err := ReplaceRoster([]RosterEntry{{
|
||||
Token: token, Name: name, Level: 14, ClassRace: "Cleric", Status: "idle",
|
||||
}}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{{Localpart: localpart, Token: token}}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvCharacterForOwnerNeedsBothHalves is the privacy contract behind every
|
||||
// owner-scoped alert. The sender asks "which character is this subscriber's" and
|
||||
// then compares that name against a dispatch's subject; if this function ever
|
||||
// answered generously, somebody's phone would buzz about another player's death.
|
||||
//
|
||||
// Each half of the join is removed in turn, because each one goes missing for a
|
||||
// real reason in production: the roster row disappears when a player opts out of
|
||||
// the news or leaves the board, and the self-detail row disappears when gogobee
|
||||
// stops pushing for them. Both must fail closed.
|
||||
func TestAdvCharacterForOwnerNeedsBothHalves(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedOwnedCharacter(t, "josie", "tok-josie", "Josie")
|
||||
|
||||
if name, ok := AdvCharacterForOwner("josie"); !ok || name != "Josie" {
|
||||
t.Fatalf("owner lookup = %q/%v, want Josie/true", name, ok)
|
||||
}
|
||||
if _, ok := AdvCharacterForOwner("quack"); ok {
|
||||
t.Error("a localpart with no self-detail row resolved to a character")
|
||||
}
|
||||
if _, ok := AdvCharacterForOwner(""); ok {
|
||||
t.Error("an empty localpart resolved to a character")
|
||||
}
|
||||
|
||||
// Off the board — opted out of the news, or removed. The self-detail row is
|
||||
// still there and still points at tok-josie, so only the roster join stops
|
||||
// this. An opted-out player's facts are anonymised on the wire anyway, so
|
||||
// there would be no name left to match even if this leaked.
|
||||
if err := ReplaceRoster(nil, 2000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name, ok := AdvCharacterForOwner("josie"); ok {
|
||||
t.Errorf("off-the-board player still resolved to %q; alerts must close with the board", name)
|
||||
}
|
||||
|
||||
// And the mirror case: on the board, but gogobee has stopped pushing the
|
||||
// owner-private half, so Pete has no basis to claim the name is theirs.
|
||||
seedOwnedCharacter(t, "josie", "tok-josie", "Josie")
|
||||
if err := ReplacePlayerDetail(nil, 3000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name, ok := AdvCharacterForOwner("josie"); ok {
|
||||
t.Errorf("resolved %q with no self-detail row; the ownership claim has no source", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvEventsSinceIsOrderedNewestFirst pins the ordering the sender depends on
|
||||
// twice over: it takes the first match as "the newest thing to tell you about",
|
||||
// and it advances every watermark to events[0]. Reverse this and the alert names
|
||||
// the oldest unseen dispatch while the watermark skips the rest.
|
||||
func TestAdvEventsSinceIsOrderedNewestFirst(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
for _, e := range []AdvEvent{
|
||||
{GUID: "g1", EventType: "death", Subject: "Josie", OccurredAt: 100},
|
||||
{GUID: "g2", EventType: "zone_clear", Subject: "Josie", OccurredAt: 300},
|
||||
{GUID: "g3", EventType: "retreat", Subject: "Josie", OccurredAt: 200},
|
||||
} {
|
||||
if err := InsertAdventureEvent(&e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := AdvEventsSince(150, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d events after ts=150, want 2 (the ts=100 one is behind the watermark)", len(got))
|
||||
}
|
||||
if got[0].GUID != "g2" || got[1].GUID != "g3" {
|
||||
t.Fatalf("order = %s,%s; want g2,g3 (newest first)", got[0].GUID, got[1].GUID)
|
||||
}
|
||||
|
||||
// limit <= 0 means "nothing", not "everything": the sender treats the result
|
||||
// as a bounded window and an unbounded read here would be a surprise.
|
||||
if got, _ := AdvEventsSince(0, 0); len(got) != 0 {
|
||||
t.Errorf("limit 0 returned %d events, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvEventsSinceCarriesRunID pins that the run link survives the read. The
|
||||
// alert for an ended expedition points at the run report when there is one, and
|
||||
// that field is the only thing distinguishing it from the plain story permalink.
|
||||
func TestAdvEventsSinceCarriesRunID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := InsertAdventureEvent(&AdvEvent{
|
||||
GUID: "g1", EventType: "zone_clear", Subject: "Josie",
|
||||
RunID: "run-7", OccurredAt: 500,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := AdvEventsSince(0, 10)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("read back %d events (err %v), want 1", len(got), err)
|
||||
}
|
||||
if got[0].RunID != "run-7" {
|
||||
t.Errorf("run id = %q, want run-7", got[0].RunID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvWatermarkIsIndependentOfTheDigest pins the schema note. The two senders
|
||||
// run on different clocks; if they shared a column, whichever ran last would
|
||||
// decide what the other had already seen, and one of the two channels would go
|
||||
// permanently quiet in a way nobody would think to look for.
|
||||
func TestAdvWatermarkIsIndependentOfTheDigest(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
const ep = "https://push.example/ep"
|
||||
if err := AddPushSubscription("sub-1", "josie", ep, "p", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := TouchAdvPushSubscription(ep, 4242); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ := ListPushSubscriptions()
|
||||
if len(subs) != 1 {
|
||||
t.Fatalf("got %d subscriptions, want 1", len(subs))
|
||||
}
|
||||
if subs[0].LastAdvNotifiedAt != 4242 {
|
||||
t.Errorf("adventure watermark = %d, want 4242", subs[0].LastAdvNotifiedAt)
|
||||
}
|
||||
if subs[0].LastNotifiedAt == 4242 {
|
||||
t.Error("touching the adventure watermark moved the digest watermark too")
|
||||
}
|
||||
if subs[0].Localpart != "josie" {
|
||||
t.Errorf("localpart = %q, want josie; owner-scoped alerts have nothing to join on without it", subs[0].Localpart)
|
||||
}
|
||||
|
||||
// The reverse direction, so neither can quietly consume the other's backlog.
|
||||
if err := TouchPushSubscription(ep, 99); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ = ListPushSubscriptions()
|
||||
if subs[0].LastAdvNotifiedAt != 4242 {
|
||||
t.Errorf("digest touch moved the adventure watermark to %d", subs[0].LastAdvNotifiedAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// W9: healing the Matrix handle onto a subscription stored before the column
|
||||
// existed. Those rows can never match an owner-scoped adventure alert, and their
|
||||
// owners have no way to notice — the browser only re-subscribes on a click.
|
||||
//
|
||||
// The trap this exists to avoid is worth stating plainly, because the obvious
|
||||
// implementation is a one-liner that reuses AddPushSubscription with the same
|
||||
// arguments: that upsert resets BOTH watermarks to now. The heal runs from the
|
||||
// page, so it would fire far more often than a subscribe does, and every run
|
||||
// would push the digest's own "last told them about" stamp forward — a reader who
|
||||
// visits daily would silently stop receiving digests and adventure alerts alike,
|
||||
// from a change made to fix notifications.
|
||||
|
||||
func findSub(t *testing.T, endpoint string) PushSubscription {
|
||||
t.Helper()
|
||||
subs, err := ListPushSubscriptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range subs {
|
||||
if s.Endpoint == endpoint {
|
||||
return s
|
||||
}
|
||||
}
|
||||
t.Fatalf("no subscription for %q", endpoint)
|
||||
return PushSubscription{}
|
||||
}
|
||||
|
||||
func TestHealFillsAnEmptyLocalpartAndNothingElse(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
const ep = "https://push.example/ep-old"
|
||||
|
||||
// A row as a pre-W6 build left it: no Matrix handle.
|
||||
if err := AddPushSubscription("sub-1", "", ep, "p256", "auth"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := findSub(t, ep)
|
||||
if before.Localpart != "" {
|
||||
t.Fatalf("seed carries a localpart %q; the test isn't testing anything", before.Localpart)
|
||||
}
|
||||
// Move both watermarks off "now" so a reset would be visible rather than
|
||||
// coincidentally equal.
|
||||
if err := TouchPushSubscription(ep, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := TouchAdvPushSubscription(ep, 2000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := HealPushSubscriptionLocalpart("sub-1", ep, "josie"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := findSub(t, ep)
|
||||
if got.Localpart != "josie" {
|
||||
t.Fatalf("localpart = %q, want josie", got.Localpart)
|
||||
}
|
||||
// The whole point: the clocks did not move.
|
||||
if got.LastNotifiedAt != 1000 {
|
||||
t.Fatalf("digest watermark = %d, want 1000 — a heal that resets it silences the digest",
|
||||
got.LastNotifiedAt)
|
||||
}
|
||||
if got.LastAdvNotifiedAt != 2000 {
|
||||
t.Fatalf("adventure watermark = %d, want 2000 — a heal that resets it silences the alerts",
|
||||
got.LastAdvNotifiedAt)
|
||||
}
|
||||
if got.P256dh != "p256" || got.Auth != "auth" {
|
||||
t.Fatal("the heal rewrote the encryption keys; it must touch one column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealNeverOverwritesAKnownHandle(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
const ep = "https://push.example/ep-good"
|
||||
if err := AddPushSubscription("sub-1", "josie", ep, "p256", "auth"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A later session whose username resolved differently must not be able to
|
||||
// rewrite a handle that is already good — the heal is for empty rows only, so
|
||||
// it is a no-op the moment one has succeeded.
|
||||
if err := HealPushSubscriptionLocalpart("sub-1", ep, "someone-else"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findSub(t, ep); got.Localpart != "josie" {
|
||||
t.Fatalf("localpart = %q, want the original josie", got.Localpart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealIsScopedToTheCaller(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
const ep = "https://push.example/ep-theirs"
|
||||
if err := AddPushSubscription("sub-owner", "", ep, "p256", "auth"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Somebody else presenting the endpoint string writes nothing. Endpoints are
|
||||
// not secrets and the client hands one straight up, so this is the guard that
|
||||
// stops a stranger attaching their own handle to another account's device.
|
||||
if err := HealPushSubscriptionLocalpart("sub-attacker", ep, "attacker"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findSub(t, ep); got.Localpart != "" {
|
||||
t.Fatalf("localpart = %q; another account healed a row it does not own", got.Localpart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealWithNoHandleIsANoOp(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
const ep = "https://push.example/ep-nouser"
|
||||
if err := AddPushSubscription("sub-1", "", ep, "p256", "auth"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A session minted before the game economy existed carries no username. There
|
||||
// is nothing to heal with, and writing "" over "" is not worth a statement.
|
||||
if err := HealPushSubscriptionLocalpart("sub-1", ep, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findSub(t, ep); got.Localpart != "" {
|
||||
t.Fatalf("localpart = %q, want empty", got.Localpart)
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,15 @@ import "testing"
|
||||
func TestPushSubscriptionLifecycle(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil {
|
||||
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second endpoint for the same user (e.g. a second device).
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil {
|
||||
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A different user.
|
||||
if err := AddPushSubscription("sub-2", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil {
|
||||
if err := AddPushSubscription("sub-2", "quack", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestPushSubscriptionLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// Re-subscribing the same endpoint updates keys in place, not a new row.
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil {
|
||||
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ = ListPushSubscriptions()
|
||||
@@ -54,7 +54,7 @@ func TestPushSubscriptionLifecycle(t *testing.T) {
|
||||
|
||||
func TestTouchPushSubscriptionAdvancesWatermark(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep", "p", "a"); err != nil {
|
||||
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep", "p", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ := ListPushSubscriptions()
|
||||
|
||||
@@ -132,10 +132,10 @@ func MarkClassified(guid, channel, platforms string) {
|
||||
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
|
||||
func GetStoryByGUID(guid string) (*Story, error) {
|
||||
row := Get().QueryRow(
|
||||
`SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
|
||||
`SELECT guid, headline, lede, COALESCE(content, ''), image_url, article_url, source, platforms, channel, seen_at
|
||||
FROM stories WHERE guid = ?`, guid)
|
||||
var s Story
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.Content, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
@@ -252,6 +252,53 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// UnpostedAdventureSince returns adventure dispatches seen at or after `since`
|
||||
// that have never been posted to Matrix (no post_log row). PRIORITY beats post
|
||||
// live and so carry a post_log row; the ones left are exactly the BULLETINs the
|
||||
// daily digest collects. Oldest first so the digest reads chronologically.
|
||||
//
|
||||
// At most `limit` rows come back, but total is how many match in all — the digest
|
||||
// quotes it to readers, so it must count the window rather than the returned page.
|
||||
func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) {
|
||||
const where = `WHERE classified = 1
|
||||
AND channel = 'adventure'
|
||||
AND seen_at >= ?
|
||||
AND guid NOT IN (SELECT guid FROM post_log)`
|
||||
|
||||
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories `+where, since).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, headline, lede, article_url, seen_at
|
||||
FROM stories `+where+`
|
||||
ORDER BY seen_at ASC
|
||||
LIMIT ?`, since, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
|
||||
// event) so the next daily digest doesn't re-collect it. Idempotent per guid via
|
||||
// the post_log OR IGNORE. eventID is the digest's synthetic key (e.g.
|
||||
// "adv-digest:2026-07-11") shared by every story that went out in that digest.
|
||||
func MarkAdventureDigested(guids []string, eventID string) {
|
||||
for _, g := range guids {
|
||||
InsertPostLog(g, "adventure", eventID, "", false)
|
||||
}
|
||||
}
|
||||
|
||||
// ListClassifiedByChannel returns up to `limit` classified stories routed to a
|
||||
// real channel, newest first, with optional offset for pagination. Sentinel
|
||||
// channels (_discarded, _duplicate) are excluded.
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// The realm, as gogobee pushes it.
|
||||
//
|
||||
// Three pages ride one snapshot: the world map, the board, and the hall of
|
||||
// firsts. They are one push rather than three because they are one *question* —
|
||||
// "what is this place, and what has happened here" — and because every number in
|
||||
// all three comes off the same scan of the same run history. Splitting them would
|
||||
// mean three ways for the same fact to be a different number depending on which
|
||||
// page you were looking at.
|
||||
//
|
||||
// Nothing here is an event. The events (a zone_first dispatch, a death) come down
|
||||
// the dispatch queue like any other fact. This is the standing state of the world,
|
||||
// which is the only kind of thing a map can honestly draw.
|
||||
|
||||
// RealmOccupant is somebody on an expedition in a zone right now. Token is the
|
||||
// public board token and is EMPTY only in the sense that this row never exists
|
||||
// for an opted-out player — unlike a siege contributor, presence is dropped
|
||||
// outright upstream rather than anonymised, so every row here has a name and a
|
||||
// link.
|
||||
type RealmOccupant struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
}
|
||||
|
||||
// RealmZone is one place in the world: what it is, who first got through it, how
|
||||
// many have since, and who is inside it.
|
||||
//
|
||||
// FirstBy with an empty FirstToken is the anonymised case — the zone HAS been
|
||||
// cleared and the claim stands, but the clearer opted out and gets no name and no
|
||||
// link. Clears > 0 with no FirstBy at all is the same state seen from the other
|
||||
// side, and both render as "cleared, by somebody" rather than as never-cleared,
|
||||
// which would be a false statement about the realm rather than a withheld one.
|
||||
type RealmZone struct {
|
||||
ID string `json:"id"`
|
||||
Display string `json:"display"`
|
||||
Tier int `json:"tier"`
|
||||
LevelMin int `json:"level_min"`
|
||||
LevelMax int `json:"level_max"`
|
||||
Faction string `json:"faction,omitempty"`
|
||||
Atmosphere string `json:"atmosphere,omitempty"`
|
||||
Postgame bool `json:"postgame,omitempty"`
|
||||
|
||||
FirstClearBy string `json:"first_clear_by,omitempty"`
|
||||
FirstClearToken string `json:"first_clear_token,omitempty"`
|
||||
FirstClearAt int64 `json:"first_clear_at,omitempty"`
|
||||
|
||||
Clears int `json:"clears"`
|
||||
Clearers int `json:"clearers"`
|
||||
|
||||
Occupants []RealmOccupant `json:"occupants,omitempty"`
|
||||
}
|
||||
|
||||
// RealmFirst is one entry in the hall of firsts.
|
||||
type RealmFirst struct {
|
||||
Kind string `json:"kind"`
|
||||
Target string `json:"target"`
|
||||
Display string `json:"display"`
|
||||
Tier int `json:"tier,omitempty"`
|
||||
Holder string `json:"holder,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
AtUnix int64 `json:"at_unix"`
|
||||
}
|
||||
|
||||
// RealmStanding is one line on the board.
|
||||
type RealmStanding struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
ClassRace string `json:"class_race,omitempty"`
|
||||
DeepestTier int `json:"deepest_tier"`
|
||||
Clears int `json:"clears"`
|
||||
Zones int `json:"zones"`
|
||||
Firsts int `json:"firsts"`
|
||||
SiegeDamage int `json:"siege_damage"`
|
||||
SiegeFights int `json:"siege_fights"`
|
||||
}
|
||||
|
||||
// Realm is the whole snapshot.
|
||||
type Realm struct {
|
||||
Zones []RealmZone `json:"zones,omitempty"`
|
||||
Firsts []RealmFirst `json:"firsts,omitempty"`
|
||||
Standings []RealmStanding `json:"standings,omitempty"`
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
}
|
||||
|
||||
// ReplaceRealm swaps the whole realm for a new snapshot, in one transaction.
|
||||
//
|
||||
// Replace, never merge, for the reason the siege does it: a zone whose clear
|
||||
// count was corrected upstream, a player who opted out, an occupant who came
|
||||
// home — all of those are *removals*, and a merge has no way to express one. The
|
||||
// transaction means a reader mid-swap sees the old realm or the new one, never a
|
||||
// zone list with the previous board under it.
|
||||
func ReplaceRealm(r Realm, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, t := range []string{
|
||||
"adventure_realm_zone",
|
||||
"adventure_realm_occupant",
|
||||
"adventure_realm_first",
|
||||
"adventure_realm_standing",
|
||||
} {
|
||||
if _, err := tx.Exec(`DELETE FROM ` + t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
zstmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_realm_zone
|
||||
(pos, zone_id, display, tier, level_min, level_max, faction, atmosphere,
|
||||
postgame, first_by, first_token, first_at, clears, clearers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zstmt.Close()
|
||||
|
||||
ostmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_realm_occupant (pos, zone_id, token, name, level, day)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ostmt.Close()
|
||||
|
||||
opos := 0
|
||||
for i, z := range r.Zones {
|
||||
if _, err := zstmt.Exec(i, z.ID, z.Display, z.Tier, z.LevelMin, z.LevelMax,
|
||||
z.Faction, z.Atmosphere, z.Postgame, z.FirstClearBy, z.FirstClearToken,
|
||||
z.FirstClearAt, z.Clears, z.Clearers); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, o := range z.Occupants {
|
||||
if _, err := ostmt.Exec(opos, z.ID, o.Token, o.Name, o.Level, o.Day); err != nil {
|
||||
return err
|
||||
}
|
||||
opos++
|
||||
}
|
||||
}
|
||||
|
||||
fstmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_realm_first (pos, kind, target, display, tier, holder, token, at_unix)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fstmt.Close()
|
||||
for i, f := range r.Firsts {
|
||||
if _, err := fstmt.Exec(i, f.Kind, f.Target, f.Display, f.Tier, f.Holder, f.Token, f.AtUnix); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
sstmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_realm_standing
|
||||
(pos, token, name, level, class_race, deepest_tier, clears, zones, firsts,
|
||||
siege_damage, siege_fights)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sstmt.Close()
|
||||
for i, s := range r.Standings {
|
||||
if _, err := sstmt.Exec(i, s.Token, s.Name, s.Level, s.ClassRace, s.DeepestTier,
|
||||
s.Clears, s.Zones, s.Firsts, s.SiegeDamage, s.SiegeFights); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO adventure_realm_meta (id, snapshot_at) VALUES (1, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// LoadRealm returns the realm as last pushed. ok is false when gogobee has never
|
||||
// pushed one — distinct from a pushed snapshot that happens to be empty, which is
|
||||
// a real answer (a realm with no living adventurers on the board is a thing that
|
||||
// can be true) and which the pages render differently.
|
||||
func LoadRealm() (Realm, bool, error) {
|
||||
var r Realm
|
||||
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_realm_meta WHERE id = 1`).
|
||||
Scan(&r.SnapshotAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return Realm{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Realm{}, false, err
|
||||
}
|
||||
|
||||
// Each cursor is drained fully before the next query opens. The pool is one
|
||||
// connection wide, and a nested read is the deadlock the run-beat batch
|
||||
// shipped with and then had to have cut out of it.
|
||||
zones, err := loadRealmZones()
|
||||
if err != nil {
|
||||
return r, true, err
|
||||
}
|
||||
occ, err := loadRealmOccupants()
|
||||
if err != nil {
|
||||
return r, true, err
|
||||
}
|
||||
for i := range zones {
|
||||
zones[i].Occupants = occ[zones[i].ID]
|
||||
}
|
||||
r.Zones = zones
|
||||
|
||||
if r.Firsts, err = loadRealmFirsts(); err != nil {
|
||||
return r, true, err
|
||||
}
|
||||
if r.Standings, err = loadRealmStandings(); err != nil {
|
||||
return r, true, err
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
func loadRealmZones() ([]RealmZone, error) {
|
||||
rows, err := Get().Query(`
|
||||
SELECT zone_id, display, tier, level_min, level_max, faction, atmosphere,
|
||||
postgame, first_by, first_token, first_at, clears, clearers
|
||||
FROM adventure_realm_zone ORDER BY pos ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RealmZone
|
||||
for rows.Next() {
|
||||
var z RealmZone
|
||||
if err := rows.Scan(&z.ID, &z.Display, &z.Tier, &z.LevelMin, &z.LevelMax,
|
||||
&z.Faction, &z.Atmosphere, &z.Postgame, &z.FirstClearBy, &z.FirstClearToken,
|
||||
&z.FirstClearAt, &z.Clears, &z.Clearers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, z)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadRealmOccupants() (map[string][]RealmOccupant, error) {
|
||||
rows, err := Get().Query(`
|
||||
SELECT zone_id, token, name, level, day
|
||||
FROM adventure_realm_occupant ORDER BY pos ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[string][]RealmOccupant{}
|
||||
for rows.Next() {
|
||||
var zoneID string
|
||||
var o RealmOccupant
|
||||
if err := rows.Scan(&zoneID, &o.Token, &o.Name, &o.Level, &o.Day); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[zoneID] = append(out[zoneID], o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadRealmFirsts() ([]RealmFirst, error) {
|
||||
rows, err := Get().Query(`
|
||||
SELECT kind, target, display, tier, holder, token, at_unix
|
||||
FROM adventure_realm_first ORDER BY pos ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RealmFirst
|
||||
for rows.Next() {
|
||||
var f RealmFirst
|
||||
if err := rows.Scan(&f.Kind, &f.Target, &f.Display, &f.Tier, &f.Holder, &f.Token, &f.AtUnix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadRealmStandings() ([]RealmStanding, error) {
|
||||
rows, err := Get().Query(`
|
||||
SELECT token, name, level, class_race, deepest_tier, clears, zones, firsts,
|
||||
siege_damage, siege_fights
|
||||
FROM adventure_realm_standing ORDER BY pos ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RealmStanding
|
||||
for rows.Next() {
|
||||
var s RealmStanding
|
||||
if err := rows.Scan(&s.Token, &s.Name, &s.Level, &s.ClassRace, &s.DeepestTier,
|
||||
&s.Clears, &s.Zones, &s.Firsts, &s.SiegeDamage, &s.SiegeFights); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeathsBySubject counts the deaths Pete has reported for each named adventurer.
|
||||
//
|
||||
// This is the one standings number that does NOT come off the gogobee push, and
|
||||
// the reason is that the game has nowhere to read it from: a character carries
|
||||
// its most recent death (source, place, date) and no running total, so there is
|
||||
// no lifetime count on the game box to send. Pete does have one — his own back
|
||||
// catalogue of death dispatches, seeded at news launch by the backfill and
|
||||
// complete since — so he counts them himself, which is a thing a newspaper is
|
||||
// entitled to do about its own reporting.
|
||||
//
|
||||
// Keyed on the character name because that is the only join the fact table
|
||||
// offers: a dispatch carries a name, never a token. Names are unique per realm in
|
||||
// practice; a collision would merge two adventurers' death counts, which is why
|
||||
// this is the only column derived this way and not, say, clears.
|
||||
func DeathsBySubject() (map[string]int, error) {
|
||||
rows, err := Get().Query(`
|
||||
SELECT subject, COUNT(*) FROM adventure_events
|
||||
WHERE event_type = 'death' AND subject IS NOT NULL AND subject <> ''
|
||||
GROUP BY subject`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var n int
|
||||
if err := rows.Scan(&name, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[name] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PeteDuelRecord is Pete's own won/lost tally, from the dispatches he filed about
|
||||
// himself. He is a companion who can be hired onto an expedition and he has a
|
||||
// record; keeping score on himself is in voice, and it is the one line on the
|
||||
// board that is not about a player.
|
||||
//
|
||||
// Both types have had templates in the renderer since before anything emitted
|
||||
// them, so this reads zero until gogobee starts filing them — and zero-zero is
|
||||
// rendered as "no bouts yet" rather than as a 0% win rate.
|
||||
func PeteDuelRecord() (wins, losses int, err error) {
|
||||
err = Get().QueryRow(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN event_type = 'pete_duel_win' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN event_type = 'pete_duel_loss' THEN 1 ELSE 0 END), 0)
|
||||
FROM adventure_events
|
||||
WHERE event_type IN ('pete_duel_win', 'pete_duel_loss')`).Scan(&wins, &losses)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, 0, nil
|
||||
}
|
||||
return wins, losses, err
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
|
||||
// gogobee pushed. Not an event — nothing here is a thing that *happened*.
|
||||
type RosterEntry struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
ClassRace string `json:"class_race"`
|
||||
Status string `json:"status"` // "expedition" | "idle"
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
IdleHours int `json:"idle_hours,omitempty"`
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
// Detail is the public expanded sheet (stats + gear), carried as raw JSON so
|
||||
// the board path never has to model or touch it — only the detail page decodes
|
||||
// it. Empty when a snapshot predates the detail push.
|
||||
Detail json.RawMessage `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
||||
//
|
||||
// Replace — never merge. A player who dropped out of gogobee's snapshot (deleted
|
||||
// character, or a fresh `!news optout`) must vanish from the board, and an
|
||||
// upsert would leave them standing there forever. The transaction means a reader
|
||||
// mid-swap sees the old board or the new one, never a half-empty realm.
|
||||
func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM adventure_roster`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_roster
|
||||
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at, detail_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, e := range entries {
|
||||
var detail any // NULL when absent, so the column reads as "no detail", not "{}"
|
||||
if len(e.Detail) > 0 {
|
||||
detail = string(e.Detail)
|
||||
}
|
||||
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
||||
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt, detail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Stamp the snapshot even when it carried zero adventurers — that is a
|
||||
// legitimate state (quiet realm) and must not read as "gogobee went away".
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO adventure_roster_meta (id, snapshot_at) VALUES (1, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// LoadRoster returns the current board: everyone on an expedition first (most
|
||||
// recently departed at the top of that group), then the idle, longest-idle last.
|
||||
// Also returns the snapshot time so the caller can decide whether the wire has
|
||||
// gone quiet — see rosterStale.
|
||||
func LoadRoster() ([]RosterEntry, int64, error) {
|
||||
rows, err := Get().Query(`
|
||||
SELECT token, name, level, COALESCE(class_race, ''), status,
|
||||
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
|
||||
FROM adventure_roster
|
||||
ORDER BY status = 'expedition' DESC, day DESC, idle_hours ASC, level DESC, name ASC`)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RosterEntry
|
||||
for rows.Next() {
|
||||
var e RosterEntry
|
||||
if err := rows.Scan(&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
||||
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out, RosterSnapshotAt(), nil
|
||||
}
|
||||
|
||||
// RosterEntryByToken looks up one adventurer by their roster token. The bool is
|
||||
// false when no such token is on the current board — which is exactly the check
|
||||
// the storefront needs: a buyer may only order a hit on a mark the live board is
|
||||
// actually showing, never a stale or guessed token.
|
||||
func RosterEntryByToken(token string) (RosterEntry, bool, error) {
|
||||
var e RosterEntry
|
||||
var detail sql.NullString
|
||||
err := Get().QueryRow(`
|
||||
SELECT token, name, level, COALESCE(class_race, ''), status,
|
||||
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at, detail_json
|
||||
FROM adventure_roster WHERE token = ?`, token).Scan(
|
||||
&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
||||
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt, &detail)
|
||||
if err == sql.ErrNoRows {
|
||||
return RosterEntry{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RosterEntry{}, false, err
|
||||
}
|
||||
if detail.Valid && detail.String != "" {
|
||||
e.Detail = json.RawMessage(detail.String)
|
||||
}
|
||||
return e, true, nil
|
||||
}
|
||||
|
||||
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
|
||||
// never pushed one. Read from the meta row, not the entries, so a snapshot that
|
||||
// legitimately carried nobody still counts as a snapshot.
|
||||
func RosterSnapshotAt() int64 {
|
||||
var at sql.NullInt64
|
||||
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_roster_meta WHERE id = 1`).Scan(&at)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("RosterSnapshotAt query failed", "err", err)
|
||||
return 0
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// The expedition liveblog, as gogobee beats it out room by room.
|
||||
//
|
||||
// Everything else the game pushes is a snapshot: the board, the war room, a
|
||||
// player's own sheet. Those get replaced whole, because they describe what is
|
||||
// currently true. Beats are the opposite kind of thing — each one is a moment
|
||||
// that happened, and the only correction a later push can make to a moment is to
|
||||
// add another one after it. So this is append-only, keyed on (run_id, seq), and
|
||||
// a re-sent batch collapses on the primary key instead of duplicating a story.
|
||||
//
|
||||
// The run header is *derived*, not pushed. gogobee sends beats and nothing else;
|
||||
// AppendRunBeats folds the identifying ones into adventure_run as they arrive.
|
||||
// The upside is that a run missing its `start` beat still has a log — anonymous,
|
||||
// but readable — rather than being discarded for want of a name to hang it on.
|
||||
|
||||
// RunBeat is one moment inside a run, exactly as gogobee filed it. Nouns and
|
||||
// numbers only: Pete writes the sentence, the same split every dispatch fact
|
||||
// already respects.
|
||||
type RunBeat struct {
|
||||
RunID string `json:"run_id"`
|
||||
Seq int64 `json:"seq"`
|
||||
Kind string `json:"kind"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
|
||||
Token string `json:"token,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Room int `json:"room,omitempty"`
|
||||
TotalRooms int `json:"total_rooms,omitempty"`
|
||||
RoomKind string `json:"room_kind,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Amount int `json:"amount,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
HP int `json:"hp,omitempty"`
|
||||
HPMax int `json:"hp_max,omitempty"`
|
||||
Crits int `json:"crits,omitempty"`
|
||||
Fumbles int `json:"fumbles,omitempty"`
|
||||
|
||||
// Prose is the single exception to "nouns and numbers only", and it is
|
||||
// deliberately confined to one beat kind ("summary"). gogobee's LLM reads the
|
||||
// finished run back and says what it was about; Pete guards that text at
|
||||
// ingest exactly as it guards a dispatch lede, then folds it onto the run
|
||||
// header. It is never rendered as a log line — see renderRunBeat.
|
||||
Prose string `json:"prose,omitempty"`
|
||||
}
|
||||
|
||||
// Run is the header: who walked, where, and how it ended (if it has).
|
||||
type Run struct {
|
||||
RunID string
|
||||
Token string
|
||||
Name string
|
||||
Level int
|
||||
Zone string
|
||||
TotalRooms int
|
||||
StartedAt int64
|
||||
UpdatedAt int64
|
||||
EndedAt int64 // 0 = still walking
|
||||
Outcome string
|
||||
Summary string // LLM run summary, empty until the summary beat lands (or forever)
|
||||
}
|
||||
|
||||
// Live reports whether this run is still in progress.
|
||||
func (r Run) Live() bool { return r.EndedAt == 0 }
|
||||
|
||||
// AppendRunBeats stores a batch and folds each beat into its run header, in one
|
||||
// transaction so a reader never sees a header that has moved ahead of its beats.
|
||||
//
|
||||
// INSERT OR IGNORE, not upsert: a beat is immutable. If gogobee re-sends
|
||||
// (run_id, seq) the stored copy wins, which makes a duplicated batch free and
|
||||
// makes a *changed* beat impossible — the second is a bug upstream, and quietly
|
||||
// rewriting history to match it would hide that.
|
||||
func AppendRunBeats(beats []RunBeat) error {
|
||||
if len(beats) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
bstmt, err := tx.Prepare(`
|
||||
INSERT OR IGNORE INTO adventure_run_beat
|
||||
(run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
|
||||
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer bstmt.Close()
|
||||
|
||||
// The header is created by whichever beat arrives first and enriched by any
|
||||
// later one that knows more. COALESCE(NULLIF(...)) is the whole trick: a beat
|
||||
// that doesn't carry a field leaves the stored value alone, so the `start`
|
||||
// beat's name and zone survive the forty beats after it that have neither.
|
||||
hstmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_run
|
||||
(run_id, token, name, level, zone, total_rooms, started_at, updated_at, ended_at, outcome, summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO UPDATE SET
|
||||
token = COALESCE(NULLIF(excluded.token, ''), adventure_run.token),
|
||||
name = COALESCE(NULLIF(excluded.name, ''), adventure_run.name),
|
||||
level = COALESCE(NULLIF(excluded.level, 0), adventure_run.level),
|
||||
zone = COALESCE(NULLIF(excluded.zone, ''), adventure_run.zone),
|
||||
total_rooms = COALESCE(NULLIF(excluded.total_rooms, 0), adventure_run.total_rooms),
|
||||
started_at = COALESCE(NULLIF(adventure_run.started_at, 0), excluded.started_at),
|
||||
updated_at = MAX(adventure_run.updated_at, excluded.updated_at),
|
||||
ended_at = COALESCE(NULLIF(adventure_run.ended_at, 0), excluded.ended_at),
|
||||
outcome = COALESCE(NULLIF(adventure_run.outcome, ''), excluded.outcome),
|
||||
summary = COALESCE(NULLIF(adventure_run.summary, ''), excluded.summary)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer hstmt.Close()
|
||||
|
||||
for _, b := range beats {
|
||||
if b.RunID == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := bstmt.Exec(b.RunID, b.Seq, b.Kind, b.OccurredAt, b.Room, b.TotalRooms,
|
||||
b.RoomKind, b.Target, b.Outcome, b.Amount, b.Count, b.HP, b.HPMax,
|
||||
b.Crits, b.Fumbles, b.Region, b.Prose); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A run ends once. First close wins here for the same reason it does on
|
||||
// the game side: the specific outcome ("died") is filed before the generic
|
||||
// one ("abandoned") that follows it down the same drain.
|
||||
var endedAt int64
|
||||
var outcome string
|
||||
if b.Kind == "end" {
|
||||
endedAt = b.OccurredAt
|
||||
outcome = b.Outcome
|
||||
}
|
||||
started := int64(0)
|
||||
if b.Kind == "start" {
|
||||
started = b.OccurredAt
|
||||
}
|
||||
// Only the summary beat may set the summary. Any other kind carrying prose
|
||||
// is upstream noise, and letting it through would put unguarded text on the
|
||||
// header — the guard at ingest only inspects the kind it knows about.
|
||||
summary := ""
|
||||
if b.Kind == "summary" {
|
||||
summary = b.Prose
|
||||
}
|
||||
if _, err := hstmt.Exec(b.RunID, b.Token, b.Name, b.Level, b.Zone,
|
||||
b.TotalRooms, started, b.OccurredAt, endedAt, outcome, summary); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// LatestRunForToken returns the run worth showing on one adventurer's page.
|
||||
//
|
||||
// A LIVE run always wins, and only then does recency decide. That ordering is
|
||||
// load-bearing at exactly one moment and it is a moment that happens on every
|
||||
// multi-region expedition: crossing a border closes one run and opens the next
|
||||
// in the same breath, so the outgoing run's `end` beat and the incoming run's
|
||||
// `start` beat carry the same second. Ordering on the clock alone would leave
|
||||
// the page showing the log of a region the party has already left, with a
|
||||
// "cleared" chip on it, while they walk on in the next one.
|
||||
//
|
||||
// Recency is updated_at rather than started_at for the same reason: the run
|
||||
// still moving is the one still being written to.
|
||||
func LatestRunForToken(token string) (Run, bool, error) {
|
||||
if token == "" {
|
||||
return Run{}, false, nil
|
||||
}
|
||||
var r Run
|
||||
err := Get().QueryRow(`
|
||||
SELECT run_id, token, name, level, zone, total_rooms,
|
||||
started_at, updated_at, ended_at, outcome, summary
|
||||
FROM adventure_run
|
||||
WHERE token = ?
|
||||
ORDER BY (ended_at = 0) DESC, updated_at DESC, started_at DESC
|
||||
LIMIT 1`, token).Scan(
|
||||
&r.RunID, &r.Token, &r.Name, &r.Level, &r.Zone, &r.TotalRooms,
|
||||
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome, &r.Summary)
|
||||
if err == sql.ErrNoRows {
|
||||
return Run{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Run{}, false, err
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
// RunByID returns one run header.
|
||||
func RunByID(runID string) (Run, bool, error) {
|
||||
var r Run
|
||||
err := Get().QueryRow(`
|
||||
SELECT run_id, token, name, level, zone, total_rooms,
|
||||
started_at, updated_at, ended_at, outcome, summary
|
||||
FROM adventure_run WHERE run_id = ?`, runID).Scan(
|
||||
&r.RunID, &r.Token, &r.Name, &r.Level, &r.Zone, &r.TotalRooms,
|
||||
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome, &r.Summary)
|
||||
if err == sql.ErrNoRows {
|
||||
return Run{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Run{}, false, err
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
// RunBeats returns a run's beats in the order they happened.
|
||||
//
|
||||
// limit caps from the END, not the start: a log is read for what just happened,
|
||||
// and a run deep into its third region would otherwise show its first forty
|
||||
// beats forever. The returned slice is still oldest-first.
|
||||
func RunBeats(runID string, limit int) ([]RunBeat, error) {
|
||||
if runID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
q := `SELECT run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
|
||||
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose
|
||||
FROM adventure_run_beat WHERE run_id = ? ORDER BY seq ASC`
|
||||
args := []any{runID}
|
||||
if limit > 0 {
|
||||
// Innermost query takes the tail, the wrapper puts it back in order.
|
||||
q = `SELECT * FROM (
|
||||
SELECT run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
|
||||
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose
|
||||
FROM adventure_run_beat WHERE run_id = ? ORDER BY seq DESC LIMIT ?
|
||||
) ORDER BY seq ASC`
|
||||
args = append(args, limit)
|
||||
}
|
||||
rows, err := Get().Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RunBeat
|
||||
for rows.Next() {
|
||||
var b RunBeat
|
||||
if err := rows.Scan(&b.RunID, &b.Seq, &b.Kind, &b.OccurredAt, &b.Room, &b.TotalRooms,
|
||||
&b.RoomKind, &b.Target, &b.Outcome, &b.Amount, &b.Count, &b.HP, &b.HPMax,
|
||||
&b.Crits, &b.Fumbles, &b.Region, &b.Prose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PruneRuns drops runs (and their beats) that ended before cutoff. Live runs are
|
||||
// never touched however old they look — a run that has been walking for a week
|
||||
// is a stuck expedition, and deleting its log is exactly the wrong response to
|
||||
// the one case where somebody wants to read it.
|
||||
func PruneRuns(cutoff int64) error {
|
||||
if _, err := Get().Exec(`
|
||||
DELETE FROM adventure_run_beat
|
||||
WHERE run_id IN (SELECT run_id FROM adventure_run WHERE ended_at > 0 AND ended_at < ?)`,
|
||||
cutoff); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := Get().Exec(`DELETE FROM adventure_run WHERE ended_at > 0 AND ended_at < ?`, cutoff)
|
||||
return err
|
||||
}
|
||||
+677
-6
@@ -21,6 +21,462 @@ CREATE TABLE IF NOT EXISTS stories (
|
||||
published_at INTEGER
|
||||
);
|
||||
|
||||
-- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live
|
||||
-- board and it replaces this table wholesale. Rows are state that is currently
|
||||
-- true ("Josie is in holymachina"), which is the one thing the story feed can
|
||||
-- never be — every dispatch there is an accomplishment, and an accomplishment is
|
||||
-- a clipping the moment it lands.
|
||||
--
|
||||
-- token is gogobee's per-player roster token, not a Matrix handle and not a
|
||||
-- story GUID. Players who ran "!news optout" are omitted from the snapshot
|
||||
-- upstream and so never appear here at all.
|
||||
CREATE TABLE IF NOT EXISTS adventure_roster (
|
||||
token TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
class_race TEXT,
|
||||
status TEXT NOT NULL, -- "expedition" | "idle"
|
||||
zone TEXT,
|
||||
region TEXT,
|
||||
day INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle
|
||||
idle_hours INTEGER NOT NULL DEFAULT 0, -- hours since last player action
|
||||
snapshot_at INTEGER NOT NULL -- when gogobee took the snapshot
|
||||
);
|
||||
|
||||
-- The snapshot time lives outside the rows because an *empty* board is
|
||||
-- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A
|
||||
-- MAX(snapshot_at) over zero rows can't tell those apart, and the page must —
|
||||
-- one is "quiet realm", the other is "the wire is down, trust nothing here".
|
||||
CREATE TABLE IF NOT EXISTS adventure_roster_meta (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
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
|
||||
-- The expedition this dispatch is the ending of, when it is the ending of one
|
||||
-- (a clear, a retreat, a death). It is the join from "how it went" to "what
|
||||
-- happened", and it is the reason Pete keeps a finished run's beats for two
|
||||
-- weeks while only *showing* them for six hours: the dispatch outlives the run
|
||||
-- it announced, and a story that can't reach its own log is the whole point
|
||||
-- of the log going missing.
|
||||
run_id TEXT,
|
||||
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);
|
||||
|
||||
-- The Siege. Three tables, all fed by one gogobee push and all replaced whole,
|
||||
-- because the Siege is state, not history — the same contract as the roster.
|
||||
--
|
||||
-- The split is by lifetime, not by convenience. adventure_siege is the single
|
||||
-- live boss (a CHECK-pinned one-row table, like adventure_roster_meta, so "no
|
||||
-- Siege camped" is a row saying active=0 rather than an ambiguous empty table).
|
||||
-- adventure_siege_defenders is the muster for that one boss and dies with it.
|
||||
-- adventure_siege_history outlives both, and is the reason the current Siege
|
||||
-- feels like it counts: a health bar with nothing behind it is a progress bar.
|
||||
CREATE TABLE IF NOT EXISTS adventure_siege (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
active INTEGER NOT NULL DEFAULT 0,
|
||||
boss_id INTEGER NOT NULL DEFAULT 0,
|
||||
boss_name TEXT NOT NULL DEFAULT '',
|
||||
tier INTEGER NOT NULL DEFAULT 0,
|
||||
hp_current INTEGER NOT NULL DEFAULT 0,
|
||||
hp_max INTEGER NOT NULL DEFAULT 0,
|
||||
starts_at INTEGER NOT NULL DEFAULT 0,
|
||||
ends_at INTEGER NOT NULL DEFAULT 0,
|
||||
bouts_today INTEGER NOT NULL DEFAULT 0,
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- pos is the push order, which is gogobee's ranking (damage desc). Kept as the
|
||||
-- key rather than the token because an opted-out defender carries NO token — the
|
||||
-- board shows their rank and their damage as "an adventurer" and offers no link,
|
||||
-- so several rows can legitimately be tokenless and they must not collide.
|
||||
CREATE TABLE IF NOT EXISTS adventure_siege_defenders (
|
||||
pos INTEGER PRIMARY KEY,
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
fights INTEGER NOT NULL DEFAULT 0,
|
||||
damage INTEGER NOT NULL DEFAULT 0,
|
||||
fought_today INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Open question, never confirmed with gogobee: whether boss_id identifies the
|
||||
-- siege instance or the boss TYPE. SiegeBarForBoss matches history on boss_name
|
||||
-- plus the nearest ended_at and its comment says "the same boss comes back month
|
||||
-- after month", which reads like a type — in which case this key collides on the
|
||||
-- second visit. ReplaceSiege inserts OR REPLACE so a collision costs one history
|
||||
-- row instead of the whole push; settle the meaning before relying on the key.
|
||||
CREATE TABLE IF NOT EXISTS adventure_siege_history (
|
||||
boss_id INTEGER PRIMARY KEY,
|
||||
boss_name TEXT NOT NULL,
|
||||
tier INTEGER NOT NULL DEFAULT 0,
|
||||
outcome TEXT NOT NULL, -- "defeated" | "survived"
|
||||
hp_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
hp_max INTEGER NOT NULL DEFAULT 0,
|
||||
defenders INTEGER NOT NULL DEFAULT 0,
|
||||
mvp TEXT NOT NULL DEFAULT '',
|
||||
mvp_fights INTEGER NOT NULL DEFAULT 0,
|
||||
ended_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- The expedition liveblog. Two tables, and — unlike everything else gogobee
|
||||
-- pushes — these are append-only history, not a replaceable snapshot. A beat is
|
||||
-- something that HAPPENED; there is no later truth that corrects it, only more
|
||||
-- of it.
|
||||
--
|
||||
-- adventure_run is the header, assembled from the run's beats rather than pushed
|
||||
-- as its own object: the "start" beat opens it and the "end" beat closes it.
|
||||
-- That means a run whose start beat never arrived still gets a row (created by
|
||||
-- whatever beat did arrive) and is simply unattributed — the log survives
|
||||
-- nameless instead of being dropped for want of a name.
|
||||
--
|
||||
-- summary is the one piece of PROSE anywhere in the liveblog. Every log line is
|
||||
-- assembled by Pete out of a beat's own nouns and numbers; this is gogobee's LLM
|
||||
-- reading the finished run back and saying what it was *about*, which is a
|
||||
-- judgement no template can make. It arrives late — its own beat, a tick or two
|
||||
-- after the run ends — and it is optional forever: with the model off, the report
|
||||
-- is the log plus the numbers, which is still the report.
|
||||
CREATE TABLE IF NOT EXISTS adventure_run (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL DEFAULT '', -- public board token; '' = unattributed
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
zone TEXT NOT NULL DEFAULT '',
|
||||
total_rooms INTEGER NOT NULL DEFAULT 0,
|
||||
started_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||
ended_at INTEGER NOT NULL DEFAULT 0, -- 0 = still walking
|
||||
outcome TEXT NOT NULL DEFAULT '', -- cleared|died|retreated|abandoned
|
||||
summary TEXT NOT NULL DEFAULT '' -- LLM run summary, post prose-guard
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_run_token ON adventure_run(token, started_at DESC);
|
||||
|
||||
-- (run_id, seq) is the identity, so a re-sent batch collapses on the primary key
|
||||
-- and needs no content comparison. seq is gogobee's monotonic counter, which is
|
||||
-- also the render order — beats can arrive out of order across two batches and
|
||||
-- still read correctly.
|
||||
CREATE TABLE IF NOT EXISTS adventure_run_beat (
|
||||
run_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
occurred_at INTEGER NOT NULL DEFAULT 0,
|
||||
room INTEGER NOT NULL DEFAULT 0,
|
||||
total_rooms INTEGER NOT NULL DEFAULT 0,
|
||||
room_kind TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
amount INTEGER NOT NULL DEFAULT 0,
|
||||
qty INTEGER NOT NULL DEFAULT 0,
|
||||
hp INTEGER NOT NULL DEFAULT 0,
|
||||
hp_max INTEGER NOT NULL DEFAULT 0,
|
||||
crits INTEGER NOT NULL DEFAULT 0,
|
||||
fumbles INTEGER NOT NULL DEFAULT 0,
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
-- prose is carried by exactly one beat kind ("summary") and is never rendered
|
||||
-- as a log line. It lives on the beat rather than on its own endpoint so the
|
||||
-- summary inherits the whole channel: idempotent on (run_id, seq), retried
|
||||
-- until delivered, and impossible to attach to a run that doesn't exist.
|
||||
prose TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (run_id, seq)
|
||||
);
|
||||
|
||||
-- The realm: the world map, the hall of firsts, and the board. Four tables from
|
||||
-- one gogobee push, all replaced whole — the same contract as the roster and the
|
||||
-- Siege, and for the same reason. Every row here is a *derived* answer (how many
|
||||
-- clears, who was first, who is inside right now) recomputed on the game box from
|
||||
-- its own run history. Pete keeping a stale one and merging into it would let a
|
||||
-- correction upstream leave a wrong number here permanently.
|
||||
--
|
||||
-- Unlike the Siege there is no live/history lifetime split, because none of this
|
||||
-- has a lifetime: a zone does not end. What varies is only how often it changes,
|
||||
-- and that is handled on the gogobee side by pushing every ten minutes instead of
|
||||
-- every two.
|
||||
--
|
||||
-- pos is the push order throughout, kept as the key for the same reason the siege
|
||||
-- muster does: an opted-out player carries NO token, so several rows can
|
||||
-- legitimately be tokenless and must not collide on one.
|
||||
CREATE TABLE IF NOT EXISTS adventure_realm_zone (
|
||||
pos INTEGER PRIMARY KEY, -- gogobee's design-doc zone order
|
||||
zone_id TEXT NOT NULL,
|
||||
display TEXT NOT NULL,
|
||||
tier INTEGER NOT NULL DEFAULT 0,
|
||||
level_min INTEGER NOT NULL DEFAULT 0,
|
||||
level_max INTEGER NOT NULL DEFAULT 0,
|
||||
faction TEXT NOT NULL DEFAULT '',
|
||||
atmosphere TEXT NOT NULL DEFAULT '',
|
||||
postgame INTEGER NOT NULL DEFAULT 0,
|
||||
first_by TEXT NOT NULL DEFAULT '',
|
||||
first_token TEXT NOT NULL DEFAULT '',
|
||||
first_at INTEGER NOT NULL DEFAULT 0,
|
||||
clears INTEGER NOT NULL DEFAULT 0,
|
||||
clearers INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Who is standing in a zone right now. Its own table rather than a JSON blob on
|
||||
-- the zone row so the map can be drawn with one join and "there are people in
|
||||
-- there" is a count, not a parse.
|
||||
CREATE TABLE IF NOT EXISTS adventure_realm_occupant (
|
||||
pos INTEGER PRIMARY KEY,
|
||||
zone_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
day INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_realm_occ_zone ON adventure_realm_occupant(zone_id);
|
||||
|
||||
-- The hall of firsts: every thing that has happened in the realm exactly once.
|
||||
-- holder is empty when the game can no longer say who did it (a treasure found
|
||||
-- and later discarded leaves no owner anywhere) — an unattributed first is still
|
||||
-- a first and is rendered as one.
|
||||
CREATE TABLE IF NOT EXISTS adventure_realm_first (
|
||||
pos INTEGER PRIMARY KEY, -- gogobee's order: oldest first
|
||||
kind TEXT NOT NULL, -- "zone" | "treasure" | whatever comes next
|
||||
target TEXT NOT NULL,
|
||||
display TEXT NOT NULL,
|
||||
tier INTEGER NOT NULL DEFAULT 0,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
at_unix INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- The board. pos IS the rank, and the ranking is gogobee's — the ordering is a
|
||||
-- statement about what the game values, and the game gets to make it.
|
||||
CREATE TABLE IF NOT EXISTS adventure_realm_standing (
|
||||
pos INTEGER PRIMARY KEY,
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
class_race TEXT NOT NULL DEFAULT '',
|
||||
deepest_tier INTEGER NOT NULL DEFAULT 0,
|
||||
clears INTEGER NOT NULL DEFAULT 0,
|
||||
zones INTEGER NOT NULL DEFAULT 0,
|
||||
firsts INTEGER NOT NULL DEFAULT 0,
|
||||
siege_damage INTEGER NOT NULL DEFAULT 0,
|
||||
siege_fights INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- One row, like adventure_siege: when the realm last arrived. Its own table
|
||||
-- because "gogobee has never pushed a realm" and "gogobee pushed a realm that is
|
||||
-- empty" are different states, and the page says different things about them.
|
||||
CREATE TABLE IF NOT EXISTS adventure_realm_meta (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- 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),
|
||||
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
||||
-- ever read for the one authenticated user asking about themselves, so the board
|
||||
-- stays anonymous and no endpoint hands out anyone else's number. Advisory only —
|
||||
-- the storefront greys out tiers it thinks you can't afford, but the real debit
|
||||
-- happens on gogobee at claim time and a stale balance just bounces an order.
|
||||
CREATE TABLE IF NOT EXISTS user_euro (
|
||||
username TEXT PRIMARY KEY, -- Matrix localpart == session Username
|
||||
euro REAL NOT NULL DEFAULT 0,
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- A mischief contract a buyer placed from the web storefront, on its way to
|
||||
-- gogobee. Pete never touches money and never runs the game rules — it only
|
||||
-- records the *intent* and later the verdict gogobee hands back. The status
|
||||
-- ladder:
|
||||
--
|
||||
-- pending -> placed (gogobee debited the buyer and opened a contract)
|
||||
-- -> bounced_funds (buyer couldn't actually afford it)
|
||||
-- -> bounced_ineligible (target no longer a valid mark: no expedition,
|
||||
-- a live contract already, cooldown, cap, ...)
|
||||
--
|
||||
-- guid is the idempotency key end to end: gogobee passes it to DebitIdem and
|
||||
-- stamps it on the contract, so a claim whose ack is lost on the wire can be
|
||||
-- retried without charging the buyer twice or opening two contracts. buyer_sub
|
||||
-- is the OIDC subject (stable across username changes) and keys "my orders";
|
||||
-- buyer_username is what gogobee turns into @username:server. target_token is
|
||||
-- the roster token of the mark — the same anonymous token the board renders, so
|
||||
-- ordering a hit never needs the victim's real handle.
|
||||
CREATE TABLE IF NOT EXISTS mischief_orders (
|
||||
guid TEXT PRIMARY KEY,
|
||||
buyer_sub TEXT NOT NULL,
|
||||
buyer_username TEXT NOT NULL,
|
||||
target_token TEXT NOT NULL,
|
||||
target_name TEXT NOT NULL, -- display copy, frozen at order time
|
||||
tier TEXT NOT NULL,
|
||||
signed INTEGER NOT NULL DEFAULT 0, -- 1 = sign openly (+25%), 0 = anonymous
|
||||
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_mischief_orders_pending ON mischief_orders(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_mischief_orders_buyer ON mischief_orders(buyer_sub, created_at DESC);
|
||||
|
||||
-- The storefront price list. gogobee is the sole authority on prices and pushes
|
||||
-- the whole catalog on the roster tick, so a fee retune reaches the storefront
|
||||
-- within a snapshot and Pete never hardcodes a number that can drift. ordinal
|
||||
-- preserves the grunt->boss order the push arrived in.
|
||||
CREATE TABLE IF NOT EXISTS mischief_tiers (
|
||||
key TEXT PRIMARY KEY,
|
||||
display TEXT NOT NULL,
|
||||
fee INTEGER NOT NULL,
|
||||
signed_fee INTEGER NOT NULL,
|
||||
blurb TEXT,
|
||||
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);
|
||||
|
||||
-- An action an owner asked for from the web — pull out of a run, take today's
|
||||
-- swing at the Siege — on its way to gogobee. Same reverse-pipe shape as
|
||||
-- equip_orders and the same guid-as-idempotency-key contract, but a SEPARATE
|
||||
-- table on purpose: every column of equip_orders is equip vocabulary (item, slot,
|
||||
-- tier), and these verbs act on the character rather than on something it is
|
||||
-- carrying. Sharing the table would have meant rows where most columns are
|
||||
-- meaningless and an action set nobody could read.
|
||||
--
|
||||
-- The status ladder:
|
||||
--
|
||||
-- pending -> applied (it happened; detail says what)
|
||||
-- -> rejected_not_running (extract/abandon/leave: no expedition)
|
||||
-- -> rejected_not_leader (extract/abandon: a member can't call it)
|
||||
-- -> rejected_is_leader (leave: the leader's row IS the expedition)
|
||||
-- -> rejected_no_siege (siege_join: nothing camped outside town)
|
||||
-- -> rejected_already_fought (siege_join: today's bout is already spent)
|
||||
-- -> rejected_busy (already out, seated, or has a sitter)
|
||||
-- -> rejected_insufficient_funds (could not cover the cost)
|
||||
-- -> rejected_zone_locked (expedition_start: not open at this level)
|
||||
-- -> rejected_nothing_to_resume (nothing extracted, or the window closed)
|
||||
-- -> rejected_nothing_to_cancel (babysit_cancel: no sitter is engaged)
|
||||
-- -> rejected_unavailable (no character, dead, or an unsold argument)
|
||||
--
|
||||
-- Like the equip queue, the underlying game action is NOT idempotent — an extract
|
||||
-- ends an expedition and a bout spends a day — so gogobee short-circuits on the
|
||||
-- guid before it mutates anything. token is the roster token the order was placed
|
||||
-- from; gogobee ignores it (the localpart names the character) but it is what
|
||||
-- Pete proved ownership against, and it keeps the row self-describing.
|
||||
CREATE TABLE IF NOT EXISTS adventure_orders (
|
||||
guid TEXT PRIMARY KEY,
|
||||
owner_sub TEXT NOT NULL,
|
||||
owner_localpart TEXT NOT NULL,
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
character_name TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL, -- see the AdvAction* set
|
||||
status TEXT NOT NULL, -- see the ladder above
|
||||
detail TEXT, -- gogobee's human note on the verdict
|
||||
params TEXT NOT NULL DEFAULT '', -- the verb's arguments as JSON; '' for the verbs that take none
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adventure_orders_pending ON adventure_orders(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_adventure_orders_owner ON adventure_orders(owner_sub, created_at DESC);
|
||||
|
||||
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
||||
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
||||
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
||||
-- like user_euro, it is only ever served back to the one authenticated user it
|
||||
-- belongs to, never on the public board. token is that player's current roster
|
||||
-- token, kept here so the detail page can prove owner↔page by a join without
|
||||
-- ever reversing the one-way token — the association lives only in this
|
||||
-- owner-private table and never reaches any public response. detail_json is the
|
||||
-- {inventory, vault, house, pets} body; it is replaced wholesale each tick, so a
|
||||
-- player who drops out of gogobee's push loses their stale self-view.
|
||||
CREATE TABLE IF NOT EXISTS player_self_detail (
|
||||
localpart TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
detail_json TEXT NOT NULL,
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
||||
|
||||
-- Per-user visit clock for the adventure section's "while you were away" panel,
|
||||
-- keyed by OIDC subject like every other per-user table.
|
||||
--
|
||||
-- TWO stamps, and the second one is the whole trick. window_from is where the
|
||||
-- panel reads from; last_seen_at is a heartbeat written on every page load. One
|
||||
-- column would make the panel a one-shot: it would show what happened, move the
|
||||
-- stamp to now, and a refresh five seconds later would render an empty box over
|
||||
-- the same news. So window_from advances only when a genuinely new visit begins
|
||||
-- (see AdvVisitWindow), which keeps the panel stable for as long as somebody is
|
||||
-- actually reading it.
|
||||
CREATE TABLE IF NOT EXISTS adventure_visit (
|
||||
user_sub TEXT PRIMARY KEY,
|
||||
window_from INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
@@ -97,13 +553,26 @@ CREATE TABLE IF NOT EXISTS source_health (
|
||||
-- server needs them to encrypt each push. last_notified_at is the per-endpoint
|
||||
-- digest watermark: the sender only counts stories seen after it. A user can
|
||||
-- have several endpoints (phone, desktop) — each is notified independently.
|
||||
--
|
||||
-- user_localpart is the same identity one level down: user_sub is the OIDC
|
||||
-- subject, but every adventure ownership join in this schema is keyed on the
|
||||
-- Matrix localpart (see player_self_detail), and nothing else persists that
|
||||
-- mapping outside a live session. It is captured at subscribe time so the
|
||||
-- adventure alert sender — which runs on a ticker with no request to read a
|
||||
-- session from — can answer "whose adventurer is this" at all.
|
||||
--
|
||||
-- last_adv_notified_at is the adventure alerts' own watermark, kept apart from
|
||||
-- the digest's on purpose: the two senders run on different clocks and one
|
||||
-- column would let each silently consume the other's backlog.
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
user_sub TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_notified_at INTEGER NOT NULL
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
user_sub TEXT NOT NULL,
|
||||
user_localpart TEXT NOT NULL DEFAULT '',
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_notified_at INTEGER NOT NULL,
|
||||
last_adv_notified_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Privacy-preserving daily unique estimate. visitor is a salted hash of
|
||||
@@ -128,6 +597,208 @@ CREATE TABLE IF NOT EXISTS story_views (
|
||||
PRIMARY KEY (story_id, day)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- games.parodia.dev
|
||||
--
|
||||
-- The invariant the whole casino rests on: a euro is either in gogobee's
|
||||
-- euro_balances or in Pete's chip escrow, never both. It crosses between them
|
||||
-- only via a GUID-idempotent claim, and Pete never writes a euro balance —
|
||||
-- gogobee does, when it claims the escrow row and tells us how it went.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- A player's chips: euros that have crossed into the casino and haven't crossed
|
||||
-- back yet. 1:1 with euros. Keyed by Matrix user id, because that's the identity
|
||||
-- gogobee's ledger uses and the one an Authentik username maps onto.
|
||||
CREATE TABLE IF NOT EXISTS game_chips (
|
||||
matrix_user TEXT PRIMARY KEY,
|
||||
chips INTEGER NOT NULL DEFAULT 0,
|
||||
-- Advisory only, and stale by design: the last euro balance gogobee told us
|
||||
-- about. Displayed, never trusted. The authoritative check is the debit at
|
||||
-- claim time, which happens on gogobee's box against gogobee's ledger.
|
||||
euro_balance REAL,
|
||||
last_played INTEGER NOT NULL DEFAULT 0, -- unix; the reaper reads this
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- One crossing of the euro/chip border, in either direction.
|
||||
--
|
||||
-- requested -> claimed -> funded (buy-in: gogobee debited, chips spendable)
|
||||
-- -> rejected (buy-in: insufficient funds, no chips)
|
||||
-- requested -> claimed -> settled (cash-out: chips gone, euros credited)
|
||||
--
|
||||
-- The guid is the idempotency key end to end: it's what gogobee passes to
|
||||
-- DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be retried
|
||||
-- without the player paying twice.
|
||||
CREATE TABLE IF NOT EXISTS game_escrow (
|
||||
guid TEXT PRIMARY KEY,
|
||||
matrix_user TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- 'buyin' | 'cashout'
|
||||
amount INTEGER NOT NULL, -- euros == chips
|
||||
state TEXT NOT NULL, -- see the ladder above
|
||||
reason TEXT, -- 'insufficient_funds', when rejected
|
||||
balance_after REAL, -- gogobee's euro balance after the move
|
||||
created_at INTEGER NOT NULL,
|
||||
claimed_at INTEGER, -- when gogobee took it; drives the re-poll
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_escrow_state ON game_escrow(state, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_escrow_user ON game_escrow(matrix_user, created_at DESC);
|
||||
|
||||
-- Every hand played, for money. This is the audit trail: seeds so a disputed
|
||||
-- hand can be re-dealt exactly as it fell, rake so the house's take is
|
||||
-- accountable, and enough shape to answer "how fast is this economy actually
|
||||
-- moving" before the answer becomes a problem.
|
||||
CREATE TABLE IF NOT EXISTS game_hands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
matrix_user TEXT NOT NULL,
|
||||
game TEXT NOT NULL, -- 'blackjack'
|
||||
bet INTEGER NOT NULL,
|
||||
payout INTEGER NOT NULL, -- chips returned, net of rake
|
||||
rake INTEGER NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
seed1 INTEGER NOT NULL, -- the shoe, reproducible
|
||||
seed2 INTEGER NOT NULL,
|
||||
played_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_hands_user ON game_hands(matrix_user, played_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_hands_played ON game_hands(played_at);
|
||||
|
||||
-- The hand a player is in the middle of. One per player: you cannot be dealt a
|
||||
-- second hand while chips are riding on the first.
|
||||
--
|
||||
-- The state column is the engine's State, serialized whole — shoe included. It
|
||||
-- lives here rather than in memory because Pete redeploys often, and a player
|
||||
-- whose stake has already been taken must find their cards where they left them
|
||||
-- rather than a table that has forgotten them. It is also why the deck never
|
||||
-- goes to the browser: the authoritative shoe is this row, on the server.
|
||||
CREATE TABLE IF NOT EXISTS game_live_hands (
|
||||
matrix_user TEXT PRIMARY KEY,
|
||||
game TEXT NOT NULL, -- 'blackjack'
|
||||
state TEXT NOT NULL, -- JSON: the engine's State
|
||||
seed1 INTEGER NOT NULL, -- carried to the audit log when it settles
|
||||
seed2 INTEGER NOT NULL,
|
||||
-- Set when the player is sitting at a shared table rather than playing alone.
|
||||
-- The engine state then lives in game_tables.state, not here, and this row is
|
||||
-- purely the occupancy claim: its PRIMARY KEY is what stops one player being
|
||||
-- in two games at once, and it is the row the cash-out check reads. Making
|
||||
-- game_seats a second uniqueness domain instead would be a split brain — see
|
||||
-- the comment on game_seats.
|
||||
table_id TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Shared tables: the casino with more than one person at it.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- A table other people can sit at. The state column is the engine's State,
|
||||
-- exactly as game_live_hands holds it for a solo game — one blob for the whole
|
||||
-- felt, because a pot is not divisible into per-player rows.
|
||||
--
|
||||
-- version is the concurrency authority, and the mutex in the web layer is only
|
||||
-- an optimisation on top of it. Every state write is a conditional UPDATE
|
||||
-- against the version the writer read; zero rows affected means somebody moved
|
||||
-- first. This has to live in the database rather than in a mutex map because a
|
||||
-- mutex does not survive a redeploy — during a drain, two processes hold two
|
||||
-- different mutexes over the same row and both believe they are alone.
|
||||
CREATE TABLE IF NOT EXISTS game_tables (
|
||||
id TEXT PRIMARY KEY,
|
||||
game TEXT NOT NULL, -- 'holdem' | 'uno' | 'blackjack'
|
||||
tier TEXT NOT NULL, -- the stake, as that game names it
|
||||
state TEXT NOT NULL, -- JSON: the engine's State
|
||||
seed1 INTEGER NOT NULL,
|
||||
seed2 INTEGER NOT NULL,
|
||||
phase TEXT NOT NULL, -- the engine's phase, lifted out so the lobby can read it
|
||||
hand_no INTEGER NOT NULL DEFAULT 0, -- with id, the identity of one hand: the payout key
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
-- Unix seconds by which the seat to act must act, or 0 for no clock. The turn
|
||||
-- clock scans this. It is set only when the turn lands on a human: bots resolve
|
||||
-- inside ApplyMove and are never waited for.
|
||||
deadline INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_tables_due ON game_tables(deadline) WHERE deadline > 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_game_tables_lobby ON game_tables(game, updated_at DESC);
|
||||
|
||||
-- Who is sitting where. A seat with no matrix_user is a bot.
|
||||
--
|
||||
-- This is deliberately *not* a uniqueness domain for players: there is no unique
|
||||
-- index on matrix_user, and there must not be one. Occupancy is decided by
|
||||
-- game_live_hands' primary key, which already stops a player being in two games,
|
||||
-- already makes a double-clicked join a 409, and is already what the cash-out
|
||||
-- check reads. A second domain that could disagree with the first would silently
|
||||
-- switch all three off — the worst of them being a player who cashes out to zero
|
||||
-- while sitting at a poker table with chips in the pot.
|
||||
--
|
||||
-- staked is what the player brought to the table and has not yet taken home. It
|
||||
-- is the chip-conservation anchor: the chips are off their game_chips stack and
|
||||
-- inside the table blob, where the idle reaper cannot see them.
|
||||
CREATE TABLE IF NOT EXISTS game_seats (
|
||||
table_id TEXT NOT NULL,
|
||||
seat INTEGER NOT NULL,
|
||||
matrix_user TEXT, -- NULL for a bot
|
||||
name TEXT NOT NULL,
|
||||
staked INTEGER NOT NULL DEFAULT 0,
|
||||
-- Set once a human's clock has run out on them. An absent human is not a bot,
|
||||
-- but the bot loop has to be allowed past their seat or a table with three
|
||||
-- ghosts spends a minute an orbit folding air. They come back the moment they act.
|
||||
away INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (table_id, seat)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_seats_user ON game_seats(matrix_user) WHERE matrix_user IS NOT NULL;
|
||||
|
||||
-- There is no payout ledger here, and its absence is deliberate — the design
|
||||
-- called for one and the money model made it unnecessary. Chips cross into a
|
||||
-- table when a player sits down and back out when they get up; a hand ending
|
||||
-- moves the pot *within* the state blob and credits nobody's game_chips row. So
|
||||
-- there is no money write to make idempotent: a settle is a state write,
|
||||
-- conditional on the version, and a replayed one affects zero rows and rolls
|
||||
-- back. See the header of internal/storage/tables.go.
|
||||
|
||||
-- Chat on the felt. Messages only — no typing indicators, which is the one thing
|
||||
-- that would have justified a socket. It does not mirror into Matrix.
|
||||
--
|
||||
-- hand_no is kept against every line for a reason: at a table of real people,
|
||||
-- collusion looks like chat, and the only way to ever answer that question is to
|
||||
-- be able to read what was said during the hand it was said in.
|
||||
CREATE TABLE IF NOT EXISTS game_chat (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_id TEXT NOT NULL,
|
||||
hand_no INTEGER NOT NULL,
|
||||
matrix_user TEXT, -- NULL when the house is talking
|
||||
name TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
said_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_chat_table ON game_chat(table_id, id);
|
||||
|
||||
-- The trivia bank: questions pulled from the Open Trivia Database ahead of time,
|
||||
-- so that asking one is a local read.
|
||||
--
|
||||
-- Prefetched rather than fetched per question because a trivia ladder asks a
|
||||
-- question every fifteen seconds with money on a clock the player is scored
|
||||
-- against. A live fetch would put somebody else's latency and rate limit inside
|
||||
-- that clock. The refill is a slow background drip (internal/opentdb); a round
|
||||
-- never waits on it.
|
||||
--
|
||||
-- The question text is UNIQUE, which is the whole dedup strategy: OpenTDB hands back
|
||||
-- overlapping batches and the bank would otherwise fill up with the same forty
|
||||
-- questions. correct/incorrect are stored as the API gives them; the *shuffle*
|
||||
-- happens in the engine, per game, against that game's seed — so where the right
|
||||
-- answer sits in this table tells a player nothing.
|
||||
CREATE TABLE IF NOT EXISTS trivia_questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
difficulty TEXT NOT NULL, -- 'easy' | 'medium' | 'hard'
|
||||
category TEXT NOT NULL,
|
||||
question TEXT NOT NULL UNIQUE,
|
||||
correct TEXT NOT NULL,
|
||||
incorrect TEXT NOT NULL, -- JSON array of the three wrong answers
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trivia_difficulty ON trivia_questions(difficulty);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The settle used to be four autocommit statements in the web layer — save the
|
||||
// state, award the payout, write the audit row, clear the felt — sequenced so
|
||||
// that a crash between any two of them cost the player as little as possible.
|
||||
//
|
||||
// These are the tests for the thing that replaced it. They are less about the
|
||||
// happy path (the game tests already cover that: a hand pays what it says it
|
||||
// pays) and more about the two properties the old shape did not have, and which
|
||||
// a shared table with a pot in it cannot do without.
|
||||
|
||||
func liveBlob(game string) LiveHand {
|
||||
return LiveHand{Game: game, State: []byte(`{"phase":"player"}`), Seed1: 7, Seed2: 9}
|
||||
}
|
||||
|
||||
// A settled hand moves the money, writes the audit row, and leaves the felt
|
||||
// empty — and it does all three or none of them.
|
||||
//
|
||||
// The old code logged and carried on if the clear failed. That is the double-pay:
|
||||
// a settled hand still sitting in game_live_hands is a hand that settles again on
|
||||
// the next request, and pays again with it.
|
||||
func TestSettleMovesTheMoneyAndTheFeltTogether(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
if err := Stake(player, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CommitHand(player, Commit{
|
||||
Live: liveBlob("blackjack"), Fresh: true, Stake: 200,
|
||||
Done: true, Payout: 390,
|
||||
Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 390, Rake: 10, Outcome: "won"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Paid: 1000 - 200 staked + 390 back.
|
||||
if c := chipsOf(t, player); c != 1190 {
|
||||
t.Fatalf("chips = %d after a 390 payout on a 200 stake, want 1190", c)
|
||||
}
|
||||
// The felt is empty, which is what stops it settling a second time.
|
||||
if _, err := LoadLiveHand(player); !errors.Is(err, ErrNoLiveHand) {
|
||||
t.Fatalf("live hand after settle: err = %v, want ErrNoLiveHand", err)
|
||||
}
|
||||
// And the house took its cut, once.
|
||||
take, err := HouseTake(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if take != 10 {
|
||||
t.Fatalf("house take = %d, want 10", take)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat that is already taken refuses the game *and hands the stake back*, in
|
||||
// the same transaction that refused it.
|
||||
//
|
||||
// This was two statements: the save came back ErrHandInProgress, and then a
|
||||
// separate Award put the chips back. A crash in between took a player's stake for
|
||||
// a game that never existed anywhere — no felt, no audit row, no way to find it.
|
||||
func TestARefusedSeatGivesTheStakeBackInTheSameBreath(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
// A game is already in progress.
|
||||
if err := Stake(player, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CommitHand(player, Commit{Live: liveBlob("uno"), Fresh: true, Stake: 200}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 800 {
|
||||
t.Fatalf("chips = %d with 200 staked on a live game, want 800", c)
|
||||
}
|
||||
|
||||
// A second game is dealt on top of it: the stake leaves, as it must, in the
|
||||
// same statement that checks it's there.
|
||||
if err := Stake(player, 300); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := CommitHand(player, Commit{
|
||||
Live: liveBlob("blackjack"), Fresh: true, Stake: 300,
|
||||
Done: true, Payout: 600, // a natural, which would settle the instant it's dealt
|
||||
Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 300, Payout: 600},
|
||||
})
|
||||
if !errors.Is(err, ErrHandInProgress) {
|
||||
t.Fatalf("err = %v, want ErrHandInProgress", err)
|
||||
}
|
||||
|
||||
// The 300 is back, and the natural was *not* paid — a game that was never
|
||||
// seated must not settle.
|
||||
if c := chipsOf(t, player); c != 800 {
|
||||
t.Fatalf("chips = %d after a refused deal, want 800 (the 300 refunded, the 600 never paid)", c)
|
||||
}
|
||||
// And the game already in progress is untouched. This is the bit that matters:
|
||||
// a natural dealt on top of a live game used to be able to settle, clear the
|
||||
// felt, and take the other game's stake down with it.
|
||||
live, err := LoadLiveHand(player)
|
||||
if err != nil {
|
||||
t.Fatalf("the live game is gone: %v", err)
|
||||
}
|
||||
if live.Game != "uno" {
|
||||
t.Fatalf("live game = %q, want the uno game still sitting there", live.Game)
|
||||
}
|
||||
// Nothing was recorded, because nothing finished.
|
||||
if take, err := HouseTake(0); err != nil || take != 0 {
|
||||
t.Fatalf("house take = %d (err %v), want 0 — no hand finished", take, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The deadlock canary.
|
||||
//
|
||||
// SQLite runs at MaxOpenConns(1), so the connection is a global mutex. A bare
|
||||
// Get().Exec inside an open transaction waits for the one connection that the
|
||||
// transaction is holding, and waits forever — it is not an error, it is a hung
|
||||
// process, and because the news app shares the pool it hangs that too.
|
||||
//
|
||||
// So: if anybody ever reaches for Award or RecordHand (rather than award/
|
||||
// recordHand) from inside CommitHand, this test stops returning. It does not
|
||||
// fail with a nice message; it hangs, and the timeout is the message. That is
|
||||
// exactly the failure mode in production, which is the point of catching it here.
|
||||
func TestTheSettleDoesNotDeadlockAgainstItsOwnConnection(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- CommitHand(player, Commit{
|
||||
Live: liveBlob("hangman"), Fresh: true, Stake: 100,
|
||||
Done: true, Payout: 234,
|
||||
Audit: Hand{MatrixUser: player, Game: "hangman", Bet: 100, Payout: 234, Rake: 12, Outcome: "won"},
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("CommitHand did not return: something inside the transaction is waiting " +
|
||||
"for the connection the transaction is holding. Look for a Get().Exec that " +
|
||||
"should be a tx.Exec.")
|
||||
}
|
||||
|
||||
if c := chipsOf(t, player); c != 1134 {
|
||||
t.Fatalf("chips = %d, want 1134", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Being paid moves the idle clock, so the reaper leaves a player who is
|
||||
// mid-session alone. Touch used to be a separate statement after the settle; it
|
||||
// is inside it now, and this is what would notice if it got dropped on the way.
|
||||
func TestSettlingKeepsTheReaperAway(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
// Backdate the session well past the reaper's patience.
|
||||
if _, err := Get().Exec(
|
||||
`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`,
|
||||
nowUnix()-int64((2 * SessionIdleAfter).Seconds()), player,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CommitHand(player, Commit{
|
||||
Live: liveBlob("trivia"), Fresh: true, Stake: 100,
|
||||
Done: true, Payout: 150,
|
||||
Audit: Hand{MatrixUser: player, Game: "trivia", Bet: 100, Payout: 150, Outcome: "won"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stacks, _, err := IdleStacks(SessionIdleAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stacks) != 0 {
|
||||
t.Fatalf("the reaper found %d idle stacks; a player who just settled a hand is not idle", len(stacks))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// The Siege, as gogobee pushes it.
|
||||
//
|
||||
// Same shape of thing as the roster and stored the same way: a whole snapshot
|
||||
// that replaces whatever we had. Nothing here is an event — the *events*
|
||||
// (siege_start / siege_win / siege_loss) come down the dispatch queue like any
|
||||
// other fact. This is the thing that is currently true, which is the only kind
|
||||
// of thing a health bar can honestly draw.
|
||||
|
||||
// SiegeDefender is one adventurer's standing in the current muster.
|
||||
//
|
||||
// Token is the same public roster token the board uses, so the defender board
|
||||
// can link a name to their page — and it is EMPTY for an opted-out player. That
|
||||
// is the whole opt-out story here: their damage still counts and still holds its
|
||||
// rank (the town's effort is the town's), but there is no name and no link. Name
|
||||
// carries gogobee's anonymised label in that case.
|
||||
type SiegeDefender struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Fights int `json:"fights"`
|
||||
Damage int `json:"damage"`
|
||||
FoughtToday bool `json:"fought_today"`
|
||||
}
|
||||
|
||||
// SiegePast is one closed-out Siege: what came, whether the town held, and who
|
||||
// turned up most. The history is what makes the live bar mean anything.
|
||||
type SiegePast struct {
|
||||
BossID int64 `json:"boss_id"`
|
||||
BossName string `json:"boss_name"`
|
||||
Tier int `json:"tier"`
|
||||
Outcome string `json:"outcome"` // "defeated" | "survived"
|
||||
HPRemaining int `json:"hp_remaining"`
|
||||
HPMax int `json:"hp_max"`
|
||||
Defenders int `json:"defenders"`
|
||||
MVP string `json:"mvp,omitempty"`
|
||||
MVPFights int `json:"mvp_fights,omitempty"`
|
||||
EndedAt int64 `json:"ended_at"`
|
||||
}
|
||||
|
||||
// Siege is the complete war-room state: the live boss (if any), its muster, and
|
||||
// every Siege that came before.
|
||||
type Siege struct {
|
||||
Active bool `json:"active"`
|
||||
BossID int64 `json:"boss_id,omitempty"`
|
||||
BossName string `json:"boss_name,omitempty"`
|
||||
Tier int `json:"tier,omitempty"`
|
||||
HPCurrent int `json:"hp_current"`
|
||||
HPMax int `json:"hp_max"`
|
||||
StartsAt int64 `json:"starts_at,omitempty"`
|
||||
EndsAt int64 `json:"ends_at,omitempty"`
|
||||
BoutsToday int `json:"bouts_today"`
|
||||
Defenders []SiegeDefender `json:"defenders,omitempty"`
|
||||
History []SiegePast `json:"history,omitempty"`
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
}
|
||||
|
||||
// ReplaceSiege swaps the whole war room for a new snapshot, in one transaction.
|
||||
//
|
||||
// Replace, never merge — for the same reason the roster does it. A defender who
|
||||
// dropped out of the payload (opted out, deleted character) has to leave the
|
||||
// board, and a Siege that ended has to stop showing a live bar. The transaction
|
||||
// means a reader mid-swap sees the old Siege or the new one, never a boss with
|
||||
// somebody else's muster under it.
|
||||
//
|
||||
// History is replaced too, not appended: gogobee is the authority on what has
|
||||
// happened, and rebuilding from its list each tick means a corrected or purged
|
||||
// row upstream can't leave a ghost siege on Pete forever.
|
||||
func ReplaceSiege(s Siege, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM adventure_siege_defenders`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM adventure_siege_history`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO adventure_siege
|
||||
(id, active, boss_id, boss_name, tier, hp_current, hp_max,
|
||||
starts_at, ends_at, bouts_today, snapshot_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
active = excluded.active, boss_id = excluded.boss_id,
|
||||
boss_name = excluded.boss_name, tier = excluded.tier,
|
||||
hp_current = excluded.hp_current, hp_max = excluded.hp_max,
|
||||
starts_at = excluded.starts_at, ends_at = excluded.ends_at,
|
||||
bouts_today = excluded.bouts_today, snapshot_at = excluded.snapshot_at`,
|
||||
s.Active, s.BossID, s.BossName, s.Tier, s.HPCurrent, s.HPMax,
|
||||
s.StartsAt, s.EndsAt, s.BoutsToday, snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_siege_defenders
|
||||
(pos, token, name, level, fights, damage, fought_today)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstmt.Close()
|
||||
for i, d := range s.Defenders {
|
||||
if _, err := dstmt.Exec(i, d.Token, d.Name, d.Level, d.Fights, d.Damage, d.FoughtToday); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// OR REPLACE, because boss_id is the primary key and a duplicate in gogobee's
|
||||
// list would otherwise fail this whole transaction — the live boss and the
|
||||
// muster with it, freezing the war room on the previous snapshot indefinitely.
|
||||
// The table is deleted and rebuilt from the pushed list every time, so a
|
||||
// collision is a wire quirk rather than data loss, and keeping the last of a
|
||||
// colliding pair is a far smaller failure than a war room that stops moving.
|
||||
hstmt, err := tx.Prepare(`
|
||||
INSERT OR REPLACE INTO adventure_siege_history
|
||||
(boss_id, boss_name, tier, outcome, hp_remaining, hp_max, defenders, mvp, mvp_fights, ended_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer hstmt.Close()
|
||||
for _, h := range s.History {
|
||||
if _, err := hstmt.Exec(h.BossID, h.BossName, h.Tier, h.Outcome, h.HPRemaining,
|
||||
h.HPMax, h.Defenders, h.MVP, h.MVPFights, h.EndedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SiegeBarForBoss finds the HP bar to draw on a siege dispatch's card: current
|
||||
// and max HP for the named boss around the time the dispatch was filed.
|
||||
//
|
||||
// A siege fact carries the boss and the defender count but not the HP, so the
|
||||
// bar has to come from the war-room snapshot. Two places to look, in order:
|
||||
//
|
||||
// - the live row, when that boss is still camped (a siege_start card should
|
||||
// show the bar as it stands right now, and it will keep sliding as the town
|
||||
// chips away);
|
||||
// - the history, for a Siege that has closed — matched on name and then on
|
||||
// the row that ended nearest the dispatch, since the same boss comes back
|
||||
// month after month and only the clock separates the two.
|
||||
//
|
||||
// ok is false when neither has it, which is a real and temporary state: the
|
||||
// win/loss dispatch is filed the moment the Siege resolves, and the history that
|
||||
// explains it doesn't reach Pete until the next 2-minute push. The card renders
|
||||
// without a bar in the meantime rather than drawing a wrong one.
|
||||
func SiegeBarForBoss(boss string, at int64) (current, max int, ok bool) {
|
||||
if boss == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
var active bool
|
||||
var name string
|
||||
var hpCur, hpMax int
|
||||
err := Get().QueryRow(`
|
||||
SELECT active, boss_name, hp_current, hp_max FROM adventure_siege WHERE id = 1`).
|
||||
Scan(&active, &name, &hpCur, &hpMax)
|
||||
if err == nil && active && name == boss && hpMax > 0 {
|
||||
return hpCur, hpMax, true
|
||||
}
|
||||
|
||||
// ORDER BY the distance from the dispatch, so a boss that has besieged the
|
||||
// town three times resolves to the siege this dispatch is actually about.
|
||||
err = Get().QueryRow(`
|
||||
SELECT hp_remaining, hp_max FROM adventure_siege_history
|
||||
WHERE boss_name = ? AND hp_max > 0
|
||||
ORDER BY ABS(ended_at - ?) ASC LIMIT 1`, boss, at).Scan(&hpCur, &hpMax)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return hpCur, hpMax, true
|
||||
}
|
||||
|
||||
// SiegeIsCamped answers the one question the siege_join pre-check asks, without
|
||||
// LoadSiege's defender rows and whole history behind it — a one-column read on a
|
||||
// pool that is MaxOpenConns(1).
|
||||
//
|
||||
// known is false when gogobee has never pushed a war room at all, which is NOT
|
||||
// the same as a pushed snapshot saying no Siege is camped. The caller has to keep
|
||||
// the two apart: a fresh deploy that has not been pushed to yet must still queue
|
||||
// the order rather than show a dead button.
|
||||
func SiegeIsCamped() (active, known bool, err error) {
|
||||
err = Get().QueryRow(`SELECT active FROM adventure_siege WHERE id = 1`).Scan(&active)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return active, true, nil
|
||||
}
|
||||
|
||||
// LoadSiege returns the war room as last pushed. ok is false when gogobee has
|
||||
// never pushed one at all — distinct from a pushed snapshot that says no Siege
|
||||
// is camped, which is a real answer the page can render.
|
||||
func LoadSiege() (Siege, bool, error) {
|
||||
var s Siege
|
||||
err := Get().QueryRow(`
|
||||
SELECT active, boss_id, boss_name, tier, hp_current, hp_max,
|
||||
starts_at, ends_at, bouts_today, snapshot_at
|
||||
FROM adventure_siege WHERE id = 1`).Scan(
|
||||
&s.Active, &s.BossID, &s.BossName, &s.Tier, &s.HPCurrent, &s.HPMax,
|
||||
&s.StartsAt, &s.EndsAt, &s.BoutsToday, &s.SnapshotAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return Siege{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Siege{}, false, err
|
||||
}
|
||||
|
||||
drows, err := Get().Query(`
|
||||
SELECT token, name, level, fights, damage, fought_today
|
||||
FROM adventure_siege_defenders ORDER BY pos ASC`)
|
||||
if err != nil {
|
||||
return s, true, err
|
||||
}
|
||||
defer drows.Close()
|
||||
for drows.Next() {
|
||||
var d SiegeDefender
|
||||
if err := drows.Scan(&d.Token, &d.Name, &d.Level, &d.Fights, &d.Damage, &d.FoughtToday); err != nil {
|
||||
return s, true, err
|
||||
}
|
||||
s.Defenders = append(s.Defenders, d)
|
||||
}
|
||||
if err := drows.Err(); err != nil {
|
||||
return s, true, err
|
||||
}
|
||||
|
||||
hrows, err := Get().Query(`
|
||||
SELECT boss_id, boss_name, tier, outcome, hp_remaining, hp_max, defenders, mvp, mvp_fights, ended_at
|
||||
FROM adventure_siege_history ORDER BY ended_at DESC, boss_id DESC`)
|
||||
if err != nil {
|
||||
return s, true, err
|
||||
}
|
||||
defer hrows.Close()
|
||||
for hrows.Next() {
|
||||
var h SiegePast
|
||||
if err := hrows.Scan(&h.BossID, &h.BossName, &h.Tier, &h.Outcome, &h.HPRemaining,
|
||||
&h.HPMax, &h.Defenders, &h.MVP, &h.MVPFights, &h.EndedAt); err != nil {
|
||||
return s, true, err
|
||||
}
|
||||
s.History = append(s.History, h)
|
||||
}
|
||||
return s, true, hrows.Err()
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Shared tables: the casino with more than one person at it.
|
||||
//
|
||||
// The money model is the thing to understand first, because everything else
|
||||
// follows from it, and it is *not* the one the plan sketched.
|
||||
//
|
||||
// **Chips cross into a table when you sit down, and back out when you get up.
|
||||
// Nothing in between touches game_chips at all.** Your buy-in leaves your stack
|
||||
// and becomes a stack inside the engine's own state; antes, bets, pots and
|
||||
// payouts are all moves within that blob; and getting up is the single write
|
||||
// that turns what is in front of you back into chips.
|
||||
//
|
||||
// This is how hold'em already worked as a solo session, and generalising it is
|
||||
// what makes a pot safe. The obvious alternative — settle each hand by crediting
|
||||
// every winner's game_chips row — puts a real money write on the end of every
|
||||
// hand, and a crash between the credit and the state write pays the winner twice.
|
||||
// Here a settle credits nobody: it is a state write, conditional on the version,
|
||||
// and a replay of it affects zero rows and rolls back. The pot cannot be paid
|
||||
// twice because it is never *paid* at all, only moved.
|
||||
//
|
||||
// So the money invariant across a table's whole life is:
|
||||
//
|
||||
// sum(seat stacks in the blob) + pot == sum(game_seats.staked) - rake taken
|
||||
//
|
||||
// and the only two statements that move chips across the border are the stake in
|
||||
// SitDown and the award in LeaveTable — each of them one transaction, each of
|
||||
// them carrying the state write that justifies it.
|
||||
//
|
||||
// The other half is concurrency. A table has two writers where a solo game had
|
||||
// one: an HTTP move, and a turn clock acting for whoever walked away. The version
|
||||
// column is the authority — every state write is conditional on the version its
|
||||
// writer read — and the striped mutex in the web layer is only an optimisation on
|
||||
// top. Correctness has to live in the database, because a mutex does not survive
|
||||
// a redeploy: during a drain, two processes hold two different mutexes over the
|
||||
// same row and both of them believe they are alone.
|
||||
|
||||
var (
|
||||
// ErrStaleTable means somebody else wrote the table first. The caller's read is
|
||||
// out of date; it must reload and decide again. This is not an error condition
|
||||
// so much as the normal outcome of a race, and it is what a 409 is made of.
|
||||
ErrStaleTable = errors.New("games: the table moved on")
|
||||
// ErrNoSuchTable means there is no table with that id.
|
||||
ErrNoSuchTable = errors.New("games: no such table")
|
||||
// ErrSeatTaken means somebody sat down there between the read and the write.
|
||||
ErrSeatTaken = errors.New("games: that seat is taken")
|
||||
)
|
||||
|
||||
// Table is a felt other people can sit at.
|
||||
//
|
||||
// State is the engine's State, serialized whole — the same blob game_live_hands
|
||||
// holds for a solo game, and for the same reason: the deck is in it, so it never
|
||||
// leaves the server, and a hand survives a redeploy.
|
||||
type Table struct {
|
||||
ID string
|
||||
Game string
|
||||
Tier string
|
||||
State []byte
|
||||
Seed1 uint64
|
||||
Seed2 uint64
|
||||
Phase string
|
||||
// HandNo, with the id, is the identity of one hand. It is what the audit trail
|
||||
// keys on now that a seed no longer reproduces a hand: at a shared table the
|
||||
// cards fall the way they do because of the order the others acted, not just
|
||||
// the seed, so "deal it again from seed1/seed2" stopped being a true story the
|
||||
// moment there was a second player.
|
||||
HandNo int64
|
||||
// Version is the concurrency authority. Read it, write against it, and a write
|
||||
// that finds it moved is a write that lost the race.
|
||||
Version int64
|
||||
// Deadline is the unix second by which the seat to act has to act, or 0 for no
|
||||
// clock at all. Only a *human* to act sets one: bots resolve inside ApplyMove
|
||||
// and there is nobody to wait for.
|
||||
Deadline int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// Seat is one chair. A seat with no MatrixUser is a bot, which is what makes solo
|
||||
// play just "a table nobody else has joined yet" rather than a second mode.
|
||||
type Seat struct {
|
||||
Seat int
|
||||
MatrixUser string // "" for a bot
|
||||
Name string
|
||||
// Staked is what this player brought and has not yet taken home. The chips are
|
||||
// off their game_chips stack and inside the table blob, where the idle reaper
|
||||
// cannot see them — so this is the row that says they exist.
|
||||
Staked int64
|
||||
Away bool
|
||||
LastSeen int64
|
||||
}
|
||||
|
||||
// Bot reports whether nobody is sitting here.
|
||||
func (s Seat) Bot() bool { return s.MatrixUser == "" }
|
||||
|
||||
// NewTableID mints a table id. Short enough to put in a URL, random enough that
|
||||
// nobody guesses their way onto somebody else's felt.
|
||||
func NewTableID() (string, error) {
|
||||
b := make([]byte, 9)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("games: mint table id: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// OpenTable creates a table and seats it — bots in every chair nobody has taken.
|
||||
func OpenTable(t Table, seats []Seat) error {
|
||||
now := nowUnix()
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin open table: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_tables (id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
||||
t.ID, t.Game, t.Tier, string(t.State), int64(t.Seed1), int64(t.Seed2),
|
||||
t.Phase, t.HandNo, t.Deadline, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: open table: %w", err)
|
||||
}
|
||||
for _, s := range seats {
|
||||
if err := upsertSeat(tx, t.ID, s, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit open table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenSoloTable opens a table with the player already sitting at it — the "solo
|
||||
// is just a table nobody else has joined yet" path. It is SitDown and OpenTable
|
||||
// fused into one transaction: stake the buy-in, claim the occupancy row, create
|
||||
// the table, and seat everyone (the human, and the bots filling the rest of the
|
||||
// ring). Any step failing rolls the buy-in back with it, so a crash never leaves
|
||||
// a player charged for a felt that does not exist.
|
||||
//
|
||||
// The occupancy claim is the same primary key that stops a second solo hand, so a
|
||||
// player already at a table (or in another game) is refused here with
|
||||
// ErrHandInProgress and their buy-in returned untouched.
|
||||
func OpenSoloTable(t Table, seats []Seat, buyIn int64) error {
|
||||
if buyIn <= 0 {
|
||||
return ErrBadAmount
|
||||
}
|
||||
// The human seat is the one row that is not a bot; its player claims the table.
|
||||
var user, name string
|
||||
for _, s := range seats {
|
||||
if !s.Bot() {
|
||||
user, name = s.MatrixUser, s.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if user == "" {
|
||||
return ErrBadAmount // a solo table with no human is a bug, not a table
|
||||
}
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin open solo: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
|
||||
WHERE matrix_user = ? AND chips >= ?`,
|
||||
buyIn, now, now, user, buyIn,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: stake solo buy-in: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrInsufficientChips
|
||||
}
|
||||
|
||||
res, err = tx.Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, table_id, updated_at)
|
||||
VALUES (?, ?, '', 0, 0, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||
user, t.Game, t.ID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: claim solo seat: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrHandInProgress
|
||||
}
|
||||
_ = name
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_tables (id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
||||
t.ID, t.Game, t.Tier, string(t.State), int64(t.Seed1), int64(t.Seed2),
|
||||
t.Phase, t.HandNo, t.Deadline, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: open solo table: %w", err)
|
||||
}
|
||||
for _, sc := range seats {
|
||||
if err := upsertSeat(tx, t.ID, sc, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit open solo: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertSeat writes a seat row inside an open transaction, bots included.
|
||||
//
|
||||
// last_seen is the caller's if they set one, and only falls back to now when they
|
||||
// did not. That distinction is load-bearing: the turn clock rewrites a seat to
|
||||
// mark it away, and it must carry the seat's *existing* last_seen through
|
||||
// unchanged — otherwise every auto-fold refreshes the away player's clock, and
|
||||
// the abandoned-table reaper (which keys on how long ago a human last acted for
|
||||
// themselves) could never fire.
|
||||
func upsertSeat(tx *sql.Tx, tableID string, s Seat, now int64) error {
|
||||
var user any
|
||||
if s.MatrixUser != "" {
|
||||
user = s.MatrixUser
|
||||
}
|
||||
seen := s.LastSeen
|
||||
if seen == 0 {
|
||||
seen = now
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_seats (table_id, seat, matrix_user, name, staked, away, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(table_id, seat) DO UPDATE SET
|
||||
matrix_user = excluded.matrix_user, name = excluded.name,
|
||||
staked = excluded.staked, away = excluded.away, last_seen = excluded.last_seen`,
|
||||
tableID, s.Seat, user, s.Name, s.Staked, boolInt(s.Away), seen,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: seat: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolInt(b bool) int64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LoadTable reads a table and everyone at it.
|
||||
func LoadTable(id string) (Table, []Seat, error) {
|
||||
var t Table
|
||||
var state string
|
||||
var s1, s2 int64
|
||||
err := Get().QueryRow(
|
||||
`SELECT id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at
|
||||
FROM game_tables WHERE id = ?`, id,
|
||||
).Scan(&t.ID, &t.Game, &t.Tier, &state, &s1, &s2, &t.Phase, &t.HandNo, &t.Version, &t.Deadline, &t.CreatedAt, &t.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Table{}, nil, ErrNoSuchTable
|
||||
}
|
||||
if err != nil {
|
||||
return Table{}, nil, fmt.Errorf("games: load table: %w", err)
|
||||
}
|
||||
t.State, t.Seed1, t.Seed2 = []byte(state), uint64(s1), uint64(s2)
|
||||
|
||||
seats, err := tableSeats(id)
|
||||
if err != nil {
|
||||
return Table{}, nil, err
|
||||
}
|
||||
return t, seats, nil
|
||||
}
|
||||
|
||||
// tableSeats reads the chairs, in seat order.
|
||||
func tableSeats(id string) ([]Seat, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT seat, matrix_user, name, staked, away, last_seen
|
||||
FROM game_seats WHERE table_id = ? ORDER BY seat`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: table seats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Seat
|
||||
for rows.Next() {
|
||||
var s Seat
|
||||
var user sql.NullString
|
||||
var away int64
|
||||
if err := rows.Scan(&s.Seat, &user, &s.Name, &s.Staked, &away, &s.LastSeen); err != nil {
|
||||
return nil, fmt.Errorf("games: scan seat: %w", err)
|
||||
}
|
||||
s.MatrixUser, s.Away = user.String, away != 0
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TableSummary is a table as the lobby lists it: enough to decide whether to sit
|
||||
// down, and nothing that would give away a card.
|
||||
type TableSummary struct {
|
||||
ID string `json:"id"`
|
||||
Game string `json:"game"`
|
||||
Tier string `json:"tier"`
|
||||
Phase string `json:"phase"`
|
||||
Humans int `json:"humans"`
|
||||
Seats int `json:"seats"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// LobbyTables lists the live tables, most recently played first. A game of "" is
|
||||
// all of them.
|
||||
func LobbyTables(game string, limit int) ([]TableSummary, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
q := `SELECT t.id, t.game, t.tier, t.phase, t.updated_at,
|
||||
COUNT(s.seat),
|
||||
COUNT(s.matrix_user)
|
||||
FROM game_tables t
|
||||
LEFT JOIN game_seats s ON s.table_id = t.id`
|
||||
args := []any{}
|
||||
if game != "" {
|
||||
q += ` WHERE t.game = ?`
|
||||
args = append(args, game)
|
||||
}
|
||||
q += ` GROUP BY t.id ORDER BY t.updated_at DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := Get().Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: lobby: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []TableSummary
|
||||
for rows.Next() {
|
||||
var s TableSummary
|
||||
if err := rows.Scan(&s.ID, &s.Game, &s.Tier, &s.Phase, &s.UpdatedAt, &s.Seats, &s.Humans); err != nil {
|
||||
return nil, fmt.Errorf("games: scan lobby row: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---- writing a table back --------------------------------------------------
|
||||
|
||||
// TableCommit is one write-back of a shared table.
|
||||
//
|
||||
// There is no payout field, and its absence is the design. A hand ending at a
|
||||
// shared table moves chips *within* the blob — the pot becomes somebody's stack —
|
||||
// so settling one credits nobody and mints nothing. What it writes is the state,
|
||||
// the audit rows, and whatever the seats now look like. The version makes it
|
||||
// exactly-once: a settle that runs twice loses the race with itself.
|
||||
type TableCommit struct {
|
||||
// Table carries the new state and the version that was *read*. The write is
|
||||
// conditional on that version and bumps it.
|
||||
Table Table
|
||||
// Seats to rewrite — a stack that changed hands, a seat that came back from
|
||||
// away. Seats not named here are left alone.
|
||||
Seats []Seat
|
||||
// Audit is the per-seat record of a hand that just ended. Empty mid-hand.
|
||||
Audit []Hand
|
||||
}
|
||||
|
||||
// CommitTable writes a table back, and the hand it just finished with it, in one
|
||||
// transaction.
|
||||
//
|
||||
// The version check is the whole safety property, and it is why this can be
|
||||
// called by the turn clock and an HTTP move at the same instant without either
|
||||
// having to trust the other. Whoever gets there first bumps the version; the
|
||||
// loser's UPDATE matches zero rows, the transaction rolls back, and it comes back
|
||||
// ErrStaleTable with nothing written — no half-settled hand, no audit row for a
|
||||
// hand that did not happen.
|
||||
//
|
||||
// Nothing in here may call Get().Exec. The pool runs at MaxOpenConns(1), so a
|
||||
// bare Exec inside an open transaction waits forever for the connection that this
|
||||
// transaction is holding — and takes the news app down with it. See CommitHand.
|
||||
func CommitTable(c TableCommit) error {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin commit table: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
if err := saveTable(tx, c.Table, now); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range c.Seats {
|
||||
if err := upsertSeat(tx, c.Table.ID, s, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, h := range c.Audit {
|
||||
if err := recordHand(tx, h, now); err != nil {
|
||||
return err
|
||||
}
|
||||
// Playing a hand is the most deliberate thing a player does. Keep the reaper
|
||||
// off them — their chips are inside the table blob, where it cannot see them
|
||||
// anyway, but their game_chips row is what it reads.
|
||||
if h.MatrixUser == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||
now, now, h.MatrixUser,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: touch session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveTable is the conditional state write: it lands only if the version is still
|
||||
// the one the caller read.
|
||||
func saveTable(tx *sql.Tx, t Table, now int64) error {
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_tables SET state = ?, phase = ?, hand_no = ?, seed1 = ?, seed2 = ?,
|
||||
deadline = ?, version = version + 1, updated_at = ?
|
||||
WHERE id = ? AND version = ?`,
|
||||
string(t.State), t.Phase, t.HandNo, int64(t.Seed1), int64(t.Seed2),
|
||||
t.Deadline, now, t.ID, t.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: save table: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrStaleTable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- sitting down and getting up -------------------------------------------
|
||||
|
||||
// Sit is one player taking one chair, with the table state that has them in it.
|
||||
type Sit struct {
|
||||
Table Table // the new state, and the version that was read
|
||||
Seat Seat // MatrixUser, Name and the chair; Staked is the buy-in
|
||||
BuyIn int64
|
||||
}
|
||||
|
||||
// SitDown moves a player's chips onto a table and puts them in a seat — the first
|
||||
// of the only two statements in the casino that cross the chip/table border.
|
||||
//
|
||||
// It is one transaction and every step of it can refuse:
|
||||
//
|
||||
// - the chips leave in the same statement that checks they are there, so two
|
||||
// joins fired at once cannot spend the same chip;
|
||||
// - the occupancy claim is game_live_hands' primary key, exactly as it is for a
|
||||
// solo hand, so a player cannot be at two tables (or at a table and in a solo
|
||||
// game) at once, and a double-clicked Join is a 409;
|
||||
// - the seat is taken only if a bot is sitting in it, so two players racing for
|
||||
// the last chair cannot both win;
|
||||
// - and the state write is conditional on the version, so the engine's idea of
|
||||
// who is at the table cannot drift from the seat rows.
|
||||
//
|
||||
// Any of those failing rolls back the buy-in with it.
|
||||
func SitDown(s Sit) error {
|
||||
if s.BuyIn <= 0 {
|
||||
return ErrBadAmount
|
||||
}
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin sit: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
|
||||
WHERE matrix_user = ? AND chips >= ?`,
|
||||
s.BuyIn, now, now, s.Seat.MatrixUser, s.BuyIn,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: stake buy-in: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrInsufficientChips
|
||||
}
|
||||
|
||||
// The occupancy claim. The state column is empty on purpose: the cards live in
|
||||
// game_tables, and this row exists to be a primary key.
|
||||
res, err = tx.Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, table_id, updated_at)
|
||||
VALUES (?, ?, '', 0, 0, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||
s.Seat.MatrixUser, s.Table.Game, s.Table.ID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: claim seat: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrHandInProgress
|
||||
}
|
||||
|
||||
// Take the chair, but only out of a bot's hands.
|
||||
res, err = tx.Exec(
|
||||
`UPDATE game_seats SET matrix_user = ?, name = ?, staked = ?, away = 0, last_seen = ?
|
||||
WHERE table_id = ? AND seat = ? AND matrix_user IS NULL`,
|
||||
s.Seat.MatrixUser, s.Seat.Name, s.BuyIn, now, s.Table.ID, s.Seat.Seat,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: take seat: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrSeatTaken
|
||||
}
|
||||
|
||||
if err := saveTable(tx, s.Table, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit sit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Leave is one player getting up, with the table state that no longer has them.
|
||||
type Leave struct {
|
||||
Table Table // the new state, and the version that was read
|
||||
Seat int
|
||||
MatrixUser string
|
||||
// Bot is who takes the chair over. A table always has a full complement, so
|
||||
// getting up hands the seat back to the house rather than leaving a hole.
|
||||
Bot string
|
||||
// Amount is what is in front of them: everything they are taking home. It may
|
||||
// be more than they brought, or nothing at all.
|
||||
Amount int64
|
||||
// Audit, if the leaving itself settles something worth recording.
|
||||
Audit []Hand
|
||||
}
|
||||
|
||||
// LeaveTable turns what is in front of a player back into chips — the second and
|
||||
// last statement that crosses the chip/table border.
|
||||
//
|
||||
// One transaction, and the state write is in it. As two statements this is a
|
||||
// double-pay waiting to happen: award 1,240 chips, fail the state write, and the
|
||||
// player reloads to find their seat still there with 1,240 in front of it. They
|
||||
// get up again, and again.
|
||||
func LeaveTable(l Leave) error {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin leave: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
if err := saveTable(tx, l.Table, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertSeat(tx, l.Table.ID, Seat{Seat: l.Seat, Name: l.Bot}, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM game_live_hands WHERE matrix_user = ? AND table_id = ?`,
|
||||
l.MatrixUser, l.Table.ID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: release seat claim: %w", err)
|
||||
}
|
||||
if err := award(tx, l.MatrixUser, l.Amount, now); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, h := range l.Audit {
|
||||
if err := recordHand(tx, h, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit leave: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlayerSeat reports the table and chair a player is sitting at. It reads the
|
||||
// seat row, which sit and leave keep in lockstep with the occupancy claim in one
|
||||
// transaction, so a row here means a live-hand row there and vice versa.
|
||||
func PlayerSeat(user string) (tableID string, seat int, err error) {
|
||||
err = Get().QueryRow(
|
||||
`SELECT table_id, seat FROM game_seats WHERE matrix_user = ?`, user,
|
||||
).Scan(&tableID, &seat)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", 0, ErrNoLiveHand
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("games: player seat: %w", err)
|
||||
}
|
||||
return tableID, seat, nil
|
||||
}
|
||||
|
||||
// TableOf reports which table a player is sitting at, if any. Read off the
|
||||
// occupancy claim, so it agrees with the cash-out check by construction.
|
||||
func TableOf(user string) (string, error) {
|
||||
var id sql.NullString
|
||||
err := Get().QueryRow(
|
||||
`SELECT table_id FROM game_live_hands WHERE matrix_user = ?`, user,
|
||||
).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNoLiveHand
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("games: table of: %w", err)
|
||||
}
|
||||
return id.String, nil
|
||||
}
|
||||
|
||||
// CloseTable deletes a table nobody is sitting at. Called when the last human
|
||||
// gets up: a felt with six bots on it and nobody watching is not a game, it is a
|
||||
// row that the lobby would advertise forever.
|
||||
func CloseTable(id string) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin close table: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
var humans int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COUNT(*) FROM game_seats WHERE table_id = ? AND matrix_user IS NOT NULL`, id,
|
||||
).Scan(&humans); err != nil {
|
||||
return fmt.Errorf("games: count humans: %w", err)
|
||||
}
|
||||
if humans > 0 {
|
||||
return nil // somebody is still playing; the table stays
|
||||
}
|
||||
for _, q := range []string{
|
||||
`DELETE FROM game_seats WHERE table_id = ?`,
|
||||
`DELETE FROM game_chat WHERE table_id = ?`,
|
||||
`DELETE FROM game_tables WHERE id = ?`,
|
||||
} {
|
||||
if _, err := tx.Exec(q, id); err != nil {
|
||||
return fmt.Errorf("games: close table: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit close table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AbandonedTables lists tables everyone walked away from: every human seat is
|
||||
// away, and the most recent one acted for themselves longer ago than the cutoff.
|
||||
//
|
||||
// It is the seated-player half of the reaper. The session reaper cashes out loose
|
||||
// chips on a game_chips stack; it cannot see a player whose chips are inside a
|
||||
// table blob, and those are exactly the chips a walked-away poker player has. So
|
||||
// this finds the tables where nobody is coming back and hands them to ReapTable.
|
||||
//
|
||||
// A table with a live hand is never abandoned in this sense — the turn clock is
|
||||
// still folding it forward — so only tables parked between hands qualify. Like
|
||||
// DueTables it closes its rows before returning, because the caller is about to
|
||||
// take a lock and open a transaction against the one connection.
|
||||
func AbandonedTables(cutoff int64) ([]TableRef, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT t.id, t.version FROM game_tables t
|
||||
WHERE t.phase = 'handover'
|
||||
AND EXISTS (SELECT 1 FROM game_seats s
|
||||
WHERE s.table_id = t.id AND s.matrix_user IS NOT NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM game_seats s
|
||||
WHERE s.table_id = t.id AND s.matrix_user IS NOT NULL
|
||||
AND (s.away = 0 OR s.last_seen >= ?))`,
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: abandoned tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []TableRef
|
||||
for rows.Next() {
|
||||
var r TableRef
|
||||
if err := rows.Scan(&r.ID, &r.Version); err != nil {
|
||||
return nil, fmt.Errorf("games: scan abandoned table: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Reap is one abandoned table being cashed out and closed. Stacks is what each
|
||||
// human seat has in front of it, read from the engine blob by the caller — the
|
||||
// only game-specific fact the reaper needs, since the chips-home number lives
|
||||
// inside a state only the engine can decode.
|
||||
type Reap struct {
|
||||
TableID string
|
||||
Version int64
|
||||
// Humans is the seats to cash out, each paired with the stack going home. A
|
||||
// seat's Amount may be zero (they busted and never got up), which still has to
|
||||
// close their occupancy row so they can play again.
|
||||
Humans []ReapSeat
|
||||
}
|
||||
|
||||
// ReapSeat is one human being sent home from an abandoned table.
|
||||
type ReapSeat struct {
|
||||
Seat int
|
||||
MatrixUser string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// ReapTable cashes out every human at an abandoned table and deletes it, in one
|
||||
// transaction, conditional on the version so it cannot race a player who came
|
||||
// back to the felt in the same instant.
|
||||
//
|
||||
// It is LeaveTable and CloseTable fused: award each stack, release each occupancy
|
||||
// claim, then drop the seats, chat and table. The version guard is what makes it
|
||||
// safe against a returning player — if their sit or move bumped the version
|
||||
// between the scan and here, every row matches zero and the whole thing rolls
|
||||
// back, leaving the table exactly as the returning player left it.
|
||||
func ReapTable(r Reap) error {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: begin reap: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
// Bump the version first, and refuse if it moved. Nothing below is conditional,
|
||||
// so this one check has to stand for the whole reap.
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_tables SET version = version + 1, updated_at = ? WHERE id = ? AND version = ?`,
|
||||
now, r.TableID, r.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: reap bump version: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrStaleTable
|
||||
}
|
||||
|
||||
for _, h := range r.Humans {
|
||||
if h.Amount > 0 {
|
||||
if err := award(tx, h.MatrixUser, h.Amount, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM game_live_hands WHERE matrix_user = ? AND table_id = ?`,
|
||||
h.MatrixUser, r.TableID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: reap release claim: %w", err)
|
||||
}
|
||||
}
|
||||
for _, q := range []string{
|
||||
`DELETE FROM game_seats WHERE table_id = ?`,
|
||||
`DELETE FROM game_chat WHERE table_id = ?`,
|
||||
`DELETE FROM game_tables WHERE id = ?`,
|
||||
} {
|
||||
if _, err := tx.Exec(q, r.TableID); err != nil {
|
||||
return fmt.Errorf("games: reap close: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("games: commit reap: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the turn clock --------------------------------------------------------
|
||||
|
||||
// TableRef is a table the clock has found expired: which one, and at what version
|
||||
// it was seen.
|
||||
//
|
||||
// The version is the point. The clock acts only if it is *still* that version by
|
||||
// the time it takes the lock, because otherwise: Bob's raise lands in the same
|
||||
// second his clock expires, the action passes to Cara, and the clock — still
|
||||
// holding its scan-time belief that the seat to act has run out of time — folds
|
||||
// Cara, who had twenty-five seconds left. That is a one-second window that recurs
|
||||
// on every single turn of every hand.
|
||||
type TableRef struct {
|
||||
ID string
|
||||
Version int64
|
||||
}
|
||||
|
||||
// DueTables lists the tables whose clock has run out.
|
||||
//
|
||||
// It closes the rows before returning, and it must: the caller is about to take a
|
||||
// table lock and open a transaction, and holding a *sql.Rows across that means
|
||||
// holding the only connection in the pool while waiting for it. That is not a
|
||||
// slow query, it is a deadlock.
|
||||
func DueTables(now int64) ([]TableRef, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT id, version FROM game_tables WHERE deadline > 0 AND deadline <= ?`, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: due tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []TableRef
|
||||
for rows.Next() {
|
||||
var r TableRef
|
||||
if err := rows.Scan(&r.ID, &r.Version); err != nil {
|
||||
return nil, fmt.Errorf("games: scan due table: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PushDeadlines shoves every live clock out by a grace period. Called once on
|
||||
// boot: a deploy takes a table's clock with it, and without this the first tick
|
||||
// after a restart wakes up to find every deadline in the casino already expired
|
||||
// and auto-folds all of them at once.
|
||||
func PushDeadlines(grace int64) error {
|
||||
if _, err := Get().Exec(
|
||||
`UPDATE game_tables SET deadline = ? WHERE deadline > 0 AND deadline < ?`,
|
||||
nowUnix()+grace, nowUnix()+grace,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: push deadlines: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- chat ------------------------------------------------------------------
|
||||
|
||||
// ChatLine is one thing somebody said at the felt.
|
||||
type ChatLine struct {
|
||||
ID int64 `json:"id"`
|
||||
HandNo int64 `json:"hand_no"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
SaidAt int64 `json:"said_at"`
|
||||
// Mine is filled in by the web layer, per reader. It is not in the database.
|
||||
Mine bool `json:"mine,omitempty"`
|
||||
}
|
||||
|
||||
// MaxChatLen is where a message stops. Long enough for a table read, short enough
|
||||
// that nobody pastes a novel onto the felt.
|
||||
const MaxChatLen = 240
|
||||
|
||||
// Say records a line of chat and returns it. Its hand_no is stamped from the
|
||||
// table, which is what makes the log answer the only question chat at a money
|
||||
// table ever really raises: what was said, during which hand.
|
||||
func Say(tableID, user, name, body string) (ChatLine, error) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ChatLine{}, ErrBadAmount
|
||||
}
|
||||
if len(body) > MaxChatLen {
|
||||
body = body[:MaxChatLen]
|
||||
}
|
||||
var handNo int64
|
||||
if err := Get().QueryRow(`SELECT hand_no FROM game_tables WHERE id = ?`, tableID).Scan(&handNo); errors.Is(err, sql.ErrNoRows) {
|
||||
return ChatLine{}, ErrNoSuchTable
|
||||
} else if err != nil {
|
||||
return ChatLine{}, fmt.Errorf("games: chat hand no: %w", err)
|
||||
}
|
||||
|
||||
now := nowUnix()
|
||||
res, err := Get().Exec(
|
||||
`INSERT INTO game_chat (table_id, hand_no, matrix_user, name, body, said_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
tableID, handNo, user, name, body, now,
|
||||
)
|
||||
if err != nil {
|
||||
return ChatLine{}, fmt.Errorf("games: say: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return ChatLine{ID: id, HandNo: handNo, Name: name, Body: body, SaidAt: now}, nil
|
||||
}
|
||||
|
||||
// Chat reads the last few lines said at a table, oldest first.
|
||||
func Chat(tableID string, limit int) ([]ChatLine, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT id, hand_no, name, body, said_at FROM game_chat
|
||||
WHERE table_id = ? ORDER BY id DESC LIMIT ?`, tableID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: chat: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ChatLine
|
||||
for rows.Next() {
|
||||
var c ChatLine
|
||||
if err := rows.Scan(&c.ID, &c.HandNo, &c.Name, &c.Body, &c.SaidAt); err != nil {
|
||||
return nil, fmt.Errorf("games: scan chat: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Read newest-first so the LIMIT takes the right end; hand them back in the
|
||||
// order they were said.
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// openTestTable stands up a table with a full ring of bots and returns it. Six
|
||||
// seats, because that is hold'em's ring and the most seats any game here has.
|
||||
func openTestTable(t *testing.T, id, game string) Table {
|
||||
t.Helper()
|
||||
tbl := Table{
|
||||
ID: id, Game: game, Tier: "1-2", State: []byte(`{}`),
|
||||
Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1,
|
||||
}
|
||||
seats := make([]Seat, 6)
|
||||
for i := range seats {
|
||||
seats[i] = Seat{Seat: i, Name: "bot"}
|
||||
}
|
||||
if err := OpenTable(tbl, seats); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
// reload reads a table back and fails the test if it is gone.
|
||||
func reload(t *testing.T, id string) (Table, []Seat) {
|
||||
t.Helper()
|
||||
tbl, seats, err := LoadTable(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tbl, seats
|
||||
}
|
||||
|
||||
func TestOpenTable_SeatsAreAllBots(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
openTestTable(t, "t1", "holdem")
|
||||
|
||||
_, seats := reload(t, "t1")
|
||||
if len(seats) != 6 {
|
||||
t.Fatalf("want 6 seats, got %d", len(seats))
|
||||
}
|
||||
for _, s := range seats {
|
||||
if !s.Bot() {
|
||||
t.Errorf("seat %d should be a bot", s.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitDown_MovesChipsOntoTheTable(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl := openTestTable(t, "t1", "holdem")
|
||||
fund(t, player, 5000)
|
||||
|
||||
if err := SitDown(Sit{
|
||||
Table: tbl,
|
||||
Seat: Seat{Seat: 2, MatrixUser: player, Name: "reala"},
|
||||
BuyIn: 1000,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The chips are off the stack...
|
||||
if got := chipsOf(t, player); got != 4000 {
|
||||
t.Errorf("stack: want 4000, got %d", got)
|
||||
}
|
||||
// ...and onto the seat.
|
||||
_, seats := reload(t, "t1")
|
||||
seat := seats[2]
|
||||
if seat.MatrixUser != player || seat.Staked != 1000 {
|
||||
t.Errorf("seat 2: want reala staked 1000, got %q staked %d", seat.MatrixUser, seat.Staked)
|
||||
}
|
||||
// The occupancy claim points at the table.
|
||||
id, err := TableOf(player)
|
||||
if err != nil || id != "t1" {
|
||||
t.Errorf("TableOf: want t1, got %q err %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitDown_CannotTakeATakenSeat(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl := openTestTable(t, "t1", "holdem")
|
||||
fund(t, player, 5000)
|
||||
fund(t, "@bob:parodia.dev", 5000)
|
||||
|
||||
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 2, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tbl2, _ := reload(t, "t1")
|
||||
err := SitDown(Sit{Table: tbl2, Seat: Seat{Seat: 2, MatrixUser: "@bob:parodia.dev", Name: "bob"}, BuyIn: 1000})
|
||||
if !errors.Is(err, ErrSeatTaken) {
|
||||
t.Fatalf("want ErrSeatTaken, got %v", err)
|
||||
}
|
||||
// Bob's chips did not move.
|
||||
if got := chipsOf(t, "@bob:parodia.dev"); got != 5000 {
|
||||
t.Errorf("bob's stack should be untouched, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitDown_CannotSitAtTwoTables(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl1 := openTestTable(t, "t1", "holdem")
|
||||
tbl2 := openTestTable(t, "t2", "holdem")
|
||||
fund(t, player, 5000)
|
||||
|
||||
if err := SitDown(Sit{Table: tbl1, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := SitDown(Sit{Table: tbl2, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000})
|
||||
if !errors.Is(err, ErrHandInProgress) {
|
||||
t.Fatalf("want ErrHandInProgress, got %v", err)
|
||||
}
|
||||
// The buy-in for the second table rolled back.
|
||||
if got := chipsOf(t, player); got != 4000 {
|
||||
t.Errorf("only the first buy-in should have left the stack, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitDown_InsufficientChipsRollsBack(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl := openTestTable(t, "t1", "holdem")
|
||||
fund(t, player, 500)
|
||||
|
||||
err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000})
|
||||
if !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("want ErrInsufficientChips, got %v", err)
|
||||
}
|
||||
if _, err := TableOf(player); !errors.Is(err, ErrNoLiveHand) {
|
||||
t.Errorf("no seat should have been claimed, got %v", err)
|
||||
}
|
||||
_, seats := reload(t, "t1")
|
||||
if seats[0].MatrixUser != "" {
|
||||
t.Errorf("seat should still be a bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveTable_BringsChipsHome(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl := openTestTable(t, "t1", "holdem")
|
||||
fund(t, player, 5000)
|
||||
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tbl2, _ := reload(t, "t1")
|
||||
// Got up with 1,240 — up on the session.
|
||||
if err := LeaveTable(Leave{Table: tbl2, Seat: 0, MatrixUser: player, Bot: "bot", Amount: 1240}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := chipsOf(t, player); got != 5240 {
|
||||
t.Errorf("want 5240 back on the stack, got %d", got)
|
||||
}
|
||||
if _, err := TableOf(player); !errors.Is(err, ErrNoLiveHand) {
|
||||
t.Errorf("seat claim should be gone, got %v", err)
|
||||
}
|
||||
_, seats := reload(t, "t1")
|
||||
if seats[0].MatrixUser != "" {
|
||||
t.Errorf("a bot should have taken the empty chair")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveTable_VersionGuardsTheWrite(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
openTestTable(t, "t1", "holdem")
|
||||
|
||||
a, _ := reload(t, "t1") // both read version 0
|
||||
b, _ := reload(t, "t1")
|
||||
|
||||
a.State = []byte(`{"a":1}`)
|
||||
if err := CommitTable(TableCommit{Table: a}); err != nil {
|
||||
t.Fatalf("first write should win: %v", err)
|
||||
}
|
||||
b.State = []byte(`{"b":2}`)
|
||||
if err := CommitTable(TableCommit{Table: b}); !errors.Is(err, ErrStaleTable) {
|
||||
t.Fatalf("second write should be stale, got %v", err)
|
||||
}
|
||||
|
||||
after, _ := reload(t, "t1")
|
||||
if string(after.State) != `{"a":1}` {
|
||||
t.Errorf("the winning write should stand, got %s", after.State)
|
||||
}
|
||||
if after.Version != 1 {
|
||||
t.Errorf("version should have bumped once, got %d", after.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueTables_OnlyExpiredClocks(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
now := nowUnix()
|
||||
|
||||
past := openTestTable(t, "past", "holdem")
|
||||
past.Deadline = now - 5
|
||||
if err := CommitTable(TableCommit{Table: past}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
future := openTestTable(t, "future", "holdem")
|
||||
future.Deadline = now + 60
|
||||
if err := CommitTable(TableCommit{Table: future}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
openTestTable(t, "noclock", "holdem") // deadline 0
|
||||
|
||||
due, err := DueTables(now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(due) != 1 || due[0].ID != "past" {
|
||||
t.Fatalf("only the past-due table should show, got %+v", due)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_KeepsTheHandItWasSaidDuring(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
openTestTable(t, "t1", "holdem") // hand_no 1
|
||||
|
||||
if _, err := Say("t1", player, "reala", "nice hand"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The table moves to the next hand.
|
||||
tbl2, _ := reload(t, "t1")
|
||||
tbl2.HandNo = 2
|
||||
if err := CommitTable(TableCommit{Table: tbl2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Say("t1", player, "reala", "and another"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lines, err := Chat("t1", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("want 2 lines, got %d", len(lines))
|
||||
}
|
||||
if lines[0].Body != "nice hand" || lines[0].HandNo != 1 {
|
||||
t.Errorf("first line should be hand 1: %+v", lines[0])
|
||||
}
|
||||
if lines[1].HandNo != 2 {
|
||||
t.Errorf("second line should be hand 2: %+v", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTable_KeepsATableWithAHumanAtIt(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
tbl := openTestTable(t, "t1", "holdem")
|
||||
fund(t, player, 5000)
|
||||
if err := SitDown(Sit{Table: tbl, Seat: Seat{Seat: 0, MatrixUser: player, Name: "reala"}, BuyIn: 1000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CloseTable("t1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := LoadTable("t1"); err != nil {
|
||||
t.Errorf("a table with a human should survive close, got %v", err)
|
||||
}
|
||||
|
||||
// Everyone leaves; now it goes.
|
||||
tbl2, _ := reload(t, "t1")
|
||||
if err := LeaveTable(Leave{Table: tbl2, Seat: 0, MatrixUser: player, Bot: "bot", Amount: 1000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CloseTable("t1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := LoadTable("t1"); !errors.Is(err, ErrNoSuchTable) {
|
||||
t.Errorf("an all-bot table should close, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
)
|
||||
|
||||
// The trivia bank.
|
||||
//
|
||||
// Questions are pulled from OpenTDB in the background (internal/opentdb) and
|
||||
// drawn from here when a ladder is built. Nothing in a player's round ever
|
||||
// touches the network.
|
||||
|
||||
// ErrBankEmpty means the bank hasn't got enough questions of that difficulty to
|
||||
// build a ladder. It is a real state, not a bug: a fresh database has an empty
|
||||
// bank until the refill loop has been round a few times.
|
||||
var ErrBankEmpty = fmt.Errorf("trivia: the bank is short of questions")
|
||||
|
||||
// AddTriviaQuestions files a fetched batch. Questions already in the bank are
|
||||
// ignored rather than replaced — OpenTDB hands back overlapping batches, and the
|
||||
// UNIQUE on the text is what stops the bank becoming forty questions deep.
|
||||
// Returns how many were actually new, which is what the refill loop logs.
|
||||
func AddTriviaQuestions(difficulty string, qs []trivia.Question) (int, error) {
|
||||
if len(qs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("trivia: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
stmt, err := tx.Prepare(
|
||||
`INSERT OR IGNORE INTO trivia_questions
|
||||
(difficulty, category, question, correct, incorrect, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("trivia: prepare: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
now := time.Now().Unix()
|
||||
added := 0
|
||||
for _, q := range qs {
|
||||
if len(q.Answers) < 2 || q.Correct < 0 || q.Correct >= len(q.Answers) {
|
||||
continue
|
||||
}
|
||||
correct := q.Answers[q.Correct]
|
||||
wrong := make([]string, 0, len(q.Answers)-1)
|
||||
for i, a := range q.Answers {
|
||||
if i != q.Correct {
|
||||
wrong = append(wrong, a)
|
||||
}
|
||||
}
|
||||
blob, err := json.Marshal(wrong)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
res, err := stmt.Exec(difficulty, q.Category, q.Text, correct, string(blob), now)
|
||||
if err != nil {
|
||||
return added, fmt.Errorf("trivia: insert: %w", err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil {
|
||||
added += int(n)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("trivia: commit: %w", err)
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// CountTrivia is how many questions of a difficulty the bank holds. The refill
|
||||
// loop reads it to decide whether to bother.
|
||||
func CountTrivia(difficulty string) (int, error) {
|
||||
var n int
|
||||
if err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM trivia_questions WHERE difficulty = ?`, difficulty,
|
||||
).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("trivia: count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DrawTrivia deals a ladder: n distinct questions of one difficulty, chosen with
|
||||
// the game's own rng.
|
||||
//
|
||||
// The choice is made in Go rather than with ORDER BY RANDOM() so that the seed
|
||||
// in the audit log means something: the same seed against the same bank deals
|
||||
// the same ladder, which is what lets a disputed game be replayed. It reads the
|
||||
// ids first and picks from them, so a bank of a few thousand questions costs one
|
||||
// small scan rather than a sort of the whole table.
|
||||
func DrawTrivia(difficulty string, n int, rng *rand.Rand) ([]trivia.Question, error) {
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT id FROM trivia_questions WHERE difficulty = ? ORDER BY id`, difficulty)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trivia: draw ids: %w", err)
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("trivia: scan id: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("trivia: draw ids: %w", err)
|
||||
}
|
||||
if len(ids) < n {
|
||||
return nil, ErrBankEmpty
|
||||
}
|
||||
|
||||
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||
pick := ids[:n]
|
||||
|
||||
out := make([]trivia.Question, 0, n)
|
||||
for _, id := range pick {
|
||||
var q trivia.Question
|
||||
var correct, blob string
|
||||
if err := Get().QueryRow(
|
||||
`SELECT category, question, correct, incorrect FROM trivia_questions WHERE id = ?`, id,
|
||||
).Scan(&q.Category, &q.Text, &correct, &blob); err != nil {
|
||||
return nil, fmt.Errorf("trivia: load question: %w", err)
|
||||
}
|
||||
var wrong []string
|
||||
if err := json.Unmarshal([]byte(blob), &wrong); err != nil {
|
||||
return nil, fmt.Errorf("trivia: unreadable answers: %w", err)
|
||||
}
|
||||
// Correct: 0 is a convention the engine immediately destroys — New()
|
||||
// reshuffles every question against the game's seed. Nothing that reaches a
|
||||
// player depends on the order they come out of the table in.
|
||||
q.Answers = append([]string{correct}, wrong...)
|
||||
q.Correct = 0
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// The visit clock behind the adventure section's "while you were away" panel.
|
||||
//
|
||||
// The panel answers "what happened to my adventurer since I last looked", which
|
||||
// needs a per-user stamp — and the obvious one-column version of that is broken
|
||||
// in a way that only shows up in a browser: show the news, move the stamp to now,
|
||||
// and the reader's first refresh renders an empty box over the same events. So
|
||||
// there are two stamps. See the adventure_visit schema comment.
|
||||
|
||||
// advVisitSessionGap is how long a gap in page loads counts as having gone away.
|
||||
// Thirty minutes: long enough that a reader clicking through a dispatch and back
|
||||
// keeps the same panel, short enough that "since last time" means something after
|
||||
// a lunch break rather than only after a day.
|
||||
const advVisitSessionGap = 30 * 60
|
||||
|
||||
// AdvVisitWindow stamps this visit and reports the instant the panel should read
|
||||
// from — every dispatch after it is news to this user.
|
||||
//
|
||||
// firstVisit is true the first time a user is ever seen, and the caller must show
|
||||
// nothing for it. The row is created stamped to now, so their history is not
|
||||
// news: somebody signing in for the first time has not been "away", and greeting
|
||||
// them with every death their character ever suffered would be a worse
|
||||
// introduction than silence.
|
||||
func AdvVisitWindow(userSub string, now int64) (from int64, firstVisit bool, err error) {
|
||||
if userSub == "" {
|
||||
return 0, true, nil
|
||||
}
|
||||
var windowFrom, lastSeen int64
|
||||
err = Get().QueryRow(
|
||||
`SELECT window_from, last_seen_at FROM adventure_visit WHERE user_sub = ?`,
|
||||
userSub).Scan(&windowFrom, &lastSeen)
|
||||
if err == sql.ErrNoRows {
|
||||
_, ierr := Get().Exec(
|
||||
`INSERT INTO adventure_visit (user_sub, window_from, last_seen_at) VALUES (?, ?, ?)`,
|
||||
userSub, now, now)
|
||||
return now, true, ierr
|
||||
}
|
||||
if err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
|
||||
// A new visit starts when the heartbeat has gone quiet for longer than the
|
||||
// session gap. Only then does the window move — and it moves to where the
|
||||
// reader actually left off (lastSeen), never to now, or the events between
|
||||
// their last page load and this one would fall down the crack between the two.
|
||||
if now-lastSeen > advVisitSessionGap {
|
||||
windowFrom = lastSeen
|
||||
}
|
||||
_, err = Get().Exec(
|
||||
`UPDATE adventure_visit SET window_from = ?, last_seen_at = ? WHERE user_sub = ?`,
|
||||
windowFrom, now, userSub)
|
||||
return windowFrom, false, err
|
||||
}
|
||||
|
||||
// EventsBySubjectSince is EventsBySubject narrowed to what is new. The limit is
|
||||
// applied to the *window*, not to the subject's whole history, so a player back
|
||||
// from a long absence gets the most recent N of what they missed rather than N
|
||||
// rows scanned from a history that might all predate the window.
|
||||
func EventsBySubjectSince(name string, sinceUnix int64, limit int) ([]AdvEvent, error) {
|
||||
if name == "" || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := Get().Query(`
|
||||
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||
level, tally, outcome, milestone, stakes, run_id, occurred_at
|
||||
FROM adventure_events
|
||||
WHERE (subject = ? OR opponent = ?) AND occurred_at > ?
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT ?`, name, name, sinceUnix, limit)
|
||||
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 sql.NullString
|
||||
var outcome, milestone, stakes, runID sql.NullString
|
||||
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent,
|
||||
&boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone,
|
||||
&stakes, &runID, &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
|
||||
e.RunID = runID.String
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAwayWindowOnlyMovesOnANewVisit pins the session gap directly. Within the
|
||||
// gap the window is held; past it, it advances to where the reader actually left
|
||||
// off — never to now, or everything between their last load and this one would
|
||||
// fall down the crack.
|
||||
func TestAwayWindowOnlyMovesOnANewVisit(t *testing.T) {
|
||||
if err := Init(t.TempDir() + "/visit.db"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { Close() })
|
||||
|
||||
t0 := int64(1_000_000)
|
||||
if _, first, err := AdvVisitWindow("sub-1", t0); err != nil || !first {
|
||||
t.Fatalf("first call: first=%v err=%v", first, err)
|
||||
}
|
||||
// A minute later: same visit, window pinned to where it started.
|
||||
from, _, err := AdvVisitWindow("sub-1", t0+60)
|
||||
if err != nil || from != t0 {
|
||||
t.Fatalf("window = %d (err %v), want it held at %d inside the session", from, err, t0)
|
||||
}
|
||||
// Well past the gap: a new visit, reading from the last heartbeat (t0+60),
|
||||
// not from now.
|
||||
from, _, err = AdvVisitWindow("sub-1", t0+60+advVisitSessionGap+1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if from != t0+60 {
|
||||
t.Errorf("window = %d, want the previous heartbeat %d — anything else drops or replays events",
|
||||
from, t0+60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
|
||||
// in pete_adventure_news_voice.md. Names are character names only (never Matrix
|
||||
// handles) and Actors is the allow-list of the only names permitted to appear in
|
||||
// rendered output.
|
||||
type AdvFact struct {
|
||||
GUID string `json:"guid"`
|
||||
EventType string `json:"event_type"`
|
||||
Tier string `json:"tier"` // "priority" | "bulletin"
|
||||
Actors []string `json:"actors"`
|
||||
Subject string `json:"subject"`
|
||||
Opponent string `json:"opponent"`
|
||||
Boss string `json:"boss"`
|
||||
Zone string `json:"zone"`
|
||||
Region string `json:"region"`
|
||||
Level int `json:"level"`
|
||||
Count int `json:"count"`
|
||||
Outcome string `json:"outcome"`
|
||||
Stakes string `json:"stakes"`
|
||||
ClassRace string `json:"class_race"`
|
||||
Milestone string `json:"milestone"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push"`
|
||||
// RunID names the expedition this dispatch is the ending of, on the three
|
||||
// event types that are one (a clear, a retreat, a death). It is what lets the
|
||||
// permalink offer the run's own report — the log, the numbers, the moment it
|
||||
// turned — instead of leaving a paragraph about an outcome with no way back to
|
||||
// what produced it. Empty on every other fact.
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
// 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
|
||||
// web-local so the web package needs no dependency on internal/poster.
|
||||
type AdvPost struct {
|
||||
GUID string
|
||||
Headline string
|
||||
Lede string
|
||||
ImageURL string
|
||||
ArticleURL string
|
||||
Source string
|
||||
Channel string
|
||||
}
|
||||
|
||||
// PriorityPoster posts a priority adventure item to Matrix immediately. main
|
||||
// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
|
||||
type PriorityPoster func(AdvPost)
|
||||
|
||||
const advSource = "Pete"
|
||||
|
||||
// advBackfillEvent is the synthetic post_log event id used to retire a no_push
|
||||
// (cold-start backfill) dispatch against the daily digest. It never went to
|
||||
// Matrix; the row exists only so the digest skips it.
|
||||
const advBackfillEvent = "adv-backfill"
|
||||
|
||||
// advRoomSilentEvent is the synthetic post_log event id used to retire a
|
||||
// dispatch whose event type gogobee announces in the games room itself. Like
|
||||
// advBackfillEvent it never went to Matrix; the row exists so the digest skips
|
||||
// it, and the distinct id keeps "TwinBee said it" separable from "backfilled"
|
||||
// when reading post_log later.
|
||||
const advRoomSilentEvent = "adv-room-silent"
|
||||
|
||||
// handleAdventureIngest receives a game-event fact from gogobee, templates it
|
||||
// into a deterministic story, publishes it to the /adventure section, and posts
|
||||
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
|
||||
func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var f AdvFact
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if f.GUID == "" || f.EventType == "" {
|
||||
http.Error(w, "guid and event_type are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Fact-guard: any player name we render must be in the actors allow-list.
|
||||
// gogobee pre-sanitizes, but Pete never trusts the channel — this is the
|
||||
// last line before a name reaches a public page.
|
||||
if !factGuard(f) {
|
||||
slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
|
||||
http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Render the template first. It is the fallback for every fact whose LLM
|
||||
// prose is absent or fails the guard.
|
||||
//
|
||||
// An event type Pete has no template for is NOT an error. It used to be a 400,
|
||||
// and that was the wrong call: gogobee retries a 400 to its cap and then parks
|
||||
// the dispatch forever, so the only thing the rejection accomplished was
|
||||
// deleting a real game event that Pete simply hadn't learned to phrase yet.
|
||||
// `companion_hire` was dropped that way from the day it shipped, and the
|
||||
// mitigation on the books ("always deploy Pete first") is a rule a human has
|
||||
// to remember rather than a property of the system.
|
||||
//
|
||||
// So: an unknown type publishes on the neutral fallback and is counted for the
|
||||
// operator. gogobee can ship a new event type any day; the worst case is a
|
||||
// thin card until Pete learns the words. 400 stays for facts that are actually
|
||||
// invalid — no guid, or a failed name guard.
|
||||
headline, lede, known := renderAdventure(f)
|
||||
if !known {
|
||||
advNoteUnknownType(f.EventType)
|
||||
slog.Warn("adventure ingest: no template for event_type, publishing on fallback",
|
||||
"guid", f.GUID, "event_type", f.EventType)
|
||||
headline, lede = advFallbackRender(f)
|
||||
}
|
||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success. Checked
|
||||
// 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) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("duplicate"))
|
||||
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:
|
||||
// dated 1970 on the permalink, pinned to the bottom of the section, and
|
||||
// outside every digest window. Treat "missing" as "now".
|
||||
occurredAt := f.OccurredAt
|
||||
if occurredAt <= 0 {
|
||||
occurredAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
articleURL := s.advPermalink(f.GUID)
|
||||
// Keyed on the guid, not the event type: the card renderer reads the fact
|
||||
// behind the dispatch so it can put the boss's name on it. The fact insert
|
||||
// below is best-effort, and the card degrades to the type-only emblem when
|
||||
// it isn't there — so this URL is safe to bake in before that runs.
|
||||
imageURL := advArtURL(f.GUID)
|
||||
if err := storage.InsertStory(&storage.Story{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ImageURL: imageURL,
|
||||
ArticleURL: articleURL,
|
||||
Source: advSource,
|
||||
Channel: "adventure",
|
||||
Classified: true,
|
||||
SeenAt: occurredAt,
|
||||
PublishedAt: occurredAt,
|
||||
}); err != nil {
|
||||
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
|
||||
http.Error(w, "insert failed", http.StatusInternalServerError)
|
||||
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,
|
||||
RunID: f.RunID,
|
||||
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)
|
||||
|
||||
// Two reasons a dispatch never reaches Matrix, both retired the same way:
|
||||
//
|
||||
// - NoPush: a cold-start backfill, the back-catalogue dump it exists to
|
||||
// prevent.
|
||||
// - A room-silent type: gogobee already announced this exact moment to the
|
||||
// games room in TwinBee's voice, and relaying it is the room hearing one
|
||||
// beat twice in two voices.
|
||||
//
|
||||
// Suppressing only the live post isn't enough: the digest collects adventure
|
||||
// rows that carry no post_log entry, so a held-back bulletin would still be
|
||||
// swept into the next roundup. Retire the guid against the digest up front
|
||||
// instead. The row was stored above either way, so the site, the permalink
|
||||
// and the push alerts keep the full record.
|
||||
if f.NoPush || s.roomSilent[f.EventType] {
|
||||
retiredAs := advBackfillEvent
|
||||
if !f.NoPush {
|
||||
retiredAs = advRoomSilentEvent
|
||||
}
|
||||
storage.MarkAdventureDigested([]string{f.GUID}, retiredAs)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
|
||||
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||||
// digest. Website section always gets the row above regardless of tier.
|
||||
//
|
||||
// An untemplated type never interrupts the room, whatever tier it claims.
|
||||
// Publishing one to the site is cheap and reversible — a thin card among
|
||||
// cards. Pinging everyone in Matrix with a dispatch Pete couldn't phrase is
|
||||
// neither. It still reaches Matrix through the daily digest, one line among
|
||||
// many, which is the right volume for something we don't understand yet.
|
||||
if f.Tier == "priority" && known && s.advPost != nil && s.adv.Channel != "" {
|
||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||
// media), and the link's og:image carries the preview instead.
|
||||
// No Source: the source tag exists to credit an outlet Pete is relaying
|
||||
// (`ars technica`). On his own reporting it renders as him signing his
|
||||
// own name under his own message.
|
||||
s.advPost(AdvPost{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: articleURL,
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// advEventMeta maps an event_type to a short display label and emoji used by the
|
||||
// permalink page (and, later, OG art selection). Unknown types fall back to a
|
||||
// neutral dispatch label so a new gogobee event never renders blank.
|
||||
func advEventMeta(eventType string) (label, emoji string) {
|
||||
switch eventType {
|
||||
case "siege_start", "siege_win", "siege_loss":
|
||||
return "The Siege", "🏰"
|
||||
case "boss_first", "boss_kill":
|
||||
return "Boss down", "🐉"
|
||||
case "zone_first":
|
||||
return "First clear", "🗺️"
|
||||
case "zone_clear":
|
||||
return "Zone cleared", "🗺️"
|
||||
case "treasure_found":
|
||||
return "Treasure", "💎"
|
||||
case "death":
|
||||
return "In memoriam", "🪦"
|
||||
case "arrival":
|
||||
return "New arrival", "👋"
|
||||
case "standings", "rival_result":
|
||||
return "The rival board", "⚔️"
|
||||
case "pete_duel_loss", "pete_duel_win":
|
||||
return "Pete's duels", "🤝"
|
||||
case "milestone":
|
||||
return "Milestone", "🏅"
|
||||
case "retreat":
|
||||
return "Pulled out", "🎒"
|
||||
case "departure":
|
||||
return "Wandered off", "🚪"
|
||||
case "mischief_contract":
|
||||
return "Coin on their head", "😈"
|
||||
case "mischief_survived":
|
||||
return "They walked away", "🛡️"
|
||||
case "mischief_downed":
|
||||
return "The contract landed", "💀"
|
||||
case "mischief_fizzled":
|
||||
return "Nobody home", "🚪"
|
||||
case "companion_hire":
|
||||
return "Pete tags along", "🎒"
|
||||
}
|
||||
return "Dispatch", "📣"
|
||||
}
|
||||
|
||||
// advUnknownTypes counts event types that arrived without a template, so an
|
||||
// operator can see what Pete needs to learn to write. Before the unknown-type
|
||||
// inversion these were 400s: gogobee retried to its cap and then parked the
|
||||
// dispatch forever, which is how `companion_hire` was silently dropped for
|
||||
// months. Now they publish on the neutral fallback and land here instead, where
|
||||
// the admin status page can say "companion_hire ×12, still no template".
|
||||
var advUnknownTypes = struct {
|
||||
sync.Mutex
|
||||
counts map[string]int
|
||||
}{counts: map[string]int{}}
|
||||
|
||||
func advNoteUnknownType(eventType string) {
|
||||
advUnknownTypes.Lock()
|
||||
defer advUnknownTypes.Unlock()
|
||||
advUnknownTypes.counts[eventType]++
|
||||
}
|
||||
|
||||
// AdvUnknownTypeCounts returns a copy of the untemplated-type tally.
|
||||
func AdvUnknownTypeCounts() map[string]int {
|
||||
advUnknownTypes.Lock()
|
||||
defer advUnknownTypes.Unlock()
|
||||
out := make(map[string]int, len(advUnknownTypes.counts))
|
||||
for k, v := range advUnknownTypes.counts {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withArticle prefixes a noun with "a"/"an". Class names are a small closed set
|
||||
// from the game ("cleric", "artificer"), so first-letter vowel is enough — this
|
||||
// is not trying to be a general English article engine.
|
||||
func withArticle(noun string) string {
|
||||
if noun == "" {
|
||||
return ""
|
||||
}
|
||||
switch noun[0] {
|
||||
case 'a', 'e', 'i', 'o', 'u':
|
||||
return "an " + noun
|
||||
}
|
||||
return "a " + noun
|
||||
}
|
||||
|
||||
// advFallbackRender is the dispatch for an event type Pete has no template for.
|
||||
//
|
||||
// It is deliberately thin and deliberately honest: it says something happened
|
||||
// and admits the details aren't in yet, rather than guessing at semantics Pete
|
||||
// doesn't have. Only guarded or game-authored fields reach it — Subject has
|
||||
// already passed factGuard, and Zone is game-authored — so it carries no more
|
||||
// exposure than any templated branch.
|
||||
//
|
||||
// In practice it is rarely what publishes. gogobee authors LLM prose for every
|
||||
// fact from the fact's fields with no per-type switch (authorDispatch in
|
||||
// pete_dispatch_voice.go), so an unknown type still arrives with a real headline
|
||||
// and lede, and this only shows through when the model is off or the prose-guard
|
||||
// rejected it.
|
||||
func advFallbackRender(f AdvFact) (headline, lede string) {
|
||||
const stillGetting = " I'm still getting the details on this one — I'll fill it in properly when I have them."
|
||||
switch {
|
||||
case f.Subject != "" && f.Zone != "":
|
||||
return fmt.Sprintf("Word in about %s.", f.Subject),
|
||||
fmt.Sprintf("Something happened out in %s involving %s.%s", f.Zone, f.Subject, stillGetting)
|
||||
case f.Subject != "":
|
||||
return fmt.Sprintf("Word in about %s.", f.Subject),
|
||||
fmt.Sprintf("%s has been up to something.%s", f.Subject, stillGetting)
|
||||
case f.Zone != "":
|
||||
return fmt.Sprintf("Something's happened in %s.", f.Zone),
|
||||
"Word just came in from the field." + stillGetting
|
||||
}
|
||||
return "Word in from the realm.", "Something happened out there." + stillGetting
|
||||
}
|
||||
|
||||
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
|
||||
// dispatch reads like the rest of the site, with an adventure-themed hero.
|
||||
type advStoryPage struct {
|
||||
pageData
|
||||
EventLabel string
|
||||
Emoji string
|
||||
Headline string
|
||||
Body string
|
||||
Region string
|
||||
When string
|
||||
Permalink string
|
||||
// RunReportURL is the link to the expedition behind this dispatch, when the
|
||||
// dispatch is the end of one and the run is still reachable. Empty is the
|
||||
// common case and renders nothing.
|
||||
RunReportURL string
|
||||
}
|
||||
|
||||
// handleAdventureStory serves the server-rendered permalink for one dispatch
|
||||
// (the article_url every ingested story points at). Public and cacheable; 404s
|
||||
// when the section is disabled or the guid is unknown. Character names in the
|
||||
// stored headline/body already passed the ingest fact-guard, so nothing
|
||||
// player-controlled reaches here unchecked.
|
||||
func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
guid := r.PathValue("guid")
|
||||
st, err := storage.GetStoryByGUID(guid)
|
||||
if err != nil || st == nil || st.Channel != "adventure" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.track(r, "adventure")
|
||||
|
||||
// event_type is encoded in the guid prefix (e.g. "death:<hash>:<ts>"); fall
|
||||
// back to the neutral dispatch meta when it isn't a known type.
|
||||
eventType, _, _ := strings.Cut(guid, ":")
|
||||
label, emoji := advEventMeta(eventType)
|
||||
|
||||
body := st.Content
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = st.Lede // template-only dispatches carry the write-up in the lede
|
||||
}
|
||||
|
||||
// The way back to what actually happened. Best-effort and usually absent: only
|
||||
// the three end-of-expedition types carry a run id at all, and the run behind
|
||||
// one is swept after a fortnight. A dispatch without it reads exactly as it
|
||||
// did before the report existed.
|
||||
// Region rides the same lookup rather than a second query. It is a *fact*
|
||||
// field, never on the story row — the story is the words Pete wrote and they
|
||||
// have no columns for where. So a dispatch filed before the fact table existed
|
||||
// still renders regionless, which is what it always did.
|
||||
runReport, region := "", ""
|
||||
if ev, err := storage.AdventureEventByGUID(guid); err != nil {
|
||||
slog.Error("adventure story: fact lookup failed", "guid", guid, "err", err)
|
||||
} else {
|
||||
runReport = runReportLinkFor(ev)
|
||||
if ev != nil {
|
||||
region = ev.Region
|
||||
}
|
||||
}
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "adventure"
|
||||
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
|
||||
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
|
||||
base.OGImage = abs + advArtURL(guid) // the dispatch's own card, for link unfurls
|
||||
}
|
||||
s.render(w, "story", advStoryPage{
|
||||
pageData: base,
|
||||
EventLabel: label,
|
||||
Emoji: emoji,
|
||||
Headline: st.Headline,
|
||||
Body: body,
|
||||
Region: region,
|
||||
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||||
Permalink: s.advPermalink(guid),
|
||||
RunReportURL: runReport,
|
||||
})
|
||||
}
|
||||
|
||||
// bearerOK checks the Authorization: Bearer header against the configured ingest
|
||||
// token in constant time.
|
||||
func (s *Server) bearerOK(r *http.Request) bool {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
|
||||
return false
|
||||
}
|
||||
got := strings.TrimPrefix(h, prefix)
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
|
||||
}
|
||||
|
||||
// siteURL makes a root-relative path absolute against BaseURL. Links that go out
|
||||
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
|
||||
// configured the relative form is all we have, and it's still fine on-site.
|
||||
func (s *Server) siteURL(path string) string {
|
||||
return strings.TrimRight(s.cfg.BaseURL, "/") + path
|
||||
}
|
||||
|
||||
// advPermalink builds the per-story Pete permalink used as article_url (the card
|
||||
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
|
||||
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
|
||||
func (s *Server) advPermalink(guid string) string {
|
||||
return s.siteURL("/adventure/" + url.PathEscape(guid))
|
||||
}
|
||||
|
||||
// factGuard verifies every player-name field we might render is present in the
|
||||
// actors allow-list. Boss/zone/region/milestone are game-authored content, not
|
||||
// player-controlled, so they are not guarded.
|
||||
func factGuard(f AdvFact) bool {
|
||||
allow := make(map[string]bool, len(f.Actors))
|
||||
for _, a := range f.Actors {
|
||||
if a != "" {
|
||||
allow[a] = true
|
||||
}
|
||||
}
|
||||
if f.Subject != "" && !allow[f.Subject] {
|
||||
return false
|
||||
}
|
||||
if f.Opponent != "" && !allow[f.Opponent] {
|
||||
return false
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
||||
atLevel := ""
|
||||
if f.Level > 0 {
|
||||
atLevel = fmt.Sprintf(", at level %d", f.Level)
|
||||
}
|
||||
switch f.EventType {
|
||||
case "siege_start":
|
||||
return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
|
||||
fmt.Sprintf("Folks, this is the big one — %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
|
||||
case "siege_win":
|
||||
return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
|
||||
fmt.Sprintf("What a turnout — %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
|
||||
case "siege_loss":
|
||||
return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
|
||||
fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time — heads up, everyone.", f.Boss), true
|
||||
case "boss_first":
|
||||
return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
|
||||
fmt.Sprintf("History in %s today — %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
|
||||
case "boss_kill":
|
||||
return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
|
||||
fmt.Sprintf("Another clean run in %s today. Routine for %s by now — but still worth a nod.", f.Zone, f.Subject), true
|
||||
case "zone_first", "zone_clear":
|
||||
// gogobee splits the realm's first-ever clear (zone_first, priority) from a
|
||||
// later repeat (zone_clear, bulletin); they share a lede but differ in
|
||||
// headline. Fall back to the tier for a legacy zone_first that predates the
|
||||
// split.
|
||||
if f.EventType == "zone_first" || f.Tier == "priority" {
|
||||
headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
|
||||
} else {
|
||||
headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
|
||||
}
|
||||
inRegion := ""
|
||||
if 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
|
||||
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":
|
||||
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
|
||||
case "retreat":
|
||||
// An expedition that came apart without killing anyone. Until gogobee
|
||||
// started sending these, the feed had no way to say "it went badly and
|
||||
// everyone lived" — so it never said it, and the classes that retreat
|
||||
// often simply never appeared. Warm, not a failure notice: everyone came
|
||||
// home, and that is the part Pete leads with.
|
||||
howFar := "barely a day in"
|
||||
if f.Count > 1 {
|
||||
howFar = fmt.Sprintf("%d days in", f.Count)
|
||||
}
|
||||
return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone),
|
||||
fmt.Sprintf("%s turned around %s — %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.",
|
||||
f.Subject, howFar, f.Zone, atLevel), true
|
||||
case "departure":
|
||||
// A bored adventurer let themselves out. Nobody sent them — they got
|
||||
// restless waiting on a player who wasn't coming, took the cheap supplies
|
||||
// they could afford, and went. Pete plays it straight and a little fond;
|
||||
// the joke tells itself, and the player it's about may well be reading.
|
||||
return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
|
||||
fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
|
||||
case "mischief_contract":
|
||||
// Somebody paid to have a monster sent after an adventurer who is out in a
|
||||
// dungeon right now. Anonymous unless the buyer paid extra to sign it, and
|
||||
// the anonymity is the story: Pete reports the money, not the name he
|
||||
// doesn't have. Opponent carries the buyer only when it's public.
|
||||
if f.Opponent != "" {
|
||||
return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject),
|
||||
fmt.Sprintf("No secret about it — %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.",
|
||||
f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true
|
||||
}
|
||||
return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject),
|
||||
fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs — and we all find out who signed the cheque.",
|
||||
f.Stakes, strings.ToLower(f.Boss), f.Subject), true
|
||||
case "mischief_survived":
|
||||
// The unseal. A survival is the only thing that names an anonymous buyer,
|
||||
// and it is the whole brake on casual griefing — so Pete leads with it.
|
||||
return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent),
|
||||
fmt.Sprintf("%s came for %s, and %s is the one still standing — %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.",
|
||||
f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true
|
||||
case "mischief_downed":
|
||||
who := "Nobody's saying who paid for it"
|
||||
if f.Opponent != "" {
|
||||
who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent)
|
||||
}
|
||||
return fmt.Sprintf("%s didn't walk away.", f.Subject),
|
||||
fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home — alive, which is more than the contract asked for, but that expedition's finished. %s.",
|
||||
f.Boss, f.Subject, atLevel, who), true
|
||||
case "mischief_fizzled":
|
||||
return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject),
|
||||
fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.",
|
||||
f.Subject, f.Subject), true
|
||||
case "arrival":
|
||||
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
|
||||
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
|
||||
case "standings":
|
||||
return "The rival board's been shaken up.",
|
||||
fmt.Sprintf("%s is on the move — here's where the standings sit today.", f.Subject), true
|
||||
case "rival_result":
|
||||
return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
|
||||
fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
|
||||
case "pete_duel_loss":
|
||||
if f.Tier == "priority" {
|
||||
headline = fmt.Sprintf("You got me, %s.", f.Subject)
|
||||
} else {
|
||||
headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
|
||||
}
|
||||
return headline, fmt.Sprintf("Credit where it's due — %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
|
||||
case "pete_duel_win":
|
||||
return fmt.Sprintf("Held my ground against %s today.", f.Subject),
|
||||
fmt.Sprintf("Closer than the record will show, honestly — %s pushed me. Rematch whenever you like.", f.Subject), true
|
||||
case "milestone":
|
||||
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
||||
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
||||
case "companion_hire":
|
||||
// Pete himself has been hired onto somebody's expedition. He is the one
|
||||
// being reported on here, so this is the one family besides his duels that
|
||||
// is properly first-person. Subject is the LEADER who hired him (and whose
|
||||
// opt-out therefore applies), class_race is the seat he's filling.
|
||||
seat, seatArticled := strings.ToLower(f.ClassRace), ""
|
||||
if seat == "" {
|
||||
seat, seatArticled = "an extra pair of hands", "an extra pair of hands"
|
||||
} else {
|
||||
seatArticled = withArticle(seat)
|
||||
}
|
||||
intoZone := ""
|
||||
if f.Zone != "" {
|
||||
intoZone = " into " + f.Zone
|
||||
}
|
||||
return fmt.Sprintf("Filling in as %s for %s.", seat, f.Subject),
|
||||
fmt.Sprintf("%s needed %s and I wasn't doing much, so I'm tagging along%s%s. I'll pull my weight — the reporting can wait till we're home.",
|
||||
f.Subject, seatArticled, intoZone, atLevel), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The dispatch card.
|
||||
//
|
||||
// Every adventure dispatch used to render the same image: one violet gradient,
|
||||
// a swapped emoji, and a label. A death, a realm-first, and a legendary hoard
|
||||
// were visually identical — and this image is not decoration, it is the og:image
|
||||
// on every link Pete puts in Matrix and the thumbnail on every feed card. The
|
||||
// most interesting thing that has ever happened in the realm looked exactly like
|
||||
// the most routine.
|
||||
//
|
||||
// Two changes fix that. The card is keyed on the dispatch GUID rather than the
|
||||
// event type, so it can read the fact behind the dispatch and put the actual
|
||||
// NOUNS on it — the boss's name, the zone, the level, the item. And each event
|
||||
// family gets its own palette, with treasure tinted by the rarity gogobee
|
||||
// already computes and throws away into a sentence.
|
||||
//
|
||||
// Everything here stays deterministic, server-rendered, dependency-free SVG:
|
||||
// same input, same bytes, no external asset, no font file, cacheable forever.
|
||||
|
||||
// advArtCard is the fully-resolved card: what to draw, already escaped-safe as
|
||||
// plain text (the renderer escapes on write). Built by advArtCardFor.
|
||||
type advArtCard struct {
|
||||
Label string // the event-family chip, e.g. "THE SIEGE"
|
||||
Emoji string
|
||||
Noun string // the headline noun: boss, zone, item, or adventurer
|
||||
Detail string // the supporting line: region, level, who
|
||||
Ceremony string // ribbon text for a realm-first; "" for everything else
|
||||
Palette advPalette
|
||||
Bar *advArtBar // siege HP, when we have it
|
||||
}
|
||||
|
||||
// advArtBar is the Siege health bar drawn onto a siege card. This is the W1
|
||||
// deferral landing: a Matrix unfurl of "the town holds" that SHOWS the bar is
|
||||
// worth ten paragraphs, and it was parked here because doing it in W1 would have
|
||||
// meant threading boss HP through art plumbing this phase was going to redesign.
|
||||
type advArtBar struct {
|
||||
Current, Max int
|
||||
}
|
||||
|
||||
// advPalette is one event family's colours. From/To are the background gradient
|
||||
// stops; Accent tints the chip, the ribbon and the bar fill, and is also what a
|
||||
// feed card borrows for its border.
|
||||
type advPalette struct {
|
||||
From, To, Accent string
|
||||
}
|
||||
|
||||
// The house palette. Dark, saturated backgrounds so white text always clears
|
||||
// contrast, with an accent bright enough to read as a border on both the light
|
||||
// and dark site themes.
|
||||
var (
|
||||
palSiege = advPalette{"#7a1f12", "#2b0a06", "#ff6b3d"} // ember: the town is on fire
|
||||
palDeath = advPalette{"#3b4250", "#171a20", "#9aa6b8"} // slate: no colour, on purpose
|
||||
palBoss = advPalette{"#4a1030", "#1a0714", "#ff4d6d"}
|
||||
palZone = advPalette{"#14532d", "#052e16", "#4ade80"}
|
||||
palMischief = advPalette{"#4c1d95", "#120524", "#a78bfa"}
|
||||
palArrival = advPalette{"#0e7490", "#083344", "#22d3ee"}
|
||||
palMilestone = advPalette{"#a16207", "#422006", "#fbbf24"}
|
||||
palSetback = advPalette{"#78350f", "#2a1206", "#f59e0b"} // retreat, departure
|
||||
palRival = advPalette{"#1e3a8a", "#0b1a3d", "#60a5fa"}
|
||||
palPete = advPalette{"#7c5ce8", "#5836b8", "#c4b5fd"} // Pete's own violet
|
||||
palNeutral = advPalette{"#7c5ce8", "#5836b8", "#c4b5fd"} // the old one-and-only
|
||||
|
||||
// Treasure is tinted by rarity — the loot-game convention, and gogobee
|
||||
// already computes the word (treasureRarityWord) and spends it on prose.
|
||||
palLegendary = advPalette{"#b4530a", "#4a1d02", "#ffb020"}
|
||||
palEpic = advPalette{"#5b21b6", "#2e1065", "#c084fc"}
|
||||
palRare = advPalette{"#1e3a8a", "#0b1a3d", "#60a5fa"}
|
||||
palUncommon = advPalette{"#14532d", "#052e16", "#4ade80"}
|
||||
palCommon = advPalette{"#3f3f46", "#18181b", "#a1a1aa"}
|
||||
)
|
||||
|
||||
// advPaletteFor picks the family colours. outcome carries the treasure rarity
|
||||
// and is ignored everywhere else.
|
||||
func advPaletteFor(eventType, outcome string) advPalette {
|
||||
switch eventType {
|
||||
case "siege_start", "siege_win", "siege_loss":
|
||||
return palSiege
|
||||
case "death":
|
||||
return palDeath
|
||||
case "boss_first", "boss_kill":
|
||||
return palBoss
|
||||
case "zone_first", "zone_clear":
|
||||
return palZone
|
||||
case "treasure_found":
|
||||
switch strings.ToLower(outcome) {
|
||||
case "legendary":
|
||||
return palLegendary
|
||||
case "epic":
|
||||
return palEpic
|
||||
case "rare":
|
||||
return palRare
|
||||
case "uncommon":
|
||||
return palUncommon
|
||||
case "common":
|
||||
return palCommon
|
||||
}
|
||||
return palLegendary // story-grade finds are typically tier 5
|
||||
case "mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled":
|
||||
return palMischief
|
||||
case "arrival":
|
||||
return palArrival
|
||||
case "milestone":
|
||||
return palMilestone
|
||||
case "retreat", "departure":
|
||||
return palSetback
|
||||
case "standings", "rival_result", "pete_duel_win", "pete_duel_loss":
|
||||
return palRival
|
||||
case "companion_hire":
|
||||
return palPete
|
||||
}
|
||||
return palNeutral
|
||||
}
|
||||
|
||||
// advIsRealmFirst reports whether a dispatch is the first time anything like it
|
||||
// has ever happened in the realm. gogobee already computes this — it is the
|
||||
// priority/bulletin split claimRealmFirst applies — and until now it only
|
||||
// decided whether Matrix got pinged. A thing nobody has ever done should also
|
||||
// LOOK different from the ninth time somebody did it.
|
||||
func advIsRealmFirst(eventType, tier string) bool {
|
||||
switch eventType {
|
||||
case "boss_first", "zone_first":
|
||||
return true
|
||||
case "treasure_found":
|
||||
// A realm-first hoard rides the priority tier, the same split
|
||||
// BuildTrophyCase counts on.
|
||||
return tier == "priority"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// advCardAccent is the feed-card tint for a dispatch: the family accent colour
|
||||
// and whether it earns the realm-first ring. Returns "" for a non-adventure or
|
||||
// unknown story so the caller leaves the card's default border alone.
|
||||
func advCardAccent(eventType, tier, outcome string) (accent string, ceremony bool) {
|
||||
if eventType == "" {
|
||||
return "", false
|
||||
}
|
||||
return advPaletteFor(eventType, outcome).Accent, advIsRealmFirst(eventType, tier)
|
||||
}
|
||||
|
||||
// advArtCardFor resolves a dispatch into everything the card draws.
|
||||
//
|
||||
// ev is nil for a dispatch with no stored fact — anything that predates the fact
|
||||
// table, plus the brief window where the story row exists and the best-effort
|
||||
// fact insert failed. That degrades to exactly the old card (family palette,
|
||||
// emoji, label) rather than to a broken one.
|
||||
func advArtCardFor(eventType string, ev *storage.AdvEvent) advArtCard {
|
||||
label, emoji := advEventMeta(eventType)
|
||||
card := advArtCard{Label: strings.ToUpper(label), Emoji: emoji, Palette: advPaletteFor(eventType, "")}
|
||||
if ev == nil {
|
||||
return card
|
||||
}
|
||||
|
||||
card.Palette = advPaletteFor(eventType, ev.Outcome)
|
||||
if advIsRealmFirst(eventType, ev.Tier) {
|
||||
card.Ceremony = "REALM FIRST"
|
||||
}
|
||||
|
||||
// The noun is whatever the dispatch is ABOUT — which is not the same field
|
||||
// from family to family. A siege is about the boss; a treasure is about the
|
||||
// item; a death is about the person.
|
||||
switch eventType {
|
||||
case "siege_start", "siege_win", "siege_loss":
|
||||
card.Noun = ev.Boss
|
||||
card.Detail = advSiegeDetail(eventType, ev.Tally)
|
||||
if cur, max, ok := storage.SiegeBarForBoss(ev.Boss, ev.OccurredAt); ok {
|
||||
card.Bar = &advArtBar{Current: cur, Max: max}
|
||||
}
|
||||
case "boss_first", "boss_kill":
|
||||
card.Noun = ev.Boss
|
||||
card.Detail = advJoinDetail(ev.Subject, ev.Zone, ev.Level)
|
||||
case "zone_first", "zone_clear":
|
||||
card.Noun = ev.Zone
|
||||
card.Detail = advJoinDetail(ev.Subject, ev.Region, ev.Level)
|
||||
case "treasure_found":
|
||||
card.Noun = ev.Stakes // the item's name
|
||||
card.Detail = advJoinDetail(ev.Subject, ev.Zone, ev.Level)
|
||||
if ev.Outcome != "" {
|
||||
card.Label = strings.ToUpper(ev.Outcome)
|
||||
}
|
||||
case "mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled":
|
||||
card.Noun = ev.Subject
|
||||
card.Detail = advJoinDetail(ev.Boss, ev.Zone, ev.Level)
|
||||
case "companion_hire":
|
||||
card.Noun = ev.Subject
|
||||
card.Detail = advJoinDetail("", ev.Zone, ev.Level)
|
||||
case "milestone":
|
||||
card.Noun = ev.Subject
|
||||
card.Detail = ev.Milestone
|
||||
default:
|
||||
card.Noun = ev.Subject
|
||||
card.Detail = advJoinDetail("", ev.Zone, ev.Level)
|
||||
}
|
||||
if card.Noun == "" { // a fact missing its own subject still gets a card
|
||||
card.Noun = ev.Subject
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
// advSiegeDetail is the siege card's supporting line. Tally is the defender
|
||||
// count on a win; a start has none yet.
|
||||
func advSiegeDetail(eventType string, defenders int) string {
|
||||
switch {
|
||||
case eventType == "siege_start":
|
||||
return "the town is called out"
|
||||
case defenders == 1:
|
||||
return "1 defender"
|
||||
case defenders > 1:
|
||||
return fmt.Sprintf("%d defenders", defenders)
|
||||
case eventType == "siege_win":
|
||||
return "the town holds"
|
||||
}
|
||||
return "the gates gave way"
|
||||
}
|
||||
|
||||
// advJoinDetail assembles the supporting line from whichever of who/where/level
|
||||
// the fact actually has, dot-separated, skipping the empties. A card with one
|
||||
// real field reads better than one padded out with "unknown".
|
||||
func advJoinDetail(who, where string, level int) string {
|
||||
var parts []string
|
||||
if who != "" {
|
||||
parts = append(parts, who)
|
||||
}
|
||||
if where != "" {
|
||||
parts = append(parts, where)
|
||||
}
|
||||
if level > 0 {
|
||||
parts = append(parts, fmt.Sprintf("level %d", level))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// advArtURL is the card/OG image for a dispatch, keyed on the dispatch GUID so
|
||||
// the renderer can read the fact behind it and name names. Root-relative, so it
|
||||
// bypasses the external-image thumbnailer.
|
||||
//
|
||||
// The guid is path-escaped for the same reason advPermalink escapes it: it is
|
||||
// ingest-supplied, and a stray "/" would produce a URL that routes somewhere
|
||||
// else entirely.
|
||||
//
|
||||
// Older stories have an event-type URL baked into their image_url column
|
||||
// (/adventure/art/death.svg). Those keep working — handleAdventureArt falls back
|
||||
// to the type-only card when the path isn't a guid it knows — so nothing has to
|
||||
// be backfilled and no card ever 404s.
|
||||
func advArtURL(guid string) string {
|
||||
return "/adventure/art/" + url.PathEscape(guid) + ".svg"
|
||||
}
|
||||
|
||||
// handleAdventureArt renders one dispatch's card.
|
||||
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
key := strings.TrimSuffix(r.PathValue("type"), ".svg")
|
||||
|
||||
// The path is either a guid ("death:<hash>:<ts>") or, for a story from
|
||||
// before this was guid-keyed, a bare event type. Both start with the event
|
||||
// type, so the family colours are right either way; only the nouns need the
|
||||
// fact row.
|
||||
ev, err := storage.AdventureEventByGUID(key)
|
||||
if err != nil {
|
||||
ev = nil // a read failure is a thinner card, not a broken image
|
||||
}
|
||||
eventType := key
|
||||
if t, _, hasSep := strings.Cut(key, ":"); hasSep {
|
||||
eventType = t
|
||||
}
|
||||
card := advArtCardFor(eventType, ev)
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
||||
// A finished card never changes, so cache it hard. The exception is a siege
|
||||
// card still waiting on its bar: the win/loss dispatch is filed before the
|
||||
// war-room push that explains it, and an unfurl fetched in that window would
|
||||
// otherwise be pinned barless for a day.
|
||||
if card.Bar == nil && strings.HasPrefix(eventType, "siege_") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
}
|
||||
_, _ = w.Write([]byte(advRenderArt(card)))
|
||||
}
|
||||
|
||||
// Card geometry. 1200×630 is the OG ratio, so the same image works as a
|
||||
// link-preview and as a feed thumbnail.
|
||||
const (
|
||||
advArtW = 1200
|
||||
advArtH = 630
|
||||
|
||||
// advSafeX is the horizontal margin anything readable has to stay inside.
|
||||
//
|
||||
// The card is 1200×630 for the OG ratio, but the feed thumbnail is a 16/10
|
||||
// object-cover box — it keeps the full height and crops the WIDTH to
|
||||
// 630×1.6 = 1008px, taking 96px off each side. A chip pinned at x=64 renders
|
||||
// perfectly on the permalink and as "EGENDARY" in the feed. Centred text is
|
||||
// unaffected; only the corner furniture has to respect this.
|
||||
advSafeX = 116
|
||||
)
|
||||
|
||||
// advDisplayFont is the site's display stack. No @font-face: an SVG served as an
|
||||
// image can't fetch one, so this resolves against whatever the renderer has and
|
||||
// falls through to the system UI face.
|
||||
const advDisplayFont = "Fredoka, Nunito, system-ui, sans-serif"
|
||||
|
||||
// advRenderArt draws the card. Deterministic: same card in, same bytes out.
|
||||
func advRenderArt(c advArtCard) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
|
||||
advArtW, advArtH, advArtW, advArtH, esc(c.Label+" "+c.Noun))
|
||||
fmt.Fprintf(&b, `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="%s"/><stop offset="1" stop-color="%s"/></linearGradient>`,
|
||||
esc(c.Palette.From), esc(c.Palette.To))
|
||||
// A soft radial lift behind the emoji so the middle of the card isn't flat.
|
||||
fmt.Fprintf(&b, `<radialGradient id="v" cx="0.5" cy="0.34" r="0.62"><stop offset="0" stop-color="%s" stop-opacity="0.35"/><stop offset="1" stop-color="%s" stop-opacity="0"/></radialGradient></defs>`,
|
||||
esc(c.Palette.Accent), esc(c.Palette.Accent))
|
||||
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="url(#g)"/><rect width="%d" height="%d" fill="url(#v)"/>`,
|
||||
advArtW, advArtH, advArtW, advArtH)
|
||||
// An accent hairline along the bottom, so even a card cropped to a strip in
|
||||
// a feed still carries its family colour.
|
||||
fmt.Fprintf(&b, `<rect x="0" y="%d" width="%d" height="8" fill="%s"/>`, advArtH-8, advArtW, esc(c.Palette.Accent))
|
||||
|
||||
advDrawChip(&b, c)
|
||||
if c.Ceremony != "" {
|
||||
advDrawRibbon(&b, c)
|
||||
}
|
||||
|
||||
// Vertical rhythm: emoji, noun, detail, and the bar when there is one. The
|
||||
// block sits higher when a bar has to fit under it. The gaps are wider than
|
||||
// they look on paper because an emoji is drawn from its own centre and a
|
||||
// name from its baseline — set them any closer and a tall glyph sits on top
|
||||
// of the capital letters underneath it.
|
||||
emojiY, nounY, detailY := 250, 420, 488
|
||||
if c.Bar != nil {
|
||||
emojiY, nounY, detailY = 200, 350, 412
|
||||
}
|
||||
if c.Noun == "" {
|
||||
// Nothing to name (a pre-fact-table dispatch): centre the emoji and let
|
||||
// the label carry the card, which is what the old emblem did.
|
||||
emojiY, nounY, detailY = 300, 0, 500
|
||||
}
|
||||
fmt.Fprintf(&b, `<text x="600" y="%d" font-size="%d" text-anchor="middle" dominant-baseline="central">%s</text>`,
|
||||
emojiY, advEmojiSize(c), esc(c.Emoji))
|
||||
|
||||
if c.Noun != "" {
|
||||
noun := advClamp(c.Noun, 38)
|
||||
fmt.Fprintf(&b, `<text x="600" y="%d" font-size="%d" font-family="%s" font-weight="700" fill="#ffffff" text-anchor="middle">%s</text>`,
|
||||
nounY, advFitSize(noun, 1060, 82, 40), advDisplayFont, esc(noun))
|
||||
}
|
||||
if c.Detail != "" {
|
||||
detail := advClamp(c.Detail, 64)
|
||||
fmt.Fprintf(&b, `<text x="600" y="%d" font-size="%d" font-family="%s" font-weight="600" fill="#ffffff" fill-opacity="0.72" text-anchor="middle">%s</text>`,
|
||||
detailY, advFitSize(detail, 1040, 38, 26), advDisplayFont, esc(detail))
|
||||
}
|
||||
if c.Bar != nil {
|
||||
advDrawBar(&b, c)
|
||||
}
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// advEmojiSize shrinks the emblem when the card also has to carry a name — the
|
||||
// old 260px glyph was the whole design, and next to a boss name it just crowds
|
||||
// it out.
|
||||
func advEmojiSize(c advArtCard) int {
|
||||
if c.Noun == "" {
|
||||
return 260
|
||||
}
|
||||
if c.Bar != nil {
|
||||
return 110
|
||||
}
|
||||
return 148
|
||||
}
|
||||
|
||||
// advDrawChip draws the event-family label as a pill in the top-left.
|
||||
func advDrawChip(b *strings.Builder, c advArtCard) {
|
||||
label := advClamp(c.Label, 28)
|
||||
// Letter-spaced small caps: width is the glyph run plus the tracking.
|
||||
const size, track = 30, 5.0
|
||||
w := int(float64(utf8.RuneCountInString(label))*(float64(size)*0.62+track)) + 56
|
||||
fmt.Fprintf(b, `<rect x="%d" y="56" width="%d" height="60" rx="30" fill="%s" fill-opacity="0.22" stroke="%s" stroke-opacity="0.55" stroke-width="2"/>`,
|
||||
advSafeX, w, esc(c.Palette.Accent), esc(c.Palette.Accent))
|
||||
fmt.Fprintf(b, `<text x="%d" y="86" font-size="%d" font-family="%s" font-weight="700" fill="#ffffff" letter-spacing="%.0f" text-anchor="middle" dominant-baseline="central">%s</text>`,
|
||||
advSafeX+w/2, size, advDisplayFont, track, esc(label))
|
||||
}
|
||||
|
||||
// advDrawRibbon draws the realm-first banner in the top-right. Filled with the
|
||||
// accent at full strength — this is the one card element allowed to shout.
|
||||
func advDrawRibbon(b *strings.Builder, c advArtCard) {
|
||||
label := advClamp(c.Ceremony, 24)
|
||||
const size, track = 28, 5.0
|
||||
w := int(float64(utf8.RuneCountInString(label))*(float64(size)*0.62+track)) + 52
|
||||
x := advArtW - advSafeX - w
|
||||
fmt.Fprintf(b, `<rect x="%d" y="56" width="%d" height="60" rx="12" fill="%s"/>`, x, w, esc(c.Palette.Accent))
|
||||
fmt.Fprintf(b, `<text x="%d" y="86" font-size="%d" font-family="%s" font-weight="700" fill="#0f0a04" letter-spacing="%.0f" text-anchor="middle" dominant-baseline="central">%s</text>`,
|
||||
x+w/2, size, advDisplayFont, track, esc(label))
|
||||
}
|
||||
|
||||
// advDrawBar draws the Siege health bar: the track, the fill, and the numbers.
|
||||
//
|
||||
// HP remaining, not damage dealt — the same direction the war room page draws,
|
||||
// so the unfurl and the page you land on from it agree. The caption is
|
||||
// event-aware because the bar alone doesn't say who won: an empty track on a
|
||||
// victory card is the best possible outcome and would otherwise read at a glance
|
||||
// as a wipe.
|
||||
func advDrawBar(b *strings.Builder, c advArtCard) {
|
||||
const x, y, w, h = 190, 470, 820, 34
|
||||
frac := 0.0
|
||||
if c.Bar.Max > 0 {
|
||||
frac = float64(c.Bar.Current) / float64(c.Bar.Max)
|
||||
}
|
||||
if frac < 0 {
|
||||
frac = 0
|
||||
}
|
||||
if frac > 1 {
|
||||
frac = 1
|
||||
}
|
||||
fill := int(frac * w)
|
||||
fmt.Fprintf(b, `<rect x="%d" y="%d" width="%d" height="%d" rx="%d" fill="#000000" fill-opacity="0.38"/>`, x, y, w, h, h/2)
|
||||
if fill > 0 {
|
||||
// rx on a very short fill would round it away to nothing; clamp the
|
||||
// corner radius to half the drawn width so a nearly-dead boss still
|
||||
// shows a sliver.
|
||||
rx := h / 2
|
||||
if fill/2 < rx {
|
||||
rx = fill / 2
|
||||
}
|
||||
fmt.Fprintf(b, `<rect x="%d" y="%d" width="%d" height="%d" rx="%d" fill="%s"/>`, x, y, fill, h, rx, esc(c.Palette.Accent))
|
||||
}
|
||||
fmt.Fprintf(b, `<text x="600" y="%d" font-size="30" font-family="%s" font-weight="700" fill="#ffffff" fill-opacity="0.82" text-anchor="middle">%s</text>`,
|
||||
y+h+40, advDisplayFont, esc(advBarCaption(c)))
|
||||
}
|
||||
|
||||
// advBarCaption says what the bar means. A siege_win's bar is empty because the
|
||||
// town emptied it.
|
||||
func advBarCaption(c advArtCard) string {
|
||||
switch {
|
||||
case c.Bar.Current <= 0:
|
||||
return fmt.Sprintf("all %s of it, brought to zero", advComma(c.Bar.Max))
|
||||
case c.Bar.Current >= c.Bar.Max:
|
||||
return fmt.Sprintf("%s HP, untouched so far", advComma(c.Bar.Max))
|
||||
}
|
||||
return fmt.Sprintf("%s HP left of %s", advComma(c.Bar.Current), advComma(c.Bar.Max))
|
||||
}
|
||||
|
||||
// advComma renders an int with thousands separators. Siege pools run to five and
|
||||
// six figures, and "18000" is a number you have to stop and parse.
|
||||
func advComma(n int) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
neg := strings.HasPrefix(s, "-")
|
||||
s = strings.TrimPrefix(s, "-")
|
||||
var out []byte
|
||||
for i, d := range []byte(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
if neg {
|
||||
return "-" + string(out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// advFitSize shrinks a font size until the string is likely to fit maxWidth,
|
||||
// never below min. SVG has no measurement API on the server, so this is the
|
||||
// standard approximation: ~0.58em per glyph for a humanist sans. Being a little
|
||||
// conservative is the correct failure — a name that renders a shade small is
|
||||
// fine, a name that runs off the card is not.
|
||||
func advFitSize(s string, maxWidth, base, min int) int {
|
||||
n := utf8.RuneCountInString(s)
|
||||
if n == 0 {
|
||||
return base
|
||||
}
|
||||
size := base
|
||||
for size > min && float64(n)*float64(size)*0.58 > float64(maxWidth) {
|
||||
size -= 2
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// advClamp truncates to n runes with an ellipsis. Rune-aware so a non-ASCII
|
||||
// name isn't cut mid-character into a replacement glyph.
|
||||
func advClamp(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
return strings.TrimRight(string(r[:n-1]), " ·") + "…"
|
||||
}
|
||||
|
||||
// esc escapes text for an SVG text node or attribute value. Everything on a card
|
||||
// is either game-authored (boss, zone, item) or a character name that already
|
||||
// passed the ingest fact-guard, so this is defence in depth rather than the only
|
||||
// line — but the card is a public URL and it stays escaped regardless.
|
||||
func esc(s string) string { return template.HTMLEscapeString(s) }
|
||||
@@ -0,0 +1,269 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestArtPaletteSplitsFamilies is the regression for the whole point of W3: two
|
||||
// different kinds of dispatch must not render the same image. Before this, every
|
||||
// card in the section was the same violet gradient with a swapped emoji.
|
||||
func TestArtPaletteSplitsFamilies(t *testing.T) {
|
||||
death := advPaletteFor("death", "")
|
||||
siege := advPaletteFor("siege_win", "")
|
||||
zone := advPaletteFor("zone_clear", "")
|
||||
if death == siege || siege == zone || death == zone {
|
||||
t.Errorf("families share a palette: death=%v siege=%v zone=%v", death, siege, zone)
|
||||
}
|
||||
|
||||
// Treasure is tinted by the rarity gogobee already computes and currently
|
||||
// spends on an adjective in a sentence.
|
||||
leg := advPaletteFor("treasure_found", "legendary")
|
||||
rare := advPaletteFor("treasure_found", "rare")
|
||||
if leg == rare {
|
||||
t.Errorf("legendary and rare hoards render identically: %v", leg)
|
||||
}
|
||||
// Case shouldn't matter — the rarity word arrives however gogobee wrote it.
|
||||
if advPaletteFor("treasure_found", "Legendary") != leg {
|
||||
t.Error("rarity match is case-sensitive")
|
||||
}
|
||||
// An unrecognised rarity still gets the story-grade treatment rather than
|
||||
// falling through to the neutral card.
|
||||
if advPaletteFor("treasure_found", "mythic") != leg {
|
||||
t.Error("unknown rarity dropped to the neutral palette")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealmFirstEarnsCeremony pins that the priority/bulletin split gogobee
|
||||
// already computes now changes how a card LOOKS, not just whether Matrix gets
|
||||
// pinged. A first-ever clear and the ninth repeat of it must be distinguishable.
|
||||
func TestRealmFirstEarnsCeremony(t *testing.T) {
|
||||
cases := []struct {
|
||||
eventType, tier string
|
||||
want bool
|
||||
}{
|
||||
{"zone_first", "priority", true},
|
||||
{"zone_clear", "bulletin", false},
|
||||
{"boss_first", "priority", true},
|
||||
{"boss_kill", "bulletin", false},
|
||||
{"treasure_found", "priority", true}, // realm-first hoard
|
||||
{"treasure_found", "bulletin", false}, // someone else already pulled it
|
||||
{"death", "priority", false}, // priority, but not a "first"
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := advIsRealmFirst(c.eventType, c.tier); got != c.want {
|
||||
t.Errorf("%s/%s ceremony = %v, want %v", c.eventType, c.tier, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// And it reaches the card as a ribbon.
|
||||
ev := &storage.AdvEvent{EventType: "zone_first", Tier: "priority", Subject: "Josie", Zone: "The Sump", Level: 9}
|
||||
card := advArtCardFor("zone_first", ev)
|
||||
if card.Ceremony == "" {
|
||||
t.Error("realm-first card has no ribbon")
|
||||
}
|
||||
if !strings.Contains(advRenderArt(card), card.Ceremony) {
|
||||
t.Error("ribbon text never reached the SVG")
|
||||
}
|
||||
plain := advArtCardFor("zone_clear", &storage.AdvEvent{EventType: "zone_clear", Tier: "bulletin", Subject: "Josie", Zone: "The Sump"})
|
||||
if plain.Ceremony != "" {
|
||||
t.Error("a repeat clear got the realm-first ribbon")
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtCardCarriesNouns: the card names the thing the dispatch is about, and
|
||||
// which field that is differs per family. A siege is about the boss; a treasure
|
||||
// is about the item.
|
||||
func TestArtCardCarriesNouns(t *testing.T) {
|
||||
treasure := advArtCardFor("treasure_found", &storage.AdvEvent{
|
||||
EventType: "treasure_found", Tier: "bulletin", Subject: "Josie",
|
||||
Zone: "The Sump", Stakes: "Ring of Nine Sorrows", Outcome: "epic", Level: 12,
|
||||
})
|
||||
if treasure.Noun != "Ring of Nine Sorrows" {
|
||||
t.Errorf("treasure noun = %q, want the item", treasure.Noun)
|
||||
}
|
||||
if treasure.Label != "EPIC" {
|
||||
t.Errorf("treasure label = %q, want the rarity", treasure.Label)
|
||||
}
|
||||
svg := advRenderArt(treasure)
|
||||
for _, want := range []string{"Ring of Nine Sorrows", "Josie", "The Sump", "level 12", "EPIC"} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Errorf("treasure card missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
boss := advArtCardFor("boss_kill", &storage.AdvEvent{
|
||||
EventType: "boss_kill", Subject: "Josie", Boss: "Aldric the Pale", Zone: "dragons_lair", Level: 14,
|
||||
})
|
||||
if boss.Noun != "Aldric the Pale" {
|
||||
t.Errorf("boss noun = %q, want the boss", boss.Noun)
|
||||
}
|
||||
|
||||
// A dispatch with no stored fact still renders — that is every story from
|
||||
// before the fact table, plus the window where the best-effort fact insert
|
||||
// failed. It degrades to the old emblem, not to a broken image.
|
||||
bare := advArtCardFor("death", nil)
|
||||
if bare.Noun != "" || bare.Label == "" {
|
||||
t.Errorf("factless card = %+v, want label-only", bare)
|
||||
}
|
||||
if b := advRenderArt(bare); !strings.Contains(b, "<svg") || !strings.Contains(b, "🪦") {
|
||||
t.Errorf("factless card didn't render an emblem: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSiegeCardDrawsTheBar is the W1 deferral landing. A Matrix unfurl of "the
|
||||
// town holds" that shows the bar is worth ten paragraphs — and the bar has to
|
||||
// come from the war-room snapshot, because the siege fact carries the boss and
|
||||
// the defender count but never the HP.
|
||||
func TestSiegeCardDrawsTheBar(t *testing.T) {
|
||||
newAdvServer(t, "t") // fresh temp DB
|
||||
|
||||
if err := storage.ReplaceSiege(storage.Siege{
|
||||
Active: false,
|
||||
History: []storage.SiegePast{{
|
||||
BossID: 7, BossName: "The Rust Sovereign", Tier: 5, Outcome: "defeated",
|
||||
HPRemaining: 0, HPMax: 18000, Defenders: 6, EndedAt: 5000,
|
||||
}},
|
||||
}, 5000); err != nil {
|
||||
t.Fatalf("seed siege: %v", err)
|
||||
}
|
||||
|
||||
ev := &storage.AdvEvent{EventType: "siege_win", Tier: "priority",
|
||||
Boss: "The Rust Sovereign", Tally: 6, OccurredAt: 5000}
|
||||
card := advArtCardFor("siege_win", ev)
|
||||
if card.Bar == nil {
|
||||
t.Fatal("siege card has no bar")
|
||||
}
|
||||
if card.Bar.Max != 18000 {
|
||||
t.Errorf("bar max = %d, want the pool", card.Bar.Max)
|
||||
}
|
||||
svg := advRenderArt(card)
|
||||
for _, want := range []string{"The Rust Sovereign", "6 defenders", "brought to zero"} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Errorf("siege card missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// A live Siege draws off the live row, so a siege_start card keeps sliding
|
||||
// as the town chips away rather than freezing at the spawn value.
|
||||
if err := storage.ReplaceSiege(storage.Siege{
|
||||
Active: true, BossID: 8, BossName: "The Rust Sovereign", Tier: 5,
|
||||
HPCurrent: 6200, HPMax: 18000, EndsAt: 9000,
|
||||
}, 6000); err != nil {
|
||||
t.Fatalf("seed live siege: %v", err)
|
||||
}
|
||||
live := advArtCardFor("siege_start", &storage.AdvEvent{
|
||||
EventType: "siege_start", Boss: "The Rust Sovereign", OccurredAt: 6000})
|
||||
if live.Bar == nil || live.Bar.Current != 6200 {
|
||||
t.Fatalf("live siege bar = %+v, want the current pool", live.Bar)
|
||||
}
|
||||
|
||||
// An unknown boss gets a siege card with no bar rather than a wrong one.
|
||||
// This is the real window between a win being filed and the war-room push
|
||||
// that explains it landing two minutes later.
|
||||
orphan := advArtCardFor("siege_loss", &storage.AdvEvent{
|
||||
EventType: "siege_loss", Boss: "Nobody In Particular", OccurredAt: 1})
|
||||
if orphan.Bar != nil {
|
||||
t.Errorf("drew a bar for a boss we have no snapshot of: %+v", orphan.Bar)
|
||||
}
|
||||
if !strings.Contains(advRenderArt(orphan), "Nobody In Particular") {
|
||||
t.Error("barless siege card lost its boss name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtRenderIsDeterministicAndEscaped: the card is a public URL served as an
|
||||
// image, and it must be byte-stable so it can be cached hard.
|
||||
func TestArtRenderIsDeterministicAndEscaped(t *testing.T) {
|
||||
card := advArtCardFor("death", &storage.AdvEvent{
|
||||
EventType: "death", Subject: `Bob<script>alert(1)</script>`, Zone: "the Underforge", Level: 3})
|
||||
a := advRenderArt(card)
|
||||
if a != advRenderArt(card) {
|
||||
t.Error("render is not deterministic")
|
||||
}
|
||||
if strings.Contains(a, "<script>") {
|
||||
t.Errorf("unescaped markup reached the SVG: %s", a)
|
||||
}
|
||||
|
||||
// Long names shrink to fit instead of running off the 1200px card.
|
||||
long := advArtCardFor("boss_kill", &storage.AdvEvent{
|
||||
EventType: "boss_kill", Boss: strings.Repeat("Nebuchadnezzar ", 6)})
|
||||
if got := advFitSize(long.Noun, 1060, 82, 40); got >= 82 {
|
||||
t.Errorf("long name kept the full font size (%d)", got)
|
||||
}
|
||||
if n := len([]rune(advClamp(long.Noun, 38))); n > 38 {
|
||||
t.Errorf("clamp let %d runes through", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCardAccentTintsTheFeed pins the border tint the feed cards read. It is the
|
||||
// same palette the art uses, so a card and its thumbnail agree.
|
||||
func TestCardAccentTintsTheFeed(t *testing.T) {
|
||||
legendary, first := advCardAccent("treasure_found", "priority", "legendary")
|
||||
if legendary == "" || !first {
|
||||
t.Errorf("legendary realm-first accent = %q ceremony=%v", legendary, first)
|
||||
}
|
||||
common, _ := advCardAccent("treasure_found", "bulletin", "common")
|
||||
if common == legendary {
|
||||
t.Error("a common find is tinted like a legendary one")
|
||||
}
|
||||
if a, _ := advCardAccent("", "", ""); a != "" {
|
||||
t.Error("a non-adventure story got an accent")
|
||||
}
|
||||
if legendary != advPaletteFor("treasure_found", "legendary").Accent {
|
||||
t.Error("feed accent and card art disagree on the colour")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedCardRendersTheTint renders the real adventure page through the real
|
||||
// template, because the interesting failure here is not in Go.
|
||||
//
|
||||
// html/template escapes a style attribute in CSS context and will replace a
|
||||
// value it doesn't trust with ZgotmplZ, silently — the card would render with no
|
||||
// border colour and nothing would say why. This is also the reason the accent is
|
||||
// an inline style at all: a generated Tailwind class would be purged out of the
|
||||
// stylesheet, which fails the same way and just as quietly.
|
||||
func TestFeedCardRendersTheTint(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "t")
|
||||
|
||||
first := AdvFact{GUID: "zone_first:a:1000", EventType: "zone_first", Tier: "priority",
|
||||
Actors: []string{"Josie"}, Subject: "Josie", Zone: "The Sump", Region: "Marches", Level: 9, OccurredAt: 1000}
|
||||
repeat := AdvFact{GUID: "zone_clear:b:1001", EventType: "zone_clear", Tier: "bulletin",
|
||||
Actors: []string{"Josie"}, Subject: "Josie", Zone: "The Sump", Region: "Marches", Level: 9, OccurredAt: 1001}
|
||||
for _, f := range []AdvFact{first, repeat} {
|
||||
if rw := postFact(t, s, "t", f); rw.Code != 200 {
|
||||
t.Fatalf("ingest %s: status %d", f.GUID, rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/adventure", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})
|
||||
body := w.Body.String()
|
||||
|
||||
accent := advPaletteFor("zone_first", "").Accent
|
||||
if !strings.Contains(body, "border-color:"+accent) {
|
||||
t.Errorf("zone accent %q never reached the card border", accent)
|
||||
}
|
||||
if strings.Contains(body, "ZgotmplZ") {
|
||||
t.Error("html/template rejected the accent as an unsafe CSS value")
|
||||
}
|
||||
if !strings.Contains(body, "Realm first") {
|
||||
t.Error("the realm-first card has no ribbon in the feed")
|
||||
}
|
||||
// Exactly one of the two cards is a first. If both got the badge the split
|
||||
// isn't doing anything.
|
||||
if n := strings.Count(body, "Realm first"); n != 1 {
|
||||
t.Errorf("realm-first badges = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaFormatsPools(t *testing.T) {
|
||||
for in, want := range map[int]string{0: "0", 999: "999", 1000: "1,000", 18000: "18,000", 1234567: "1,234,567"} {
|
||||
if got := advComma(in); got != want {
|
||||
t.Errorf("advComma(%d) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
|
||||
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
|
||||
// that were seen since the last digest but never posted live — exactly the
|
||||
// bulletins — and posts a single warm roundup to the adventure channel. The
|
||||
// website already carries each one as its own card; the digest is the Matrix-only
|
||||
// nudge so quiet-but-real activity surfaces without one ping per event.
|
||||
|
||||
const (
|
||||
// digestWindow bounds how far back a digest looks. Wider than a day so a
|
||||
// missed run (process down over a digest hour) still sweeps up the gap;
|
||||
// re-collection is prevented by MarkAdventureDigested, not by the window.
|
||||
digestWindow = 48 * time.Hour
|
||||
// digestCap bounds a single digest. Far past the observed volume (a dormant
|
||||
// community, per the voice spec); a runaway just truncates with a log line.
|
||||
digestCap = 40
|
||||
// digestPreview is how many headlines the roundup lede lists by name before
|
||||
// collapsing the rest to "…and N more".
|
||||
digestPreview = 4
|
||||
)
|
||||
|
||||
// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
|
||||
// section is enabled AND there's a live Matrix poster AND a channel to post to —
|
||||
// i.e. website-only and local modes never post a digest.
|
||||
func (s *Server) StartAdventureDigest(ctx context.Context) {
|
||||
if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
|
||||
return
|
||||
}
|
||||
go s.runAdventureDigest(ctx)
|
||||
}
|
||||
|
||||
// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
|
||||
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
|
||||
// digest at a predictable time of day across restarts.
|
||||
func (s *Server) runAdventureDigest(ctx context.Context) {
|
||||
hour := s.adv.DigestHourOrDefault()
|
||||
slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel)
|
||||
for {
|
||||
wait := durUntilNextHour(time.Now().UTC(), hour)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
s.postDailyDigest(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// durUntilNextHour returns the duration from now to the next occurrence of the
|
||||
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
|
||||
// at the hour doesn't double-fire.
|
||||
func durUntilNextHour(now time.Time, hour int) time.Duration {
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
|
||||
if !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
|
||||
// and marks them digested so they don't recur. Silent when there's nothing new —
|
||||
// a dormant realm should stay quiet, not ship an empty digest.
|
||||
func (s *Server) postDailyDigest(now time.Time) {
|
||||
since := now.Add(-digestWindow).Unix()
|
||||
bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
|
||||
if err != nil {
|
||||
slog.Error("adventure digest: query failed", "err", err)
|
||||
return
|
||||
}
|
||||
if len(bulletins) == 0 {
|
||||
return
|
||||
}
|
||||
if total > len(bulletins) {
|
||||
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
|
||||
}
|
||||
|
||||
date := now.Format("2006-01-02")
|
||||
eventID := "adv-digest:" + date
|
||||
headline, lede := buildDigest(bulletins, total)
|
||||
|
||||
s.advPost(AdvPost{
|
||||
GUID: eventID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: s.digestURL(date),
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
|
||||
guids := make([]string, len(bulletins))
|
||||
for i, b := range bulletins {
|
||||
guids[i] = b.GUID
|
||||
}
|
||||
storage.MarkAdventureDigested(guids, eventID)
|
||||
slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
|
||||
}
|
||||
|
||||
// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
|
||||
// total is how many bulletins the window actually holds, which is larger than
|
||||
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
|
||||
// have to describe the realm, not the slice. Headlines come from stored,
|
||||
// fact-guarded rows, so no player-controlled text is introduced here.
|
||||
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
|
||||
if total == 1 {
|
||||
return "Today in the realm: one dispatch.",
|
||||
fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
|
||||
}
|
||||
headline = fmt.Sprintf("Today in the realm: %d dispatches.", total)
|
||||
|
||||
shown := bulletins
|
||||
if len(shown) > digestPreview {
|
||||
shown = shown[:digestPreview]
|
||||
}
|
||||
parts := make([]string, len(shown))
|
||||
for i, b := range shown {
|
||||
parts[i] = trimHeadline(b.Headline)
|
||||
}
|
||||
list := strings.Join(parts, " ")
|
||||
more := ""
|
||||
if total > len(shown) {
|
||||
more = fmt.Sprintf(" …and %d more.", total-len(shown))
|
||||
}
|
||||
return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
|
||||
}
|
||||
|
||||
// trimHeadline ensures a headline ends with sentence punctuation so the joined
|
||||
// lede reads as prose rather than a run-on.
|
||||
func trimHeadline(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
|
||||
h += "."
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// digestURL points the roundup at the section, with a per-day query param so the
|
||||
// canonical-URL dedup treats each day's digest as distinct (it strips only
|
||||
// tracking params, keeping ?digest=).
|
||||
func (s *Server) digestURL(date string) string {
|
||||
return s.siteURL("/adventure?digest=" + date)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The retreat bulletin — gogobee's newest event type.
|
||||
//
|
||||
// The failure this guards against is silent and total: handleAdventureIngest
|
||||
// answers an unrecognized event_type with 400, and gogobee's sender treats any
|
||||
// non-2xx as a failure, retries eight times over ~two hours, and then PARKS the
|
||||
// row forever. So a gogobee that emits `retreat` against a Pete that doesn't
|
||||
// know the word doesn't log an error anyone reads — it just quietly drops every
|
||||
// retreat the realm ever files. Pete has to learn the word first, and this test
|
||||
// is what says he has.
|
||||
func TestAdventureIngest_AcceptsRetreat(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "retreat:abc:1000", EventType: "retreat", Tier: "bulletin",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 12, Count: 3, Outcome: "retreated",
|
||||
OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d body=%s — Pete rejected a retreat, so gogobee will "+
|
||||
"retry it eight times and park it forever", rw.Code, rw.Body.String())
|
||||
}
|
||||
|
||||
got, err := storage.GetStoryByGUID("retreat:abc:1000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("retreat was accepted but not stored: %v", err)
|
||||
}
|
||||
// It has to actually say what happened, in Pete's voice, using only the
|
||||
// supplied facts.
|
||||
body := got.Headline + " " + got.Lede
|
||||
for _, want := range []string{"Brannigan", "the Underforge"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("rendered retreat is missing %q: %q", want, body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got.Lede, "3 days in") {
|
||||
t.Errorf("the day count never made it into the copy: %q", got.Lede)
|
||||
}
|
||||
// Bulletin, not priority: a retreat goes in the daily digest, it does not
|
||||
// interrupt the room. Pete announcing every failed run live would be a
|
||||
// firehose — and an unkind one.
|
||||
if len(*posted) != 0 {
|
||||
t.Errorf("a bulletin was posted live: %+v", *posted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// newAdvServer builds a web server with the adventure seam enabled and a
|
||||
// capturing priority poster, backed by a fresh temp DB.
|
||||
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
|
||||
t.Helper()
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
var posted []AdvPost
|
||||
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
|
||||
// Mirror the production poster's side effect: queue.PostNow records a
|
||||
// post_log row, which is exactly what marks a beat "posted" and thus
|
||||
// excludes it from the bulletin digest.
|
||||
poster := func(p AdvPost) {
|
||||
posted = append(posted, p)
|
||||
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
|
||||
}
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
|
||||
nil, true, adv, poster)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, &posted
|
||||
}
|
||||
|
||||
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(f)
|
||||
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureIngest(rw, req)
|
||||
return rw
|
||||
}
|
||||
|
||||
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
|
||||
// bearer-accepted, templated, stored as an adventure story, and posted live.
|
||||
func TestAdventureIngestEndToEnd(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
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("death:abc:1000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
if got.Channel != "adventure" || got.Source != advSource {
|
||||
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
|
||||
}
|
||||
if got.Headline != "We lost Brannigan in the Underforge." {
|
||||
t.Errorf("headline = %q", got.Headline)
|
||||
}
|
||||
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
|
||||
t.Errorf("article_url = %q", got.ArticleURL)
|
||||
}
|
||||
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
|
||||
t.Fatalf("priority post not delivered: %+v", *posted)
|
||||
}
|
||||
|
||||
// Idempotent re-delivery: no error, no second post.
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("dup ingest status = %d", rw.Code)
|
||||
}
|
||||
if len(*posted) != 1 {
|
||||
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventurePermalink renders the per-story page the article_url points at:
|
||||
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
|
||||
// and body, and an unknown guid must 404.
|
||||
func TestAdventurePermalink(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||
req.SetPathValue("guid", "death:abc:1000")
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw, req)
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
body := rw.Body.String()
|
||||
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
|
||||
t.Errorf("permalink missing headline; body=%s", body)
|
||||
}
|
||||
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
|
||||
t.Errorf("permalink missing event label; body=%s", body)
|
||||
}
|
||||
|
||||
// Unknown guid 404s.
|
||||
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
|
||||
req2.SetPathValue("guid", "nope:1")
|
||||
rw2 := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw2, req2)
|
||||
if rw2.Code != 404 {
|
||||
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
|
||||
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
|
||||
func TestDurUntilNextHour(t *testing.T) {
|
||||
// 14:30 UTC, targeting 17:00 → 2h30m today.
|
||||
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
|
||||
t.Errorf("before hour: got %v", got)
|
||||
}
|
||||
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
|
||||
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
|
||||
t.Errorf("at hour: got %v", got)
|
||||
}
|
||||
// 20:00, targeting 17:00 → tomorrow, 21h.
|
||||
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
|
||||
t.Errorf("after hour: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureDigest covers the batched path: bulletins (no live post) are
|
||||
// collected into one roundup, priority beats are excluded (they already posted),
|
||||
// digested bulletins don't recur, and an empty window stays silent.
|
||||
func TestAdventureDigest(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
now := time.Now()
|
||||
|
||||
// Two bulletins + one priority (which posts live and must be excluded).
|
||||
// Both bulletin types are ones TwinBee does NOT announce itself — a
|
||||
// room-silent type never reaches the digest (see TestAdventureRoomSilent).
|
||||
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
|
||||
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "milestone:b:2", EventType: "milestone", Tier: "bulletin",
|
||||
Actors: []string{"Kif"}, Subject: "Kif", Milestone: "Ten zones cleared", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
|
||||
if len(*posted) != 1 {
|
||||
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
|
||||
}
|
||||
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 2 {
|
||||
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
|
||||
}
|
||||
dg := (*posted)[1]
|
||||
if !strings.Contains(dg.Headline, "2 dispatches") {
|
||||
t.Errorf("digest headline = %q", dg.Headline)
|
||||
}
|
||||
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
|
||||
t.Errorf("digest guid = %q", dg.GUID)
|
||||
}
|
||||
|
||||
// Re-running finds nothing new (bulletins marked digested).
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 2 {
|
||||
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureRoomSilent: an event type gogobee announces in the games room
|
||||
// itself is published to the site but never reaches Matrix — not as a live
|
||||
// priority beat, and not swept into the next digest either.
|
||||
func TestAdventureRoomSilent(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
now := time.Now()
|
||||
|
||||
postFact(t, s, token, AdvFact{GUID: "treasure_found:e:5", EventType: "treasure_found", Tier: "priority",
|
||||
Actors: []string{"Rurina"}, Subject: "Rurina", Zone: "Dragon's Lair", Level: 20,
|
||||
Stakes: "The Cartographer's Final Map", Outcome: "legendary", OccurredAt: now.Unix()})
|
||||
if len(*posted) != 0 {
|
||||
t.Fatalf("room-silent beat posted live: %d posts, want 0", len(*posted))
|
||||
}
|
||||
|
||||
// The site keeps the full record — suppression is Matrix-only.
|
||||
got, err := storage.GetStoryByGUID("treasure_found:e:5")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("room-silent beat missing from the site: %v", err)
|
||||
}
|
||||
|
||||
// And it doesn't come back around in the roundup.
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 0 {
|
||||
t.Errorf("room-silent beat swept into digest: %d posts, want 0", len(*posted))
|
||||
}
|
||||
|
||||
// An explicit empty list turns suppression off: the same beat posts live.
|
||||
s.roomSilent = config.AdventureConfig{RoomSilentTypes: []string{}}.RoomSilentSet()
|
||||
postFact(t, s, token, AdvFact{GUID: "treasure_found:e:6", EventType: "treasure_found", Tier: "priority",
|
||||
Actors: []string{"Rurina"}, Subject: "Rurina", Zone: "Dragon's Lair", Level: 20,
|
||||
Stakes: "The Cartographer's Final Map", Outcome: "legendary", OccurredAt: now.Unix()})
|
||||
if len(*posted) != 1 {
|
||||
t.Errorf("suppression off: %d posts, want 1", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
|
||||
// returns a themed SVG, ingested cards carry its local path, and the permalink
|
||||
// page is noindex with an og:image.
|
||||
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
|
||||
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
|
||||
func TestRenderZoneTaxonomy(t *testing.T) {
|
||||
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
|
||||
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
||||
hl, _, ok := renderAdventure(first)
|
||||
if !ok || !strings.Contains(hl, "very first time") {
|
||||
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
|
||||
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
||||
hl2, _, ok := renderAdventure(repeat)
|
||||
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
|
||||
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
|
||||
}
|
||||
|
||||
// The permalink label distinguishes the two, so a repeat's page isn't stamped
|
||||
// "First clear".
|
||||
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
|
||||
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
// A bare event type still renders — that is what every story ingested before
|
||||
// the card became guid-keyed has in its image_url column, and those links
|
||||
// must not start 404ing.
|
||||
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
|
||||
areq.SetPathValue("type", "death.svg")
|
||||
arw := httptest.NewRecorder()
|
||||
s.handleAdventureArt(arw, areq)
|
||||
if arw.Code != 200 {
|
||||
t.Fatalf("art status = %d", arw.Code)
|
||||
}
|
||||
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
|
||||
t.Errorf("art content-type = %q", ct)
|
||||
}
|
||||
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
|
||||
t.Errorf("art body missing emblem: %s", b)
|
||||
}
|
||||
|
||||
// Ingest keys the card image on the GUID, not the event type: the renderer
|
||||
// reads the fact behind the dispatch so it can name the zone and the level
|
||||
// instead of drawing the identical emblem for every death.
|
||||
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, _ := storage.GetStoryByGUID("death:abc:1000")
|
||||
if got == nil || got.ImageURL != "/adventure/art/death:abc:1000.svg" {
|
||||
t.Errorf("story image = %q", got.ImageURL)
|
||||
}
|
||||
|
||||
// And that card carries the nouns.
|
||||
greq := httptest.NewRequest("GET", "/adventure/art/death:abc:1000.svg", nil)
|
||||
greq.SetPathValue("type", "death:abc:1000.svg")
|
||||
grw := httptest.NewRecorder()
|
||||
s.handleAdventureArt(grw, greq)
|
||||
gb := grw.Body.String()
|
||||
if !strings.Contains(gb, "Brannigan") || !strings.Contains(gb, "the Underforge") || !strings.Contains(gb, "level 4") {
|
||||
t.Errorf("guid card missing the fact's nouns: %s", gb)
|
||||
}
|
||||
|
||||
// Permalink page is noindex with an og:image pointing at that same card.
|
||||
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||
preq.SetPathValue("guid", "death:abc:1000")
|
||||
prw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(prw, preq)
|
||||
body := prw.Body.String()
|
||||
if !strings.Contains(body, `name="robots" content="noindex"`) {
|
||||
t.Error("permalink not noindex")
|
||||
}
|
||||
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death:abc:1000.svg"`) {
|
||||
t.Errorf("permalink missing og:image; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureIngestBearer rejects missing/wrong tokens.
|
||||
func TestAdventureIngestBearer(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "right")
|
||||
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
|
||||
if rw := postFact(t, s, "", f); rw.Code != 401 {
|
||||
t.Errorf("no token: status = %d, want 401", rw.Code)
|
||||
}
|
||||
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
|
||||
t.Errorf("wrong token: status = %d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
|
||||
// so a name that slipped the source can't reach a public page.
|
||||
func TestAdventureFactGuard(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "death:evil:1", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
|
||||
Zone: "the Underforge", Level: 3, OccurredAt: 1,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
||||
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
|
||||
}
|
||||
if storage.IsGUIDSeen("death:evil:1") {
|
||||
t.Error("guarded fact was stored")
|
||||
}
|
||||
if len(*posted) != 0 {
|
||||
t.Error("guarded fact was posted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureDisabled 404s when the seam is off.
|
||||
func TestAdventureDisabled(t *testing.T) {
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
|
||||
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
|
||||
t.Errorf("disabled: status = %d, want 404", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderMischief: gogobee's four mischief event types must all render. An
|
||||
// untemplated type no longer 400s — it publishes on the neutral fallback (see
|
||||
// TestUnknownEventTypePublishes) — so what is at stake here is voice, not data
|
||||
// loss: these four carry the anonymity mechanic, and the generic fallback would
|
||||
// strip out the part that makes it work.
|
||||
//
|
||||
// It also pins the anonymity contract, which is the feature's whole social
|
||||
// engine: an unsigned contract must not name the buyer, and a survival must.
|
||||
func TestRenderMischief(t *testing.T) {
|
||||
anon := AdvFact{EventType: "mischief_contract", Tier: "priority",
|
||||
Subject: "Josie", Boss: "Elite", Stakes: "€350", Level: 14}
|
||||
hl, lede, ok := renderAdventure(anon)
|
||||
if !ok {
|
||||
t.Fatal("mischief_contract did not render — ingest would 400")
|
||||
}
|
||||
if strings.Contains(hl+lede, "Brannigan") {
|
||||
t.Error("anonymous contract leaked a buyer name")
|
||||
}
|
||||
if !strings.Contains(hl, "€350") {
|
||||
t.Errorf("contract headline lost the stakes: %q", hl)
|
||||
}
|
||||
|
||||
signed := anon
|
||||
signed.Opponent = "Brannigan"
|
||||
hl, _, ok = renderAdventure(signed)
|
||||
if !ok || !strings.Contains(hl, "Brannigan") {
|
||||
t.Errorf("signed contract should name the buyer: %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
// The unseal: a survival names the buyer whether or not they signed.
|
||||
survived := AdvFact{EventType: "mischief_survived", Tier: "priority",
|
||||
Subject: "Josie", Opponent: "Brannigan", Boss: "Bone Colossus", Stakes: "€228"}
|
||||
hl, _, ok = renderAdventure(survived)
|
||||
if !ok || !strings.Contains(hl, "Brannigan") {
|
||||
t.Errorf("survival must unseal the buyer: %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
// A downed target with an anonymous buyer stays anonymous — being maimed
|
||||
// doesn't buy you the name.
|
||||
downed := AdvFact{EventType: "mischief_downed", Tier: "priority",
|
||||
Subject: "Josie", Boss: "Bone Colossus", Level: 14}
|
||||
hl, lede, ok = renderAdventure(downed)
|
||||
if !ok {
|
||||
t.Fatal("mischief_downed did not render")
|
||||
}
|
||||
if strings.Contains(hl+lede, "Brannigan") {
|
||||
t.Error("anonymous buyer named on a downed contract")
|
||||
}
|
||||
|
||||
if _, _, ok := renderAdventure(AdvFact{EventType: "mischief_fizzled", Subject: "Josie", Stakes: "€315"}); !ok {
|
||||
t.Error("mischief_fizzled did not render")
|
||||
}
|
||||
|
||||
for _, et := range []string{"mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled"} {
|
||||
if lbl, _ := advEventMeta(et); lbl == "Dispatch" {
|
||||
t.Errorf("%s has no permalink label", et)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderCompanionHire pins the template whose absence was a live bug.
|
||||
//
|
||||
// gogobee has emitted companion_hire from `!expedition hire` since the combat-
|
||||
// engine work landed (expedition_companion_cmd.go). Pete had no case for it, so
|
||||
// every one of those dispatches 400'd, retried to peteclient's cap, and parked
|
||||
// forever. Nothing surfaced the loss: the game logged a successful emit, the
|
||||
// queue row just never sent.
|
||||
//
|
||||
// The unknown-type inversion (TestUnknownEventTypePublishes) means a repeat of
|
||||
// this costs a thin card rather than a deleted event — but the template is still
|
||||
// the point, and this test is what says so.
|
||||
func TestRenderCompanionHire(t *testing.T) {
|
||||
f := AdvFact{EventType: "companion_hire", Tier: "bulletin",
|
||||
Subject: "Josie", ClassRace: "Cleric", Zone: "holymachina", Level: 14}
|
||||
hl, lede, ok := renderAdventure(f)
|
||||
if !ok {
|
||||
t.Fatal("companion_hire did not render — this is the bug, do not re-break it")
|
||||
}
|
||||
if !strings.Contains(hl, "cleric") {
|
||||
t.Errorf("headline lost the seat Pete is filling: %q", hl)
|
||||
}
|
||||
if !strings.Contains(lede, "Josie") || !strings.Contains(lede, "holymachina") {
|
||||
t.Errorf("lede lost the leader or the zone: %q", lede)
|
||||
}
|
||||
// He is talking about himself here, like his duels. Third-person Pete filling
|
||||
// in as a cleric reads as someone else reporting on him.
|
||||
if !strings.Contains(lede, "I'm") && !strings.Contains(lede, "I ") {
|
||||
t.Errorf("companion_hire should be first-person Pete: %q", lede)
|
||||
}
|
||||
// "needed a cleric", never "needed cleric".
|
||||
if !strings.Contains(lede, "a cleric") {
|
||||
t.Errorf("seat needs its article in the lede: %q", lede)
|
||||
}
|
||||
if lbl, _ := advEventMeta("companion_hire"); lbl == "Dispatch" {
|
||||
t.Error("companion_hire has no permalink label")
|
||||
}
|
||||
|
||||
// A missing class must not produce "needed a ." — the fallback seat carries
|
||||
// its own article.
|
||||
bare := AdvFact{EventType: "companion_hire", Subject: "Josie"}
|
||||
_, bareLede, ok := renderAdventure(bare)
|
||||
if !ok {
|
||||
t.Fatal("companion_hire with no class did not render")
|
||||
}
|
||||
if strings.Contains(bareLede, "a .") || strings.Contains(bareLede, "needed ") {
|
||||
t.Errorf("empty class produced malformed prose: %q", bareLede)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownEventTypePublishes is the regression for the whole class of bug.
|
||||
//
|
||||
// An event type Pete has no template for must PUBLISH, not 400. A 400 is retried
|
||||
// to peteclient's cap and then parked forever, so rejecting an unrecognised type
|
||||
// does not defer the event — it deletes it, permanently, and that is how
|
||||
// companion_hire went missing. The site can carry a thin card; it cannot recover
|
||||
// a dispatch gogobee has given up on.
|
||||
func TestUnknownEventTypePublishes(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "brand_new_thing:abc:5000", EventType: "brand_new_thing",
|
||||
Tier: "priority", // claims priority, and still must not interrupt Matrix
|
||||
Subject: "Josie", Actors: []string{"Josie"}, Zone: "holymachina",
|
||||
OccurredAt: 5000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("unknown event_type: status = %d, want 200 — a 400 parks the dispatch forever", rw.Code)
|
||||
}
|
||||
|
||||
got, err := storage.GetStoryByGUID("brand_new_thing:abc:5000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatal("unknown event_type was not stored; the event is lost")
|
||||
}
|
||||
if !strings.Contains(got.Headline+got.Lede, "Josie") {
|
||||
t.Errorf("fallback dropped the subject: %q / %q", got.Headline, got.Lede)
|
||||
}
|
||||
|
||||
// Untemplated types never post live, whatever tier they claim: a thin card on
|
||||
// the site is cheap, a thin ping to everyone in the room is not. It still
|
||||
// reaches Matrix via the daily digest.
|
||||
if len(*posted) != 0 {
|
||||
t.Errorf("untemplated priority fact posted live to Matrix: %+v", *posted)
|
||||
}
|
||||
|
||||
// And the operator can see what Pete needs to learn.
|
||||
if AdvUnknownTypeCounts()["brand_new_thing"] == 0 {
|
||||
t.Error("unknown type was not counted for the status page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownEventTypeUsesProse: the inversion is not a downgrade in practice.
|
||||
// gogobee authors LLM prose from the fact's fields with no per-type switch
|
||||
// (authorDispatch), so a type Pete has never heard of still arrives with a real
|
||||
// headline and lede — and must be allowed to use them. The thin fallback is only
|
||||
// for when the model is off or the prose-guard rejected the output.
|
||||
func TestUnknownEventTypeUsesProse(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "another_new_thing:def:6000", EventType: "another_new_thing",
|
||||
Tier: "bulletin", Subject: "Josie", Actors: []string{"Josie"},
|
||||
OccurredAt: 6000,
|
||||
Headline: "Josie has taken up beekeeping.",
|
||||
Lede: "Not the news I expected today, but there she is, out behind the chapel with a smoker and a very calm expression.",
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", rw.Code)
|
||||
}
|
||||
got, err := storage.GetStoryByGUID("another_new_thing:def:6000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatal("story not stored")
|
||||
}
|
||||
if got.Headline != f.Headline {
|
||||
t.Errorf("LLM prose was discarded for an unknown type: got %q", got.Headline)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownEventTypeStillGuarded: publishing an untemplated type must not
|
||||
// weaken the name guards. The fact-guard rejection is still a 400, because a
|
||||
// fact naming someone it did not authorise is genuinely invalid — unlike a type
|
||||
// Pete simply hasn't learned to phrase.
|
||||
func TestUnknownEventTypeStillGuarded(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "unknowable:evil:1", EventType: "unknowable",
|
||||
Subject: "Josie", Actors: []string{"Brannigan"}, OccurredAt: 1,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
||||
t.Errorf("unguarded subject on an unknown type: status = %d, want 400", rw.Code)
|
||||
}
|
||||
if storage.IsGUIDSeen("unknowable:evil:1") {
|
||||
t.Error("fact-guard rejection was stored anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermalinkNamesTheRegion. Region is a fact field, never a story column — the
|
||||
// story is Pete's words and they have no place for a where — so the permalink has
|
||||
// to read it off the fact row it already loads for the run-report link. It was
|
||||
// hardcoded empty with a `// reserved` comment for the whole life of the page.
|
||||
//
|
||||
// The second half matters as much: a multi-region zone is the only place a region
|
||||
// exists, so a dispatch without one must not print an empty separator.
|
||||
func TestPermalinkNamesTheRegion(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
withRegion := AdvFact{
|
||||
GUID: "zone_clear:reg:1000", EventType: "zone_clear", Tier: "bulletin",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Region: "the Cinder Reach", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
without := AdvFact{
|
||||
GUID: "zone_clear:noreg:1001", EventType: "zone_clear", Tier: "bulletin",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1001,
|
||||
}
|
||||
for _, f := range []AdvFact{withRegion, without} {
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest %s = %d", f.GUID, rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
story := func(guid string) string {
|
||||
req := httptest.NewRequest("GET", "/adventure/"+guid, nil)
|
||||
req.SetPathValue("guid", guid)
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw, req)
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("permalink %s = %d", guid, rw.Code)
|
||||
}
|
||||
return rw.Body.String()
|
||||
}
|
||||
|
||||
if !strings.Contains(story(withRegion.GUID), "the Cinder Reach") {
|
||||
t.Error("permalink does not name the region the fact carries")
|
||||
}
|
||||
// The template joins the region on with " · "; a regionless dispatch must not
|
||||
// render a dangling one.
|
||||
if body := story(without.GUID); strings.Contains(body, "Reported ") &&
|
||||
strings.Contains(body, " · </p>") {
|
||||
t.Error("a dispatch with no region printed an empty separator")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
Fredoka is copyright the Fredoka Project Authors
|
||||
(https://github.com/hafontia/Fredoka), licensed under the SIL Open Font
|
||||
License, Version 1.1: https://openfontlicense.org
|
||||
|
||||
It is vendored here because the share card (games_og.go) is drawn on the
|
||||
server, and a server cannot reach for a font over the network the way the
|
||||
page does. The site itself still loads Fredoka from Google's CDN, so this is
|
||||
the same typeface arriving by a second road, not a second typeface.
|
||||
+87
-6
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -32,6 +33,7 @@ type Authenticator struct {
|
||||
oauth *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
secret []byte
|
||||
domain string // cookie Domain; empty means host-only
|
||||
}
|
||||
|
||||
// SessionUser is the identity carried in the signed session cookie.
|
||||
@@ -39,7 +41,23 @@ type SessionUser struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Exp int64 `json:"exp"`
|
||||
// Username is the Authentik preferred_username, which MAS imported as the
|
||||
// Matrix localpart — so it is also who this person is in the game economy.
|
||||
// Sessions signed before games existed don't carry it; MatrixUser returns
|
||||
// "" for those and the caller sends them back through sign-in.
|
||||
Username string `json:"username,omitempty"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// MatrixUser maps the session to a Matrix ID on the given server name, e.g.
|
||||
// "reala" on "parodia.dev" -> "@reala:parodia.dev". Empty if either half is
|
||||
// missing, which callers must treat as "not identified in the economy".
|
||||
func (u *SessionUser) MatrixUser(serverName string) string {
|
||||
name := strings.ToLower(strings.TrimSpace(u.Username))
|
||||
if name == "" || serverName == "" {
|
||||
return ""
|
||||
}
|
||||
return "@" + name + ":" + serverName
|
||||
}
|
||||
|
||||
// Display is the friendly name shown in the header (name, else email, else sub).
|
||||
@@ -88,6 +106,7 @@ func newAuthenticator(ctx context.Context, cfg config.AuthConfig) (*Authenticato
|
||||
},
|
||||
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
secret: []byte(cfg.SessionSecret),
|
||||
domain: strings.TrimSpace(cfg.CookieDomain),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -150,11 +169,23 @@ func (a *Authenticator) userFromRequest(r *http.Request) *SessionUser {
|
||||
return &u
|
||||
}
|
||||
|
||||
// cookieDomain is the Domain attribute for a given cookie. Only the session
|
||||
// cookie is widened: the OAuth round-trip cookie stays host-only, because it
|
||||
// pairs with a redirect back to the host that started the login and has no
|
||||
// business being readable from anywhere else.
|
||||
func (a *Authenticator) cookieDomain(name string) string {
|
||||
if name == sessionCookie {
|
||||
return a.domain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
Domain: a.cookieDomain(name),
|
||||
Expires: time.Now().Add(ttl),
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
@@ -164,10 +195,54 @@ func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
// redirect_url names one host (news), but a login that starts on games has to
|
||||
// come back to games — otherwise the browser is dumped on the news site with
|
||||
// its "next" path pointing at a page that lives elsewhere. So when the request
|
||||
// arrives on a host inside the shared cookie domain, keep the redirect on that
|
||||
// host, reusing the configured URL's scheme and path. Every host used this way
|
||||
// must be registered as a redirect URI in Authentik.
|
||||
func (a *Authenticator) oauthFor(r *http.Request) *oauth2.Config {
|
||||
if a.domain == "" || !hostInDomain(r.Host, a.domain) {
|
||||
return a.oauth
|
||||
}
|
||||
u, err := url.Parse(a.oauth.RedirectURL)
|
||||
if err != nil || u.Host == r.Host {
|
||||
return a.oauth
|
||||
}
|
||||
cfg := *a.oauth
|
||||
cfg.RedirectURL = u.Scheme + "://" + r.Host + u.Path
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// hostInDomain reports whether host sits inside a cookie domain like
|
||||
// ".parodia.dev" (which also covers the bare "parodia.dev").
|
||||
func hostInDomain(host, domain string) bool {
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
host = host[:i] // strip port
|
||||
}
|
||||
host = strings.ToLower(host)
|
||||
bare := strings.TrimPrefix(strings.ToLower(domain), ".")
|
||||
return host == bare || strings.HasSuffix(host, "."+bare)
|
||||
}
|
||||
|
||||
// ---- handlers -------------------------------------------------------------
|
||||
@@ -182,7 +257,7 @@ func (a *Authenticator) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
payload, _ := json.Marshal(st)
|
||||
a.setCookie(w, oauthCookie, a.sign(payload), oauthTTL)
|
||||
http.Redirect(w, r, a.oauth.AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
|
||||
http.Redirect(w, r, a.oauthFor(r).AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleCallback completes the flow: validates state, exchanges the code,
|
||||
@@ -212,7 +287,7 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tok, err := a.oauth.Exchange(ctx, r.URL.Query().Get("code"))
|
||||
tok, err := a.oauthFor(r).Exchange(ctx, r.URL.Query().Get("code"))
|
||||
if err != nil {
|
||||
slog.Error("auth: code exchange failed", "err", err)
|
||||
http.Error(w, "sign-in failed", http.StatusBadGateway)
|
||||
@@ -247,7 +322,13 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if name == "" {
|
||||
name = claims.PreferredUsername
|
||||
}
|
||||
u := SessionUser{Sub: claims.Sub, Name: name, Email: claims.Email, Exp: time.Now().Add(sessionTTL).Unix()}
|
||||
u := SessionUser{
|
||||
Sub: claims.Sub,
|
||||
Name: name,
|
||||
Email: claims.Email,
|
||||
Username: claims.PreferredUsername,
|
||||
Exp: time.Now().Add(sessionTTL).Unix(),
|
||||
}
|
||||
sess, _ := json.Marshal(u)
|
||||
a.setCookie(w, sessionCookie, a.sign(sess), sessionTTL)
|
||||
slog.Info("auth: user signed in", "sub", claims.Sub, "name", name)
|
||||
|
||||
+149
-1
@@ -1,6 +1,62 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
@@ -53,3 +109,95 @@ func TestSafeNext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatrixUser(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
u SessionUser
|
||||
want string
|
||||
}{
|
||||
{"lowercased", SessionUser{Username: "Reala"}, "@reala:parodia.dev"},
|
||||
{"trimmed", SessionUser{Username: " reala "}, "@reala:parodia.dev"},
|
||||
{"old session with no username", SessionUser{Sub: "abc", Name: "Reala"}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.u.MatrixUser("parodia.dev"); got != tc.want {
|
||||
t.Fatalf("MatrixUser = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := (&SessionUser{Username: "reala"}).MatrixUser(""); got != "" {
|
||||
t.Fatalf("no server name should yield no identity, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostInDomain(t *testing.T) {
|
||||
cases := []struct {
|
||||
host, domain string
|
||||
want bool
|
||||
}{
|
||||
{"games.parodia.dev", ".parodia.dev", true},
|
||||
{"news.parodia.dev:8080", ".parodia.dev", true},
|
||||
{"parodia.dev", ".parodia.dev", true},
|
||||
{"GAMES.PARODIA.DEV", ".parodia.dev", true},
|
||||
{"evil.com", ".parodia.dev", false},
|
||||
// The suffix check must not match a domain that merely ends in the
|
||||
// same letters: notparodia.dev is a different site entirely.
|
||||
{"notparodia.dev", ".parodia.dev", false},
|
||||
{"games.parodia.dev.evil.com", ".parodia.dev", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := hostInDomain(tc.host, tc.domain); got != tc.want {
|
||||
t.Errorf("hostInDomain(%q, %q) = %v, want %v", tc.host, tc.domain, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthForKeepsLoginOnTheHostItStartedOn(t *testing.T) {
|
||||
base := &oauth2.Config{RedirectURL: "https://news.parodia.dev/auth/callback"}
|
||||
a := &Authenticator{oauth: base, domain: ".parodia.dev"}
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/login", nil)
|
||||
req.Host = "games.parodia.dev"
|
||||
if got := a.oauthFor(req).RedirectURL; got != "https://games.parodia.dev/auth/callback" {
|
||||
t.Fatalf("games login should come back to games, got %q", got)
|
||||
}
|
||||
|
||||
req.Host = "news.parodia.dev"
|
||||
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||
t.Fatalf("news login should use the configured URL, got %q", got)
|
||||
}
|
||||
|
||||
// A Host we don't own must never be echoed back into a redirect URI.
|
||||
req.Host = "evil.com"
|
||||
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||
t.Fatalf("foreign host must fall back to the configured URL, got %q", got)
|
||||
}
|
||||
|
||||
// With no cookie domain configured there is nothing to share, so the
|
||||
// configured redirect stands whatever the Host header says.
|
||||
off := &Authenticator{oauth: base}
|
||||
req.Host = "games.parodia.dev"
|
||||
if got := off.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||
t.Fatalf("host-only mode must not rewrite the redirect, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieIsSharedButOAuthCookieIsNot(t *testing.T) {
|
||||
a := &Authenticator{secret: []byte("test-secret-key-at-least-16"), domain: ".parodia.dev"}
|
||||
rec := httptest.NewRecorder()
|
||||
a.setCookie(rec, sessionCookie, "v", time.Minute)
|
||||
a.setCookie(rec, oauthCookie, "v", time.Minute)
|
||||
|
||||
got := map[string]string{}
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
got[c.Name] = c.Domain
|
||||
}
|
||||
if got[sessionCookie] != "parodia.dev" {
|
||||
t.Fatalf("session cookie domain = %q, want it shared across parodia.dev", got[sessionCookie])
|
||||
}
|
||||
if got[oauthCookie] != "" {
|
||||
t.Fatalf("oauth cookie must stay host-only, got domain %q", got[oauthCookie])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// "While you were away" — the one panel on the site that is about the reader.
|
||||
//
|
||||
// Everything else in the adventure section is the realm's news: the board, the
|
||||
// Siege, the standings. This is the owner's own adventurer, and only what has
|
||||
// happened to them since they last looked. It pairs with W6's push alerts and
|
||||
// covers the gap those deliberately leave: the alerts are four opt-in categories
|
||||
// chosen for being worth interrupting somebody over, while this catches
|
||||
// everything, for people who would rather not be interrupted at all.
|
||||
//
|
||||
// It renders on /adventure page 1 only. The panel is present tense and page 2 of
|
||||
// an archive is not where anybody looks for what just happened, which is the same
|
||||
// rule the roster and the Siege strip already follow.
|
||||
|
||||
// awayCap bounds the panel. Six lines is a glance; a longer list is the trail on
|
||||
// the adventurer's own page, which is where the "all of it" link goes.
|
||||
const awayCap = 6
|
||||
|
||||
// awayView is the panel. Has is false in every case where there is nothing
|
||||
// honest to show — not signed in, no adventurer, first ever visit, or simply
|
||||
// nothing new — and the template renders nothing at all rather than an empty box
|
||||
// announcing that nothing happened.
|
||||
type awayView struct {
|
||||
Has bool
|
||||
Name string // the reader's own character
|
||||
Since string // "3 hours", "2 days" — how long they were gone
|
||||
Lines []awayLine
|
||||
// HasMore says there is more than the cap, without saying how much more. The
|
||||
// window query reads one row past the cap to learn this; an exact count would
|
||||
// need a second query over the same window to tell somebody a number they are
|
||||
// about to click past anyway.
|
||||
HasMore bool
|
||||
Token string // their adventurer page, where the rest of the trail is
|
||||
}
|
||||
|
||||
// awayLine is one thing that happened, in the trail's own shape. Built from the
|
||||
// fact rather than from the dispatch headline for the same reason buildTimeline
|
||||
// is: a headline is a news sentence written to be shouted once, and six of them
|
||||
// stacked in a panel read as shouting.
|
||||
type awayLine struct {
|
||||
Emoji string
|
||||
Label string
|
||||
Line string
|
||||
When string // relative: this panel is about recency
|
||||
Permalink string
|
||||
Notable bool
|
||||
}
|
||||
|
||||
// awayPanel builds the panel for whoever is asking, and stamps their visit.
|
||||
//
|
||||
// The stamp is written even when the panel comes back empty — even for a signed-in
|
||||
// user with no adventurer at all — and that is deliberate: a clock that only
|
||||
// advances when there is something to show would hand somebody their entire
|
||||
// backlog on the day they finally rolled a character.
|
||||
func (s *Server) awayPanel(r *http.Request) awayView {
|
||||
if s.auth == nil {
|
||||
return awayView{}
|
||||
}
|
||||
u := s.auth.userFromRequest(r)
|
||||
if u == nil {
|
||||
return awayView{}
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
from, first, err := storage.AdvVisitWindow(u.Sub, now)
|
||||
if err != nil {
|
||||
slog.Error("away: visit clock failed", "sub", u.Sub, "err", err)
|
||||
return awayView{}
|
||||
}
|
||||
if first {
|
||||
// Never seen before. Their history is not news to them, and a first visit
|
||||
// greeted by every death their character ever suffered is a worse welcome
|
||||
// than no panel at all.
|
||||
return awayView{}
|
||||
}
|
||||
|
||||
// The ownership join, re-read on every request rather than cached anywhere —
|
||||
// same discipline as the alert sender and the run report link. It fails closed
|
||||
// on an opt-out and on a player gogobee has stopped pushing, both of which mean
|
||||
// Pete cannot honestly say which adventurer is this reader's.
|
||||
lp := buyerLocalpart(u)
|
||||
if lp == "" {
|
||||
return awayView{}
|
||||
}
|
||||
name, ok := storage.AdvCharacterForOwner(lp)
|
||||
if !ok {
|
||||
return awayView{}
|
||||
}
|
||||
|
||||
// One extra row is fetched past the cap purely to answer "is there more",
|
||||
// without a second COUNT query over the same window.
|
||||
events, err := storage.EventsBySubjectSince(name, from, awayCap+1)
|
||||
if err != nil {
|
||||
slog.Error("away: dispatch lookup failed", "subject", name, "err", err)
|
||||
return awayView{}
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return awayView{}
|
||||
}
|
||||
|
||||
v := awayView{Has: true, Name: name, Since: awaySince(now - from)}
|
||||
if token, ok := storage.SelfToken(lp); ok {
|
||||
v.Token = token
|
||||
}
|
||||
if len(events) > awayCap {
|
||||
v.HasMore = true
|
||||
events = events[:awayCap]
|
||||
}
|
||||
for _, e := range events {
|
||||
label, emoji := advEventMeta(e.EventType)
|
||||
v.Lines = append(v.Lines, awayLine{
|
||||
Emoji: emoji,
|
||||
Label: label,
|
||||
Line: timelineLine(name, e),
|
||||
When: awayAgo(now - e.OccurredAt),
|
||||
Permalink: s.advPermalink(e.GUID),
|
||||
Notable: e.EventType == "boss_first" || e.EventType == "zone_first" ||
|
||||
e.EventType == "death",
|
||||
})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// awaySince phrases the gap the panel covers. Rounded down, and it never claims
|
||||
// less than an hour: the window is at least one session gap wide, and "since 34
|
||||
// minutes ago" is a precision the clock behind it does not have.
|
||||
func awaySince(secs int64) string {
|
||||
switch d := time.Duration(secs) * time.Second; {
|
||||
case d < 2*time.Hour:
|
||||
return "an hour"
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%d hours", int(d.Hours()))
|
||||
case d < 14*24*time.Hour:
|
||||
return fmt.Sprintf("%d days", int(d.Hours())/24)
|
||||
default:
|
||||
return "a while"
|
||||
}
|
||||
}
|
||||
|
||||
// awayAgo is a compact relative stamp for one line. Deliberately not the trail's
|
||||
// "Jan 2, 2006": everything in this panel is recent by construction, and a date
|
||||
// on it would make the reader do the subtraction themselves.
|
||||
func awayAgo(secs int64) string {
|
||||
switch d := time.Duration(secs) * time.Second; {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The "while you were away" panel. Two things are worth pinning: it never shows
|
||||
// somebody else's adventurer, and its window survives a page refresh — the
|
||||
// failure that would make the whole panel useless without breaking anything a
|
||||
// unit test would normally notice.
|
||||
|
||||
// awayReq builds a request for /adventure as a signed-in user, or anonymously
|
||||
// when sub is empty.
|
||||
func awayReq(t *testing.T, s *Server, sub, username string) *http.Request {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest("GET", "/adventure", nil)
|
||||
if sub != "" {
|
||||
payload, _ := json.Marshal(SessionUser{
|
||||
Sub: sub, Username: username, Exp: time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// seedAwayOwner puts one adventurer on the board owned by localpart, the way the
|
||||
// two real pushes do.
|
||||
func seedAwayOwner(t *testing.T, localpart, character string) {
|
||||
t.Helper()
|
||||
now := time.Now().Unix()
|
||||
if err := storage.ReplaceRoster([]storage.RosterEntry{{
|
||||
Token: "tok-" + localpart, Name: character, Level: 14, Status: "idle",
|
||||
}}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := storage.ReplacePlayerDetail([]storage.PlayerDetail{{
|
||||
Localpart: localpart, Token: "tok-" + localpart,
|
||||
}}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedAwayEvent(t *testing.T, guid, kind, subject string, at int64) {
|
||||
t.Helper()
|
||||
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
|
||||
GUID: guid, EventType: kind, Subject: subject, Zone: "holymachina",
|
||||
OccurredAt: at,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAwayPanelIsSilentOnAFirstVisit. A brand-new row means "never seen before",
|
||||
// and treating that as "away since the epoch" would greet somebody's first
|
||||
// sign-in with every death their character ever suffered.
|
||||
func TestAwayPanelIsSilentOnAFirstVisit(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
seedAwayOwner(t, "josie", "Josie")
|
||||
seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Hour).Unix())
|
||||
|
||||
if v := s.awayPanel(awayReq(t, s, "sub-1", "josie")); v.Has {
|
||||
t.Errorf("first visit rendered a panel of %d lines; it must be silent", len(v.Lines))
|
||||
}
|
||||
// And the clock was still stamped, so the next visit has a window to read from.
|
||||
from, first, err := storage.AdvVisitWindow("sub-1", time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first || from == 0 {
|
||||
t.Errorf("visit clock not stamped on the first pass (from=%d first=%v)", from, first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAwayPanelSurvivesARefresh is the reason adventure_visit has two columns.
|
||||
// The naive one-column version shows the news, moves the stamp to now, and then
|
||||
// renders an empty box over the same events the moment the reader reloads —
|
||||
// which is exactly what somebody does after clicking into a dispatch and back.
|
||||
func TestAwayPanelSurvivesARefresh(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
seedAwayOwner(t, "josie", "Josie")
|
||||
|
||||
// A visit two hours ago established the clock. Stamped directly rather than
|
||||
// through awayPanel, because the panel reads the wall clock and this test is
|
||||
// about what happens between two visits rather than inside one.
|
||||
if _, first, err := storage.AdvVisitWindow("sub-1", time.Now().Add(-2*time.Hour).Unix()); err != nil || !first {
|
||||
t.Fatalf("seed visit: first=%v err=%v", first, err)
|
||||
}
|
||||
// Then something happened to Josie.
|
||||
seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Minute).Unix())
|
||||
|
||||
first := s.awayPanel(awayReq(t, s, "sub-1", "josie"))
|
||||
if !first.Has || len(first.Lines) != 1 {
|
||||
t.Fatalf("panel = %+v, want one line about the death", first)
|
||||
}
|
||||
if first.Name != "Josie" {
|
||||
t.Errorf("panel names %q, want Josie", first.Name)
|
||||
}
|
||||
|
||||
// The refresh. Same panel, not an empty one.
|
||||
again := s.awayPanel(awayReq(t, s, "sub-1", "josie"))
|
||||
if !again.Has || len(again.Lines) != len(first.Lines) {
|
||||
t.Errorf("refresh emptied the panel: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAwayPanelNeverShowsAnotherPlayersNews. The panel is keyed on a fact's
|
||||
// character name, resolved through the owner join — the same join the alert
|
||||
// sender uses, and the same failure-closed rule. A signed-in visitor who owns
|
||||
// nothing must see nothing, never the realm's news relabelled as their own.
|
||||
func TestAwayPanelNeverShowsAnotherPlayersNews(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
seedAwayOwner(t, "josie", "Josie")
|
||||
seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Minute).Unix())
|
||||
|
||||
// Anonymous: no panel, and no visit row to create either.
|
||||
if v := s.awayPanel(awayReq(t, s, "", "")); v.Has {
|
||||
t.Error("an anonymous visitor got a personal panel")
|
||||
}
|
||||
// Signed in, but owns no adventurer on the board.
|
||||
if v := s.awayPanel(awayReq(t, s, "sub-stranger", "stranger")); v.Has {
|
||||
t.Errorf("a visitor with no adventurer got %+v", v)
|
||||
}
|
||||
// Second pass, now that their visit row exists — the branch that would fall
|
||||
// through to a broadcast if the ownership join were ever treated as optional.
|
||||
if v := s.awayPanel(awayReq(t, s, "sub-stranger", "stranger")); v.Has {
|
||||
t.Errorf("a visitor with no adventurer got %+v on their second visit", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAwayPanelCapsAndCounts: six lines is a glance, and the overflow has to be
|
||||
// counted rather than silently dropped.
|
||||
func TestAwayPanelCapsAndCounts(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
seedAwayOwner(t, "josie", "Josie")
|
||||
|
||||
if _, first, err := storage.AdvVisitWindow("sub-1", time.Now().Add(-4*time.Hour).Unix()); err != nil || !first {
|
||||
t.Fatalf("seed visit: first=%v err=%v", first, err)
|
||||
}
|
||||
base := time.Now().Add(-time.Hour).Unix()
|
||||
for i := 0; i < awayCap+3; i++ {
|
||||
seedAwayEvent(t, "boss_kill:"+string(rune('a'+i))+":1", "boss_kill", "Josie", base+int64(i))
|
||||
}
|
||||
|
||||
v := s.awayPanel(awayReq(t, s, "sub-1", "josie"))
|
||||
if len(v.Lines) != awayCap {
|
||||
t.Errorf("panel drew %d lines, want the cap of %d", len(v.Lines), awayCap)
|
||||
}
|
||||
if !v.HasMore {
|
||||
t.Error("overflow was not flagged; the extra events would read as if they never happened")
|
||||
}
|
||||
if v.Token != "tok-josie" {
|
||||
t.Errorf("panel links to %q, want the reader's own adventurer page", v.Token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The Tailwind purge trap, made loud.
|
||||
//
|
||||
// tailwind.config.js has input.css in its OWN content glob, so a hand-written
|
||||
// component class survives the purge only if its literal name can be *extracted*
|
||||
// from that file. A name that only ever appears glued to something else — the
|
||||
// canonical case is a rule written solely as `.foo::before` — is not extractable
|
||||
// and Tailwind drops the rule from output.css. Nothing errors. The page just
|
||||
// renders unstyled, and it is invisible until somebody looks at that exact
|
||||
// element on that exact page.
|
||||
//
|
||||
// That has now cost three phases: flagged twice, and actually bitten once when
|
||||
// `.firsts-entry-zone::before` was silently dropped. The mitigation everybody
|
||||
// reached for — "remember to grep output.css after make css" — is the discipline
|
||||
// that failed, and it is worse than it looks because Tailwind ESCAPES class
|
||||
// names in its output (`.text-[color:var(--warn)]` is written
|
||||
// `.text-\[color\:var\(--warn\)\]`), so a naive grep for the literal name
|
||||
// reports a false negative that looks exactly like a purge failure.
|
||||
//
|
||||
// So: a test. It needs no list to maintain — the list IS input.css — and it
|
||||
// turns a silent styling failure into a red build.
|
||||
//
|
||||
// A Tailwind `safelist` was the other option and is worse: a list somebody has
|
||||
// to remember to add to is the same failure mode one level up.
|
||||
|
||||
// cssClassInSelector matches a class name in a selector. The leading dot must
|
||||
// not be preceded by an identifier character, so `1.5rem` in a declaration is
|
||||
// never mistaken for a class.
|
||||
var cssClassInSelector = regexp.MustCompile(`\.(-?[A-Za-z_][-\w]*)`)
|
||||
|
||||
// cssComment strips /* ... */ so a class name mentioned in prose can't be read
|
||||
// as a declaration. Several of the component blocks have long explanatory
|
||||
// comments that name other classes.
|
||||
var cssComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
|
||||
|
||||
// componentClasses returns every class name declared inside an `@layer
|
||||
// components` block of input.css.
|
||||
//
|
||||
// It walks the file rather than regexing whole rules because a component block
|
||||
// can contain nested at-rules (`@media`, `@supports`) and because a selector can
|
||||
// be a list spanning several lines. The walk collects each *prelude* — the text
|
||||
// between one brace and the next — and reads class names out of it. An at-rule
|
||||
// prelude (`@media ...`) is skipped; a declaration body is never a prelude
|
||||
// because it is followed by `}`, not `{`.
|
||||
func componentClasses(t *testing.T, css string) []string {
|
||||
t.Helper()
|
||||
css = cssComment.ReplaceAllString(css, " ")
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
|
||||
// Find each `@layer components` block by brace-counting from its opening
|
||||
// brace, then walk only inside it.
|
||||
const marker = "@layer components"
|
||||
for idx := 0; ; {
|
||||
i := strings.Index(css[idx:], marker)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
i += idx
|
||||
open := strings.Index(css[i:], "{")
|
||||
if open < 0 {
|
||||
break
|
||||
}
|
||||
open += i
|
||||
|
||||
depth := 0
|
||||
var prelude strings.Builder
|
||||
end := len(css)
|
||||
for j := open; j < len(css); j++ {
|
||||
switch css[j] {
|
||||
case '{':
|
||||
depth++
|
||||
// depth 1 is the @layer's own brace; anything deeper opened on a
|
||||
// prelude we have been buffering.
|
||||
if depth > 1 {
|
||||
sel := strings.TrimSpace(prelude.String())
|
||||
if !strings.HasPrefix(sel, "@") {
|
||||
for _, m := range cssClassInSelector.FindAllStringSubmatch(sel, -1) {
|
||||
if !seen[m[1]] {
|
||||
seen[m[1]] = true
|
||||
out = append(out, m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prelude.Reset()
|
||||
case '}':
|
||||
depth--
|
||||
prelude.Reset()
|
||||
if depth == 0 {
|
||||
end = j
|
||||
}
|
||||
default:
|
||||
prelude.WriteByte(css[j])
|
||||
}
|
||||
if depth == 0 && j > open {
|
||||
break
|
||||
}
|
||||
}
|
||||
idx = end + 1
|
||||
}
|
||||
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// cssEscape renders a class name the way Tailwind writes it into output.css:
|
||||
// every character outside [A-Za-z0-9_-] is backslash-escaped. This is the half
|
||||
// of the check that a grep gets wrong.
|
||||
func cssEscape(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if r == '-' || r == '_' || (r >= '0' && r <= '9') ||
|
||||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r > 127 {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('\\')
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// selectorPresent reports whether `.name` appears in the (minified) stylesheet
|
||||
// as a selector rather than as a prefix of a longer class name. Tailwind's
|
||||
// output has no line breaks, so the boundary check is the whole test.
|
||||
func selectorPresent(css, name string) bool {
|
||||
needle := "." + cssEscape(name)
|
||||
for i := 0; ; {
|
||||
j := strings.Index(css[i:], needle)
|
||||
if j < 0 {
|
||||
return false
|
||||
}
|
||||
j += i
|
||||
i = j + 1
|
||||
// A match must not be the head of a longer name: `.map` inside
|
||||
// `.map-svg` ends on '-', which is an identifier character.
|
||||
if k := j + len(needle); k < len(css) {
|
||||
c := css[k]
|
||||
if c == '-' || c == '_' || c == '\\' ||
|
||||
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryComponentClassSurvivesThePurge is the whole point of this file. If it
|
||||
// fails, run `make css` first — a stale output.css looks identical to a purged
|
||||
// class from here, and that is deliberate: shipping a stylesheet that predates
|
||||
// the rule you just wrote is the same bug wearing a different hat.
|
||||
func TestEveryComponentClassSurvivesThePurge(t *testing.T) {
|
||||
inRaw, err := os.ReadFile("static/css/input.css")
|
||||
if err != nil {
|
||||
t.Fatalf("read input.css: %v", err)
|
||||
}
|
||||
outRaw, err := os.ReadFile("static/css/output.css")
|
||||
if err != nil {
|
||||
t.Fatalf("read output.css: %v — run `make css`", err)
|
||||
}
|
||||
in, out := string(inRaw), string(outRaw)
|
||||
|
||||
classes := componentClasses(t, in)
|
||||
if len(classes) < 50 {
|
||||
t.Fatalf("only found %d component classes in input.css — the parser has stopped working, "+
|
||||
"which would make this test pass for the wrong reason", len(classes))
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, c := range classes {
|
||||
if !selectorPresent(out, c) {
|
||||
missing = append(missing, c)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("%d class(es) declared in input.css's @layer components are absent from output.css: %s\n"+
|
||||
"Either run `make css`, or the name is not extractable from input.css — a rule written only as "+
|
||||
"`.foo::before` or only inside a nested selector cannot be extracted, and Tailwind purges it "+
|
||||
"silently. Give it a plain `.foo { ... }` declaration (a custom property is enough).",
|
||||
len(missing), strings.Join(missing, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurgeCheckCatchesAPseudoOnlyClass proves the check would have caught the
|
||||
// W4 bug, using a synthetic pair rather than trusting that the real stylesheet
|
||||
// happens to exercise the path. Without this, a parser that silently found
|
||||
// nothing would leave the real test green forever.
|
||||
func TestPurgeCheckCatchesAPseudoOnlyClass(t *testing.T) {
|
||||
in := `@layer components {
|
||||
/* a comment naming .decoy-class, which must not be collected */
|
||||
.kept { color: red; }
|
||||
.pseudo-only::before { content: ""; }
|
||||
@media (min-width: 40rem) {
|
||||
.nested { display: none; }
|
||||
}
|
||||
}`
|
||||
got := componentClasses(t, in)
|
||||
want := []string{"kept", "nested", "pseudo-only"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("componentClasses = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// Tailwind's output as it would be if `.pseudo-only` were not extractable.
|
||||
out := `.kept{color:red}.nested-thing{display:block}`
|
||||
if !selectorPresent(out, "kept") {
|
||||
t.Error("kept should be present")
|
||||
}
|
||||
if selectorPresent(out, "pseudo-only") {
|
||||
t.Error("pseudo-only should be reported missing — this is the W4 bug")
|
||||
}
|
||||
// The boundary check: `.nested` must not match inside `.nested-thing`.
|
||||
if selectorPresent(out, "nested") {
|
||||
t.Error("nested matched the prefix of .nested-thing — the boundary check is broken")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurgeCheckComparesEscapedForms is the W6 lesson as a test: a raw-name grep
|
||||
// reports a false negative on any class Tailwind had to escape, which reads
|
||||
// exactly like a purge failure and once cost a session's time "fixing" a class
|
||||
// that was never broken.
|
||||
func TestPurgeCheckComparesEscapedForms(t *testing.T) {
|
||||
out := `.text-\[color\:var\(--warn\)\]{color:var(--warn)}`
|
||||
if !selectorPresent(out, "text-[color:var(--warn)]") {
|
||||
t.Error("escaped class reported missing — the check must escape before comparing")
|
||||
}
|
||||
if strings.Contains(out, ".text-[color:var(--warn)]") {
|
||||
t.Error("fixture is wrong: the raw form should not appear in Tailwind output")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/opentdb"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestDevCasino is not a test. It is the casino, running, on a port, with one
|
||||
// signed-in player who has chips — so the table can be driven in a real browser,
|
||||
// which is the only honest way to review an animation.
|
||||
//
|
||||
// Skipped unless you ask for it:
|
||||
//
|
||||
// PETE_DEV_CASINO=:7788 go test ./internal/web -run TestDevCasino -timeout 0
|
||||
//
|
||||
// It prints the session cookie to plant. The routes are wired here rather than
|
||||
// taken from New(), because New() decides whether the casino exists at the
|
||||
// moment it builds the mux, and the test rig only signs the player in afterwards.
|
||||
func TestDevCasino(t *testing.T) {
|
||||
addr := os.Getenv("PETE_DEV_CASINO")
|
||||
if addr == "" {
|
||||
t.Skip("set PETE_DEV_CASINO=:port to run the casino for a browser")
|
||||
}
|
||||
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
fundUser(t, bobPlayer, 5000)
|
||||
seedTriviaBank(t)
|
||||
|
||||
// The full table runtime, so the turn clock and the reaper are live under the
|
||||
// browser exactly as in production.
|
||||
s.StartTableClock(context.Background())
|
||||
|
||||
cookie := devCookie(s, "reala", "Reala")
|
||||
// A second player, so a shared table can be reviewed — hold'em is multiplayer
|
||||
// now, and one browser cannot see two people at the felt. Plant this cookie in
|
||||
// a second browser profile (or a private window) to sit down as Bob.
|
||||
bobCookie := devCookie(s, "bob", "Bob")
|
||||
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||
s.casinoRoutes(mux)
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Written to a file, not printed: `go test` buffers stdout, and the browser
|
||||
// driver needs the cookie while the server is still running. The second cookie
|
||||
// rides alongside it, newline-separated, for the second browser.
|
||||
if out := os.Getenv("PETE_DEV_COOKIE_FILE"); out != "" {
|
||||
if err := os.WriteFile(out, []byte(cookie+"\n"+bobCookie), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\nBOB %s=%s\n\n",
|
||||
addr, sessionCookie, cookie, sessionCookie, bobCookie)
|
||||
|
||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
t.Cleanup(func() { _ = srv.Close() })
|
||||
_ = srv.Serve(ln)
|
||||
}
|
||||
|
||||
// devCookie mints a signed session for a player the rig has funded, so the felt
|
||||
// can be driven as them.
|
||||
func devCookie(s *Server, username, name string) string {
|
||||
payload, _ := json.Marshal(SessionUser{
|
||||
Sub: "sub-" + username, Username: username, Name: name,
|
||||
Exp: time.Now().Add(24 * time.Hour).Unix(),
|
||||
})
|
||||
return s.auth.sign(payload)
|
||||
}
|
||||
|
||||
// seedTriviaBank puts enough questions in the bank to deal a ladder of each
|
||||
// difficulty.
|
||||
//
|
||||
// The rig does not run StartTriviaBank — a dev casino that spends its first two
|
||||
// minutes dripping four hundred questions per difficulty out of a free API is a
|
||||
// dev casino you cannot use. But a fresh database has an empty bank, and an
|
||||
// empty bank means every start 503s, so the rig would be unable to show you the
|
||||
// one game it exists to show you.
|
||||
//
|
||||
// One real batch per difficulty, through the real client: fifty questions is
|
||||
// four ladders' worth, and it means what the browser renders came out of OpenTDB
|
||||
// and through the same decode-and-store path production uses, entities and all.
|
||||
func seedTriviaBank(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
client := opentdb.New()
|
||||
|
||||
for i, tier := range trivia.Tiers {
|
||||
have, err := storage.CountTrivia(tier.Difficulty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if have >= trivia.Rungs {
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(opentdb.Politeness) // the API asks; asking faster earns nothing
|
||||
}
|
||||
qs, err := client.Fetch(ctx, tier.Difficulty, opentdb.Batch)
|
||||
if err != nil {
|
||||
t.Fatalf("seeding the trivia bank (%s): %v", tier.Difficulty, err)
|
||||
}
|
||||
added, err := storage.AddTriviaQuestions(tier.Difficulty, qs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf("BANK %-6s %d questions\n", tier.Difficulty, added)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestFeeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The euro/chip wire.
|
||||
//
|
||||
// gogobee owns the euros and has no inbound API, so it is the only initiator:
|
||||
// it polls this endpoint for border crossings, moves the money on its side, and
|
||||
// pushes the verdict back through the durable queue it already uses for
|
||||
// adventure facts. Pete never calls gogobee. That direction of travel is a
|
||||
// standing rule of the seam, not an implementation detail — see roster.go.
|
||||
//
|
||||
// All three endpoints are bearer-authed with the same ingest token as the
|
||||
// adventure seam, and all three are idempotent, because the thing on the other
|
||||
// end of them is a retrying queue and the thing they move is money.
|
||||
//
|
||||
// The storage layer under this (internal/storage/games.go) is where the actual
|
||||
// invariant lives: chips exist only once gogobee confirms it took the euros.
|
||||
// These handlers are transport, and deliberately nothing more.
|
||||
|
||||
// escrowGUID is the body of the two POSTs that name a row.
|
||||
type escrowGUID struct {
|
||||
GUID string `json:"guid"`
|
||||
}
|
||||
|
||||
// escrowVerdict is gogobee's answer: did the money move, and what is the
|
||||
// player's euro balance now.
|
||||
type escrowVerdict struct {
|
||||
GUID string `json:"guid"`
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BalanceAfter float64 `json:"balance_after"`
|
||||
}
|
||||
|
||||
// handleEscrowPending is gogobee's poll: every crossing waiting to be moved.
|
||||
//
|
||||
// It includes rows gogobee claimed but never reported on — see
|
||||
// storage.PendingEscrow. Re-offering those is the whole reason the guid is an
|
||||
// idempotency key: if gogobee already moved the euros, the retry is a no-op
|
||||
// that reports the same answer, and if it died before moving them, the money
|
||||
// gets moved now instead of being stranded.
|
||||
func (s *Server) handleEscrowPending(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
rows, err := storage.PendingEscrow(escrowPollLimit)
|
||||
if err != nil {
|
||||
slog.Error("games: pending escrow", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []storage.Escrow{} // an empty poll is [], never null
|
||||
}
|
||||
writeJSON(w, rows)
|
||||
}
|
||||
|
||||
// escrowPollLimit caps one poll. gogobee polls every few seconds, so a backlog
|
||||
// drains in a handful of ticks rather than arriving as one enormous body.
|
||||
const escrowPollLimit = 50
|
||||
|
||||
// handleEscrowClaim marks a row as taken. It is not a lock — a row already
|
||||
// claimed can be claimed again, which is how a stale re-offer works — but a row
|
||||
// that has already reached a verdict cannot be, which is what stops a settled
|
||||
// cash-out being paid twice.
|
||||
//
|
||||
// The claimed row goes back in the response, so gogobee moves the money against
|
||||
// the amount and the user *Pete* holds rather than the ones it read a poll ago.
|
||||
func (s *Server) handleEscrowClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req escrowGUID
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil || req.GUID == "" {
|
||||
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := storage.ClaimEscrow(req.GUID)
|
||||
if errors.Is(err, storage.ErrNoSuchEscrow) {
|
||||
http.Error(w, "no such escrow", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: claim escrow", "guid", req.GUID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, e)
|
||||
}
|
||||
|
||||
// handleEscrowSettled applies gogobee's verdict. This is the only way chips are
|
||||
// ever created, and it runs exactly once per guid no matter how many times the
|
||||
// push is redelivered.
|
||||
//
|
||||
// An unknown guid is a 400 rather than a shrug: gogobee has, by this point,
|
||||
// already moved real euros for a row Pete has no record of. Under the contract
|
||||
// the adventure seam established, a 400 makes gogobee's sender park the row
|
||||
// instead of retrying it forever — which is right, because no amount of retrying
|
||||
// invents the missing row. It leaves the payload sitting in gogobee's queue,
|
||||
// where a human can find it.
|
||||
func (s *Server) handleEscrowSettled(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var v escrowVerdict
|
||||
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
|
||||
}
|
||||
|
||||
e, err := storage.SettleEscrow(v.GUID, v.OK, v.Reason, v.BalanceAfter)
|
||||
if errors.Is(err, storage.ErrNoSuchEscrow) {
|
||||
slog.Error("games: verdict for an escrow row we have never heard of — "+
|
||||
"gogobee has moved euros against it and Pete cannot honour them",
|
||||
"guid", v.GUID, "ok", v.OK)
|
||||
http.Error(w, "no such escrow", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: settle escrow", "guid", v.GUID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("games: escrow settled", "guid", e.GUID, "user", e.MatrixUser,
|
||||
"kind", e.Kind, "amount", e.Amount, "state", e.State, "reason", e.Reason)
|
||||
writeJSON(w, e)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("games: write response", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The turn clock: the first goroutine in Pete that has ever mutated game state.
|
||||
//
|
||||
// Every other background loop here reads, refills or prunes. This one plays. When
|
||||
// a human sits at a felt and walks away mid-hand, three other people are waiting
|
||||
// on a decision that is never coming, and something has to make it for them. That
|
||||
// something is this.
|
||||
//
|
||||
// It is built on one discipline and one guard.
|
||||
//
|
||||
// The discipline is rule 1: **collect the due tables and close the rows before
|
||||
// taking any lock.** The scan reads *sql.Rows from the one-connection pool; a lock
|
||||
// taken while those rows are open would hold the connection the rows need, and the
|
||||
// process would wedge. So DueTables returns a plain slice and the connection is
|
||||
// free before the first table is touched.
|
||||
//
|
||||
// The guard is the version. The scan records each table's version; the clock acts
|
||||
// on a table only if it is *still* that version by the time it holds the lock and
|
||||
// has reloaded. Without that check the clock and a real move race and both land:
|
||||
// Bob raises in the same second his clock expires, the action moves to Cara, and
|
||||
// the clock — still believing the seat that ran out of time is to act — folds
|
||||
// Cara, who had twenty-five seconds left. The version check turns that into a
|
||||
// no-op: Bob's move bumped the version, the reload shows the new one, and the
|
||||
// clock steps aside.
|
||||
|
||||
// clockInterval is how often the clock looks for expired turns. Sub-second
|
||||
// precision buys nothing at a card table and would only spin the CPU.
|
||||
const clockInterval = time.Second
|
||||
|
||||
// reaperInterval is how often idle sessions are cashed out. The reaper is a
|
||||
// slow-moving safety net — a player who wandered off half an hour ago is not in a
|
||||
// hurry — so it runs far less often than the clock.
|
||||
const reaperInterval = time.Minute
|
||||
|
||||
// games returns the multiplayer engines by their storage key. It is the registry
|
||||
// the clock and the handlers both dispatch through. Empty until an engine is
|
||||
// wired; a clock over no games is a loop that finds nothing, which is correct.
|
||||
func (s *Server) games() map[string]tableGame {
|
||||
out := make(map[string]tableGame)
|
||||
for _, g := range s.tableGames {
|
||||
out[g.name()] = g
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StartTableClock launches the turn clock and the session reaper if the casino is
|
||||
// on. Safe to call unconditionally.
|
||||
func (s *Server) StartTableClock(ctx context.Context) {
|
||||
if !s.gamesReady() {
|
||||
return
|
||||
}
|
||||
// A deploy just took every table's clock down with it. Shove the live deadlines
|
||||
// out so the first tick does not auto-act the whole room at once.
|
||||
if err := storage.PushDeadlines(bootGrace); err != nil {
|
||||
slog.Error("games: push deadlines on boot", "err", err)
|
||||
}
|
||||
go s.runTableClock(ctx)
|
||||
go s.runSessionReaper(ctx)
|
||||
go s.runTableReaper(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) runTableClock(ctx context.Context) {
|
||||
slog.Info("games: turn clock started", "interval", clockInterval)
|
||||
ticker := time.NewTicker(clockInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.tickClock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tickClock finds the tables whose turn has expired and acts on each. The scan is
|
||||
// done and the connection released before any table is locked — rule 1.
|
||||
func (s *Server) tickClock() {
|
||||
due, err := storage.DueTables(time.Now().Unix())
|
||||
if err != nil {
|
||||
slog.Error("games: due tables", "err", err)
|
||||
return
|
||||
}
|
||||
for _, ref := range due {
|
||||
s.runClockTable(ref)
|
||||
}
|
||||
}
|
||||
|
||||
// runClockTable acts for the walked-away player at one table, but only if the
|
||||
// table is still at the version the scan saw.
|
||||
//
|
||||
// The whole read-modify-write is done under the table's stripe, which is what
|
||||
// keeps the clock from racing a second copy of itself — but the stripe is only an
|
||||
// optimisation. The version check inside CommitTable is the real thing: even
|
||||
// across a redeploy, when two processes hold two different stripes over this row,
|
||||
// the one whose write lands first bumps the version and the other's write finds
|
||||
// zero rows and rolls back.
|
||||
func (s *Server) runClockTable(ref storage.TableRef) {
|
||||
err := s.tableLocks.withTable(ref.ID, func() error {
|
||||
t, seats, err := storage.LoadTable(ref.ID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
return nil // closed out from under us; nothing to do
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Somebody moved between the scan and now. Their move set a fresh deadline (or
|
||||
// cleared it), so this expiry is stale — step aside.
|
||||
if t.Version != ref.Version {
|
||||
return nil
|
||||
}
|
||||
// A deadline in the future means the scan is looking at an old view; leave it.
|
||||
if t.Deadline == 0 || t.Deadline > time.Now().Unix() {
|
||||
return nil
|
||||
}
|
||||
|
||||
game := s.games()[t.Game]
|
||||
if game == nil {
|
||||
slog.Error("games: clock over unknown game", "game", t.Game, "table", t.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
st, newSeats, err := game.timeout(t.State, seats)
|
||||
if errors.Is(err, errNotDue) {
|
||||
return nil // the race resolved without us; nothing to act on
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: clock timeout", "table", t.ID, "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
|
||||
if err := storage.CommitTable(storage.TableCommit{Table: t, Seats: newSeats, Audit: st.Audit}); err != nil {
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
return nil // lost the race after all; the winner handled it
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.publishTable(ref.ID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: clock table", "table", ref.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishTable pushes the current table view to everyone watching it. It reads
|
||||
// the table fresh (the authoritative state) and fans an opaque frame out through
|
||||
// the hub. Called after every committed write, under no lock the hub cares about
|
||||
// — the hub's sends are non-blocking, so this never stalls a caller.
|
||||
//
|
||||
// A view here is deliberately seat-blind: it carries only what every seat may see
|
||||
// (the version and the public table shape), and each subscriber's own stream
|
||||
// redacts and re-renders for the seat that is watching. That keeps a hole card
|
||||
// from ever entering a frame that fans to the whole table.
|
||||
func (s *Server) publishTable(tableID string) {
|
||||
if s.hub.watchers(tableID) == 0 {
|
||||
return
|
||||
}
|
||||
t, _, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: publish table load", "table", tableID, "err", err)
|
||||
return
|
||||
}
|
||||
// The frame is just a nudge carrying the version: a subscriber that sees a gap
|
||||
// refetches the authoritative, per-seat table. So the payload can be minimal.
|
||||
// The type tells the browser a table changed (come and look) from a chat line
|
||||
// (render it in place) — both ride the one stream.
|
||||
data, _ := json.Marshal(map[string]any{"type": "table", "version": t.Version, "phase": t.Phase})
|
||||
s.hub.publish(tableID, hubFrame{Version: t.Version, Data: data})
|
||||
}
|
||||
|
||||
// runSessionReaper cashes out players who wandered off, on a timer. This is the
|
||||
// loop the plan noted never existed: ReapIdleSessions has always been safe to run
|
||||
// and nothing ever ran it, so chips in abandoned *solo* sessions sat in limbo.
|
||||
//
|
||||
// A seated player is invisible to it — their chips are inside a table blob, not on
|
||||
// their game_chips stack — so it only ever reaps a player who is genuinely idle
|
||||
// with loose chips. Getting up from a table returns them to the stack, and then
|
||||
// this is what eventually sends them home.
|
||||
func (s *Server) runSessionReaper(ctx context.Context) {
|
||||
slog.Info("games: session reaper started", "interval", reaperInterval, "idle", storage.SessionIdleAfter)
|
||||
ticker := time.NewTicker(reaperInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := storage.ReapIdleSessions(storage.SessionIdleAfter)
|
||||
if err != nil {
|
||||
slog.Error("games: reap idle sessions", "err", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
slog.Info("games: reaped idle sessions", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// fakeGame is a tableGame that records whether its clock ever fired. It lets the
|
||||
// runtime tests exercise the lock discipline and the version guard without a real
|
||||
// engine — the thing under test is the clock, not the cards.
|
||||
type fakeGame struct {
|
||||
fired int
|
||||
}
|
||||
|
||||
func (g *fakeGame) name() string { return "fake" }
|
||||
|
||||
func (g *fakeGame) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
|
||||
g.fired++
|
||||
// Act: clear the deadline and bump the hand, as a real settle would.
|
||||
return step{State: []byte(`{"acted":true}`), Phase: "handover", HandNo: 2, Deadline: 0}, seats, nil
|
||||
}
|
||||
|
||||
func (g *fakeGame) stacks(state []byte) ([]int64, error) { return []int64{0, 0}, nil }
|
||||
|
||||
// clockTestServer stands up a Server with just the table machinery wired and a
|
||||
// fresh DB. Enough to drive the clock, nothing else.
|
||||
func clockTestServer(t *testing.T, g tableGame) *Server {
|
||||
t.Helper()
|
||||
// Reset the storage singleton onto a temp DB.
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "clock.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
s := &Server{hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{g}}
|
||||
return s
|
||||
}
|
||||
|
||||
func openClockTable(t *testing.T, id string, deadline int64) storage.Table {
|
||||
t.Helper()
|
||||
tbl := storage.Table{
|
||||
ID: id, Game: "fake", Tier: "1-2", State: []byte(`{}`),
|
||||
Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1, Deadline: deadline,
|
||||
}
|
||||
seats := []storage.Seat{{Seat: 0, MatrixUser: "@reala:parodia.dev", Name: "reala"}, {Seat: 1, Name: "bot"}}
|
||||
if err := storage.OpenTable(tbl, seats); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
func TestClock_ActsOnExpiredTable(t *testing.T) {
|
||||
g := &fakeGame{}
|
||||
s := clockTestServer(t, g)
|
||||
openClockTable(t, "t1", time.Now().Unix()-5) // already expired
|
||||
|
||||
s.tickClock()
|
||||
|
||||
if g.fired != 1 {
|
||||
t.Fatalf("clock should have fired once, got %d", g.fired)
|
||||
}
|
||||
after, _, err := storage.LoadTable("t1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Phase != "handover" || after.Deadline != 0 {
|
||||
t.Errorf("table should have advanced: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClock_IgnoresFutureDeadlines(t *testing.T) {
|
||||
g := &fakeGame{}
|
||||
s := clockTestServer(t, g)
|
||||
openClockTable(t, "t1", time.Now().Unix()+60)
|
||||
|
||||
s.tickClock()
|
||||
|
||||
if g.fired != 0 {
|
||||
t.Fatalf("clock should not have fired on a future deadline, got %d", g.fired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClock_VersionGuardStopsTheDoubleMove is the scenario the whole design turns
|
||||
// on. A move lands in the same tick the clock's scan found the table expired. The
|
||||
// move bumps the version; the clock, acting on its stale scan, must see the new
|
||||
// version and step aside rather than acting a second time.
|
||||
func TestClock_VersionGuardStopsTheDoubleMove(t *testing.T) {
|
||||
g := &fakeGame{}
|
||||
s := clockTestServer(t, g)
|
||||
tbl := openClockTable(t, "t1", time.Now().Unix()-5)
|
||||
|
||||
// The scan saw version 0.
|
||||
due, err := storage.DueTables(time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("want 1 due table, got %d", len(due))
|
||||
}
|
||||
|
||||
// A real move lands first, bumping the version and setting a fresh deadline for
|
||||
// the next player.
|
||||
tbl.State = []byte(`{"moved":true}`)
|
||||
tbl.Deadline = time.Now().Unix() + 30
|
||||
if err := storage.CommitTable(storage.TableCommit{Table: tbl}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Now the clock acts on its stale scan (version 0). It must not fire.
|
||||
s.runClockTable(due[0])
|
||||
|
||||
if g.fired != 0 {
|
||||
t.Fatalf("the version guard should have stopped the clock, but it fired %d time(s)", g.fired)
|
||||
}
|
||||
after, _, _ := storage.LoadTable("t1")
|
||||
if string(after.State) != `{"moved":true}` {
|
||||
t.Errorf("the real move should stand, got %s", after.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClock_PublishesToWatchers(t *testing.T) {
|
||||
g := &fakeGame{}
|
||||
s := clockTestServer(t, g)
|
||||
openClockTable(t, "t1", time.Now().Unix()-5)
|
||||
|
||||
ch, done := s.hub.subscribe("t1")
|
||||
defer done()
|
||||
|
||||
s.tickClock()
|
||||
|
||||
select {
|
||||
case f := <-ch:
|
||||
if f.Version == 0 {
|
||||
t.Errorf("frame should carry the bumped version, got %d", f.Version)
|
||||
}
|
||||
default:
|
||||
t.Fatal("a watcher should have received a frame after the clock acted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/hangman"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Hangman, played for chips.
|
||||
//
|
||||
// The same shape as the blackjack table: the browser sends intents, the server
|
||||
// holds the state, and the payload carries only what the player is entitled to
|
||||
// see. Here that means the *masked* phrase. The unmasked one is in the engine
|
||||
// state, which is in game_live_hands, which is on this side of the wire — a
|
||||
// phrase sent down and flagged hidden is a phrase read out of devtools, and the
|
||||
// game would be a formality.
|
||||
|
||||
// cellView is one position in the phrase, as the browser draws it.
|
||||
//
|
||||
// Ch is empty while the letter is hidden — not the letter with a flag beside
|
||||
// it. Slot says whether this is a position you'd guess at all: a space or an
|
||||
// exclamation mark is scaffolding, shows from the start, and gets no tile.
|
||||
type cellView struct {
|
||||
Ch string `json:"ch"`
|
||||
Slot bool `json:"slot"`
|
||||
}
|
||||
|
||||
// hangmanView is a game as its player may see it.
|
||||
type hangmanView struct {
|
||||
Tier hangman.Tier `json:"tier"`
|
||||
Cells []cellView `json:"cells"`
|
||||
Tried []string `json:"tried"` // every letter guessed, right or wrong
|
||||
Wrong []string `json:"wrong"` // just the misses — the gallows counts these
|
||||
Lives int `json:"lives"`
|
||||
MaxWrong int `json:"max_wrong"`
|
||||
Multiple float64 `json:"multiple"` // what a win is worth right now
|
||||
Bet int64 `json:"bet"`
|
||||
Stands int64 `json:"stands"` // what the player would actually be paid if they won now
|
||||
|
||||
Phase string `json:"phase"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Phrase string `json:"phrase,omitempty"` // only once it's over
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
}
|
||||
|
||||
func viewHangman(g hangman.State) hangmanView {
|
||||
v := hangmanView{
|
||||
Tier: g.Tier,
|
||||
Lives: g.Lives(),
|
||||
MaxWrong: hangman.MaxWrong,
|
||||
Multiple: g.Multiple(),
|
||||
Bet: g.Bet,
|
||||
// What the player would actually collect, rake already taken out. Quoting
|
||||
// the pre-rake figure here would have the felt advertising a payout the
|
||||
// house doesn't hand over.
|
||||
Stands: g.Pays(),
|
||||
Phase: string(g.Phase),
|
||||
Outcome: string(g.Outcome),
|
||||
Payout: g.Payout,
|
||||
Rake: g.Rake,
|
||||
Net: g.Net(),
|
||||
}
|
||||
for i, r := range g.Runes {
|
||||
c := cellView{Slot: hangman.Guessable(r)}
|
||||
if i < len(g.Shown) && g.Shown[i] {
|
||||
c.Ch = string(r)
|
||||
}
|
||||
v.Cells = append(v.Cells, c)
|
||||
}
|
||||
for _, r := range g.Tried {
|
||||
v.Tried = append(v.Tried, string(r))
|
||||
}
|
||||
for _, r := range g.Wrong {
|
||||
v.Wrong = append(v.Wrong, string(r))
|
||||
}
|
||||
// The phrase goes over the wire exactly once: when the game is over and it no
|
||||
// longer decides anything.
|
||||
if g.Phase == hangman.PhaseDone {
|
||||
v.Phrase = g.Phrase
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// handleHangmanStart takes the bet and draws a phrase. Same order as a deal:
|
||||
// the chips are staked first, in the same statement that checks they exist, so
|
||||
// two starts fired at once cannot bet the same chip.
|
||||
func (s *Server) handleHangmanStart(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Bet int64 `json:"bet"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||
return
|
||||
}
|
||||
tier, err := hangman.TierBySlug(req.Tier)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a length"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.Stake(user, req.Bet); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: hangman stake", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
seed1, seed2 := newSeeds()
|
||||
rng := rand.New(rand.NewPCG(seed1, seed2))
|
||||
g, evs, err := hangman.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
|
||||
if err != nil {
|
||||
// The game never happened, so the stake never should have left.
|
||||
_ = storage.Award(user, req.Bet)
|
||||
slog.Error("games: hangman start", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.persistHangman(w, user, g, evs, seed1, seed2, true)
|
||||
}
|
||||
|
||||
// handleHangmanGuess plays one guess: a letter, or the whole phrase.
|
||||
func (s *Server) handleHangmanGuess(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var move hangman.Move
|
||||
if err := decodeJSON(r, &move); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
live, err := storage.LoadLiveHand(user)
|
||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: hangman load", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if live.Game != gameHangman {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
|
||||
return
|
||||
}
|
||||
var g hangman.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
slog.Error("games: unreadable hangman game", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
next, evs, err := hangman.ApplyMove(g, move)
|
||||
if err != nil {
|
||||
// A letter already tried is the one illegal move a player makes by
|
||||
// accident rather than by trying it on, so it gets its own answer.
|
||||
msg := "that guess isn't legal here"
|
||||
if errors.Is(err, hangman.ErrAlreadyTried) {
|
||||
msg = "you've already tried that one"
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
s.persistHangman(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||
}
|
||||
|
||||
// persistHangman writes the game back and answers the browser.
|
||||
func (s *Server) persistHangman(w http.ResponseWriter, user string, g hangman.State, evs []hangman.Event, seed1, seed2 uint64, fresh bool) {
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal hangman", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
done := g.Phase == hangman.PhaseDone
|
||||
v, ok := s.commit(w, user, finished{
|
||||
Game: gameHangman, Blob: blob,
|
||||
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||
Outcome: string(g.Outcome), Done: done,
|
||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A finished game is gone from storage, so the table has none to show — but
|
||||
// the browser still needs the final board to reveal the phrase onto.
|
||||
if done {
|
||||
hv := viewHangman(g)
|
||||
v.Hangman = &hv
|
||||
}
|
||||
v.HangEvents = evs
|
||||
writeJSON(w, v)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The one thing this table cannot get wrong: the stake leaves the stack, and the
|
||||
// phrase does not leave the server.
|
||||
func TestHangmanStartTakesTheStakeAndKeepsThePhrase(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("start = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 900 {
|
||||
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||
}
|
||||
if v.Hangman == nil {
|
||||
t.Fatal("start returned no game")
|
||||
}
|
||||
if v.Game != gameHangman {
|
||||
t.Errorf("game = %q, want hangman", v.Game)
|
||||
}
|
||||
if v.Hangman.Phrase != "" {
|
||||
t.Fatalf("the phrase was sent to the browser before it was won: %q", v.Hangman.Phrase)
|
||||
}
|
||||
// Nothing is revealed at the start except the scaffolding, and a space is not
|
||||
// a letter you have to earn.
|
||||
for _, c := range v.Hangman.Cells {
|
||||
if c.Slot && c.Ch != "" {
|
||||
t.Fatalf("a letter was face up before it was guessed: %+v", c)
|
||||
}
|
||||
}
|
||||
if v.Hangman.Lives != 6 {
|
||||
t.Errorf("lives = %d, want 6", v.Hangman.Lives)
|
||||
}
|
||||
}
|
||||
|
||||
// A win pays what the felt said it would, and the rake comes out of the winnings.
|
||||
func TestHangmanWinPaysWhatTheFeltQuoted(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, _ := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
quoted := v.Hangman.Stands
|
||||
|
||||
// The server holds the phrase, so read it out of the live row — which is the
|
||||
// only place it exists — and solve it.
|
||||
phrase := livePhrase(t)
|
||||
v, code := call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
|
||||
map[string]string{"solve": phrase}))
|
||||
if code != 200 {
|
||||
t.Fatalf("solve = %d, want 200", code)
|
||||
}
|
||||
if v.Hangman.Outcome != "solved" {
|
||||
t.Fatalf("outcome = %q, want solved", v.Hangman.Outcome)
|
||||
}
|
||||
// No wrong guesses, so the full 2.6×: 260 gross, 160 profit, 8 rake, 252 back.
|
||||
if v.Hangman.Payout != quoted {
|
||||
t.Errorf("felt quoted %d, house paid %d", quoted, v.Hangman.Payout)
|
||||
}
|
||||
if v.Hangman.Payout != 252 || v.Hangman.Rake != 8 {
|
||||
t.Errorf("payout/rake = %d/%d, want 252/8", v.Hangman.Payout, v.Hangman.Rake)
|
||||
}
|
||||
if got := chipsNow(t); got != 900+252 {
|
||||
t.Errorf("chips = %d, want %d", got, 900+252)
|
||||
}
|
||||
// And the phrase is finally allowed out, now that it decides nothing.
|
||||
if v.Hangman.Phrase == "" {
|
||||
t.Error("a finished game never told the player what the phrase was")
|
||||
}
|
||||
// The game is off the felt.
|
||||
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
|
||||
t.Error("a settled game is still sitting in game_live_hands")
|
||||
}
|
||||
}
|
||||
|
||||
// Six wrong guesses take the stake and nothing more.
|
||||
func TestHangmanHangingCostsExactlyTheStake(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
|
||||
// Six solves that are certainly wrong — a wrong solve costs a life, same as a
|
||||
// wrong letter, and this needs no knowledge of the phrase.
|
||||
var v tableView
|
||||
for i := 0; i < 6; i++ {
|
||||
v, _ = call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
|
||||
map[string]string{"solve": "definitely not the phrase at all"}))
|
||||
}
|
||||
if v.Hangman == nil || v.Hangman.Outcome != "hung" {
|
||||
t.Fatalf("outcome = %+v, want hung", v.Hangman)
|
||||
}
|
||||
if v.Hangman.Payout != 0 {
|
||||
t.Errorf("payout = %d, want 0", v.Hangman.Payout)
|
||||
}
|
||||
if got := chipsNow(t); got != 900 {
|
||||
t.Errorf("chips = %d, want 900 — a loss costs the stake and no more", got)
|
||||
}
|
||||
if v.Hangman.Phrase == "" {
|
||||
t.Error("hung without being told the answer")
|
||||
}
|
||||
}
|
||||
|
||||
// One game at a time, across games: you cannot walk from a hangman into a hand of
|
||||
// blackjack with chips still riding on a phrase.
|
||||
func TestHangmanHoldsTheSeatAgainstBlackjack(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
|
||||
_, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal",
|
||||
map[string]int64{"bet": 100}))
|
||||
if code != 409 {
|
||||
t.Fatalf("dealt blackjack on top of a live hangman: %d, want 409", code)
|
||||
}
|
||||
// And the stake that was refused came back: 1000 - 100 (the hangman) and not a
|
||||
// chip more.
|
||||
if got := chipsNow(t); got != 900 {
|
||||
t.Errorf("chips = %d, want 900 — the refused deal kept the stake", got)
|
||||
}
|
||||
if _, err := storage.LoadLiveHand(testPlayer); err != nil {
|
||||
t.Errorf("the hangman was evicted by the deal it refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cashing out mid-phrase is refused, for the same reason as mid-hand.
|
||||
func TestCannotCashOutMidPhrase(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
|
||||
_, code := call(t, s, s.handleCashOut, as(t, s, "reala", "POST", "/api/games/cashout",
|
||||
map[string]int64{"amount": 0}))
|
||||
if code != 409 {
|
||||
t.Fatalf("cash-out mid-phrase = %d, want 409", code)
|
||||
}
|
||||
}
|
||||
|
||||
// A tier the browser made up is refused, and costs nothing.
|
||||
func TestHangmanRefusesAnInventedTier(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
_, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "impossible"}))
|
||||
if code != 400 {
|
||||
t.Fatalf("start on a made-up tier = %d, want 400", code)
|
||||
}
|
||||
if got := chipsNow(t); got != 1000 {
|
||||
t.Errorf("chips = %d, want 1000 — a refused game must not take a stake", got)
|
||||
}
|
||||
}
|
||||
|
||||
// livePhrase digs the phrase out of the live row. Only a test may do this: it is
|
||||
// reaching past the wire on purpose, to prove the wire doesn't carry it.
|
||||
func livePhrase(t *testing.T) string {
|
||||
t.Helper()
|
||||
live, err := storage.LoadLiveHand(testPlayer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob := string(live.State)
|
||||
const key = `"phrase":"`
|
||||
i := strings.Index(blob, key)
|
||||
if i < 0 {
|
||||
t.Fatalf("no phrase in the live row: %s", blob)
|
||||
}
|
||||
rest := blob[i+len(key):]
|
||||
j := strings.Index(rest, `"`)
|
||||
if j < 0 {
|
||||
t.Fatal("unterminated phrase in the live row")
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/holdem"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Texas hold'em, played for chips against the trained bots.
|
||||
//
|
||||
// This is the only table in the casino that is a *session* rather than a game.
|
||||
// Everywhere else you stake, you play once, and a multiple pays: the hand is the
|
||||
// unit and the money moves at both ends of it. Poker is not that shape. You buy
|
||||
// chips onto the table, you play as many hands as you feel like, and you leave
|
||||
// with whatever is in front of you — so the live row lives across hands, and the
|
||||
// chips move exactly twice: once when you sit down, once when you get up.
|
||||
//
|
||||
// Which means there is no "payout" until you stand up, and `commit` is only ever
|
||||
// told the game is Done when you do (or when you have nothing left to sit with).
|
||||
// In between, every pot won and lost is inside the engine, and storage sees none
|
||||
// of it. That is the honest model, and it is also the safe one: a hand that dies
|
||||
// halfway leaves the chips where they were, on the table, in the live row.
|
||||
//
|
||||
// What the browser is allowed to see: your two cards, the board, everybody's
|
||||
// stacks and bets, and nothing else. Not the deck, and not a bot's hand — until
|
||||
// a showdown turns it over, which is the moment it stops being a secret.
|
||||
|
||||
// holdemSeatView is one seat. Cards are present only when the viewer is entitled
|
||||
// to them: yours always, a bot's never, until the hand is shown down.
|
||||
type holdemSeatView struct {
|
||||
Name string `json:"name"`
|
||||
Bot bool `json:"bot"`
|
||||
You bool `json:"you"`
|
||||
Stack int64 `json:"stack"`
|
||||
Bet int64 `json:"bet"`
|
||||
State string `json:"state"` // active | folded | allin | out
|
||||
Pos string `json:"pos"` // BTN, SB, BB, UTG…
|
||||
Cards []cardView `json:"cards,omitempty"`
|
||||
Won int64 `json:"won,omitempty"`
|
||||
}
|
||||
|
||||
var seatStates = map[holdem.SeatState]string{
|
||||
holdem.Active: "active",
|
||||
holdem.Folded: "folded",
|
||||
holdem.AllIn: "allin",
|
||||
holdem.Out: "out",
|
||||
}
|
||||
|
||||
// holdemView is the table as its player may see it.
|
||||
type holdemView struct {
|
||||
Tier holdem.Tier `json:"tier"`
|
||||
// YourSeat is which chair in Seats is the viewer's. It used to be a convention
|
||||
// (seat zero is you) that the felt hardcoded; at a shared table it is whatever
|
||||
// chair you took, so it rides in the view and the browser reads it rather than
|
||||
// assuming it.
|
||||
YourSeat int `json:"your_seat"`
|
||||
Seats []holdemSeatView `json:"seats"`
|
||||
Button int `json:"button"`
|
||||
HandNo int `json:"hand_no"`
|
||||
|
||||
Board []cardView `json:"board"`
|
||||
Street string `json:"street"`
|
||||
Pot int64 `json:"pot"`
|
||||
Side []int64 `json:"side,omitempty"`
|
||||
|
||||
ToAct int `json:"to_act"`
|
||||
Phase string `json:"phase"`
|
||||
|
||||
// What you may do, decided here rather than in the browser — the felt should
|
||||
// never offer a button the table would refuse.
|
||||
Owed int64 `json:"owed"`
|
||||
CanCheck bool `json:"can_check"`
|
||||
CanRaise bool `json:"can_raise"`
|
||||
MinRaise int64 `json:"min_raise_to"`
|
||||
MaxRaise int64 `json:"max_raise_to"`
|
||||
|
||||
Stack int64 `json:"stack"` // what's in front of you
|
||||
BoughtIn int64 `json:"bought_in"`
|
||||
Rake int64 `json:"rake"` // what the house has taken this session
|
||||
MaxTopUp int64 `json:"max_topup"`
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
}
|
||||
|
||||
// viewHoldem renders the table as one seat may see it. viewer is which seat is
|
||||
// looking — their cards are the only hole cards it will ever put in the payload
|
||||
// (until a showdown turns the rest over), and the action panel is filled in only
|
||||
// when it is that seat's turn.
|
||||
//
|
||||
// This is the security boundary. Before SSE a missed check here leaked one bot's
|
||||
// cards to one player through a bug in one handler; now the same view fans to
|
||||
// every subscriber's stream, so a seat that renders anyone else's hole card fans
|
||||
// it to the whole table. TestHoldemViewNeverLeaksAnotherSeatsCards renders every
|
||||
// seat's view at every street and greps for cards that are not theirs.
|
||||
func viewHoldem(g holdem.State, viewer int) holdemView {
|
||||
v := holdemView{
|
||||
Tier: g.Tier,
|
||||
YourSeat: viewer,
|
||||
Button: g.Button,
|
||||
HandNo: g.HandNo,
|
||||
Street: g.Street.String(),
|
||||
Pot: g.Total(),
|
||||
ToAct: g.ToAct,
|
||||
Phase: string(g.Phase),
|
||||
Stack: g.Seats[viewer].Stack,
|
||||
BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake
|
||||
Rake: g.Seats[viewer].Paid, // this seat's rake alone, not the table's — see the ledger line on the felt
|
||||
Payout: g.Payout,
|
||||
}
|
||||
for _, p := range g.Side {
|
||||
v.Side = append(v.Side, p.Amount)
|
||||
}
|
||||
// An empty board is an empty board, not null. A Go slice with nothing in it
|
||||
// marshals to null, and a browser that has to write `(board || [])` everywhere
|
||||
// is a browser one forgotten guard away from a crash on every preflop.
|
||||
v.Board = []cardView{}
|
||||
for _, c := range g.Community {
|
||||
v.Board = append(v.Board, viewCard(c))
|
||||
}
|
||||
|
||||
// The wall. Another seat's hand crosses the wire in exactly one situation — the
|
||||
// hand was shown down and they did not fold — because that is the only situation
|
||||
// in which a player at a real table would be looking at it.
|
||||
shown := g.Street == holdem.Showdown
|
||||
for i, p := range g.Seats {
|
||||
seat := holdemSeatView{
|
||||
Name: p.Name,
|
||||
Bot: p.Bot,
|
||||
You: i == viewer,
|
||||
Stack: p.Stack,
|
||||
Bet: p.Bet,
|
||||
State: seatStates[p.State],
|
||||
Pos: g.Position(i),
|
||||
Won: p.Won,
|
||||
}
|
||||
mine := i == viewer
|
||||
dealt := p.State != holdem.Out && p.Hole[0].Rank != 0
|
||||
if dealt && (mine || (shown && p.State != holdem.Folded)) {
|
||||
seat.Cards = []cardView{viewCard(p.Hole[0]), viewCard(p.Hole[1])}
|
||||
}
|
||||
v.Seats = append(v.Seats, seat)
|
||||
}
|
||||
|
||||
if g.Phase == holdem.PhaseBetting && g.ToAct == viewer {
|
||||
v.Owed = g.Owed(viewer)
|
||||
v.CanCheck = v.Owed == 0
|
||||
v.CanRaise = g.CanRaise(viewer)
|
||||
v.MinRaise = g.MinRaiseTo(viewer)
|
||||
v.MaxRaise = g.MaxRaiseTo(viewer)
|
||||
}
|
||||
if top := g.Tier.MaxBuy - g.Seats[viewer].Stack; top > 0 {
|
||||
v.MaxTopUp = top
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// holdemEventView is one beat of the script the felt plays back. The engine only
|
||||
// ever attaches a bot's cards to a showdown; this drops them again anywhere else,
|
||||
// which is the second of the two walls.
|
||||
type holdemEventView struct {
|
||||
Kind string `json:"kind"`
|
||||
Seat int `json:"seat"`
|
||||
Cards []cardView `json:"cards,omitempty"`
|
||||
Amount int64 `json:"amount,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// viewHoldemEvents redacts the engine's script for one viewer. The engine emits
|
||||
// every seat's hole cards now (it cannot know who a shared stream is for), so
|
||||
// this is where the cards that are not the viewer's are stripped — turning a
|
||||
// "deal seat 3 these two cards" beat into "deal seat 3 two face-down cards".
|
||||
//
|
||||
// A card may ride an event only if it is a board card (Seat < 0), the viewer's
|
||||
// own, or a hand being shown down. Everything else is nulled here, and a missed
|
||||
// case is the leak that fans one seat's hole card to every subscriber.
|
||||
func viewHoldemEvents(evs []holdem.Event, viewer int) []holdemEventView {
|
||||
out := make([]holdemEventView, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
v := holdemEventView{Kind: e.Kind, Seat: e.Seat, Amount: e.Amount, Total: e.Total, Text: e.Text}
|
||||
for _, c := range e.Cards {
|
||||
v.Cards = append(v.Cards, viewCard(c))
|
||||
}
|
||||
if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != viewer && e.Kind != "show" {
|
||||
v.Cards = nil
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
// ---- sitting down: a table of your own, or somebody else's -----------------
|
||||
|
||||
// displayName is what goes on the felt. It is the player's session name if they
|
||||
// have one, the local part of their Matrix id otherwise — never empty, which
|
||||
// would sit a nameless chair at the table.
|
||||
func (s *Server) displayName(r *http.Request, user string) string {
|
||||
if u := s.auth.userFromRequest(r); u != nil {
|
||||
if u.Name != "" {
|
||||
return u.Name
|
||||
}
|
||||
if u.Username != "" {
|
||||
return u.Username
|
||||
}
|
||||
}
|
||||
name := strings.TrimPrefix(user, "@")
|
||||
if i := strings.IndexByte(name, ':'); i > 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// seatRows mirrors the engine's seats into the storage rows that shadow them,
|
||||
// index for index — seat i in the blob is seat i in game_seats — which is the
|
||||
// alignment the view redacts by and the audit attributes by. A human's staked is
|
||||
// the buy-in that actually crossed the border; a bot's is zero, because the only
|
||||
// real money at the table is in the human seats and staked is where the border
|
||||
// accounting lives.
|
||||
func seatRows(g holdem.State, human string, buyIn int64) []storage.Seat {
|
||||
rows := make([]storage.Seat, len(g.Seats))
|
||||
for i := range g.Seats {
|
||||
p := g.Seats[i]
|
||||
row := storage.Seat{Seat: i, Name: p.Name}
|
||||
if !p.Bot {
|
||||
row.MatrixUser = human
|
||||
row.Staked = buyIn
|
||||
}
|
||||
rows[i] = row
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// handleHoldemSit seats a player: at a fresh table of their own (solo is just a
|
||||
// table nobody else has joined yet), or at an open chair on somebody else's.
|
||||
func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Tier string `json:"tier"`
|
||||
Bots int `json:"bots"`
|
||||
BuyIn int64 `json:"buyin"`
|
||||
Table string `json:"table"` // set to join an existing table rather than open one
|
||||
Seat *int `json:"seat"` // which chair to take when joining; optional
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Table != "" {
|
||||
s.joinHoldem(w, r, user, req.Table, req.Seat, req.BuyIn)
|
||||
return
|
||||
}
|
||||
s.openHoldem(w, r, user, req.Tier, req.Bots, req.BuyIn)
|
||||
}
|
||||
|
||||
// openHoldem opens a fresh table with the player in seat zero and bots in the
|
||||
// rest. It is the old solo flow, now a real shared table that simply has no other
|
||||
// humans on it yet.
|
||||
func (s *Server) openHoldem(w http.ResponseWriter, r *http.Request, user, tierSlug string, bots int, buyIn int64) {
|
||||
tier, err := holdem.TierBySlug(tierSlug)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
|
||||
return
|
||||
}
|
||||
if buyIn < tier.MinBuy || buyIn > tier.MaxBuy {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"})
|
||||
return
|
||||
}
|
||||
if bots < 1 || bots > holdem.MaxBots {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"})
|
||||
return
|
||||
}
|
||||
|
||||
name := s.displayName(r, user)
|
||||
seed1, seed2 := newSeeds()
|
||||
g, _, err := holdem.New(tier, holdem.TableSeats(tier, name, bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||
if err != nil {
|
||||
slog.Error("games: holdem open", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal new holdem", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
id, err := storage.NewTableID()
|
||||
if err != nil {
|
||||
slog.Error("games: mint table id", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
t := storage.Table{
|
||||
ID: id, Game: gameHoldem, Tier: tier.Slug, State: blob,
|
||||
Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo),
|
||||
}
|
||||
err = storage.OpenSoloTable(t, seatRows(g, user, buyIn), buyIn)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
|
||||
return
|
||||
case errors.Is(err, storage.ErrHandInProgress):
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||
return
|
||||
case err != nil:
|
||||
slog.Error("games: open solo table", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.writeHoldemTable(w, user, nil)
|
||||
}
|
||||
|
||||
// pickOpenSeat chooses a chair to join: the one the caller asked for if it is a
|
||||
// bot's, otherwise the first bot seat. Returns -1 if there is nowhere to sit.
|
||||
func pickOpenSeat(g holdem.State, want *int) int {
|
||||
if want != nil {
|
||||
i := *want
|
||||
if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot {
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
for i := range g.Seats {
|
||||
if g.Seats[i].Bot {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// joinHoldem sits a player down at an open chair on an existing table. It runs
|
||||
// under the table lock, and every step of the sit-down is one transaction in
|
||||
// SitDown — stake, claim, take the chair out of a bot's hands, save the state —
|
||||
// so two people racing for the last seat cannot both win it.
|
||||
func (s *Server) joinHoldem(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) {
|
||||
name := s.displayName(r, user)
|
||||
var respErr error
|
||||
err := s.tableLocks.withTable(tableID, func() error {
|
||||
t, _, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
respErr = storage.ErrNoSuchTable
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Game != gameHoldem {
|
||||
respErr = holdem.ErrUnknownMove
|
||||
return nil
|
||||
}
|
||||
var g holdem.State
|
||||
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Phase == holdem.PhaseBetting {
|
||||
respErr = holdem.ErrHandLive // you join between hands, not into one
|
||||
return nil
|
||||
}
|
||||
seat := pickOpenSeat(g, wantSeat)
|
||||
if seat < 0 {
|
||||
respErr = holdem.ErrTableFull
|
||||
return nil
|
||||
}
|
||||
if err := g.Occupy(seat, name, buyIn); err != nil {
|
||||
respErr = err
|
||||
return nil
|
||||
}
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo)
|
||||
err = storage.SitDown(storage.Sit{
|
||||
Table: t,
|
||||
Seat: storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn},
|
||||
BuyIn: buyIn,
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
|
||||
respErr = storage.ErrInsufficientChips
|
||||
return nil
|
||||
case errors.Is(err, storage.ErrHandInProgress):
|
||||
respErr = storage.ErrHandInProgress
|
||||
return nil
|
||||
case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable):
|
||||
respErr = storage.ErrSeatTaken
|
||||
return nil
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: join holdem", "user", user, "table", tableID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if respErr != nil {
|
||||
writeJSONStatus(w, joinStatus(respErr), map[string]string{"error": joinMessage(respErr)})
|
||||
return
|
||||
}
|
||||
s.writeHoldemTable(w, user, nil)
|
||||
}
|
||||
|
||||
func joinStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, holdem.ErrTableFull),
|
||||
errors.Is(err, storage.ErrSeatTaken), errors.Is(err, holdem.ErrHandLive):
|
||||
return http.StatusConflict
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func joinMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNoSuchTable):
|
||||
return "that table has closed"
|
||||
case errors.Is(err, holdem.ErrTableFull), errors.Is(err, storage.ErrSeatTaken):
|
||||
return "that seat is taken"
|
||||
case errors.Is(err, holdem.ErrHandLive):
|
||||
return "a hand is in play — sit down when it's over"
|
||||
case errors.Is(err, holdem.ErrBadBuyIn):
|
||||
return "that isn't a legal buy-in for this table"
|
||||
case errors.Is(err, storage.ErrInsufficientChips):
|
||||
return "not enough chips to sit down"
|
||||
case errors.Is(err, storage.ErrHandInProgress):
|
||||
return "finish the game you're in first"
|
||||
default:
|
||||
return "you can't sit there"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- playing a hand --------------------------------------------------------
|
||||
|
||||
// handleHoldemMove plays one move at the player's table: a betting action, or the
|
||||
// two session moves that are not leaving (deal the next hand, top up between
|
||||
// them). Leaving is its own endpoint, because it is a storage operation rather
|
||||
// than an engine one.
|
||||
func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var move holdem.Move
|
||||
if err := decodeJSON(r, &move); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if move.Kind == holdem.Leave {
|
||||
s.leaveHoldem(w, user)
|
||||
return
|
||||
}
|
||||
|
||||
tableID, seat, err := storage.PlayerSeat(user)
|
||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: holdem move seat", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var respErr error
|
||||
var respEvents []holdem.Event
|
||||
err = s.tableLocks.withTable(tableID, func() error {
|
||||
t, seats, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
respErr = storage.ErrNoSuchTable
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var g holdem.State
|
||||
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A top-up is real chips crossing the border, so it comes off the stack
|
||||
// before the engine is asked, and goes straight back if the engine says no —
|
||||
// the same order, and the same reason, as doubling down at blackjack.
|
||||
topped := int64(0)
|
||||
if move.Kind == holdem.TopUp {
|
||||
if err := storage.Stake(user, move.Amount); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
respErr = holdem.ErrTooBig
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
topped = move.Amount
|
||||
}
|
||||
|
||||
next, evs, aerr := holdem.ApplyMove(g, seat, move)
|
||||
if aerr != nil {
|
||||
if topped > 0 {
|
||||
_ = storage.Award(user, topped) // the top-up didn't happen
|
||||
}
|
||||
respErr = aerr
|
||||
return nil
|
||||
}
|
||||
|
||||
// A solo session that just ended (the one human busted) is not a table any
|
||||
// more: cash the seat out — for nothing, but the claim still has to be
|
||||
// released — and close the felt. Only a one-human bust reaches PhaseDone; a
|
||||
// shared table sits the busted seat Out and plays on.
|
||||
if next.Phase == holdem.PhaseDone {
|
||||
if err := s.settleLeave(t, next, seat, user); err != nil {
|
||||
if topped > 0 {
|
||||
_ = storage.Award(user, topped)
|
||||
}
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
respErr = storage.ErrStaleTable
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
respEvents = evs
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
}
|
||||
|
||||
st, err := holdemStep(g, next, evs, seats)
|
||||
if err != nil {
|
||||
if topped > 0 {
|
||||
_ = storage.Award(user, topped)
|
||||
}
|
||||
return err
|
||||
}
|
||||
acting := seats[seat]
|
||||
acting.Away = false
|
||||
acting.LastSeen = time.Now().Unix()
|
||||
// A top-up is more real money crossing the border, so the seat's staked row —
|
||||
// the record of what they brought and can still take home — has to grow with
|
||||
// it. Without this the storage invariant (stacks + pot == staked - rake) drifts
|
||||
// by every top-up, and the felt under-reports what they bought in for.
|
||||
acting.Staked += topped
|
||||
|
||||
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
|
||||
if err := storage.CommitTable(storage.TableCommit{
|
||||
Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit,
|
||||
}); err != nil {
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
if topped > 0 {
|
||||
_ = storage.Award(user, topped)
|
||||
}
|
||||
respErr = storage.ErrStaleTable
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
respEvents = evs
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: holdem move", "user", user, "table", tableID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if respErr != nil {
|
||||
writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
|
||||
return
|
||||
}
|
||||
s.writeHoldemTable(w, user, respEvents)
|
||||
}
|
||||
|
||||
func moveStatus(err error) int {
|
||||
// A 409 is a concurrency verdict: the table is not where the caller thought, so
|
||||
// reload and look again. Everything else — an illegal move for this state — is
|
||||
// the caller's mistake, a 400. Leaving mid-hand or acting out of turn is a 400:
|
||||
// the request was simply not allowed, not raced.
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
|
||||
return http.StatusConflict
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func moveMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrStaleTable):
|
||||
return "the table moved on — take another look"
|
||||
case errors.Is(err, storage.ErrNoSuchTable):
|
||||
return "that table has closed"
|
||||
case errors.Is(err, holdem.ErrHandLive):
|
||||
return "finish the hand first"
|
||||
case errors.Is(err, holdem.ErrNotYourTurn):
|
||||
return "it isn't your turn"
|
||||
case errors.Is(err, holdem.ErrCantCheck):
|
||||
return "there's a bet to you"
|
||||
case errors.Is(err, holdem.ErrTooSmall):
|
||||
return "that's under the minimum raise"
|
||||
case errors.Is(err, holdem.ErrTooBig):
|
||||
return "you don't have that many chips"
|
||||
case errors.Is(err, holdem.ErrBadBuyIn):
|
||||
return "that would put you over the table maximum"
|
||||
default:
|
||||
return "that move isn't legal here"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getting up ------------------------------------------------------------
|
||||
|
||||
// handleHoldemLeave is the get-up endpoint. Leaving is its own route because it
|
||||
// is a storage operation, not an engine move — the chips cross the border and the
|
||||
// felt may close — even though the felt also lets you send it as a "leave" move.
|
||||
func (s *Server) handleHoldemLeave(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.leaveHoldem(w, user)
|
||||
}
|
||||
|
||||
// leaveHoldem gets a player up from their table, turning what is in front of them
|
||||
// back into chips. It refuses mid-hand — you cannot walk out on chips you have in
|
||||
// the pot — and closes the felt behind the last human to leave.
|
||||
func (s *Server) leaveHoldem(w http.ResponseWriter, user string) {
|
||||
tableID, seat, err := storage.PlayerSeat(user)
|
||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: holdem leave seat", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var respErr error
|
||||
err = s.tableLocks.withTable(tableID, func() error {
|
||||
t, _, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
respErr = storage.ErrNoSuchTable
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var g holdem.State
|
||||
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Phase == holdem.PhaseBetting {
|
||||
respErr = holdem.ErrHandLive
|
||||
return nil
|
||||
}
|
||||
if err := s.settleLeave(t, g, seat, user); err != nil {
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
respErr = storage.ErrStaleTable
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: holdem leave", "user", user, "table", tableID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if respErr != nil {
|
||||
writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
|
||||
return
|
||||
}
|
||||
s.writeHoldemTable(w, user, nil)
|
||||
}
|
||||
|
||||
// settleLeave vacates a seat, credits the stack home, and closes the table if
|
||||
// nobody human is left — all inside the caller's lock. The engine's Vacate turns
|
||||
// the chair back into the house's (its chips become house money, rebought like
|
||||
// any bot's), and storage.LeaveTable does the border crossing in one transaction
|
||||
// with the state write, so a crash can never pay a player and then leave their
|
||||
// seat sitting there to be cashed out again.
|
||||
func (s *Server) settleLeave(t storage.Table, g holdem.State, seat int, user string) error {
|
||||
home, err := g.Vacate(seat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0
|
||||
if err := storage.LeaveTable(storage.Leave{
|
||||
Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := storage.CloseTable(t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the response ----------------------------------------------------------
|
||||
|
||||
// writeHoldemTable answers with the whole page state — the money and the table as
|
||||
// the player's own seat may see it — plus, when a move produced one, the redacted
|
||||
// event script for that seat to animate. A player who has just got up has no seat
|
||||
// and no table; the money view carries the leftover verdict and the felt clears.
|
||||
func (s *Server) writeHoldemTable(w http.ResponseWriter, user string, evs []holdem.Event) {
|
||||
v, err := s.table(user)
|
||||
if err != nil {
|
||||
slog.Error("games: holdem table", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(evs) > 0 {
|
||||
if _, seat, serr := storage.PlayerSeat(user); serr == nil {
|
||||
v.HoldemEvents = viewHoldemEvents(evs, seat)
|
||||
}
|
||||
}
|
||||
writeJSON(w, v)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
const bobPlayer = "@bob:parodia.dev"
|
||||
|
||||
// fundUser puts chips in front of a named player the way the border really does.
|
||||
func fundUser(t *testing.T, user string, chips int64) {
|
||||
t.Helper()
|
||||
e, err := storage.RequestBuyIn(user, chips)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := storage.ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func chipsOf(t *testing.T, user string) int64 {
|
||||
t.Helper()
|
||||
st, err := storage.Chips(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st.Chips
|
||||
}
|
||||
|
||||
// A second person can sit down at a table somebody else opened, taking a chair a
|
||||
// bot was keeping warm — which is the whole point of the thing being multiplayer.
|
||||
func TestHoldemJoinTakesAnOpenSeat(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000) // reala
|
||||
fundUser(t, bobPlayer, 5000)
|
||||
|
||||
if _, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500})); code != 200 {
|
||||
t.Fatalf("reala sit = %d, want 200", code)
|
||||
}
|
||||
tableID, err := storage.TableOf(testPlayer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v, code := call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"table": tableID, "buyin": 500}))
|
||||
if code != 200 {
|
||||
t.Fatalf("bob join = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 4500 {
|
||||
t.Errorf("bob's chips after a 500 buy-in = %d, want 4500", v.Chips)
|
||||
}
|
||||
if v.Holdem == nil {
|
||||
t.Fatal("join returned no table")
|
||||
}
|
||||
|
||||
_, bobSeat, err := storage.PlayerSeat(bobPlayer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bobSeat == 0 {
|
||||
t.Errorf("bob took reala's seat %d", bobSeat)
|
||||
}
|
||||
if v.Holdem.YourSeat != bobSeat {
|
||||
t.Errorf("the view says bob is at seat %d, storage says %d", v.Holdem.YourSeat, bobSeat)
|
||||
}
|
||||
|
||||
_, seats, err := storage.LoadTable(tableID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
humans := 0
|
||||
for _, seat := range seats {
|
||||
if seat.MatrixUser != "" {
|
||||
humans++
|
||||
}
|
||||
}
|
||||
if humans != 2 {
|
||||
t.Errorf("two people at the table, storage says %d humans", humans)
|
||||
}
|
||||
}
|
||||
|
||||
// The felt tells each player "you bought in for X" — their own stake, not the
|
||||
// table's. The engine's BoughtIn is the sum across every human (the audit wants
|
||||
// that), so a two-human table would quote both of them the pair's total if the
|
||||
// view read it straight. It reads each seat's own staked row from storage instead.
|
||||
func TestBoughtInIsPerPlayerNotTheTableTotal(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000) // reala
|
||||
fundUser(t, bobPlayer, 5000)
|
||||
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||
tableID, _ := storage.TableOf(testPlayer)
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"table": tableID, "buyin": 500}))
|
||||
|
||||
for _, who := range []string{testPlayer, bobPlayer} {
|
||||
v, err := s.table(who)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Holdem == nil {
|
||||
t.Fatalf("%s has no table", who)
|
||||
}
|
||||
if v.Holdem.BoughtIn != 500 {
|
||||
t.Errorf("%s is shown bought-in %d, want their own 500 — not the table's 1000",
|
||||
who, v.Holdem.BoughtIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving a shared table gives you your stack and hands your chair back to the
|
||||
// house; the table stays open for the people still on it. Only the last human out
|
||||
// closes the felt.
|
||||
func TestHoldemLeavingSharedTableKeepsItOpen(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
fundUser(t, bobPlayer, 5000)
|
||||
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||
tableID, _ := storage.TableOf(testPlayer)
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"table": tableID, "buyin": 500}))
|
||||
|
||||
// Bob gets up. His 500 comes home and his chair goes back to a bot.
|
||||
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "bob", "POST", "/api/games/holdem/leave", nil)); code != 200 {
|
||||
t.Fatalf("bob leave = %d, want 200", code)
|
||||
}
|
||||
if got := chipsOf(t, bobPlayer); got != 5000 {
|
||||
t.Errorf("bob left with %d, want his 5000 back", got)
|
||||
}
|
||||
if _, _, err := storage.PlayerSeat(bobPlayer); err != storage.ErrNoLiveHand {
|
||||
t.Errorf("bob still holds a seat after leaving: %v", err)
|
||||
}
|
||||
if _, _, err := storage.LoadTable(tableID); err != nil {
|
||||
t.Errorf("the table closed under reala when bob left: %v", err)
|
||||
}
|
||||
|
||||
// Reala is the last one out, so the felt closes behind them.
|
||||
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "reala", "POST", "/api/games/holdem/leave", nil)); code != 200 {
|
||||
t.Fatalf("reala leave = %d, want 200", code)
|
||||
}
|
||||
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
|
||||
t.Errorf("an empty table survived the last human: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A table everyone has walked away from is cashed out and closed by the reaper —
|
||||
// the chips inside a walked-away poker session are not left in limbo.
|
||||
func TestHoldemReaperCashesOutAbandonedTable(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||
tableID, _ := storage.TableOf(testPlayer)
|
||||
|
||||
// The seat has been away, and last acted for itself longer ago than the idle
|
||||
// cutoff — the state the reaper is meant to find.
|
||||
tbl, seats, err := storage.LoadTable(tableID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seats[0].Away = true
|
||||
seats[0].LastSeen = time.Now().Unix() - int64(storage.SessionIdleAfter.Seconds()) - 60
|
||||
if err := storage.CommitTable(storage.TableCommit{Table: tbl, Seats: []storage.Seat{seats[0]}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s.reapAbandonedTables()
|
||||
|
||||
if got := chipsOf(t, testPlayer); got != 5000 {
|
||||
t.Errorf("the reaper sent home %d, want the whole 5000 back (buy-in and all)", got)
|
||||
}
|
||||
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
|
||||
t.Errorf("the reaper left the table standing: %v", err)
|
||||
}
|
||||
if _, err := storage.TableOf(testPlayer); err != storage.ErrNoLiveHand {
|
||||
t.Errorf("the reaper left the occupancy claim behind: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh table is not abandoned, and the reaper leaves it alone.
|
||||
func TestHoldemReaperSparesALiveTable(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||
tableID, _ := storage.TableOf(testPlayer)
|
||||
|
||||
s.reapAbandonedTables()
|
||||
|
||||
if _, _, err := storage.LoadTable(tableID); err != nil {
|
||||
t.Errorf("the reaper closed a table someone is sitting at: %v", err)
|
||||
}
|
||||
if got := chipsOf(t, testPlayer); got != 4500 {
|
||||
t.Errorf("chips = %d — the reaper should not have moved a live table's money", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The lobby lists a table with a seat going spare, and drops it once it is full.
|
||||
func TestHoldemLobbyListsJoinableTables(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
// A one-bot table: two seats, one human, one open.
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 1, "buyin": 500}))
|
||||
|
||||
tables, err := storage.LobbyTables(gameHoldem, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tables) != 1 {
|
||||
t.Fatalf("lobby has %d tables, want 1", len(tables))
|
||||
}
|
||||
if tables[0].Humans != 1 || tables[0].Seats != 2 {
|
||||
t.Errorf("lobby table = %d/%d humans/seats, want 1/2", tables[0].Humans, tables[0].Seats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/holdem"
|
||||
)
|
||||
|
||||
// The security boundary of the whole multiplayer table, tested the way the plan
|
||||
// insists it must be: render every seat's view at every street, and string-search
|
||||
// the JSON for any card that belongs to another seat. Zero hits, for every seat,
|
||||
// at every point before a showdown turns cards over.
|
||||
//
|
||||
// This is not belt-and-braces. After SSE the view a seat renders fans to that
|
||||
// seat's stream, so a single missed redaction does not leak one card to one
|
||||
// player through one handler bug — it broadcasts a hole card to the whole felt,
|
||||
// continuously. The engine deliberately emits every seat's cards now (it cannot
|
||||
// know who a shared stream is for), which makes this function the only thing
|
||||
// standing between the deck and the network tab.
|
||||
|
||||
// holeLabel is a seat's two cards as they would be rendered — the exact strings a
|
||||
// leak would put in the JSON.
|
||||
func holeLabels(g holdem.State, seat int) []string {
|
||||
h := g.Seats[seat].Hole
|
||||
return []string{viewCard(h[0]).Label, viewCard(h[1]).Label}
|
||||
}
|
||||
|
||||
func TestHoldemViewNeverLeaksAnotherSeatsCards(t *testing.T) {
|
||||
tier := holdem.Tiers[0]
|
||||
// Two humans (0, 2) and two bots (1, 3), so the test covers a seat seeing both
|
||||
// another human and a bot, and a human sitting at a non-zero index.
|
||||
seats := []holdem.SeatConfig{
|
||||
{Name: "Ana", Stack: tier.MaxBuy},
|
||||
{Name: "Bot A", Bot: true, Stack: tier.MaxBuy},
|
||||
{Name: "Bo", Stack: tier.MaxBuy},
|
||||
{Name: "Bot B", Bot: true, Stack: tier.MaxBuy},
|
||||
}
|
||||
g, _, err := holdem.New(tier, seats, tier.RakePct, 5, 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Deal, then walk the hand to the river without anyone folding — every seat
|
||||
// stays in, so every seat has cards that must not leak to the others. Checking
|
||||
// and calling keeps everyone live; the redaction is checked after every step.
|
||||
g, dealEvs, err := holdem.ApplyMove(g, 0, holdem.Move{Kind: holdem.Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The deal script carries every hole, so it is the sharpest test of event
|
||||
// redaction: for each viewer, no other seat's cards may survive.
|
||||
assertEventsRedacted(t, g, dealEvs)
|
||||
assertViewsRedacted(t, g)
|
||||
|
||||
for step := 0; g.Phase == holdem.PhaseBetting && step < 80; step++ {
|
||||
seat := g.ToAct
|
||||
if g.Seats[seat].Bot {
|
||||
t.Fatalf("advance stopped on a bot at seat %d", seat)
|
||||
}
|
||||
move := holdem.Move{Kind: holdem.Check}
|
||||
if g.Owed(seat) > 0 {
|
||||
move = holdem.Move{Kind: holdem.Call}
|
||||
}
|
||||
var evs []holdem.Event
|
||||
g, evs, err = holdem.ApplyMove(g, seat, move)
|
||||
if err != nil {
|
||||
t.Fatalf("seat %d %s: %v", seat, move.Kind, err)
|
||||
}
|
||||
assertEventsRedacted(t, g, evs)
|
||||
assertViewsRedacted(t, g)
|
||||
}
|
||||
}
|
||||
|
||||
// assertViewsRedacted renders every seat's table view and fails if any of them
|
||||
// carries a card belonging to a seat that is still hiding it.
|
||||
func assertViewsRedacted(t *testing.T, g holdem.State) {
|
||||
t.Helper()
|
||||
shown := g.Street == holdem.Showdown
|
||||
for viewer := range g.Seats {
|
||||
blob, err := json.Marshal(viewHoldem(g, viewer))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := string(blob)
|
||||
for other := range g.Seats {
|
||||
if other == viewer {
|
||||
continue // your own cards are yours to see
|
||||
}
|
||||
// A seat's cards are legitimately visible only at a showdown, and then only
|
||||
// if they did not fold. Everywhere else, a hit is a leak.
|
||||
if shown && g.Seats[other].State != holdem.Folded {
|
||||
continue
|
||||
}
|
||||
if g.Seats[other].State == holdem.Out {
|
||||
continue
|
||||
}
|
||||
for _, label := range holeLabels(g, other) {
|
||||
if strings.Contains(payload, label) {
|
||||
t.Fatalf("seat %d's view (%s street) leaks seat %d's card %q",
|
||||
viewer, g.Street, other, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertEventsRedacted renders the event script for every viewer and fails if a
|
||||
// beat carries another seat's card outside a showdown "show".
|
||||
func assertEventsRedacted(t *testing.T, g holdem.State, evs []holdem.Event) {
|
||||
t.Helper()
|
||||
for viewer := range g.Seats {
|
||||
blob, err := json.Marshal(viewHoldemEvents(evs, viewer))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := string(blob)
|
||||
// The engine only puts a non-viewer's cards in a "show" beat and in the hole
|
||||
// beats it emits for everyone. If any card label of another seat survives the
|
||||
// redaction *and* there is no show event in this batch, it leaked.
|
||||
hasShow := false
|
||||
for _, e := range evs {
|
||||
if e.Kind == "show" {
|
||||
hasShow = true
|
||||
}
|
||||
}
|
||||
if hasShow {
|
||||
continue // a showdown legitimately turns cards over
|
||||
}
|
||||
for other := range g.Seats {
|
||||
if other == viewer || g.Seats[other].State == holdem.Out {
|
||||
continue
|
||||
}
|
||||
for _, label := range holeLabels(g, other) {
|
||||
if strings.Contains(payload, label) {
|
||||
t.Fatalf("seat %d's event stream leaks seat %d's card %q", viewer, other, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/holdem"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Hold'em as a shared table: the seam the runtime drives it through, and the two
|
||||
// facts about a hand that only the engine can tell the runtime — when the clock
|
||||
// must next act, and what a finished hand owes the audit trail.
|
||||
//
|
||||
// Everything about *playing* poker is still in the engine. This file is the
|
||||
// translation layer between a holdem.State and the game-agnostic table runtime:
|
||||
// it decodes the blob, asks the engine to act, and re-derives the deadline and
|
||||
// the audit from the state that comes back.
|
||||
|
||||
// holdemTable is the tableGame for poker. It holds no state — the table's state
|
||||
// is the blob in game_tables — so a single value in s.tableGames serves every
|
||||
// felt in the room.
|
||||
type holdemTable struct{}
|
||||
|
||||
func (holdemTable) name() string { return gameHoldem }
|
||||
|
||||
// timeout acts for the human whose clock ran out. At a card table the standing
|
||||
// courtesy is check if you can, fold if you cannot: a walked-away player never
|
||||
// puts more chips in, and folding keeps the hand moving for everyone still there.
|
||||
//
|
||||
// It marks the seat away so the runtime stops waiting a full clock on it next
|
||||
// time — an absent human auto-folds on sight after this, rather than holding three
|
||||
// other people hostage for thirty seconds an orbit.
|
||||
//
|
||||
// If, on decode, the seat to act is not a waiting human, the scan raced a real
|
||||
// move that had not yet bumped the version: errNotDue, and the clock steps aside.
|
||||
func (holdemTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
|
||||
var g holdem.State
|
||||
if err := json.Unmarshal(state, &g); err != nil {
|
||||
return step{}, nil, err
|
||||
}
|
||||
if g.Phase != holdem.PhaseBetting {
|
||||
return step{}, seats, errNotDue
|
||||
}
|
||||
seat := g.ToAct
|
||||
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
|
||||
return step{}, seats, errNotDue
|
||||
}
|
||||
|
||||
move := holdem.Move{Kind: holdem.Fold}
|
||||
if g.Owed(seat) == 0 {
|
||||
move.Kind = holdem.Check
|
||||
}
|
||||
next, evs, err := holdem.ApplyMove(g, seat, move)
|
||||
if err != nil {
|
||||
return step{}, nil, err
|
||||
}
|
||||
|
||||
changed := markAway(seats, seat)
|
||||
st, err := holdemStep(g, next, evs, seats)
|
||||
return st, changed, err
|
||||
}
|
||||
|
||||
// stacks reports the chips in front of each seat, index-aligned with the table's
|
||||
// seat rows. The abandoned-table reaper reads it to know what to send each
|
||||
// walked-away human home with, without having to understand poker.
|
||||
func (holdemTable) stacks(state []byte) ([]int64, error) {
|
||||
var g holdem.State
|
||||
if err := json.Unmarshal(state, &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]int64, len(g.Seats))
|
||||
for i := range g.Seats {
|
||||
out[i] = g.Seats[i].Stack
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// holdemStep packages a played-out state for the runtime: the blob to persist,
|
||||
// the deadline the clock must honour next, and the audit of any hand that just
|
||||
// ended. prev is the state before the move, which is what lets it work out how
|
||||
// much rake this one hand took.
|
||||
func holdemStep(prev, next holdem.State, evs []holdem.Event, seats []storage.Seat) (step, error) {
|
||||
blob, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
return step{}, err
|
||||
}
|
||||
st := step{
|
||||
State: blob,
|
||||
Phase: string(next.Phase),
|
||||
HandNo: int64(next.HandNo),
|
||||
Deadline: holdemDeadline(next, seats),
|
||||
Audit: holdemAudit(prev, next, evs, seats),
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// holdemDeadline is when the clock must next act, or 0 for never. A clock is only
|
||||
// ever set on a *present* human whose turn it is: a bot resolves inside the move
|
||||
// and never waits, and an away human is auto-acted the moment the clock sees them
|
||||
// rather than waited on. Between hands there is no per-seat clock at all — an
|
||||
// abandoned table is the reaper's job, not the turn clock's.
|
||||
func holdemDeadline(g holdem.State, seats []storage.Seat) int64 {
|
||||
if g.Phase != holdem.PhaseBetting {
|
||||
return 0
|
||||
}
|
||||
seat := g.ToAct
|
||||
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
|
||||
return 0
|
||||
}
|
||||
if seatAway(seats, seat) {
|
||||
return 0 // an away human doesn't get waited on; the clock acts next tick
|
||||
}
|
||||
return time.Now().Unix() + turnSeconds
|
||||
}
|
||||
|
||||
// holdemAudit is the per-hand record of a finished hand — one row per human who
|
||||
// was in it. Empty until a hand actually ends, which is exactly when the engine
|
||||
// emits an "end" beat.
|
||||
//
|
||||
// The rake is the trap the plan flagged. game_hands.rake is summed into the
|
||||
// house's income (HouseTake), so a pot's rake must be recorded once and once
|
||||
// only. It rides on the winner's row alone; every other seat carries zero. And it
|
||||
// is this hand's rake, not the session's: next.Paid is cumulative, so the hand's
|
||||
// take is the amount it climbed by since prev — the part of *this* pot that came
|
||||
// out of a human's winnings.
|
||||
func holdemAudit(prev, next holdem.State, evs []holdem.Event, seats []storage.Seat) []storage.Hand {
|
||||
if !handEnded(evs) {
|
||||
return nil
|
||||
}
|
||||
handRake := next.Paid - prev.Paid
|
||||
|
||||
// The human who won the most is who the rake is attributed to: rake only ever
|
||||
// comes out of a pot a human won, so when handRake is positive there is one.
|
||||
winner, best := -1, int64(0)
|
||||
for i := range next.Seats {
|
||||
if next.Seats[i].Bot {
|
||||
continue
|
||||
}
|
||||
if next.Seats[i].Won > best {
|
||||
winner, best = i, next.Seats[i].Won
|
||||
}
|
||||
}
|
||||
|
||||
var audit []storage.Hand
|
||||
for i := range next.Seats {
|
||||
p := next.Seats[i]
|
||||
if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" {
|
||||
continue
|
||||
}
|
||||
if p.Total == 0 && p.Won == 0 {
|
||||
continue // dealt out, or never in the hand — nothing to record
|
||||
}
|
||||
outcome := "lost"
|
||||
switch {
|
||||
case p.Won > p.Total:
|
||||
outcome = "won"
|
||||
case p.Won == p.Total:
|
||||
outcome = "push"
|
||||
}
|
||||
rake := int64(0)
|
||||
if i == winner {
|
||||
rake = handRake
|
||||
}
|
||||
audit = append(audit, storage.Hand{
|
||||
MatrixUser: seats[i].MatrixUser,
|
||||
Game: gameHoldem,
|
||||
Bet: p.Total,
|
||||
Payout: p.Won,
|
||||
Rake: rake,
|
||||
Outcome: outcome,
|
||||
Seed1: next.Seed1,
|
||||
Seed2: next.Seed2,
|
||||
})
|
||||
}
|
||||
return audit
|
||||
}
|
||||
|
||||
// handEnded reports whether a hand finished in this batch of events. endHand is
|
||||
// the only thing that emits "end", and it emits exactly one, so this is the clean
|
||||
// signal that Won and Total on the seats are this hand's final numbers.
|
||||
func handEnded(evs []holdem.Event) bool {
|
||||
for _, e := range evs {
|
||||
if e.Kind == "end" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- seat bookkeeping ------------------------------------------------------
|
||||
|
||||
// seatAway reports whether the seat at an index is a human who has walked away.
|
||||
func seatAway(seats []storage.Seat, seat int) bool {
|
||||
return seat >= 0 && seat < len(seats) && seats[seat].Away
|
||||
}
|
||||
|
||||
// markAway returns the one seat row the clock needs to write back: the timed-out
|
||||
// seat, flagged away, with its last_seen left exactly as it was. Preserving
|
||||
// last_seen is the whole point — the reaper measures abandonment from when a
|
||||
// human last acted *for themselves*, and an auto-fold is not that.
|
||||
func markAway(seats []storage.Seat, seat int) []storage.Seat {
|
||||
if seat < 0 || seat >= len(seats) {
|
||||
return nil
|
||||
}
|
||||
s := seats[seat]
|
||||
s.Away = true
|
||||
return []storage.Seat{s}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/holdem"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Sitting down is the only time chips leave your stack at this table, and getting
|
||||
// up is the only time they come back. Everything in between is inside the engine.
|
||||
func TestHoldemSitTakesTheBuyInAndNothingElse(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
v, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 600}))
|
||||
if code != 200 {
|
||||
t.Fatalf("sit = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 4400 {
|
||||
t.Fatalf("chips after a 600 buy-in = %d, want 4400", v.Chips)
|
||||
}
|
||||
if v.Game != gameHoldem || v.Holdem == nil {
|
||||
t.Fatalf("sit returned no table: game=%q", v.Game)
|
||||
}
|
||||
|
||||
g := v.Holdem
|
||||
if g.Stack != 600 {
|
||||
t.Errorf("you sat down with %d, want the 600 you bought in for", g.Stack)
|
||||
}
|
||||
if len(g.Seats) != 3 {
|
||||
t.Fatalf("two bots and you is three seats, got %d", len(g.Seats))
|
||||
}
|
||||
if g.Phase != "handover" {
|
||||
t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase)
|
||||
}
|
||||
|
||||
// Play a whole hand, then check that not one chip has crossed the border.
|
||||
deal, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||
map[string]any{"move": "deal"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("deal = %d, want 200", code)
|
||||
}
|
||||
for i := 0; deal.Holdem != nil && deal.Holdem.Phase == "betting" && i < 60; i++ {
|
||||
move := "check"
|
||||
if deal.Holdem.Owed > 0 {
|
||||
move = "fold"
|
||||
}
|
||||
deal, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||
map[string]any{"move": move}))
|
||||
if code != 200 {
|
||||
t.Fatalf("%s = %d, want 200", move, code)
|
||||
}
|
||||
}
|
||||
if deal.Chips != 4400 {
|
||||
t.Errorf("chips moved during a hand: %d, want the 4400 that were left after the buy-in — "+
|
||||
"a pot is settled inside the engine, not across the border", deal.Chips)
|
||||
}
|
||||
}
|
||||
|
||||
// The wall. A bot's hole cards are the game; a browser that held them would make
|
||||
// this unplayable, and the payload is where that has to be true.
|
||||
func TestHoldemNeverSendsABotsCards(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "micro", "bots": 3, "buyin": 200}))
|
||||
if v.Holdem == nil {
|
||||
t.Fatal("no table")
|
||||
}
|
||||
|
||||
// Play hands until one of them ends without a showdown, checking every payload
|
||||
// on the way. Folding is the case that matters: nobody has earned the right to
|
||||
// see anybody's cards, so nobody's may be in there.
|
||||
for hand := 0; hand < 8; hand++ {
|
||||
v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||
map[string]any{"move": "deal"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("deal = %d", code)
|
||||
}
|
||||
noBotCards(t, v)
|
||||
|
||||
for i := 0; v.Holdem != nil && v.Holdem.Phase == "betting" && i < 60; i++ {
|
||||
move := "check"
|
||||
if v.Holdem.Owed > 0 {
|
||||
move = "call"
|
||||
}
|
||||
v, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||
map[string]any{"move": move}))
|
||||
if code != 200 {
|
||||
t.Fatalf("%s = %d", move, code)
|
||||
}
|
||||
noBotCards(t, v)
|
||||
}
|
||||
if v.Holdem == nil || v.Holdem.Phase == "done" {
|
||||
return // busted out; the wall held all the way
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noBotCards checks a payload. A bot's cards may appear in exactly one place: a
|
||||
// seat that is being shown down, on a board that reached a showdown.
|
||||
func noBotCards(t *testing.T, v tableView) {
|
||||
t.Helper()
|
||||
g := v.Holdem
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
shown := g.Street == "showdown"
|
||||
for i, seat := range g.Seats {
|
||||
if i == 0 || len(seat.Cards) == 0 {
|
||||
continue
|
||||
}
|
||||
if !shown {
|
||||
t.Fatalf("seat %d (%s) sent %d cards on the %s — nobody has shown down",
|
||||
i, seat.Name, len(seat.Cards), g.Street)
|
||||
}
|
||||
if seat.State == "folded" {
|
||||
t.Fatalf("seat %d (%s) folded and its cards were sent anyway", i, seat.Name)
|
||||
}
|
||||
}
|
||||
for _, e := range v.HoldemEvents {
|
||||
if e.Seat > 0 && len(e.Cards) > 0 && e.Kind != "show" {
|
||||
t.Fatalf("a %q event carries seat %d's cards — that's a bot's hand", e.Kind, e.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Getting up is what pays. Everything the session did lands in one movement.
|
||||
func TestHoldemLeavingBringsTheStackBack(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 1, "buyin": 500}))
|
||||
if v.Chips != 4500 || v.Holdem == nil {
|
||||
t.Fatalf("sit: chips=%d holdem=%v", v.Chips, v.Holdem)
|
||||
}
|
||||
stack := v.Holdem.Stack
|
||||
|
||||
v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||
map[string]any{"move": "leave"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("leave = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 4500+stack {
|
||||
t.Errorf("chips after getting up = %d, want %d (the %d that was in front of us)",
|
||||
v.Chips, 4500+stack, stack)
|
||||
}
|
||||
if v.Game != "" {
|
||||
t.Errorf("still at a table after getting up: %q", v.Game)
|
||||
}
|
||||
|
||||
// And there is no table left to play at.
|
||||
_, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||
map[string]any{"move": "deal"}))
|
||||
if code != 409 {
|
||||
t.Errorf("dealt a hand at a table we got up from: %d, want 409", code)
|
||||
}
|
||||
}
|
||||
|
||||
// You cannot walk out on a hand you have chips riding on.
|
||||
func TestHoldemCannotLeaveMidHand(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||
v, _ := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||
map[string]any{"move": "deal"}))
|
||||
if v.Holdem == nil || v.Holdem.Phase != "betting" {
|
||||
t.Skip("the hand ended before we could act")
|
||||
}
|
||||
|
||||
_, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||
map[string]any{"move": "leave"}))
|
||||
if code != 400 {
|
||||
t.Errorf("left in the middle of a hand: %d, want 400", code)
|
||||
}
|
||||
|
||||
// And the chips are still on the table, not back on the stack.
|
||||
after, err := storage.Chips("@reala:parodia.dev")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Chips != 4500 {
|
||||
t.Errorf("chips = %d, want 4500 — the buy-in is still on the table", after.Chips)
|
||||
}
|
||||
}
|
||||
|
||||
// A buy-in outside the table's range is not a buy-in, and it must not take chips.
|
||||
func TestHoldemRefusesABadBuyIn(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
tier, _ := holdem.TierBySlug("low")
|
||||
for _, amount := range []int64{tier.MinBuy - 1, tier.MaxBuy + 1, 0} {
|
||||
_, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 2, "buyin": amount}))
|
||||
if code != 400 {
|
||||
t.Errorf("buy-in of %d at a %d–%d table = %d, want 400", amount, tier.MinBuy, tier.MaxBuy, code)
|
||||
}
|
||||
}
|
||||
|
||||
st, err := storage.Chips("@reala:parodia.dev")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Chips != 5000 {
|
||||
t.Errorf("a refused buy-in took %d chips", 5000-st.Chips)
|
||||
}
|
||||
}
|
||||
|
||||
// A top-up is real money crossing the border. If the engine refuses it, the chips
|
||||
// come straight back — the same order, and the same reason, as doubling down.
|
||||
func TestHoldemTopUpRefundsWhenRefused(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
|
||||
// Sitting down at the maximum means there is no room to top up into.
|
||||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||
map[string]any{"tier": "low", "bots": 1, "buyin": 1000}))
|
||||
|
||||
_, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||
map[string]any{"move": "topup", "amount": 100}))
|
||||
if code != 400 {
|
||||
t.Errorf("topped up over the table maximum: %d, want 400", code)
|
||||
}
|
||||
|
||||
st, err := storage.Chips("@reala:parodia.dev")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Chips != 4000 {
|
||||
t.Errorf("chips = %d, want 4000 — a refused top-up must give the chips back", st.Chips)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// The SSE hub: how a move one player makes reaches the phones of everyone else
|
||||
// at the felt.
|
||||
//
|
||||
// It is in-memory and it is intentionally dumb. It holds no game state and makes
|
||||
// no decisions — it is a fan-out of opaque byte frames, keyed by table id. The
|
||||
// authority is always the database; a frame is a nudge that says "the table at
|
||||
// this version changed, come and look", and a subscriber that misses one (a
|
||||
// dropped send, a reconnect) refetches the table, which is authoritative anyway.
|
||||
// So a lost frame is a cosmetic hiccup, never a wrong balance.
|
||||
//
|
||||
// Two rules hold it together, and both are load-bearing:
|
||||
//
|
||||
// 1. **Sends are non-blocking.** A subscriber's channel is buffered, and a send
|
||||
// that would block is dropped, not waited on. The publish happens under the
|
||||
// table lock (which is what orders frames correctly for free), so a blocking
|
||||
// send would hold that lock while one phone on a train stalls — and the turn
|
||||
// clock behind that lock stalls with it, for the whole casino. A dropped frame
|
||||
// costs that one subscriber a refetch; a held lock costs everyone the room.
|
||||
//
|
||||
// 2. **The publisher never touches the database.** The hub is reached only after
|
||||
// the DB work is done and the connection released. Holding a *sql.Rows or a tx
|
||||
// open for the life of a stream would hold the one connection in the pool
|
||||
// forever, and a single subscriber would brick the whole application.
|
||||
|
||||
// hubFrame is what goes down the wire: an opaque payload the browser knows how to
|
||||
// read (a JSON table view), tagged with the version it represents so a subscriber
|
||||
// can tell a frame it already has from one it missed.
|
||||
type hubFrame struct {
|
||||
Version int64
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// tableSub is one open EventSource: a buffered channel and the id that lets the
|
||||
// subscriber unregister itself when the stream closes.
|
||||
type tableSub struct {
|
||||
id int64
|
||||
ch chan hubFrame
|
||||
}
|
||||
|
||||
// gamesHub fans table frames out to whoever is watching each table.
|
||||
type gamesHub struct {
|
||||
mu sync.Mutex
|
||||
tables map[string]map[int64]*tableSub
|
||||
nextID atomic.Int64
|
||||
}
|
||||
|
||||
func newGamesHub() *gamesHub {
|
||||
return &gamesHub{tables: make(map[string]map[int64]*tableSub)}
|
||||
}
|
||||
|
||||
// subChanBuffer is how many frames a slow subscriber can fall behind before the
|
||||
// hub starts dropping theirs. A few is plenty: a subscriber that far behind is
|
||||
// going to refetch the authoritative table anyway, so buffering more just delays
|
||||
// that with staler frames.
|
||||
const subChanBuffer = 8
|
||||
|
||||
// subscribe registers a new watcher of a table and returns its channel plus the
|
||||
// unsubscribe to defer. The channel is buffered so a publish never blocks on a
|
||||
// reader that is mid-write to its socket.
|
||||
func (h *gamesHub) subscribe(tableID string) (<-chan hubFrame, func()) {
|
||||
sub := &tableSub{id: h.nextID.Add(1), ch: make(chan hubFrame, subChanBuffer)}
|
||||
|
||||
h.mu.Lock()
|
||||
subs := h.tables[tableID]
|
||||
if subs == nil {
|
||||
subs = make(map[int64]*tableSub)
|
||||
h.tables[tableID] = subs
|
||||
}
|
||||
subs[sub.id] = sub
|
||||
h.mu.Unlock()
|
||||
|
||||
return sub.ch, func() { h.unsubscribe(tableID, sub.id) }
|
||||
}
|
||||
|
||||
func (h *gamesHub) unsubscribe(tableID string, id int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
subs := h.tables[tableID]
|
||||
if subs == nil {
|
||||
return
|
||||
}
|
||||
delete(subs, id)
|
||||
if len(subs) == 0 {
|
||||
delete(h.tables, tableID)
|
||||
}
|
||||
}
|
||||
|
||||
// publish pushes a frame to everyone watching a table, dropping it for any
|
||||
// subscriber whose buffer is full rather than waiting on them. See rule 1: this
|
||||
// is called under the table lock, so it must never block.
|
||||
func (h *gamesHub) publish(tableID string, f hubFrame) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, sub := range h.tables[tableID] {
|
||||
select {
|
||||
case sub.ch <- f:
|
||||
default:
|
||||
// Full buffer: this subscriber is behind. Dropping is correct — they will
|
||||
// refetch the authoritative table when they next read a version gap.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchers reports how many streams are open on a table. Used by the caller that
|
||||
// decides whether a frame is worth rendering at all.
|
||||
func (h *gamesHub) watchers(tableID string) int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.tables[tableID])
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHub_DeliversToSubscribers(t *testing.T) {
|
||||
h := newGamesHub()
|
||||
ch, done := h.subscribe("t1")
|
||||
defer done()
|
||||
|
||||
h.publish("t1", hubFrame{Version: 3, Data: []byte("hi")})
|
||||
f := <-ch
|
||||
if f.Version != 3 || string(f.Data) != "hi" {
|
||||
t.Fatalf("got %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_OnlyToTheRightTable(t *testing.T) {
|
||||
h := newGamesHub()
|
||||
ch1, d1 := h.subscribe("t1")
|
||||
defer d1()
|
||||
ch2, d2 := h.subscribe("t2")
|
||||
defer d2()
|
||||
|
||||
h.publish("t1", hubFrame{Version: 1})
|
||||
select {
|
||||
case <-ch2:
|
||||
t.Fatal("t2 should not have received t1's frame")
|
||||
default:
|
||||
}
|
||||
if f := <-ch1; f.Version != 1 {
|
||||
t.Fatalf("t1 got %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHub_PublishNeverBlocks is the load-bearing property: a subscriber that
|
||||
// never reads must not be able to hold up a publish, because publish happens
|
||||
// under the table lock and a blocked publish stalls the turn clock for everyone.
|
||||
func TestHub_PublishNeverBlocks(t *testing.T) {
|
||||
h := newGamesHub()
|
||||
_, done := h.subscribe("t1") // never read from
|
||||
defer done()
|
||||
|
||||
// Far more than the buffer. If any of these blocked, the test would hang.
|
||||
blocked := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < subChanBuffer*10; i++ {
|
||||
h.publish("t1", hubFrame{Version: int64(i)})
|
||||
}
|
||||
close(blocked)
|
||||
}()
|
||||
<-blocked
|
||||
}
|
||||
|
||||
func TestHub_UnsubscribeStopsDelivery(t *testing.T) {
|
||||
h := newGamesHub()
|
||||
ch, done := h.subscribe("t1")
|
||||
done()
|
||||
|
||||
if h.watchers("t1") != 0 {
|
||||
t.Fatalf("watchers should be 0 after unsubscribe, got %d", h.watchers("t1"))
|
||||
}
|
||||
h.publish("t1", hubFrame{Version: 1})
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if ok {
|
||||
t.Fatal("a frame arrived after unsubscribe")
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ConcurrentSubscribers(t *testing.T) {
|
||||
h := newGamesHub()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ch, done := h.subscribe("t1")
|
||||
defer done()
|
||||
<-ch
|
||||
}()
|
||||
}
|
||||
// Let them all register, then flood so every one of them reads at least one.
|
||||
for h.watchers("t1") < 50 {
|
||||
}
|
||||
for i := 0; i < subChanBuffer; i++ {
|
||||
h.publish("t1", hubFrame{Version: int64(i)})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user