mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
adventure: repair the realm-firsts ledger the backfill got wrong
The one-shot that seeded news_realm_firsts filtered on `abandoned = 0`, and `abandoned` does not mean anybody gave up: abandonZoneRunByID exists to retire a run whose boss is already dead when the expedition travels onward, which is how 30 of prod's 32 boss kills are stored. So the ledger holds 6 zones where the run history knows 9, and it dates every row to the minute the job ran rather than to the clear. The ledger is what claimRealmFirst tiers live dispatches against, so each missed zone is a spurious PRIORITY "realm first" waiting to fire the next time somebody clears it, months late. Fix the shared query, and add a re-seed (not a re-run) under its own job name: it writes the ledger, backdated to the real earliest clear, and emits nothing at all. It runs regardless of the news switches, because a ledger that is right only while emission is on mis-tiers the first dispatch after somebody flips it. Kept in place afterwards as an ordinary bootstrap for fresh deploys. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user