Files
gogobee/gogobee_code_review_followups.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

44 KiB
Raw Permalink Blame History

Code review follow-ups — n1-restoration (2026-07-10)

Review scope: git diff main...HEAD, 99 files / ~16.7k lines (N1, N2, R1, R2, N3 P1P7). Method: 8 finder angles → dedup → 1 verifier per correctness candidate. Baseline before and after: go build, go vet, go test ./internal/... all green.

Working-tree doc — do not commit (per project convention on gogobee_*.md).

Status. Fixes 15 shipped in 1f21156. Deferred items A and B fixed 2026-07-10 (d76c63b); A turned out to have a deeper root cause and surfaced three new findings, M, N and O. Item C fixed 2026-07-10 — its premise was wrong and the bug underneath it was worse (the seven-day resume window was never enforced; see below). Item M fixed 2026-07-10 — two of its three claims were wrong (the mage hooks do run on the turn path); the one real defect, Grim Harvest, had a different cause than recorded. Items D, I, L, N fixed. Item K verified 2026-07-10 — the anchor gap is a real, confirmed narrowing, left as an owner design call (no code change). Item H measured 2026-07-10 and declined — its "per-message cascade" premise was overstated, the hot path is already optimized, and the proposed MessageContext thread-through is disproportionate churn with a staleness regression surface.

All correctness work is committed. Everything remaining is deliberate, not a defect: E (intentional, roster-size death rule), F (grantAutorunGrace stopgap, deferred to R5 by design), G (party-combat DB chatter — a perf refactor whose only safe fix is a fight-lifetime roster cache, i.e. the same staleness surface H was declined for; deferred), H (declined, above), J (!sell anchor — PLAUSIBLE, presence == any chat, left per convention), K (owner design call, above), O (already fixed as a side-effect of A; only the sim re-baseline is pending, which is operational).

Method note. Both C and M were mis-diagnosed in the same way: the finder read one call site, concluded "this is the only caller", and wrote it down. Verify the callers before planning the fix.

Third pass (2026-07-10, 88c5fcd). /code-review high --fix over the pushed follow-up stack (git diff origin/main...HEAD, 7 commits). Five fixes shipped; they harden the item-C extraction work and the item-N Misty ordering rather than opening new ground. See "Third pass" below.


Before you commit: split the formatting from the fixes

A repo-wide gofmt -w ./internal ./cmd ran after the review. It reformatted 105 files that have nothing to do with these findings — they were already non-conforming on main (struct-field alignment, mostly), and gofmt is semantics-preserving, so nothing changed behaviourally.

Net state: 117 files dirty, only 13 carry review fixes. Commit them apart, or the fixes become unreviewable inside a wall of realignment.

The 13 fix-bearing files:

internal/plugin/adventure.go               *  internal/plugin/dnd_expedition_supplies.go  *
internal/plugin/adventure_arena_season.go     internal/plugin/dnd_zone_cmd.go             *
internal/plugin/combat_cmd.go                 internal/plugin/expedition_party.go
internal/plugin/combat_party_turn.go          internal/plugin/expedition_party_cmd.go
internal/plugin/dnd_expedition.go          *  internal/plugin/expedition_party_resolve.go
internal/plugin/dnd_expedition_combat.go      internal/plugin/zone_combat_party.go
internal/plugin/combat_engine_party_test.go

Plus one new file: internal/plugin/zone_combat_party_casualty_test.go.

The four marked * carry both a fix and gofmt realignment (adventure.go, dnd_expedition.go, dnd_expedition_supplies.go, dnd_zone_cmd.go), so a clean split needs git add -p on those rather than a straight per-file split. The other nine are fix-only and can be staged whole.


Fixed in this pass

# File Defect
1 combat_party_turn.go Settle-to-terminal dropped the entire close-out
2 expedition_party_cmd.go Party member permanently soft-locked on leader extract
3 expedition_party_cmd.go Lost update on the shared supply pool
4 dnd_expedition_combat.go Elite harvest interrupt granted no elite loot
5 adventure_arena_season.go Transient failure permanently lost a season crown

Plus cleanups: deleted dead partySurvivors; collapsed the zoneCombatRoster alias into fightRoster; partyCasualtyLine now uses joinNames; consolidated four copies of the expedition column projection into expeditionSelectCols; corrected two false doc comments on applyDailyBurn/applyDailyBurnP; replyDM no longer sends a blank DM.

1. Close-out dropped when the settle turns lethal — the worst one

beginCombatTurn calls settleCombatSession to resolve a phase the engine owes (a fight parked mid enemy-turn after a restart). That settle can end the fight. The old code then saw !sess.IsActive() and returned "you're not in a fight."

The terminal status was already persisted, so nothing ever paid the party out: no XP, no loot, no markAdventureDead, no abandonZoneRun, no forceExtractExpeditionForRunLoss. The reaper cannot rescue it either — listExpiredCombatSessions filters status = 'active', and the session is now terminal. The win or the death simply evaporates.

The !fight start path and the reaper both already do settle-then-closePartyRound; beginCombatTurn was the one settle site that skipped it. Now it closes out and announces, matching them.

2. Member soft-locked when the leader extracts and walks away

seatedExpeditionFor (the guard) spans status IN ('active','extracting'). expeditionForMember (what !expedition leave resolved through) filtered status = 'active' only. During the leader's 7-day extracting limbo a member was therefore refused any new adventure by the guard, and told "No active expedition" by the very command the guard pointed them at. Nothing sweeps stale extracting rows, and only the leader can trigger the transition that clears them — so if the leader quit, the member was stuck forever with no self-service recovery.

!expedition leave now resolves the seat through seatedExpeditionFor, so the exit sees every state the gate sees. (seatedExpeditionFor already excludes leaders, so they still fall through to the !extract message.)

3. Lost update on the shared supply pool

updateSupplies rewrites supplies_json wholesale. expeditionCmdAccept folded a new member's packs onto an exp snapshot read ~60 lines earlier, with no lock. Handlers run one goroutine per event (mautrix AsyncHandlers, never overridden), so two invitees accepting at once genuinely interleave: both read pool P, one writes P+a, the other P+b — a member's packs vanish, or spent SU is resurrected by the day-burn tick.

Note advUserLock cannot fix this: it is keyed by sender, so two members take two different mutexes. Added advExpeditionLock(expID) and re-read the pool under it. This closes accept-vs-accept only — see deferred item B.

4. Elite harvest interrupt paid standard loot

runHarvestInterrupt computed elite := kind == InterruptElite, used it to pick an elite enemy and to pick elite narration, then passed a hardcoded false as isElite to closeOutZoneWin. Since dropZoneLoot gates on isBoss || isElite, beating an elite interrupt skipped the masterwork roll and used the 1.0 standard treasure weight instead of 2.0 — while the identical elite fought via !zone paid out correctly.

Now passes the live elite. The param only flows into dropZoneLoot, so this adds the masterwork roll and doubles treasure weight for surviving seats; threat, XP, and narration are untouched. A small reward buff on a fight players already win, consistent with lifting trailers rather than nerfing.

5. A failed season crown was lost permanently, not retried

arenaSeasonRollover logged-and-continued 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.goexpedition_extract_sweep_test.go, per the no-new-dnd_-names rule (the diff's three other new test files already avoided the prefix).

Reported, not fixed (deliberate):

  • Misty ordering was the only correctness-shaped finding; the other angles agreed the stack was clean. Two lower items were left: dnd_setup.go's respec/wipe disbands an extracted party with no member DM — but the disband is intended (item C hole #3) and the missing DM is outside the reviewed lines; and abandonExpedition re-queries the row the caller already resolved — the divergence a finder posited isn't reachable (the extracted-row branch only runs when no active row exists), so the extra indexed SELECT was left alone.

Full plugin suite green before and after. No auto-resolve path touched, so the class-balance corpus and combat_characterization.golden do not move.


Deferred — real, not fixed

M. Mage subclass spell hooks never run in turn-based combat — TWO-THIRDS MIS-STATED. Fixed 2026-07-10.

The premise was wrong. applyMageSubclassSpellHooks has three non-test callers, not one. resolveTurnSpell (dnd_spell_combat.go:341) has called it since Phase 13 shipped per-round casting (5cd343a, 2026-05-14).

So on the turn-based surface:

  • Empowered Evocation (L7, +INT to evocation damage) — always worked.
  • Evocation Overchannel (L10, ×1.5 on a 1st5th-level slot) — always worked.

Both only move mods.SpellPreDamage, which resolveTurnSpell returns as out.EnemyDamage. Nothing was lost.

Grim Harvest was the only real defect, and its cause was narrower than "the hooks don't run": the hooks ran, wrote mods.GrimHarvestSlot — and resolveTurnSpell dropped that local CombatModifiers on the floor, because turnSpellOutcome had no field to carry it out. A Necromancy Mage who killed with a spell in a manual fight never healed.

The stash cannot ride on fight-start mods the way it does in auto-resolve: the spell is cast mid-fight, and the turn engine rebuilds its combatants every round. So it rides where every other mid-fight fact rides — the casting seat's ActorStatuses:

  1. turnSpellOutcome gained GrimHarvestSlot / GrimHarvestNecrotic.
  2. castActionForSeat parks them on actorStatusesPtr(seat) when a cast lands. snapshotActor starts from the prior snapshot, so they survive commit() untouched, exactly as ArmedAbility does.
  3. seatFightStartMods reads them back at close-out, in place of the comment that used to explain why they were permanently zero.

And the killing-blow check was wrong for this surface. grimHarvestHeal scanned for the first spell_cast event and refused the heal if the enemy survived it. Auto-resolve casts once, pre-combat, so first == last there; a turn-based mage casts every round, so an opening cantrip that left the enemy standing vetoed the heal the round-3 killing spell had earned. It now scans for the last spell_cast — provably identical on the auto-resolve path, which is why the golden corpus does not move.

Each damaging cast overwrites the stash, so it always describes the seat's most recent landed spell — the only one that can have been lethal. A miss stashes nothing, and a stale stash is inert because the last spell_cast event then shows the enemy alive.

Balance note. This is a caster buff on the manual surface — 2×/3×/4× slot level in HP, once, on a spell kill, for L5+ Necromancy Mages. It is the buff the subclass was written to have and auto-resolve already paid out. Consistent with the standing "lift trailers" stance. The class-balance corpus measures auto-resolve and is unmoved.

New tests: internal/plugin/combat_grim_harvest_turn_test.go (7 cases) — the stash surviving resolveTurnSpell, nobody-else-stashes, last-cast-not-first, weapon-kill-after-a-spell, the snapshotActor round-trip, the end-to-end heal at postCombatBookkeepingForSeat, and per-seat isolation in a party fight.

N. The NPC procs do not exist in the turn engine (RE-VERIFIED 2026-07-10 — conclusion holds, evidence corrected, scope wider) — the debuff exploit fixed 2026-07-10.

Verified before planning, after items C and M both turned out to rest on a bad "only one caller" claim. This one survives, but two of its three sentences did not.

The CrowdRevengeProc exploit is now closed (see the fix note at the end of this item). Closing it pulled MistyHealProc in with it: both are round-end effects, and the honest hook runs the pair rather than the debuff alone — a one-sided hook would have been a second parallel sibling of the kind item D warns about. So Misty's heal is now live on the turn path too. That is a player-favourable discovery mechanic and fits the "lift trailers" stance, so it ships together; only SniperKillProc stays turn-dead, because it is a pre-combat one-shot with no round-end seam to hang on.

Correction 1 — the location was wrong. MistyHealProc is not read inside simulatePartyWithRNG. It is read at combat_engine_party.go:520 inside endOfRoundForSeat, a helper, which is exactly the shape that made C and M wrong. So the question had to be asked properly: endOfRoundForSeat has one caller, simulatePartyRound (:418), which is the auto-resolve round. The turn engine runs its own stepRoundEnd and never calls it. Conclusion stands. SniperKillProc (:102, genuinely in simulatePartyWithRNG, a pre-combat one-shot) has no turn-engine counterpart either.

Correction 2 — the procs are built, then ignored. buildZoneCombatants calls DerivePlayerStats (combat_session_build.go:61), which sets mods.MistyHealProc / mods.SniperKillProc (combat_stats.go:180,184) on every turn-based combatant. The mods are present on the struct every single round. No turn-engine code path reads either field. It is a dangling write, not a missing build.

Correction 3 — there is a third proc, and it cuts the other way. CrowdRevengeProc (:473) sits in the same endOfRoundForSeat and is equally absent from the turn engine. That one is the Misty debuff. So the asymmetry is not "manual fights are worse": a buffed player loses their buff by fighting manually, and a debuffed player escapes their debuff by doing the same. The debuff half is the one worth caring about, because it is exploitable and needs no discovery to exploit — just fight everything with !attack.

Not gaps — checked and explained, so nobody re-files them:

  • PetAttackProc and SpiritWeaponProc are reimplemented in the turn engine (petStrike, spiritWeaponStrike), though they fire after a player action rather than at round end.
  • EnvironmentProc is absent because turnCombatPhase is a single flat "Duel" phase with the proc at 0 — deliberate, not dropped.
  • HealItem's auto-heal never fires, but the turn engine seeds and snapshots HealChargesLeft and gives the player !consume instead. Player-driven by design, not a leak. (Worth confirming the charges aren't double-counted across the two surfaces.)

The achievement claim held when written; the fix moved it. At the time, combat_sniper_kill and combat_misty_clutch were both unreachable from a manual kill and seatCombatResult hardcoded both flags false — so "six achievements is really four" was correct. Wiring the heal changed that: seatCombatResult now reads MistyHealed back off the misty_heal event on the log (the same way combat_pet_save has always been detected), so combat_misty_clutch is reachable turn-based now — it is five, not four. combat_sniper_kill stays unreachable. The other four were always reachable: combat_near_death (computed by seatCombatResult), combat_pet_save (the turn engine does call resolveEnemyAttack, which emits pet_whiff), combat_death_save (trySave in stepRoundEnd), and combat_consumable_used (combat_cmd.go:773).

(Keep this internal. Per project convention these procs are hidden discovery mechanics and must not surface in help text, player docs, or DMs.)

Fix (2026-07-10). Both Misty procs were hoisted out of endOfRoundForSeat into shared helpers — mistyCrowdRevenge and mistyHeal — and a seatEndOfRound hook that runs the pair in the order the auto-resolve engine does (crowd first, then, if the seat survived, the heal). endOfRoundForSeat now calls the helpers; the turn engine's stepRoundEnd calls seatEndOfRound per seat, after the poison tick so a heal can answer the round's damage. Both helpers short-circuit before touching the RNG when their proc is unarmed, so a character with no Misty history draws exactly the dice it drew before — the sim corpus and combat_characterization.golden do not move (full suite confirms).

renderAllySeatEvent gained a crowd_revenge case: an ally sees the damage land (silence would read as HP vanishing) but not Misty's name, matching the neighbouring misty_heal's neutral "recovers N" — the grudge stays the owner's own discovery.

New tests: combat_misty_turn_test.go (6 cases) — the crowd landing on the manual surface (the exploit), a lethal crowd ending a solo fight, the heal firing and capping, no heal for a seat the crowd just dropped, the no-RNG-when-unarmed property that protects the golden file, and an end-to-end wiring test through advanceCombatSession that reports PlayerHP=40 (the shipped exploit) if the stepRoundEnd call is removed.

O. trySimAutoArm used to re-arm every round — sim baselines will move (NEW)

Before item A's fix, trySimAutoArm lived inside buildZoneCombatants. On the turn-based path that meant: every rebuild re-armed the class-default ability, spent another point of the resource, and re-applied it. A simulated Fighter got a fresh Second Wind heal every single round until stamina ran dry; a Cleric, a Healing Word.

expedition-sim's autoDriveCombat drives handleAttackCmd, i.e. the turn-based path — so this inflated every turn-based sim result. It now arms once per fight, which is what simAutoArmEnabled's own doc comment says it models ("a competent real player who would !arm Second Wind before each fight").

Any expedition-sim corpus that involved turn-based combat is stale. The pending final L10/L12 re-baseline should be run after this change, not before. The pure auto-resolve corpus (SimulateCombat / simulateParty) is unaffected — that path always armed exactly once.

C. Members and leaders disagree about an expedition during extracting — MIS-STATED; the real bug was worse. Fixed 2026-07-10.

The premise was wrong. getActiveExpedition — the leader's lookup — also filters status = 'active'. During extracting the leader gets "no active expedition" from !expedition, !map, !camp &c. exactly as the member does. They already agree, and honestly so: nobody is standing in the dungeon. Widening expeditionForMember would have made the member the only player who can see a paused expedition. Not done, and it should not be.

Reading for it surfaced the real defect underneath.

The seven-day resume window was never enforced. releaseParty fires only on a terminal status, and dnd_expedition.go:428 justifies skipping it for extracting by asserting "the roster is cleared when the resume window lapses and the row flips to failed". Nothing did that. The only extracting → failed transition in the tree was inside handleResumeCmd — a lazy expiry that runs only when the leader personally types !resume.

So a leader who quit, forgot, or simply started a different expedition left the row extracting forever. releaseParty never ran, every member stayed seated, and assertNotAdventuring kept refusing them a run of their own. Fix #2 gave them !expedition leave as an escape, which is why this was a permanent nag rather than a permanent soft-lock — but a player should not have to find it.

Three holes, all closed:

  1. No sweeper. sweepLapsedExtractions (dnd_expedition_extract.go) reaps every extracting row past completed_at + 7d through completeExpedition, which releases the roster, and DMs the audience. Hourly ticker (expeditionExtractionSweepTicker), plus a one-shot at Start() — a lapse that happened while the bot was down is blocking those members now. The audience is read before the close-out, since completeExpedition disbands the roster expeditionAudience reads. handleResumeCmd's lazy expiry stays, now sharing the extractionLapsed predicate (which treats a NULL completed_at as lapsed — it is the only clock the window has).

  2. The leader could orphan their own party. !expedition start checked only getActiveExpedition, so a leader could start fresh on top of an extracted row. !resume resolves the newest extracting row, so the old one became unreachable — un-resumable, un-abandonable, roster still held. It now refuses, but only when that row has a roster (partySize > 1); a solo extraction strands nobody, so walking away from one stays a normal thing to do. A lapsed row is reaped in place and the start proceeds.

  3. The leader had no way out but to pay. expeditionCmdAbandon resolved through activeExpeditionFor and so could not see the extracted row it owns — the leader had to buy a !resume just to abandon. Both it and abandonExpedition now span extracting via ownedLiveExpedition, which sorts active rows first so expeditionCmdStart's run-spawn rollback still tears down the row it just created rather than an older extracted one. This also fixes dnd_setup.go:394,449 for free: a leader who rerolled their character used to strand their whole party.

Abandoning now DMs the members too, and says the true thing when the party is standing in town rather than in the dungeon (supplies already spent, loot already banked — not "supplies are forfeit").

New tests: dnd_expedition_extract_sweep_test.go (6 cases) — the sweep releasing a stranded roster, leaving a live extraction alone, the NULL-completed_at predicate, abandon reaching an extracted row and freeing the party, abandon preferring the active row, and the no-live-row error.

Not covered: the two expeditionCmdStart / expeditionCmdAbandon refusals, which sit above the DM boundary. Still the dmSink gap.

D. Two turn-based win close-outs must be kept in sync by hand — effect-drift closed 2026-07-10.

finishPartyWin (combat_party_finish.go) was a hand-copied sibling of the CombatStatusWon block in finishCombatSession (combat_cmd.go): mood scan, kill record, threat, boss threat, XP + near-death, loot, TwinBee line, continue hint. finishPartyCombatSession routes solo back to finishCombatSession, so the two must stay equivalent forever, with nothing forcing it. Item A was the first drift.

Item A's fix already pulled the bookkeeping out of both into postCombatBookkeepingForSeat. This pass hoists the remaining duplicated effects into three shared helpers in combat_party_finish.go, and routes both close-outs through them:

  • applyOwnerWinEffects(owner, enemyID, elite) bool — the zone-kill record, room threat, and boss-defeat threat drop; returns bossOnExpedition. Fires once, owner-scoped (a member owns neither row).
  • grantSeatWinXP(uid, hp, hpMax, monster, tier) — near-death calc + XP grant. Per seat (solo calls it once with seat 0).
  • endRunOnLoss(owner, runID, death) — mood event (death only) + abandonZoneRun
    • forceExtractExpeditionForRunLoss, shared by both the Lost and Fled paths.

What deliberately stays divergent, so this was not a full closeOutSeat merge: the player-facing text genuinely differs (solo "You finished at N/M HP" vs party "The party stands"; the party's 4-way boss/hint split vs the solo 2-way), and the death-on-win rule is roster-size-dependent by design — solo finishCombatSession never marks a winner dead, finishPartyWin marks a seat that won from 0 HP dead. That is item E and must not be unified. So the effect lists are now a single copy each; the text is not.

Full plugin suite green; no behavior change (helpers are extract-and-call).

Note this is the only place the party work chose a parallel sibling. Elsewhere the branch is a genuine N-body generalization, not a bolt-on: SimulateCombatsimulatePartyWithRNG, runCombatRoundrunPartyCombatRound, runZoneCombatrunZoneCombatRoster, RenderTurnRoundRenderPartyTurnRound.

E. Death-on-win depends on roster size — intentional, leave it

closeOutZoneWin marks a downed seat dead on a win only when len(seated) > 1. A solo player who wins at 0 HP (retaliate aura kills the swinger on the killing blow) survives; a party member in the same spot dies.

This is deliberate and documented in place (zone_combat_party.go:183-188): unifying it would move the sim's outcome labels and force a re-baseline of testdata/combat_characterization.golden. Not a defect. Listed so the next person to find it doesn't "fix" it by accident.

F. grantAutorunGrace is a stopgap (already tracked as R5)

zone_revisit.go:172 stamps last_autorun_at to buy one autoRunCooldown window after a !revisit, because the autopilot ticker has no concept of position-vs-frontier and will otherwise march the party back out of the room they just backtracked to. The code says so itself and defers the real fix to R5. It is coupled to the exact value of autoRunCooldown and to the ticker cadence.

G. Party combat multiplies the known per-turn DB chatter

Already tracked as project_combat_session_cache_deferred, now worse:

  • partyCombatantsForSession (combat_session_build.go:137) rebuilds every seat from scratch on every !attack/!cast/!consume (~68 SELECTs per seat). A 3-member, 5-round fight ≈ 270360 SELECTs.
  • rebuildRoster (combat_cmd.go:620) then rebuilds all seats again after a mid-fight buff, when only the casting seat changed.
  • runZoneCombatRoster (zone_combat_party.go:73) loads char + equipment, then buildZoneCombatants loads both again and discards the first pair — once per seat per room, on the path that fights every room.
  • nudgeStalledPartyTurns (combat_party_turn.go:281) does two session reads plus a full N-seat rebuild per stalled fight, serially, on the 1-minute ticker, while holding the fight lock.
  • The enemy stat block is rebuilt once per seat and kept only for seat 0 (the code comments admit it). CPU-only, but pure waste.

Cheapest real fix: memoize the built roster for the fight's lifetime. Mid-fight buffs are already tracked as ActorStatuses deltas and re-applied by applySessionBuffs, so stats are reproducible without re-reading the DB.

H. Guard probes re-query per message — MEASURED, DECLINED 2026-07-10.

isPartyMemberactiveExpeditionFor is up to 2 queries; activeZoneRunFor adds more. A solo player with nothing in flight costs 3 queries to conclude "not in a party". The original write-up said this was "repeated by each guard a handler consults" and proposed resolving seat/expedition/run once per message into MessageContext and threading it.

The premise was overstated, and the fix is disproportionate. Not done.

  • No per-message guard cascade exists. OnMessage dispatch is strictly one-handler-per-command (if IsCommand(...) return handle(...)). Nothing runs these resolvers for every message; the redundancy is intra-handler only.
  • The one hot inbound path is already optimized. zoneCmdAdvance / expeditionCmdRun call isPartyMember directly by design — the code comment spells out that this avoids re-resolving the run (which advanceOnce is about to resolve) and double-firing the §4.3 idle reap. The authors already closed the hot case.
  • The remaining 23× callers are cold. A per-function scan found ≥2 resolver calls only in zoneCmdEnter, zoneCmdAbandon, expeditionCmdStart, the status impls (all one-shot commands), and runAutopilotWalk (5×, but it is the background ticker path, not an inbound message). In those the calls are typically a guard-check on one branch then a resolve on another — not the same read twice.
  • The proposed fix has a real regression surface. MessageContext is a shared cross-plugin value type (movies, vibe, miniflux, …); the resolvers are free functions used at 60+ sites. Threading a resolved snapshot means changing the shared type and every call site, and carrying a memo that goes stale the moment a handler writes the row mid-flight — the exact staleness-bug class items A/M/N just fixed. All to save a few cheap indexed local SQLite reads on a low-volume bot.

Cost/benefit is upside-down: high churn + a staleness regression surface for a negligible perf gain on paths that are mostly already optimal. Left as-is deliberately. Revisit only if a profiler ever shows these reads on a hot path.

I. parseTemperIndex is the third copy of the same menu parser — Fixed 2026-07-10.

adventure_temper.go duplicated the "read leading digits, 1-indexed → 0-indexed, bounds-check" reply parser already inlined in resolveMagicEquipReply (magic_items_gameplay.go) and handleMasterworkEquipReply (adventure_masterwork.go). Promoted the temper copy to parseMenuIndex(reply, n) and routed the other two through it.

Behaviour-preserving on all three: the old parsed flag was redundant with the idx < 0 guard (no leading digit leaves idx=0, then idx-- → -1, rejected either way), so no input changes verdict. The retry disagreement the doc flagged was deliberately left as-is — this was a pure dedup, so magic-items and temper still re-store the pending interaction on a bad parse and masterwork still doesn't. Unifying that is a behaviour change and belongs in its own pass if wanted. Full plugin suite green; the existing parseMenuIndex table test moved with the rename.

J. !sell fires the event anchor even when nothing sold (PLAUSIBLE)

adventure.go:719 calls maybeFireAnchoredEvent unconditionally after handleSellCmd, so !sell nonexistentitem can burn the player's one daily event slot. Gating it needs advSellItem/advSellAll to return a sold bool.

Probably leave it. The anchor's stated design is presence-based — "a moment the player is present and reading" — and a player who typed !sell and read the reply is present. That matches the project's own rule that presence means any chat, not a successful command. Flagged only so the choice is explicit.

K. Confirm the A6 anchor set covers the player population — CONFIRMED REAL GAP 2026-07-10.

N1/A6 replaced the old "any daily-active player, any activity" mid-day event roll with three presence anchors: the expedition Night digest, !sell, and arena cashout. tryTriggerEvent's HasActedToday gate was dropped with it.

The code confirms the gap is real, not hypothetical. The three, and only three, maybeFireAnchoredEvent call sites are:

  1. expedition_autorun.go:312 — the end-of-day digest, and it is gated on campDecision.Night. A Night camp only happens on a multi-day expedition; a single-day dungeon/!zone run never reaches one.
  2. adventure.go:734 — after a !sell at Thom's.
  3. adventure_arena.go:633 — after an arena cashout.

So the excluded population is concrete: a player whose daily loop is mining / foraging / fishing / babysit / single-day dungeon, who never sells at Thom's, never enters the arena, and never runs a multi-day expedition, receives zero mid-day events — the roll never even happens for them. That is exactly the core daily-grind loop, so this is plausibly a large fraction of players, not an edge case.

Fourth anchor drafted 2026-07-10 (uncommitted, pending owner review). The on-theme, farm-resistant home is a foreground single-day !zone clear — the climax DM the dungeon/harvest-via-walk player is demonstrably reading, and one they cannot spam (a clear costs a full walk). Implemented as:

  • advEventChanceZoneClear = 0.08 in adventure_events.go (matches the digest; it is the day's climax moment, and it is the single tuning knob).
  • advanceResult.zoneCleared set true only in the full-clear branch of advanceOnceWithOpts (not a mid-zone region clear, which continues the run and would fire per region).
  • Rolled in zoneCmdAdvance after the clear DM streams, via the unchanged maybeFireAnchoredEvent. That is the foreground path only — autopilot (runAutopilotWalk) and party members (refused earlier by isPartyMember) never reach it. The per-day slot guard still caps everyone at one event/day.

Rates (see TestZoneClearAnchorRate): a zone-clear-only grind player at ~2 clears/day lands ~1.08 events/week — the same band as the fully-engaged digest+sell+arena player (~1.19/week, unchanged). The rare all-four-in-one-day player peaks at a ~1.65/week probability ceiling but still gets at most one actual event/day.

Two knobs remain the owner's call, hence "pending review": the 0.08 chance, and whether a single-day zone clear is even the right anchor for the grind loop vs. !resources (rejected here as farmable — repeated !resources would just re-roll the same daily slot) or leaving the grind loop event-free by design. Full plugin suite green; no existing behaviour moved (the three prior anchors and TestAnchoredEventWeeklyRate are untouched).

L. Dead helper: openParty — Verified and removed 2026-07-10.

Confirmed dead: a repo-wide grep found openParty only in expedition_party.go (the def) and expedition_party_test.go. The invite path goes joinPartyseatLeader, 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.