Files
gogobee/gogobee_engagement_plan.md
prosolis 6d402343e6 games: gogobee learns to poll Pete for money it has to move
Pete holds the chips; we hold the euros and are the only one who can move
them. This is the loop that turns an escrow row on games.parodia.dev into a
real balance change here.

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

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

First GET gogobee has ever made to Pete.
2026-07-13 23:00:11 -07:00

93 KiB
Raw Blame History

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. N1N7 are all shipped, merged to main, and live in prod as of 2026-07-11 — prod jumped 69 commits (c07d228f4a4c0d), 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 P1P8; 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 PlayerActionrunPartyCombatRound, 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 812 sessions, not 45. 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, 2N1 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 autoDriveCombatstartPartyCombatSession. 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 23 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 4767%
party 3 fighter 100% 0% 0% 0 / 120 seats
solo cleric 1347%
party 3 cleric 3353% 1340% 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 6179% 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 3353% 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 P2P6d.
  • 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 4773% / cleric 2033%, 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 4773%
solo cleric 2033%
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, 2N1 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, ~6075% (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 voluntaryExtractextracting 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 <kind>, !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 saveHarvestNodespersistRegionState 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 !resumes. 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 floatint(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 *Combatants it used to discard (CombatSeatSetup.C), which also kills the second full roster rebuild the opening block was doing. handleFightCmd then settleCombatSessions 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 startCombatSessionstartPartyCombatSession. 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 60120s, 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 lifetimeloadArenaLeaderboard() 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 saveAdvCharacterupsertPlayerMetaMiscState 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 A1A3 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 ~23 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 (N1N7) 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) — T2T5 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 <item> 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 23; 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 23 player party at L15+ should hit the D11 6179% 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). ~45 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 (R1R5)

Fully specced in gogobee_revisit_plan.mdrooms_traversed schema split, !revisit <N> 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 CurrentRoomRoomsTraversed audit) before C1 if possible — party navigation should land on the reworked model, not be rewritten after. ~34 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 23 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 resolveRoomresolveSecretRoom (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_<zone>.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] <item>; 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 <item> @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 boardadventure_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 34 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 A1A6 ~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) ~45 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 R2R5 (R1 pulled earlier if N3 needs it) ~3.5 Slot anywhere after N1

~2830 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.