11 Commits

Author SHA1 Message Date
prosolis
b814f936a8 solitaire: detect a won board and finish it in one press
A drained, all-face-up board is a guaranteed clear that auto() sweeps home
in a single cascade, but the only way to trigger it was double-clicking
thirty cards home by hand. Add State.Won(), surface it in the view, and
swap the ordinary controls for one pulsing finish button when it's true.
2026-07-15 18:50:43 -07:00
prosolis
8504c4f47a games: burial send-offs, money rain on wins, and honest stack targeting
Three front-end changes to the casino games:

- uno: a draw-card stack now names the seat it's actually pointed at
  ("+N on Beep") instead of always reading "+N on you". The bill reads
  v.turn, which the engine advances to the target when a draw card lands.

- uno: the No Mercy rule buries a seat with some ceremony now. bury()
  rolls one of four send-offs over the seat (or your hand, if it's you) —
  a rockslide that piles up, a tombstone, a coffin, or the Mega Man death
  burst — each with its own synth sound, and a static headstone under
  reduced motion. The "Buried on N" badge still rides alongside.

- all games: winning makes it rain. FX.moneyRain drops a curtain of bills
  and coins with a jackpot coin-cascade sound, gated by significance so it
  reads as a win and not as noise: hold'em keeps its light per-pot confetti
  and only rains on a real haul (>= 20bb), while the confetti stays the
  rare cherry (natural 21, full board clear, phrase solved outright).
2026-07-15 18:37:19 -07:00
prosolis
e0d90ff7cc pwa: serve static assets network-first so redeploys stop serving stale JS/CSS
Asset URLs are not content-hashed, so the cache-first strategy pinned
whatever bytes were first cached and never noticed a redeployed file
changed — leaving stale weather-gl.js/output.css in place until a manual
CACHE_VERSION bump. Only a hard reload (which bypasses the SW) showed the
new assets. Switch /static/ to network-first (fall back to cache offline)
and bump CACHE_VERSION v4->v5 to flush the stale shell.
2026-07-15 17:45:53 -07:00
prosolis
790a118273 games: widen foley pitch variance so takes are actually distinct
vary was a per-step multiplier of ~0.05, capping the swing at a barely
audible +/-0.10. Redefine vary as the max deviation itself (chips +/-0.22,
cards +/-0.20) and spread it over seven pitch steps; give the single-take
shuffle a wobble too.
2026-07-14 23:51:21 -07:00
prosolis
8df2212aad games: drop chipLay3, it clashed with the other two chip takes 2026-07-14 23:48:58 -07:00
prosolis
1ca794ea1a games: rotate through foley takes on every play, not just by v
Take-selection was keyed only on the caller's v, but callers pass small
constants (chip is always v:0), so only the first take of each sound ever
played. Add a per-name cursor that advances every call: consecutive plays
now walk the takes, v just offsets simultaneous sounds and drives pitch.
2026-07-14 23:44:24 -07:00
prosolis
30b0e8debb games: real recorded foley for cards and chips, synth for the rest
The card and chip sounds never convinced as oscillators, so they're now
CC0 recordings (Kenney's casino pack) under /static/audio/casino, embedded
in the binary. Six names (card, deal, flip, shuffle, chip, sweep) play
several rotating takes with a little pitch jitter; everything melodic stays
synthesised. PeteSFX's API is unchanged and synthesis is the fallback while
an ogg is still decoding, so no caller changed and the table is never silent.
2026-07-14 23:28:10 -07:00
prosolis
4189c03a82 mischief: tighten the detail page's live poll and drop dead state
The who-page poll now keeps the location line honest both ways (a mark
that came back to town stops showing its old expedition) and stops
polling once the adventurer leaves the board. Also collapses a redundant
branch in ResolveMischiefOrder and removes the always-false whoPage.Stale.
2026-07-14 23:12:01 -07:00
prosolis
16711e13e6 adventure: a click-through page for every adventurer on the board
Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
2026-07-14 22:22:52 -07:00
prosolis
2ac6ec6b91 mischief: a storefront where money buys a stranger some trouble
The web half of Mischief Makers M3. A signed-in buyer picks a mark off the
anonymous roster board and pays for a monster to find them; gogobee does the
real work and hands back a verdict Pete files against the order.

- mischief_orders: intent in, verdict out, idempotent on a guid that is the
  end-to-end key gogobee passes to DebitIdem and stamps on the contract
- user_euro + mischief_tiers: advisory balance and the live price list, pushed
  on the roster tick so the storefront never hardcodes a number that can drift
- OIDC-gated buy API (target + tier + signed), bearer-authed poll/claim wire
- roster board grows a 'send trouble' button, a tier picker, and a status panel

Pete never touches money and never runs a game rule. It records what a buyer
wants and what gogobee said happened.
2026-07-14 20:55:15 -07:00
prosolis
983748ea98 games: brighter marquee bulbs, a chase that loops, and no more subtitle
The Casino Night light strip was a dim blurred stripe that jumped every
cycle (it slid further than the bulb pattern repeats). Now it's a row of
round bulbs with a white-hot core and a real glow, and the chase travels
exactly one bulb-period so it loops seamlessly. Dropped the 'Chips are
euros' line under the room name; the welcome card and house rules already
say it.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 20:26:48 -07:00
50 changed files with 2865 additions and 71 deletions

View File

@@ -620,6 +620,31 @@ func (s State) Net() int64 {
// cleared reports whether every card is home. // cleared reports whether every card is home.
func (s State) cleared() bool { return s.Home() == FullDeck } func (s State) cleared() bool { return s.Home() == FullDeck }
// Won reports that the board is a guaranteed clear: nothing left in the stock or
// waste, and not a single face-down card under any column. From here every card
// is a face-up run and a single auto() drains the whole board to the foundations
// without a choice left to make — which is exactly the tedious tail the felt
// should offer to finish in one gesture instead of thirty double-clicks.
//
// It's deliberately narrower than "solvable": a draw-one board with cards still
// in the stock is winnable too, but auto() won't turn the stock over, so calling
// that won would light a finish button that then stalls. This is the state the
// finish button actually finishes.
func (s State) Won() bool {
if s.Phase == PhaseDone || s.cleared() {
return false
}
if len(s.Stock) > 0 || len(s.Waste) > 0 {
return false
}
for _, p := range s.Table {
if len(p.Down) > 0 {
return false
}
}
return true
}
// CanAuto reports whether anything can go home at all — which is what greys the // CanAuto reports whether anything can go home at all — which is what greys the
// finish button out rather than letting it be pressed at a board that has nothing // finish button out rather than letting it be pressed at a board that has nothing
// for it. // for it.

View File

@@ -423,6 +423,52 @@ func TestAutoSendsEverythingItCanHome(t *testing.T) {
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome) refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
} }
// Won is the state the finish button finishes: every card face up, the stock and
// waste drained, so a single auto sweeps the lot home. The button is only honest
// if these two agree — Won lighting up on a board auto can't clear would stall.
func TestWonIsExactlyWhatAutoCanFinish(t *testing.T) {
s := board(patient(), 5200)
// A won board: three short face-up runs across the columns, nothing face down,
// nothing in the stock or waste. Every card is reachable.
s.Table[0].Up = []cards.Card{card(2, cards.Spades), card(cards.Ace, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(2, cards.Hearts), card(cards.Ace, cards.Spades)}
s.Table[2].Up = []cards.Card{card(2, cards.Diamonds), card(cards.Ace, cards.Clubs)}
s.Table[3].Up = []cards.Card{card(2, cards.Clubs), card(cards.Ace, cards.Diamonds)}
if !s.Won() {
t.Fatalf("a board with everything face up and the piles empty is won")
}
next, _ := apply(t, s, Move{Kind: "auto"})
if next.Home() != 8 { // the eight cards laid out above
t.Fatalf("auto homed %d cards, want 8 — the whole won board", next.Home())
}
// Anything still hidden or still in a pile is not won: auto would stall on it.
withStock := s.clone()
withStock.Stock = cards.Deck{card(5, cards.Spades)}
if withStock.Won() {
t.Errorf("a board with a card still in the stock is not won — auto won't turn it over")
}
withWaste := s.clone()
withWaste.Waste = []cards.Card{card(5, cards.Spades)}
if withWaste.Won() {
t.Errorf("a board with a card still in the waste is not won")
}
withDown := s.clone()
withDown.Table[5].Down = []cards.Card{card(5, cards.Spades)}
if withDown.Won() {
t.Errorf("a board with a face-down card is not won")
}
// A finished board is never won: the button belongs to a game still in play.
cleared := s.clone()
cleared.Phase = PhaseDone
if cleared.Won() {
t.Errorf("a settled board reports won")
}
}
// ---- the money ------------------------------------------------------------- // ---- the money -------------------------------------------------------------
// The number the felt quotes while you play and the number settle() lands on are // The number the felt quotes while you play and the number settle() lands on are

View File

@@ -97,6 +97,10 @@ func runMigrations(d *sql.DB) error {
// Occupancy of a shared table. Rows written before the casino went multiplayer // 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. // are solo games and read as NULL, which is exactly what they are.
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT") 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")
// FTS5 virtual tables don't support IF NOT EXISTS reliably. // FTS5 virtual tables don't support IF NOT EXISTS reliably.
// Check sqlite_master before creating. // Check sqlite_master before creating.

131
internal/storage/detail.go Normal file
View File

@@ -0,0 +1,131 @@
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"`
House HouseView `json:"house"`
Pets []PetView `json:"pets,omitempty"`
}
// ItemView is one backpack or vault item.
type ItemView struct {
Name string `json:"name"`
Type string `json:"type"`
Tier int `json:"tier"`
Value int64 `json:"value"`
Temper int `json:"temper,omitempty"`
}
// 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.
type PetView struct {
Type string `json:"type"`
Name string `json:"name"`
Level int `json:"level"`
XP int `json:"xp,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
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -2,6 +2,7 @@ package storage
import ( import (
"database/sql" "database/sql"
"encoding/json"
"log/slog" "log/slog"
) )
@@ -18,6 +19,10 @@ type RosterEntry struct {
Day int `json:"day,omitempty"` Day int `json:"day,omitempty"`
IdleHours int `json:"idle_hours,omitempty"` IdleHours int `json:"idle_hours,omitempty"`
SnapshotAt int64 `json:"snapshot_at"` 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. // ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
@@ -38,16 +43,20 @@ func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
} }
stmt, err := tx.Prepare(` stmt, err := tx.Prepare(`
INSERT INTO adventure_roster INSERT INTO adventure_roster
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at) (token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at, detail_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil { if err != nil {
return err return err
} }
defer stmt.Close() defer stmt.Close()
for _, e := range entries { 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, if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil { e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt, detail); err != nil {
return err return err
} }
} }
@@ -91,6 +100,31 @@ func LoadRoster() ([]RosterEntry, int64, error) {
return out, RosterSnapshotAt(), nil 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 // 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 // never pushed one. Read from the meta row, not the entries, so a snapshot that
// legitimately carried nobody still counts as a snapshot. // legitimately carried nobody still counts as a snapshot.

View File

@@ -52,6 +52,83 @@ CREATE TABLE IF NOT EXISTS adventure_roster_meta (
snapshot_at INTEGER NOT NULL DEFAULT 0 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
);
-- 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);
CREATE TABLE IF NOT EXISTS post_log ( CREATE TABLE IF NOT EXISTS post_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL, guid TEXT NOT NULL,

View File

@@ -60,6 +60,7 @@ type solitaireView struct {
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
Moves int `json:"moves"` Moves int `json:"moves"`
CanAuto bool `json:"can_auto"` CanAuto bool `json:"can_auto"`
Won bool `json:"won"` // every card face up, stock and waste empty: one press finishes it
Home int `json:"home"` // cards on the foundations Home int `json:"home"` // cards on the foundations
PerCard float64 `json:"per_card"` // what one more is worth PerCard float64 `json:"per_card"` // what one more is worth
@@ -86,6 +87,7 @@ func viewSolitaire(g klondike.State) solitaireView {
Passes: g.PassesLeft(), Passes: g.PassesLeft(),
Moves: g.Moves, Moves: g.Moves,
CanAuto: g.CanAuto(), CanAuto: g.CanAuto(),
Won: g.Won(),
Home: g.Home(), Home: g.Home(),
PerCard: g.PerCard(), PerCard: g.PerCard(),
BreakEven: g.Tier.BreakEven(), BreakEven: g.Tier.BreakEven(),

262
internal/web/mischief.go Normal file
View File

@@ -0,0 +1,262 @@
package web
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"pete/internal/storage"
)
// buyerLocalpart normalises a session's Authentik username to the Matrix
// localpart gogobee keys everything on. Matrix localparts are lowercase; an
// Authentik display of the same name may not be, and gogobee pushes balances and
// resolves orders under the lowercase form. Lowercasing on both sides is what
// keeps the buyer's balance lookup and their order's identity pointing at the
// same person.
func buyerLocalpart(u *SessionUser) string {
return strings.ToLower(strings.TrimSpace(u.Username))
}
// The mischief storefront's web seam.
//
// Two audiences, two auth models, same file. Browsers hit the OIDC-gated buy
// endpoints: a signed-in buyer picks a mark off the anonymous board and places a
// hit, and reads back their own orders' standing. gogobee hits the bearer-authed
// pair: it polls pending orders and pushes a verdict, exactly like the escrow
// seam in games.go and under the same standing rule — Pete never calls gogobee,
// never moves money, never runs a game rule. It records intent and files a
// verdict; everything real happens on the game box.
// mischiefBurstWindow / mischiefBurstMax are Pete's own anti-spam guard, and
// nothing more. The real economic caps — two contracts a day, one live per mark,
// a boss a week — are gogobee's and are enforced at claim time. This only stops a
// signed-in buyer from spooling the orders table with a stuck mouse button.
const (
mischiefBurstWindow = time.Hour
mischiefBurstMax = 20
)
// mischiefOrderReq is the browser's buy request. Price and eligibility are not
// the buyer's to assert: they send a tier key and a mark, never a number.
type mischiefOrderReq struct {
TargetToken string `json:"target_token"`
Tier string `json:"tier"`
Signed bool `json:"signed"`
}
// handleMischiefOrder places a pending order for the signed-in buyer. It asserts
// only what Pete can honestly know: the buyer is signed in with a username the
// game box can resolve, the tier is one gogobee currently offers, and the mark is
// actually on the live board. Money and the full rulebook are gogobee's, checked
// when it claims the order.
func (s *Server) handleMischiefOrder(w http.ResponseWriter, r *http.Request) {
u := s.requireUser(w, r)
if u == nil {
return
}
// A session minted before the storefront existed carries no username, and
// without one gogobee can't name the buyer to its ledger. Send them back
// through sign-in to mint a fresh one rather than place an unfulfillable order.
buyer := buyerLocalpart(u)
if buyer == "" {
writeMischiefError(w, http.StatusConflict, "please sign in again")
return
}
var req mischiefOrderReq
if !decodeStateBody(w, r, &req) {
return
}
if req.TargetToken == "" {
writeMischiefError(w, http.StatusBadRequest, "no target")
return
}
tier, ok, err := storage.MischiefTierByKey(req.Tier)
if err != nil {
slog.Error("mischief: tier lookup", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
}
if !ok {
writeMischiefError(w, http.StatusBadRequest, "no such tier")
return
}
mark, ok, err := storage.RosterEntryByToken(req.TargetToken)
if err != nil {
slog.Error("mischief: target lookup", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
}
if !ok {
// The board moved on, or the token was never real. Either way there is no
// one to send trouble to.
writeMischiefError(w, http.StatusNotFound, "that adventurer is no longer on the board")
return
}
// Burst guard. Keyed on the OIDC subject so a username change can't reset it.
since := time.Now().Add(-mischiefBurstWindow).Unix()
if n, err := storage.CountMischiefOrdersSince(u.Sub, since); err != nil {
slog.Error("mischief: burst count", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
} else if n >= mischiefBurstMax {
writeMischiefError(w, http.StatusTooManyRequests, "slow down — too many orders in a short while")
return
}
order, err := storage.InsertMischiefOrder(u.Sub, buyer, mark.Token, mark.Name, tier.Key, req.Signed)
if err != nil {
slog.Error("mischief: insert order", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
}
slog.Info("mischief: order placed", "guid", order.GUID, "buyer", buyer, "tier", tier.Key, "signed", req.Signed)
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, order)
}
// handleMischiefOrders returns the signed-in buyer's own orders for the status
// panel, newest first. Scoped to their OIDC subject — a buyer sees their history
// and no one else's.
func (s *Server) handleMischiefOrders(w http.ResponseWriter, r *http.Request) {
u := s.requireUser(w, r)
if u == nil {
return
}
orders, err := storage.MischiefOrdersByBuyer(u.Sub, 20)
if err != nil {
slog.Error("mischief: orders by buyer", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
}
if orders == nil {
orders = []storage.MischiefOrder{}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, orders)
}
// mischiefCatalogResp is what the storefront reads to draw itself: the live price
// list plus the signed-in buyer's own advisory balance, so it can grey out tiers
// they plainly can't afford. HasBalance distinguishes "gogobee says €0" from
// "gogobee has never mentioned this buyer" — the latter shows no affordability
// hint rather than a discouraging, possibly-wrong zero.
type mischiefCatalogResp struct {
Tiers []storage.MischiefTier `json:"tiers"`
Euro float64 `json:"euro"`
HasBalance bool `json:"has_balance"`
}
// handleMischiefCatalog serves the price list and the buyer's balance together.
func (s *Server) handleMischiefCatalog(w http.ResponseWriter, r *http.Request) {
u := s.requireUser(w, r)
if u == nil {
return
}
tiers, err := storage.MischiefTiers()
if err != nil {
slog.Error("mischief: catalog", "err", err)
writeMischiefError(w, http.StatusInternalServerError, "internal error")
return
}
if tiers == nil {
tiers = []storage.MischiefTier{}
}
resp := mischiefCatalogResp{Tiers: tiers}
if lp := buyerLocalpart(u); lp != "" {
euro, has, err := storage.UserEuro(lp)
if err != nil {
slog.Error("mischief: balance", "err", err)
} else {
resp.Euro, resp.HasBalance = euro, has
}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, resp)
}
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
// handleMischiefPending is gogobee's poll: every order still waiting to be acted
// on. A pending order carries no intermediate state, so unlike escrow there is no
// stale-reoffer window — a gogobee that dies mid-claim simply leaves the order
// pending to be offered again, and the guid makes the replay a no-op.
func (s *Server) handleMischiefPending(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
orders, err := storage.PendingMischiefOrders(mischiefPollLimit)
if err != nil {
slog.Error("mischief: pending", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if orders == nil {
orders = []storage.MischiefOrder{}
}
writeJSON(w, orders)
}
// mischiefPollLimit caps one poll, matching the escrow seam.
const mischiefPollLimit = 50
// mischiefVerdict is gogobee's answer on a claimed order: the terminal status and
// a human note to render.
type mischiefVerdict struct {
GUID string `json:"guid"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
}
// handleMischiefClaim 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 one moves the order. An unknown guid is a 400 — by this point
// gogobee has already debited the buyer for an order Pete has no record of, and
// under the seam's contract a 400 parks the row for a human rather than retrying
// forever against a row that will never exist.
func (s *Server) handleMischiefClaim(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var v mischiefVerdict
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.ResolveMischiefOrder(v.GUID, v.Status, v.Detail)
if errors.Is(err, storage.ErrNoSuchOrder) {
slog.Error("mischief: verdict for an order we have never heard of — "+
"gogobee may have debited a buyer for it", "guid", v.GUID, "status", v.Status)
http.Error(w, "no such order", http.StatusBadRequest)
return
}
if err != nil {
// A malformed verdict status lands here — also a contract mismatch, so 400.
slog.Error("mischief: resolve", "guid", v.GUID, "status", v.Status, "err", err)
http.Error(w, "bad verdict", http.StatusBadRequest)
return
}
slog.Info("mischief: order resolved", "guid", order.GUID, "status", order.Status)
writeJSON(w, order)
}
func writeMischiefError(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})
}

View File

@@ -0,0 +1,222 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"pete/internal/storage"
)
// newStore is a server with the adventure seam on, a signed-in buyer, and a
// board + catalog already pushed. Auth is built by hand for the same reason the
// casino tests do it: the OIDC handshake is a network call and none of what's
// under test is about it.
func newStore(t *testing.T) *Server {
t.Helper()
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
now := time.Now().Unix()
push := rosterPush{
SnapshotAt: now,
Adventurers: []storage.RosterEntry{entry("tok-josie", "Josie", "expedition", "holymachina")},
Tiers: []storage.MischiefTier{
{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50},
{Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500},
},
Balances: []storage.MischiefBalance{{Username: "reala", Euro: 500}},
}
if w := postRoster(t, s, "tok", push); w.Code != 200 {
t.Fatalf("seed roster = %d", w.Code)
}
return s
}
// jsonReq builds a bearer-authed (or unauthed, empty token) machine request.
func jsonReq(t *testing.T, method, path, token string, body any) *http.Request {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatal(err)
}
}
r := httptest.NewRequest(method, path, &buf)
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
return r
}
func placeOrder(t *testing.T, s *Server, username string, req mischiefOrderReq) *httptest.ResponseRecorder {
t.Helper()
r := as(t, s, username, "POST", "/api/mischief/order", req)
w := httptest.NewRecorder()
s.handleMischiefOrder(w, r)
return w
}
// TestMischiefOrderHappyPath: a signed-in buyer names a mark on the board and a
// tier gogobee offers, and gets a pending order back.
func TestMischiefOrderHappyPath(t *testing.T) {
s := newStore(t)
w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt", Signed: false})
if w.Code != 200 {
t.Fatalf("order = %d body=%s", w.Code, w.Body.String())
}
var o storage.MischiefOrder
if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil {
t.Fatal(err)
}
if o.Status != storage.MischiefPending || o.BuyerUsername != "reala" || o.TargetName != "Josie" {
t.Fatalf("order = %+v", o)
}
if pending, _ := storage.PendingMischiefOrders(10); len(pending) != 1 {
t.Fatalf("order didn't land in the pending set")
}
}
func TestMischiefOrderRejections(t *testing.T) {
s := newStore(t)
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "dragon"}); w.Code != 400 {
t.Errorf("unknown tier = %d, want 400", w.Code)
}
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "ghost", Tier: "grunt"}); w.Code != 404 {
t.Errorf("off-board target = %d, want 404", w.Code)
}
if w := placeOrder(t, s, "reala", mischiefOrderReq{Tier: "grunt"}); w.Code != 400 {
t.Errorf("empty target = %d, want 400", w.Code)
}
// Signed in, but no username the ledger can name (a pre-storefront session).
if w := placeOrder(t, s, "", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 409 {
t.Errorf("usernameless session = %d, want 409", w.Code)
}
}
func TestMischiefOrderBurstGuard(t *testing.T) {
s := newStore(t)
for i := 0; i < mischiefBurstMax; i++ {
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 {
t.Fatalf("order %d = %d, want 200", i, w.Code)
}
}
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 429 {
t.Fatalf("over-cap order = %d, want 429", w.Code)
}
}
func TestMischiefOrderRequiresAuth(t *testing.T) {
s := newStore(t)
r := httptest.NewRequest("POST", "/api/mischief/order", nil)
w := httptest.NewRecorder()
s.handleMischiefOrder(w, r)
if w.Code != 401 {
t.Fatalf("anonymous order = %d, want 401", w.Code)
}
}
func TestMischiefCatalogCarriesBalance(t *testing.T) {
s := newStore(t)
r := as(t, s, "reala", "GET", "/api/mischief/catalog", nil)
w := httptest.NewRecorder()
s.handleMischiefCatalog(w, r)
if w.Code != 200 {
t.Fatalf("catalog = %d", w.Code)
}
var resp mischiefCatalogResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Tiers) != 2 || !resp.HasBalance || resp.Euro != 500 {
t.Fatalf("catalog resp = %+v", resp)
}
}
// ---- the bearer wire -----------------------------------------------------------
func TestMischiefPendingAndClaimBearer(t *testing.T) {
s := newStore(t)
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "boss", Signed: true}); w.Code != 200 {
t.Fatalf("seed order = %d", w.Code)
}
// Missing bearer is rejected.
{
w := httptest.NewRecorder()
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "", nil))
if w.Code != 401 {
t.Fatalf("no-bearer pending = %d, want 401", w.Code)
}
}
// Poll returns the order.
var pending []storage.MischiefOrder
{
w := httptest.NewRecorder()
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil))
if w.Code != 200 {
t.Fatalf("pending = %d", w.Code)
}
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
t.Fatal(err)
}
}
if len(pending) != 1 {
t.Fatalf("pending count = %d, want 1", len(pending))
}
guid := pending[0].GUID
// Claim it placed.
{
w := httptest.NewRecorder()
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
mischiefVerdict{GUID: guid, Status: storage.MischiefPlaced, Detail: "out for delivery"}))
if w.Code != 200 {
t.Fatalf("claim = %d body=%s", w.Code, w.Body.String())
}
}
if got, _ := storage.MischiefOrderByGUID(guid); got.Status != storage.MischiefPlaced {
t.Fatalf("order status after claim = %q", got.Status)
}
// And it's gone from the pending set.
{
w := httptest.NewRecorder()
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil))
var again []storage.MischiefOrder
_ = json.Unmarshal(w.Body.Bytes(), &again)
if len(again) != 0 {
t.Fatalf("placed order still polled: %d", len(again))
}
}
}
func TestMischiefClaimUnknownGUIDis400(t *testing.T) {
s := newStore(t)
w := httptest.NewRecorder()
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
mischiefVerdict{GUID: "nope", Status: storage.MischiefPlaced}))
if w.Code != 400 {
t.Fatalf("unknown-guid claim = %d, want 400 (so gogobee parks it)", w.Code)
}
}
func TestMischiefClaimBadVerdictIs400(t *testing.T) {
s := newStore(t)
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 {
t.Fatal("seed")
}
pending, _ := storage.PendingMischiefOrders(10)
w := httptest.NewRecorder()
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
mischiefVerdict{GUID: pending[0].GUID, Status: "exploded"}))
if w.Code != 400 {
t.Fatalf("bad-verdict claim = %d, want 400", w.Code)
}
}

View File

@@ -41,13 +41,26 @@ const (
) )
// rosterPush is the payload gogobee POSTs to /api/ingest/roster. // rosterPush is the payload gogobee POSTs to /api/ingest/roster.
//
// Balances ride the same tick as the board but live in a separate keyspace: they
// are keyed by localpart (a buyer's own sign-in name), not by the anonymous
// roster token, and Pete only ever reads one back for the authenticated user
// asking about themselves. So the board stays anonymous while the storefront can
// still grey out tiers a buyer plainly can't afford.
type rosterPush struct { type rosterPush struct {
SnapshotAt int64 `json:"snapshot_at"` SnapshotAt int64 `json:"snapshot_at"`
Adventurers []storage.RosterEntry `json:"adventurers"` Adventurers []storage.RosterEntry `json:"adventurers"`
Balances []storage.MischiefBalance `json:"balances,omitempty"`
Tiers []storage.MischiefTier `json:"tiers,omitempty"`
} }
// RosterView is one row as the page renders it. // RosterView is one row as the page renders it.
//
// Token is the mark's anonymous roster token, exposed so the storefront can name
// a target without ever knowing their real handle. It is safe on a public page by
// design — non-reversible, stable, and already how gogobee keys the board.
type RosterView struct { type RosterView struct {
Token string
Name string Name string
Level int Level int
ClassRace string ClassRace string
@@ -103,7 +116,66 @@ func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers)) // Advisory balances are best-effort: a board that landed is worth keeping
// even if the balances behind it didn't, so a failure here is logged, not
// fatal. Stale affordability only ever bounces an order, never miscounts money.
if err := storage.ReplaceUserEuro(push.Balances, push.SnapshotAt); err != nil {
slog.Error("roster ingest: balance replace failed", "err", err)
}
// The tier catalog is likewise best-effort and only refreshed when the push
// actually carried one, so a gogobee build that predates the storefront (no
// tiers field) can't wipe a catalog a newer one already established.
if len(push.Tiers) > 0 {
if err := storage.ReplaceMischiefTiers(push.Tiers); err != nil {
slog.Error("roster ingest: tier replace failed", "err", err)
}
}
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers), "balances", len(push.Balances))
w.WriteHeader(http.StatusOK)
}
// detailPush is the payload gogobee POSTs to /api/ingest/detail: the private,
// owner-only expansion for every player, in its own keyspace (keyed by localpart)
// and on its own endpoint so a fat inventory can't blow the roster push's budget.
type detailPush struct {
SnapshotAt int64 `json:"snapshot_at"`
Players []storage.PlayerDetail `json:"players"`
}
// handleDetailIngest replaces the private self-detail set with gogobee's latest
// push. Bearer-authed like the roster; the data is owner-private and only ever
// served back to the one authenticated user it belongs to (see PlayerDetailByOwner).
func (s *Server) handleDetailIngest(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
}
// A generous cap: this carries every player's inventory + vault, heavier than
// the board, but still a bounded realm — the ceiling only stops a runaway push.
var push detailPush
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&push); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if len(push.Players) > rosterMaxEntries {
http.Error(w, "detail set too large", http.StatusBadRequest)
return
}
if push.SnapshotAt <= 0 {
push.SnapshotAt = time.Now().Unix()
}
if err := storage.ReplacePlayerDetail(push.Players, push.SnapshotAt); err != nil {
slog.Error("detail ingest: replace failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("detail ingest: self-view set replaced", "players", len(push.Players))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
@@ -142,6 +214,7 @@ func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
func toRosterView(e storage.RosterEntry) RosterView { func toRosterView(e storage.RosterEntry) RosterView {
v := RosterView{ v := RosterView{
Token: e.Token,
Name: e.Name, Name: e.Name,
Level: e.Level, Level: e.Level,
ClassRace: e.ClassRace, ClassRace: e.ClassRace,

View File

@@ -102,7 +102,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
shared []string shared []string
pages []string pages []string
}{ }{
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}}, {"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}}, {"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
} }
tpls := make(map[string]*template.Template) tpls := make(map[string]*template.Template)
@@ -219,6 +219,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest) mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
mux.HandleFunc("GET /api/roster", s.handleRosterAPI) mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
// The private, owner-only self-detail (inventory/house/pets). Bearer-authed
// ingest like the roster; there is no public read — it is only ever served
// back to its owner, inline on the detail page below.
mux.HandleFunc("POST /api/ingest/detail", s.handleDetailIngest)
// One adventurer's detail page and its live re-poll. Both public (stats +
// gear); the page adds the owner's private extras when the signed-in viewer
// owns it. Three path segments, so it never overlaps /adventure/{guid} (two)
// or /adventure/art/{type}.
mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)
// Per-dispatch permalink (the article_url every ingested story points at). // Per-dispatch permalink (the article_url every ingested story points at).
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the // Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
// channel listing, registered in the channels loop above). // channel listing, registered in the channels loop above).
@@ -236,6 +248,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim) mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled) mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
// The mischief storefront's game-box wire: gogobee polls pending orders and
// pushes a verdict. Bearer-authed on the same ingest token, same reason as the
// escrow seam — the caller is a machine on the tailnet. The buyer-facing half
// hangs off the auth block below.
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
// The casino. Signed-in only — there is money in it — so these hang off the // The casino. Signed-in only — there is money in it — so these hang off the
// auth block, and gamesReady() also insists on a Matrix server name: without // auth block, and gamesReady() also insists on a Matrix server name: without
// one, no player can be named to gogobee's ledger and the tables stay shut. // one, no player can be named to gogobee's ledger and the tables stay shut.
@@ -254,6 +273,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("GET /api/state", s.handleState) mux.HandleFunc("GET /api/state", s.handleState)
mux.HandleFunc("GET /bookmarks", s.handleBookmarks) mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
mux.HandleFunc("GET /for-you", s.handleForYou) mux.HandleFunc("GET /for-you", s.handleForYou)
// The mischief storefront, buyer side. Signed-in only — an order names a
// buyer to gogobee's ledger — so like the casino it hangs off the auth
// block. Only registered when the adventure seam is on; otherwise there is
// no board to order a hit from.
if s.adv.Enabled {
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
}
if s.cfg.Push.Enabled { if s.cfg.Push.Enabled {
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe) mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe) mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)

View File

@@ -0,0 +1,21 @@
Casino Audio
by Kenney Vleugels (Kenney.nl)
------------------------------
License (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
You may use these assets in personal and commercial projects.
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
------------------------------
Donate: http://support.kenney.nl
Request: http://request.kenney.nl
Follow on Twitter for updates:
@KenneyNL

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -940,6 +940,18 @@ html[data-phase="night"] {
will-change: transform, opacity; will-change: transform, opacity;
} }
/* Falling money — the win curtain. Positioned entirely by the transform WAAPI
drives (see moneyRain in casino-fx.js), so it starts pinned to the top-left. */
.pete-money {
position: absolute;
top: 0;
left: 0;
font-size: 1.6rem;
line-height: 1;
filter: drop-shadow(0 2px 2px rgba(0,0,0,0.35));
will-change: transform, opacity;
}
/* The dealer's beat before they draw out. */ /* The dealer's beat before they draw out. */
.pete-dealer-think { .pete-dealer-think {
animation: pete-think 0.9s ease-in-out infinite; animation: pete-think 0.9s ease-in-out infinite;
@@ -1444,6 +1456,14 @@ html[data-phase="night"] {
100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); } 100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
} }
/* The finish button on a won board breathes, so the one press left to make is
the one the eye lands on. */
.pete-finish { animation: pete-finish-pulse 1.6s ease-in-out infinite; }
@keyframes pete-finish-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(242, 181, 61, 0.55); }
50% { box-shadow: 0 0 0 0.9rem rgba(242, 181, 61, 0); }
}
/* The rail: the house's rack, what you've banked, the meter. On a wide screen /* The rail: the house's rack, what you've banked, the meter. On a wide screen
it's a column down the right of the felt; on a narrow one it lies down and it's a column down the right of the felt; on a narrow one it lies down and
sits under the board. */ sits under the board. */
@@ -1978,6 +1998,93 @@ html[data-phase="night"] {
} }
.pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; } .pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; }
/* The send-off. When the mercy rule buries a seat, uno.js rolls one of four
of these and drops it over that seat — or over your hand, if it's you going
down. Every piece is built fresh and clears itself, so it can't outlive the
hand. The wrap is a zero-size anchor at the seat's middle; the pieces hang
off it. */
.pete-uno-bury {
position: absolute;
left: 50%;
top: 45%;
width: 0;
height: 0;
z-index: 40;
pointer-events: none;
}
/* Rockslide: a handful of boulders come down and pile where the seat was. */
.pete-uno-rock {
position: absolute;
left: 0;
top: 0;
font-size: 1.5rem;
line-height: 1;
filter: drop-shadow(0 2px 1px rgba(0,0,0,0.4));
animation: pete-bury-rock 0.6s cubic-bezier(0.5, 0, 0.7, 1) both;
animation-delay: var(--d, 0ms);
}
@keyframes pete-bury-rock {
0% { transform: translate(calc(-50% + var(--dx)), -3.4rem) rotate(0deg); opacity: 0; }
20% { opacity: 1; }
75% { transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot)); }
85% { transform: translate(calc(-50% + var(--dx)), calc(var(--dy) - 0.22rem)) rotate(var(--rot)); }
100% { transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot)); opacity: 1; }
}
/* Tombstone and coffin: one heavy thing drops in and sets, with a small bounce. */
.pete-uno-tomb,
.pete-uno-coffin {
position: absolute;
left: 0;
top: 0;
line-height: 1;
filter: drop-shadow(0 3px 2px rgba(0,0,0,0.45));
animation: pete-bury-drop 0.5s cubic-bezier(0.34, 1.3, 0.64, 1) both;
}
.pete-uno-tomb { font-size: 2.4rem; }
.pete-uno-coffin { font-size: 2.5rem; }
@keyframes pete-bury-drop {
0% { transform: translate(-50%, -3.2rem) scale(0.7); opacity: 0; }
55% { opacity: 1; }
70% { transform: translate(-50%, -50%) scale(1.06); }
82% { transform: translate(-50%, -44%) scale(1); }
100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
/* The Mega Man death: a flash, then eight pieces fire out and wink off. */
.pete-uno-zap-flash {
position: absolute;
left: 0;
top: 0;
width: 1.5rem;
height: 1.5rem;
margin: -0.75rem 0 0 -0.75rem;
border-radius: 999px;
background: radial-gradient(circle, #fff 0%, rgba(180,220,255,0) 70%);
animation: pete-bury-flash 0.35s ease-out both;
}
@keyframes pete-bury-flash {
0% { transform: scale(0.3); opacity: 0.95; }
100% { transform: scale(1.9); opacity: 0; }
}
.pete-uno-zap-bit {
position: absolute;
left: 0;
top: 0;
width: 0.5rem;
height: 0.5rem;
margin: -0.25rem 0 0 -0.25rem;
border-radius: 999px;
background: #cfeaff;
box-shadow: 0 0 6px 1px rgba(150,210,255,0.9);
animation: pete-bury-zap 0.5s ease-out both;
}
@keyframes pete-bury-zap {
0% { transform: rotate(var(--ang)) translateX(0.2rem); opacity: 1; }
100% { transform: rotate(var(--ang)) translateX(3.4rem); opacity: 0; }
}
/* The rules switch: two dials, and this is the one that isn't the table size. */ /* The rules switch: two dials, and this is the one that isn't the table size. */
.pete-seg { .pete-seg {
display: inline-flex; display: inline-flex;
@@ -2031,7 +2138,8 @@ html[data-phase="night"] {
.pete-tile-hit, .pete-tile-hit,
.pete-meter[data-hit="1"] { animation: none; } .pete-meter[data-hit="1"] { animation: none; }
.pete-nope, .pete-nope,
.pete-home-flash { animation: none; } .pete-home-flash,
.pete-finish { animation: none; }
.pete-card[data-held="1"] { transition: none; } .pete-card[data-held="1"] { transition: none; }
/* The clock still drains — it is information, not decoration — but it stops /* The clock still drains — it is information, not decoration — but it stops
pulsing at you, and a wrong answer stops shaking. */ pulsing at you, and a wrong answer stops shaking. */
@@ -2043,6 +2151,9 @@ html[data-phase="night"] {
.pete-uno-card[data-glow="1"]::before { animation: none; } .pete-uno-card[data-glow="1"]::before { animation: none; }
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; } .pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; }
.pete-uno-pending { animation: none; } .pete-uno-pending { animation: none; }
/* The grave still gets marked — that's information — it just doesn't fall in.
uno.js drops a single static headstone here rather than the full rockslide. */
.pete-uno-tomb { animation: none; transform: translate(-50%, -50%); }
} }
} }
@@ -2096,22 +2207,30 @@ html[data-room] .cs-room {
radial-gradient(120% 90% at 50% 120%, rgba(0,0,0,0.45), transparent 55%); radial-gradient(120% 90% at 50% 120%, rgba(0,0,0,0.45), transparent 55%);
} }
/* Casino Night is lit by bulbs, so it gets a few of them: a slow chase across /* Casino Night is lit by bulbs, so it gets a row of them: a slow chase across
the top of the room. Cheap (one gradient, one transform) and it makes the the top of the room. One radial tile repeated, one transform — each bulb is a
place feel plugged in rather than painted. */ hot white core in a yellow body, and the glow is a pair of drop-shadows so it
actually throws light rather than being a painted dot. */
html[data-room="casino-night"] .cs-room::before { html[data-room="casino-night"] .cs-room::before {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0 -50% auto -50%; inset: 0.4rem -50% auto -50%;
height: 0.5rem; height: 0.7rem;
background: repeating-linear-gradient(90deg, background:
rgba(255,204,47,0.55) 0 0.5rem, transparent 0.5rem 2.25rem); radial-gradient(circle at center,
filter: blur(1px); #fffbe6 0 0.14rem,
animation: cs-bulbs 2.4s linear infinite; #ffcc2f 0.14rem 0.26rem,
rgba(255,204,47,0.45) 0.26rem 0.42rem,
transparent 0.44rem)
0.3rem 50% / 1.9rem 100% repeat-x;
filter:
drop-shadow(0 0 3px rgba(255,214,90,0.95))
drop-shadow(0 0 10px rgba(255,204,47,0.6));
animation: cs-bulbs 1.8s linear infinite;
} }
@keyframes cs-bulbs { @keyframes cs-bulbs {
from { transform: translateX(0); } from { transform: translateX(0); }
to { transform: translateX(2.75rem); } to { transform: translateX(1.9rem); }
} }
/* The house shadow, restruck for a dark room: the news app's soft brown lift is /* The house shadow, restruck for a dark room: the news app's soft brown lift is

File diff suppressed because one or more lines are too long

View File

@@ -265,13 +265,10 @@
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
// The one thing in this room that gets confetti. A natural is rare, it pays // Every win gets it to rain, because a win should feel like one. A natural is
// 3:2, and if everything celebrated then nothing would. // rarer and pays 3:2, so it keeps the confetti on top of the money as well.
// if (v.outcome === "blackjack") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
// The *sound* is not so precious: a win is a win and you should hear it. So else if (v.net > 0) FX.moneyRain();
// the fanfare rides on the money, not on the confetti.
if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 });
else if (v.net > 0) FX.sfx("win");
else if (v.net < 0) FX.sfx("lose"); else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push"); else FX.sfx("push");
} }

View File

@@ -319,6 +319,65 @@
return Promise.all(done); return Promise.all(done);
} }
// moneyRain: it rains money. The big finish on a win — bills and coins tumbling
// the whole height of the window, swaying and spinning as they fall. Louder than
// confetti, and it carries its own sound (a coin cascade) so it can stand in for
// the plain win fanfare wherever a table decides a win is worth the theatre.
//
// Like burst, the sound comes before the reduced-motion bail: no rain still
// deserves to be heard.
function moneyRain(opts) {
opts = opts || {};
if (opts.sound !== false) sfx(opts.sound || "jackpot");
if (reduced) return Promise.resolve();
var n = opts.count || 22;
var glyphs = opts.glyphs || ["💶", "💰", "🪙", "💵", "💴", "🤑"];
var vw = window.innerWidth || document.documentElement.clientWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
var done = [];
for (var i = 0; i < n; i++) {
var bit = document.createElement("div");
bit.className = "pete-money";
bit.textContent = glyphs[i % glyphs.length];
stage().appendChild(bit);
// Spread across the width in even lanes, each nudged off its lane so the
// curtain doesn't read as a grid. It sways sideways and spins on the way down.
var x = ((i + 0.5) / n) * vw + jitter(i, vw / n / 2);
var drift = jitter(i + 5, 80);
var startY = -50 - Math.abs(jitter(i + 2, 180));
var spin = jitter(i, 300);
var scale = 0.85 + Math.abs(jitter(i + 3, 0.55));
done.push(
bit
.animate(
[
{ transform: t(x, startY, scale, 0), opacity: 0, offset: 0 },
{ transform: t(x + drift, startY + 40, scale, spin * 0.2), opacity: 1, offset: 0.1 },
{ transform: t(x - drift, vh * 0.55, scale, spin * 0.6), opacity: 1, offset: 0.6 },
{ transform: t(x + drift * 0.5, vh + 60, scale, spin), opacity: 1, offset: 1 },
],
{
duration: 1600 + Math.abs(jitter(i + 9, 1)) * 900,
easing: "cubic-bezier(0.35, 0.15, 0.55, 1)",
fill: "both",
delay: Math.abs(jitter(i, 320)),
}
)
.finished.catch(function () {})
.then(
(function (b) {
return function () { b.remove(); };
})(bit)
)
);
}
return Promise.all(done);
}
// count rolls a number to a new value instead of swapping it. A chip count that // count rolls a number to a new value instead of swapping it. A chip count that
// jumps is a variable; one that climbs is a payout. // jumps is a variable; one that climbs is a payout.
function count(el, to, opts) { function count(el, to, opts) {
@@ -359,6 +418,7 @@
flyMany: flyMany, flyMany: flyMany,
spot: spot, spot: spot,
burst: burst, burst: burst,
moneyRain: moneyRain,
count: count, count: count,
centre: centre, centre: centre,
}; };

View File

@@ -1,12 +1,18 @@
// The noise the room makes. // The noise the room makes.
// //
// There are no audio files here and there is nothing to download. Every sound in // The room speaks in two voices. The cards and chips are *recorded* — real clay
// this casino is *made* — an oscillator, a burst of filtered noise, an envelope — // and real card stock, CC0 foley from Kenney's casino pack, sitting as small ogg
// the same bargain the weather engine takes with its clouds. A card is a short // files under /static/audio/casino. Synthesis never quite caught the grain of a
// slap of noise through a bandpass; a chip is two detuned sines with a click on // chip landing on a chip, so for those we stopped trying. Everything melodic —
// the front; a win is four notes going up. It costs about six kilobytes and no // the wins, the losses, the little ticks — is still *made*: an oscillator, a
// round trips, and it means a sound can be pitched, stretched and detuned per // burst of filtered noise, an envelope, the same bargain the weather engine takes
// call instead of being the same wav 300 times. // with its clouds. A win is four notes going up; a chip is a recording of a chip.
//
// A name is a recording if it appears in SAMPLES and synthesised if it appears in
// SOUNDS. The recordings guard against the one weakness of a sample — the same wav
// 300 times is a machine gun — by keeping several takes per sound and rotating
// through them, and by nudging every play a few percent off pitch. The synthesised
// half was always immune to that; it varies itself.
// //
// Two rules hold the whole file up. // Two rules hold the whole file up.
// //
@@ -21,6 +27,10 @@
// default-off switch for the entire file, checked before anything else happens. // default-off switch for the entire file, checked before anything else happens.
// //
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is. // Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
//
// If a recording hasn't finished decoding on the frame it's first needed, that one
// call falls through to the synthesised version — so the table is never silent
// while the ogg loads, and by the second card everything is real.
(function () { (function () {
"use strict"; "use strict";
@@ -108,6 +118,95 @@
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02); src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
} }
// ---- the recordings --------------------------------------------------------
//
// The foley. Each name maps to a handful of takes; `v` (the index of the card or
// chip in a run) picks which one and how far off pitch it lands, so a dealt hand
// is four different cards rather than one card four times. `gain` is per-sound
// and multiplies the master, exactly like the synthesised sounds' peak does.
var SAMPLE_BASE = "/static/audio/casino/";
var SAMPLES = {
// A card thrown down onto the table. `vary` is the largest fraction a take is
// ever pitched by, up or down — 0.20 means a play can land anywhere from a fifth
// slower (deeper) to a fifth faster (brighter).
card: { files: ["cardPlace1", "cardPlace2", "cardPlace3", "cardPlace4"], gain: 0.6, vary: 0.20 },
// A card slid into place — softer, longer than a throw.
deal: { files: ["cardSlide1", "cardSlide2", "cardSlide3", "cardSlide4", "cardSlide5", "cardSlide6"], gain: 0.5, vary: 0.20 },
// A card flicked over.
flip: { files: ["cardFan1", "cardFan2"], gain: 0.5, vary: 0.18 },
// The riffle — one take, so the pitch wobble is all that keeps two shuffles apart.
shuffle: { files: ["cardShuffle"], gain: 0.6, vary: 0.10 },
// A clay chip set down on a stack.
chip: { files: ["chipLay1", "chipLay2"], gain: 0.6, vary: 0.22 },
// Chips gathered and slid away.
sweep: { files: ["chipsHandle2", "chipsHandle4", "chipsHandle6"], gain: 0.5, vary: 0.14 },
};
// Decoded buffers by filename. A value of null means "claimed, in flight or
// decoding"; false means "we tried and it failed, never bother again"; an
// AudioBuffer means ready.
var buffers = {};
var warmed = false;
// A rotating cursor per sound, so that even a run of identical calls walks
// through the takes rather than replaying the first one. Callers pass `v` to
// separate simultaneous sounds (the cards of one deal), but many pass the same
// constant every time — the cursor is what keeps a stack of chips from being the
// same click over and over.
var turn = {};
// decode fetches a file and turns it into a buffer, exactly once per filename.
// decodeAudioData exists in a modern promise flavour and an old callback one;
// this handles both so the foley works on older Safari too.
function decode(file) {
if (file in buffers) return; // ready, in flight, or known-bad
buffers[file] = null;
fetch(SAMPLE_BASE + file + ".ogg")
.then(function (r) { return r.ok ? r.arrayBuffer() : Promise.reject(); })
.then(function (ab) {
return new Promise(function (res, rej) {
var p = ctx.decodeAudioData(ab, res, rej);
if (p && p.then) p.then(res, rej);
});
})
.then(function (buf) { buffers[file] = buf; })
.catch(function () { buffers[file] = false; });
}
// warm pulls every take down once the context is awake, so that after the first
// click the whole set is decoded and ready and nothing has to fall back.
function warm() {
if (warmed || !ctx) return;
warmed = true;
for (var name in SAMPLES) SAMPLES[name].files.forEach(decode);
}
// sample plays one recorded take. It returns false — rather than making a noise —
// when the name isn't a recording or its buffer isn't decoded yet, which is the
// caller's signal to reach for the synthesised version instead.
function sample(name, t0, v) {
var cfg = SAMPLES[name];
if (!cfg) return false;
// Advance the cursor, then offset it by v. The cursor guarantees consecutive
// calls move to the next take; v spreads apart sounds fired together (one
// deal's cards) so they don't all land on the same take at once.
var k = (turn[name] = (turn[name] || 0) + 1) + Math.abs(Math.round(v));
var buf = buffers[cfg.files[k % cfg.files.length]];
if (!buf) return false; // not decoded yet, or failed: let synth cover it
var src = ctx.createBufferSource();
src.buffer = buf;
// Pitch, walking with the same cursor across seven steps between -vary and
// +vary, so even a single-take sound like the shuffle isn't identical twice in
// a row. Seven steps rather than a couple means the runs don't read as a loop.
if (cfg.vary) src.playbackRate.value = 1 + (((k % 7) - 3) / 3) * cfg.vary;
var g = ctx.createGain();
g.gain.value = cfg.gain == null ? 0.6 : cfg.gain;
src.connect(g).connect(master);
src.start(t0);
return true;
}
// ---- the sounds ------------------------------------------------------------ // ---- the sounds ------------------------------------------------------------
// //
// Each one takes the time it starts at, and a `v` — a small per-call variation, // Each one takes the time it starts at, and a `v` — a small per-call variation,
@@ -165,6 +264,17 @@
}); });
}, },
// It rains money. A bright run of coins spilling out over the win fanfare —
// ten metallic pings climbing a pentatonic scale, each with a tick on the
// front of it so it lands as coin rather than as bell.
jackpot: function (t) {
var notes = [784, 988, 1175, 1319, 1568, 1760];
for (var i = 0; i < 10; i++) {
tone(t + i * 0.05, notes[i % notes.length], { type: "triangle", decay: 0.13, gain: 0.09 });
hiss(t + i * 0.05, { freq: 5200, q: 3, decay: 0.02, gain: 0.05 });
}
},
// Two notes down, and the second one is flat. Nobody needs telling twice. // Two notes down, and the second one is flat. Nobody needs telling twice.
lose: function (t) { lose: function (t) {
tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 }); tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 });
@@ -208,6 +318,43 @@
tick: function (t) { tick: function (t) {
hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 }); hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 });
}, },
// The mercy rule buries someone. Four ways to go, one sound each — uno.js
// rolls which animation plays and asks for the sound that matches it.
//
// Rocks coming down: a low rumble with tumbling knocks over the top, and a
// second slump as the pile settles.
rockslide: function (t) {
hiss(t, { filter: "lowpass", freq: 320, sweepTo: 140, decay: 0.5, gain: 0.34, q: 0.6 });
for (var i = 0; i < 6; i++) {
tone(t + i * 0.055, 128 - i * 9, { type: "square", decay: 0.1, gain: 0.09 });
}
hiss(t + 0.3, { filter: "lowpass", freq: 200, decay: 0.32, gain: 0.3, q: 0.5 });
},
// A headstone dropping in: one heavy stone thud that rings a little as it sets.
tombstone: function (t) {
tone(t, 92, { type: "sine", to: 58, glide: 0.16, decay: 0.42, gain: 0.32 });
hiss(t, { filter: "lowpass", freq: 520, decay: 0.12, gain: 0.34, q: 0.6 });
tone(t + 0.02, 184, { type: "triangle", decay: 0.5, gain: 0.08 });
},
// The Mega Man death: the pieces zip outward and the pitch falls away as they go.
zap: function (t) {
tone(t, 1250, { type: "sawtooth", to: 170, glide: 0.34, decay: 0.36, gain: 0.12 });
for (var i = 0; i < 4; i++) {
tone(t + i * 0.02, 940 - i * 130, { type: "square", decay: 0.06, gain: 0.06 });
}
hiss(t, { freq: 3000, sweepTo: 600, decay: 0.3, gain: 0.1, q: 0.8 });
},
// A coffin lid: two flat wooden knocks, the second lower and heavier.
coffin: function (t) {
tone(t, 150, { type: "triangle", decay: 0.14, gain: 0.26 });
hiss(t, { filter: "lowpass", freq: 820, decay: 0.05, gain: 0.24, q: 0.7 });
tone(t + 0.17, 108, { type: "triangle", decay: 0.22, gain: 0.28 });
hiss(t + 0.17, { filter: "lowpass", freq: 600, decay: 0.06, gain: 0.24, q: 0.7 });
},
}; };
// ---- the door -------------------------------------------------------------- // ---- the door --------------------------------------------------------------
@@ -217,6 +364,7 @@
// gesture — after which play() can schedule freely. // gesture — after which play() can schedule freely.
function wake() { function wake() {
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {}); if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
warm();
} }
["pointerdown", "keydown", "touchstart"].forEach(function (ev) { ["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
window.addEventListener(ev, wake, { passive: true }); window.addEventListener(ev, wake, { passive: true });
@@ -231,13 +379,18 @@
// lands in 400ms can say so rather than sleeping. // lands in 400ms can say so rather than sleeping.
function play(name, opts) { function play(name, opts) {
if (muted) return; if (muted) return;
var s = SOUNDS[name]; if (!SOUNDS[name] && !SAMPLES[name]) return; // known to neither voice
if (!s) return;
if (!boot()) return; if (!boot()) return;
wake(); wake();
if (ctx.state !== "running") return; // not yet touched: no sound, and no error if (ctx.state !== "running") return; // not yet touched: no sound, and no error
var t0 = ctx.currentTime + ((opts && opts.delay) || 0);
var v = (opts && opts.v) || 0;
try { try {
s(ctx.currentTime + ((opts && opts.delay) || 0), ((opts && opts.v) || 0)); // A recording if we have one decoded; the synthesised take otherwise — both
// for names that are only ever synthesised, and for the first call of a
// recorded name while its ogg is still loading.
if (sample(name, t0, v)) return;
if (SOUNDS[name]) SOUNDS[name](t0, v);
} catch (e) { } catch (e) {
/* a sound is never worth throwing over */ /* a sound is never worth throwing over */
} }

View File

@@ -293,10 +293,10 @@
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
// Confetti for a phrase guessed outright — the one call you make on your own // A phrase guessed outright keeps the confetti — the one call you make on your
// rather than by grinding the alphabet. // own rather than by grinding the alphabet — and any win at all makes it rain.
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 }); if (v.outcome === "solved" && v.net > 0) { FX.burst(verdictEl, { count: 30 }); FX.moneyRain({ count: 28 }); }
else if (v.net > 0) FX.sfx("win"); else if (v.net > 0) FX.moneyRain();
else if (v.net < 0) FX.sfx("lose"); else if (v.net < 0) FX.sfx("lose");
} }

View File

@@ -370,7 +370,12 @@
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () { return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
potTotal.textContent = money(pot.amount); potTotal.textContent = money(pot.amount);
moveStack(e.seat, e.amount); moveStack(e.seat, e.amount);
if (e.seat === me && e.amount > 0) FX.burst(s.plate, { count: 18 }); if (e.seat === me && e.amount > 0) {
FX.burst(s.plate, { count: 18 });
// A real haul makes it rain; a blind-steal doesn't, or poker would be
// raining money every thirty seconds. Twenty big blinds is a pot.
if (view && view.tier && e.amount >= 20 * view.tier.bb) FX.moneyRain({ count: 24 });
}
return wait(260); return wait(260);
}); });

View File

@@ -44,7 +44,10 @@
var playing = root.querySelector("[data-playing]"); var playing = root.querySelector("[data-playing]");
var betting = root.querySelector("[data-betting]"); var betting = root.querySelector("[data-betting]");
var playControls = root.querySelector("[data-play-controls]");
var wonControls = root.querySelector("[data-won-controls]");
var autoBtn = root.querySelector("[data-auto]"); var autoBtn = root.querySelector("[data-auto]");
var finishBtn = root.querySelector("[data-finish]");
var cashBtn = root.querySelector("[data-cash]"); var cashBtn = root.querySelector("[data-cash]");
var cashAmountEl = root.querySelector("[data-cash-amount]"); var cashAmountEl = root.querySelector("[data-cash-amount]");
var startBtn = root.querySelector("[data-start]"); var startBtn = root.querySelector("[data-start]");
@@ -258,6 +261,11 @@
playing.classList.toggle("hidden", !live); playing.classList.toggle("hidden", !live);
betting.classList.toggle("hidden", live); betting.classList.toggle("hidden", live);
if (!live) return; if (!live) return;
// A won board has nothing left to decide, so the ordinary controls step aside
// for the one button that finishes it.
var won = !!v.won;
wonControls.classList.toggle("hidden", !won);
playControls.classList.toggle("hidden", won);
autoBtn.disabled = !v.can_auto; autoBtn.disabled = !v.can_auto;
cashAmountEl.textContent = (v.stands || 0).toLocaleString(); cashAmountEl.textContent = (v.stands || 0).toLocaleString();
} }
@@ -545,6 +553,10 @@
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); }); autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
// The finish button. On a won board a single auto drains every card home in one
// cascade, and the board settles cleared on the far end of it.
finishBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
cashBtn.addEventListener("click", function () { cashBtn.addEventListener("click", function () {
drop(); drop();
send({ kind: "concede" }); send({ kind: "concede" });
@@ -576,9 +588,9 @@
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in // Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
// this room, so it's the one that gets the confetti. // this room, so it keeps the confetti — but any win now makes it rain.
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 }); if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 40 }); FX.moneyRain({ count: 34 }); }
else if (v.net > 0) FX.sfx("win"); else if (v.net > 0) FX.moneyRain();
else if (v.net < 0) FX.sfx("lose"); else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push"); else FX.sfx("push");
} }

View File

@@ -307,8 +307,9 @@
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
// Confetti only for clearing all twelve the one thing in here worth it. // Clearing all twelve keeps the confetti on top; any win at all makes it rain.
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 }); if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
else if (v.net > 0) FX.moneyRain();
} }
function setPhase(v) { function setPhase(v) {

View File

@@ -304,12 +304,22 @@
colourEl.textContent = ""; colourEl.textContent = "";
if (feltEl) feltEl.dataset.c = ""; if (feltEl) feltEl.dataset.c = "";
} }
pending(live(v) ? (v.pending || 0) : 0); pending(live(v) ? (v.pending || 0) : 0, v);
} }
function pending(n) { // Who the running stack lands on next: during a stack phase that's whoever's
// turn it is. Fall back to the view the caller passed, then to the live game.
function pendingTarget(v) {
var src = v || game;
if (!src) return "you";
if (src.turn === me) return "you";
var s = (src.seats && src.seats[src.turn]) || null;
return (s && s.name) || "them";
}
function pending(n, v) {
if (billEl) { if (billEl) {
billEl.textContent = n > 0 ? "+" + n + " on you" : ""; billEl.textContent = n > 0 ? "+" + n + " on " + pendingTarget(v) : "";
billEl.classList.toggle("hidden", n <= 0); billEl.classList.toggle("hidden", n <= 0);
} }
if (takeN) takeN.textContent = String(n); if (takeN) takeN.textContent = String(n);
@@ -355,7 +365,7 @@
if (!text) { verdictEl.classList.add("hidden"); return; } if (!text) { verdictEl.classList.add("hidden"); return; }
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
if (v.winner === me && v.outcome !== "tie") FX.burst(verdictEl, { count: 34 }); if (v.winner === me && v.outcome !== "tie") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose"); else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose");
} }
@@ -475,6 +485,80 @@
return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); }); return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); });
} }
// bury gives the mercy rule some ceremony. It rolls one of four send-offs and
// drops it over the buried seat — or over your hand, if it's you going down —
// with the noise that goes with it. Every piece is built from scratch and
// clears itself, so a repaint that lands mid-fall just cuts it short. Resolves
// on the dramatic beat, not on cleanup: the hand shouldn't wait for the dust.
var BURIALS = ["rocks", "tomb", "zap", "coffin"];
function bury(seat) {
var host = seat === me ? handEl : seatEl(seat);
if (!host) return Promise.resolve();
var wrap = document.createElement("div");
wrap.className = "pete-uno-bury";
host.appendChild(wrap);
// No theatre for reduced motion: a single static headstone marks the grave,
// with the stone-drop thud so it isn't silent either.
if (reduced) {
FX.sfx("tombstone");
var stone = document.createElement("div");
stone.className = "pete-uno-tomb";
stone.textContent = "🪦";
wrap.appendChild(stone);
setTimeout(function () { wrap.remove(); }, 1400);
return Promise.resolve();
}
var kind = BURIALS[Math.floor(Math.random() * BURIALS.length)];
wrap.dataset.kind = kind;
if (kind === "rocks") {
FX.sfx("rockslide");
[
{ dx: "-1.15rem", dy: "0.55rem", rot: "-20deg", d: 0 },
{ dx: "0.95rem", dy: "0.7rem", rot: "24deg", d: 70 },
{ dx: "-0.15rem", dy: "0.15rem", rot: "8deg", d: 150 },
{ dx: "-1.5rem", dy: "1.0rem", rot: "-34deg", d: 220 },
{ dx: "1.45rem", dy: "0.95rem", rot: "16deg", d: 300 },
{ dx: "0.35rem", dy: "1.05rem", rot: "-10deg", d: 360 },
].forEach(function (r) {
var el = document.createElement("div");
el.className = "pete-uno-rock";
el.textContent = "🪨";
el.style.setProperty("--dx", r.dx);
el.style.setProperty("--dy", r.dy);
el.style.setProperty("--rot", r.rot);
el.style.setProperty("--d", r.d + "ms");
wrap.appendChild(el);
});
} else if (kind === "zap") {
FX.sfx("zap");
var flash = document.createElement("div");
flash.className = "pete-uno-zap-flash";
wrap.appendChild(flash);
for (var a = 0; a < 8; a++) {
var bit = document.createElement("div");
bit.className = "pete-uno-zap-bit";
bit.style.setProperty("--ang", (a * 45) + "deg");
wrap.appendChild(bit);
}
} else {
// tomb or coffin: one heavy thing drops in and sets.
FX.sfx(kind === "tomb" ? "tombstone" : "coffin");
var mark = document.createElement("div");
mark.className = kind === "tomb" ? "pete-uno-tomb" : "pete-uno-coffin";
mark.textContent = kind === "tomb" ? "🪦" : "⚰️";
wrap.appendChild(mark);
}
// The zap is gone in half a second; the graves linger before they clear.
setTimeout(function () { wrap.remove(); }, kind === "zap" ? 650 : 1500);
return new Promise(function (r) { setTimeout(r, kind === "zap" ? 480 : 640); });
}
// play walks the server's script for a move the acting player just made. // play walks the server's script for a move the acting player just made.
function play(view) { function play(view) {
var events = view.uno_events || []; var events = view.uno_events || [];
@@ -570,7 +654,7 @@
} }
case "stack": case "stack":
pending(e.n); pending(e.n, final);
spotlight(e.seat); spotlight(e.seat);
FX.sfx("bad"); FX.sfx("bad");
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); }); return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
@@ -614,8 +698,12 @@
bump(e.seat, 0); bump(e.seat, 0);
showHand(e.hand); showHand(e.hand);
pending(0); pending(0);
FX.sfx("bad"); // bury() rolls the send-off and makes its own noise; the badge rides
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); }); // alongside it so you still see what count did them in.
return Promise.all([
bury(e.seat),
badge(e.seat, "Buried on " + e.n, "bad"),
]).then(function () { return wait(300); });
} }
case "skip": case "skip":

View File

@@ -4,7 +4,7 @@
// //
// Bump CACHE_VERSION whenever the precached shell assets change; activate() // Bump CACHE_VERSION whenever the precached shell assets change; activate()
// drops every cache that doesn't match the current version. // drops every cache that doesn't match the current version.
var CACHE_VERSION = "v4"; var CACHE_VERSION = "v5";
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION; var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION; var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
@@ -117,18 +117,24 @@ self.addEventListener("fetch", function (event) {
return; return;
} }
// Static assets: cache-first (they're versioned by deploy), fill the cache on // Static assets: network-first. Our asset URLs are NOT content-hashed
// first miss so a later offline visit has them. // (/static/js/weather-gl.js stays the same URL across deploys), so a
// cache-first strategy would pin whatever bytes were first cached and never
// notice a redeployed file changed — leaving stale JS/CSS in place until the
// CACHE_VERSION bump below. Go's FileServer sets ETag/Last-Modified, so an
// online refetch is a cheap 304 when nothing changed. We still fill the cache
// on every success and fall back to it when the network is unreachable, so
// offline reading keeps the shell it needs.
if (url.pathname.indexOf("/static/") === 0) { if (url.pathname.indexOf("/static/") === 0) {
event.respondWith( event.respondWith(
caches.match(req).then(function (hit) { fetch(req).then(function (res) {
return hit || fetch(req).then(function (res) { if (res && res.ok) {
if (res && res.ok) { var copy = res.clone();
var copy = res.clone(); caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); }); }
} return res;
return res; }).catch(function () {
}); return caches.match(req);
}) })
); );
return; return;

View File

@@ -25,19 +25,62 @@
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card"> <div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list"> <ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
{{range .Roster}} {{range .Roster}}
<li class="flex items-center gap-4 px-5 py-3"> <li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}">
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span> <span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
<span class="font-semibold">{{.Name}}</span> <a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span> <span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}"> <span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}} {{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
</span> </span>
{{if and $.User .OnRun}}
<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
{{end}}
</li> </li>
{{else}} {{else}}
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li> <li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
{{end}} {{end}}
</ul> </ul>
</div> </div>
{{if not .User}}
<p class="mt-3 text-xs text-[color:var(--ink)]/50">
<a href="/auth/login?next={{.Path}}" class="underline hover:text-red-500">Sign in</a> to send something unpleasant their way.
</p>
{{end}}
{{if .User}}
<!-- Your open contracts. Rendered from filed facts, never from live game state:
Pete only knows what gogobee told it happened. -->
<div id="mischief-orders" class="mt-4 hidden">
<h3 class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50 mb-2">Trouble you've paid for</h3>
<ul id="mischief-orders-list" class="space-y-2 text-sm"></ul>
</div>
<!-- The buy dialog. Prices are gogobee's, pulled live from /api/mischief/catalog;
nothing here asserts a number of its own. -->
<div id="mischief-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-[color:var(--ink)]/10">
<h3 class="font-display text-xl font-bold">Send trouble to <span id="mischief-target"></span></h3>
<button type="button" id="mischief-close" class="text-2xl leading-none text-[color:var(--ink)]/50 hover:text-[color:var(--ink)]" aria-label="close">×</button>
</div>
<div class="px-5 py-4 space-y-3">
<p class="text-sm text-[color:var(--ink)]/60">Pick how bad. They won't know who sent it — unless you sign it, or they live to read the receipt.</p>
<div id="mischief-tiers" class="space-y-2"></div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" id="mischief-signed" class="rounded border-[color:var(--ink)]/30">
<span>Sign my name to it <span class="text-[color:var(--ink)]/45">(+25%, and they'll know)</span></span>
</label>
<p id="mischief-balance" class="text-xs text-[color:var(--ink)]/50"></p>
<p id="mischief-result" class="text-sm hidden"></p>
</div>
<div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-[color:var(--ink)]/10">
<button type="button" id="mischief-cancel" class="rounded-full px-4 py-2 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">Never mind</button>
<button type="button" id="mischief-confirm" class="rounded-full bg-red-500 text-white px-5 py-2 text-sm font-semibold shadow-pete hover:-translate-y-0.5 transition disabled:opacity-40 disabled:translate-y-0" disabled>Put the word out</button>
</div>
</div>
</div>
{{end}}
</section> </section>
<script> <script>
@@ -49,6 +92,8 @@
var card = document.getElementById('roster-card'); var card = document.getElementById('roster-card');
if (!list) return; if (!list) return;
var signedIn = {{if .User}}true{{else}}false{{end}};
function esc(s) { function esc(s) {
var d = document.createElement('div'); var d = document.createElement('div');
d.textContent = s == null ? '' : String(s); d.textContent = s == null ? '' : String(s);
@@ -57,12 +102,15 @@
function row(a) { function row(a) {
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : ''; var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
return '<li class="flex items-center gap-4 px-5 py-3">' + var button = (signedIn && a.OnRun)
? '<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
: '';
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' + '<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
'<span class="font-semibold">' + esc(a.Name) + '</span>' + '<a href="/adventure/who/' + esc(a.Token) + '" class="font-semibold hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' + '<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' + '<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
esc(a.Where) + idle + '</span></li>'; esc(a.Where) + idle + '</span>' + button + '</li>';
} }
function refresh() { function refresh() {
@@ -84,7 +132,157 @@
document.addEventListener('visibilitychange', function () { document.addEventListener('visibilitychange', function () {
if (!document.hidden) refresh(); if (!document.hidden) refresh();
}); });
if (signedIn) initMischief(list, esc);
})(); })();
// The storefront: pick a mark, pick how bad, pay. The only thing this code is
// authoritative about is the buyer's intent — every price, every yes/no, and the
// buyer's actual balance are gogobee's, reached through the API.
function initMischief(list, esc) {
var modal = document.getElementById('mischief-modal');
if (!modal) return;
var targetEl = document.getElementById('mischief-target');
var tiersEl = document.getElementById('mischief-tiers');
var signedEl = document.getElementById('mischief-signed');
var balanceEl = document.getElementById('mischief-balance');
var resultEl = document.getElementById('mischief-result');
var confirmEl = document.getElementById('mischief-confirm');
var ordersWrap = document.getElementById('mischief-orders');
var ordersList = document.getElementById('mischief-orders-list');
var catalog = null; // {tiers, euro, has_balance}
var chosen = null; // selected tier key
var current = null; // {token, name}
function euros(n) { return '€' + Number(n).toLocaleString(); }
function loadCatalog() {
return fetch('/api/mischief/catalog', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (c) { catalog = c; return c; });
}
function drawTiers() {
if (!catalog || !catalog.tiers || !catalog.tiers.length) {
tiersEl.innerHTML = '<p class="text-sm text-[color:var(--ink)]/50">The catalogue is between snapshots. Try again in a moment.</p>';
return;
}
var signed = signedEl.checked;
var known = catalog.has_balance;
var euro = catalog.euro;
tiersEl.innerHTML = catalog.tiers.map(function (t) {
var price = signed ? t.signed_fee : t.fee;
var tooDear = known && euro < price;
return '<button type="button" class="mischief-tier w-full text-left rounded-2xl border-2 px-4 py-2 transition ' +
(chosen === t.key ? 'border-red-500 bg-red-500/10' : 'border-[color:var(--ink)]/10 hover:border-[color:var(--ink)]/25') +
(tooDear ? ' opacity-40 cursor-not-allowed' : '') + '" data-key="' + esc(t.key) + '"' + (tooDear ? ' disabled' : '') + '>' +
'<span class="flex items-baseline justify-between"><span class="font-semibold">' + esc(t.display) + '</span>' +
'<span class="font-semibold">' + euros(price) + '</span></span>' +
(t.blurb ? '<span class="block text-xs text-[color:var(--ink)]/55 mt-0.5">' + esc(t.blurb) + '</span>' : '') +
'</button>';
}).join('');
balanceEl.textContent = known ? 'You have about ' + euros(euro) + ' to spend.' : '';
Array.prototype.forEach.call(tiersEl.querySelectorAll('.mischief-tier'), function (b) {
b.addEventListener('click', function () {
chosen = b.getAttribute('data-key');
confirmEl.disabled = false;
drawTiers();
});
});
}
function open(token, name) {
current = { token: token, name: name };
chosen = null;
confirmEl.disabled = true;
resultEl.classList.add('hidden');
resultEl.textContent = '';
signedEl.checked = false;
targetEl.textContent = name;
modal.classList.remove('hidden');
modal.classList.add('flex');
(catalog ? Promise.resolve() : loadCatalog()).then(drawTiers);
}
function close() {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
function badge(statusText) {
var map = {
pending: ['on its way', 'bg-amber-500/15 text-amber-700'],
placed: ['out for delivery', 'bg-red-500/15 text-red-600'],
bounced_funds: ["couldn't afford it", 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'],
bounced_ineligible: ['no longer a mark', 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60']
};
var m = map[statusText] || [statusText, 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'];
return '<span class="rounded-full px-2 py-0.5 text-xs font-semibold ' + m[1] + '">' + esc(m[0]) + '</span>';
}
function loadOrders() {
fetch('/api/mischief/orders', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (orders) {
if (!orders || !orders.length) { ordersWrap.classList.add('hidden'); return; }
ordersWrap.classList.remove('hidden');
ordersList.innerHTML = orders.map(function (o) {
var detail = o.detail ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(o.detail) + '</span>' : '';
return '<li class="flex items-center gap-3">' +
'<span class="font-semibold">' + esc(o.target_name) + '</span>' +
'<span class="text-[color:var(--ink)]/55">' + esc(o.tier) + (o.signed ? ', signed' : '') + '</span>' +
'<span class="ml-auto">' + badge(o.status) + '</span>' + detail + '</li>';
}).join('');
})
.catch(function () {});
}
list.addEventListener('click', function (e) {
var b = e.target.closest('.mischief-send');
if (!b) return;
open(b.getAttribute('data-token'), b.getAttribute('data-name'));
});
signedEl.addEventListener('change', drawTiers);
document.getElementById('mischief-close').addEventListener('click', close);
document.getElementById('mischief-cancel').addEventListener('click', close);
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
confirmEl.addEventListener('click', function () {
if (!current || !chosen) return;
confirmEl.disabled = true;
resultEl.classList.add('hidden');
fetch('/api/mischief/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_token: current.token, tier: chosen, signed: signedEl.checked })
})
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
.then(function (res) {
resultEl.classList.remove('hidden');
if (res.ok) {
resultEl.className = 'text-sm text-red-600 font-semibold';
resultEl.textContent = 'The word is out. Watch the board.';
catalog = null; // balance moved; refetch next open
loadOrders();
setTimeout(close, 1200);
} else {
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
resultEl.textContent = (res.body && res.body.error) || 'That didn\'t take. Try again.';
confirmEl.disabled = false;
}
})
.catch(function () {
resultEl.classList.remove('hidden');
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
resultEl.textContent = 'The line dropped. Try again.';
confirmEl.disabled = false;
});
});
loadOrders();
setInterval(loadOrders, 60000);
}
</script> </script>
{{end}} {{end}}

View File

@@ -68,7 +68,6 @@
{{template "_comb" .}} {{template "_comb" .}}
<span class="min-w-0"> <span class="min-w-0">
<span data-room-name class="block font-display text-2xl sm:text-3xl font-bold leading-none tracking-tight">{{.Room.Name}}</span> <span data-room-name class="block font-display text-2xl sm:text-3xl font-bold leading-none tracking-tight">{{.Room.Name}}</span>
<span class="mt-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--ink)]/45">Chips are euros</span>
</span> </span>
</a> </a>

View File

@@ -86,7 +86,9 @@
<!-- Playing: shown while a board is live. --> <!-- Playing: shown while a board is live. -->
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10"> <section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3">
<!-- The ordinary controls, while there's still a game to play. -->
<div data-play-controls class="flex flex-wrap items-center gap-3">
<button type="button" data-auto <button type="button" data-auto
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10 class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition"> hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
@@ -103,6 +105,22 @@
Cash the board · <span data-cash-amount class="tabular-nums">0</span> Cash the board · <span data-cash-amount class="tabular-nums">0</span>
</button> </button>
</div> </div>
<!-- The board's won: every card's face up and the piles are drained, so
there's nothing left to decide. One press sends the lot home. -->
<div data-won-controls class="hidden">
<div class="flex flex-wrap items-center gap-3">
<button type="button" data-finish
class="pete-finish rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:pointer-events-none transition">
You've cracked it. Send them all home 🎉
</button>
<p class="text-xs text-[color:var(--ink)]/45">
The whole board's face up now, so the rest plays itself.
</p>
</div>
</div>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p> <p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section> </section>

View File

@@ -0,0 +1,207 @@
{{define "title"}}{{.Mark.Name}} — {{.SiteTitle}}{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
<nav class="mb-4">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<span aria-hidden="true"></span> The board
</a>
</nav>
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{if .Mark.OnRun}}⚔{{else}}🏠{{end}}</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">adventurer</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Mark.Name}}</h1>
<p class="mt-2 opacity-90">Level {{.Mark.Level}} {{.Mark.ClassRace}}</p>
<p class="mt-4 text-xs uppercase tracking-wider opacity-75" id="who-where">
{{if .Mark.OnRun}}{{.Mark.Where}}{{else}}In town{{if .Mark.Idle}} · {{.Mark.Idle}}{{end}}{{end}}
</p>
</div>
</header>
{{if .HasDetail}}
<!-- Stats + gear: public, the same anonymity model as the board. -->
<section class="mt-8 grid gap-6 sm:grid-cols-2">
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Vitals</h2>
<div class="flex items-baseline justify-between">
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Hit points</span>
<span class="font-semibold" id="who-hp">{{.Detail.HPCurrent}} / {{.Detail.HPMax}}{{if .Detail.TempHP}} <span class="text-theme-adventure">+{{.Detail.TempHP}}</span>{{end}}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-[color:var(--ink)]/10 overflow-hidden">
<div class="h-full rounded-full bg-theme-adventure transition-all" id="who-hp-bar" style="width:0%"></div>
</div>
<div class="mt-4 flex items-baseline justify-between">
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Armor class</span>
<span class="font-semibold" id="who-ac">{{.Detail.ArmorClass}}</span>
</div>
<div class="mt-5 grid grid-cols-3 gap-2">
{{range .Abilities}}
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50">{{.Label}}</div>
<div class="font-display text-lg font-bold leading-none mt-1">{{.Score}}</div>
<div class="text-xs text-[color:var(--ink)]/60">{{.ModStr}}</div>
</div>
{{end}}
</div>
{{if .Mark.OnRun}}
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 space-y-1.5 text-sm" id="who-expedition">
{{if .Detail.Room}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Room</span><span class="font-semibold" id="who-room">{{.Detail.Room}}</span></div>{{end}}
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Supplies</span><span class="font-semibold" id="who-supplies">{{.Detail.Supplies}}</span></div>
{{if .Detail.ThreatLevel}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Threat</span><span class="font-semibold" id="who-threat">{{.Detail.ThreatLevel}}</span></div>{{end}}
</div>
{{end}}
</div>
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Gear</h2>
{{if .Detail.Gear}}
<ul class="space-y-2">
{{range .Detail.Gear}}
<li class="flex items-baseline justify-between gap-3">
<span class="text-xs uppercase tracking-wider text-[color:var(--ink)]/45 w-16 shrink-0">{{.Slot}}</span>
<span class="font-semibold flex-1">{{.Name}}{{if .Masterwork}} <span title="masterwork"></span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
{{end}}
</div>
</section>
{{else}}
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
</section>
{{end}}
{{if .HasSelf}}
<!-- Owner-only: this is you. Inventory, vault, house, pets — private, served
only because your signed-in localpart owns this page's token. -->
<section class="mt-8">
<div class="flex items-center gap-2 mb-3">
<span class="rounded-full bg-theme-adventure text-white text-xs font-semibold px-3 py-1">Your adventurer</span>
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
</div>
<div class="grid gap-6 sm:grid-cols-2">
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Home</h2>
<div class="space-y-1.5 text-sm">
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">House tier</span><span class="font-semibold">{{if .Self.House.Tier}}{{.Self.House.Tier}}{{else}}no house{{end}}</span></div>
{{if .Self.House.LoanBalance}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Loan balance</span><span class="font-semibold">{{.Self.House.LoanBalance}}</span></div>{{end}}
{{if .Self.House.Rate}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Rate</span><span class="font-semibold">{{.Self.House.Rate}}</span></div>{{end}}
{{if .Self.House.Autopay}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Autopay</span><span class="font-semibold">on</span></div>{{end}}
</div>
<h3 class="font-display text-lg font-bold mt-6 mb-3">Pets</h3>
{{if .Self.Pets}}
<ul class="space-y-2 text-sm">
{{range .Self.Pets}}
<li class="flex items-baseline justify-between gap-3">
<span class="font-semibold flex-1">{{if .Name}}{{.Name}}{{else}}your {{.Type}}{{end}} <span class="text-[color:var(--ink)]/45 font-normal">{{.Type}}</span></span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}{{if .ArmorTier}} · barding T{{.ArmorTier}}{{end}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">No pets right now.</p>
{{end}}
</div>
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Backpack</h2>
{{if .Self.Inventory}}
<ul class="space-y-1.5 text-sm max-h-72 overflow-y-auto pr-1">
{{range .Self.Inventory}}
<li class="flex items-baseline justify-between gap-3">
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">Backpack's empty.</p>
{{end}}
{{if .Self.Vault}}
<h3 class="font-display text-lg font-bold mt-6 mb-3">Vault</h3>
<ul class="space-y-1.5 text-sm max-h-52 overflow-y-auto pr-1">
{{range .Self.Vault}}
<li class="flex items-baseline justify-between gap-3">
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
</li>
{{end}}
</ul>
{{end}}
</div>
</div>
</section>
{{end}}
</article>
<script>
// The sheet is state, like the board: keep an open tab honest without a reload.
// Public detail only (HP, room, supplies) — the private panels stay as rendered.
(function () {
var root = document.getElementById('who');
if (!root) return;
var token = root.getAttribute('data-token');
function setBar() {
var el = document.getElementById('who-hp-bar');
var hp = document.getElementById('who-hp');
if (!el || !hp) return;
var m = hp.textContent.match(/(-?\d+)\s*\/\s*(\d+)/);
if (!m) return;
var cur = parseInt(m[1], 10), max = parseInt(m[2], 10);
var pct = max > 0 ? Math.max(0, Math.min(100, Math.round(cur / max * 100))) : 0;
el.style.width = pct + '%';
}
setBar();
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
var timer = null;
function refresh() {
fetch('/api/adventure/who/' + encodeURIComponent(token), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
if (!data.live) {
// Off the board entirely (expedition ended, opted out): nothing more to
// poll for, so stop the timer rather than hammer a token that's gone.
txt('who-where', 'Back in town');
if (timer) clearInterval(timer);
return;
}
var d = data.detail || {};
var mark = data.mark || {};
if (data.has_detail) {
var temp = d.temp_hp ? ' +' + d.temp_hp : '';
txt('who-hp', d.hp_current + ' / ' + d.hp_max + temp);
txt('who-ac', d.armor_class);
txt('who-room', d.room);
txt('who-supplies', d.supplies);
txt('who-threat', d.threat_level);
setBar();
}
// Keep the location line honest in both directions: a mark that came back
// to town while still on the board must stop showing its old expedition.
if (mark.OnRun) {
txt('who-where', mark.Where);
} else {
txt('who-where', 'In town' + (mark.Idle ? ' · ' + mark.Idle : ''));
}
})
.catch(function () { /* transient — next tick will do */ });
}
timer = setInterval(refresh, 60000);
})();
</script>
{{end}}

167
internal/web/who.go Normal file
View File

@@ -0,0 +1,167 @@
package web
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"pete/internal/storage"
)
// The adventurer detail page.
//
// A click-through from the live board: anyone may see a mark's current stats and
// equipped gear (the same anonymity model as the board — a character name and a
// sheet, never a Matrix handle). The signed-in owner sees the same page enriched
// with their private inventory, vault, house, and pets.
//
// Both halves are still gogobee → Pete: the public detail rides the roster
// snapshot, the private detail rides its own push. Pete renders what it was
// given; it never reaches back into the game box.
// whoDetail is the public sheet as gogobee pushed it (RosterEntry.Detail).
type whoDetail struct {
HPCurrent int `json:"hp_current"`
HPMax int `json:"hp_max"`
TempHP int `json:"temp_hp"`
ArmorClass int `json:"armor_class"`
Abilities [6]int `json:"abilities"`
Modifiers [6]int `json:"modifiers"`
Gear []whoGear `json:"gear"`
Supplies int `json:"supplies"`
ThreatLevel int `json:"threat_level"`
Room string `json:"room"`
}
type whoGear struct {
Slot string `json:"slot"`
Name string `json:"name"`
Tier int `json:"tier"`
Condition int `json:"condition"`
Masterwork bool `json:"masterwork"`
}
// abilityRow is one ability line, pre-formatted for the template.
type abilityRow struct {
Label string
Score int
ModStr string // "+2", "-1"
}
var abilityLabels = [6]string{"STR", "DEX", "CON", "INT", "WIS", "CHA"}
type whoPage struct {
pageData
Mark RosterView
HasDetail bool
Detail whoDetail
Abilities []abilityRow
HasSelf bool
Self storage.PlayerDetail
}
// handleAdventureWho serves one adventurer's detail page. Public; 404s when the
// token isn't on the current board — the same liveness gate the storefront uses,
// so a stale or guessed token never resolves to a page.
func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
token := r.PathValue("token")
entry, ok, err := storage.RosterEntryByToken(token)
if err != nil {
slog.Error("who: roster lookup failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
s.track(r, "adventure")
base := s.base(r)
base.Active = "adventure"
base.NoIndex = true // names a player character; keep out of search indexes
page := whoPage{
pageData: base,
Mark: toRosterView(entry),
}
if d, abil, ok := decodeWhoDetail(entry.Detail); ok {
page.HasDetail = true
page.Detail = d
page.Abilities = abil
}
// Owner enrichment: only when the signed-in user's localpart owns this exact
// page token. The ownership is proven by a row gogobee pushed, never by
// reversing the token, so no visitor can unlock another player's self extras.
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
page.HasSelf = true
page.Self = self
}
}
}
s.render(w, "who", page)
}
// handleAdventureWhoAPI serves the public detail as JSON for the page's live
// re-poll, so an open tab tracks HP / room / supplies as they move. Public — the
// same exposure as the rendered page. The private self extras are not included:
// inventory changes rarely and stays server-rendered behind the ownership check.
func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
token := r.PathValue("token")
entry, ok, err := storage.RosterEntryByToken(token)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if !ok {
// Gone from the board (expedition ended, opted out): tell the poller so it
// can stop, rather than 404-ing an open tab into an error.
_ = json.NewEncoder(w).Encode(map[string]any{"live": false})
return
}
d, abil, hasDetail := decodeWhoDetail(entry.Detail)
_ = json.NewEncoder(w).Encode(map[string]any{
"live": true,
"mark": toRosterView(entry),
"has_detail": hasDetail,
"detail": d,
"abilities": abil,
})
}
// decodeWhoDetail unpacks the public detail blob and builds the ability rows.
// Returns ok=false when there is no detail (a snapshot from before the detail
// push), so the page can fall back to the summary the board already has.
func decodeWhoDetail(raw json.RawMessage) (whoDetail, []abilityRow, bool) {
if len(raw) == 0 {
return whoDetail{}, nil, false
}
var d whoDetail
if err := json.Unmarshal(raw, &d); err != nil {
return whoDetail{}, nil, false
}
rows := make([]abilityRow, 0, 6)
for i := 0; i < 6; i++ {
rows = append(rows, abilityRow{
Label: abilityLabels[i],
Score: d.Abilities[i],
ModStr: fmt.Sprintf("%+d", d.Modifiers[i]),
})
}
return d, rows, true
}

199
internal/web/who_test.go Normal file
View File

@@ -0,0 +1,199 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"pete/internal/storage"
)
// The adventurer detail page. Two contracts under test: the public sheet reaches
// any visitor (stats + gear, never a handle), and the private self-view unlocks
// only for the signed-in owner of that exact page — anon and other users see the
// public page and nothing more.
func postDetail(t *testing.T, s *Server, token string, push detailPush) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(push)
req := httptest.NewRequest("POST", "/api/ingest/detail", bytes.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
s.handleDetailIngest(w, req)
return w
}
// publicDetail is the sheet gogobee hangs on a roster entry, marshalled the way
// the roster push carries it.
func publicDetail(t *testing.T) json.RawMessage {
t.Helper()
raw, err := json.Marshal(map[string]any{
"hp_current": 30,
"hp_max": 42,
"temp_hp": 5,
"armor_class": 17,
"abilities": [6]int{16, 14, 15, 10, 12, 8},
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
"gear": []map[string]any{
{"slot": "weapon", "name": "Rusty Sword", "tier": 0, "condition": 100},
{"slot": "armor", "name": "Padded Vest", "tier": 0, "condition": 100},
},
"supplies": 8,
"threat_level": 2,
"room": "3 / 7",
})
if err != nil {
t.Fatal(err)
}
return raw
}
// seedWho puts one adventurer on the board with a public detail sheet, and
// pushes a private self-detail set owned by `owner` for that same token.
func seedWho(t *testing.T, owner string) *Server {
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
now := time.Now().Unix()
e := entry("tok-josie", "Josie", "expedition", "holymachina")
e.Level = 14
e.ClassRace = "human cleric"
e.Detail = publicDetail(t)
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{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
}}}); w.Code != 200 {
t.Fatalf("seed detail = %d", w.Code)
}
return s
}
func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder {
t.Helper()
var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil)
req.SetPathValue("token", token)
if asUser != "" {
payload, _ := json.Marshal(SessionUser{Sub: "sub-1", Username: asUser, Exp: time.Now().Add(time.Hour).Unix()})
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
}
w := httptest.NewRecorder()
s.handleAdventureWho(w, req)
return w
}
// TestWhoPublicSheet: any visitor sees the mark's stats and gear — driven
// through the real template, so a field slip 500s here rather than in prod.
func TestWhoPublicSheet(t *testing.T) {
s := seedWho(t, "josie")
w := getWho(t, s, "tok-josie", "") // anonymous
if w.Code != 200 {
t.Fatalf("GET who = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Josie", "human cleric", "Rusty Sword", "Padded Vest"} {
if !strings.Contains(body, want) {
t.Errorf("public page missing %q", want)
}
}
// The private goods must NOT be on an anonymous render.
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
if strings.Contains(body, leak) {
t.Errorf("anonymous page leaked private detail %q", leak)
}
}
}
// TestWhoSelfUnlock: the owner, signed in on their own page, sees the private
// inventory/vault/house/pets inline.
func TestWhoSelfUnlock(t *testing.T) {
s := seedWho(t, "josie")
w := getWho(t, s, "tok-josie", "josie")
if w.Code != 200 {
t.Fatalf("owner GET who = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
if !strings.Contains(body, want) {
t.Errorf("owner page missing private detail %q", want)
}
}
}
// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
// username; the localpart it maps to is lowercase. The owner must still unlock.
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {
s := seedWho(t, "josie")
w := getWho(t, s, "tok-josie", "Josie") // capital-J session
if !strings.Contains(w.Body.String(), "Iron Ore") {
t.Error("a mixed-case owner username failed to unlock their own self-view")
}
}
// TestWhoOtherUserNoUnlock: a different signed-in player sees only the public
// sheet on someone else's page — the ownership join must not leak across users.
func TestWhoOtherUserNoUnlock(t *testing.T) {
s := seedWho(t, "josie")
w := getWho(t, s, "tok-josie", "quack") // signed in, but not the owner
if w.Code != 200 {
t.Fatalf("other-user GET who = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Josie") {
t.Error("public sheet missing for a signed-in non-owner")
}
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
if strings.Contains(body, leak) {
t.Errorf("a non-owner unlocked private detail %q", leak)
}
}
}
// TestWhoOffBoardIs404: a token that isn't on the current board resolves to
// nothing — the same liveness gate the storefront uses, so a stale or guessed
// token never renders a page.
func TestWhoOffBoardIs404(t *testing.T) {
s := seedWho(t, "josie")
if w := getWho(t, s, "tok-ghost", ""); w.Code != 404 {
t.Errorf("off-board token = %d, want 404", w.Code)
}
}
// TestDetailIngestReplacesAndAuth: the detail push is bearer-gated and swaps the
// set whole, so a player dropped from a later push loses their self-view.
func TestDetailIngestReplacesAndAuth(t *testing.T) {
s := seedWho(t, "josie")
if w := postDetail(t, s, "wrong", detailPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 {
t.Errorf("bad bearer = %d, want 401", w.Code)
}
// A later push without Josie: her self-view must be gone, but the public
// board (a separate keyspace) is untouched, so her page still renders public.
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: time.Now().Unix() + 60}); w.Code != 200 {
t.Fatalf("empty detail push = %d, want 200", w.Code)
}
body := getWho(t, s, "tok-josie", "josie").Body.String()
if strings.Contains(body, "Iron Ore") {
t.Error("owner still unlocked after her detail was dropped from the push")
}
if !strings.Contains(body, "Josie") {
t.Error("public page vanished when only the private detail was dropped")
}
}