22 Commits

Author SHA1 Message Date
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
da94d51857 J3 D8-d: T4 caster wall diagnostic — HP+AC, not transit/heals
Plan update only. Diagnostic in sim_results/d8d_findings.md
(gitignored). n=3-5 per caster L10 underdark:

- Multi-region transit eliminated: all casters TPK in r1
  before any cross-region transit; Combats=0 (no elite/boss
  reached).
- Heal-stock not the lever: bumped simConsumableBundle T4
  2 -> 5 Spirit Tonics, +3-5 rooms median, 0/5 clears.
  Reverted.
- Confirmed lever: computeAC class floors (casters 11-14
  vs martial 16-17) + d6/d8 HP scaling.

Recommended next session: lift floors in computeAC
(dnd.go:195) — cleric/druid 3->5, bard/warlock 1->3,
mage/sorcerer 0->2. Validate vs d8prereq_corpus.
2026-05-28 00:41:28 -07:00
prosolis
151d6abc01 J3 D8-prereq: plan update — corpus diff + D8-c/d/e queued
Marks D8-prereq shipped with the 5000-run d8prereq_corpus measurements
(cleric +25.8, sorcerer +22.6, warlock +20.2, mage +17.8, druid +14.4,
bard +12.0 vs d7d). Caster/martial gap closed from ~40pp to ~22pp; T2/T3
zones are where the picker pays off.

Queues three follow-ups in §8: D8-c concentration-damage modeling, D8-d
T4 caster wall diagnostic (every caster still 0% at underdark — picker
no longer the suspect), D8-e martial T4/T5 regression triage (-6 to -18
pp from routing boss/elite through the turn-engine instead of inline
sim; likely inline was over-rosy, not turn-engine wrong).

Findings detail: sim_results/d8prereq_findings.md (local, sim_results/
is gitignored). Re-baseline corpus: sim_results/d8prereq_corpus.jsonl.
2026-05-28 00:18:29 -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
50 changed files with 3067 additions and 299 deletions

View File

@@ -7,6 +7,11 @@ BOT_DISPLAY_NAME=GogoBee
# Which rooms the bot posts scheduled content to (comma-separated room IDs) # Which rooms the bot posts scheduled content to (comma-separated room IDs)
BROADCAST_ROOMS=!roomid:example.com BROADCAST_ROOMS=!roomid:example.com
# The daily 08:00 WOTD auto-post is disabled by default.
# Set to true to enable it. The !wotd command and passive WOTD
# usage tracking work regardless of this setting.
ENABLE_WOTD_POST=false
# Admins who can run admin-only commands (comma-separated Matrix user IDs) # Admins who can run admin-only commands (comma-separated Matrix user IDs)
ADMIN_USERS=@yourmxid:example.com ADMIN_USERS=@yourmxid:example.com

View File

@@ -13,12 +13,16 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"os/exec"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync"
"gogobee/internal/plugin" "gogobee/internal/plugin"
@@ -32,6 +36,7 @@ func main() {
zone = flag.String("zone", "goblin_warrens", "zone id (single-run mode)") zone = flag.String("zone", "goblin_warrens", "zone id (single-run mode)")
bank = flag.Float64("bank", 1000, "starting coin balance — must cover outfitting") bank = flag.Float64("bank", 1000, "starting coin balance — must cover outfitting")
cap = flag.Int("cap", 50, "max autopilot bursts per expedition (each = up to autopilotRoomCap rooms)") cap = flag.Int("cap", 50, "max autopilot bursts per expedition (each = up to autopilotRoomCap rooms)")
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)") dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)") userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)") logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
@@ -45,6 +50,8 @@ func main() {
trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps") trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps")
petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)") petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)")
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
) )
flag.Parse() flag.Parse()
@@ -65,14 +72,14 @@ func main() {
includeLog = *logFlag includeLog = *logFlag
} }
}) })
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, includeLog) runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel)
return return
} }
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *logFlag) runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag)
} }
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap int, includeLog bool) { func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool) {
dir := dataDir dir := dataDir
if dir == "" { if dir == "" {
var err error var err error
@@ -83,7 +90,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
} }
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap) res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days)
if err != nil { if err != nil {
if res != nil { if res != nil {
if !includeLog { if !includeLog {
@@ -99,47 +106,129 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
emitIndented(res) emitIndented(res)
} }
func runMatrix(classes, levels, zones string, runs int, bank float64, cap int, includeLog bool) { // matrixJob is one (class, level, zone, replicate-index) cell of the
// matrix sweep. Each job is run by a worker as a single-run subprocess so
// it gets its own SQLite handle — the plugin package's db.* globals
// preclude in-process parallelism.
type matrixJob struct {
class string
level int
zone string
rep int
}
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel int) {
cs := splitNonEmpty(classes) cs := splitNonEmpty(classes)
ls := parseLevels(levels) ls := parseLevels(levels)
zs := splitNonEmpty(zones) zs := splitNonEmpty(zones)
if len(cs) == 0 || len(ls) == 0 || len(zs) == 0 || runs <= 0 { if len(cs) == 0 || len(ls) == 0 || len(zs) == 0 || runs <= 0 {
fail("matrix mode requires non-empty -classes, -levels, -zones and runs > 0") fail("matrix mode requires non-empty -classes, -levels, -zones and runs > 0")
} }
enc := json.NewEncoder(os.Stdout) if jobs <= 0 {
jobs = runtime.NumCPU()
}
exe, err := os.Executable()
if err != nil {
fail("os.Executable:", err)
}
work := make([]matrixJob, 0, len(cs)*len(ls)*len(zs)*runs)
for _, c := range cs { for _, c := range cs {
for _, lv := range ls { for _, lv := range ls {
for _, z := range zs { for _, z := range zs {
for r := 0; r < runs; r++ { for r := 0; r < runs; r++ {
dir, err := os.MkdirTemp("", "expedition-sim-") work = append(work, matrixJob{class: c, level: lv, zone: z, rep: r})
if err != nil {
fail("mkdir temp:", err)
}
uid := id.UserID(fmt.Sprintf("@sim:%s-l%d-%s-%d", c, lv, z, r))
res, runErr := runOne(dir, uid, plugin.DnDClass(c), lv, plugin.ZoneID(z), bank, cap)
if res != nil && !includeLog {
res.Log = nil
}
if runErr != nil && res == nil {
// Synthesize a row so the corpus has one line per
// cell regardless of init failures.
res = &plugin.SimResult{
UserID: string(uid),
Class: c,
Level: lv,
Zone: z,
Outcome: "halted",
}
}
_ = enc.Encode(res)
_ = os.RemoveAll(dir)
}
} }
} }
} }
} }
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap int) (*plugin.SimResult, error) { workCh := make(chan matrixJob)
resCh := make(chan *plugin.SimResult, len(work))
var wg sync.WaitGroup
for i := 0; i < jobs; i++ {
wg.Add(1)
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel)
}
go func() {
for _, j := range work {
workCh <- j
}
close(workCh)
}()
go func() {
wg.Wait()
close(resCh)
}()
enc := json.NewEncoder(os.Stdout)
for r := range resCh {
_ = enc.Encode(r)
}
}
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel int) {
defer wg.Done()
for j := range in {
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
dir, err := os.MkdirTemp("", "expedition-sim-")
if err != nil {
out <- &plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
continue
}
args := []string{
"-class", j.class,
"-level", strconv.Itoa(j.level),
"-zone", j.zone,
"-bank", strconv.FormatFloat(bank, 'f', -1, 64),
"-cap", strconv.Itoa(cap),
"-days", strconv.Itoa(days),
"-data", dir,
"-user", uid,
fmt.Sprintf("-log=%t", includeLog),
fmt.Sprintf("-pet-level=%d", petLevel),
}
if trace {
args = append(args, "-trace")
}
cmd := exec.Command(exe, args...)
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
stdout, runErr := cmd.Output()
var res plugin.SimResult
jerr := json.Unmarshal(stdout, &res)
if jerr != nil {
res = plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
}
if runErr != nil && res.Outcome == "" {
res.Outcome = "halted"
}
// Surface subprocess failures: parse error with non-empty stdout
// (corrupted JSON) and non-zero exits both get a stderr dump so the
// user sees the underlying cause instead of just a halted row.
if jerr != nil || runErr != nil {
fmt.Fprintf(os.Stderr, "sim cell %s halted: runErr=%v jerr=%v\n", uid, runErr, jerr)
if stderrBuf.Len() > 0 {
fmt.Fprintf(os.Stderr, " child stderr:\n%s\n", stderrBuf.String())
}
if jerr != nil && len(stdout) > 0 {
snip := stdout
if len(snip) > 200 {
snip = snip[:200]
}
fmt.Fprintf(os.Stderr, " child stdout (first 200B): %q\n", snip)
}
}
if !includeLog {
res.Log = nil
}
out <- &res
_ = os.RemoveAll(dir)
}
}
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int) (*plugin.SimResult, error) {
runner, err := plugin.NewSimRunner(dataDir) runner, err := plugin.NewSimRunner(dataDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("init runner: %w", err) return nil, fmt.Errorf("init runner: %w", err)
@@ -150,7 +239,7 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
return nil, fmt.Errorf("build character: %w", err) return nil, fmt.Errorf("build character: %w", err)
} }
runner.Euro.Credit(uid, bank, "expedition-sim bankroll") runner.Euro.Credit(uid, bank, "expedition-sim bankroll")
return runner.RunExpedition(uid, zone, cap) return runner.RunExpedition(uid, zone, cap, days)
} }
func emitIndented(res *plugin.SimResult) { func emitIndented(res *plugin.SimResult) {

View File

@@ -2,6 +2,18 @@
> Goal: dungeon expeditions become real multi-day journeys (T1 ≈ 2 days → T5 ≈ 7 days). Player picks **camp supplies** at launch, then watches it play out. Camp pitch/break, elite engagement, and **boss engagement** all become autonomous. Foreground `!camp` / `!fight` survive as overrides, not the expected path. > Goal: dungeon expeditions become real multi-day journeys (T1 ≈ 2 days → T5 ≈ 7 days). Player picks **camp supplies** at launch, then watches it play out. Camp pitch/break, elite engagement, and **boss engagement** all become autonomous. Foreground `!camp` / `!fight` survive as overrides, not the expected path.
## Status @ 2026-05-28
**Core track (D1D7) — DONE.** All phases shipped; v1 of the long-expedition mechanics is live on `long-expeditions-d1`.
**§6 decisions (2026-05-28) re-opened two threads** that ride after D8:
- **D9** — T1T3 room-count bump toward the 35× target band (D1 landed T1T3 at ~2×). Sim-first: read T1/T2 day-counts from a fresh corpus *after* D8 lands.
- **D10** — T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone). Bundle with D9 to avoid touching graphs twice.
**Active work — §8 (J3 caster sim-picker):** D8-a / D8-b / D8-prereq / D8-d-diagnostic shipped; **D8-c (concentration), D8-d-fix (AC floor lift), D8-e (martial regression triage)** queued. D8 is sequentially the gate before D9/D10 — room counts only make sense once the sim accurately scores the classes that will walk through them.
**Sequence:** D8-c → D8-d-fix → D8-e → D9 → D10. Re-baseline once at the end.
## 1. Why the current shape is "too short" ## 1. Why the current shape is "too short"
| Tier | Zones | Rooms | Real-time exit | | Tier | Zones | Rooms | Real-time exit |
@@ -136,10 +148,65 @@ Each successful background walk now logs a `walk` entry (`appendExpeditionLog(..
- No new public command surface, no schema change, no test churn (no existing test asserted on the rewritten strings); full `go test ./...` green. - No new public command surface, no schema change, no test churn (no existing test asserted on the rewritten strings); full `go test ./...` green.
### D7 — Sim + class re-baseline ### D7 — Sim + class re-baseline
- `cmd/expedition-sim` extensions: multi-day mode, autopilot camp, autopilot boss.
- Re-run the class win-rate corpus (cross-link `project_class_balance`, `project_j2_sim_artifact`). **D7-a (shipped 2026-05-27):** `SimRunner.TickDay` now drives event-anchored expeditions. `expedition_sim.go` short-circuits when `isEventAnchored(exp)` is true: instead of calling `deliverBriefing` (whose `deliverBriefingEventAnchored` branch reads `time.Now().UTC()` and would never trip the safety-net under synthetic ticks), it runs `nightRolloverBurn` → optional `applyCampRest(Standard)` if affordable (Rough fallback, skipped on low-SU) → `nightRolloverDrift(briefAt)`. Mirrors `pitchAutopilotCamp` with `Night=true`. Production paths untouched. New `expedition_sim_test.go` covers (i) 3 ticks advancing day 1→4 with supply burn + stamped `LastBriefingAt`, and (ii) the low-SU branch where the rest is skipped but burn/drift still fire. Unblocks D5-d empirical re-baseline of `phase5BDailyBurnRatePct` and the class corpus re-run.
- Re-check trailers (bard/cleric per `project_j1_post_sweep_findings`) — autopilot camp pacing may relieve their HP-cliff issue.
- New balance memory entry once corpus stabilizes. **D7-b (shipped 2026-05-27):** `SimRunner.RunExpedition` now drives the production camp scheduler under a synthetic clock (`simWalkInterval = autoRunCooldown`, 2h per walk). After every soft-stop walk it calls `maybeAutoCamp(exp, simNow)`; `stopBossSafety` routes to `pitchBossSafetyCamp(exp, simNow)`. The helpers (`applyAutoCamp` / `applyAutoCampBossSafety`) advance `simNow` past `minAutoCampDwell` (4h) and break the auto-pitched camp via `breakAutoCampIfDue` so the next walk proceeds. `maybeAutoCamp`, `pitchAutopilotCamp`, and `pitchBossSafetyCamp` gained a `now time.Time` parameter (the only prod caller, `tryAutoRun`, still passes `time.Now().UTC()`). Effect: HP-low mid-day rests, multi-region base-camp waypoints, Night-camp rollovers, and boss-safety holds all fire under the sim — the D7-a `tickEventAnchoredRollover` shortcut is no longer used by RunExpedition (kept on TickDay for tests + the pre-cutoff legacy path). Coverage in `expedition_sim_test.go`: Night-camp day advance, HP-low mid-day rest, boss-safety force-pitch, no-op when neither trigger fires.
**D7-c (shipped 2026-05-27):** `cmd/expedition-sim` gained a `-days N` flag — when set, `RunExpedition` exits with `Outcome="day_capped"` after the Nth synthetic day rollover; 0 (default) is unbounded and only the existing `-cap` walk safety net applies. `SimResult` gained `DaySnapshots []SimDaySnapshot` ({Day, HPCurrent, HPMax, Supplies, Threat, Rooms}), captured at expedition start (Day 0), after every `applyAutoCamp`/`applyAutoCampBossSafety` Night-camp rollover, and one final end-of-run entry (reads `getExpedition(mostRecentExpeditionID(...))` when the row already extracted so closed runs still close their trajectory). `RunExpedition` signature picked up the `maxDays int` parameter; sole caller is the CLI. Coverage: `TestSimRunner_CaptureDaySnapshot_PopulatesFields` exercises the helper in isolation (HP from the live character row, SU/threat/day from the live expedition, Rooms from `res.Rooms`). Unblocks empirical D5-d retune of `phase5BDailyBurnRatePct` against per-day SU draws.
**D7-d (shipped 2026-05-27):** class corpus re-run + D5-d retune decision. First fixed a sim regression `expedition_sim.go` introduced by D5-b — bare `expedition start <zone>` now returns the loadout prompt instead of outfitting, so the sim was halting before persisting any expedition; harness now passes the `heavy` preset. Corpus: `sim_results/d7d_corpus.jsonl` (n=100 × 10 classes × 5 zones × L10 = 5000 runs, `heavy` preset, `-cap 300`). Analysis: `sim_results/d7d_analyze.py`. Writeup: `sim_results/d7d_findings.md`. **Key reads:** (i) leaderboard mirrors [[project_j2_sim_artifact]] — martials 7882%, casters 2142%, ~40pp cluster gap unchanged by long-expedition mechanics; (ii) bard/cleric trailers **not** relieved by autopilot camp pacing — cleric actually regressed at L10 (21% vs J2b L12 39%) on spell-pool richness, J3-territory; (iii) T3 hits target 4-day duration (median 3), T4 hits 56 days (median 5), T5 hits 5 of 7 (only 21 clears); (iv) **D5-d retune: no change.** `phase5BDailyBurnRatePct=50` + per-tier `DailyBurn` stay as-is — heavy-preset cleared runs end with 7898% SU remaining; supply economy is not a binding constraint and players lose to HP zero, not starvation. New memory entry: [[project_d7d_baseline]].
**Remaining work (D7-e+):** none in the core D7 scope. Two follow-up phases (D9 room-count bump, D10 T4/T5 anchor variety) were opened by the 2026-05-28 §6 resolution and are sequenced after D8. See §9 below.
## 8. J3 caster-picker rework (next session)
**Why split out:** D7-d corpus confirmed the martial/caster gap is class-roster + sim-picker driven, not autopilot/burn/pacing driven. Long-expedition mechanics are settled; the trailer fix lives in the sim picker, not the expedition shell.
**D8-a (shipped 2026-05-27, this session):** quick data-layer cleanup. Bard L1 prepared list gained `thunderwave`; L2 gained `heat_metal`. Cleric L1 gained `inflict_wounds`. `shatter` overlay Classes broadened to mage/bard/sorcerer (overlay was already unioning via `mergeClassList`, so this is defensive). New `simPickSpiritualWeapon` runs before `simPickSpell` in `simPickCombatAction`: cleric with an L2 slot + spiritual_weapon prepared + `sess.Statuses.BuffSpiritProc == 0` opens the fight with it, picking up the existing `spiritWeaponStrike` per-round bonus-action attack. **Measured impact (L10, n=100/zone, heavy preset):** cleric 21.0 → 23.2% (+2.2pp), bard 34.2 → 32.8% (within noise). T3+ wall unchanged — the data fixes are correct but the picker math is what's binding. Files touched: `internal/plugin/dnd_spells_data.go:192`, `internal/plugin/dnd_spells.go:822,881,884`, `internal/plugin/expedition_sim.go:simPickCombatAction + new simPickSpiritualWeapon`. All green.
**D8-b (implemented 2026-05-27, **inert** — see D8-prereq below): sim picker upcasting.** `simPickSpell` (`internal/plugin/expedition_sim.go:933`) now enumerates one candidate per available slot ≥ native for every prepared damage spell, scored via `spellExpectedDamage(sp, slotLevel, c.Level)`. Cantrips contribute a single slot-0 candidate. Tie-break: highest slot first, then highest expDmg. When the winning slot exceeds the spell's native level, the picker returns `"<id> --upcast N"` so `parseCombatCast` upcasts in the engine. Build + tests green.
**Measured impact: nothing.** d8b corpus (`sim_results/d8b_corpus.jsonl`, same matrix as d7d) lands every class within ±1.5pp of the d7d baseline — bard 1.0, cleric 0.4, mage 1.2, sorcerer +1.4, warlock 0.8, druid +0.6, martials ±0.2. Pure noise band. Expected bard +510pp / mage+sorcerer+warlock +28pp never materialized.
**Root cause (discovered post-corpus, this session):** `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.jsonl` (bard 40%, cleric 39%) was measured before D3, with the picker live.** d7d's L10 baseline (bard 34%, cleric 21%) was measured *after* D3, with the picker disconnected — that ~618pp drop is the post-D3 caster regression, not "long-expedition mechanics didn't help casters" as the d7d writeup framed it. D8-a's "+2.2pp cleric" was also noise (1σ ≈ 1.8pp on n=500).
**D8-prereq (SHIPPED 2026-05-28, commit `631764b`):** split the `compact` flag in `runAutopilotWalk` / `advanceOnceWithOpts` into `compact` + `inlineBossCombat`. Production background autorun (`expedition_autorun.go:173`) passes `(true, true)` — unchanged inline auto-resolve. Sim (`expedition_sim.go:432`) passes `(true, false)` so boss/elite doorways return `stopBoss`/`stopElite` after the safety gate; `autoResolveCombat``simPickCombatAction``simPickSpell` / `simPickSpiritualWeapon` now drive boss/elite fights through the turn-based `!fight` engine. D8-b upcasting went live with the rewire. Foreground (`!expedition run`) flow unchanged (compact=false short-circuits).
Also parallelized matrix mode via subprocess workers (`cmd/expedition-sim/main.go`, `-jobs` flag; defaults to NumCPU). Each worker is its own process so the plugin's global SQLite handle isn't a contention point.
**Measured impact (5000-run L10 corpus, `sim_results/d8prereq_corpus.jsonl` — full diff in `sim_results/d8prereq_findings.md`):**
| Class | d7d | d8prereq | Δ |
|----------|-----:|---------:|-------:|
| cleric | 21.0 | 46.8 | +25.8 |
| sorcerer | 29.4 | 52.0 | +22.6 |
| warlock | 35.4 | 55.6 | +20.2 |
| mage | 36.4 | 54.2 | +17.8 |
| druid | 42.2 | 56.6 | +14.4 |
| bard | 34.2 | 46.2 | +12.0 |
| (martials) | ~80 | ~78 | -2 to -4 (noise + T4/T5 turn-engine regression) |
Caster/martial gap closed from ~40pp to ~22pp. T2/T3 zones are now the picker payoff (forest_shadows 75 → 99, manor_blackspire 45 → 75). **T4 caster wall persists** (every caster 0% at underdark); **T5 is universally 0%** including martials. Both require separate diagnostics — picker isn't the binding constraint past T3.
**D8-c (SHIPPED 2026-05-28):** concentration-damage multiplier. `spellExpectedDamage` (`internal/plugin/dnd_class_balance.go:280`) multiplies expected damage by 3 when `sp.Concentration && sp.Effect ∈ {EffectDamageSave, EffectDamageAuto}`. `EffectDamageAttack` excluded by design — single-target attack-roll spells aren't typically concentration; hex-style cases get their lift from mods. Test: `TestSpellExpectedDamage_ConcentrationMultiplier`.
**Measured impact (d8c_corpus.jsonl vs d8prereq, full diff in `sim_results/d8c_findings.md`):** every class within ±2.4pp on the leaderboard (1σ ≈ 2.2pp on n=500) — *macro-leaderboard unchanged*. Picker swap landed where expected at **T3 manor_blackspire**: bard +11pp (32→43), mage +10pp (71→81), sorcerer +9pp (60→69), druid +5pp manor + +1pp underdark. T4/T5 caster wall unchanged (still 0% for every caster except druid 0→1 underdark) — confirms [[project_d8d_diagnostic]]: past T3 the binding constraint is HP+AC, not picker quality.
**Side discovery (engine modelling gap):** cleric stayed flat across all zones despite having spirit_guardians (L3, concentration, save) prepared — and warlock dropped 6pp at manor. Likely the sim's `EffectDamageSave` path resolves the AOE save *once* rather than re-ticking per round while concentration holds, so the multiplier overrates spells the engine then under-delivers on. **Filed as a separate follow-up** (concentration re-tick gap in `SimulateCombat`/turn-engine); does *not* unwind D8-c — bard/mage/sorcerer/druid picker swaps are clean and within plan. Cross-link [[project_concentration_retick_gap]] when written.
**D8-d (DIAGNOSTIC SHIPPED 2026-05-28):** T4 caster wall diagnosed; no code change landed. Writeup: `sim_results/d8d_findings.md`. **Hypothesis disambiguation across cleric/bard/mage/sorcerer/warlock/druid L10 underdark (n=35 each):** (a) multi-region transit damage — REJECTED. All casters TPK in r1 (`underdark_surface_tunnels`) before any cross-region transit; `Combats=0` (the elite/boss session table is empty); deaths are entirely inline `SimulateCombat` mob rooms. (b) T4 attack-bonus vs caster AC — confirmed contributor. `computeAC` floor gives casters AC 1114 vs martial 1617; T4 standard roster (Atk +5 to +7) hits ~6065%. (c) HP scaling — confirmed contributor. Caster HPMax 93110 vs martial 141 (~30% gap). (d) Heal-stock — minor lever, NOT the lever. Direct experiment: bumped `simConsumableBundle` T4 from 2 → 5 Spirit Tonics, re-ran n=5 per class. Median rooms-before-TPK lifted +35 but 0/5 clears across every caster. Reverted. **Recommended tuning lever (next session, NOT shipped here):** lift `computeAC` class floors (cleric/druid 3 → 5, bard/warlock 1 → 3, mage/sorcerer 0 → 2) — single function, easy to revert, doesn't move the martial leaders. Validate against `d8prereq_corpus.jsonl`. Hit-dice rescale is the second option but is bigger surface area.
**D8-e (TODO next session): martial T4/T5 regression triage.** Paladin -18pp T4 / fighter -6pp T4 -5pp T5 / ranger -13pp T5 — the cost of moving boss/elite fights from inline `SimulateCombat` to the turn-based `!fight` engine. Two possibilities: (1) turn-engine plays martials slightly worse than inline-sim (action economy / multiattack handling differs — see [[project_sim_multiattack_gap]]); (2) inline-sim was over-rosy and the new numbers are the real ones. Read: trace one paladin underdark run pre/post and diff damage exchanges. If turn-engine is correct, prod martial numbers were inflated and we should not "fix" the regression.
**Sequence:** D8-c → D8-d → D8-e. D8-c is the smallest lever and confirms the picker model. D8-d is the highest-value (caster T4 is the next gap to close). D8-e is informational — settles whether to trust the new numbers.
**Open questions for D8:**
1. ~~D8-b upcast aggressively or conservatively?~~ **Resolved:** aggressive — the T3 lift (mage +61, warlock +65, druid +68) confirms the model.
2. D8-c concentration-rounds factor: 3 fixed, or scale with tier (longer fights at T4/T5)? Fixed is simpler; tier-scaled is more correct.
3. ~~Cleric T3+ cliff~~**partial resolution:** picker rewire took cleric T3 0% → 39%. Cleric T4 is still 0% (and so is every other caster); now subsumed by D8-d.
## 4. Cross-cutting risks ## 4. Cross-cutting risks
@@ -161,15 +228,53 @@ Likely none. Existing columns cover it:
## 6. Open questions for next session ## 6. Open questions for next session
1. ~~Day-advance semantics~~**decided 2026-05-27: event-anchored.** day++ fires on autopilot camp-pitch (autopilot night-camp = sleep = day burn + briefing). UTC clock becomes a re-engagement-DM anchor only, not a state mutator. See §3-D2 for implementation impact. 1. ~~Day-advance semantics~~**decided 2026-05-27: event-anchored.** day++ fires on autopilot camp-pitch (autopilot night-camp = sleep = day burn + briefing). UTC clock becomes a re-engagement-DM anchor only, not a state mutator. See §3-D2 for implementation impact.
2. **Boss autopilot default**: always-on, opt-out, or opt-in? My lean: always-on with a per-expedition opt-out flag; revisit after D3 reads. 2. ~~Boss autopilot default~~**decided 2026-05-28: default ON.** D3 already ships compact-mode boss auto-resolve gated by `bossSafetyGate` (HP/SU/exhaustion). No opt-out flag for now; revisit if players want manual final-blow framing.
3. **Per-tier room counts**: the table in §2 is a starting point. Worth a sim pass before D1 commits — or fine to ship a guess and refine in D7? 3. ~~Per-tier room counts: sim-first vs ship-and-refine~~**decided 2026-05-28: sim-first refinement in D7-d.** D1 shipped a guess; we're much closer now. Measured vs prior: T1 ~2x (7→16), T2 ~1.8x (9→19), T3 ~2x (11→35), T4 ~3x (10→46), T5 ~3x (13→47). Target band is 35x; T1T3 likely want another bump but defer until the D7-d sim corpus reads day-counts. Don't churn graphs blindly.
4. **Extra anchor rooms**: T4/T5 with one elite + one trap may feel thin spread over 30+ rooms. Add a second elite mid-zone, or rely on multi-region + patrols + threat for variety? 4. ~~Extra anchor rooms (T4/T5)~~**decided 2026-05-28: yes, add anchor-room variety in T4/T5.** Second elite + second trap mid-zone (not just rely on multi-region/patrols/threat). Slot into D7-d alongside the room-count retune so we don't re-touch the graphs twice.
5. **Mobile/AFK friendliness**: should the autopilot-DM cadence aim for "1 DM per real-time evening" regardless of room count, or "1 DM per in-game day" (which could be hours apart if the player set the pacing fast)? 5. ~~Mobile/AFK / DM cadence~~**decided in D4-a (already shipped):** event-anchored to in-game day. Night-camp pitch flushes the day as a digest; fork/death/run-complete still DM the walk stream; mid-day rests/waypoints surface only the camp block; per-tick auto-walks silent. No real-time pacing knob.
## 7. Sequence ## 7. Sequence
D1 → D2 → D3 (independent of D4/D5) → D5 → D4 → D6 → D7. **Original D1D7 plan (all shipped):** D1 → D2 → D3 → D5 → D4 → D6 → D7.
D2 and D3 can land in either order; D4 needs the volume problem to be real (so after D2/D3 at minimum). D5 wants D1's new lengths but can run in parallel with D2/D3. D7 always last. **Current sequence (post-2026-05-28):** D8-c → D8-d-fix → D8-e → D9 → D10 → final re-baseline.
Estimated sessions: 57. D2 and D5 are the biggest single-session chunks. | Phase | What | Blocks |
|------|------|--------|
| D8-c | Concentration-damage multiplier in `spellExpectedDamage` | D8-d-fix read |
| D8-d-fix | Lift `computeAC` caster floors (cleric/druid 3→5, bard/warlock 1→3, mage/sorcerer 0→2) | D8-e read |
| D8-e | Diff turn-engine vs inline-sim for paladin/fighter/ranger on T4/T5; decide whether martial regression is real or a "fix" target | D9 (need stable class baseline) |
| D9 | T1T3 room-count bump toward 35× band, sim-validated against fresh corpus | D10 |
| D10 | T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone) | final re-baseline |
| — | One final L10 + L12 corpus pass to confirm tier day-counts + class spread | — |
## 9. Post-§6 follow-ups (D9 / D10)
### D9 — T1T3 room-count bump
**Why now:** §6 fork 3 resolved to "sim-first refinement"; D7-d corpus shows T3 hitting only median 3 days (target 4) and T1/T2 weren't even measured because the corpus only matrixed T3+. T1T3 graphs landed at ~2× prior length in D1; target band is 35×.
**Plan:**
- Pull T1/T2 day-counts from a post-D8 corpus run (matrix needs to include `goblin_warrens`, `crypt_valdris`, `forest_shadows`, `sunken_temple` at L4/L6).
- Lift graph sizes only if median days < tier target (T1=2, T2=3, T3=4). Don't churn graphs that already hit target.
- Reuse the D1-a..e fork/anchor/merge patterns (`internal/plugin/zone_graph_*.go`); preserve existing topology — only add Exploration nodes + the second-anchor slot D10 will fill.
- One-shot bootstrap: existing in-flight expeditions fence by `start_date` per §4 (consistent with D1 deploy).
**Risk:** if D8-d-fix lifts caster T3 clears materially, day-counts move too; measure after D8 stabilizes, not before.
### D10 — T4/T5 anchor-room variety
**Why now:** §6 fork 4 resolved "yes". T4/T5 zones run 3050 rooms with only one Trap + one Elite anchor; the long middle reads same-y. Bundle with D9 so we touch each `zone_graph_*.go` once.
**Plan:**
- Add a second Trap + second Elite anchor mid-zone for all T4/T5 zones (`underdark`, `feywild_crossing`, `dragons_lair`, `abyss_portal`).
- Place the second Elite at the region-boundary spurs (good narrative justification — region-guardian rather than zone-guardian) so multi-region travel finally gets a teeth-y interrupt.
- Place the second Trap on the long Exploration runs of fork branches, not on the merge node (keeps fork-choice loot/risk asymmetric).
- Preserve longest-path length within current band — added anchors replace Exploration nodes, not append.
**Validation:** same final corpus pass as D9. Look for boss-clear rate dipping by <5pp (anchor difficulty is intended, not punishing) and median day-count stable.
### Cross-links picked up here
- [[project_multiregion_travel_unwired]] — boss-clear → next-region auto-advance is wired (verified 2026-05-27); D10's region-boundary elites give it something to interrupt.
- [[project_j3_caster_picker]] / [[project_d8d_diagnostic]] — D8 gates the whole sequence.
- [[project_d8prereq_baseline]] — the corpus to diff against, not d7d.

View File

@@ -2,6 +2,8 @@ package bot
import ( import (
"log/slog" "log/slog"
"os"
"strings"
"sync" "sync"
"gogobee/internal/plugin" "gogobee/internal/plugin"
@@ -11,11 +13,22 @@ import (
type Registry struct { type Registry struct {
mu sync.RWMutex mu sync.RWMutex
plugins []plugin.Plugin plugins []plugin.Plugin
ignoredBots map[string]struct{}
} }
// NewRegistry creates an empty plugin registry. // NewRegistry creates an empty plugin registry. Senders listed in the
// IGNORED_BOTS env var (comma-separated full Matrix user IDs, e.g.
// "@pete:parodia.dev") are dropped before any plugin sees them.
func NewRegistry() *Registry { func NewRegistry() *Registry {
return &Registry{} ignored := make(map[string]struct{})
if raw := os.Getenv("IGNORED_BOTS"); raw != "" {
for _, u := range strings.Split(raw, ",") {
if u = strings.TrimSpace(u); u != "" {
ignored[u] = struct{}{}
}
}
}
return &Registry{ignoredBots: ignored}
} }
// Register adds a plugin to the registry. // Register adds a plugin to the registry.
@@ -42,6 +55,9 @@ func (r *Registry) Init() error {
// DispatchMessage sends a message context to all plugins in order. // DispatchMessage sends a message context to all plugins in order.
func (r *Registry) DispatchMessage(ctx plugin.MessageContext) { func (r *Registry) DispatchMessage(ctx plugin.MessageContext) {
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
return
}
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
for _, p := range r.plugins { for _, p := range r.plugins {
@@ -71,6 +87,9 @@ func (r *Registry) safeOnMessage(p plugin.Plugin, ctx plugin.MessageContext) {
// DispatchReaction sends a reaction context to all plugins in order. // DispatchReaction sends a reaction context to all plugins in order.
func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) { func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) {
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
return
}
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
for _, p := range r.plugins { for _, p := range r.plugins {

View File

@@ -333,6 +333,9 @@ func runMigrations(d *sql.DB) error {
// engages when a fork / elite / boss / supply pinch actually // engages when a fork / elite / boss / supply pinch actually
// needs a decision. CAS-claim on this column gates re-entry. // needs a decision. CAS-claim on this column gates re-entry.
`ALTER TABLE dnd_expedition ADD COLUMN last_autorun_at DATETIME`, `ALTER TABLE dnd_expedition ADD COLUMN last_autorun_at DATETIME`,
// URL link previews now post the page's og:image/twitter:image
// thumbnail; cache it alongside the title/description.
`ALTER TABLE url_cache ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`,
} }
for _, stmt := range columnMigrations { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {
@@ -1124,6 +1127,7 @@ CREATE TABLE IF NOT EXISTS url_cache (
url TEXT PRIMARY KEY, url TEXT PRIMARY KEY,
title TEXT DEFAULT '', title TEXT DEFAULT '',
description TEXT DEFAULT '', description TEXT DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
cached_at INTEGER DEFAULT (unixepoch()) cached_at INTEGER DEFAULT (unixepoch())
); );

View File

@@ -220,6 +220,16 @@ func (p *AdventurePlugin) Init() error {
// migration walks dnd_character once at startup; idempotent via // migration walks dnd_character once at startup; idempotent via
// JobCompleted gate. // JobCompleted gate.
bootstrapPhase5BHPRefresh() bootstrapPhase5BHPRefresh()
// J3 D8-d-fix: caster HP lift (casterHPMult in dnd.go). Refresh
// existing caster rows once at startup so the lift reaches live
// players without waiting for level-up.
bootstrapCasterHPRefresh()
// 2026-06-18 caster-aid: backfill default spells that postdate a
// character's roll (ensureSpellsForCharacter only seeds an empty book),
// and a one-off pet gift for an endgame player who never got the morning
// arrival roll. Both idempotent via JobCompleted gates.
bootstrapCasterSpellBackfill()
bootstrapGrantStarterPet()
// Phase R1 orphan-archive used to run here on every Init, but it // Phase R1 orphan-archive used to run here on every Init, but it
// over-archived: it treats any active dnd_zone_run row not linked to // over-archived: it treats any active dnd_zone_run row not linked to
// an active expedition as a legacy `!adventure dungeon` orphan, which // an active expedition as a legacy `!adventure dungeon` orphan, which

View File

@@ -83,18 +83,19 @@ var srdProfiles = map[string]SRDProfile{
{Name: "Scourge", AttackBonus: 9, Damage: 13}, {Name: "Scourge", AttackBonus: 9, Damage: 13},
{Name: "Scourge", AttackBonus: 9, Damage: 12}, {Name: "Scourge", AttackBonus: 9, Damage: 12},
}}, }},
"boss_thornmother": {Attacks: []SRDAttack{ // Attack 18 → ~23 "boss_thornmother": {Attacks: []SRDAttack{ // D8-f #2: 2→3 lashes, ~23→~36 (faceroll → band)
{Name: "Thorned Lash", AttackBonus: 8, Damage: 12}, {Name: "Thorned Lash", AttackBonus: 9, Damage: 13},
{Name: "Thorned Lash", AttackBonus: 8, Damage: 11}, {Name: "Thorned Lash", AttackBonus: 9, Damage: 12},
{Name: "Thorned Lash", AttackBonus: 9, Damage: 11},
}}, }},
"boss_infernax": {Attacks: []SRDAttack{ // Attack 38 → ~49 "boss_infernax": {Attacks: []SRDAttack{ // D11 T5 lift: 49 → ~42 (nerf; was an impossible wall at L15-16)
{Name: "Bite", AttackBonus: 11, Damage: 19}, {Name: "Bite", AttackBonus: 11, Damage: 16},
{Name: "Claw", AttackBonus: 11, Damage: 15}, {Name: "Claw", AttackBonus: 11, Damage: 13},
{Name: "Claw", AttackBonus: 11, Damage: 15}, {Name: "Claw", AttackBonus: 11, Damage: 13},
}}, }},
"boss_belaxath": {Attacks: []SRDAttack{ // Attack 31 → ~40 "boss_belaxath": {Attacks: []SRDAttack{ // D11 T5 lift: 40 → ~41 (buff; was a leader faceroll)
{Name: "Longsword", AttackBonus: 11, Damage: 24}, {Name: "Longsword", AttackBonus: 11, Damage: 24},
{Name: "Whip", AttackBonus: 11, Damage: 16}, {Name: "Whip", AttackBonus: 11, Damage: 17},
}}, }},
// ── Multiattack elites ─────────────────────────────────────────────── // ── Multiattack elites ───────────────────────────────────────────────
@@ -169,6 +170,24 @@ var srdProfiles = map[string]SRDProfile{
{Name: "Unarmed Strike", AttackBonus: 6, Damage: 6}, {Name: "Unarmed Strike", AttackBonus: 6, Damage: 6},
{Name: "Bite", AttackBonus: 6, Damage: 4}, {Name: "Bite", AttackBonus: 6, Damage: 4},
}}, }},
// ── Feywild elites (D8-f #2) ─────────────────────────────────────────
// Feywild martials facerolled at 97100% even after an HP/AC raise: the
// roster was single-attack and low-damage, so tankier monsters just took
// longer to kill. Multiattack is the lever that actually pulls leaders
// into band (mirrors underdark's drow_elite). Casters trail (by design).
"fomorian": {Attacks: []SRDAttack{ // Attack 13 → ~21 (2 big fists)
{Name: "Greatclub", AttackBonus: 9, Damage: 11},
{Name: "Greatclub", AttackBonus: 9, Damage: 10},
}},
"night_hag": {Attacks: []SRDAttack{ // Attack 8 → ~12 (claw flurry)
{Name: "Claws", AttackBonus: 7, Damage: 6},
{Name: "Claws", AttackBonus: 7, Damage: 6},
}},
"green_hag": {Attacks: []SRDAttack{ // Attack 6 → ~10 (claw flurry)
{Name: "Claws", AttackBonus: 6, Damage: 5},
{Name: "Claws", AttackBonus: 6, Damage: 5},
}},
} }
// enemyAttackProfile returns the attack rolls a creature makes on its turn. // enemyAttackProfile returns the attack rolls a creature makes on its turn.

View File

@@ -0,0 +1,84 @@
package plugin
import (
"log/slog"
"gogobee/internal/db"
)
// bootstrapCasterHPRefresh recomputes hp_max for caster characters after
// the J3 D8-d-fix caster HP multiplier (casterHPMult in dnd.go) shipped.
// Without this, existing caster rows keep their pre-lift hp_max until
// the next computeMaxHP recall (level-up / reset). Mirrors
// bootstrapPhase5BHPRefresh — only ever raises hp_max, preserves the
// absolute wound (delta added to hp_current, capped at new max). Run
// once per startup, idempotent via db.JobCompleted.
func bootstrapCasterHPRefresh() {
const jobName = "caster_hp_refresh_v1"
if db.JobCompleted(jobName, "once") {
return
}
d := db.Get()
rows, err := d.Query(`
SELECT user_id, class, con_score, dnd_level, hp_max, hp_current
FROM dnd_character WHERE dnd_level > 0`)
if err != nil {
slog.Error("caster hp refresh: enumerate failed", "err", err)
return
}
defer rows.Close()
type row struct {
userID string
class DnDClass
conScore int
level int
hpMax int
hpCurrent int
}
var batch []row
for rows.Next() {
var r row
var classStr string
if err := rows.Scan(&r.userID, &classStr, &r.conScore, &r.level, &r.hpMax, &r.hpCurrent); err != nil {
slog.Warn("caster hp refresh: scan failed", "err", err)
continue
}
r.class = DnDClass(classStr)
batch = append(batch, r)
}
refreshed := 0
for _, r := range batch {
if casterHPMult(r.class) == 1.0 {
continue
}
conMod := abilityModifier(r.conScore)
newMax := computeMaxHP(r.class, conMod, r.level)
if newMax <= r.hpMax {
continue
}
delta := newMax - r.hpMax
newCurrent := r.hpCurrent + delta
if newCurrent > newMax {
newCurrent = newMax
}
if newCurrent < 1 {
newCurrent = 1
}
if _, err := d.Exec(`UPDATE dnd_character
SET hp_max = ?, hp_current = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?`,
newMax, newCurrent, r.userID); err != nil {
slog.Warn("caster hp refresh: update failed", "user", r.userID, "err", err)
continue
}
refreshed++
}
db.MarkJobCompleted(jobName, "once")
if refreshed > 0 {
slog.Info("caster hp refresh: refreshed caster HPMax to D8-d-fix floor", "count", refreshed)
}
}

View File

@@ -0,0 +1,144 @@
package plugin
import (
"log/slog"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// Two one-shot startup bootstraps that lift a low-DPS caster who was stuck in a
// boss-wall "death loop". Diagnosis: the boss isn't overtuned — the player is an
// over-levelled cleric whose damage output is structurally low. Two account
// gaps, fixed idempotently here so they reach the live player without a respec.
//
// 1. bootstrapCasterSpellBackfill — characters created before a default spell
// was added to defaultKnownSpells keep their original spellbook forever:
// ensureSpellsForCharacter only seeds when the known-spell list is EMPTY, so
// later default additions never reach existing casters. This backfills any
// missing default into known+prepared (e.g. inflict_wounds, added to the
// cleric defaults after the affected character was rolled). General + future
// proof — it fixes any caster with the same stale-default gap.
//
// 2. bootstrapGrantStarterPet — a targeted gift of a combat companion to a
// specific endgame player who never received the 25% morning pet-arrival
// roll. A pet adds sustained per-round damage + deflect mitigation, which
// helps caster trailers most.
// bootstrapCasterSpellBackfill adds any missing defaultKnownSpells entry to
// every existing caster as known+prepared. addKnownSpell is idempotent and
// leaves the prepared flag of already-known spells untouched (ON CONFLICT only
// refreshes source), so this only ever adds the genuinely-missing defaults.
func bootstrapCasterSpellBackfill() {
const jobName = "caster_default_spell_backfill_v1"
if db.JobCompleted(jobName, "once") {
return
}
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("bootstrap: caster spell backfill — load characters failed", "err", err)
return
}
added := 0
for _, ac := range chars {
c, err := LoadDnDCharacter(ac.UserID)
if err != nil || c == nil || !isSpellcaster(c) {
continue
}
known, err := listKnownSpells(c.UserID)
if err != nil {
slog.Warn("bootstrap: caster spell backfill — list known failed", "user", c.UserID, "err", err)
continue
}
have := make(map[string]bool, len(known))
for _, k := range known {
have[k.SpellID] = true
}
defaults := defaultKnownSpells(c.Class, c.Level)
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
defaults = arcaneTricksterDefaultSpells(c.Level)
}
for _, sid := range defaults {
if have[sid] {
continue
}
if err := addKnownSpell(c.UserID, sid, "class", true); err != nil {
slog.Error("bootstrap: caster spell backfill — add failed", "user", c.UserID, "spell", sid, "err", err)
continue
}
slog.Info("bootstrap: backfilled default spell", "user", c.UserID, "spell", sid)
added++
}
}
db.MarkJobCompleted(jobName, "once")
if added > 0 {
slog.Warn("bootstrap: caster default-spell backfill complete", "spells_added", added)
}
}
// josieStarterPet identifies the one player the pet gift targets and the pet
// it grants. Kept as data so the intent is legible: this is an admin gift, not
// a game-wide policy.
var josieStarterPet = struct {
userID id.UserID
typ string
name string
level int
}{
userID: "@holymachina:parodia.dev",
typ: "dog",
name: "Biscuit",
level: 10,
}
// bootstrapGrantStarterPet gives the targeted player a combat companion if they
// have none. No-op once they have a pet (this gift, a later arrival, or one
// chased away — we don't override the player's own pet history). Idempotent via
// the job gate AND the has-pet guard.
func bootstrapGrantStarterPet() {
const jobName = "grant_starter_pet_holymachina_v1"
if db.JobCompleted(jobName, "once") {
return
}
g := josieStarterPet
char, err := loadAdvCharacter(g.userID)
if err != nil || char == nil {
// Target not present in this DB (e.g. fresh deploy) — mark done so we
// don't re-scan every startup; the gift is a one-off, not a standing rule.
db.MarkJobCompleted(jobName, "once")
return
}
if char.PetType != "" || char.PetArrived {
slog.Info("bootstrap: starter pet — target already has a pet, skipping", "user", g.userID)
db.MarkJobCompleted(jobName, "once")
return
}
char.PetType = g.typ
char.PetName = g.name
char.PetLevel = g.level
char.PetXP = 0
char.PetArrived = true
char.PetChasedAway = false
if g.level >= 10 {
// Mirror the babysit path that stamps the L10 date when a pet first
// reaches the cap, so milestone/supply-shop logic stays consistent.
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
}
if err := saveAdvCharacter(char); err != nil {
slog.Error("bootstrap: starter pet — save failed", "user", g.userID, "err", err)
return
}
if err := upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)); err != nil {
slog.Error("bootstrap: starter pet — player_meta mirror failed", "user", g.userID, "err", err)
return
}
db.MarkJobCompleted(jobName, "once")
slog.Warn("bootstrap: granted starter pet", "user", g.userID, "pet", g.name, "level", g.level)
}

View File

@@ -27,6 +27,19 @@ func encounterIDForRoom(roomIdx int) string {
return fmt.Sprintf("room%d", roomIdx) return fmt.Sprintf("room%d", roomIdx)
} }
// replyDM sends a player-facing combat reply unless ctx.Silent is set. The
// turn-engine combat commands route all their DMs through here so the
// background autopilot can drive a boss/elite fight on the real engine
// (long-expedition D8-f) without spamming the player a DM per round — the
// state mutations (HP, XP, threat, run-clear) still happen; only the
// narration is dropped. Non-silent callers (manual !fight) are unchanged.
func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error {
if ctx.Silent {
return nil
}
return p.SendDM(ctx.Sender, text)
}
// ── !fight ────────────────────────────────────────────────────────────────── // ── !fight ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
@@ -36,25 +49,25 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender) run, err := getActiveZoneRun(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) return p.replyDM(ctx, "Couldn't read run state: "+err.Error())
} }
if run == nil { if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>` first.") return p.replyDM(ctx, "No active zone run. Use `!zone enter <id>` first.")
} }
roomType := run.CurrentRoomType() roomType := run.CurrentRoomType()
if roomType != RoomElite && roomType != RoomBoss { if roomType != RoomElite && roomType != RoomBoss {
return p.SendDM(ctx.Sender, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.") return p.replyDM(ctx, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.")
} }
encID := encounterIDForRoom(run.CurrentRoom) encID := encounterIDForRoom(run.CurrentRoom)
if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil { if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil {
switch existing.Status { switch existing.Status {
case CombatStatusActive: case CombatStatusActive:
return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.") return p.replyDM(ctx, "You're already in this fight — `!attack` or `!flee`.")
case CombatStatusWon: case CombatStatusWon:
return p.SendDM(ctx.Sender, "You've already cleared this room. "+continueHint(ctx.Sender)) return p.replyDM(ctx, "You've already cleared this room. "+continueHint(ctx.Sender))
default: default:
return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.") return p.replyDM(ctx, "This fight is already over. `!zone status` for where you stand.")
} }
} }
@@ -67,27 +80,32 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
} else { } else {
monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true) monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true)
} }
if !ok { if !ok || monster.ID == "" {
return p.SendDM(ctx.Sender, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_") // monster.ID == "" guards a malformed bestiary entry (e.g. one whose
// ID field was dropped): startCombatSession would otherwise persist a
// session with an empty EnemyID, and the turn engine — having no enemy
// to resolve — spins inertly until autoDriveCombat's round cap. Fail
// loudly instead of stalling.
return p.replyDM(ctx, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
} }
player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood) player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't set up the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't set up the fight: "+err.Error())
} }
playerHP, playerMax := dndHPSnapshot(ctx.Sender) playerHP, playerMax := dndHPSnapshot(ctx.Sender)
if playerHP <= 0 { if playerHP <= 0 {
return p.SendDM(ctx.Sender, "You're in no shape to fight. `!rest` first.") return p.replyDM(ctx, "You're in no shape to fight. `!rest` first.")
} }
enemyHP := enemy.Stats.MaxHP enemyHP := enemy.Stats.MaxHP
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP) sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
if err != nil { if err != nil {
if err == ErrCombatSessionAlreadyActive { if err == ErrCombatSessionAlreadyActive {
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it with `!attack` / `!flee`.") return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.")
} }
return p.SendDM(ctx.Sender, "Couldn't start the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
} }
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto // Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
@@ -118,7 +136,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(combatTurnPrompt(sess)) b.WriteString(combatTurnPrompt(sess))
return p.SendDM(ctx.Sender, b.String()) return p.replyDM(ctx, b.String())
} }
// ── !attack / !flee ───────────────────────────────────────────────────────── // ── !attack / !flee ─────────────────────────────────────────────────────────
@@ -138,22 +156,22 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe
sess, err := getActiveCombatSession(ctx.Sender) sess, err := getActiveCombatSession(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error()) return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
} }
if sess == nil { if sess == nil {
return p.SendDM(ctx.Sender, "You're not in a fight. `!fight` at an Elite or Boss room to start one.") return p.replyDM(ctx, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
} }
player, enemy, err := p.combatantsForSession(sess) player, enemy, err := p.combatantsForSession(sess)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
events, err := runCombatRound(sess, &player, &enemy, action) events, err := runCombatRound(sess, &player, &enemy, action)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error()) return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
} }
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
} }
// renderRoundResult turns a resolved round into the player-facing block: the // renderRoundResult turns a resolved round into the player-facing block: the
@@ -393,35 +411,35 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
sess, err := getActiveCombatSession(ctx.Sender) sess, err := getActiveCombatSession(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error()) return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
} }
if sess == nil { if sess == nil {
// Race: the fight closed between the route check and the lock. // Race: the fight closed between the route check and the lock.
return p.SendDM(ctx.Sender, "You're not in a fight anymore.") return p.replyDM(ctx, "You're not in a fight anymore.")
} }
advChar, _ := loadAdvCharacter(ctx.Sender) advChar, _ := loadAdvCharacter(ctx.Sender)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar) c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
if err != nil || c == nil { if err != nil || c == nil {
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.") return p.replyDM(ctx, "Couldn't load your Adv 2.0 sheet.")
} }
if !isSpellcaster(c) { if !isSpellcaster(c) {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.replyDM(ctx, fmt.Sprintf(
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))) "%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
} }
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args)) spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
if errMsg != "" { if errMsg != "" {
return p.SendDM(ctx.Sender, errMsg) return p.replyDM(ctx, errMsg)
} }
if spell.Effect == EffectReaction { if spell.Effect == EffectReaction {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.replyDM(ctx, fmt.Sprintf(
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name)) "%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name))
} }
player, enemy, err := p.combatantsForSession(sess) player, enemy, err := p.combatantsForSession(sess)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
var eff *turnActionEffect var eff *turnActionEffect
@@ -433,11 +451,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
applySpellBuff(spell, c, &as, &am, slotLevel) applySpellBuff(spell, c, &as, &am, slotLevel)
d := diffTurnBuff(player.Stats, as, player.Mods, am) d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() { if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.replyDM(ctx, fmt.Sprintf(
"%s has no effect the turn-based engine can apply yet.", spell.Name)) "%s has no effect the turn-based engine can apply yet.", spell.Name))
} }
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" { if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg) return p.replyDM(ctx, msg)
} }
sess.Statuses.applyBuffDelta(d) sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess) player, enemy, err = p.combatantsForSession(sess)
@@ -445,7 +463,7 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 { if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel) _ = refundSpellSlot(ctx.Sender, slotLevel)
} }
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
label := spell.Name + " — active" label := spell.Name + " — active"
if d.heal > 0 { if d.heal > 0 {
@@ -458,11 +476,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
} else { } else {
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats) out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
if !supported { if !supported {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.replyDM(ctx, fmt.Sprintf(
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)) "%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name))
} }
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" { if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg) return p.replyDM(ctx, msg)
} }
eff = &turnActionEffect{ eff = &turnActionEffect{
Label: out.Desc, Label: out.Desc,
@@ -471,6 +489,14 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
PlayerHeal: out.PlayerHeal, PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip, EnemySkip: out.EnemySkip,
} }
// Concentration AOE damage spells linger: the burst lands this round
// (EnemyDamage) and the same value re-ticks every round_end after, via
// the engine's concentration aura. spiritual_weapon already covers the
// cleric's bonus-action half of the combo; this restores the action half.
if spell.Concentration &&
(spell.Effect == EffectDamageAuto || spell.Effect == EffectDamageSave) {
eff.ConcentrationDmg = out.EnemyDamage
}
} }
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff}) events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
@@ -478,9 +504,9 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 { if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel) _ = refundSpellSlot(ctx.Sender, slotLevel)
} }
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error()) return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
} }
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
} }
// chargeSpellCost debits a spell's material component and leveled slot for a // chargeSpellCost debits a spell's material component and leveled slot for a
@@ -551,37 +577,37 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
sess, err := getActiveCombatSession(ctx.Sender) sess, err := getActiveCombatSession(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error()) return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
} }
if sess == nil { if sess == nil {
return p.SendDM(ctx.Sender, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.") return p.replyDM(ctx, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
} }
inv := p.loadConsumableInventory(ctx.Sender) inv := p.loadConsumableInventory(ctx.Sender)
args = strings.TrimSpace(args) args = strings.TrimSpace(args)
if args == "" { if args == "" {
if len(inv) == 0 { if len(inv) == 0 {
return p.SendDM(ctx.Sender, "You have no combat consumables. `!attack` / `!cast` / `!flee`.") return p.replyDM(ctx, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
} }
names := make([]string, len(inv)) names := make([]string, len(inv))
for i, c := range inv { for i, c := range inv {
names[i] = c.Def.Name names[i] = c.Def.Name
} }
return p.SendDM(ctx.Sender, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".") return p.replyDM(ctx, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
} }
item, ambig := matchConsumable(inv, args) item, ambig := matchConsumable(inv, args)
if ambig != "" { if ambig != "" {
return p.SendDM(ctx.Sender, ambig) return p.replyDM(ctx, ambig)
} }
if item == nil { if item == nil {
return p.SendDM(ctx.Sender, fmt.Sprintf("No consumable matching %q in your inventory.", args)) return p.replyDM(ctx, fmt.Sprintf("No consumable matching %q in your inventory.", args))
} }
def := item.Def def := item.Def
player, enemy, err := p.combatantsForSession(sess) player, enemy, err := p.combatantsForSession(sess)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
eff := &turnActionEffect{Action: "use_consumable"} eff := &turnActionEffect{Action: "use_consumable"}
@@ -600,13 +626,13 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}}) ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
d := diffTurnBuff(player.Stats, as, player.Mods, am) d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() { if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.replyDM(ctx, fmt.Sprintf(
"**%s** has no effect the turn-based engine can apply yet.", def.Name)) "**%s** has no effect the turn-based engine can apply yet.", def.Name))
} }
sess.Statuses.applyBuffDelta(d) sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess) player, enemy, err = p.combatantsForSession(sess)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
eff.Label = def.Name + " — active" eff.Label = def.Name + " — active"
eff.PlayerHeal = d.heal eff.PlayerHeal = d.heal
@@ -615,7 +641,7 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff}) events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error()) return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
} }
// Round resolved and persisted — now burn the item. A removal failure here // Round resolved and persisted — now burn the item. A removal failure here
// leaves the player a free use (logged, rare); better than charging them // leaves the player a free use (logged, rare); better than charging them
@@ -623,5 +649,5 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil { if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr) slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr)
} }
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
} }

View File

@@ -332,6 +332,14 @@ type combatState struct {
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer. // Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
arcaneWardHP int arcaneWardHP int
// concentrationDmg — per-round damage of an active concentration AOE
// (Spirit Guardians et al.). Armed by a !cast of a concentration damage
// spell, ticked against the enemy every round_end until the fight ends
// or another concentration spell overwrites it. Only the turn engine
// reads it; SimulateCombat resolves whole fights in one pass and folds
// the aura's value into the picker's concentration multiplier instead.
concentrationDmg int
// Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is // Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is
// armed by applyAbility and read by the shared resolution primitives, so // armed by applyAbility and read by the shared resolution primitives, so
// both engines honour them; the turn-based engine additionally round-trips // both engines honour them; the turn-based engine additionally round-trips

View File

@@ -239,6 +239,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
case "spirit_weapon_strike": case "spirit_weapon_strike":
return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage) return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage)
case "concentration_tick":
return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage)
case "pet_deflect": case "pet_deflect":
return pickRand(narrativePetDeflect) return pickRand(narrativePetDeflect)
@@ -536,6 +539,13 @@ var narrativeSpiritWeapon = []string{
"✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.", "✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.",
} }
var narrativeConcentrationTick = []string{
"🌀 The lingering aura grinds the enemy down — %d damage. Still humming. Still hungry.",
"🌀 Your spell hasn't let go: the spirits sweep through again for %d damage.",
"🌀 The radiant field pulses once more — %d damage. Concentration holds.",
"🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.",
}
var narrativePetDeflect = []string{ var narrativePetDeflect = []string{
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.", "🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.", "🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",

View File

@@ -77,6 +77,15 @@ type CombatStatuses struct {
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"` ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
HealChargesLeft int `json:"heal_charges_left,omitempty"` HealChargesLeft int `json:"heal_charges_left,omitempty"`
// ConcentrationDmg is the per-round damage of the player's active
// concentration AOE (Spirit Guardians, Spike Growth, Call Lightning,
// Flaming Sphere…). A one-shot !cast lands its burst the casting round,
// then this re-ticks the aura at round_end every round until the fight
// ends or another concentration spell overwrites it — the lingering half
// of the spell the engine used to drop on the floor, which left clerics
// and druids with no sustained DPS once their burst landed.
ConcentrationDmg int `json:"concentration_dmg,omitempty"`
// Once-per-fight class/race/subclass one-shots: the "already used" flags. // Once-per-fight class/race/subclass one-shots: the "already used" flags.
// Without persistence these reset every round on resume, letting a Halfling // Without persistence these reset every round on resume, letting a Halfling
// reroll a nat 1 or an Orc rage every single round of a turn-based fight. // reroll a nat 1 or an Orc rage every single round of a turn-based fight.

View File

@@ -263,6 +263,74 @@ func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
} }
} }
func TestTurnEngine_ConcentrationTickAtRoundEnd(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
sess.Statuses = CombatStatuses{ConcentrationDmg: 12}
player, enemy := basePlayer(), baseEnemy()
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
if err != nil {
t.Fatal(err)
}
if sess.EnemyHP != 68 {
t.Errorf("enemy_hp = %d, want 68 (80 - 12 aura)", sess.EnemyHP)
}
if sess.Statuses.ConcentrationDmg != 12 {
t.Errorf("concentration_dmg = %d, want 12 (aura persists)", sess.Statuses.ConcentrationDmg)
}
if len(events) != 1 || events[0].Action != "concentration_tick" || events[0].Damage != 12 {
t.Errorf("expected one concentration_tick event, got %+v", events)
}
if sess.Round != 2 || sess.Phase != CombatPhasePlayerTurn {
t.Errorf("round=%d phase=%q, want 2/player_turn", sess.Round, sess.Phase)
}
}
func TestTurnEngine_ConcentrationTickCanBeLethal(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 9)
sess.Statuses = CombatStatuses{ConcentrationDmg: 12}
player, enemy := basePlayer(), baseEnemy()
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.Status != CombatStatusWon || sess.Phase != CombatPhaseOver {
t.Errorf("status=%q phase=%q, want won/over (aura dropped enemy)", sess.Status, sess.Phase)
}
if sess.EnemyHP != 0 {
t.Errorf("enemy_hp = %d, want 0", sess.EnemyHP)
}
}
// A concentration damage cast lands its burst this round AND arms the aura so
// it re-ticks at every subsequent round_end.
func TestTurnEngine_ConcentrationCastArmsAura(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 50, 200)
player, enemy := basePlayer(), baseEnemy()
eff := &turnActionEffect{
Label: "Spirit Guardians", Action: "spell_cast",
EnemyDamage: 15, ConcentrationDmg: 15,
}
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff}); err != nil {
t.Fatal(err)
}
if sess.EnemyHP != 185 {
t.Errorf("enemy_hp = %d, want 185 (200 - 15 burst)", sess.EnemyHP)
}
if sess.Statuses.ConcentrationDmg != 15 {
t.Fatalf("concentration_dmg = %d, want 15 (aura armed)", sess.Statuses.ConcentrationDmg)
}
// Drive to round_end and confirm the aura bites again.
sess.Phase = CombatPhaseRoundEnd
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.EnemyHP != 170 {
t.Errorf("enemy_hp = %d, want 170 (185 - 15 aura tick)", sess.EnemyHP)
}
}
func TestTurnEngine_Flee(t *testing.T) { func TestTurnEngine_Flee(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 50, 50) sess := turnSession(CombatPhasePlayerTurn, 50, 50)
player, enemy := basePlayer(), baseEnemy() player, enemy := basePlayer(), baseEnemy()

View File

@@ -64,6 +64,12 @@ type turnActionEffect struct {
EnemyDamage int EnemyDamage int
PlayerHeal int PlayerHeal int
EnemySkip bool // control spell: enemy forfeits its attack this round EnemySkip bool // control spell: enemy forfeits its attack this round
// ConcentrationDmg arms a per-round aura tick when a concentration damage
// spell is cast: EnemyDamage is the burst that lands this round, this is
// what re-ticks at every round_end after. Zero for one-shot spells; a
// non-zero value overwrites any aura already running (5e: one
// concentration at a time).
ConcentrationDmg int
} }
// turnEngine wraps a combatState reconstructed from a persisted CombatSession // turnEngine wraps a combatState reconstructed from a persisted CombatSession
@@ -125,6 +131,7 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
autoCrit: sess.Statuses.AutoCritFirst, autoCrit: sess.Statuses.AutoCritFirst,
arcaneWardHP: sess.Statuses.ArcaneWardHP, arcaneWardHP: sess.Statuses.ArcaneWardHP,
healChargesLeft: sess.Statuses.HealChargesLeft, healChargesLeft: sess.Statuses.HealChargesLeft,
concentrationDmg: sess.Statuses.ConcentrationDmg,
deathSaveUsed: sess.Statuses.DeathSaveUsed, deathSaveUsed: sess.Statuses.DeathSaveUsed,
luckyUsed: sess.Statuses.LuckyUsed, luckyUsed: sess.Statuses.LuckyUsed,
raged: sess.Statuses.Raged, raged: sess.Statuses.Raged,
@@ -299,6 +306,12 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain) hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain)
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
} }
// Arm / replace the concentration aura. A new concentration cast overwrites
// the old one (5e: one concentration at a time); non-concentration casts
// leave any running aura alone.
if eff.ConcentrationDmg > 0 {
st.concentrationDmg = eff.ConcentrationDmg
}
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action, Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action,
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label, Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label,
@@ -454,6 +467,20 @@ func (te *turnEngine) stepRoundEnd() {
} }
} }
} }
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
// the enemy each round it stays up. Ticks before enemy regen so a lethal
// pulse settles the fight before the enemy knits its wounds back.
if st.concentrationDmg > 0 && st.enemyHP > 0 {
st.enemyHP = max(0, st.enemyHP-st.concentrationDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick",
Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.enemyHP <= 0 {
te.finish(CombatStatusWon)
return
}
}
// Regenerate (monster ability): the enemy knits its wounds at round end. // Regenerate (monster ability): the enemy knits its wounds at round end.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP { if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {
st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen) st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
@@ -499,6 +526,7 @@ func (te *turnEngine) commit() {
s.AutoCritFirst = st.autoCrit s.AutoCritFirst = st.autoCrit
s.ArcaneWardHP = st.arcaneWardHP s.ArcaneWardHP = st.arcaneWardHP
s.HealChargesLeft = st.healChargesLeft s.HealChargesLeft = st.healChargesLeft
s.ConcentrationDmg = st.concentrationDmg
s.DeathSaveUsed = st.deathSaveUsed s.DeathSaveUsed = st.deathSaveUsed
s.LuckyUsed = st.luckyUsed s.LuckyUsed = st.luckyUsed
s.Raged = st.raged s.Raged = st.raged

View File

@@ -161,6 +161,20 @@ func abilityModifier(score int) int {
// startup to refresh stale rows. // startup to refresh stale rows.
const phase5BHPMult = 1.5 const phase5BHPMult = 1.5
// casterHPMult is the J3 D8-d-fix caster durability lift. T4 sim showed
// caster HPMax sat ~30% below martial (~95 vs 141 at L10) and the AC-floor
// lift alone wasn't enough to crack the wall. Applied multiplicatively in
// computeMaxHP on top of phase5BHPMult so the existing Phase 5-B floor
// stays intact for martials. Refreshed for existing rows by
// bootstrapCasterHPRefresh (job key caster_hp_refresh_v1).
func casterHPMult(class DnDClass) float64 {
switch class {
case ClassCleric, ClassDruid, ClassBard, ClassWarlock, ClassMage, ClassSorcerer:
return 1.25
}
return 1.0
}
// computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1). // computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1).
// Per level after: average HP die (roundup of die/2 + 1) + CON mod. // Per level after: average HP die (roundup of die/2 + 1) + CON mod.
// The result is scaled by phase5BHPMult — see that constant for the // The result is scaled by phase5BHPMult — see that constant for the
@@ -181,8 +195,8 @@ func computeMaxHP(class DnDClass, conMod, level int) int {
} }
hp += gain hp += gain
} }
// Phase 5-B player power floor — round to nearest int. // Phase 5-B player power floor + J3 D8-d-fix caster lift — round to nearest int.
scaled := int(float64(hp)*phase5BHPMult + 0.5) scaled := int(float64(hp)*phase5BHPMult*casterHPMult(class) + 0.5)
if scaled < 1 { if scaled < 1 {
scaled = 1 scaled = 1
} }
@@ -197,10 +211,16 @@ func computeAC(class DnDClass, dexMod int) int {
switch class { switch class {
case ClassFighter, ClassPaladin: case ClassFighter, ClassPaladin:
floor = 6 // heavy/medium-armor baseline floor = 6 // heavy/medium-armor baseline
case ClassCleric, ClassRanger, ClassDruid: case ClassCleric, ClassDruid:
floor = 5
case ClassRanger:
floor = 3 floor = 3
case ClassRogue, ClassBard, ClassWarlock: case ClassBard, ClassWarlock:
floor = 3
case ClassRogue:
floor = 1 floor = 1
case ClassMage, ClassSorcerer:
floor = 2
} }
return 10 + dexMod + floor return 10 + dexMod + floor
} }

View File

@@ -42,6 +42,145 @@ func TestZoneRun_AutoAbandonsAfter24h(t *testing.T) {
} }
} }
// A run reaped by the idle timeout must also terminate the wrapping active
// expedition, or the expedition is orphaned 'active' over a dead run and the
// player soft-locks (the original feywild "stuck, can't route" bug).
func TestZoneRun_IdleTimeoutExtractsWrappingExpedition(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@orphan-run:example")
defer cleanupExpeditions(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatalf("startExpedition: %v", err)
}
if err := setExpeditionRunID(exp.ID, run.RunID); err != nil {
t.Fatalf("link run: %v", err)
}
// Backdate the run past the 24h stale threshold.
if _, err := dbExecZoneRunBackdate(run.RunID, 25*time.Hour); err != nil {
t.Fatalf("backdate: %v", err)
}
got, err := getActiveZoneRun(uid)
if err != nil {
t.Fatalf("getActiveZoneRun: %v", err)
}
if got != nil {
t.Errorf("expected nil after timeout, got run %q", got.RunID)
}
// The wrapping expedition must no longer be active.
after, err := getExpedition(exp.ID)
if err != nil {
t.Fatalf("getExpedition: %v", err)
}
if after == nil {
t.Fatal("expedition row vanished")
}
if after.Status == ExpeditionStatusActive {
t.Errorf("expedition still active after run idle-timeout; status=%q", after.Status)
}
}
// A stale background fork auto-picks the first unlocked route so the
// expedition keeps moving instead of idling out to the reaper.
func TestAutoPickStaleFork_TakesAvailableRoute(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@stale-fork:example")
defer cleanupExpeditions(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatalf("startExpedition: %v", err)
}
if err := setExpeditionRunID(exp.ID, run.RunID); err != nil {
t.Fatalf("link run: %v", err)
}
pf := pendingFork{
PendingAt: "goblin_warrens.cavern_junction",
Options: []pendingChoice{
{Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post",
Unlocked: false, Lock: "perception_check", Reason: "Perception 8 vs DC 14"},
{Index: 2, To: "goblin_warrens.kennel_path", Label: "Kennel Path",
Unlocked: true, Lock: "none"},
},
}
if err := writePendingFork(run.RunID, pf); err != nil {
t.Fatalf("writePendingFork: %v", err)
}
r2, _ := getZoneRun(run.RunID)
got, _ := decodePendingFork(r2.NodeChoices)
if got == nil {
t.Fatal("fork not persisted")
}
p := &AdventurePlugin{}
if ok := p.autoPickStaleFork(exp, r2, got); !ok {
t.Fatal("expected auto-pick to commit a route")
}
after, _ := getZoneRun(run.RunID)
if after.CurrentNode != "goblin_warrens.kennel_path" {
t.Errorf("did not advance to the unlocked route: current_node=%q", after.CurrentNode)
}
if pf2, _ := decodePendingFork(after.NodeChoices); pf2 != nil {
t.Errorf("pending fork not cleared after auto-pick")
}
}
// When every option is locked there's nothing safe to auto-pick — leave the
// fork intact for the player (or the reaper).
func TestAutoPickStaleFork_AllLockedLeavesForkIntact(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@locked-fork:example")
defer cleanupExpeditions(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatalf("startExpedition: %v", err)
}
_ = setExpeditionRunID(exp.ID, run.RunID)
pf := pendingFork{
PendingAt: "goblin_warrens.cavern_junction",
Options: []pendingChoice{
{Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post",
Unlocked: false, Lock: "perception_check", Reason: "DC 14"},
},
}
if err := writePendingFork(run.RunID, pf); err != nil {
t.Fatalf("writePendingFork: %v", err)
}
r2, _ := getZoneRun(run.RunID)
before := r2.CurrentNode
got, _ := decodePendingFork(r2.NodeChoices)
p := &AdventurePlugin{}
if ok := p.autoPickStaleFork(exp, r2, got); ok {
t.Fatal("expected no auto-pick when every option is locked")
}
after, _ := getZoneRun(run.RunID)
if after.CurrentNode != before {
t.Errorf("current_node moved on an all-locked fork: %q → %q", before, after.CurrentNode)
}
if pf2, _ := decodePendingFork(after.NodeChoices); pf2 == nil {
t.Errorf("pending fork was cleared on an all-locked fork")
}
}
func TestZoneRun_FreshRunNotAutoAbandoned(t *testing.T) { func TestZoneRun_FreshRunNotAutoAbandoned(t *testing.T) {
setupZoneRunTestDB(t) setupZoneRunTestDB(t)
uid := id.UserID("@fresh-run:example") uid := id.UserID("@fresh-run:example")

View File

@@ -256,7 +256,7 @@ var _ = func() bool {
}, },
"green_hag": { "green_hag": {
ID: "green_hag", Name: "Green Hag", ID: "green_hag", Name: "Green Hag",
CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12, CR: 3, HP: 105, AC: 18, Attack: 6, AttackBonus: 5, Speed: 12,
BlockRate: 0.15, BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"}, Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"},
XPValue: 700, XPValue: 700,
@@ -463,7 +463,7 @@ var _ = func() bool {
}, },
"drow_elite_warrior": { "drow_elite_warrior": {
ID: "drow_elite_warrior", Name: "Drow Elite Warrior", ID: "drow_elite_warrior", Name: "Drow Elite Warrior",
CR: 5, HP: 71, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12, CR: 5, HP: 95, AC: 19, Attack: 8, AttackBonus: 7, Speed: 12,
BlockRate: 0.20, BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"}, Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"},
XPValue: 1800, XPValue: 1800,
@@ -471,23 +471,23 @@ var _ = func() bool {
}, },
"drow_mage": { "drow_mage": {
ID: "drow_mage", Name: "Drow Mage", ID: "drow_mage", Name: "Drow Mage",
CR: 7, HP: 45, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12, CR: 7, HP: 65, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12,
BlockRate: 0.05, BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"}, Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.25, Effect: "aoe"},
XPValue: 2900, XPValue: 2900,
Notes: "Spells: Lightning Bolt, Fly, Darkness, Faerie Fire. Magic Resistance.", Notes: "Spells: Lightning Bolt, Fly, Darkness, Faerie Fire. Magic Resistance.",
}, },
"mind_flayer": { "mind_flayer": {
ID: "mind_flayer", Name: "Mind Flayer", ID: "mind_flayer", Name: "Mind Flayer",
CR: 7, HP: 71, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12, CR: 7, HP: 90, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12,
BlockRate: 0.10, BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.55, Effect: "stun"}, Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.30, Effect: "stun"},
XPValue: 2900, XPValue: 2900,
Notes: "Mind Blast AoE psychic INT DC 15 or Stunned; Extract Brain instakills stunned; telepathy.", Notes: "Mind Blast AoE psychic INT DC 15 or Stunned; Extract Brain instakills stunned; telepathy.",
}, },
"hook_horror": { "hook_horror": {
ID: "hook_horror", Name: "Hook Horror", ID: "hook_horror", Name: "Hook Horror",
CR: 3, HP: 75, AC: 15, Attack: 6, AttackBonus: 6, Speed: 10, CR: 3, HP: 95, AC: 16, Attack: 6, AttackBonus: 6, Speed: 10,
BlockRate: 0.10, BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"}, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"},
XPValue: 700, XPValue: 700,
@@ -495,15 +495,15 @@ var _ = func() bool {
}, },
"roper": { "roper": {
ID: "roper", Name: "Roper", ID: "roper", Name: "Roper",
CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4, CR: 5, HP: 115, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4,
BlockRate: 0.20, BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.50, Effect: "stun"}, Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.30, Effect: "stun"},
XPValue: 1800, XPValue: 1800,
Notes: "Elite. 6 tendrils grapple+restrain; Reel pulls 25 ft; False Appearance.", Notes: "Elite. 6 tendrils grapple+restrain; Reel pulls 25 ft; False Appearance.",
}, },
"boss_ilvaras_xunyl": { "boss_ilvaras_xunyl": {
ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess", ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess",
CR: 12, HP: 162, AC: 16, Attack: 19, AttackBonus: 9, Speed: 12, CR: 12, HP: 210, AC: 18, Attack: 19, AttackBonus: 9, Speed: 12,
BlockRate: 0.15, BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"}, Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"},
XPValue: 8400, XPValue: 8400,
@@ -512,7 +512,7 @@ var _ = func() bool {
// ── Feywild Crossing ───────────────────────────────────────────── // ── Feywild Crossing ─────────────────────────────────────────────
"redcap": { "redcap": {
ID: "redcap", Name: "Redcap", ID: "redcap", Name: "Redcap",
CR: 3, HP: 45, AC: 13, Attack: 6, AttackBonus: 6, Speed: 14, CR: 3, HP: 60, AC: 15, Attack: 6, AttackBonus: 6, Speed: 14,
BlockRate: 0.10, BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"}, Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"},
XPValue: 700, XPValue: 700,
@@ -520,7 +520,7 @@ var _ = func() bool {
}, },
"will_o_wisp": { "will_o_wisp": {
ID: "will_o_wisp", Name: "Will-o'-Wisp", ID: "will_o_wisp", Name: "Will-o'-Wisp",
CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14, CR: 2, HP: 30, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14,
BlockRate: 0.0, BlockRate: 0.0,
Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"}, Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"},
XPValue: 450, XPValue: 450,
@@ -528,7 +528,7 @@ var _ = func() bool {
}, },
"quickling": { "quickling": {
ID: "quickling", Name: "Quickling", ID: "quickling", Name: "Quickling",
CR: 1, HP: 10, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18, CR: 1, HP: 16, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18,
BlockRate: 0.05, BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"}, Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"},
XPValue: 200, XPValue: 200,
@@ -536,7 +536,7 @@ var _ = func() bool {
}, },
"night_hag": { "night_hag": {
ID: "night_hag", Name: "Night Hag", ID: "night_hag", Name: "Night Hag",
CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12, CR: 5, HP: 135, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12,
BlockRate: 0.15, BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"}, Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"},
XPValue: 1800, XPValue: 1800,
@@ -544,7 +544,7 @@ var _ = func() bool {
}, },
"fomorian": { "fomorian": {
ID: "fomorian", Name: "Fomorian", ID: "fomorian", Name: "Fomorian",
CR: 8, HP: 149, AC: 14, Attack: 13, AttackBonus: 9, Speed: 12, CR: 8, HP: 180, AC: 16, Attack: 13, AttackBonus: 9, Speed: 12,
BlockRate: 0.10, BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"}, Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"},
XPValue: 3900, XPValue: 3900,
@@ -552,7 +552,7 @@ var _ = func() bool {
}, },
"boss_thornmother": { "boss_thornmother": {
ID: "boss_thornmother", Name: "The Thornmother", ID: "boss_thornmother", Name: "The Thornmother",
CR: 11, HP: 187, AC: 17, Attack: 18, AttackBonus: 8, Speed: 12, CR: 11, HP: 235, AC: 18, Attack: 18, AttackBonus: 8, Speed: 12,
BlockRate: 0.15, BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"}, Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"},
XPValue: 7200, XPValue: 7200,
@@ -602,9 +602,18 @@ var _ = func() bool {
}, },
"boss_infernax": { "boss_infernax": {
ID: "boss_infernax", Name: "Infernax the Undying", ID: "boss_infernax", Name: "Infernax the Undying",
CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 11, Speed: 18, // T5 lift (D11): dragons_lair was an impossible wall — at L15-16
// (mid-range) leaders cleared 0-2%, 100% TPK at the boss (it has
// 546 HP / AC22 / 49-dmg multiattack / near-guaranteed stun, a
// CR24 block fought ~9 levels under its CR). Nerfed to land
// martial leaders in the 60-75% band: HP 546→405, AC 22→20,
// frightful-presence stun 0.80→0.40, multiattack 49→42 (srd).
// (Pass 1's 360/AC20/36 over-nerfed → 98% leaders; Pass 2's
// 460/AC21/42 over-corrected → ~40%; this Pass 3 405/AC20/42
// lands leaders in band.)
CR: 24, HP: 405, AC: 20, Attack: 38, AttackBonus: 11, Speed: 18,
BlockRate: 0.15, BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.80, Effect: "stun"}, Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.40, Effect: "stun"},
XPValue: 62000, XPValue: 62000,
Notes: "Dragon's Lair boss. Ancient Red Dragon. Legendary Actions; Lair Actions; phase 2 below 50% HP — fire ignores resistance.", Notes: "Dragon's Lair boss. Ancient Red Dragon. Legendary Actions; Lair Actions; phase 2 below 50% HP — fire ignores resistance.",
FireAttacker: true, FireAttacker: true,
@@ -652,7 +661,15 @@ var _ = func() bool {
}, },
"boss_belaxath": { "boss_belaxath": {
ID: "boss_belaxath", Name: "Belaxath the Undivided", ID: "boss_belaxath", Name: "Belaxath the Undivided",
CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 11, Speed: 14, // T5 lift (D11): abyss_portal was a faceroll for leaders — at its
// own floor (L15) fighter/ranger cleared 88-92% (above the 60-75%
// band), killing the 262-HP boss in 6-13 rounds with HP to spare.
// Buffed to bring leaders into band: HP 262→300, AC 19→20,
// multiattack 40→41 (srd). Elites (nalfeshnee/marilith) already
// wall the trailers, so this only moves the leaders. (Pass 1's
// 360/AC20/45 over-buffed → 19-40% leaders; this is the Pass 2
// correction back down toward band.)
CR: 19, HP: 300, AC: 20, Attack: 31, AttackBonus: 11, Speed: 14,
BlockRate: 0.20, BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Lightning Discharge", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"}, Ability: &MonsterAbility{Name: "Lightning Discharge", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
XPValue: 22000, XPValue: 22000,

View File

@@ -272,9 +272,19 @@ func spellExpectedDamage(s SpellDefinition, slot, charLevel int) float64 {
} }
avgFace := (float64(faces) + 1) / 2 avgFace := (float64(faces) + 1) / 2
avg := float64(dice)*avgFace + float64(flat) avg := float64(dice)*avgFace + float64(flat)
// Auto-damage (Magic Missile) doesn't roll to hit — count its // Concentration damage spells (heat_metal, spirit_guardians,
// expected-on-table value at face. Attack/save spells roll, and the // flaming_sphere, call_lightning, spike_growth, cloud_of_daggers, …)
// engine will resolve hit chance at cast time. // re-tick each round while concentration holds. Without this factor
// the picker scores them as one-shots and they lose to higher-tier
// blasts on every comparison. Conservative ×3 = roughly the median
// fight length the picker can hope to keep concentration up; tier-
// scaled would be more correct but adds noise here.
// EffectDamageAttack is excluded — single-target attack-roll spells
// aren't generally concentration; the rare ones (hex-style) get
// their lift from mods, not this score.
if s.Concentration && (s.Effect == EffectDamageSave || s.Effect == EffectDamageAuto) {
avg *= 3
}
return avg return avg
} }

View File

@@ -408,3 +408,45 @@ func TestClassBalance_Phase1_FullMatrix(t *testing.T) {
} }
} }
} }
// D8-c: concentration damage spells re-tick each round; the picker has to
// score them above one-shots of similar level or it'll never pick them.
func TestSpellExpectedDamage_ConcentrationMultiplier(t *testing.T) {
const concentrationFactor = 3.0
// 2d8 base: avg = 9.
const baseAvg = 9.0
cases := []struct {
name string
sp SpellDefinition
want float64
}{
{"oneshot save (shatter-like)",
SpellDefinition{Level: 2, Effect: EffectDamageSave, DamageDice: "2d8"},
baseAvg},
{"oneshot attack",
SpellDefinition{Level: 2, Effect: EffectDamageAttack, DamageDice: "2d8"},
baseAvg},
{"concentration save (spirit_guardians-like)",
SpellDefinition{Level: 2, Effect: EffectDamageSave, Concentration: true, DamageDice: "2d8"},
baseAvg * concentrationFactor},
{"concentration auto (spike_growth-like)",
SpellDefinition{Level: 2, Effect: EffectDamageAuto, Concentration: true, DamageDice: "2d8"},
baseAvg * concentrationFactor},
{"concentration attack stays unmultiplied",
SpellDefinition{Level: 2, Effect: EffectDamageAttack, Concentration: true, DamageDice: "2d8"},
baseAvg},
}
for _, tc := range cases {
got := spellExpectedDamage(tc.sp, tc.sp.Level, 10)
if got != tc.want {
t.Errorf("%s: expDmg=%.2f want %.2f", tc.name, got, tc.want)
}
}
// Upcast still applies before the multiplier: 2d8 at slot 3 = 3d8 = 13.5, ×3 = 40.5.
sp := SpellDefinition{Level: 2, Effect: EffectDamageSave, Concentration: true, DamageDice: "2d8"}
if got := spellExpectedDamage(sp, 3, 10); got != 13.5*concentrationFactor {
t.Errorf("upcast + concentration: got %.2f want %.2f", got, 13.5*concentrationFactor)
}
}

View File

@@ -2,6 +2,7 @@ package plugin
import ( import (
"fmt" "fmt"
"log/slog"
"math" "math"
"strconv" "strconv"
"strings" "strings"
@@ -642,7 +643,7 @@ type autopilotWalkResult struct {
// combat already auto-resolves inside resolveCombatRoom; elite/boss // combat already auto-resolves inside resolveCombatRoom; elite/boss
// doorways stop here so the player can choose !fight on their own terms. // doorways stop here so the player can choose !fight on their own terms.
func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error { func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
r := p.runAutopilotWalk(ctx, autopilotRoomCap, false) r := p.runAutopilotWalk(ctx, autopilotRoomCap, false, false)
if r.initErr != "" { if r.initErr != "" {
return p.SendDM(ctx.Sender, r.initErr) return p.SendDM(ctx.Sender, r.initErr)
} }
@@ -667,7 +668,46 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
// run graph / harvest tally / supplies / threat — same as before, just // run graph / harvest tally / supplies / threat — same as before, just
// no streamFlow here. compact==true switches the underlying combat // no streamFlow here. compact==true switches the underlying combat
// narration into terse mode and auto-resolves elite (not boss) rooms. // narration into terse mode and auto-resolves elite (not boss) rooms.
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact bool) autopilotWalkResult { // forkAutoPickTimeout — how long a background fork may sit unanswered
// before the autopilot picks an available route itself. Short enough that
// the expedition keeps moving rather than idling out to the 24h stale-run
// reaper; long enough that a player away for the evening still gets first
// say on a genuine fork.
const forkAutoPickTimeout = 8 * time.Hour
// autoPickStaleFork commits the first unlocked option of a stale background
// fork, advancing the run to that node exactly as `!zone go <n>` would
// (advanceZoneRunNode + region-transition hook). Returns false — no pick —
// when every option is locked, so the caller re-emits the prompt and the
// run idles on toward the reaper. The choice is logged as a narrative entry
// so the end-of-day digest can surface the decision the player missed.
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
var chosen *pendingChoice
for i := range pf.Options {
if pf.Options[i].Unlocked {
chosen = &pf.Options[i]
break
}
}
if chosen == nil {
return false // nothing unlocked — leave it for the player / reaper
}
if err := advanceZoneRunNode(run.RunID, chosen.To); err != nil {
slog.Warn("expedition: auto-pick stale fork",
"user", run.UserID, "run", run.RunID, "err", err)
return false
}
g, _ := loadZoneGraph(run.ZoneID)
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
if exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s",
int(forkAutoPickTimeout.Hours()), chosen.Label), "")
}
return true
}
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult {
exp, err := getActiveExpedition(ctx.Sender) exp, err := getActiveExpedition(ctx.Sender)
if err != nil { if err != nil {
return autopilotWalkResult{initErr: "Couldn't read expedition state: " + err.Error()} return autopilotWalkResult{initErr: "Couldn't read expedition state: " + err.Error()}
@@ -679,12 +719,19 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."} return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."}
} }
// Already standing at a pending fork: autopilot can't pick for the // Already standing at a pending fork. The autopilot can't pick for the
// player. Re-emit the prompt with rooms=0 so the background DM // player, so a fresh fork re-emits the prompt with rooms=0 (background
// suppression keeps quiet and we don't tick the rooms counter on a // DM suppression keeps quiet; the rooms counter doesn't tick on a no-op
// no-op walk. // walk). But a background fork left unanswered past forkAutoPickTimeout
// would otherwise idle all the way to the 24h stale-run reaper and end
// the expedition — so once it's stale, auto-pick the first available
// (unlocked) route and keep walking instead of stalling out.
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil { if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil { if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
picked := compact &&
time.Since(run.LastActionAt) > forkAutoPickTimeout &&
p.autoPickStaleFork(exp, run, pf)
if !picked {
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
return autopilotWalkResult{ return autopilotWalkResult{
finalMsg: renderForkPrompt(zone, *pf), finalMsg: renderForkPrompt(zone, *pf),
@@ -692,6 +739,9 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
reason: stopFork, reason: stopFork,
} }
} }
// Auto-picked: the run now points at the chosen node. Fall
// through into the walk loop so this same tick advances it.
}
} }
var stream []string var stream []string
@@ -717,7 +767,7 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
} }
} }
res, aerr := p.advanceOnceWithOpts(ctx, compact) res, aerr := p.advanceOnceWithOpts(ctx, compact, inlineBossCombat)
if aerr != nil { if aerr != nil {
return autopilotWalkResult{initErr: aerr.Error()} return autopilotWalkResult{initErr: aerr.Error()}
} }

View File

@@ -239,19 +239,26 @@ func TestFireBriefings_EventAnchoredActivePlayerDelivers(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
useEventAnchored(t, exp)
wall := time.Now().UTC() wall := time.Now().UTC()
now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC) now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC)
threshold := time.Date(now.Year(), now.Month(), now.Day(), threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionBriefingHour, 0, 0, 0, time.UTC) expeditionBriefingHour, 0, 0, 0, time.UTC)
activeActivity := threshold.Add(15 * time.Minute) activeActivity := threshold.Add(15 * time.Minute)
priorBriefing := now.Add(-24 * time.Hour) priorBriefing := now.Add(-24 * time.Hour)
// Pin start_date before today's threshold. Left at the default (real
// time.Now()), a suite run after 06:00 UTC lands start_date past the
// threshold and loadExpeditionsNeedingBriefing (start_date < threshold)
// filters the row out — a wall-clock-of-day flake. useEventAnchored runs
// after, so its cutoff tracks the backdated start and the run stays
// event-anchored.
startAt := now.Add(-24 * time.Hour)
exp.StartDate = startAt
if _, err := db.Get().Exec( if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`, `UPDATE dnd_expedition SET start_date = ?, last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`,
activeActivity, priorBriefing, exp.ID); err != nil { startAt, activeActivity, priorBriefing, exp.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
useEventAnchored(t, exp)
p := &AdventurePlugin{} p := &AdventurePlugin{}
p.fireExpeditionBriefings(now) p.fireExpeditionBriefings(now)

View File

@@ -258,7 +258,13 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
// Initial D&D level seeded from existing combat_level (v1.1 §4.1). // Initial D&D level seeded from existing combat_level (v1.1 §4.1).
// combat_level "freezes" thereafter — dnd_level is canonical. // combat_level "freezes" thereafter — dnd_level is canonical.
advChar, _ := loadAdvCharacter(ctx.Sender) //
// ensureCharacter (not a bare loadAdvCharacter) so the canonical
// player_meta seed row + tier-0 equipment exist for D&D-only players
// who reach !setup confirm without ever touching the legacy adventure
// path. Without this seed, loadAdvCharacter returns "sql: no rows in
// result set" on every legacy-layer command (arena, npcs, events, …).
advChar, _, _ := p.ensureCharacter(ctx.Sender)
startLevel := 1 startLevel := 1
if advChar != nil { if advChar != nil {
startLevel = dndLevelFromCombatLevel(advChar.CombatLevel) startLevel = dndLevelFromCombatLevel(advChar.CombatLevel)

View File

@@ -819,7 +819,7 @@ func defaultKnownSpells(class DnDClass, level int) []string {
case ClassCleric: case ClassCleric:
out := []string{"sacred_flame", "guidance", "mending"} out := []string{"sacred_flame", "guidance", "mending"}
if level >= 1 { if level >= 1 {
out = append(out, "cure_wounds", "healing_word", "bless", "guiding_bolt", "shield_of_faith") out = append(out, "cure_wounds", "healing_word", "bless", "guiding_bolt", "inflict_wounds", "shield_of_faith")
} }
if maxSlot >= 2 { if maxSlot >= 2 {
out = append(out, "spiritual_weapon", "lesser_restoration", "aid") out = append(out, "spiritual_weapon", "lesser_restoration", "aid")
@@ -878,10 +878,10 @@ func defaultKnownSpells(class DnDClass, level int) []string {
case ClassBard: case ClassBard:
out := []string{"vicious_mockery", "minor_illusion", "message"} out := []string{"vicious_mockery", "minor_illusion", "message"}
if level >= 1 { if level >= 1 {
out = append(out, "cure_wounds", "healing_word", "heroism", "hideous_laughter", "faerie_fire") out = append(out, "cure_wounds", "healing_word", "heroism", "hideous_laughter", "faerie_fire", "thunderwave")
} }
if maxSlot >= 2 { if maxSlot >= 2 {
out = append(out, "hold_person", "shatter", "invisibility", "lesser_restoration") out = append(out, "hold_person", "shatter", "heat_metal", "invisibility", "lesser_restoration")
} }
if maxSlot >= 3 { if maxSlot >= 3 {
out = append(out, "hypnotic_pattern", "dispel_magic", "fear") out = append(out, "hypnotic_pattern", "dispel_magic", "fear")

View File

@@ -190,7 +190,7 @@ func buildSpellList() []SpellDefinition {
Description: "A foe locks rigid mid-step. Anything you hit them with next lands devastatingly well.", Description: "A foe locks rigid mid-step. Anything you hit them with next lands devastatingly well.",
Upcast: "+1 target per slot above 2nd"}, Upcast: "+1 target per slot above 2nd"},
{ID: "shatter", Name: "Shatter", Level: 2, School: "evocation", {ID: "shatter", Name: "Shatter", Level: 2, School: "evocation",
Classes: mage, Effect: EffectDamageSave, CastTime: CastAction, Classes: []DnDClass{ClassMage, ClassBard, ClassSorcerer}, Effect: EffectDamageSave, CastTime: CastAction,
SaveStat: "CON", DamageDice: "3d8", DamageType: "thunder", AOE: true, SaveStat: "CON", DamageDice: "3d8", DamageType: "thunder", AOE: true,
Description: "An invisible chord rings, then snaps — everything brittle nearby cracks at once.", Description: "An invisible chord rings, then snaps — everything brittle nearby cracks at once.",
Upcast: "louder per slot above 2nd"}, Upcast: "louder per slot above 2nd"},

View File

@@ -70,10 +70,10 @@ func TestComputeMaxHP_MageLevel5(t *testing.T) {
// Mage d6, CON +1 // Mage d6, CON +1
// L1: 6 + 1 = 7 // L1: 6 + 1 = 7
// L2-5: 4 levels × (avg 4 + 1) = 4 × 5 = 20 // L2-5: 4 levels × (avg 4 + 1) = 4 × 5 = 20
// Raw total: 27; Phase 5-B: 27 × 1.5 = 40.5 → 41 (round half-up). // Raw total: 27; Phase 5-B × casterHPMult: 27 × 1.5 × 1.25 = 50.625 → 51.
got := computeMaxHP(ClassMage, 1, 5) got := computeMaxHP(ClassMage, 1, 5)
if got != 41 { if got != 51 {
t.Errorf("Mage L5 (CON+1) = %d, want 41 (27 raw × phase5BHPMult)", got) t.Errorf("Mage L5 (CON+1) = %d, want 51 (27 raw × phase5BHPMult × casterHPMult)", got)
} }
} }
@@ -94,8 +94,12 @@ func TestComputeAC(t *testing.T) {
{ClassFighter, 0, 16}, // 10 + 0 + 6 {ClassFighter, 0, 16}, // 10 + 0 + 6
{ClassFighter, 2, 18}, // 10 + 2 + 6 {ClassFighter, 2, 18}, // 10 + 2 + 6
{ClassRogue, 3, 14}, // 10 + 3 + 1 {ClassRogue, 3, 14}, // 10 + 3 + 1
{ClassMage, 0, 10}, // 10 + 0 + 0 {ClassMage, 0, 12}, // 10 + 0 + 2 (D8-d-fix caster floor)
{ClassCleric, 1, 14}, // 10 + 1 + 3 {ClassSorcerer, 1, 13}, // 10 + 1 + 2
{ClassCleric, 1, 16}, // 10 + 1 + 5 (D8-d-fix caster floor)
{ClassDruid, 0, 15}, // 10 + 0 + 5
{ClassBard, 2, 15}, // 10 + 2 + 3 (D8-d-fix caster floor)
{ClassWarlock, 0, 13}, // 10 + 0 + 3
{ClassRanger, 2, 15}, // 10 + 2 + 3 {ClassRanger, 2, 15}, // 10 + 2 + 3
} }
for _, c := range cases { for _, c := range cases {

View File

@@ -472,10 +472,19 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
// doorways (boss still stops; boss is the climax beat). Foreground // doorways (boss still stops; boss is the climax beat). Foreground
// `!expedition run` / `!zone advance` always pass false. // `!expedition run` / `!zone advance` always pass false.
func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error) { func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error) {
return p.advanceOnceWithOpts(ctx, false) return p.advanceOnceWithOpts(ctx, false, false)
} }
func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) (advanceResult, error) { // inlineBossCombat (only consulted when compact==true) selects between the
// two background combat paths at a boss/elite doorway. true keeps the
// legacy inline auto-resolve (SimulateCombat — fast, but ignores enemy
// multiattack). false returns stopBoss/stopElite after the safety gate so
// a turn-based driver — autoDriveCombat / pickAutoCombatAction — handles
// the fight via the regular !fight / !attack engine. Both the headless sim
// and the production autorun (long-expedition D8-f) now pass false so the
// real engine (with multiattack) resolves the encounter and simPickSpell
// actually fires; D8-prereq re-wired this seam, D8-f flipped prod onto it.
func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) {
run, err := getActiveZoneRun(ctx.Sender) run, err := getActiveZoneRun(ctx.Sender)
if err != nil { if err != nil {
return advanceResult{}, fmt.Errorf("Couldn't read run state: %s", err.Error()) return advanceResult{}, fmt.Errorf("Couldn't read run state: %s", err.Error())
@@ -560,6 +569,23 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
}, nil }, nil
} }
} }
if !inlineBossCombat {
// Background caller wants to drive the fight itself via the
// turn engine (autoDriveCombat / pickAutoCombatAction).
// Surface the doorway like the foreground path does, after
// the safety gate has had a chance to defer the engagement.
kind := "Elite"
r := stopElite
if prev == RoomBoss {
kind = "Boss"
r = stopBoss
}
return advanceResult{
final: fmt.Sprintf("**Room %d/%d — %s.** Type `!fight` to engage.",
prevIdx+1, run.TotalRooms, kind),
reason: r,
}, nil
}
// Compact-mode elite/boss auto-resolve. resolveCombatRoom // Compact-mode elite/boss auto-resolve. resolveCombatRoom
// selects monster + label by run.CurrentRoomType(). // selects monster + label by run.CurrentRoomType().
ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, prev == RoomElite, true) ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, prev == RoomElite, true)
@@ -929,6 +955,7 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
// resolveCombatRoom spawns one roster enemy (elite filter optional), // resolveCombatRoom spawns one roster enemy (elite filter optional),
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas, // runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
// and renders the staged narration. Returns: // and renders the staged narration. Returns:
//
// intro — pre-combat block (TwinBee combat-start + monster stat block) // intro — pre-combat block (TwinBee combat-start + monster stat block)
// phases — RenderCombatLog output, streamed with delays by the caller // phases — RenderCombatLog output, streamed with delays by the caller
// outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary // outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary
@@ -1263,4 +1290,3 @@ func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
"🚪 Abandoned **%s** at room %d/%d. No rewards.", "🚪 Abandoned **%s** at room %d/%d. No rewards.",
zone.Display, run.CurrentRoom+1, run.TotalRooms)) zone.Display, run.CurrentRoom+1, run.TotalRooms))
} }

View File

@@ -290,6 +290,17 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
} }
if time.Since(r.LastActionAt) > zoneRunInactivityTimeout { if time.Since(r.LastActionAt) > zoneRunInactivityTimeout {
_ = abandonZoneRunByID(r.RunID) _ = abandonZoneRunByID(r.RunID)
// A run reaped by the §4.3 idle timeout must also terminate the
// wrapping active expedition. Without this, the expedition is left
// status='active' pointing at a now-abandoned run: the autopilot's
// runAutopilotWalk reads run==nil and bails, but the briefing/recap
// ambient tickers keep firing — the player soft-locks at the last
// fork, "stuck" with no way to route on. Mirror the run-loss seam,
// but only when this run is the active expedition's current run so
// a standalone (non-expedition) stale run still reaps cleanly.
if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID {
forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)")
}
return nil, nil return nil, nil
} }
return r, nil return r, nil

View File

@@ -157,7 +157,12 @@ func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) {
// "" if no camp was pitched), along with the decision and an ok flag. // "" if no camp was pitched), along with the decision and an ok flag.
// D4-a uses the Night bit on the decision to switch tryAutoRun's DM // D4-a uses the Night bit on the decision to switch tryAutoRun's DM
// rendering into end-of-day digest mode. // rendering into end-of-day digest mode.
func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecision, bool) { //
// now drives the nightCampWindow check inside decideAutopilotCamp and
// the camp.EstablishedAt / drift timestamps inside pitchAutopilotCamp.
// Production callers pass time.Now().UTC(); the sim injects a synthetic
// clock so multi-day rollovers fire without real-time waits.
func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition, now time.Time) (string, autoCampDecision, bool) {
uid := id.UserID(exp.UserID) uid := id.UserID(exp.UserID)
var run *DungeonRun var run *DungeonRun
@@ -190,7 +195,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi
HPCur: hpCur, HPCur: hpCur,
HPMax: hpMax, HPMax: hpMax,
Supplies: exp.Supplies, Supplies: exp.Supplies,
Now: time.Now().UTC(), Now: now,
EventAnchored: isEventAnchored(exp), EventAnchored: isEventAnchored(exp),
LastBriefingAt: exp.LastBriefingAt, LastBriefingAt: exp.LastBriefingAt,
StartDate: exp.StartDate, StartDate: exp.StartDate,
@@ -199,7 +204,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi
if !ok { if !ok {
return "", autoCampDecision{}, false return "", autoCampDecision{}, false
} }
block, err := p.pitchAutopilotCamp(exp, d) block, err := p.pitchAutopilotCamp(exp, d, now)
if err != nil { if err != nil {
slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err) slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err)
return "", autoCampDecision{}, false return "", autoCampDecision{}, false
@@ -215,7 +220,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi
// too: nightRolloverBurn before the camp cost (so the burn lands on // too: nightRolloverBurn before the camp cost (so the burn lands on
// pre-pitch supplies, matching the legacy morning-burn ordering), then // pre-pitch supplies, matching the legacy morning-burn ordering), then
// applyCampRest, then nightRolloverDrift. // applyCampRest, then nightRolloverDrift.
func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision) (string, error) { func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision, now time.Time) (string, error) {
var ( var (
nightBurn float32 nightBurn float32
nightTemp []string nightTemp []string
@@ -234,7 +239,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
// Starvation during burn is rare (burn alone doesn't trigger // Starvation during burn is rare (burn alone doesn't trigger
// starvation — that needs supplies <= 0 *after* burn), but // starvation — that needs supplies <= 0 *after* burn), but
// guard anyway so we don't pitch a camp on an abandoned exp. // guard anyway so we don't pitch a camp on an abandoned exp.
drift := p.nightRolloverDrift(exp, time.Now().UTC()) drift := p.nightRolloverDrift(exp, now)
nightTemp = drift.TemporalLines nightTemp = drift.TemporalLines
nightMile = drift.MilestoneLines nightMile = drift.MilestoneLines
nightForce = true nightForce = true
@@ -253,7 +258,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
Active: true, Active: true,
Type: d.Kind, Type: d.Kind,
RoomIndex: campCurrentRoomIndex(exp), RoomIndex: campCurrentRoomIndex(exp),
EstablishedAt: time.Now().UTC(), EstablishedAt: now,
NightEvents: []string{}, NightEvents: []string{},
AutoPitched: true, AutoPitched: true,
} }
@@ -287,7 +292,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
} }
if d.Night { if d.Night {
drift := p.nightRolloverDrift(exp, time.Now().UTC()) drift := p.nightRolloverDrift(exp, now)
nightTemp = drift.TemporalLines nightTemp = drift.TemporalLines
nightMile = drift.MilestoneLines nightMile = drift.MilestoneLines
} }
@@ -348,7 +353,7 @@ func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flav
// time has elapsed since the last briefing on an event-anchored // time has elapsed since the last briefing on an event-anchored
// expedition, the pitch carries Night=true and runs the burn/drift // expedition, the pitch carries Night=true and runs the burn/drift
// alongside the rest. // alongside the rest.
func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCampDecision, bool) { func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition, now time.Time) (string, autoCampDecision, bool) {
if exp == nil || exp.Status != ExpeditionStatusActive { if exp == nil || exp.Status != ExpeditionStatusActive {
return "", autoCampDecision{}, false return "", autoCampDecision{}, false
} }
@@ -371,9 +376,9 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCamp
if isEventAnchored(exp) { if isEventAnchored(exp) {
var since time.Duration var since time.Duration
if exp.LastBriefingAt != nil { if exp.LastBriefingAt != nil {
since = time.Since(*exp.LastBriefingAt) since = now.Sub(*exp.LastBriefingAt)
} else { } else {
since = time.Since(exp.StartDate) since = now.Sub(exp.StartDate)
} }
night = since >= nightCampWindow night = since >= nightCampWindow
} }
@@ -382,7 +387,7 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCamp
Reason: "boss-safety hold — resting before re-engaging", Reason: "boss-safety hold — resting before re-engaging",
Night: night, Night: night,
} }
block, err := p.pitchAutopilotCamp(exp, d) block, err := p.pitchAutopilotCamp(exp, d, now)
if err != nil { if err != nil {
slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err) slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err)
return "", autoCampDecision{}, false return "", autoCampDecision{}, false

View File

@@ -160,7 +160,7 @@ func TestPitchAutopilotCamp_DeductsSuppliesAndRestores(t *testing.T) {
p := &AdventurePlugin{} p := &AdventurePlugin{}
block, err := p.pitchAutopilotCamp(exp, autoCampDecision{ block, err := p.pitchAutopilotCamp(exp, autoCampDecision{
Kind: CampTypeStandard, Reason: "test pitch", Kind: CampTypeStandard, Reason: "test pitch",
}) }, time.Now().UTC())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -300,7 +300,7 @@ func TestPitchAutopilotCamp_NightRunsProcessNightCamp(t *testing.T) {
p := &AdventurePlugin{} p := &AdventurePlugin{}
_, err = p.pitchAutopilotCamp(exp, autoCampDecision{ _, err = p.pitchAutopilotCamp(exp, autoCampDecision{
Kind: CampTypeStandard, Reason: "night-camp test", Night: true, Kind: CampTypeStandard, Reason: "night-camp test", Night: true,
}) }, time.Now().UTC())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -138,6 +138,84 @@ func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error)
return ids, rows.Err() return ids, rows.Err()
} }
// autoRunCombatSegmentCap bounds the walk→fight→walk ping-pong inside a
// single background tick. With autoRunRoomCap == 3 rooms/tick the loop
// can realistically only hit a couple doorways; the cap is a backstop
// against a pathological state where a fight wins but the next walk
// re-presents the same doorway.
const autoRunCombatSegmentCap = 8
// runAutopilotWalkDriven runs the compact background walk and, when it
// halts at a boss/elite doorway, drives that fight through the real turn
// engine (manual `!fight` parity — long-expedition D8-f) before resuming
// the walk. It loops walk→fight→walk so one tick still covers up to
// maxRooms rooms, exactly as the old inline-boss path did, but bosses now
// face the player's full kit against the enemy's full multiattack profile
// instead of the rosier inline SimulateCombat path.
//
// Combat narration is suppressed via a silent ctx — the day digest
// summarizes the outcome, matching the rest of the compact autopilot
// surface. The returned result carries the cumulative room count and the
// reason of whichever non-combat stop ended the loop.
func (p *AdventurePlugin) runAutopilotWalkDriven(ctx MessageContext, maxRooms int) autopilotWalkResult {
silent := ctx
silent.Silent = true
total := 0
for seg := 0; seg < autoRunCombatSegmentCap; seg++ {
budget := maxRooms - total
if budget <= 0 {
return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)}
}
r := p.runAutopilotWalk(ctx, budget, true, false)
if r.initErr != "" {
return r
}
total += r.rooms
r.rooms = total
if r.reason != stopBoss && r.reason != stopElite {
return r
}
// Standing at an elite/boss doorway — drive the fight on the turn
// engine. handleFightCmd opens the session at the current doorway;
// autoDriveCombat loops until it resolves.
won, err := p.autoDriveCombat(silent)
if err != nil {
slog.Warn("expedition: autopilot turn-engine combat", "user", ctx.Sender, "err", err)
// Leave the doorway stop in place; the next tick retries the
// engagement after the cooldown.
return r
}
if won {
// The won session is recorded; the next walk advances the now-
// cleared room (a boss win surfaces as stopComplete, an elite as
// a normal continue). Loop.
continue
}
// Lost: the turn engine never voluntarily flees, so a non-win means
// the party fell. finishCombatSession (CombatStatusLost) already
// abandoned the run and force-extracted the expedition; surface it
// as a death so the digest + pet-emergence seam fire.
if c, _ := LoadDnDCharacter(ctx.Sender); c == nil || c.HPCurrent <= 0 {
r.reason = stopEnded
r.finalMsg = fmt.Sprintf("💀 The party fell in battle after %s. The expedition is over.", roomsWalkedPhrase(total))
return r
}
// Alive but the session didn't open / resolve to a win (rare —
// bestiary miss or stall). Leave the doorway stop; retry next tick.
return r
}
// Segment cap hit — stop cleanly rather than spin.
return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)}
}
// roomsWalkedPhrase renders "N room(s)" for autopilot footers.
func roomsWalkedPhrase(rooms int) string {
if rooms == 1 {
return "1 room"
}
return fmt.Sprintf("%d rooms", rooms)
}
// tryAutoRun claims the slot for this expedition, runs one background // tryAutoRun claims the slot for this expedition, runs one background
// walk, and DMs the result if the suppression rules say to. The CAS- // walk, and DMs the result if the suppression rules say to. The CAS-
// update is the only persistent side effect on the autorun column — // update is the only persistent side effect on the autorun column —
@@ -170,7 +248,10 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
} }
uid := id.UserID(e.UserID) uid := id.UserID(e.UserID)
r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true) // D8-f — boss/elite encounters route through the real turn engine for
// manual `!fight` parity (enemy multiattack included). Trash mobs still
// auto-resolve on the fast inline path inside the walk.
r := p.runAutopilotWalkDriven(MessageContext{Sender: uid}, autoRunRoomCap)
if r.initErr != "" { if r.initErr != "" {
// "no expedition" / "no run" — race with abandon/extract. Silent. // "no expedition" / "no run" — race with abandon/extract. Silent.
return nil return nil
@@ -198,13 +279,13 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// its RoomBoss block. Next tick past dwell retries the boss. // its RoomBoss block. Next tick past dwell retries the boss.
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil && if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
fresh.Status == ExpeditionStatusActive { fresh.Status == ExpeditionStatusActive {
campBlock, campDecision, campPitched = p.pitchBossSafetyCamp(fresh) campBlock, campDecision, campPitched = p.pitchBossSafetyCamp(fresh, now)
} }
} else if r.reason != stopEnded && r.reason != stopComplete && } else if r.reason != stopEnded && r.reason != stopComplete &&
r.reason != stopBlocked && r.reason != stopFork { r.reason != stopBlocked && r.reason != stopFork {
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil && if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
fresh.Status == ExpeditionStatusActive { fresh.Status == ExpeditionStatusActive {
campBlock, campDecision, campPitched = p.maybeAutoCamp(fresh) campBlock, campDecision, campPitched = p.maybeAutoCamp(fresh, now)
} }
} }
_ = autoCampBroken // hint reserved for downstream digest tweaks _ = autoCampBroken // hint reserved for downstream digest tweaks

View File

@@ -14,6 +14,7 @@ package plugin
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"sort" "sort"
"time" "time"
@@ -22,6 +23,12 @@ import (
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
// simInlineBossCombat is a D8-e DIAGNOSTIC toggle. When GOGOBEE_SIM_INLINE_BOSS=1
// the sim routes boss/elite doorways through the inline SimulateCombat path
// (production autopilot behavior) instead of the turn-based !fight engine.
// Used to A/B the martial T4/T5 regression. NOT for production.
func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") == "1" }
// SimRunner owns a temp-DB AdventurePlugin + EuroPlugin pair and exposes // SimRunner owns a temp-DB AdventurePlugin + EuroPlugin pair and exposes
// just enough surface to drive synthetic players end-to-end. // just enough surface to drive synthetic players end-to-end.
type SimRunner struct { type SimRunner struct {
@@ -116,7 +123,7 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
// stockSimConsumables drops a small tier-appropriate bundle of potions // stockSimConsumables drops a small tier-appropriate bundle of potions
// + a couple offensive items into the synthetic player's inventory so // + a couple offensive items into the synthetic player's inventory so
// SelectConsumables / setupAutoHealFromInventory have something to fire // SelectConsumables / setupAutoHealFromInventory have something to fire
// during autoResolveCombat. Counts are deliberately modest — a real // during autoDriveCombat. Counts are deliberately modest — a real
// L7+ player typically carries 3-6 heals plus a couple of buffs; we // L7+ player typically carries 3-6 heals plus a couple of buffs; we
// mirror that band rather than max-stocking, which would mask class // mirror that band rather than max-stocking, which would mask class
// power gaps. // power gaps.
@@ -282,9 +289,27 @@ type SimResult struct {
// without re-running the matrix. Populated from combat_sessions // without re-running the matrix. Populated from combat_sessions
// rows + their TurnLog at end-of-run. // rows + their TurnLog at end-of-run.
Combats []SimCombatSummary Combats []SimCombatSummary
// DaySnapshots traces HP/SU/threat/rooms at every day rollover
// (Night camp) plus the start (Day 0) and the final state. Used by
// D7-c long-expedition baselining to see how the trajectory bends
// across multi-day runs without scrubbing the log.
DaySnapshots []SimDaySnapshot
Log []SimLogEntry Log []SimLogEntry
} }
// SimDaySnapshot is a point-in-time projection of the sim state at a
// day-rollover boundary. Day 0 is captured at expedition start; every
// subsequent entry lands right after a Night camp lands (CurrentDay
// already incremented). A final entry is appended at end-of-run.
type SimDaySnapshot struct {
Day int
HPCurrent int
HPMax int
Supplies float32
Threat int
Rooms int // cumulative autopilot rooms walked at snapshot time
}
// SimCombatSummary is a compact per-fight trace: the entry stats, the // SimCombatSummary is a compact per-fight trace: the entry stats, the
// per-round damage dealt by each side, and the outcome. Lets J-phase // per-round damage dealt by each side, and the outcome. Lets J-phase
// analysis ask "did Fighter L12 hit the manor boss often enough?" // analysis ask "did Fighter L12 hit the manor boss often enough?"
@@ -350,17 +375,30 @@ type SimLogEntry struct {
TS time.Time TS time.Time
} }
// simWalkInterval — how much synthetic time each autopilot walk
// represents. Matches the production autorun cooldown so the
// nightCampWindow check inside decideAutopilotCamp lands on the same
// real-time cadence: ~8 walks ≈ 16h ≈ one Night-camp rollover.
const simWalkInterval = autoRunCooldown
// RunExpedition starts an expedition for uid in zoneID and loops the // RunExpedition starts an expedition for uid in zoneID and loops the
// autopilot walk (compact mode, so elite rooms auto-resolve inline) // autopilot walk (compact mode, so elite rooms auto-resolve inline)
// until a hard stop fires. Between walks it fast-forwards the day // until a hard stop fires. Between walks it advances a synthetic clock
// cycle so multi-day expeditions complete without real-time waits. // (simWalkInterval per walk) and calls into the same maybeAutoCamp /
// pitchBossSafetyCamp scheduler the production autorun ticker uses, so
// HP-low mid-day rests + Night-camp rollovers fire under the sim.
// //
// walkCap bounds the number of autopilot bursts as a safety net. Each // walkCap bounds the number of autopilot bursts as a safety net. Each
// burst walks up to autopilotRoomCap rooms. // burst walks up to autopilotRoomCap rooms.
// //
// maxDays, when > 0, stops the run once res.DayTicks reaches that count
// — used by D7-c long-expedition baselining to bound multi-day runs by
// synthetic day count rather than walk count. 0 leaves the run
// unbounded by days (the walkCap safety net still applies).
//
// Pre-state: uid must own a synthetic character via BuildCharacter and // Pre-state: uid must own a synthetic character via BuildCharacter and
// have a coin balance sufficient for outfitting (caller's responsibility). // have a coin balance sufficient for outfitting (caller's responsibility).
func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*SimResult, error) { func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays int) (*SimResult, error) {
c, err := LoadDnDCharacter(uid) c, err := LoadDnDCharacter(uid)
if err != nil || c == nil { if err != nil || c == nil {
return nil, fmt.Errorf("LoadDnDCharacter: %w", err) return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
@@ -375,7 +413,10 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
} }
ctx := MessageContext{Sender: uid} ctx := MessageContext{Sender: uid}
if err := s.P.handleDnDExpeditionCmd(ctx, "start "+string(zoneID)); err != nil { // D5-b made a bare "start <zone>" return the loadout prompt without
// outfitting. Force the tier-max "heavy" preset so multi-day runs
// have enough supplies to actually exercise [[project-sim-event-anchored-broken]] rollovers.
if err := s.P.handleDnDExpeditionCmd(ctx, "start "+string(zoneID)+" heavy"); err != nil {
return res, fmt.Errorf("expedition start: %w", err) return res, fmt.Errorf("expedition start: %w", err)
} }
exp, _ := getActiveExpedition(uid) exp, _ := getActiveExpedition(uid)
@@ -384,9 +425,18 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
return res, fmt.Errorf("expedition did not persist after start") return res, fmt.Errorf("expedition did not persist after start")
} }
res.SUStart = exp.Supplies.Current res.SUStart = exp.Supplies.Current
// Day-0 baseline so the snapshot stream always opens with a known
// starting state, even if the run halts before the first rollover.
s.captureDaySnapshot(res, exp, uid)
// Synthetic clock — anchored on the expedition's real start_date so
// nightCampWindow / nightSafetyNet comparisons against LastBriefingAt
// stay coherent with the rows the autopilot scheduler reads.
simNow := exp.StartDate
for i := 0; i < walkCap; i++ { for i := 0; i < walkCap; i++ {
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true) simNow = simNow.Add(simWalkInterval)
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, simInlineBossCombat())
if walk.initErr != "" { if walk.initErr != "" {
res.Outcome = "halted" res.Outcome = "halted"
res.StopCode = "init:" + walk.initErr res.StopCode = "init:" + walk.initErr
@@ -430,7 +480,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
case stopBoss, stopElite: case stopBoss, stopElite:
// Auto-resolve the encounter: !fight to open, then !attack // Auto-resolve the encounter: !fight to open, then !attack
// per round until the session resolves (won / lost / fled). // per round until the session resolves (won / lost / fled).
killed, err := s.autoResolveCombat(ctx) killed, err := s.P.autoDriveCombat(ctx)
if err != nil { if err != nil {
res.Outcome = "halted" res.Outcome = "halted"
res.StopCode = "combat:" + err.Error() res.StopCode = "combat:" + err.Error()
@@ -456,9 +506,22 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
// Boss kill closes the run via combat resolution → continue // Boss kill closes the run via combat resolution → continue
// the loop so the next walk picks up the cleared-run state. // the loop so the next walk picks up the cleared-run state.
case stopFork: case stopFork:
// Deterministic sim policy: always take path 1. Real players // Deterministic sim policy: take the first UNLOCKED path. The
// pick based on intent; the sim just needs to make progress. // old blind "go 1" stalled forever on all-skill-check forks
if err := s.P.handleDnDExpeditionCmd(ctx, "go 1"); err != nil { // (feywild fork1) — resolveForkChoice rejects a locked edge but
// zoneCmdGo swallows it as a sent-DM with a nil return, so the
// run never advanced and burned every walk at the same node. A
// real player reads the menu and picks a passable path; mirror
// that. choice==0 means every edge is locked (a graph soft-lock
// the author must fix) — halt loudly rather than spin.
choice := s.firstUnlockedForkChoice(ctx.Sender)
if choice == 0 {
res.Outcome = "halted"
res.StopCode = "fork_all_locked"
i = walkCap
break
}
if err := s.P.handleDnDExpeditionCmd(ctx, fmt.Sprintf("go %d", choice)); err != nil {
res.Outcome = "halted" res.Outcome = "halted"
res.StopCode = "fork:" + err.Error() res.StopCode = "fork:" + err.Error()
i = walkCap i = walkCap
@@ -467,28 +530,75 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
case stopBlocked: case stopBlocked:
res.Outcome = "blocked" res.Outcome = "blocked"
i = walkCap i = walkCap
case stopBossSafety:
// Compact autopilot bailed before the boss (HP/SU gate). Mirror
// the production autorun: force-pitch a rest camp, dwell, then
// break it so the next walk re-engages the boss.
fresh, _ := getActiveExpedition(uid)
if fresh == nil {
res.Outcome = "extracted"
i = walkCap
break
}
advanced, err := s.applyAutoCampBossSafety(fresh, &simNow)
if err != nil {
res.Outcome = "halted"
res.StopCode = "boss_safety_camp:" + err.Error()
i = walkCap
break
}
if advanced {
res.DayTicks++
if fresh2, _ := getActiveExpedition(uid); fresh2 != nil {
s.captureDaySnapshot(res, fresh2, uid)
}
}
if exp, _ = getActiveExpedition(uid); exp == nil {
res.Outcome = "extracted"
i = walkCap
}
if maxDays > 0 && res.DayTicks >= maxDays {
if res.Outcome == "" {
res.Outcome = "day_capped"
}
i = walkCap
}
default: default:
// stopOK / stopPreflight / stopHarvestCombat — soft stops. // stopOK / stopPreflight / stopHarvestCombat — soft stops.
// Fast-forward a day so the next walk has fresh supplies // Run the same camp scheduler the production autorun fires
// budgeted, HP that overnight camp may have healed, and // after every walk. With simNow past nightCampWindow the
// threat drift recorded. // pitch is a Night camp and drives the day rollover; below
if exp, _ = getActiveExpedition(uid); exp == nil { // that, an HP-low rest may fire mid-day.
fresh, _ := getActiveExpedition(uid)
if fresh == nil {
res.Outcome = "extracted" res.Outcome = "extracted"
i = walkCap i = walkCap
break break
} }
if err := s.TickDay(exp); err != nil { advanced, err := s.applyAutoCamp(fresh, &simNow)
if err != nil {
res.Outcome = "halted" res.Outcome = "halted"
res.StopCode = "tick:" + err.Error() res.StopCode = "auto_camp:" + err.Error()
i = walkCap i = walkCap
break break
} }
if advanced {
res.DayTicks++ res.DayTicks++
// TickDay may have force-extracted (starvation). Re-check. if fresh2, _ := getActiveExpedition(uid); fresh2 != nil {
s.captureDaySnapshot(res, fresh2, uid)
}
}
// maybeAutoCamp's drift step may have force-extracted (starvation).
if exp, _ = getActiveExpedition(uid); exp == nil { if exp, _ = getActiveExpedition(uid); exp == nil {
res.Outcome = "extracted" res.Outcome = "extracted"
i = walkCap i = walkCap
} }
if maxDays > 0 && res.DayTicks >= maxDays {
if res.Outcome == "" {
res.Outcome = "day_capped"
}
i = walkCap
}
} }
} }
if res.Outcome == "" { if res.Outcome == "" {
@@ -514,9 +624,58 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
} }
res.YieldCount, res.YieldsByName = simMaterialYields(uid) res.YieldCount, res.YieldsByName = simMaterialYields(uid)
res.Combats = simCombatSummaries(uid) res.Combats = simCombatSummaries(uid)
// Final snapshot. Re-read the expedition so closed-run state is
// visible (extracted runs return nil from getActiveExpedition; the
// row is still on disk via mostRecentExpeditionID). If the
// expedition row is gone we synthesize from the cached SU/threat
// already on res so the snapshot stream always closes.
if exp2, _ := getActiveExpedition(uid); exp2 != nil {
s.captureDaySnapshot(res, exp2, uid)
} else if past := mostRecentExpeditionID(uid); past != "" {
if exp3, _ := getExpedition(past); exp3 != nil {
s.captureDaySnapshot(res, exp3, uid)
}
}
return res, nil return res, nil
} }
// firstUnlockedForkChoice returns the 1-based index of the first
// traversable option at the pending fork, or 0 if every edge is locked
// (a graph soft-lock — see feywild fork1, which had no LockNone exit).
func (s *SimRunner) firstUnlockedForkChoice(uid id.UserID) int {
run, err := getActiveZoneRun(uid)
if err != nil || run == nil {
return 1
}
pf, err := decodePendingFork(run.NodeChoices)
if err != nil || pf == nil {
return 1
}
for _, o := range pf.Options {
if o.Unlocked {
return o.Index
}
}
return 0
}
// captureDaySnapshot appends a SimDaySnapshot reflecting current state.
// HP is read from the live character row; SU/threat/day from the live
// expedition. Rooms is the running res.Rooms counter.
func (s *SimRunner) captureDaySnapshot(res *SimResult, exp *Expedition, uid id.UserID) {
snap := SimDaySnapshot{
Day: exp.CurrentDay,
Supplies: exp.Supplies.Current,
Threat: exp.ThreatLevel,
Rooms: res.Rooms,
}
if c, _ := LoadDnDCharacter(uid); c != nil {
snap.HPCurrent = c.HPCurrent
snap.HPMax = c.HPMax
}
res.DaySnapshots = append(res.DaySnapshots, snap)
}
// simCombatSummaries pulls every combat_sessions row for uid and folds // simCombatSummaries pulls every combat_sessions row for uid and folds
// its TurnLog into a SimCombatSummary. AC values are inferred from the // its TurnLog into a SimCombatSummary. AC values are inferred from the
// RollAgainst column on attack events (the engine writes the defender's // RollAgainst column on attack events (the engine writes the defender's
@@ -616,16 +775,24 @@ func simMaterialYields(uid id.UserID) (int, map[string]int) {
return total, out return total, out
} }
// autoResolveCombat dispatches !fight at the current elite/boss gate, // autoDriveCombat dispatches !fight at the current elite/boss gate,
// then loops !attack until the combat session resolves. Returns true // then loops !attack/!cast/!consume until the combat session resolves.
// when the player won (enemy dead, room cleared), false when the // Returns true when the player won (enemy dead, room cleared), false when
// player lost or fled. autoCombatRoundCap is a safety cap against // the player lost or fled. autoCombatRoundCap is a safety cap against
// pathological stalemates (shouldn't trigger in practice — combat is // pathological stalemates (shouldn't trigger in practice — combat is
// strictly monotone in HP). // strictly monotone in HP).
//
// Shared by the headless sim and the production background autopilot
// (long-expedition D8-f): a silent ctx (ctx.Silent) suppresses the
// per-round DM narration so the autorun digest can summarize the fight
// without spamming the player a message per round. Driving the real
// !fight/!attack engine here is what gives autopilot bosses true parity
// with manual `!fight` — including enemy multiattack, which the old
// inline SimulateCombat path ignored.
const autoCombatRoundCap = 200 const autoCombatRoundCap = 200
func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) { func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
if err := s.P.handleFightCmd(ctx); err != nil { if err := p.handleFightCmd(ctx); err != nil {
return false, fmt.Errorf("fight: %w", err) return false, fmt.Errorf("fight: %w", err)
} }
sess, err := getActiveCombatSession(ctx.Sender) sess, err := getActiveCombatSession(ctx.Sender)
@@ -653,15 +820,15 @@ func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
case CombatStatusLost, CombatStatusFled: case CombatStatusLost, CombatStatusFled:
return false, nil return false, nil
} }
kind, arg := s.simPickCombatAction(ctx.Sender, cur) kind, arg := p.pickAutoCombatAction(ctx.Sender, cur)
var dispatchErr error var dispatchErr error
switch kind { switch kind {
case "consume": case "consume":
dispatchErr = s.P.handleConsumeCmd(ctx, arg) dispatchErr = p.handleConsumeCmd(ctx, arg)
case "cast": case "cast":
dispatchErr = s.P.handleCombatCastCmd(ctx, arg) dispatchErr = p.handleCombatCastCmd(ctx, arg)
default: default:
dispatchErr = s.P.handleAttackCmd(ctx) dispatchErr = p.handleAttackCmd(ctx)
} }
if dispatchErr != nil { if dispatchErr != nil {
return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr) return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr)
@@ -709,7 +876,7 @@ func (s *SimRunner) maybeShortRest(ctx MessageContext, uid id.UserID) {
// one more big hit, not so early that a 1-HP scratch burns a potion. // one more big hit, not so early that a 1-HP scratch burns a potion.
const simHealHPThresholdPct = 40 const simHealHPThresholdPct = 40
// simPickCombatAction is the sim's per-turn decision tree, mirroring // pickAutoCombatAction is the per-turn decision tree, mirroring
// what a competent prod player would type: // what a competent prod player would type:
// //
// 1. If HP is below simHealHPThresholdPct and the inventory has a heal // 1. If HP is below simHealHPThresholdPct and the inventory has a heal
@@ -728,14 +895,14 @@ const simHealHPThresholdPct = 40
// //
// Pre-J2a the sim looped !attack only, which underweighted every caster // Pre-J2a the sim looped !attack only, which underweighted every caster
// class — see sim_results/j2_findings.md for the trace evidence. // class — see sim_results/j2_findings.md for the trace evidence.
func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) { func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) {
c, _ := LoadDnDCharacter(uid) c, _ := LoadDnDCharacter(uid)
if c == nil || sess == nil { if c == nil || sess == nil {
return "attack", "" return "attack", ""
} }
lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct
if lowHP { if lowHP {
inv := s.P.loadConsumableInventory(uid) inv := p.loadConsumableInventory(uid)
for _, it := range inv { for _, it := range inv {
if it.Def.Effect == EffectHeal { if it.Def.Effect == EffectHeal {
return "consume", it.Def.Name return "consume", it.Def.Name
@@ -743,7 +910,21 @@ func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kin
} }
} }
if isSpellcaster(c) && !simMartialFirstClass(c.Class) { if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
if id := simPickSpell(c, uid); id != "" { // Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
// simPickSpell skips BuffSelf entries by design, so a cleric
// otherwise never spends an L2 slot on it. Force the pick once
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
// it's not a damage option.
if id := simPickSpiritualWeapon(c, uid, sess); id != "" {
return "cast", id
}
// Once a concentration aura is up, a competent caster maintains it and
// attacks (or casts a non-concentration spell) rather than burning a
// slot to re-arm the same aura — so the picker excludes concentration
// spells while one is active.
auraActive := sess.Statuses.ConcentrationDmg > 0
if id := simPickSpell(c, uid, auraActive); id != "" {
return "cast", id return "cast", id
} }
} }
@@ -766,8 +947,58 @@ func simMartialFirstClass(class DnDClass) bool {
return false return false
} }
// simPickSpell returns the spell ID a competent player would cast this // simPickSpiritualWeapon returns a !cast argument for Spiritual Weapon
// turn, or "" when no usable spell is available (forcing a !attack). // when a cleric should open the fight with it: the buff is not already
// active on the session, the spell is prepared, and some slot ≥ L2 is
// available. Returns "" otherwise. The buff path in combat_cmd.go folds
// into BuffSpiritProc/Dmg via applyBuffDelta, which the turn engine fires
// each round via spiritWeaponStrike — so one cast is worth more than a
// single L2 damage spell across a multi-round fight.
//
// Slot pick: lowest available slot ≥ 2. Upcasting is +1d8 per 2 slots
// above 2nd, so spending a precious L5 to add a single d8 to the proc is
// not worth burning the bigger slot's damage potential elsewhere; sim
// behaves like a competent player and saves the high slot.
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession) string {
if c == nil || c.Class != ClassCleric || sess == nil {
return ""
}
if sess.Statuses.BuffSpiritProc > 0 {
return ""
}
known, err := listKnownSpells(uid)
if err != nil {
return ""
}
prepared := false
for _, k := range known {
if k.SpellID == "spiritual_weapon" && k.Prepared {
prepared = true
break
}
}
if !prepared {
return ""
}
slots, _ := getSpellSlots(uid)
const simMaxSlot = 5
for sl := 2; sl <= simMaxSlot; sl++ {
pair, ok := slots[sl]
if !ok || pair[0]-pair[1] <= 0 {
continue
}
if sl == 2 {
return "spiritual_weapon"
}
return fmt.Sprintf("spiritual_weapon --upcast %d", sl)
}
return ""
}
// simPickSpell returns the spell argument a competent player would pass
// to !cast this turn, or "" when no usable spell is available (forcing a
// !attack). The return value is fed straight to handleCombatCastCmd, so
// upcast picks come back as `"<spell_id> --upcast N"`.
// Selection rules: // Selection rules:
// - Only damage-effect spells (damage_attack / damage_save / damage_auto). // - Only damage-effect spells (damage_attack / damage_save / damage_auto).
// Control/buff/heal are out (J2c sweep showed control scoring at // Control/buff/heal are out (J2c sweep showed control scoring at
@@ -775,13 +1006,14 @@ func simMartialFirstClass(class DnDClass) bool {
// no headroom worth the complexity). Healing is handled by the // no headroom worth the complexity). Healing is handled by the
// consumable-first branch in simPickCombatAction. // consumable-first branch in simPickCombatAction.
// - Reaction-cast spells are excluded (engine rejects them). // - Reaction-cast spells are excluded (engine rejects them).
// - Non-cantrips require an available slot at their native level (no // - For each prepared leveled spell, enumerate one candidate per
// upcasting — preserves high slots for high-level spells). // available slot at level ≥ native (D8-b, aggressive upcasting).
// - Among feasible candidates, prefer higher slot level; tie-break on // spellExpectedDamage handles the +1-die-per-slot-above-native
// scaling. Cantrips contribute one slot-0 candidate.
// - Among feasible candidates, prefer higher slot level (preserves
// high-slot supremacy and burns the big slots first); tie-break on
// expected damage from the dice string. // expected damage from the dice string.
// func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string {
// Returns the spell ID for handleCombatCastCmd.
func simPickSpell(c *DnDCharacter, uid id.UserID) string {
known, err := listKnownSpells(uid) known, err := listKnownSpells(uid)
if err != nil || len(known) == 0 { if err != nil || len(known) == 0 {
return "" return ""
@@ -789,7 +1021,8 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
slots, _ := getSpellSlots(uid) slots, _ := getSpellSlots(uid)
type cand struct { type cand struct {
id string id string
level int slot int
nativeLevel int
expDmg float64 expDmg float64
} }
var cands []cand var cands []cand
@@ -809,6 +1042,11 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
if sp.CastTime == CastReaction { if sp.CastTime == CastReaction {
continue continue
} }
// An aura is already ticking — don't re-arm it; prefer attacks or a
// non-concentration spell this turn.
if auraActive && sp.Concentration {
continue
}
onList := false onList := false
for _, cl := range sp.Classes { for _, cl := range sp.Classes {
if cl == c.Class { if cl == c.Class {
@@ -819,24 +1057,81 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
if !onList { if !onList {
continue continue
} }
if sp.Level > 0 { if sp.Level == 0 {
pair, ok := slots[sp.Level] cands = append(cands, cand{id: sp.ID, slot: 0, nativeLevel: 0, expDmg: spellExpectedDamage(sp, 0, c.Level)})
continue
}
// simMaxSlot mirrors parseCombatCast's slot-level cap; anything
// above it would be rejected by the cast handler anyway.
const simMaxSlot = 5
for sl := sp.Level; sl <= simMaxSlot; sl++ {
pair, ok := slots[sl]
if !ok || pair[0]-pair[1] <= 0 { if !ok || pair[0]-pair[1] <= 0 {
continue continue
} }
cands = append(cands, cand{
id: sp.ID,
slot: sl,
nativeLevel: sp.Level,
expDmg: spellExpectedDamage(sp, sl, c.Level),
})
} }
cands = append(cands, cand{id: sp.ID, level: sp.Level, expDmg: spellExpectedDamage(sp, sp.Level, c.Level)})
} }
if len(cands) == 0 { if len(cands) == 0 {
return "" return ""
} }
sort.Slice(cands, func(i, j int) bool { sort.Slice(cands, func(i, j int) bool {
if cands[i].level != cands[j].level { if cands[i].slot != cands[j].slot {
return cands[i].level > cands[j].level return cands[i].slot > cands[j].slot
} }
return cands[i].expDmg > cands[j].expDmg return cands[i].expDmg > cands[j].expDmg
}) })
return cands[0].id best := cands[0]
if best.slot > best.nativeLevel && best.nativeLevel > 0 {
return fmt.Sprintf("%s --upcast %d", best.id, best.slot)
}
return best.id
}
// applyAutoCamp drives the production camp scheduler under the sim's
// synthetic clock: call maybeAutoCamp with *simNow, then if a camp
// pitched, advance *simNow past minAutoCampDwell and break the camp so
// the next walk can proceed. Returns whether the pitch was a Night
// camp (i.e. a day rollover fired).
func (s *SimRunner) applyAutoCamp(exp *Expedition, simNow *time.Time) (bool, error) {
_, d, ok := s.P.maybeAutoCamp(exp, *simNow)
if !ok {
return false, nil
}
return s.dwellAndBreakAutoCamp(exp, simNow, d.Night)
}
// applyAutoCampBossSafety mirrors applyAutoCamp for the stopBossSafety
// gate — the camp is force-pitched even when the regular HP threshold
// hasn't tripped (decideAutopilotCamp also blocks pitches at boss
// rooms, which is exactly where this one belongs).
func (s *SimRunner) applyAutoCampBossSafety(exp *Expedition, simNow *time.Time) (bool, error) {
_, d, ok := s.P.pitchBossSafetyCamp(exp, *simNow)
if !ok {
return false, nil
}
return s.dwellAndBreakAutoCamp(exp, simNow, d.Night)
}
// dwellAndBreakAutoCamp advances *simNow past minAutoCampDwell and
// breaks the auto-pitched camp. Reloads exp from the DB first so the
// camp row reflects the just-applied pitch. Returns the night flag
// passed in (for the DayTicks counter).
func (s *SimRunner) dwellAndBreakAutoCamp(exp *Expedition, simNow *time.Time, night bool) (bool, error) {
*simNow = simNow.Add(minAutoCampDwell)
fresh, err := getExpedition(exp.ID)
if err != nil {
return night, err
}
if fresh != nil {
_ = breakAutoCampIfDue(fresh, *simNow)
}
return night, nil
} }
// TickDay drives one synthetic day rollover for exp: 21:00 recap of // TickDay drives one synthetic day rollover for exp: 21:00 recap of
@@ -847,6 +1142,13 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
// //
// The clock is anchored on exp.StartDate so repeat calls advance one // The clock is anchored on exp.StartDate so repeat calls advance one
// real-day per call regardless of wall-clock time when the sim runs. // real-day per call regardless of wall-clock time when the sim runs.
//
// Event-anchored expeditions (D2-b) own the rollover inside the
// autopilot's night-camp pitch — RunExpedition exercises that path via
// applyAutoCamp; TickDay is retained for tests and the legacy
// non-event-anchored fallback. The event-anchored branch here short-
// circuits to processNightCamp so callers that DO invoke TickDay on a
// post-cutoff expedition still see one-call-one-day semantics.
func (s *SimRunner) TickDay(exp *Expedition) error { func (s *SimRunner) TickDay(exp *Expedition) error {
if exp == nil { if exp == nil {
return fmt.Errorf("nil expedition") return fmt.Errorf("nil expedition")
@@ -868,15 +1170,53 @@ func (s *SimRunner) TickDay(exp *Expedition) error {
} }
briefAt := dayBase.AddDate(0, 0, 1).Add(time.Duration(expeditionBriefingHour) * time.Hour).Add(30 * time.Second) briefAt := dayBase.AddDate(0, 0, 1).Add(time.Duration(expeditionBriefingHour) * time.Hour).Add(30 * time.Second)
if isEventAnchored(exp) {
if err := s.tickEventAnchoredRollover(exp, briefAt); err != nil {
return err
}
} else {
if err := s.P.deliverBriefing(exp, briefAt); err != nil { if err := s.P.deliverBriefing(exp, briefAt); err != nil {
return fmt.Errorf("deliverBriefing: %w", err) return fmt.Errorf("deliverBriefing: %w", err)
} }
}
if fresh, _ := getExpedition(exp.ID); fresh != nil { if fresh, _ := getExpedition(exp.ID); fresh != nil {
*exp = *fresh *exp = *fresh
} }
return nil return nil
} }
// tickEventAnchoredRollover mirrors pitchAutopilotCamp with Night=true
// for the sim: burn → optional Standard rest (if supplies cover it) →
// drift → stamp last_briefing_at. No DM, no camp row left active (rest
// is applied and immediately cleared the way pitchAutopilotCamp does
// via applyCampRest + the next walk-tick break). On the no-rest branch
// we still want burn/drift so supplies drain — matches a stalled
// autopilot which D2-b's safety net force-fires via processNightCamp.
func (s *SimRunner) tickEventAnchoredRollover(exp *Expedition, briefAt time.Time) error {
burn, err := s.P.nightRolloverBurn(exp)
if err != nil {
return fmt.Errorf("nightRolloverBurn: %w", err)
}
// Pitch a Standard rest if affordable, else skip (autopilot would
// also bail in low-SU; the burn already landed). Rough is the
// fallback so the sim isn't stuck at zero healing on tight budgets.
kind := CampTypeStandard
if exp.Supplies.Current < campSupplyCost[kind] {
kind = CampTypeRough
}
if exp.Supplies.Current >= campSupplyCost[kind] {
exp.Supplies.Current -= campSupplyCost[kind]
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return fmt.Errorf("updateSupplies: %w", err)
}
applyCampRest(exp, kind)
}
drift := s.P.nightRolloverDrift(exp, briefAt)
_ = burn
_ = drift
return nil
}
// simLogEntries returns every dnd_expedition_log row for expID, oldest // simLogEntries returns every dnd_expedition_log row for expID, oldest
// first, projected into SimLogEntry. // first, projected into SimLogEntry.
func simLogEntries(expID string) ([]SimLogEntry, error) { func simLogEntries(expID string) ([]SimLogEntry, error) {

View File

@@ -0,0 +1,343 @@
package plugin
import (
"testing"
"time"
"maunium.net/go/mautrix/id"
)
// D7-b: applyAutoCamp drives maybeAutoCamp under the sim's synthetic
// clock. When simNow is past the nightCampWindow on an event-anchored
// expedition, the scheduler pitches a Night camp — current_day++,
// supplies burn, last_briefing_at stamped. The helper then advances
// simNow past minAutoCampDwell and breaks the camp so the next walk
// proceeds.
func TestSimRunner_ApplyAutoCamp_NightCampAdvancesDay(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@sim-applycamp-night:example")
campTestCharacter(t, uid, 3)
defer cleanupExpeditions(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 2, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
sim := &SimRunner{P: &AdventurePlugin{}}
// Park simNow past the night-camp window so decideAutopilotCamp
// flags Night=true on a healthy character.
simNow := exp.StartDate.Add(nightCampWindow + time.Hour)
startDay := exp.CurrentDay
startSU := exp.Supplies.Current
before := simNow
night, err := sim.applyAutoCamp(exp, &simNow)
if err != nil {
t.Fatalf("applyAutoCamp: %v", err)
}
if !night {
t.Fatal("expected Night=true beyond nightCampWindow")
}
if simNow.Sub(before) < minAutoCampDwell {
t.Errorf("simNow advanced %v, want >= %v", simNow.Sub(before), minAutoCampDwell)
}
got, _ := getExpedition(exp.ID)
if got == nil {
t.Fatal("expedition missing after night camp")
}
if got.CurrentDay != startDay+1 {
t.Errorf("CurrentDay = %d, want %d", got.CurrentDay, startDay+1)
}
if got.Supplies.Current >= startSU {
t.Errorf("Supplies.Current = %v, want < %v (burn should have landed)", got.Supplies.Current, startSU)
}
if got.Camp != nil && got.Camp.Active {
t.Errorf("camp should have been broken after dwell, got %+v", got.Camp)
}
}
// HP-low mid-day rest: under nightCampWindow with a hurt character,
// maybeAutoCamp pitches a Standard camp (cleared room → standard) but
// doesn't advance the day. HP gets restored by applyCampRest; DayTicks
// stays untouched.
func TestSimRunner_ApplyAutoCamp_HPLowMidDayRest(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@sim-applycamp-hp:example")
campTestCharacter(t, uid, 3)
c, _ := LoadDnDCharacter(uid)
c.HPCurrent = 1 // far below autoCampHPPct (55%)
_ = SaveDnDCharacter(c)
defer cleanupExpeditions(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
sim := &SimRunner{P: &AdventurePlugin{}}
// simNow well inside the active-day window — no Night pitch.
simNow := exp.StartDate.Add(2 * time.Hour)
startDay := exp.CurrentDay
startHP := c.HPCurrent
night, err := sim.applyAutoCamp(exp, &simNow)
if err != nil {
t.Fatalf("applyAutoCamp: %v", err)
}
if night {
t.Error("expected non-Night pitch within active-day window")
}
got, _ := getExpedition(exp.ID)
if got == nil {
t.Fatal("expedition missing")
}
if got.CurrentDay != startDay {
t.Errorf("CurrentDay = %d, want %d (no day advance on HP rest)", got.CurrentDay, startDay)
}
c2, _ := LoadDnDCharacter(uid)
if c2 == nil || c2.HPCurrent <= startHP {
t.Errorf("HPCurrent = %v, want > %v (rest should heal)", c2.HPCurrent, startHP)
}
if got.Camp != nil && got.Camp.Active {
t.Errorf("camp should have been broken after dwell, got %+v", got.Camp)
}
}
// stopBossSafety: pitchBossSafetyCamp force-pitches a camp regardless
// of the regular HP threshold or the boss-room block in decideAutopilotCamp.
// applyAutoCampBossSafety wraps that and runs the same dwell/break dance.
func TestSimRunner_ApplyAutoCampBossSafety_PitchesAndHeals(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@sim-bosssafety:example")
campTestCharacter(t, uid, 3)
c, _ := LoadDnDCharacter(uid)
// HP at 80% — above autoCampHPPct so maybeAutoCamp would NOT fire,
// but the boss-safety path should still pitch.
c.HPCurrent = (c.HPMax * 80) / 100
if c.HPCurrent < 1 {
c.HPCurrent = 1
}
_ = SaveDnDCharacter(c)
defer cleanupExpeditions(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
sim := &SimRunner{P: &AdventurePlugin{}}
simNow := exp.StartDate.Add(2 * time.Hour)
startSU := exp.Supplies.Current
startHP := c.HPCurrent
before := simNow
if _, err := sim.applyAutoCampBossSafety(exp, &simNow); err != nil {
t.Fatalf("applyAutoCampBossSafety: %v", err)
}
if simNow.Sub(before) < minAutoCampDwell {
t.Errorf("simNow advanced %v, want >= %v", simNow.Sub(before), minAutoCampDwell)
}
got, _ := getExpedition(exp.ID)
if got == nil {
t.Fatal("expedition missing after boss-safety pitch")
}
if got.Supplies.Current >= startSU {
t.Errorf("Supplies.Current = %v, want < %v (camp cost should have debited)", got.Supplies.Current, startSU)
}
c2, _ := LoadDnDCharacter(uid)
if c2 == nil || c2.HPCurrent <= startHP {
t.Errorf("HPCurrent = %v, want > %v (rest should heal)", c2.HPCurrent, startHP)
}
if got.Camp != nil && got.Camp.Active {
t.Errorf("camp should have been broken after dwell, got %+v", got.Camp)
}
}
// No trigger → no pitch: healthy HP, fresh simNow, no region cleared.
// maybeAutoCamp should bail and applyAutoCamp returns (false, nil).
func TestSimRunner_ApplyAutoCamp_NoTriggerIsNoop(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@sim-applycamp-noop:example")
campTestCharacter(t, uid, 3)
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
sim := &SimRunner{P: &AdventurePlugin{}}
simNow := exp.StartDate.Add(time.Hour)
before := simNow
night, err := sim.applyAutoCamp(exp, &simNow)
if err != nil {
t.Fatalf("applyAutoCamp: %v", err)
}
if night {
t.Error("expected no Night pitch from no-op call")
}
if !simNow.Equal(before) {
t.Errorf("simNow advanced %v on no-op call", simNow.Sub(before))
}
}
// D7-a: SimRunner.TickDay must advance event-anchored expeditions. The
// production deliverBriefing branch reads wall-clock time for its safety-
// net check, so the sim path short-circuits to processNightCamp + a
// Standard rest. Without this fix, CurrentDay never increments under
// D2-b and supply-burn baselining is impossible.
func TestSimRunner_TickDay_EventAnchoredAdvancesDay(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@sim-tickday-evt:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 2, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
sim := &SimRunner{P: &AdventurePlugin{}}
for i := 0; i < 3; i++ {
fresh, _ := getExpedition(exp.ID)
if fresh == nil {
t.Fatalf("expedition vanished after %d ticks", i)
}
if err := sim.TickDay(fresh); err != nil {
t.Fatalf("TickDay #%d: %v", i+1, err)
}
}
got, _ := getExpedition(exp.ID)
if got == nil {
t.Fatal("expedition missing after ticks")
}
if got.CurrentDay != 4 {
t.Errorf("CurrentDay = %d, want 4 after 3 ticks (started at 1)", got.CurrentDay)
}
if got.Supplies.Current >= 20 {
t.Errorf("Supplies.Current = %v, want < 20 (burn should have landed)", got.Supplies.Current)
}
if got.LastBriefingAt == nil {
t.Fatal("LastBriefingAt nil — drift stamp didn't fire")
}
// Each tick stamps last_briefing_at at the synthetic briefAt
// (start_date + N days at 06:00:30). After 3 ticks the stamp should
// be at least 2 days past start_date.
if got.LastBriefingAt.Sub(exp.StartDate) < 48*time.Hour {
t.Errorf("LastBriefingAt %v not advanced enough vs start %v",
got.LastBriefingAt, exp.StartDate)
}
}
// Low-SU branch: a tight supplies budget shouldn't crash the sim — the
// rest is skipped, but burn/drift still fire so the day advances. This
// mirrors prod where a stalled autopilot in a low-SU state has its
// rollover force-fired by the safety net without a rest.
func TestSimRunner_TickDay_EventAnchoredLowSupplies(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@sim-tickday-lowsu:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
// 0.5 SU is below Rough camp cost (1 SU) but above zero, so the
// rest branch is skipped while burn still proceeds.
ExpeditionSupplies{Current: 0.5, Max: 20, DailyBurn: 0.2, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
// Pin supplies low after start (startExpedition may reset them).
exp.Supplies.Current = 0.5
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
t.Fatal(err)
}
sim := &SimRunner{P: &AdventurePlugin{}}
if err := sim.TickDay(exp); err != nil {
t.Fatalf("TickDay: %v", err)
}
got, _ := getExpedition(exp.ID)
if got == nil {
// Forced extraction from starvation is acceptable — the day still
// advanced before extraction, which is what matters for the sim.
return
}
if got.CurrentDay < 2 {
t.Errorf("CurrentDay = %d, want >= 2", got.CurrentDay)
}
}
// D7-c: captureDaySnapshot must record the current expedition's day,
// supplies, and threat plus the character's live HP into res.DaySnapshots.
// Used by RunExpedition to trace state at every day rollover for
// long-expedition baselining; verified here in isolation since
// RunExpedition end-to-end isn't covered by package tests.
func TestSimRunner_CaptureDaySnapshot_PopulatesFields(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@sim-snap:example")
campTestCharacter(t, uid, 3)
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 12, Max: 20, DailyBurn: 2, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
exp.CurrentDay = 3
exp.ThreatLevel = 7
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
t.Fatal(err)
}
c, _ := LoadDnDCharacter(uid)
sim := &SimRunner{P: &AdventurePlugin{}}
res := &SimResult{Rooms: 9}
sim.captureDaySnapshot(res, exp, uid)
if len(res.DaySnapshots) != 1 {
t.Fatalf("DaySnapshots len = %d, want 1", len(res.DaySnapshots))
}
snap := res.DaySnapshots[0]
if snap.Day != 3 {
t.Errorf("Day = %d, want 3", snap.Day)
}
if snap.Supplies != 12 {
t.Errorf("Supplies = %v, want 12", snap.Supplies)
}
if snap.Threat != 7 {
t.Errorf("Threat = %d, want 7", snap.Threat)
}
if snap.Rooms != 9 {
t.Errorf("Rooms = %d, want 9", snap.Rooms)
}
if snap.HPCurrent != c.HPCurrent || snap.HPMax != c.HPMax {
t.Errorf("HP = %d/%d, want %d/%d", snap.HPCurrent, snap.HPMax, c.HPCurrent, c.HPMax)
}
}

View File

@@ -0,0 +1,184 @@
package plugin
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif" // register GIF decoder for DecodeConfig
_ "image/jpeg" // register JPEG decoder for DecodeConfig
_ "image/png" // register PNG decoder for DecodeConfig
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gogobee/internal/safehttp"
_ "golang.org/x/image/webp" // register WebP decoder for DecodeConfig
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// thumbnailUserAgent identifies the bot when probing and downloading the
// preview image for a posted link.
const thumbnailUserAgent = "GogoBee/1.0 (+link thumbnail fetch)"
// thumbnailClient validates and downloads link-supplied image URLs. It routes
// through safehttp so a posted link can't steer fetches at loopback / RFC1918 /
// cloud-metadata IPs — the dial-time guard re-checks every redirect target too.
// The 10 MiB download ceiling below bounds memory regardless.
var thumbnailClient = safehttp.NewClient(15 * time.Second)
// resolveURL turns a possibly-relative ref into an absolute URL against base.
func resolveURL(base, ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" {
return ""
}
b, err := url.Parse(base)
if err != nil {
return ref
}
r, err := url.Parse(ref)
if err != nil {
return ref
}
return b.ResolveReference(r).String()
}
// normalizeImageURL rewrites known CDN thumbnail URLs to a higher-resolution
// variant. Currently handles The Guardian's i.guim.co.uk, whose pages hand out
// narrow thumbnails. Signed URLs are left alone (re-signing isn't possible),
// as are unrecognized hosts.
func normalizeImageURL(raw string) string {
if raw == "" {
return raw
}
u, err := url.Parse(raw)
if err != nil || u.Host != "i.guim.co.uk" {
return raw
}
q := u.Query()
if q.Get("width") == "" || q.Get("s") != "" {
return raw
}
q.Set("width", "1200")
u.RawQuery = q.Encode()
return u.String()
}
// validateImageURL HEAD-probes an image URL: it must be http(s), return 200,
// have an image/* content type, and (if a length is declared) exceed 5 KiB so
// tracking pixels are filtered. Returns false with no error on any failure.
func validateImageURL(rawURL string) bool {
if rawURL == "" || safehttp.ValidateURL(rawURL) != nil {
return false
}
req, err := http.NewRequest("HEAD", rawURL, nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", thumbnailUserAgent)
resp, err := thumbnailClient.Do(req)
if err != nil {
slog.Debug("thumbnail: image HEAD failed", "url", rawURL, "err", err)
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false
}
if !strings.HasPrefix(resp.Header.Get("Content-Type"), "image/") {
return false
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
if size, err := strconv.ParseInt(cl, 10, 64); err == nil && size <= 5120 {
return false // tracking pixel
}
}
return true
}
// SendImageFromURL downloads imageURL, uploads it to Matrix, and posts it as an
// m.image event in roomID. Returns an error on any failure so callers can fall
// back to a text-only preview. The fetch is SSRF-guarded and size-capped.
func (b *Base) SendImageFromURL(roomID id.RoomID, imageURL string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
if err != nil {
return fmt.Errorf("create image request: %w", err)
}
req.Header.Set("User-Agent", thumbnailUserAgent)
resp, err := thumbnailClient.Do(req)
if err != nil {
return fmt.Errorf("download image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("image download status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return fmt.Errorf("read image: %w", err)
}
contentType := resp.Header.Get("Content-Type")
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = strings.TrimSpace(contentType[:i])
}
if contentType == "" {
contentType = "image/jpeg"
}
if !strings.HasPrefix(contentType, "image/") {
return fmt.Errorf("not an image: %s", contentType)
}
// Decode dimensions so clients render the image inline rather than as a
// downloadable file attachment.
var width, height int
if cfg, _, decErr := image.DecodeConfig(bytes.NewReader(data)); decErr == nil {
width, height = cfg.Width, cfg.Height
}
ext := ".jpg"
switch contentType {
case "image/png":
ext = ".png"
case "image/gif":
ext = ".gif"
case "image/webp":
ext = ".webp"
}
filename := "thumbnail" + ext
uri, err := b.UploadContent(data, contentType, filename)
if err != nil {
return fmt.Errorf("upload image: %w", err)
}
content := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: filename,
FileName: filename,
URL: uri.CUString(),
Info: &event.FileInfo{
MimeType: contentType,
Size: len(data),
Width: width,
Height: height,
},
}
_, err = b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
return err
}

View File

@@ -0,0 +1,40 @@
package plugin
import "testing"
func TestResolveURL(t *testing.T) {
cases := []struct {
base, ref, want string
}{
{"https://e.com/news/story", "https://cdn.e.com/a.jpg", "https://cdn.e.com/a.jpg"},
{"https://e.com/news/story", "//cdn.e.com/a.jpg", "https://cdn.e.com/a.jpg"},
{"https://e.com/news/story", "/img/a.jpg", "https://e.com/img/a.jpg"},
{"https://e.com/news/story", "a.jpg", "https://e.com/news/a.jpg"},
{"https://e.com/news/story", "", ""},
}
for _, c := range cases {
if got := resolveURL(c.base, c.ref); got != c.want {
t.Errorf("resolveURL(%q, %q) = %q, want %q", c.base, c.ref, got, c.want)
}
}
}
func TestNormalizeImageURL(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"non-guardian passthrough", "https://e.com/a.jpg?width=140", "https://e.com/a.jpg?width=140"},
{"signed left alone", "https://i.guim.co.uk/x.jpg?width=140&s=abc", "https://i.guim.co.uk/x.jpg?width=140&s=abc"},
{"no width passthrough", "https://i.guim.co.uk/x.jpg", "https://i.guim.co.uk/x.jpg"},
{"empty", "", ""},
}
for _, c := range cases {
if got := normalizeImageURL(c.in); got != c.want {
t.Errorf("%s: normalizeImageURL(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
// Unsigned Guardian thumbnail gets bumped to width=1200.
if got := normalizeImageURL("https://i.guim.co.uk/x.jpg?width=140"); got != "https://i.guim.co.uk/x.jpg?width=1200" {
t.Errorf("normalizeImageURL unsigned = %q, want width=1200", got)
}
}

View File

@@ -41,6 +41,13 @@ type MessageContext struct {
// routes DM commands to the player's game room). When set, sender-private // routes DM commands to the player's game room). When set, sender-private
// replies should go here instead of the rewritten RoomID. // replies should go here instead of the rewritten RoomID.
OriginRoomID id.RoomID OriginRoomID id.RoomID
// Silent suppresses player-facing replies for handlers that honor it
// (currently the turn-engine combat commands via replyDM). Set by the
// background autopilot when it drives a boss/elite fight through the
// real !fight/!attack engine for manual parity (long-expedition D8-f) —
// the day digest summarizes the outcome, so the per-round narration is
// dropped rather than DM'd round-by-round.
Silent bool
} }
// ReactionContext holds the context for a reaction event. // ReactionContext holds the context for a reaction event.

View File

@@ -0,0 +1,429 @@
package plugin
// Scenario tests run against a copy of the prod DB (data/gogobee.db).
// Gated on GOGOBEE_PRODDB_SCENARIOS=1 so they don't run on default
// `go test ./...` invocations. Pattern mirrors setupAuditTestDB.
//
// Run with: GOGOBEE_PRODDB_SCENARIOS=1 go test -run TestScenario_ -v \
// ./internal/plugin/
import (
"os"
"regexp"
"strings"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func requireScenarioEnv(t *testing.T) {
t.Helper()
if os.Getenv("GOGOBEE_PRODDB_SCENARIOS") != "1" {
t.Skip("scenario tests gated on GOGOBEE_PRODDB_SCENARIOS=1")
}
}
// ── Scenario: Josie caster-aid bootstraps ──────────────────────────────────
//
// Verifies (against a copy of the live DB) that the two 2026-06-18 caster-aid
// bootstraps land for @holymachina: the spell backfill adds inflict_wounds to
// her known+prepared book, and the pet gift grants a L10 dog mirrored into
// player_meta. Both must be idempotent on a second run.
func TestScenario_JosieCasterAid(t *testing.T) {
requireScenarioEnv(t)
setupAuditTestDB(t)
const uid = id.UserID("@holymachina:parodia.dev")
hasInflict := func() bool {
known, err := listKnownSpells(uid)
if err != nil {
t.Fatalf("list known: %v", err)
}
for _, k := range known {
if k.SpellID == "inflict_wounds" {
return k.Prepared
}
}
return false
}
if hasInflict() {
t.Fatal("precondition failed: target already knows inflict_wounds")
}
char, err := loadAdvCharacter(uid)
if err != nil || char == nil {
t.Fatalf("load target char: %v", err)
}
if char.PetArrived || char.PetType != "" {
t.Fatal("precondition failed: target already has a pet")
}
// Run twice to prove idempotency.
for i := 0; i < 2; i++ {
bootstrapCasterSpellBackfill()
bootstrapGrantStarterPet()
}
if !hasInflict() {
t.Error("spell backfill did not add inflict_wounds as prepared")
}
got, err := loadAdvCharacter(uid)
if err != nil || got == nil {
t.Fatalf("reload target char: %v", err)
}
if !got.PetArrived || got.PetType != "dog" || got.PetLevel != 10 {
t.Errorf("pet grant: arrived=%v type=%q level=%d, want true/dog/10",
got.PetArrived, got.PetType, got.PetLevel)
}
pet, err := loadPetState(uid)
if err != nil {
t.Fatalf("load pet state: %v", err)
}
if !pet.HasPet() || pet.Level != 10 {
t.Errorf("player_meta pet mirror: hasPet=%v level=%d, want true/10", pet.HasPet(), pet.Level)
}
}
// ── Scenario 1: Phase 5-B HP bootstrap ─────────────────────────────────────
//
// Expected: bootstrapPhase5BHPRefresh() walks dnd_character rows where
// dnd_level > 0, refreshes hp_max upward toward computeMaxHP (which
// applies phase5BHPMult=1.5), bumps hp_current by the same delta, and
// marks the daily_prefetch job key so reruns are no-ops.
func TestScenario_Phase5BHPBootstrap(t *testing.T) {
requireScenarioEnv(t)
setupAuditTestDB(t)
type charRow struct {
userID string
class string
level int
conScore int
hpMax int
hpCurrent int
}
snapshot := func() map[string]charRow {
rows, err := db.Get().Query(`
SELECT user_id, class, dnd_level, con_score, hp_max, hp_current
FROM dnd_character WHERE dnd_level > 0`)
if err != nil {
t.Fatalf("query: %v", err)
}
defer rows.Close()
out := map[string]charRow{}
for rows.Next() {
var r charRow
if err := rows.Scan(&r.userID, &r.class, &r.level, &r.conScore, &r.hpMax, &r.hpCurrent); err != nil {
t.Fatalf("scan: %v", err)
}
out[r.userID] = r
}
return out
}
before := snapshot()
t.Logf("[pre-bootstrap] %d characters with dnd_level > 0", len(before))
for _, r := range before {
t.Logf(" %s class=%q L%d con=%d hp=%d/%d",
r.userID, r.class, r.level, r.conScore, r.hpCurrent, r.hpMax)
}
if db.JobCompleted("phase5b_hp_refresh_v1", "once") {
t.Fatalf("job already marked completed before bootstrap — snapshot was post-bootstrap?")
}
bootstrapPhase5BHPRefresh()
after := snapshot()
if !db.JobCompleted("phase5b_hp_refresh_v1", "once") {
t.Errorf("expected JobCompleted=true after bootstrap")
}
refreshed := 0
for uid, b := range before {
a := after[uid]
conMod := abilityModifier(b.conScore)
_, ok := classInfo(DnDClass(b.class))
var expectedMax int
if !ok {
expectedMax = 1 // computeMaxHP returns 1 for unknown class.
} else {
expectedMax = computeMaxHP(DnDClass(b.class), conMod, b.level)
}
// Bootstrap skips rows where newMax <= oldMax (never lowers HP).
if expectedMax <= b.hpMax {
if a.hpMax != b.hpMax {
t.Errorf("%s: expected hp_max unchanged (%d), got %d", uid, b.hpMax, a.hpMax)
}
continue
}
delta := expectedMax - b.hpMax
expectedCurrent := b.hpCurrent + delta
if expectedCurrent > expectedMax {
expectedCurrent = expectedMax
}
if expectedCurrent < 1 {
expectedCurrent = 1
}
if a.hpMax != expectedMax {
t.Errorf("%s: hp_max want %d got %d", uid, expectedMax, a.hpMax)
}
if a.hpCurrent != expectedCurrent {
t.Errorf("%s: hp_current want %d (was %d, +delta %d) got %d",
uid, expectedCurrent, b.hpCurrent, delta, a.hpCurrent)
}
// Wound-preservation invariant: absolute wound (max-current) stays
// constant unless clamped at floor 1 or at the new ceiling.
preWound := b.hpMax - b.hpCurrent
postWound := a.hpMax - a.hpCurrent
if preWound != postWound && expectedCurrent != 1 && expectedCurrent != expectedMax {
t.Errorf("%s: wound size changed pre=%d post=%d (no clamp expected)",
uid, preWound, postWound)
}
refreshed++
t.Logf("[refreshed] %s: hp_max %d→%d (+%d), hp_current %d→%d",
uid, b.hpMax, a.hpMax, delta, b.hpCurrent, a.hpCurrent)
}
t.Logf("[post-bootstrap] %d/%d characters refreshed", refreshed, len(before))
// Idempotency: second call is a no-op.
bootstrapPhase5BHPRefresh()
after2 := snapshot()
for uid, a := range after {
if after2[uid].hpMax != a.hpMax || after2[uid].hpCurrent != a.hpCurrent {
t.Errorf("%s: second bootstrap call mutated HP", uid)
}
}
}
// ── Scenario 2: Magic-item plumbing ────────────────────────────────────────
//
// Expected:
// - magic_item_equipped table exists (Phase 5 migration).
// - magicItemRegistry is non-empty; rarity index covers every rarity.
// - Slot classifier output (baked into magic_items_srd_data.go via gen)
// puts known edge-case items in the right slot per the UX S4 fix.
// - dailyCuriosStock() returns a non-empty rotating shelf.
func TestScenario_MagicItemPlumbing(t *testing.T) {
requireScenarioEnv(t)
setupAuditTestDB(t)
// (a) Migration created the table.
var n int
err := db.Get().QueryRow(`
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name='magic_item_equipped'`).Scan(&n)
if err != nil || n != 1 {
t.Fatalf("magic_item_equipped table missing (n=%d err=%v)", n, err)
}
// (b) Schema sanity — required columns.
cols, err := db.Get().Query(`PRAGMA table_info(magic_item_equipped)`)
if err != nil {
t.Fatalf("pragma: %v", err)
}
defer cols.Close()
have := map[string]bool{}
for cols.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt any
_ = cols.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk)
have[name] = true
}
for _, want := range []string{"user_id", "item_id", "slot"} {
if !have[want] {
t.Errorf("magic_item_equipped missing column %q (have: %v)", want, have)
}
}
// (c) Registry populated and rarity index covers every rarity.
if len(magicItemRegistry) == 0 {
t.Fatalf("magicItemRegistry is empty")
}
t.Logf("magicItemRegistry: %d items", len(magicItemRegistry))
byRarity := magicItemsByRarity()
for _, r := range []DnDRarity{RarityCommon, RarityUncommon, RarityRare, RarityVeryRare, RarityLegendary} {
if len(byRarity[r]) == 0 {
t.Errorf("rarity %q has no items in index", r)
} else {
t.Logf(" rarity %s: %d items", r, len(byRarity[r]))
}
}
// (d) Slot baking — known edge-case items from UX S4 B4 should land
// in the slot the fix intended. Slots are baked into the generated
// data file by the importer's classifier; the lookup is a fixed table.
type slotCheck struct {
id string
wantSlot DnDSlot
mustNotBeRingForSubstring string // sanity vs word-boundary regressions
}
checks := []slotCheck{
{"ring_of_protection", DnDSlotRing1, ""},
{"boots_of_striding_and_springing", DnDSlotFeet, "springing"},
{"gloves_of_missile_snaring", DnDSlotHands, "snaring"},
{"bag_of_devouring", "", "devouring"}, // wondrous w/ no carryable noun
{"cloak_of_displacement", DnDSlotCloak, ""},
}
for _, c := range checks {
item, ok := magicItemRegistry[c.id]
if !ok {
t.Errorf("registry missing %q", c.id)
continue
}
if c.wantSlot != "" && item.Slot != c.wantSlot {
t.Errorf("%s: slot=%q want %q", c.id, item.Slot, c.wantSlot)
}
if item.Slot == DnDSlotRing1 || item.Slot == DnDSlotRing2 {
if c.mustNotBeRingForSubstring != "" {
t.Errorf("%s: misclassified as ring (substring trap %q)",
c.id, c.mustNotBeRingForSubstring)
}
}
t.Logf(" %s → kind=%s slot=%q rarity=%s attune=%v",
c.id, item.Kind, item.Slot, item.Rarity, item.Attunement)
}
// (e) Daily curios shelf rotates and returns a non-empty list.
shelf := dailyCuriosStock()
if len(shelf) == 0 {
t.Errorf("dailyCuriosStock returned empty")
} else {
t.Logf("dailyCuriosStock: %d items (first: %s @ %d, rarity %s)",
len(shelf), shelf[0].Name, shelf[0].Value, shelf[0].Rarity)
}
}
// ── Scenario 3: Expedition autopilot plumbing ──────────────────────────────
//
// Expected:
// - dnd_expedition.last_ambient_at column exists (Phase 3 migration).
// - autopilotFooter renders non-empty paused-state copy for pause
// reasons; renders empty for terminal/already-narrated reasons.
// - Ambient event pool has positive weights and non-empty flavor pools.
func TestScenario_ExpeditionAutopilotPlumbing(t *testing.T) {
requireScenarioEnv(t)
setupAuditTestDB(t)
// (a) Migration column present.
cols, err := db.Get().Query(`PRAGMA table_info(dnd_expedition)`)
if err != nil {
t.Fatalf("pragma: %v", err)
}
defer cols.Close()
have := map[string]bool{}
for cols.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt any
_ = cols.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk)
have[name] = true
}
if !have["last_ambient_at"] {
t.Errorf("dnd_expedition.last_ambient_at missing — Phase 3 migration didn't run")
}
// (b) Stop-reason footers — pause reasons render copy, terminal
// reasons render empty (death narration / completion block / etc.
// is the final).
type footerCheck struct {
reason stopReason
wantText bool
}
for _, c := range []footerCheck{
{stopFork, true},
{stopElite, true},
{stopBoss, true},
{stopHarvestCombat, true},
{stopOK, true}, // hit room cap → "stretch complete"
{stopEnded, false},
{stopComplete, false},
{stopBlocked, false},
{stopBossSafety, false}, // res.final carries the held-back line
} {
got := autopilotFooter(c.reason, 3)
if c.wantText && got == "" {
t.Errorf("stop reason %v: expected non-empty footer, got empty", c.reason)
}
if !c.wantText && got != "" {
t.Errorf("stop reason %v: expected empty footer, got %q", c.reason, got)
}
t.Logf(" %v (3 rooms) → %q", c.reason, got)
}
// (c) Ambient event pool — every event has a positive weight and a
// non-empty flavor pool. Build a temporary Expedition to satisfy
// pickAmbientEvent's eligibility predicates without persisting.
events := ambientEvents()
if len(events) == 0 {
t.Fatalf("ambientEvents() empty")
}
t.Logf("ambient pool: %d events", len(events))
for _, ev := range events {
if ev.Weight <= 0 {
t.Errorf("ambient event %q has non-positive weight %d", ev.Kind, ev.Weight)
}
if len(ev.Pool) == 0 {
t.Errorf("ambient event %q has empty pool", ev.Kind)
} else {
t.Logf(" %-22s weight=%d pool=%d sample=%q",
ev.Kind, ev.Weight, len(ev.Pool), ev.Pool[0])
}
}
}
// ── Scenario 4: Spell/help jargon regression ───────────────────────────────
//
// Expected: live merged spell registry has no banned-phrase leaks across
// Description. Mirrors TestSpellDescriptionsAreJargonFree at runtime so
// it shows up in this pass.
func TestScenario_SpellJargonRegression(t *testing.T) {
requireScenarioEnv(t)
setupAuditTestDB(t)
// Mirror dnd_spells_prose_test's TestSpellDescriptionsAreJargonFree:
// substring matches for jargon, regex for the SRD-importer placeholder
// signature ("Whatever " + lowercase, distinct from legit contractions).
bannedSubstrings := []string{
"saving throw", "Saving Throw",
"spell slot of", "Spell Slot of",
"ability modifier", "Ability Modifier",
"hit points equal to",
}
bannedRegexes := []*regexp.Regexp{
regexp.MustCompile(`\bd(4|6|8|10|12|20|100)\b`),
regexp.MustCompile(`\b\d+d\d+\b`),
regexp.MustCompile(`\bWhatever [a-z]`), // placeholder signature
regexp.MustCompile(`(?:\.\.\.|…)\s*$`), // trailing ellipsis
regexp.MustCompile(`\b[a-z]{1,3}(?:\.\.\.|…)\s*$`), // truncated word
regexp.MustCompile(`(?i)\bno larger than in any\b`),
}
totalSpells := 0
leaks := 0
for id, s := range dndSpellRegistry {
totalSpells++
desc := s.Description
if desc == "" {
continue
}
for _, b := range bannedSubstrings {
if strings.Contains(desc, b) {
t.Errorf("spell %q leaks banned phrase %q: %q", id, b, desc)
leaks++
}
}
for _, re := range bannedRegexes {
if re.MatchString(desc) {
t.Errorf("spell %q matches banned pattern %v: %q", id, re, desc)
leaks++
}
}
}
t.Logf("scanned %d spells; %d jargon leaks", totalSpells, leaks)
}

View File

@@ -10,6 +10,7 @@ import (
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/safehttp"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
@@ -40,9 +41,10 @@ func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
Base: NewBase(client), Base: NewBase(client),
enabled: enabled, enabled: enabled,
ignoreUsers: ignore, ignoreUsers: ignore,
httpClient: &http.Client{ // Route page scrapes through safehttp so a posted link can't steer the
Timeout: 3 * time.Second, // fetch at loopback / RFC1918 / cloud-metadata IPs (re-checked on every
}, // redirect), and can't OOM the parser by streaming an unbounded body.
httpClient: safehttp.NewClient(8 * time.Second),
} }
} }
@@ -90,12 +92,23 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
} }
func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) { func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
title, desc, err := p.fetchPreview(targetURL) title, desc, image, err := p.fetchPreview(targetURL)
if err != nil { if err != nil {
slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err) slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err)
return return
} }
if title == "" && desc == "" && image == "" {
return
}
// Post the thumbnail first (best-effort), so it renders above the text.
if image != "" && validateImageURL(image) {
if err := p.SendImageFromURL(ctx.RoomID, image); err != nil {
slog.Debug("urls: thumbnail post failed", "url", targetURL, "image", image, "err", err)
}
}
if title == "" && desc == "" { if title == "" && desc == "" {
return return
} }
@@ -120,63 +133,69 @@ func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
} }
} }
// fetchPreview retrieves og:title and og:description, checking cache first. // fetchPreview retrieves og:title, og:description and og:image, checking cache first.
func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) { func (p *URLsPlugin) fetchPreview(rawURL string) (title, desc, image string, err error) {
d := db.Get() d := db.Get()
now := time.Now().UTC().Unix() now := time.Now().UTC().Unix()
cacheTTL := int64(24 * 60 * 60) cacheTTL := int64(24 * 60 * 60)
// Check cache // Check cache
var title, desc string
var cachedAt int64 var cachedAt int64
err := d.QueryRow( err = d.QueryRow(
`SELECT title, description, cached_at FROM url_cache WHERE url = ?`, rawURL, `SELECT title, description, image_url, cached_at FROM url_cache WHERE url = ?`, rawURL,
).Scan(&title, &desc, &cachedAt) ).Scan(&title, &desc, &image, &cachedAt)
if err == nil && now-cachedAt < cacheTTL { if err == nil && now-cachedAt < cacheTTL {
return title, desc, nil return title, desc, image, nil
} }
// Fetch from web // Fetch from web
title, desc, err = p.scrapeOG(rawURL) title, desc, image, err = p.scrapeOG(rawURL)
if err != nil { if err != nil {
return "", "", err return "", "", "", err
} }
// Cache the result // Cache the result
db.Exec("urls: cache write", db.Exec("urls: cache write",
`INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?) `INSERT INTO url_cache (url, title, description, image_url, cached_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`, ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, image_url = ?, cached_at = ?`,
rawURL, title, desc, now, title, desc, now, rawURL, title, desc, image, now, title, desc, image, now,
) )
return title, desc, nil return title, desc, image, nil
}
// scrapeOG fetches a URL and extracts og:title, og:description and og:image.
func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, string, error) {
if err := safehttp.ValidateURL(rawURL); err != nil {
return "", "", "", err
} }
// scrapeOG fetches a URL and extracts og:title and og:description.
func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
req, err := http.NewRequest("GET", rawURL, nil) req, err := http.NewRequest("GET", rawURL, nil)
if err != nil { if err != nil {
return "", "", fmt.Errorf("create request: %w", err) return "", "", "", fmt.Errorf("create request: %w", err)
} }
req.Header.Set("User-Agent", "GogoBee Bot/1.0") req.Header.Set("User-Agent", "GogoBee Bot/1.0")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := p.httpClient.Do(req) resp, err := p.httpClient.Do(req)
if err != nil { if err != nil {
return "", "", fmt.Errorf("fetch: %w", err) return "", "", "", fmt.Errorf("fetch: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("status %d", resp.StatusCode) return "", "", "", fmt.Errorf("status %d", resp.StatusCode)
} }
doc, err := goquery.NewDocumentFromReader(resp.Body) // Cap the parsed body at 2 MiB — og: tags live in <head>, near the top.
doc, err := goquery.NewDocumentFromReader(safehttp.LimitedBody(resp.Body, 2*1024*1024))
if err != nil { if err != nil {
return "", "", fmt.Errorf("parse HTML: %w", err) return "", "", "", fmt.Errorf("parse HTML: %w", err)
} }
title := "" title := ""
desc := "" desc := ""
image := ""
doc.Find("meta").Each(func(_ int, s *goquery.Selection) { doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
prop, _ := s.Attr("property") prop, _ := s.Attr("property")
@@ -186,6 +205,10 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
title = content title = content
case "og:description": case "og:description":
desc = content desc = content
case "og:image:secure_url", "og:image:url", "og:image":
if image == "" && strings.TrimSpace(content) != "" {
image = content
}
} }
}) })
@@ -205,5 +228,9 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
}) })
} }
return strings.TrimSpace(title), strings.TrimSpace(desc), nil if image != "" {
image = normalizeImageURL(resolveURL(rawURL, strings.TrimSpace(image)))
}
return strings.TrimSpace(title), strings.TrimSpace(desc), image, nil
} }

View File

@@ -247,8 +247,17 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
if _, delErr := d.Exec(`DELETE FROM wotd_log WHERE date = ?`, today); delErr != nil { if _, delErr := d.Exec(`DELETE FROM wotd_log WHERE date = ?`, today); delErr != nil {
slog.Error("wotd: force delete failed", "err", delErr) slog.Error("wotd: force delete failed", "err", delErr)
} }
// Also clear job-completed flags so PostWOTD will re-post // Also clear job-completed flags so PostWOTD will re-post. The
d.Exec(`DELETE FROM job_completed WHERE job_name = 'wotd' AND job_key LIKE ?`, today+"%") // per-room dedup keys are "<date>:<roomID>" in daily_prefetch, so
// match every room's flag for today. (Was targeting a non-existent
// job_completed/job_key table — a silent no-op that left forced
// re-posts blocked by the stale dedup row.)
if _, delErr := d.Exec(
`DELETE FROM daily_prefetch WHERE job_name = 'wotd' AND date LIKE ?`,
today+"%",
); delErr != nil {
slog.Error("wotd: force clear dedup flags failed", "err", delErr)
}
} }
_, err := d.Exec( _, err := d.Exec(

View File

@@ -56,6 +56,18 @@ package plugin
// Longest entry→boss walk: 10 (R1) + 10 (R2) + 10 (R3) + 10 (R4) = 40 // Longest entry→boss walk: 10 (R1) + 10 (R2) + 10 (R3) + 10 (R4) = 40
// nodes, inside [36, 44]. All four meaningful walks (fork1 × fork2 × // nodes, inside [36, 44]. All four meaningful walks (fork1 × fork2 ×
// capstone) hit the same node count by construction. // capstone) hit the same node count by construction.
//
// D10 anchor variety (in-place kind swaps, no length change): the Abyss
// shipped with NO trap node and only the fork2 vrock ELITE, so D10 adds
// two traps and a region-guardian elite —
// - Hush Corridor (fork1 silent_chambers Perception-loot spur) and
// Seam Threshold (fork3 reality_seam SECRET spur) become TRAPs, so
// the two loot/secret branches each carry a hazard.
// - Warden's Hall (R3 wardens_post buildup, main path) becomes an
// ELITE — the region literally named for its wardens finally has
// one, giving every walk a mid-zone region-guardian.
// The burning_wastes / mind_corridor / void_charge / usurper_throne
// routes stay clean, preserving the safe-vs-loot fork trade-off.
func zoneAbyssPortalGraph() ZoneGraph { func zoneAbyssPortalGraph() ZoneGraph {
r1 := "abyss_outer_rift" r1 := "abyss_outer_rift"
@@ -96,7 +108,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
{NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, RegionID: r2, {NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, RegionID: r2,
Label: "Silent Chambers", PosX: 10, PosY: 3}, Label: "Silent Chambers", PosX: 10, PosY: 3},
{NodeID: "abyss_portal.hush_corridor", Kind: NodeKindExploration, RegionID: r2, {NodeID: "abyss_portal.hush_corridor", Kind: NodeKindTrap, RegionID: r2,
Label: "Hush Corridor", PosX: 11, PosY: 3}, Label: "Hush Corridor", PosX: 11, PosY: 3},
{NodeID: "abyss_portal.listening_room", Kind: NodeKindExploration, RegionID: r2, {NodeID: "abyss_portal.listening_room", Kind: NodeKindExploration, RegionID: r2,
Label: "Listening Room", PosX: 12, PosY: 3}, Label: "Listening Room", PosX: 12, PosY: 3},
@@ -135,7 +147,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
// R3 buildup to fork3. // R3 buildup to fork3.
{NodeID: "abyss_portal.wardens_outer_post", Kind: NodeKindExploration, RegionID: r3, {NodeID: "abyss_portal.wardens_outer_post", Kind: NodeKindExploration, RegionID: r3,
Label: "Outer Warden Post", PosX: 23, PosY: 2}, Label: "Outer Warden Post", PosX: 23, PosY: 2},
{NodeID: "abyss_portal.wardens_hall", Kind: NodeKindExploration, RegionID: r3, {NodeID: "abyss_portal.wardens_hall", Kind: NodeKindElite, RegionID: r3,
Label: "Warden's Hall", PosX: 24, PosY: 2}, Label: "Warden's Hall", PosX: 24, PosY: 2},
{NodeID: "abyss_portal.wardens_chapel", Kind: NodeKindExploration, RegionID: r3, {NodeID: "abyss_portal.wardens_chapel", Kind: NodeKindExploration, RegionID: r3,
Label: "Warden's Chapel", PosX: 25, PosY: 2}, Label: "Warden's Chapel", PosX: 25, PosY: 2},
@@ -163,7 +175,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
{NodeID: "abyss_portal.claimed_path", Kind: NodeKindExploration, RegionID: r4, {NodeID: "abyss_portal.claimed_path", Kind: NodeKindExploration, RegionID: r4,
Label: "Claimed Path", PosX: 32, PosY: 2}, Label: "Claimed Path", PosX: 32, PosY: 2},
{NodeID: "abyss_portal.seam_threshold", Kind: NodeKindExploration, RegionID: r4, {NodeID: "abyss_portal.seam_threshold", Kind: NodeKindTrap, RegionID: r4,
Label: "Seam Threshold", PosX: 30, PosY: 3}, Label: "Seam Threshold", PosX: 30, PosY: 3},
{NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, RegionID: r4, {NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, RegionID: r4,
Label: "Reality Seam", PosX: 31, PosY: 3, Label: "Reality Seam", PosX: 31, PosY: 3,

View File

@@ -119,3 +119,34 @@ func TestAbyssPortalGraph_RealitySeamHighestBias(t *testing.T) {
t.Errorf("reality_seam LootBias = %v, want >= 3.0 (Abyss capstone)", seam.Content.LootBias) t.Errorf("reality_seam LootBias = %v, want >= 3.0 (Abyss capstone)", seam.Content.LootBias)
} }
} }
// TestAbyssPortalGraph_D10Anchors verifies the D10 anchor-variety pass.
// The Abyss shipped with no Trap node and a single fork2 Elite, so D10
// adds two branch Traps (Hush Corridor, Seam Threshold) and a main-path
// region-guardian Elite (Warden's Hall). Counts: 2 traps, 2 elites.
func TestAbyssPortalGraph_D10Anchors(t *testing.T) {
g := zoneAbyssPortalGraph()
var trapCount, eliteCount int
for _, n := range g.Nodes {
switch n.Kind {
case NodeKindTrap:
trapCount++
case NodeKindElite:
eliteCount++
}
}
if trapCount != 2 {
t.Errorf("trap nodes = %d, want 2 (D10 hush_corridor + seam_threshold)", trapCount)
}
if eliteCount != 2 {
t.Errorf("elite nodes = %d, want 2 (vrock_aerie + D10 wardens_hall)", eliteCount)
}
if g.Nodes["abyss_portal.wardens_hall"].Kind != NodeKindElite {
t.Error("D10: wardens_hall (R3 region-guardian) should be an Elite")
}
for _, id := range []string{"abyss_portal.hush_corridor", "abyss_portal.seam_threshold"} {
if g.Nodes[id].Kind != NodeKindTrap {
t.Errorf("D10: %s should be a Trap", id)
}
}
}

View File

@@ -52,6 +52,17 @@ package plugin
// Longest entry→boss walk: 10 (R1) + 10 (R2) + 9 (R3) + 10 (R4) = 39 // Longest entry→boss walk: 10 (R1) + 10 (R2) + 9 (R3) + 10 (R4) = 39
// nodes, inside [36, 44]. The two R2 spurs and three R4 capstones each // nodes, inside [36, 44]. The two R2 spurs and three R4 capstones each
// reach the boss in the same node count by construction. // reach the boss in the same node count by construction.
//
// D10 anchor variety (in-place kind swaps, no length change): the two
// loot-leaning branches each gain a guard, so "the richer route costs
// more" —
// - Coin-Strewn Hall (treasure_vault spur) becomes an ELITE; the
// Perception loot route is now guarded, mirroring the ash_bridge
// spur's TRAP so both fork1 branches carry an anchor.
// - Hidden Passage (hoard_pillar SECRET capstone spur) becomes a
// TRAP guarding the densest loot in the zone.
// The direct-confrontation and dragon-bargain routes stay clean, so the
// fork choice trades safety for loot.
func zoneDragonsLairGraph() ZoneGraph { func zoneDragonsLairGraph() ZoneGraph {
r1 := "dragons_lair_kobold_warrens" r1 := "dragons_lair_kobold_warrens"
@@ -110,7 +121,7 @@ func zoneDragonsLairGraph() ZoneGraph {
{NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration, RegionID: r2, {NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration, RegionID: r2,
Label: "Treasure Vault", PosX: 17, PosY: 2, Label: "Treasure Vault", PosX: 17, PosY: 2,
Content: ZoneNodeContent{LootBias: 1.5}}, Content: ZoneNodeContent{LootBias: 1.5}},
{NodeID: "dragons_lair.coin_strewn_hall", Kind: NodeKindExploration, RegionID: r2, {NodeID: "dragons_lair.coin_strewn_hall", Kind: NodeKindElite, RegionID: r2,
Label: "Coin-Strewn Hall", PosX: 18, PosY: 2}, Label: "Coin-Strewn Hall", PosX: 18, PosY: 2},
{NodeID: "dragons_lair.vault_passage", Kind: NodeKindExploration, RegionID: r2, {NodeID: "dragons_lair.vault_passage", Kind: NodeKindExploration, RegionID: r2,
Label: "Vault Passage", PosX: 19, PosY: 2}, Label: "Vault Passage", PosX: 19, PosY: 2},
@@ -152,7 +163,7 @@ func zoneDragonsLairGraph() ZoneGraph {
Label: "Audience Hall", PosX: 31, PosY: 1}, Label: "Audience Hall", PosX: 31, PosY: 1},
// R4 hoard_pillar spur (Perception 17, SECRET). // R4 hoard_pillar spur (Perception 17, SECRET).
{NodeID: "dragons_lair.hidden_passage", Kind: NodeKindExploration, RegionID: r4, {NodeID: "dragons_lair.hidden_passage", Kind: NodeKindTrap, RegionID: r4,
Label: "Hidden Passage", PosX: 29, PosY: 2}, Label: "Hidden Passage", PosX: 29, PosY: 2},
{NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret, RegionID: r4, {NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret, RegionID: r4,
Label: "Hidden Hoard Pillar", PosX: 30, PosY: 2, Label: "Hidden Hoard Pillar", PosX: 30, PosY: 2,

View File

@@ -111,3 +111,32 @@ func TestDragonsLairGraph_LootBiasEscalation(t *testing.T) {
t.Errorf("hoard_pillar LootBias = %v, want >= 2.5 (T5 secret)", hoard.Content.LootBias) t.Errorf("hoard_pillar LootBias = %v, want >= 2.5 (T5 secret)", hoard.Content.LootBias)
} }
} }
// TestDragonsLairGraph_D10Anchors verifies the D10 anchor-variety pass:
// the treasure_vault spur gains a guarding Elite (Coin-Strewn Hall) to
// mirror the ash_bridge spur's Trap, and the hoard_pillar SECRET capstone
// spur gains a Trap (Hidden Passage). Counts: 2 traps, 2 elites.
func TestDragonsLairGraph_D10Anchors(t *testing.T) {
g := zoneDragonsLairGraph()
var trapCount, eliteCount int
for _, n := range g.Nodes {
switch n.Kind {
case NodeKindTrap:
trapCount++
case NodeKindElite:
eliteCount++
}
}
if trapCount != 2 {
t.Errorf("trap nodes = %d, want 2 (ash_bridge + D10 hidden_passage)", trapCount)
}
if eliteCount != 2 {
t.Errorf("elite nodes = %d, want 2 (wyrmlings_nest + D10 coin_strewn_hall)", eliteCount)
}
if g.Nodes["dragons_lair.coin_strewn_hall"].Kind != NodeKindElite {
t.Error("D10: coin_strewn_hall (treasure_vault spur) should be an Elite")
}
if g.Nodes["dragons_lair.hidden_passage"].Kind != NodeKindTrap {
t.Error("D10: hidden_passage (hoard_pillar spur) should be a Trap")
}
}

View File

@@ -18,14 +18,24 @@ package plugin
// Also new: cursed_thicket TRAP anchor in the preamble — every walk // Also new: cursed_thicket TRAP anchor in the preamble — every walk
// hits it. The original G8f graph had no Trap node. // hits it. The original G8f graph had no Trap node.
// //
// D10 anchor variety (in-place kind swaps, no length change): the two
// first-stage approaches each gain one distinct anchor so the fork
// choice carries a flavor, not just a skill gate —
// - Grove approach: Singing Orchard becomes an ELITE (the CHA-bonus
// route is guarded; you pay for the bargain in blood).
// - Marsh approach: Mire Steps becomes a TRAP (the free route is
// hazardous footing rather than a fight).
// The time_eddy / illusion_garden exclusive endings stay anchor-light so
// they keep their "skip the hag elite for loot" trade-off.
//
// Preamble (10 nodes, ending in fork1): // Preamble (10 nodes, ending in fork1):
// entry → twilight_path → veiled_glade → faerie_lights → // entry → twilight_path → veiled_glade → faerie_lights →
// cursed_thicket (TRAP) → revel_road → moonshadow_bridge → // cursed_thicket (TRAP) → revel_road → moonshadow_bridge →
// bargain_walk → fey_market → fork1 // bargain_walk → fey_market → fork1
// //
// Fork1 → both options locked (CHA vs Perception, no free choice): // Fork1 → marsh (free default) | grove (CHA DC 14 bonus — the bargain):
// //
// Grove approach (8 nodes, CHA DC 14): // Grove approach (8 nodes, CHA DC 14 bonus):
// grove_threshold → starlight_path → singing_orchard → mirror_pond // grove_threshold → starlight_path → singing_orchard → mirror_pond
// → prismatic_arbor → moonpetal_clearing → dawnglow_walk → // → prismatic_arbor → moonpetal_clearing → dawnglow_walk →
// glamoured_grove (fork2a) // glamoured_grove (fork2a)
@@ -87,7 +97,7 @@ func zoneFeywildCrossingGraph() ZoneGraph {
Label: "Grove Threshold", PosX: 10, PosY: 0}, Label: "Grove Threshold", PosX: 10, PosY: 0},
{NodeID: "feywild_crossing.starlight_path", Kind: NodeKindExploration, {NodeID: "feywild_crossing.starlight_path", Kind: NodeKindExploration,
Label: "Starlight Path", PosX: 11, PosY: 0}, Label: "Starlight Path", PosX: 11, PosY: 0},
{NodeID: "feywild_crossing.singing_orchard", Kind: NodeKindExploration, {NodeID: "feywild_crossing.singing_orchard", Kind: NodeKindElite,
Label: "Singing Orchard", PosX: 12, PosY: 0}, Label: "Singing Orchard", PosX: 12, PosY: 0},
{NodeID: "feywild_crossing.mirror_pond", Kind: NodeKindExploration, {NodeID: "feywild_crossing.mirror_pond", Kind: NodeKindExploration,
Label: "Mirror Pond", PosX: 13, PosY: 0}, Label: "Mirror Pond", PosX: 13, PosY: 0},
@@ -109,7 +119,7 @@ func zoneFeywildCrossingGraph() ZoneGraph {
Label: "Fog Basin", PosX: 12, PosY: 4}, Label: "Fog Basin", PosX: 12, PosY: 4},
{NodeID: "feywild_crossing.moaning_reeds", Kind: NodeKindExploration, {NodeID: "feywild_crossing.moaning_reeds", Kind: NodeKindExploration,
Label: "Moaning Reeds", PosX: 13, PosY: 4}, Label: "Moaning Reeds", PosX: 13, PosY: 4},
{NodeID: "feywild_crossing.mire_steps", Kind: NodeKindExploration, {NodeID: "feywild_crossing.mire_steps", Kind: NodeKindTrap,
Label: "Mire Steps", PosX: 14, PosY: 4}, Label: "Mire Steps", PosX: 14, PosY: 4},
{NodeID: "feywild_crossing.submerged_stones", Kind: NodeKindExploration, {NodeID: "feywild_crossing.submerged_stones", Kind: NodeKindExploration,
Label: "Submerged Stones", PosX: 15, PosY: 4}, Label: "Submerged Stones", PosX: 15, PosY: 4},
@@ -203,13 +213,19 @@ func zoneFeywildCrossingGraph() ZoneGraph {
{From: "feywild_crossing.bargain_walk", To: "feywild_crossing.fey_market", Lock: LockNone}, {From: "feywild_crossing.bargain_walk", To: "feywild_crossing.fey_market", Lock: LockNone},
{From: "feywild_crossing.fey_market", To: "feywild_crossing.fork1", Lock: LockNone}, {From: "feywild_crossing.fey_market", To: "feywild_crossing.fork1", Lock: LockNone},
// Fork1 — both options locked (CHA vs. Perception, no LockNone). // Fork1 — marsh is the free default path; grove is a CHA-gated
// bonus route (the fey bargain). (Both were skill-locked originally,
// with no LockNone exit — a soft-lock: any character failing both
// the CHA and Perception checks was permanently stranded here, since
// fork rolls are deterministic with no retry. Every other zone fork
// has a free path; freeing marsh restores that invariant while
// keeping the signature CHA fey-bargain route as a bonus.)
{From: "feywild_crossing.fork1", To: "feywild_crossing.grove_threshold", {From: "feywild_crossing.fork1", To: "feywild_crossing.grove_threshold",
Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14}, Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14},
Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1}, Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1},
{From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold", {From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold",
Lock: LockPerception, LockData: map[string]any{"dc": 14}, Lock: LockNone,
Hint: "wisp-light flickering between two trees — they look like the same tree", Weight: 1}, Hint: "wisp-light flickering between two trees — pick your way through", Weight: 1},
// Grove approach. // Grove approach.
{From: "feywild_crossing.grove_threshold", To: "feywild_crossing.starlight_path", Lock: LockNone}, {From: "feywild_crossing.grove_threshold", To: "feywild_crossing.starlight_path", Lock: LockNone},

View File

@@ -55,16 +55,23 @@ func TestFeywildCrossingGraph_PartialOverlap(t *testing.T) {
} }
} }
// TestFeywildCrossingGraph_NoFreeChoiceAtFork1 captures the design // TestFeywildCrossingGraph_Fork1HasFreePath guards the no-soft-lock
// intent: both fork1 outgoing edges are locked. The player must succeed // invariant: fork1 must offer at least one LockNone exit. The original
// at CHA or Perception to enter; no LockNone fallback. // design locked BOTH edges (CHA + Perception) with no fallback — fork
func TestFeywildCrossingGraph_NoFreeChoiceAtFork1(t *testing.T) { // rolls are deterministic with no retry, so a character failing both was
// permanently stranded (D8-f part 2 found this stranded ~60% of runs).
// Every other zone fork has a free path; fork1 must too.
func TestFeywildCrossingGraph_Fork1HasFreePath(t *testing.T) {
g := zoneFeywildCrossingGraph() g := zoneFeywildCrossingGraph()
free := 0
for _, e := range g.outgoingEdges("feywild_crossing.fork1") { for _, e := range g.outgoingEdges("feywild_crossing.fork1") {
if e.Lock == LockNone { if e.Lock == LockNone || e.Lock == "" {
t.Errorf("fork1 has unlocked edge to %s — expected all locked", e.To) free++
} }
} }
if free == 0 {
t.Error("fork1 has no free (LockNone) exit — soft-lock: a player failing every skill check is permanently stranded")
}
} }
// TestFeywildCrossingGraph_FirstCHALock confirms this zone uses // TestFeywildCrossingGraph_FirstCHALock confirms this zone uses
@@ -86,6 +93,8 @@ func TestFeywildCrossingGraph_FirstCHALock(t *testing.T) {
// TestFeywildCrossingGraph_TrapAnchor verifies D1-d added the missing // TestFeywildCrossingGraph_TrapAnchor verifies D1-d added the missing
// Trap node. Original G8f graph had elite/secret but no trap. // Trap node. Original G8f graph had elite/secret but no trap.
// TestFeywildCrossingGraph_TrapAnchor verifies the preamble trap plus
// the D10 marsh-branch trap (Mire Steps).
func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) { func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) {
g := zoneFeywildCrossingGraph() g := zoneFeywildCrossingGraph()
var trapCount int var trapCount int
@@ -94,8 +103,30 @@ func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) {
trapCount++ trapCount++
} }
} }
if trapCount != 1 { if trapCount != 2 {
t.Errorf("trap nodes = %d, want 1", trapCount) t.Errorf("trap nodes = %d, want 2 (cursed_thicket + D10 mire_steps)", trapCount)
}
if g.Nodes["feywild_crossing.mire_steps"].Kind != NodeKindTrap {
t.Error("D10: mire_steps (marsh branch) should be a Trap")
}
}
// TestFeywildCrossingGraph_EliteAnchors verifies the shared hag_circle
// elite plus the D10 grove-branch elite (Singing Orchard), so the first
// fork carries a per-branch anchor (grove=elite, marsh=trap).
func TestFeywildCrossingGraph_EliteAnchors(t *testing.T) {
g := zoneFeywildCrossingGraph()
var eliteCount int
for _, n := range g.Nodes {
if n.Kind == NodeKindElite {
eliteCount++
}
}
if eliteCount != 2 {
t.Errorf("elite nodes = %d, want 2 (hag_circle + D10 singing_orchard)", eliteCount)
}
if g.Nodes["feywild_crossing.singing_orchard"].Kind != NodeKindElite {
t.Error("D10: singing_orchard (grove branch) should be an Elite")
} }
} }

View File

@@ -190,6 +190,40 @@ func TestCompileLegacyZoneGraph_AllRegistered(t *testing.T) {
} }
} }
// TestZoneGraphs_NoSoftLockedFork guards the invariant that every fork
// node offers at least one traversable (LockNone) exit. A fork whose
// every edge is skill-locked strands any player who fails all the checks
// — fork rolls are deterministic with no retry. D8-f part 2 found
// feywild fork1 violating this (it stranded ~60% of runs). Catch any
// future author who locks every exit of a fork.
func TestZoneGraphs_NoSoftLockedFork(t *testing.T) {
for _, z := range allZones() {
g, ok := loadZoneGraph(z.ID)
if !ok {
continue
}
for nodeID, node := range g.Nodes {
if node.Kind != NodeKindFork {
continue
}
outs := g.outgoingEdges(nodeID)
if len(outs) == 0 {
continue
}
free := false
for _, e := range outs {
if e.Lock == LockNone || e.Lock == "" {
free = true
break
}
}
if !free {
t.Errorf("zone %q fork %q: every exit is locked — soft-lock (a player failing all checks is stranded; add a LockNone fallback)", z.ID, nodeID)
}
}
}
}
func TestDeriveLegacyNodeID_StableShape(t *testing.T) { func TestDeriveLegacyNodeID_StableShape(t *testing.T) {
got := deriveLegacyNodeID("crypt_valdris", 0) got := deriveLegacyNodeID("crypt_valdris", 0)
want := "crypt_valdris.r1" want := "crypt_valdris.r1"

View File

@@ -51,6 +51,16 @@ package plugin
// //
// Trap anchor (Collapsed Arch) is new — the original G8i graph had no // Trap anchor (Collapsed Arch) is new — the original G8i graph had no
// Trap node. Placed in the R1 preamble so every walk hits it. // Trap node. Placed in the R1 preamble so every walk hits it.
//
// D10 anchor variety (in-place kind swaps, no length change):
// - Silenced Chamber (R3 illithid arm) becomes a TRAP — the second
// trap, on a fork branch so the illithid route reads riskier than
// the drow route.
// - Drow Gate Garrison (R2 arm tail, at the R2→R4 boundary) becomes a
// region-guardian ELITE, giving the drow arm two elites (Captain +
// Garrison) and the multi-region transition a teeth-y interrupt.
// The deep_chasm spur stays anchor-light (harvest, no second trap/elite)
// so the CON-gated climb keeps its "denser loot, less fighting" identity.
func zoneUnderdarkGraph() ZoneGraph { func zoneUnderdarkGraph() ZoneGraph {
r1 := "underdark_surface_tunnels" r1 := "underdark_surface_tunnels"
@@ -111,15 +121,15 @@ func zoneUnderdarkGraph() ZoneGraph {
Label: "Drow Descent", PosX: 17, PosY: 0}, Label: "Drow Descent", PosX: 17, PosY: 0},
{NodeID: "underdark.drow_passage", Kind: NodeKindExploration, RegionID: r2, {NodeID: "underdark.drow_passage", Kind: NodeKindExploration, RegionID: r2,
Label: "Lower Passage", PosX: 18, PosY: 0}, Label: "Lower Passage", PosX: 18, PosY: 0},
{NodeID: "underdark.drow_gate", Kind: NodeKindExploration, RegionID: r2, {NodeID: "underdark.drow_gate", Kind: NodeKindElite, RegionID: r2,
Label: "Drow Gate", PosX: 19, PosY: 0}, Label: "Drow Gate Garrison", PosX: 19, PosY: 0},
// R3 illithid_warren arm. // R3 illithid_warren arm.
{NodeID: "underdark.psionic_corridor", Kind: NodeKindExploration, RegionID: r3, {NodeID: "underdark.psionic_corridor", Kind: NodeKindExploration, RegionID: r3,
Label: "Psionic Corridor", PosX: 8, PosY: 2}, Label: "Psionic Corridor", PosX: 8, PosY: 2},
{NodeID: "underdark.whispering_hall", Kind: NodeKindExploration, RegionID: r3, {NodeID: "underdark.whispering_hall", Kind: NodeKindExploration, RegionID: r3,
Label: "Whispering Hall", PosX: 9, PosY: 2}, Label: "Whispering Hall", PosX: 9, PosY: 2},
{NodeID: "underdark.silenced_chamber", Kind: NodeKindExploration, RegionID: r3, {NodeID: "underdark.silenced_chamber", Kind: NodeKindTrap, RegionID: r3,
Label: "Silenced Chamber", PosX: 10, PosY: 2}, Label: "Silenced Chamber", PosX: 10, PosY: 2},
{NodeID: "underdark.mind_tank_room", Kind: NodeKindExploration, RegionID: r3, {NodeID: "underdark.mind_tank_room", Kind: NodeKindExploration, RegionID: r3,
Label: "Brine Tanks", PosX: 11, PosY: 2}, Label: "Brine Tanks", PosX: 11, PosY: 2},

View File

@@ -106,8 +106,8 @@ func TestUnderdarkGraph_AllArmsReachBoss(t *testing.T) {
} }
} }
// TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing Trap // TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing R1 Trap
// node. Original G8i graph had elite/boss/harvest but no trap. // (collapsed_arch) and D10 added the illithid-arm Trap (silenced_chamber).
func TestUnderdarkGraph_TrapAnchor(t *testing.T) { func TestUnderdarkGraph_TrapAnchor(t *testing.T) {
g := zoneUnderdarkGraph() g := zoneUnderdarkGraph()
var trapCount int var trapCount int
@@ -116,7 +116,30 @@ func TestUnderdarkGraph_TrapAnchor(t *testing.T) {
trapCount++ trapCount++
} }
} }
if trapCount != 1 { if trapCount != 2 {
t.Errorf("trap nodes = %d, want 1", trapCount) t.Errorf("trap nodes = %d, want 2 (collapsed_arch + D10 silenced_chamber)", trapCount)
}
if g.Nodes["underdark.silenced_chamber"].Kind != NodeKindTrap {
t.Error("D10: silenced_chamber (illithid arm) should be a Trap")
}
}
// TestUnderdarkGraph_EliteAnchors verifies the per-arm elites (drow
// Captain, illithid Mind Flayer) plus the D10 region-guardian elite at
// the drow→throne boundary (drow_gate), so the drow arm carries two
// elites while the chasm spur stays anchor-light.
func TestUnderdarkGraph_EliteAnchors(t *testing.T) {
g := zoneUnderdarkGraph()
var eliteCount int
for _, n := range g.Nodes {
if n.Kind == NodeKindElite {
eliteCount++
}
}
if eliteCount != 3 {
t.Errorf("elite nodes = %d, want 3 (drow_captain + mind_flayer + D10 drow_gate)", eliteCount)
}
if g.Nodes["underdark.drow_gate"].Kind != NodeKindElite {
t.Error("D10: drow_gate (R2→R4 boundary) should be a region-guardian Elite")
} }
} }

View File

@@ -0,0 +1,146 @@
// Package safehttp provides an http.Client hardened against SSRF and
// memory-DoS via hostile upstreams. Every outbound fetch the bot makes
// against feed-supplied URLs (RSS articles, image hosts) should go through
// one of these clients so a malicious feed can't steer the bot at loopback,
// link-local, RFC1918, or cloud metadata IPs, and can't OOM the process by
// streaming an unbounded body.
package safehttp
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// ErrBlockedHost is returned when a URL resolves to a non-public IP.
var ErrBlockedHost = errors.New("safehttp: blocked non-public host")
// AllowPrivate, when true, disables the loopback/RFC1918 dial guard. It
// exists for tests that spin up httptest.NewServer on 127.0.0.1 — never
// set this in production.
var AllowPrivate bool
// safeDialContext refuses connections to non-public IPs. It runs after
// DNS resolution, so a hostile DNS rebinding that returns 127.0.0.1
// still gets blocked at dial time.
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := (&net.Resolver{}).LookupIP(ctx, "ip", host)
if err != nil {
return nil, err
}
var allowed net.IP
for _, ip := range ips {
if AllowPrivate || isPublicIP(ip) {
allowed = ip
break
}
}
if allowed == nil {
return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host)
}
d := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
return d.DialContext(ctx, network, net.JoinHostPort(allowed.String(), port))
}
// isPublicIP reports whether ip is a globally routable unicast address.
// Rejects loopback, link-local, multicast, RFC1918, CGNAT, and the
// AWS/GCP/Azure metadata IPs 169.254.169.254 / fd00:ec2::254 (these
// already fall under link-local but spell it out for clarity).
func isPublicIP(ip net.IP) bool {
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsPrivate() {
return false
}
// 100.64.0.0/10 (CGNAT) is not covered by IsPrivate on older Go.
if v4 := ip.To4(); v4 != nil {
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
return false
}
// 0.0.0.0/8 and friends.
if v4[0] == 0 {
return false
}
}
return true
}
// ValidateURL returns nil if the URL is http(s) and parseable. It does
// not resolve DNS — the dial step does that — but it does reject bare
// schemes (file://, gopher://, etc.) before we even open a connection.
func ValidateURL(raw string) error {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("safehttp: unsupported scheme %q", u.Scheme)
}
if u.Host == "" {
return errors.New("safehttp: empty host")
}
return nil
}
// NewClient returns an http.Client whose transport blocks non-public
// destinations at dial time, caps redirects at 5, and re-validates each
// redirect target's scheme. timeout is the per-request overall budget.
func NewClient(timeout time.Duration) *http.Client {
tr := &http.Transport{
DialContext: safeDialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 32,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
return &http.Client{
Transport: tr,
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("safehttp: stopped after 5 redirects")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("safehttp: unsupported redirect scheme %q", req.URL.Scheme)
}
return nil
},
}
}
// LimitedBody wraps r in a reader that errors once more than max bytes have
// been read. Use to cap how much of a response body downstream parsers
// (goquery, image.Decode) will ever see — a hostile origin streaming an
// endless body otherwise OOMs the process.
func LimitedBody(r io.Reader, max int64) io.Reader {
return &limitedReader{R: r, N: max}
}
type limitedReader struct {
R io.Reader
N int64
}
func (l *limitedReader) Read(p []byte) (int, error) {
if l.N <= 0 {
return 0, fmt.Errorf("safehttp: response body exceeded cap")
}
if int64(len(p)) > l.N {
p = p[:l.N]
}
n, err := l.R.Read(p)
l.N -= int64(n)
return n, err
}

View File

@@ -406,8 +406,8 @@ func setupScheduledJobs(
} }
}) })
// WOTD post at 08:00 // WOTD post at 08:00 (disabled by default; opt in via ENABLE_WOTD_POST=true)
if strings.ToLower(os.Getenv("DISABLE_WOTD_POST")) != "true" { if strings.ToLower(os.Getenv("ENABLE_WOTD_POST")) == "true" {
c.AddFunc("0 8 * * *", func() { c.AddFunc("0 8 * * *", func() {
slog.Info("scheduler: posting WOTD") slog.Info("scheduler: posting WOTD")
for _, r := range rooms { for _, r := range rooms {
@@ -415,7 +415,7 @@ func setupScheduledJobs(
} }
}) })
} else { } else {
slog.Info("scheduler: WOTD daily post disabled via DISABLE_WOTD_POST") slog.Info("scheduler: WOTD daily post disabled (set ENABLE_WOTD_POST=true to enable)")
} }
// Game releases Monday 09:00 // Game releases Monday 09:00