The equip queue proved the reverse pipe works. This gives it verbs that play the game: pull out of a run from the adventurer page, take today's bout from the war room. Its own table and its own poll, not more actions on equip_orders. Every column of that table is equip vocabulary (item, slot, tier) and these verbs act on the character rather than on something it is carrying. Nothing in a request names an adventurer. The session maps to one localpart and a localpart to one adventurer, so Pete resolves the character itself and there is no id on the wire to forge. The panel's copy is kept honest by the verdict: an applied action hides the offer it has just spent, a refusal puts the button back. Watching it run is what put that there, along with the strip's layout — gogobee answers a bout with a whole sentence of damage, which the equip strip's two-column row squeezed into a column and wrapped the verb. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
293 lines
9.4 KiB
Go
293 lines
9.4 KiB
Go
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
|
||
// The viewer's own standing in the muster, when they are signed in and have
|
||
// an adventurer. This is the only personal thing on an otherwise wholly
|
||
// public page, and it exists to hang one button off: the war room is where
|
||
// somebody realises the town needs them, so it is where they should be able
|
||
// to answer.
|
||
//
|
||
// YouFought reads a snapshot up to two minutes old, so it decides what the
|
||
// page OFFERS and never what the game allows — a bout taken in Matrix inside
|
||
// that window comes back from gogobee as rejected_already_fought, which is
|
||
// the honest answer and the one the strip shows.
|
||
YouOnBoard bool
|
||
YouFought bool
|
||
}
|
||
|
||
// 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"
|
||
view := s.siege()
|
||
page := siegePage{pageData: base, Siege: view}
|
||
if base.User != nil {
|
||
if token, ok := storage.SelfToken(buyerLocalpart(base.User)); ok {
|
||
page.YouOnBoard = true
|
||
for _, d := range view.Fought {
|
||
if d.Token == token {
|
||
page.YouFought = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 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", page)
|
||
}
|
||
|
||
// 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
|
||
}
|