adventure: give the Siege a war room instead of a Matrix-only rumour
The Siege is the one mechanic where the whole town works on a single object, and it existed exclusively in Matrix — so anybody not in the room at the time never knew it happened. A communal event nobody can see is a communal event that fails. gogobee now pushes the war room on the roster tick and Pete replaces its copy, the same snapshot contract the board has and for the same reason: the shared pool is state, not history, and a retried snapshot would be a lie about how much HP is left. /adventure/siege draws it. The load-bearing detail is one CSS line — the health bar has a width transition, so a poll that lands a lower pool slides the bar down instead of snapping it. That is the difference between watching the town chip a boss down and reading a report about it. Alongside: a countdown to the window's close, past sieges with the bar each one ended on, and the muster split into who took today's bout and who still has one going spare — the mechanic is one fight per person per day, so that second column is a hit the town hasn't taken yet. The opt-out rule here deliberately differs from the board's. The board omits an opted-out player outright, because class + level + zone re-identifies them. A Siege contributor is anonymised instead: their damage is part of what the town did to the boss, and deleting it would understate the shared effort and stop the totals adding up. They keep their rank, lose their name, and carry no token — so there is no link back to a page that names them. An opted-out player who never fought is still omitted; there is nothing to account for. siege_start / siege_win / siege_loss are wired on the gogobee side; the templates for all three have been sitting unused in renderAdventure since the section shipped.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user