diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index dcf2911..2df4fb6 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -373,6 +373,76 @@ func PushRoster(ctx context.Context, snap RosterSnapshot) error { return std.post(ctx, "/api/ingest/roster", payload) } +// SiegeDefender is one adventurer's standing in the current Siege muster. +// +// Token is the same public board token the roster uses, so Pete can link a +// defender to their page — and it is EMPTY for an opted-out player, with Name +// carrying anonName instead. That is the whole opt-out story here: their damage +// still counts and still holds its rank (the town's effort is the town's), but +// there is no name to click. It differs from the board's rule (omit entirely) +// on purpose — a defender board that silently dropped contributors would +// understate what the town actually did to the boss. +type SiegeDefender struct { + Token string `json:"token,omitempty"` + Name string `json:"name"` + Level int `json:"level,omitempty"` + Fights int `json:"fights"` + Damage int `json:"damage"` + FoughtToday bool `json:"fought_today"` +} + +// SiegePast is one closed-out Siege for the history table. +type SiegePast struct { + BossID int64 `json:"boss_id"` + BossName string `json:"boss_name"` + Tier int `json:"tier"` + Outcome string `json:"outcome"` // "defeated" | "survived" + HPRemaining int `json:"hp_remaining"` + HPMax int `json:"hp_max"` + Defenders int `json:"defenders"` + MVP string `json:"mvp,omitempty"` + MVPFights int `json:"mvp_fights,omitempty"` + EndedAt int64 `json:"ended_at"` +} + +// SiegeSnapshot is the complete war room: the live boss (if any), its muster, +// and every Siege that came before. Same complete-snapshot contract as the +// roster — Pete replaces its copy — so a defender omitted here leaves the board +// and a resolved Siege stops showing a live bar. +// +// Defenders carries EVERY alive adventurer, not just contributors. The zero-fight +// rows are the point: one bout per person per day means somebody who hasn't +// swung today is a hit the town hasn't taken, and the page can only show that gap +// if the people in it are on the wire. +type SiegeSnapshot struct { + SnapshotAt int64 `json:"snapshot_at"` + Active bool `json:"active"` + BossID int64 `json:"boss_id,omitempty"` + BossName string `json:"boss_name,omitempty"` + Tier int `json:"tier,omitempty"` + HPCurrent int `json:"hp_current"` + HPMax int `json:"hp_max"` + StartsAt int64 `json:"starts_at,omitempty"` + EndsAt int64 `json:"ends_at,omitempty"` + BoutsToday int `json:"bouts_today"` + Defenders []SiegeDefender `json:"defenders,omitempty"` + History []SiegePast `json:"history,omitempty"` +} + +// PushSiege sends the war room to Pete, synchronously, and drops it on failure — +// the same drop-the-lie semantics as PushRoster. A retried snapshot would claim +// a pool level that has since moved, and the next tick carries the truth anyway. +func PushSiege(ctx context.Context, snap SiegeSnapshot) error { + if !Enabled() { + return nil + } + payload, err := json.Marshal(snap) + if err != nil { + return err + } + return std.post(ctx, "/api/ingest/siege", payload) +} + // PlayerDetail is the private, owner-only expansion for one player: inventory, // vault, house, and pets. Like MischiefBalance it is keyed by localpart (the // sign-in name), in its own keyspace on Pete — Pete only ever serves it back to diff --git a/internal/plugin/adventure_worldboss.go b/internal/plugin/adventure_worldboss.go index 3eda294..93c74f0 100644 --- a/internal/plugin/adventure_worldboss.go +++ b/internal/plugin/adventure_worldboss.go @@ -394,6 +394,7 @@ func (p *AdventurePlugin) spawnWorldBoss(eventKey string) (*worldBossState, erro return nil, err } p.announceWorldBossSpawn(boss, activeN) + emitSiegeStart(boss) slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN) return boss, nil } @@ -496,6 +497,7 @@ func (p *AdventurePlugin) resolveWorldBossDefeated(boss *worldBossState) { } } p.announceWorldBossDefeated(boss, payouts) + emitSiegeWin(boss, len(payouts)) slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts)) } @@ -516,6 +518,7 @@ func (p *AdventurePlugin) resolveWorldBossSurvived(boss *worldBossState) { paid = tribute } p.announceWorldBossSurvived(boss, paid) + emitSiegeLoss(boss) slog.Info("worldboss: survived", "id", boss.ID, "tribute", paid) } diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go index e8c123c..46a367a 100644 --- a/internal/plugin/pete.go +++ b/internal/plugin/pete.go @@ -279,8 +279,12 @@ func claimRealmFirst(kind, target string) bool { // *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. +// The event_type no longer has to be one Pete already knows. It used to: an +// unknown type was a 400, which retried to the cap and then parked the bulletin +// forever, so shipping a new event type meant remembering to deploy Pete first. +// Pete now publishes an untemplated type on a neutral fallback and counts it for +// the operator, so the ordering rule is a property of the system rather than +// something a human has to hold. func emitBoredomDeparture(userID id.UserID, zone ZoneDefinition, level int) { if !peteclient.Enabled() || !newsEmissionOn() { return diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index 0e3ad7d..c369a50 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -52,6 +52,7 @@ func (p *AdventurePlugin) peteRosterTicker() { } p.pushRoster() p.pushDetails() + p.pushSiege() } } diff --git a/internal/plugin/pete_siege.go b/internal/plugin/pete_siege.go new file mode 100644 index 0000000..2739bcf --- /dev/null +++ b/internal/plugin/pete_siege.go @@ -0,0 +1,376 @@ +package plugin + +import ( + "context" + "log/slog" + "sort" + "strconv" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// The Siege war room, pushed to Pete. +// +// The Siege is the only mechanic where the whole town works on one object, and +// until now it existed exclusively in Matrix — which means anybody not in the +// room at the time never knew it happened. A communal event nobody can see is a +// communal event that fails. +// +// It rides the roster ticker and follows the roster's rules exactly, because it +// is the same kind of thing: a snapshot of what is currently true, pushed whole, +// replacing whatever Pete had, dropped rather than retried on failure. A retried +// snapshot would be a lie about how much HP is left. +// +// The one place it deliberately departs from the board is the opt-out. The board +// omits an opted-out player entirely — a row showing class + level + zone is +// trivially re-identifiable, so absence is the only honest option there. Here a +// contributor is anonymised instead of dropped: their damage is part of what the +// town did to the boss, and a defender board that quietly deleted it would +// understate the shared effort and stop the numbers adding up. An opted-out +// player who has NOT fought is still omitted — there is nothing to account for, +// so naming their absence would be exposure for nothing. + +// siegeHistoryLimit bounds the "sieges past" table. A Siege a month means this is +// years of history; the cap only exists so the payload can't grow without bound. +const siegeHistoryLimit = 24 + +// pushSiege builds and sends the war room. Mirrors pushRoster: transitions are +// logged, the steady state is silent. +var siegePushOK bool + +func (p *AdventurePlugin) pushSiege() { + snap, err := buildSiegeSnapshot(time.Now().UTC()) + if err != nil { + slog.Error("siege: build snapshot failed", "err", err) + return + } + ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout) + defer cancel() + + if err := peteclient.PushSiege(ctx, snap); err != nil { + if siegePushOK { + slog.Warn("siege: push failed, war room will go stale on Pete", "err", err) + } else { + slog.Debug("siege: push failed, dropping snapshot", "err", err) + } + siegePushOK = false + return + } + if !siegePushOK { + slog.Info("siege: war room accepted by Pete", "active", snap.Active, "defenders", len(snap.Defenders)) + siegePushOK = true + } +} + +// buildSiegeSnapshot assembles the whole war room: the live boss, the muster, +// and the history. +func buildSiegeSnapshot(now time.Time) (peteclient.SiegeSnapshot, error) { + snap := peteclient.SiegeSnapshot{SnapshotAt: now.Unix()} + + hist, err := loadResolvedWorldBosses(siegeHistoryLimit) + if err != nil { + return snap, err + } + snap.History = hist + + boss, err := loadActiveWorldBoss() + if err != nil { + return snap, err + } + if boss == nil { + return snap, nil // no Siege camped: a real answer, not an empty snapshot + } + + snap.Active = true + snap.BossID = boss.ID + snap.BossName = boss.Name + snap.Tier = boss.Tier + snap.HPCurrent = boss.HPCurrent + snap.HPMax = boss.HPMax + snap.StartsAt = boss.StartsAt.Unix() + snap.EndsAt = boss.EndsAt.Unix() + + defenders, boutsToday, err := buildSiegeMuster(boss.ID, now) + if err != nil { + return snap, err + } + snap.Defenders = defenders + snap.BoutsToday = boutsToday + return snap, nil +} + +// buildSiegeMuster returns every alive adventurer's standing against this boss, +// ranked, plus how many bouts have been taken today. +// +// It starts from the contribution rows rather than from the roster so a +// contributor who has since died (or whose player_meta row went away) still +// appears — the damage they did is on the boss whether they are standing or not. +// The alive roster is then folded in on top to produce the zero-fight rows that +// make the "bout still going spare" column exist. +func buildSiegeMuster(bossID int64, now time.Time) ([]peteclient.SiegeDefender, int, error) { + contribs, err := loadWorldBossContribs(bossID) + if err != nil { + return nil, 0, err + } + today := now.Format("2006-01-02") + + byUser := make(map[id.UserID]worldBossContrib, len(contribs)) + for _, c := range contribs { + byUser[c.UserID] = c + } + + // Everyone alive, so the un-fought have a row to stand in. + rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`) + if err != nil { + return nil, 0, err + } + order := make([]id.UserID, 0, len(contribs)) + seen := make(map[id.UserID]bool, len(contribs)) + for rows.Next() { + var uid string + if err := rows.Scan(&uid); err != nil { + rows.Close() + return nil, 0, err + } + u := id.UserID(uid) + if !seen[u] { + seen[u] = true + order = append(order, u) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, 0, err + } + // Contributors who are no longer on the alive roster still owe the board a row. + for _, c := range contribs { + if !seen[c.UserID] { + seen[c.UserID] = true + order = append(order, c.UserID) + } + } + + boutsToday := 0 + out := make([]peteclient.SiegeDefender, 0, len(order)) + for _, uid := range order { + c, fought := byUser[uid] + if fought && c.LastFightDate == today { + boutsToday++ + } + optedOut := isNewsOptedOut(uid) + if optedOut && !fought { + continue // nothing to account for; naming the absence is exposure for nothing + } + + d := peteclient.SiegeDefender{Name: anonName} + if fought { + d.Fights = c.Fights + d.Damage = c.Damage + d.FoughtToday = c.LastFightDate == today + } + if !optedOut { + name := charName(uid) + if name == "" { + // No character name means no honest way to render the row: never fall + // back to a Matrix handle on a public page. A contributor in this state + // keeps their damage, anonymously; a non-contributor is simply dropped. + if !fought { + continue + } + } else { + d.Name = name + d.Token = eventToken(uid, "roster") + if ch, err := LoadDnDCharacter(uid); err == nil && ch != nil && !ch.PendingSetup { + d.Level = ch.Level + } + } + } + out = append(out, d) + } + + // Rank: damage, then bouts, then name. Deterministic to the last key so an + // unchanged muster produces a byte-identical snapshot and Pete's board doesn't + // reshuffle itself every two minutes. + sort.SliceStable(out, func(i, j int) bool { + if out[i].Damage != out[j].Damage { + return out[i].Damage > out[j].Damage + } + if out[i].Fights != out[j].Fights { + return out[i].Fights > out[j].Fights + } + return out[i].Name < out[j].Name + }) + return out, boutsToday, nil +} + +// loadResolvedWorldBosses reads the closed-out Sieges, newest first, each with +// its defender count and the contributor who turned up most. +func loadResolvedWorldBosses(limit int) ([]peteclient.SiegePast, error) { + // resolved_at is selected raw and folded in Go, never COALESCE()'d in SQL: + // modernc.org/sqlite rebuilds a time.Time from the column's DECLARED type and + // COALESCE erases that affinity, so the Scan would fail. Same trap + // buildRosterSnapshot documents. + rows, err := db.Get().Query(` + SELECT id, name, tier, hp_max, hp_current, status, resolved_at, ends_at + FROM world_boss + WHERE status IN ('defeated', 'survived') + ORDER BY id DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []peteclient.SiegePast + for rows.Next() { + var ( + h peteclient.SiegePast + resolvedAt, endsAt *time.Time + ) + if err := rows.Scan(&h.BossID, &h.BossName, &h.Tier, &h.HPMax, &h.HPRemaining, + &h.Outcome, &resolvedAt, &endsAt); err != nil { + return nil, err + } + // A boss resolved by the ticker has resolved_at; a legacy row might not. + // The window's close is the honest fallback — it is when the Siege ended + // either way, and it is never null. + switch { + case resolvedAt != nil: + h.EndedAt = resolvedAt.Unix() + case endsAt != nil: + h.EndedAt = endsAt.Unix() + } + out = append(out, h) + } + if err := rows.Err(); err != nil { + return nil, err + } + + for i := range out { + n, mvp, fights, err := worldBossMuster(out[i].BossID) + if err != nil { + slog.Warn("siege: history muster load failed", "boss", out[i].BossID, "err", err) + continue + } + out[i].Defenders = n + out[i].MVP = mvp + out[i].MVPFights = fights + } + return out, nil +} + +// worldBossMuster reports how many people fought a boss and who fought it most. +// The MVP is by fights, not damage — the same accessibility call the payout +// split makes (computeWorldBossPayouts): turning up is the contribution the +// mechanic actually asks for. An opted-out MVP is anonymised, never dropped; +// the count behind the name is a fact about the town. +func worldBossMuster(bossID int64) (defenders int, mvp string, mvpFights int, err error) { + contribs, err := loadWorldBossContribs(bossID) + if err != nil { + return 0, "", 0, err + } + // loadWorldBossContribs already orders by fights desc, damage desc, so the + // first row with any fights at all is the MVP. + for _, c := range contribs { + if c.Fights <= 0 { + continue + } + defenders++ + if mvp == "" { + mvp = anonName + if !isNewsOptedOut(c.UserID) { + if name := charName(c.UserID); name != "" { + mvp = name + } + } + mvpFights = c.Fights + } + } + return defenders, mvp, mvpFights, nil +} + +// ── Dispatches ─────────────────────────────────────────────────────────────── + +// emitSiegeStart files the "a boss is at the gates" dispatch. PRIORITY: this is +// the one beat where the right response is for everyone to look up right now, +// and unlike a zone clear it is not something TwinBee has already announced to +// the same people — the games-room shout and this go to different rooms. +// +// Realm-level, so there is no subject player and no opt-out to apply. +func emitSiegeStart(boss *worldBossState) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + emitFact(peteclient.Fact{ + GUID: siegeGUID("siege_start", boss.ID), + EventType: "siege_start", + Tier: "priority", + Boss: boss.Name, + Level: boss.Tier, + Stakes: siegeWindowPhrase(boss), + OccurredAt: boss.StartsAt.Unix(), + }, "", "") +} + +// emitSiegeWin files the "the town held" dispatch. Count is the number of +// defenders, which is what Pete's template reads to say how many stood. +func emitSiegeWin(boss *worldBossState, defenders int) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + emitFact(peteclient.Fact{ + GUID: siegeGUID("siege_win", boss.ID), + EventType: "siege_win", + Tier: "priority", + Boss: boss.Name, + Level: boss.Tier, + Count: defenders, + Outcome: "defeated", + OccurredAt: nowUnix(), + }, "", "") +} + +// emitSiegeLoss files the "it broke through" dispatch. +func emitSiegeLoss(boss *worldBossState) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + emitFact(peteclient.Fact{ + GUID: siegeGUID("siege_loss", boss.ID), + EventType: "siege_loss", + Tier: "priority", + Boss: boss.Name, + Level: boss.Tier, + Outcome: "survived", + OccurredAt: nowUnix(), + }, "", "") +} + +// siegeGUID keys a Siege dispatch on the boss row id, which is unique and stable +// for the life of the event. That makes each of the three beats fire at most +// once per Siege however many times its resolution path is re-entered — the +// status guard in setWorldBossStatus already dedupes the payout, and this dedupes +// the news the same way. +func siegeGUID(eventType string, bossID int64) string { + return eventType + ":" + strconv.FormatInt(bossID, 10) +} + +// siegeWindowPhrase is the deadline as Pete's siege_start template wants it — +// "You've got %s" — so it must read as a duration, not a timestamp. +func siegeWindowPhrase(boss *worldBossState) string { + h := int(boss.EndsAt.Sub(boss.StartsAt).Hours()) + switch { + case h <= 0: + return "no time at all" + case h == 24: + return "a day" + case h%24 == 0: + return strconv.Itoa(h/24) + " days" + default: + return strconv.Itoa(h) + " hours" + } +} diff --git a/internal/plugin/pete_siege_test.go b/internal/plugin/pete_siege_test.go new file mode 100644 index 0000000..cc3eea8 --- /dev/null +++ b/internal/plugin/pete_siege_test.go @@ -0,0 +1,254 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// seedSiege writes a world_boss row directly and returns it. Real rows on +// purpose: the history query scans two declared DATETIME columns (resolved_at, +// ends_at) and the modernc affinity trap only fires against actual stored +// values, never against a hand-built struct. +func seedSiege(t *testing.T, name string, tier, hpMax, hpCurrent int, status string, starts, ends time.Time, resolved *time.Time) int64 { + t.Helper() + res, err := db.Get().Exec( + `INSERT INTO world_boss (name, tier, hp_max, hp_current, status, starts_at, ends_at, resolved_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + name, tier, hpMax, hpCurrent, status, starts, ends, resolved) + if err != nil { + t.Fatalf("seed world_boss: %v", err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatal(err) + } + return id +} + +func seedContrib(t *testing.T, bossID int64, uid id.UserID, fights, damage int, lastDate string) { + t.Helper() + if _, err := db.Get().Exec( + `INSERT INTO world_boss_contrib (boss_id, user_id, fights, damage, last_fight_date) + VALUES (?, ?, ?, ?, ?)`, bossID, string(uid), fights, damage, lastDate); err != nil { + t.Fatalf("seed contrib: %v", err) + } +} + +// TestSiegeSnapshotMustersEveryoneAlive is the reason the payload carries +// zero-fight rows at all. The mechanic is one bout per person per day, so the +// interesting number is not "who fought" but "who still could" — and Pete can +// only draw that column if the people in it are on the wire. A snapshot of +// contributors alone would render a board that quietly congratulates itself. +func TestSiegeSnapshotMustersEveryoneAlive(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-40 * time.Hour) + today := now.Format("2006-01-02") + + seedRosterPlayer(t, "@a:test", "Josie", &old, &old) + seedRosterPlayer(t, "@b:test", "Quack", &old, &old) + seedRosterPlayer(t, "@c:test", "Camcast", &old, &old) + + bossID := seedSiege(t, "Gorloth the Sunderer", 4, 1000, 400, "active", + now.Add(-2*time.Hour), now.Add(70*time.Hour), nil) + seedContrib(t, bossID, "@a:test", 3, 500, today) + seedContrib(t, bossID, "@b:test", 1, 100, now.AddDate(0, 0, -1).Format("2006-01-02")) + + snap, err := buildSiegeSnapshot(now) + if err != nil { + t.Fatalf("buildSiegeSnapshot: %v", err) + } + if !snap.Active || snap.BossName != "Gorloth the Sunderer" { + t.Fatalf("snapshot missed the live boss: %+v", snap) + } + if snap.HPCurrent != 400 || snap.HPMax != 1000 { + t.Errorf("pool = %d/%d, want 400/1000", snap.HPCurrent, snap.HPMax) + } + if len(snap.Defenders) != 3 { + t.Fatalf("muster has %d rows, want 3 — the un-fought must have a row to stand in", len(snap.Defenders)) + } + if snap.BoutsToday != 1 { + t.Errorf("bouts_today = %d, want 1 — only Josie has been out today", snap.BoutsToday) + } + + // Ranked by damage: Josie, Quack, then the adventurer who hasn't started. + if snap.Defenders[0].Name != "Josie" || !snap.Defenders[0].FoughtToday { + t.Errorf("top of the muster = %+v, want Josie having fought today", snap.Defenders[0]) + } + if snap.Defenders[1].Name != "Quack" || snap.Defenders[1].FoughtToday { + t.Errorf("second = %+v, want Quack with a bout still spare (hers was yesterday)", snap.Defenders[1]) + } + if snap.Defenders[2].Name != "Camcast" || snap.Defenders[2].Fights != 0 { + t.Errorf("third = %+v, want Camcast at zero fights", snap.Defenders[2]) + } + for _, d := range snap.Defenders { + if d.Token == "" { + t.Errorf("%s has no board token — the defender board can't link to their page", d.Name) + } + } +} + +// TestSiegeOptOutAnonymisesContributorAndDropsBystander is the one place the +// Siege deliberately breaks the board's opt-out rule, so it is worth pinning +// both halves. +// +// The board omits an opted-out player outright: a row showing class + level + +// zone re-identifies them, so absence is the only honest option. Here a +// CONTRIBUTOR is anonymised instead — the damage they did is on the boss and is +// part of what the town accomplished, and deleting it would understate the +// shared effort and stop the totals adding up. A non-contributor is still +// dropped, because there is nothing to account for and naming their absence +// would be exposure for nothing. +func TestSiegeOptOutAnonymisesContributorAndDropsBystander(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-40 * time.Hour) + today := now.Format("2006-01-02") + + seedRosterPlayer(t, "@shy:test", "Ghost", &old, &old) // opted out, fought + seedRosterPlayer(t, "@lurk:test", "Silent", &old, &old) // opted out, never fought + seedRosterPlayer(t, "@open:test", "Josie", &old, &old) // opted in, fought + setNewsOptout("@shy:test", true) + setNewsOptout("@lurk:test", true) + + bossID := seedSiege(t, "The Iron Colossus", 4, 1000, 200, "active", + now.Add(-time.Hour), now.Add(71*time.Hour), nil) + seedContrib(t, bossID, "@shy:test", 4, 600, today) + seedContrib(t, bossID, "@open:test", 1, 200, today) + + snap, err := buildSiegeSnapshot(now) + if err != nil { + t.Fatalf("buildSiegeSnapshot: %v", err) + } + if len(snap.Defenders) != 2 { + t.Fatalf("muster has %d rows, want 2 — the opted-out bystander should be gone and the opted-out contributor kept", len(snap.Defenders)) + } + + top := snap.Defenders[0] + if top.Damage != 600 { + t.Fatalf("top of the muster did %d damage, want 600 — the anonymous contributor lost their rank", top.Damage) + } + if top.Name != anonName { + t.Errorf("opted-out contributor rendered as %q, want %q", top.Name, anonName) + } + if top.Token != "" { + t.Error("opted-out contributor carries a board token — that is a link straight back to a page that names them") + } + if top.Level != 0 { + t.Errorf("opted-out contributor leaked level %d — level + damage is most of a re-identification", top.Level) + } + + for _, d := range snap.Defenders { + if d.Name == "Silent" || d.Name == "Ghost" { + t.Errorf("opted-out player %q reached the wire under their character name", d.Name) + } + } +} + +// TestSiegeHistoryReadsResolvedClock is the scan-affinity guard for the history +// query, the exact trap buildRosterSnapshot documents: resolved_at and ends_at +// are declared DATETIME, and a COALESCE() in the SQL would erase that affinity +// so the Scan fails — which would silently publish an empty history rather than +// anything obviously broken. +func TestSiegeHistoryReadsResolvedClock(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-40 * time.Hour) + + seedRosterPlayer(t, "@a:test", "Josie", &old, &old) + seedRosterPlayer(t, "@b:test", "Quack", &old, &old) + + resolved := now.Add(-24 * time.Hour) + won := seedSiege(t, "The Ashen Wyrm", 5, 1200, 0, "defeated", + now.Add(-96*time.Hour), now.Add(-24*time.Hour), &resolved) + seedContrib(t, won, "@a:test", 6, 700, "2026-07-01") + seedContrib(t, won, "@b:test", 2, 500, "2026-07-01") + + // A legacy row with no resolved_at must still date itself — off the window's + // close, which is when the Siege ended either way and is never null. + lost := seedSiege(t, "Kravok, Maw of the Deep", 4, 800, 300, "survived", + now.Add(-200*time.Hour), now.Add(-128*time.Hour), nil) + seedContrib(t, lost, "@a:test", 1, 500, "2026-06-01") + + snap, err := buildSiegeSnapshot(now) + if err != nil { + t.Fatalf("buildSiegeSnapshot: %v", err) + } + if snap.Active { + t.Error("no boss is camped but the snapshot claims one is") + } + if len(snap.History) != 2 { + t.Fatalf("history has %d rows, want 2", len(snap.History)) + } + + // Newest first (id desc): the survived Kravok was inserted last. + h := snap.History[0] + if h.BossName != "Kravok, Maw of the Deep" || h.Outcome != "survived" { + t.Fatalf("history[0] = %+v, want the survived Kravok first", h) + } + if h.HPRemaining != 300 || h.HPMax != 800 { + t.Errorf("survived bar = %d/%d, want 300/800", h.HPRemaining, h.HPMax) + } + if h.EndedAt != now.Add(-128*time.Hour).Unix() { + t.Errorf("legacy row dated %d, want the window close %d", h.EndedAt, now.Add(-128*time.Hour).Unix()) + } + + w := snap.History[1] + if w.Outcome != "defeated" || w.HPRemaining != 0 { + t.Errorf("defeated Siege = %+v, want a pool at zero", w) + } + if w.EndedAt != resolved.Unix() { + t.Errorf("resolved row dated %d, want resolved_at %d", w.EndedAt, resolved.Unix()) + } + if w.Defenders != 2 { + t.Errorf("defenders = %d, want 2", w.Defenders) + } + // MVP is by fights, not damage — the same accessibility call the payout split + // makes. Josie fought six times for 700; had it been by damage she'd still win, + // so the ordering is pinned by loadWorldBossContribs' fights-desc ordering. + if w.MVP != "Josie" || w.MVPFights != 6 { + t.Errorf("MVP = %q with %d fights, want Josie with 6", w.MVP, w.MVPFights) + } +} + +// TestSiegeDispatchesFireOncePerSiege. The three Siege beats key their GUID on +// the boss row id, so a resolution path re-entered (a redeploy mid-window, the +// ticker's safety net firing after an inline kill) files the same dispatch guid +// and Pete dedupes it. Without that, a restart could announce the same Siege +// twice to the whole room. +func TestSiegeDispatchesFireOncePerSiege(t *testing.T) { + if a, b := siegeGUID("siege_start", 7), siegeGUID("siege_start", 7); a != b { + t.Errorf("guid not stable: %q vs %q", a, b) + } + if a, b := siegeGUID("siege_start", 7), siegeGUID("siege_start", 8); a == b { + t.Errorf("two different Sieges share guid %q", a) + } + if a, b := siegeGUID("siege_win", 7), siegeGUID("siege_loss", 7); a == b { + t.Errorf("win and loss share guid %q — one Siege cannot file both", a) + } +} + +// TestSiegeWindowPhrase: the siege_start template says "You've got %s", so the +// stakes field has to read as a duration in a sentence, not as a timestamp. +func TestSiegeWindowPhrase(t *testing.T) { + base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + cases := []struct { + window time.Duration + want string + }{ + {worldBossWindow, "3 days"}, + {24 * time.Hour, "a day"}, + {36 * time.Hour, "36 hours"}, + {0, "no time at all"}, + } + for _, c := range cases { + b := &worldBossState{StartsAt: base, EndsAt: base.Add(c.window)} + if got := siegeWindowPhrase(b); got != c.want { + t.Errorf("siegeWindowPhrase(%v) = %q, want %q", c.window, got, c.want) + } + } +}