Files
gogobee/internal/plugin/pete_roster.go
prosolis 11bfce780c adventure: gogobee pushes each adventurer's sheet to Pete's detail page
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).
2026-07-14 22:22:39 -07:00

404 lines
13 KiB
Go

package plugin
import (
"context"
"fmt"
"log/slog"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// The live adventurer board pushed to Pete (gogobee_boredom_plan.md's sibling —
// see the roster section of the Pete plan).
//
// Everything else we send Pete is an accomplishment: a death, a clear, a
// milestone. Those are clippings — they read as archive the moment they land, no
// matter how fast we deliver them. The board is the other kind of thing: state
// that is *currently true*, which is the only thing that can make a page feel
// alive. So it is a snapshot, pushed whole, replacing whatever Pete had.
//
// It is also, by design, a target list. The plan is to let people who aren't
// even playing hire assassins and mobs against adventurers who are out in the
// world right now — so the board carries a stable per-player token and real zone
// depth, not just a pretty display string, and it shows the zone *live* while
// they're still in it.
const (
// rosterTickInterval — how often we push. Pete's staleness window is several
// times this, so a missed push or two is invisible; a real outage isn't.
rosterTickInterval = 2 * time.Minute
// rosterPushTimeout — the push is dropped on failure, never retried (a stale
// snapshot is a lie, and the next tick carries the truth), so it must not be
// able to pile up.
rosterPushTimeout = 15 * time.Second
)
// peteRosterTicker pushes the board to Pete forever.
func (p *AdventurePlugin) peteRosterTicker() {
if !peteclient.Enabled() {
return
}
ticker := time.NewTicker(rosterTickInterval)
defer ticker.Stop()
for range ticker.C {
if !newsEmissionOn() {
continue // master switch off: the board goes stale on Pete and says so
}
p.pushRoster()
p.pushDetails()
}
}
// rosterPushOK tracks the last push's outcome so we can log the transitions and
// nothing else. A push every 2 minutes forever is far too noisy to log at INFO,
// but total silence is worse: a ticker that is succeeding quietly looks exactly
// like one that never started, and that ambiguity already cost an operator a
// wrong-turn debug during the first deploy. So: say something the first time it
// works, say something when it breaks, say something when it recovers.
var rosterPushOK bool
func (p *AdventurePlugin) pushRoster() {
snap, err := buildRosterSnapshot(time.Now().UTC(), p.euro)
if err != nil {
slog.Error("roster: build snapshot failed", "err", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
defer cancel()
if err := peteclient.PushRoster(ctx, snap); err != nil {
// The failure itself is not alarming — the next tick retries by simply
// being a fresher snapshot, and if we stay down Pete's board correctly
// stops claiming to be live. Only the *transition* is worth a line.
if rosterPushOK {
slog.Warn("roster: push failed, board will go stale on Pete", "err", err)
} else {
slog.Debug("roster: push failed, dropping snapshot", "err", err)
}
rosterPushOK = false
return
}
if !rosterPushOK {
slog.Info("roster: board accepted by Pete — live adventurer board is publishing",
"adventurers", len(snap.Adventurers))
rosterPushOK = true
}
}
// 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
// omit drops off the public page. That is exactly how the opt-out is enforced —
// an opted-out player is simply never in the payload, rather than being sent and
// anonymized. A standing row showing class + level + zone is trivially
// re-identifiable (there is one level-14 cleric), so "an adventurer" would have
// been a fig leaf; absence is the only honest option.
func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnapshot, error) {
snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix(), Tiers: mischiefTierCatalog()}
// Both DATETIME columns selected raw and folded in Go — NOT COALESCE()'d in
// SQL. modernc.org/sqlite rebuilds a time.Time from the column's *declared*
// type, and COALESCE() erases that affinity: the value comes back a string
// and the Scan fails. Same trap playerIsIdle documents.
rows, err := db.Get().Query(`
SELECT user_id, last_player_action_at, created_at
FROM player_meta
WHERE alive = 1`)
if err != nil {
return snap, err
}
defer rows.Close()
type player struct {
uid id.UserID
lastAction *time.Time
}
var players []player
for rows.Next() {
var uid string
var lastAction, created *time.Time
if err := rows.Scan(&uid, &lastAction, &created); err != nil {
return snap, err
}
if lastAction == nil {
lastAction = created
}
players = append(players, player{id.UserID(uid), lastAction})
}
if err := rows.Err(); err != nil {
return snap, err
}
for _, pl := range players {
// The buyer's own advisory balance rides along in a separate keyspace,
// keyed by localpart (their sign-in name), and is collected *before* the
// opt-out skip: opt-out hides a player from the public board, but their own
// balance is private on Pete — only ever read for the user asking about
// themselves — so there is no reason to deny an opted-out player the
// storefront's affordability hint. localpart is lowercase, matching how the
// buyer signs in and how a web order's username is resolved back to an MXID.
if euro != nil {
if lp := localpartOf(pl.uid); lp != "" {
snap.Balances = append(snap.Balances, peteclient.MischiefBalance{
Username: lp,
Euro: euro.GetBalance(pl.uid),
})
}
}
if isNewsOptedOut(pl.uid) {
continue
}
c, err := LoadDnDCharacter(pl.uid)
if err != nil || c == nil || c.PendingSetup {
continue // no character to show; a half-made one has no name yet
}
name := charName(pl.uid)
if name == "" {
continue // never fall back to a Matrix handle on a public page
}
e := peteclient.RosterEntry{
// Stable per-player board token: salted, so it can't be recomputed
// from the handle, and distinct from every event token, so the board
// doesn't become the key that links a player's dispatches together.
Token: eventToken(pl.uid, "roster"),
Name: name,
Level: c.Level,
ClassRace: classRaceLabel(c),
Status: "idle",
}
e.Detail = rosterDetail(pl.uid, c)
if exp, _ := getActiveExpedition(pl.uid); exp != nil {
zone := zoneOrFallback(exp.ZoneID)
e.Status = "expedition"
e.Zone = zone.Display
e.Day = exp.CurrentDay
if IsMultiRegionZone(exp.ZoneID) {
if r, ok := CurrentRegion(exp); ok {
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
}
}
snap.Adventurers = append(snap.Adventurers, e)
}
return snap, nil
}
// resolveRosterToken maps a board token back to the adventurer it names. The
// token is a one-way HMAC (eventToken), so it can't be inverted — instead we
// recompute every live player's token and match. The salt is DB-persisted, so a
// token minted before a restart still resolves after one. O(players) per call,
// which is nothing at realm scale and only runs when a web order is being placed.
func resolveRosterToken(token string) (id.UserID, bool) {
if token == "" {
return "", false
}
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
if err != nil {
return "", false
}
defer rows.Close()
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
continue
}
if eventToken(id.UserID(uid), "roster") == token {
return id.UserID(uid), true
}
}
return "", false
}
// mischiefTierCatalog is the storefront price list, pushed on every tick so the
// web shop always renders gogobee's current prices — a fee retune here reaches
// Pete within a snapshot, and Pete never hardcodes a number of its own. The
// signed fee is the same +25% sink a Matrix buyer pays to put their name on it.
func mischiefTierCatalog() []peteclient.MischiefTier {
out := make([]peteclient.MischiefTier, 0, len(mischiefTiers))
for _, t := range mischiefTiers {
out = append(out, peteclient.MischiefTier{
Key: t.Key,
Display: t.Display,
Fee: t.Fee,
SignedFee: mischiefSignedFee(t.Fee),
Blurb: t.Blurb,
})
}
return out
}