URL link previews now post the page's og:image as an inline m.image
thumbnail above the title/description reply. All outbound fetches in the
preview path (page scrape, image HEAD probe, image download) route through
a new SSRF/DoS-hardened safehttp client so a posted link can't steer
fetches at loopback/RFC1918/cloud-metadata IPs or OOM the parser.
Also flips the daily WOTD auto-post to opt-in (ENABLE_WOTD_POST=true)
instead of opt-out.
Diagnosed a cleric "death loop" (L14 dying at T2/T3 bosses while
over-levelled): the boss isn't overtuned — caster sustained DPS is
under-delivered, compounded by a fragile healer build.
Engine fix — concentration AOE re-tick:
- Concentration damage spells (spirit_guardians, heat_metal, spike_growth,
call_lightning, flaming_sphere) now tick the enemy every round at
round_end instead of resolving as a one-shot, via a new
CombatStatuses.ConcentrationDmg armed on cast and round-tripped through
the turn engine. Closes the long-tracked turn-engine concentration gap;
the burst still lands the casting round, then the aura lingers.
- Sim picker skips re-casting an already-active aura (models competent play
and prevents a burst+aura double-dip). Re-baseline (n=30 sweep + n=100
confirm): bard +47pp T3 (heat_metal), druid +3-7, cleric/mage flat,
fighter unchanged — no regressions.
Player-data bootstraps (idempotent, run once on Init):
- bootstrapCasterSpellBackfill: ensureSpellsForCharacter only seeds an empty
book, so defaults added after a character's roll never reach it. Backfill
missing defaults into known+prepared for existing casters (gives the
affected cleric inflict_wounds + a working healing_word, since her
healing_word_spell is a dead alias).
- bootstrapGrantStarterPet: one-off L10 pet for an endgame player who never
got the morning arrival roll; adds per-round proc damage + deflect.
- TestScenario_JosieCasterAid verifies both against a copy of the live DB,
incl. idempotency.
Also fix a pre-existing wall-clock flake in
TestFireBriefings_EventAnchoredActivePlayerDelivers (start_date defaulted to
real now, filtering the row out when the suite runs after 06:00 UTC).
getActiveZoneRun's 24h stale-run reaper abandoned the run but left the
wrapping expedition status='active' pointing at a dead run. The autopilot
then read run==nil and bailed while briefing/recap tickers kept firing, so
the player soft-locked at the last fork with no way to route on. The reaper
now force-extracts the wrapping active expedition (run-loss seam) when it
reaps that expedition's current run.
Root cause was a background fork: the autopilot can't pick for the player,
so the run idled all the way to the reaper. Add an 8h forkAutoPickTimeout —
a background fork left unanswered that long now auto-picks the first
unlocked route (same path as !zone go) and keeps walking; all-locked forks
are left for the player. Foreground !expedition run still always prompts.
Tests: idle-timeout extracts the expedition; stale fork takes the available
route; all-locked fork stays intact.
!setup confirm wrote only dnd_character, never the canonical player_meta
seed row. A player who built a character via !setup and played purely in
the D&D/expedition system (which writes inventory directly, bypassing
ensureCharacter) ended up with no player_meta — so every legacy-layer
command's loadAdvCharacter returned 'sql: no rows in result set'.
dndSetupConfirm now routes through ensureCharacter (which seeds
player_meta + tier-0 equipment on first contact) instead of a bare
loadAdvCharacter.
Also fix !wotd force: it was deleting from a non-existent
job_completed/job_key table (silent no-op that left forced re-posts
blocked by the stale dedup row). Now clears the real daily_prefetch
rows and checks the error.
Boss-only tuning at the L15/L16 mid-range (D8-f #2 had tested T5 at the L12
floor where everything walls). The two T5 zones needed opposite treatment:
- dragons_lair (infernax): impossible wall, leaders 0-2% -> HP 546->405,
AC 22->20, frightful-presence stun 0.80->0.40, multiattack 49->42.
- abyss_portal (belaxath): leader faceroll 88-92% -> HP 262->300, AC 19->20,
multiattack 40->41.
Final n=50: leaders 61% at L15 (both zones), 72-79% at L16 -- same
leaders-define-band shape as T4; casters ~0% (J3 caster-track gap, not
monster-tunable). Bosses are the sole binding lever (every run decided at the
boss; standard/elite pools already survivable).
Also harden handleFightCmd: a malformed bestiary entry with an empty ID was
silently persisting an enemy-less combat session that spun to autoDriveCombat's
200-round cap. Now treated as a bestiary miss (fail loud). Writeup:
sim_results/t5_findings.md (gitignored).
T4 monster tuning so martial leaders land in the 60-75% band (underdark
59/80, feywild 65/84 at L10/L12, n=50); casters trail (class-side gap that
monster tuning provably can't compress -- Pass 1 showed casters pinned at 0%
while martials moved). T5 deferred (walls everyone at its L12 floor; needs an
L15-16 corpus).
- dnd_bestiary.go: underdark elite/boss HP+AC up + caster-lethal proc cuts
(mind_flayer/drow_mage/roper); feywild HP+AC up.
- bestiary_srd.go: feywild multiattack profiles (fomorian/night_hag/green_hag)
+ Thornmother 2->3 lashes -- HP/AC alone didn't move feywild's low-damage
roster; multiattack is what pulls facerolling martials into band.
- zone_graph_feywild_crossing.go: free fork1's marsh edge. fork1 was the only
fork in the game with every exit skill-locked (CHA+Perception, no LockNone)
and deterministic no-retry rolls -- a prod SOFT-LOCK stranding ~60% of
players (low CHA+WIS). Kept grove's CHA bargain as a bonus route.
- expedition_sim.go: firstUnlockedForkChoice -- sim fork policy picks the
first unlocked option instead of blind 'go 1' (which looped forever on
locked forks); halts fork_all_locked if none.
- tests: graph-wide TestZoneGraphs_NoSoftLockedFork + fork1 free-path guard.
Writeup: sim_results/d8f_findings.md
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.
Diagnostic env toggle (off by default) routing the sim's boss/elite
doorways through the inline SimulateCombat path instead of the turn
engine, for A/B-ing the martial T4/T5 'regression'. D8-e confirmed the
gap is honest multiattack math (inline swings the enemy once/round; the
turn engine loops the full SRD profile) and that prod autopilot is
secretly easier than manual !fight. Toggle left in for the D8-f parity
work.
`spellExpectedDamage` (`dnd_class_balance.go`) now multiplies expected
damage by 3 when `sp.Concentration && sp.Effect ∈ {EffectDamageSave,
EffectDamageAuto}`. Attack-roll concentration excluded by design —
hex-style cases ride per-hit mods, not the score. Closes the picker
gap that walked past heat_metal / spike_growth / flaming_sphere /
cloud_of_daggers in favour of one-shot blasts of similar slot level.
Measured on n=5000 d8c corpus vs d8prereq baseline (sim_results,
gitignored): per-class means all within ±2.4pp (1σ ≈ 2.2pp); macro
leaderboard unchanged. Real picker swap lands at T3 manor_blackspire
— bard +11pp, mage +10pp, sorcerer +9pp, druid +5pp. T4/T5 caster
wall unchanged → confirms HP+AC is the binding constraint past T3
(D8-d-fix territory), not picker quality.
Side discovery: cleric flat / warlock −6pp manor — spirit_guardians-
style AOE-save concentration spells appear to resolve once in
SimulateCombat / turn-engine instead of re-ticking per round. The
multiplier is correct in expectation; the engine under-delivers.
Filed as separate follow-up; D8-c stays.
Plan §8 D8-c marked SHIPPED.
- expedition-sim matrix worker now captures child stderr and dumps
runErr/stderr/stdout-snippet on failure so halted rows have a cause.
- simPickSpiritualWeapon walks slots 2..5 and upcasts when L2 is spent
instead of silently skipping the spell on high-level clerics.
- advanceOnceWithOpts !inlineBossCombat branch now emits the same
"Room X/Y — Boss/Elite. Type !fight to engage." line as foreground.
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.
simPickSpell now enumerates one candidate per available slot >= native
for every prepared damage spell, scored via spellExpectedDamage with the
existing "+1 die per slot" scaling. Cantrips contribute a single slot-0
candidate. Tie-break: highest slot first, then highest expDmg. Picker
returns "<id> --upcast N" when the winning slot exceeds native so
parseCombatCast upcasts in the engine. Build + plugin tests green.
Then: measured zero impact. d8b_corpus.jsonl (same 10x5x100 matrix as
d7d) lands every class within +/-1.5pp of d7d baseline.
Root cause discovered post-corpus: simPickSpell is dead code in the
current sim path. Commit 68ed8e7 ("Long expeditions D3: compact
autopilot auto-resolves boss rooms") added a !compact gate at
dnd_expedition_cmd.go:810 so compact autopilot inline-resolves boss/
elite rooms via resolveCombatRoom -> runZoneCombat -> SimulateCombat
instead of returning stopBoss/stopElite for autoResolveCombat to handle.
The sim uses compact=true (expedition_sim.go:433), so autoResolveCombat
- the only caller of simPickCombatAction/simPickSpell - never fires.
Verified empirically: a stderr-traced simPickSpell produced zero hits
across full bard/cleric L10 runs.
This rewrites the picker-era retrospective. J2's baseline_j2a_v2_all10
(bard 40%, cleric 39%) was measured before D3 with the picker live.
d7d's L10 baseline (bard 34%, cleric 21%) is post-D3 with the picker
disconnected - that 6-18pp drop is the post-D3 caster regression, not
"long-expedition mechanics didn't help casters" as d7d framed it.
D8-a's "+2.2pp cleric" was also noise (1sigma ~ 1.8pp on n=500).
D8-b code kept in tree (correct, currently inert, turns on as soon as
the wire reconnects). Plan rewritten in section 8: D8-b and D8-c are
deferred behind D8-prereq, which splits compact so the sim opts out of
compact-inline boss/elite combat without affecting prod rendering.
Files:
- internal/plugin/expedition_sim.go: simPickSpell upcast enumeration
- gogobee_long_expedition_plan.md: D8-b implemented-but-inert,
D8-prereq added as next step
- sim_results/d8b_corpus.jsonl: 5000-row corpus retained for the
picker-dead baseline
Fix sim regression introduced by D5-b: bare `expedition start <zone>`
returns the loadout prompt DM without outfitting, so SimRunner.RunExpedition
was halting before persisting any expedition. Pass `heavy` from the
harness — tier-max packs, no prod paths touched.
D7-d corpus (sim_results/, gitignored): n=100 × 10 classes × 5 zones ×
L10. Leaderboard mirrors J2b — martials 78–82%, casters 21–42%, cluster
gap unchanged by long-expedition mechanics. Cleric worst at 21% (L10).
Bard/cleric trailers not relieved by autopilot camp pacing; remains
J3-territory. T3/T4 cleared runs hit their §2 target durations.
D5-d retune decision: no change. phase5BDailyBurnRatePct=50 + per-tier
DailyBurn stay as-is — heavy-preset cleared runs end with 78–98% SU
surplus; even TPK runs leave packs mostly full. Supply economy is not
a binding constraint at heavy preset.
Closes the long-expedition track.
cmd/expedition-sim -days N caps runs by synthetic day rollovers
(Outcome="day_capped"). SimResult.DaySnapshots traces HP/SU/threat/rooms
at start, every Night-camp rollover, and end-of-run — unblocks empirical
D5-d retune of phase5BDailyBurnRatePct against per-day SU draws.
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.
deliverBriefingEventAnchored reads time.Now().UTC() for its safety-net
check, so synthetic TickDay calls never advanced CurrentDay on D2-b
expeditions — DaysAtEnd / SUEnd stayed at start values. Short-circuit
in TickDay: when isEventAnchored, fire nightRolloverBurn → optional
applyCampRest (Standard, Rough fallback, skipped on low-SU) →
nightRolloverDrift(briefAt). Mirrors pitchAutopilotCamp Night=true.
Production paths untouched.
Unblocks D5-d retune of phase5BDailyBurnRatePct and the class corpus
re-run.
Rewrite !expedition help around the autopilot loop, frame !camp/!fight
as overrides, and add Day X / ~Y expected + rooms/total + last-3-events
to !expedition status.
§4.2's "Ranger, 1d4 SU/day" perk had been dead since Phase 12 E1b —
SupplyForageMaxSU was defined but unreferenced, ForagedToday was only
ever reset to false. New applyRangerForage helper grants 1d4 SU once
per day (headroom-capped, Ranger-only), fires at the top of
nightRolloverBurn, and surfaces as a "forage" line in the end-of-day
digest. No DC roll — accessibility over crunch, and the D5-a caps
already give all loadouts comfortable headroom.
`!expedition start <zone>` (and `!resume`) with no pack arg now DMs a
Lean / Balanced / Heavy menu sized per zone tier. Player replies with
the preset name (short forms l/b/h also work). Raw `Ns Md` syntax stays
as the advanced override.
Heavy always equals supplyPackCaps (the D5-a harsh×3 ceiling); Lean
covers intended days at raw burn; Balanced sits between.
Today's global SupplyPackStandardMax=3 / SupplyPackDeluxeMax=1 were
a 2-day shape; with D1's longer room budgets and D2-b's event-anchored
night burns, a T4/T5 player can't legally buy enough supplies for the
intended duration. supplyPackCaps(tier) now returns (std,dlx) per
tier — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2) —
sized to clear DailyBurn(raw) × intendedDays × 1.3 even with the
harsh×3 multiplier layered on. Validate takes a tier; both call sites
(!expedition start, !resume) pass the resolved zone's tier. Holiday
+1 standard pack still bypasses the cap on purpose. DailyBurn /
phase5BDailyBurnRatePct unchanged; that's a D7 lever once the sim
can measure event-anchored rollovers.
Event-anchored expeditions no longer pin their re-engagement DM to 06:00
UTC. The ticker skips idle players (last_activity older than today's
threshold, inside the 28h safety net); maybeDeliverDeferredBriefing fires
the owed briefing on the next inbound message in any room. The OnMessage
hook also stamps last_activity so chat presence (not just bot commands)
counts toward "the player is here."
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.
Splits the legacy briefing body into nightRolloverBurn + nightRolloverDrift,
plus a processNightCamp convenience. For expeditions started after the new
eventAnchoredCutoff, the 06:00 UTC briefing stops mutating: it posts a
re-engagement DM, and only force-fires processNightCamp itself after a 28h
safety-net window. Day++/burn/threat-drift now ride along the autopilot
night-camp pitch (decideAutopilotCamp sets Night=true when ≥16h since the
last rollover) and on the first player !camp since the last rollover. The
legacy UTC-anchored flow still works via the same staged helpers, with
processOvernightCamp interleaved to preserve the rest-before-drift ordering.
Tests pin eventAnchoredCutoff to year 9999 in TestMain so the existing
legacy assertions still run; new tests cover the night decision, the
night-camp pitch advancing the day, the event-anchored skip, and the
safety-net force-rollover.
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.
Closes D1. Both T5 zones follow the D1-a/b/c/d pattern: extend the
graph so the longest entry→boss walk lands in band, keep the zone's
authored topology intact. Also backfills the missing per-node
RegionID authoring against dnd_expedition_region.go — the D1-d
deferral note flagged this as a prerequisite for T5.
dragons_lair (T5): 12 → 47 nodes, longest 38 (band 36-44)
abyss_portal (T5): 13 → 51 nodes, longest 39 (band 36-44)
Topology preserved per zone:
- Dragon's Lair: 4-region authoring (every node carries a valid
RegionID); binary-converging fork1 (ash_bridge TRAP / treasure_vault
Perception 15) still converges at the R3 wyrmlings_nest elite;
capstone fork2 still 3-way (LockNone / CHA 16 / Perception 17
SECRET LootBias 2.5) with each spur preserving its own named
capstone node. New R4 infernax_doors MERGE consolidates the long
final hall instead of triplicating it.
- Abyss Portal: 4-region authoring; three sequential forks preserved
(binary / binary / ternary) — fork1 Perception 16, fork2 CON 16
(full STR/DEX/CON/INT/WIS/CHA roster still covered), fork3 capstone
3-way with reality_seam SECRET LootBias 3.0. New R4 belaxath_doors
MERGE consolidates the final tear-approach.
MinRooms/MaxRooms re-pitched 9-10 → 36-44 so the dice fallback also
lands in band for any future graphless variant.
Both T4 zones follow the D1-a/b/c pattern: extend the graph so the
longest entry→boss walk lands in band, keep the zone's authored
topology intact.
underdark (T4): 10 → 46 nodes, longest 30 (band 28-34)
feywild_crossing (T4): 9 → 53 nodes, longest 30 (band 28-34)
Topology preserved per zone:
- Underdark: 4-region authoring intact (every node carries a valid
RegionID); 3-way fork1 (drow R2 / illithid R3 / deep_chasm R1)
with LockNone / LockPerception / LockStatCheck CON; deep_chasm
intentionally stays R1; all three arms converge at the R4
throne_approach merge. Adds the missing Trap anchor (Collapsed
Arch) in the R1 preamble.
- Feywild Crossing: woven forks intact — hag_circle still has two
incoming edges (one per first-stage path), time_eddy stays
grove-exclusive, illusion_garden stays marsh-exclusive, fork1
remains CHA-vs-Perception with no LockNone option. Adds the
missing Trap anchor (Cursed Thicket) and a fae_court MERGE so
the three second-stage endings converge into one final boss
approach instead of triplicating it.
MinRooms/MaxRooms re-pitched 8-10 → 28-34 so the dice fallback also
lands in band for any future graphless variant.
T5 zones (dragons_lair, abyss_portal) deferred to D1-e — those
need a RegionID backfill on top of the length extension since their
regionsByZone entries are not currently honored by their graphs.
Both T3 zones follow the D1-a/b pattern: extend the graph so the longest
entry→boss walk lands in band, keep the zone's authored topology intact.
manor_blackspire (T3): 11 → 35 nodes, longest 23 (band 22-26)
underforge (T3): 10 → 31 nodes, longest 23 (band 22-26)
Topology preserved per zone:
- Manor: two-stacked-3-way forks identity + full lock-kind coverage
(LockNone, LockPerception, LockStatCheck, LockLevelMin). Adds the
missing Trap anchor (Cursed Threshold). All six spokes are 3 mid-nodes
so route choice is loot/encounter character, not shortcut economics.
- Underforge: one-way-descent gauntlet identity preserved — 17-node
linear preamble through the existing Cooling River (TRAP) and Magma
Chamber (ELITE), single 3-way antechamber fork before boss. All three
antechamber spokes are 4 mid-nodes.
MinRooms/MaxRooms re-pitched 7-9 → 22-26 so the dice fallback also lands
in band for any future graphless variant.
Three more zones follow the goblin_warrens pattern from D1-a: add the
missing Trap anchor and deepen the graph so the longest entry→boss walk
lands in its tier's target band.
crypt_valdris (T1): 8 → 17 nodes, longest 13 (band 12–14)
forest_shadows (T2): 9 → 19 nodes, longest 16 (band 16–20)
sunken_temple (T2): 10 → 26 nodes, longest 16 (band 16–20)
Topology preserved per zone:
- Crypt diamond + Perception-DC-15 secret off side_chapel.
- Forest asymmetric main fork (long branch carries the elite) +
WIS-DC-14 secret pre-boss.
- Sunken Temple sequential forks with no mid-path merge (four leaves
all terminate at the single boss room).
MinRooms/MaxRooms re-pitched to the §2 bands so the dice fallback also
lands in band for any future graphless zone.
Length is now sourced from the zone graph's longest entry→boss path,
not the dice roll, so "Room X/Y" matches what the player walks. The
dice fallback stays for graphless zones.
Goblin Warrens grows to the T1 12–14 band (13-node traversal): adds the
missing Trap anchor and deepens both fork branches + the post-merge
approach. Pattern reference for the remaining zones in D1-b…d.
!camp now applies long-rest effects (HP, spell slots, exhaustion,
threat -5, Underforge heat) immediately instead of waiting for the
06:00 UTC briefing. New CampState.RestApplied flag stops
processOvernightCamp from re-applying at briefing — it just strikes
the camp.
Also dials ambient cooldown 3h → 6h so a workday gives ~1–2 hits
instead of ~3.
midnightReset gated shame DMs on LastActionDate + a fallback busy check
for active expedition / combat session. Players running on background
autopilot got shamed at midnight: dnd_zone_cmd.go gates markActedToday
on !compact (to keep autopilot out of streak credit), so LastActionDate
stayed stale; and if the autopilot's expedition completed / extracted /
got force-extracted before midnight, the busy check also missed it
(getActiveExpedition only matches status='active').
Switch the reaper to loadAdvDailyActivity — the same oracle the daily
report uses, which unions adventure_activity_log + dnd_zone_run +
dnd_expedition_log. Three explicit branches:
- engaged (LastActionDate today/yesterday or legacy counters bumped)
→ streak bumps, LastActionDate restamped
- activity-without-tap (autopilot beats, expedition log, or live
active expedition/combat as safety net) → hold: no bump, no shame,
no decay
- truly idle → shame DM + streak halve
The old busy branch bumped streak; new behavior holds. Aligns autopilot
days with mid-fight-at-midnight days: both engaged earlier but typed
nothing today, neither pumps the streak.
Tests cover all three branches + the death-window early-return.
Spiritual Weapon used to ride the pet-attack channel, so a petless
cleric saw "🐾 Your faithful companion" each round and couldn't tell
the spell was firing. Split it to SpiritWeaponProc/Dmg with its own
✨ flavor; damage now scales with spell mod + upcast.
Rest also fired mid-dungeon — only the autorun honored RestingUntil,
the !rest commands themselves had no gate. Block both short and long
rest when an expedition or combat session is active.
Camp was a stationary "rest here until the next briefing" intent, but
nothing struck it when the autopilot / !zone advance / fork picks walked
the party into a new room. The stale CampState then blocked !camp <type>
with "already camped" even though the player was several rooms away.
autoBreakCampOnMove clears the camp (no rest bonuses — those only land
at briefing time via processOvernightCamp) whenever Camp.RoomIndex no
longer matches run.CurrentRoom. Wired into advanceOnceWithOpts (covers
advance/autopilot, complete/fork/next-room branches all surface a
"⛺ Camp struck" banner) and zoneCmdGo (fork commit).
These three stateful enemy-effect cases emitted their flavor pool via bare
pickRand, leaking the literal %d placeholder to players ("-%d damage on
every strike from here"). The magnitude is carried in CombatEvent.Damage,
exactly like the already-correct max_hp_drain / spell_fizzle cases; wrap
them in fmt.Sprintf. Add a regression test asserting no placeholder leaks.
resolvePendingInteraction deletes the pending entry before dispatch, but
resolvePetName then re-ran LoadAndDelete to recover the carried PetType.
That second lookup always missed, so the if !ok branch returned nil: the
save never ran and no pet was ever persisted (adoption was 100% broken).
Pass the already-loaded interaction in, matching every other handler.
Add a regression test driving the real dispatcher path.
Synthetic sim chars are vanilla base-class (no pet, no subclass), so the
per-round pet attack/deflect/whiff path was never exercised in the sim.
Add -pet-level N (1-10) which stamps a base Massive Dog (no armor) onto the
AdventureCharacter via the normal save path before the run, so combat's
DerivePlayerStats sees HasPet()==true. 0 (default) stays petless.
Measured lift (n=40, 10 classes x L3/7/12 x 3 zones): overall clear-rate
38.3% petless -> 47.8% at pet L10 (+9.4pp); biggest gains go to the caster
trailers (bard +13.3, warlock +11.9, cleric +11.4), narrowing class spread.
- forex_crypto.go: cap the CoinGecko response with io.LimitReader(64KiB);
build the simple/price query via url.Values instead of bare Sprintf.
- dnd_expedition_camp.go: a combat-session lookup error now fails open
(allow camp) + logs a warning, instead of blocking standard camp with a
misleading empty 'not cleared' rejection.
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.
- Camp: campLocationCheck rejects only on live combat; no-encounter and
post-kill rooms are campable (kills the misleading "clear it first").
- Fork: markActedToday moved after the pending-fork early-return so spamming
!zone advance at a fork no longer keeps the daily streak alive.
- Pet whiff/deflect: single proc spent on the first multiattack swing only
(was applied to every swing); also dedups the per-swing event spam.
- Autopilot: background region crossing now runs an HP/SU preflight and
pauses if low, instead of burning a transit day while the player is idle.
- Legacy streak: acted-branch restamps LastActionDate=today so a purely-legacy
actor's streak no longer resets to 1 every night.
midnightReset runs at 00:00 UTC closing out yesterday, but gated idle
shame on HasActedToday(), which compares LastActionDate to today (the
new day). DnD-side players credit the day via LastActionDate only, so
anyone who played yesterday and extracted before midnight read as idle
and had their streak halved. Credit activity on the closing day
(LastActionDate==yesterday) directly, matching the streak-bump branch.
(cherry picked from commit f6cb4889e7e6bfacce41ea40853456b08d6857a9)
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)
Camp rest rules:
• Standard camp now rejects an uncleared room with a clear message
instead of silently downgrading to rough — let the player make the
call rather than spending supplies on a worse rest they didn't pick.
• campLocationCheck treats a room with a resolved (non-active) combat
session as cleared, so the natural "just killed, not yet advanced"
pause still qualifies for a standard camp.
• Reword fortified-camp gating + babysit help to "zone boss defeated"
(cache sites aren't a thing yet) so the requirement isn't misleading.
Map: renderVisitedPath adds a 1-indexed breadcrumb of visited rooms to
!expedition map, so players can reference rooms by index (e.g. !revisit 2).
(cherry picked from commit a38fc77eed888e9790c7a7cff24369b98910b43e)
The live turn engine only struck once per fight and never rolled pet
deflect or whiff, so pet armor (deflect-only) bought nothing in real
runs. Roll pet attack each player turn and roll deflect/whiff per enemy
turn, mirroring the auto-resolve engine; retire the one-shot pet-proc
machinery (rollCombatSessionPetProc / PetProcReady).
(cherry picked from commit a0e41c97801e500efad13c7e9a06be4c345e464e)
On an expedition the autopilot drives the walk, so the manual Elite/Boss
fight close-outs and per-room next-room lines pointed players at the wrong
surface (`!zone advance` instead of `!expedition run`). Route every
"keep moving" prompt through continueHint(), which picks the verb by mode.
Special-case the boss victory to read as the expedition win.
(cherry picked from commit 30b51b91445f3bb9680cd252df6c761e3ce61d0a)
Mirror runAutopilotWalk's multi-region auto-advance in the headless sim:
on a mid-zone stopComplete (active multi-region exp with a next region),
advanceToNextRegion and keep simulating instead of scoring a premature
"cleared". A transit error there is now recorded as halted, not cleared.
Also fix misleading run-complete wording: a non-boss region clear of a
multi-region zone now reads "Cleared {region}. The way to {next} opens
ahead." instead of "Cleared {zone}. Run complete." New midZoneRegionClear
helper shares finalizeExpeditionOnZoneClear's gating; the path is shared
with manual !region travel, so both autopilot and manual play get it.
(cherry picked from commit 5d2bba70849a0a3fdeac285cc55ea9b8fadea29c)
Extract regionCmdTravel's transit core into advanceToNextRegion (shared
by manual !region travel), then have runAutopilotWalk auto-advance into
the next region on a mid-zone stopComplete instead of dead-stopping the
walk. Transit cost (day + supplies + wandering check) is identical on
both paths.
(cherry picked from commit 8ac09cf3b059a086c431859c8aaaf046aa992342)