Files
Pete/internal/web/siege.go
T
prosolis 23563b6a6a 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.
2026-07-24 15:21:14 -07:00

268 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 0100 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
}