Compare commits
5 Commits
adventure-
...
99574db3e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99574db3e9 | ||
|
|
8cb5b38599 | ||
|
|
a614077cff | ||
|
|
82d1c6ebeb | ||
|
|
10bcc78c51 |
@@ -37,7 +37,9 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
|||||||
if s.Lede != "" {
|
if s.Lede != "" {
|
||||||
plainParts = append(plainParts, 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")
|
plain = strings.Join(plainParts, "\n")
|
||||||
|
|
||||||
// HTML body
|
// HTML body
|
||||||
@@ -55,25 +57,32 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
|||||||
if s.Lede != "" {
|
if s.Lede != "" {
|
||||||
htmlParts = append(htmlParts, html.EscapeString(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/>")
|
htmlBody = strings.Join(htmlParts, "<br/>")
|
||||||
|
|
||||||
return plain, htmlBody
|
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 {
|
func formatSourceTag(source string, platforms []string, isHTML bool) string {
|
||||||
if isHTML {
|
var parts []string
|
||||||
parts := []string{fmt.Sprintf("<code>%s</code>", html.EscapeString(strings.ToLower(source)))}
|
if source != "" {
|
||||||
for _, p := range platforms {
|
parts = append(parts, strings.ToLower(source))
|
||||||
parts = append(parts, fmt.Sprintf("<code>%s</code>", html.EscapeString(p)))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, " \u00b7 ")
|
|
||||||
}
|
}
|
||||||
|
parts = append(parts, platforms...)
|
||||||
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
|
if len(parts) == 0 {
|
||||||
for _, p := range platforms {
|
return ""
|
||||||
parts = append(parts, fmt.Sprintf("`%s`", p))
|
}
|
||||||
|
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 ")
|
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
|
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 (
|
CREATE TABLE IF NOT EXISTS post_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
guid TEXT NOT NULL,
|
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 != "" {
|
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
||||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||||
// media), and the link's og:image carries the preview instead.
|
// 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{
|
s.advPost(AdvPost{
|
||||||
GUID: f.GUID,
|
GUID: f.GUID,
|
||||||
Headline: headline,
|
Headline: headline,
|
||||||
Lede: lede,
|
Lede: lede,
|
||||||
ArticleURL: articleURL,
|
ArticleURL: articleURL,
|
||||||
Source: advSource,
|
|
||||||
Channel: s.adv.Channel,
|
Channel: s.adv.Channel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -188,6 +190,10 @@ func advEventMeta(eventType string) (label, emoji string) {
|
|||||||
return "Pete's duels", "🤝"
|
return "Pete's duels", "🤝"
|
||||||
case "milestone":
|
case "milestone":
|
||||||
return "Milestone", "🏅"
|
return "Milestone", "🏅"
|
||||||
|
case "retreat":
|
||||||
|
return "Pulled out", "🎒"
|
||||||
|
case "departure":
|
||||||
|
return "Wandered off", "🚪"
|
||||||
}
|
}
|
||||||
return "Dispatch", "📣"
|
return "Dispatch", "📣"
|
||||||
}
|
}
|
||||||
@@ -375,6 +381,26 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
|||||||
case "death":
|
case "death":
|
||||||
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
||||||
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
||||||
|
case "retreat":
|
||||||
|
// An expedition that came apart without killing anyone. Until gogobee
|
||||||
|
// started sending these, the feed had no way to say "it went badly and
|
||||||
|
// everyone lived" — so it never said it, and the classes that retreat
|
||||||
|
// often simply never appeared. Warm, not a failure notice: everyone came
|
||||||
|
// home, and that is the part Pete leads with.
|
||||||
|
howFar := "barely a day in"
|
||||||
|
if f.Count > 1 {
|
||||||
|
howFar = fmt.Sprintf("%d days in", f.Count)
|
||||||
|
}
|
||||||
|
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":
|
case "arrival":
|
||||||
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
|
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
|
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,
|
Headline: headline,
|
||||||
Lede: lede,
|
Lede: lede,
|
||||||
ArticleURL: s.digestURL(date),
|
ArticleURL: s.digestURL(date),
|
||||||
Source: advSource,
|
|
||||||
Channel: s.adv.Channel,
|
Channel: s.adv.Channel,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
55
internal/web/adventure_retreat_test.go
Normal file
55
internal/web/adventure_retreat_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The retreat bulletin — gogobee's newest event type.
|
||||||
|
//
|
||||||
|
// The failure this guards against is silent and total: handleAdventureIngest
|
||||||
|
// answers an unrecognized event_type with 400, and gogobee's sender treats any
|
||||||
|
// non-2xx as a failure, retries eight times over ~two hours, and then PARKS the
|
||||||
|
// row forever. So a gogobee that emits `retreat` against a Pete that doesn't
|
||||||
|
// know the word doesn't log an error anyone reads — it just quietly drops every
|
||||||
|
// retreat the realm ever files. Pete has to learn the word first, and this test
|
||||||
|
// is what says he has.
|
||||||
|
func TestAdventureIngest_AcceptsRetreat(t *testing.T) {
|
||||||
|
const token = "s3cret-token"
|
||||||
|
s, posted := newAdvServer(t, token)
|
||||||
|
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "retreat:abc:1000", EventType: "retreat", Tier: "bulletin",
|
||||||
|
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||||
|
Zone: "the Underforge", Level: 12, Count: 3, Outcome: "retreated",
|
||||||
|
OccurredAt: 1000,
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("ingest status = %d body=%s — Pete rejected a retreat, so gogobee will "+
|
||||||
|
"retry it eight times and park it forever", rw.Code, rw.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := storage.GetStoryByGUID("retreat:abc:1000")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("retreat was accepted but not stored: %v", err)
|
||||||
|
}
|
||||||
|
// It has to actually say what happened, in Pete's voice, using only the
|
||||||
|
// supplied facts.
|
||||||
|
body := got.Headline + " " + got.Lede
|
||||||
|
for _, want := range []string{"Brannigan", "the Underforge"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("rendered retreat is missing %q: %q", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Lede, "3 days in") {
|
||||||
|
t.Errorf("the day count never made it into the copy: %q", got.Lede)
|
||||||
|
}
|
||||||
|
// Bulletin, not priority: a retreat goes in the daily digest, it does not
|
||||||
|
// interrupt the room. Pete announcing every failed run live would be a
|
||||||
|
// firehose — and an unkind one.
|
||||||
|
if len(*posted) != 0 {
|
||||||
|
t.Errorf("a bulletin was posted live: %+v", *posted)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,13 @@ type channelPage struct {
|
|||||||
PrevURL string
|
PrevURL string
|
||||||
NextURL string
|
NextURL string
|
||||||
Total int
|
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 {
|
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),
|
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
|
||||||
Total: total,
|
Total: total,
|
||||||
}
|
}
|
||||||
|
if ch.Slug == "adventure" && page == 1 {
|
||||||
|
data.Roster, data.RosterStale, _ = s.roster()
|
||||||
|
data.ShowRoster = true
|
||||||
|
}
|
||||||
s.render(w, "channel", data)
|
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).
|
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
|
||||||
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
|
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).
|
// Per-dispatch permalink (the article_url every ingested story points at).
|
||||||
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
||||||
// channel listing, registered in the channels loop above).
|
// channel listing, registered in the channels loop above).
|
||||||
|
|||||||
@@ -13,6 +13,81 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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}}
|
{{if .Stories}}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
{{range .Stories}}
|
{{range .Stories}}
|
||||||
|
|||||||
13
main.go
13
main.go
@@ -267,11 +267,16 @@ func main() {
|
|||||||
|
|
||||||
// Adapter: the web ingest handler posts priority adventure beats live to
|
// Adapter: the web ingest handler posts priority adventure beats live to
|
||||||
// Matrix through the same queue everything else uses (bypasses pacing).
|
// Matrix through the same queue everything else uses (bypasses pacing).
|
||||||
// PostNow doesn't consult posting.enabled, so gate here: with posting off,
|
//
|
||||||
// adventure stays website-only like every other channel (nil poster also
|
// Adventure carries its own enable switch and is push-based: facts arrive
|
||||||
// keeps the digest loop from starting).
|
// from gogobee at ingest, they are not polled, classified or paced, and they
|
||||||
|
// never enter the round-robin rotation. So it is gated on [adventure] alone,
|
||||||
|
// NOT on posting.enabled — that flag governs the RSS pipeline's chatter, and
|
||||||
|
// an operator running "news on the web only, adventure live in the games
|
||||||
|
// room" is a legitimate configuration. A nil poster (adventure off, or no
|
||||||
|
// channel) still keeps both the live beats and the digest loop shut.
|
||||||
var advPost web.PriorityPoster
|
var advPost web.PriorityPoster
|
||||||
if postingEnabled {
|
if cfg.Adventure.Enabled && cfg.Adventure.Channel != "" {
|
||||||
advPost = func(p web.AdvPost) {
|
advPost = func(p web.AdvPost) {
|
||||||
queue.PostNow(poster.QueueItem{
|
queue.PostNow(poster.QueueItem{
|
||||||
GUID: p.GUID,
|
GUID: p.GUID,
|
||||||
|
|||||||
Reference in New Issue
Block a user