7 Commits

Author SHA1 Message Date
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
36 changed files with 2391 additions and 23 deletions

View File

@@ -97,6 +97,10 @@ func runMigrations(d *sql.DB) error {
// Occupancy of a shared table. Rows written before the casino went multiplayer
// are solo games and read as NULL, which is exactly what they are.
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT")
// The public detail sheet (stats + equipped gear) for an adventurer's
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
// gogobee build that predates the detail page.
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
// 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 (
"database/sql"
"encoding/json"
"log/slog"
)
@@ -18,6 +19,10 @@ type RosterEntry struct {
Day int `json:"day,omitempty"`
IdleHours int `json:"idle_hours,omitempty"`
SnapshotAt int64 `json:"snapshot_at"`
// Detail is the public expanded sheet (stats + gear), carried as raw JSON so
// the board path never has to model or touch it — only the detail page decodes
// it. Empty when a snapshot predates the detail push.
Detail json.RawMessage `json:"detail,omitempty"`
}
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
@@ -38,16 +43,20 @@ func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
}
stmt, err := tx.Prepare(`
INSERT INTO adventure_roster
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at, detail_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, e := range entries {
var detail any // NULL when absent, so the column reads as "no detail", not "{}"
if len(e.Detail) > 0 {
detail = string(e.Detail)
}
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil {
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt, detail); err != nil {
return err
}
}
@@ -91,6 +100,31 @@ func LoadRoster() ([]RosterEntry, int64, error) {
return out, RosterSnapshotAt(), nil
}
// RosterEntryByToken looks up one adventurer by their roster token. The bool is
// false when no such token is on the current board — which is exactly the check
// the storefront needs: a buyer may only order a hit on a mark the live board is
// actually showing, never a stale or guessed token.
func RosterEntryByToken(token string) (RosterEntry, bool, error) {
var e RosterEntry
var detail sql.NullString
err := Get().QueryRow(`
SELECT token, name, level, COALESCE(class_race, ''), status,
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at, detail_json
FROM adventure_roster WHERE token = ?`, token).Scan(
&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt, &detail)
if err == sql.ErrNoRows {
return RosterEntry{}, false, nil
}
if err != nil {
return RosterEntry{}, false, err
}
if detail.Valid && detail.String != "" {
e.Detail = json.RawMessage(detail.String)
}
return e, true, nil
}
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
// never pushed one. Read from the meta row, not the entries, so a snapshot that
// legitimately carried nobody still counts as a snapshot.

View File

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

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.
//
// 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 {
SnapshotAt int64 `json:"snapshot_at"`
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.
//
// 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 {
Token string
Name string
Level int
ClassRace string
@@ -103,7 +116,66 @@ func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
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)
}
@@ -142,6 +214,7 @@ func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
func toRosterView(e storage.RosterEntry) RosterView {
v := RosterView{
Token: e.Token,
Name: e.Name,
Level: e.Level,
ClassRace: e.ClassRace,

View File

@@ -102,7 +102,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
shared []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"}},
}
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("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).
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
// 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/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
// 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.
@@ -254,6 +273,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("GET /api/state", s.handleState)
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
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 {
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
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.

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,18 @@
// The noise the room makes.
//
// There are no audio files here and there is nothing to download. Every sound in
// this casino is *made* — an oscillator, a burst of filtered noise, an envelope —
// the same bargain the weather engine takes with its clouds. A card is a short
// slap of noise through a bandpass; a chip is two detuned sines with a click on
// the front; a win is four notes going up. It costs about six kilobytes and no
// round trips, and it means a sound can be pitched, stretched and detuned per
// call instead of being the same wav 300 times.
// The room speaks in two voices. The cards and chips are *recorded* — real clay
// and real card stock, CC0 foley from Kenney's casino pack, sitting as small ogg
// files under /static/audio/casino. Synthesis never quite caught the grain of a
// chip landing on a chip, so for those we stopped trying. Everything melodic —
// the wins, the losses, the little ticks — is still *made*: an oscillator, a
// burst of filtered noise, an envelope, the same bargain the weather engine takes
// 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.
//
@@ -21,6 +27,10 @@
// default-off switch for the entire file, checked before anything else happens.
//
// 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 () {
"use strict";
@@ -108,6 +118,95 @@
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 ------------------------------------------------------------
//
// Each one takes the time it starts at, and a `v` — a small per-call variation,
@@ -217,6 +316,7 @@
// gesture — after which play() can schedule freely.
function wake() {
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
warm();
}
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
window.addEventListener(ev, wake, { passive: true });
@@ -231,13 +331,18 @@
// lands in 400ms can say so rather than sleeping.
function play(name, opts) {
if (muted) return;
var s = SOUNDS[name];
if (!s) return;
if (!SOUNDS[name] && !SAMPLES[name]) return; // known to neither voice
if (!boot()) return;
wake();
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 {
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) {
/* a sound is never worth throwing over */
}

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">
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
{{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="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="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}}
</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>
{{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>
{{end}}
</ul>
</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>
<script>
@@ -49,6 +92,8 @@
var card = document.getElementById('roster-card');
if (!list) return;
var signedIn = {{if .User}}true{{else}}false{{end}};
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
@@ -57,12 +102,15 @@
function row(a) {
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="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="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() {
@@ -84,7 +132,157 @@
document.addEventListener('visibilitychange', function () {
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>
{{end}}

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