adventure: keep the facts, not just the sentence we made of them

gogobee already sends boss/opponent/zone/outcome/level on every dispatch.
renderAdventure melted them into prose and only the prose was persisted, so
"how many bosses has she downed" was answerable only by parsing English back
out of a headline.

adventure_events keeps the fact as fact. It's the one adventure table that's a
log rather than a snapshot: the roster answers where Josie is now and is
replaced every tick; this answers what she has ever done, which no snapshot
can. INSERT OR IGNORE on the guid because gogobee retries a fact whose ack it
lost, and this is the only adventure store where a duplicate is permanently
wrong — the roster forgives one by replacing itself, a double-counted kill is
in the tally forever.

On top of it the who page grows two public sections, counted from dispatches
that were already public: the record (tallies, per-boss and per-zone, realm
firsts, milestones) and the trail (the last 40 facts, each linking to the
dispatch that told it). One read feeds both — the pool is MaxOpenConns(1), so
six COUNT queries would serialize for an answer that fits in memory.

Only the trail is capped. A limit on the read would truncate a *tally* rather
than a list, and a veteran's kill count frozen at 40 reads as a fact instead of
a missing page. Only the subject of a fact earns a trophy: a duel you lost
still names you, and it belongs on your trail but not in your record.

Trophies count forward only. Every adventurer's past is prose in the feed and
can't be counted back out, so they show no record until they next do something
— a clean absence rather than a wall of zeroes.

No treasures: there's no loot fact on the wire, and inventory is current-state
with no history, so counting the vault would score a bought sword as a trophy.
Needs a fact type upstream.
This commit is contained in:
prosolis
2026-07-16 23:57:23 -07:00
parent b814f936a8
commit cbe9e67b3e
7 changed files with 709 additions and 0 deletions

View File

@@ -52,6 +52,46 @@ CREATE TABLE IF NOT EXISTS adventure_roster_meta (
snapshot_at INTEGER NOT NULL DEFAULT 0
);
-- adventure_events is the structured residue of a dispatch, and it is the one
-- adventure table that is a *log* rather than a snapshot. That inversion is the
-- point. The roster answers "where is Josie now" and is replaced every tick;
-- this answers "what has Josie ever done", which no snapshot can, because each
-- push throws the last one away.
--
-- It exists because the facts were already arriving and we were burning them.
-- gogobee sends boss/opponent/zone/outcome on every fact; renderAdventure melted
-- them into a sentence and only the sentence was kept, so "how many bosses has
-- she downed" was answerable only by parsing English back out of a headline.
-- These rows are that fact, kept as fact. The story row remains the thing people
-- read; this is the thing we can count.
--
-- Keyed on guid, the same idempotency key as stories: a gogobee retry must not
-- double-count a kill. subject/opponent are *character names*, not roster tokens,
-- because that is what a fact carries and what the feed already prints in public.
-- Names are therefore the join back to a board row (roster.name -> token), which
-- is weaker than an id: a renamed or recycled character takes their history with
-- them. gogobee doesn't put a stable character id on the wire today, and inventing
-- one Pete-side would only be a guess at which two names were the same person.
CREATE TABLE IF NOT EXISTS adventure_events (
guid TEXT PRIMARY KEY, -- == stories.guid; the dispatch this fact rendered into
event_type TEXT NOT NULL,
tier TEXT, -- "priority" | "bulletin"
subject TEXT, -- character name: the one it happened to
opponent TEXT, -- character name: the other player, when there is one
boss TEXT, -- game-authored monster name, not player-controlled
zone TEXT,
region TEXT,
level INTEGER NOT NULL DEFAULT 0,
tally INTEGER NOT NULL DEFAULT 0, -- the fact's Count: defenders, days in, ...
outcome TEXT,
milestone TEXT,
actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit
occurred_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_adv_events_opponent ON adventure_events(opponent, occurred_at DESC) WHERE opponent IS NOT NULL AND opponent <> '';
CREATE INDEX IF NOT EXISTS idx_adv_events_type ON adventure_events(event_type, occurred_at DESC);
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only