mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Compare commits
10 Commits
6c4b14e113
...
63ad423b79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ad423b79 | ||
|
|
da94d51857 | ||
|
|
151d6abc01 | ||
|
|
631764bbbd | ||
|
|
ad2a8258ba | ||
|
|
2fdb280477 | ||
|
|
4576c75722 | ||
|
|
3b29d10461 | ||
|
|
29cad7972a | ||
|
|
a2992ea06c |
@@ -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) {
|
||||
|
||||
@@ -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 78–82%, casters 21–42%, ~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 5–6 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 78–98% 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 +5–10pp / mage+sorcerer+warlock +2–8pp 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 ~6–18pp 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=3–5 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 11–14 vs martial 16–17; T4 standard roster (Atk +5 to +7) hits ~60–65%. (c) HP scaling — confirmed contributor. Caster HPMax 93–110 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 +3–5 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
|
||||
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -282,7 +282,25 @@ type SimResult struct {
|
||||
// without re-running the matrix. Populated from combat_sessions
|
||||
// rows + their TurnLog at end-of-run.
|
||||
Combats []SimCombatSummary
|
||||
Log []SimLogEntry
|
||||
// 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
|
||||
@@ -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
|
||||
}
|
||||
res.DayTicks++
|
||||
// TickDay may have force-extracted (starvation). Re-check.
|
||||
if advanced {
|
||||
res.DayTicks++
|
||||
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 {
|
||||
@@ -788,9 +967,10 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
type cand struct {
|
||||
id string
|
||||
level int
|
||||
expDmg float64
|
||||
id string
|
||||
slot int
|
||||
nativeLevel int
|
||||
expDmg float64
|
||||
}
|
||||
var cands []cand
|
||||
for _, k := range known {
|
||||
@@ -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,8 +1112,14 @@ func (s *SimRunner) TickDay(exp *Expedition) error {
|
||||
}
|
||||
|
||||
briefAt := dayBase.AddDate(0, 0, 1).Add(time.Duration(expeditionBriefingHour) * time.Hour).Add(30 * time.Second)
|
||||
if err := s.P.deliverBriefing(exp, briefAt); err != nil {
|
||||
return fmt.Errorf("deliverBriefing: %w", err)
|
||||
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
|
||||
@@ -877,6 +1127,38 @@ func (s *SimRunner) TickDay(exp *Expedition) error {
|
||||
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) {
|
||||
|
||||
343
internal/plugin/expedition_sim_test.go
Normal file
343
internal/plugin/expedition_sim_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user