Files
Pete/internal/web/siege.go
T
prosolis 6b0aae9f4a adventure: let a player act from the web, not just read about it
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
2026-07-24 18:55:42 -07:00

293 lines
9.4 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
// 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 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
}