From 11bfce780c76766ee36f38b0eae2d21e5325fe2a Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:22:39 -0700 Subject: [PATCH] adventure: gogobee pushes each adventurer's sheet to Pete's detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board on Pete shows flat rows; this hands it two more channels so a name can become a page. Public stats + equipped gear ride the roster snapshot (RosterDetail on each entry, keyed by the anonymous token, no handle). The private self-view — inventory, vault, house, pets — rides its own push keyed by localpart, so Pete only ever serves it back to the one signed-in owner it belongs to; the board token rides along so the ownership check is a join, never a reversal of the one-way token. The private set skips no one for opt-out (that governs the public board only) and skips the dead (no live page to own). --- internal/peteclient/client.go | 98 ++++++++++++ internal/plugin/pete_detail_test.go | 228 ++++++++++++++++++++++++++++ internal/plugin/pete_roster.go | 166 ++++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 internal/plugin/pete_detail_test.go diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index 5159ad6..7add4a7 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -245,6 +245,39 @@ type RosterEntry struct { Region string `json:"region,omitempty"` Day int `json:"day,omitempty"` IdleHours int `json:"idle_hours,omitempty"` + + // Detail is the public, expanded sheet — stats and equipped gear — shown on + // the click-through detail page. Nil for an entry we couldn't fully load. + // Public-tier: no Matrix handle, keyed only by the anonymous Token, same as + // the summary fields above. + Detail *RosterDetail `json:"detail,omitempty"` +} + +// RosterDetail is everything a visitor may see on an adventurer's detail page: +// the current combat sheet and equipped gear, plus live expedition context. It +// rides the roster push because it is small and shares the board's semantics — +// a photograph of the present, fine to drop and refresh, never a handle. +type RosterDetail struct { + HPCurrent int `json:"hp_current"` + HPMax int `json:"hp_max"` + TempHP int `json:"temp_hp,omitempty"` + ArmorClass int `json:"armor_class"` + Abilities [6]int `json:"abilities"` // STR, DEX, CON, INT, WIS, CHA scores + Modifiers [6]int `json:"modifiers"` // matching ability modifiers + Gear []GearItem `json:"gear,omitempty"` + // Expedition context, present only while on a run. + Supplies int `json:"supplies,omitempty"` + ThreatLevel int `json:"threat_level,omitempty"` + Room string `json:"room,omitempty"` +} + +// GearItem is one equipped piece for the armor/gear panel. +type GearItem struct { + Slot string `json:"slot"` // weapon | armor | helmet | boots | tool + Name string `json:"name"` + Tier int `json:"tier"` + Condition int `json:"condition"` + Masterwork bool `json:"masterwork,omitempty"` } // MischiefBalance is one buyer's advisory euro balance, ridden along with the @@ -303,6 +336,71 @@ func PushRoster(ctx context.Context, snap RosterSnapshot) error { return std.post(ctx, "/api/ingest/roster", payload) } +// PlayerDetail is the private, owner-only expansion for one player: inventory, +// vault, house, and pets. Like MischiefBalance it is keyed by localpart (the +// sign-in name), in its own keyspace on Pete — Pete only ever serves it back to +// the one authenticated user it belongs to, never on the public board. It +// carries the player's current board Token too, so Pete can answer "is the +// signed-in viewer the owner of the adventurer on this page?" by a join, without +// ever having to reverse the one-way 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 for the private inventory panel. +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"` +} + +// DetailSnapshot is the complete private-detail set, pushed whole and replacing +// Pete's copy — same complete-snapshot contract as the roster, so a player who +// drops out of the game stops having a stale self-view on Pete. +type DetailSnapshot struct { + SnapshotAt int64 `json:"snapshot_at"` + Players []PlayerDetail `json:"players"` +} + +// PushDetails sends the private self-detail set to Pete, best-effort. Like the +// roster it is dropped on failure — the next tick carries a fresher copy — and +// it rides its own endpoint (not the roster body) so a fat inventory can't blow +// the roster push's size budget or its drop-the-lie semantics. +func PushDetails(ctx context.Context, snap DetailSnapshot) error { + if !Enabled() { + return nil + } + payload, err := json.Marshal(snap) + if err != nil { + return err + } + return std.post(ctx, "/api/ingest/detail", payload) +} + // post sends one payload to a Pete endpoint with bearer auth. Mirrors the // bearer-POST pattern in email_nag.go:sendCode. func (c *Client) post(ctx context.Context, path string, payload []byte) error { diff --git a/internal/plugin/pete_detail_test.go b/internal/plugin/pete_detail_test.go new file mode 100644 index 0000000..6ca2b91 --- /dev/null +++ b/internal/plugin/pete_detail_test.go @@ -0,0 +1,228 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// The adventurer detail-page push has two halves, matched to two sensitivities: +// the public sheet (stats + gear) rides the roster snapshot keyed by the +// anonymous token, and the private self-view (inventory/vault/house/pets) rides +// its own push keyed by localpart. These tests pin both halves at the gogobee +// end — that the public detail carries no handle, and that the private detail is +// keyed so Pete can only ever hand it back to its owner. + +// seedDetailPlayer builds a real, playable adventurer: player_meta + tier-0 +// equipment (via createAdvCharacter) plus a confirmed combat sheet. Real rows on +// purpose — the same modernc scan hazard the roster snapshot dances around. +func seedDetailPlayer(t *testing.T, uid id.UserID, name string, level int) { + t.Helper() + if err := createAdvCharacter(uid, name); err != nil { + t.Fatalf("createAdvCharacter(%s): %v", uid, err) + } + if err := SaveDnDCharacter(&DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level, + STR: 16, DEX: 14, CON: 15, INT: 10, WIS: 12, CHA: 8, + HPMax: 42, HPCurrent: 30, TempHP: 5, ArmorClass: 17, + PendingSetup: false, + }); err != nil { + t.Fatalf("SaveDnDCharacter(%s): %v", uid, err) + } +} + +// TestRosterDetailPublicSheet: buildRosterSnapshot hangs the public sheet on +// each board entry — HP/AC/abilities/mods and equipped gear — and nothing that +// could name the player. This is the click-through page's whole payload. +func TestRosterDetailPublicSheet(t *testing.T) { + newMischiefTestDB(t) + uid := id.UserID("@josie:test") + seedDetailPlayer(t, uid, "Josie", 5) + + snap, err := buildRosterSnapshot(time.Now().UTC(), nil) + if err != nil { + t.Fatalf("buildRosterSnapshot: %v", err) + } + if len(snap.Adventurers) != 1 { + t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers)) + } + e := snap.Adventurers[0] + if e.Detail == nil { + t.Fatal("board entry carries no detail sheet — the detail page would fall back to the bare row") + } + d := e.Detail + if d.HPMax != 42 || d.HPCurrent != 30 || d.TempHP != 5 || d.ArmorClass != 17 { + t.Errorf("detail sheet = %+v, want HP 30/42 (+5 temp), AC 17", d) + } + if d.Abilities != [6]int{16, 14, 15, 10, 12, 8} { + t.Errorf("abilities = %v, want STR..CHA 16,14,15,10,12,8", d.Abilities) + } + // CON 15 → +2; a mismatched mods slice would misprint the whole sheet. + if d.Modifiers[2] != 2 { + t.Errorf("CON modifier = %d, want +2", d.Modifiers[2]) + } + // createAdvCharacter seeds tier-0 gear in every slot, so the panel is full. + if len(d.Gear) != len(allSlots) { + t.Errorf("gear panel has %d pieces, want %d (one per slot)", len(d.Gear), len(allSlots)) + } + for _, g := range d.Gear { + if g.Slot == "" || g.Name == "" { + t.Errorf("gear piece is unlabelled: %+v", g) + } + } +} + +// TestRosterDetailExpeditionContext: a player who is out on a run gets the live +// expedition fields layered onto their sheet — supplies, threat, and the room +// counter — so the detail page shows where they are, not just who they are. +func TestRosterDetailExpeditionContext(t *testing.T) { + newMischiefTestDB(t) + uid := id.UserID("@onrun:test") + exp := seedMischiefTarget(t, uid, 5, 60) // createAdvCharacter + sheet + live run + _ = exp + + snap, err := buildRosterSnapshot(time.Now().UTC(), nil) + if err != nil { + t.Fatalf("buildRosterSnapshot: %v", err) + } + if len(snap.Adventurers) != 1 { + t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers)) + } + e := snap.Adventurers[0] + if e.Status != "expedition" { + t.Fatalf("status = %q, want expedition", e.Status) + } + if e.Detail == nil { + t.Fatal("on-run entry carries no detail") + } + if e.Detail.Room == "" { + t.Error("expedition detail has no room counter — the run context didn't layer on") + } +} + +// TestDetailSnapshotKeyedByLocalpart is the ownership contract at the source. +// The private set is keyed by localpart and carries the player's current board +// token, so Pete can answer "is this signed-in viewer the owner of this page?" +// by a join — never by reversing the one-way token. And it carries the actual +// private goods: inventory, vault, house, pets. +func TestDetailSnapshotKeyedByLocalpart(t *testing.T) { + newMischiefTestDB(t) + uid := id.UserID("@quack:test") + seedDetailPlayer(t, uid, "Quack", 7) + + // Backpack + vault: two distinct stashes that must not blur together. + if err := addAdvInventoryItem(uid, AdvItem{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}); err != nil { + t.Fatal(err) + } + if err := addAdvInventoryItem(uid, AdvItem{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}); err != nil { + t.Fatal(err) + } + if got := vaultStoreItem(uid, "crown"); got == "" { + t.Fatal("failed to vault the crown") + } + + // House + a pet, through the canonical save path. + adv, err := loadAdvCharacter(uid) + if err != nil { + t.Fatalf("loadAdvCharacter: %v", err) + } + adv.HouseTier = 2 + adv.HouseLoanBalance = 1500 + adv.PetType = "cat" + adv.PetName = "Mittens" + adv.PetLevel = 3 + if err := saveAdvCharacter(adv); err != nil { + t.Fatalf("saveAdvCharacter: %v", err) + } + + snap, err := buildDetailSnapshot(time.Now().UTC()) + if err != nil { + t.Fatalf("buildDetailSnapshot: %v", err) + } + if len(snap.Players) != 1 { + t.Fatalf("detail set has %d players, want 1", len(snap.Players)) + } + pd := snap.Players[0] + if pd.Localpart != "quack" { + t.Errorf("localpart = %q, want the lowercase mxid localpart 'quack'", pd.Localpart) + } + if pd.Token != eventToken(uid, "roster") { + t.Error("detail token is not the board token — the ownership join on Pete would never match the page") + } + // Inventory holds the ore (crown was vaulted out of it); vault holds the crown. + if len(pd.Inventory) != 1 || pd.Inventory[0].Name != "Iron Ore" { + t.Errorf("inventory = %+v, want just the ore", pd.Inventory) + } + if len(pd.Vault) != 1 || pd.Vault[0].Name != "Jeweled Crown" { + t.Errorf("vault = %+v, want just the crown", pd.Vault) + } + if pd.House.Tier != 2 || pd.House.LoanBalance != 1500 { + t.Errorf("house = %+v, want tier 2 / loan 1500", pd.House) + } + if len(pd.Pets) != 1 || pd.Pets[0].Name != "Mittens" || pd.Pets[0].Level != 3 { + t.Errorf("pets = %+v, want the cat Mittens L3", pd.Pets) + } +} + +// TestDetailSnapshotIgnoresOptOut: the news opt-out governs the *public* board, +// not the private self-view. A player who opted out must still receive their own +// sheet — Pete only ever serves this back to them — so buildDetailSnapshot must +// NOT filter on it, unlike buildRosterSnapshot. +func TestDetailSnapshotIgnoresOptOut(t *testing.T) { + newMischiefTestDB(t) + shown := id.UserID("@shown:test") + hidden := id.UserID("@hidden:test") + seedDetailPlayer(t, shown, "Shown", 4) + seedDetailPlayer(t, hidden, "Hidden", 4) + setNewsOptout(hidden, true) + + // The public board drops the opted-out player... + roster, err := buildRosterSnapshot(time.Now().UTC(), nil) + if err != nil { + t.Fatalf("buildRosterSnapshot: %v", err) + } + if len(roster.Adventurers) != 1 || roster.Adventurers[0].Name != "Shown" { + t.Fatalf("public board = %d entries, want just Shown", len(roster.Adventurers)) + } + + // ...but the private detail set keeps them both. + detail, err := buildDetailSnapshot(time.Now().UTC()) + if err != nil { + t.Fatalf("buildDetailSnapshot: %v", err) + } + if len(detail.Players) != 2 { + t.Fatalf("private detail set = %d players, want 2 — opt-out must not deny a player their own self-view", len(detail.Players)) + } + byLP := map[string]bool{} + for _, p := range detail.Players { + byLP[p.Localpart] = true + } + if !byLP["hidden"] { + t.Error("opted-out player is missing from the private self-view set") + } +} + +// TestDetailSnapshotSkipsDeadPlayers: the set is drawn from alive players only. +// A dead character has no live board page to own, and pushing their stale +// inventory would leave it standing on Pete after they're gone. +func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) { + newMischiefTestDB(t) + alive := id.UserID("@alive:test") + dead := id.UserID("@dead:test") + seedDetailPlayer(t, alive, "Alive", 4) + seedDetailPlayer(t, dead, "Dead", 4) + if _, err := db.Get().Exec(`UPDATE player_meta SET alive = 0 WHERE user_id = ?`, string(dead)); err != nil { + t.Fatalf("kill player: %v", err) + } + + snap, err := buildDetailSnapshot(time.Now().UTC()) + if err != nil { + t.Fatalf("buildDetailSnapshot: %v", err) + } + if len(snap.Players) != 1 || snap.Players[0].Localpart != "alive" { + t.Fatalf("detail set = %+v, want just the living player", snap.Players) + } +} diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index 07f9313..3aa5a53 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -2,6 +2,7 @@ package plugin import ( "context" + "fmt" "log/slog" "time" @@ -48,6 +49,7 @@ func (p *AdventurePlugin) peteRosterTicker() { continue // master switch off: the board goes stale on Pete and says so } p.pushRoster() + p.pushDetails() } } @@ -88,6 +90,159 @@ func (p *AdventurePlugin) pushRoster() { } } +// detailPushOK mirrors rosterPushOK for the private self-detail push: log the +// transitions and nothing else. +var detailPushOK bool + +func (p *AdventurePlugin) pushDetails() { + snap, err := buildDetailSnapshot(time.Now().UTC()) + if err != nil { + slog.Error("roster: build detail snapshot failed", "err", err) + return + } + ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout) + defer cancel() + + if err := peteclient.PushDetails(ctx, snap); err != nil { + if detailPushOK { + slog.Warn("roster: detail push failed, self-view will go stale on Pete", "err", err) + } else { + slog.Debug("roster: detail push failed, dropping snapshot", "err", err) + } + detailPushOK = false + return + } + + if !detailPushOK { + slog.Info("roster: private self-detail accepted by Pete", "players", len(snap.Players)) + detailPushOK = true + } +} + +// rosterDetail assembles the public detail sheet for one adventurer: the combat +// stats every visitor may see, plus equipped gear. Expedition context (supplies, +// threat, room) is layered on by the caller when a run is active. +func rosterDetail(uid id.UserID, c *DnDCharacter) *peteclient.RosterDetail { + d := &peteclient.RosterDetail{ + HPCurrent: c.HPCurrent, + HPMax: c.HPMax, + TempHP: c.TempHP, + ArmorClass: c.ArmorClass, + Abilities: [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA}, + Modifiers: c.Modifiers(), + } + if equip, err := loadAdvEquipment(uid); err == nil { + for _, slot := range allSlots { + e, ok := equip[slot] + if !ok || e == nil { + continue + } + d.Gear = append(d.Gear, peteclient.GearItem{ + Slot: string(slot), + Name: e.Name, + Tier: e.Tier, + Condition: e.Condition, + Masterwork: e.Masterwork, + }) + } + } + return d +} + +// buildDetailSnapshot assembles the private, owner-only detail set — inventory, +// vault, house, and pets for every alive player, keyed by localpart. It does NOT +// skip opted-out players: the news opt-out governs the *public* board, but this +// data is never shown there — Pete only ever serves it back to the one signed-in +// owner it belongs to, so hiding a player from it would only deny them their own +// sheet. The board token rides along so Pete can match owner↔page without ever +// reversing the one-way token. +func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) { + snap := peteclient.DetailSnapshot{SnapshotAt: now.Unix()} + rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`) + if err != nil { + return snap, err + } + var uids []id.UserID + for rows.Next() { + var uid string + if err := rows.Scan(&uid); err != nil { + rows.Close() + return snap, err + } + uids = append(uids, id.UserID(uid)) + } + rows.Close() + if err := rows.Err(); err != nil { + return snap, err + } + + for _, uid := range uids { + lp := localpartOf(uid) + if lp == "" { + continue + } + adv, err := loadAdvCharacter(uid) + if err != nil || adv == nil { + continue + } + pd := peteclient.PlayerDetail{ + Localpart: lp, + Token: eventToken(uid, "roster"), + House: peteclient.HouseView{ + Tier: adv.HouseTier, + LoanBalance: adv.HouseLoanBalance, + Autopay: adv.HouseAutopay, + Rate: adv.HouseCurrentRate, + }, + Pets: petViews(adv), + } + if items, err := loadAdvInventory(uid); err == nil { + pd.Inventory = itemViews(items) + } + if items, err := loadAdvVault(uid); err == nil { + pd.Vault = itemViews(items) + } + snap.Players = append(snap.Players, pd) + } + return snap, nil +} + +func itemViews(items []AdvItem) []peteclient.ItemView { + if len(items) == 0 { + return nil + } + out := make([]peteclient.ItemView, 0, len(items)) + for _, it := range items { + out = append(out, peteclient.ItemView{ + Name: it.Name, + Type: it.Type, + Tier: it.Tier, + Value: it.Value, + Temper: it.Temper, + }) + } + return out +} + +// petViews returns the player's live pet slots. A pet that was chased away is +// omitted — it isn't with them right now, and the self-view shows the present. +func petViews(adv *AdventureCharacter) []peteclient.PetView { + var out []peteclient.PetView + if adv.PetType != "" && !adv.PetChasedAway { + out = append(out, peteclient.PetView{ + Type: adv.PetType, Name: adv.PetName, Level: adv.PetLevel, + XP: adv.PetXP, ArmorTier: adv.PetArmorTier, + }) + } + if adv.Pet2Type != "" && !adv.Pet2ChasedAway { + out = append(out, peteclient.PetView{ + Type: adv.Pet2Type, Name: adv.Pet2Name, Level: adv.Pet2Level, + XP: adv.Pet2XP, ArmorTier: adv.Pet2ArmorTier, + }) + } + return out +} + // buildRosterSnapshot assembles the complete board. // // Complete is the contract: Pete *replaces* its board with this, so anyone we @@ -172,6 +327,8 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap Status: "idle", } + e.Detail = rosterDetail(pl.uid, c) + if exp, _ := getActiveExpedition(pl.uid); exp != nil { zone := zoneOrFallback(exp.ZoneID) e.Status = "expedition" @@ -182,6 +339,15 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap e.Region = r.Name } } + if e.Detail != nil { + e.Detail.Supplies = int(exp.Supplies.Current) + e.Detail.ThreatLevel = exp.ThreatLevel + if exp.RunID != "" { + if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 { + e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms) + } + } + } } else if pl.lastAction != nil { if h := int(now.Sub(*pl.lastAction).Hours()); h > 0 { e.IdleHours = h