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.
44 KiB
Code review follow-ups — n1-restoration (2026-07-10)
Review scope: git diff main...HEAD, 99 files / ~16.7k lines (N1, N2, R1, R2, N3 P1–P7).
Method: 8 finder angles → dedup → 1 verifier per correctness candidate.
Baseline before and after: go build, go vet, go test ./internal/... all green.
Working-tree doc — do not commit (per project convention on gogobee_*.md).
Status. Fixes 1–5 shipped in 1f21156. Deferred items A and B fixed
2026-07-10 (d76c63b); A turned out to have a deeper root cause and surfaced
three new findings, M, N and O. Item C fixed 2026-07-10 — its
premise was wrong and the bug underneath it was worse (the seven-day resume
window was never enforced; see below). Item M fixed 2026-07-10 — two of its
three claims were wrong (the mage hooks do run on the turn path); the one real
defect, Grim Harvest, had a different cause than recorded. Items D, I,
L, N fixed. Item K verified 2026-07-10 — the anchor gap is a real,
confirmed narrowing, left as an owner design call (no code change). Item H
measured 2026-07-10 and declined — its "per-message cascade" premise was
overstated, the hot path is already optimized, and the proposed
MessageContext thread-through is disproportionate churn with a staleness
regression surface.
All correctness work is committed. Everything remaining is deliberate, not a
defect: E (intentional, roster-size death rule), F (grantAutorunGrace
stopgap, deferred to R5 by design), G (party-combat DB chatter — a perf
refactor whose only safe fix is a fight-lifetime roster cache, i.e. the same
staleness surface H was declined for; deferred), H (declined, above),
J (!sell anchor — PLAUSIBLE, presence == any chat, left per convention),
K (owner design call, above), O (already fixed as a side-effect of A;
only the sim re-baseline is pending, which is operational).
Method note. Both C and M were mis-diagnosed in the same way: the finder read one call site, concluded "this is the only caller", and wrote it down. Verify the callers before planning the fix.
Third pass (2026-07-10, 88c5fcd). /code-review high --fix over the pushed
follow-up stack (git diff origin/main...HEAD, 7 commits). Five fixes shipped;
they harden the item-C extraction work and the item-N Misty ordering rather than
opening new ground. See "Third pass" below.
Before you commit: split the formatting from the fixes
A repo-wide gofmt -w ./internal ./cmd ran after the review. It reformatted
105 files that have nothing to do with these findings — they were already
non-conforming on main (struct-field alignment, mostly), and gofmt is
semantics-preserving, so nothing changed behaviourally.
Net state: 117 files dirty, only 13 carry review fixes. Commit them apart, or the fixes become unreviewable inside a wall of realignment.
The 13 fix-bearing files:
internal/plugin/adventure.go * internal/plugin/dnd_expedition_supplies.go *
internal/plugin/adventure_arena_season.go internal/plugin/dnd_zone_cmd.go *
internal/plugin/combat_cmd.go internal/plugin/expedition_party.go
internal/plugin/combat_party_turn.go internal/plugin/expedition_party_cmd.go
internal/plugin/dnd_expedition.go * internal/plugin/expedition_party_resolve.go
internal/plugin/dnd_expedition_combat.go internal/plugin/zone_combat_party.go
internal/plugin/combat_engine_party_test.go
Plus one new file: internal/plugin/zone_combat_party_casualty_test.go.
The four marked * carry both a fix and gofmt realignment
(adventure.go, dnd_expedition.go, dnd_expedition_supplies.go,
dnd_zone_cmd.go), so a clean split needs git add -p on those rather than a
straight per-file split. The other nine are fix-only and can be staged whole.
Fixed in this pass
| # | File | Defect |
|---|---|---|
| 1 | combat_party_turn.go |
Settle-to-terminal dropped the entire close-out |
| 2 | expedition_party_cmd.go |
Party member permanently soft-locked on leader extract |
| 3 | expedition_party_cmd.go |
Lost update on the shared supply pool |
| 4 | dnd_expedition_combat.go |
Elite harvest interrupt granted no elite loot |
| 5 | adventure_arena_season.go |
Transient failure permanently lost a season crown |
Plus cleanups: deleted dead partySurvivors; collapsed the zoneCombatRoster
alias into fightRoster; partyCasualtyLine now uses joinNames; consolidated
four copies of the expedition column projection into expeditionSelectCols;
corrected two false doc comments on applyDailyBurn/applyDailyBurnP; replyDM
no longer sends a blank DM.
1. Close-out dropped when the settle turns lethal — the worst one
beginCombatTurn calls settleCombatSession to resolve a phase the engine owes
(a fight parked mid enemy-turn after a restart). That settle can end the fight.
The old code then saw !sess.IsActive() and returned "you're not in a fight."
The terminal status was already persisted, so nothing ever paid the party out:
no XP, no loot, no markAdventureDead, no abandonZoneRun, no
forceExtractExpeditionForRunLoss. The reaper cannot rescue it either —
listExpiredCombatSessions filters status = 'active', and the session is now
terminal. The win or the death simply evaporates.
The !fight start path and the reaper both already do settle-then-closePartyRound;
beginCombatTurn was the one settle site that skipped it. Now it closes out and
announces, matching them.
2. Member soft-locked when the leader extracts and walks away
seatedExpeditionFor (the guard) spans status IN ('active','extracting').
expeditionForMember (what !expedition leave resolved through) filtered
status = 'active' only. During the leader's 7-day extracting limbo a member was
therefore refused any new adventure by the guard, and told "No active expedition"
by the very command the guard pointed them at. Nothing sweeps stale extracting
rows, and only the leader can trigger the transition that clears them — so if
the leader quit, the member was stuck forever with no self-service recovery.
!expedition leave now resolves the seat through seatedExpeditionFor, so the
exit sees every state the gate sees. (seatedExpeditionFor already excludes
leaders, so they still fall through to the !extract message.)
3. Lost update on the shared supply pool
updateSupplies rewrites supplies_json wholesale. expeditionCmdAccept folded
a new member's packs onto an exp snapshot read ~60 lines earlier, with no lock.
Handlers run one goroutine per event (mautrix AsyncHandlers, never overridden),
so two invitees accepting at once genuinely interleave: both read pool P, one
writes P+a, the other P+b — a member's packs vanish, or spent SU is
resurrected by the day-burn tick.
Note advUserLock cannot fix this: it is keyed by sender, so two members take
two different mutexes. Added advExpeditionLock(expID) and re-read the pool
under it. This closes accept-vs-accept only — see deferred item B.
4. Elite harvest interrupt paid standard loot
runHarvestInterrupt computed elite := kind == InterruptElite, used it to pick
an elite enemy and to pick elite narration, then passed a hardcoded false as
isElite to closeOutZoneWin. Since dropZoneLoot gates on isBoss || isElite,
beating an elite interrupt skipped the masterwork roll and used the 1.0 standard
treasure weight instead of 2.0 — while the identical elite fought via !zone
paid out correctly.
Now passes the live elite. The param only flows into dropZoneLoot, so this
adds the masterwork roll and doubles treasure weight for surviving seats; threat,
XP, and narration are untouched. A small reward buff on a fight players already
win, consistent with lifting trailers rather than nerfing.
5. A failed season crown was lost permanently, not retried
arenaSeasonRollover logged-and-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'sApplywrites 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:
-
The extraction guard fell open on a DB error.
expeditionCmdStart's resumable-guard didswitch n, err := partySize(pending.ID)and gated the party-blocking refusal oncase 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 checksextractionLapsedfirst (pure, no DB call on the reap path) and treats apartySizeerror as "assume occupied → refuse". Also drops the wastedCOUNTthe old switch-init ran on the lapsed-reap path. -
The start-path lapsed reap unseated members silently. When a leader with a lapsed party extraction ran
!expedition start, the reap calledcompleteExpedition(...Failed)— which frees the roster — with no DM, unlike the hourly sweeper and!expedition abandon, which both notify. ExtractedreapLapsedExtraction(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. -
Misty's crowd swung before the concentration pulse. Item N's new
stepRoundEndseat-loop ranseatEndOfRound(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 viafinish(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".) -
Fourth copy of the event-log scan.
seatCombatResulthand-rolled amisty_healscan loop. Promoted the test-onlyhasAction(events, action)helper to production (combat_session_build.go), used it there, and removed the duplicate test definition. -
New
dnd_-prefixed file. Renameddnd_expedition_extract_sweep_test.go→expedition_extract_sweep_test.go, per the no-new-dnd_-names rule (the diff's three other new test files already avoided the prefix).
Reported, not fixed (deliberate):
- Misty ordering was the only correctness-shaped finding; the other angles
agreed the stack was clean. Two lower items were left:
dnd_setup.go's respec/wipe disbands an extracted party with no member DM — but the disband is intended (item C hole #3) and the missing DM is outside the reviewed lines; andabandonExpeditionre-queries the row the caller already resolved — the divergence a finder posited isn't reachable (the extracted-row branch only runs when no active row exists), so the extra indexed SELECT was left alone.
Full plugin suite green before and after. No auto-resolve path touched, so the
class-balance corpus and combat_characterization.golden do not move.
Deferred — real, not fixed
M. Mage subclass spell hooks never run in turn-based combat — TWO-THIRDS MIS-STATED. Fixed 2026-07-10.
The premise was wrong. applyMageSubclassSpellHooks has three non-test
callers, not one. resolveTurnSpell (dnd_spell_combat.go:341) has called it
since Phase 13 shipped per-round casting (5cd343a, 2026-05-14).
So on the turn-based surface:
- Empowered Evocation (L7, +INT to evocation damage) — always worked.
- Evocation Overchannel (L10, ×1.5 on a 1st–5th-level slot) — always worked.
Both only move mods.SpellPreDamage, which resolveTurnSpell returns as
out.EnemyDamage. Nothing was lost.
Grim Harvest was the only real defect, and its cause was narrower than "the
hooks don't run": the hooks ran, wrote mods.GrimHarvestSlot — and
resolveTurnSpell dropped that local CombatModifiers on the floor, because
turnSpellOutcome had no field to carry it out. A Necromancy Mage who killed
with a spell in a manual fight never healed.
The stash cannot ride on fight-start mods the way it does in auto-resolve: the
spell is cast mid-fight, and the turn engine rebuilds its combatants every
round. So it rides where every other mid-fight fact rides — the casting seat's
ActorStatuses:
turnSpellOutcomegainedGrimHarvestSlot/GrimHarvestNecrotic.castActionForSeatparks them onactorStatusesPtr(seat)when a cast lands.snapshotActorstarts from the prior snapshot, so they survivecommit()untouched, exactly asArmedAbilitydoes.seatFightStartModsreads 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:
PetAttackProcandSpiritWeaponProcare reimplemented in the turn engine (petStrike,spiritWeaponStrike), though they fire after a player action rather than at round end.EnvironmentProcis absent becauseturnCombatPhaseis 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 snapshotsHealChargesLeftand gives the player!consumeinstead. 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.
extractingThe 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:
-
No sweeper.
sweepLapsedExtractions(dnd_expedition_extract.go) reaps everyextractingrow pastcompleted_at + 7dthroughcompleteExpedition, which releases the roster, and DMs the audience. Hourly ticker (expeditionExtractionSweepTicker), plus a one-shot atStart()— a lapse that happened while the bot was down is blocking those members now. The audience is read before the close-out, sincecompleteExpeditiondisbands the rosterexpeditionAudiencereads.handleResumeCmd's lazy expiry stays, now sharing theextractionLapsedpredicate (which treats a NULLcompleted_atas lapsed — it is the only clock the window has). -
The leader could orphan their own party.
!expedition startchecked onlygetActiveExpedition, so a leader could start fresh on top of an extracted row.!resumeresolves the newestextractingrow, 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. -
The leader had no way out but to pay.
expeditionCmdAbandonresolved throughactiveExpeditionForand so could not see the extracted row it owns — the leader had to buy a!resumejust to abandon. Both it andabandonExpeditionnow spanextractingviaownedLiveExpedition, which sorts active rows first soexpeditionCmdStart's run-spawn rollback still tears down the row it just created rather than an older extracted one. This also fixesdnd_setup.go:394,449for 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; returnsbossOnExpedition. 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) +abandonZoneRunforceExtractExpeditionForRunLoss, shared by both the Lost and Fled paths.
What deliberately stays divergent, so this was not a full closeOutSeat
merge: the player-facing text genuinely differs (solo "You finished at N/M HP"
vs party "The party stands"; the party's 4-way boss/hint split vs the solo
2-way), and the death-on-win rule is roster-size-dependent by design — solo
finishCombatSession never marks a winner dead, finishPartyWin marks a seat
that won from 0 HP dead. That is item E and must not be unified. So the effect
lists are now a single copy each; the text is not.
Full plugin suite green; no behavior change (helpers are extract-and-call).
Note this is the only place the party work chose a parallel sibling. Elsewhere
the branch is a genuine N-body generalization, not a bolt-on: SimulateCombat →
simulatePartyWithRNG, runCombatRound → runPartyCombatRound, runZoneCombat
→ runZoneCombatRoster, RenderTurnRound → RenderPartyTurnRound.
E. Death-on-win depends on roster size — intentional, leave it
closeOutZoneWin marks a downed seat dead on a win only when len(seated) > 1.
A solo player who wins at 0 HP (retaliate aura kills the swinger on the killing
blow) survives; a party member in the same spot dies.
This is deliberate and documented in place (zone_combat_party.go:183-188):
unifying it would move the sim's outcome labels and force a re-baseline of
testdata/combat_characterization.golden. Not a defect. Listed so the next
person to find it doesn't "fix" it by accident.
F. grantAutorunGrace is a stopgap (already tracked as R5)
zone_revisit.go:172 stamps last_autorun_at to buy one autoRunCooldown
window after a !revisit, because the autopilot ticker has no concept of
position-vs-frontier and will otherwise march the party back out of the room they
just backtracked to. The code says so itself and defers the real fix to R5. It is
coupled to the exact value of autoRunCooldown and to the ticker cadence.
G. Party combat multiplies the known per-turn DB chatter
Already tracked as project_combat_session_cache_deferred, now worse:
partyCombatantsForSession(combat_session_build.go:137) rebuilds every seat from scratch on every!attack/!cast/!consume(~6–8 SELECTs per seat). A 3-member, 5-round fight ≈ 270–360 SELECTs.rebuildRoster(combat_cmd.go:620) then rebuilds all seats again after a mid-fight buff, when only the casting seat changed.runZoneCombatRoster(zone_combat_party.go:73) loads char + equipment, thenbuildZoneCombatantsloads both again and discards the first pair — once per seat per room, on the path that fights every room.nudgeStalledPartyTurns(combat_party_turn.go:281) does two session reads plus a full N-seat rebuild per stalled fight, serially, on the 1-minute ticker, while holding the fight lock.- The enemy stat block is rebuilt once per seat and kept only for seat 0 (the code comments admit it). CPU-only, but pure waste.
Cheapest real fix: memoize the built roster for the fight's lifetime. Mid-fight
buffs are already tracked as ActorStatuses deltas and re-applied by
applySessionBuffs, so stats are reproducible without re-reading the DB.
H. Guard probes re-query per message — MEASURED, DECLINED 2026-07-10.
isPartyMember → activeExpeditionFor is up to 2 queries; activeZoneRunFor
adds more. A solo player with nothing in flight costs 3 queries to conclude "not
in a party". The original write-up said this was "repeated by each guard a
handler consults" and proposed resolving seat/expedition/run once per message
into MessageContext and threading it.
The premise was overstated, and the fix is disproportionate. Not done.
- No per-message guard cascade exists.
OnMessagedispatch 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/expeditionCmdRuncallisPartyMemberdirectly by design — the code comment spells out that this avoids re-resolving the run (whichadvanceOnceis about to resolve) and double-firing the §4.3 idle reap. The authors already closed the hot case. - The remaining 2–3× callers are cold. A per-function scan found ≥2 resolver
calls only in
zoneCmdEnter,zoneCmdAbandon,expeditionCmdStart, the status impls (all one-shot commands), andrunAutopilotWalk(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.
MessageContextis 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.
parseTemperIndex is the third copy of the same menu parseradventure_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:
expedition_autorun.go:312— the end-of-day digest, and it is gated oncampDecision.Night. A Night camp only happens on a multi-day expedition; a single-day dungeon/!zonerun never reaches one.adventure.go:734— after a!sellat Thom's.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.08inadventure_events.go(matches the digest; it is the day's climax moment, and it is the single tuning knob).advanceResult.zoneClearedset true only in the full-clear branch ofadvanceOnceWithOpts(not a mid-zone region clear, which continues the run and would fire per region).- Rolled in
zoneCmdAdvanceafter the clear DM streams, via the unchangedmaybeFireAnchoredEvent. That is the foreground path only — autopilot (runAutopilotWalk) and party members (refused earlier byisPartyMember) 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.
openPartyConfirmed dead: a repo-wide grep found openParty only in expedition_party.go
(the def) and expedition_party_test.go. The invite path goes joinParty →
seatLeader, which runs the same INSERT ... role 'leader' ON CONFLICT DO NOTHING but reads the owner off the expedition row instead of trusting a
passed-in id. Deleted openParty; the eleven test call sites now use a
seatLeaderFixture(t, expID) helper that wraps seatLeader in a transaction
(the owner comes from the seedExpedition row, which always matched the id the
old calls passed). seatLeader's doc comment absorbed the idempotency note that
justified openParty. Full plugin suite green.
Test gap worth closing — the DM seam CLOSED 2026-07-10
SendDM went straight through Base to a live client, with no fake/sink seam,
so the handler-level paths behind fixes #1, #2 and #5 could not be unit-tested.
That is why the party close-out bug survived a suite this thorough: every party
test is unit-level and stops below the DM boundary.
Fixed 2026-07-10. Added a MessageSink interface and a Base.Sink field
(plugin.go). When non-nil it captures every outbound message — both the DM
route (SendDM/SendDMID) and the room route (SendMessage/SendMessageID,
which is what the arena announcement actually uses) — and the live client is
never touched. Nil in production, so behaviour is unchanged; the whole
internal/... suite is green with the field added. One seam, no call-site churn:
the 765 SendDM/replyDM/SendMessage sites are all downstream of these four
methods.
New tests (message_sink_test.go): a captureSink test double + installSink
helper; TestMessageSink_CapturesDMAndRoom proving both routes divert with the
right target/text and that the *ID variants report back an id; and
TestExpeditionCmdLeave_DMsBothEnds, which drives the real fix-#2 handler end to
end and asserts both DMs land (member "turned back", leader notified) — a path
that was literally a silent no-op in a unit test before. beginCombatTurn's
terminal path and the arena rollover announcement are now reachable the same way
(seam proven for both the DM and room routes); dedicated end-to-end tests for
those two remain easy follow-ups if wanted.
Added this pass: TestPartyCasualtyLine_JoinsNamesLikeTheRestOfTheBot,
and TestPartySurvivors_SplitsOnHPNotOnOutcome was rewritten as
TestAnySurvivor_ReadsHPNotOutcome when its helper was deleted.
Added in the second pass (combat_armed_ability_test.go): the close-out
bookkeeping is now reachable below the DM boundary via
postCombatBookkeepingForSeat(sess, seat), which takes a plain *CombatSession
and needs no client — so item A's behaviour is directly unit-tested even though
finishCombatSession itself still isn't. (The dmSink seam that would have
covered finishCombatSession itself now exists — see above.)
Not covered by tests: item B's locking. withExpeditionSupplies is exercised
incidentally by the existing expedition suite, but nothing asserts the mutual
exclusion. A t.Parallel accept-vs-burn race test under -race would.