mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
156
internal/plugin/pete_roster.go
Normal file
156
internal/plugin/pete_roster.go
Normal 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
|
||||||
|
}
|
||||||
127
internal/plugin/pete_roster_test.go
Normal file
127
internal/plugin/pete_roster_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user