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.
255 lines
9.7 KiB
Go
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()
|
|
}
|