10 Commits

Author SHA1 Message Date
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
11 changed files with 890 additions and 91 deletions

View File

@@ -13,12 +13,16 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"gogobee/internal/plugin"
@@ -32,6 +36,7 @@ func main() {
zone = flag.String("zone", "goblin_warrens", "zone id (single-run mode)")
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)")
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)")
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)")
@@ -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")
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()
@@ -65,14 +72,14 @@ func main() {
includeLog = *logFlag
}
})
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, includeLog)
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel)
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
if dir == "" {
var err error
@@ -83,7 +90,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
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 res != nil {
if !includeLog {
@@ -99,47 +106,129 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
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)
ls := parseLevels(levels)
zs := splitNonEmpty(zones)
if len(cs) == 0 || len(ls) == 0 || len(zs) == 0 || 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 _, lv := range ls {
for _, z := range zs {
for r := 0; r < runs; r++ {
dir, err := os.MkdirTemp("", "expedition-sim-")
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)
work = append(work, matrixJob{class: c, level: lv, zone: z, rep: r})
}
}
}
}
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 runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap int) (*plugin.SimResult, error) {
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)
if err != nil {
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)
}
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) {

View File

@@ -136,10 +136,66 @@ 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.
### 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`).
- 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-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.
**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 scheduled; long-expedition mechanics are at v1. Future tuning rides on the J3 caster sim-picker work below (split out of the long-expedition track).
## 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 (TODO next session): concentration-damage modeling.** `heat_metal`, `spirit_guardians`, `spike_growth`, `call_lightning`, `flaming_sphere`, `cloud_of_daggers`, etc. are scored once by `spellExpectedDamage` as if they were a single-shot, but their value is N rounds of repetition while concentration holds. The picker therefore under-ranks them — bard's `heat_metal` (2d8 concentration) loses to `shatter` (3d8 one-shot) on every L2 pick, even though heat_metal in real play deals 2d8 × ~4 rounds = 32 vs shatter's 13.5.
- Touch: `spellExpectedDamage` in `internal/plugin/dnd_spell_combat.go` (or wherever it lives — verify). Add a concentration multiplier: if `sp.Concentration && sp.Effect ∈ {DamageAuto, DamageSave}` and damage dice present, multiply expDmg by an "expected concentration rounds" factor. Conservative start: 3 rounds (short of a real fight-length estimate, but big enough to flip the picker).
- Risk: this also boosts druid (call_lightning, flaming_sphere) and cleric (spirit_guardians). That's desired — those are exactly the under-played identity spells. Don't multiplier-boost EffectDamageAttack (single-target attack-roll spells aren't typically concentration; `hex`-style cases are handled by mods, not the picker).
- Subtle gotcha: casting a new concentration spell drops the old one. Picker is stateless (one decision per turn) so it can't easily know "I'm already concentrating on X." Acceptable v1: let it overwrite — sim noise will eat the rare double-cast. Stateful version: read `sess.Statuses` for an active concentration marker; defer.
- Validation: re-baseline against `sim_results/d8prereq_corpus.jsonl`, **not** d7d. Expected: incremental movement at T3 (already high after prereq); unclear lift at T4 (depends on whether picker-gap is the binding constraint there — see D8-d).
**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

View File

@@ -642,7 +642,7 @@ type autopilotWalkResult struct {
// combat already auto-resolves inside resolveCombatRoom; elite/boss
// doorways stop here so the player can choose !fight on their own terms.
func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
r := p.runAutopilotWalk(ctx, autopilotRoomCap, false)
r := p.runAutopilotWalk(ctx, autopilotRoomCap, false, false)
if r.initErr != "" {
return p.SendDM(ctx.Sender, r.initErr)
}
@@ -667,7 +667,7 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
// run graph / harvest tally / supplies / threat — same as before, just
// no streamFlow here. compact==true switches the underlying combat
// narration into terse mode and auto-resolves elite (not boss) rooms.
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact bool) autopilotWalkResult {
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult {
exp, err := getActiveExpedition(ctx.Sender)
if err != nil {
return autopilotWalkResult{initErr: "Couldn't read expedition state: " + err.Error()}
@@ -717,7 +717,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 {
return autopilotWalkResult{initErr: aerr.Error()}
}

View File

@@ -819,7 +819,7 @@ func defaultKnownSpells(class DnDClass, level int) []string {
case ClassCleric:
out := []string{"sacred_flame", "guidance", "mending"}
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 {
out = append(out, "spiritual_weapon", "lesser_restoration", "aid")
@@ -878,10 +878,10 @@ func defaultKnownSpells(class DnDClass, level int) []string {
case ClassBard:
out := []string{"vicious_mockery", "minor_illusion", "message"}
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 {
out = append(out, "hold_person", "shatter", "invisibility", "lesser_restoration")
out = append(out, "hold_person", "shatter", "heat_metal", "invisibility", "lesser_restoration")
}
if maxSlot >= 3 {
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.",
Upcast: "+1 target per slot above 2nd"},
{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,
Description: "An invisible chord rings, then snaps — everything brittle nearby cracks at once.",
Upcast: "louder per slot above 2nd"},

View File

@@ -472,10 +472,17 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
// doorways (boss still stops; boss is the climax beat). Foreground
// `!expedition run` / `!zone advance` always pass false.
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
// long-expedition D3 inline auto-resolve (production autorun). false
// returns stopBoss/stopElite after the safety gate so a turn-based driver
// — currently only the headless sim's autoResolveCombat / simPickCombatAction
// — handles the fight via the regular !fight / !attack engine. The sim
// uses false so simPickSpell actually fires; D8-prereq re-wired this seam.
func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) {
run, err := getActiveZoneRun(ctx.Sender)
if err != nil {
return advanceResult{}, fmt.Errorf("Couldn't read run state: %s", err.Error())
@@ -560,6 +567,23 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
}, nil
}
}
if !inlineBossCombat {
// Background caller wants to drive the fight itself (sim's
// autoResolveCombat / simPickCombatAction). 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
// selects monster + label by run.CurrentRoomType().
ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, prev == RoomElite, true)

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.
// D4-a uses the Night bit on the decision to switch tryAutoRun's DM
// 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)
var run *DungeonRun
@@ -190,7 +195,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi
HPCur: hpCur,
HPMax: hpMax,
Supplies: exp.Supplies,
Now: time.Now().UTC(),
Now: now,
EventAnchored: isEventAnchored(exp),
LastBriefingAt: exp.LastBriefingAt,
StartDate: exp.StartDate,
@@ -199,7 +204,7 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecisi
if !ok {
return "", autoCampDecision{}, false
}
block, err := p.pitchAutopilotCamp(exp, d)
block, err := p.pitchAutopilotCamp(exp, d, now)
if err != nil {
slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err)
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
// pre-pitch supplies, matching the legacy morning-burn ordering), then
// 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 (
nightBurn float32
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 — that needs supplies <= 0 *after* burn), but
// 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
nightMile = drift.MilestoneLines
nightForce = true
@@ -253,7 +258,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
Active: true,
Type: d.Kind,
RoomIndex: campCurrentRoomIndex(exp),
EstablishedAt: time.Now().UTC(),
EstablishedAt: now,
NightEvents: []string{},
AutoPitched: true,
}
@@ -287,7 +292,7 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
}
if d.Night {
drift := p.nightRolloverDrift(exp, time.Now().UTC())
drift := p.nightRolloverDrift(exp, now)
nightTemp = drift.TemporalLines
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
// expedition, the pitch carries Night=true and runs the burn/drift
// 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 {
return "", autoCampDecision{}, false
}
@@ -371,9 +376,9 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCamp
if isEventAnchored(exp) {
var since time.Duration
if exp.LastBriefingAt != nil {
since = time.Since(*exp.LastBriefingAt)
since = now.Sub(*exp.LastBriefingAt)
} else {
since = time.Since(exp.StartDate)
since = now.Sub(exp.StartDate)
}
night = since >= nightCampWindow
}
@@ -382,7 +387,7 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCamp
Reason: "boss-safety hold — resting before re-engaging",
Night: night,
}
block, err := p.pitchAutopilotCamp(exp, d)
block, err := p.pitchAutopilotCamp(exp, d, now)
if err != nil {
slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err)
return "", autoCampDecision{}, false

View File

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

View File

@@ -170,7 +170,7 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
}
uid := id.UserID(e.UserID)
r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true)
r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true, true)
if r.initErr != "" {
// "no expedition" / "no run" — race with abandon/extract. Silent.
return nil
@@ -198,13 +198,13 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// its RoomBoss block. Next tick past dwell retries the boss.
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
fresh.Status == ExpeditionStatusActive {
campBlock, campDecision, campPitched = p.pitchBossSafetyCamp(fresh)
campBlock, campDecision, campPitched = p.pitchBossSafetyCamp(fresh, now)
}
} else if r.reason != stopEnded && r.reason != stopComplete &&
r.reason != stopBlocked && r.reason != stopFork {
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
fresh.Status == ExpeditionStatusActive {
campBlock, campDecision, campPitched = p.maybeAutoCamp(fresh)
campBlock, campDecision, campPitched = p.maybeAutoCamp(fresh, now)
}
}
_ = autoCampBroken // hint reserved for downstream digest tweaks

View File

@@ -282,9 +282,27 @@ type SimResult struct {
// without re-running the matrix. Populated from combat_sessions
// rows + their TurnLog at end-of-run.
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
}
// 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
// per-round damage dealt by each side, and the outcome. Lets J-phase
// analysis ask "did Fighter L12 hit the manor boss often enough?"
@@ -350,17 +368,30 @@ type SimLogEntry struct {
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
// autopilot walk (compact mode, so elite rooms auto-resolve inline)
// until a hard stop fires. Between walks it fast-forwards the day
// cycle so multi-day expeditions complete without real-time waits.
// until a hard stop fires. Between walks it advances a synthetic clock
// (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
// 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
// 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)
if err != nil || c == nil {
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
@@ -375,7 +406,10 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
}
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)
}
exp, _ := getActiveExpedition(uid)
@@ -384,9 +418,18 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
return res, fmt.Errorf("expedition did not persist after start")
}
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++ {
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true)
simNow = simNow.Add(simWalkInterval)
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, false)
if walk.initErr != "" {
res.Outcome = "halted"
res.StopCode = "init:" + walk.initErr
@@ -467,28 +510,75 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
case stopBlocked:
res.Outcome = "blocked"
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:
// stopOK / stopPreflight / stopHarvestCombat — soft stops.
// Fast-forward a day so the next walk has fresh supplies
// budgeted, HP that overnight camp may have healed, and
// threat drift recorded.
if exp, _ = getActiveExpedition(uid); exp == nil {
// Run the same camp scheduler the production autorun fires
// after every walk. With simNow past nightCampWindow the
// pitch is a Night camp and drives the day rollover; below
// that, an HP-low rest may fire mid-day.
fresh, _ := getActiveExpedition(uid)
if fresh == nil {
res.Outcome = "extracted"
i = walkCap
break
}
if err := s.TickDay(exp); err != nil {
advanced, err := s.applyAutoCamp(fresh, &simNow)
if err != nil {
res.Outcome = "halted"
res.StopCode = "tick:" + err.Error()
res.StopCode = "auto_camp:" + err.Error()
i = walkCap
break
}
if advanced {
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 {
res.Outcome = "extracted"
i = walkCap
}
if maxDays > 0 && res.DayTicks >= maxDays {
if res.Outcome == "" {
res.Outcome = "day_capped"
}
i = walkCap
}
}
}
if res.Outcome == "" {
@@ -514,9 +604,38 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
}
res.YieldCount, res.YieldsByName = simMaterialYields(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
}
// 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
// its TurnLog into a SimCombatSummary. AC values are inferred from the
// RollAgainst column on attack events (the engine writes the defender's
@@ -743,6 +862,15 @@ func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kin
}
}
if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
// 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
}
if id := simPickSpell(c, uid); id != "" {
return "cast", id
}
@@ -766,8 +894,58 @@ func simMartialFirstClass(class DnDClass) bool {
return false
}
// simPickSpell returns the spell ID a competent player would cast this
// turn, or "" when no usable spell is available (forcing a !attack).
// simPickSpiritualWeapon returns a !cast argument for Spiritual Weapon
// 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:
// - Only damage-effect spells (damage_attack / damage_save / damage_auto).
// Control/buff/heal are out (J2c sweep showed control scoring at
@@ -775,12 +953,13 @@ func simMartialFirstClass(class DnDClass) bool {
// no headroom worth the complexity). Healing is handled by the
// consumable-first branch in simPickCombatAction.
// - Reaction-cast spells are excluded (engine rejects them).
// - Non-cantrips require an available slot at their native level (no
// upcasting — preserves high slots for high-level spells).
// - Among feasible candidates, prefer higher slot level; tie-break on
// - For each prepared leveled spell, enumerate one candidate per
// available slot at level ≥ native (D8-b, aggressive upcasting).
// 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.
//
// Returns the spell ID for handleCombatCastCmd.
func simPickSpell(c *DnDCharacter, uid id.UserID) string {
known, err := listKnownSpells(uid)
if err != nil || len(known) == 0 {
@@ -789,7 +968,8 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
slots, _ := getSpellSlots(uid)
type cand struct {
id string
level int
slot int
nativeLevel int
expDmg float64
}
var cands []cand
@@ -819,24 +999,81 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
if !onList {
continue
}
if sp.Level > 0 {
pair, ok := slots[sp.Level]
if sp.Level == 0 {
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 {
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 {
return ""
}
sort.Slice(cands, func(i, j int) bool {
if cands[i].level != cands[j].level {
return cands[i].level > cands[j].level
if cands[i].slot != cands[j].slot {
return cands[i].slot > cands[j].slot
}
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
@@ -847,6 +1084,13 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
//
// 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.
//
// 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 {
if exp == nil {
return fmt.Errorf("nil expedition")
@@ -868,15 +1112,53 @@ func (s *SimRunner) TickDay(exp *Expedition) error {
}
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 {
return fmt.Errorf("deliverBriefing: %w", err)
}
}
if fresh, _ := getExpedition(exp.ID); fresh != nil {
*exp = *fresh
}
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
// first, projected into SimLogEntry.
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)
}
}