Files
Pete/internal/storage/siege.go
T
prosolis aac6c3e127 adventure: work the five review findings the last pass left open
The extract pre-check is gone. It read a snapshot up to two minutes behind and
still got the last word, so somebody who set out over Matrix during a lagging
roster push was told they weren't on an expedition for a run gogobee would
happily have ended. Same call abandon and leave already made: let it through and
let rejected_not_running be the answer.

The siege_join check stays, because whether a boss is camped outside town is
town-wide and runs on a day-or-longer clock, but it now reads one column through
SiegeIsCamped instead of loading every defender row and the whole history to
look at one flag.

The war-room history insert is OR REPLACE. boss_id is the primary key and it was
never settled whether gogobee means the siege instance or the boss type by it, so
a duplicate pair used to fail the transaction carrying the live boss and the
muster too and freeze the war room on the last good snapshot. A dropped history
row is the smaller failure; the open question is noted in the schema.

offersToUndo's guard didn't cover the case its comment claimed. A gogobee too old
to push seats sends a valid blob with no party key, which decodes to the same
empty slice as a solo run, and a party member got shown the button that throws
away everyone's day. That needs a new field, so whoDetail gains party_known and
the flag gates the empty-list branch alone; the branch that reads the viewer's
own seat is self-evidencing and keeps working against any sender. gogobee's half
is written up in adventure_party_known_flag.md.

And an empty offer list no longer claims "you're already out there", which Pete
can't actually know from a game box too old to push offers at all.
2026-07-24 22:45:26 -07:00

255 lines
9.7 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
}
}
// OR REPLACE, because boss_id is the primary key and a duplicate in gogobee's
// list would otherwise fail this whole transaction — the live boss and the
// muster with it, freezing the war room on the previous snapshot indefinitely.
// The table is deleted and rebuilt from the pushed list every time, so a
// collision is a wire quirk rather than data loss, and keeping the last of a
// colliding pair is a far smaller failure than a war room that stops moving.
hstmt, err := tx.Prepare(`
INSERT OR REPLACE 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
}
// SiegeIsCamped answers the one question the siege_join pre-check asks, without
// LoadSiege's defender rows and whole history behind it — a one-column read on a
// pool that is MaxOpenConns(1).
//
// known is false when gogobee has never pushed a war room at all, which is NOT
// the same as a pushed snapshot saying no Siege is camped. The caller has to keep
// the two apart: a fresh deploy that has not been pushed to yet must still queue
// the order rather than show a dead button.
func SiegeIsCamped() (active, known bool, err error) {
err = Get().QueryRow(`SELECT active FROM adventure_siege WHERE id = 1`).Scan(&active)
if err == sql.ErrNoRows {
return false, false, nil
}
if err != nil {
return false, false, err
}
return active, true, nil
}
// 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()
}