Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.
Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
!expedition invite / accept / decline / party / leave. The invitee buys
their own loadout and it pools -- a party is a shared burden, not a free
ride.
The plan said invites close "before the first walk". That is not a window,
it is a race: autoRunMinExpeditionAge leaves a fresh expedition alone for
thirty minutes and then the autopilot starts walking it, and the leader's
own !expedition run can beat it there. Thirty minutes is not enough to ask
a friend who is asleep.
So two changes to what the plan specified:
- The window is all of Day 1, not the first step. Supplies burn at the
night rollover, so a companion who arrives three rooms in pays and
receives exactly what one who arrived at the gate does.
- An unanswered invite pins the autopilot: loadExpeditionsForAutoRun skips
any expedition somebody has been asked to join. The leader must not be
dragged into a boss room while their friend reads the DM. Bounded by
expeditionInviteTTL (2h) in the query itself, so a forgotten invite
costs an afternoon, not the expedition.
New table expedition_invite. Absent == nobody was asked, which is true of
every expedition predating N3 -- nothing to backfill, same reading that
let expedition_party and roster_size ship without one.
Details worth keeping:
- Outstanding invites count against expeditionPartyMax. Otherwise a leader
asks four people and three accept.
- Pooling raises Current *and* Max. supplyDepletion reads the ratio, so
folding in only Current would read as the party suddenly starving.
- A member's supplies stay in the pool when they !leave. They were spent
on the expedition, not lent to it; clawing them back 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 -- startExpedition does.
- A party is not a taxi: zoneOpenToLevel gates the invitee on the same
tier rule !expedition start applies to the leader.
- releaseParty now clears invites too, or someone could accept onto a
corpse.
- expeditionCmdStatus and the bare `!expedition` switched to
activeExpeditionFor, and a member typing `!expedition go 2` is told the
leader picks the path instead of falling through to `start` and being
told "2" is an unknown zone.
Combat still seats one player -- handleFightCmd is P6c. go test ./...
green, golden byte-identical.
Every ownership lookup in the adventure module keys on a user id, and a
party member owns no row: not the expedition, not the zone run. P4 gave
them activeExpeditionFor; this gives them activeZoneRunFor, and gives the
DM seams the audience they never had.
- activeZoneRunFor(user) -> (run, isLeader, err). An owner's lookup is
exactly getActiveZoneRun, side effects and all -- in particular the
§4.3 idle reap, which force-extracts the wrapping expedition. A member
must never re-enter it, or glancing at the map would end the leader's
run. Pinned.
- expeditionAudience / fanOutExpeditionDM. Briefing, recap and digest all
DM'd id.UserID(e.UserID) alone. They now loop the roster, which
partyMemberIDs collapses to exactly the owner when there is none -- so
a solo expedition sends the same bytes to the same user it always has.
The briefing's body is expedition-scoped but its pet prefix is not:
each member has their own pet and their own sheet, so the roll rides a
per-reader decorator (the shape P5 settled on for combat narration).
The digest's A6 event anchor rolls per member for the same reason.
- releaseParty on every terminal transition. A seated member is barred
from adventuring elsewhere, so a roster outliving its expedition
strands the party. Deliberately NOT on 'extracting': that is a 7-day
resumable limbo and !resume must bring everyone back. The roster clears
when the window lapses to 'failed', which routes through
completeExpedition like the rest.
Rosters are still empty in production -- nothing seats a member yet -- so
every loop here has exactly one element and the whole change is a no-op
until P6b. Golden byte-identical, go test ./... green.
A4 — the three "deferred to hookup" milestone grants now pay out:
Long Game (T5 clear) guaranteed Legendary via pickMagicItemForRarity
-> dropMagicItemLoot, rendered in a new
milestoneAward.Extra block.
Survivalist (clean T3+) writes AdventureCharacter.Title and announces to
the games room. No schema bump — player_meta.title
already exists and saveAdvCharacter persists it.
Two Weeks (day 15) restocks 3 days of rations (clamped to Supplies.Max)
and grants 3 zone-tier consumables.
Two Weeks was specced as +5 max HP "via the expedition row". Dropped: combat
MaxHP comes from stats.HPBonus, built in combat_stats.go from gear/arena/
housing with no expedition in scope. Threading one through would leak an
expedition-only buff into the sim's balance corpus. A supply cache
self-expires with the run and needs no combat math.
A6 — mid-day events rolled 0.5%/player/day from a deferred ticker slot: one
sighting per ~200 days. The roll and its roll-minute scheduler are gone.
Events now fire where the player is demonstrably present and reading a DM:
the end-of-day digest (8%), a sale at Thom's (5%), and an arena cashout (5%),
capped at one event per player per UTC day. A player who hits all three lands
at ~1.19/week. tryTriggerEvent returns bool so a bail (dead / mid-fight /
event already active) hands the day's slot back rather than burning it.
The frequency test drives its own seeded PCG over the chance constants and
the daily cap, so it measures the policy and can't flake on global RNG order.
Prod autopilot resolved boss/elite fights inline via SimulateCombat, which
swings the enemy once per round (Combatant has no ID to look up the SRD
multiattack profile). Manual !fight uses the turn engine, which loops the
full profile — so autopilot players faced strictly weaker bosses than
manual. D8-e confirmed this is the gap, not a turn-engine artifact.
- Promote the sim's autoResolveCombat/simPickCombatAction to shared plugin
methods autoDriveCombat/pickAutoCombatAction (single source of truth; the
sim now calls the same code prod does).
- Add MessageContext.Silent + a replyDM helper; the turn-engine combat
handlers route their DMs through it so the background autopilot can drive
the real !fight/!attack engine without spamming a DM per round (the EoD
digest summarizes the outcome).
- tryAutoRun now calls runAutopilotWalkDriven (inlineBossCombat flipped
true->false): walk->fight->walk loop so one tick still covers ~autoRunRoomCap
rooms, but boss AND elite now face the player's full kit against the
enemy's full multiattack. Loss surfaces as stopEnded (run already
force-extracted by finishCombatSession).
Trash mobs stay on the fast inline path. GOGOBEE_SIM_INLINE_BOSS=1 A/B
toggle preserved. Build + plugin tests green; sim smoke-run unchanged.
Adds inlineBossCombat alongside compact in runAutopilotWalk and
advanceOnceWithOpts. Production background autorun keeps both true
(inline auto-resolve), foreground stays both false (manual !fight), the
sim now uses compact=true + inlineBossCombat=false so the boss/elite
doorway returns stopBoss/stopElite after the safety gate — autoResolveCombat
+ simPickCombatAction / simPickSpell drive the fight via the turn-based
engine. The picker (and D8-b upcasting) has been dead since D3's
compact-inline boss rooms; this re-wires it.
n=50/cell L10 smoke vs d7d (zones T1-T3):
bard forest_shadows 61 → 100 (+39)
bard manor_blackspire 10 → 34 (+24)
cleric forest_shadows 15 → 96 (+81)
cleric manor_blackspire 0 → 54 (+54)
fighter T1-T3 100 (unchanged)
Also parallelizes matrix mode via subprocess workers (each child has its
own SQLite — db package globals preclude in-process parallelism). New
-jobs flag, defaults to runtime.NumCPU(). 8 workers gave ~7x speedup on
the smoke matrix.
maybeAutoCamp / pitchAutopilotCamp / pitchBossSafetyCamp now take a
now time.Time so the sim can inject a synthetic clock; tryAutoRun
still passes time.Now().UTC(). SimRunner.RunExpedition advances simNow
by autoRunCooldown per walk and runs the production camp scheduler
after each soft stop (and pitchBossSafetyCamp on stopBossSafety),
dwelling minAutoCampDwell + breakAutoCampIfDue so the next walk can
proceed. Effect: HP-low mid-day rests, base-camp waypoints, Night-camp
rollovers, and boss-safety holds all fire under the sim; D7-a's
tickEventAnchoredRollover shortcut is retained on TickDay for tests
and the pre-cutoff legacy path.
Suppress per-tick auto-walk DMs in compact mode. tryAutoRun now only
surfaces for fork / death / run-complete / boss-safety camp / Night-camp
pitch. A Night-camp pitch flushes the day as an EoD digest (counts of
walk/harvest/interrupt plus threat/milestone/narrative bullets, built
from dnd_expedition_log) followed by the camp block. Each successful
background walk writes a `walk` log entry so the digest can count rooms
without persisting raw stream narration. maybeAutoCamp and
pitchBossSafetyCamp now return the autoCampDecision so callers can
branch on dec.Night.
Drops the boss carve-out in the compact (background autorun) path so
boss rooms resolve through the same forward-sim engine elites already
use. A `bossSafetyGate` (HP < 80% / supplies < daily burn /
exhaustion >= 3) guards the engage; when it trips, the walk returns
`stopBossSafety` and the autorun ticker force-pitches a rest camp via
`pitchBossSafetyCamp` (bypasses the normal scheduler's HP threshold
and its RoomBoss room-type block; keeps event-anchored night handling).
`resolveCombatRoom` now selects monster + label + loot drop by
`run.CurrentRoomType()` so the same callsite handles boss kills
(zone.Boss bestiary, "Boss — name down", boss-loot drop, elite-tier
threat bump). The walk loop only breaks at elite/boss doorways when
`!compact`; compact lets the next iteration auto-resolve.
Foreground `!fight` and `!expedition run` are unchanged. Sim path is
unaffected — stopBossSafety falls into the default soft-stop branch.
New expedition_autocamp.go: pure decideAutopilotCamp + pitchAutopilotCamp
+ dwell-window lifecycle. Wired into tryAutoRun so the background ticker
pitches a rest camp on low HP and a base-camp waypoint on region-boss
clear; auto-pitched camps last minAutoCampDwell (4h) before the next tick
breaks them and walks. CampState.AutoPitched separates auto- vs player-
pitched camp lifetimes so a player !camp stays sticky.
Day-rollover semantics unchanged — still UTC-anchored; D2-b moves
day++/burn/threat-drift onto the camp-pitch event.
The run-complete emergence seam rolled the pet-arrival DM synchronously,
then handed the run narration to streamFlow's paced (fire-and-forget)
streamer. The 'animal in your house' prompt landed ahead of the queued
'Run complete' + loot beats. Add streamFlowThen to run the roll after the
final message is delivered; fix the same inverted order in tryAutoRun.
Four fixes for the auto-walk loop that was re-clearing the same room
every 15 min while a fork was pending, ignoring the rest lockout, and
not counting expedition activity toward the daily streak:
- advanceOnceWithOpts / runAutopilotWalk now short-circuit to stopFork
when NodeChoices has a pending fork. Stops phantom kills + duplicate
loot drops + fork re-prompts on the same cleared room.
- fireExpeditionAutoRuns honors restingLockoutRemaining so the
background ticker no longer walks through a long rest.
- autoRunCooldown 15m -> 2h, autoRunTickInterval 5m -> 15m. Auto-walk
DMs are now a once-in-a-while ping, not a steady drip.
- markActedToday + HasActedToday recognise LastActionDate. Wired into
!rest short/long, !expedition start/abandon, !extract, foreground
!zone advance, and !zone go so DnD-side activity credits the streak
even when the expedition ends before midnight.
(cherry picked from commit 9e27fd8257a4c92150ad584b393bf5a72270b82c)
The emergence-seam roll added in 3ed2e1d covered extract, abandon,
forced extraction, and death-respawn — but not a natural run-complete
(boss down / dead-end node), which is the most common successful
emergence. Players who cleared a run cleanly never got the arrival roll
despite meeting every condition.
Wire maybeRollPetArrivalOnEmerge into the two real run-complete callers
(expeditionCmdRun foreground + the autorun background ticker), gated on
reason == stopComplete. Kept out of runAutopilotWalk itself so the sim
path (which calls the walk directly) never fires arrival DMs.
Tedium-removal pass driven by live play feedback. Three big threads:
Expedition autopilot Phase 4 — background auto-run + harvest-until-dry:
- New expeditionAutoRunTicker walks active expeditions every 15min
(5min tick, per-expedition CAS on new last_autorun_at column). Skips
combat sessions, briefing/recap quiet windows, expeditions <30min old.
- Walks up to 3 rooms/tick with compact narration; suppresses DMs when
0 rooms walked or just hitting the per-tick cap (no "stretch complete"
filler). Player only hears from the bot when a real decision is needed.
- Compact mode: one-line combat narration for trash/elite, auto-resolves
elite doorways via the forward-sim engine (boss still pauses). Threaded
via new advanceOnceWithOpts → resolveRoom → resolveCombatRoom.
- Auto-harvest now grinds each Common/Uncommon node until dry (cap at 8
attempts/visit), mirroring manual !scavenge retries. Rare+ still pauses.
- Ambient ticker: anti-repeat by Kind via new last_ambient_kind column
(avoids two pack_rat DMs in a row when the pool only has 6 lines).
- !expedition go <n> now routes to the fork-choice handler when active +
numeric; fork footer rewritten to suggest !expedition go instead of
!zone go. Boss/Elite doorway: formatNextRoomMessage routes the action
hint by next room type and autopilot loop breaks via new nextRoomType
field so "Room X/Y — Boss" doesn't double-print with contradictory
hints (!zone advance vs !fight).
- runAutopilotWalk extracted from expeditionCmdRun so foreground and
background share the loop body.
Rogue Sneak Attack actually exists now:
- Audit found the rogue's "Sneak Attack" passive was AutoCritFirst +
5% damage rider. No Nd6, ever. Phase 2 Monte Carlo masked this
with a small flat buff; the class's defining mechanic never matched
its tooltip or 5e identity.
- Added SneakAttackDie int to CombatModifiers, per-hit Nd6 in
combat_primitives.go (same lane as DivineStrikePerHit). Scales with
level: 1d6 at L1-2 ... 4d6 at L7-8 ... capped at 10d6 at L19-20.
- AutoCritFirst + 5% rider retained as bonuses on top of working sneak
attack — preserves the opener-burst feel.
- L7 rogue expected per-hit ~8 → ~22 damage. Valdris fight math goes
from "16 rounds to kill, die in 8" to winnable.
TwinBee voice sweep (Phase B3):
- ~530 line changes across 24 files replacing third-person "TwinBee
notes/files/tracks/respects/..." constructions with first-person
inside flavor string literals. Code identifiers (TwinBeeLine,
twinBeeLine, etc.), chat-prefix labels (🎭 **TwinBee:**), item names
(TwinBee's Bell), achievements, and game-title references in
fun.go (Konami TwinBee ship) left intact.
- Catches a real bug: Valdris fight rendered "TwinBee marks the
Legendary Resistance" mid-combat in third person, contradicting
the Phase B2 convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>