mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
Four things the review found, all of them the code telling a player something that isn't true. A run that walks into a dead end filed its ending as "cleared" whatever the caller said, so both the liveblog and the summary reported a clear for a party that merely ran out of map — and the end beat is first-writer-wins, so nothing later could take it back. It says "cleared" only for a boss now. The re-offer branches of babysit and resume returned a zero cost, so a player who was in fact charged read "0 coins" in the verdict. Both re-quote the price they actually took. And the realm-firsts reseed retired its one-shot job even when the read under it had failed, which on a transient fault at Init would have left the ledger mis-dated permanently. The read now says whether it worked, and the job stays open when it didn't. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
323 lines
10 KiB
Go
323 lines
10 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
"gogobee/internal/peteclient"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// bootstrapPeteNewsBackfill replays the back-catalogue through the news emit
|
|
// path the first time the seam comes up enabled, so a launch doesn't open onto
|
|
// an empty section. It covers the three tractable, high-signal sources:
|
|
//
|
|
// 1. Realm-first zone clears — the earliest boss-defeated run of each zone. This
|
|
// also SEEDS news_realm_firsts, so a later live clear of an already-cleared
|
|
// zone tiers as a BULLETIN repeat, not a mis-announced "first ever".
|
|
// 2. Deaths — every player_meta row that carries a death (graveyard content).
|
|
// 3. Single-holder achievements — rarity-gated to one holder, matching the live
|
|
// milestone rule so routine unlocks don't flood the backlog.
|
|
//
|
|
// Every fact here is a BULLETIN, like every fact gogobee files (1cbd68a). Priority
|
|
// is the one tier that makes Pete post a live Matrix beat, and TwinBee already
|
|
// narrates these moments in the games room as they happen — a priority fact would
|
|
// have Pete read his version back to the people who watched it. Nothing in the
|
|
// back catalogue is news, least of all a death from three weeks ago.
|
|
//
|
|
// NoPush independently short-circuits Pete's beat today, so this is belt AND
|
|
// braces: the tier is the rule, and it must not depend on NoPush staying in front
|
|
// of it. Rendering keys off event_type, not tier, so nothing on the site moves.
|
|
//
|
|
// Backfilled facts are backdated to when they happened and marked NoPush (no
|
|
// web-push spam on launch). GUIDs are deterministic (keyed on the historical
|
|
// timestamp), so a re-run — or a later live emit of the same event — dedups on
|
|
// Pete's side. Guarded one-shot; kept (never deleted at close-out) so a fresh
|
|
// deploy doesn't ghost-town again. See pete_adventure_news_plan.md gap #7.
|
|
func (p *AdventurePlugin) bootstrapPeteNewsBackfill() {
|
|
const jobName = "pete_news_backfill_v1"
|
|
if db.JobCompleted(jobName, "once") {
|
|
return
|
|
}
|
|
// The backfill IS the launch content, so it should fire the first boot the
|
|
// seam is actually live — not get silently consumed on a boot where emission
|
|
// is off. Leave the job unmarked until both switches are on.
|
|
if !peteclient.Enabled() || !newsEmissionOn() {
|
|
return
|
|
}
|
|
|
|
firsts := p.backfillZoneFirsts()
|
|
deaths := backfillDeaths()
|
|
achv := p.backfillSoloAchievements()
|
|
|
|
db.MarkJobCompleted(jobName, "once")
|
|
slog.Warn("bootstrap: pete news backfill complete",
|
|
"zone_firsts", firsts, "deaths", deaths, "achievements", achv)
|
|
}
|
|
|
|
// 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.
|
|
// ok is false only when the read itself failed. A caller that is about to mark
|
|
// a one-shot job complete has to be able to tell "no zone has ever been cleared"
|
|
// from "the query fell over", or a transient DB fault at boot retires the repair
|
|
// permanently.
|
|
func zoneFirstClears() (firsts []zoneFirstClear, ok bool) {
|
|
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
|
|
GROUP BY zone_id`)
|
|
if err != nil {
|
|
slog.Error("backfill: zone-firsts query", "err", err)
|
|
return nil, false
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
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)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Error("backfill: zone-firsts rows", "err", err)
|
|
return nil, false
|
|
}
|
|
return firsts, true
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
clears, ok := zoneFirstClears()
|
|
if !ok {
|
|
// The read failed. Leave the job unmarked so the next boot tries again —
|
|
// marking it here would retire the repair on the strength of a transient
|
|
// DB fault and leave the ledger wrong forever.
|
|
return
|
|
}
|
|
|
|
seeded := 0
|
|
for _, f := range clears {
|
|
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 {
|
|
uid := id.UserID(f.userID)
|
|
// Seed the ledger regardless of whether we can render a dispatch, so the
|
|
// realm-first tiering is correct even for an unnamed straggler.
|
|
claimRealmFirst("zone", f.zoneID)
|
|
|
|
name := charName(uid)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
ts, ok := parseSQLiteTime(f.completedAt)
|
|
if !ok {
|
|
continue
|
|
}
|
|
zone := zoneOrFallback(ZoneID(f.zoneID))
|
|
lvl := charLevel(uid)
|
|
emitFact(peteclient.Fact{
|
|
GUID: fmt.Sprintf("zone_first:%s:%s:%d", eventToken(uid, fmt.Sprintf("%s:%d", f.zoneID, ts.Unix())), f.zoneID, ts.Unix()),
|
|
EventType: "zone_first",
|
|
Tier: "bulletin",
|
|
Subject: name,
|
|
Zone: zone.Display,
|
|
Boss: zone.Boss.Name,
|
|
Level: lvl,
|
|
Outcome: "cleared",
|
|
OccurredAt: ts.Unix(),
|
|
NoPush: true,
|
|
}, uid, "")
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
// backfillDeaths emits one death dispatch per player_meta death record. Deaths
|
|
// are day-granular (last_death_date), so the dispatch is backdated to that day.
|
|
func backfillDeaths() int {
|
|
rows, err := db.Get().Query(
|
|
`SELECT user_id, last_death_date, death_location
|
|
FROM player_meta
|
|
WHERE last_death_date != ''`)
|
|
if err != nil {
|
|
slog.Error("backfill: deaths query", "err", err)
|
|
return 0
|
|
}
|
|
type death struct {
|
|
userID, date, location string
|
|
}
|
|
var deaths []death
|
|
for rows.Next() {
|
|
var d death
|
|
if err := rows.Scan(&d.userID, &d.date, &d.location); err != nil {
|
|
slog.Error("backfill: deaths scan", "err", err)
|
|
continue
|
|
}
|
|
deaths = append(deaths, d)
|
|
}
|
|
rows.Close()
|
|
|
|
n := 0
|
|
for _, d := range deaths {
|
|
uid := id.UserID(d.userID)
|
|
name := charName(uid)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
day, err := time.Parse("2006-01-02", d.date)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
lvl := charLevel(uid)
|
|
emitFact(peteclient.Fact{
|
|
GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), d.date),
|
|
EventType: "death",
|
|
Tier: "bulletin",
|
|
Subject: name,
|
|
Zone: d.location,
|
|
Level: lvl,
|
|
Outcome: "lost",
|
|
OccurredAt: day.UTC().Unix(),
|
|
NoPush: true,
|
|
}, uid, "")
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
// backfillSoloAchievements emits a milestone dispatch for every achievement held
|
|
// by exactly one player — the same rarity gate the live path uses — so the
|
|
// backlog carries the genuinely distinctive unlocks, not routine ones.
|
|
func (p *AdventurePlugin) backfillSoloAchievements() int {
|
|
rows, err := db.Get().Query(
|
|
`SELECT achievement_id, user_id, unlocked_at
|
|
FROM achievements
|
|
GROUP BY achievement_id
|
|
HAVING COUNT(*) = 1`)
|
|
if err != nil {
|
|
slog.Error("backfill: achievements query", "err", err)
|
|
return 0
|
|
}
|
|
type solo struct {
|
|
achID, userID string
|
|
unlockedAt int64
|
|
}
|
|
var solos []solo
|
|
for rows.Next() {
|
|
var s solo
|
|
if err := rows.Scan(&s.achID, &s.userID, &s.unlockedAt); err != nil {
|
|
slog.Error("backfill: achievements scan", "err", err)
|
|
continue
|
|
}
|
|
solos = append(solos, s)
|
|
}
|
|
rows.Close()
|
|
|
|
n := 0
|
|
for _, s := range solos {
|
|
uid := id.UserID(s.userID)
|
|
name := charName(uid)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
label := s.achID
|
|
if p.achievements != nil {
|
|
for _, a := range p.achievements.achievements {
|
|
if a.ID == s.achID {
|
|
label = a.Name
|
|
break
|
|
}
|
|
}
|
|
}
|
|
ts := s.unlockedAt
|
|
if ts == 0 {
|
|
ts = nowUnix()
|
|
}
|
|
emitFact(peteclient.Fact{
|
|
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(uid, s.achID), s.achID),
|
|
EventType: "milestone",
|
|
Tier: "bulletin",
|
|
Subject: name,
|
|
Milestone: label,
|
|
OccurredAt: ts,
|
|
NoPush: true,
|
|
}, uid, "")
|
|
n++
|
|
}
|
|
return n
|
|
}
|