Files
Pete/internal/storage/siege.go
T
prosolis 7051e8ffff adventure: give every dispatch a card that looks like what it is
Every dispatch rendered the same image: one violet gradient, a swapped
emoji, a label. A death, a first-ever clear and a legendary hoard were
visually identical — and this image is not decoration. It is the
og:image on every link Pete puts in Matrix and the thumbnail on every
feed card. The most interesting thing that has ever happened in the
realm looked exactly like the most routine.

The card is now keyed on the dispatch GUID instead of the event type,
so the renderer can read the fact behind it and put the actual nouns on
it — the boss's name, the zone, the item, the level. Each family gets
its own palette, and treasure is tinted by the rarity gogobee already
computes and currently spends on an adjective in a sentence.

A realm-first gets visible ceremony. The priority/bulletin split is
already computed upstream and until now it only decided whether Matrix
got pinged; a thing nobody has ever done should also look different
from the ninth time somebody did it. It earns a ribbon on the card and
a badge and a heavier ring in the feed.

Siege cards carry the HP bar, which is the W1 deferral landing. The
siege fact has the boss and the defender count but never the HP, so the
bar comes from the war-room snapshot: the live row while that boss is
still camped, the history once it has closed. When neither has it yet —
the real two-minute window between a win being filed and the push that
explains it — the card renders barless rather than wrong, and caches
for five minutes instead of a day so the unfurl isn't pinned that way.

Nothing needs backfilling. A story with a type-keyed image_url still
serves, and a dispatch with no stored fact degrades to the old emblem.

Two things learned by looking at the rendered output rather than at the
tests, both of which unit tests would never have caught: the feed
thumbnail is a 16/10 crop of a 1200x630 card and was eating the chip
("EGENDARY"), hence advSafeX; and an empty bar under a victory headline
reads at a glance as a wipe, hence the event-aware caption.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 15:47:44 -07:00

230 lines
8.5 KiB
Go

package storage
import (
"database/sql"
)
// The Siege, as gogobee pushes it.
//
// Same shape of thing as the roster and stored the same way: a whole snapshot
// that replaces whatever we had. Nothing here is an event — the *events*
// (siege_start / siege_win / siege_loss) come down the dispatch queue like any
// other fact. This is the thing that is currently true, which is the only kind
// of thing a health bar can honestly draw.
// SiegeDefender is one adventurer's standing in the current muster.
//
// Token is the same public roster token the board uses, so the defender board
// can link a name to their page — and it is EMPTY for an opted-out player. 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 and no link. Name
// carries gogobee's anonymised label in that case.
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: what came, whether the town held, and who
// turned up most. The history is what makes the live bar mean anything.
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"`
}
// Siege is the complete war-room state: the live boss (if any), its muster, and
// every Siege that came before.
type Siege struct {
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"`
SnapshotAt int64 `json:"snapshot_at"`
}
// ReplaceSiege swaps the whole war room for a new snapshot, in one transaction.
//
// Replace, never merge — for the same reason the roster does it. A defender who
// dropped out of the payload (opted out, deleted character) has to leave the
// board, and a Siege that ended has to stop showing a live bar. The transaction
// means a reader mid-swap sees the old Siege or the new one, never a boss with
// somebody else's muster under it.
//
// History is replaced too, not appended: gogobee is the authority on what has
// happened, and rebuilding from its list each tick means a corrected or purged
// row upstream can't leave a ghost siege on Pete forever.
func ReplaceSiege(s Siege, snapshotAt int64) error {
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`DELETE FROM adventure_siege_defenders`); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM adventure_siege_history`); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO adventure_siege
(id, active, boss_id, boss_name, tier, hp_current, hp_max,
starts_at, ends_at, bouts_today, snapshot_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
active = excluded.active, boss_id = excluded.boss_id,
boss_name = excluded.boss_name, tier = excluded.tier,
hp_current = excluded.hp_current, hp_max = excluded.hp_max,
starts_at = excluded.starts_at, ends_at = excluded.ends_at,
bouts_today = excluded.bouts_today, snapshot_at = excluded.snapshot_at`,
s.Active, s.BossID, s.BossName, s.Tier, s.HPCurrent, s.HPMax,
s.StartsAt, s.EndsAt, s.BoutsToday, snapshotAt); err != nil {
return err
}
dstmt, err := tx.Prepare(`
INSERT INTO adventure_siege_defenders
(pos, token, name, level, fights, damage, fought_today)
VALUES (?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer dstmt.Close()
for i, d := range s.Defenders {
if _, err := dstmt.Exec(i, d.Token, d.Name, d.Level, d.Fights, d.Damage, d.FoughtToday); err != nil {
return err
}
}
hstmt, err := tx.Prepare(`
INSERT INTO adventure_siege_history
(boss_id, boss_name, tier, outcome, hp_remaining, hp_max, defenders, mvp, mvp_fights, ended_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer hstmt.Close()
for _, h := range s.History {
if _, err := hstmt.Exec(h.BossID, h.BossName, h.Tier, h.Outcome, h.HPRemaining,
h.HPMax, h.Defenders, h.MVP, h.MVPFights, h.EndedAt); err != nil {
return err
}
}
return tx.Commit()
}
// SiegeBarForBoss finds the HP bar to draw on a siege dispatch's card: current
// and max HP for the named boss around the time the dispatch was filed.
//
// A siege fact carries the boss and the defender count but not the HP, so the
// bar has to come from the war-room snapshot. Two places to look, in order:
//
// - the live row, when that boss is still camped (a siege_start card should
// show the bar as it stands right now, and it will keep sliding as the town
// chips away);
// - the history, for a Siege that has closed — matched on name and then on
// the row that ended nearest the dispatch, since the same boss comes back
// month after month and only the clock separates the two.
//
// ok is false when neither has it, which is a real and temporary state: the
// win/loss dispatch is filed the moment the Siege resolves, and the history that
// explains it doesn't reach Pete until the next 2-minute push. The card renders
// without a bar in the meantime rather than drawing a wrong one.
func SiegeBarForBoss(boss string, at int64) (current, max int, ok bool) {
if boss == "" {
return 0, 0, false
}
var active bool
var name string
var hpCur, hpMax int
err := Get().QueryRow(`
SELECT active, boss_name, hp_current, hp_max FROM adventure_siege WHERE id = 1`).
Scan(&active, &name, &hpCur, &hpMax)
if err == nil && active && name == boss && hpMax > 0 {
return hpCur, hpMax, true
}
// ORDER BY the distance from the dispatch, so a boss that has besieged the
// town three times resolves to the siege this dispatch is actually about.
err = Get().QueryRow(`
SELECT hp_remaining, hp_max FROM adventure_siege_history
WHERE boss_name = ? AND hp_max > 0
ORDER BY ABS(ended_at - ?) ASC LIMIT 1`, boss, at).Scan(&hpCur, &hpMax)
if err != nil {
return 0, 0, false
}
return hpCur, hpMax, true
}
// LoadSiege returns the war room as last pushed. ok is false when gogobee has
// never pushed one at all — distinct from a pushed snapshot that says no Siege
// is camped, which is a real answer the page can render.
func LoadSiege() (Siege, bool, error) {
var s Siege
err := Get().QueryRow(`
SELECT active, boss_id, boss_name, tier, hp_current, hp_max,
starts_at, ends_at, bouts_today, snapshot_at
FROM adventure_siege WHERE id = 1`).Scan(
&s.Active, &s.BossID, &s.BossName, &s.Tier, &s.HPCurrent, &s.HPMax,
&s.StartsAt, &s.EndsAt, &s.BoutsToday, &s.SnapshotAt)
if err == sql.ErrNoRows {
return Siege{}, false, nil
}
if err != nil {
return Siege{}, false, err
}
drows, err := Get().Query(`
SELECT token, name, level, fights, damage, fought_today
FROM adventure_siege_defenders ORDER BY pos ASC`)
if err != nil {
return s, true, err
}
defer drows.Close()
for drows.Next() {
var d SiegeDefender
if err := drows.Scan(&d.Token, &d.Name, &d.Level, &d.Fights, &d.Damage, &d.FoughtToday); err != nil {
return s, true, err
}
s.Defenders = append(s.Defenders, d)
}
if err := drows.Err(); err != nil {
return s, true, err
}
hrows, err := Get().Query(`
SELECT boss_id, boss_name, tier, outcome, hp_remaining, hp_max, defenders, mvp, mvp_fights, ended_at
FROM adventure_siege_history ORDER BY ended_at DESC, boss_id DESC`)
if err != nil {
return s, true, err
}
defer hrows.Close()
for hrows.Next() {
var h SiegePast
if err := hrows.Scan(&h.BossID, &h.BossName, &h.Tier, &h.Outcome, &h.HPRemaining,
&h.HPMax, &h.Defenders, &h.MVP, &h.MVPFights, &h.EndedAt); err != nil {
return s, true, err
}
s.History = append(s.History, h)
}
return s, true, hrows.Err()
}