adventure: a click-through page for every adventurer on the board
Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
This commit is contained in:
@@ -97,6 +97,10 @@ func runMigrations(d *sql.DB) error {
|
||||
// Occupancy of a shared table. Rows written before the casino went multiplayer
|
||||
// are solo games and read as NULL, which is exactly what they are.
|
||||
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT")
|
||||
// The public detail sheet (stats + equipped gear) for an adventurer's
|
||||
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
|
||||
// gogobee build that predates the detail page.
|
||||
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
|
||||
|
||||
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
||||
// Check sqlite_master before creating.
|
||||
|
||||
131
internal/storage/detail.go
Normal file
131
internal/storage/detail.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// PlayerDetail is one player's private, owner-only expansion — inventory, vault,
|
||||
// house, pets — pushed by gogobee keyed by localpart. Pete stores it in its own
|
||||
// keyspace (player_self_detail) and only ever serves it back to the one
|
||||
// authenticated user it belongs to. Token rides along so the detail page can
|
||||
// prove owner↔page without ever reversing the anonymous roster token.
|
||||
type PlayerDetail struct {
|
||||
Localpart string `json:"localpart"`
|
||||
Token string `json:"token"`
|
||||
Inventory []ItemView `json:"inventory,omitempty"`
|
||||
Vault []ItemView `json:"vault,omitempty"`
|
||||
House HouseView `json:"house"`
|
||||
Pets []PetView `json:"pets,omitempty"`
|
||||
}
|
||||
|
||||
// ItemView is one backpack or vault item.
|
||||
type ItemView struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tier int `json:"tier"`
|
||||
Value int64 `json:"value"`
|
||||
Temper int `json:"temper,omitempty"`
|
||||
}
|
||||
|
||||
// HouseView is the owner's housing summary.
|
||||
type HouseView struct {
|
||||
Tier int `json:"tier"`
|
||||
LoanBalance int `json:"loan_balance,omitempty"`
|
||||
Autopay bool `json:"autopay,omitempty"`
|
||||
Rate float64 `json:"rate,omitempty"`
|
||||
}
|
||||
|
||||
// PetView is one pet slot.
|
||||
type PetView struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
XP int `json:"xp,omitempty"`
|
||||
ArmorTier int `json:"armor_tier,omitempty"`
|
||||
}
|
||||
|
||||
// ReplacePlayerDetail swaps the whole private-detail set in one transaction —
|
||||
// replace, never merge, the same contract as the roster: a player who dropped
|
||||
// out of gogobee's push must lose their stale self-view rather than have it
|
||||
// linger. localpart is lowercased upstream to match how a session Username reads.
|
||||
func ReplacePlayerDetail(players []PlayerDetail, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM player_self_detail`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO player_self_detail (localpart, token, detail_json, snapshot_at)
|
||||
VALUES (?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, p := range players {
|
||||
if p.Localpart == "" || p.Token == "" {
|
||||
continue // a self-view with no owner or no page to hang on is unusable
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := stmt.Exec(p.Localpart, p.Token, string(body), snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// PlayerDetailByOwner returns the private detail for localpart, but only when it
|
||||
// owns the given page token. This is the ownership join the detail page needs:
|
||||
// the signed-in user's localpart is trusted (it comes from their verified
|
||||
// session), and a row exists only if gogobee pushed that same (localpart, token)
|
||||
// pair — so a viewer can only ever unlock the self extras on their own page, and
|
||||
// Pete never has to turn a token back into a handle to decide it.
|
||||
func PlayerDetailByOwner(localpart, token string) (PlayerDetail, bool, error) {
|
||||
if localpart == "" || token == "" {
|
||||
return PlayerDetail{}, false, nil
|
||||
}
|
||||
var storedToken, detailJSON string
|
||||
err := Get().QueryRow(
|
||||
`SELECT token, detail_json FROM player_self_detail WHERE localpart = ?`, localpart).
|
||||
Scan(&storedToken, &detailJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return PlayerDetail{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PlayerDetail{}, false, err
|
||||
}
|
||||
if storedToken != token {
|
||||
return PlayerDetail{}, false, nil // signed in, but not the owner of this page
|
||||
}
|
||||
var pd PlayerDetail
|
||||
if err := json.Unmarshal([]byte(detailJSON), &pd); err != nil {
|
||||
return PlayerDetail{}, false, err
|
||||
}
|
||||
pd.Localpart = localpart
|
||||
pd.Token = storedToken
|
||||
return pd, true, nil
|
||||
}
|
||||
|
||||
// SelfToken returns the roster token owned by localpart, if gogobee's last push
|
||||
// carried one. Lets the board mark "your adventurer" without exposing the
|
||||
// localpart↔token map anywhere public.
|
||||
func SelfToken(localpart string) (string, bool) {
|
||||
if localpart == "" {
|
||||
return "", false
|
||||
}
|
||||
var token string
|
||||
err := Get().QueryRow(
|
||||
`SELECT token FROM player_self_detail WHERE localpart = ?`, localpart).Scan(&token)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
126
internal/storage/detail_test.go
Normal file
126
internal/storage/detail_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// The private self-detail set is the one place Pete holds a localpart↔token
|
||||
// association. These tests pin the security contract of that store: the
|
||||
// ownership join only ever unlocks a page for the localpart that owns it, and a
|
||||
// replace wipes a departed player's stale self-view rather than leaving it to
|
||||
// linger.
|
||||
|
||||
func seedSelfDetail(t *testing.T, localpart, token string) PlayerDetail {
|
||||
t.Helper()
|
||||
return PlayerDetail{
|
||||
Localpart: localpart,
|
||||
Token: token,
|
||||
Inventory: []ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
|
||||
Vault: []ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||
House: HouseView{Tier: 2, LoanBalance: 1500},
|
||||
Pets: []PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayerDetailByOwnerMatrix is the ownership matrix: the self-view unlocks
|
||||
// only when the signed-in localpart owns the exact page token. A different
|
||||
// localpart, or the owner viewing someone else's page, gets nothing.
|
||||
func TestPlayerDetailByOwnerMatrix(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
seedSelfDetail(t, "quack", "tok-quack"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatalf("ReplacePlayerDetail: %v", err)
|
||||
}
|
||||
|
||||
// The owner, on their own page: unlocked, and the private goods come through.
|
||||
pd, ok, err := PlayerDetailByOwner("josie", "tok-josie")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("owner on own page: ok=%v err=%v, want unlocked", ok, err)
|
||||
}
|
||||
if len(pd.Inventory) != 1 || pd.House.Tier != 2 || len(pd.Pets) != 1 {
|
||||
t.Errorf("owner detail = %+v, want inventory+house+pet carried", pd)
|
||||
}
|
||||
|
||||
// Josie signed in, looking at Quack's page: her localpart doesn't own that
|
||||
// token, so the join must refuse — no peeking at another player's private set.
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-quack"); ok {
|
||||
t.Error("owner unlocked ANOTHER player's page — the token guard failed")
|
||||
}
|
||||
|
||||
// A signed-in stranger with no self-detail row at all.
|
||||
if _, ok, _ := PlayerDetailByOwner("nobody", "tok-josie"); ok {
|
||||
t.Error("a stranger unlocked a page they have no row for")
|
||||
}
|
||||
|
||||
// Empty inputs never unlock.
|
||||
if _, ok, _ := PlayerDetailByOwner("", "tok-josie"); ok {
|
||||
t.Error("empty localpart unlocked a page")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", ""); ok {
|
||||
t.Error("empty token unlocked a page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailReplaces: the set is swapped whole, never merged — a
|
||||
// player gogobee stops pushing (deleted character, dropped out) must lose their
|
||||
// stale self-view, the same complete-snapshot contract as the board.
|
||||
func TestReplacePlayerDetailReplaces(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
seedSelfDetail(t, "quack", "tok-quack"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Next push carries only Josie; Quack has left.
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
}, 1060); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("quack", "tok-quack"); ok {
|
||||
t.Error("a dropped player's self-view survived the replace")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-josie"); !ok {
|
||||
t.Error("the surviving player lost their self-view")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailTokenFollows: when a player's board token rotates
|
||||
// (re-derived each push), the ownership check must follow it. The stale token no
|
||||
// longer unlocks; the current one does.
|
||||
func TestReplacePlayerDetailTokenFollows(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-old")}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-new")}, 1060); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-old"); ok {
|
||||
t.Error("the stale token still unlocked the page after a rotation")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-new"); !ok {
|
||||
t.Error("the current token failed to unlock the page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailSkipsUnusable: a row with no owner or no page to hang
|
||||
// on is dropped at write time — it could never be served and would only be dead
|
||||
// weight.
|
||||
func TestReplacePlayerDetailSkipsUnusable(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
{Localpart: "", Token: "tok-orphan"},
|
||||
{Localpart: "ghost", Token: ""},
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok, ok := SelfToken("josie"); !ok || tok != "tok-josie" {
|
||||
t.Errorf("SelfToken(josie) = %q,%v, want tok-josie", tok, ok)
|
||||
}
|
||||
if _, ok := SelfToken("ghost"); ok {
|
||||
t.Error("a tokenless row was stored")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,10 @@ type RosterEntry struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
IdleHours int `json:"idle_hours,omitempty"`
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
// Detail is the public expanded sheet (stats + gear), carried as raw JSON so
|
||||
// the board path never has to model or touch it — only the detail page decodes
|
||||
// it. Empty when a snapshot predates the detail push.
|
||||
Detail json.RawMessage `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
||||
@@ -38,16 +43,20 @@ func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
|
||||
}
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_roster
|
||||
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at, detail_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, e := range entries {
|
||||
var detail any // NULL when absent, so the column reads as "no detail", not "{}"
|
||||
if len(e.Detail) > 0 {
|
||||
detail = string(e.Detail)
|
||||
}
|
||||
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
||||
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil {
|
||||
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt, detail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -97,18 +106,22 @@ func LoadRoster() ([]RosterEntry, int64, error) {
|
||||
// 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
|
||||
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)
|
||||
&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
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,24 @@ CREATE TABLE IF NOT EXISTS mischief_tiers (
|
||||
ordinal INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
||||
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
||||
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
||||
-- like user_euro, it is only ever served back to the one authenticated user it
|
||||
-- belongs to, never on the public board. token is that player's current roster
|
||||
-- token, kept here so the detail page can prove owner↔page by a join without
|
||||
-- ever reversing the one-way token — the association lives only in this
|
||||
-- owner-private table and never reaches any public response. detail_json is the
|
||||
-- {inventory, vault, house, pets} body; it is replaced wholesale each tick, so a
|
||||
-- player who drops out of gogobee's push loses their stale self-view.
|
||||
CREATE TABLE IF NOT EXISTS player_self_detail (
|
||||
localpart TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
detail_json TEXT NOT NULL,
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user