games: gogobee learns to poll Pete for money it has to move

Pete holds the chips; we hold the euros and are the only one who can move
them. This is the loop that turns an escrow row on games.parodia.dev into a
real balance change here.

It is a poll because Pete cannot call us and isn't going to be able to. Every
step is keyed on the escrow guid, because every step can be interrupted in the
worst possible place: die after DebitIdem and before the verdict is queued,
and Pete re-offers the row, we claim it again, the debit replays as a no-op
and reports the same answer. The player is charged once. That is the only
property here that really matters, and there is a test that kills us three
times to prove it.

The verdict rides the queue that already carries adventure facts — it wants
the same durability, backoff and parking, so the row now says where it's going
rather than the queue growing a twin. Flush sends it at once instead of after
a 15s sender tick, because there's a person at the other end watching a
spinner.

First GET gogobee has ever made to Pete.
This commit is contained in:
prosolis
2026-07-13 23:00:11 -07:00
parent ab2bcf0c59
commit 6d402343e6
16 changed files with 5051 additions and 15 deletions

View File

@@ -0,0 +1,120 @@
# Adventure — the big update (2026-07-09 → 2026-07-11)
62 commits, ~31k lines, 7 phases. All merged to `main` and live in prod as of
2026-07-11. Working-tree doc; do not commit.
---
## The one-line version
Adventure stopped being a solo grind. You can now bring friends, fight each
other, fight the world together, prestige past the level cap, and there's an
actual story running underneath it — and Pete reports on all of it.
---
## 1. Co-op — you can bring people now (N3)
The headline. The combat engine was 1-versus-1 at its core; it was rewritten
into an N-player engine with real initiative, and every seat gets its own turn.
- `!expedition invite @user` — leader, Day 1 only
- `!expedition accept` / `decline` / `party` / `leave`
- Everyone buys their own supply pack. Everyone acts on their own turn.
- The enemy's action economy scales to the party, so four people don't trivialize
a room.
## 2. Fight each other (N6)
- `!duel @user [stake]`, then `!duel accept` / `decline` / `status`.
Staked, and **nobody dies** — it's a bout, not a mugging.
- `!rivals` for your record, `!rivals board` for room-wide standings.
## 3. Fight the world together (N6)
- `!adventure worldboss` — the Siege. One communal bout a day; `worldboss fight`
to take your swing at it. Everybody's damage counts toward the same health bar.
## 4. The Shadow (N6)
- `!adventure shadow` — a rival adventurer who runs the dungeon on their own
schedule whether you play or not, and you can check how your run stacks up
against theirs.
## 5. The Town (N4)
Housing finally pays off, and the room has a civic life.
- **T3 payoffs** — trophy hall + workshop; a long rest at home now grants a
**well-rested** buff that scales with your house tier.
- **T4 Estate vault** — `!adventure vault` / `vault store <item>` / `vault take
<item>`. 10 protected slots. Safe from `!sell all`, safe from combat.
- **A second pet** at Tier 4 — it shows up on its own.
- **Gifting** — `!give <item> @user`. Small handling fee to the community pot.
- **Registries** — `!town` (civic pride, housing street, pet showcase),
`!graveyard` (recent deaths), `!rivals`.
## 6. Prestige and a world that moves (N7)
- **Renown** — XP past L20 no longer evaporates. It accrues into a Renown rank
that shows on your `!sheet` and gets announced when it ticks up.
- **The Omen** — one rotating world modifier per week. TwinBee tells you what's
in the air in the morning DM.
- **Seasonal events** — holiday weeks re-skin the day's events and flavor.
- **Achievements** — a whole Renown wing added to `!achievements`.
## 7. The chase (N2)
- **Tempering** — `!adventure temper` pushes a magic item one rarity step higher
at the blacksmith. Euros plus a T5 material (one per clear — *that's* the real
gate).
- **Arena seasons** — `!arena leaderboard` is now the current season; champions
get titles. `!arena stats` keeps your lifetime record.
## 8. Story (N5 — the Hollow King)
- **`!adventure journal`** — recover campaign pages through the zones and read
them back. Bosses now have epilogues, and there's a real finale.
- NPC arcs land as DMs mid-run — Misty, Robbie, and Thom all have somewhere to
go now.
## 9. Backtracking (C5)
- **`!revisit <N>`** — walk back one room from the Path strip in `!map` for +1
threat. Blocked mid-fight, at a fork, and when it's too hot to double back.
## 10. Pete reports on it (Pete Adventure News)
Guild events — zone firsts, deaths, achievements — are now narrated into a live
news feed by Pete, the deadpan announcer.
- `news.parodia.dev/adventure` (feed, permalinks, daily digest)
- Live beats in the games room
- `!news` to read; `!news optout` if you'd rather not be named.
## 11. Restoration + plumbing (N1, unglamorous but real)
- The drop systems Phase R orphaned are wired back up; milestone rewards that
were stubs actually pay out.
- Lottery ticket sales fund the community pot.
- Fixed outside Adventure but shipped alongside: URL previews stopped dropping
thumbnails, and the bot stopped reminting its cross-signing identity on every
restart.
---
## ⚠️ Do NOT put in the announcement
- **Secret rooms and the cross-zone keys** (Sunken Sigil, Underforge Seal). These
are discovery content — no command, found by walking. Announcing them deletes
the discovery.
- **The NPC buffs** (Misty/Arina). Hidden mechanics by design. Say the NPCs have
arcs; do not say what befriending them *does*.
- Numbers, rolls, and rarity math generally — house style is verbs and outcomes,
not crunch.
## Watch list (first live exercise in prod)
Renown, the Omen, seasonal events, the Hollow King arc, the world boss and duels
have **never fired against real players before today**. Expect the first firings
to be the real test.

View File

@@ -0,0 +1,84 @@
# Announcement copy — your voice (draft)
Working-tree doc; do not commit.
Register: a person telling their friends what they built. No press-release
cadence, no bold-hook-per-paragraph, no claiming things are new when they're
expanded (houses, the town, and the story all already existed).
---
## Long version (post to the games room)
Big Adventure update went out. The main thing: you can play it with other people
now.
`!expedition invite @user` on day one of a run and they come with you. Everyone
buys their own supplies and takes their own turn, and enemies get more to do when
the party's bigger, so a group of four doesn't just walk through everything. This
was most of the work — the combat engine only ever knew how to run one player
against one monster, so it had to be rebuilt.
And if nobody's around, you can hire Pete. `!expedition hire` and he'll fill
whatever role you're short of — he fights his own turns, takes no loot and no XP,
and files a story about it afterwards. He's one guy, so if he's already out with
someone else you'll be told he's on assignment.
While I was in there: you can duel each other for a stake (`!duel @user 500`,
nobody dies), and there's a world boss once a day that everyone chips at together
(`!adventure worldboss`). There's also a rival called the Shadow who runs the
dungeon whether or not you log in — `!adventure shadow` to see how you compare.
Houses do a bit more at the top end. Tier 3 gets you a trophy hall and a
workshop, and resting at home now leaves you well-rested — better the higher your
tier. Tier 4 gets you a vault that `!sell all` can't touch, and a second pet turns
up on its own.
The town's grown too. You can hand items to each other with `!give`, and `!town`,
`!graveyard` and `!rivals` will show you how everyone else is doing.
L20 isn't the cap anymore — XP past it turns into Renown, which shows on your
sheet and keeps climbing. And there's a new modifier every week that changes how
the week plays; I'll tell you what's in the air in the morning.
The story's been expanded into a proper campaign that runs through the zones. A
king was emptied out so the crown could keep wearing him; he's been dead a long
time and the debt is still accruing. `!adventure journal` to read what you've
picked up. Misty, Robbie and Thom all have somewhere to go now, too.
Loot chasing got a couple of new levers: `!adventure temper` pushes a magic item
one rarity step up at the blacksmith, and the arena runs in seasons now, with
titles for whoever's on top when one closes.
Pete's reporting on all of it at news.parodia.dev/adventure — zone firsts, deaths,
achievements — plus live beats in here. `!news` to read it, `!news optout` if you
don't want your name in it.
Also `!revisit <N>` if you want to double back a room, and a pile of things that
were quietly broken now aren't.
It's all live. Let me know what's broken.
---
## Short version (one-screen post)
Big Adventure update, and the main thing is you can play it with other people now.
- **Parties** — `!expedition invite @user`. Real co-op, own turns, enemies scale
to the group. The combat engine got rebuilt for it.
- **Hire Pete** — `!expedition hire`. He fills the role you're missing, takes no
cut, and writes about it after.
- **Duels** — `!duel @user <stake>`. Staked, nobody dies.
- **World boss** — `!adventure worldboss`. One communal bout a day.
- **The Shadow** — `!adventure shadow`. A rival who plays whether you do or not.
- **Houses** — more at the top end: well-rested buff, a vault at T4, a second pet.
- **The town's grown** — `!give`, `!town`, `!graveyard`, `!rivals`.
- **Renown** — L20 isn't the cap anymore.
- **A weekly modifier** — the world changes shape every week.
- **The story's now a campaign** — a dead king, a crown still wearing him, a debt
still accruing. `!adventure journal`.
- **Tempering + arena seasons** — `!adventure temper`, `!arena`.
- **Pete's on the beat** — news.parodia.dev/adventure. `!news`.
All live. Let me know what's broken.

View File

@@ -0,0 +1,100 @@
# GogoBee → Appservice Auth Migration (multi-session)
Goal: move TwinBee's Matrix auth from the **MAS OAuth 2.0 device grant** (working
stopgap) to the **full appservice transaction/push model** — the MAS-durable end
state: no human login, no MFA, no consent, `as_token` never expires, scales at
485+ rooms. The device grant stays behind `AUTH_MODE=masdevice` as instant
rollback.
Why the *previous* appservice attempt failed: it was a hybrid (as_token + `/sync`),
and Synapse forbids appservice-namespace users from `/sync`. This migration
replaces `/sync` with Synapse→bot transaction push + MSC3202/MSC2409 E2EE
extensions, which is the supported path in mautrix v0.28.1.
Keep this doc working-tree only — do NOT commit (see feedback_dont_commit_plan_mds).
## Key facts established (investigation, 2026-07-03)
- **HEAD 20d1f92 is MISLABELED**: commit msg says "device grant" but it only
deleted registration.yaml.example; committed client.go is still the FAILED
appservice+/sync hybrid. The real device-grant (masauth.go + client.go rewrite)
is UNCOMMITTED working tree — that's what runs in prod. Backed up to scratchpad.
- mautrix v0.28.1 fully supports appservice E2EE: transaction carries
`to_device`/`device_lists`/`device_one_time_keys_count` (stable + msc2409/msc3202
unstable field names); `CreateDeviceMSC4190`; cryptohelper `MSC4190=true` mints
the device (no /login). HEAD's old appservice code already had auth+crypto
correct — only the /sync event loop was wrong.
- To-device processing gates on `Registration.EphemeralEvents` (yaml
`receive_ephemeral: true`) — `appservice/http.go:133`.
- Encrypted rooms: must feed `m.room.encryption`/`m.room.member` to the client's
StateStore via `mautrix.UpdateStateStore(...)` + `mach.HandleMemberEvent`, or the
bot sends plaintext into encrypted rooms / can't share keys.
- Both hosts SSH-reachable: `reala@parodia.dev` (Synapse/MAS/Authentik docker),
`reala@192.168.1.212` (millenia, bot in screen). Retired registration at
`/home/reala/matrix/compose/synapse/gogobee-registration.yaml.retired-20260703-223425`.
## Decisions (user, 2026-07-03)
- Cutover: **AUTH_MODE flag** (keep device grant as rollback), not full replace.
- Synapse changes: **I apply + restart, confirm right before restart**.
## Session 1 — bot-side code (local, no prod risk) ✅ DONE (build+vet green)
- [x] `Config`: AuthMode + appservice fields (RegistrationPath, ListenHost/Port,
HomeserverDomain).
- [x] `internal/bot/session.go`: `Session` unifying both modes (OnEventType/Run/
Stop); `NewSession(cfg)` dispatches on AuthMode; masdevice path wraps existing
device-grant NewClient + DefaultSyncer + sync loop (verbatim).
- [x] `internal/bot/appservice.go`: LoadRegistration → CreateFull → BotClient;
cryptohelper MSC4190 device create; ep.OnOTK/OnDeviceList + 9 crypto to-device
types → crypto machine; EventEncrypted decrypt+redispatch; StateStore
population (encryption + members) via lazy per-room resolveRoom + UpdateStateStore
on member/encryption state; HTTP listener via as.Start() in goroutine.
- [x] `main.go`: NewSession + sess.OnEventType + sess.Run; env AUTH_MODE,
AS_REGISTRATION, AS_LISTEN_HOST, AS_LISTEN_PORT, HOMESERVER_DOMAIN; envInt helper.
- [x] `.env.example` documented; go.mod tidied (zerolog now direct dep).
- [x] `go build`/`go vet` green (CGO=1 -tags goolm).
New files: internal/bot/session.go, internal/bot/appservice.go. NOT yet committed.
### Known correctness risks to verify in Session 3
- **resolveRoom is lazy on inbound encrypted events only.** Scheduled/broadcast
posts into an encrypted room the bot hasn't received from yet would send
plaintext (StateStore unresolved). Mitigation for S3: prewarm the configured
rooms (BROADCAST_ROOMS, GAMES_ROOM, ESTEEMED_ROOM, MOD_ADMIN_ROOM, MINIFLUX_*)
at startup, or resolve all joined rooms once. Interactive `!` commands are
reply-driven so lazy resolve covers them.
- Whether crypto machine successfully shares keys after ReplaceCachedMembers +
device fetch (FetchKeys) — the make-or-break, only testable against live Synapse.
- MSC4190 device creation must succeed against MAS-fronted Synapse (untested end
to end; HEAD's old code used the same path but was never verified past whoami).
## Session 2 — Synapse infra on parodia (touches live homeserver)
- [ ] Restore gogobee-registration.yaml: set `url:` = bot tailnet addr, add
`receive_ephemeral: true`, `org.matrix.msc3202: true`, keep
`io.element.msc4190: true`, `rate_limited: false`.
- [ ] homeserver.yaml `experimental_features`: msc2409_to_device_messages_enabled,
msc3202_device_masquerading, msc3202_transaction_extensions,
msc4190_device_management. Wire `app_service_config_files`.
- [ ] Determine bot's headscale tailnet address on parodia (Synapse must reach the
bot's ListenPort). Confirm routing.
- [ ] Backup homeserver.yaml + registration; **confirm before restart**; restart
matrix-synapse; check it comes back for all users.
## Session 3 — cutover + E2EE verification on millenia
- [ ] Copy registration to bot host; set AUTH_MODE=appservice + AS_* env.
- [ ] rsync source + build in place (CGO=1 -tags goolm), restart screen session.
Remove empty local data/gogobee.db first (empty_local_db_wipes_prod).
- [ ] Verify: transactions arrive (listener logs), whoami OK, MSC4190 device made,
bot responds to `!` command in a PLAINTEXT room.
- [ ] Verify E2EE: bot decrypts an incoming encrypted-room message AND its reply
decrypts for a human client (encrypt-out works). This is the make-or-break.
- [ ] If broken: AUTH_MODE=masdevice, restart → back on device grant. Then triage.
## Cleanup (after proven)
- Prune stale @twinbee devices, orphan MAS client, retire masauth.go or leave
behind the flag. Fix HEAD mislabel (the eventual commit should actually contain
device-grant + appservice, not the stale hybrid).

244
gogobee_boredom_plan.md Normal file
View File

@@ -0,0 +1,244 @@
# Bored Adventurers — autonomous expedition starts
**Premise.** A player who stops tending their adventurer doesn't stop having an
adventurer. After 24h with no Adventure action, the character gets restless and
leaves on an expedition by itself. It goes with the gear it already has and the
cheapest supplies it can afford. If you never arm it, the results are bad — and
that's the mechanic, not a bug.
## Why this is small
The autopilot is already almost fully autonomous. `runAutopilotWalkDriven`
(expedition_autorun.go:168) walks rooms, drives elite and boss fights on the real
turn engine, auto-camps, auto-harvests, rolls day rollovers, and auto-picks a
fork after 8h of silence. Nothing in that path ever needs a human.
The one thing that still needs a human is the *start*: `!expedition start <zone>
<loadout>` (dnd_expedition_cmd.go:281). So this feature is one new ticker that
performs that start, plus a clock to decide when.
## Decisions (locked)
| Question | Answer |
|---|---|
| Idle clock | **Any action against Adventure**, from any interface — not Matrix chatter, not autopilot writes |
| Threshold | 24h idle → the adventurer leaves. No warning DM. |
| Re-trigger | After a bored run ends, another 24h of silence starts another. Forever. |
| Zone | Easiest zone whose **level band** contains the character. Never trivial low-tier farming. |
| "Raid-shaped" | = **multi-region** zones (underdark, dragons_lair, abyss_portal). Avoided — *unless* they're the only at-band option (L13+), then allowed. |
| Supplies | **Lean** loadout (cheapest). Broke → no run. |
| Gear | Never bought, never equipped. This is the whole point. |
## §1 — The clock: `last_player_action_at`
The existing timestamps are all unusable:
- `player_meta.last_active_at` — auto-bumped on **every character save**
(adventure_character.go:584). The autopilot saves constantly, so a bored
character would refresh its own idle clock and never qualify again.
- `user_stats.updated_at` — bumped on every Matrix message in any room
(stats.go:103). That's chat presence, not *game* action. Rejected per the
"any action against Adventure, not just in Matrix" rule.
- `daily_activity` / `loadAdvDailyActivity` — unified across
`dnd_expedition_log`, so the autopilot's own walk entries count as player
activity. Same self-refresh problem.
**New:** `player_meta.last_player_action_at DATETIME`, written *only* by
`markPlayerAction(uid)` — a direct UPDATE, never routed through
`saveAdvCharacter`, so no ticker or autopilot path can touch it. Any future
non-Matrix entry point (web, Pete, API) calls the same helper.
Stamped from `AdventurePlugin.OnMessage` when the message is a real player
action: an `!` command in `advActionCommands`, or a reply to a pending
interaction (`p.pending`, adventure.go:889 — shop/blacksmith/pet prompts).
`advActionCommands` is the 47 names actually routed in `OnMessage`'s if-chain.
Note `Commands()` (adventure.go:158) only registers **28** — it's a help
surface, not a routing table, and is missing `expedition`, `zone`, `fight`,
`camp`, `extract`, `resume`, and more. A test greps the `p.IsCommand(ctx.Body,
"…")` literals out of adventure.go and asserts each one is in the set, so a
future command can't silently fall out of the clock.
## §2 — Zone pick: `pickBoredomZone(level, uid)`
`zonesForLevel` is wrong for this: it filters on **tier only** and ignores the
`LevelMin`/`LevelMax` fields that already exist on `ZoneDefinition` — which is
why a level-1 player can currently walk into a T3 zone. The boredom picker uses
the bands instead.
1. Candidates = zones where `LevelMin <= level <= LevelMax`.
2. Prefer **non**-multi-region (`IsMultiRegionZone`, dnd_expedition_region.go:135).
Among those, lowest `Tier` — the "easiest at-level" rule.
3. Ties broken by **least-recently-started by this player**, so a bored
adventurer rotates zones instead of grinding one forever.
4. If step 2 is empty, retry allowing multi-region. This only bites at L13+,
where `dragons_lair` and `abyss_portal` are the only at-band zones and both
are multi-region.
5. Still empty → no run.
Worked examples:
| Level | At-band | Picked |
|---|---|---|
| 5 | forest_shadows, sunken_temple (T2), manor, underforge (T3) | a T2 zone |
| 10 | underdark (multi), feywild_crossing | feywild_crossing |
| 12 | underdark (multi), feywild (T4), dragons_lair (multi, T5) | feywild_crossing |
| 15 | dragons_lair (multi), abyss_portal (multi) | dragons_lair — fallback fires |
## §3 — The run
Ticker `expeditionBoredomTicker`, 30m interval, skips the ambient quiet window.
Candidates: `player_meta` rows, `alive = 1`, `COALESCE(last_player_action_at,
created_at) < now-24h`, and `last_boredom_at` NULL or older than the 24h
cooldown. (COALESCE on `created_at` so a character made and never played still
qualifies, but not on its first tick.)
Per-user skips, all reusing existing guards: resting lockout, seated on someone
else's party, active expedition, resumable (extracted, pending `!resume`)
expedition, active zone run, active combat session.
CAS-claim `last_boredom_at` **before** starting, so a crash mid-start can't loop.
Then: `loadoutPurchase(zone.Tier, LoadoutLean)` → balance check → shared
`beginExpedition` helper → DM.
A zero-supply start is impossible (`SupplyPurchase.Validate`,
dnd_expedition_supplies.go:270, hard-rejects it), so the cheapest bored run costs
50 coins at T1 and 250 at T5. Can't afford it → no run, one DM (rate-limited by
the cooldown we already claimed).
## §4 — Refactor: `beginExpedition`
`expeditionCmdStart` is prompt-layer (:282309) followed by a reusable
transaction (:369424): balance check, debit, holiday/omen freebies,
`startExpedition`, `ensureRegionRun`, log, refund-on-failure. Extract that second
half into `beginExpedition(uid, c, zone, purchase, reason)` so the command and
the boredom ticker share one code path and one refund story.
## §5 — Why the results will be bad (no work required)
This all already exists; the feature just points it at neglected characters:
- The autopilot **never buys or equips anything** — verified, no `buy`/`equip`/
`Debit` calls anywhere in expedition_autorun.go, expedition_autocamp.go, or
dnd_expedition_autopilot_harvest.go. Stale gear stays stale.
- Lean supplies deplete fast → **Rationing** (1 attack/skill) at 25% →
**Severe** (2, long rests blocked) at 10% → **Starvation** at 0, which forces
an extract with a **20% coin tax** on the haul (dnd_expedition_cycle.go:140).
- The boss-safety gate holds the autopilot out of boss rooms below 80% HP, so an
under-geared character stalls, camps, and burns supplies it doesn't have.
The neglected adventurer grinds mediocre, half-starved runs on rusting gear and
comes home taxed. Exactly the intent.
## §6 — Streaks: no credit for a run you didn't ask for
Bored runs write `dnd_expedition_log` entries, which `loadAdvDailyActivity`
counts as player activity — so without care a bored run would **suppress the
idle-shame DM and preserve the daily streak** for someone who hasn't shown up in
weeks.
Two halves, and only one needed fixing:
- **Bumping** the streak was already safe. `engaged` reduces to
`LastActionDate == today|yesterday` (the `CombatActionsUsed`/
`HarvestActionsUsed` clauses are dead — nothing in the tree increments them),
and `LastActionDate` is only set by `markActedToday`, which is called only
from real player commands. `beginExpedition` deliberately does not call it.
- **Shielding** was not. Both hold branches in `midnightReset` (the activity
oracle, and the active-expedition safety net) would spare an absent player.
Fixed with `dnd_expedition.boredom` + `isBoredomDriven(uid, now)`, which requires
*both* halves: the player hasn't acted in 24h **and** their most recent
expedition is one the adventurer started for itself. A manually-started
expedition still holds the streak while the autopilot walks it — that behavior is
untouched. Reading the *most recent* expedition rather than the active one keeps
it honest on the night after a bored run ends, when its logs would otherwise
still read as player activity.
## §7 — Robbie
Robbie sweeps every non-slotted inventory item daily (ores, fish, junk,
treasure — `robbieQualifyingItems` takes all of them unconditionally) and pays
`Value / 4`. The autopilot deposits harvested loot straight into
`adventure_inventory` (dnd_expedition_harvest.go:478, dnd_zone_loot.go), so:
**Robbie funds the boredom loop.** Bored run → auto-harvested junk → Robbie
converts it to coins → pays for the next lean pack → forever. The "they go broke
and stop" poverty floor does not exist. Abandoned characters are perpetual
motion, and that is the accepted design.
What was *not* accepted was the noise: Robbie posts a public room announcement
per visit, so every abandoned character would file a daily bulletin about
somebody who stopped playing weeks ago. He now visits and pays silently for idle
players (adventure_robbie.go), gated on the same clock.
## §8 — The bug this nearly shipped with
`playerIsIdle` originally scanned `COALESCE(last_player_action_at, created_at)`
straight into a `*time.Time`, and `lastExpeditionByZone` scanned
`MAX(start_date)`. **modernc.org/sqlite rebuilds a `time.Time` from the column's
declared type, and both `COALESCE()` and `MAX()` erase it** — the value comes
back a string and the Scan fails.
`lastExpeditionByZone` failed loudly. `playerIsIdle` failed *open*: an unreadable
row counts as idle, so it returned `true` for every player alive, including one
who had acted an hour earlier. Shipped, that would have marched the entire server
into dungeons at once and silenced Robbie for everybody.
The unit tests missed it at first because they ran against empty tables, so the
scan never executed. Both now select the declared columns and fold in Go.
Note the asymmetry: `loadBoredomCandidates` uses `COALESCE` too and is fine —
it's evaluated *inside* SQL and never scanned. Only Go-side scans through an
aggregate or COALESCE lose the type.
## §9 — Status
Built, vetted, full suite green. **Not deployed, not committed.** Working tree
carries: db.go (3 columns), expedition_boredom.go (new), expedition_boredom_test.go
+ expedition_boredom_clock_test.go (new), dnd_expedition_cmd.go (beginExpedition
extraction), adventure.go (clock stamp + ticker), adventure_scheduler.go (idle
reaper gate), adventure_robbie.go (silent for idle), twinbee_expedition_flavor.go
(ExpeditionBoredomStart).
## §10 — Verified end-to-end against prod data
`internal/plugin/exercise_boredom_prod_test.go` (build tag `prodexercise`), run
against a throwaway copy of the live DB:
```
GOGOBEE_PROD_DB_DIR=<dbcopy> go test -tags prodexercise -run TestExerciseBoredomProd -v ./internal/plugin/
```
PASS. What it actually proved, on real characters with real gear:
- **The clock gates the sweep.** A control player stamped as having acted an hour
ago was not a candidate and was not charged; every player who departed passed
`playerIsIdle`. This is the assertion that would have caught the COALESCE
fail-open bug (§8), which is why the control arm is not optional.
- **Real departures.** 3 of 5 candidates left. `@holymachina` (L20 cleric) →
`dragons_lair`, `@quack` (L4 mage) → `forest_shadows`, `@camcast` (L1) →
`crypt_valdris`. The other two stayed home for the right reasons and the test
names them: one has no character, one never finished creation.
- **L20 into the raid, as designed.** `dragons_lair` is the only zone in
`@holymachina`'s band, so the fallback fired and the departure DM carried the
raid warning. Exactly the decision that was locked.
- **It bought nothing.** Coins debited equal the lean pack to the coin (250 at
T5, 50 at T1/T2), and the equipment fingerprint is byte-identical across the
run.
- **Flagged and cooled.** `boredom = 1` on every row, `isBoredomDriven` true, and
a second tick 30 minutes later charged nobody a second time.
- **The autopilot walks it.** Rooms accumulate across segments.
And it drew blood on the first run: **`@quack` (L4 mage, stale gear) died four
rooms in.** That is the mechanic, working, on the very first exercise. Death is
`Kill()``alive = 0`, 6h respawn, auto-revived by the scheduler ticker
(adventure_scheduler.go:72), and `loadBoredomCandidates` requires `alive = 1`, so
a dead adventurer sits out its six hours and then gets restless again. Death
pauses the loop; it doesn't end it.
## §11 — Status
Built, vetted, full suite green, exercised end-to-end against prod data.
**Not deployed, not committed.**

View File

@@ -0,0 +1,787 @@
# Code review follow-ups — `n1-restoration` (2026-07-10)
Review scope: `git diff main...HEAD`, 99 files / ~16.7k lines (N1, N2, R1, R2, N3 P1P7).
Method: 8 finder angles → dedup → 1 verifier per correctness candidate.
Baseline before and after: `go build`, `go vet`, `go test ./internal/...` all green.
Working-tree doc — **do not commit** (per project convention on `gogobee_*.md`).
**Status.** Fixes 15 shipped in `1f21156`. Deferred items **A** and **B** fixed
2026-07-10 (`d76c63b`); A turned out to have a deeper root cause and surfaced
three new findings, **M**, **N** and **O**. Item **C** fixed 2026-07-10 — its
premise was wrong and the bug underneath it was worse (the seven-day resume
window was never enforced; see below). Item **M** fixed 2026-07-10 — two of its
three claims were wrong (the mage hooks *do* run on the turn path); the one real
defect, Grim Harvest, had a different cause than recorded. Items **D**, **I**,
**L**, **N** fixed. Item **K** verified 2026-07-10 — the anchor gap is a real,
confirmed narrowing, left as an owner design call (no code change). Item **H**
measured 2026-07-10 and **declined** — its "per-message cascade" premise was
overstated, the hot path is already optimized, and the proposed
`MessageContext` thread-through is disproportionate churn with a staleness
regression surface.
**All correctness work is committed.** Everything remaining is deliberate, not a
defect: **E** (intentional, roster-size death rule), **F** (`grantAutorunGrace`
stopgap, deferred to R5 by design), **G** (party-combat DB chatter — a perf
refactor whose only safe fix is a fight-lifetime roster cache, i.e. the same
staleness surface H was declined for; deferred), **H** (declined, above),
**J** (`!sell` anchor — PLAUSIBLE, presence == any chat, left per convention),
**K** (owner design call, above), **O** (already fixed as a side-effect of A;
only the sim re-baseline is pending, which is operational).
**Method note.** Both C and M were mis-diagnosed in the same way: the finder read
one call site, concluded "this is the only caller", and wrote it down. Verify the
callers before planning the fix.
**Third pass (2026-07-10, `88c5fcd`).** `/code-review high --fix` over the pushed
follow-up stack (`git diff origin/main...HEAD`, 7 commits). Five fixes shipped;
they harden the item-C extraction work and the item-N Misty ordering rather than
opening new ground. See "Third pass" below.
---
## Before you commit: split the formatting from the fixes
A repo-wide `gofmt -w ./internal ./cmd` ran after the review. It reformatted
**105 files that have nothing to do with these findings** — they were already
non-conforming on `main` (struct-field alignment, mostly), and gofmt is
semantics-preserving, so nothing changed behaviourally.
Net state: **117 files dirty, only 13 carry review fixes.** Commit them apart, or
the fixes become unreviewable inside a wall of realignment.
The 13 fix-bearing files:
```
internal/plugin/adventure.go * internal/plugin/dnd_expedition_supplies.go *
internal/plugin/adventure_arena_season.go internal/plugin/dnd_zone_cmd.go *
internal/plugin/combat_cmd.go internal/plugin/expedition_party.go
internal/plugin/combat_party_turn.go internal/plugin/expedition_party_cmd.go
internal/plugin/dnd_expedition.go * internal/plugin/expedition_party_resolve.go
internal/plugin/dnd_expedition_combat.go internal/plugin/zone_combat_party.go
internal/plugin/combat_engine_party_test.go
```
Plus one new file: `internal/plugin/zone_combat_party_casualty_test.go`.
The four marked `*` carry **both** a fix and gofmt realignment
(`adventure.go`, `dnd_expedition.go`, `dnd_expedition_supplies.go`,
`dnd_zone_cmd.go`), so a clean split needs `git add -p` on those rather than a
straight per-file split. The other nine are fix-only and can be staged whole.
---
## Fixed in this pass
| # | File | Defect |
|---|------|--------|
| 1 | `combat_party_turn.go` | Settle-to-terminal dropped the entire close-out |
| 2 | `expedition_party_cmd.go` | Party member permanently soft-locked on leader extract |
| 3 | `expedition_party_cmd.go` | Lost update on the shared supply pool |
| 4 | `dnd_expedition_combat.go` | Elite harvest interrupt granted no elite loot |
| 5 | `adventure_arena_season.go` | Transient failure permanently lost a season crown |
Plus cleanups: deleted dead `partySurvivors`; collapsed the `zoneCombatRoster`
alias into `fightRoster`; `partyCasualtyLine` now uses `joinNames`; consolidated
four copies of the expedition column projection into `expeditionSelectCols`;
corrected two false doc comments on `applyDailyBurn`/`applyDailyBurnP`; `replyDM`
no longer sends a blank DM.
### 1. Close-out dropped when the settle turns lethal — the worst one
`beginCombatTurn` calls `settleCombatSession` to resolve a phase the engine owes
(a fight parked mid enemy-turn after a restart). That settle can end the fight.
The old code then saw `!sess.IsActive()` and returned "you're not in a fight."
The terminal status was already persisted, so nothing ever paid the party out:
no XP, no loot, no `markAdventureDead`, no `abandonZoneRun`, no
`forceExtractExpeditionForRunLoss`. The reaper cannot rescue it either —
`listExpiredCombatSessions` filters `status = 'active'`, and the session is now
terminal. The win or the death simply evaporates.
The `!fight` start path and the reaper both already do settle-then-`closePartyRound`;
`beginCombatTurn` was the one settle site that skipped it. Now it closes out and
announces, matching them.
### 2. Member soft-locked when the leader extracts and walks away
`seatedExpeditionFor` (the guard) spans `status IN ('active','extracting')`.
`expeditionForMember` (what `!expedition leave` resolved through) filtered
`status = 'active'` only. During the leader's 7-day extracting limbo a member was
therefore refused any new adventure by the guard, and told "No active expedition"
by the very command the guard pointed them at. Nothing sweeps stale `extracting`
rows, and only the *leader* can trigger the transition that clears them — so if
the leader quit, the member was stuck forever with no self-service recovery.
`!expedition leave` now resolves the seat through `seatedExpeditionFor`, so the
exit sees every state the gate sees. (`seatedExpeditionFor` already excludes
leaders, so they still fall through to the `!extract` message.)
### 3. Lost update on the shared supply pool
`updateSupplies` rewrites `supplies_json` wholesale. `expeditionCmdAccept` folded
a new member's packs onto an `exp` snapshot read ~60 lines earlier, with no lock.
Handlers run one goroutine per event (mautrix `AsyncHandlers`, never overridden),
so two invitees accepting at once genuinely interleave: both read pool `P`, one
writes `P+a`, the other `P+b` — a member's packs vanish, or spent SU is
resurrected by the day-burn tick.
Note `advUserLock` cannot fix this: it is keyed by sender, so two members take
two different mutexes. Added `advExpeditionLock(expID)` and re-read the pool
under it. **This closes accept-vs-accept only** — see deferred item B.
### 4. Elite harvest interrupt paid standard loot
`runHarvestInterrupt` computed `elite := kind == InterruptElite`, used it to pick
an elite enemy and to pick elite narration, then passed a hardcoded `false` as
`isElite` to `closeOutZoneWin`. Since `dropZoneLoot` gates on `isBoss || isElite`,
beating an elite interrupt skipped the masterwork roll and used the 1.0 standard
treasure weight instead of 2.0 — while the identical elite fought via `!zone`
paid out correctly.
Now passes the live `elite`. The param only flows into `dropZoneLoot`, so this
adds the masterwork roll and doubles treasure weight for surviving seats; threat,
XP, and narration are untouched. A small reward buff on a fight players already
win, consistent with lifting trailers rather than nerfing.
### 5. A failed season crown was lost permanently, not retried
`arenaSeasonRollover` logged-and-`continue`d when `recordArenaSeasonTitle`
failed, then called `db.MarkJobCompleted` unconditionally. `JobCompleted`
short-circuits every future run for that quarter, so a transient SQLite `BUSY`
meant the crown was never awarded — ever.
`recordArenaSeasonTitle` is idempotent (`arena_season_titles` has
`PRIMARY KEY (season, kind)`, insert is `ON CONFLICT DO NOTHING`) and a past
season's data is frozen, so retrying is safe. The job is now marked complete only
when no crown failed. The "no entrants" path still closes the season correctly.
---
## Fixed in the second pass (2026-07-10) — items A and B
### A. Turn-based combat skipped achievements and post-combat subclass state
The root cause was one level deeper than this doc originally recorded, and it is
worth writing down because the surface symptom was misleading.
`buildZoneCombatants` used to call `applyArmedAbility`, which applies an ability's
mods **and then clears `c.ArmedAbility` and saves the sheet**. The turn engine
calls that same builder again on *every* `!attack` / `!cast` / `!consume`. So in a
turn-based fight:
- Round 1's build fired the ability, spent the resource, cleared the arm flag.
- Round 2+ rebuilt the character, found `ArmedAbility == ""`, and produced a
combatant with **none of the ability's mods**.
A Berserker paid stamina and got exactly one round of `BerserkerRage`,
`RageMeleeDmg`, `PhysicalResistRage`, `FrenzyDmgBonus`. Every entry in
`dndActiveAbilities` had the same shape. `mods.BerserkerRage` was not merely
unread at close-out — by then it no longer existed.
Fixed by splitting arming into its two halves:
- `consumeArmedAbility(c)` — the mutation. Disarms, saves, returns the id. Runs
**once**, at fight start.
- `applyAbilityByID(c, id, mods)` — pure. No DB write, no disarm. Safe on every
rebuild. (No ability's `Apply` writes to the character, so this is genuinely pure.)
- `armAbilityForFight(c, mods)` — consume + apply, for the auto-resolve callers
that build and fight in one breath.
`buildZoneCombatants` now takes the already-consumed `armed` id and re-applies it.
The id rides on `ActorStatuses.ArmedAbility`, seeded per seat at fight start
(`seedActorOneShots`), so `partyCombatantsForSession` reproduces the ability on
every rebuild and the close-out can still see that a rage fired.
On top of that, the close-out itself: `postCombatBookkeeping(...)` in
`combat_bridge.go` now carries the achievements + subclass persistence, and all
four close-outs route through it — `runDungeonCombat`, `runZoneCombatRoster`,
`finishCombatSession`, `finishPartyCombatSession`. The turn-based pair reach it
via `postCombatBookkeepingForSeat`, which rebuilds the `CombatResult` the hooks
need from the persisted session (`seatCombatResult`) and re-derives the
fight-start mods (`seatFightStartMods`).
It fires on **every** terminal status, not just a win — a Berserker who rages and
loses is still exhausted, which is what auto-resolve always did.
Also fixed in passing: `buildFightSeats` and `runZoneCombatRoster` consumed the
armed ability *before* the checks that could sit a seat out, so a downed member
was disarmed for a fight they never joined. The refusals now run first.
**Balance note.** Turn-based Berserkers now keep rage for the whole fight instead
of one round. That is a buff, but it is the buff the player already paid for.
The class-balance corpus measures the auto-resolve path, so it is unmoved.
New tests: `internal/plugin/combat_armed_ability_test.go` (9 cases) —
purity/repeatability of `applyAbilityByID`, once-only consume, rage surviving
rebuilds, the sat-out member keeping their ability, per-seat statuses isolation,
and exhaustion on both a manual win and a manual loss.
The Grim Harvest half of this item was closed separately — see item **M**.
### B. The other six supply writers raced
All six now go through `withExpeditionSupplies` (`dnd_expedition.go`), which takes
`advExpeditionLock(expID)`, re-reads the expedition, hands the closure the fresh
row, and persists what it returns:
`dnd_expedition_cycle.go` (`nightRolloverBurn`, forage + burn in one write),
`dnd_expedition_milestone.go` (`grantTwoWeeksCache`),
`dnd_expedition_region_cmd.go` (`advanceToNextRegion` transit burn),
`dnd_expedition_camp.go` (`campPitch`), `expedition_autocamp.go`
(`pitchAutopilotCamp`), `expedition_ambient.go` (pack-rat drain).
Fix #3's hand-rolled lock in `expeditionCmdAccept` was folded onto the same
helper. Seven call sites, no nesting — verified none of the closure callees
re-enter the lock (`sync.Mutex` is not reentrant).
`expedition_sim.go:1421` is left alone on purpose: the sim is single-threaded and
takes no plugin locks.
The atomic-`json_set`-delta alternative is still the better long-term shape (it
would drop the lock entirely) but it couples `updateSupplies` to the blob format.
---
## Third pass (2026-07-10, `88c5fcd`) — `/code-review high` on the follow-up stack
Reviewed `git diff origin/main...HEAD` (the 7 pushed follow-up commits) with 8
finder angles → dedup → 1 verifier per candidate. The removed-behavior angle came
back empty: the close-out refactor (item D), `parseMenuIndex` (item I), the
GrimHarvest stash (item M), and the `endRunOnLoss`/`applyOwnerWinEffects`/
`grantSeatWinXP` extractions all verified behavior-preserving. Findings clustered
in the new item-C extraction guard, plus one ordering nuance in the item-N Misty
wiring. Five fixed:
1. **The extraction guard fell open on a DB error.** `expeditionCmdStart`'s
resumable-guard did `switch n, err := partySize(pending.ID)` and gated the
party-blocking refusal on `case err == nil && n > 1`. A transient roster-read
error skipped both cases, fell through, and started a new expedition on top of
the still-seated party — the exact orphaning the guard exists to prevent, in
the one situation (a DB hiccup on the roster read) where it matters. Now checks
`extractionLapsed` first (pure, no DB call on the reap path) and treats a
`partySize` error as "assume occupied → refuse". Also drops the wasted `COUNT`
the old switch-init ran on the lapsed-reap path.
2. **The start-path lapsed reap unseated members silently.** When a leader with a
lapsed party extraction ran `!expedition start`, the reap called
`completeExpedition(...Failed)` — which frees the roster — with no DM, unlike
the hourly sweeper and `!expedition abandon`, which both notify. Extracted
`reapLapsedExtraction(e)` (reap + notify the audience) as the single
reap-with-notify path; the sweeper and the start-path reap both route through
it, so a member is never silently unseated by whichever path reaches the row
first.
3. **Misty's crowd swung before the concentration pulse.** Item N's new
`stepRoundEnd` seat-loop ran `seatEndOfRound` (crowd revenge, which can end the
fight) *before* the pre-existing concentration-aura tick. Concentration is
turn-engine-only (no auto-resolve counterpart), so this was a within-engine
ordering call, not a cross-engine divergence — but a caster whose lingering
aura would kill the enemy that round could be dropped by crowd revenge first,
contradicting the concentration block's own "a lethal pulse settles the fight"
intent. Moved the Misty seat-loop to *after* the concentration tick: a lethal
pulse now wins via `finish(CombatStatusWon)` before the end-of-round crowd
swing; on any round the pulse doesn't end the fight, both procs fire exactly as
before. (Owner-approved reorder — it was reported as a balance call and the
answer was "move it".)
4. **Fourth copy of the event-log scan.** `seatCombatResult` hand-rolled a
`misty_heal` scan loop. Promoted the test-only `hasAction(events, action)`
helper to production (`combat_session_build.go`), used it there, and removed the
duplicate test definition.
5. **New `dnd_`-prefixed file.** Renamed `dnd_expedition_extract_sweep_test.go`
`expedition_extract_sweep_test.go`, per the no-new-`dnd_`-names rule (the diff's
three other new test files already avoided the prefix).
**Reported, not fixed** (deliberate):
- **Misty ordering** was the only correctness-shaped finding; the other angles
agreed the stack was clean. Two lower items were left: `dnd_setup.go`'s
respec/wipe disbands an extracted party with no member DM — but the disband is
intended (item C hole #3) and the missing DM is outside the reviewed lines; and
`abandonExpedition` re-queries the row the caller already resolved — the
divergence a finder posited isn't reachable (the extracted-row branch only runs
when no active row exists), so the extra indexed SELECT was left alone.
Full plugin suite green before and after. No auto-resolve path touched, so the
class-balance corpus and `combat_characterization.golden` do not move.
---
## Deferred — real, not fixed
### ~~M. Mage subclass spell hooks never run in turn-based combat~~ — TWO-THIRDS MIS-STATED. Fixed 2026-07-10.
**The premise was wrong.** `applyMageSubclassSpellHooks` has three non-test
callers, not one. `resolveTurnSpell` (`dnd_spell_combat.go:341`) has called it
since Phase 13 shipped per-round casting (`5cd343a`, 2026-05-14).
So on the turn-based surface:
- **Empowered Evocation** (L7, +INT to evocation damage) — *always worked.*
- **Evocation Overchannel** (L10, ×1.5 on a 1st5th-level slot) — *always worked.*
Both only move `mods.SpellPreDamage`, which `resolveTurnSpell` returns as
`out.EnemyDamage`. Nothing was lost.
**Grim Harvest was the only real defect**, and its cause was narrower than "the
hooks don't run": the hooks ran, wrote `mods.GrimHarvestSlot` — and
`resolveTurnSpell` dropped that local `CombatModifiers` on the floor, because
`turnSpellOutcome` had no field to carry it out. A Necromancy Mage who killed
with a spell in a manual fight never healed.
The stash cannot ride on fight-start mods the way it does in auto-resolve: the
spell is cast *mid*-fight, and the turn engine rebuilds its combatants every
round. So it rides where every other mid-fight fact rides — the casting seat's
`ActorStatuses`:
1. `turnSpellOutcome` gained `GrimHarvestSlot` / `GrimHarvestNecrotic`.
2. `castActionForSeat` parks them on `actorStatusesPtr(seat)` when a cast lands.
`snapshotActor` starts from the prior snapshot, so they survive `commit()`
untouched, exactly as `ArmedAbility` does.
3. `seatFightStartMods` reads them back at close-out, in place of the comment
that used to explain why they were permanently zero.
**And the killing-blow check was wrong for this surface.** `grimHarvestHeal`
scanned for the **first** `spell_cast` event and refused the heal if the enemy
survived it. Auto-resolve casts once, pre-combat, so first == last there; a
turn-based mage casts every round, so an opening cantrip that left the enemy
standing vetoed the heal the round-3 killing spell had earned. It now scans for
the **last** `spell_cast` — provably identical on the auto-resolve path, which is
why the golden corpus does not move.
Each damaging cast overwrites the stash, so it always describes the seat's most
recent landed spell — the only one that can have been lethal. A miss stashes
nothing, and a stale stash is inert because the last `spell_cast` event then
shows the enemy alive.
**Balance note.** This is a caster buff on the manual surface — 2×/3×/4× slot
level in HP, once, on a spell kill, for L5+ Necromancy Mages. It is the buff the
subclass was written to have and auto-resolve already paid out. Consistent with
the standing "lift trailers" stance. The class-balance corpus measures
auto-resolve and is unmoved.
New tests: `internal/plugin/combat_grim_harvest_turn_test.go` (7 cases) — the
stash surviving `resolveTurnSpell`, nobody-else-stashes, last-cast-not-first,
weapon-kill-after-a-spell, the `snapshotActor` round-trip, the end-to-end heal at
`postCombatBookkeepingForSeat`, and per-seat isolation in a party fight.
### N. The NPC procs do not exist in the turn engine (RE-VERIFIED 2026-07-10 — conclusion holds, evidence corrected, scope wider) — the debuff exploit fixed 2026-07-10.
**Verified before planning**, after items C and M both turned out to rest on a
bad "only one caller" claim. This one survives, but two of its three sentences
did not.
**The `CrowdRevengeProc` exploit is now closed** (see the fix note at the end of
this item). Closing it pulled `MistyHealProc` in with it: both are round-end
effects, and the honest hook runs the pair rather than the debuff alone — a
one-sided hook would have been a second parallel sibling of the kind item D
warns about. So Misty's **heal** is now live on the turn path too. That is a
player-favourable discovery mechanic and fits the "lift trailers" stance, so it
ships together; only `SniperKillProc` stays turn-dead, because it is a
pre-combat one-shot with no round-end seam to hang on.
**Correction 1 — the location was wrong.** `MistyHealProc` is not read inside
`simulatePartyWithRNG`. It is read at `combat_engine_party.go:520` inside
`endOfRoundForSeat`, a *helper*, which is exactly the shape that made C and M
wrong. So the question had to be asked properly: `endOfRoundForSeat` has one
caller, `simulatePartyRound` (`:418`), which is the auto-resolve round. The turn
engine runs its own `stepRoundEnd` and never calls it. Conclusion stands.
`SniperKillProc` (`:102`, genuinely in `simulatePartyWithRNG`, a pre-combat
one-shot) has no turn-engine counterpart either.
**Correction 2 — the procs are built, then ignored.** `buildZoneCombatants` calls
`DerivePlayerStats` (`combat_session_build.go:61`), which sets
`mods.MistyHealProc` / `mods.SniperKillProc` (`combat_stats.go:180,184`) on every
turn-based combatant. The mods are present on the struct every single round. No
turn-engine code path reads either field. It is a dangling write, not a missing
build.
**Correction 3 — there is a third proc, and it cuts the other way.**
`CrowdRevengeProc` (`:473`) sits in the same `endOfRoundForSeat` and is equally
absent from the turn engine. That one is the Misty **debuff**. So the asymmetry
is not "manual fights are worse": a buffed player loses their buff by fighting
manually, and a debuffed player *escapes their debuff* by doing the same. The
debuff half is the one worth caring about, because it is exploitable and needs no
discovery to exploit — just fight everything with `!attack`.
**Not gaps** — checked and explained, so nobody re-files them:
- `PetAttackProc` and `SpiritWeaponProc` *are* reimplemented in the turn engine
(`petStrike`, `spiritWeaponStrike`), though they fire after a player action
rather than at round end.
- `EnvironmentProc` is absent because `turnCombatPhase` is a single flat "Duel"
phase with the proc at 0 — deliberate, not dropped.
- `HealItem`'s auto-heal never fires, but the turn engine seeds and snapshots
`HealChargesLeft` and gives the player `!consume` instead. Player-driven by
design, not a leak. (Worth confirming the charges aren't double-counted across
the two surfaces.)
**The achievement claim held when written; the fix moved it.** At the time,
`combat_sniper_kill` and `combat_misty_clutch` were both unreachable from a
manual kill and `seatCombatResult` hardcoded both flags false — so "six
achievements is really four" was correct. Wiring the heal changed that:
`seatCombatResult` now reads `MistyHealed` back off the `misty_heal` event on the
log (the same way `combat_pet_save` has always been detected), so
**`combat_misty_clutch` is reachable turn-based now** — it is five, not four.
`combat_sniper_kill` stays unreachable. The other four were always reachable:
`combat_near_death` (computed by `seatCombatResult`), `combat_pet_save` (the turn
engine does call `resolveEnemyAttack`, which emits `pet_whiff`),
`combat_death_save` (`trySave` in `stepRoundEnd`), and `combat_consumable_used`
(`combat_cmd.go:773`).
(Keep this internal. Per project convention these procs are hidden discovery
mechanics and must not surface in help text, player docs, or DMs.)
**Fix (2026-07-10).** Both Misty procs were hoisted out of `endOfRoundForSeat`
into shared helpers — `mistyCrowdRevenge` and `mistyHeal` — and a `seatEndOfRound`
hook that runs the pair in the order the auto-resolve engine does (crowd first,
then, if the seat survived, the heal). `endOfRoundForSeat` now calls the helpers;
the turn engine's `stepRoundEnd` calls `seatEndOfRound` per seat, after the
poison tick so a heal can answer the round's damage. Both helpers short-circuit
before touching the RNG when their proc is unarmed, so a character with no Misty
history draws exactly the dice it drew before — the sim corpus and
`combat_characterization.golden` do not move (full suite confirms).
`renderAllySeatEvent` gained a `crowd_revenge` case: an ally sees the damage land
(silence would read as HP vanishing) but not Misty's name, matching the
neighbouring `misty_heal`'s neutral "recovers N" — the grudge stays the owner's
own discovery.
New tests: `combat_misty_turn_test.go` (6 cases) — the crowd landing on the
manual surface (the exploit), a lethal crowd ending a solo fight, the heal firing
and capping, no heal for a seat the crowd just dropped, the no-RNG-when-unarmed
property that protects the golden file, and an end-to-end wiring test through
`advanceCombatSession` that reports `PlayerHP=40` (the shipped exploit) if the
`stepRoundEnd` call is removed.
### O. `trySimAutoArm` used to re-arm every round — sim baselines will move (NEW)
Before item A's fix, `trySimAutoArm` lived inside `buildZoneCombatants`. On the
turn-based path that meant: every rebuild re-armed the class-default ability,
**spent another point of the resource**, and re-applied it. A simulated Fighter
got a fresh Second Wind heal every single round until stamina ran dry; a Cleric,
a Healing Word.
`expedition-sim`'s `autoDriveCombat` drives `handleAttackCmd`, i.e. the
turn-based path — so this inflated every turn-based sim result. It now arms once
per fight, which is what `simAutoArmEnabled`'s own doc comment says it models
("a competent real player who would `!arm` Second Wind before each fight").
**Any expedition-sim corpus that involved turn-based combat is stale.** The
pending final L10/L12 re-baseline should be run after this change, not before.
The pure auto-resolve corpus (`SimulateCombat` / `simulateParty`) is unaffected —
that path always armed exactly once.
### ~~C. Members and leaders disagree about an expedition during `extracting`~~ — MIS-STATED; the real bug was worse. Fixed 2026-07-10.
**The premise was wrong.** `getActiveExpedition` — the *leader's* lookup — also
filters `status = 'active'`. During `extracting` the leader gets "no active
expedition" from `!expedition`, `!map`, `!camp` &c. exactly as the member does.
They already agree, and honestly so: nobody is standing in the dungeon. Widening
`expeditionForMember` would have made the member the only player who can see a
paused expedition. Not done, and it should not be.
Reading for it surfaced the real defect underneath.
**The seven-day resume window was never enforced.** `releaseParty` fires only on
a terminal status, and `dnd_expedition.go:428` justifies skipping it for
`extracting` by asserting "the roster is cleared when the resume window lapses
and the row flips to `failed`". Nothing did that. The only `extracting → failed`
transition in the tree was inside `handleResumeCmd` — a lazy expiry that runs
only when **the leader personally types `!resume`**.
So a leader who quit, forgot, or simply started a different expedition left the
row `extracting` **forever**. `releaseParty` never ran, every member stayed
seated, and `assertNotAdventuring` kept refusing them a run of their own. Fix #2
gave them `!expedition leave` as an escape, which is why this was a permanent
nag rather than a permanent soft-lock — but a player should not have to find it.
Three holes, all closed:
1. **No sweeper.** `sweepLapsedExtractions` (`dnd_expedition_extract.go`) reaps
every `extracting` row past `completed_at + 7d` through `completeExpedition`,
which releases the roster, and DMs the audience. Hourly ticker
(`expeditionExtractionSweepTicker`), plus a one-shot at `Start()` — a lapse
that happened while the bot was down is blocking those members *now*. The
audience is read before the close-out, since `completeExpedition` disbands the
roster `expeditionAudience` reads. `handleResumeCmd`'s lazy expiry stays, now
sharing the `extractionLapsed` predicate (which treats a NULL `completed_at`
as lapsed — it is the only clock the window has).
2. **The leader could orphan their own party.** `!expedition start` checked only
`getActiveExpedition`, so a leader could start fresh on top of an extracted
row. `!resume` resolves the *newest* `extracting` row, so the old one became
unreachable — un-resumable, un-abandonable, roster still held. It now refuses,
but only when that row has a roster (`partySize > 1`); a solo extraction
strands nobody, so walking away from one stays a normal thing to do. A lapsed
row is reaped in place and the start proceeds.
3. **The leader had no way out but to pay.** `expeditionCmdAbandon` resolved
through `activeExpeditionFor` and so could not see the extracted row it owns —
the leader had to buy a `!resume` just to abandon. Both it and
`abandonExpedition` now span `extracting` via `ownedLiveExpedition`, which
sorts active rows first so `expeditionCmdStart`'s run-spawn rollback still
tears down the row it just created rather than an older extracted one. This
also fixes `dnd_setup.go:394,449` for free: a leader who rerolled their
character used to strand their whole party.
Abandoning now DMs the members too, and says the true thing when the party is
standing in town rather than in the dungeon (supplies already spent, loot
already banked — not "supplies are forfeit").
New tests: `dnd_expedition_extract_sweep_test.go` (6 cases) — the sweep releasing
a stranded roster, leaving a live extraction alone, the NULL-`completed_at`
predicate, abandon reaching an extracted row and freeing the party, abandon
preferring the active row, and the no-live-row error.
Not covered: the two `expeditionCmdStart` / `expeditionCmdAbandon` refusals,
which sit above the DM boundary. Still the `dmSink` gap.
### ~~D. Two turn-based win close-outs must be kept in sync by hand~~ — effect-drift closed 2026-07-10.
`finishPartyWin` (`combat_party_finish.go`) was a hand-copied sibling of the
`CombatStatusWon` block in `finishCombatSession` (`combat_cmd.go`): mood
scan, kill record, threat, boss threat, XP + near-death, loot, TwinBee line,
continue hint. `finishPartyCombatSession` routes solo back to
`finishCombatSession`, so the two must stay equivalent forever, with nothing
forcing it. Item A was the first drift.
Item A's fix already pulled the *bookkeeping* out of both into
`postCombatBookkeepingForSeat`. This pass hoists the remaining duplicated
**effects** into three shared helpers in `combat_party_finish.go`, and routes
both close-outs through them:
- `applyOwnerWinEffects(owner, enemyID, elite) bool` — the zone-kill record,
room threat, and boss-defeat threat drop; returns `bossOnExpedition`. Fires
once, owner-scoped (a member owns neither row).
- `grantSeatWinXP(uid, hp, hpMax, monster, tier)` — near-death calc + XP grant.
Per seat (solo calls it once with seat 0).
- `endRunOnLoss(owner, runID, death)` — mood event (death only) + `abandonZoneRun`
+ `forceExtractExpeditionForRunLoss`, shared by both the Lost and Fled paths.
What deliberately stays divergent, so this was **not** a full `closeOutSeat`
merge: the player-facing text genuinely differs (solo "You finished at N/M HP"
vs party "The party stands"; the party's 4-way boss/hint split vs the solo
2-way), and the death-on-win rule is roster-size-dependent by design — solo
`finishCombatSession` never marks a winner dead, `finishPartyWin` marks a seat
that won from 0 HP dead. That is item E and must not be unified. So the effect
lists are now a single copy each; the text is not.
Full plugin suite green; no behavior change (helpers are extract-and-call).
Note this is the *only* place the party work chose a parallel sibling. Elsewhere
the branch is a genuine N-body generalization, not a bolt-on: `SimulateCombat`
`simulatePartyWithRNG`, `runCombatRound``runPartyCombatRound`, `runZoneCombat`
`runZoneCombatRoster`, `RenderTurnRound``RenderPartyTurnRound`.
### E. Death-on-win depends on roster size — intentional, leave it
`closeOutZoneWin` marks a downed seat dead on a win only when `len(seated) > 1`.
A solo player who wins at 0 HP (retaliate aura kills the swinger on the killing
blow) survives; a party member in the same spot dies.
This is **deliberate and documented in place** (`zone_combat_party.go:183-188`):
unifying it would move the sim's outcome labels and force a re-baseline of
`testdata/combat_characterization.golden`. Not a defect. Listed so the next
person to find it doesn't "fix" it by accident.
### F. `grantAutorunGrace` is a stopgap (already tracked as R5)
`zone_revisit.go:172` stamps `last_autorun_at` to buy one `autoRunCooldown`
window after a `!revisit`, because the autopilot ticker has no concept of
position-vs-frontier and will otherwise march the party back out of the room they
just backtracked to. The code says so itself and defers the real fix to R5. It is
coupled to the exact value of `autoRunCooldown` and to the ticker cadence.
### G. Party combat multiplies the known per-turn DB chatter
Already tracked as `project_combat_session_cache_deferred`, now worse:
- `partyCombatantsForSession` (`combat_session_build.go:137`) rebuilds every seat
from scratch on every `!attack`/`!cast`/`!consume` (~68 SELECTs per seat).
A 3-member, 5-round fight ≈ 270360 SELECTs.
- `rebuildRoster` (`combat_cmd.go:620`) then rebuilds *all* seats again after a
mid-fight buff, when only the casting seat changed.
- `runZoneCombatRoster` (`zone_combat_party.go:73`) loads char + equipment, then
`buildZoneCombatants` loads both again and discards the first pair — once per
seat per room, on the path that fights every room.
- `nudgeStalledPartyTurns` (`combat_party_turn.go:281`) does two session reads
plus a full N-seat rebuild per stalled fight, serially, on the 1-minute ticker,
while holding the fight lock.
- The enemy stat block is rebuilt once per seat and kept only for seat 0 (the
code comments admit it). CPU-only, but pure waste.
Cheapest real fix: memoize the built roster for the fight's lifetime. Mid-fight
buffs are already tracked as `ActorStatuses` deltas and re-applied by
`applySessionBuffs`, so stats are reproducible without re-reading the DB.
### ~~H. Guard probes re-query per message~~ — MEASURED, DECLINED 2026-07-10.
`isPartyMember``activeExpeditionFor` is up to 2 queries; `activeZoneRunFor`
adds more. A solo player with nothing in flight costs 3 queries to conclude "not
in a party". The original write-up said this was "repeated by each guard a
handler consults" and proposed resolving seat/expedition/run once per message
into `MessageContext` and threading it.
**The premise was overstated, and the fix is disproportionate. Not done.**
- **No per-message guard cascade exists.** `OnMessage` dispatch is strictly
one-handler-per-command (`if IsCommand(...) return handle(...)`). Nothing runs
these resolvers for *every* message; the redundancy is intra-handler only.
- **The one hot inbound path is already optimized.** `zoneCmdAdvance` /
`expeditionCmdRun` call `isPartyMember` directly *by design* — the code comment
spells out that this avoids re-resolving the run (which `advanceOnce` is about
to resolve) and double-firing the §4.3 idle reap. The authors already closed the
hot case.
- **The remaining 23× callers are cold.** A per-function scan found ≥2 resolver
calls only in `zoneCmdEnter`, `zoneCmdAbandon`, `expeditionCmdStart`, the status
impls (all one-shot commands), and `runAutopilotWalk` (5×, but it is the
*background* ticker path, not an inbound message). In those the calls are
typically a guard-check on one branch then a resolve on another — not the same
read twice.
- **The proposed fix has a real regression surface.** `MessageContext` is a
shared cross-plugin value type (movies, vibe, miniflux, …); the resolvers are
free functions used at 60+ sites. Threading a resolved snapshot means changing
the shared type and every call site, and carrying a memo that goes stale the
moment a handler writes the row mid-flight — the exact staleness-bug class items
A/M/N just fixed. All to save a few cheap indexed local SQLite reads on a
low-volume bot.
Cost/benefit is upside-down: high churn + a staleness regression surface for a
negligible perf gain on paths that are mostly already optimal. Left as-is
deliberately. Revisit only if a profiler ever shows these reads on a hot path.
### ~~I. `parseTemperIndex` is the third copy of the same menu parser~~ — Fixed 2026-07-10.
`adventure_temper.go` duplicated the "read leading digits, 1-indexed →
0-indexed, bounds-check" reply parser already inlined in `resolveMagicEquipReply`
(`magic_items_gameplay.go`) and `handleMasterworkEquipReply`
(`adventure_masterwork.go`). Promoted the temper copy to `parseMenuIndex(reply, n)`
and routed the other two through it.
Behaviour-preserving on all three: the old `parsed` flag was redundant with the
`idx < 0` guard (no leading digit leaves `idx=0`, then `idx--` → -1, rejected
either way), so no input changes verdict. The **retry disagreement the doc
flagged was deliberately left as-is** — this was a pure dedup, so magic-items and
temper still re-store the pending interaction on a bad parse and masterwork still
doesn't. Unifying that is a behaviour change and belongs in its own pass if
wanted. Full plugin suite green; the existing `parseMenuIndex` table test moved
with the rename.
### J. `!sell` fires the event anchor even when nothing sold (PLAUSIBLE)
`adventure.go:719` calls `maybeFireAnchoredEvent` unconditionally after
`handleSellCmd`, so `!sell nonexistentitem` can burn the player's one daily event
slot. Gating it needs `advSellItem`/`advSellAll` to return a `sold bool`.
**Probably leave it.** The anchor's stated design is presence-based — "a moment
the player is present and reading" — and a player who typed `!sell` and read the
reply *is* present. That matches the project's own rule that presence means any
chat, not a successful command. Flagged only so the choice is explicit.
### K. Confirm the A6 anchor set covers the player population — CONFIRMED REAL GAP 2026-07-10.
N1/A6 replaced the old "any daily-active player, any activity" mid-day event roll
with three presence anchors: the expedition Night digest, `!sell`, and arena
cashout. `tryTriggerEvent`'s `HasActedToday` gate was dropped with it.
**The code confirms the gap is real, not hypothetical.** The three, and only
three, `maybeFireAnchoredEvent` call sites are:
1. `expedition_autorun.go:312` — the end-of-day digest, and it is gated on
`campDecision.Night`. A Night camp only happens on a **multi-day** expedition;
a single-day dungeon/`!zone` run never reaches one.
2. `adventure.go:734` — after a `!sell` at Thom's.
3. `adventure_arena.go:633` — after an arena cashout.
So the excluded population is concrete: a player whose daily loop is
mining / foraging / fishing / babysit / single-day dungeon, who never sells at
Thom's, never enters the arena, and never runs a multi-day expedition, receives
**zero** mid-day events — the roll never even happens for them. That is exactly
the core daily-grind loop, so this is plausibly a large fraction of players, not
an edge case.
**Fourth anchor drafted 2026-07-10 (uncommitted, pending owner review).** The
on-theme, farm-resistant home is a foreground **single-day `!zone` clear** — the
climax DM the dungeon/harvest-via-walk player is demonstrably reading, and one
they cannot spam (a clear costs a full walk). Implemented as:
- `advEventChanceZoneClear = 0.08` in `adventure_events.go` (matches the digest;
it is the day's climax moment, and it is the single tuning knob).
- `advanceResult.zoneCleared` set true only in the *full*-clear branch of
`advanceOnceWithOpts` (not a mid-zone region clear, which continues the run and
would fire per region).
- Rolled in `zoneCmdAdvance` after the clear DM streams, via the unchanged
`maybeFireAnchoredEvent`. That is the foreground path only — autopilot
(`runAutopilotWalk`) and party members (refused earlier by `isPartyMember`)
never reach it. The per-day slot guard still caps everyone at one event/day.
Rates (see `TestZoneClearAnchorRate`): a zone-clear-only grind player at ~2
clears/day lands ~1.08 events/week — the same band as the fully-engaged
digest+sell+arena player (~1.19/week, unchanged). The rare all-four-in-one-day
player peaks at a ~1.65/week *probability* ceiling but still gets at most one
actual event/day.
**Two knobs remain the owner's call**, hence "pending review": the 0.08 chance,
and whether a single-day zone clear is even the right anchor for the grind loop
vs. `!resources` (rejected here as farmable — repeated `!resources` would just
re-roll the same daily slot) or leaving the grind loop event-free by design.
Full plugin suite green; no existing behaviour moved (the three prior anchors and
`TestAnchoredEventWeeklyRate` are untouched).
### ~~L. Dead helper: `openParty`~~ — Verified and removed 2026-07-10.
Confirmed dead: a repo-wide grep found `openParty` only in `expedition_party.go`
(the def) and `expedition_party_test.go`. The invite path goes `joinParty`
`seatLeader`, which runs the same `INSERT ... role 'leader' ON CONFLICT DO
NOTHING` but reads the owner off the expedition row instead of trusting a
passed-in id. Deleted `openParty`; the eleven test call sites now use a
`seatLeaderFixture(t, expID)` helper that wraps `seatLeader` in a transaction
(the owner comes from the `seedExpedition` row, which always matched the id the
old calls passed). `seatLeader`'s doc comment absorbed the idempotency note that
justified `openParty`. Full plugin suite green.
---
## Test gap worth closing — ~~the DM seam~~ CLOSED 2026-07-10
`SendDM` went straight through `Base` to a live client, with no fake/sink seam,
so the handler-level paths behind fixes #1, #2 and #5 could not be unit-tested.
That is why the party close-out bug survived a suite this thorough: every party
test is unit-level and stops below the DM boundary.
**Fixed 2026-07-10.** Added a `MessageSink` interface and a `Base.Sink` field
(`plugin.go`). When non-nil it captures every outbound message — both the DM
route (`SendDM`/`SendDMID`) and the room route (`SendMessage`/`SendMessageID`,
which is what the arena announcement actually uses) — and the live client is
never touched. Nil in production, so behaviour is unchanged; the whole
`internal/...` suite is green with the field added. One seam, no call-site churn:
the 765 `SendDM`/`replyDM`/`SendMessage` sites are all downstream of these four
methods.
New tests (`message_sink_test.go`): a `captureSink` test double + `installSink`
helper; `TestMessageSink_CapturesDMAndRoom` proving both routes divert with the
right target/text and that the `*ID` variants report back an id; and
`TestExpeditionCmdLeave_DMsBothEnds`, which drives the real fix-#2 handler end to
end and asserts both DMs land (member "turned back", leader notified) — a path
that was literally a silent no-op in a unit test before. `beginCombatTurn`'s
terminal path and the arena rollover announcement are now reachable the same way
(seam proven for both the DM and room routes); dedicated end-to-end tests for
those two remain easy follow-ups if wanted.
Added this pass: `TestPartyCasualtyLine_JoinsNamesLikeTheRestOfTheBot`,
and `TestPartySurvivors_SplitsOnHPNotOnOutcome` was rewritten as
`TestAnySurvivor_ReadsHPNotOutcome` when its helper was deleted.
Added in the second pass (`combat_armed_ability_test.go`): the close-out
bookkeeping is now reachable below the DM boundary via
`postCombatBookkeepingForSeat(sess, seat)`, which takes a plain `*CombatSession`
and needs no client — so item A's behaviour is directly unit-tested even though
`finishCombatSession` itself still isn't. (The `dmSink` seam that would have
covered `finishCombatSession` itself now exists — see above.)
Not covered by tests: item B's locking. `withExpeditionSupplies` is exercised
incidentally by the existing expedition suite, but nothing asserts the mutual
exclusion. A `t.Parallel` accept-vs-burn race test under `-race` would.

View File

@@ -0,0 +1,519 @@
# Combat engine — paying off the N-body debt
## Session 2026-07-11 (c) — §6 was never a balance problem
**IN FLIGHT: the verification sweep is still running on millenia** (`/tmp/s6_after.jsonl`,
10 classes × L10,L12 × 10 zones × n=25). Nothing below the diagnosis is measured yet.
### Committed
- `c9128fb` — §1's other half. Out-of-combat `!cast <heal> @friend`, scoped to the
people on your expedition. `--target` had been advertised by `!help` and swallowed
by the parser since SP2. Ally row is mutated with one guarded UPDATE in a
transaction (gifting's precedent), not a read-modify-write under a second lock:
two clerics healing each other would take their `advUserLock`s in opposite orders
and deadlock. Refunds the slot on every path that heals nobody.
- §6 auto-cast (`zone_combat_autocast.go`) — see below. Tested, unmeasured.
### The finding: cleric was not weak, it was not being played
Baseline re-run on HEAD (`sim_results/s6_baseline_l10l12.jsonl`, 5000 rows) confirmed
cleric still dead last: **46.4% vs fighter 81.4%**. §1's self-heal moved it ~0, as
predicted. But the *shape* of the failures is the whole story:
| class | cleared | **fled** | tpk |
|---|---|---|---|
| fighter | 407 | **0** | 93 |
| ranger | 395 | **0** | 105 |
| cleric | 232 | **167** | 101 |
**Cleric dies LESS than mage, sorcerer or warlock.** Its entire 35pp deficit is
*fleeing* — 167 of 500 runs end with the player alive and the expedition over.
Instrumenting the three `stopEnded` sites: **every one of those runs died at
`stopEnded via ROOM`** — an ordinary room fight. Not a patrol, not an elite, not the
boss.
**Root.** An ordinary room is `runZoneCombatRoster``SimulateCombat` on an 8-round
clock: one breath, **no turn engine, no action picker**. The only spell that could
ever land there was a `PendingCast` the player queued BY HAND with `!cast` before
walking in. On autopilot nobody queues one — so for every room of every expedition, a
caster fought with a weapon and nothing else. A cleric has no Extra Attack, a mace,
and its whole kit unusable. Measured on the room path at L10 (loss% by entry HP):
| entry HP | fighter | druid | bard | mage | sorcerer | **cleric** |
|---|---|---|---|---|---|---|
| 100% | 0.0% | 0.0% | 0.0% | 0.0% | 0.0% | 0.0% |
| 70% | 0.0% | 0.0% | 0.0% | 0.0% | 1.0% | **1.5%** |
| 35% | 0.0% | 0.0% | 0.5% | 0.5% | 10.0% | **8.0%** |
Small per room — and a run is ~16 rooms, and **one loss ends the expedition**
("the fight drags on, X outlasts you, you retreat"). It compounds into 33% of runs.
**The tell.** `dnd_class_balance.go:431` — the harness the class corpus was tuned on
*always* hands a caster their best damage spell (`pickBestDamageSpell` +
`applyHarnessSpellCast`) before it simulates. So the numbers that say "cleric is a
weak class" modelled a cleric who casts; the live room modelled one who does not.
The corpus and the game disagreed about what a cleric is.
This is the same defect as everything else in this plan: **the action model is
narrower than the kit.** §1 (the picker never healed), §3 (the companion never
acted), Pete (`LoadDnDCharacter` → nil → `"attack"`), and now every caster in every
room. It is not a tuning dial and it must not be fixed with one.
### The change (unmeasured)
`autoCastForAutoResolve` — no queued spell + a real slot in hand → cast the best
damage spell, spend the slot, fold it in. Additive pre-damage, exactly as a
hand-queued `PendingCast` has always been, so it stays comparable to the corpus
rather than being a fresh mechanic.
- **Slot-aware, not free.** `pickBestDamageSpell` reads `slotsForClassLevel` — the
*theoretical* class table. Reusing it here would be an infinite spell: the same
"no row to persist onto, so it arrives fresh" bug that gave the companion an
unlimited body ([[project_companion_free_lunches]]). The new picker reads the
seat's actual remaining slots and `consumeSpellSlot`s.
- **Never upcasts.** The big slots are what the elite and the boss are for, and the
turn engine spends them there. A picker that nukes a goblin with a 5th-level slot
leaves the caster swinging a stick at the thing that matters.
- Cleric owns no damage spell at slot 2 or 4, so those stay unspendable in rooms —
correct, and pinned by a test.
- Both goldens byte-identical (they pin the engine; this changes its *inputs* at the
zone layer). Martials provably untouched.
### When the sweep lands
Read cleric's **fled** count, not just its clear rate — fled going to ~0 is the
thing this predicts. Expect all 6 casters to move; that is the deliberate
re-baseline. **Watch for overshoot on bard/druid** (already 6772%): they get the
same buff and were not the problem. If they overshoot, the lever is this picker, not
monster scaling ([[project_difficulty_target]]).
## Session 2026-07-11 (b) — the companion can cast, and three free lunches fell out
**Committed: `01c2cb2` (§1 — the seat-scoped spellbook).** Everything below it is
working-tree.
The revisit-Pete step ("Pete cannot cast") turned out to be four separate defects
stacked on each other. The unit tests were green for all four; only the sweep with
a **control arm** ever told the truth. Read [[feedback_unit_tests_miss_engine_bugs]]
and then read it again.
### The one that was asked for
Every spell lookup is keyed on a user id and answered by a `dnd_*` table. Pete has
rows in none of them, by design. So `pickAutoCombatActionForSeat`'s first line —
`LoadDnDCharacter(uid)` → nil → `return "attack"` — meant **a hired Cleric swung a
mace, every turn, forever**. Role-fill hands a lone martial a Cleric, so that was
the *common* case. Fixed with a seat-scoped spellbook (`combat_seat_spells.go`):
humans delegate to the DB verbatim (both goldens byte-identical), the companion is
answered from his synthetic sheet.
### The three that were not
Each of these was found by measuring, and each is the same mistake: *"he has no row
to persist onto, so he arrives fresh."*
1. **His spell slots refilled every fight.** I parked the ledger on his combat
*seat* — and a seat is per-session. A human rations one pool across a 30-room
run and gets it back at camp; rationing it **is** the caster's game. Moved to
`expedition_party.companion_slots_used`, refreshed at camp.
*(Worth ~0pp on its own — an expedition only holds ~2 real fights, so the pool
never binds. Correct, but not the lever. I predicted this one would be the whole
answer and I was wrong.)*
2. **His body refilled every fight.** `combat_party_start.go` seated him at
`player.Stats.MaxHP` and the close-out skipped him with the comment *"he arrives
fresh next time"*. That is an infinite body: he soaked a share of every fight's
incoming and then reset, while the humans beside him bled all the way to camp.
**This was the carry.** Moved to `expedition_party.companion_hp`; healed at camp.
3. **No autopiloted caster has ever healed itself.** `simPickAllyHeal` skipped
`i == seat` and bailed on `!IsParty()`. Between them: a solo cleric carries
`cure_wounds` for a whole run and dies with a full pool of level-1 slots. Now
`simPickHeal`: heal whoever is worst off, which is sometimes you.
**It is NOT the answer to §6, and I guessed that it would be.** It moved solo
66.1% → 66.2%: the picker reaches for a healing *consumable* first and the sim
stocks them, so a human almost never falls through to the spell. The corpus is
therefore undisturbed and no re-baseline is owed. Pete carries no consumables,
so it is his only heal — which is the reason to keep it.
### What the arms actually said
640 runs/arm, 10 classes × L10,L12 × 4 zones. "Like-for-like" = the leaders whose
role-fill gives Pete a **Cleric**, compared against a human **Cleric** follower.
| arm | like-for-like clear | vs solo |
|---|---|---|
| solo | 68.2% | — |
| + a human cleric follower (leader's level, geared) | 79.7% | +11.5pp |
| + Pete, HEAD (free heal, cannot cast) | 77.1% | +8.9pp |
| + Pete, casting + free heal | **94.8%** | +26.6pp — **carry** |
| + Pete, casting + carries his wounds | **69.5%** | +1.3pp — **useless** |
**The reference arm is the whole point.** Measured against *solo*, even mace-only
Pete looked like a carry (+8.9pp) — but parties are *designed* to be safer, so solo
is the wrong yardstick. Measured against **a human follower**, the real finding
appears: a gearless, level-penalized hireling was out-clearing a fully-geared human
cleric *of the leader's own level* by 15pp. A hireling must never beat a player.
His party fled 5 runs out of 640; the human party fled 56.
### §2(a) SHIPPED — and it landed in band
`055f07d`. Seats carry a **SeatWeight**; both levers (boss HP, enemy action economy)
scale on the summed weight of the **living** seats instead of a head count. The
weight is **level-based** — priced against the leader, times a hireling discount
(`companionSeatWeight = 0.65`, the one tunable).
**Not** a power score: HP×damage would rank a cleric below a fighter and quietly
make every mixed *human* party easier — a difficulty regression smuggled in under a
bug fix.
The safety argument is one property: **a peer weighs exactly 1.0.** The curves
interpolate between P8's tuned integer knots — (1, 1.0), (2, 2.4), 2n1 from 3 up —
so every integer input returns exactly what it always returned. Solo byte-identical,
a party of same-level humans byte-identical, both goldens unmoved. Only an *unequal*
roster lands between knots, which is the whole point. It also finishes §2(b): a
downed seat now buys the enemy nothing (§2(b) fixed only the head-count half — a
corpse still carried its full weight).
| | solo | + Pete | + human cleric peer |
|---|---|---|---|
| like-for-like clear | 69.0% | **76.8%** (+7.8pp) | 77.6% (+8.6pp) |
| band | solo | + Pete | lift |
|---|---|---|---|
| trailing (solo <40%) | 10.0% | 31.0% | **+21.0pp** |
| middle | 58.9% | 76.8% | +17.9pp |
| leading (solo ≥70%) | 93.5% | 99.2% | **+5.7pp** |
Help, never a carry — and he stays below a real human of the leader's level, which
is the invariant a hireling must never break.
### How it got there (the record of being wrong)
With all three free lunches gone, the same grid says (like-for-like subset):
| | solo | + human cleric peer | + Pete |
|---|---|---|---|
| clear | 69.0% | 77.6% (+8.6pp) | **66.1% (2.9pp)** |
**Hiring him is now worse than going alone** — and that is §2, unmasked. The free
full-heal had been paying his enemy-scaling bill for him. A below-median seat costs
the boss +15% HP and lifts the enemy's action economy from 1 to 2.4 attacks a round,
and he does not give that much back.
I talked myself out of §2(a) mid-session, on the grounds that discounting a weak
seat would enlarge the carry. That was right *about the buggy build* and wrong about
the engine: with the infinite body removed, the plan's original diagnosis is exactly
correct and the sweep now argues **for** §2(a).
I talked myself **out** of §2(a) mid-session, on the grounds that discounting a weak
seat would enlarge the carry. That was right about the *buggy build* and wrong about
the engine: the infinite body had been paying the seat's scaling bill for it. With
the free lunches gone, the plan's original diagnosis was exactly correct. **The
order mattered** — §2(a) done first, on top of the free full-heal, would have made
things worse and looked like it was working.
## Still open
- A dropped companion returns at 1 HP (`companionSeatHP`'s floor) rather than being
benched until camp. Deliberate: there is no companion-death rule and inventing one
inside a bug fix would have been a second feature.
- **§6 is still open, and self-heal was NOT it.** Solo cleric is 2834% against
fighter's 100% on this grid. Re-read it off the current corpus before tuning.
- `companionSeatWeight = 0.65` is the single tunable and was not swept — it landed
in band on the first value tried. If Pete ever needs to be weaker, raise it.
- Out-of-combat `!cast --target` (`dnd_cast.go`) is still self-only.
- **Deployable now.** Hiring him is a real, bounded help at every level.
> The party engine (N3) widened the *roster* from 1 to N but never widened the
> *action model*, the *scaling model*, or the *test net* to match. Everything
> below is a symptom of that one gap. Working-tree doc; do not commit
> (feedback_dont_commit_plan_mds).
## Status @ 2026-07-11 — §1§5 SHIPPED (uncommitted, unmeasured)
All five sections are implemented and the suite is green. Nothing is committed.
| § | What | State |
|---|------|-------|
| §5 | Party golden + seat attribution | ✅ `party_characterization.golden` (1938 lines, 9 scenarios × 5 seeds) + `TestPartyCharacterization_OneSeatIsStillSolo`; sim trace gets `simTraceEvent` (seat always emitted). Wire format left alone — `TestP5Fields_StayOffSoloRows` was right and I was wrong. |
| §3 | Engine-driven seats | ✅ `ActorStatuses.EngineDriven`, permanent, no command clears it. `beginCombatTurn` now REFUSES a command from an engine seat. `autoDriveCombat` calls `driveEngineSeat` instead of impersonating the seat. |
| §2(b) | Living-seat action economy | ✅ `enemyActionsThisRound` counts `livingActors(st)`, not `len(st.actors)`. **Party golden moved deliberately** (survivor ends 59 HP, was 51). Solo golden byte-identical. |
| §4 | One roster accessor | ✅ `expeditionParty()` / `partyHumans()` / `PartySeat{Kind}`. Cannot return an empty party for a solo run — that is the level-1-companion bug, pinned by `TestParty_SoloExpeditionStillHasAParty`. |
| §1 | Ally-targeted actions | ✅ `turnActionEffect.AllyHeal/AllySeat`; `!cast cure wounds @alex` **and** `--target @alex` (the flag `!help` advertised and `parseCombatCast` silently swallowed since SP2). `simPickAllyHeal` so autopiloted/engine healers use it. Will not raise the dead. |
**Both goldens hold. Solo is byte-identical throughout — the balance corpus is
intact.**
### Post-fix sweep (2026-07-11, n=750/arm on millenia)
| | clear |
|---|---|
| solo | 48.5% |
| solo + hired Pete | **63.9%** (+15.3pp) |
| solo *(the 2-human cells)* | 65.7% |
| \+ a human cleric follower | **87.7%** (+22.0pp) |
Pete went from **28pp (worse than nobody) to +15.3pp**, and the lift lands exactly
where the plan asked for it:
| band | n | mean lift |
|---|---|---|
| trailing cells (solo <40%) | 15 | **+28.0pp** |
| middle (4070%) | 3 | +5.3pp |
| leading cells (solo ≥70%) | 12 | **+2.0pp** |
That is "help, never a carry", measured rather than asserted: he rescues the
players who were drowning and barely moves the ones who were already fine. §2(a)
may now be unnecessary — the §2(b) fix appears to have been most of it.
**⚠️ READ THE NOISE FLOOR BEFORE TRUSTING ANY CELL.** The sim is not seeded per
cell. The same binary, same cell (bard/L10/feywild, n=25), three times:
**36% / 56% / 56%** — a 20pp spread from RNG alone.
- **Per-cell numbers at n=25 are noise.** Do not tune against them.
- Only the **n=750 aggregates** carry signal. The solo arm's 51.9% → 48.5% drift
across engine versions is inside that noise band (and the solo golden is
byte-identical, so solo combat provably did not change).
- Anything huge (the old fighter/L10/feywild 100% → 4%) is real; anything under
~20pp on a single cell is not.
- **Next sweep: raise `-runs` to 100+ per cell** if per-cell reads are needed.
### What is NOT done
- **Measurement.** The engine moved under every number in this doc. A full
re-sweep (solo / solo+Pete / 2-human-with-a-cleric) is in flight; every figure
quoted below predates §1§5 and is now **stale**.
- **§2(a)** — power-based enemy scaling. §2(b) only stopped corpses from buffing
the boss. A *weak* living seat still costs a full seat's worth of enemy scaling,
which is why hiring Pete could still hurt a strong leader.
- **§6** — solo cleric bump. Blocked on the re-baseline, deliberately.
- **Pete cannot cast.** `castActionForSeat` loads a sheet from the DB and he has
none by design, so a hired "Cleric" *still* cannot heal. §1 fixed human clerics;
the companion needs a synthetic spell pool (or to be restricted to martial
chassis). **This is the revisit-Pete step.**
- Out-of-combat `!cast --target` (`dnd_cast.go`) is still self-only. Combat is
where it mattered; that one can follow.
## Why this plan exists
The Pete companion (`!expedition hire`) was built in one session and worked on
every unit test while being, in the sweep, **worse than no companion at all**
(solo 65% clear → solo+Pete 33%). Chasing that found four defects. Only one of
them was Pete's. The rest were sitting in the engine, in prod, since N3:
| Symptom found via the companion | Who else it affects | Root |
|---|---|---|
| A hired Cleric can't heal anyone | **Every human cleric in every party** | §1 |
| A weak seat makes the party *worse* | Any under-levelled friend; every downed seat | §2 |
| The companion lost his turn after round 1 | Any engine-driven seat, incl. away-player autopilot | §3 |
| The companion was silently level 1 | Anything reading "the party" of a solo run | §4 |
| Nobody noticed any of it | — | §5 |
§5 is the one that matters most: **there is no party golden.** The
characterization golden (`combat_characterization.golden`, 7468 lines) runs
`simulateCombatWithRNG(player, enemy)` — one player, one enemy, every scenario.
Party combat has *no* pinned behaviour, so N3 shipped a healer class that cannot
heal and the corpus said nothing. Whatever else we do, that gets fixed.
**Sequencing:** §5 first (build the net), then §1§4 (change the engine while the
net is under us). Doing it the other way round means changing the most heavily
tuned code in the repo with no way to see what moved.
---
## §1 — Actions cannot target another seat
**The bug.** Every heal in the engine is self-scoped. `MistyHealProc`/
`MistyHealAmt` heal the actor. `HealItem`/`HealItemCharges` fire the actor's own
sub-50% trigger. `grep` for an ally-targeted action returns nothing; the only
seat-targeting that exists anywhere is `enemyTargetSeat` — which seat the
*monster* swings at. A party cleric is a cleric in chassis and passives only:
**they cannot put one hit point back on a friend.**
**Root.** `PlayerAction{Kind, Effect}` (`combat_turn_engine.go:51`) has no target
field. It was written when "the player" was unambiguous, and N3 never revisited
it — the roster got wider, the action stayed pointed at the same two things (me,
and the enemy).
**Change.**
- `PlayerAction` gains a target: `TargetSeat int` (1 = the enemy, the default,
which keeps every existing call site meaning exactly what it means today).
- `turnActionEffect` grows an ally-heal payload; `runPartyCombatRound` applies HP
deltas to `TargetSeat` rather than to the actor.
- `!cast <spell> @user` parses a target; solo ignores it.
- Cleric/Druid/Bard/Paladin get their heal spells actually wired to it.
- `pickAutoCombatActionForSeat` gains a heal-the-lowest-living-seat rule, so
autopiloted and companion healers behave like a competent player.
**This moves the golden.** It adds an action the picker can choose. Deliberate
re-baseline, after §5.
**Risk.** Touching `runPartyCombatRound` is touching the most tuned code we own.
Mitigated by §5's party golden plus the existing solo golden staying byte-identical
(no solo fight has a second seat to target).
---
## §2 — Enemy scaling counts seats, not strength
**The bug.** P8 scales the enemy's action economy to the roster, and
`partyEnemyHPScale` gives +15% HP for any roster ≥ 2. Both count **seats**. So a
seat contributes its full cost to the enemy the moment it sits down, regardless
of what it actually brings — and keeps contributing that cost **after it dies**.
Measured, in the same cell (fighter L10, feywild):
| | clear |
|---|---|
| solo | 100% |
| \+ Pete as Cleric (a chassis he can't play) | 32% |
| \+ Pete as Fighter (his best case) | 68% |
| \+ a second *human* | 100% |
A deliberately below-median body is a **net negative at every level** — the boss
gains more than the seat gives back. That is not a Pete property. It is true of
any under-levelled friend you invite, and of every seat that goes down early
(the corpse keeps inflating the boss for the survivors).
**Root.** The scaling model assumes every seat is a median player. Nothing
enforces that, and two shipped features (parties, then companions) violate it.
**Change — pick one, and it is a design call, not a mechanical one:**
- **(a) Scale on contributed power, not seat count.** Enemy HP/actions scale on
the roster's summed effective level relative to the leader's. Correct, and it
fixes the under-levelled-friend case too. Most work; needs its own sweep.
- **(b) Scale on *living* seats, re-derived per round.** Cheap, and kills the
dead-seat-still-inflates-the-boss bug on its own. Does not fix the
weak-seat case.
- **(c) Don't scale for engine-driven seats at all.** Cheapest; makes hiring
strictly positive. Risks "carry", and leaves the human-party half untouched.
Recommendation: **(b) now** (it is a straight bug — a dead man should not be
buffing the boss), then **(a)** as the real fix once §5 can see it.
---
## §3 — Engine-driven seats are a status flag, not a concept
**The bug.** A seat with nobody at the keyboard is modelled as
`ActorStatuses.Autopilot = true`. But `beginCombatTurn` clears that flag on any
command arriving from that seat — *"They typed, so they are here."* And
`autoDriveCombat` drives a party by **dispatching each seat's turn as a fake chat
command from that seat's Matrix ID**. So the autopilot driver's own move looked
like the player coming back, cleared the latch, and the seat went inert for the
rest of the fight.
That is what made the companion stand in fights doing nothing. The same shape is
live for away-player autopilot today, and it is only luck that a human eventually
types something and re-latches.
**Root.** There is no first-class notion of "this combatant is driven by the
engine". Ownership of a turn is inferred from a Matrix message, which is a
transport detail leaking into the combat engine.
**Change.**
- A seat is `DrivenBy: human | away | engine`, persisted, not a bool that any
command can clear. `engine` is permanent; `away` is what a keystroke clears.
- `autoDriveCombat` stops impersonating seats. It calls the seat driver directly
(`driveCompanionSeat` is the shape; generalize it to any non-human seat).
- Command handlers refuse any non-human seat outright, rather than half-working.
**Bonus.** This is the prerequisite for the deferred Phase-16 "Pete runs his own
expeditions", and for any future NPC ally.
---
## §4 — "the party" and "the roster" disagree
**The bug.** A solo expedition has **no `expedition_party` rows** (absence means
solo; the roster materializes on the first invite). So any code that answers
"who is in this party?" by reading the roster gets **nobody** for a solo player
— and a plausible-looking fallback silently takes over. That is exactly how the
companion got hired at **level 1** for every solo player: the one case the
feature exists for.
**Root.** Two representations of the same fact ("who is on this expedition") that
disagree in the solo case, with no single accessor. `partySize()` and
`partyMembers()` and `expeditionAudience()` and `expeditionSeats()` and
`fightRoster()` all answer slightly different questions and are easy to reach for
by the wrong name — I reached for the wrong one twice while building the
companion, once fixed by a test and once only by a 1500-run sweep.
**Change.** One accessor that always includes the owner —
`expeditionParty(expeditionID) []Member` with `Member.Kind = leader|member|companion`
— and every consumer derives its own view from it (mail excludes companions,
seats include them, supply burn counts mouths). Delete the ad-hoc set.
---
## §5 — There is no party golden (do this first)
**The bug.** `TestCombatCharacterization` pins *solo* combat, exhaustively (57
scenarios × seeds). Party combat has nothing. The entire N3 engine — initiative,
seat order, enemy targeting, action economy, close-out — is unpinned. That is why
a healer class that cannot heal shipped without a single test going red, and why
the companion's collapse was invisible until a 1500-run sweep.
**Change.**
- `TestPartyCharacterization` + `party_characterization.golden`: N-seat rosters
(2 and 3), mixed classes, downed seats, an engine-driven seat, seeded and
byte-pinned exactly like the solo one.
- A **balance regression sweep** in CI-able form: the `expedition-sim` matrix at
low n, asserting clear-rates stay inside a band. The sweep is what caught all
of this; it should not have to be run by hand.
- Fix event seat attribution: `CombatEvent.Seat` is `omitempty`, so seat 0 and
"no seat" are indistinguishable in a trace — which cost real time during this
investigation, sending me after a phantom "Pete never swings" bug that was
partly a reporting artifact.
---
## §6 — Clerics are weak in solo (raised 2026-07-11)
Separate from the party gap, and real: the class corpus has cleric in the 4656%
band against fighter's ~82% ([[project_d8prereq_baseline]]).
**Do not tune this yet, and specifically do not tune it in isolation.** Two
reasons:
1. §1 *is* the cleric fix in party play — an ally-targeted heal is the class's
entire identity, and today they cannot use it on anybody. Whatever solo gap
remains should be measured **after** that lands, or we buff them twice.
2. `d8prereq_corpus` predates parties entirely and the party engine has moved
twice today (§2b, and §1 next). It is a stale baseline. **Re-baseline first**,
then read the cleric gap off the new corpus.
Then, if solo cleric is still short: lift the trailer, per the standing rule
([[project_difficulty_target]]) — do not nerf the martial leaders and do not touch
monster scaling.
## Ordering
1. **§5** — build the net. Party golden + seat attribution. No behaviour change.
2. **§3** — engine-driven seats. Contained, no balance impact, unblocks the rest.
3. **§2(b)** — living-seat scaling. Straight bug fix; party golden will move once,
deliberately.
4. **§4** — unify the roster accessor. Mechanical, guarded by §5.
5. **§1** — ally-targeted actions + heals. The big one. Moves both goldens;
re-baseline the class corpus after ([[project_d8prereq_baseline]] predates
parties entirely and is due anyway).
6. **§2(a)** — power-based scaling, then re-sweep the companion and re-decide his
below-median premise. It may not need to survive: once the boss stops
overcharging for him, "below median" may be exactly right.
## Meanwhile: the companion
He is **fixed and net-positive where he is meant to be** (+15.7pp in trailing
cells, ~neutral overall), but he still regresses strong solo leaders (§2) and
role-fill still hands martials a Cleric who cannot heal (§1). Two options until
this plan lands:
- **Ship him martial-only** (he plays chassis he can actually use — measured
32% → 68%), and let §1 restore the healer fill later. Honest, cheap, no engine
change.
- **Hold him** until §1/§2 land, and announce the rest of Adventure without him.
Do NOT bandaid by hand-tuning his stats until he "looks right" — his numbers are
not the problem, and tuning them would bake the engine's bugs into his stat block.

1547
gogobee_engagement_plan.md Normal file

File diff suppressed because it is too large Load Diff

356
gogobee_mischief_plan.md Normal file
View File

@@ -0,0 +1,356 @@
# Mischief Makers — paid monster hits on live expeditions
Working-tree plan doc. Do not commit.
## The pitch (restated)
Parodia members spend euros to send a monster — grunt / mob / elite / boss — at a
player who is out on an expedition. Orders come from Pete's Adventure web page
(and from Matrix). The games room hears that a contract is out and gets a window
to help the target… or "help" them. Surviving an attempt pays the target.
## What the codebase already gives us (verified 2026-07-13)
| Need | Existing machinery |
|---|---|
| Currency + escrow | `EuroPlugin.Debit/Credit` (`euro.go:364,:395`), duel escrow pattern (`adventure_duel.go:422,:529,:667`), community pot (`adventure_rival.go:193-241`) |
| "Is X out right now?" | `getActiveExpedition` (`dnd_expedition.go:215`), roster already pushed to Pete every 2 min |
| Mid-run combat injection | `runHarvestInterrupt` (`dnd_expedition_combat.go:121`) — picks enemy, surprise nick, `runZoneCombatRoster`, full win/retreat/death close-out. **This is the template.** |
| Safe-delivery gating | ambient ticker's CAS claim + `hasActiveCombatSession` + quiet-window checks (`expedition_ambient.go:97,:109,:156`) |
| Grunt→boss ladder | Arena tiers 15 (`adventure_arena_monsters.go`), `arenaMonsterToTemplate` (`:251`); zone pools tag `IsElite` |
| Games-room announce | `gamesRoom()` + world-boss announce helpers (`adventure_worldboss.go:528-566`) |
| Respond-within-window | duel challenge window + atomic single-winner claim (`adventure_duel.go:43,:85`) |
| Survival payout kit | `euro.Credit`, `rollAdvTreasureDrop`, `consumableCache`, renown, world-boss payout bundle (`adventure_worldboss.go:466-493`) |
| Pete pipe | durable fact queue, bearer auth, event-type taxonomy; **strictly one-way gogobee→Pete today** |
Hard constraint (Pete `internal/web/roster.go:23-25`): *"Pete has no route back
into the game box's network and we are not opening one."* We honor that: the web
storefront stores orders in Pete's DB and **gogobee polls Pete** (gogobee stays
the only initiator, same tailnet + bearer pattern as ingest, just a GET).
## Design
### Contract lifecycle
```
placed ──announce──▶ open (help/"help" window, default 60 min)
│ │
│ ├──▶ delivered (next safe tick: target active, no combat,
│ │ not in briefing/recap quiet window)
│ │ ├── target survives → SURVIVED payout + unseal buyer
│ │ └── target defeated → DOWNED consequences
│ └──▶ fizzled (expedition ended first → refund minus rake)
└── rejected at claim time (funds/eligibility) → never announced
```
- New table `mischief_contracts`: id, buyer_id, target_id, tier, fee, status
(`pending|open|delivered|fizzled|rejected`), signed (bool), escalation_count,
blessing json, source (`matrix|web`), created_at, window_ends_at, resolved_at.
Status transitions via conditional UPDATE (CAS), mirroring `deliverAmbient`.
- One live contract per target at a time; delivery fires from a sibling of the
ambient ticker after `window_ends_at`.
### The four tiers — PRICED (M0 done 2026-07-13)
Monster strength is **relative to the target**: pull from the target's own
bracket **zone pools** (grunt/mob = non-elite, elite = `IsElite`, boss = the
zone boss). NOT the arena ladder — arena `BaseLethality` is a legacy death
chance; arena T5 one-shots L20s and would make every tier an execution.
**M0 survival sweep** (n=2000/cell, `SimulateCombat` via the class-balance
harness builds at full HP, zone-pool monsters, ambush nick on elite/boss;
throwaway harness left skip-gated at
`internal/plugin/mischief_pricing_sweep_test.go`, env `MISCHIEF_SWEEP=1`):
| target build | grunt | mob (3 chained) | elite | boss |
|---|---|---|---|---|
| paladin L1 | 99.8% | 98.2% | 57.6% | 15.3% |
| mage L4 | 100% | 97.8% | 49.5% | 0.0% |
| mage L8 evoc | 100% | 100% | 56.5% | 0.4% |
| fighter L12 champ | 100% | 100% | 100% | 99.9% |
| rogue L20 AT (prosolis) | 100% | 100% | 100% | 35.9% |
| cleric L20 life (holymachina) | 100% | 100% | 100% | 14.1% |
Reading: grunt/mob are pure theater (+HP/supply attrition); elite is a real
coin-flip for at-bracket targets and trivial for overleveled martials; boss is
expedition-ending for casters at any level and a genuine 1-in-3 scare even for
the L20 rogue. Caveats: harness builds ≠ real sheets, full-HP-at-delivery is
optimistic (mid-run wounds raise real danger), no pets/party/consumables.
**Prod economy snapshot** (2026-07-13, 23 wallets): median balance ≈ €500;
whales prosolis €180k / holymachina €149k / nonk €62k hold 89% of all money.
Daily earn: whales ~€1.82.3k, mid-tier €20500, casuals <€10. Reference
sinks: lottery ticket €100, duel escrow €1.55k, daily share ~€455, endgame
shop items €1145k. Only 5 characters exist as targets (two L20s, L4, two
L1s) — the whales are both the richest buyers *and* the only high-bracket
targets, so boss-tier is effectively their PvP toy; casuals buy theater.
| Tier | Fee | Signed (+25%) | Survival payout (of fee) | Extras on survival |
|---|---|---|---|---|
| grunt | €40 | €50 | 40% → €16 | flavor line |
| mob | €100 | €125 | 50% → €50 | treasure roll (standard) |
| elite | €350 | €438 | 65% → €228 | treasure roll (elite) + renown |
| boss | €1,200 | €1,500 | 75% → €900 | elite treasure + consumable cache + renown + Pete milestone |
Rationale: grunt under the lottery-ticket impulse line so anyone can play;
mob = exactly one lottery ticket; elite ≈ one active day of mid-tier earnings
(a considered purchase, and its ~50% at-bracket survival odds are honest
stakes); boss in duel-stake territory — whale money, priced like the "end an
expedition" button it is against caster targets. Escalation cost = the
tier-delta fee; blessings €25 flat.
Anti-collusion invariant: **total payout (fee + escalations) capped at 75%**
in every case, so collusion is strictly dominated by `!baltransfer`, which is
free. No danger multiplier needed — the cap does all the work.
Money flow: survival payout to target, remainder → community pot. Defeat →
entire fee to community pot (never back to the buyer — glory only). Fizzle →
90% refund, 10% rake to pot.
Boss-tier friction (014% caster survival means "boss ≈ certain maiming"):
max 1 boss contract per target per week, on top of the global limits below.
### Death policy (recommendation: mischief maims, it doesn't murder)
`runHarvestInterrupt`'s loss path perma-kills (`abandonZoneRun` +
`forcedExtractExpedition` + mark dead). For a *purchased* attack that lands
while the victim is offline, perma-death feels like paid murder, not mischief.
Recommended: on defeat, apply the world-boss HP floor (`worldBossFloorHP`
pattern), force-extract the expedition (run-loss seam already exists), drop a
chunk of un-banked expedition coins/supplies, +threat. Character lives.
**Open decision** — could make boss tier lethal for stakes, but then it needs an
opt-in (hardcore flag) or it's a griefing lever.
### Anonymity + the unseal twist
Default: contracts are anonymous — "someone in town has put coin on
<target>'s head." Buyer can pay +25% to *sign* it (taunt rights).
**If the target survives, the contract is unsealed** — Pete names the buyer in
the survival bulletin. Risk of exposure is the natural brake on casual griefing,
and it makes survival announcements delicious.
### The games-room window (help or "help")
On placement, announce in `GAMES_ROOM` (world-boss style) + Pete priority beat.
During the window (default 60 min):
- `!mischief bless @target` — pay a small fee (~€15) for temp HP / +AC on the
incoming fight (well-rested-style bundle). Stacks, capped (say 3 blessings).
- `!mischief escalate @target` — pay ~half the next-tier delta to bump the
contract one tier, max one step total, boss can't escalate. Escalation money
joins the payout escrow — piling on raises the target's survival jackpot.
- Victim gets a TwinBee DM (first person, per voice rules): word has reached
them that someone wants them dead. Pure flavor — expeditions are autonomous —
but it lets them rally blessings.
Blessing/escalation state lives on the contract row (real rows, no phantom
per-fight state — lesson from the companion free-lunch bugs).
### Delivery mechanics
New `runMischiefInterrupt`, modeled line-for-line on `runHarvestInterrupt`:
pick monster per tier table → apply blessings to the roster → surprise nick
(attacker's privilege) → `runZoneCombatRoster(fightRoster(target), …)` → custom
close-out. Do **not** call `recordZoneKill`/advance zone state — the fight is
extrinsic to the dungeon. Party seats fight together and earn seat XP as usual;
the survival purse goes to the target (leader).
Fizzle: if the expedition ends before delivery, refund fee minus 10% rake
(deadpan Pete line: the monster arrived to an empty dungeon).
### Rate limits & eligibility (anti-grief)
- Target must have an active expedition **and** be level ≥ 3 (or similar floor).
- One live contract per target; per-target cooldown 24 h after resolution.
- Per-buyer cap: 2 contracts/day. Can't target yourself. Escrow debited at
placement via `euro.Debit` (respects the debt floor for free).
- Boredom-ticker auto-expeditions: **targetable** (they're real runs and it's
funny), but revisit if it feels bad in practice.
- Opt-out: none for v1 — being in the world means being in the world — but keep
the decision explicitly revisitable. News anonymization (`!news optout`)
still applies to Pete-facing names as it does today.
### Pete web storefront (the reverse pipe)
**Decision (2026-07-13): mischief UI requires Authentik sign-in on Pete.**
Pete's OIDC layer already exists (`[web.auth]`, disabled by default) and the
callback already parses `preferred_username` from the ID token
(`auth.go:236-250`) — it just isn't persisted into the `pete_session` cookie.
**VERIFIED 2026-07-13** on parodia.dev (`matrix-mas-1`,
`/home/reala/matrix/compose/mas/config.yaml`): MAS imports localpart with
`action: require, template: '{{ user.preferred_username }}'` — so
**Authentik username == Matrix localpart**, guaranteed. Identity is free:
add PreferredUsername to `SessionUser`, gogobee maps it to
`@<username>:<server>` (lowercase it — Matrix localparts must be lowercase,
Authentik usernames may not be). No link codes.
The one-way *network* rule (`roster.go:23-25`) stays intact — both new data
flows are gogobee-initiated:
- **Balances out (push):** gogobee already pushes a roster snapshot every
2 min; add euro-balance entries for linked users on the same tick. Pete
renders "~€X" and greys out unaffordable tiers. Advisory only; up to 2 min
stale is fine.
- **Orders in (poll):** signed-in buyer POSTs the order form → `mischief_orders`
row in pete.db keyed by preferred_username, status `pending`. gogobee's
peteclient grows a poll loop: `GET /api/mischief/pending` (bearer) every
30 s, claims each order (`POST /api/mischief/claim`, idempotent on order
GUID), then validates in-game.
- **Money truth lives only in gogobee:** the authoritative check is
`euro.Debit` at claim time. A stale web balance just means an occasional
"order bounced — insufficient funds" status (rendered from a fact) + TwinBee
DM to the buyer. Pete never writes a balance, so no double-spend surface.
Order targets: roster board (already live on `/adventure`) grows a "send
trouble" button per entry (roster token identifies the target — already
stable + non-deanonymizing). Pete renders order status from resulting facts,
never from live game state.
Ops note: enabling `[web.auth]` on prod Pete needs the Authentik OAuth client
provisioned + config.toml edit on the box (config is box-only).
### New Pete event types (deploy Pete FIRST — unknown types 400 → park forever)
`mischief_contract` (priority: a hit is out, tier, anonymous-or-signed),
`mischief_survived` (priority: target thwarted it — unseal buyer here),
`mischief_downed` (priority), `mischief_fizzled` (bulletin),
`mischief_link` (internal, no render — or handle out-of-band). Deadpan voice
throughout; check `adventure_flavor_*.go` for reusable lines before writing new
flavor (standing rule).
## Phases
- **M0 — price the tiers. DONE 2026-07-13** (single-fight sweep + prod
economy snapshot; see tier table above). Follow-up for M1 close-out: re-run
the sweep through the *real* delivery path once `runMischiefInterrupt`
exists, since full-HP single fights understate mid-run danger.
- **M1 — core engine, Matrix-only. DONE 2026-07-13 (uncommitted, not deployed).**
`adventure_mischief.go` (tiers/pricing/persistence/eligibility/`!mischief`),
`adventure_mischief_deliver.go` (ticker + `runMischiefInterrupt` + close-outs),
`mischief_contracts` table, Pete's 4 `mischief_*` event types. 17 tests,
4 of them end-to-end through the real fight + euro + extraction.
Decisions taken during implementation (all inside the plan's intent):
- **Bracket monster-selection promoted out of the M0 test** into prod
(`mischiefBracketZone`/`mischiefMonsters`), so the sweep and the live
delivery now drive *the same* selection code — the fee table can't drift
away from the fight it priced.
- **Survival is read off the target's HP, not `PlayerWon`.** The engine's
timeout is a retreat, not a lethal blow: a target who ran out the clock
with HP left held the thing off, and a bought monster that merely outlasted
them hasn't earned a maiming. (M0 counted `!PlayerWon` as death, so real
survival rates are a touch *better* than priced. Pricing stands.)
- **No-perma-death extended to the whole party** (`floorMischiefRoster`, run
on BOTH outcomes). A leader can win a fight their friend went down in, and
the delivery skips `closeOutZoneWin` — without the floor, the member is left
alive at 0 HP, which every `HPCurrent <= 0` gate reads as broken.
- **One-live-contract-per-target is a partial UNIQUE INDEX**, not a read-then-
write check: placement holds only the *buyer's* lock, so two buyers racing at
one victim would both pass an in-code check. The loser is refunded.
- **Stale-delivery sweep** (`delivering` + 15 min grace → full refund). Without
it a crash mid-fight strands the row: target permanently un-targetable,
buyer's money gone.
- All contract timestamps bind as Go `time.Time` — never `CURRENT_TIMESTAMP`.
The driver stores RFC3339 (`2026-07-14T03:48:52Z`); a SQL-side stamp writes
`2026-07-14 03:48:52`, and the two compare lexicographically wrong.
- Fizzle DMs + rake, 90% refund; unstageable/stranded contracts refund 100%
(our fault, not a bet they lost).
**M1 close-out sweep DONE 2026-07-13** (`mischief_delivery_sweep_test.go`,
`MISCHIEF_SWEEP=1`, n=400/cell, 108 cells through the REAL delivery path:
`runMischiefInterrupt``runZoneCombatRoster`). Arms: entry HP 100/70/40%
(100% = control, reproduces M0 through the new path) × wards 0/3 on elite+boss.
| target | tier | 100% HP | 70% HP | 40% HP | 70% +3 wards |
|---|---|---|---|---|---|
| paladin L3 | elite | 84.8% | 73.2% | 62.7% | 80.5% |
| paladin L3 | boss | 87.8% | 45.8% | 8.0% | 88.0% |
| mage L4 | elite | 66.0% | 29.5% | 4.0% | 64.0% |
| mage L8 evoc | elite | 90.2% | 72.2% | 15.0% | 84.2% |
| mage L8 evoc | boss | 6.5% | 2.0% | 0.2% | 7.8% |
| fighter L12 | boss | 88.2% | 48.0% | 6.8% | 89.8% |
| rogue L20 | boss | 19.2% | 2.0% | 0.0% | 18.5% |
| cleric L20 | boss | 42.0% | 6.0% | 0.0% | 42.8% |
(grunt/mob: 100% for everyone except mage L4, who can lose a wounded mob.)
Three findings:
- **The M0 table was priced on a fight we don't deliver.** The control arm
diverges from M0 in BOTH directions: up where an engine timeout now counts as
survival (paladin boss 15→88%), and *down* where the turn engine loops a
boss's full multiattack profile and `SimulateCombat` doesn't (fighter boss
99.9→88%, rogue boss 36→19%) — see [[project_sim_multiattack_gap]]. Fees kept
(the tiers still do what they were priced to do); this table is now the
reference.
- **Wounding is the dominant variable, and M0 was blind to it.** Boss at a
realistic mid-run 70% HP collapses across the board (fighter 88→48, cleric
42→6, rogue 19→2); at 40% it is near-zero for everyone. Boss really is the
"end their expedition" button, as priced.
- **Wards were too cheap** (see M2 below): 3 of them buy ~+40pp.
- **M2 — the window. DONE 2026-07-13.** `!mischief bless @user` (€25, cap 3,
+10% MaxHP temp HP each) · `!mischief escalate @user` (pays the tier delta,
one step, boss is the ceiling) · victim DM on placement · escalator unsealed
alongside the buyer on survival. 6 new tests (22 total).
Decisions taken during implementation:
- **Blessing fee is a pure sink** (community pot), never a purse top-up. If a
ward raised the payout basis, warding a friend would become a money-routing
move; buying them HP can't be.
- **Ward price scales with the contract** — `max(€25, 10% of fee)`: grunt/mob
€25, elite €35, boss €120 (€360 to cover someone completely). The close-out
sweep measured 3 wards at ~+40pp (fighter vs boss at 70% HP: 48→90%), so a
flat €25 let the room halve a €1,200 boss contract for €75 — pocket change
against the tier the whole economy rests on. Reads the contract's *current*
basis, so an escalation raises the price of saving its target too. The €25
floor is knowingly above-market at grunt (3 wards > the €40 fee): nothing
needs warding against theatre, and the floor exists to keep a ward an impulse.
- **Escalation raises `fee` (the payout basis) AND `paid` together**, so the
75% cap and "purse < outlay" survive an escalation untouched — asserted.
- **Escalating into boss answers to the boss-per-target-per-week cap.**
Otherwise the cap is bought around for the price of an elite plus the delta.
- **Both window commands are CAS-then-refund**, like placement: the ward cap and
the one-step limit live in the UPDATE's WHERE clause, because a scramble is
exactly when four people press the button in the same second.
- **The delivery re-reads the contract AFTER claiming it.** The due-sweep's copy
is stale by construction — the window keeps writing to an open row up to the
claim, so a last-second escalation was paid for and then delivered the *old*
tier's monster. The claim is the fence. (Fixed + regression test.)
- **The ward is temp HP** (the well-rested mechanism), added to whatever cushion
the target already carried and subtracted again after the fight — a bought ward
must not eat a home long rest. It refreshes per link of the mob chain; that only
affects the one tier the sweep priced as theatre.
- Blessers are named on the spot (helping is proud); escalators are anonymous
until the survival unseals them, exactly like the buyer.
- Fizzled/unstageable contracts do **not** refund blessers. Their €25 was charity,
and it went to the pot. Revisit if it stings in practice.
- Fixed a pre-existing wall-clock flake in the M1 fizzle test: it drove
`fireMischiefDeliveries` with `time.Now()`, which refuses to run inside the
±1 h quiet windows around the 06:00 briefing and 21:00 recap.
- **M3 — Pete storefront (1 session, cross-repo).** Enable + extend OIDC
(persist preferred_username; provision Authentik client), orders table +
form + status rendering, balance push, gogobee poll/claim loop. First
visitor-facing write path on Pete — auth-gated + rate-limited per user.
(MAS localpart == Authentik preferred_username: verified 2026-07-13.)
- **M4 — polish.** Notoriety/renown for buyers, survival streaks → milestones,
digest lines, boredom-expedition policy review, tune fees from live data.
## Decisions — ALL CONFIRMED by reala 2026-07-13
1. **No perma-death anywhere** — mischief maims: HP floor + force-extract +
un-banked loot/supply loss + threat bump. Boss tier included.
2. **Anonymous by default; survival unseals the buyer** in Pete's bulletin;
+25% to sign openly.
3. **Fizzle refund 90%**, 10% rake to community pot.
4. **Targets: level ≥ 3 with an active expedition**; boredom auto-runs are
fair game. One live contract per target, 24 h post-resolution cooldown,
2 contracts/day per buyer, 1 boss/target/week.
5. **Fee/payout table as priced above** (M0 sweep + prod economy).
## Standing-rule checklist
- No new `dnd_`-prefixed files/tables → `adventure_mischief*.go`, `mischief_*` tables.
- TwinBee first-person; Pete deadpan third-person announcer.
- Plan doc stays working-tree only.
- Pete deploys before gogobee whenever event types change.
- Sim sweeps: control arm + n≥750; run heavy sweeps on Parodia/millenia.
- Any loader/bootstrap added must keep its backfill as a one-shot bootstrap.

267
gogobee_revisit_plan.md Normal file
View File

@@ -0,0 +1,267 @@
# Revisit / Backtrack Navigation — Design Plan
Lets players walk back to previously visited rooms within an active zone
run to re-harvest, re-fork (e.g. after acquiring a key), or pick up
something they skipped. Forward-only navigation has been the rule since
Phase G; this plan retires that assumption deliberately.
## Goals
- Re-harvest depleted nodes after a long rest (primary use case).
- Allow general exploration freedom — wander back, look around.
- Re-pick a fork after acquiring a key or changing strategy.
- Preserve the existing pacing pressure (threat clock, SU drain) but
make backtracking *cheaper* than fresh exploration to reflect that
the route is already known.
## Non-goals
- Teleporting to arbitrary discovered rooms.
- Backtracking during `!expedition run` autopilot (autopilot is
forward-only by design).
- Re-fighting cleared encounters or re-arming disarmed traps.
## Player-facing surface
- **`!revisit <N>`** — walks one edge from the current node toward a
previously-visited node. `N` is the 1-indexed room number printed in
`!map`'s Path strip.
- Must be in `VisitedNodes`.
- Must be adjacent to `CurrentNode` via an edge in the zone graph
(forward or reverse direction; graph edges are directed but we
allow reverse traversal for revisit).
- Costs threat and SU at a reduced rate (see Cost Model).
- No combat re-roll, no trap re-arm in cleared rooms.
- **`!map`** — already shows the numbered Path strip; players read N
from there.
- **`!zone advance`** post-revisit — unchanged. From any node, picks
the first outgoing edge (or shows the fork prompt). Re-forking a
previously-resolved fork node clears the prior choice silently.
## Cost model
> **CORRECTED 2026-07-09 during R1.** The table below originally claimed a
> fresh `!zone advance` costs +2 threat / 1.0 SU. It does not. Verified at
> HEAD: **movement itself is free.** There is no per-room tick anywhere.
> - **Supplies burn per *day*,** not per room — `applyDailyBurn` is called
> only from `dnd_expedition_cycle.go:108` (the day rollover),
> `dnd_expedition_extract.go:52`, and region transit. Never from advance.
> - **Threat comes from *combat*,** not from walking:
> `applyRoomCombatThreatForUser` (+5 normal, +8 elite/boss) fires from the
> three combat-resolution sites, plus daily drift, harvest noise (+2), and
> ambient. `zoneCmdAdvance` / `advanceOnce` touch neither clock.
>
> This inverts R2's central premise. A revisited room fires no combat
> (terminal `CombatSession` rows gate re-entry), so backtracking is already
> free — the design problem is not *discounting* a cost, it's that there is
> **no pacing pressure to discount**. Whatever R2 charges is a net-new cost.
| Move | Threat tick | SU tick |
|------|-------------|---------|
| Fresh-room `!zone advance` | **0** (actual) | **0** (actual) |
| Combat resolved in a room | +5, +8 elite/boss | 0 |
| Day rollover | drift (mood-scaled) | daily burn |
| `!revisit <N>` (one edge) | **TBD — see below** | n/a (no per-move SU exists) |
**DECIDED 2026-07-09 — option (1), +1 threat per revisit edge.** Shipped in
R2 as `revisitThreatCost`. Standalone (non-expedition) zone runs have no
threat clock and so pay nothing. A `revisitSiegeGuard` refuses the move at
threat ≥ 99: siege is one-way sticky, and a player must not be able to strand
their own expedition on a move they made to go pick up a herb.
Options as they were weighed, cheapest first:
1. **Charge +1 threat per revisit edge.** One `applyThreatDelta` call,
mirrors "harvest noise". Reads as "you stir up attention doubling back."
Cheap, honest, and it's the only clock revisit can plausibly touch.
2. **Charge nothing.** Backtracking is already free of combat; the real
cost is the day clock, which a long detour burns anyway. Simplest, and
arguably correct — the pacing pressure is the multi-day supply budget,
not the step count.
3. **Charge a fractional day.** Rejected: the day counter is the spine of
every milestone, digest and anchored event. Don't make it fractional.
Recommend **(1)** at +1: it's the smallest lever that makes a detour a real
choice, and it rides an existing, tested seam.
## State model
The hard problem: `CurrentRoom` is currently derived as
`len(VisitedNodes)-1` (dnd_zone_run.go:377), which assumes
monotonically-growing visit history. Backtracking breaks that.
### Schema changes
- **`VisitedNodes`** — unchanged semantics: ordered set of nodes ever
entered, first-entry order. Stays append-only when entering a *new*
node; revisits do not append.
- **`CurrentNode`** — already the source-of-truth for "where am I."
- **`CurrentRoom`** — repurpose from `len(VisitedNodes)-1` to "index of
CurrentNode within VisitedNodes" (the room's first-entry index, which
is the path-relative label the player sees).
- **`RoomsTraversed`** (NEW, int column on `dnd_zone_run`) — monotonic
step counter. Drives threat/SU ticks. Bootstrapped to
`len(VisitedNodes)` for existing rows.
### Audit pass required
Every read of `r.CurrentRoom` and `len(r.VisitedNodes)` needs to be
classified:
- **"Path index" reads (keep as CurrentRoom)**: display headers
("Room 3/7"), encounter ID derivation (`encounterIDForRoom`), enemy
salt seeds, harvest room keys, status/abandon strings, camp room
index.
- **"Progress counter" reads (switch to RoomsTraversed)**: threat
clock ticks, supplies ticks, ambient narration cadence, autopilot
preflight, fatigue/exhaustion accrual if any.
Concrete callsite list will be produced during implementation; estimate
~1015 callsites total.
## Fork re-pick
Re-entering a fork node clears its resolved choice in `node_choices`
(or equivalent). Next `!zone advance` shows the fork prompt fresh,
evaluated against current inventory (so a newly-acquired key unlocks
previously-locked edges).
Silent — no warning. Graph-back navigation means the player has
walked back through their previously-chosen path; nothing in
`VisitedNodes` is destroyed; the alternate branch they previously
took is still revisitable. There is no "discard" semantics.
## RoomsCleared semantics
Idempotent — exiting a room a second time doesn't re-append to
`RoomsCleared`. The "advanced past" flag is sticky.
## Preflight checks (rejections)
`!revisit` rejects with a clear DM when:
- Target room is not in `VisitedNodes` ("You haven't been there yet.")
- Target equals `CurrentNode` (no-op)
- Target is not adjacent to `CurrentNode` via any graph edge
("Room X isn't directly connected — backtrack one step at a time.")
- Active combat session in current room
("Finish the fight first.")
- Threat clock would tick past max (use existing camp-eligibility
preflight pattern)
- SU would drop below survival floor (same)
- Character is dead / expedition is paused (same gates as `!zone advance`)
- Autopilot run is active
## Rollout phases
Tentative ordering. Each phase ships independently.
### R1 — schema + audit ✅ **DONE 2026-07-09** (branch `n1-restoration`)
Shipped: `rooms_traversed` column + `bootstrapRoomsTraversed` backfill;
`CurrentRoom` re-derived via `pathIndexOf(VisitedNodes, CurrentNode)`;
`appendVisited` / `appendClearedRoom` made set-semantic; `narrationCadence`
routed off `RoomsTraversed`; `advanceZoneRunNode` now returns the moved-to
path index. Tests in `zone_revisit_r1_test.go`. Full suite green. No
player-visible behavior change.
**Audit outcome — the callsite split was smaller than estimated (~1015).**
Almost every `CurrentRoom` read is a *path index* and correctly stays put:
enemy/trap salts (`pickZoneEnemy`, `pickZoneTrap`, `rollTrapDamage`), loot
RNG seed, `encounterIDForRoom`, harvest room keys, camp `RoomIndex`, map
render, "Room X/Y" headers. Keying those on the node's first-entry index is
exactly what makes a revisited room resolve to *the same room*.
Only two reads were progress-shaped:
- `narrationCadence` → now `RoomsTraversed-1`, so a backtracking player
draws fresh flavor instead of replaying the lines they read on the way in.
- `nextIdx := run.CurrentRoom + 1` (`dnd_zone_cmd_graph.go`) — not a
progress read but a latent bug: "+1" only labels correctly while the
player is at the frontier. `advanceZoneRunNode` now returns the true index.
**Nothing to route off `RoomsTraversed` for threat/SU** — there are no
per-room ticks to route (see the corrected Cost model above). The counter
currently drives narration cadence and stands ready for R2's cost decision.
Two behaviors were hardened in passing, both no-ops under forward-only
navigation but load-bearing the moment R2 lands:
- `VisitedNodes` is an ordered **set** — a re-entered node is not
re-appended, so room numbers never renumber under the player.
- `RoomsCleared` is **idempotent** — exiting a room twice can't inflate it
past `TotalRooms` and skew the "N/M rooms" renders.
### R2 — `!revisit` command ✅ **DONE 2026-07-09** (branch `n1-restoration`)
Shipped: `!revisit <N>` (alias `!zone revisit`), `adjacentNodes` reverse-edge
traversal, full preflight, +1 threat, `!map` now prints a **Back to:** strip
of legal targets. Fork re-pick still deferred to R3 — revisit refuses while a
fork prompt is pending, because both `!zone advance` and `!zone go` resolve
`node_choices` without checking which node the player is standing on.
**"No combat/trap re-trigger logic needed" was wrong — and it was an exploit.**
Terminal `CombatSession` rows gate *only* Elite and Boss rooms, at the doorway
check in `advanceOnceWithOpts`. An Exploration room re-enters
`resolveCombatRoom` unconditionally and a Trap room re-arms. Left alone,
`!revisit` would have been an infinite farm: step back one room, `!zone
advance`, repeat for loot and XP. Fixed with a `RoomIsCleared(CurrentRoom)`
early-return at the top of `resolveRoom` — a no-op forward-only, since advance
clears a room only *after* resolving it. Guarded by
`TestRevisitedRoom_DoesNotReResolve` and `TestFreshRoom_StillResolves`.
**Autopilot needed a stopgap, sooner than R5 planned.** Autopilot has no
on/off switch — `tryAutoRun` is ticker-driven for every active expedition on a
2h CAS cooldown. Without intervention the background walker marches the player
straight back out of the room they just revisited, and the whole feature reads
as broken. `grantAutorunGrace` stamps `last_autorun_at` on revisit, buying one
full cooldown window. **R5 still owes the real policy decision** (refuse to
auto-walk from a non-frontier node, vs. walk back to the frontier first).
Tests in `zone_revisit_r2_test.go`. Full suite green.
### R3 — fork re-pick
- On revisit landing into a fork node, clear `node_choices` for that
node.
- Next advance re-prompts.
- Tests: lock-with-key acquired between two fork visits, alternate
branch selection, no-change re-pick.
### R4 — UX polish
- DM line on revisit explaining the cost ("You retrace your steps to
Room 3 (the fork). Threat +1, SU 0.5.").
- Surface `!revisit` in `!help` and `!zone` help blocks.
- Ambient/narration audit: any "you press deeper into the dungeon"
lines that no longer fit when player is backtracking.
### R5 — autopilot guard
- `!expedition run` refuses to start while at a non-frontier node?
Or auto-walks back to frontier first? Decide based on R1R4 feel.
Likely: refuse with a "use !zone advance to return to the frontier
first" hint.
## Open questions (lower priority)
- Should there be a small flavor-only narrative beat the first time
the player backtracks in an expedition? ("TwinBee notes you doubling
back — there's no shame in it. Sometimes the answer was in a room
you'd already passed.")
- Does a babysitter's safe-rest affect revisit cost? (Probably not —
babysitter is a camp upgrade, revisit is movement.)
- Multi-region expeditions — does revisit work across region
boundaries within the same expedition? (Initial answer: no, only
within the current zone run; cross-region travel is its own seam.)
## Effort estimate
Comparable to a small Phase pass (G6-sized). Roughly:
- R1: 1 session (schema bump + audit + tests).
- R2: 1 session.
- R3: 0.5 session.
- R4: 0.5 session.
- R5: 0.5 session.
Total: ~34 working sessions.

View File

@@ -457,6 +457,11 @@ func runMigrations(d *sql.DB) error {
// message arrives once; a poll loop whose ack is lost on the wire will
// retry, and without this the player pays twice. See euro.DebitIdem.
`ALTER TABLE euro_transactions ADD COLUMN external_id TEXT`,
// Pete games — the outbound queue carries more than adventure facts now.
// An escrow verdict goes to a different Pete endpoint, but it wants the
// same durability, backoff and parking, so it rides the same queue and the
// row says where it's going. Existing rows are all facts, hence the default.
`ALTER TABLE pete_emit_queue ADD COLUMN path TEXT NOT NULL DEFAULT '/api/ingest/adventure'`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {

View File

@@ -20,6 +20,7 @@ import (
"net/http"
"os"
"strings"
"sync"
"time"
"gogobee/internal/db"
@@ -62,12 +63,18 @@ type Config struct {
// so emit hooks scattered across plugins (and free functions like
// markAdventureDead) can call Emit without threading a handle through.
type Client struct {
cfg Config
http *http.Client
cfg Config
http *http.Client
draining sync.Mutex // one drain at a time; see drain
}
var std *Client
// factPath is where an adventure fact goes. Every queue row carries its own
// destination now, because escrow verdicts ride the same queue to a different
// endpoint.
const factPath = "/api/ingest/adventure"
// Tuning for the background sender.
const (
senderTick = 15 * time.Second
@@ -118,11 +125,20 @@ func Emit(f Fact) {
slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err)
return
}
// OR IGNORE gives GUID-idempotency: a re-emit of the same event is dropped.
enqueue(f.GUID, factPath, payload)
}
// enqueue puts one payload on the durable queue, addressed to a Pete endpoint.
//
// OR IGNORE gives GUID-idempotency: a re-emit of the same key is dropped. That
// is the whole safety story for money — an escrow verdict is queued under its
// escrow guid, so a verdict can never be enqueued twice and can never be
// delivered as two different answers.
func enqueue(guid, path string, payload []byte) {
db.Exec("pete emit enqueue",
`INSERT OR IGNORE INTO pete_emit_queue (guid, payload, created_at, attempts, next_attempt_at)
VALUES (?, ?, unixepoch(), 0, 0)`,
f.GUID, string(payload))
`INSERT OR IGNORE INTO pete_emit_queue (guid, path, payload, created_at, attempts, next_attempt_at)
VALUES (?, ?, ?, unixepoch(), 0, 0)`,
guid, path, string(payload))
}
// StartSender launches the background drain loop. It runs until ctx is
@@ -147,10 +163,33 @@ func StartSender(ctx context.Context) {
}()
}
// Flush drains the queue right now instead of waiting for the next tick.
//
// The escrow loop needs this. A player who clicked "buy chips" is watching a
// spinner, and a verdict that sat in the queue for a 15-second sender tick would
// make the whole border feel broken even though nothing is. Durability is not
// weakened: the row is written first and only then sent, exactly as the ticker
// does it.
func Flush(ctx context.Context) {
if std == nil || !std.cfg.Enabled {
return
}
std.drain(ctx)
}
// drain sends up to senderBatch due rows, one at a time.
//
// Serialized: the ticker and Flush can both call this, and two drains racing
// would send the same row twice. Every Pete endpoint we push to is idempotent,
// so that would be survivable rather than harmful — but it would also mean an
// escrow verdict arriving twice as a matter of routine, and "harmless in theory"
// is not how the money path should be run.
func (c *Client) drain(ctx context.Context) {
c.draining.Lock()
defer c.draining.Unlock()
rows, err := db.Get().Query(
`SELECT guid, payload FROM pete_emit_queue
`SELECT guid, path, payload FROM pete_emit_queue
WHERE sent_at IS NULL AND attempts < ? AND next_attempt_at <= unixepoch()
ORDER BY created_at LIMIT ?`,
maxAttempts, senderBatch)
@@ -158,11 +197,11 @@ func (c *Client) drain(ctx context.Context) {
slog.Error("peteclient: drain query", "err", err)
return
}
type item struct{ guid, payload string }
type item struct{ guid, path, payload string }
var batch []item
for rows.Next() {
var it item
if err := rows.Scan(&it.guid, &it.payload); err != nil {
if err := rows.Scan(&it.guid, &it.path, &it.payload); err != nil {
slog.Error("peteclient: drain scan", "err", err)
continue
}
@@ -174,7 +213,7 @@ func (c *Client) drain(ctx context.Context) {
if ctx.Err() != nil {
return
}
if err := c.send(ctx, []byte(it.payload)); err != nil {
if err := c.post(ctx, it.path, []byte(it.payload)); err != nil {
if ctx.Err() != nil {
// Shutdown canceled the in-flight send — Pete didn't reject
// anything. Don't burn a durable retry attempt; the row is picked
@@ -235,12 +274,8 @@ func PushRoster(ctx context.Context, snap RosterSnapshot) error {
return std.post(ctx, "/api/ingest/roster", payload)
}
// send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the
// post sends one payload to a Pete endpoint with bearer auth. Mirrors the
// bearer-POST pattern in email_nag.go:sendCode.
func (c *Client) send(ctx context.Context, payload []byte) error {
return c.post(ctx, "/api/ingest/adventure", payload)
}
func (c *Client) post(ctx context.Context, path string, payload []byte) error {
url := c.cfg.IngestURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
@@ -261,6 +296,139 @@ func (c *Client) post(ctx context.Context, path string, payload []byte) error {
return nil
}
// ---------------------------------------------------------------------------
// The euro/chip border
//
// Pete holds chips; we hold the euros. A player buying in or cashing out opens
// an escrow row on Pete, and we are the only one who can move the money for it —
// Pete has no route into this box's network and is not getting one. So we poll.
//
// This is the first GET gogobee has ever made to Pete. Everything else in this
// package is us pushing facts outward; here we are asking for work.
//
// The escrow guid is the idempotency key end to end: it names the row on Pete,
// it is the external_id on our euro transaction, and it is the queue key of the
// verdict we push back. That is what makes every step here safe to retry, which
// matters because every step here can be interrupted between moving real money
// and saying so.
// ---------------------------------------------------------------------------
// Escrow is one pending crossing, as Pete describes it. Amounts are whole euros:
// chips are 1:1 and there is no sub-unit to lose.
type Escrow struct {
GUID string `json:"guid"`
MatrixUser string `json:"matrix_user"`
Kind string `json:"kind"` // "buyin" | "cashout"
Amount int64 `json:"amount"`
State string `json:"state"`
}
// EscrowVerdict is our answer: did the euros move, and what is the balance now.
// A rejected buy-in carries the reason, which Pete shows the player.
type EscrowVerdict struct {
GUID string `json:"guid"`
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
BalanceAfter float64 `json:"balance_after"`
}
const escrowVerdictPath = "/api/games/escrow/settled"
// PendingEscrow asks Pete for crossings waiting on us. Includes rows we claimed
// but never answered — if we died holding one, the player's money is stranded
// until we pick it up again.
func PendingEscrow(ctx context.Context) ([]Escrow, error) {
if !Enabled() {
return nil, nil
}
var out []Escrow
if err := std.getJSON(ctx, "/api/games/escrow/pending", &out); err != nil {
return nil, err
}
return out, nil
}
// ClaimEscrow tells Pete we are taking a row, and returns the row as Pete now
// holds it. Move the money against *this*, not against the copy from the poll:
// the claim is the moment the amount and the player are fixed.
//
// A row Pete has already decided comes back in a terminal state rather than
// "claimed". That is not an error — it means the work is done, and it is exactly
// what stops a settled cash-out from being paid a second time.
func ClaimEscrow(ctx context.Context, guid string) (Escrow, error) {
var e Escrow
payload, err := json.Marshal(map[string]string{"guid": guid})
if err != nil {
return e, err
}
if err := std.postJSON(ctx, "/api/games/escrow/claim", payload, &e); err != nil {
return e, err
}
return e, nil
}
// EmitEscrowVerdict durably queues our answer and returns immediately. Keyed on
// the escrow guid, so a verdict is enqueued once and only once, and the sender's
// retry/backoff/parking machinery carries it the rest of the way.
//
// The caller should Flush after this: a player is watching a spinner.
func EmitEscrowVerdict(v EscrowVerdict) {
if !Enabled() {
return
}
payload, err := json.Marshal(v)
if err != nil {
slog.Error("peteclient: marshal escrow verdict", "guid", v.GUID, "err", err)
return
}
// Namespaced so an escrow guid can never collide with a fact guid in the
// queue's primary key. Fact guids are "<event_type>:<token>:<ts>"; escrow
// guids are random. A collision would be a lost verdict, so don't rely on
// luck for it.
enqueue("escrow:"+v.GUID, escrowVerdictPath, payload)
}
// getJSON does a bearer-authed GET and decodes the body.
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
return c.do(req, out)
}
// postJSON does a bearer-authed POST and decodes the body. Distinct from post,
// which is the fire-and-forget path the queue uses and ignores the response.
func (c *Client) postJSON(ctx context.Context, path string, payload []byte, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.IngestURL+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
req.Header.Set("Content-Type", "application/json")
return c.do(req, out)
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("pete %s status %d: %s", req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("pete %s: decode: %w", req.URL.Path, err)
}
return nil
}
// backoffSec computes the retry delay for a row. It re-reads the current attempt
// count so the delay grows geometrically without needing it passed in.
func backoffSec(guid string) int {

View File

@@ -0,0 +1,165 @@
package plugin
import (
"context"
"log/slog"
"time"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// The casino's money loop.
//
// Pete runs the games and holds the chips; we hold the euros and are the only
// one who can move them. A player who buys in or cashes out on games.parodia.dev
// opens an escrow row over there, and this loop is what turns it into a real
// balance change over here.
//
// It has to be a poll because Pete cannot call us — there is no inbound API on
// this box and there isn't going to be one. That constraint is what shaped the
// whole design: money crosses the border twice per *session*, not twice per
// hand, so a few seconds of poll latency lands on "buying chips…" and never on
// "deal me in".
//
// Every step is idempotent on the escrow guid, because every step can be
// interrupted in the worst possible place. If we die after DebitIdem and before
// the verdict is queued, Pete re-offers the row, we claim it again, DebitIdem
// replays as a no-op and reports the same answer, and the verdict goes out. The
// player is charged once. That is the only property here that really matters.
const (
// escrowPollInterval — a player is watching a spinner, so this is seconds,
// not the 30s the mischief seam gets away with.
escrowPollInterval = 3 * time.Second
// escrowPollTimeout — one full poll-claim-settle pass. Generous: the
// alternative to finishing is a claimed row nobody answers.
escrowPollTimeout = 30 * time.Second
reasonInsufficientFunds = "insufficient_funds"
)
// StartPeteEscrowLoop runs the border forever. Safe to call when the Pete seam
// is off — it simply never starts.
func StartPeteEscrowLoop(ctx context.Context, euro *EuroPlugin) {
if !peteclient.Enabled() || euro == nil {
return
}
slog.Info("pete games: escrow loop started", "interval", escrowPollInterval)
go func() {
t := time.NewTicker(escrowPollInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
pollEscrowOnce(ctx, euro)
}
}
}()
}
// escrowPollOK tracks the poll's health so we log transitions and not a line
// every three seconds. Pete being down is normal during its redeploys and is not
// worth shouting about; Pete being down for an hour is.
var escrowPollOK = true
func pollEscrowOnce(ctx context.Context, euro *EuroPlugin) {
ctx, cancel := context.WithTimeout(ctx, escrowPollTimeout)
defer cancel()
pending, err := peteclient.PendingEscrow(ctx)
if err != nil {
if escrowPollOK {
slog.Warn("pete games: escrow poll failed — buy-ins and cash-outs are stalled", "err", err)
escrowPollOK = false
}
return
}
if !escrowPollOK {
slog.Info("pete games: escrow poll recovered")
escrowPollOK = true
}
if len(pending) == 0 {
return
}
for _, e := range pending {
if ctx.Err() != nil {
return
}
settleEscrow(ctx, euro, e)
}
// The verdicts are on the durable queue now. Send them at once rather than
// waiting up to a sender tick — there is a person at the other end of this.
peteclient.Flush(ctx)
}
// settleEscrow moves the euros for one crossing and queues the answer.
func settleEscrow(ctx context.Context, euro *EuroPlugin, e peteclient.Escrow) {
// Claim first. The claim is what fixes the amount and the player: the poll's
// copy of the row is already a few milliseconds stale, and this is money.
claimed, err := peteclient.ClaimEscrow(ctx, e.GUID)
if err != nil {
slog.Warn("pete games: claim failed, will retry", "guid", e.GUID, "err", err)
return
}
// Pete has already decided this one — a stale re-offer that raced our own
// earlier verdict. Nothing to do, and nothing went wrong.
if claimed.State != "claimed" {
slog.Debug("pete games: escrow already decided", "guid", e.GUID, "state", claimed.State)
return
}
if claimed.Amount <= 0 {
slog.Error("pete games: escrow with a non-positive amount, refusing",
"guid", claimed.GUID, "amount", claimed.Amount)
return
}
user := id.UserID(claimed.MatrixUser)
amount := float64(claimed.Amount)
var ok bool
var balance float64
switch claimed.Kind {
case "buyin":
ok, balance, err = euro.DebitIdem(user, amount, "games_buyin", claimed.GUID)
case "cashout":
ok, balance, err = euro.CreditIdem(user, amount, "games_cashout", claimed.GUID)
default:
// Not a transient failure and not something a retry fixes. Say no, so the
// row reaches a terminal state on Pete instead of being re-offered forever.
slog.Error("pete games: unknown escrow kind", "guid", claimed.GUID, "kind", claimed.Kind)
peteclient.EmitEscrowVerdict(peteclient.EscrowVerdict{
GUID: claimed.GUID, OK: false, Reason: "unknown_kind",
})
return
}
if err != nil {
// The money did not move and we don't know that it didn't — so say
// nothing. Pete re-offers the row once the claim goes stale, and the guid
// makes the retry safe whichever way this actually landed.
slog.Error("pete games: euro move failed, leaving the row for a retry",
"guid", claimed.GUID, "kind", claimed.Kind, "err", err)
return
}
reason := ""
if !ok {
// The only way DebitIdem says no: the player cannot cover it. A cash-out
// has no floor to breach, so this is always a buy-in.
reason = reasonInsufficientFunds
}
peteclient.EmitEscrowVerdict(peteclient.EscrowVerdict{
GUID: claimed.GUID,
OK: ok,
Reason: reason,
BalanceAfter: balance,
})
slog.Info("pete games: escrow moved", "guid", claimed.GUID, "user", claimed.MatrixUser,
"kind", claimed.Kind, "amount", claimed.Amount, "ok", ok, "balance_after", balance)
}

View File

@@ -0,0 +1,222 @@
package plugin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// fakePete is Pete's half of the escrow wire: it offers rows, lets us claim
// them, and records the verdicts we push back. Enough to drive the loop end to
// end without either real box.
type fakePete struct {
mu sync.Mutex
pending []peteclient.Escrow
claimed map[string]bool
verdicts []peteclient.EscrowVerdict
srv *httptest.Server
}
func newFakePete(t *testing.T, token string, rows ...peteclient.Escrow) *fakePete {
t.Helper()
f := &fakePete{pending: rows, claimed: map[string]bool{}}
mux := http.NewServeMux()
auth := func(r *http.Request) bool { return r.Header.Get("Authorization") == "Bearer "+token }
mux.HandleFunc("GET /api/games/escrow/pending", func(w http.ResponseWriter, r *http.Request) {
if !auth(r) {
w.WriteHeader(401)
return
}
f.mu.Lock()
defer f.mu.Unlock()
_ = json.NewEncoder(w).Encode(f.pending)
})
mux.HandleFunc("POST /api/games/escrow/claim", func(w http.ResponseWriter, r *http.Request) {
if !auth(r) {
w.WriteHeader(401)
return
}
var req struct{ GUID string }
_ = json.NewDecoder(r.Body).Decode(&req)
f.mu.Lock()
defer f.mu.Unlock()
for _, e := range f.pending {
if e.GUID != req.GUID {
continue
}
f.claimed[e.GUID] = true
e.State = "claimed"
_ = json.NewEncoder(w).Encode(e)
return
}
w.WriteHeader(404)
})
mux.HandleFunc("POST /api/games/escrow/settled", func(w http.ResponseWriter, r *http.Request) {
if !auth(r) {
w.WriteHeader(401)
return
}
var v peteclient.EscrowVerdict
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
w.WriteHeader(400)
return
}
f.mu.Lock()
defer f.mu.Unlock()
f.verdicts = append(f.verdicts, v)
w.WriteHeader(200)
})
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
}
// wire points the peteclient singleton at the fake and turns the seam on.
func (f *fakePete) wire(t *testing.T, token string) {
t.Helper()
t.Setenv("PETE_INGEST_URL", f.srv.URL)
t.Setenv("PETE_INGEST_TOKEN", token)
t.Setenv("FEATURE_PETE_NEWS", "true")
peteclient.Init()
}
func (f *fakePete) got() []peteclient.EscrowVerdict {
f.mu.Lock()
defer f.mu.Unlock()
return append([]peteclient.EscrowVerdict(nil), f.verdicts...)
}
// TestEscrowLoopBuyInDebitsOnceAndReportsIt is the happy path across the border:
// Pete offers a buy-in, we take the euros, and the verdict lands back on Pete
// carrying the balance the player now has.
func TestEscrowLoopBuyInDebitsOnceAndReportsIt(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@reala:parodia.dev")
seedBalance(t, euro, user, 1000)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-1", MatrixUser: string(user), Kind: "buyin", Amount: 400, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 600 {
t.Fatalf("balance = %v, want 600 (1000 less a 400 buy-in)", got)
}
v := pete.got()
if len(v) != 1 || !v[0].OK || v[0].GUID != "esc-1" {
t.Fatalf("verdicts = %+v, want one ok verdict for esc-1", v)
}
if v[0].BalanceAfter != 600 {
t.Fatalf("balance_after = %v, want 600", v[0].BalanceAfter)
}
}
// TestEscrowLoopReofferedRowDebitsOnce is the failure this whole design is built
// around. We move the euros, then die before the verdict is delivered. Pete
// re-offers the row. The player must not pay twice.
func TestEscrowLoopReofferedRowDebitsOnce(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@twice:parodia.dev")
seedBalance(t, euro, user, 1000)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-dup", MatrixUser: string(user), Kind: "buyin", Amount: 250, State: "requested",
})
pete.wire(t, "tok")
// Three passes over a row Pete keeps offering, as it would after a crash.
for i := 0; i < 3; i++ {
pollEscrowOnce(context.Background(), euro)
}
if got := euro.GetBalance(user); got != 750 {
t.Fatalf("balance = %v, want 750 — the re-offered row debited more than once", got)
}
// The verdict is queued under the escrow guid, so it is enqueued once no
// matter how many times we decide it. Pete's settle is idempotent anyway,
// but the queue is the first line of that defence.
if v := pete.got(); len(v) != 1 {
t.Fatalf("delivered %d verdicts for one row, want 1: %+v", len(v), v)
}
}
// TestEscrowLoopBrokePlayerIsRejectedCleanly — the debit is refused. Nothing may
// move, and Pete has to be told why, because the player is staring at a spinner
// that needs to become a sentence.
func TestEscrowLoopBrokePlayerIsRejectedCleanly(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@broke:parodia.dev")
seedBalance(t, euro, user, 10)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-broke", MatrixUser: string(user), Kind: "buyin", Amount: 5000, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 10 {
t.Fatalf("balance = %v, want 10 untouched", got)
}
v := pete.got()
if len(v) != 1 || v[0].OK || v[0].Reason != reasonInsufficientFunds {
t.Fatalf("verdicts = %+v, want one rejection carrying insufficient_funds", v)
}
}
// TestEscrowLoopCashOutCredits — the way out of the casino.
func TestEscrowLoopCashOutCredits(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@winner:parodia.dev")
seedBalance(t, euro, user, 100)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-out", MatrixUser: string(user), Kind: "cashout", Amount: 900, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 1000 {
t.Fatalf("balance = %v, want 1000", got)
}
if v := pete.got(); len(v) != 1 || !v[0].OK || v[0].BalanceAfter != 1000 {
t.Fatalf("verdicts = %+v, want one ok cash-out at 1000", v)
}
}
// TestEscrowLoopUnknownKindIsRefusedNotRetried — a row we can't interpret. A
// silent skip would leave it re-offered forever, so say no and let it reach a
// terminal state on Pete.
func TestEscrowLoopUnknownKindIsRefusedNotRetried(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@weird:parodia.dev")
seedBalance(t, euro, user, 500)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-weird", MatrixUser: string(user), Kind: "tribute", Amount: 100, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 500 {
t.Fatalf("balance = %v, want 500 untouched", got)
}
v := pete.got()
if len(v) != 1 || v[0].OK || v[0].Reason != "unknown_kind" {
t.Fatalf("verdicts = %+v, want one refusal", v)
}
}

View File

@@ -169,6 +169,9 @@ func main() {
// Games & Economy
euroPlugin := plugin.NewEuroPlugin(client)
registry.Register(euroPlugin)
// The web casino's money loop. Not a plugin: it answers to Pete's poll, not
// to a Matrix message, so it has no commands and nothing to register.
plugin.StartPeteEscrowLoop(ctx, euroPlugin)
registry.Register(plugin.NewFlipPlugin(client))
registry.Register(plugin.NewHangmanPlugin(client, euroPlugin, dictClient))
registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin))

225
pete_adventure_news_plan.md Normal file
View File

@@ -0,0 +1,225 @@
# Pete / Gogobee Adventure News Integration — quick plan
> High-level only. No assumptions about current code shape; codebase has moved
> since Pete was last touched. This is a scoping doc, not an implementation plan.
## What this is
A news.parodia.dev section where Pete reports on Adventure module activity:
deaths, boss defeats, new players, notable events, and periodic generated
recaps. Distinct from the in-bot commands (`!town`, `!graveyard`, `!rivals
board`) that already surface this data on request — this makes the same kind
of information ambient and discoverable instead of command-gated.
## Relationship to existing plans
- **Converges with "Phase 16 Pete" (the deferred Pete bot) — doesn't collide.**
The news feed is the near-term, separable slice and ships first. But the
Phase 16 bot turns out to be the thing that makes the feed *first-person*: an
adventuring Pete is an embedded correspondent who files dispatches from
dungeons he's actually in. Feed first, bot later, same character. See "Pete as
a character" below.
- **Overlaps with Track E (E3 — surfacing the buried social data).** E3 builds
`!town`, `!graveyard`, `!rivals board` as pull-based bot commands. Pete's
news feed is the push-based counterpart to the same underlying data. Ideally
they share one source of truth rather than each growing its own read path.
- **Depends on C3 (World boss / "the Siege") shipping.** That's sequenced in
N6, well after N4's town work. Pete's biggest story beat — the town under
attack — doesn't exist as an event to report on until C3 lands. Fine to plan
for now, but the event-feed and stats sections can go live well before this
piece is reachable.
## Content types
1. **Event feed (low effort, high value)**
- Deaths (source, location) — same data `!graveyard` already surfaces.
- Boss defeats — first-clears, notable kills.
- New player arrivals.
- Zone first-clears / notable milestones.
- **World boss / "the Siege" (C3).** This is the marquee story beat: a
named boss camps outside town for 72h with a shared community HP pool.
Pete should report both ends of it — the announcement/start of a Siege,
and the resolution (boss falls and payout goes out, or it survives and
"loots the town," a visible pot tribute). The town-attack framing already
baked into C3's design (it's explicitly a communal threat, not just a
tougher solo fight) makes it the best fit for the generated-article
treatment, not just a log line.
- This is closest to a straightforward log-to-article pass. Good starting
point since it needs the least judgment about what's "interesting."
2. **Generated articles (higher effort, more judgment required)**
- Narrative recaps of notable happenings — a boss kill or first-clear
written up as a short story beat rather than a log line, in the same
spirit as the flavor-voice work already done elsewhere in the bot
ecosystem.
- LLM-authored via Gogobee's existing inference access, following the same
pattern Petal already established: model as a config value behind an
abstraction, not hardcoded. Pete's news writing shouldn't need its own
bespoke integration if that seam already exists.
- Event data (who, what, where, stakes) stays structured and feeds the
model as context; the model's job is prose, not fact invention. Worth
deciding early whether there's a review/approval step before publish, or
whether it goes straight to the site — the risk profile is different
between "reads a little dry" and "confidently wrong about who died."
- Open question: live per-event, or batched into a daily/weekly digest.
Batching gives editorial control over what's worth writing up instead of
reporting every routine kill, and is also cheaper on inference calls than
firing one generation per event — probably the safer default to start.
3. **Player stats**
- Needs the same leak-check the engagement plan already flags for E3:
nothing that ranks or badges donation counts (Misty/Arina), since that
could let players reverse-engineer secret NPC buffs. Tax-ledger totals
were called out as safe; donation counts were not. Any stat Pete
considers for public display should be run through that same filter
before it's greenlit, not decided ad hoc per-stat.
- Safer starting stats: playtime/level distributions, zone clear counts,
arena standings, rival board — anything already deemed room-safe under
E3's design.
## Sequencing suggestion
1. Event feed first (deaths, boss defeats, new players) — mechanical, low
judgment, reuses E3's data model.
2. Stats section next, gated through the leak-check before anything ships.
3. Generated articles last — highest creative/quality bar, and benefits from
having the event feed already running as its raw material.
## Resolved decisions
- **Public-facing — yes.** The wall isn't what protects players; the leak-check
and name-scoping are, and those apply either way. So publish publicly, but
only the public-safe subset, decided on purpose:
- **Safe to publish:** boss first-clears, Siege announcements/resolutions,
zone clear counts, arena standings, aggregate level/playtime distributions,
the rival board.
- **Community-only or omit:** anything donation-derived (Misty/Arina secret
buffs), anything that names a Matrix account, and raw per-player time series
fine-grained enough to diff and reverse-engineer a hidden buff. Public
raises the adversary from "a curious member" to "anyone with a scraper," so
the leak-check is load-bearing, not advisory.
- **Name-scoping:** report on **character names only**, never the Matrix
handle behind them. Consider a per-player opt-out of being named in
articles. Decide before launch — retroactively scrubbing an indexed/cached
public page is the URL-preview problem again.
## Pete as a character (forward tier — after the feed + section land)
The most ambitious slice, and the payoff for the whole arc: Pete stops being a
wire service that reports on others and becomes a **playable character in the
world** — the realm's embedded correspondent. Converges with the deferred
Phase 16 Pete bot. Two surfaces, both feeding the news:
1. **Solo runs.** Pete runs his own expeditions on autopilot (the existing
`runAutopilotWalk` path the sim already drives) and files first-person
dispatches: *"Your correspondent nearly didn't make it out of the
Underforge."* His deaths/level-ups/kills emit the same fact payload every
player does, so the news writes itself — and the fact-guard is trivially safe
when the subject is Pete himself.
2. **Hireable companion (class-fluid).** Players short on help can hire Pete
into their party. Unlike a player character he is **role-fluid** — he fills
the gap: no healer → Cleric, no damage → Mage, need a body up front →
Fighter. Default behavior is auto-fill the missing role, with a manual
override. When hired, that expedition becomes a dispatch too: *"Filled in as
cleric for [party] today — nobody died on my watch."*
3. **Standing rival (the house challenger).** Players can challenge Pete to a
duel any time via the existing C2 staked/no-death system — so there's always
an opponent even when the game is quiet (same trailing-case lift as hiring,
for the PvP side). He **responds to win and loss himself**, first person and
gracious either way — his losses are the better content. First time anyone
beats him is an alert. Rival results feed the news via `adventure_rival_records`.
### Identity — Pete owns Pete (corrected after reading the code)
`@pete:parodia.dev` is already an **independent bot** (pete repo) with its own
account; gogobee ignores it via `IGNORED_BOTS`. So Pete is NOT a gogobee
appservice ghost — his Matrix presence is his own bot, and **Pete owns his own
voice** (persona, templates, editorial). See `project_pete_bot_architecture`.
- **Pete's surfaces (all his bot):** the news website, his Matrix posts, and —
for the character tier — duel reactions / hire banter / replies. He's already
an in-room command bot (`!post`, `!petestats`), so he has the interactive
skeleton; the character behaviors extend it.
- **gogobee's job:** emit game-event *facts* to Pete, and provide LLM *compute*
via a generic endpoint. It does NOT voice Pete. The `📣 Pete:` broadcast lines
currently hardcoded in gogobee's flavor file are the old two-Petes situation
and should migrate to facts Pete voices.
- **LLM:** Pete stays LLM-free in his own codebase (deliberate — it was
removed). When he wants prose he calls gogobee's generic tailnet inference
endpoint. Default is template-only; LLM is opt-in per marquee beat.
Why this fits the existing guardrails:
- **It's the sanctioned difficulty lever.** A short-handed / missing-a-role
party is the trailing case; a hireable Pete lifts exactly that party without
nerfing leaders or touching monster scaling. Tune him as a competent
*below-median* member of whatever class he plays — help, never a carry.
- **Balance corpus stays clean.** Golden pins run solo, so baseline sims never
hire Pete. Hiring is a live player-help feature entirely outside the baseline;
the two paths never cross.
- **Gold sink.** Hiring costs gold per expedition — economy pressure of the same
kind the tax-ledger / lottery tuning already wants. Scarcity is a knob:
Pete "on assignment" (off covering a Siege) is both a reason he's sometimes
unavailable and a story in itself.
- **Duel economy caution.** As a standing rival he's *beatable* by design — so
his duel stakes must be bragging-rights / bounded, NOT real gold, or players
farm the house challenger for income. Tuning-pass number; flagged so it isn't
discovered as an exploit.
- **Guardrails.** Pete keeps clear of the Misty/Arina secret-buff mechanics (a
public reporter benefiting from hidden buffs and reporting outcomes is a leak
vector). His voice is his own — NOT TwinBee's.
Open tuning-pass questions (not blockers): hire cost (flat vs level-scaled);
his power scaling (fixed level vs scales-to-party); availability (always vs
cooldown / on-assignment scarcity).
## Gaps & risks register
The design's blind spot was treating the gogobee→Pete channel as internal and
trusted — it's neither once player-named characters and LLM prose flow onto a
public front page. Seven gaps, three of them must-solve-before-launch.
1. **[MUST — security] Untrusted input into LLM prompt + public HTML.** A
character name is player-controlled. It reaches (a) the LLM prompt →
prompt-injection, and (b) rendered `content` → XSS. Fix: names get escaped
before the prompt; Adventure `content` goes through Pete's **existing** RSS
sanitizer (it already strips inline scripts), never around it.
2. **[MUST] `article_url` has nowhere to point.** Schema is `NOT NULL`; a
self-hosted event has no external article. Point it at a Pete permalink
(`/adventure/<guid>` / the existing per-story reader page).
3. **[MUST] Visual identity.** No OG images → the section looks broken next to
RSS cards. Ship per-event-type art (Siege banner, graveyard mark, Pete's
avatar for his own dispatches).
4. **[RESOLVED] Selection/aggregation thresholds.** Data-derived from the prod
DB — see the thresholds section in the voice spec. Dormant community → the
risk is emptiness, not spam; near-inclusive, with grind telemetry filtered
out and achievements rarity-gated.
5. **[RESOLVED — security] Opt-out.** Default-featured (informed, announced at
launch), one gogobee command backed by a `news_optout` table (mirrors the
existing `shade_optout`). Enforced at emit time. Opt-out **anonymizes, not
deletes** ("an adventurer…"). Retroactive: `POST /ingest/adventure/scrub`
anonymizes existing rows; whole section is `noindex` to bound the
cached-forever problem.
6. **[RESOLVED — security] Kill-switch.** Global `adventure_news_enabled` flag
in gogobee kills emission at the source (generation is gogobee-side, so this
is the real switch). Per-story **admin delete on Pete**, reusing the OIDC
`adminSubs` gate that already guards `/status`.
7. **[RESOLVED] Cold-start backfill.** Guarded one-shot in gogobee replays the
back-catalog (realm-firsts, deaths, notable clears, single-holder
achievements) through the emit path. Backdated `published_at`, dispatch tone,
**no web-push**. Idempotent on `guid`; kept as a marked one-shot (not deleted
at close-out) so fresh deploys don't re-ghost-town. At this volume, backfill
is most of the launch content, not a nice-to-have.
## Open questions to settle before scoping further
- Does Pete write live off events, or on a batch cadence?
- Single source of truth for this data shared with E3's bot commands, or a
separate read path for the news site?
- Voice: does Pete have an established voice yet, or does this news section
define it?
- Which model tier for article generation — same one Petal's conversational
side uses, or a separate config entry, given the different latency/quality
tradeoffs of a batch job versus interactive chat?

View File

@@ -0,0 +1,224 @@
# Pete × Parodia — Adventure section voice + architecture spec
> Companion to `pete_adventure_news_plan.md`. Pete is an independent LLM-free bot
> that OWNS his voice; gogobee is the game-event source + generic LLM compute.
> Voice: a warm, friendly reporter (owner's call). See
> `project_pete_bot_architecture` memory.
## Architecture (the load-bearing part)
- **Pete owns voice, authoring, identity, publishing.** Persona, templates,
editorial, the `@pete:parodia.dev` account. Pete decides what is said and says
it. Pete's codebase has **zero** Ollama/vLLM connectivity, by design.
- **gogobee owns game events + raw LLM compute.** Source of truth for what
happened; the only codebase that touches the models.
- **gogobee → Pete: structured event *facts*** (the payload below), NOT finished
prose. gogobee is compute, not ghostwriter.
- **Pete → gogobee: a generic, voice-agnostic inference endpoint.** Prompt in,
completion out — gogobee never sees "Pete." Pete builds the voiced prompt in
his own codebase and calls it. Reusable for Pete's other channels later.
- **Network:** both directions ride the Headscale tailnet (Parodia ↔ LAN box).
gogobee's inference endpoint is tailnet-bound + bearer-authed; Ollama/vLLM
stays `localhost` on the LAN box, never on the tailnet.
## Voice — Pete the friendly reporter
Pete is a **reporter, and a warm one** — think a beloved local newscaster who
genuinely knows everyone in town and is glad to see you. Journalistic bones
(clear headline, a who/what/where/when lede, gets the facts right) delivered
with a personable, first-person warmth. He roots for the community, celebrates
wins, mourns losses gently, and welcomes newcomers by name. Conversational,
never snarky. Friendly, not a caps-lock hype-man.
- Celebratory win: *"What a turnout — the town held the line tonight."*
- Gentle loss: *"Sad news to pass along today."*
- Warm welcome: *"Say hello if you see them out there."*
- His own sign-off: a short reporter's tag closes bigger pieces — *"Reporting
from {region}, this is Pete."*
Tiering is by **prefix + placement**, not by shouting:
- **PRIORITY** (rare, live): Sieges, world-firsts, deaths, a new arrival, the
first time Pete loses a duel. Posted immediately.
- **BULLETIN** (batched daily digest): everything else worth noting.
Warmth carries the register, not exclamation marks. A Siege is urgent but Pete
is steady and rallying, not panicked: *"Folks, this is the big one — and the
whole community's needed. Let's rally."*
## Hard rules (govern templates AND any LLM prose)
1. **Character names only.** Never the Matrix handle behind a character.
2. **No donation-derived anything** (Misty/Arina secret buffs).
3. **Facts are supplied, never invented.** Names/numbers/outcomes come only from
the payload `actors[]` allow-list; the fact-guard rejects anything else.
4. **Public front-page surface.** Safe-subset rule from the plan applies.
## Fact payload (gogobee → Pete)
Flat, pre-sanitized. `actors[]` is the ONLY set of names allowed in output.
Character names are player-controlled → sanitize before they enter a prompt or
rendered HTML (see plan gap #1).
```
event_type siege_start | siege_win | siege_loss | boss_first | boss_kill |
zone_first | zone_clear | death | retreat | arrival | standings |
rival_result | pete_duel_loss | pete_duel_win | milestone
// GUID prefix == event_type (permalink derives its label from it)
tier priority | bulletin
actors[] ["Brannigan", ...] // allow-list for output
subject "Brannigan"
opponent "Kif" // duels / rivalries
boss "The Hollow King"
zone "Dragon's Lair"
region "the Underforge"
level 14
count 37 // defenders, clears, etc.
outcome won | lost | ...
stakes "72h" / figure
occurred_at unix
```
## Templates (Pete the friendly reporter)
Format: `TIER · cadence`. `content` (article body) is optional LLM prose via the
gogobee endpoint; headline/lede are always template — deterministic and safe.
### siege_start — PRIORITY · live
- **headline:** `Breaking: {boss} is marching on the town.`
- **lede:** `Folks, this is the big one — {boss} has camped outside the gates and the whole community's needed to turn it back. You've got {stakes}. Let's rally.`
### siege_win — PRIORITY · live
- **headline:** `The town holds! {boss} turned back.`
- **lede:** `What a turnout — {count} defenders stood shoulder to shoulder and sent {boss} packing. Spoils are going out now. Proud of you all.`
### siege_loss — PRIORITY · live
- **headline:** `Heavy news: {boss} broke through.`
- **lede:** `We gave it everything, but {boss} got past the gates and took its tribute. We'll be ready next time — heads up, everyone.`
### boss_first — PRIORITY · live
- **headline:** `First ever: {subject} brings down {boss}.`
- **lede:** `History in {region} today — {subject} is the first anyone's seen clear {boss}. Nobody had done it before. Hats off.`
### zone_first — PRIORITY (realm-first) · live
- **headline:** `{zone} cleared for the very first time.`
- **lede:** `{subject} made it through {zone} in {region}{, at level {level}}. Nicely done.`
### zone_clear — BULLETIN (repeat) · batch
- **headline:** `{subject} clears {zone}.`
- **lede:** same as zone_first (shared).
- gogobee emits `zone_first` (priority) only for the realm's first clear of a
zone and `zone_clear` (bulletin) for every later clear, so a repeat is never
filed under "first ever" — headline, permalink label, and emblem all match.
### boss_kill — BULLETIN · batch
- **headline:** `{subject} takes down {boss} again.`
- **lede:** `Another clean run in {zone} today. Routine for {subject} by now — but still worth a nod.`
### death — PRIORITY · live
- **headline:** `We lost {subject} in {zone}.`
- **lede:** `Sad news to pass along: {subject} fell at level {level} in {zone}. The graveyard's a little fuller tonight. Rest easy.`
### arrival — BULLETIN · batch
- **headline:** `Welcome to the realm, {subject}!`
- **lede:** `A new {class_race} just walked through the gates. Say hello if you see them out there.`
### standings — BULLETIN · batch
- **headline:** `The rival board's been shaken up.`
- **lede:** `{subject} is on the move — here's where the standings sit today.`
### rival_result — BULLETIN · batch (Pete uninvolved)
- **headline:** `{subject} settles the score with {opponent}.`
- **lede:** `Their duel went {subject}'s way today, and the board reflects it. Good match, you two.`
### pete_duel_loss — PRIORITY (first-ever) / BULLETIN · live · FIRST PERSON
- **headline (first-ever):** `You got me, {subject}.`
- **headline (repeat):** `{subject} got the better of me again.`
- **lede:** `Credit where it's due — {subject} beat me fair and square. Good duel. I'll want a rematch when you're ready.`
### pete_duel_win — BULLETIN · batch · FIRST PERSON
- **headline:** `Held my ground against {subject} today.`
- **lede:** `Closer than the record will show, honestly — {subject} pushed me. Rematch whenever you like.`
### retreat — BULLETIN · batch
- **headline:** `{subject} backed out of {zone}.`
- **lede:** `{subject} turned around {N days in} — {zone} got the better of them
this time, at level {level}, 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.`
- An expedition that ended with the player ALIVE (a solo clock-out retreat, or a
party the turn engine broke off). `count` = the day they reached.
- Emitted from gogobee's one run-loss chokepoint, gated on the reason: a **death**
files its own priority dispatch and must never ALSO appear as a retreat, and an
**idle reap** is never filed at all — a player who closed their laptop did not
flee anything, and saying so by name would be a lie about a person.
- Warm, not a failure notice. Lead with everyone coming home.
### milestone — BULLETIN · batch
- **headline:** `{subject} hits {milestone}.`
- **lede:** `One for the books — {subject} just reached {milestone}. The long road continues.`
## Optional LLM prose (Pete authors, gogobee computes)
For the PRIORITY beats only, Pete MAY expand `content` into a short friendly
write-up in his reporter voice. Pete builds the prompt (his persona + the fact
payload) in Pete's
codebase and calls gogobee's generic inference endpoint; gogobee returns raw
text; Pete posts it. gogobee never authors — it computes.
Given you removed LLM from Pete deliberately, template-only is the safe default
and the data supports it (see thresholds — tiny volume). LLM prose is opt-in per
beat, not the baseline.
### Fallback ladder (never blocks publish)
Headline + lede are always template-derived, so Pete publishes regardless.
`content` LLM expansion is best-effort:
1. Endpoint disabled in Pete config → template body.
2. Endpoint unreachable / timeout → template body.
3. Empty or malformed completion → template body.
4. Fact-guard fails (a name not in `actors[]`, or a number that contradicts the
payload) → template body. Guard runs in Pete, on the completion.
## Selection thresholds (data-derived, prod DB July 2026)
Measured against live prod (`millenia`, `data/gogobee.db`). Community is
**dormant** — 4 players ever (L2049). Risk is *emptiness*, not spam;
near-inclusive.
Observed (AprJul 2026): deaths ~3 total; boss kills 23 / 62 days; milestones
43 / 60 days; achievements 181 (only flood-prone type); arena runs 82 / rivals
6; Sieges 0 ever.
**Negative filter (matters most):** high-volume tables are grind telemetry, NOT
news — `adventure_activity_log` successes (mining/fishing/foraging) and
expedition-log `interrupt|walk|harvest|ambient|rest|night`. Never publish. Only
`milestone`/`recap` entry types, deaths, and boss/zone firsts surface.
**Rules:**
- **PRIORITY (live, individual):** realm-first zone clear, realm-first boss kill,
any Siege start/resolve, new-player arrival, any death, first-ever Pete duel loss.
- **BULLETIN (daily digest):** repeat boss kills, repeat zone clears,
standings/rival shifts, Pete duel wins.
- **Rarity-gated:** an achievement publishes only if held by a *single* player.
- **Never published:** harvest/mining/fishing outcomes, expedition
interrupt/walk/ambient/rest noise.
- **Aggregation:** collapse to a count line only if the same `(event_type,
target)` recurs **≥3×** in a digest window (historically never triggers).
- **Cadence:** bulletin = daily digest; priority fires live.
## Delivery seam
### gogobee → Pete: event facts
- Transport: gogobee POSTs the fact payload to Pete over the tailnet
(or Pete's HTTPS ingest), bearer-authed.
- Idempotent on a stable event key (`siege_win:<ts>` etc.) so retries are no-ops.
- gogobee owns delivery reliability: queue + retry so a Pete restart loses
nothing.
- Adventure output is Pete-on-Matrix + the website. gogobee must NOT also
announce these itself — the `📣 Pete:` broadcast lines currently hardcoded in
gogobee's flavor file should migrate to facts Pete voices (see plan).
### Pete → gogobee: generic inference
- Pete POSTs `{prompt, params}` to gogobee's tailnet inference endpoint,
bearer-authed. Completion returned as raw text. gogobee proxies to
`localhost` Ollama/vLLM.
- Voice-agnostic: gogobee has no Adventure/Pete-specific logic on this path.