diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index 87951a3..155eb6d 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -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 // bearer-POST pattern in email_nag.go:sendCode. 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)) if err != nil { return err diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 66fa45b..d717e38 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -285,6 +285,7 @@ func (p *AdventurePlugin) Init() error { go p.expeditionRecapTicker() go p.expeditionAmbientTicker() go p.expeditionAutoRunTicker() + go p.peteRosterTicker() go p.expeditionExtractionSweepTicker() go p.expeditionBoredomTicker() diff --git a/internal/plugin/expedition_boredom.go b/internal/plugin/expedition_boredom.go index 0c23633..590a212 100644 --- a/internal/plugin/expedition_boredom.go +++ b/internal/plugin/expedition_boredom.go @@ -286,6 +286,7 @@ func (p *AdventurePlugin) tryBoredomStart(uid id.UserID, now time.Time) error { slog.Info("boredom: adventurer left on its own", "user", uid, "zone", zone.ID, "level", c.Level, "cost", int(cost)) + emitBoredomDeparture(uid, zone, c.Level) return p.SendDM(uid, renderBoredomDepartureDM(zone, supplies, purchase)) } diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go index d5e585f..5266a3c 100644 --- a/internal/plugin/pete.go +++ b/internal/plugin/pete.go @@ -268,6 +268,35 @@ func claimRealmFirst(kind, target string) bool { // 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 // 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) { if !peteclient.Enabled() || !newsEmissionOn() { return diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go new file mode 100644 index 0000000..e3761c7 --- /dev/null +++ b/internal/plugin/pete_roster.go @@ -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 +} diff --git a/internal/plugin/pete_roster_test.go b/internal/plugin/pete_roster_test.go new file mode 100644 index 0000000..957375b --- /dev/null +++ b/internal/plugin/pete_roster_test.go @@ -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") + } +}