news: tell Pete who's out there right now, not just who died

We only ever told Pete about outcomes. Nothing emitted when an expedition
*started*, which is why the two bored adventurers walked into dungeons and the
news feed said nothing at all — it wasn't broken, it had nothing to say.

Two halves:

A roster snapshot, pushed every 2 minutes. Deliberately NOT on the durable fact
queue: a fact is history and losing it loses it forever, so it retries. A
snapshot is a photograph of the present, and a retried one is a lie — by the
time it lands, she's moved. The next tick carries the truth. That's also what
lets Pete's staleness timer work: if we stay down, nothing arrives, and the
board stops claiming to be live instead of insisting forever that Josie is
still in holymachina.

And a "departure" bulletin when a bored adventurer lets itself out.

The snapshot omits opted-out players rather than anonymizing them, and carries a
board token distinct from every event token, so a standing row can't become the
key that links a player's dispatches back together.

The player_meta scan folds last_player_action_at/created_at in Go instead of
COALESCE()ing in SQL — modernc rebuilds time.Time from the declared column type
and COALESCE erases it. A failed scan here would publish an empty board and
every adventurer would vanish from the page.
This commit is contained in:
prosolis
2026-07-13 18:05:49 -07:00
parent 1cbd68a718
commit ced75786b9
6 changed files with 360 additions and 1 deletions

View File

@@ -194,10 +194,55 @@ func (c *Client) drain(ctx context.Context) {
} }
} }
// RosterEntry is one adventurer's currently-true state for Pete's live board.
// Unlike a Fact, nothing here is an event — it is what is true right now.
type RosterEntry struct {
Token string `json:"token"` // stable per-player board token, never a Matrix handle
Name string `json:"name"` // character name only
Level int `json:"level"`
ClassRace string `json:"class_race,omitempty"`
Status string `json:"status"` // "expedition" | "idle"
Zone string `json:"zone,omitempty"`
Region string `json:"region,omitempty"`
Day int `json:"day,omitempty"`
IdleHours int `json:"idle_hours,omitempty"`
}
// RosterSnapshot is the complete board. Complete is load-bearing: Pete replaces
// its whole board with this, so anyone omitted (opted out, no character) drops
// off the public page. A partial snapshot would silently strand people on it.
type RosterSnapshot struct {
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []RosterEntry `json:"adventurers"`
}
// PushRoster sends the board to Pete, synchronously, and drops it on failure.
//
// Deliberately NOT on the durable queue that carries Facts. A fact is history —
// losing "Josie died" loses it forever, so it retries. A snapshot is a
// photograph of the present, and a retried one is a *lie*: by the time it lands,
// Josie has moved. The next tick carries the truth anyway, so a failed push is
// simply forgotten. That is also what lets Pete's staleness timer work — if we
// stay down, nothing arrives, and the board correctly stops claiming to be live.
func PushRoster(ctx context.Context, snap RosterSnapshot) error {
if !Enabled() {
return nil
}
payload, err := json.Marshal(snap)
if err != nil {
return err
}
return std.post(ctx, "/api/ingest/roster", payload)
}
// send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the // send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the
// bearer-POST pattern in email_nag.go:sendCode. // bearer-POST pattern in email_nag.go:sendCode.
func (c *Client) send(ctx context.Context, payload []byte) error { func (c *Client) send(ctx context.Context, payload []byte) error {
url := c.cfg.IngestURL + "/api/ingest/adventure" return c.post(ctx, "/api/ingest/adventure", payload)
}
func (c *Client) post(ctx context.Context, path string, payload []byte) error {
url := c.cfg.IngestURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil { if err != nil {
return err return err

View File

@@ -285,6 +285,7 @@ func (p *AdventurePlugin) Init() error {
go p.expeditionRecapTicker() go p.expeditionRecapTicker()
go p.expeditionAmbientTicker() go p.expeditionAmbientTicker()
go p.expeditionAutoRunTicker() go p.expeditionAutoRunTicker()
go p.peteRosterTicker()
go p.expeditionExtractionSweepTicker() go p.expeditionExtractionSweepTicker()
go p.expeditionBoredomTicker() go p.expeditionBoredomTicker()

View File

@@ -286,6 +286,7 @@ func (p *AdventurePlugin) tryBoredomStart(uid id.UserID, now time.Time) error {
slog.Info("boredom: adventurer left on its own", slog.Info("boredom: adventurer left on its own",
"user", uid, "zone", zone.ID, "level", c.Level, "cost", int(cost)) "user", uid, "zone", zone.ID, "level", c.Level, "cost", int(cost))
emitBoredomDeparture(uid, zone, c.Level)
return p.SendDM(uid, renderBoredomDepartureDM(zone, supplies, purchase)) return p.SendDM(uid, renderBoredomDepartureDM(zone, supplies, purchase))
} }

View File

@@ -268,6 +268,35 @@ func claimRealmFirst(kind, target string) bool {
// zone_first would just echo TwinBee back at the same people. Bulletin still // zone_first would just echo TwinBee back at the same people. Bulletin still
// gets the story onto the site and into the daily digest, where it reads as a // gets the story onto the site and into the daily digest, where it reads as a
// roundup rather than a repeat. // roundup rather than a repeat.
// emitBoredomDeparture announces that an adventurer got restless and let itself
// out. The one thing gogobee never used to tell Pete was that an expedition
// *started* — every dispatch was an outcome — which is why the two live boredom
// runs produced no news at all.
//
// The event_type must be one Pete already knows: an unknown type is a 400, which
// retries and then parks the bulletin forever. Deploy Pete first.
func emitBoredomDeparture(userID id.UserID, zone ZoneDefinition, level int) {
if !peteclient.Enabled() || !newsEmissionOn() {
return
}
name := charName(userID)
if name == "" {
return
}
ts := nowUnix()
disc := fmt.Sprintf("departure:%s:%d", zone.ID, ts)
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("departure:%s:%s:%d", eventToken(userID, disc), zone.ID, ts),
EventType: "departure",
Tier: "bulletin",
Subject: name,
Zone: zone.Display,
Level: level,
Outcome: "departed",
OccurredAt: ts,
}, userID, "")
}
func emitZoneClearNews(userID id.UserID, exp *Expedition) { func emitZoneClearNews(userID id.UserID, exp *Expedition) {
if !peteclient.Enabled() || !newsEmissionOn() { if !peteclient.Enabled() || !newsEmissionOn() {
return return

View File

@@ -0,0 +1,156 @@
package plugin
import (
"context"
"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()
}
}
func (p *AdventurePlugin) pushRoster() {
snap, err := buildRosterSnapshot(time.Now().UTC())
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 {
// Not an error worth shouting about: the next tick retries by simply
// being a fresher snapshot, and if we stay down Pete's board correctly
// stops claiming to be live.
slog.Warn("roster: push failed, dropping snapshot", "err", err)
}
}
// 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) (peteclient.RosterSnapshot, error) {
snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix()}
// 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 {
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",
}
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
}
}
} 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
}

View File

@@ -0,0 +1,127 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// seedRosterPlayer puts a real, playable character on the board: a player_meta
// row (which carries the idle clock and the display name) plus a confirmed
// dnd_character. Real rows on purpose — the snapshot query reads two DATETIME
// columns, and the modernc affinity trap only fires against actual stored
// values, never against a hand-built struct.
func seedRosterPlayer(t *testing.T, uid id.UserID, name string, created, lastAction *time.Time) {
t.Helper()
if _, err := db.Get().Exec(
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_player_action_at)
VALUES (?, ?, 1, ?, ?)`,
string(uid), name, created, lastAction); err != nil {
t.Fatalf("seed player_meta: %v", err)
}
if err := SaveDnDCharacter(&DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
STR: 15, DEX: 14, CON: 13, INT: 12, WIS: 10, CHA: 8,
HPMax: 30, HPCurrent: 30, ArmorClass: 16,
PendingSetup: false,
}); err != nil {
t.Fatalf("seed character: %v", err)
}
}
// TestRosterSnapshotReadsTheClock is the scan-affinity guard. The snapshot
// selects last_player_action_at and created_at as declared DATETIME columns and
// folds them in Go; a COALESCE() in the SQL would erase the affinity, the Scan
// would fail, and buildRosterSnapshot would return an error — publishing an
// empty board (every adventurer vanishes from the public page) rather than
// anything obviously broken.
func TestRosterSnapshotReadsTheClock(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-50 * time.Hour)
recent := now.Add(-3 * time.Hour)
seedRosterPlayer(t, "@quiet:test", "Quack", &old, &old)
seedRosterPlayer(t, "@recent:test", "Josie", &old, &recent)
// Never acted at all: the fold falls through to created_at.
seedRosterPlayer(t, "@never:test", "Camcast", &old, nil)
snap, err := buildRosterSnapshot(now)
if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err)
}
if len(snap.Adventurers) != 3 {
t.Fatalf("board has %d adventurers, want 3", len(snap.Adventurers))
}
if snap.SnapshotAt != now.Unix() {
t.Errorf("snapshot_at = %d, want %d", snap.SnapshotAt, now.Unix())
}
byName := map[string]int{}
for _, a := range snap.Adventurers {
byName[a.Name] = a.IdleHours
if a.Status != "idle" {
t.Errorf("%s status = %q, want idle (nobody is on an expedition)", a.Name, a.Status)
}
if a.Token == "" {
t.Errorf("%s has no board token", a.Name)
}
}
if got := byName["Quack"]; got != 50 {
t.Errorf("Quack idle hours = %d, want 50 — the clock didn't survive the scan", got)
}
if got := byName["Josie"]; got != 3 {
t.Errorf("Josie idle hours = %d, want 3", got)
}
if got := byName["Camcast"]; got != 50 {
t.Errorf("Camcast idle hours = %d, want 50 (fell through to created_at)", got)
}
}
// TestRosterOmitsOptedOut is the privacy contract. Pete *replaces* its whole
// board with this payload, so omission is what enforces the opt-out. Anonymizing
// instead would be a fig leaf: a standing row with class + level + zone names the
// player anyway.
func TestRosterOmitsOptedOut(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-30 * time.Hour)
seedRosterPlayer(t, "@shown:test", "Josie", &old, &old)
seedRosterPlayer(t, "@hidden:test", "Quack", &old, &old)
setNewsOptout("@hidden:test", true)
snap, err := buildRosterSnapshot(now)
if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err)
}
if len(snap.Adventurers) != 1 {
t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers))
}
if snap.Adventurers[0].Name != "Quack" && snap.Adventurers[0].Name != "Josie" {
t.Fatalf("unexpected adventurer %q", snap.Adventurers[0].Name)
}
if snap.Adventurers[0].Name == "Quack" {
t.Error("an opted-out player is on the public board")
}
}
// TestRosterTokenIsNotAnEventToken: the board token must not be one of the
// player's event tokens, or a standing row would become the key that links all
// their dispatches back together — the exact unlinkability eventToken exists to
// provide.
func TestRosterTokenIsNotAnEventToken(t *testing.T) {
newBoredomTestDB(t)
uid := id.UserID("@josie:test")
board := eventToken(uid, "roster")
if board == eventToken(uid, "arrival") || board == eventToken(uid, "death:crypt:1") {
t.Error("board token collides with an event token — the board would deanonymize the feed")
}
if board != eventToken(uid, "roster") {
t.Error("board token not stable — the row would churn identity every snapshot")
}
}