mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
Adds the realm snapshot behind Pete's map, board and hall of firsts: every zone with its clear history and who is standing in it, the ledger of things that have happened exactly once, and one line per adventurer. Snapshot semantics like the roster and the Siege — pushed whole, replaces Pete's copy, dropped rather than retried. It rides the roster ticker but at a ten-minute stride: these are aggregate scans over the whole run history, and a first clear does not move. Two things the real data corrected, neither of which any unit test would have caught: `abandoned` does not mean the player gave up. It means the run row was retired, and abandonZoneRunByID exists precisely to retire a run whose boss is already dead when the expedition travels on. 30 of prod's 32 boss kills carry abandoned = 1, so filtering on `abandoned = 0` — as this did, copying the shipped backfill — drew a realm in which almost nothing had ever been beaten. boss_defeated = 1 is the clear. And news_realm_firsts is not a usable history book on its own. It only covers what happened after the news seam went live, its one-shot seeder carried that same wrong filter, and its first_at is when the claim was written down, not when the thing happened — every backfilled row in prod shares one timestamp. So the zone half of the hall is derived from the run history instead, which is complete and correctly dated, and the ledger supplies the kinds the run history knows nothing about. That also makes the hall agree with the board by construction rather than by coincidence. Opt-out follows the rule that fits each surface: a first-clearer is anonymised (deleting the claim would redraw a conquered zone as unbeaten), a player is dropped from the board outright, and presence is dropped entirely — who is in a dungeon right now is the live-location fact the liveblog already refuses. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
352 lines
13 KiB
Go
352 lines
13 KiB
Go
package plugin
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// seedRealmFixture builds a small but realistic realm: two players, three
|
|
// cleared runs across two zones, one live expedition, and the realm-first ledger
|
|
// that the zone clears would have seeded.
|
|
func seedRealmFixture(t *testing.T) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatalf("db.Init: %v", err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
|
|
db.Exec("seed josie", `INSERT INTO player_meta (user_id, display_name, alive) VALUES (?, ?, 1)`,
|
|
"@josie:x", "Josie")
|
|
db.Exec("seed quack", `INSERT INTO player_meta (user_id, display_name, alive) VALUES (?, ?, 1)`,
|
|
"@quack:x", "Quack")
|
|
|
|
// The board walks player_meta and then loads a character, so both halves have
|
|
// to exist: a player_meta row with no character is somebody who never finished
|
|
// setup, and standings correctly leaves them off.
|
|
for _, c := range []*DnDCharacter{
|
|
{UserID: "@josie:x", Race: RaceHuman, Class: ClassFighter, Level: 12,
|
|
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
|
HPMax: 120, HPCurrent: 120, ArmorClass: 18},
|
|
{UserID: "@quack:x", Race: RaceElf, Class: ClassMage, Level: 8,
|
|
STR: 8, DEX: 16, CON: 12, INT: 18, WIS: 12, CHA: 10,
|
|
HPMax: 48, HPCurrent: 48, ArmorClass: 12},
|
|
} {
|
|
if err := SaveDnDCharacter(c); err != nil {
|
|
t.Fatalf("SaveDnDCharacter(%s): %v", c.UserID, err)
|
|
}
|
|
}
|
|
|
|
// Josie cleared the Warrens first, then again; Quack cleared them later. Only
|
|
// Josie has been through the Crypt — and it is a deeper tier, which is what
|
|
// makes the board's depth-before-breadth ordering testable.
|
|
for _, r := range []struct {
|
|
runID, user, zone, at string
|
|
}{
|
|
{"r1", "@josie:x", string(ZoneGoblinWarrens), "2026-01-10 12:00:00"},
|
|
{"r2", "@quack:x", string(ZoneGoblinWarrens), "2026-03-02 12:00:00"},
|
|
{"r3", "@josie:x", string(ZoneGoblinWarrens), "2026-04-05 12:00:00"},
|
|
{"r4", "@josie:x", string(ZoneCryptValdris), "2026-05-01 12:00:00"},
|
|
} {
|
|
db.Exec("seed run", `INSERT INTO dnd_zone_run
|
|
(run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at)
|
|
VALUES (?, ?, ?, 6, 1, 0, ?)`, r.runID, r.user, r.zone, r.at)
|
|
}
|
|
// A retired-but-won run: boss_defeated = 1 with abandoned = 1. This IS a
|
|
// clear — `abandoned` means the run row was retired (the expedition travelled
|
|
// on after the kill), not that anybody gave up, and in prod it is how 30 of
|
|
// the realm's 32 boss kills are stored. The fixture carries one so the
|
|
// aggregate can never quietly go back to filtering them out.
|
|
db.Exec("seed retired-win", `INSERT INTO dnd_zone_run
|
|
(run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at)
|
|
VALUES ('r5', '@quack:x', 'crypt_valdris', 6, 1, 1, '2026-05-02 12:00:00')`)
|
|
// A genuinely unfinished run: no boss, no completion. Not a clear.
|
|
db.Exec("seed inflight", `INSERT INTO dnd_zone_run
|
|
(run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at)
|
|
VALUES ('r6', '@quack:x', 'crypt_valdris', 6, 0, 0, NULL)`)
|
|
|
|
claimRealmFirst("zone", string(ZoneGoblinWarrens))
|
|
claimRealmFirst("zone", string(ZoneCryptValdris))
|
|
}
|
|
|
|
// TestRealmClearStatsPickTheEarliestClearer is the load-bearing query on the
|
|
// whole map: "who first got through here" is the single most interesting fact a
|
|
// zone has, and it has to be the person who was actually first.
|
|
//
|
|
// It leans on SQLite's bare-column min/max rule — the user_id comes from the same
|
|
// row MIN(completed_at) came from — which is the same thing backfillZoneFirsts
|
|
// has relied on since the news seam shipped. If that ever stopped holding, this
|
|
// would attribute somebody else's conquest to whoever the grouping happened to
|
|
// land on, silently.
|
|
func TestRealmClearStatsPickTheEarliestClearer(t *testing.T) {
|
|
seedRealmFixture(t)
|
|
|
|
stats, err := loadRealmClearStats()
|
|
if err != nil {
|
|
t.Fatalf("loadRealmClearStats: %v", err)
|
|
}
|
|
|
|
warrens, ok := stats["goblin_warrens"]
|
|
if !ok {
|
|
t.Fatal("no stats for goblin_warrens")
|
|
}
|
|
if warrens.clears != 3 {
|
|
t.Errorf("warrens clears = %d, want 3", warrens.clears)
|
|
}
|
|
if warrens.clearers != 2 {
|
|
t.Errorf("warrens clearers = %d, want 2", warrens.clearers)
|
|
}
|
|
if warrens.firstUser != id.UserID("@josie:x") {
|
|
t.Errorf("warrens first clearer = %q, want @josie:x — the earliest run didn't win", warrens.firstUser)
|
|
}
|
|
|
|
// Two clears: Josie's, and Quack's retired-but-won run. The in-flight run is
|
|
// correctly excluded. A regression to `AND abandoned = 0` shows up here as 1.
|
|
crypt := stats["crypt_valdris"]
|
|
if crypt.clears != 2 {
|
|
t.Errorf("crypt clears = %d, want 2 — a won-then-retired run is a clear, "+
|
|
"and an in-flight one is not", crypt.clears)
|
|
}
|
|
if crypt.firstUser != id.UserID("@josie:x") {
|
|
t.Errorf("crypt first clearer = %q, want @josie:x", crypt.firstUser)
|
|
}
|
|
}
|
|
|
|
// TestOptedOutFirstClearerIsAnonymisedNotErased. The Siege contributor rule,
|
|
// applied to the map: deleting an opted-out clearer's claim would leave the zone
|
|
// drawn as never-cleared, and "nobody has ever come out of there" is the most
|
|
// dramatic thing the page can say. Saying it falsely because somebody chose
|
|
// privacy would be worse than saying nothing.
|
|
func TestOptedOutFirstClearerIsAnonymisedNotErased(t *testing.T) {
|
|
seedRealmFixture(t)
|
|
setNewsOptout(id.UserID("@josie:x"), true)
|
|
|
|
snap, err := buildRealmSnapshot(time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("buildRealmSnapshot: %v", err)
|
|
}
|
|
|
|
var warrens *struct {
|
|
clears int
|
|
by, token string
|
|
}
|
|
for _, z := range snap.Zones {
|
|
if z.ID == "goblin_warrens" {
|
|
warrens = &struct {
|
|
clears int
|
|
by, token string
|
|
}{z.Clears, z.FirstClearBy, z.FirstClearToken}
|
|
}
|
|
}
|
|
if warrens == nil {
|
|
t.Fatal("goblin_warrens is not in the snapshot at all")
|
|
}
|
|
if warrens.clears != 3 {
|
|
t.Errorf("clears = %d, want 3 — an opt-out deleted the town's history", warrens.clears)
|
|
}
|
|
if warrens.by != "" || warrens.token != "" {
|
|
t.Errorf("opted-out clearer still named: by=%q token=%q", warrens.by, warrens.token)
|
|
}
|
|
}
|
|
|
|
// TestOptedOutPlayerLeavesTheBoardEntirely. Standings follow the board's rule,
|
|
// not the Siege's: an opted-out player is omitted outright. Their level, class
|
|
// and clear count would re-identify them, and unlike a siege contribution there
|
|
// is no shared total that stops adding up without them.
|
|
func TestOptedOutPlayerLeavesTheBoardEntirely(t *testing.T) {
|
|
seedRealmFixture(t)
|
|
setNewsOptout(id.UserID("@quack:x"), true)
|
|
|
|
snap, err := buildRealmSnapshot(time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("buildRealmSnapshot: %v", err)
|
|
}
|
|
for _, s := range snap.Standings {
|
|
if s.Name == "Quack" {
|
|
t.Fatal("an opted-out player is still on the standings board")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOccupantsDropOptedOutPlayers. Presence is the strictest case on the page
|
|
// and deliberately stricter than a first clear: "who is in the Crypt of Valdris
|
|
// right now" is the live-location fact the run liveblog refuses to publish at
|
|
// all, so an opted-out player is dropped rather than anonymised.
|
|
func TestOccupantsDropOptedOutPlayers(t *testing.T) {
|
|
seedRealmFixture(t)
|
|
db.Exec("seed expedition", `INSERT INTO dnd_expedition
|
|
(expedition_id, user_id, zone_id, status, current_day)
|
|
VALUES ('e1', '@josie:x', 'crypt_valdris', 'active', 3)`)
|
|
|
|
if occ := loadRealmOccupants(); len(occ["crypt_valdris"]) != 1 {
|
|
t.Fatalf("opted-in occupant missing: %+v", occ)
|
|
} else if occ["crypt_valdris"][0].Name != "Josie" || occ["crypt_valdris"][0].Day != 3 {
|
|
t.Errorf("occupant = %+v, want Josie on day 3", occ["crypt_valdris"][0])
|
|
}
|
|
|
|
setNewsOptout(id.UserID("@josie:x"), true)
|
|
if occ := loadRealmOccupants(); len(occ["crypt_valdris"]) != 0 {
|
|
t.Errorf("opted-out player still shows as standing in a zone: %+v", occ["crypt_valdris"])
|
|
}
|
|
}
|
|
|
|
// TestStandingsCountFirstsFromTheSameAuthorityTheMapDoes. A zone's "first
|
|
// through: Josie" and Josie's own firsts count come off one map in one pass, so
|
|
// the two can never disagree — which they would if the board recounted the
|
|
// ledger itself and the two queries drifted.
|
|
func TestStandingsCountFirstsFromTheSameAuthorityTheMapDoes(t *testing.T) {
|
|
seedRealmFixture(t)
|
|
|
|
snap, err := buildRealmSnapshot(time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("buildRealmSnapshot: %v", err)
|
|
}
|
|
|
|
firstsByName := map[string]int{}
|
|
for _, s := range snap.Standings {
|
|
firstsByName[s.Name] = s.Firsts
|
|
}
|
|
// Josie was first through both zones; Quack was first through neither.
|
|
if firstsByName["Josie"] != 2 {
|
|
t.Errorf("Josie holds %d firsts, want 2", firstsByName["Josie"])
|
|
}
|
|
if firstsByName["Quack"] != 0 {
|
|
t.Errorf("Quack holds %d firsts, want 0", firstsByName["Quack"])
|
|
}
|
|
|
|
named := 0
|
|
for _, z := range snap.Zones {
|
|
if z.FirstClearBy == "Josie" {
|
|
named++
|
|
}
|
|
}
|
|
if named != firstsByName["Josie"] {
|
|
t.Errorf("the map names Josie on %d zones but the board credits her with %d — "+
|
|
"the two disagree about the same fact", named, firstsByName["Josie"])
|
|
}
|
|
}
|
|
|
|
// TestStandingsRankDeepestFirst. The ordering is the game's statement about what
|
|
// it values, and Pete renders it without renumbering — so it has to be right
|
|
// here. Depth beats breadth: somebody who has put down a Tier 5 boss is ahead of
|
|
// somebody who has cleared the whole of Tier 1 forty times.
|
|
func TestStandingsRankDeepestFirst(t *testing.T) {
|
|
seedRealmFixture(t)
|
|
|
|
rows, err := loadRealmStandings(map[string]realmClearStats{})
|
|
if err != nil {
|
|
t.Fatalf("loadRealmStandings: %v", err)
|
|
}
|
|
if len(rows) < 2 {
|
|
t.Fatalf("got %d standings rows, want 2", len(rows))
|
|
}
|
|
for i := 1; i < len(rows); i++ {
|
|
a, b := rows[i-1], rows[i]
|
|
if a.DeepestTier < b.DeepestTier {
|
|
t.Errorf("row %d (T%d) sorts above row %d (T%d) — the board is not deepest-first",
|
|
i-1, a.DeepestTier, i, b.DeepestTier)
|
|
}
|
|
if a.DeepestTier == b.DeepestTier && a.Zones < b.Zones {
|
|
t.Errorf("equal depth but row %d covers %d zones above row %d's %d",
|
|
i-1, a.Zones, i, b.Zones)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFirstsLedgerIsRenderedNotJustCounted. news_realm_firsts has existed since
|
|
// the news seam shipped and has only ever been used to decide a dispatch tier —
|
|
// the ledger itself was never read back. This is the whole point of the hall: it
|
|
// is a history book, and every row needs a name and a date on it.
|
|
func TestFirstsLedgerIsRenderedNotJustCounted(t *testing.T) {
|
|
seedRealmFixture(t)
|
|
|
|
stats, err := loadRealmClearStats()
|
|
if err != nil {
|
|
t.Fatalf("loadRealmClearStats: %v", err)
|
|
}
|
|
firsts := loadRealmFirsts(stats)
|
|
if len(firsts) != 2 {
|
|
t.Fatalf("got %d firsts, want 2", len(firsts))
|
|
}
|
|
// Oldest first, which is the order it happened in.
|
|
if firsts[0].Target != "goblin_warrens" {
|
|
t.Errorf("ledger order starts with %q, want goblin_warrens", firsts[0].Target)
|
|
}
|
|
for _, f := range firsts {
|
|
if f.Display == "" {
|
|
t.Errorf("first %q has no display name — it would render as a blank row", f.Target)
|
|
}
|
|
if f.Holder != "Josie" {
|
|
t.Errorf("first %q holder = %q, want Josie (she cleared both zones first)", f.Target, f.Holder)
|
|
}
|
|
if f.Token == "" {
|
|
t.Errorf("first %q has a holder but no token, so the hall can't link to them", f.Target)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnrecoverableFirstStillGetsAnEntry. The ledger records (kind, target,
|
|
// first_at) and nothing else; the holder is recovered at push time from the run
|
|
// history. A treasure found and later discarded leaves no owner anywhere, and
|
|
// that entry has to survive as an unattributed first rather than vanish — it
|
|
// still happened, and the hall is a record of what happened.
|
|
func TestUnrecoverableFirstStillGetsAnEntry(t *testing.T) {
|
|
seedRealmFixture(t)
|
|
claimRealmFirst("treasure", "a_hat_nobody_kept")
|
|
|
|
var found bool
|
|
stats, err := loadRealmClearStats()
|
|
if err != nil {
|
|
t.Fatalf("loadRealmClearStats: %v", err)
|
|
}
|
|
for _, f := range loadRealmFirsts(stats) {
|
|
if f.Target == "a_hat_nobody_kept" {
|
|
found = true
|
|
if f.Holder != "" {
|
|
t.Errorf("holder = %q, want empty — nothing in the game knows who had it", f.Holder)
|
|
}
|
|
if f.Display == "" {
|
|
t.Error("an unrecoverable first got no display name at all")
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("a first with no recoverable holder was dropped from the ledger entirely")
|
|
}
|
|
}
|
|
|
|
// TestRealmPushIsRateLimitedBelowTheRosterTick. The realm rides the 2-minute
|
|
// roster ticker but is aggregate scans over the whole run history, and none of
|
|
// it moves at roster speed. The self-limit is the thing that makes riding that
|
|
// ticker acceptable, so it is worth pinning — and so is the other half: a FAILED
|
|
// push must not stamp the clock, or an outage would be followed by ten minutes
|
|
// of silence instead of a retry on the next tick.
|
|
func TestRealmPushIsRateLimitedBelowTheRosterTick(t *testing.T) {
|
|
if realmPushInterval <= rosterTickInterval {
|
|
t.Fatalf("realmPushInterval (%v) is not longer than the roster tick (%v) — "+
|
|
"the realm would be recomputed every tick", realmPushInterval, rosterTickInterval)
|
|
}
|
|
|
|
// The gate itself: zero means never-pushed and must always go.
|
|
realmLastPush = time.Time{}
|
|
t.Cleanup(func() { realmLastPush = time.Time{} })
|
|
now := time.Now().UTC()
|
|
if !realmLastPush.IsZero() {
|
|
t.Fatal("fixture broken")
|
|
}
|
|
// A stamp inside the window suppresses; one outside it does not.
|
|
realmLastPush = now.Add(-realmPushInterval / 2)
|
|
if now.Sub(realmLastPush) >= realmPushInterval {
|
|
t.Error("a push half an interval old is not being suppressed")
|
|
}
|
|
realmLastPush = now.Add(-realmPushInterval - time.Minute)
|
|
if now.Sub(realmLastPush) < realmPushInterval {
|
|
t.Error("a push older than the interval is still being suppressed")
|
|
}
|
|
}
|