mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
worldboss: push the Siege to Pete and file its three dispatches
The Siege had no web presence at all, and three dispatch templates (siege_start / siege_win / siege_loss) have been sitting written and unemitted in Pete's renderAdventure since the adventure section shipped. Both halves fixed here. The snapshot rides the existing roster ticker with the roster's rules: pushed whole, replacing Pete's copy, dropped rather than retried on failure. Retrying would be a lie about how much HP is left, and the next tick carries the truth anyway. The muster 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 Pete can only draw that column if the people in it are on the wire. Opt-out departs from the board's rule on purpose. The board omits an opted-out player outright — class + level + zone re-identifies them. A contributor here is anonymised instead: the damage they did is on the boss and is part of what the town accomplished, so deleting it would understate the shared effort and stop the totals adding up. They keep their damage and their rank, and carry no board token, so nothing links back to a page that names them. An opted-out player who never fought is still dropped — nothing to account for. The three dispatches 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 guid and Pete dedupes it, rather than announcing one Siege to the room twice. Also retires the stale "deploy Pete first, an unknown event_type is a 400" note on emitBoredomDeparture. Pete now publishes an untemplated type on a neutral fallback and counts it for the operator, so the ordering is a property of the system rather than a rule to remember.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,7 @@ func (p *AdventurePlugin) peteRosterTicker() {
|
||||
}
|
||||
p.pushRoster()
|
||||
p.pushDetails()
|
||||
p.pushSiege()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user