diff --git a/internal/config/config.go b/internal/config/config.go index af4fae8..a724b96 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,6 +38,48 @@ type AdventureConfig struct { // 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from // "unset" and doesn't get silently rewritten to the default. DigestHour *int `toml:"digest_hour"` + // RoomSilentTypes lists event types gogobee already announces in the games + // room in TwinBee's own voice. Pete stores them and publishes them on the + // site, but never posts them to Matrix — neither the live priority beat nor + // the daily digest — so the room hears each moment once. + // + // Unset falls back to defaultRoomSilentTypes; an explicit empty list turns + // the suppression off and restores the double-post. + RoomSilentTypes []string `toml:"room_silent_types"` +} + +// defaultRoomSilentTypes are the event types TwinBee announces to the games room +// itself as of gogobee HEAD. Each has a matching room announce on the gogobee +// side (announceTreasureToRoom, the duel broadcast, announceMischief*, +// announceWorldBoss), so a Pete post is a second telling of the same beat. +// +// Types NOT listed here have no room announce behind them and are Pete's alone +// to report: zone_clear, zone_first, boss_kill, arrival, departure, death, +// retreat, milestone, companion_hire. +var defaultRoomSilentTypes = []string{ + "treasure_found", + "rival_result", + "mischief_contract", + "mischief_survived", + "mischief_downed", + "mischief_fizzled", + "siege_start", + "siege_win", + "siege_loss", +} + +// RoomSilentSet returns the room-suppressed event types as a lookup set, +// resolving the unset case. Safe on a zero-value AdventureConfig. +func (a AdventureConfig) RoomSilentSet() map[string]bool { + types := a.RoomSilentTypes + if types == nil { + types = defaultRoomSilentTypes + } + set := make(map[string]bool, len(types)) + for _, t := range types { + set[t] = true + } + return set } // DigestHourOrDefault is the UTC hour the daily digest posts, resolving the diff --git a/internal/web/adventure.go b/internal/web/adventure.go index 532fba1..e20fc04 100644 --- a/internal/web/adventure.go +++ b/internal/web/adventure.go @@ -76,6 +76,13 @@ const advSource = "Pete" // Matrix; the row exists only so the digest skips it. const advBackfillEvent = "adv-backfill" +// advRoomSilentEvent is the synthetic post_log event id used to retire a +// dispatch whose event type gogobee announces in the games room itself. Like +// advBackfillEvent it never went to Matrix; the row exists so the digest skips +// it, and the distinct id keeps "TwinBee said it" separable from "backfilled" +// when reading post_log later. +const advRoomSilentEvent = "adv-room-silent" + // handleAdventureIngest receives a game-event fact from gogobee, templates it // into a deterministic story, publishes it to the /adventure section, and posts // PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID. @@ -210,13 +217,25 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) { slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier) - // NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only - // the live post isn't enough: the digest collects adventure rows that carry no - // post_log entry, so a backfilled bulletin would still be swept into the next - // roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid - // against the digest up front instead. - if f.NoPush { - storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent) + // Two reasons a dispatch never reaches Matrix, both retired the same way: + // + // - NoPush: a cold-start backfill, the back-catalogue dump it exists to + // prevent. + // - A room-silent type: gogobee already announced this exact moment to the + // games room in TwinBee's voice, and relaying it is the room hearing one + // beat twice in two voices. + // + // Suppressing only the live post isn't enough: the digest collects adventure + // rows that carry no post_log entry, so a held-back bulletin would still be + // swept into the next roundup. Retire the guid against the digest up front + // instead. The row was stored above either way, so the site, the permalink + // and the push alerts keep the full record. + if f.NoPush || s.roomSilent[f.EventType] { + retiredAs := advBackfillEvent + if !f.NoPush { + retiredAs = advRoomSilentEvent + } + storage.MarkAdventureDigested([]string{f.GUID}, retiredAs) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) return diff --git a/internal/web/adventure_test.go b/internal/web/adventure_test.go index 6bfee1c..c6c1c45 100644 --- a/internal/web/adventure_test.go +++ b/internal/web/adventure_test.go @@ -162,10 +162,12 @@ func TestAdventureDigest(t *testing.T) { now := time.Now() // Two bulletins + one priority (which posts live and must be excluded). + // Both bulletin types are ones TwinBee does NOT announce itself — a + // room-silent type never reaches the digest (see TestAdventureRoomSilent). postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin", Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()}) - postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin", - Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()}) + postFact(t, s, token, AdvFact{GUID: "milestone:b:2", EventType: "milestone", Tier: "bulletin", + Actors: []string{"Kif"}, Subject: "Kif", Milestone: "Ten zones cleared", OccurredAt: now.Unix()}) postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority", Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()}) if len(*posted) != 1 { @@ -191,6 +193,43 @@ func TestAdventureDigest(t *testing.T) { } } +// TestAdventureRoomSilent: an event type gogobee announces in the games room +// itself is published to the site but never reaches Matrix — not as a live +// priority beat, and not swept into the next digest either. +func TestAdventureRoomSilent(t *testing.T) { + const token = "t" + s, posted := newAdvServer(t, token) + now := time.Now() + + postFact(t, s, token, AdvFact{GUID: "treasure_found:e:5", EventType: "treasure_found", Tier: "priority", + Actors: []string{"Rurina"}, Subject: "Rurina", Zone: "Dragon's Lair", Level: 20, + Stakes: "The Cartographer's Final Map", Outcome: "legendary", OccurredAt: now.Unix()}) + if len(*posted) != 0 { + t.Fatalf("room-silent beat posted live: %d posts, want 0", len(*posted)) + } + + // The site keeps the full record — suppression is Matrix-only. + got, err := storage.GetStoryByGUID("treasure_found:e:5") + if err != nil || got == nil { + t.Fatalf("room-silent beat missing from the site: %v", err) + } + + // And it doesn't come back around in the roundup. + s.postDailyDigest(now.UTC()) + if len(*posted) != 0 { + t.Errorf("room-silent beat swept into digest: %d posts, want 0", len(*posted)) + } + + // An explicit empty list turns suppression off: the same beat posts live. + s.roomSilent = config.AdventureConfig{RoomSilentTypes: []string{}}.RoomSilentSet() + postFact(t, s, token, AdvFact{GUID: "treasure_found:e:6", EventType: "treasure_found", Tier: "priority", + Actors: []string{"Rurina"}, Subject: "Rurina", Zone: "Dragon's Lair", Level: 20, + Stakes: "The Cartographer's Final Map", Outcome: "legendary", OccurredAt: now.Unix()}) + if len(*posted) != 1 { + t.Errorf("suppression off: %d posts, want 1", len(*posted)) + } +} + // TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint // returns a themed SVG, ingested cards carry its local path, and the permalink // page is noindex with an og:image. diff --git a/internal/web/server.go b/internal/web/server.go index 76913b6..f0eb73d 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -69,6 +69,7 @@ type Server struct { pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient() adv config.AdventureConfig // gogobee adventure-news seam advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only + roomSilent map[string]bool // event types TwinBee announces itself: site yes, Matrix never channels []Channel // live sections: the catalogue minus anything gated off (adventure) // Daily-rotated salt for the privacy-preserving unique-visitor estimate. @@ -144,7 +145,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo live = append(live, ch) } - s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}} + s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, roomSilent: adv.RoomSilentSet(), channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}} // Optional OIDC sign-in (Authentik). Discovery is a network call; if the // provider is unreachable at boot we log and serve anonymously rather than