Compare commits

...

2 Commits

Author SHA1 Message Date
prosolis
99574db3e9 news: the adventure page gets something that's actually happening
Every dispatch Pete publishes is an accomplishment — a death, a clear, a
milestone — and an accomplishment is a newspaper clipping the moment it lands.
No refresh interval fixes that. So the page never felt alive, and it never was
going to.

The board is the other kind of thing: state that is currently true. gogobee
pushes the whole roster, we replace ours with it, and it renders above the
clippings. An open tab re-polls so it keeps telling the truth.

Replace, never merge: anyone gogobee omits (opted out, no character) drops off
the public page. That omission IS the opt-out — a standing row showing class,
level and zone names the player anyway, so "an adventurer" would have been a fig
leaf.

The snapshot time lives in its own row, because an empty board is ambiguous:
nobody playing, or gogobee stopped talking to us. The page has to tell those
apart — one is a quiet realm, the other is a board that lies confidently, which
is worse than one that admits it lost the wire.

Also teaches Pete "departure", so a bored adventurer letting itself out is news.
2026-07-13 18:05:38 -07:00
prosolis
8cb5b38599 Adventure: stop signing my own posts
The source tag credits an outlet Pete is relaying (`ars technica`). On
his own reporting it rendered as a trailing `pete` under a message he
already sent - him signing his own name. Drop Source on adventure posts
and omit the tag line entirely when there's nothing to credit; RSS posts
keep theirs.
2026-07-12 22:01:12 -07:00
10 changed files with 640 additions and 15 deletions

View File

@@ -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 {
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)))
}
return strings.Join(parts, " \u00b7 ")
var parts []string
if source != "" {
parts = append(parts, strings.ToLower(source))
}
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
for _, p := range platforms {
parts = append(parts, fmt.Sprintf("`%s`", p))
parts = append(parts, platforms...)
if len(parts) == 0 {
return ""
}
for i, p := range parts {
if isHTML {
parts[i] = fmt.Sprintf("<code>%s</code>", html.EscapeString(p))
} else {
parts[i] = fmt.Sprintf("`%s`", p)
}
}
return strings.Join(parts, " \u00b7 ")
}

108
internal/storage/roster.go Normal file
View 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
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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
View 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
View 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)
}
}
}

View File

@@ -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).

View File

@@ -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}}