Commit Graph

369 Commits

Author SHA1 Message Date
prosolis
c9df282fde N1/A5: ticket sales fund the community pot
`!lottery pot` has always claimed "pot is funded by ... ticket sales", and
the draw debits prizes from that pot -- but handleLotteryBuy only debited
the player, so ticket euros were burned and the lottery ran as a pure drain
on rake income from elsewhere.

Credit the pot after lotteryInsertTickets succeeds, so the refund path can't
strand money in it.

Watch pot-drain-vs-rake per project_lottery_economics: if weekly pot growth
jumps, tune the prize tiers rather than the ticket price.
2026-07-09 18:32:23 -07:00
prosolis
e8f4863ae0 N1/A1-A3: rewire the drop systems Phase R orphaned
Treasure, masterwork, and consumable drops each had zero call sites: they
only ever fired from the legacy daily activity loop, which adventure.go now
intercepts with a deprecation DM. Hook all three to the zone-combat seam.

A1 - treasure: rollAdvTreasureDropDetailed takes a weight, applied to the
base rate. Boss x4, elite x2, standard x1, plus one x1 roll on zone clear.
Near-miss DMs now fire only for weighted moments; at x1 on autopilot they'd
land on ~3% of every kill.

A2 - masterwork: the catalog is keyed to mining/fishing/foraging, so the
dungeon lookup returned nil and the hook would have been a silent no-op.
masterworkDefForZone rolls across all three slot lines at the zone's tier.
Flavor now follows loc.Activity rather than the item's catalog line, with a
new dungeon pool - a crypt boss must not narrate a pickaxe striking ore.

A3 - consumables + ingredients: the audit found more than the four named
ingredients were stranded. generateAdvLoot is reachable only from
resolveDungeonAction, which has no callers, so all four legacy loot tables
were dead and every one of the 12 recipes was uncraftable. rollZoneIngredient
draws from those tables directly at the zone's tier, reviving them wholesale
rather than re-keying 24 ingredients into the per-zone slates.
2026-07-09 18:29:31 -07:00
prosolis
a4f162d2ee appservice: heartbeat presence every 20s to beat Synapse's 30s offline timeout (was flapping at 60s) 2026-07-04 10:42:55 -07:00
prosolis
0f82a088f9 appservice: start presence heartbeat before plugin init so bot shows online from boot 2026-07-04 10:36:44 -07:00
prosolis
d3f42009f7 appservice: heartbeat presence online so bot shows green while running 2026-07-04 09:54:46 -07:00
prosolis
6387380d0a Add appservice auth mode behind AUTH_MODE; land device grant
Two things, entangled in the working tree because 20d1f92 was mislabeled
(its message claimed "device grant" but it only deleted registration.yaml.example
and left the failed appservice+/sync hybrid in client.go):

1. Land the MAS OAuth 2.0 device grant that was running in prod but never
   committed: masauth.go + client.go rewrite. Bot stays a normal user so /sync
   works; refresh token persisted in data/mas_auth.json.

2. Add "appservice" as an alternate transport behind AUTH_MODE (default
   "masdevice" = unchanged device-grant path, instant rollback):
   - internal/bot/session.go: Session abstraction unifying both transports
     (OnEventType/Run/Stop); NewSession dispatches on AuthMode.
   - internal/bot/appservice.go: as_token auth (MAS-durable, no login/MFA/expiry),
     cryptohelper MSC4190 device creation, crypto machine fed from Synapse
     transaction pushes (to-device/device-lists/OTK) instead of /sync, inbound
     decrypt+redispatch, and lazy per-room StateStore backfill (encryption +
     members) so replies in encrypted rooms encrypt to the right recipients.
   - main.go: NewSession/sess.OnEventType/sess.Run in place of the /sync loop.
   - .env.example: AUTH_MODE, AS_REGISTRATION, AS_LISTEN_HOST/PORT, HOMESERVER_DOMAIN.

The appservice path fixes the earlier hybrid's fatal flaw (Synapse forbids AS
users from /sync) by using the transaction/push model. Bot-side only; Synapse
registration + experimental_features (msc2409/msc3202/msc4190) and E2EE cutover
are the next steps. Build + vet green (CGO=1 -tags goolm).
2026-07-03 17:11:58 -07:00
prosolis
48330be3d5 Migrate Matrix auth to appservice (MAS-durable)
Replace password login + password-UIA cross-signing with appservice
as_token auth and MSC4190 device creation, so the bot survives the
Matrix Authentication Service (MAS) migration that removes m.login.password
and UIA.

- internal/bot/client.go: NewClient uses AS_TOKEN, SetAppServiceUserID,
  whoami validation, cryptohelper MSC4190 device create; drop device.json
  (crypto store persists device id); cross-signing best-effort/soft-fail.
- main.go: Config.Password -> ASToken (reads AS_TOKEN).
- internal/util/auth.go: deleted (password login dead in a MAS world).
- Bump mautrix-go v0.28.0 -> v0.28.1.
- registration.yaml.example + README/.env.example: appservice setup docs.
2026-07-03 15:12:47 -07:00
prosolis
f93fddd1d6 Add email-nag plugin: collect+verify missing Authentik emails over Matrix DM
One-shot MAS-migration helper. DMs a fixed target set of users with no
email on file in Authentik, collects an address, verifies inbox ownership
with an emailed 6-digit code (via Resend), and only writes it back to
Authentik after confirmation — so a mistyped/hostile address never becomes
a live recovery address. FSM state persists in email_nag_prompts so
restarts never re-nag anyone done or mid-flow.

- Per-user rolling-1h cap on verification emails (EMAIL_NAG_MAX_SENDS_PER_HOUR)
  to prevent send-spam abuse.
- Misconfig disables only this plugin instead of aborting bot startup.
2026-06-28 17:39:29 -07:00
prosolis
8122973b74 Link thumbnails: post og:image previews + WOTD default-off
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.
2026-06-24 22:00:09 -07:00
prosolis
cbfca525f5 Lift caster trailers: concentration re-tick + Josie caster-aid bootstraps
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).
2026-06-18 06:34:16 -07:00
prosolis
f4a39b46e9 Fix expedition soft-lock: extract on run idle-timeout + auto-pick stale forks
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.
2026-06-18 00:28:05 -07:00
prosolis
6a47be34bc Honor IGNORED_BOTS env var: global sender ignore at dispatch
IGNORED_BOTS was set in .env but never read by any code; only the
URL-preview plugin filtered, and via a different unset var
(URL_PREVIEW_IGNORE_USERS). Parse IGNORED_BOTS in NewRegistry and
short-circuit DispatchMessage/DispatchReaction so ignored senders
(e.g. @pete:parodia.dev) are dropped before any plugin runs.
2026-06-05 19:26:49 -07:00
prosolis
667f87f9d0 Fix SQL errors for D&D-only players: seed player_meta on setup confirm
!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.
2026-06-03 17:23:53 -07:00
prosolis
1b8d13e0dd J3 D11: T5 difficulty lift (leaders-define-band) + empty-EnemyID combat guard
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).
2026-05-28 21:55:34 -07:00
prosolis
d80b437525 J3 D10: T4/T5 anchor-room variety (2nd elite + trap per zone)
In-place node kind swaps on fork branches — no node added/removed, longest
path unchanged — adding one Elite + one Trap of anchor variety to each T4/T5
zone, placed to keep fork-choice risk/loot asymmetric:

  underdark   +Elite drow_gate (R2->R4 region-guardian)  +Trap silenced_chamber
  feywild     +Elite singing_orchard (grove branch)      +Trap mire_steps (marsh)
  dragons_lair+Elite coin_strewn_hall (treasure spur)    +Trap hidden_passage (hoard spur)
  abyss_portal+Elite wardens_hall (R3 guardian)          +Trap hush_corridor, seam_threshold

abyss_portal shipped with zero trap nodes; D10 gives it two. A/B sim corpus
(n=480/side): boss-clear flat (+1.5pp aggregate, per-cell within n=15 noise),
median day-counts stable, combats/run rose where anchors hit common paths.
New Test*Graph_*Anchors tests lock the per-zone trap/elite counts.
2026-05-28 20:00:09 -07:00
prosolis
4934383a9a J3 D8-f #2: T4 difficulty lift (leaders-define-band) + feywild fork1 soft-lock fix
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
2026-05-28 19:02:58 -07:00
prosolis
a46b773750 J3 D8-f #1: route prod autopilot boss/elite through the turn engine
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.
2026-05-28 15:30:48 -07:00
prosolis
b80de43db1 J3 D8-e: GOGOBEE_SIM_INLINE_BOSS toggle for engine A/B
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.
2026-05-28 15:08:16 -07:00
prosolis
81dda51907 J3 D8-d-fix: caster AC floor + HP ×1.25
computeAC: lift caster floors (cleric/druid 3→5, bard/warlock 1→3,
mage/sorcerer 0→2). Ranger 3 / rogue 1 / fighter+paladin 6 unchanged.

computeMaxHP: new casterHPMult(class) = 1.25 for the same caster set,
applied multiplicatively on top of phase5BHPMult. Martials unchanged.
New caster L10 HPMax: mage/sorc ~98 (was 78), bard/cleric/druid/warlock
~118 (was 95); martial L10 ~141 unchanged.

bootstrap_caster_hp.go: new bootstrapCasterHPRefresh (job key
caster_hp_refresh_v1) refreshes existing rows once at startup; mirrors
bootstrapPhase5BHPRefresh — only raises HPMax, preserves absolute
wound. Wired in adventure.go.

Measured on n=2500 d8dfix2 corpus (gitignored sim_results) vs d8c:
- T3 manor caster lift confirmed — bard +13 (43→56), druid +8 (88→96),
  warlock +8, sorcerer +5, mage +5, cleric +2. Martials flat.
- Macro deltas: bard +2.6, warlock +2.0, druid +1.4, mage/sorcerer +1.0,
  cleric -0.2. n=500 1σ ≈ 2.2pp — bard/warlock cleanly past noise.
- T4 underdark wall still holds (every caster ~0%). Lift directionally
  correct but undersized for T4 atk-bonus + multiattack — separate
  follow-up.
- T5 dragons_lair universal wall unchanged (martials TPK too).

Diagnostic context: project_d8d_diagnostic. Cleric flat manor likely
the concentration AOE re-tick engine gap (filed for D8-e+).
2026-05-28 09:35:04 -07:00
prosolis
d7ced471a1 J3 D8-c: concentration ×3 multiplier in spellExpectedDamage
`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.
2026-05-28 08:14:13 -07:00
prosolis
63ad423b79 J3 D8-review: surface sim subprocess errors + SW upcast + doorway msg
- 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.
2026-05-28 00:53:25 -07:00
prosolis
631764bbbd J3 D8-prereq: split compact flag so sim drives the picker
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.
2026-05-27 23:16:33 -07:00
prosolis
ad2a8258ba J3 D8-b: sim picker upcasting + discovery picker is dead since D3
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
2026-05-27 22:27:04 -07:00
prosolis
2fdb280477 J3 D8-a: caster picker data fixes + Spiritual Weapon pick
Bard L1 += thunderwave, L2 += heat_metal; cleric L1 += inflict_wounds;
shatter overlay Classes broadened (defensive — mergeClassList already
unioned). New simPickSpiritualWeapon runs before simPickSpell so a
cleric with an L2 slot opens fights with the BuffSelf-tagged spell that
the regular picker (damage-effects only) was skipping; existing
spiritWeaponStrike per-round mace path lights up.

Measured L10 n=100/zone: cleric 21.0 → 23.2% (+2.2pp), bard within
noise. T3+ wall unchanged — picker upcasting + concentration-damage
modeling (D8-b/c) are the bigger levers, queued in plan §8.
2026-05-27 22:00:02 -07:00
prosolis
4576c75722 Long expeditions D7-d: corpus re-run + sim heavy-preset fix
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.
2026-05-27 21:18:48 -07:00
prosolis
3b29d10461 Long expeditions D7-c: -days flag + per-day snapshots in SimResult
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.
2026-05-27 20:47:28 -07:00
prosolis
29cad7972a Long expeditions D7-b: drive autopilot camp from SimRunner
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.
2026-05-27 20:40:04 -07:00
prosolis
a2992ea06c Long expeditions D7-a: teach SimRunner.TickDay event-anchored rollover
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.
2026-05-27 20:15:54 -07:00
prosolis
6c4b14e113 Long expeditions D6: player-facing surface cleanup
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.
2026-05-27 20:01:50 -07:00
prosolis
9be85ba954 Long expeditions D5-c: wire Ranger forage SU
§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.
2026-05-27 19:48:44 -07:00
prosolis
26cda148fb Long expeditions D5-b: loadout preset prompt at launch
`!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.
2026-05-27 19:41:49 -07:00
prosolis
040cfba514 Long expeditions D5-a: per-tier supply pack caps
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.
2026-05-27 19:33:57 -07:00
prosolis
aaec0ba225 Long expeditions D4-b: activity-anchored morning briefing
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."
2026-05-27 19:21:49 -07:00
prosolis
5a6e395805 Long expeditions D4-a: autopilot DM bundling + EoD digest
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.
2026-05-27 19:07:37 -07:00
prosolis
68ed8e7c60 Long expeditions D3: compact autopilot auto-resolves boss rooms
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.
2026-05-27 18:56:52 -07:00
prosolis
c729433353 Long expeditions D2-b: event-anchored day rollover
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.
2026-05-27 18:42:57 -07:00
prosolis
7115c536ef Long expeditions D2-a: autopilot camp scheduler
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.
2026-05-27 18:23:26 -07:00
prosolis
42fb805ee0 Long expeditions D1-e: T5 zones to new 36-44 room band
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.
2026-05-27 18:13:33 -07:00
prosolis
d99f975074 Long expeditions D1-d: T4 zones to new 28-34 room band
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.
2026-05-27 18:02:05 -07:00
prosolis
bbc25fe958 Long expeditions D1-c: T3 zones to new 22-26 room band
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.
2026-05-27 17:46:38 -07:00
prosolis
89edc76829 Long expeditions D1-b: crypt_valdris + T2 zones to new room bands
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.
2026-05-27 17:32:29 -07:00
prosolis
4999368031 Long expeditions D1-a: graph-driven TotalRooms; goblin_warrens 7→16
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.
2026-05-27 17:24:36 -07:00
prosolis
92b99a0399 Camp: heal on pitch; ambient cooldown 3h → 6h
!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.
2026-05-27 16:57:57 -07:00
prosolis
0f72484653 Idle reaper: use unified activity oracle, hold streak on autopilot
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.
2026-05-23 10:30:47 -07:00
prosolis
2e6274c1b7 Spiritual Weapon: own channel + narration; rest blocked mid-zone
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.
2026-05-23 10:14:51 -07:00
prosolis
9eed921e4b Camp: auto-break when party moves to a different room
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).
2026-05-23 10:05:19 -07:00
prosolis
2ce56cf76a Combat narration: format magnitude for stat_drain/debuff/ally_buff
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.
2026-05-22 09:26:17 -07:00
prosolis
7dbfa0b56f Pet adoption: stop double-deleting pending, pass interaction to resolvePetName
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.
2026-05-22 08:42:47 -07:00
prosolis
b167882e3e expedition-sim: -pet-level flag to model a base housing pet
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.
2026-05-22 08:28:51 -07:00
prosolis
95e0995c7f Forex/camp review hardening: bound CoinGecko body, escape URL params, fail-open camp on DB error
- 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.
2026-05-22 07:25:08 -07:00