Compare commits
11 Commits
e5af5326d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b814f936a8 | ||
|
|
8504c4f47a | ||
|
|
e0d90ff7cc | ||
|
|
790a118273 | ||
|
|
8df2212aad | ||
|
|
1ca794ea1a | ||
|
|
30b0e8debb | ||
|
|
4189c03a82 | ||
|
|
16711e13e6 | ||
|
|
2ac6ec6b91 | ||
|
|
983748ea98 |
@@ -620,6 +620,31 @@ func (s State) Net() int64 {
|
|||||||
// cleared reports whether every card is home.
|
// cleared reports whether every card is home.
|
||||||
func (s State) cleared() bool { return s.Home() == FullDeck }
|
func (s State) cleared() bool { return s.Home() == FullDeck }
|
||||||
|
|
||||||
|
// Won reports that the board is a guaranteed clear: nothing left in the stock or
|
||||||
|
// waste, and not a single face-down card under any column. From here every card
|
||||||
|
// is a face-up run and a single auto() drains the whole board to the foundations
|
||||||
|
// without a choice left to make — which is exactly the tedious tail the felt
|
||||||
|
// should offer to finish in one gesture instead of thirty double-clicks.
|
||||||
|
//
|
||||||
|
// It's deliberately narrower than "solvable": a draw-one board with cards still
|
||||||
|
// in the stock is winnable too, but auto() won't turn the stock over, so calling
|
||||||
|
// that won would light a finish button that then stalls. This is the state the
|
||||||
|
// finish button actually finishes.
|
||||||
|
func (s State) Won() bool {
|
||||||
|
if s.Phase == PhaseDone || s.cleared() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(s.Stock) > 0 || len(s.Waste) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range s.Table {
|
||||||
|
if len(p.Down) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// CanAuto reports whether anything can go home at all — which is what greys the
|
// CanAuto reports whether anything can go home at all — which is what greys the
|
||||||
// finish button out rather than letting it be pressed at a board that has nothing
|
// finish button out rather than letting it be pressed at a board that has nothing
|
||||||
// for it.
|
// for it.
|
||||||
|
|||||||
@@ -423,6 +423,52 @@ func TestAutoSendsEverythingItCanHome(t *testing.T) {
|
|||||||
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
|
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Won is the state the finish button finishes: every card face up, the stock and
|
||||||
|
// waste drained, so a single auto sweeps the lot home. The button is only honest
|
||||||
|
// if these two agree — Won lighting up on a board auto can't clear would stall.
|
||||||
|
func TestWonIsExactlyWhatAutoCanFinish(t *testing.T) {
|
||||||
|
s := board(patient(), 5200)
|
||||||
|
// A won board: three short face-up runs across the columns, nothing face down,
|
||||||
|
// nothing in the stock or waste. Every card is reachable.
|
||||||
|
s.Table[0].Up = []cards.Card{card(2, cards.Spades), card(cards.Ace, cards.Hearts)}
|
||||||
|
s.Table[1].Up = []cards.Card{card(2, cards.Hearts), card(cards.Ace, cards.Spades)}
|
||||||
|
s.Table[2].Up = []cards.Card{card(2, cards.Diamonds), card(cards.Ace, cards.Clubs)}
|
||||||
|
s.Table[3].Up = []cards.Card{card(2, cards.Clubs), card(cards.Ace, cards.Diamonds)}
|
||||||
|
|
||||||
|
if !s.Won() {
|
||||||
|
t.Fatalf("a board with everything face up and the piles empty is won")
|
||||||
|
}
|
||||||
|
|
||||||
|
next, _ := apply(t, s, Move{Kind: "auto"})
|
||||||
|
if next.Home() != 8 { // the eight cards laid out above
|
||||||
|
t.Fatalf("auto homed %d cards, want 8 — the whole won board", next.Home())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything still hidden or still in a pile is not won: auto would stall on it.
|
||||||
|
withStock := s.clone()
|
||||||
|
withStock.Stock = cards.Deck{card(5, cards.Spades)}
|
||||||
|
if withStock.Won() {
|
||||||
|
t.Errorf("a board with a card still in the stock is not won — auto won't turn it over")
|
||||||
|
}
|
||||||
|
withWaste := s.clone()
|
||||||
|
withWaste.Waste = []cards.Card{card(5, cards.Spades)}
|
||||||
|
if withWaste.Won() {
|
||||||
|
t.Errorf("a board with a card still in the waste is not won")
|
||||||
|
}
|
||||||
|
withDown := s.clone()
|
||||||
|
withDown.Table[5].Down = []cards.Card{card(5, cards.Spades)}
|
||||||
|
if withDown.Won() {
|
||||||
|
t.Errorf("a board with a face-down card is not won")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A finished board is never won: the button belongs to a game still in play.
|
||||||
|
cleared := s.clone()
|
||||||
|
cleared.Phase = PhaseDone
|
||||||
|
if cleared.Won() {
|
||||||
|
t.Errorf("a settled board reports won")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- the money -------------------------------------------------------------
|
// ---- the money -------------------------------------------------------------
|
||||||
|
|
||||||
// The number the felt quotes while you play and the number settle() lands on are
|
// The number the felt quotes while you play and the number settle() lands on are
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// Occupancy of a shared table. Rows written before the casino went multiplayer
|
// Occupancy of a shared table. Rows written before the casino went multiplayer
|
||||||
// are solo games and read as NULL, which is exactly what they are.
|
// are solo games and read as NULL, which is exactly what they are.
|
||||||
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT")
|
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT")
|
||||||
|
// The public detail sheet (stats + equipped gear) for an adventurer's
|
||||||
|
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
|
||||||
|
// gogobee build that predates the detail page.
|
||||||
|
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
|
||||||
|
|
||||||
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
||||||
// Check sqlite_master before creating.
|
// Check sqlite_master before creating.
|
||||||
|
|||||||
131
internal/storage/detail.go
Normal file
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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ type RosterEntry struct {
|
|||||||
Day int `json:"day,omitempty"`
|
Day int `json:"day,omitempty"`
|
||||||
IdleHours int `json:"idle_hours,omitempty"`
|
IdleHours int `json:"idle_hours,omitempty"`
|
||||||
SnapshotAt int64 `json:"snapshot_at"`
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
// Detail is the public expanded sheet (stats + gear), carried as raw JSON so
|
||||||
|
// the board path never has to model or touch it — only the detail page decodes
|
||||||
|
// it. Empty when a snapshot predates the detail push.
|
||||||
|
Detail json.RawMessage `json:"detail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
||||||
@@ -38,16 +43,20 @@ func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
|
|||||||
}
|
}
|
||||||
stmt, err := tx.Prepare(`
|
stmt, err := tx.Prepare(`
|
||||||
INSERT INTO adventure_roster
|
INSERT INTO adventure_roster
|
||||||
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
|
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at, detail_json)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
var detail any // NULL when absent, so the column reads as "no detail", not "{}"
|
||||||
|
if len(e.Detail) > 0 {
|
||||||
|
detail = string(e.Detail)
|
||||||
|
}
|
||||||
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
||||||
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil {
|
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt, detail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +100,31 @@ func LoadRoster() ([]RosterEntry, int64, error) {
|
|||||||
return out, RosterSnapshotAt(), nil
|
return out, RosterSnapshotAt(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RosterEntryByToken looks up one adventurer by their roster token. The bool is
|
||||||
|
// false when no such token is on the current board — which is exactly the check
|
||||||
|
// the storefront needs: a buyer may only order a hit on a mark the live board is
|
||||||
|
// actually showing, never a stale or guessed token.
|
||||||
|
func RosterEntryByToken(token string) (RosterEntry, bool, error) {
|
||||||
|
var e RosterEntry
|
||||||
|
var detail sql.NullString
|
||||||
|
err := Get().QueryRow(`
|
||||||
|
SELECT token, name, level, COALESCE(class_race, ''), status,
|
||||||
|
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at, detail_json
|
||||||
|
FROM adventure_roster WHERE token = ?`, token).Scan(
|
||||||
|
&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
||||||
|
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt, &detail)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return RosterEntry{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return RosterEntry{}, false, err
|
||||||
|
}
|
||||||
|
if detail.Valid && detail.String != "" {
|
||||||
|
e.Detail = json.RawMessage(detail.String)
|
||||||
|
}
|
||||||
|
return e, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
|
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
|
||||||
// never pushed one. Read from the meta row, not the entries, so a snapshot that
|
// never pushed one. Read from the meta row, not the entries, so a snapshot that
|
||||||
// legitimately carried nobody still counts as a snapshot.
|
// legitimately carried nobody still counts as a snapshot.
|
||||||
|
|||||||
@@ -52,6 +52,83 @@ CREATE TABLE IF NOT EXISTS adventure_roster_meta (
|
|||||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
|
||||||
|
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
|
||||||
|
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
||||||
|
-- ever read for the one authenticated user asking about themselves, so the board
|
||||||
|
-- stays anonymous and no endpoint hands out anyone else's number. Advisory only —
|
||||||
|
-- the storefront greys out tiers it thinks you can't afford, but the real debit
|
||||||
|
-- happens on gogobee at claim time and a stale balance just bounces an order.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_euro (
|
||||||
|
username TEXT PRIMARY KEY, -- Matrix localpart == session Username
|
||||||
|
euro REAL NOT NULL DEFAULT 0,
|
||||||
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A mischief contract a buyer placed from the web storefront, on its way to
|
||||||
|
-- gogobee. Pete never touches money and never runs the game rules — it only
|
||||||
|
-- records the *intent* and later the verdict gogobee hands back. The status
|
||||||
|
-- ladder:
|
||||||
|
--
|
||||||
|
-- pending -> placed (gogobee debited the buyer and opened a contract)
|
||||||
|
-- -> bounced_funds (buyer couldn't actually afford it)
|
||||||
|
-- -> bounced_ineligible (target no longer a valid mark: no expedition,
|
||||||
|
-- a live contract already, cooldown, cap, ...)
|
||||||
|
--
|
||||||
|
-- guid is the idempotency key end to end: gogobee passes it to DebitIdem and
|
||||||
|
-- stamps it on the contract, so a claim whose ack is lost on the wire can be
|
||||||
|
-- retried without charging the buyer twice or opening two contracts. buyer_sub
|
||||||
|
-- is the OIDC subject (stable across username changes) and keys "my orders";
|
||||||
|
-- buyer_username is what gogobee turns into @username:server. target_token is
|
||||||
|
-- the roster token of the mark — the same anonymous token the board renders, so
|
||||||
|
-- ordering a hit never needs the victim's real handle.
|
||||||
|
CREATE TABLE IF NOT EXISTS mischief_orders (
|
||||||
|
guid TEXT PRIMARY KEY,
|
||||||
|
buyer_sub TEXT NOT NULL,
|
||||||
|
buyer_username TEXT NOT NULL,
|
||||||
|
target_token TEXT NOT NULL,
|
||||||
|
target_name TEXT NOT NULL, -- display copy, frozen at order time
|
||||||
|
tier TEXT NOT NULL,
|
||||||
|
signed INTEGER NOT NULL DEFAULT 0, -- 1 = sign openly (+25%), 0 = anonymous
|
||||||
|
status TEXT NOT NULL, -- see the ladder above
|
||||||
|
detail TEXT, -- gogobee's human note on the verdict
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mischief_orders_pending ON mischief_orders(status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mischief_orders_buyer ON mischief_orders(buyer_sub, created_at DESC);
|
||||||
|
|
||||||
|
-- The storefront price list. gogobee is the sole authority on prices and pushes
|
||||||
|
-- the whole catalog on the roster tick, so a fee retune reaches the storefront
|
||||||
|
-- within a snapshot and Pete never hardcodes a number that can drift. ordinal
|
||||||
|
-- preserves the grunt->boss order the push arrived in.
|
||||||
|
CREATE TABLE IF NOT EXISTS mischief_tiers (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
display TEXT NOT NULL,
|
||||||
|
fee INTEGER NOT NULL,
|
||||||
|
signed_fee INTEGER NOT NULL,
|
||||||
|
blurb TEXT,
|
||||||
|
ordinal INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
||||||
|
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
||||||
|
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
||||||
|
-- like user_euro, it is only ever served back to the one authenticated user it
|
||||||
|
-- belongs to, never on the public board. token is that player's current roster
|
||||||
|
-- token, kept here so the detail page can prove owner↔page by a join without
|
||||||
|
-- ever reversing the one-way token — the association lives only in this
|
||||||
|
-- owner-private table and never reaches any public response. detail_json is the
|
||||||
|
-- {inventory, vault, house, pets} body; it is replaced wholesale each tick, so a
|
||||||
|
-- player who drops out of gogobee's push loses their stale self-view.
|
||||||
|
CREATE TABLE IF NOT EXISTS player_self_detail (
|
||||||
|
localpart TEXT PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
detail_json TEXT NOT NULL,
|
||||||
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS post_log (
|
CREATE TABLE IF NOT EXISTS post_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
guid TEXT NOT NULL,
|
guid TEXT NOT NULL,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type solitaireView struct {
|
|||||||
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
|
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
|
||||||
Moves int `json:"moves"`
|
Moves int `json:"moves"`
|
||||||
CanAuto bool `json:"can_auto"`
|
CanAuto bool `json:"can_auto"`
|
||||||
|
Won bool `json:"won"` // every card face up, stock and waste empty: one press finishes it
|
||||||
|
|
||||||
Home int `json:"home"` // cards on the foundations
|
Home int `json:"home"` // cards on the foundations
|
||||||
PerCard float64 `json:"per_card"` // what one more is worth
|
PerCard float64 `json:"per_card"` // what one more is worth
|
||||||
@@ -86,6 +87,7 @@ func viewSolitaire(g klondike.State) solitaireView {
|
|||||||
Passes: g.PassesLeft(),
|
Passes: g.PassesLeft(),
|
||||||
Moves: g.Moves,
|
Moves: g.Moves,
|
||||||
CanAuto: g.CanAuto(),
|
CanAuto: g.CanAuto(),
|
||||||
|
Won: g.Won(),
|
||||||
Home: g.Home(),
|
Home: g.Home(),
|
||||||
PerCard: g.PerCard(),
|
PerCard: g.PerCard(),
|
||||||
BreakEven: g.Tier.BreakEven(),
|
BreakEven: g.Tier.BreakEven(),
|
||||||
|
|||||||
262
internal/web/mischief.go
Normal file
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.
|
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
|
||||||
|
//
|
||||||
|
// Balances ride the same tick as the board but live in a separate keyspace: they
|
||||||
|
// are keyed by localpart (a buyer's own sign-in name), not by the anonymous
|
||||||
|
// roster token, and Pete only ever reads one back for the authenticated user
|
||||||
|
// asking about themselves. So the board stays anonymous while the storefront can
|
||||||
|
// still grey out tiers a buyer plainly can't afford.
|
||||||
type rosterPush struct {
|
type rosterPush struct {
|
||||||
SnapshotAt int64 `json:"snapshot_at"`
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
Adventurers []storage.RosterEntry `json:"adventurers"`
|
Adventurers []storage.RosterEntry `json:"adventurers"`
|
||||||
|
Balances []storage.MischiefBalance `json:"balances,omitempty"`
|
||||||
|
Tiers []storage.MischiefTier `json:"tiers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RosterView is one row as the page renders it.
|
// RosterView is one row as the page renders it.
|
||||||
|
//
|
||||||
|
// Token is the mark's anonymous roster token, exposed so the storefront can name
|
||||||
|
// a target without ever knowing their real handle. It is safe on a public page by
|
||||||
|
// design — non-reversible, stable, and already how gogobee keys the board.
|
||||||
type RosterView struct {
|
type RosterView struct {
|
||||||
|
Token string
|
||||||
Name string
|
Name string
|
||||||
Level int
|
Level int
|
||||||
ClassRace string
|
ClassRace string
|
||||||
@@ -103,7 +116,66 @@ func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers))
|
// Advisory balances are best-effort: a board that landed is worth keeping
|
||||||
|
// even if the balances behind it didn't, so a failure here is logged, not
|
||||||
|
// fatal. Stale affordability only ever bounces an order, never miscounts money.
|
||||||
|
if err := storage.ReplaceUserEuro(push.Balances, push.SnapshotAt); err != nil {
|
||||||
|
slog.Error("roster ingest: balance replace failed", "err", err)
|
||||||
|
}
|
||||||
|
// The tier catalog is likewise best-effort and only refreshed when the push
|
||||||
|
// actually carried one, so a gogobee build that predates the storefront (no
|
||||||
|
// tiers field) can't wipe a catalog a newer one already established.
|
||||||
|
if len(push.Tiers) > 0 {
|
||||||
|
if err := storage.ReplaceMischiefTiers(push.Tiers); err != nil {
|
||||||
|
slog.Error("roster ingest: tier replace failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers), "balances", len(push.Balances))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailPush is the payload gogobee POSTs to /api/ingest/detail: the private,
|
||||||
|
// owner-only expansion for every player, in its own keyspace (keyed by localpart)
|
||||||
|
// and on its own endpoint so a fat inventory can't blow the roster push's budget.
|
||||||
|
type detailPush struct {
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
Players []storage.PlayerDetail `json:"players"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDetailIngest replaces the private self-detail set with gogobee's latest
|
||||||
|
// push. Bearer-authed like the roster; the data is owner-private and only ever
|
||||||
|
// served back to the one authenticated user it belongs to (see PlayerDetailByOwner).
|
||||||
|
func (s *Server) handleDetailIngest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.adv.Enabled {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A generous cap: this carries every player's inventory + vault, heavier than
|
||||||
|
// the board, but still a bounded realm — the ceiling only stops a runaway push.
|
||||||
|
var push detailPush
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&push); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(push.Players) > rosterMaxEntries {
|
||||||
|
http.Error(w, "detail set too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if push.SnapshotAt <= 0 {
|
||||||
|
push.SnapshotAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.ReplacePlayerDetail(push.Players, push.SnapshotAt); err != nil {
|
||||||
|
slog.Error("detail ingest: replace failed", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("detail ingest: self-view set replaced", "players", len(push.Players))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +214,7 @@ func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
|
|||||||
|
|
||||||
func toRosterView(e storage.RosterEntry) RosterView {
|
func toRosterView(e storage.RosterEntry) RosterView {
|
||||||
v := RosterView{
|
v := RosterView{
|
||||||
|
Token: e.Token,
|
||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
Level: e.Level,
|
Level: e.Level,
|
||||||
ClassRace: e.ClassRace,
|
ClassRace: e.ClassRace,
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
shared []string
|
shared []string
|
||||||
pages []string
|
pages []string
|
||||||
}{
|
}{
|
||||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who"}},
|
||||||
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
|
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
|
||||||
}
|
}
|
||||||
tpls := make(map[string]*template.Template)
|
tpls := make(map[string]*template.Template)
|
||||||
@@ -219,6 +219,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
|
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
|
||||||
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
|
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
|
||||||
|
|
||||||
|
// The private, owner-only self-detail (inventory/house/pets). Bearer-authed
|
||||||
|
// ingest like the roster; there is no public read — it is only ever served
|
||||||
|
// back to its owner, inline on the detail page below.
|
||||||
|
mux.HandleFunc("POST /api/ingest/detail", s.handleDetailIngest)
|
||||||
|
|
||||||
|
// One adventurer's detail page and its live re-poll. Both public (stats +
|
||||||
|
// gear); the page adds the owner's private extras when the signed-in viewer
|
||||||
|
// owns it. Three path segments, so it never overlaps /adventure/{guid} (two)
|
||||||
|
// or /adventure/art/{type}.
|
||||||
|
mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
|
||||||
|
mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)
|
||||||
|
|
||||||
// Per-dispatch permalink (the article_url every ingested story points at).
|
// Per-dispatch permalink (the article_url every ingested story points at).
|
||||||
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
||||||
// channel listing, registered in the channels loop above).
|
// channel listing, registered in the channels loop above).
|
||||||
@@ -236,6 +248,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
|
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
|
||||||
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
|
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
|
||||||
|
|
||||||
|
// The mischief storefront's game-box wire: gogobee polls pending orders and
|
||||||
|
// pushes a verdict. Bearer-authed on the same ingest token, same reason as the
|
||||||
|
// escrow seam — the caller is a machine on the tailnet. The buyer-facing half
|
||||||
|
// hangs off the auth block below.
|
||||||
|
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
|
||||||
|
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
|
||||||
|
|
||||||
// The casino. Signed-in only — there is money in it — so these hang off the
|
// The casino. Signed-in only — there is money in it — so these hang off the
|
||||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||||
@@ -254,6 +273,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("GET /api/state", s.handleState)
|
mux.HandleFunc("GET /api/state", s.handleState)
|
||||||
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
|
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
|
||||||
mux.HandleFunc("GET /for-you", s.handleForYou)
|
mux.HandleFunc("GET /for-you", s.handleForYou)
|
||||||
|
|
||||||
|
// The mischief storefront, buyer side. Signed-in only — an order names a
|
||||||
|
// buyer to gogobee's ledger — so like the casino it hangs off the auth
|
||||||
|
// block. Only registered when the adventure seam is on; otherwise there is
|
||||||
|
// no board to order a hit from.
|
||||||
|
if s.adv.Enabled {
|
||||||
|
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
|
||||||
|
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
|
||||||
|
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
|
||||||
|
}
|
||||||
if s.cfg.Push.Enabled {
|
if s.cfg.Push.Enabled {
|
||||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||||
|
|||||||
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.
@@ -940,6 +940,18 @@ html[data-phase="night"] {
|
|||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Falling money — the win curtain. Positioned entirely by the transform WAAPI
|
||||||
|
drives (see moneyRain in casino-fx.js), so it starts pinned to the top-left. */
|
||||||
|
.pete-money {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 2px rgba(0,0,0,0.35));
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
/* The dealer's beat before they draw out. */
|
/* The dealer's beat before they draw out. */
|
||||||
.pete-dealer-think {
|
.pete-dealer-think {
|
||||||
animation: pete-think 0.9s ease-in-out infinite;
|
animation: pete-think 0.9s ease-in-out infinite;
|
||||||
@@ -1444,6 +1456,14 @@ html[data-phase="night"] {
|
|||||||
100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
|
100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The finish button on a won board breathes, so the one press left to make is
|
||||||
|
the one the eye lands on. */
|
||||||
|
.pete-finish { animation: pete-finish-pulse 1.6s ease-in-out infinite; }
|
||||||
|
@keyframes pete-finish-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(242, 181, 61, 0.55); }
|
||||||
|
50% { box-shadow: 0 0 0 0.9rem rgba(242, 181, 61, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* The rail: the house's rack, what you've banked, the meter. On a wide screen
|
/* The rail: the house's rack, what you've banked, the meter. On a wide screen
|
||||||
it's a column down the right of the felt; on a narrow one it lies down and
|
it's a column down the right of the felt; on a narrow one it lies down and
|
||||||
sits under the board. */
|
sits under the board. */
|
||||||
@@ -1978,6 +1998,93 @@ html[data-phase="night"] {
|
|||||||
}
|
}
|
||||||
.pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; }
|
.pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; }
|
||||||
|
|
||||||
|
/* The send-off. When the mercy rule buries a seat, uno.js rolls one of four
|
||||||
|
of these and drops it over that seat — or over your hand, if it's you going
|
||||||
|
down. Every piece is built fresh and clears itself, so it can't outlive the
|
||||||
|
hand. The wrap is a zero-size anchor at the seat's middle; the pieces hang
|
||||||
|
off it. */
|
||||||
|
.pete-uno-bury {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 45%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: 40;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rockslide: a handful of boulders come down and pile where the seat was. */
|
||||||
|
.pete-uno-rock {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 1px rgba(0,0,0,0.4));
|
||||||
|
animation: pete-bury-rock 0.6s cubic-bezier(0.5, 0, 0.7, 1) both;
|
||||||
|
animation-delay: var(--d, 0ms);
|
||||||
|
}
|
||||||
|
@keyframes pete-bury-rock {
|
||||||
|
0% { transform: translate(calc(-50% + var(--dx)), -3.4rem) rotate(0deg); opacity: 0; }
|
||||||
|
20% { opacity: 1; }
|
||||||
|
75% { transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot)); }
|
||||||
|
85% { transform: translate(calc(-50% + var(--dx)), calc(var(--dy) - 0.22rem)) rotate(var(--rot)); }
|
||||||
|
100% { transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot)); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tombstone and coffin: one heavy thing drops in and sets, with a small bounce. */
|
||||||
|
.pete-uno-tomb,
|
||||||
|
.pete-uno-coffin {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 3px 2px rgba(0,0,0,0.45));
|
||||||
|
animation: pete-bury-drop 0.5s cubic-bezier(0.34, 1.3, 0.64, 1) both;
|
||||||
|
}
|
||||||
|
.pete-uno-tomb { font-size: 2.4rem; }
|
||||||
|
.pete-uno-coffin { font-size: 2.5rem; }
|
||||||
|
@keyframes pete-bury-drop {
|
||||||
|
0% { transform: translate(-50%, -3.2rem) scale(0.7); opacity: 0; }
|
||||||
|
55% { opacity: 1; }
|
||||||
|
70% { transform: translate(-50%, -50%) scale(1.06); }
|
||||||
|
82% { transform: translate(-50%, -44%) scale(1); }
|
||||||
|
100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Mega Man death: a flash, then eight pieces fire out and wink off. */
|
||||||
|
.pete-uno-zap-flash {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
margin: -0.75rem 0 0 -0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: radial-gradient(circle, #fff 0%, rgba(180,220,255,0) 70%);
|
||||||
|
animation: pete-bury-flash 0.35s ease-out both;
|
||||||
|
}
|
||||||
|
@keyframes pete-bury-flash {
|
||||||
|
0% { transform: scale(0.3); opacity: 0.95; }
|
||||||
|
100% { transform: scale(1.9); opacity: 0; }
|
||||||
|
}
|
||||||
|
.pete-uno-zap-bit {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
margin: -0.25rem 0 0 -0.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #cfeaff;
|
||||||
|
box-shadow: 0 0 6px 1px rgba(150,210,255,0.9);
|
||||||
|
animation: pete-bury-zap 0.5s ease-out both;
|
||||||
|
}
|
||||||
|
@keyframes pete-bury-zap {
|
||||||
|
0% { transform: rotate(var(--ang)) translateX(0.2rem); opacity: 1; }
|
||||||
|
100% { transform: rotate(var(--ang)) translateX(3.4rem); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* The rules switch: two dials, and this is the one that isn't the table size. */
|
/* The rules switch: two dials, and this is the one that isn't the table size. */
|
||||||
.pete-seg {
|
.pete-seg {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -2031,7 +2138,8 @@ html[data-phase="night"] {
|
|||||||
.pete-tile-hit,
|
.pete-tile-hit,
|
||||||
.pete-meter[data-hit="1"] { animation: none; }
|
.pete-meter[data-hit="1"] { animation: none; }
|
||||||
.pete-nope,
|
.pete-nope,
|
||||||
.pete-home-flash { animation: none; }
|
.pete-home-flash,
|
||||||
|
.pete-finish { animation: none; }
|
||||||
.pete-card[data-held="1"] { transition: none; }
|
.pete-card[data-held="1"] { transition: none; }
|
||||||
/* The clock still drains — it is information, not decoration — but it stops
|
/* The clock still drains — it is information, not decoration — but it stops
|
||||||
pulsing at you, and a wrong answer stops shaking. */
|
pulsing at you, and a wrong answer stops shaking. */
|
||||||
@@ -2043,6 +2151,9 @@ html[data-phase="night"] {
|
|||||||
.pete-uno-card[data-glow="1"]::before { animation: none; }
|
.pete-uno-card[data-glow="1"]::before { animation: none; }
|
||||||
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; }
|
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; }
|
||||||
.pete-uno-pending { animation: none; }
|
.pete-uno-pending { animation: none; }
|
||||||
|
/* The grave still gets marked — that's information — it just doesn't fall in.
|
||||||
|
uno.js drops a single static headstone here rather than the full rockslide. */
|
||||||
|
.pete-uno-tomb { animation: none; transform: translate(-50%, -50%); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2096,22 +2207,30 @@ html[data-room] .cs-room {
|
|||||||
radial-gradient(120% 90% at 50% 120%, rgba(0,0,0,0.45), transparent 55%);
|
radial-gradient(120% 90% at 50% 120%, rgba(0,0,0,0.45), transparent 55%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Casino Night is lit by bulbs, so it gets a few of them: a slow chase across
|
/* Casino Night is lit by bulbs, so it gets a row of them: a slow chase across
|
||||||
the top of the room. Cheap (one gradient, one transform) and it makes the
|
the top of the room. One radial tile repeated, one transform — each bulb is a
|
||||||
place feel plugged in rather than painted. */
|
hot white core in a yellow body, and the glow is a pair of drop-shadows so it
|
||||||
|
actually throws light rather than being a painted dot. */
|
||||||
html[data-room="casino-night"] .cs-room::before {
|
html[data-room="casino-night"] .cs-room::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 -50% auto -50%;
|
inset: 0.4rem -50% auto -50%;
|
||||||
height: 0.5rem;
|
height: 0.7rem;
|
||||||
background: repeating-linear-gradient(90deg,
|
background:
|
||||||
rgba(255,204,47,0.55) 0 0.5rem, transparent 0.5rem 2.25rem);
|
radial-gradient(circle at center,
|
||||||
filter: blur(1px);
|
#fffbe6 0 0.14rem,
|
||||||
animation: cs-bulbs 2.4s linear infinite;
|
#ffcc2f 0.14rem 0.26rem,
|
||||||
|
rgba(255,204,47,0.45) 0.26rem 0.42rem,
|
||||||
|
transparent 0.44rem)
|
||||||
|
0.3rem 50% / 1.9rem 100% repeat-x;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 3px rgba(255,214,90,0.95))
|
||||||
|
drop-shadow(0 0 10px rgba(255,204,47,0.6));
|
||||||
|
animation: cs-bulbs 1.8s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes cs-bulbs {
|
@keyframes cs-bulbs {
|
||||||
from { transform: translateX(0); }
|
from { transform: translateX(0); }
|
||||||
to { transform: translateX(2.75rem); }
|
to { transform: translateX(1.9rem); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The house shadow, restruck for a dark room: the news app's soft brown lift is
|
/* The house shadow, restruck for a dark room: the news app's soft brown lift is
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -265,13 +265,10 @@
|
|||||||
verdictEl.textContent = text;
|
verdictEl.textContent = text;
|
||||||
verdictEl.classList.remove("hidden");
|
verdictEl.classList.remove("hidden");
|
||||||
|
|
||||||
// The one thing in this room that gets confetti. A natural is rare, it pays
|
// Every win gets it to rain, because a win should feel like one. A natural is
|
||||||
// 3:2, and if everything celebrated then nothing would.
|
// rarer and pays 3:2, so it keeps the confetti on top of the money as well.
|
||||||
//
|
if (v.outcome === "blackjack") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
|
||||||
// The *sound* is not so precious: a win is a win and you should hear it. So
|
else if (v.net > 0) FX.moneyRain();
|
||||||
// the fanfare rides on the money, not on the confetti.
|
|
||||||
if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 });
|
|
||||||
else if (v.net > 0) FX.sfx("win");
|
|
||||||
else if (v.net < 0) FX.sfx("lose");
|
else if (v.net < 0) FX.sfx("lose");
|
||||||
else FX.sfx("push");
|
else FX.sfx("push");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,65 @@
|
|||||||
return Promise.all(done);
|
return Promise.all(done);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// moneyRain: it rains money. The big finish on a win — bills and coins tumbling
|
||||||
|
// the whole height of the window, swaying and spinning as they fall. Louder than
|
||||||
|
// confetti, and it carries its own sound (a coin cascade) so it can stand in for
|
||||||
|
// the plain win fanfare wherever a table decides a win is worth the theatre.
|
||||||
|
//
|
||||||
|
// Like burst, the sound comes before the reduced-motion bail: no rain still
|
||||||
|
// deserves to be heard.
|
||||||
|
function moneyRain(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
if (opts.sound !== false) sfx(opts.sound || "jackpot");
|
||||||
|
if (reduced) return Promise.resolve();
|
||||||
|
|
||||||
|
var n = opts.count || 22;
|
||||||
|
var glyphs = opts.glyphs || ["💶", "💰", "🪙", "💵", "💴", "🤑"];
|
||||||
|
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
var done = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
var bit = document.createElement("div");
|
||||||
|
bit.className = "pete-money";
|
||||||
|
bit.textContent = glyphs[i % glyphs.length];
|
||||||
|
stage().appendChild(bit);
|
||||||
|
|
||||||
|
// Spread across the width in even lanes, each nudged off its lane so the
|
||||||
|
// curtain doesn't read as a grid. It sways sideways and spins on the way down.
|
||||||
|
var x = ((i + 0.5) / n) * vw + jitter(i, vw / n / 2);
|
||||||
|
var drift = jitter(i + 5, 80);
|
||||||
|
var startY = -50 - Math.abs(jitter(i + 2, 180));
|
||||||
|
var spin = jitter(i, 300);
|
||||||
|
var scale = 0.85 + Math.abs(jitter(i + 3, 0.55));
|
||||||
|
|
||||||
|
done.push(
|
||||||
|
bit
|
||||||
|
.animate(
|
||||||
|
[
|
||||||
|
{ transform: t(x, startY, scale, 0), opacity: 0, offset: 0 },
|
||||||
|
{ transform: t(x + drift, startY + 40, scale, spin * 0.2), opacity: 1, offset: 0.1 },
|
||||||
|
{ transform: t(x - drift, vh * 0.55, scale, spin * 0.6), opacity: 1, offset: 0.6 },
|
||||||
|
{ transform: t(x + drift * 0.5, vh + 60, scale, spin), opacity: 1, offset: 1 },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 1600 + Math.abs(jitter(i + 9, 1)) * 900,
|
||||||
|
easing: "cubic-bezier(0.35, 0.15, 0.55, 1)",
|
||||||
|
fill: "both",
|
||||||
|
delay: Math.abs(jitter(i, 320)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.finished.catch(function () {})
|
||||||
|
.then(
|
||||||
|
(function (b) {
|
||||||
|
return function () { b.remove(); };
|
||||||
|
})(bit)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.all(done);
|
||||||
|
}
|
||||||
|
|
||||||
// count rolls a number to a new value instead of swapping it. A chip count that
|
// count rolls a number to a new value instead of swapping it. A chip count that
|
||||||
// jumps is a variable; one that climbs is a payout.
|
// jumps is a variable; one that climbs is a payout.
|
||||||
function count(el, to, opts) {
|
function count(el, to, opts) {
|
||||||
@@ -359,6 +418,7 @@
|
|||||||
flyMany: flyMany,
|
flyMany: flyMany,
|
||||||
spot: spot,
|
spot: spot,
|
||||||
burst: burst,
|
burst: burst,
|
||||||
|
moneyRain: moneyRain,
|
||||||
count: count,
|
count: count,
|
||||||
centre: centre,
|
centre: centre,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
// The noise the room makes.
|
// The noise the room makes.
|
||||||
//
|
//
|
||||||
// There are no audio files here and there is nothing to download. Every sound in
|
// The room speaks in two voices. The cards and chips are *recorded* — real clay
|
||||||
// this casino is *made* — an oscillator, a burst of filtered noise, an envelope —
|
// and real card stock, CC0 foley from Kenney's casino pack, sitting as small ogg
|
||||||
// the same bargain the weather engine takes with its clouds. A card is a short
|
// files under /static/audio/casino. Synthesis never quite caught the grain of a
|
||||||
// slap of noise through a bandpass; a chip is two detuned sines with a click on
|
// chip landing on a chip, so for those we stopped trying. Everything melodic —
|
||||||
// the front; a win is four notes going up. It costs about six kilobytes and no
|
// the wins, the losses, the little ticks — is still *made*: an oscillator, a
|
||||||
// round trips, and it means a sound can be pitched, stretched and detuned per
|
// burst of filtered noise, an envelope, the same bargain the weather engine takes
|
||||||
// call instead of being the same wav 300 times.
|
// with its clouds. A win is four notes going up; a chip is a recording of a chip.
|
||||||
|
//
|
||||||
|
// A name is a recording if it appears in SAMPLES and synthesised if it appears in
|
||||||
|
// SOUNDS. The recordings guard against the one weakness of a sample — the same wav
|
||||||
|
// 300 times is a machine gun — by keeping several takes per sound and rotating
|
||||||
|
// through them, and by nudging every play a few percent off pitch. The synthesised
|
||||||
|
// half was always immune to that; it varies itself.
|
||||||
//
|
//
|
||||||
// Two rules hold the whole file up.
|
// Two rules hold the whole file up.
|
||||||
//
|
//
|
||||||
@@ -21,6 +27,10 @@
|
|||||||
// default-off switch for the entire file, checked before anything else happens.
|
// default-off switch for the entire file, checked before anything else happens.
|
||||||
//
|
//
|
||||||
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
|
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
|
||||||
|
//
|
||||||
|
// If a recording hasn't finished decoding on the frame it's first needed, that one
|
||||||
|
// call falls through to the synthesised version — so the table is never silent
|
||||||
|
// while the ogg loads, and by the second card everything is real.
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -108,6 +118,95 @@
|
|||||||
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
|
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the recordings --------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The foley. Each name maps to a handful of takes; `v` (the index of the card or
|
||||||
|
// chip in a run) picks which one and how far off pitch it lands, so a dealt hand
|
||||||
|
// is four different cards rather than one card four times. `gain` is per-sound
|
||||||
|
// and multiplies the master, exactly like the synthesised sounds' peak does.
|
||||||
|
|
||||||
|
var SAMPLE_BASE = "/static/audio/casino/";
|
||||||
|
var SAMPLES = {
|
||||||
|
// A card thrown down onto the table. `vary` is the largest fraction a take is
|
||||||
|
// ever pitched by, up or down — 0.20 means a play can land anywhere from a fifth
|
||||||
|
// slower (deeper) to a fifth faster (brighter).
|
||||||
|
card: { files: ["cardPlace1", "cardPlace2", "cardPlace3", "cardPlace4"], gain: 0.6, vary: 0.20 },
|
||||||
|
// A card slid into place — softer, longer than a throw.
|
||||||
|
deal: { files: ["cardSlide1", "cardSlide2", "cardSlide3", "cardSlide4", "cardSlide5", "cardSlide6"], gain: 0.5, vary: 0.20 },
|
||||||
|
// A card flicked over.
|
||||||
|
flip: { files: ["cardFan1", "cardFan2"], gain: 0.5, vary: 0.18 },
|
||||||
|
// The riffle — one take, so the pitch wobble is all that keeps two shuffles apart.
|
||||||
|
shuffle: { files: ["cardShuffle"], gain: 0.6, vary: 0.10 },
|
||||||
|
// A clay chip set down on a stack.
|
||||||
|
chip: { files: ["chipLay1", "chipLay2"], gain: 0.6, vary: 0.22 },
|
||||||
|
// Chips gathered and slid away.
|
||||||
|
sweep: { files: ["chipsHandle2", "chipsHandle4", "chipsHandle6"], gain: 0.5, vary: 0.14 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decoded buffers by filename. A value of null means "claimed, in flight or
|
||||||
|
// decoding"; false means "we tried and it failed, never bother again"; an
|
||||||
|
// AudioBuffer means ready.
|
||||||
|
var buffers = {};
|
||||||
|
var warmed = false;
|
||||||
|
|
||||||
|
// A rotating cursor per sound, so that even a run of identical calls walks
|
||||||
|
// through the takes rather than replaying the first one. Callers pass `v` to
|
||||||
|
// separate simultaneous sounds (the cards of one deal), but many pass the same
|
||||||
|
// constant every time — the cursor is what keeps a stack of chips from being the
|
||||||
|
// same click over and over.
|
||||||
|
var turn = {};
|
||||||
|
|
||||||
|
// decode fetches a file and turns it into a buffer, exactly once per filename.
|
||||||
|
// decodeAudioData exists in a modern promise flavour and an old callback one;
|
||||||
|
// this handles both so the foley works on older Safari too.
|
||||||
|
function decode(file) {
|
||||||
|
if (file in buffers) return; // ready, in flight, or known-bad
|
||||||
|
buffers[file] = null;
|
||||||
|
fetch(SAMPLE_BASE + file + ".ogg")
|
||||||
|
.then(function (r) { return r.ok ? r.arrayBuffer() : Promise.reject(); })
|
||||||
|
.then(function (ab) {
|
||||||
|
return new Promise(function (res, rej) {
|
||||||
|
var p = ctx.decodeAudioData(ab, res, rej);
|
||||||
|
if (p && p.then) p.then(res, rej);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (buf) { buffers[file] = buf; })
|
||||||
|
.catch(function () { buffers[file] = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// warm pulls every take down once the context is awake, so that after the first
|
||||||
|
// click the whole set is decoded and ready and nothing has to fall back.
|
||||||
|
function warm() {
|
||||||
|
if (warmed || !ctx) return;
|
||||||
|
warmed = true;
|
||||||
|
for (var name in SAMPLES) SAMPLES[name].files.forEach(decode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sample plays one recorded take. It returns false — rather than making a noise —
|
||||||
|
// when the name isn't a recording or its buffer isn't decoded yet, which is the
|
||||||
|
// caller's signal to reach for the synthesised version instead.
|
||||||
|
function sample(name, t0, v) {
|
||||||
|
var cfg = SAMPLES[name];
|
||||||
|
if (!cfg) return false;
|
||||||
|
// Advance the cursor, then offset it by v. The cursor guarantees consecutive
|
||||||
|
// calls move to the next take; v spreads apart sounds fired together (one
|
||||||
|
// deal's cards) so they don't all land on the same take at once.
|
||||||
|
var k = (turn[name] = (turn[name] || 0) + 1) + Math.abs(Math.round(v));
|
||||||
|
var buf = buffers[cfg.files[k % cfg.files.length]];
|
||||||
|
if (!buf) return false; // not decoded yet, or failed: let synth cover it
|
||||||
|
var src = ctx.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
// Pitch, walking with the same cursor across seven steps between -vary and
|
||||||
|
// +vary, so even a single-take sound like the shuffle isn't identical twice in
|
||||||
|
// a row. Seven steps rather than a couple means the runs don't read as a loop.
|
||||||
|
if (cfg.vary) src.playbackRate.value = 1 + (((k % 7) - 3) / 3) * cfg.vary;
|
||||||
|
var g = ctx.createGain();
|
||||||
|
g.gain.value = cfg.gain == null ? 0.6 : cfg.gain;
|
||||||
|
src.connect(g).connect(master);
|
||||||
|
src.start(t0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- the sounds ------------------------------------------------------------
|
// ---- the sounds ------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Each one takes the time it starts at, and a `v` — a small per-call variation,
|
// Each one takes the time it starts at, and a `v` — a small per-call variation,
|
||||||
@@ -165,6 +264,17 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// It rains money. A bright run of coins spilling out over the win fanfare —
|
||||||
|
// ten metallic pings climbing a pentatonic scale, each with a tick on the
|
||||||
|
// front of it so it lands as coin rather than as bell.
|
||||||
|
jackpot: function (t) {
|
||||||
|
var notes = [784, 988, 1175, 1319, 1568, 1760];
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
tone(t + i * 0.05, notes[i % notes.length], { type: "triangle", decay: 0.13, gain: 0.09 });
|
||||||
|
hiss(t + i * 0.05, { freq: 5200, q: 3, decay: 0.02, gain: 0.05 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Two notes down, and the second one is flat. Nobody needs telling twice.
|
// Two notes down, and the second one is flat. Nobody needs telling twice.
|
||||||
lose: function (t) {
|
lose: function (t) {
|
||||||
tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 });
|
tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 });
|
||||||
@@ -208,6 +318,43 @@
|
|||||||
tick: function (t) {
|
tick: function (t) {
|
||||||
hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 });
|
hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The mercy rule buries someone. Four ways to go, one sound each — uno.js
|
||||||
|
// rolls which animation plays and asks for the sound that matches it.
|
||||||
|
//
|
||||||
|
// Rocks coming down: a low rumble with tumbling knocks over the top, and a
|
||||||
|
// second slump as the pile settles.
|
||||||
|
rockslide: function (t) {
|
||||||
|
hiss(t, { filter: "lowpass", freq: 320, sweepTo: 140, decay: 0.5, gain: 0.34, q: 0.6 });
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
tone(t + i * 0.055, 128 - i * 9, { type: "square", decay: 0.1, gain: 0.09 });
|
||||||
|
}
|
||||||
|
hiss(t + 0.3, { filter: "lowpass", freq: 200, decay: 0.32, gain: 0.3, q: 0.5 });
|
||||||
|
},
|
||||||
|
|
||||||
|
// A headstone dropping in: one heavy stone thud that rings a little as it sets.
|
||||||
|
tombstone: function (t) {
|
||||||
|
tone(t, 92, { type: "sine", to: 58, glide: 0.16, decay: 0.42, gain: 0.32 });
|
||||||
|
hiss(t, { filter: "lowpass", freq: 520, decay: 0.12, gain: 0.34, q: 0.6 });
|
||||||
|
tone(t + 0.02, 184, { type: "triangle", decay: 0.5, gain: 0.08 });
|
||||||
|
},
|
||||||
|
|
||||||
|
// The Mega Man death: the pieces zip outward and the pitch falls away as they go.
|
||||||
|
zap: function (t) {
|
||||||
|
tone(t, 1250, { type: "sawtooth", to: 170, glide: 0.34, decay: 0.36, gain: 0.12 });
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
tone(t + i * 0.02, 940 - i * 130, { type: "square", decay: 0.06, gain: 0.06 });
|
||||||
|
}
|
||||||
|
hiss(t, { freq: 3000, sweepTo: 600, decay: 0.3, gain: 0.1, q: 0.8 });
|
||||||
|
},
|
||||||
|
|
||||||
|
// A coffin lid: two flat wooden knocks, the second lower and heavier.
|
||||||
|
coffin: function (t) {
|
||||||
|
tone(t, 150, { type: "triangle", decay: 0.14, gain: 0.26 });
|
||||||
|
hiss(t, { filter: "lowpass", freq: 820, decay: 0.05, gain: 0.24, q: 0.7 });
|
||||||
|
tone(t + 0.17, 108, { type: "triangle", decay: 0.22, gain: 0.28 });
|
||||||
|
hiss(t + 0.17, { filter: "lowpass", freq: 600, decay: 0.06, gain: 0.24, q: 0.7 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- the door --------------------------------------------------------------
|
// ---- the door --------------------------------------------------------------
|
||||||
@@ -217,6 +364,7 @@
|
|||||||
// gesture — after which play() can schedule freely.
|
// gesture — after which play() can schedule freely.
|
||||||
function wake() {
|
function wake() {
|
||||||
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
|
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
|
||||||
|
warm();
|
||||||
}
|
}
|
||||||
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
|
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
|
||||||
window.addEventListener(ev, wake, { passive: true });
|
window.addEventListener(ev, wake, { passive: true });
|
||||||
@@ -231,13 +379,18 @@
|
|||||||
// lands in 400ms can say so rather than sleeping.
|
// lands in 400ms can say so rather than sleeping.
|
||||||
function play(name, opts) {
|
function play(name, opts) {
|
||||||
if (muted) return;
|
if (muted) return;
|
||||||
var s = SOUNDS[name];
|
if (!SOUNDS[name] && !SAMPLES[name]) return; // known to neither voice
|
||||||
if (!s) return;
|
|
||||||
if (!boot()) return;
|
if (!boot()) return;
|
||||||
wake();
|
wake();
|
||||||
if (ctx.state !== "running") return; // not yet touched: no sound, and no error
|
if (ctx.state !== "running") return; // not yet touched: no sound, and no error
|
||||||
|
var t0 = ctx.currentTime + ((opts && opts.delay) || 0);
|
||||||
|
var v = (opts && opts.v) || 0;
|
||||||
try {
|
try {
|
||||||
s(ctx.currentTime + ((opts && opts.delay) || 0), ((opts && opts.v) || 0));
|
// A recording if we have one decoded; the synthesised take otherwise — both
|
||||||
|
// for names that are only ever synthesised, and for the first call of a
|
||||||
|
// recorded name while its ogg is still loading.
|
||||||
|
if (sample(name, t0, v)) return;
|
||||||
|
if (SOUNDS[name]) SOUNDS[name](t0, v);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* a sound is never worth throwing over */
|
/* a sound is never worth throwing over */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,10 +293,10 @@
|
|||||||
verdictEl.textContent = text;
|
verdictEl.textContent = text;
|
||||||
verdictEl.classList.remove("hidden");
|
verdictEl.classList.remove("hidden");
|
||||||
|
|
||||||
// Confetti for a phrase guessed outright — the one call you make on your own
|
// A phrase guessed outright keeps the confetti — the one call you make on your
|
||||||
// rather than by grinding the alphabet.
|
// own rather than by grinding the alphabet — and any win at all makes it rain.
|
||||||
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 });
|
if (v.outcome === "solved" && v.net > 0) { FX.burst(verdictEl, { count: 30 }); FX.moneyRain({ count: 28 }); }
|
||||||
else if (v.net > 0) FX.sfx("win");
|
else if (v.net > 0) FX.moneyRain();
|
||||||
else if (v.net < 0) FX.sfx("lose");
|
else if (v.net < 0) FX.sfx("lose");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -370,7 +370,12 @@
|
|||||||
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
|
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
|
||||||
potTotal.textContent = money(pot.amount);
|
potTotal.textContent = money(pot.amount);
|
||||||
moveStack(e.seat, e.amount);
|
moveStack(e.seat, e.amount);
|
||||||
if (e.seat === me && e.amount > 0) FX.burst(s.plate, { count: 18 });
|
if (e.seat === me && e.amount > 0) {
|
||||||
|
FX.burst(s.plate, { count: 18 });
|
||||||
|
// A real haul makes it rain; a blind-steal doesn't, or poker would be
|
||||||
|
// raining money every thirty seconds. Twenty big blinds is a pot.
|
||||||
|
if (view && view.tier && e.amount >= 20 * view.tier.bb) FX.moneyRain({ count: 24 });
|
||||||
|
}
|
||||||
return wait(260);
|
return wait(260);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,10 @@
|
|||||||
|
|
||||||
var playing = root.querySelector("[data-playing]");
|
var playing = root.querySelector("[data-playing]");
|
||||||
var betting = root.querySelector("[data-betting]");
|
var betting = root.querySelector("[data-betting]");
|
||||||
|
var playControls = root.querySelector("[data-play-controls]");
|
||||||
|
var wonControls = root.querySelector("[data-won-controls]");
|
||||||
var autoBtn = root.querySelector("[data-auto]");
|
var autoBtn = root.querySelector("[data-auto]");
|
||||||
|
var finishBtn = root.querySelector("[data-finish]");
|
||||||
var cashBtn = root.querySelector("[data-cash]");
|
var cashBtn = root.querySelector("[data-cash]");
|
||||||
var cashAmountEl = root.querySelector("[data-cash-amount]");
|
var cashAmountEl = root.querySelector("[data-cash-amount]");
|
||||||
var startBtn = root.querySelector("[data-start]");
|
var startBtn = root.querySelector("[data-start]");
|
||||||
@@ -258,6 +261,11 @@
|
|||||||
playing.classList.toggle("hidden", !live);
|
playing.classList.toggle("hidden", !live);
|
||||||
betting.classList.toggle("hidden", live);
|
betting.classList.toggle("hidden", live);
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
|
// A won board has nothing left to decide, so the ordinary controls step aside
|
||||||
|
// for the one button that finishes it.
|
||||||
|
var won = !!v.won;
|
||||||
|
wonControls.classList.toggle("hidden", !won);
|
||||||
|
playControls.classList.toggle("hidden", won);
|
||||||
autoBtn.disabled = !v.can_auto;
|
autoBtn.disabled = !v.can_auto;
|
||||||
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
|
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
|
||||||
}
|
}
|
||||||
@@ -545,6 +553,10 @@
|
|||||||
|
|
||||||
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
||||||
|
|
||||||
|
// The finish button. On a won board a single auto drains every card home in one
|
||||||
|
// cascade, and the board settles cleared on the far end of it.
|
||||||
|
finishBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
||||||
|
|
||||||
cashBtn.addEventListener("click", function () {
|
cashBtn.addEventListener("click", function () {
|
||||||
drop();
|
drop();
|
||||||
send({ kind: "concede" });
|
send({ kind: "concede" });
|
||||||
@@ -576,9 +588,9 @@
|
|||||||
verdictEl.textContent = text;
|
verdictEl.textContent = text;
|
||||||
verdictEl.classList.remove("hidden");
|
verdictEl.classList.remove("hidden");
|
||||||
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
|
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
|
||||||
// this room, so it's the one that gets the confetti.
|
// this room, so it keeps the confetti — but any win now makes it rain.
|
||||||
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 });
|
if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 40 }); FX.moneyRain({ count: 34 }); }
|
||||||
else if (v.net > 0) FX.sfx("win");
|
else if (v.net > 0) FX.moneyRain();
|
||||||
else if (v.net < 0) FX.sfx("lose");
|
else if (v.net < 0) FX.sfx("lose");
|
||||||
else FX.sfx("push");
|
else FX.sfx("push");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,8 +307,9 @@
|
|||||||
verdictEl.textContent = text;
|
verdictEl.textContent = text;
|
||||||
verdictEl.classList.remove("hidden");
|
verdictEl.classList.remove("hidden");
|
||||||
|
|
||||||
// Confetti only for clearing all twelve — the one thing in here worth it.
|
// Clearing all twelve keeps the confetti on top; any win at all makes it rain.
|
||||||
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 });
|
if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
|
||||||
|
else if (v.net > 0) FX.moneyRain();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPhase(v) {
|
function setPhase(v) {
|
||||||
|
|||||||
@@ -304,12 +304,22 @@
|
|||||||
colourEl.textContent = "";
|
colourEl.textContent = "";
|
||||||
if (feltEl) feltEl.dataset.c = "";
|
if (feltEl) feltEl.dataset.c = "";
|
||||||
}
|
}
|
||||||
pending(live(v) ? (v.pending || 0) : 0);
|
pending(live(v) ? (v.pending || 0) : 0, v);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pending(n) {
|
// Who the running stack lands on next: during a stack phase that's whoever's
|
||||||
|
// turn it is. Fall back to the view the caller passed, then to the live game.
|
||||||
|
function pendingTarget(v) {
|
||||||
|
var src = v || game;
|
||||||
|
if (!src) return "you";
|
||||||
|
if (src.turn === me) return "you";
|
||||||
|
var s = (src.seats && src.seats[src.turn]) || null;
|
||||||
|
return (s && s.name) || "them";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pending(n, v) {
|
||||||
if (billEl) {
|
if (billEl) {
|
||||||
billEl.textContent = n > 0 ? "+" + n + " on you" : "";
|
billEl.textContent = n > 0 ? "+" + n + " on " + pendingTarget(v) : "";
|
||||||
billEl.classList.toggle("hidden", n <= 0);
|
billEl.classList.toggle("hidden", n <= 0);
|
||||||
}
|
}
|
||||||
if (takeN) takeN.textContent = String(n);
|
if (takeN) takeN.textContent = String(n);
|
||||||
@@ -355,7 +365,7 @@
|
|||||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||||
verdictEl.textContent = text;
|
verdictEl.textContent = text;
|
||||||
verdictEl.classList.remove("hidden");
|
verdictEl.classList.remove("hidden");
|
||||||
if (v.winner === me && v.outcome !== "tie") FX.burst(verdictEl, { count: 34 });
|
if (v.winner === me && v.outcome !== "tie") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
|
||||||
else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose");
|
else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +485,80 @@
|
|||||||
return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); });
|
return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bury gives the mercy rule some ceremony. It rolls one of four send-offs and
|
||||||
|
// drops it over the buried seat — or over your hand, if it's you going down —
|
||||||
|
// with the noise that goes with it. Every piece is built from scratch and
|
||||||
|
// clears itself, so a repaint that lands mid-fall just cuts it short. Resolves
|
||||||
|
// on the dramatic beat, not on cleanup: the hand shouldn't wait for the dust.
|
||||||
|
var BURIALS = ["rocks", "tomb", "zap", "coffin"];
|
||||||
|
|
||||||
|
function bury(seat) {
|
||||||
|
var host = seat === me ? handEl : seatEl(seat);
|
||||||
|
if (!host) return Promise.resolve();
|
||||||
|
|
||||||
|
var wrap = document.createElement("div");
|
||||||
|
wrap.className = "pete-uno-bury";
|
||||||
|
host.appendChild(wrap);
|
||||||
|
|
||||||
|
// No theatre for reduced motion: a single static headstone marks the grave,
|
||||||
|
// with the stone-drop thud so it isn't silent either.
|
||||||
|
if (reduced) {
|
||||||
|
FX.sfx("tombstone");
|
||||||
|
var stone = document.createElement("div");
|
||||||
|
stone.className = "pete-uno-tomb";
|
||||||
|
stone.textContent = "🪦";
|
||||||
|
wrap.appendChild(stone);
|
||||||
|
setTimeout(function () { wrap.remove(); }, 1400);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
var kind = BURIALS[Math.floor(Math.random() * BURIALS.length)];
|
||||||
|
wrap.dataset.kind = kind;
|
||||||
|
|
||||||
|
if (kind === "rocks") {
|
||||||
|
FX.sfx("rockslide");
|
||||||
|
[
|
||||||
|
{ dx: "-1.15rem", dy: "0.55rem", rot: "-20deg", d: 0 },
|
||||||
|
{ dx: "0.95rem", dy: "0.7rem", rot: "24deg", d: 70 },
|
||||||
|
{ dx: "-0.15rem", dy: "0.15rem", rot: "8deg", d: 150 },
|
||||||
|
{ dx: "-1.5rem", dy: "1.0rem", rot: "-34deg", d: 220 },
|
||||||
|
{ dx: "1.45rem", dy: "0.95rem", rot: "16deg", d: 300 },
|
||||||
|
{ dx: "0.35rem", dy: "1.05rem", rot: "-10deg", d: 360 },
|
||||||
|
].forEach(function (r) {
|
||||||
|
var el = document.createElement("div");
|
||||||
|
el.className = "pete-uno-rock";
|
||||||
|
el.textContent = "🪨";
|
||||||
|
el.style.setProperty("--dx", r.dx);
|
||||||
|
el.style.setProperty("--dy", r.dy);
|
||||||
|
el.style.setProperty("--rot", r.rot);
|
||||||
|
el.style.setProperty("--d", r.d + "ms");
|
||||||
|
wrap.appendChild(el);
|
||||||
|
});
|
||||||
|
} else if (kind === "zap") {
|
||||||
|
FX.sfx("zap");
|
||||||
|
var flash = document.createElement("div");
|
||||||
|
flash.className = "pete-uno-zap-flash";
|
||||||
|
wrap.appendChild(flash);
|
||||||
|
for (var a = 0; a < 8; a++) {
|
||||||
|
var bit = document.createElement("div");
|
||||||
|
bit.className = "pete-uno-zap-bit";
|
||||||
|
bit.style.setProperty("--ang", (a * 45) + "deg");
|
||||||
|
wrap.appendChild(bit);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// tomb or coffin: one heavy thing drops in and sets.
|
||||||
|
FX.sfx(kind === "tomb" ? "tombstone" : "coffin");
|
||||||
|
var mark = document.createElement("div");
|
||||||
|
mark.className = kind === "tomb" ? "pete-uno-tomb" : "pete-uno-coffin";
|
||||||
|
mark.textContent = kind === "tomb" ? "🪦" : "⚰️";
|
||||||
|
wrap.appendChild(mark);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zap is gone in half a second; the graves linger before they clear.
|
||||||
|
setTimeout(function () { wrap.remove(); }, kind === "zap" ? 650 : 1500);
|
||||||
|
return new Promise(function (r) { setTimeout(r, kind === "zap" ? 480 : 640); });
|
||||||
|
}
|
||||||
|
|
||||||
// play walks the server's script for a move the acting player just made.
|
// play walks the server's script for a move the acting player just made.
|
||||||
function play(view) {
|
function play(view) {
|
||||||
var events = view.uno_events || [];
|
var events = view.uno_events || [];
|
||||||
@@ -570,7 +654,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "stack":
|
case "stack":
|
||||||
pending(e.n);
|
pending(e.n, final);
|
||||||
spotlight(e.seat);
|
spotlight(e.seat);
|
||||||
FX.sfx("bad");
|
FX.sfx("bad");
|
||||||
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
|
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
|
||||||
@@ -614,8 +698,12 @@
|
|||||||
bump(e.seat, 0);
|
bump(e.seat, 0);
|
||||||
showHand(e.hand);
|
showHand(e.hand);
|
||||||
pending(0);
|
pending(0);
|
||||||
FX.sfx("bad");
|
// bury() rolls the send-off and makes its own noise; the badge rides
|
||||||
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
|
// alongside it so you still see what count did them in.
|
||||||
|
return Promise.all([
|
||||||
|
bury(e.seat),
|
||||||
|
badge(e.seat, "Buried on " + e.n, "bad"),
|
||||||
|
]).then(function () { return wait(300); });
|
||||||
}
|
}
|
||||||
|
|
||||||
case "skip":
|
case "skip":
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
||||||
// drops every cache that doesn't match the current version.
|
// drops every cache that doesn't match the current version.
|
||||||
var CACHE_VERSION = "v4";
|
var CACHE_VERSION = "v5";
|
||||||
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
||||||
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||||
|
|
||||||
@@ -117,18 +117,24 @@ self.addEventListener("fetch", function (event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static assets: cache-first (they're versioned by deploy), fill the cache on
|
// Static assets: network-first. Our asset URLs are NOT content-hashed
|
||||||
// first miss so a later offline visit has them.
|
// (/static/js/weather-gl.js stays the same URL across deploys), so a
|
||||||
|
// cache-first strategy would pin whatever bytes were first cached and never
|
||||||
|
// notice a redeployed file changed — leaving stale JS/CSS in place until the
|
||||||
|
// CACHE_VERSION bump below. Go's FileServer sets ETag/Last-Modified, so an
|
||||||
|
// online refetch is a cheap 304 when nothing changed. We still fill the cache
|
||||||
|
// on every success and fall back to it when the network is unreachable, so
|
||||||
|
// offline reading keeps the shell it needs.
|
||||||
if (url.pathname.indexOf("/static/") === 0) {
|
if (url.pathname.indexOf("/static/") === 0) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(req).then(function (hit) {
|
fetch(req).then(function (res) {
|
||||||
return hit || fetch(req).then(function (res) {
|
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
var copy = res.clone();
|
var copy = res.clone();
|
||||||
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
|
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
});
|
}).catch(function () {
|
||||||
|
return caches.match(req);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -25,19 +25,62 @@
|
|||||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
||||||
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
||||||
{{range .Roster}}
|
{{range .Roster}}
|
||||||
<li class="flex items-center gap-4 px-5 py-3">
|
<li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}">
|
||||||
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||||||
<span class="font-semibold">{{.Name}}</span>
|
<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
|
||||||
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
||||||
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
||||||
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||||||
</span>
|
</span>
|
||||||
|
{{if and $.User .OnRun}}
|
||||||
|
<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
|
||||||
|
{{end}}
|
||||||
</li>
|
</li>
|
||||||
{{else}}
|
{{else}}
|
||||||
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
|
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if not .User}}
|
||||||
|
<p class="mt-3 text-xs text-[color:var(--ink)]/50">
|
||||||
|
<a href="/auth/login?next={{.Path}}" class="underline hover:text-red-500">Sign in</a> to send something unpleasant their way.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .User}}
|
||||||
|
<!-- Your open contracts. Rendered from filed facts, never from live game state:
|
||||||
|
Pete only knows what gogobee told it happened. -->
|
||||||
|
<div id="mischief-orders" class="mt-4 hidden">
|
||||||
|
<h3 class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50 mb-2">Trouble you've paid for</h3>
|
||||||
|
<ul id="mischief-orders-list" class="space-y-2 text-sm"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The buy dialog. Prices are gogobee's, pulled live from /api/mischief/catalog;
|
||||||
|
nothing here asserts a number of its own. -->
|
||||||
|
<div id="mischief-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-[color:var(--ink)]/10">
|
||||||
|
<h3 class="font-display text-xl font-bold">Send trouble to <span id="mischief-target">—</span></h3>
|
||||||
|
<button type="button" id="mischief-close" class="text-2xl leading-none text-[color:var(--ink)]/50 hover:text-[color:var(--ink)]" aria-label="close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 py-4 space-y-3">
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">Pick how bad. They won't know who sent it — unless you sign it, or they live to read the receipt.</p>
|
||||||
|
<div id="mischief-tiers" class="space-y-2"></div>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" id="mischief-signed" class="rounded border-[color:var(--ink)]/30">
|
||||||
|
<span>Sign my name to it <span class="text-[color:var(--ink)]/45">(+25%, and they'll know)</span></span>
|
||||||
|
</label>
|
||||||
|
<p id="mischief-balance" class="text-xs text-[color:var(--ink)]/50"></p>
|
||||||
|
<p id="mischief-result" class="text-sm hidden"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-[color:var(--ink)]/10">
|
||||||
|
<button type="button" id="mischief-cancel" class="rounded-full px-4 py-2 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">Never mind</button>
|
||||||
|
<button type="button" id="mischief-confirm" class="rounded-full bg-red-500 text-white px-5 py-2 text-sm font-semibold shadow-pete hover:-translate-y-0.5 transition disabled:opacity-40 disabled:translate-y-0" disabled>Put the word out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -49,6 +92,8 @@
|
|||||||
var card = document.getElementById('roster-card');
|
var card = document.getElementById('roster-card');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
|
var signedIn = {{if .User}}true{{else}}false{{end}};
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
var d = document.createElement('div');
|
var d = document.createElement('div');
|
||||||
d.textContent = s == null ? '' : String(s);
|
d.textContent = s == null ? '' : String(s);
|
||||||
@@ -57,12 +102,15 @@
|
|||||||
|
|
||||||
function row(a) {
|
function row(a) {
|
||||||
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
||||||
return '<li class="flex items-center gap-4 px-5 py-3">' +
|
var button = (signedIn && a.OnRun)
|
||||||
|
? '<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
|
||||||
|
: '';
|
||||||
|
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
|
||||||
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||||||
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
|
'<a href="/adventure/who/' + esc(a.Token) + '" class="font-semibold hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
|
||||||
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
||||||
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
||||||
esc(a.Where) + idle + '</span></li>';
|
esc(a.Where) + idle + '</span>' + button + '</li>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
@@ -84,7 +132,157 @@
|
|||||||
document.addEventListener('visibilitychange', function () {
|
document.addEventListener('visibilitychange', function () {
|
||||||
if (!document.hidden) refresh();
|
if (!document.hidden) refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (signedIn) initMischief(list, esc);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// The storefront: pick a mark, pick how bad, pay. The only thing this code is
|
||||||
|
// authoritative about is the buyer's intent — every price, every yes/no, and the
|
||||||
|
// buyer's actual balance are gogobee's, reached through the API.
|
||||||
|
function initMischief(list, esc) {
|
||||||
|
var modal = document.getElementById('mischief-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
var targetEl = document.getElementById('mischief-target');
|
||||||
|
var tiersEl = document.getElementById('mischief-tiers');
|
||||||
|
var signedEl = document.getElementById('mischief-signed');
|
||||||
|
var balanceEl = document.getElementById('mischief-balance');
|
||||||
|
var resultEl = document.getElementById('mischief-result');
|
||||||
|
var confirmEl = document.getElementById('mischief-confirm');
|
||||||
|
var ordersWrap = document.getElementById('mischief-orders');
|
||||||
|
var ordersList = document.getElementById('mischief-orders-list');
|
||||||
|
|
||||||
|
var catalog = null; // {tiers, euro, has_balance}
|
||||||
|
var chosen = null; // selected tier key
|
||||||
|
var current = null; // {token, name}
|
||||||
|
|
||||||
|
function euros(n) { return '€' + Number(n).toLocaleString(); }
|
||||||
|
|
||||||
|
function loadCatalog() {
|
||||||
|
return fetch('/api/mischief/catalog', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (c) { catalog = c; return c; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTiers() {
|
||||||
|
if (!catalog || !catalog.tiers || !catalog.tiers.length) {
|
||||||
|
tiersEl.innerHTML = '<p class="text-sm text-[color:var(--ink)]/50">The catalogue is between snapshots. Try again in a moment.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var signed = signedEl.checked;
|
||||||
|
var known = catalog.has_balance;
|
||||||
|
var euro = catalog.euro;
|
||||||
|
tiersEl.innerHTML = catalog.tiers.map(function (t) {
|
||||||
|
var price = signed ? t.signed_fee : t.fee;
|
||||||
|
var tooDear = known && euro < price;
|
||||||
|
return '<button type="button" class="mischief-tier w-full text-left rounded-2xl border-2 px-4 py-2 transition ' +
|
||||||
|
(chosen === t.key ? 'border-red-500 bg-red-500/10' : 'border-[color:var(--ink)]/10 hover:border-[color:var(--ink)]/25') +
|
||||||
|
(tooDear ? ' opacity-40 cursor-not-allowed' : '') + '" data-key="' + esc(t.key) + '"' + (tooDear ? ' disabled' : '') + '>' +
|
||||||
|
'<span class="flex items-baseline justify-between"><span class="font-semibold">' + esc(t.display) + '</span>' +
|
||||||
|
'<span class="font-semibold">' + euros(price) + '</span></span>' +
|
||||||
|
(t.blurb ? '<span class="block text-xs text-[color:var(--ink)]/55 mt-0.5">' + esc(t.blurb) + '</span>' : '') +
|
||||||
|
'</button>';
|
||||||
|
}).join('');
|
||||||
|
balanceEl.textContent = known ? 'You have about ' + euros(euro) + ' to spend.' : '';
|
||||||
|
Array.prototype.forEach.call(tiersEl.querySelectorAll('.mischief-tier'), function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
chosen = b.getAttribute('data-key');
|
||||||
|
confirmEl.disabled = false;
|
||||||
|
drawTiers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(token, name) {
|
||||||
|
current = { token: token, name: name };
|
||||||
|
chosen = null;
|
||||||
|
confirmEl.disabled = true;
|
||||||
|
resultEl.classList.add('hidden');
|
||||||
|
resultEl.textContent = '';
|
||||||
|
signedEl.checked = false;
|
||||||
|
targetEl.textContent = name;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
(catalog ? Promise.resolve() : loadCatalog()).then(drawTiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(statusText) {
|
||||||
|
var map = {
|
||||||
|
pending: ['on its way', 'bg-amber-500/15 text-amber-700'],
|
||||||
|
placed: ['out for delivery', 'bg-red-500/15 text-red-600'],
|
||||||
|
bounced_funds: ["couldn't afford it", 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'],
|
||||||
|
bounced_ineligible: ['no longer a mark', 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60']
|
||||||
|
};
|
||||||
|
var m = map[statusText] || [statusText, 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'];
|
||||||
|
return '<span class="rounded-full px-2 py-0.5 text-xs font-semibold ' + m[1] + '">' + esc(m[0]) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOrders() {
|
||||||
|
fetch('/api/mischief/orders', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (orders) {
|
||||||
|
if (!orders || !orders.length) { ordersWrap.classList.add('hidden'); return; }
|
||||||
|
ordersWrap.classList.remove('hidden');
|
||||||
|
ordersList.innerHTML = orders.map(function (o) {
|
||||||
|
var detail = o.detail ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(o.detail) + '</span>' : '';
|
||||||
|
return '<li class="flex items-center gap-3">' +
|
||||||
|
'<span class="font-semibold">' + esc(o.target_name) + '</span>' +
|
||||||
|
'<span class="text-[color:var(--ink)]/55">' + esc(o.tier) + (o.signed ? ', signed' : '') + '</span>' +
|
||||||
|
'<span class="ml-auto">' + badge(o.status) + '</span>' + detail + '</li>';
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener('click', function (e) {
|
||||||
|
var b = e.target.closest('.mischief-send');
|
||||||
|
if (!b) return;
|
||||||
|
open(b.getAttribute('data-token'), b.getAttribute('data-name'));
|
||||||
|
});
|
||||||
|
signedEl.addEventListener('change', drawTiers);
|
||||||
|
document.getElementById('mischief-close').addEventListener('click', close);
|
||||||
|
document.getElementById('mischief-cancel').addEventListener('click', close);
|
||||||
|
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
|
||||||
|
|
||||||
|
confirmEl.addEventListener('click', function () {
|
||||||
|
if (!current || !chosen) return;
|
||||||
|
confirmEl.disabled = true;
|
||||||
|
resultEl.classList.add('hidden');
|
||||||
|
fetch('/api/mischief/order', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target_token: current.token, tier: chosen, signed: signedEl.checked })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
resultEl.classList.remove('hidden');
|
||||||
|
if (res.ok) {
|
||||||
|
resultEl.className = 'text-sm text-red-600 font-semibold';
|
||||||
|
resultEl.textContent = 'The word is out. Watch the board.';
|
||||||
|
catalog = null; // balance moved; refetch next open
|
||||||
|
loadOrders();
|
||||||
|
setTimeout(close, 1200);
|
||||||
|
} else {
|
||||||
|
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
|
||||||
|
resultEl.textContent = (res.body && res.body.error) || 'That didn\'t take. Try again.';
|
||||||
|
confirmEl.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
resultEl.classList.remove('hidden');
|
||||||
|
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
|
||||||
|
resultEl.textContent = 'The line dropped. Try again.';
|
||||||
|
confirmEl.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadOrders();
|
||||||
|
setInterval(loadOrders, 60000);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,6 @@
|
|||||||
{{template "_comb" .}}
|
{{template "_comb" .}}
|
||||||
<span class="min-w-0">
|
<span class="min-w-0">
|
||||||
<span data-room-name class="block font-display text-2xl sm:text-3xl font-bold leading-none tracking-tight">{{.Room.Name}}</span>
|
<span data-room-name class="block font-display text-2xl sm:text-3xl font-bold leading-none tracking-tight">{{.Room.Name}}</span>
|
||||||
<span class="mt-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--ink)]/45">Chips are euros</span>
|
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,9 @@
|
|||||||
|
|
||||||
<!-- Playing: shown while a board is live. -->
|
<!-- Playing: shown while a board is live. -->
|
||||||
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
|
<!-- The ordinary controls, while there's still a game to play. -->
|
||||||
|
<div data-play-controls class="flex flex-wrap items-center gap-3">
|
||||||
<button type="button" data-auto
|
<button type="button" data-auto
|
||||||
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
|
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
|
||||||
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
@@ -103,6 +105,22 @@
|
|||||||
Cash the board · <span data-cash-amount class="tabular-nums">0</span>
|
Cash the board · <span data-cash-amount class="tabular-nums">0</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- The board's won: every card's face up and the piles are drained, so
|
||||||
|
there's nothing left to decide. One press sends the lot home. -->
|
||||||
|
<div data-won-controls class="hidden">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" data-finish
|
||||||
|
class="pete-finish rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:pointer-events-none transition">
|
||||||
|
You've cracked it. Send them all home 🎉
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-[color:var(--ink)]/45">
|
||||||
|
The whole board's face up now, so the rest plays itself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
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