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,267 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The Siege war room.
|
||||
//
|
||||
// The Siege is the one thing in the realm everybody works on at once: a named
|
||||
// boss camps outside town for 72 hours behind a single shared HP pool, and every
|
||||
// adventurer gets one bout a day against it. Until now that existed only in
|
||||
// Matrix, which means it was invisible to anyone not in the room at the time —
|
||||
// a communal event nobody can see is a communal event that fails.
|
||||
//
|
||||
// It arrives the same way the board does: gogobee pushes the whole thing on the
|
||||
// roster tick and Pete replaces its copy. That is the right shape here for the
|
||||
// same reason it was there — the pool is state, not history. A retried snapshot
|
||||
// would be a lie about how much HP is left, and the next tick carries the truth.
|
||||
//
|
||||
// The page's job is one thing above all others: make the bar visibly move. The
|
||||
// whole point of a shared pool is watching the town chip it down, and a number
|
||||
// that only changes when you reload is not a siege, it is a report about one.
|
||||
|
||||
const (
|
||||
// siegeStaleAfter — how old the snapshot can get before the page stops
|
||||
// claiming the bar is live. Same reasoning and same ticker as the roster, so
|
||||
// the same window: several missed pushes, not one unlucky one.
|
||||
siegeStaleAfter = 12 * time.Minute
|
||||
|
||||
// siegeMaxDefenders / siegeMaxHistory bound a push. A realm has tens of
|
||||
// players and a Siege a month; these only stop a malformed or hostile payload
|
||||
// spooling unbounded rows.
|
||||
siegeMaxDefenders = 500
|
||||
siegeMaxHistory = 200
|
||||
)
|
||||
|
||||
// siegePush is the payload gogobee POSTs to /api/ingest/siege.
|
||||
type siegePush struct {
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
storage.Siege
|
||||
}
|
||||
|
||||
// SiegeView is the war room as the page renders it: gogobee's facts plus the
|
||||
// few presentational things Pete is allowed to decide (percentages, wording,
|
||||
// the fought/waiting split).
|
||||
type SiegeView struct {
|
||||
Active bool
|
||||
Stale bool
|
||||
Known bool // gogobee has pushed at least one snapshot
|
||||
BossName string
|
||||
Tier int
|
||||
HPCurrent int
|
||||
HPMax int
|
||||
HPPercent int
|
||||
Damage int // HPMax - HPCurrent, the town's total contribution
|
||||
StartsAt int64
|
||||
EndsAt int64
|
||||
BoutsToday int
|
||||
Fought []storage.SiegeDefender // took today's bout
|
||||
Waiting []storage.SiegeDefender // hasn't yet — the gap the page wants felt
|
||||
Mustered int // defenders who have fought at least once
|
||||
History []SiegePastView
|
||||
SnapshotAt int64
|
||||
LastSeenAgo string
|
||||
}
|
||||
|
||||
// SiegePastView is one closed-out Siege, with the bar it ended on.
|
||||
type SiegePastView struct {
|
||||
storage.SiegePast
|
||||
Won bool
|
||||
HPPercent int
|
||||
When string
|
||||
}
|
||||
|
||||
type siegePage struct {
|
||||
pageData
|
||||
Siege SiegeView
|
||||
}
|
||||
|
||||
// handleSiegeIngest replaces the war room with gogobee's latest snapshot.
|
||||
func (s *Server) handleSiegeIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var push siegePush
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&push); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(push.Defenders) > siegeMaxDefenders {
|
||||
http.Error(w, "defender board too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(push.History) > siegeMaxHistory {
|
||||
http.Error(w, "history too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if push.SnapshotAt <= 0 {
|
||||
push.SnapshotAt = time.Now().Unix()
|
||||
}
|
||||
// A snapshot with no timestamp can't age, so it would claim to be live
|
||||
// forever; the roster ingest treats that the same way.
|
||||
push.Siege.SnapshotAt = push.SnapshotAt
|
||||
|
||||
// Never trust the channel with a name. gogobee already anonymises opted-out
|
||||
// defenders (empty token, "an adventurer"), but a nameless row would render
|
||||
// as a blank line on a public page, so it is rejected rather than drawn.
|
||||
for i, d := range push.Defenders {
|
||||
if d.Name == "" {
|
||||
http.Error(w, fmt.Sprintf("defender %d: name is required", i), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
// An active Siege with no pool is not a Siege — it is a division by zero on
|
||||
// the bar, and the page has no honest way to draw it.
|
||||
if push.Active && push.HPMax <= 0 {
|
||||
http.Error(w, "active siege needs hp_max", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.ReplaceSiege(push.Siege, push.SnapshotAt); err != nil {
|
||||
slog.Error("siege ingest: replace failed", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("siege ingest: war room replaced",
|
||||
"active", push.Active, "boss", push.BossName,
|
||||
"defenders", len(push.Defenders), "history", len(push.History))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleSiegePage serves the war room. Public: the Siege is a town-wide event
|
||||
// and the defender board is the same anonymity model as the live board.
|
||||
func (s *Server) handleSiegePage(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.track(r, "adventure")
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "adventure"
|
||||
// Unlike the who page this one is NOT noindex: it names a boss and a town,
|
||||
// and the defender list is character names that are already public on the
|
||||
// board. There is nothing here that ties a page to a person more than
|
||||
// /adventure already does.
|
||||
s.render(w, "siege", siegePage{pageData: base, Siege: s.siege()})
|
||||
}
|
||||
|
||||
// handleSiegeAPI serves the war room as JSON for the page's own re-poll. This
|
||||
// is what makes the bar move without a reload, so it is deliberately cheap and
|
||||
// deliberately public — the same exposure as the rendered page, no more.
|
||||
func (s *Server) handleSiegeAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
v := s.siege()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"active": v.Active,
|
||||
"stale": v.Stale,
|
||||
"known": v.Known,
|
||||
"boss_name": v.BossName,
|
||||
"tier": v.Tier,
|
||||
"hp_current": v.HPCurrent,
|
||||
"hp_max": v.HPMax,
|
||||
"hp_percent": v.HPPercent,
|
||||
"damage": v.Damage,
|
||||
"ends_at": v.EndsAt,
|
||||
"bouts_today": v.BoutsToday,
|
||||
"mustered": v.Mustered,
|
||||
"fought": v.Fought,
|
||||
"waiting": v.Waiting,
|
||||
"snapshot_at": v.SnapshotAt,
|
||||
})
|
||||
}
|
||||
|
||||
// siege builds the view from the last snapshot.
|
||||
//
|
||||
// A stale war room is still returned, dimmed and labelled, for the same reason
|
||||
// the board is: "here is where the pool stood when we lost contact" beats an
|
||||
// empty page, and it stops the bar from quietly lying about being live.
|
||||
func (s *Server) siege() SiegeView {
|
||||
snap, known, err := storage.LoadSiege()
|
||||
if err != nil {
|
||||
slog.Error("siege: load failed", "err", err)
|
||||
return SiegeView{Stale: true}
|
||||
}
|
||||
v := SiegeView{
|
||||
Active: snap.Active,
|
||||
Known: known,
|
||||
BossName: snap.BossName,
|
||||
Tier: snap.Tier,
|
||||
HPCurrent: snap.HPCurrent,
|
||||
HPMax: snap.HPMax,
|
||||
StartsAt: snap.StartsAt,
|
||||
EndsAt: snap.EndsAt,
|
||||
BoutsToday: snap.BoutsToday,
|
||||
SnapshotAt: snap.SnapshotAt,
|
||||
}
|
||||
if !known || snap.SnapshotAt == 0 || time.Since(time.Unix(snap.SnapshotAt, 0)) > siegeStaleAfter {
|
||||
v.Stale = true
|
||||
}
|
||||
if snap.SnapshotAt > 0 {
|
||||
v.LastSeenAgo = shortTimeAgo(time.Unix(snap.SnapshotAt, 0))
|
||||
}
|
||||
if snap.HPMax > 0 {
|
||||
v.HPPercent = clampPercent(snap.HPCurrent * 100 / snap.HPMax)
|
||||
v.Damage = snap.HPMax - snap.HPCurrent
|
||||
}
|
||||
|
||||
// The fought/waiting split is the mechanic made visible: one bout per person
|
||||
// per day means an adventurer standing in the "yet to fight" column is a bout
|
||||
// the town has not spent yet. gogobee sends every alive, non-opted-out
|
||||
// adventurer — not just contributors — precisely so this column exists.
|
||||
for _, d := range snap.Defenders {
|
||||
if d.Fights > 0 {
|
||||
v.Mustered++
|
||||
}
|
||||
if d.FoughtToday {
|
||||
v.Fought = append(v.Fought, d)
|
||||
} else {
|
||||
v.Waiting = append(v.Waiting, d)
|
||||
}
|
||||
}
|
||||
|
||||
for _, h := range snap.History {
|
||||
pv := SiegePastView{SiegePast: h, Won: h.Outcome == "defeated"}
|
||||
if h.HPMax > 0 {
|
||||
pv.HPPercent = clampPercent(h.HPRemaining * 100 / h.HPMax)
|
||||
}
|
||||
if h.EndedAt > 0 {
|
||||
pv.When = time.Unix(h.EndedAt, 0).UTC().Format("Jan 2, 2006")
|
||||
}
|
||||
v.History = append(v.History, pv)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// clampPercent keeps a computed bar width inside 0–100 whatever the snapshot
|
||||
// claimed. gogobee clamps its own pool at zero, but the bar is drawn from
|
||||
// arithmetic on two numbers off the wire and must not be able to overflow its
|
||||
// track on a malformed one.
|
||||
func clampPercent(p int) int {
|
||||
if p < 0 {
|
||||
return 0
|
||||
}
|
||||
if p > 100 {
|
||||
return 100
|
||||
}
|
||||
return p
|
||||
}
|
||||
Reference in New Issue
Block a user