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() } // 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() }