emitFact now runs the final fact through authorDispatch, which asks the local
Ollama model for a warm-reporter headline and lede and ships them on the Fact.
Pete guards and publishes them, falling back to its own templates on anything
it rejects — so authoring is best-effort by design: LLM off, a timeout, a
malformed generation, or an over-length pair all return an empty prose pair and
Pete templates the fact. The names allowed in the prose are the fact's Actors,
built from the post-opt-out subject/opponent, so what the model may say and what
Pete's guard permits are the same list. Synchronous like the holdem tip rewrite,
but on a tight 15s budget: news facts are infrequent and a template now beats a
voiced dispatch late. With no route for Pete to call back into this box, the
voice lives in the prompt here rather than in a Pete-owned inference endpoint.
A treasure that earns a public room announce now also files a treasure_found
fact, so Pete can count it on the finder's trophy case. The emit rides inside
announceTreasureToRoom, reusing the RoomAnnounce != "" gate as the newsworthiness
filter — a copper-piece pickup never becomes news, and a reversed auto-swap never
emits, because the announce it shares is cancelled on undo.
The realm's first finder of a given treasure is a priority hoard; a later finder
of the same item is a bulletin, keyed on the treasure across the realm via
claimRealmFirst, the same first/repeat split zone_first uses. The item name rides
in stakes and the tier-derived rarity in outcome.
treasure_found is a new event_type, so Pete's ingest must deploy first or the
first finds park on the retry ladder forever.
We only ever told Pete about outcomes. Nothing emitted when an expedition
*started*, which is why the two bored adventurers walked into dungeons and the
news feed said nothing at all — it wasn't broken, it had nothing to say.
Two halves:
A roster snapshot, pushed every 2 minutes. Deliberately NOT on the durable fact
queue: a fact is history and losing it loses it forever, so it retries. A
snapshot is a photograph of the present, and a retried one is a lie — by the
time it lands, she's moved. The next tick carries the truth. That's also what
lets Pete's staleness timer work: if we stay down, nothing arrives, and the
board stops claiming to be live instead of insisting forever that Josie is
still in holymachina.
And a "departure" bulletin when a bored adventurer lets itself out.
The snapshot omits opted-out players rather than anonymizing them, and carries a
board token distinct from every event token, so a standing row can't become the
key that links a player's dispatches back together.
The player_meta scan folds last_player_action_at/created_at in Go instead of
COALESCE()ing in SQL — modernc rebuilds time.Time from the declared column type
and COALESCE erases it. A failed scan here would publish an empty board and
every adventurer would vanish from the page.
Priority tier is the only thing that makes Pete post a live Matrix beat,
and the only two priority facts gogobee filed - zone_first and death -
are both moments TwinBee narrates in-room as they happen. Every live beat
was an echo. File them as bulletins: still on the site, still in the
daily digest, no second telling to the people who were there.
- emitZoneClearNews: claim realm-first before the name guard so an unnamed
straggler's first clear seeds news_realm_firsts (else the next named
clearer is mis-announced as first-ever). Mirrors backfillZoneFirsts.
- RunMaintenance: reap permanently-parked pete_emit_queue rows (unsent,
>30d) so a durable Pete outage can't accrete rows forever.
- emitDeathNews: gate on Enabled()/newsEmissionOn() before the char DB
reads; markAdventureDead fires per-member on party wipes + in the sim.
Closes the two deferred code-review follow-ups on the adventure-news
seam, plus folds in two pre-committed WIP fixes.
A. Privacy — the public GUID no longer leaks a stable per-player id.
Replaced userHash(userID)=sha256(userID)[:6] with
eventToken(userID, discriminator)=HMAC-SHA256(salt, userID‖disc).
The salt is 32 random bytes, auto-generated once and persisted in the
durable news_config table (cached via sync.Once). Because each event
uses a distinct HMAC message, tokens are a PRF output and are BOTH
uncomputable from a Matrix handle (no enumeration of a player's
events, incl. ones anonymized after !news optout) AND mutually
unlinkable (a named event can't be walked back to a player's other,
anonymized events). Updated all emit sites: pete.go zone, dnd_combat
death, adventure_duel rival, dnd_setup arrival, achievements
milestone, bootstrap x3.
B. Taxonomy — repeat zone clears were mislabeled zone_first. Now emit
zone_clear (bulletin) vs zone_first (realm-first, priority). Adopted
the invariant GUID-prefix == event_type, which also fixes latent
permalink mislabels (achv->milestone, rival->rival_result rendered a
neutral "Dispatch" on their permalink pages).
Folded-in WIP fixes: create the news_config table b42beec's
newsEmissionOn reads but never created; reap sent pete_emit_queue rows
in RunMaintenance; don't burn a retry attempt when shutdown cancels an
in-flight send.
Tests: TestEventToken (salted/stable/per-event/persisted),
TestEmitZoneClearTaxonomy (first->zone_first, repeat->zone_clear),
updated pete_test.go prefixes. Full internal suite + vet green.
Unshipped. Deploy Pete first (it must know zone_clear), then gogobee.