diff --git a/gogobee_adventure_announcement.md b/gogobee_adventure_announcement.md new file mode 100644 index 0000000..2402a7e --- /dev/null +++ b/gogobee_adventure_announcement.md @@ -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 ` / `vault take + `. 10 protected slots. Safe from `!sell all`, safe from combat. +- **A second pet** at Tier 4 — it shows up on its own. +- **Gifting** — `!give @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 `** — 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. diff --git a/gogobee_adventure_announcement_copy.md b/gogobee_adventure_announcement_copy.md new file mode 100644 index 0000000..646c6be --- /dev/null +++ b/gogobee_adventure_announcement_copy.md @@ -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 ` 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 `. 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. diff --git a/gogobee_appservice_migration.md b/gogobee_appservice_migration.md new file mode 100644 index 0000000..e6e5940 --- /dev/null +++ b/gogobee_appservice_migration.md @@ -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). diff --git a/gogobee_boredom_plan.md b/gogobee_boredom_plan.md new file mode 100644 index 0000000..f8873e3 --- /dev/null +++ b/gogobee_boredom_plan.md @@ -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 +` (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 (:282–309) followed by a reusable +transaction (:369–424): 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= 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.** diff --git a/gogobee_code_review_followups.md b/gogobee_code_review_followups.md new file mode 100644 index 0000000..f47e043 --- /dev/null +++ b/gogobee_code_review_followups.md @@ -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 P1–P7). +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 1–5 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 1st–5th-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` (~6–8 SELECTs per seat). + A 3-member, 5-round fight ≈ 270–360 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 2–3× 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. diff --git a/gogobee_combat_engine_plan.md b/gogobee_combat_engine_plan.md new file mode 100644 index 0000000..552ad7b --- /dev/null +++ b/gogobee_combat_engine_plan.md @@ -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 @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 67–72%): 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), 2n−1 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 28–34% 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 (40–70%) | 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 @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 46–56% +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. diff --git a/gogobee_engagement_plan.md b/gogobee_engagement_plan.md new file mode 100644 index 0000000..a6002c4 --- /dev/null +++ b/gogobee_engagement_plan.md @@ -0,0 +1,1547 @@ +# Adventure Engagement Plan — new modes, mechanics, story + +> Goal: make the Adventure module more engaging across the whole player +> lifecycle — fix the drop systems the Phase R transition orphaned, give +> the endgame a real chase, add player-driven modes (co-op, duels, world +> bosses), thread story through the zones, and surface the social data +> that already exists. Working-tree doc; do not commit (feedback_dont_commit_plan_mds). + +## Status @ 2026-07-11 + +**THE PLAN IS COMPLETE AND DEPLOYED. N1–N7 are all shipped, merged to `main`, +and live in prod as of 2026-07-11** — prod jumped 69 commits (`c07d228` → +`f4a4c0d`), taking N5 + N6 + N7 and the Pete news seam live in one restart. +Startup was clean (appservice token valid, crypto DB current, cross-signing +preserved, no errors); the `rooms_traversed` bootstrap backfilled 67 rows and the +Pete news backfill emitted 16 facts. Prod is now the first real exercise of +Renown, the Omen, seasonal events, the Hollow King arc, the world boss and duels +— watch for their first live firings. + +| Phase | State | Landed at | +|-------|-------|-----------| +| N1 Restoration | ✅ merged | `e8f4863` / `c9df282` / `b5493a0` | +| N2 The Chase | ✅ merged | `adcc621` — see [[project_n2_the_chase]] | +| N3 Parties | ✅ merged | P1–P8; ledger below — see [[project_n3_parties]] | +| N4 The Town | ✅ merged | E1/E2/E3 — see [[project_n4_the_town]] | +| N5 The Hollow King | ✅ merged | `81b2359` — see [[project_n5_hollow_king]] | +| N6 Rivalry & Siege | ✅ merged | `401b17c` — see [[project_n6_rivalry_siege]] | +| N7 Living World | ✅ merged | `a6f1de4` (B2 Renown, B3 Omen, B4 achievements, E4 Seasonal) — see [[project_n7_living_world]] | +| C5 Revisit R1+R2 | ✅ merged | `31e3d69` / `ae5762f` — see `gogobee_revisit_plan.md` | + +Since N7 landed, `main` has also taken the sim-real-char harness (`3f4b4ec`), the +`player_meta` auto-migration seed (`fedd357`), and the Pete Adventure News seam +(`f4a4c0d`). `main` is **4 commits ahead of `origin/main`** — unpushed. + +### Deploy record (2026-07-11) + +Pete first, then gogobee, per the durable-queue ordering. Pete needed a code fix +found at deploy time (`82d1c6e`): `posting.enabled=false` — how Pete's RSS +chatter is kept out of the Matrix rooms — *also* nil'd `advPost`, which killed +live adventure beats **and** the digest loop, making "news on the web only, +adventure live in the games room" inexpressible. `advPost` is now gated on +`[adventure]` alone, since adventure facts are push-based and never enter the +RSS pipeline or the round-robin rotation. + +Live surfaces: `news.parodia.dev/adventure` (feed + permalinks + daily 17:00 UTC +digest) and PRIORITY beats to the games room. The cold-start backfill emitted 16 +facts (2 zone-firsts, 3 deaths, 11 achievements) with **zero** Matrix posts — +backdated + NoPush suppressed the live push exactly as designed. + +### Loose ends carried out of the plan (none blocking) + +- **N3 OPEN — no seat can heal another seat. Clerics must be able to heal the + party.** The N-body engine seats N players against 1 monster, but every heal + in it is *self-scoped*: `MistyHealProc`/`MistyHealAmt` heal the actor, + `HealItem`/`HealItemCharges` fire the actor's own <50% trigger, and there is no + ally-targeted action anywhere (`grep ActionHeal|healAlly|TargetSeat` → + nothing; the only seat-targeting in the engine is `enemyTargetSeat`, i.e. who + the *monster* swings at). So a party cleric is a cleric in chassis and passives + only — they cannot put a single HP back on a friend. Nobody noticed because + N3 shipped without a support role to test it with; hiring a Cleric companion + (`!expedition hire cleric`) is what surfaced it, since role-fill promises a + healer and the engine cannot deliver one. + Needs: an ally-targeted heal action (`!cast cure @user` / an auto-picked heal + for autopiloted + companion seats), a target argument through + `PlayerAction` → `runPartyCombatRound`, and a heal-the-lowest-seat rule in + `pickAutoCombatActionForSeat`. **This will move the golden** (it adds an + action the picker can choose), so it wants a deliberate re-baseline, not a + drive-by. Affects human clerics and the companion equally. +- **N3 perf:** `partyCombatantsForSession` rebuilds every seat's sheet per phase + step — ~2N sheet builds per dispatch. This is + [[project_combat_session_cache_deferred]] coming due. +- **N3 balance:** the enemy-multiattack gap was deliberately left open in P8 + (closing it would move the golden) — [[project_sim_multiattack_gap]]. +- **Companion balance is unmeasured.** Pete is statted below-median by + construction (level −1, no masterwork gear, no magic items, no subclass, no + armed ability) but nobody has run a win-rate sweep with vs without him. The + plan's rule is "help, never a carry" — that is currently an assertion, not a + measurement. Wants an `expedition-sim` sweep with a companion seat. +- **N2:** arena season titles are stored (`loadArenaSeasonTitles`) but nothing + renders them; natural home is `!sheet` or the E3 `!town` registry. Tempering is + untested end-to-end at the handler level (no Matrix client stub in the plugin + package). `loadArenaLeaderboard()` is live with no caller. +- **N2 prod watch:** tempering is an uncapped euro sink; watch whether €150k reads + as too cheap once T5 clears are routine. The material (1/clear) is the real gate. +- **A6 prod watch:** events fire at digest 8% / sell 5% / arena cashout 5%, one per + player per UTC day. Bump `advEventChanceDigest` if it reads too quiet. +- **Re-baseline class balance** against the post-N3 engine — the corpus of record + is still [[project_d8prereq_baseline]], which predates parties. + +Party navigation should read `CurrentNode` as position and `RoomsTraversed` as +effort (see the R1 note below — `CurrentRoom` is *derived*, not persisted, and +moves backwards on a backtrack; it is not the field party code wants). + +--- + +*Everything below this line is the phase-by-phase build record, kept for the +decisions and gotchas it documents. It is history, not a work queue.* + +### N3 — the plan's C1 combat claim was wrong (verified 2026-07-09 @ `ae5762f`) + +C1 below says *"the turn engine already loads multiple combatants per +session"* and treats seating N players as an extension. **It is false.** + +- `combatantsForSession` returns a `(player, enemy)` **pair** + (`combat_session_build.go:114`). +- `turnEngine` holds one `player`, one `enemy` (`combat_turn_engine.go:77`). +- `CombatSession` has scalar `PlayerHP`/`EnemyHP` and **no participants + table** — combatants are rebuilt every round, never persisted. +- The turn engine has **no initiative**. It is a fixed + `player_turn → enemy_turn → round_end` phase machine; `InitiativeBias` is + read only by `SimulateCombat`. +- `grep '\[\]Combatant'` matches **nothing repo-wide**, arena included. +- C1 names `simPickCombatAction` as the autopilot fallback. No such function; + it is `pickAutoCombatAction` (`expedition_sim.go:898`). The name survives + only in a stale comment. + +Two resolvers coexist: the **turn engine** (manual play + expedition-boss +autopilot via `autoDriveCombat`) and **`SimulateCombat`** (harvest interrupts, +patrols, arena, class-balance harness). + +So C1 is a rewrite of the most heavily-tuned code in the repo, not an +extension. Estimate is **8–12 sessions**, not 4–5. Decision taken: do the full +multi-combatant rewrite (chosen over a shared-enemy-HP-pool model and a +"fellowship" model where members fight separate encounters). Scope: **N players +vs 1 monster** in v1, roster shaped so M>1 monsters is a later additive change. + +**The balance corpus does not have to die.** `SimulateCombat` is the core of +the harness that produced `d8prereq_corpus`. If the N-body core draws RNG in +the same order for the degenerate 1-player case, every solo baseline replays +bit-for-bit. `TestCombatCharacterization` (golden: +`internal/plugin/testdata/combat_characterization.golden`) is the proof. +**If that golden moves during N3, solo balance moved — stop and investigate.** +Regenerate only deliberately: +`go test ./internal/plugin -run TestCombatCharacterization -update`. + +#### N3 phase ledger + +| Phase | State | Landed in | Notes | +|-------|-------|-----------|-------| +| P1 widen the golden | ✅ done | `fc0dff7` | 22→57 scenarios, 2047→7468 lines. Old golden is a byte-exact prefix of the new one. | +| P2 roster data model | ✅ done | `41f98b7` | `combatState` embeds `*actor`; `seat(i)` cursor. Golden byte-identical. | +| P3 initiative + N-body turn engine | ✅ done | `ec614e8` | `turnOrder`, `phaseForSeat`, `turnIdxForPhase`, `combatSessionStepRNG`, `enemyTarget`, `advancePartyCombatSession`. Golden byte-identical. | +| P4 schema | ✅ done | `d7d0230` | `ActorStatuses` split, `combat_participant`, `roster_size`, `expedition_party`. No `party_id`, no bootstraps — see below. Golden byte-identical. | +| P5 session layer | ✅ done | `e8d0619` | Multi-participant sessions, fight lock, turn ownership, 3-min turn deadline + autopilot latch, reaper roster, per-seat close-out. Golden byte-identical, `go test ./...` green. | +| P6a member-resolution + lifecycle | ✅ done | `0f144fa` | `activeZoneRunFor`, `expeditionAudience`, `fanOutExpeditionDM`, `briefingPetPrefix`, `releaseParty`. Golden byte-identical. | +| P6b invite/roster commands | ✅ done | `1928f75` | `expedition_invite` table, `inviteToParty`, `latestInviteFor`, `addSupplyPurchase`, `!expedition invite/accept/decline/party/leave`. Golden byte-identical. | +| P6c party combat + leader gates + burn | ✅ done | | Seats the roster in `handleFightCmd`; seat-aware `autoDriveCombat`; leader-only fork/`!extract`; burn × size × 0.8. All 5 review findings fixed before it landed (below). Golden byte-identical, `go test ./...` green, solo T1 sim clears. | +| P6d rewire the `getActive*` reads | ✅ done | | Split out of P6c. Every player-facing read now resolves through `activeExpeditionFor` / `activeZoneRunFor` / the new `isPartyMember` / `seatedExpeditionFor`. Four review findings fixed before it landed (below). Golden byte-identical, `go test ./...` green, solo T1 sim clears. | +| P7 sim `-party N` | ✅ done | `32e3148` | `-party N` / `-party-classes` seat followers through the real invite/accept commands. Golden byte-identical, `go test ./...` green. Band read after P6e — see below. | +| P6e seat the party in inline room + patrol combat | ✅ done | `08d3053` | N-body auto-resolve. `combat_engine_party.go` + `zone_combat_party.go`. Golden byte-identical; solo T5 unregressed. Band now reads **100% at T5** — see P8. | +| P8 enemy action economy | ✅ done | `0d18cea` | Roster-scaled re-targeted enemy actions/round (fractional: 2.4 duo, 2N−1 for N≥3) + party enemy HP ×1.15, both engines, solo-exempt (golden byte-identical). Band: solo f70/c27 → duo f75/c31 → trio f90/c72, monotonic. Multiattack gap left open (would move golden). | + +#### P7 — the harness works, and it found that a party is only a party twice + +Written 2026-07-09/10. New: `-party N` + `-party-classes` on `cmd/expedition-sim`, +`SimRunner.RunPartyExpedition`, `seatParty`, `simRunEndOutcome`, +`expedition_sim_party_test.go`. `Base.DisplayName` gained a nil-`Client` guard — +the party commands call it, and the headless sim has no Matrix client. + +Followers are seated through the **real** `!expedition invite` / `!expedition +accept heavy` pair, so the sim measures the tier gate, the busy guard and the +supply pooling rather than a hand-built roster that cannot fail. Both commands +answer a refusal with a DM and a `nil` error, so `seatParty` verifies the seat +against `partySize` afterwards and **halts the run** if anyone was refused: a +party reading taken from a walk that was secretly solo is worse than no reading. + +**The finding: the roster is seated at elite/boss doorways and nowhere else.** +`advanceOnce` stops the walk at those two room types (`stopElite` / `stopBoss`) +and hands them to `autoDriveCombat` → `startPartyCombatSession`. *Every other +fight* — exploration rooms, §4.1 patrol encounters, harvest interrupts — goes +through `resolveRoom` / `tryPatrolEncounter` / `runHarvestInterrupt`, which call +`SimulateCombat` against `ctx.Sender`. `ctx.Sender` is the leader, because P6d +made `!zone advance` and `!expedition run` leader-only. + +So on a 38-room T5 expedition the party fights **2–3 rooms together** and the +leader solos the other ~35. And because only the leader owns the run and +expedition rows, their death tears both down for everyone. Measured, party of 3, +n=15/cell, L15+16 × `dragons_lair` + `abyss_portal`: + +| roster | class | clear% | leader_down% | tpk% | member deaths | +|--------|-------|--------|--------------|------|---------------| +| solo | fighter | 47–67% | — | — | — | +| party 3 | fighter | **100%** | 0% | 0% | **0 / 120 seats** | +| solo | cleric | 13–47% | — | — | — | +| party 3 | cleric | 33–53% | 13–40% | 0% | **0 / 120 seats** | + +**Zero member deaths and zero TPKs in 240 seats.** Every party failure is the +leader dying alone while two untouched members stand at full HP. That is what +`simRunEndOutcome`'s new `leader_down` label exists to say out loud; the old code +called it `tpk`, which is how it stayed invisible through P5 and P6. + +**Do not apply the C1 contingency (+35% monster HP per member) on these numbers.** +Fighter's 100% is not "parties overshoot the 61–79% band" — it is a 3v1 boss on +top of trash the leader was already clearing solo. An HP scalar applied to *all* +monsters would make the trash the leader fights alone deadlier while barely +denting the doorway fights, i.e. punish exactly the wrong rooms. Cleric's +33–53% is the same story read from the other end: the lift over solo is real but +it lands entirely on the two fights that were never the problem. + +**P6e, before P7's band reading.** Route inline room/patrol/harvest combat +through the party engine when `isPartyMember` / a roster exists. Notes: + +- `resolveCombatRoom` already takes a `userID` and calls `runZoneCombat`; the + N-body core is `SimulateCombat`, which P2 widened to a roster. The seam is + there — it is the *callers* that still pass one player. +- Patrols and harvest interrupts have "retreat continues the run" semantics + (`retreatThreatBump`), unlike a gating elite. Per-seat close-out has to respect + that: a member who retreats is not dead. +- Watch the golden. Solo must keep drawing RNG in the same order, exactly as it + did through P2–P6d. +- Once P6e lands, re-run: `expedition-sim -matrix -party 3 -classes fighter,cleric + -levels 15,16 -zones dragons_lair,abyss_portal -runs 15 -bank 20000`, and only + then decide on a party-size monster scalar. + +**Two bugs a `/code-review` caught before this landed:** + +1. **An unknown class was not an error.** `applyClassBaselineStats` and + `computeMaxHP` both fall through to a default, so `-class fightr` (or a typo + in `-party-classes`) built a **1-HP** character, walked it into the zone, and + reported an ordinary `tpk` / `cleared` for a character nobody asked to + simulate. This bit the leader's own `-class` too, long before P7. *Fixed* in + `BuildCharacter` rather than in the flag parser, so every sim caller inherits + the guard. +2. **The roster short-rest healed the dead.** The post-win rest loop runs for + every seat, and `handleDnDShortRest` does not gate on the death flag — so a + member killed in the boss fight the party *won* got rested back above 0 HP, + and vanished from `res.Members[].EndHP` as a casualty. *Fixed:* skip seats + `simSeatAlive` says are down. + +Also fixed here: `SimResult.Outcome` used to call any run-ending event `tpk`, +including a **timeout loss** — which `resolveCombatRoom` deliberately does *not* +mark as a death ("mechanically ran out the clock"). The vocabulary is now +`tpk` (whole roster dead) / `leader_down` / `fled` (run over, leader alive), +read off the `AdventureCharacter` death flag rather than off HP, which the +close-out leaves in no particular state. **Solo labels are unchanged for a real +death** — for a one-seat roster, leader-dead *is* all-dead — but a solo timeout +loss now scores `fled` where it used to score `tpk`. `cleared%`, the band metric, +is untouched, so the `d8prereq_corpus` baselines still compare. That last claim +is established from the diff, not from a re-run: **the sim is unseeded**, so at +n=15 a cell swings ±13pp on RNG alone and a before/after sweep proves nothing. +No line that sets `cleared` moved, and for a one-seat roster the new short-rest +loop calls `maybeShortRest` with exactly the old `ctx`. + +#### P6e — the party fights every room, and the monster's turn doesn't scale + +Written 2026-07-10, landed `08d3053`. New: `combat_engine_party.go` +(`simulateParty`, `simulatePartyRound`, `roundInitiative`, `enemyTargetSeat`, +`eventsForSeat`, `finalizeParty`), `zone_combat_party.go` (`runZoneCombatRoster`, +`zoneCombatRoster`, `closeOutZoneWin`, `closeOutZoneLoss`, `partyCasualtyLine`), +`combat_engine_party_test.go`. Touched: `combat_engine.go` (`simulateRound` and +`finalize` moved out / deleted), `combat_turn_engine.go` (`enemyTarget` collapsed +onto the shared `enemyTargetSeat`), `dnd_zone_cmd.go`, `dnd_zone_combat.go`, +`dnd_expedition_combat.go`. + +**P6e's own note above was wrong about the seam.** It said "the N-body core is +`SimulateCombat`, which P2 widened to a roster — the seam is there, it is the +*callers* that still pass one player." `SimulateCombat` built a **one-seat roster +internally**; there was no N-body entry point. What *was* true, and what made this +cheap, is that the resolution primitives (`resolvePlayerSwings`, +`resolveEnemyAttack`, `applyAbility`, `trySave`) take `(st, player, enemy)` where +`player` is always `st.c` — the cursor's own `Combatant` — because the turn engine +has called them that way since P3. **The primitives needed no change at all.** +Only the round loop did. + +Solo stays bit-identical by copying P3's exemptions, not by branching: +`enemyTargetSeat` draws nothing for a one-seat roster, `roundInitiative` draws one +player roll then one enemy roll (the pre-roster order, ties to the player), and +every per-seat loop runs once. `TestCombatCharacterization` is byte-identical and +`TestSimulateCombat_IsTheOneSeatPartyCase` pins the delegation event-for-event over +40 seeds. A solo T5 re-sweep reads fighter 47–73% / cleric 20–33%, matching P7's +solo numbers inside the ±13pp the unseeded sim gives at n=15. + +Decisions worth keeping: + +- **`runZoneCombat` stays the explicit solo entry point.** `adventure_arena.go` + calls it, and an arena fighter must never drag their party into the ring. The + expedition callers pass `zoneCombatRoster(walker)` themselves. +- **Death is per seat, off HP, never off the fight's terminal status.** A + timed-out *party* can still have lost somebody, so both the retreat branches + (`runHarvestInterrupt`, `tryPatrolEncounter`) now mark casualties while keeping + the run alive. For one seat `PlayerEndHP <= 0` is exactly the old `!TimedOut` + rule, because a solo player at 0 HP has already ended the fight. +- **The close-out splits on the P5 seam.** Character-scoped effects (HP, XP, + achievements, subclass, heal items burned, Misty's repair, loot) fan out per + seat; run-scoped ones (threat, kill record, teardown) stay with the owner. +- **Seat-filtered event logs.** `Seats[i].Events` is that character's sub-log for a + party, and the whole log by identity for a solo fight — otherwise a member's + potions get burned for the leader's heals. Unstamped fight-scoped events (enemy + regen, the timeout) read as the leader's. +- **A preserved quirk, deliberately not fixed here.** A *solo* player can win at + 0 HP — a retaliate aura kills the swinger on the same blow that drops the enemy, + and `resolvePlayerAttack` returns before `enemyDown` is consumed — and is not + marked dead. A *party* marks its downed seats dead on a win, which is what + `finishPartyWin` has always done. Changing the solo case would move the sim's + outcome labels; it is not P6e's business. + +**The band reading, and why C1's contingency is the wrong lever.** Party of 3, +n=15/cell, L15+16 × `dragons_lair` + `abyss_portal`, 120 runs: + +| roster | class | clear% | member deaths | tpk | +|--------|-------|--------|---------------|-----| +| solo | fighter | 47–73% | — | — | +| solo | cleric | 20–33% | — | — | +| party 3 | fighter | **100%** | 0 / 240 seats | 0 | +| party 3 | cleric | **100%** | 0 / 240 seats | 0 | + +Still zero member deaths — but for the opposite reason to P7's. The members really +are fighting now: they take damage, earn XP, and level mid-run. The monster cannot +keep up. **The enemy takes one turn per round and swings at one seat**, so a party +of N deals ×N damage, the fight lasts 1/N as long, and each member absorbs roughly +**1/N² of the solo incoming**. A T5 member finished a 39-room expedition down 12 HP. + +So an HP scalar cannot close this, and `+35%/member` is not merely too small. +Multiplying enemy HP by ×N restores the fight's *duration*, but the enemy still +spends one attack per round on one seat — per-member damage taken stays at 1/N of +solo however much HP you add. **HP is the wrong axis**, which is the whole finding. +Not "HP is forbidden": it simply does not touch the term that produced the 1/N². + +### P8 — scale the enemy's action economy (✅ done, `0d18cea`) + +Shipped as designed below, with one change the sim forced: the action count is a +**fractional expectation** (2.4 for a duo, 2N−1 for N≥3, realised as a per-round +coin-flip), plus a party-only enemy HP ×1.15. Solo is exempt on both levers and the +golden is byte-identical. Final T5 band is monotonic by party size and never below +the solo rate: solo f70/c27 → duo f75/c31 → trio f90/c72. Lessons: linear-N still +cleared 100%; fighter is HP-insensitive (100% even at HP ×1.6, so *actions* are the +load-bearing lever, exactly as predicted); the integer action lever is too coarse at +N=2, hence the fraction. The multiattack gap was deliberately NOT closed — it would +move the golden. Original brief follows. + +Give the enemy attacks-per-round proportional to the seated roster, so a party of +three faces three swings a round rather than one. That is the axis the 1/N² comes +from. It is also the same lever as a long-standing sim gap: `SimulateCombat` +ignores enemy multiattack entirely while the turn engine loops the full profile +(project_sim_multiattack_gap), which is what made prod autopilot secretly easier +than manual play and drove the D8-e/D8-f T4/T5 difficulty lift. P8 closes both. + +Attack count is the tool that fits, not a tool chosen around a constraint — no +axis is off-limits here (project_difficulty_target). Combine it with HP, damage or +AC if the band asks for it. + +Notes for the executor: + +- Solo must draw RNG in the same order or the golden moves and the corpus dies. + One seat ⇒ one enemy attack: the loop has to collapse exactly as + `enemyTargetSeat` and `roundInitiative` already do. +- Re-target per swing, not per round, or three attacks all land on one member and + a party is a coin-flip on who gets focused. +- `abilityDealtDamage` currently suppresses *the* enemy attack. With N swings, + decide whether a cleave/lifesteal round suppresses all of them or one. +- Whether N swings is right, or `1 + (N-1)×k` for some k < 1, is an empirical + question — a party should be *easier* than solo (that is the point of a party), + just not 100%. +- Re-run `expedition-sim -matrix -party 3 -party-classes fighter,cleric -classes + fighter,cleric -levels 15,16 -zones dragons_lair,abyss_portal -runs 15 -bank + 20000`. Target is the T4/T5 band for a competent build, ~60–75% + (project_d8f2_t4_difficulty), read off **martial leaders**; casters trail by a + player-side class gap that monster tuning provably cannot compress. +- The sim is unseeded: ±13pp per cell at n=15. Raise `-runs` before reading a + 10pp move as real. + +**P6a/P6b decisions.** + +- **The whole party layer was dead code.** `partyMemberIDs`, `disbandParty`, + `openParty`, `joinParty`, `activeExpeditionFor` had zero callers outside their + own test. P4 built the tables; P6a/P6b are what wired them. +- **A member owns no zone-run row either**, not just no expedition row — the plan + and P4's notes only flagged the latter. `!fight`, `!zone status`, `!map`, + `!revisit` all resolve through `getActiveZoneRun(sender)`. `activeZoneRunFor` + is the twin of `activeExpeditionFor`; a member reaches the run in two hops via + the leader's `exp.RunID`. +- **A member must never call `getActiveZoneRun` on the leader's behalf.** That + function carries the §4.3 idle reap, which force-extracts the wrapping + expedition — a member glancing at the map would end everyone's run. Pinned by + `TestActiveZoneRunFor_MemberDoesNotReapAStaleRun`. +- **DMs are per-reader, not per-expedition** — the same lesson P5 learned about + combat narration. The briefing's *body* is expedition-scoped but its pet-event + prefix is not: each member has their own pet and their own sheet. Hence + `fanOutExpeditionDM(e, body, perReader)`. The digest's A6 event anchor rolls + per member too (it debits a per-player daily slot). +- **`releaseParty` fires on `completeExpedition` / `abandonExpedition` / + `forcedExtractExpedition`, and deliberately NOT on `voluntaryExtract`** — + `extracting` is a 7-day resumable limbo and `!resume` must bring the party + back. The roster clears when the window lapses to `failed`, which routes + through `completeExpedition` like everything else. A seated member is barred + from adventuring elsewhere, so a roster outliving its expedition strands them. +- **The plan's invite window is a race, not a window.** "Before the first walk" + is at most `autoRunMinExpeditionAge` = 30 min, and the leader's own + `!expedition run` can close it sooner. Changed to **all of Day 1**, plus: + *an unanswered invite pins the autopilot* (`loadExpeditionsForAutoRun` skips + expeditions with a live invite), bounded by `expeditionInviteTTL` = 2h **in the + query itself**, so a forgotten invite costs an afternoon, not the expedition. + Supplies burn at the night rollover, so a Day-1 latecomer pays and receives + exactly what a gate-joiner does. +- **Pooling raises `Current` *and* `Max`.** `supplyDepletion` reads the ratio; + folding in only `Current` would read as the party suddenly starving. Burn rate + is untouched — scaling it by party size is P6c. +- **Outstanding invites count against `expeditionPartyMax`**, or a leader asks + four people and three accept. +- **A member's supplies stay in the pool on `!leave`.** They were spent on the + expedition, not lent to it; a refund would let someone starve the party on + their way out. +- `assertNotAdventuring` guards expeditions and rosters but **not bare zone + runs**, so `accept` checks `getActiveZoneRun` itself, as `startExpedition` does. +- **A party is not a taxi:** `zoneOpenToLevel` gates the invitee on the same tier + rule the leader faced. Multiple pending invites are legal; the freshest wins + `!expedition accept`, and `joinParty` refuses the rest. + +#### P6d — what was built, and the 4 findings that were fixed before it shipped + +Written 2026-07-09. New file `expedition_party_reads_test.go`; new seams in +`expedition_party_resolve.go`. Touched: `dnd_zone_cmd.go`, `dnd_zone_cmd_graph.go`, +`dnd_zone_narration.go`, `zone_revisit.go`, `dnd_expedition_cmd.go`, +`dnd_expedition_map.go`, `dnd_expedition_threat.go`, `dnd_expedition_harvest.go`, +`dnd_expedition_camp.go`, `dnd_expedition_region_cmd.go`, `dnd_expedition_extract.go`, +`dnd_economy.go`. + +The reads split three ways, and the split is the design: + +- **Reads a member should see** resolve through `activeExpeditionFor` / + `activeZoneRunFor`: `!zone status/map/lore/list`, `!expedition status/log/list`, + `!map`, `!threat`, `!resources`, `!region` (list), `!camp` (bare). +- **Leader-only actions** get member-specific copy rather than "you have no + expedition": `!zone advance`, `!expedition run`, `!zone abandon`, + `!expedition abandon`, `!revisit`, `!region travel`, `!camp `, `!resume`. +- **Busy-guards must refuse a member**, and did not: `!zone enter`, + `!expedition start`, `!sell`. These were the real bugs — a seated member could + open a private dungeon, outfit a rival expedition, or run a shop from inside + the leader's boss room. + +**The four findings, and how each was fixed:** + +1. **`!resources` clobbered the leader's `region_state`.** It looks like a pure + read, but it seed-persists harvest nodes, and `saveHarvestNodes` → + `persistRegionState` is a last-write-wins UPDATE of the *whole blob* — which + also carries region kills, event gates and the temporal stack. Rewiring the + read to `activeExpeditionFor` is exactly what made the write reachable: a + member's `!resources` mid-walk would resurrect harvested nodes and revert + region progression from a stale snapshot, under their own lock. *Fixed:* + seed-persist only when `isLeader`. `seedRoomNodes` is a pure function of the + zone, so a member re-derives identical nodes without writing. +2. **`!zone taunt` raced the leader's mood.** `applyMoodEvent` is an atomic + `gm_mood + delta`, so a member moving the party's gauge is safe and intended. + Its neighbour `applyMoodDecayIfStale` is **not**: it writes gm_mood as an + *absolute* value derived from the caller's snapshot. Every command takes the + *sender's* `advUserLock`, so a member running decay against the leader's run + holds the wrong mutex and drops whatever the leader's walk banked. *Fixed:* + `applyMoodDecayIfStale(r, owner bool)` — the constraint lives with the write, + not at three call sites. Decay is time-based maintenance; the owner's next + command applies the same correction. +3. **The seat outlives `status='active'`.** `expeditionForMember` filters on + `active`, but `releaseParty` is deliberately *not* called on `extracting` — a + seven-day resumable limbo (see P6a). So `activeExpeditionFor` goes blind for + the whole window while the roster still holds the member, and the new + busy-guards waved them through: open a private run, and it wins every + `activeZoneRunFor` lookup the moment the leader `!resume`s. *Fixed:* + `seatedExpeditionFor` spans `active` **and** `extracting`; it is what the + busy-guards ask. +4. **`!expedition run` was still member-blind** — the most-typed walk command, + and the sibling of the `!zone advance` this phase gated. *Fixed:* same refusal. + +Also folded in: `isPartyMember(userID)` replaces the `run != nil && !isLeader` +spelling at the copy gates. **`activeZoneRunFor` reports `isLeader=false` for a +player with no run anywhere**, so a bare `!isLeader` tells a solo player with +nothing in flight to go ask their leader — a footgun that cost this phase one +bug during authoring and would have cost the next call site another. It also +avoids re-resolving the run `advanceOnce` is about to resolve, which was firing +the §4.3 idle reap twice per `!zone advance`. `msgLeaderPicksPath` de-duplicates +the fork/revisit refusal, and `!revisit` now answers the fight before the route. + +Deliberately **not** changed: a member can still swing the party's DM mood with +`!zone taunt` (griefing surface, but a party is consensual, and the write is +atomic); `!sell` now refuses a member for the leader's whole expedition, even for +loot from an earlier solo trip — same rule a leader lives under. + +#### P6c — what was built, and the 5 findings that were fixed before it shipped + +Written 2026-07-09. `go build ./...`, `go vet ./...`, `go test ./...` all green; +`TestCombatCharacterization` byte-identical; a real solo T1 expedition clears +end-to-end through the rewritten seam via `cmd/expedition-sim` (elite + boss both +resolved). A high-effort `/code-review` turned up five real problems the suite did +not catch; all five are fixed in the commit. + +New files: `combat_party_start.go` (roster assembly + opening block), +`combat_party_start_test.go`. Touched: `combat_cmd.go`, `combat_session.go`, +`combat_party_turn.go`, `dnd_cast.go`, `dnd_rest.go`, `expedition_sim.go`, +`dnd_expedition_supplies.go`, `dnd_expedition_cycle.go`, +`dnd_expedition_extract.go`, `dnd_expedition_region_cmd.go`, `dnd_zone_cmd_graph.go`. + +Design as built: +- **Seat 0 is always the expedition leader**, whoever typed `!fight`. Every seat-0 + invariant P4/P5 laid down rests on it: the lock key, the once-only close-out + effects, leader-only `!flee`. `fightRoster` resolves it *before* the lock (it must + not touch `getActiveZoneRun` — that carries the §4.3 idle reap), and `roster[0]` + is the owner, so there is one lookup rather than two. +- A downed **member** sits the fight out; a downed **leader** refuses it for + everyone, with copy that differs for leader vs member (`seatZeroRefusal`). +- `autoDriveCombat` re-targets `ctx.Sender` to `sess.seatUserID(actingSeat(...))` + each iteration, gated on `cur.IsParty()`. Its round cap is now scaled by roster + size, because it counts **dispatches**, not rounds. +- Burn: `applyExpeditionDailyBurn(e, harsh, siege)` wraps `applyDailyBurnP` with + `expeditionBurnRatePct(e.ID)` = `50 × N × 4/5`. **The 0.8 is an exact 4/5 ratio, + not a float** — `int(float32(50*3) * 0.8)` truncates to 119, a silent permanent + tax on every party of three. Solo returns exactly 50. + +**The five findings, and how each was fixed:** + +1. **The enemy lost its round-1 turn whenever it won initiative.** P3 code, only + reachable once a party is seated. `startCombatSession` writes + `Phase: player_turn, TurnIdx: 0` unconditionally; on resume `turnIdxForPhase` + snaps the cursor to the first `player_turn` slot, so an `order[0] == enemySeat` + round-1 sequence skipped the enemy entirely. Round 2+ was always correct because + `stepRoundEnd` sets `Phase = phaseForSeat(te.order[0])`. Solo is immune (order is + hardcoded `[0, enemySeat]`). + *Fixed:* `startPartyCombatSession` now takes the built `*Combatant` enemy, derives + round 1's order, and sets `Phase` from `order[0]`. `buildFightSeats` hands back the + `*Combatant`s it used to discard (`CombatSeatSetup.C`), which also kills the + second full roster rebuild the opening block was doing. `handleFightCmd` then + `settleCombatSession`s before announcing, so a monster that swings first is + *narrated* in the opening block rather than silently subtracting HP; if that + opening wipes the party, `closePartyRound` runs the close-out. Pinned by + `TestStartPartyCombatSession_EnemyThatWinsInitiativeOpensTheRound` and its + party-wins twin. +2. **`!cast` was member-blind.** `handleDnDCastCmd` gated on + `getActiveCombatSession(ctx.Sender)`, nil for a member, so a member's `!cast` in a + party fight fell through to the out-of-combat path and queued a `PendingCast` + instead of acting. *Fixed:* `activeCombatSessionFor`. +3. **`!rest` was member-blind.** `restBlockedReason` used `hasActiveCombatSession` + + `getActiveExpedition`, both of which answer "no" for a member — a seated member + could `!rest` to full HP mid party boss fight. *Fixed:* `activeCombatSessionFor` + + `activeExpeditionFor`, with member-specific copy (a member cannot `!extract`). +4. **A downed member who typed `!fight` got no reply.** They opened the fight for + everyone, were skipped from seating, and `announcePartyFightStart` only DMs seated + members. *Fixed:* `buildFightSeats` returns `senderSkip` — the sender's own reason + for being left out — and `handleFightCmd` answers with it. The one-seat tail + handles the case where the skip leaves a *solo* session: the block goes to the + leader by `SendDM`, the skip to the sender. +5. **`!extract` was leader-only but still DM'd only the leader.** *Fixed:* + `fanOutExpeditionDM` reaches the whole roster (the roster deliberately outlives a + voluntary extract), with a per-reader tail because only the leader can `!resume`; + `maybeRollPetArrivalOnEmerge` now rolls for every member, since every member + surfaced. + +Also folded in from the same review: `fightOwner` deleted (it was `fightRoster(s)[0]`), +the "Your move" line unified behind `partyMovePrompt`. + +Still deferred (perf, not correctness): `actingSeatForAutopilot` calls +`partyCombatantsForSession` purely to translate seat→UserID and `beginCombatTurn` +then rebuilds the roster again — ~2N sheet rebuilds per dispatch. This is +project_combat_session_cache_deferred coming due; P7 will run it at volume. + +Pre-existing, **not** caused by P6c (verified against HEAD in a clean worktree): +`expedition-sim -zone manor_of_whispers -level 10` halts with "expedition did not +persist after start", at any `-bank`. T1 `goblin_warrens` clears fine. + +#### P6c — the blocker P5/P6 did not anticipate + +`autoDriveCombat` (`expedition_sim.go:794`) is the production autopilot *and* the +sim harness. It calls `handleFightCmd(ctx)` then loops +`pickAutoCombatAction(ctx.Sender, cur)` → `handleAttackCmd(ctx)` — always as the +**same sender**. The moment `handleFightCmd` seats a roster, seats 1+ reject that +sender with `beginCombatTurn`'s "not your turn" and the loop spins to +`autoCombatRoundCap`. + +So P6c cannot just swap `startCombatSession` → `startPartyCombatSession`. It must +also give `autoDriveCombat` a seat-aware loop: read `actingSeat(sess, players, +enemy)`, re-target `ctx.Sender` to `sess.seatUserID(seat)`, dispatch. **Gate the +whole thing on `sess.IsParty()`** — the solo path must stay bit-identical or the +`d8prereq_corpus` baselines move. + +Other P6c notes gathered but not yet acted on: + +- `handleFightCmd` takes `p.advUserLock(sender)`. A party fight must take + `p.lockCombatFight(leader, sender)` instead (P5's ordering: fight lock first). +- `startPartyCombatSession` checks only seat 0 for an in-flight session. Guard + every seat with `hasActiveCombatSession` before seating. +- Seat only members with HP > 0; a downed member sits the fight out (they own no + close-out claim, and `finishPartyCombatSession` decides death per seat off HP). +- Leader-only surfaces still to gate: `zoneCmdGo` (fork), `handleExtractCmd` + (currently just says "no active expedition" to a member — wrong copy, right + outcome), `!flee` (P5 already made it leader-only in a party). +- Remaining `getActive*` reads to rewire: ~76 call sites, but only the + player-facing ones matter — `dnd_zone_cmd.go` (status/map/lore/advance), + `dnd_expedition_map.go`, `zone_revisit.go`, `dnd_expedition_threat.go`, + `dnd_expedition_harvest.go` (`!resources`), `dnd_economy.go` (`!sell`), + `dnd_expedition_camp.go`. The tickers and the sim keep `getActiveExpedition`. +- Burn scaling: `supplyDailyBurn(tier)` × `partySize(expID)` × 0.8, applied where + `applyDailyBurn` is called (`nightRolloverBurn`, voluntary extract, region + transit). `addSupplyPurchase` deliberately left `DailyBurn` alone. + +**P5 decisions.** The turn timeout is **3 minutes, not the plan's 60s**, and it +latches. Reasons: the sweep rides the existing one-minute `eventTicker` (D4: no +net-new ticker), so any deadline really fires in `[d, d+1m)` — 60s would have +meant 60–120s, and this is an async Matrix bot whose expeditions run for *days*. +A lapsed deadline sets `ActorStatuses.Autopilot` (JSON, `omitempty`, no schema +bump) so the absent member costs the party **one** wait rather than one per +round; any combat command from them clears it. Solo sessions are never swept — +`roster_size > 1` is in the query — so a lone player still answers only to the +1h reaper. + +New files: `combat_party_turn.go` (lock, turn ownership, autopilot, deadline +sweep), `combat_party_finish.go` (per-seat close-out), `combat_party_turn_test.go`. + +Things P5 found that the plan did not anticipate: + +- **The narration is second person.** `RenderTurnRound`'s pool says "You score 9 + damage", "A hit gets through your guard" — `playerName` was almost unused. + Swapping names per seat would have told all three members they each landed the + same blow. A round is now rendered **once per reader** (`RenderPartyTurnRound` + takes a `viewerSeat`): your own events go through the untouched flavor pool, + your allies' through a terse third-person summariser (`renderAllySeatEvent`). + Solo renders the same bytes it always did. +- **`CombatEvent` gained `Seat int` (`json:"Seat,omitempty"`).** The golden + formats fields explicitly rather than reflecting, so it did not move. Events + are stamped once per phase step in `turnEngine.stampSeat`, not at the ~20 + append sites in `combat_primitives.go` — the primitives emit against the + cursor and know nothing of seats. `combatState.seatIdx` trails `seat()`. +- **`runCombatRound` would have stalled a party on a corpse.** Its loop rested on + any `player_turn`, and a downed seat still holds one. Split into + `settleCombatSession` (drains enemy turn, round end, *and* downed seats) + + `runPartyCombatRound`. `beginCombatTurn` settles before reading whose turn it + is, which also fixes a latent solo bug: a fight interrupted mid `enemy_turn` + used to resume parked there and silently eat the player's next `!attack`. +- **Buffs were landing on seat 0.** `handleCombatCastCmd`/`handleConsumeCmd` + folded every delta into `sess.Statuses.applyBuffDelta` — the session's embedded + copy, i.e. the leader. A member casting Shield on themselves would have + armoured the leader. Now `sess.actorStatusesPtr(seat)`. +- **So was the auto-picker.** `pickAutoCombatAction` read `sess.PlayerHP` / + `PlayerHPMax` / `Statuses.ConcentrationDmg` — all seat-0 fields. Generalised to + `pickAutoCombatActionForSeat`; the old name is a seat-0 wrapper so the sim's + behaviour is bit-identical. `simPickSpiritualWeapon` now takes `ActorStatuses` + rather than the session. +- **Locking.** Three members typing `!attack` at once took three *different* user + locks. `lockCombatFight(owner, sender)` takes the fight's lock (keyed on seat 0) + then the member's own, always in that order; every other command in the repo + takes at most one user lock, so no cycle closes. A solo fight's owner *is* the + sender and `sync.Mutex` is not reentrant, so that case takes exactly one lock — + `TestLockCombatFight_SoloTakesExactlyOneLock` hangs rather than fails if that + regresses. +- **Close-out fans out along the data model's own seam.** Run- and + expedition-scoped effects (threat, zone-kill record, boss-defeat threat, the + loss/flee teardown) all resolve through `getActiveExpedition(userID)` or + `getActiveZoneRun(userID)` and a member owns neither row — so they fire **once**, + for the owner. Fanning them out would have tripled the threat one kill costs. + Character-scoped effects (HP, XP, loot, death) fan out per seat. A member can + be dead in a fight the party won, so death is decided per seat off HP, not off + the session's terminal status. +- **The reaper stays attack-only.** It does *not* use the picker: finishing an + abandoned fight should not quietly burn the player's spell slots and potions. + The turn-deadline latch does use the picker, because that member is mid-fight + with a party waiting on them. Deliberate asymmetry. +- **`!flee` is leader-only in a party** — it ends the run for everyone, the same + reasoning that makes `!extract` leader-only. Not in the plan; revisit in P6. + +Carried into P6: + +- `startPartyCombatSession` has **no production caller**. `handleFightCmd` still + opens a solo session off `startCombatSession`; wiring it is P6's job, once + `joinParty` can actually seat somebody. +- `partyCombatantsForSession` rebuilds **every seat from its own sheet** — N× the + per-round DB chatter `combatantsForSession` already had + (project_combat_session_cache_deferred). Solo still issues exactly one build. + A party of 3 pays it three times per phase step; worth a cache before P7's sim + runs it at volume. +- `beginCombatTurn` probes for the session **unlocked**, then re-reads under the + lock and revalidates (the reaper's own pattern). The window is real but the + revalidation closes it. +- Nothing calls `activeExpeditionFor` yet either — still P6. + +**P1 findings.** The golden pinned 22 scenarios and missed everything the +refactor was most likely to disturb: `ExtraAttacks` (the lever J1 moved) and +the rest of the 2026-05-16 class-identity mods, the race passives, and **all +13 Phase-13 bestiary slice-3/4 effects** (evade, block, advantage, retaliate, +regenerate, survive_at_1, stat_drain, debuff, max_hp_drain, spell_resist, +reveal_action, fear_immune, ally_buff) — none had a pin. Enemies are sized per +scenario so per-hit riders accumulate; abilities proc at 1.0 so the pin +captures the effect, not the roll. + +**P2 decisions.** `combatState` embeds `*actor` **by pointer** — promoted +fields keep their names, so ~230 call sites (`st.playerHP`, `st.wardCharges`, +…) compile untouched, and `seat(i)` is a one-line cursor move. A *value* embed +would silently copy on `seat()`; `TestCombatState_SeatSwitchesPerActorStateOnly` +pins that. Split rule: the enemy's **stance** (evade/block/advantage/retaliate/ +regen/survive) and hold-person are **fight-scoped** (holding the enemy holds it +for everyone); debuffs stacked on one character (stat_drain / debuff / +max_hp_drain) and concentration are **per-actor**. P2 landed the data model +only — nothing yet decides who the enemy swings at. + +**P3 decisions.** Initiative is **solo-exempt**: `turnOrder` returns the fixed +`[player, enemy]` for a one-seat roster and rolls nothing. The turn engine has +never had initiative, so rolling one would have been a live balance change to +every manual elite/boss fight. Parties use the auto-resolve formula (`speed + +d10 + InitiativeBias`), re-rolled per round, derived from `(sessionID, round)` +so a resume rebuilds the same order. + +RNG: every seat's `player_turn` in a round shares a `(round, phase)` pair, so +the acting seat is mixed into the PCG **seed**, not the stream. Seat 0 and the +enemy sentinel mix to zero → solo streams are bit-identical to pre-roster. + +The round cursor is `Statuses.TurnIdx` (JSON, `omitempty` — no schema bump, no +solo row carries it). **Prod rows written before P3 decode it as 0**, so +`turnIdxForPhase` reconciles cursor-vs-`Phase` with `Phase` winning; without it +a suspended `enemy_turn` resumes into an infinite enemy turn. Keep that +reconciliation until every pre-P3 session has aged out (TTL 1h). + +Latent solo bug fixed in passing: `resolvePlayerSwings` returns **false** when a +retaliate aura kills the swinger between extra attacks, and `stepPlayerTurn` used +to read the outcome off that bool — walking a corpse into the enemy's turn. +Outcome is now read off HP + `anyAlive()`. + +Still single-seat in persistence: `commit()` writes seat 0 explicitly (the enemy +turn parks the cursor on its target; `round_end` walks it across the roster), and +`resumeTurnEngine` opens seats 1+ fresh from their `Mods`. **P4 must give seats +1+ their own persisted statuses**, or a party fight resets their once-per-fight +one-shots every step. *(Done in P4 — `restoreActor` / `snapshotActor` are exact +inverses, and `commit` now addresses seats by index rather than off the cursor.)* + +**P4 decisions.** `CombatStatuses` split into a fight-scoped half (enemy stance, +`TurnIdx`) and a per-character `ActorStatuses`, embedded **anonymously and +untagged** so `statuses_json` stays the same flat one-level object. Prod rows +decode unchanged; `TestCombatStatuses_JSONStaysFlat` pins the flattening, because +adding a json tag to that embed would silently wipe every in-flight fight's +poison, charges, and one-shots on the next resume. + +Seat 0 stays on `combat_session`; seats 1+ get `combat_participant` rows. The +asymmetry is deliberate — **a solo fight writes the exact bytes it wrote before +N3, and zero participant rows**. A new `roster_size` column (DEFAULT 1) guards +the read so the solo loader never issues the second query +(project_combat_session_cache_deferred: don't make the chatter worse). Party +saves wrap the session row + all seats in one transaction; solo keeps its single +unwrapped UPDATE. + +**No `party_id` column.** The plan called for one on `dnd_expedition`, but +`expedition_id` *is* the party's identity — a second key would be a second answer +to "who is in this party", free to disagree with `expedition_party`. Cost: a +member owns no expedition row, so `getActiveExpedition` is blind to them. +`activeExpeditionFor(user) (e, isLeader, err)` is the lookup every player-facing +command must switch to in P6; `getActiveCombatSessionForMember` is its combat twin. + +**No bootstraps.** Both new tables read *absent == solo*, and `roster_size`'s +DEFAULT 1 is correct for every pre-existing row — the same reasoning that let N2's +`temper` ALTERs ship without a backfill. Nothing to reconstruct. + +`joinParty` seats the leader itself (reading the owner off `dnd_expedition`) +rather than trusting callers to call `openParty` first: `partyMemberIDs` reads the +roster, not the expedition row, so a leaderless roster would silently stop DMing +the leader. It also enforces "one active expedition per user" across membership, +in-transaction, so two simultaneous invites can't both take the last seat. + +**Flaky-test pair fixed** (the one N1 flagged as "worth seeding at some point"). +`TestSimulateCombat_FirstAttackBonusImprovesEarlyHits` and +`TestResolvePlayerAttack_AssassinateBonusFirstHitOnly` called `SimulateCombat` +with a nil RNG — the *package-global* stream — so their verdict depended on how +much randomness every test declared before them consumed. Adding P4's test files +shifted that stream and pushed the suite to ~2-in-3 failing. Both effects are +real, but both tests were underpowered: over 40 seeds, Precision averaged +127 +wins against its +50 threshold with a **worst case of −42**, and Assassinate +averaged +12.8 with two seeds outright negative. Now seeded (`statCompareRNG`) +and raised to 24000 / 6000 trials, where all 40 seeds clear (worst +267 / +68). +Assassinate's `> 0` assertion became `≥ 25`. Suite is 5/5 green. + +**Known gotchas for P6+.** `SendDM` resolves exactly one `id.UserID` to one DM +room (`plugin.go:702`); the whole digest/briefing/recap seam is +single-recipient with no fan-out anywhere. `dnd_expedition` and `dnd_zone_run` +are single-`user_id` rows, but "one active per user" is **code**-enforced +(`startExpedition`), not a schema constraint — so a membership table is clean. +Repo is not gofmt-clean at baseline (104 files); don't chase it. + +### N2 item ledger + +| Item | State | Landed in | Key symbols | Tests | +|------|-------|-----------|-------------|-------| +| B1 tempering | ✅ done | `adcc621` | `temperLadder`, `temperedRarity`, `temperedItem`, `temperStepsToLegendary`, `EquippedMagicItem.Effective`, `magicItemSellAt`, `temperEquippedItem`, `temperInventoryItem`, `handleTemperCmd` | `adventure_temper_test.go` | +| B4 achievements | ✅ done | `adcc621` | `clearedZoneIDs`, `clearedAnyZoneOfTier`, `clearedEveryZoneOfTier`, `clearedUnderThreat50`, ids `expedition_*` + `temper_legendary` | `achievements_expedition_test.go` | +| C4 arena seasons | ✅ done | `adcc621` | `arenaSeasonKey/Start/Bounds`, `previousArenaSeason`, `loadArenaSeasonLeaderboard`, `arenaSeasonChampion`, `arenaSeasonRollover` | `adventure_arena_season_test.go` | + +### N2 decisions taken (deviations from the plan above) + +- **Temper is a per-instance step count, not a rarity rewrite.** The plan + assumed a magic item had a mutable rarity. It does not: `MagicItem.Rarity` + is a field on the registry *definition*, re-resolved from + `magicItemRegistry` on every load (`magic_items_gameplay.go`), and an + equipped item is stored only as `(user, slot, item_id, attuned)`. So a + `temper` column was added to `adventure_inventory` and + `magic_item_equipped`; effective rarity is derived on read via + `temperedItem()`. The base rarity is **never** written back — that's what + makes a load/save round-trip idempotent (`TestTemperNeverDoubleBumps`). + Both ALTERs default to 0, which is correct for every pre-existing row, so + no bootstrap backfill was needed. +- **VeryRare is not a rung.** It collapses onto Epic in both + `rarityLootTierNum` and `rarityPowerScalar`, so a VeryRare→Epic temper + would change no number. The ladder is Common→Uncommon→Rare→Epic→Legendary, + which is exactly the 4 steps the plan's cost table priced. +- **Materials already drop; the plan was wrong twice.** `thyraks_core` and + `portal_fragment` are not "loot-note strings" needing promotion to real + items at ~15% from T5 bosses. They are `UniqueAlways: true` entries on the + Underforge / Abyss Portal slates (`dnd_zone.go`), materialized by + `zoneLootToInventory` (`dnd_zone_combat.go`) into real `adventure_inventory` + rows named "Thyraks Core" / "Portal Fragment" — at **100% per zone clear**, + not 15%. `rollZoneLoot` (the slate one, `dnd_zone_combat.go:360`) fires once, + on zone clear. Nothing needed adding; tempering just consumes them. + `TestTemperMaterialsAreObtainable` guards the name match against slate edits. +- **Only the Legendary rung eats a material.** Gating every rung on a T5 + material makes tempering unreachable until a player already clears T5 — + precisely when they no longer need it. Euros + Foraging gate the rest. +- **Quiet-clear requires the threat key's presence, not a low value.** + `recordMaxThreat` has exactly one production caller + (`dnd_expedition_cycle.go:148`, the day rollover) and its `cur <= prev` + guard means it never stores a zero. An empty `region_state` therefore means + "no threat samples" (a same-day clear), not "threat stayed at zero" — + reading it as the latter handed the achievement to everyone. +- **No-death-T4 achievement is NOT shipped.** There is no per-expedition + death counter — no column, no `deaths_during` field. It needs a schema bump + to be checkable and was not faked. Pick it up if/when one lands. +- **`combat_pet_save` already existed** (`achievements.go`, granted from + `combat_bridge.go`), so the plan's "pet-saved-my-life" item was already done. +- **Renown achievements skipped** — B2 (renown) hasn't shipped. +- **Arena seasons derive, they don't reset.** The plan called for a quarterly + wipe of `arena_stats`. Instead season standings aggregate `arena_history` + over `[quarter_start, quarter_end)`. Identical player-visible effect (the + board clears each quarter), but lifetime totals survive for `!arena stats` + and there is no destructive job that can double-fire or half-complete. + `renderArenaLeaderboard` now takes a season label. +- **Season crowns go to their own table, not `player_meta.title`.** That + column already carries the Survivalist milestone + (`dnd_expedition_milestone.go`); a season champion would silently destroy + someone's expedition title. New `arena_season_titles(season, kind, user_id, + value, awarded_at)`, PK `(season, kind)`, insert is `DO NOTHING` so the + rollover is safe to re-run. +- **Rollover self-heals.** `arenaSeasonRollover` runs every midnight off the + existing ticker, dedups on `db.JobCompleted("arena_season_rollover", season)`, + and refuses to close a season that hasn't ended. A bot down on Jan 1 crowns + Q4 the next time it wakes. No net-new ticker (D4 discipline). + +Open items carried out of N2: + +- **`temper_legendary` is the only new event-driven achievement** — granted + from `executeTemper`. The 11 others are passive query checks. +- **Season titles are stored but not yet surfaced.** `loadArenaSeasonTitles` + exists and is tested; nothing renders it. Natural home is `!sheet` or the + E3 `!town` registry. Cheap follow-up. +- **`!arena leaderboard` no longer shows lifetime standings.** It footers a + pointer to `!arena stats`. If that reads badly in prod, add + `!arena leaderboard lifetime` — `loadArenaLeaderboard()` is still live and + now has no caller. +- **Tempering is untested end-to-end at the handler level** — same gap as A5: + the plugin package has no Matrix client stub. `executeTemper`'s rollback + paths (euro refund + material restore) are reasoned, not exercised. +- **Prod balance note:** tempering is a euro sink with no cap on how many + items a rich player upgrades. Watch whether €150k reads as too cheap once + T5 clears are routine — the material is the real gate, at 1/clear. + +### N1 item ledger + +| Item | State | Landed in | Key symbols | Tests | +|------|-------|-----------|-------------|-------| +| A1 treasure | ✅ done | `e8f4863` | `rollAdvTreasureDropDetailed(…, weight)`, `checkTreasureDrop(…, weight)`, `rollZoneTreasure`, `advLocForZone`, `advTreasureWeight*` | `adventure_treasure_test.go` | +| A2 masterwork | ✅ done | `e8f4863` | `masterworkDefForZone`, `masterworkSlotActivities`, dungeon case in `masterworkDropFlavorText` | `adventure_masterwork_test.go` | +| A3 consumables + ingredients | ✅ done | `e8f4863` | `rollZoneIngredient`, `advIngredientDropChance`, `advIngredientActivities`, `grantZoneItem`, `joinLootLines` | `adventure_ingredients_test.go` | +| A4 milestones | ✅ done | uncommitted | `grantTwoWeeksCache`, `grantSurvivalistTitle`, `grantLongGameLegendary`, `consumableCache`, `consumableAdvItem`, `milestoneAward.Extra` | `dnd_expedition_milestone_test.go` | +| A5 lottery pot | ✅ done | `c9df282` | `communityPotAdd` in `handleLotteryBuy` | none (no client stub; see below) | +| A6 mid-day events | ✅ done | uncommitted | `maybeFireAnchoredEvent`, `claimDailyEventSlot`, `releaseDailyEventSlot`, `advEventChance{Digest,Sell,Arena}` | `adventure_events_test.go` | + +### A4 / A6 decisions taken + +- **Two Weeks pays in supplies, not stats.** The `+5 max HP` idea was + dropped: `stats.HPBonus` is built from gear/arena/housing in + `combat_stats.go:88` with no expedition in scope, and threading one + through would leak an expedition-only buff into the sim's balance + corpus. Day 15 now restocks 3 days of rations (clamped to + `Supplies.Max`) and grants 3 consumables from the zone-tier dungeon + pool. Self-expires when the run ends; zero combat math. +- **Survivalist needed no schema bump.** `player_meta.title` already + exists (`db.go:306`) and `saveAdvCharacter` → `upsertPlayerMetaMiscState` + already persists `AdventureCharacter.Title`. Sets the title + posts a + games-room notice. +- **Long Game** grants `pickMagicItemForRarity(RarityLegendary, nil)` via + `dropMagicItemLoot(…, LootTierLegendary)`, rendered in the new + `milestoneAward.Extra` block (below Notes, not italicized). +- **A6 anchors are digest 8% / sell 5% / arena cashout 5%**, with a + one-event-per-player-per-UTC-day cap. A player who hits all three lands + at ~1.19 events/week (was ~1 per 200 days). The `eventTicker` keeps only + `expireAdvPendingEvents` + `reapExpiredCombatSessions`; the whole + deferred roll-minute scheduler is gone. `tryTriggerEvent` now returns + `bool` so a bail (dead / mid-fight / event already active) hands the + day's slot back via `releaseDailyEventSlot`. +- **Digest anchor fires exactly on the digest branch.** `campDecision.Night` + is true iff `buildAutoRunDM` renders the EoD digest — including the + boss-safety camp path, which sets `Night` when past `nightCampWindow`. +- **`tryTriggerEvent` no longer gates on `HasActedToday`** — the anchor + *is* the presence signal now. +- **A6's frequency test is deterministic.** It drives its own seeded + `rand.New(rand.NewPCG(…))` over the chance constants + daily cap rather + than sampling the global RNG, so it can't join the flaky-test set below. + +**Seam of record:** everything in A1–A3 hangs off `dropZoneLoot` +(`dnd_zone_loot.go`), which now takes `(…, isBoss, isElite bool)` and is +called from 5 sites: `combat_cmd.go:290`, `dnd_zone_cmd.go:1019` + `:1124`, +`dnd_expedition_combat.go:209` + `:539`. Zone-clear's bonus treasure roll +lives in `finalizeExpeditionOnZoneClear` (`dnd_expedition_extract.go`). + +Behaviour notes worth keeping: +- Treasure near-miss DMs fire **only** for weighted moments (boss/elite/zone + clear). At ×1 on autopilot they'd land on ~3% of every kill. +- Masterwork is elite/boss only. Arena gear stays the premium line (×1.5 + effective tier vs masterwork ×1.25), so this competes with the shop. +- Consumables + ingredients roll on *any* win, independently of the zone + slate — a dry slate roll doesn't cost the player either. + +Survey corrections found while building (the plan's anchors were right; +these are things it *understated*): + +1. **A2 was a no-op as specced.** The masterwork catalog is keyed only to + mining/fishing/foraging, so `masterworkDefFor(AdvActivityDungeon, …)` + returns nil. Fixed with `masterworkDefForZone`, which rolls across all + three slot lines at the zone tier, plus a new dungeon flavor pool + (flavor now follows `loc.Activity`, not the item's catalog line). +2. **A3's ingredient audit was worse than "needs an audit."** + `combat_bridge.go`'s `resolveDungeonAction` — the only caller of + `generateAdvLoot` — itself has **zero callers**. All four legacy loot + tables were dead, so *every* ingredient was unobtainable and all 12 + recipes were uncraftable, not just the four named. Fixed by having + `rollZoneIngredient` draw from the legacy tables directly at zone tier + (15%/win), reviving them wholesale. Guard test: + `TestEveryRecipeIngredientHasALiveSource`. +3. **Pre-existing flaky tests**, unrelated to N1: + `TestResolvePlayerAttack_AssassinateBonusFirstHitOnly` and + `TestSimulateCombat_FirstAttackBonusImprovesEarlyHits` are statistical + and ride the global RNG — they fail intermittently on a *clean* tree + too. A1 perturbs RNG ordering, so it changes which one flakes. Worth + seeding them deterministically at some point. + +Open items carried out of N1: + +- **A5 has no handler-level test** — the plugin package has no Matrix + client stub, so an end-to-end `handleLotteryBuy` test would mean + building one. The change is one line after the persist guard. +- **Cartographer is still deferred** — it's map-coverage-shaped; leave it + until C5 (revisit) ships, per the original plan. +- **Rates to watch in prod:** the A6 anchor chances (8/5/5) target ~1 + event/week for a player who does all three daily. Most players only hit + the digest, so the realistic rate is ~0.56/week. Bump `advEventChanceDigest` + if that reads too quiet. +- **Rates I picked, flag if wrong:** ingredient drop 15%/win + (`advIngredientDropChance`); `RollConsumableDrop` kept its existing flat + 15%. On autopilot that's ~2–3 of each per active day. Consumables have + no tier-1 dungeon entry, so T1 zones drop none. + +Note the working tree also carries unrelated in-flight work (appservice, +link_thumbnail, safehttp, crosssigning) — stage N1 files explicitly. + +Grounded in a 3-agent codebase survey (core loop / expedition depth / +social-meta) run 2026-07-09 against HEAD `a4f162d`. + +## Executor guide (read first) + +- All `file:line` anchors below were read at HEAD `a4f162d` on + 2026-07-09. **Verify every anchor against HEAD before building on it** + (feedback_verify_audit_findings) — especially the "zero callers" + claims, which a later session may have fixed. +- Each phase (N1–N7) ships independently: code + tests + `go test ./...` + green. Within a phase, items are ordered; don't reorder across the + dependency notes. +- Every schema change ships with a one-shot bootstrap that stays in the + tree (feedback_loader_rewire_needs_bootstrap). Follow the + `bootstrap_*.go` pattern (e.g. `bootstrap_player_meta.go`). +- Flavor: check `adventure_flavor_*.go` for reusable lines before writing + new ones (feedback_reuse_existing_flavor). TwinBee lines are + first-person / implicit-subject only, he/him or they + (feedback_twinbee_voice, feedback_twinbee_is_male). +- Build/deploy: CGO=1 `-tags goolm` (Dockerfile's CGO=0 crashes). Never + deploy with an empty local `data/gogobee.db` + (feedback_empty_local_db_wipes_prod). +- Difficulty changes lift trailers, never nerf leaders + (feedback_difficulty_target). Surface verbs/outcomes, hide math + (feedback_accessibility_over_dnd_crunch). +- No new `dnd_`-prefixed files/types/tables (feedback_avoid_dnd_naming). + New files use `adventure_*` / `expedition_*` / `zone_*` prefixes. + Editing existing `dnd_*.go` files is fine. +- Misty/Arina buff mechanics are secret discovery content + (feedback_npc_buffs_are_secret) — nothing player-facing may imply + donation → buff. Flagged per-item below where it bites. + +## 0. Where engagement actually leaks today (survey findings) + +**The Phase R transition orphaned reward systems.** The legacy daily +activity loop was retired (`adventure.go:973-1022` now intercepts legacy +activity input with a deprecation DM), but three drop systems only fired +from it and now have **zero callers** (verified by call-site grep at +survey time): + +- `checkTreasureDrop` / `rollAdvTreasureDrop` (`adventure.go:1075`) — the + 26-treasure catalog (`adventure_treasure.go:55-241`), tiered drop rates + 1.5%→0.15% (`adventure_treasure.go:43-51`), auto-swap-worst with 10-min + undo, lock/unlock, T5 room announcements (`adventure.go:1075-1282`): + all unreachable. No new treasure has dropped since Phase R. Existing + owners keep bonuses via `computeAdvBonuses`. +- `checkMasterworkDrop` (`adventure_masterwork.go:161`) — 15-item MW + catalog can no longer drop (arena helms are the only live MW source). +- `RollConsumableDrop` (`adventure_consumables.go:225`) — T2–T5 drop-only + consumables (Spirit Tonic, Voidstone Shard, …) unobtainable except + crafting; several craft ingredients ("Goblin Trinket", "Blooper Ink", + "Ancient Artifact", "Dragon Scale") live in legacy loot tables + (`adventure_activities.go:133-163`) whose only live caller is + `combat_bridge.go:555` — whether they still flow needs an audit. + +**Headline rewards are stubbed.** The Long Game (T5 completion) legendary +grant is "deferred to loot-grant hookup" +(`dnd_expedition_milestone.go:199`); Survivalist title, Two Weeks stat +bump, and the Cartographer milestone are likewise unwired. Zone loot +notes reference legendary crafting materials (`thyraks_core`, +`portal_fragment`) whose recipes don't exist. + +**Endgame is thin.** L20 cap at 85,000 cumulative XP (`dnd_xp.go:50`), no +prestige. Gear best-in-slot is deterministic — one Legendary per slot, +rarity-scalar formulas in `magic_items_gameplay.go:125-167` (Legendary +weapon +25% dmg, armor −20% taken, wondrous +10 HP + init), 3-attunement +cap (`dndMagicItemAttuneLimit`) — reachable well before content runs out. +Post-cap loops: repeat T5, arena streaks, collection. T5 zones are +explicitly raid-shaped (`raidContentWarning`, +`dnd_expedition_cmd.go:358-373`) with multiplayer referenced in copy but +unbuilt. + +**Money stops mattering.** Houses T3 (€300k) / T4 (€600k) +(`adventure_housing.go:22-57`, +11% fees) buy zero mechanics — the whole +housing payoff is the T2 pet gate (`petShouldArrive`, +`adventure_pets.go:193-207`). Shop says "nothing left to buy" at max +(`luigiMaxedOut`, `adventure_shop.go:182`). One buyable consumable +(Berry Poultice, `adventure_consumables.go:50`). + +**No player-to-player play.** No item trading/gifting (only +`!baltransfer`, `euro.go:511-543`), no co-op, no player-initiated duels +(rival RPS is system-paired, asymmetric — `adventure_rival.go:325-380`), +no seasons. Rich `player_meta` data (tax_ledger, rival records, +death/grudge fields, pets, hospital visits) is persisted but unsurfaced. + +**Events players never see.** Mid-day events roll 0.5%/player/day with a +2h expiry (`adventure_events.go:131`) — one sighting every ~200 days. + +**Bug found in passing:** `!lottery pot` copy (`lottery.go:190-191`) says +ticket sales fund the pot, but `handleLotteryBuy` (`lottery.go:111`) only +debits the player — ticket euros are burned, never credited to +`community_pot`. + +--- + +## Track A — Rewire the orphans (quick wins, do first) + +The cheapest engagement per session in the plan: the content is already +written and tuned — it just lost its call sites. + +### A1 — Treasure drops → expedition beats +**Hook:** the combat-win loot seam in `dnd_zone_loot.go` (where rarity is +derived from enemy CR) and the zone-clear path +(`finalizeExpeditionOnZoneClear`, `dnd_expedition_extract.go:126-171`). +Call `checkTreasureDrop(userID)` after the normal loot roll with a +room-type weight: boss kill ×4, elite ×2, secret room ×2, standard ×1, +zone clear = one bonus roll. Base rates stay the existing tiered +1.5%→0.15% table. The auto-swap/undo/lock/announce machinery +(`adventure.go:1075-1282`) is reused untouched — it takes over once the +drop lands. +**Accept:** a sim or test run through a boss kill produces a treasure at +forced-RNG; `!adventure treasures` shows it; T5 announce fires in the +games room. ~0.5 session. + +### A2 — Masterwork drops → elite/boss kills +Same seam as A1: `checkMasterworkDrop` on elite/boss wins only. Keep +arena gear as the ×1.5 effective-tier line and MW as ×1.25 +(`advEffectiveTier`, `adventure_character.go:381`) so arena stays +premium. **Accept:** forced roll grants an MW item; `!adventure equip` +lists it; blacksmith bills it one tier up +(`adventure_blacksmith.go:15-40` already handles this). ~0.25 session. + +### A3 — Consumable drops + ingredient audit +Hook `RollConsumableDrop` into the same combat-win seam (any win, not +just elite/boss). Then audit: do "Goblin Trinket"-class ingredients +still reach players via `combat_bridge.go:555`? If not, add them to the +per-zone loot slates in `dnd_zone_loot.go` keyed by zone tier matching +the legacy table tiers (`adventure_activities.go:133-163`). +**Accept:** every recipe in `adventure_consumables.go` has all +ingredients obtainable from at least one live source; add a test that +walks the recipe list and asserts each ingredient appears in some live +loot table. ~0.5 session. + +### A4 — Wire the stubbed milestone rewards +In `dnd_expedition_milestone.go`: +- **Long Game** (T5 completion): grant a guaranteed Legendary roll from + the cleared zone's slate — call the existing magic-item grant path used + by `dnd_zone_loot.go` with rarity forced to Legendary. +- **Survivalist** (T3+ completion): write title to `player_meta.title` + + games-room announce. +- **Two Weeks** (day 15): smallest defensible bump — +5 max HP for the + remainder of that expedition (apply via the expedition row, not the + character, so it self-expires). +- **Cartographer:** leave deferred unless C5 (revisit) ships — it's + map-coverage-shaped. +**Accept:** milestone tests extended to assert the grants. ~0.5 session. + +### A5 — Lottery pot fix +Route ticket purchases into `communityPotAdd` (`handleLotteryBuy`, +`lottery.go:111`; pot helper lives in `adventure_rival.go:193-241`). +Mechanic-matches-copy, and strengthens the pot flywheel. Watch +pot-drain-vs-rake per project_lottery_economics — if weekly pot growth +jumps, tune prize tiers, not the ticket price. One-liner + test. + +### A6 — Mid-day events resurrection +Retire the 0.5% daily roll in `adventure_events.go:131`. Re-anchor the +existing event content to moments the player is present: +- end-of-day digest (`expedition_autorun_digest.go`): ~10% chance an + event rides along at the bottom; +- post-`!sell` at Thom's (`adventure_shop.go:833-939` sell path); +- arena cashout (`arenaCompleteSession`, `adventure_arena.go:518`). +Keep the existing 2h response window + `!adventure respond` plumbing. +**Accept:** target rate ~1 event/week for a daily player; add a +frequency test with fixed RNG. ~0.5 session. + +**Track A total ≈ 2.5 sessions.** Ship as one phase; announce nothing — +players just start finding things again. + +--- + +## Track B — Endgame & the item chase + +### B1 — Legendary crafting ("tempering") +**Shape:** at the blacksmith (`adventure_blacksmith.go`, flavor in +`adventure_flavor_blacksmith.go`), `!adventure temper ` upgrades an +owned magic item one rarity step, capped at Legendary. Cost: one +legendary material (`thyraks_core` / `portal_fragment` — currently +loot-note strings in the T5 zone tables in `dnd_zone.go`; promote them to +real inventory items dropping from T5 bosses at ~15%) + euros scaled by +target rarity (suggest 10k/25k/60k/150k for Uncommon→Rare→Epic→Legendary) ++ a Foraging-level gate (reuse the recipe-tier gates at 10/15/20/25/30, +`adventure_consumables.go:307-338`). +**Why tempering over unique recipes:** every duplicate drop becomes a +potential input, and the blacksmith gets an endgame role. Rarity change +flows through the existing scalar formulas in +`magic_items_gameplay.go:125-167` — no new combat math. +**Accept:** temper path test (material + euros debited, rarity bumped, +attunement preserved); ceiling unchanged (cap at Legendary means B1 +accelerates reaching the existing BiS, doesn't raise it — no re-baseline +needed). ~1.5 sessions. + +### B2 — Renown (prestige without reset) +No character wipe — wiping fights the multi-day expedition identity. +At L20 (`dnd_xp.go:50`), overflow XP converts to **Renown** at a steep +rate (suggest 25,000 XP/renown level; hook where `checkAdvLevelUp` / +`dnd_xp.go` clamps at cap). Renown grants: +- title ladder announced in the games room (write to + `player_meta.title`), +- a cosmetic marker on `!sheet` and the leaderboard + (`adventure_render.go:1028-1032`), +- one small account perk per 3 renown levels drawn from the existing + streak-grant vocabulary (+loot quality, −death penalty — + `computeAdvBonuses`, `adventure_activities.go:322-337`), capped so + renown ≤ streak-30 in total power. Never combat-stat inflation — the + balance corpus stays valid. +**Schema:** `renown_level`, `renown_xp` on `player_meta` + bootstrap. +~1 session. + +### B3 — Weekly mutators ("the Omen") +One rotating world modifier per ISO week, announced by TwinBee Monday +morning (first-person voice, via the existing morning DM seam in +`adventure_scheduler.go`). Implementation: new +`adventure_omen.go` with a table keyed by `(year, isoWeek) % len(table)` +so it's deterministic with no schema. Read it at the seams holidays +already use — the pattern is exactly: +- supply freebie: `dnd_expedition_cmd.go:300-307` +- expedition mood: `dnd_expedition.go:160-162` +- harvest yield: `dnd_expedition_harvest.go:463-465` +Launch set (all buffs-with-texture or trade-offs, never pure difficulty): +elites drop double loot but +2 ATK; threat drifts −25% overnight; harvest ++1 yield; arena payouts +20%; consumable drops ×2. ~1 session. + +### B4 — Achievements refresh +`achievements.go` defs are chat-centric (adventure ones at lines +753-1133 are mostly streak/death). Add an expedition wing, all passive +checks against existing tables: first clear per tier (5), both-zones-set +per tier (5), no-death T4 clear, threat-under-50 clear (pairs with the +Patient Zero milestone), pet-saved-my-life (ditch recovery fired), +tempered-to-Legendary, renown 1/5/10. ~0.5 session. + +--- + +## Track C — New modes + +### C1 — Co-op expeditions ("parties") — the marquee feature +Already promised in shipped copy ("Until multiplayer expeditions +ship…", near `raidContentWarning`, `dnd_expedition_cmd.go:358-373`). +Scope v1 tightly: + +**Player surface:** `!expedition invite @user` after `!expedition start` +but before the first walk; party of 2–3; invitee confirms with their own +loadout purchase (reuses `resolveLoadoutOrParse`). `!expedition party` +shows roster. Leader = inviter; leader gets fork prompts (8h auto-pick +unchanged, `forkAutoPickTimeout`, `dnd_expedition_cmd.go:676`). + +**Model:** one shared zone run + threat clock + day counter; supplies +pool (sum of purchases; burn = per-tier rate × party size × 0.8 so +parties are slightly supply-efficient). One expedition row remains the +source of truth; members reference it. +**Schema:** `expedition_party(expedition_id, user_id, role, joined_at)` ++ `party_id` nullable on the expedition row + one-shot bootstrap +(existing solo expeditions get no party row — code treats absent as +solo). + +**Combat:** the turn engine already loads multiple combatants per +session (`combatantsForSession` — note its known 6-load DB chatter, +project_combat_session_cache_deferred; don't make it worse, fix it here +if convenient). Extend `combat_session_build.go` to seat N player +characters; initiative interleaves; each member acts on their own turn +via DM (`!attack`/`!cast`/…) with a 60s per-turn timeout falling back to +the autopilot picker (`simPickCombatAction` already encodes sane +class-aware choices). Autopilot walks resolve party combat fully +automatically, same as solo. + +**Death/extract:** member death → pet-rescue/hospital as solo, expedition +continues for the rest; `!extract` is leader-only and party-wide; +forced-extract rules unchanged. + +**Loot/XP:** loot rolls per-member independently (no split arguments, no +dependency on trading); XP full to each member (accessibility over +crunch); milestones to all. + +**DM surface:** each member gets their own end-of-day digest; walk +stream stays suppressed per D4 rules. No net-new tickers. + +**T5 answer:** T5 is raid-shaped by design; a 2–3 player party at L15+ +should hit the D11 61–79% leader band **without touching monster +tuning**. Before shipping, extend `cmd/expedition-sim` with a `-party N` +flag (seat N clones or N distinct classes) and validate the band +empirically. If parties overshoot (>90% T5), add a party-size monster HP +scalar (+35%/member) — HP only, never damage, per the difficulty rule. + +**Accept:** sim clears a full T4 with a 2-party, zero manual commands; +solo path regression suite untouched; party of 2 casters beats their +solo baseline materially (this is also a trailer lift). +**~4–5 sessions. Biggest item; highest ceiling.** New files: +`expedition_party.go`, `expedition_party_test.go`. + +### C2 — Player-initiated duels +`!duel @user [stake]` — real turn-based PvP through the existing turn +engine (`combat_turn_engine.go`), fought "at the arena" (flavor), best +of 1, **no death**: loser yields at 0 HP — no hospital bill, no respawn +timer, no death-source stamp. Stake escrowed on accept; winner 70%, pot +30% (mirrors rival economics, `adventure_rival.go:638-646`). 24h accept +window; per-pair 7-day cooldown (reuse the rival pair-cooldown pattern); +babysit auto-declines (parity with `adventure_rival.go` RPS). Results +write to `adventure_rival_records` so one W/L history covers both duel +kinds. +**Balance caveat:** PvP exposes class-vs-class deltas the PvE corpus +never measured. Frame as bragging rights; cap stakes at level×€500 so +imbalance can't be farmed; explicitly out of scope to balance classes +for PvP. New file: `adventure_duel.go`. ~2 sessions. + +### C3 — World boss ("the Siege") +Monthly communal event, announced in the games room: a named boss +"camps outside town" for 72h with a shared HP pool. +**Model:** new table `world_boss(id, name, hp_max, hp_current, starts_at, +ends_at, status)` + `world_boss_contrib(boss_id, user_id, fights, damage)`. +Each player gets one fight per day against it — an arena-style bout +through `runZoneCombat` with a boss template scaled off the *median* +active-player level (reuse the TwinBee tier-selection idea, +`adventure_twinbee.go:22-91`); player damage dealt is subtracted from the +pool win or lose. +**Resolution:** boss falls → payout to every contributor scaled by +*fights fought, not damage share* (accessibility; reuse the TwinBee +distribution shape, `adventure_twinbee.go:239-320`), plus a consumable +cache (A3 pool) and one low-rate treasure roll each (A1). Boss survives → +"it loots the town": community pot pays a visible tribute +(`communityPotDebit`) — a pot **sink**, which the economy needs. +**Scheduling:** ride the existing 1-minute ticker + `db.JobCompleted` +dedup pattern in `adventure_scheduler.go`; no new ticker process. New +file: `adventure_worldboss.go`. ~2 sessions. + +#### C3 build plan (N6, branch `n6-rivalry-siege`) + +**Decisions locked (user, 2026-07-10):** +- **Spawn = both.** Auto on the 1st of each UTC month (dedup + `db.JobCompleted("worldboss_spawn_"+YYYY-MM)`) **plus** an operator + override `!adventure worldboss spawn`. +- **Defeat payout = minted** (`p.euro.Credit`, faucet) + item cache + + one treasure roll per contributor, scaled by **fights fought** (not + damage — accessibility). **Survive = pot tribute** (`communityPotDebit`, + a sink). So the boss is faucet-on-win / sink-on-loss; the item drops are + the only guaranteed mint. +- **HP = real cost, like the arena.** No HP restore; the bout persists HP + the way `resolveArenaBoss` does. No death / no hospital, though — mirror + arena's no-permadeath, not the expedition death path. + +**Seams confirmed (2026-07-10 subagent map):** +- Single bout: `runZoneCombat(userID, monster DnDMonsterTemplate, tier, + bossCombatPhases, dmMood)` → `CombatResult`. Damage to boss = + `EnemyEntryHP - EnemyEndHP`. `runZoneCombatRoster` persists HP + grants + XP internally — same as the arena, which is what "real HP cost" rides. +- Boss template: build a `DnDMonsterTemplate` directly (per-fight enemy is + disposable; the shared pool lives in `world_boss.hp_current`). Scale off + `arenaTierBaseStats(tier)` for the per-fight stat block. +- Ticker: add `p.worldBossTick()` to the `eventTicker` `case <-ticker.C:` + block (`adventure_events.go:~110`). No new goroutine. +- Games room: `gr := gamesRoom(); if gr == "" { return }; p.SendMessage(gr, …)`. +- Pot: `communityPotAdd/Balance/Debit(int) bool`; debit returns false if short. +- Active players: `SELECT DISTINCT user_id FROM daily_activity WHERE date >= cutoff` + (any-chat presence, feedback_presence_is_any_chat). No median helper exists — + reduce combined level (`dndLevelForUser + Mining+Foraging+Fishing`) myself. +- Rival split reference is actually winner-30% / pot-70% (`adventure_rival.go:638`, + the plan's "70/30" is inverted) — not reused here since defeat is minted. + +**Isolation:** own tables `world_boss` + `world_boss_contrib`, own load/upsert, +ticker-driven, **outside** the `saveAdvCharacter` fan-out — same structural +isolation `adventure_shadow` earns (a char save can't clobber the pool). No +bootstrap: absent active row == no event. + +**Phase ledger:** + +| Phase | State | Notes | +|-------|-------|-------| +| W1 model + spawn + lifecycle | ✅ `c3e1226` | schema, `worldBossState`/contrib load+upsert, median scaling, `spawnWorldBoss`, `worldBossTick` (auto-spawn 1st + 72h expiry → resolution), games-room announce, resolution (defeat mint / survive tribute). No player fight yet. Pure logic unit-tested; golden byte-identical; suite green. | +| W2 fight + contrib + command | ✅ `127d252` | `!adventure worldboss [status\|fight\|spawn]` (alias `siege`), once/day gate (`advUserLock`-serialised), arena-style solo bout via `runZoneCombat`, atomic pool subtract, contrib record, no-death HP floor (`worldBossFloorHP`), defeat trips resolution after the bout DM. Bout core factored out + unit-tested with a real fightable char; suite green. | + +**Tuning knobs to watch in prod:** `worldBossDefeatBaseEuro` (1500/fight — +minted faucet; a 10-player town clearing it ≈ €45k/mo minted), `worldBossBoutsPerPlayer` +(2.0 — pool size vs turnout), `worldBossTributePct` (0.20 — pot sink on survive). +All consts in `adventure_worldboss.go`. Not deployed (operator's schedule). + +**Deliberately not built (candidate W3 / fold-in):** morning-briefing line while a +Siege is live (the Shadow has one; the spawn announce is games-room only right now), +and surfacing contribution in `!town`. Both cheap, both ride existing seams. + +### C4 — Arena seasons +Quarterly reset of the arena leaderboard (`!arena leaderboard`, +`adventure_arena.go:52`) with a season title for top streak + top +earnings, archived to `player_meta` (`season_titles` JSON or a small +table). Announce season start/end via the evening summary seam. +~0.5 session. + +### C5 — Revisit/backtrack (R1–R5) +Fully specced in `gogobee_revisit_plan.md` — `rooms_traversed` schema +split, `!revisit ` reverse-edge walk at reduced cost (threat +1 / SU +−0.5 vs +2/−1.0), fork re-pick, autopilot guard. **Sequencing note:** do +R1 (the `CurrentRoom` → `RoomsTraversed` audit) *before* C1 if possible — +party navigation should land on the reworked model, not be rewritten +after. ~3–4 sessions per its own estimate. + +--- + +## Track D — Story & world + +### D1 — The Campaign: a serialized zone arc +The 10 zones are mechanically linked but narratively disconnected. Add a +light campaign layer: a named antagonist (working name: **the Hollow +King**) threading T1→T5. Delivery uses only existing seams — no new +commands except one viewer: + +- **Journal pages:** a loot-note class dropping from elites/secret rooms + (hook = A1's seam), ~24 numbered fragments. Tracked as a bitmask/JSON + in `player_meta`; `!adventure journal` renders found pages in order + with gaps shown as "…". +- **Boss epilogues:** each zone boss death gets 2–3 lines tying it to the + arc — append to the boss-kill narration path (`renderBossOutcome` / + zone narration in `dnd_zone_narration.go`), flavor lives in a new + `adventure_flavor_campaign.go`. +- **Finale:** all pages + both T5 zones cleared → unlocks a one-room + epilogue encounter (variant of an existing T5 boss stat block from + `dnd_zone.go`, new name/flavor, no new mechanics) via + `!expedition start epilogue`; reward = unique title + one Legendary + roll. +- TwinBee reacts to newly-found pages in the morning digest — + first-person, curious, one line, never expository. +~1 session of plumbing + flavor passes that can ride along with any +phase. ~2 sessions total. + +### D2 — NPC arcs +- **Misty:** 3 dialogue stages at 5/15/30 encounters + (`MistyEncounterCount` already tracked, `adventure_npcs.go`); the + existing housing hint (`adventure_pets.go:422-430`) and pet + reactivation (`mistyReactivatePet`, `adventure_pets.go:437-439`) become + stage beats. **Fiction deepens; mechanics stay hidden** — no new copy + may connect donations to combat outcomes. +- **Robbie:** every ~10th visit (`RobbieVisitCount`, + `adventure_robbie.go`) he *leaves* a consumable from the A3 pool — "for + the trouble." +- **Thom:** on final mortgage payoff (`adventure_housing.go:553-576` + region), one letter + a pet treat. Voice from + `thom_krooke_mortgage_flavor.go`. +- **Pete bot** stays deferred (Phase 16) — out of scope. +~1 session, mostly flavor. + +### D3 — The Shadow ✅ SHIPPED + MERGED (N6, `cc165be`, no deploy) + +Built as spec'd: per-player `adventure_shadow` table (isolated from the +player_meta save fan-out so the ticker can't be clobbered; no bootstrap), +advanced once/UTC-day by `midnightReset` at ~1.3× the player's clear pace, +lead-capped so it stays a race. Surfaces: morning-briefing race one-liners +(TwinBee voice) + a zone-clear payoff in `finalizeExpeditionOnZoneClear` — a +set-once bonus-XP crow when the player was first, or a guaranteed D1 journal +page waiting in the boss room when the Shadow cleared first. `!adventure shadow` +status view. 3-finder+verify review folded in (crow farm, page-loss-on-transient, +false +XP claim all fixed). Combat golden byte-identical. Original spec below. + +Per-player persistent NPC (name seeded from the player's, stored in +`player_meta`) who "runs" the same zones on a simulated schedule — a row +with `current_zone`, `day_counter`, `zones_cleared`, advanced by the +midnight ticker (~1.3× the player's own clear pace so it stays close). +Surfaces: +- morning briefing/digest one-liners ("passed the Shadow's camp — cold + ashes, two days old") — TwinBee voice rules apply; +- player clears a zone first → small XP crow; Shadow clears first → a D1 + journal page waits in that boss room (tie-in). +Pure simulation theater — no combat, no punishment, race pressure only. +New file: `adventure_shadow.go`. ~1.5 sessions. + +### D4 — Secret content pass ✅ SHIPPED (N5, branch `n5-hollow-king`) + +Built: secret rooms now resolve as **no-combat treasure caches** (was: they +silently collapsed to a normal exploration fight; `LootBias` was dead code). +Each pays a guaranteed journal page + LootBias-weighted treasure roll + +guaranteed zone-tier consumable cache. Underdark got its missing secret +(throne_gallery fork → Lost Reliquary). Two cross-zone keys: Sunken Sigil +(Sunken Temple → Manor `sealed_reliquary`) and Underforge Seal (Underforge → +Underdark `sealed_forge_vault`). Seam is `resolveRoom` → `resolveSecretRoom` +(shared by manual/autopilot/sim); flavor + key catalog in +`adventure_flavor_campaign.go`. Graph validator + no-soft-lock pass; combat +golden byte-identical. Original spec below. + +Zone graphs already support `secret` node kinds and +perception/key/stat-check edge locks (`zone_graph.go`, per-zone +`zone_graph_.go`). Add one secret room per T2+ zone (loot: A1 +treasure roll + a D1 journal page) and two **cross-zone keys** (item +found in one zone unlocks an edge in another — e.g. a Sunken Temple +sigil opens a Manor Blackspire vault). Run the existing graph validator ++ the no-soft-lock test (added after the feywild fork1 incident) on +every touched graph. Makes revisit (C5) and re-running lower tiers +meaningful. ~1 session + graph edits. + +--- + +## Track E — Social & economy + +### E1 — Housing T3/T4 payoffs +- **T3 "Fine home":** **trophy room** — treasure cap 3→4 where the 4th + slot is house-bound (active only while owned; enforce in + `computeAdvBonuses`); **workshop** — +5% craft success at home (hook + the success roll in `adventure_consumables.go:307-338`). +- **T4 "Estate":** **vault** — 10-slot item storage + (`!adventure vault [store|take] `; prerequisite plumbing for E2); + long-rest at home counts as inn-quality (hook `dnd_rest.go` where + housing already grants HP bonus — `adventure_character.go:279`); + **second pet slot** — a second arrival roll unlocks via + `petShouldArrive` (`adventure_pets.go:193-207`) with `HouseTier >= 4`; + both pets roll combat procs at half weight (halve the per-level + scalars in `adventure_pets.go:62-94` when two pets are active) so it's + flavor-forward, not a stat spike. **✅ SHIPPED `57a0ea9`** — combat seam + was `combat_stats.go` DerivePlayerStats (not the dead `petRollCombatActions`); + two pets *average* their procs (byte-identical for one), morning-defense / + ditch-recovery / supply-shop kept pet-1-only. N4 now complete. +Turns €900k of dead prestige tiers into the game's biggest sinks. +**Schema:** vault table + pet-2 fields on `player_meta` + bootstrap. +~1.5 sessions. + +### E2 — Item gifting +`!give @user` for consumables and unequipped non-magic gear. 5% of +item value to the community pot (`communityTax` pattern), daily cap of 3 +gifts/sender (twink-funnel guard), recipient must have done `!setup`. +No magic-item gifting in v1 — attunement/BiS economy stays personal. +~1 session. + +### E3 — Surfacing the buried social data ✅ SHIPPED (`27d6bfd`, N4) + +`!town` / `!graveyard` / `!rivals board` — read-only boards off +`player_meta` / `tax_ledger` / `adventure_rival_records`. No schema, golden +byte-identical. Leak-check verified at HEAD (tax_ledger carries zero +Misty/Arina donation signal). `adventure_town.go` (+test). Gotcha logged: +`MAX(datetime)` loses SQLite type affinity — parse the text by hand. Remaining +N4 work: only E1's T4 second pet slot (invasive). Original spec below. + + +- `!town` — town registry: civic-pride board (lifetime `tax_ledger` + contributions), housing street (name + house tier only), pet showcase + (name/type/level/armor from `player_meta`). +- `!graveyard` — recent deaths with `death_source`/`death_location` from + `player_meta`; the grudge-revenge bonus already exists + (`adventure_activities.go:340-343`) — this just makes the story + visible. Comedic-respectful tone; reuse hospital flavor voice. +- `!rivals board` — `adventure_rival_records` as room-wide standings + (today it's per-user only, `adventure_rival.go:858-900`). +**Leak check:** nothing here may rank or badge Misty/Arina donation +counts — a "generosity" board would let players correlate donations with +performance and reverse-engineer the secret buffs. Tax-ledger totals are +safe (dominated by gambling rake); donation counts are not. ~1 session. + +### E4 — Seasonal events +Pick 3–4 anchor holidays/year (calendar engine already computes ~35, +`adventure_holidays.go:34-101`) and attach a 1-week skin: a themed +mutator (B3 machinery with a holiday override slot), a limited-time curio +shelf at Luigi's (the daily-seeded shelf pattern, `dailyCuriosStock`, +`adventure_shop.go:1123-1166`, with a holiday item pool), and one holiday +variant elite seeded into active expeditions (reskin an existing stat +block, spawn via the ambient-event seam in `expedition_ambient.go`). +~1 session of machinery once B3 exists. + +--- + +## Sequencing + +Phases named to avoid the dnd prefix; each ships independently. + +| Phase | Contents | Sessions | Why this order | +|-------|----------|---------:|----------------| +| **N1 — Restoration** | A1–A6 | ~2.5 | Pure recovered value; B1, C3, D1, D4, E1 all assume drops flow again | +| **N2 — The Chase** | B1 tempering, B4 achievements, C4 arena seasons | ~2.5 | Endgame chase online before new modes bring players back | +| **N3 — Parties** | C1 (+ sim `-party` flag; ideally C5's R1 schema pass first) | ~4–5 | Marquee; the designed answer to raid-shaped T5 | +| **N4 — The Town** | E1 housing, E3 registries, E2 gifting | ~3.5 | Money sinks + social surface while party meta settles | +| **N5 — The Hollow King** | D1 campaign, D2 NPC arcs, D4 secret pass | ~4 | Story layer; journal pages want A1/D4 loot seams live | +| **N6 — Rivalry & Siege** | C2 duels, C3 world boss, D3 the Shadow | ~5.5 | PvP/communal modes; benefit from N3's session-build work | +| **N7 — Living World** | B2 renown, B3 mutators, E4 seasonal events | ~3 | Long-horizon retention; mutator machinery feeds E4 | +| *(flex)* | C5 revisit R2–R5 (R1 pulled earlier if N3 needs it) | ~3.5 | Slot anywhere after N1 | + +~28–30 sessions at listed scope. N1 first is unambiguous. If trimming to +a 10-session core: **N1 + B1 + C1** — fixes the leak, gives the endgame a +chase, delivers the promised multiplayer. + +## Cross-cutting risks + +- **Balance corpus staleness:** C1 parties shift effective power; + re-baseline with the existing sim harness after N3 (current corpus of + record: `sim_results/d8prereq_corpus.jsonl` lineage — check memory for + the latest before diffing). B1 tempering caps at Legendary so it only + accelerates reaching the existing ceiling. +- **DM volume:** every new system rides existing surfaces (digest, + morning briefing, evening summary, games room). Hard rule: **no + net-new scheduled DM in any phase** (D4 discipline; + feedback_skip_recaps). +- **Community pot flows:** A5, C2, C3, E2 all touch pot in/out. Keep the + `tax_ledger` pattern for every new flow; after N6, run a + drain-vs-rake review like the lottery pass + (project_lottery_economics). +- **Secret-buff leakage:** E3 and D2 are the danger zones — review both + against feedback_npc_buffs_are_secret before ship. +- **Schema bumps** (party, vault, world boss, renown, journal, shadow): + each ships with a one-shot bootstrap kept in-tree + (feedback_loader_rewire_needs_bootstrap). +- **Zone graph edits** (D4, E4): always run the graph validator + the + no-soft-lock test (feywild fork1 precedent, + project_feywild_fork1_softlock). +- **Presence semantics:** any "active player" gating (C3 contribution, + B3 announcements) uses the any-chat presence definition + (feedback_presence_is_any_chat), not bot-command activity. diff --git a/gogobee_mischief_plan.md b/gogobee_mischief_plan.md new file mode 100644 index 0000000..5b9b348 --- /dev/null +++ b/gogobee_mischief_plan.md @@ -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 1–5 (`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.8–2.3k, mid-tier €20–500, casuals <€10. Reference +sinks: lottery ticket €100, duel escrow €1.5–5k, daily share ~€455, endgame +shop items €11–45k. 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 (0–14% 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 +'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 +`@:` (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. diff --git a/gogobee_revisit_plan.md b/gogobee_revisit_plan.md new file mode 100644 index 0000000..df19981 --- /dev/null +++ b/gogobee_revisit_plan.md @@ -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 `** — 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 ` (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 +~10–15 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 (~10–15).** +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 ` (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 R1–R4 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: ~3–4 working sessions. diff --git a/internal/db/db.go b/internal/db/db.go index 018add9..e8d6f49 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 { diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index 155eb6d..7d64ccb 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -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 "::"; 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 { diff --git a/internal/plugin/pete_games.go b/internal/plugin/pete_games.go new file mode 100644 index 0000000..d33f82f --- /dev/null +++ b/internal/plugin/pete_games.go @@ -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) +} diff --git a/internal/plugin/pete_games_test.go b/internal/plugin/pete_games_test.go new file mode 100644 index 0000000..7698754 --- /dev/null +++ b/internal/plugin/pete_games_test.go @@ -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) + } +} diff --git a/main.go b/main.go index 4ba7f02..48342ef 100644 --- a/main.go +++ b/main.go @@ -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)) diff --git a/pete_adventure_news_plan.md b/pete_adventure_news_plan.md new file mode 100644 index 0000000..0a53b63 --- /dev/null +++ b/pete_adventure_news_plan.md @@ -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/` / 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? diff --git a/pete_adventure_news_voice.md b/pete_adventure_news_voice.md new file mode 100644 index 0000000..5493191 --- /dev/null +++ b/pete_adventure_news_voice.md @@ -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 (L20–49). Risk is *emptiness*, not spam; +near-inclusive. + +Observed (Apr–Jul 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:` 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.