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:
prosolis
2026-07-14 22:22:52 -07:00
parent 2ac6ec6b91
commit 16711e13e6
13 changed files with 940 additions and 14 deletions

View File

@@ -0,0 +1,126 @@
package storage
import "testing"
// The private self-detail set is the one place Pete holds a localpart↔token
// association. These tests pin the security contract of that store: the
// ownership join only ever unlocks a page for the localpart that owns it, and a
// replace wipes a departed player's stale self-view rather than leaving it to
// linger.
func seedSelfDetail(t *testing.T, localpart, token string) PlayerDetail {
t.Helper()
return PlayerDetail{
Localpart: localpart,
Token: token,
Inventory: []ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
Vault: []ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
House: HouseView{Tier: 2, LoanBalance: 1500},
Pets: []PetView{{Type: "cat", Name: "Mittens", Level: 3}},
}
}
// TestPlayerDetailByOwnerMatrix is the ownership matrix: the self-view unlocks
// only when the signed-in localpart owns the exact page token. A different
// localpart, or the owner viewing someone else's page, gets nothing.
func TestPlayerDetailByOwnerMatrix(t *testing.T) {
setupTestDB(t)
if err := ReplacePlayerDetail([]PlayerDetail{
seedSelfDetail(t, "josie", "tok-josie"),
seedSelfDetail(t, "quack", "tok-quack"),
}, 1000); err != nil {
t.Fatalf("ReplacePlayerDetail: %v", err)
}
// The owner, on their own page: unlocked, and the private goods come through.
pd, ok, err := PlayerDetailByOwner("josie", "tok-josie")
if err != nil || !ok {
t.Fatalf("owner on own page: ok=%v err=%v, want unlocked", ok, err)
}
if len(pd.Inventory) != 1 || pd.House.Tier != 2 || len(pd.Pets) != 1 {
t.Errorf("owner detail = %+v, want inventory+house+pet carried", pd)
}
// Josie signed in, looking at Quack's page: her localpart doesn't own that
// token, so the join must refuse — no peeking at another player's private set.
if _, ok, _ := PlayerDetailByOwner("josie", "tok-quack"); ok {
t.Error("owner unlocked ANOTHER player's page — the token guard failed")
}
// A signed-in stranger with no self-detail row at all.
if _, ok, _ := PlayerDetailByOwner("nobody", "tok-josie"); ok {
t.Error("a stranger unlocked a page they have no row for")
}
// Empty inputs never unlock.
if _, ok, _ := PlayerDetailByOwner("", "tok-josie"); ok {
t.Error("empty localpart unlocked a page")
}
if _, ok, _ := PlayerDetailByOwner("josie", ""); ok {
t.Error("empty token unlocked a page")
}
}
// TestReplacePlayerDetailReplaces: the set is swapped whole, never merged — a
// player gogobee stops pushing (deleted character, dropped out) must lose their
// stale self-view, the same complete-snapshot contract as the board.
func TestReplacePlayerDetailReplaces(t *testing.T) {
setupTestDB(t)
if err := ReplacePlayerDetail([]PlayerDetail{
seedSelfDetail(t, "josie", "tok-josie"),
seedSelfDetail(t, "quack", "tok-quack"),
}, 1000); err != nil {
t.Fatal(err)
}
// Next push carries only Josie; Quack has left.
if err := ReplacePlayerDetail([]PlayerDetail{
seedSelfDetail(t, "josie", "tok-josie"),
}, 1060); err != nil {
t.Fatal(err)
}
if _, ok, _ := PlayerDetailByOwner("quack", "tok-quack"); ok {
t.Error("a dropped player's self-view survived the replace")
}
if _, ok, _ := PlayerDetailByOwner("josie", "tok-josie"); !ok {
t.Error("the surviving player lost their self-view")
}
}
// TestReplacePlayerDetailTokenFollows: when a player's board token rotates
// (re-derived each push), the ownership check must follow it. The stale token no
// longer unlocks; the current one does.
func TestReplacePlayerDetailTokenFollows(t *testing.T) {
setupTestDB(t)
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-old")}, 1000); err != nil {
t.Fatal(err)
}
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-new")}, 1060); err != nil {
t.Fatal(err)
}
if _, ok, _ := PlayerDetailByOwner("josie", "tok-old"); ok {
t.Error("the stale token still unlocked the page after a rotation")
}
if _, ok, _ := PlayerDetailByOwner("josie", "tok-new"); !ok {
t.Error("the current token failed to unlock the page")
}
}
// TestReplacePlayerDetailSkipsUnusable: a row with no owner or no page to hang
// on is dropped at write time — it could never be served and would only be dead
// weight.
func TestReplacePlayerDetailSkipsUnusable(t *testing.T) {
setupTestDB(t)
if err := ReplacePlayerDetail([]PlayerDetail{
{Localpart: "", Token: "tok-orphan"},
{Localpart: "ghost", Token: ""},
seedSelfDetail(t, "josie", "tok-josie"),
}, 1000); err != nil {
t.Fatal(err)
}
if tok, ok := SelfToken("josie"); !ok || tok != "tok-josie" {
t.Errorf("SelfToken(josie) = %q,%v, want tok-josie", tok, ok)
}
if _, ok := SelfToken("ghost"); ok {
t.Error("a tokenless row was stored")
}
}