diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 91952d3..51e1411 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -253,6 +253,12 @@ func (p *AdventurePlugin) Init() error { // deaths, single-holder achievements) the first boot the seam is live, so // launch doesn't open onto an empty section. One-shot, kept (see gap #7). p.bootstrapPeteNewsBackfill() + // Repair the zone half of news_realm_firsts: the original one-shot filtered + // on `abandoned = 0` (which does not mean anybody gave up) and dated every + // row to the minute it ran. Seeds only, emits nothing. Runs regardless of the + // news switches — a ledger that is right only while emission is on mis-tiers + // the first dispatch after somebody flips it. One-shot, kept. + bootstrapRealmFirstsReseed() // Phase R1 orphan-archive used to run here on every Init, but it // over-archived: it treats any active dnd_zone_run row not linked to // an active expedition as a legacy `!adventure dungeon` orphan, which diff --git a/internal/plugin/bootstrap_pete_news.go b/internal/plugin/bootstrap_pete_news.go index baa19f8..c647538 100644 --- a/internal/plugin/bootstrap_pete_news.go +++ b/internal/plugin/bootstrap_pete_news.go @@ -58,34 +58,104 @@ func (p *AdventurePlugin) bootstrapPeteNewsBackfill() { "zone_firsts", firsts, "deaths", deaths, "achievements", achv) } -// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY -// realm-first dispatch per zone, attributed to its earliest boss-defeating -// clearer. SQLite returns the user_id from the same row as MIN(completed_at) -// (bare-column min/max rule), so the (zone, first clearer, time) triple is -// consistent. Returns the count emitted. -func (p *AdventurePlugin) backfillZoneFirsts() int { +// zoneFirstClear is one zone's earliest boss kill: who did it and when. +type zoneFirstClear struct { + zoneID, userID, completedAt string +} + +// zoneFirstClears reads the earliest boss-defeating run of every zone. +// +// The filter is `boss_defeated = 1` and NOTHING else, and that is the whole +// point. `abandoned` does not mean anybody gave up — abandonZoneRunByID exists +// to retire a run whose boss is ALREADY DEAD when the expedition travels onward +// (dnd_zone_run.go), so in prod 30 of 32 boss kills carry abandoned = 1. An +// `AND abandoned = 0` here drew a realm where 2 zones had ever been beaten +// instead of 9. It is the same filter loadRealmClearStats documents; do not +// reintroduce it. +// +// SQLite returns the user_id from the same row as MIN(completed_at) (bare-column +// min/max rule), so the (zone, first clearer, time) triple is internally +// consistent. +func zoneFirstClears() []zoneFirstClear { rows, err := db.Get().Query( `SELECT zone_id, user_id, MIN(completed_at) FROM dnd_zone_run - WHERE boss_defeated = 1 AND completed_at IS NOT NULL AND abandoned = 0 + WHERE boss_defeated = 1 AND completed_at IS NOT NULL GROUP BY zone_id`) if err != nil { slog.Error("backfill: zone-firsts query", "err", err) - return 0 + return nil } - type first struct { - zoneID, userID, completedAt string - } - var firsts []first + defer rows.Close() + + var firsts []zoneFirstClear for rows.Next() { - var f first + var f zoneFirstClear if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil { slog.Error("backfill: zone-firsts scan", "err", err) continue } firsts = append(firsts, f) } - rows.Close() + return firsts +} + +// bootstrapRealmFirstsReseed repairs the zone half of news_realm_firsts. +// +// The ledger is what claimRealmFirst tiers live dispatches against, so a zone +// whose first clear the original one-shot missed is a spurious PRIORITY "realm +// first" waiting to fire the next time somebody clears it, months after the +// fact. Two things were wrong with what got seeded: +// +// 1. The `abandoned = 0` filter above, which is why prod holds 6 zones where +// the run history knows 9. +// 2. first_at is claimRealmFirst's unixepoch() — when the claim was RECORDED, +// not when the clear happened. Every backfilled prod row carries the one +// minute the job ran. +// +// This is a re-SEED, not a re-run: it writes the ledger and emits nothing at +// all, so no historical realm-first dispatch reaches the room. It has its own +// job name because the original one-shot's gate is already marked, and per +// feedback_loader_rewire_needs_bootstrap it stays in place afterwards — a fresh +// deploy runs it as an ordinary bootstrap. +// +// It runs unconditionally on the news seam's switches, unlike the backfill: a +// ledger that is correct only when emission happens to be on is a ledger that +// mis-tiers the first dispatch after somebody flips it. +// +// A zone claim with no surviving run behind it is left exactly as it is. The run +// history is the better record of both who and when, but only where it has one. +func bootstrapRealmFirstsReseed() { + const jobName = "pete_realm_firsts_reseed_v1" + if db.JobCompleted(jobName, "once") { + return + } + + seeded := 0 + for _, f := range zoneFirstClears() { + ts, ok := parseSQLiteTime(f.completedAt) + if !ok { + slog.Warn("reseed: unparseable clear time", "zone", f.zoneID, "at", f.completedAt) + continue + } + // Upsert, not INSERT OR IGNORE: the six rows that already exist carry the + // wrong date and correcting them is half of what this job is for. + db.Exec("realm-firsts reseed", + `INSERT INTO news_realm_firsts (kind, target, first_at) VALUES ('zone', ?, ?) + ON CONFLICT(kind, target) DO UPDATE SET first_at = excluded.first_at`, + f.zoneID, ts.Unix()) + seeded++ + } + + db.MarkJobCompleted(jobName, "once") + slog.Warn("bootstrap: realm-firsts ledger reseeded", "zones", seeded) +} + +// backfillZoneFirsts seeds news_realm_firsts from history and emits one +// dispatch per zone, attributed to its earliest boss-defeating clearer. +// Returns the count emitted. +func (p *AdventurePlugin) backfillZoneFirsts() int { + firsts := zoneFirstClears() n := 0 for _, f := range firsts { diff --git a/internal/plugin/bootstrap_realm_firsts_test.go b/internal/plugin/bootstrap_realm_firsts_test.go new file mode 100644 index 0000000..47c4fdf --- /dev/null +++ b/internal/plugin/bootstrap_realm_firsts_test.go @@ -0,0 +1,156 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" +) + +// ledgerFirstAt reads a zone's recorded claim time, or -1 if the ledger has no +// row for it at all. +func ledgerFirstAt(t *testing.T, zoneID string) int64 { + t.Helper() + var at int64 + err := db.Get().QueryRow( + `SELECT first_at FROM news_realm_firsts WHERE kind = 'zone' AND target = ?`, + zoneID).Scan(&at) + if err != nil { + return -1 + } + return at +} + +func unixOf(t *testing.T, sqliteTime string) int64 { + t.Helper() + ts, ok := parseSQLiteTime(sqliteTime) + if !ok { + t.Fatalf("parseSQLiteTime(%q) failed", sqliteTime) + } + return ts.Unix() +} + +// TestReseedClaimsAZoneWhoseClearsWereAllRetired is the regression for the bug +// that made this job necessary: every clear of forest_shadows carries +// abandoned = 1, which is how the game stores a kill the expedition walked on +// from, and the original one-shot's `abandoned = 0` filter therefore never saw +// the zone at all. An unclaimed zone is a spurious PRIORITY "realm first" +// waiting to fire the next time somebody clears it, months after the fact — so +// the assertion that matters is the claimRealmFirst one at the end. +func TestReseedClaimsAZoneWhoseClearsWereAllRetired(t *testing.T) { + seedRealmFixture(t) + + db.Exec("seed retired-only zone", `INSERT INTO dnd_zone_run + (run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at) + VALUES ('r7', '@josie:x', 'forest_shadows', 6, 1, 1, '2026-02-14 09:00:00')`) + + if got := ledgerFirstAt(t, "forest_shadows"); got != -1 { + t.Fatalf("forest_shadows already claimed before the reseed (first_at=%d) — fixture drift", got) + } + + bootstrapRealmFirstsReseed() + + if got := ledgerFirstAt(t, "forest_shadows"); got != unixOf(t, "2026-02-14 09:00:00") { + t.Errorf("forest_shadows first_at = %d, want %d (the real clear, not the minute the job ran)", + got, unixOf(t, "2026-02-14 09:00:00")) + } + // The point of the whole job. Before the reseed this returns true and the + // next clear of a zone beaten in February announces itself as a realm first. + if claimRealmFirst("zone", "forest_shadows") { + t.Error("forest_shadows was still unclaimed after the reseed — the next clear would fire a spurious realm-first") + } +} + +// TestReseedCorrectsTheBackfillsDates pins the second half. claimRealmFirst +// stamps unixepoch(), so every row the original one-shot wrote carries the one +// minute that job ran — in prod, all six share the identical timestamp. The +// reseed has to overwrite an existing row, not INSERT OR IGNORE past it. +func TestReseedCorrectsTheBackfillsDates(t *testing.T) { + seedRealmFixture(t) + + // The fixture claims both zones the way the backfill did: at claim time. + before := ledgerFirstAt(t, "goblin_warrens") + if before < time.Now().Unix()-300 { + t.Fatalf("fixture claim for goblin_warrens is not a now-stamp (%d) — fixture drift", before) + } + + bootstrapRealmFirstsReseed() + + // Josie's r1, January, not r2 or r3 and not today. + if got, want := ledgerFirstAt(t, "goblin_warrens"), unixOf(t, "2026-01-10 12:00:00"); got != want { + t.Errorf("goblin_warrens first_at = %d, want %d (earliest real clear)", got, want) + } + // crypt_valdris has a clean clear (r4) and a retired-but-won one (r5). The + // earliest is r4. + if got, want := ledgerFirstAt(t, "crypt_valdris"), unixOf(t, "2026-05-01 12:00:00"); got != want { + t.Errorf("crypt_valdris first_at = %d, want %d", got, want) + } +} + +// TestReseedEmitsNothing is the reason this is a re-seed and not a re-run of the +// backfill. The ledger has to be repaired without any historical realm-first +// dispatch reaching the room; a zone beaten in February is not news in July. +func TestReseedEmitsNothing(t *testing.T) { + seedRealmFixture(t) + db.Exec("seed retired-only zone", `INSERT INTO dnd_zone_run + (run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at) + VALUES ('r7', '@josie:x', 'forest_shadows', 6, 1, 1, '2026-02-14 09:00:00')`) + + bootstrapRealmFirstsReseed() + + var queued int + if err := db.Get().QueryRow(`SELECT COUNT(*) FROM pete_emit_queue`).Scan(&queued); err != nil { + t.Fatalf("count pete_emit_queue: %v", err) + } + if queued != 0 { + t.Errorf("reseed queued %d dispatches, want 0 — the ledger repair must be silent", queued) + } +} + +// TestReseedIsAOneShot. It is a bootstrap kept in place for fresh deploys (per +// feedback_loader_rewire_needs_bootstrap), so it runs on every start and must +// cost nothing after the first — and, more importantly, must not undo a +// later live claim by rewriting the ledger from stale history on every boot. +func TestReseedIsAOneShot(t *testing.T) { + seedRealmFixture(t) + bootstrapRealmFirstsReseed() + + // A zone cleared after the reseed, claimed live. + if !claimRealmFirst("zone", "sunken_temple") { + t.Fatal("sunken_temple should have been an unclaimed realm-first") + } + live := ledgerFirstAt(t, "sunken_temple") + + bootstrapRealmFirstsReseed() + + if got := ledgerFirstAt(t, "sunken_temple"); got != live { + t.Errorf("second reseed moved a live claim: %d -> %d", live, got) + } + if claimRealmFirst("zone", "goblin_warrens") { + t.Error("second reseed dropped an existing claim") + } +} + +// TestZoneFirstClearsCountsRetiredKills guards the shared query itself, which +// the kept backfill also uses. `abandoned` means the run row was retired, not +// that anybody gave up. +func TestZoneFirstClearsCountsRetiredKills(t *testing.T) { + seedRealmFixture(t) + db.Exec("seed retired-only zone", `INSERT INTO dnd_zone_run + (run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at) + VALUES ('r7', '@josie:x', 'forest_shadows', 6, 1, 1, '2026-02-14 09:00:00')`) + + byZone := map[string]zoneFirstClear{} + for _, f := range zoneFirstClears() { + byZone[f.zoneID] = f + } + if len(byZone) != 3 { + t.Fatalf("zoneFirstClears returned %d zones, want 3 (a regression to `abandoned = 0` gives 2)", len(byZone)) + } + if got := byZone["forest_shadows"].userID; got != "@josie:x" { + t.Errorf("forest_shadows first clearer = %q, want @josie:x", got) + } + if _, ok := byZone["arena"]; ok { + t.Error("an unfinished run counted as a clear") + } +}