Compare commits
2 Commits
a614077cff
...
99574db3e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99574db3e9 | ||
|
|
8cb5b38599 |
@@ -37,7 +37,9 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
||||
if s.Lede != "" {
|
||||
plainParts = append(plainParts, s.Lede)
|
||||
}
|
||||
plainParts = append(plainParts, formatSourceTag(s.Source, s.Platforms, false))
|
||||
if tag := formatSourceTag(s.Source, s.Platforms, false); tag != "" {
|
||||
plainParts = append(plainParts, tag)
|
||||
}
|
||||
plain = strings.Join(plainParts, "\n")
|
||||
|
||||
// HTML body
|
||||
@@ -55,25 +57,32 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
||||
if s.Lede != "" {
|
||||
htmlParts = append(htmlParts, html.EscapeString(s.Lede))
|
||||
}
|
||||
htmlParts = append(htmlParts, formatSourceTag(s.Source, s.Platforms, true))
|
||||
if tag := formatSourceTag(s.Source, s.Platforms, true); tag != "" {
|
||||
htmlParts = append(htmlParts, tag)
|
||||
}
|
||||
htmlBody = strings.Join(htmlParts, "<br/>")
|
||||
|
||||
return plain, htmlBody
|
||||
}
|
||||
|
||||
// formatSourceTag builds the source + platform tags line.
|
||||
// formatSourceTag builds the source + platform tags line. An empty source is
|
||||
// omitted rather than tagged: Pete's own reporting has no outlet to credit, and
|
||||
// an empty tag would read as him signing his own name.
|
||||
func formatSourceTag(source string, platforms []string, isHTML bool) string {
|
||||
var parts []string
|
||||
if source != "" {
|
||||
parts = append(parts, strings.ToLower(source))
|
||||
}
|
||||
parts = append(parts, platforms...)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
for i, p := range parts {
|
||||
if isHTML {
|
||||
parts := []string{fmt.Sprintf("<code>%s</code>", html.EscapeString(strings.ToLower(source)))}
|
||||
for _, p := range platforms {
|
||||
parts = append(parts, fmt.Sprintf("<code>%s</code>", html.EscapeString(p)))
|
||||
parts[i] = fmt.Sprintf("<code>%s</code>", html.EscapeString(p))
|
||||
} else {
|
||||
parts[i] = fmt.Sprintf("`%s`", p)
|
||||
}
|
||||
return strings.Join(parts, " \u00b7 ")
|
||||
}
|
||||
|
||||
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
|
||||
for _, p := range platforms {
|
||||
parts = append(parts, fmt.Sprintf("`%s`", p))
|
||||
}
|
||||
return strings.Join(parts, " \u00b7 ")
|
||||
}
|
||||
|
||||
108
internal/storage/roster.go
Normal file
108
internal/storage/roster.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
|
||||
// gogobee pushed. Not an event — nothing here is a thing that *happened*.
|
||||
type RosterEntry struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
ClassRace string `json:"class_race"`
|
||||
Status string `json:"status"` // "expedition" | "idle"
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
IdleHours int `json:"idle_hours,omitempty"`
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
}
|
||||
|
||||
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
||||
//
|
||||
// Replace — never merge. A player who dropped out of gogobee's snapshot (deleted
|
||||
// character, or a fresh `!news optout`) must vanish from the board, and an
|
||||
// upsert would leave them standing there forever. The transaction means a reader
|
||||
// mid-swap sees the old board or the new one, never a half-empty realm.
|
||||
func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM adventure_roster`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_roster
|
||||
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, e := range entries {
|
||||
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
||||
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Stamp the snapshot even when it carried zero adventurers — that is a
|
||||
// legitimate state (quiet realm) and must not read as "gogobee went away".
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO adventure_roster_meta (id, snapshot_at) VALUES (1, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// LoadRoster returns the current board: everyone on an expedition first (most
|
||||
// recently departed at the top of that group), then the idle, longest-idle last.
|
||||
// Also returns the snapshot time so the caller can decide whether the wire has
|
||||
// gone quiet — see rosterStale.
|
||||
func LoadRoster() ([]RosterEntry, int64, error) {
|
||||
rows, err := Get().Query(`
|
||||
SELECT token, name, level, COALESCE(class_race, ''), status,
|
||||
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
|
||||
FROM adventure_roster
|
||||
ORDER BY status = 'expedition' DESC, day DESC, idle_hours ASC, level DESC, name ASC`)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RosterEntry
|
||||
for rows.Next() {
|
||||
var e RosterEntry
|
||||
if err := rows.Scan(&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
||||
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out, RosterSnapshotAt(), nil
|
||||
}
|
||||
|
||||
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
|
||||
// never pushed one. Read from the meta row, not the entries, so a snapshot that
|
||||
// legitimately carried nobody still counts as a snapshot.
|
||||
func RosterSnapshotAt() int64 {
|
||||
var at sql.NullInt64
|
||||
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_roster_meta WHERE id = 1`).Scan(&at)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("RosterSnapshotAt query failed", "err", err)
|
||||
return 0
|
||||
}
|
||||
return at.Int64
|
||||
}
|
||||
@@ -21,6 +21,37 @@ CREATE TABLE IF NOT EXISTS stories (
|
||||
published_at INTEGER
|
||||
);
|
||||
|
||||
-- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live
|
||||
-- board and it replaces this table wholesale. Rows are state that is currently
|
||||
-- true ("Josie is in holymachina"), which is the one thing the story feed can
|
||||
-- never be — every dispatch there is an accomplishment, and an accomplishment is
|
||||
-- a clipping the moment it lands.
|
||||
--
|
||||
-- token is gogobee's per-player roster token, not a Matrix handle and not a
|
||||
-- story GUID. Players who ran "!news optout" are omitted from the snapshot
|
||||
-- upstream and so never appear here at all.
|
||||
CREATE TABLE IF NOT EXISTS adventure_roster (
|
||||
token TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
class_race TEXT,
|
||||
status TEXT NOT NULL, -- "expedition" | "idle"
|
||||
zone TEXT,
|
||||
region TEXT,
|
||||
day INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle
|
||||
idle_hours INTEGER NOT NULL DEFAULT 0, -- hours since last player action
|
||||
snapshot_at INTEGER NOT NULL -- when gogobee took the snapshot
|
||||
);
|
||||
|
||||
-- The snapshot time lives outside the rows because an *empty* board is
|
||||
-- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A
|
||||
-- MAX(snapshot_at) over zero rows can't tell those apart, and the page must —
|
||||
-- one is "quiet realm", the other is "the wire is down, trust nothing here".
|
||||
CREATE TABLE IF NOT EXISTS adventure_roster_meta (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
|
||||
@@ -151,12 +151,14 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||
// media), and the link's og:image carries the preview instead.
|
||||
// No Source: the source tag exists to credit an outlet Pete is relaying
|
||||
// (`ars technica`). On his own reporting it renders as him signing his
|
||||
// own name under his own message.
|
||||
s.advPost(AdvPost{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: articleURL,
|
||||
Source: advSource,
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
}
|
||||
@@ -190,6 +192,8 @@ func advEventMeta(eventType string) (label, emoji string) {
|
||||
return "Milestone", "🏅"
|
||||
case "retreat":
|
||||
return "Pulled out", "🎒"
|
||||
case "departure":
|
||||
return "Wandered off", "🚪"
|
||||
}
|
||||
return "Dispatch", "📣"
|
||||
}
|
||||
@@ -390,6 +394,13 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
||||
return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone),
|
||||
fmt.Sprintf("%s turned around %s — %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.",
|
||||
f.Subject, howFar, f.Zone, atLevel), true
|
||||
case "departure":
|
||||
// A bored adventurer let themselves out. Nobody sent them — they got
|
||||
// restless waiting on a player who wasn't coming, took the cheap supplies
|
||||
// they could afford, and went. Pete plays it straight and a little fond;
|
||||
// the joke tells itself, and the player it's about may well be reading.
|
||||
return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
|
||||
fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
|
||||
case "arrival":
|
||||
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
|
||||
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
|
||||
|
||||
@@ -94,7 +94,6 @@ func (s *Server) postDailyDigest(now time.Time) {
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: s.digestURL(date),
|
||||
Source: advSource,
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ type channelPage struct {
|
||||
PrevURL string
|
||||
NextURL string
|
||||
Total int
|
||||
|
||||
// Roster is the live adventurer board, populated on the adventure section
|
||||
// only and only on page 1 — it is present tense, and page 2 of an archive is
|
||||
// not where anyone looks for what's happening right now.
|
||||
Roster []RosterView
|
||||
RosterStale bool
|
||||
ShowRoster bool
|
||||
}
|
||||
|
||||
type indexPage struct {
|
||||
@@ -343,6 +350,10 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
||||
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
|
||||
Total: total,
|
||||
}
|
||||
if ch.Slug == "adventure" && page == 1 {
|
||||
data.Roster, data.RosterStale, _ = s.roster()
|
||||
data.ShowRoster = true
|
||||
}
|
||||
s.render(w, "channel", data)
|
||||
}
|
||||
|
||||
|
||||
183
internal/web/roster.go
Normal file
183
internal/web/roster.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The live adventurer board.
|
||||
//
|
||||
// Everything else Pete publishes about the realm is an *accomplishment* — a
|
||||
// death, a clear, a milestone. Those are clippings: they read as archive the
|
||||
// instant they land, however fast we deliver them, and no refresh interval fixes
|
||||
// that. The board is the opposite kind of thing. It is state that is currently
|
||||
// true, so it is worth looking at *now*, and it goes stale on its own if we stop
|
||||
// hearing from gogobee.
|
||||
//
|
||||
// Direction of travel is gogobee → Pete, like every other adventure payload:
|
||||
// Pete has no route back into the game box's network and we are not opening one.
|
||||
// gogobee pushes the whole board; we replace ours with it.
|
||||
|
||||
const (
|
||||
// rosterStaleAfter — how old a snapshot can get before the board stops
|
||||
// claiming to be live. gogobee pushes every couple of minutes, so this is
|
||||
// several missed pushes, not one unlucky one.
|
||||
//
|
||||
// This matters more than it looks: if gogobee dies mid-expedition, the last
|
||||
// snapshot says "Josie is in holymachina" and would say so forever. A board
|
||||
// that lies confidently is worse than one that admits it lost the wire —
|
||||
// especially once players can act on it (see the target-list note below).
|
||||
rosterStaleAfter = 12 * time.Minute
|
||||
|
||||
// rosterMaxEntries — hard cap on a snapshot. A bounded realm; this only
|
||||
// exists so a malformed or hostile push can't spool unbounded rows.
|
||||
rosterMaxEntries = 500
|
||||
)
|
||||
|
||||
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
|
||||
type rosterPush struct {
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
Adventurers []storage.RosterEntry `json:"adventurers"`
|
||||
}
|
||||
|
||||
// RosterView is one row as the page renders it.
|
||||
type RosterView struct {
|
||||
Name string
|
||||
Level int
|
||||
ClassRace string
|
||||
OnRun bool
|
||||
Zone string
|
||||
Region string
|
||||
Where string // "holymachina, day 3" | "in town"
|
||||
Idle string // "quiet for 2 days" — only when idle
|
||||
}
|
||||
|
||||
// handleRosterIngest replaces the board with gogobee's latest snapshot.
|
||||
func (s *Server) handleRosterIngest(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 rosterPush
|
||||
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.Adventurers) > rosterMaxEntries {
|
||||
http.Error(w, "roster too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// A snapshot with no timestamp can't be aged, so it could never go stale —
|
||||
// it would sit on the page claiming to be live forever. Treat it as now.
|
||||
if push.SnapshotAt <= 0 {
|
||||
push.SnapshotAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
// Never trust the channel with a name. Same rule as factGuard: gogobee
|
||||
// pre-sanitizes to character names, and Pete checks anyway, because this is
|
||||
// the last thing standing between the payload and a public page.
|
||||
for i, e := range push.Adventurers {
|
||||
if e.Token == "" || e.Name == "" {
|
||||
http.Error(w, "each adventurer needs token and name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if e.Status != "expedition" && e.Status != "idle" {
|
||||
http.Error(w, fmt.Sprintf("adventurer %d: unknown status %q", i, e.Status), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := storage.ReplaceRoster(push.Adventurers, push.SnapshotAt); err != nil {
|
||||
slog.Error("roster ingest: replace failed", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleRosterAPI serves the board as JSON for the page's own re-poll, so an
|
||||
// open tab goes live without a reload. Public — same exposure as the rendered
|
||||
// page, no more.
|
||||
func (s *Server) handleRosterAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
views, stale, _ := s.roster()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"stale": stale,
|
||||
"adventurers": views,
|
||||
})
|
||||
}
|
||||
|
||||
// roster loads the board and decides whether it is still live. A stale board is
|
||||
// still returned — the page shows it dimmed and says so, rather than blanking,
|
||||
// because "here is who was out when we lost contact" beats an empty page.
|
||||
func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
|
||||
entries, snapshotAt, err := storage.LoadRoster()
|
||||
if err != nil {
|
||||
slog.Error("roster: load failed", "err", err)
|
||||
return nil, true, 0
|
||||
}
|
||||
stale = snapshotAt == 0 || time.Since(time.Unix(snapshotAt, 0)) > rosterStaleAfter
|
||||
for _, e := range entries {
|
||||
views = append(views, toRosterView(e))
|
||||
}
|
||||
return views, stale, snapshotAt
|
||||
}
|
||||
|
||||
func toRosterView(e storage.RosterEntry) RosterView {
|
||||
v := RosterView{
|
||||
Name: e.Name,
|
||||
Level: e.Level,
|
||||
ClassRace: e.ClassRace,
|
||||
OnRun: e.Status == "expedition",
|
||||
Zone: e.Zone,
|
||||
Region: e.Region,
|
||||
}
|
||||
if v.OnRun {
|
||||
where := e.Zone
|
||||
if e.Region != "" {
|
||||
where = fmt.Sprintf("%s, %s", e.Zone, e.Region)
|
||||
}
|
||||
if e.Day > 0 {
|
||||
where = fmt.Sprintf("%s — day %d", where, e.Day)
|
||||
}
|
||||
v.Where = where
|
||||
return v
|
||||
}
|
||||
v.Where = "in town"
|
||||
v.Idle = humanIdle(e.IdleHours)
|
||||
return v
|
||||
}
|
||||
|
||||
// humanIdle renders the idle clock the way a person would say it. Deliberately
|
||||
// coarse: this is colour on a board, not the boredom ticker's actual threshold.
|
||||
func humanIdle(hours int) string {
|
||||
switch {
|
||||
case hours <= 0:
|
||||
return ""
|
||||
case hours < 2:
|
||||
return "just got back"
|
||||
case hours < 24:
|
||||
return fmt.Sprintf("quiet for %dh", hours)
|
||||
case hours < 48:
|
||||
return "quiet for a day"
|
||||
default:
|
||||
return fmt.Sprintf("quiet for %d days", hours/24)
|
||||
}
|
||||
}
|
||||
193
internal/web/roster_test.go
Normal file
193
internal/web/roster_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
func postRoster(t *testing.T, s *Server, token string, push rosterPush) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(push)
|
||||
req := httptest.NewRequest("POST", "/api/ingest/roster", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleRosterIngest(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func entry(token, name, status, zone string) storage.RosterEntry {
|
||||
return storage.RosterEntry{Token: token, Name: name, Level: 5, ClassRace: "elf ranger", Status: status, Zone: zone}
|
||||
}
|
||||
|
||||
// TestRosterReplacesNeverMerges is the whole contract of the board. gogobee
|
||||
// sends the *complete* set of adventurers it is willing to show; anyone missing
|
||||
// from a later snapshot has left the board — they deleted their character, or
|
||||
// (the case that actually matters) they just ran `!news optout` and must
|
||||
// disappear from a public page. An upsert would leave them standing there
|
||||
// forever, which is precisely the exposure the opt-out exists to prevent.
|
||||
func TestRosterReplacesNeverMerges(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
now := time.Now().Unix()
|
||||
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||
entry("t1", "Josie", "expedition", "holymachina"),
|
||||
entry("t2", "Quack", "idle", ""),
|
||||
}}); w.Code != 200 {
|
||||
t.Fatalf("first push = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// Quack opts out; gogobee stops including her.
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now + 60, Adventurers: []storage.RosterEntry{
|
||||
entry("t1", "Josie", "expedition", "holymachina"),
|
||||
}}); w.Code != 200 {
|
||||
t.Fatalf("second push = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
views, stale, _ := s.roster()
|
||||
if stale {
|
||||
t.Error("fresh snapshot read as stale")
|
||||
}
|
||||
if len(views) != 1 {
|
||||
t.Fatalf("board has %d rows, want 1 — a dropped adventurer survived the swap", len(views))
|
||||
}
|
||||
if views[0].Name != "Josie" {
|
||||
t.Errorf("board kept %q, want Josie", views[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRosterGoesStale: if gogobee dies mid-expedition, the last snapshot says
|
||||
// "Josie is in holymachina" and would say so forever. The board has to admit it
|
||||
// lost the wire rather than keep asserting a stale fact as live.
|
||||
func TestRosterGoesStale(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
old := time.Now().Add(-2 * rosterStaleAfter).Unix()
|
||||
|
||||
postRoster(t, s, "tok", rosterPush{SnapshotAt: old, Adventurers: []storage.RosterEntry{
|
||||
entry("t1", "Josie", "expedition", "holymachina"),
|
||||
}})
|
||||
|
||||
views, stale, _ := s.roster()
|
||||
if !stale {
|
||||
t.Error("snapshot older than rosterStaleAfter still reported live")
|
||||
}
|
||||
// Stale, but still shown: "who was out when we lost contact" beats a blank page.
|
||||
if len(views) != 1 {
|
||||
t.Errorf("stale board dropped its rows (%d), want them kept and dimmed", len(views))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRosterEmptySnapshotIsNotStale: a realm where nobody is playing is a
|
||||
// legitimate state and must not be confused with gogobee having gone away. This
|
||||
// is why the snapshot time lives in its own row instead of MAX() over entries.
|
||||
func TestRosterEmptySnapshotIsNotStale(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: time.Now().Unix()}); w.Code != 200 {
|
||||
t.Fatalf("empty push = %d, want 200", w.Code)
|
||||
}
|
||||
views, stale, _ := s.roster()
|
||||
if len(views) != 0 {
|
||||
t.Errorf("empty snapshot produced %d rows", len(views))
|
||||
}
|
||||
if stale {
|
||||
t.Error("a quiet realm read as a dead wire — the two must not collapse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRosterIngestRejects(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
now := time.Now().Unix()
|
||||
|
||||
if w := postRoster(t, s, "wrong", rosterPush{SnapshotAt: now}); w.Code != 401 {
|
||||
t.Errorf("bad bearer = %d, want 401", w.Code)
|
||||
}
|
||||
// An unknown status would render as neither "out there" nor "in town".
|
||||
bad := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||
{Token: "t1", Name: "Josie", Status: "vibing"},
|
||||
}}
|
||||
if w := postRoster(t, s, "tok", bad); w.Code != 400 {
|
||||
t.Errorf("unknown status = %d, want 400", w.Code)
|
||||
}
|
||||
// A nameless row would render an empty slot on a public page.
|
||||
nameless := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||
{Token: "t1", Status: "idle"},
|
||||
}}
|
||||
if w := postRoster(t, s, "tok", nameless); w.Code != 400 {
|
||||
t.Errorf("nameless adventurer = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDepartureRenders guards the cross-repo contract: gogobee emits this
|
||||
// event_type for a bored adventurer, and an event_type Pete doesn't know is a
|
||||
// 400 that retries and parks the bulletin forever.
|
||||
func TestDepartureRenders(t *testing.T) {
|
||||
headline, lede, ok := renderAdventure(AdvFact{
|
||||
EventType: "departure",
|
||||
Subject: "Camcast",
|
||||
Zone: "crypt_valdris",
|
||||
Level: 1,
|
||||
Actors: []string{"Camcast"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("departure event_type unknown to Pete — gogobee's bulletin would 400 and park")
|
||||
}
|
||||
if headline == "" || lede == "" {
|
||||
t.Error("departure rendered empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRosterViewWhere(t *testing.T) {
|
||||
out := toRosterView(storage.RosterEntry{
|
||||
Name: "Josie", Status: "expedition", Zone: "holymachina", Region: "the deep", Day: 3,
|
||||
})
|
||||
if !out.OnRun || out.Where != "holymachina, the deep — day 3" {
|
||||
t.Errorf("expedition row = %+v", out)
|
||||
}
|
||||
in := toRosterView(storage.RosterEntry{Name: "Quack", Status: "idle", IdleHours: 50})
|
||||
if in.OnRun || in.Where != "in town" || in.Idle != "quiet for 2 days" {
|
||||
t.Errorf("idle row = %+v", in)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventurePageRendersBoard drives the real page through the real template.
|
||||
// The handler tests above prove the data is right; this proves it reaches a
|
||||
// reader. A template slip (bad field, unclosed block) doesn't fail a build — it
|
||||
// 500s the whole adventure section at request time, board and clippings alike.
|
||||
func TestAdventurePageRendersBoard(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
|
||||
postRoster(t, s, "tok", rosterPush{
|
||||
SnapshotAt: time.Now().Unix(),
|
||||
Adventurers: []storage.RosterEntry{
|
||||
{Token: "t1", Name: "Josie", Level: 14, ClassRace: "human cleric",
|
||||
Status: "expedition", Zone: "holymachina", Day: 3},
|
||||
{Token: "t2", Name: "Quack", Level: 4, ClassRace: "elf ranger",
|
||||
Status: "idle", IdleHours: 50},
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/adventure", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("GET /adventure = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"Out there right now", // the board's headline
|
||||
"Josie", "holymachina", "day 3", // live zone, shown while she's still in it
|
||||
"Quack", "in town", "quiet for 2 days",
|
||||
"/api/roster", // the client re-poll, so an open tab stays true
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("adventure page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,11 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
|
||||
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
|
||||
|
||||
// The live board. Ingest is bearer-authed like the fact seam; the read side
|
||||
// is public because it renders on a public page anyway.
|
||||
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
|
||||
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
|
||||
|
||||
// Per-dispatch permalink (the article_url every ingested story points at).
|
||||
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
||||
// channel listing, registered in the channels loop above).
|
||||
|
||||
@@ -13,6 +13,81 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .ShowRoster}}
|
||||
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
|
||||
<p class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50" id="roster-status">
|
||||
{{if .RosterStale}}last known — the wire's gone quiet{{else}}live{{end}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
||||
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
||||
{{range .Roster}}
|
||||
<li class="flex items-center gap-4 px-5 py-3">
|
||||
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||||
<span class="font-semibold">{{.Name}}</span>
|
||||
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
||||
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
||||
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||||
</span>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// The board is state, not a story: an open tab should keep telling the truth
|
||||
// without a reload. Cheap poll — a handful of rows, no auth, no cache.
|
||||
(function () {
|
||||
var list = document.getElementById('roster-list');
|
||||
var status = document.getElementById('roster-status');
|
||||
var card = document.getElementById('roster-card');
|
||||
if (!list) return;
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function row(a) {
|
||||
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
||||
return '<li class="flex items-center gap-4 px-5 py-3">' +
|
||||
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||||
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
|
||||
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
||||
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
||||
esc(a.Where) + idle + '</span></li>';
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
fetch('/api/roster', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
var rows = data.adventurers || [];
|
||||
list.innerHTML = rows.length
|
||||
? rows.map(row).join('')
|
||||
: '<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody\'s out at the moment. Quiet week in the realm.</li>';
|
||||
status.textContent = data.stale ? "last known — the wire's gone quiet" : 'live';
|
||||
card.classList.toggle('opacity-60', !!data.stale);
|
||||
})
|
||||
.catch(function () { /* transient — the next tick will do */ });
|
||||
}
|
||||
|
||||
setInterval(refresh, 60000);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (!document.hidden) refresh();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{if .Stories}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{{range .Stories}}
|
||||
|
||||
Reference in New Issue
Block a user