3 Commits

Author SHA1 Message Date
prosolis
a614077cff Adventure: Pete learns the word "retreat"
gogobee has started filing a `retreat` bulletin — an expedition that ended with
the player walking out alive. Pete had no case for it, and handleAdventureIngest
answers an unrecognized event_type with 400.

That failure is silent and total. gogobee's sender treats any non-2xx as a
failure, retries eight times over roughly two hours, and then parks the row for
good. A gogobee emitting `retreat` at a Pete who doesn't know the word would not
log an error anyone reads — it would just quietly drop every retreat the realm
ever files.

So Pete has to learn it BEFORE gogobee starts saying it. Deploy order matters
here, and it is Pete first.

The copy leads with everyone coming home, because that is the true and the kind
part: a retreat is a bad day, not a funeral. Bulletin tier — it rides the daily
digest and does not interrupt the room. Announcing every failed run live would
be a firehose, and an unkind one.

https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
2026-07-12 10:33:53 -07:00
prosolis
82d1c6ebeb Adventure: gate live beats on [adventure], not posting.enabled
posting.enabled governs the RSS pipeline's Matrix chatter. Adventure facts
are push-based -- they arrive from gogobee at ingest, are never polled,
classified or paced, and are excluded from the round-robin rotation -- so
folding them under the same flag made 'news on the web only, adventure live
in the games room' inexpressible. Gate advPost on [adventure].enabled + a
configured channel instead; a nil poster still shuts off both the live beats
and the digest loop.
2026-07-11 08:25:29 -07:00
prosolis
10bcc78c51 Merge adventure-news: gogobee adventure news section 2026-07-11 08:07:58 -07:00
3 changed files with 79 additions and 4 deletions

View File

@@ -188,6 +188,8 @@ 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", "🎒"
} }
return "Dispatch", "📣" return "Dispatch", "📣"
} }
@@ -375,6 +377,19 @@ 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 "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

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

13
main.go
View File

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