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 }