Compare commits
7 Commits
983748ea98
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
790a118273 | ||
|
|
8df2212aad | ||
|
|
1ca794ea1a | ||
|
|
30b0e8debb | ||
|
|
4189c03a82 | ||
|
|
16711e13e6 | ||
|
|
2ac6ec6b91 |
@@ -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
131
internal/storage/detail.go
Normal 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
|
||||
}
|
||||
126
internal/storage/detail_test.go
Normal file
126
internal/storage/detail_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
335
internal/storage/mischief.go
Normal file
335
internal/storage/mischief.go
Normal 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
|
||||
}
|
||||
178
internal/storage/mischief_test.go
Normal file
178
internal/storage/mischief_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
262
internal/web/mischief.go
Normal 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})
|
||||
}
|
||||
222
internal/web/mischief_test.go
Normal file
222
internal/web/mischief_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
internal/web/static/audio/casino/LICENSE.txt
Normal file
21
internal/web/static/audio/casino/LICENSE.txt
Normal 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
|
||||
BIN
internal/web/static/audio/casino/cardFan1.ogg
Normal file
BIN
internal/web/static/audio/casino/cardFan1.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardFan2.ogg
Normal file
BIN
internal/web/static/audio/casino/cardFan2.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardPlace1.ogg
Normal file
BIN
internal/web/static/audio/casino/cardPlace1.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardPlace2.ogg
Normal file
BIN
internal/web/static/audio/casino/cardPlace2.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardPlace3.ogg
Normal file
BIN
internal/web/static/audio/casino/cardPlace3.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardPlace4.ogg
Normal file
BIN
internal/web/static/audio/casino/cardPlace4.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardShuffle.ogg
Normal file
BIN
internal/web/static/audio/casino/cardShuffle.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide1.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide1.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide2.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide2.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide3.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide3.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide4.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide4.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide5.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide5.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide6.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide6.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/chipLay1.ogg
Normal file
BIN
internal/web/static/audio/casino/chipLay1.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/chipLay2.ogg
Normal file
BIN
internal/web/static/audio/casino/chipLay2.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/chipsHandle2.ogg
Normal file
BIN
internal/web/static/audio/casino/chipsHandle2.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/chipsHandle4.ogg
Normal file
BIN
internal/web/static/audio/casino/chipsHandle4.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/chipsHandle6.ogg
Normal file
BIN
internal/web/static/audio/casino/chipsHandle6.ogg
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
207
internal/web/templates/who.html
Normal file
207
internal/web/templates/who.html
Normal 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
167
internal/web/who.go
Normal 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
199
internal/web/who_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user