diff --git a/.env.example b/.env.example index 2fab177..e1510e2 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ BOT_DISPLAY_NAME=GogoBee # Which rooms the bot posts scheduled content to (comma-separated room IDs) BROADCAST_ROOMS=!roomid:example.com +# The daily 08:00 WOTD auto-post is disabled by default. +# Set to true to enable it. The !wotd command and passive WOTD +# usage tracking work regardless of this setting. +ENABLE_WOTD_POST=false + # Admins who can run admin-only commands (comma-separated Matrix user IDs) ADMIN_USERS=@yourmxid:example.com diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go index 147e029..48aeba3 100644 --- a/cmd/expedition-sim/main.go +++ b/cmd/expedition-sim/main.go @@ -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)") @@ -43,10 +48,19 @@ func main() { runs = flag.Int("runs", 1, "replicates per (class,level,zone) cell (matrix mode)") 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() + if *petLevel < 0 || *petLevel > 10 { + fail("pet-level must be 0-10, got", *petLevel) + } + plugin.SetSimIncludeTrace(*trace) + plugin.SetSimPetLevel(*petLevel) if *matrix { // Matrix default: drop log to keep stdout manageable; explicit @@ -58,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 @@ -76,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 { @@ -92,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) @@ -143,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) { diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md new file mode 100644 index 0000000..f0a133e --- /dev/null +++ b/gogobee_long_expedition_plan.md @@ -0,0 +1,280 @@ +# Long Expeditions — multi-session plan + +> Goal: dungeon expeditions become real multi-day journeys (T1 ≈ 2 days → T5 ≈ 7 days). Player picks **camp supplies** at launch, then watches it play out. Camp pitch/break, elite engagement, and **boss engagement** all become autonomous. Foreground `!camp` / `!fight` survive as overrides, not the expected path. + +## Status @ 2026-05-28 + +**Core track (D1–D7) — DONE.** All phases shipped; v1 of the long-expedition mechanics is live on `long-expeditions-d1`. + +**§6 decisions (2026-05-28) re-opened two threads** that ride after D8: +- **D9** — T1–T3 room-count bump toward the 3–5× target band (D1 landed T1–T3 at ~2×). Sim-first: read T1/T2 day-counts from a fresh corpus *after* D8 lands. +- **D10** — T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone). Bundle with D9 to avoid touching graphs twice. + +**Active work — §8 (J3 caster sim-picker):** D8-a / D8-b / D8-prereq / D8-d-diagnostic shipped; **D8-c (concentration), D8-d-fix (AC floor lift), D8-e (martial regression triage)** queued. D8 is sequentially the gate before D9/D10 — room counts only make sense once the sim accurately scores the classes that will walk through them. + +**Sequence:** D8-c → D8-d-fix → D8-e → D9 → D10. Re-baseline once at the end. + +## 1. Why the current shape is "too short" + +| Tier | Zones | Rooms | Real-time exit | +|------|-------|------|----------------| +| T1 | Goblin Warrens, Crypt Valdris | 6–7 | usually <1 calendar day | +| T2 | Forest Shadows, Sunken Temple | 7–8 | usually 1 day | +| T3 | Manor Blackspire, Underforge | 7–9 | 1–2 days | +| T4 | Underdark, Feywild Crossing | 8–10 (×regions) | 2–3 days | +| T5 | Dragon's Lair, Abyss Portal | 9–10 (×regions) | 2–4 days | + +(Room counts from `internal/plugin/dnd_zone.go:70-134`; tier zones from memory `project_expedition_difficulty`.) + +Because the autopilot walks ~3 rooms / 2h (`expedition_autorun.go:43,53`) and boss/elite stops are manual, a player who pays attention clears T1–T3 in an evening. Briefing/recap UTC ticks fire but rarely matter — the expedition is over before "day 2" ever lands. + +## 2. Target shape + +| Tier | Target duration | New room budget (single-region) | Notes | +|------|-----------------|---------------------------------|-------| +| T1 | 2 days | 12–14 | one autopilot-camp | +| T2 | 3 days | 16–20 | two camps | +| T3 | 4 days | 22–26 | three camps; threat starts to bite | +| T4 | 5–6 days | 28–34 (split across 3–4 regions) | base camp emerges | +| T5 | 7 days | 36–44 (split across 3–4 regions) | full siege/temporal pressure | + +(Numbers are first-draft anchors; class-balance sim drives the actual dial in D7.) + +Duration is measured in **expedition-days advanced by autopilot camp**, not real-time UTC days. Each autopilot night-camp = day++. Real-time UTC still drives the morning briefing DM cadence, but day-count and supply burn become event-driven (camp = sleep = day++ = burn). + +The autopilot decides: +- when to camp (and which camp type) +- whether to engage elites and bosses, or to camp first and retry +- whether to extract early on starvation/HP collapse + +The player decides: +- supply pack purchases at launch (only meaningful pre-expedition choice) +- whether to override (`!camp `, `!fight`, `!expedition extract`) + +## 3. Phasing + +Built across multiple sessions; each phase ships independently with tests and a small DM-side feedback loop. + +### D1 — Length + room-count rework +**Files:** `dnd_zone.go` (per-zone Min/MaxRooms), `dnd_expedition_region.go` (multi-region region sizes), `dnd_zone_run.go:generateRoomSequence` (still terminates Entry → … → Elite → Boss, but with more Explorations). +**Work:** +- Re-pitch Min/MaxRooms per zone to the table in §2. +- Multi-region zones: extend per-region room budgets so the total stays in band when summed. +- Keep the fixed Entry / Trap / Elite / Boss anchors; only Exploration count scales. +- Consider 2 traps + 2 elites for T4–T5 to break up the long middle (deferred decision — see §6). +- Update existing zone graphs in `zone_graph_*.go` for the new lengths. *Auditing whether each zone has a hand-tuned graph or relies on the linear compiler is part of D1 scoping.* + +**Exit criteria:** `expedition-sim` shows mean-room-cleared per zone in the new band; no test fixtures hard-code old counts. + +### D2 — Autopilot camp scheduler + +**D2-a (shipped 2026-05-27):** new `expedition_autocamp.go` with pure `decideAutopilotCamp` + `pitchAutopilotCamp` + dwell-window lifecycle (`shouldSkipAutoRunForCamp`, `breakAutoCampIfDue`). Wired into `tryAutoRun`: skip while inside `minAutoCampDwell` (4h), break + walk past it, post-walk scheduler pitches HP-low rest camps and region-boss base-camp waypoints. CampState gained `AutoPitched` so auto- vs player-pitched lifetimes diverge. Day rollover is still UTC-anchored; D2-b moves it event-anchored. + +**D2-b (shipped 2026-05-27):** event-anchored day rollover. `dnd_expedition_cycle.go` adds `eventAnchoredCutoff` + `isEventAnchored`, splits the briefing body into `nightRolloverBurn` + `nightRolloverDrift` (+ a convenience `processNightCamp` that runs both back-to-back). For expeditions started ≥ cutoff, `deliverBriefing` skips mutators and posts a re-engagement DM; if `last_briefing_at` is older than `nightSafetyNet` (28h) it force-fires `processNightCamp` so a stalled autopilot doesn't freeze the expedition. The autopilot scheduler grew a `Night` flag — `decideAutopilotCamp` sets it when `time.Since(LastBriefingAt) ≥ nightCampWindow` (16h); `pitchAutopilotCamp` then runs the burn → camp → rest → drift sequence in one pitch. Manual `!camp ` runs the same flow when it's the first camp since the last rollover. Legacy UTC-anchored expeditions (start_date < cutoff) keep the original mutator flow via the same staged helpers, preserving rest-before-drift ordering through `processOvernightCamp`. Tests fence cutoff to year 9999 in `TestMain` so existing legacy assertions stand; new tests exercise the night decision, night-pitch rollover, event-anchored skip, and safety-net force. + + +**Files:** new `expedition_autocamp.go`; hooks in `expedition_autorun.go:tryAutoRun`; reuse `dnd_expedition_camp.go:campPitch` / `applyCampRest`. +**Heuristics (in priority order):** +1. **Day-budget pacing** — pitch when rooms-walked-this-day ≥ tier-specific day-room target. Standard if room cleared, rough if not. +2. **Resource gates** — pitch if HP < 35% of max, or supplies projected to cover <1 more day's burn (`exp.Supplies.Current < exp.Supplies.DailyBurn * harshMod`). +3. **Post-elite breath** — auto-pitch a standard camp in the cleared elite room. +4. **Region boss cleared in multi-region zone** — auto-pitch base camp at first eligible site. +5. **Don't pitch when** mid-fight, in a trap/boss room, in a fork-pending state, or already camped. + +Auto-break already happens on move (`autoBreakCampOnMove` in `dnd_expedition_camp.go:401`). Autopilot just needs to *not* break a freshly-pitched camp by immediately walking on — gate by `time.Since(camp.EstablishedAt) > minCampDwell` (15min real-time? or simply: don't auto-walk while camped). + +**Day-rollover semantics change (decided event-anchored):** today, day++ happens at the 06:00 UTC briefing (`dnd_expedition_cycle.go:236`). After D2, the autopilot **night-camp pitch** is the event that fires day++, supply burn, overnight rest, and threat drift — in one atomic rollover the camp scheduler triggers. The 06:00 UTC briefing tick survives only as a re-engagement DM anchor (and a safety net: if for some reason no camp pitched in N hours, force one). Concretely: +- Move the body of `deliverBriefing` (`dnd_expedition_cycle.go:186-`) into a `processNightCamp(exp)` helper called from the autopilot camp scheduler when it pitches a *night* camp (rough/standard/fortified — not mid-day breath stops). +- Distinguish "night camp" (advances day) from "rest stop" (doesn't). Heuristic: a camp pitched at the end of the day-budget room count is night; one pitched purely for HP/SU recovery mid-day is a rest stop. Both apply `applyCampRest`; only night also runs supply burn + day++ + threat drift. +- The existing UTC briefing ticker becomes: if `last_briefing_at` is older than ~20h *and* the user is active, post a "Day N — here's where you stand" re-engagement DM. It calls **no** mutators. +- Manual `!camp ` from the player still does what it does today (apply rest immediately) but **also** counts as a night camp if invoked with no other camp today — keep parity with the autopilot behavior so the override doesn't accidentally stall day advancement. + +**Migration:** in-flight expeditions at deploy time keep their existing 06:00 UTC day++ until they end (fence by `start_date`). New expeditions use the event-anchored model. + +**Exit criteria:** sim shows autopilot pitches the right kind of camp at the right room with no player input; supply economy still survives a 7-day T5. + +### D3 — Autonomous elite + boss engagement +**Files:** `dnd_zone_cmd.go:497-535` (the prev==RoomElite / RoomBoss branch). + +**Shipped 2026-05-27.** `dnd_zone_cmd.go` adds `stopBossSafety` + `bossSafetyGate(uid, exp)` (HP < 80%, supplies < daily burn, exhaustion ≥ 3 — the "active low status" interpretation). The elite/boss doorway branch drops the `prev == RoomBoss || !compact` carve-out: in compact mode bosses fall through to the same `resolveCombatRoom` path elites have used, after the gate clears. `resolveCombatRoom` selects monster + label + loot-drop by `run.CurrentRoomType()` so the same call site handles boss kills (zone.Boss bestiary, "👑 Boss — name down", boss-loot drop, elite-tier threat bump). Loss on auto-resolved boss falls through to the existing player-death narration / forceExtractExpeditionForRunLoss path — no special treatment. + +The walk loop (`dnd_expedition_cmd.go:717`) only breaks at the elite/boss doorway when `!compact`; compact lets the next iteration auto-resolve. `stopBossSafety` is excluded from the rooms-walked tally and its `autopilotFooter` returns "" — `res.final` already carries the held-back line. + +When the gate trips, `tryAutoRun` calls `pitchBossSafetyCamp(exp)` (new in `expedition_autocamp.go`): force-pitches Standard (or Rough fallback) regardless of `decideAutopilotCamp`'s HP threshold or its RoomBoss room-type block, with the same `Night` event-anchored rollover handling. Dwell expires → next tick retries the boss. Foreground `!fight` and foreground `!expedition run` are unchanged: both still stop at the boss doorway for the player who wants to watch. Sim path is unaffected — `stopBossSafety` falls into `expedition_sim.go`'s default branch (tick-a-day, retry next walk). + +**Open question:** do we want a "set autopilot off" toggle for players who actively want to play the boss themselves? My take: default-on, single-flag per expedition (`autopilot_engage`) settable at start or via `!expedition autopilot off`. Defer until D3 lands and we see how it reads. + +**Exit criteria:** sim runs a full T5 expedition launch → boss kill with zero player commands besides `!expedition start`. + +### D4 — DM volume + day surfaces +The big risk: a 7-day T5 with autopilot walking, camping, fighting elites, and killing bosses will spam DMs unless we bundle. The user has already pushed back on recap chatter (`feedback_skip_recaps`). + +**D4-a (shipped 2026-05-27):** autopilot DM bundling. `expedition_autorun.go:tryAutoRun` now drops per-tick auto-walk DMs in compact mode. New surface rules: +- Fork / death / run-complete still DM the walk stream — interactive + climax beats. +- A Night=true camp pitch flushes the day as an end-of-day digest (`renderEndOfDayDigest` in new `expedition_autorun_digest.go`) followed by the camp block. +- Boss-safety camp pitch gets a short "holding before the boss" header + camp block (walk stream dropped). +- Non-night auto-camp (mid-day rest / base-camp waypoint) surfaces the camp block by itself. +- Everything else — uneventful walks, preflight pauses, harvest interrupts — goes silent. +Each successful background walk now logs a `walk` entry (`appendExpeditionLog(... "walk" ...)`) so the digest can count rooms walked from structured logs (the raw `r.stream` narration is no longer persisted across ticks). Digest groups counts of walk/harvest/interrupt and inlines threat/milestone/narrative lines for the prior day (`dayExpeditionLog` helper). `maybeAutoCamp` + `pitchBossSafetyCamp` now return the `autoCampDecision` so `tryAutoRun` can branch on `dec.Night`. + +**D4-b (shipped 2026-05-27):** morning re-engagement DM anchored to user activity, not 06:00 UTC. `fireExpeditionBriefings` now skips event-anchored expeditions whose `last_activity` is older than today's 06:00 UTC threshold (and we're still inside `nightSafetyNet` — stalled-autopilot force-fires still win). New `maybeDeliverDeferredBriefing` runs at the top of `AdventurePlugin.OnMessage` on every inbound message in any room: it stamps `last_activity` (so the next ticker pass sees the player as present, even from non-bot chatter) and posts the deferred briefing if one is owed (CAS-gated by today's 06:00 threshold; pre-06:00 lazy fires are suppressed to avoid double-emit with the ticker sweep that follows). Day-1 inbounds don't trigger a briefing before the first night camp has happened. + +**Remaining work:** +- TwinBee voice + no-recap copy pass on the digest once the shape settles. + +**Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency). + +### D5 — Supplies economics retune + +**D5-a (shipped 2026-05-27):** per-tier pack caps. `dnd_expedition_supplies.go` retires the global `SupplyPackStandardMax`/`SupplyPackDeluxeMax` constants in favor of `supplyPackCaps(tier) (std, dlx int)` — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2). `SupplyPurchase.Validate` is now `Validate(tier ZoneTier)`; both call sites (`!expedition start`, `!resume`) pass the resolved zone's tier. The cap clears `DailyBurn(raw) × intendedDays × 1.3` for every tier under the §2 target durations even with harsh×3 layered on top, so a player who wants to buy for the long shape can. DailyBurn / harsh-multiplier / `phase5BDailyBurnRatePct` are unchanged here — they're a D7 lever, alongside the sim work that should unblock empirical baselining. The help text now describes the cap as "scales by zone tier"; the holiday +1 standard pack still bypasses the cap on purpose (it's a freebie on top). + +> **Note:** the original "Empirical (sim-driven)" path was blocked: under D2-b event-anchored expeditions, `SimRunner.TickDay` calls `deliverBriefing` → `deliverBriefingEventAnchored`, which reads `time.Now().UTC()` for its safety-net check, so synthetic ticks never advance `CurrentDay` and `DaysAtEnd` stays at 0. Re-baselining off real day-counts requires teaching the sim to drive the event-anchored rollover (D7). + +**D5-b (shipped 2026-05-27):** "Pick your loadout" preset prompt at launch. `!expedition start ` with no pack arg now DMs a Lean / Balanced / Heavy menu (sized per zone tier via new `loadoutPurchase(tier, SupplyLoadout)` in `dnd_expedition_supplies.go`); player replies with `!expedition start lean|balanced|heavy` (short forms `l`/`b`/`h` also work). `!resume` got the same treatment. Raw `Ns Md` syntax remains the advanced override — `resolveLoadoutOrParse` in `dnd_expedition_cmd.go` tries a single-token preset first, falls back to `parseSupplyArgs`. Heavy always equals `supplyPackCaps` (the D5-a harsh×3 ceiling); Lean covers intended days at raw burn; Balanced sits between. `parseSupplyArgs("")` still returns 1 standard for any tier — it's no longer reachable from `!expedition start` (the empty path is intercepted to prompt), but the `1s` default is preserved so future callers don't get surprise zero-pack purchases. Help text updated; two existing scenario tests now pass `lean` explicitly, three new tests cover preset resolution + the prompt-vs-start guard. + +**D5-c (shipped 2026-05-27):** Ranger forage wired against the new caps. Audit found `SupplyForageMaxSU = 4` defined in `dnd_expedition_supplies.go` since Phase 12 E1b but never referenced — `ForagedToday` was only ever reset to `false`, never set to `true`, so the §4.2 "Ranger, WIS DC 12, 1d4 SU/day" perk had never actually granted supplies. New helper `applyRangerForage(e, c, rng)` rolls 1d4 SU once per day for Ranger characters, headroom-capped against `Supplies.Max` so a Heavy loadout doesn't overflow its purchased ceiling; non-Rangers and post-roll repeats are no-ops. Fires at the top of `nightRolloverBurn` (before the daily burn) so the +SU lands on today's bag and a "forage" log entry flows into the end-of-day digest via a new `renderEndOfDayDigest` case. No DC roll — accessibility over crunch (`feedback_accessibility_over_dnd_crunch`); the DC-12 gate would've added a fail case with no upside given the new headroom. Average +2.5 SU/day off a Ranger is a quiet Lean-loadout cushion (~1 extra day of T5 late-stage burn over a 7-day run), not a Heavy multiplier. Tests cover ranger-grants, non-ranger no-op, repeat-day no-op, headroom-cap, full-bag-still-stamps-flag, nil-safety. + +**Remaining work (D5-d+):** +- DailyBurn / `phase5BDailyBurnRatePct` revisit once D7 sim measures real elapsed-day counts. + +**Exit criteria:** balanced loadout completes intended-duration expedition in ≥ 85% of sim runs without starvation extraction. + +### D6 — Player-facing surface cleanup + +**D6 (shipped 2026-05-27):** player-facing copy now matches the autopilot-first reality. +- `expeditionHelpText` rewritten around the loop "pick a zone → pick a loadout → `!expedition run` watches it play out"; commands grouped into **Run an expedition** / **Mid-expedition** / **Overrides** with a one-line shape paragraph at the top. +- `!camp` and `!fight` were already absent from the primary `!expedition` help; both are now explicitly listed under **Overrides** and framed as "autopilot covers these — only reach for them if you want manual control." `campHelpText` itself opens with the same override framing so a player who types `!camp` lands on the right mental model. +- `expeditionCmdStatusImpl` (default `!expedition status`) now leads with **Day X / ~Y expected** (driven by new `expeditionTargetDays(tier ZoneTier)` in `dnd_expedition_supplies.go` — T1=2 → T5=7, the §2 anchor table), adds a **Rooms: N / Total in this region** line under the region marker (sourced from `getActiveZoneRun(...).CurrentRoom/TotalRooms`), and appends a **Recent:** block with the last 3 log entries (summary fallback to flavor). Supplies and threat lines unchanged; the `--debug` view is untouched. +- No new public command surface, no schema change, no test churn (no existing test asserted on the rewritten strings); full `go test ./...` green. + +### D7 — Sim + class re-baseline + +**D7-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 ` 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 in the core D7 scope. Two follow-up phases (D9 room-count bump, D10 T4/T5 anchor variety) were opened by the 2026-05-28 §6 resolution and are sequenced after D8. See §9 below. + +## 8. J3 caster-picker rework (next session) + +**Why split out:** D7-d corpus confirmed the martial/caster gap is class-roster + sim-picker driven, not autopilot/burn/pacing driven. Long-expedition mechanics are settled; the trailer fix lives in the sim picker, not the expedition shell. + +**D8-a (shipped 2026-05-27, this session):** quick data-layer cleanup. Bard L1 prepared list gained `thunderwave`; L2 gained `heat_metal`. Cleric L1 gained `inflict_wounds`. `shatter` overlay Classes broadened to mage/bard/sorcerer (overlay was already unioning via `mergeClassList`, so this is defensive). New `simPickSpiritualWeapon` runs before `simPickSpell` in `simPickCombatAction`: cleric with an L2 slot + spiritual_weapon prepared + `sess.Statuses.BuffSpiritProc == 0` opens the fight with it, picking up the existing `spiritWeaponStrike` per-round bonus-action attack. **Measured impact (L10, n=100/zone, heavy preset):** cleric 21.0 → 23.2% (+2.2pp), bard 34.2 → 32.8% (within noise). T3+ wall unchanged — the data fixes are correct but the picker math is what's binding. Files touched: `internal/plugin/dnd_spells_data.go:192`, `internal/plugin/dnd_spells.go:822,881,884`, `internal/plugin/expedition_sim.go:simPickCombatAction + new simPickSpiritualWeapon`. All green. + +**D8-b (implemented 2026-05-27, **inert** — see D8-prereq below): sim picker upcasting.** `simPickSpell` (`internal/plugin/expedition_sim.go:933`) now enumerates one candidate per available slot ≥ native for every prepared damage spell, scored via `spellExpectedDamage(sp, slotLevel, c.Level)`. Cantrips contribute a single slot-0 candidate. Tie-break: highest slot first, then highest expDmg. When the winning slot exceeds the spell's native level, the picker returns `" --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 (SHIPPED 2026-05-28):** concentration-damage multiplier. `spellExpectedDamage` (`internal/plugin/dnd_class_balance.go:280`) multiplies expected damage by 3 when `sp.Concentration && sp.Effect ∈ {EffectDamageSave, EffectDamageAuto}`. `EffectDamageAttack` excluded by design — single-target attack-roll spells aren't typically concentration; hex-style cases get their lift from mods. Test: `TestSpellExpectedDamage_ConcentrationMultiplier`. + +**Measured impact (d8c_corpus.jsonl vs d8prereq, full diff in `sim_results/d8c_findings.md`):** every class within ±2.4pp on the leaderboard (1σ ≈ 2.2pp on n=500) — *macro-leaderboard unchanged*. Picker swap landed where expected at **T3 manor_blackspire**: bard +11pp (32→43), mage +10pp (71→81), sorcerer +9pp (60→69), druid +5pp manor + +1pp underdark. T4/T5 caster wall unchanged (still 0% for every caster except druid 0→1 underdark) — confirms [[project_d8d_diagnostic]]: past T3 the binding constraint is HP+AC, not picker quality. + +**Side discovery (engine modelling gap):** cleric stayed flat across all zones despite having spirit_guardians (L3, concentration, save) prepared — and warlock dropped 6pp at manor. Likely the sim's `EffectDamageSave` path resolves the AOE save *once* rather than re-ticking per round while concentration holds, so the multiplier overrates spells the engine then under-delivers on. **Filed as a separate follow-up** (concentration re-tick gap in `SimulateCombat`/turn-engine); does *not* unwind D8-c — bard/mage/sorcerer/druid picker swaps are clean and within plan. Cross-link [[project_concentration_retick_gap]] when written. + +**D8-d (DIAGNOSTIC SHIPPED 2026-05-28):** T4 caster wall diagnosed; no code change landed. Writeup: `sim_results/d8d_findings.md`. **Hypothesis disambiguation across cleric/bard/mage/sorcerer/warlock/druid L10 underdark (n=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 + +- **Existing in-flight expeditions** at deploy time. Either fence by `start_date` (old expeditions finish under old rules) or write a one-shot migration that retunes their room counts. Lean: fence — these are typically <2 days. +- **The 24h zone-run inactivity timeout** (`dnd_zone_run.go:254`) — must be raised, or the autopilot must self-poke `last_action_at` per tick. With multi-day expeditions, a quiet stretch overnight is now expected. +- **Idle reaper** (commit `0f72484`, see `expedition_autorun.go` references) — already holds streak on autopilot; verify it tolerates 5-day expeditions. +- **Babysit perk** — `BabysitSafeRest` already upgrades Standard → Fortified at rest (`dnd_expedition_camp.go:241-245`); confirm interaction with autopilot picking Standard (no change needed; the upgrade fires inside `applyCampRest`). +- **Multi-region travel still unwired in autopilot** (memory `project_multiregion_travel_unwired`). For T4/T5 to actually take 5–7 days, autopilot **must** auto-advance regions on region-clear. D2/D3 covers this implicitly but call it out — likely a small dedicated commit. +- **Pet arrival** (memory `project_pet_event_reachability`) — emergence seam is at run-complete; longer expedition just delays the roll, no code change needed. +- **Twinbee voice + secret NPC buffs** (memories `feedback_twinbee_voice`, `feedback_npc_buffs_are_secret`) — none of the new copy can break these. + +## 5. Schema impact + +Likely none. Existing columns cover it: +- `current_day` → autopilot increments via `advanceExpeditionDay`. +- `camp_json`, `supplies_json`, `region_state`, `threat_*` already in place. +- One **possible** new column: `expected_days` int (set at launch from zone tier + loadout) so status/end-of-day DMs can render "Day 3/5". Cheaper to derive than store — defer. + +## 6. Open questions for next session + +1. ~~Day-advance semantics~~ — **decided 2026-05-27: event-anchored.** day++ fires on autopilot camp-pitch (autopilot night-camp = sleep = day burn + briefing). UTC clock becomes a re-engagement-DM anchor only, not a state mutator. See §3-D2 for implementation impact. +2. ~~Boss autopilot default~~ — **decided 2026-05-28: default ON.** D3 already ships compact-mode boss auto-resolve gated by `bossSafetyGate` (HP/SU/exhaustion). No opt-out flag for now; revisit if players want manual final-blow framing. +3. ~~Per-tier room counts: sim-first vs ship-and-refine~~ — **decided 2026-05-28: sim-first refinement in D7-d.** D1 shipped a guess; we're much closer now. Measured vs prior: T1 ~2x (7→16), T2 ~1.8x (9→19), T3 ~2x (11→35), T4 ~3x (10→46), T5 ~3x (13→47). Target band is 3–5x; T1–T3 likely want another bump but defer until the D7-d sim corpus reads day-counts. Don't churn graphs blindly. +4. ~~Extra anchor rooms (T4/T5)~~ — **decided 2026-05-28: yes, add anchor-room variety in T4/T5.** Second elite + second trap mid-zone (not just rely on multi-region/patrols/threat). Slot into D7-d alongside the room-count retune so we don't re-touch the graphs twice. +5. ~~Mobile/AFK / DM cadence~~ — **decided in D4-a (already shipped):** event-anchored to in-game day. Night-camp pitch flushes the day as a digest; fork/death/run-complete still DM the walk stream; mid-day rests/waypoints surface only the camp block; per-tick auto-walks silent. No real-time pacing knob. + +## 7. Sequence + +**Original D1–D7 plan (all shipped):** D1 → D2 → D3 → D5 → D4 → D6 → D7. + +**Current sequence (post-2026-05-28):** D8-c → D8-d-fix → D8-e → D9 → D10 → final re-baseline. + +| Phase | What | Blocks | +|------|------|--------| +| D8-c | Concentration-damage multiplier in `spellExpectedDamage` | D8-d-fix read | +| D8-d-fix | Lift `computeAC` caster floors (cleric/druid 3→5, bard/warlock 1→3, mage/sorcerer 0→2) | D8-e read | +| D8-e | Diff turn-engine vs inline-sim for paladin/fighter/ranger on T4/T5; decide whether martial regression is real or a "fix" target | D9 (need stable class baseline) | +| D9 | T1–T3 room-count bump toward 3–5× band, sim-validated against fresh corpus | D10 | +| D10 | T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone) | final re-baseline | +| — | One final L10 + L12 corpus pass to confirm tier day-counts + class spread | — | + +## 9. Post-§6 follow-ups (D9 / D10) + +### D9 — T1–T3 room-count bump + +**Why now:** §6 fork 3 resolved to "sim-first refinement"; D7-d corpus shows T3 hitting only median 3 days (target 4) and T1/T2 weren't even measured because the corpus only matrixed T3+. T1–T3 graphs landed at ~2× prior length in D1; target band is 3–5×. + +**Plan:** +- Pull T1/T2 day-counts from a post-D8 corpus run (matrix needs to include `goblin_warrens`, `crypt_valdris`, `forest_shadows`, `sunken_temple` at L4/L6). +- Lift graph sizes only if median days < tier target (T1=2, T2=3, T3=4). Don't churn graphs that already hit target. +- Reuse the D1-a..e fork/anchor/merge patterns (`internal/plugin/zone_graph_*.go`); preserve existing topology — only add Exploration nodes + the second-anchor slot D10 will fill. +- One-shot bootstrap: existing in-flight expeditions fence by `start_date` per §4 (consistent with D1 deploy). + +**Risk:** if D8-d-fix lifts caster T3 clears materially, day-counts move too; measure after D8 stabilizes, not before. + +### D10 — T4/T5 anchor-room variety + +**Why now:** §6 fork 4 resolved "yes". T4/T5 zones run 30–50 rooms with only one Trap + one Elite anchor; the long middle reads same-y. Bundle with D9 so we touch each `zone_graph_*.go` once. + +**Plan:** +- Add a second Trap + second Elite anchor mid-zone for all T4/T5 zones (`underdark`, `feywild_crossing`, `dragons_lair`, `abyss_portal`). +- Place the second Elite at the region-boundary spurs (good narrative justification — region-guardian rather than zone-guardian) so multi-region travel finally gets a teeth-y interrupt. +- Place the second Trap on the long Exploration runs of fork branches, not on the merge node (keeps fork-choice loot/risk asymmetric). +- Preserve longest-path length within current band — added anchors replace Exploration nodes, not append. + +**Validation:** same final corpus pass as D9. Look for boss-clear rate dipping by <5pp (anchor difficulty is intended, not punishing) and median day-count stable. + +### Cross-links picked up here +- [[project_multiregion_travel_unwired]] — boss-clear → next-region auto-advance is wired (verified 2026-05-27); D10's region-boundary elites give it something to interrupt. +- [[project_j3_caster_picker]] / [[project_d8d_diagnostic]] — D8 gates the whole sequence. +- [[project_d8prereq_baseline]] — the corpus to diff against, not d7d. diff --git a/internal/bot/dispatch.go b/internal/bot/dispatch.go index e1e4925..a2d2351 100644 --- a/internal/bot/dispatch.go +++ b/internal/bot/dispatch.go @@ -2,6 +2,8 @@ package bot import ( "log/slog" + "os" + "strings" "sync" "gogobee/internal/plugin" @@ -9,13 +11,24 @@ import ( // Registry manages plugin registration and event dispatch. type Registry struct { - mu sync.RWMutex - plugins []plugin.Plugin + mu sync.RWMutex + plugins []plugin.Plugin + ignoredBots map[string]struct{} } -// NewRegistry creates an empty plugin registry. +// NewRegistry creates an empty plugin registry. Senders listed in the +// IGNORED_BOTS env var (comma-separated full Matrix user IDs, e.g. +// "@pete:parodia.dev") are dropped before any plugin sees them. func NewRegistry() *Registry { - return &Registry{} + ignored := make(map[string]struct{}) + if raw := os.Getenv("IGNORED_BOTS"); raw != "" { + for _, u := range strings.Split(raw, ",") { + if u = strings.TrimSpace(u); u != "" { + ignored[u] = struct{}{} + } + } + } + return &Registry{ignoredBots: ignored} } // Register adds a plugin to the registry. @@ -42,6 +55,9 @@ func (r *Registry) Init() error { // DispatchMessage sends a message context to all plugins in order. func (r *Registry) DispatchMessage(ctx plugin.MessageContext) { + if _, ok := r.ignoredBots[string(ctx.Sender)]; ok { + return + } r.mu.RLock() defer r.mu.RUnlock() for _, p := range r.plugins { @@ -71,6 +87,9 @@ func (r *Registry) safeOnMessage(p plugin.Plugin, ctx plugin.MessageContext) { // DispatchReaction sends a reaction context to all plugins in order. func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) { + if _, ok := r.ignoredBots[string(ctx.Sender)]; ok { + return + } r.mu.RLock() defer r.mu.RUnlock() for _, p := range r.plugins { diff --git a/internal/db/db.go b/internal/db/db.go index 0acb3f2..b985f47 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -333,6 +333,9 @@ func runMigrations(d *sql.DB) error { // engages when a fork / elite / boss / supply pinch actually // needs a decision. CAS-claim on this column gates re-entry. `ALTER TABLE dnd_expedition ADD COLUMN last_autorun_at DATETIME`, + // URL link previews now post the page's og:image/twitter:image + // thumbnail; cache it alongside the title/description. + `ALTER TABLE url_cache ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -1124,6 +1127,7 @@ CREATE TABLE IF NOT EXISTS url_cache ( url TEXT PRIMARY KEY, title TEXT DEFAULT '', description TEXT DEFAULT '', + image_url TEXT NOT NULL DEFAULT '', cached_at INTEGER DEFAULT (unixepoch()) ); @@ -2041,15 +2045,15 @@ func SeedSchedulerDefaults(d *sql.DB) error { name string cron string }{ - {"prefetch", "5 0 * * *"}, // 00:05 daily - {"maintenance", "0 3 * * *"}, // 03:00 daily - {"wotd", "0 8 * * *"}, // 08:00 daily - {"holidays", "0 7 * * *"}, // 07:00 daily - {"releases", "0 9 * * 1"}, // 09:00 Monday - {"birthday_check", "0 6 * * *"}, // 06:00 daily - {"anime_releases", "0 10 * * *"},// 10:00 daily - {"movie_releases", "0 11 * * *"},// 11:00 daily - {"concert_digest", "0 12 * * 0"},// 12:00 Sunday + {"prefetch", "5 0 * * *"}, // 00:05 daily + {"maintenance", "0 3 * * *"}, // 03:00 daily + {"wotd", "0 8 * * *"}, // 08:00 daily + {"holidays", "0 7 * * *"}, // 07:00 daily + {"releases", "0 9 * * 1"}, // 09:00 Monday + {"birthday_check", "0 6 * * *"}, // 06:00 daily + {"anime_releases", "0 10 * * *"}, // 10:00 daily + {"movie_releases", "0 11 * * *"}, // 11:00 daily + {"concert_digest", "0 12 * * 0"}, // 12:00 Sunday } stmt, err := d.Prepare(`INSERT OR IGNORE INTO scheduler_config (job_name, cron_expr) VALUES (?, ?)`) diff --git a/internal/plugin/adv2_scenario_test.go b/internal/plugin/adv2_scenario_test.go index 3b619d3..b1511e5 100644 --- a/internal/plugin/adv2_scenario_test.go +++ b/internal/plugin/adv2_scenario_test.go @@ -221,10 +221,9 @@ func TestAdv2Scenario_ExpeditionCryptValdris(t *testing.T) { t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter) } - // Backdate start so deliverBriefing's same-day guard passes. - if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil { - t.Fatalf("backdate: %v", err) - } + // Backdate start so deliverBriefing's same-day guard passes, and to + // before eventAnchoredCutoff so the legacy mutator path still fires. + rewindToLegacyAnchor(t, exp) // Drive 3 daily briefings — verify supply burn + day advance. now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC) @@ -305,7 +304,7 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) { p := &AdventurePlugin{euro: euro} // Forage-friendly expedition. - if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows"); err != nil { + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows lean"); err != nil { t.Fatalf("expedition start: %v", err) } exp, _ := getActiveExpedition(uid) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 6ed76c6..3e8cda3 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -220,6 +220,16 @@ func (p *AdventurePlugin) Init() error { // migration walks dnd_character once at startup; idempotent via // JobCompleted gate. bootstrapPhase5BHPRefresh() + // J3 D8-d-fix: caster HP lift (casterHPMult in dnd.go). Refresh + // existing caster rows once at startup so the lift reaches live + // players without waiting for level-up. + bootstrapCasterHPRefresh() + // 2026-06-18 caster-aid: backfill default spells that postdate a + // character's roll (ensureSpellsForCharacter only seeds an empty book), + // and a one-off pet gift for an endgame player who never got the morning + // arrival roll. Both idempotent via JobCompleted gates. + bootstrapCasterSpellBackfill() + bootstrapGrantStarterPet() // Phase R1 orphan-archive used to run here on every Init, but it // over-archived: it treats any active dnd_zone_run row not linked to // an active expedition as a legacy `!adventure dungeon` orphan, which @@ -279,6 +289,12 @@ func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil } // ── Message Dispatch ───────────────────────────────────────────────────────── func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { + // D4-b: lazy morning briefing. When the 06:00 UTC ticker skips an idle + // player's event-anchored expedition, the briefing fires here on their + // next inbound message. Fast-paths to a no-op for users with no active + // expedition. + p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC()) + // 0. D&D layer commands (Phase 1 — work in rooms and DMs) if p.IsCommand(ctx.Body, "setup") { return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup")) @@ -853,7 +869,7 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact case "pet_type": return p.resolvePetType(ctx) case "pet_name": - return p.resolvePetName(ctx) + return p.resolvePetName(ctx, interaction) } return nil } diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go index 0ceb4b6..1f86e57 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -98,7 +98,7 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+ "Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+ " • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+ - " • Standard camps act like fortified ones — rest deeply, no need for boss-cleared rooms\n"+ + " • Standard camps act like fortified ones — rest deeply, no need to have downed the zone boss\n"+ " • Rival duels declined on your behalf\n\n"+ "`!adventure babysit week` — 7 days of service\n"+ "`!adventure babysit month` — 30 days of service\n"+ diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index e094946..3838d67 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -311,7 +311,30 @@ func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool { } func (c *AdventureCharacter) HasActedToday() bool { - return c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0 + if c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0 { + return true + } + // DnD-side flows (expedition / zone / rest / autopilot) don't touch + // the legacy action counters; they credit the day via LastActionDate + // instead. Honor that so a player who ran an expedition all day and + // extracted before midnight still counts as having acted. + return c.LastActionDate == time.Now().UTC().Format("2006-01-02") +} + +// markActedToday stamps the player's LastActionDate to today so the +// midnight reset credits the day. Safe to call on every DnD-side action; +// no-ops if the date is already today. +func markActedToday(userID id.UserID) { + today := time.Now().UTC().Format("2006-01-02") + c, err := loadAdvCharacter(userID) + if err != nil || c == nil { + return + } + if c.LastActionDate == today { + return + } + c.LastActionDate = today + _ = saveAdvCharacter(c) } func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool { diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go index f3f4825..e1bf26b 100644 --- a/internal/plugin/adventure_pets.go +++ b/internal/plugin/adventure_pets.go @@ -206,6 +206,25 @@ func petShouldArrive(pet PetState, house HouseState) bool { return rand.Float64() < 0.30 } +// maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player +// surfaces from an expedition (voluntary extract, abandon, or a survived +// forced extraction) or revives after death. The arrival roll lives on the +// emergence seam — not the legacy 08:00 overworld morning DM — because +// expedition players are almost never in the overworld at the scheduled hour, +// so the morning roll never reached them. Story beat: while the player was +// underground, an animal wandered into the empty house looking for food. +// +// Safe to call unconditionally on any emergence: petShouldArrive gates on +// house tier / not-yet-arrived, and petArrivalDM won't clobber an existing +// pending interaction. +func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) { + pet, _ := loadPetState(userID) + house, _ := loadHouseState(userID) + if petShouldArrive(pet, house) { + p.petArrivalDM(userID) + } +} + // petArrivalDM sends the initial "there's an animal in your house" DM. func (p *AdventurePlugin) petArrivalDM(userID id.UserID) { // Don't overwrite an existing pending interaction @@ -310,8 +329,11 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error { article, titleCase(petType))) } -// resolvePetName handles naming the pet. -func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error { +// resolvePetName handles naming the pet. The dispatcher (handlePendingReply) +// has already deleted the pending interaction and passes it in, so this must +// NOT re-load from p.pending — doing so previously made every adoption fail +// silently (the carried PetType was lost and the save never ran). +func (p *AdventurePlugin) resolvePetName(ctx MessageContext, interaction *advPendingInteraction) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() @@ -321,20 +343,15 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Failed to load your character.") } - val, ok := p.pending.LoadAndDelete(string(ctx.Sender)) - if !ok { - return nil - } - pi := val.(*advPendingInteraction) - data := pi.Data.(*advPendingPetName) + data := interaction.Data.(*advPendingPetName) name := strings.TrimSpace(ctx.Body) if len(name) == 0 || len(name) > 30 { - p.pending.Store(string(ctx.Sender), pi) + p.pending.Store(string(ctx.Sender), interaction) return p.SendDM(ctx.Sender, "Name must be 1-30 characters. Try again.") } if !petNameValid.MatchString(name) { - p.pending.Store(string(ctx.Sender), pi) + p.pending.Store(string(ctx.Sender), interaction) return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.") } diff --git a/internal/plugin/adventure_pets_dispatch_test.go b/internal/plugin/adventure_pets_dispatch_test.go new file mode 100644 index 0000000..6e3b206 --- /dev/null +++ b/internal/plugin/adventure_pets_dispatch_test.go @@ -0,0 +1,51 @@ +package plugin + +import ( + "testing" + "time" + + "maunium.net/go/mautrix/id" +) + +// TestResolvePetName_ThroughDispatcher is a regression test for the bug where +// resolvePendingInteraction deleted the pending entry before dispatch, while +// resolvePetName then tried to LoadAndDelete it again — always missing, so the +// adoption silently no-op'd and no pet was ever persisted. The fix passes the +// already-loaded interaction into resolvePetName. This test drives the real +// dispatcher path (resolvePendingInteraction) to lock that in. +func TestResolvePetName_ThroughDispatcher(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@pet-name-dispatch:example") + if err := createAdvCharacter(uid, "petnamer"); err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{} + interaction := &advPendingInteraction{ + Type: "pet_name", + Data: &advPendingPetName{PetType: "dog"}, + ExpiresAt: time.Now().Add(time.Hour), + } + p.pending.Store(string(uid), interaction) + + if err := p.resolvePendingInteraction(MessageContext{Sender: uid, Body: "Pepper"}, interaction); err != nil { + t.Fatal(err) + } + + pet, err := loadPetState(uid) + if err != nil { + t.Fatal(err) + } + if !pet.HasPet() { + t.Fatalf("expected pet to be adopted; got %+v", pet) + } + if pet.Name != "Pepper" { + t.Errorf("pet name = %q, want Pepper", pet.Name) + } + if pet.Type != "dog" { + t.Errorf("pet type = %q, want dog", pet.Type) + } + if !pet.Arrived || pet.Level != 1 { + t.Errorf("pet arrived=%v level=%d, want true/1", pet.Arrived, pet.Level) + } +} diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 7988f3f..06acabb 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -82,6 +82,12 @@ func (p *AdventurePlugin) sendMorningDMs() { if err := p.SendDM(char.UserID, text); err != nil { slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err) } + + // Emergence seam (death case): a player who died underground + // "comes home" on respawn. This is the deferred half of the + // emergence roll — survived extractions roll at their exit + // site; deaths roll here. See maybeRollPetArrivalOnEmerge. + p.maybeRollPetArrivalOnEmerge(char.UserID) } // Babysitting: pet-care trickle (no harvest actions; safe-rest perk @@ -133,13 +139,12 @@ func (p *AdventurePlugin) sendMorningDMs() { continue } - // Pet arrival check (fires before normal morning DM) - house, _ := loadHouseState(char.UserID) + // Pet arrival no longer rolls here. The 08:00 overworld morning DM + // is skipped for anyone underground (expedition gate above), so it + // never reached expedition players. Arrival now fires on the + // emergence seam — see maybeRollPetArrivalOnEmerge, called from the + // extract/abandon/forced-extract and respawn paths. pet, _ := loadPetState(char.UserID) - if petShouldArrive(pet, house) { - p.petArrivalDM(char.UserID) - continue - } // Morning pet event petEvent := petMorningEvent(pet) @@ -391,7 +396,6 @@ func (p *AdventurePlugin) midnightTicker() { } func (p *AdventurePlugin) midnightReset() error { - // Send idle shame DMs to players who didn't act chars, err := loadAllAdvCharacters() if err != nil { return fmt.Errorf("load chars: %w", err) @@ -400,79 +404,89 @@ func (p *AdventurePlugin) midnightReset() error { today := time.Now().UTC().Format("2006-01-02") yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") + // Unified activity oracle — same source the daily report uses. Unions + // adventure_activity_log + dnd_zone_run + dnd_expedition_log, so + // background-autopilot walks, expedition extracts/completions, and any + // subsystem that logged a beat all count as "something happened on this + // player's behalf." LastActionDate alone misses the autopilot path + // (dnd_zone_cmd.go gates markActedToday on !compact to keep autopilot + // out of streak credit) — without this oracle, a player who let the + // autopilot run a full expedition gets shamed at midnight. + todayActs, _ := loadAdvDailyActivity(today) + yesterdayActs, _ := loadAdvDailyActivity(yesterday) + dmsSent := 0 for _, char := range chars { - if !char.HasActedToday() { - // If the player died today or yesterday, they couldn't act — no shame, - // no streak reset. This covers both currently-dead players and players - // who were just revived at midnight (Alive already flipped to true by - // the reminder loop before midnightReset runs). - if char.LastDeathDate == today || char.LastDeathDate == yesterday { - continue - } + // Died inside the window — no shame, no streak change. Covers both + // currently-dead players and players revived at midnight (Alive + // already flipped to true by the reminder loop before this runs). + if char.LastDeathDate == today || char.LastDeathDate == yesterday { + continue + } - // An active expedition — or a turn-based fight locked open across - // midnight — counts as activity. Both track their own action flow - // (zone/harvest/combat/transit/extract) and never touch the legacy - // CombatActionsUsed/HarvestActionsUsed counters, so HasActedToday() - // reports false. Treat them like the acted-today branch below: - // advance the streak and bail out (no idle-shame, no streak decay). - busy := false - if exp, err := getActiveExpedition(char.UserID); err != nil { - slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err) - } else if exp != nil { - busy = true - } - if !busy && hasActiveCombatSession(char.UserID) { - busy = true - } - if busy { - if char.LastActionDate == yesterday || char.LastActionDate == today { - char.CurrentStreak++ - } else { - char.CurrentStreak = 1 - } - if char.CurrentStreak > char.BestStreak { - char.BestStreak = char.CurrentStreak - } - char.LastActionDate = today - _ = saveAdvCharacter(&char) - continue - } + // Player-initiated engagement — only this credits the streak. + // LastActionDate is stamped at action time by markActedToday + !rest; + // the legacy counters get bumped by the legacy CanDo... paths. + engaged := char.LastActionDate == today || char.LastActionDate == yesterday || + char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0 - // Jitter between DMs to avoid Matrix rate limits - if dmsSent > 0 { - time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) - } - dmsSent++ - - // Idle shame DM - text := renderAdvIdleShameDM(char.UserID) - if char.CurrentStreak > 0 { - oldStreak := char.CurrentStreak - char.CurrentStreak /= 2 - char.StreakDecayed = true - text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak) - if char.CurrentStreak > 0 { - text += " — not all is lost." - } - _ = saveAdvCharacter(&char) - } - if err := p.SendDM(char.UserID, text); err != nil { - slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err) - } - } else { - // Update streak — LastActionDate was set at action time + if engaged { if char.LastActionDate == yesterday || char.LastActionDate == today { char.CurrentStreak++ } else { - // Gap in activity — start fresh + // Legacy-only path: counters bumped but LastActionDate stale. + // Without this fall-through the streak would reset to 1 every + // night even with continuous play. char.CurrentStreak = 1 } if char.CurrentStreak > char.BestStreak { char.BestStreak = char.CurrentStreak } + char.LastActionDate = today _ = saveAdvCharacter(&char) + continue + } + + // Activity happened on the player's behalf without an explicit tap — + // autopilot walked rooms, expedition log gained beats, a fight session + // is still locked open. Hold the streak: no bump, no shame, no decay. + // The player engaged earlier to kick this off; autopilot is a feature, + // not a way to game streaks, and absence of taps shouldn't strip + // progress they earned through real play. + if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 { + continue + } + // Safety net for live state the activity logs don't reflect yet + // (e.g. an active expedition that's been quiet today, or a combat + // session locked open across midnight without a log append). + if exp, err := getActiveExpedition(char.UserID); err != nil { + slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err) + } else if exp != nil { + continue + } + if hasActiveCombatSession(char.UserID) { + continue + } + + // Truly idle — shame DM + streak halve. + if dmsSent > 0 { + time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) + } + dmsSent++ + + text := renderAdvIdleShameDM(char.UserID) + if char.CurrentStreak > 0 { + oldStreak := char.CurrentStreak + char.CurrentStreak /= 2 + char.StreakDecayed = true + text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak) + if char.CurrentStreak > 0 { + text += " — not all is lost." + } + _ = saveAdvCharacter(&char) + } + if err := p.SendDM(char.UserID, text); err != nil { + slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err) } } @@ -490,6 +504,12 @@ func (p *AdventurePlugin) midnightReset() error { return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr) } + // Clear the one-day pet morning-defense buff so it re-rolls fresh each + // morning (briefing or overworld DM) instead of leaking permanently. + if err := resetAllPetMorningDefense(); err != nil { + slog.Error("adventure: failed to reset pet morning defense", "err", err) + } + // Prune expired buffs if err := pruneAdvExpiredBuffs(); err != nil { slog.Error("adventure: failed to prune expired buffs", "err", err) diff --git a/internal/plugin/adventure_scheduler_test.go b/internal/plugin/adventure_scheduler_test.go new file mode 100644 index 0000000..fa88dde --- /dev/null +++ b/internal/plugin/adventure_scheduler_test.go @@ -0,0 +1,145 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// TestMidnightReset_Branching exercises the three idle-reaper branches: +// - engaged: LastActionDate stamped today/yesterday → streak bumps +// - activity-without-tap: autopilot/background activity logged today but +// no LastActionDate stamp → streak holds, no shame DM, no decay +// - truly idle: nothing anywhere → streak halves, StreakDecayed=true +// +// Regression guard for the autopilot bug: a player who let the background +// auto-run walk an expedition all day used to get shamed at midnight because +// markActedToday is gated on !compact at dnd_zone_cmd.go. +func TestMidnightReset_Branching(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + today := time.Now().UTC().Format("2006-01-02") + yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") + + mk := func(uid, lastAction string, streak int) id.UserID { + u := id.UserID(uid) + if err := createAdvCharacter(u, uid); err != nil { + t.Fatalf("createAdvCharacter %s: %v", uid, err) + } + c, err := loadAdvCharacter(u) + if err != nil { + t.Fatalf("load %s: %v", uid, err) + } + c.LastActionDate = lastAction + c.CurrentStreak = streak + c.BestStreak = streak + if err := saveAdvCharacter(c); err != nil { + t.Fatalf("save %s: %v", uid, err) + } + return u + } + + engaged := mk("@engaged:example", yesterday, 5) + autopilot := mk("@autopilot:example", "2020-01-01", 7) + idle := mk("@idle:example", "2020-01-01", 8) + + // Simulate autopilot activity: a legacy activity-log row dated today. + // loadAdvDailyActivity unions this in, so the reaper sees the player as + // "had activity" without their LastActionDate being current. + if _, err := db.Get().Exec( + `INSERT INTO adventure_activity_log + (user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key, logged_at) + VALUES (?, 'zone', 'Test Zone', 'in_progress', 0, 0, '', CURRENT_TIMESTAMP)`, + string(autopilot), + ); err != nil { + t.Fatalf("insert activity row: %v", err) + } + + p := &AdventurePlugin{} + if err := p.midnightReset(); err != nil { + t.Fatalf("midnightReset: %v", err) + } + + // Engaged → streak bumped, LastActionDate restamped to today, no decay. + if c, _ := loadAdvCharacter(engaged); c != nil { + if c.CurrentStreak != 6 { + t.Errorf("engaged: streak = %d, want 6", c.CurrentStreak) + } + if c.LastActionDate != today { + t.Errorf("engaged: LastActionDate = %q, want %q", c.LastActionDate, today) + } + if c.StreakDecayed { + t.Error("engaged: StreakDecayed = true, want false") + } + } + + // Autopilot → hold. Streak unchanged, LastActionDate stays stale, no decay. + if c, _ := loadAdvCharacter(autopilot); c != nil { + if c.CurrentStreak != 7 { + t.Errorf("autopilot: streak = %d, want 7 (held)", c.CurrentStreak) + } + if c.StreakDecayed { + t.Error("autopilot: StreakDecayed = true, want false (shamed by mistake)") + } + if c.LastActionDate == today { + t.Errorf("autopilot: LastActionDate restamped to today — autopilot must not credit streak via LastActionDate") + } + } + + // Idle → shame DM (no-op without Matrix client) + streak halved + decay flagged. + if c, _ := loadAdvCharacter(idle); c != nil { + if c.CurrentStreak != 4 { + t.Errorf("idle: streak = %d, want 4 (halved from 8)", c.CurrentStreak) + } + if !c.StreakDecayed { + t.Error("idle: StreakDecayed = false, want true") + } + } +} + +// TestMidnightReset_DeathWindowSkips: a player who died today/yesterday is +// left untouched — no shame, no streak change, no decay flag. +func TestMidnightReset_DeathWindowSkips(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + today := time.Now().UTC().Format("2006-01-02") + + u := id.UserID("@dead:example") + if err := createAdvCharacter(u, "dead"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + c, _ := loadAdvCharacter(u) + c.LastActionDate = "2020-01-01" + c.CurrentStreak = 10 + c.BestStreak = 10 + c.LastDeathDate = today + if err := saveAdvCharacter(c); err != nil { + t.Fatalf("save: %v", err) + } + + p := &AdventurePlugin{} + if err := p.midnightReset(); err != nil { + t.Fatalf("midnightReset: %v", err) + } + + got, _ := loadAdvCharacter(u) + if got.CurrentStreak != 10 { + t.Errorf("dead: streak = %d, want 10 (untouched)", got.CurrentStreak) + } + if got.StreakDecayed { + t.Error("dead: StreakDecayed = true, want false") + } +} diff --git a/internal/plugin/bestiary_srd.go b/internal/plugin/bestiary_srd.go index 055fd33..c7d48c0 100644 --- a/internal/plugin/bestiary_srd.go +++ b/internal/plugin/bestiary_srd.go @@ -83,18 +83,19 @@ var srdProfiles = map[string]SRDProfile{ {Name: "Scourge", AttackBonus: 9, Damage: 13}, {Name: "Scourge", AttackBonus: 9, Damage: 12}, }}, - "boss_thornmother": {Attacks: []SRDAttack{ // Attack 18 → ~23 - {Name: "Thorned Lash", AttackBonus: 8, Damage: 12}, - {Name: "Thorned Lash", AttackBonus: 8, Damage: 11}, + "boss_thornmother": {Attacks: []SRDAttack{ // D8-f #2: 2→3 lashes, ~23→~36 (faceroll → band) + {Name: "Thorned Lash", AttackBonus: 9, Damage: 13}, + {Name: "Thorned Lash", AttackBonus: 9, Damage: 12}, + {Name: "Thorned Lash", AttackBonus: 9, Damage: 11}, }}, - "boss_infernax": {Attacks: []SRDAttack{ // Attack 38 → ~49 - {Name: "Bite", AttackBonus: 11, Damage: 19}, - {Name: "Claw", AttackBonus: 11, Damage: 15}, - {Name: "Claw", AttackBonus: 11, Damage: 15}, + "boss_infernax": {Attacks: []SRDAttack{ // D11 T5 lift: 49 → ~42 (nerf; was an impossible wall at L15-16) + {Name: "Bite", AttackBonus: 11, Damage: 16}, + {Name: "Claw", AttackBonus: 11, Damage: 13}, + {Name: "Claw", AttackBonus: 11, Damage: 13}, }}, - "boss_belaxath": {Attacks: []SRDAttack{ // Attack 31 → ~40 + "boss_belaxath": {Attacks: []SRDAttack{ // D11 T5 lift: 40 → ~41 (buff; was a leader faceroll) {Name: "Longsword", AttackBonus: 11, Damage: 24}, - {Name: "Whip", AttackBonus: 11, Damage: 16}, + {Name: "Whip", AttackBonus: 11, Damage: 17}, }}, // ── Multiattack elites ─────────────────────────────────────────────── @@ -169,6 +170,24 @@ var srdProfiles = map[string]SRDProfile{ {Name: "Unarmed Strike", AttackBonus: 6, Damage: 6}, {Name: "Bite", AttackBonus: 6, Damage: 4}, }}, + + // ── Feywild elites (D8-f #2) ───────────────────────────────────────── + // Feywild martials facerolled at 97–100% even after an HP/AC raise: the + // roster was single-attack and low-damage, so tankier monsters just took + // longer to kill. Multiattack is the lever that actually pulls leaders + // into band (mirrors underdark's drow_elite). Casters trail (by design). + "fomorian": {Attacks: []SRDAttack{ // Attack 13 → ~21 (2 big fists) + {Name: "Greatclub", AttackBonus: 9, Damage: 11}, + {Name: "Greatclub", AttackBonus: 9, Damage: 10}, + }}, + "night_hag": {Attacks: []SRDAttack{ // Attack 8 → ~12 (claw flurry) + {Name: "Claws", AttackBonus: 7, Damage: 6}, + {Name: "Claws", AttackBonus: 7, Damage: 6}, + }}, + "green_hag": {Attacks: []SRDAttack{ // Attack 6 → ~10 (claw flurry) + {Name: "Claws", AttackBonus: 6, Damage: 5}, + {Name: "Claws", AttackBonus: 6, Damage: 5}, + }}, } // enemyAttackProfile returns the attack rolls a creature makes on its turn. diff --git a/internal/plugin/bootstrap_caster_hp.go b/internal/plugin/bootstrap_caster_hp.go new file mode 100644 index 0000000..ed8f64e --- /dev/null +++ b/internal/plugin/bootstrap_caster_hp.go @@ -0,0 +1,84 @@ +package plugin + +import ( + "log/slog" + + "gogobee/internal/db" +) + +// bootstrapCasterHPRefresh recomputes hp_max for caster characters after +// the J3 D8-d-fix caster HP multiplier (casterHPMult in dnd.go) shipped. +// Without this, existing caster rows keep their pre-lift hp_max until +// the next computeMaxHP recall (level-up / reset). Mirrors +// bootstrapPhase5BHPRefresh — only ever raises hp_max, preserves the +// absolute wound (delta added to hp_current, capped at new max). Run +// once per startup, idempotent via db.JobCompleted. +func bootstrapCasterHPRefresh() { + const jobName = "caster_hp_refresh_v1" + if db.JobCompleted(jobName, "once") { + return + } + + d := db.Get() + rows, err := d.Query(` + SELECT user_id, class, con_score, dnd_level, hp_max, hp_current + FROM dnd_character WHERE dnd_level > 0`) + if err != nil { + slog.Error("caster hp refresh: enumerate failed", "err", err) + return + } + defer rows.Close() + + type row struct { + userID string + class DnDClass + conScore int + level int + hpMax int + hpCurrent int + } + var batch []row + for rows.Next() { + var r row + var classStr string + if err := rows.Scan(&r.userID, &classStr, &r.conScore, &r.level, &r.hpMax, &r.hpCurrent); err != nil { + slog.Warn("caster hp refresh: scan failed", "err", err) + continue + } + r.class = DnDClass(classStr) + batch = append(batch, r) + } + + refreshed := 0 + for _, r := range batch { + if casterHPMult(r.class) == 1.0 { + continue + } + conMod := abilityModifier(r.conScore) + newMax := computeMaxHP(r.class, conMod, r.level) + if newMax <= r.hpMax { + continue + } + delta := newMax - r.hpMax + newCurrent := r.hpCurrent + delta + if newCurrent > newMax { + newCurrent = newMax + } + if newCurrent < 1 { + newCurrent = 1 + } + if _, err := d.Exec(`UPDATE dnd_character + SET hp_max = ?, hp_current = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ?`, + newMax, newCurrent, r.userID); err != nil { + slog.Warn("caster hp refresh: update failed", "user", r.userID, "err", err) + continue + } + refreshed++ + } + + db.MarkJobCompleted(jobName, "once") + if refreshed > 0 { + slog.Info("caster hp refresh: refreshed caster HPMax to D8-d-fix floor", "count", refreshed) + } +} diff --git a/internal/plugin/bootstrap_josie_caster_aid.go b/internal/plugin/bootstrap_josie_caster_aid.go new file mode 100644 index 0000000..3537ecd --- /dev/null +++ b/internal/plugin/bootstrap_josie_caster_aid.go @@ -0,0 +1,144 @@ +package plugin + +import ( + "log/slog" + "time" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// Two one-shot startup bootstraps that lift a low-DPS caster who was stuck in a +// boss-wall "death loop". Diagnosis: the boss isn't overtuned — the player is an +// over-levelled cleric whose damage output is structurally low. Two account +// gaps, fixed idempotently here so they reach the live player without a respec. +// +// 1. bootstrapCasterSpellBackfill — characters created before a default spell +// was added to defaultKnownSpells keep their original spellbook forever: +// ensureSpellsForCharacter only seeds when the known-spell list is EMPTY, so +// later default additions never reach existing casters. This backfills any +// missing default into known+prepared (e.g. inflict_wounds, added to the +// cleric defaults after the affected character was rolled). General + future +// proof — it fixes any caster with the same stale-default gap. +// +// 2. bootstrapGrantStarterPet — a targeted gift of a combat companion to a +// specific endgame player who never received the 25% morning pet-arrival +// roll. A pet adds sustained per-round damage + deflect mitigation, which +// helps caster trailers most. + +// bootstrapCasterSpellBackfill adds any missing defaultKnownSpells entry to +// every existing caster as known+prepared. addKnownSpell is idempotent and +// leaves the prepared flag of already-known spells untouched (ON CONFLICT only +// refreshes source), so this only ever adds the genuinely-missing defaults. +func bootstrapCasterSpellBackfill() { + const jobName = "caster_default_spell_backfill_v1" + if db.JobCompleted(jobName, "once") { + return + } + + chars, err := loadAllAdvCharacters() + if err != nil { + slog.Error("bootstrap: caster spell backfill — load characters failed", "err", err) + return + } + + added := 0 + for _, ac := range chars { + c, err := LoadDnDCharacter(ac.UserID) + if err != nil || c == nil || !isSpellcaster(c) { + continue + } + known, err := listKnownSpells(c.UserID) + if err != nil { + slog.Warn("bootstrap: caster spell backfill — list known failed", "user", c.UserID, "err", err) + continue + } + have := make(map[string]bool, len(known)) + for _, k := range known { + have[k.SpellID] = true + } + defaults := defaultKnownSpells(c.Class, c.Level) + if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster { + defaults = arcaneTricksterDefaultSpells(c.Level) + } + for _, sid := range defaults { + if have[sid] { + continue + } + if err := addKnownSpell(c.UserID, sid, "class", true); err != nil { + slog.Error("bootstrap: caster spell backfill — add failed", "user", c.UserID, "spell", sid, "err", err) + continue + } + slog.Info("bootstrap: backfilled default spell", "user", c.UserID, "spell", sid) + added++ + } + } + + db.MarkJobCompleted(jobName, "once") + if added > 0 { + slog.Warn("bootstrap: caster default-spell backfill complete", "spells_added", added) + } +} + +// josieStarterPet identifies the one player the pet gift targets and the pet +// it grants. Kept as data so the intent is legible: this is an admin gift, not +// a game-wide policy. +var josieStarterPet = struct { + userID id.UserID + typ string + name string + level int +}{ + userID: "@holymachina:parodia.dev", + typ: "dog", + name: "Biscuit", + level: 10, +} + +// bootstrapGrantStarterPet gives the targeted player a combat companion if they +// have none. No-op once they have a pet (this gift, a later arrival, or one +// chased away — we don't override the player's own pet history). Idempotent via +// the job gate AND the has-pet guard. +func bootstrapGrantStarterPet() { + const jobName = "grant_starter_pet_holymachina_v1" + if db.JobCompleted(jobName, "once") { + return + } + g := josieStarterPet + + char, err := loadAdvCharacter(g.userID) + if err != nil || char == nil { + // Target not present in this DB (e.g. fresh deploy) — mark done so we + // don't re-scan every startup; the gift is a one-off, not a standing rule. + db.MarkJobCompleted(jobName, "once") + return + } + if char.PetType != "" || char.PetArrived { + slog.Info("bootstrap: starter pet — target already has a pet, skipping", "user", g.userID) + db.MarkJobCompleted(jobName, "once") + return + } + + char.PetType = g.typ + char.PetName = g.name + char.PetLevel = g.level + char.PetXP = 0 + char.PetArrived = true + char.PetChasedAway = false + if g.level >= 10 { + // Mirror the babysit path that stamps the L10 date when a pet first + // reaches the cap, so milestone/supply-shop logic stays consistent. + char.PetLevel10Date = time.Now().UTC().Format("2006-01-02") + } + if err := saveAdvCharacter(char); err != nil { + slog.Error("bootstrap: starter pet — save failed", "user", g.userID, "err", err) + return + } + if err := upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)); err != nil { + slog.Error("bootstrap: starter pet — player_meta mirror failed", "user", g.userID, "err", err) + return + } + + db.MarkJobCompleted(jobName, "once") + slog.Warn("bootstrap: granted starter pet", "user", g.userID, "pet", g.name, "level", g.level) +} diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index 91c4fc9..e3da704 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -27,6 +27,19 @@ func encounterIDForRoom(roomIdx int) string { return fmt.Sprintf("room%d", roomIdx) } +// replyDM sends a player-facing combat reply unless ctx.Silent is set. The +// turn-engine combat commands route all their DMs through here so the +// background autopilot can drive a boss/elite fight on the real engine +// (long-expedition D8-f) without spamming the player a DM per round — the +// state mutations (HP, XP, threat, run-clear) still happen; only the +// narration is dropped. Non-silent callers (manual !fight) are unchanged. +func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error { + if ctx.Silent { + return nil + } + return p.SendDM(ctx.Sender, text) +} + // ── !fight ────────────────────────────────────────────────────────────────── func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { @@ -36,25 +49,25 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { run, err := getActiveZoneRun(ctx.Sender) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) + return p.replyDM(ctx, "Couldn't read run state: "+err.Error()) } if run == nil { - return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter ` first.") + return p.replyDM(ctx, "No active zone run. Use `!zone enter ` first.") } roomType := run.CurrentRoomType() if roomType != RoomElite && roomType != RoomBoss { - return p.SendDM(ctx.Sender, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.") + return p.replyDM(ctx, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.") } encID := encounterIDForRoom(run.CurrentRoom) if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil { switch existing.Status { case CombatStatusActive: - return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.") + return p.replyDM(ctx, "You're already in this fight — `!attack` or `!flee`.") case CombatStatusWon: - return p.SendDM(ctx.Sender, "You've already cleared this room. `!zone advance` to move on.") + return p.replyDM(ctx, "You've already cleared this room. "+continueHint(ctx.Sender)) default: - return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.") + return p.replyDM(ctx, "This fight is already over. `!zone status` for where you stand.") } } @@ -67,35 +80,38 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { } else { monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true) } - if !ok { - return p.SendDM(ctx.Sender, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_") + if !ok || monster.ID == "" { + // monster.ID == "" guards a malformed bestiary entry (e.g. one whose + // ID field was dropped): startCombatSession would otherwise persist a + // session with an empty EnemyID, and the turn engine — having no enemy + // to resolve — spins inertly until autoDriveCombat's round cap. Fail + // loudly instead of stalling. + return p.replyDM(ctx, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_") } player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't set up the fight: "+err.Error()) + return p.replyDM(ctx, "Couldn't set up the fight: "+err.Error()) } playerHP, playerMax := dndHPSnapshot(ctx.Sender) if playerHP <= 0 { - return p.SendDM(ctx.Sender, "You're in no shape to fight. `!rest` first.") + return p.replyDM(ctx, "You're in no shape to fight. `!rest` first.") } enemyHP := enemy.Stats.MaxHP sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP) if err != nil { if err == ErrCombatSessionAlreadyActive { - return p.SendDM(ctx.Sender, "You're already in a fight. Finish it with `!attack` / `!flee`.") + return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.") } - return p.SendDM(ctx.Sender, "Couldn't start the fight: "+err.Error()) + return p.replyDM(ctx, "Couldn't start the fight: "+err.Error()) } // Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto - // the session so they survive the turn engine's resume/commit cycle, and - // make the one-and-only per-fight pet-attack roll. - seeded := seedCombatSessionOneShots(sess, player.Mods) - pet := rollCombatSessionPetProc(sess, player.Mods) - if seeded || pet { + // the session so they survive the turn engine's resume/commit cycle. The + // pet now rolls per-turn inside the engine, so there's no fight-start roll. + if seedCombatSessionOneShots(sess, player.Mods) { if err := saveCombatSession(sess); err != nil { slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err) } @@ -120,7 +136,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { } b.WriteString("\n") b.WriteString(combatTurnPrompt(sess)) - return p.SendDM(ctx.Sender, b.String()) + return p.replyDM(ctx, b.String()) } // ── !attack / !flee ───────────────────────────────────────────────────────── @@ -140,22 +156,22 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe sess, err := getActiveCombatSession(ctx.Sender) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error()) + return p.replyDM(ctx, "Couldn't read combat state: "+err.Error()) } if sess == nil { - return p.SendDM(ctx.Sender, "You're not in a fight. `!fight` at an Elite or Boss room to start one.") + return p.replyDM(ctx, "You're not in a fight. `!fight` at an Elite or Boss room to start one.") } player, enemy, err := p.combatantsForSession(sess) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) + return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) } events, err := runCombatRound(sess, &player, &enemy, action) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error()) + return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) } - return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) + return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) } // renderRoundResult turns a resolved round into the player-facing block: the @@ -204,6 +220,18 @@ func combatTurnPrompt(sess *CombatSession) string { // ── close-out ─────────────────────────────────────────────────────────────── +// continueHint returns the verb the player uses to keep moving after a +// manual Elite/Boss fight, phrased for their current mode. On an +// expedition the autopilot drives the walk, so `!zone advance` is the +// wrong surface — point them at `!expedition run` instead. Standalone +// zone runs still advance with `!zone advance`. +func continueHint(userID id.UserID) string { + if exp, err := getActiveExpedition(userID); err == nil && exp != nil { + return "`!expedition run` to keep going." + } + return "`!zone advance` to move on." +} + // finishCombatSession runs the post-fight side effects once a CombatSession // has reached a terminal status, and returns the player-facing outcome block. // The graph is NOT advanced here: the terminal session row is the record that @@ -241,11 +269,13 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err) } } + bossOnExpedition := false if !elite { // §8.1 — zone boss defeat drops expedition threat. Silent no-op // for standalone zone runs (no active expedition). if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil { _ = applyBossDefeatThreat(exp.ID) + bossOnExpedition = true } } if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" { @@ -260,7 +290,15 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite); drop != "" { b.WriteString(drop + "\n") } - b.WriteString("`!zone advance` to move on.") + if bossOnExpedition { + // The boss is the expedition's climax. Frame the close-out as + // the win rather than a "keep walking" nudge. One more + // `!expedition run` walks out the cleared room and triggers + // finalizeExpeditionOnZoneClear (rewards + status flip). + b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.") + } else { + b.WriteString(continueHint(userID)) + } case CombatStatusLost: if run != nil { @@ -373,35 +411,35 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e sess, err := getActiveCombatSession(ctx.Sender) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error()) + return p.replyDM(ctx, "Couldn't read combat state: "+err.Error()) } if sess == nil { // Race: the fight closed between the route check and the lock. - return p.SendDM(ctx.Sender, "You're not in a fight anymore.") + return p.replyDM(ctx, "You're not in a fight anymore.") } advChar, _ := loadAdvCharacter(ctx.Sender) c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar) if err != nil || c == nil { - return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.") + return p.replyDM(ctx, "Couldn't load your Adv 2.0 sheet.") } if !isSpellcaster(c) { - return p.SendDM(ctx.Sender, fmt.Sprintf( + return p.replyDM(ctx, fmt.Sprintf( "%s isn't a caster class. `!attack` or `!consume ` instead.", titleClass(c.Class))) } spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args)) if errMsg != "" { - return p.SendDM(ctx.Sender, errMsg) + return p.replyDM(ctx, errMsg) } if spell.Effect == EffectReaction { - return p.SendDM(ctx.Sender, fmt.Sprintf( + return p.replyDM(ctx, fmt.Sprintf( "%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name)) } player, enemy, err := p.combatantsForSession(sess) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) + return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) } var eff *turnActionEffect @@ -413,11 +451,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e applySpellBuff(spell, c, &as, &am, slotLevel) d := diffTurnBuff(player.Stats, as, player.Mods, am) if !d.any() { - return p.SendDM(ctx.Sender, fmt.Sprintf( + return p.replyDM(ctx, fmt.Sprintf( "%s has no effect the turn-based engine can apply yet.", spell.Name)) } if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" { - return p.SendDM(ctx.Sender, msg) + return p.replyDM(ctx, msg) } sess.Statuses.applyBuffDelta(d) player, enemy, err = p.combatantsForSession(sess) @@ -425,7 +463,7 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e if spell.Level > 0 { _ = refundSpellSlot(ctx.Sender, slotLevel) } - return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) + return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) } label := spell.Name + " — active" if d.heal > 0 { @@ -438,11 +476,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e } else { out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats) if !supported { - return p.SendDM(ctx.Sender, fmt.Sprintf( + return p.replyDM(ctx, fmt.Sprintf( "%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)) } if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" { - return p.SendDM(ctx.Sender, msg) + return p.replyDM(ctx, msg) } eff = &turnActionEffect{ Label: out.Desc, @@ -451,6 +489,14 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e PlayerHeal: out.PlayerHeal, EnemySkip: out.EnemySkip, } + // Concentration AOE damage spells linger: the burst lands this round + // (EnemyDamage) and the same value re-ticks every round_end after, via + // the engine's concentration aura. spiritual_weapon already covers the + // cleric's bonus-action half of the combo; this restores the action half. + if spell.Concentration && + (spell.Effect == EffectDamageAuto || spell.Effect == EffectDamageSave) { + eff.ConcentrationDmg = out.EnemyDamage + } } events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff}) @@ -458,9 +504,9 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e if spell.Level > 0 { _ = refundSpellSlot(ctx.Sender, slotLevel) } - return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error()) + return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) } - return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) + return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) } // chargeSpellCost debits a spell's material component and leveled slot for a @@ -531,37 +577,37 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro sess, err := getActiveCombatSession(ctx.Sender) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error()) + return p.replyDM(ctx, "Couldn't read combat state: "+err.Error()) } if sess == nil { - return p.SendDM(ctx.Sender, "`!consume ` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.") + return p.replyDM(ctx, "`!consume ` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.") } inv := p.loadConsumableInventory(ctx.Sender) args = strings.TrimSpace(args) if args == "" { if len(inv) == 0 { - return p.SendDM(ctx.Sender, "You have no combat consumables. `!attack` / `!cast` / `!flee`.") + return p.replyDM(ctx, "You have no combat consumables. `!attack` / `!cast` / `!flee`.") } names := make([]string, len(inv)) for i, c := range inv { names[i] = c.Def.Name } - return p.SendDM(ctx.Sender, "Usage: `!consume `. You're carrying: "+strings.Join(names, ", ")+".") + return p.replyDM(ctx, "Usage: `!consume `. You're carrying: "+strings.Join(names, ", ")+".") } item, ambig := matchConsumable(inv, args) if ambig != "" { - return p.SendDM(ctx.Sender, ambig) + return p.replyDM(ctx, ambig) } if item == nil { - return p.SendDM(ctx.Sender, fmt.Sprintf("No consumable matching %q in your inventory.", args)) + return p.replyDM(ctx, fmt.Sprintf("No consumable matching %q in your inventory.", args)) } def := item.Def player, enemy, err := p.combatantsForSession(sess) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) + return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) } eff := &turnActionEffect{Action: "use_consumable"} @@ -580,13 +626,13 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}}) d := diffTurnBuff(player.Stats, as, player.Mods, am) if !d.any() { - return p.SendDM(ctx.Sender, fmt.Sprintf( + return p.replyDM(ctx, fmt.Sprintf( "**%s** has no effect the turn-based engine can apply yet.", def.Name)) } sess.Statuses.applyBuffDelta(d) player, enemy, err = p.combatantsForSession(sess) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) + return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) } eff.Label = def.Name + " — active" eff.PlayerHeal = d.heal @@ -595,7 +641,7 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff}) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error()) + return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) } // Round resolved and persisted — now burn the item. A removal failure here // leaves the player a free use (logged, rare); better than charging them @@ -603,5 +649,5 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil { slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr) } - return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) + return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) } diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index e79f4f7..09ed397 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -53,6 +53,11 @@ type CombatModifiers struct { PetAttackDmg int PetDeflectProc float64 PetWhiffProc float64 // pet distracts enemy → guaranteed miss + // Spiritual Weapon — separate channel from the pet so the spectral mace + // gets its own narration when a cleric without a companion casts it. + // Damage formula mirrors PetAttack (Dmg + d5), proc rolls per round. + SpiritWeaponProc float64 + SpiritWeaponDmg int SniperKillProc float64 // Arina instant-kill MistyHealProc float64 MistyHealAmt int @@ -319,10 +324,6 @@ type combatState struct { // the enemy would otherwise attack). enemySkipFirst bool - // Phase 13 turn-based — pet attack decided once at fight start; the pet - // strikes once on the player's first acting turn, which clears this. - petProcReady bool - // Phase 10 SUB2a-ii first-attack one-shots. firstAttackBonusUsed bool assassinateRerollUsed bool @@ -331,6 +332,14 @@ type combatState struct { // Phase 10 SUB2b — Abjuration Arcane Ward HP buffer. arcaneWardHP int + // concentrationDmg — per-round damage of an active concentration AOE + // (Spirit Guardians et al.). Armed by a !cast of a concentration damage + // spell, ticked against the enemy every round_end until the fight ends + // or another concentration spell overwrites it. Only the turn engine + // reads it; SimulateCombat resolves whole fights in one pass and folds + // the aura's value into the picker's concentration multiplier instead. + concentrationDmg int + // Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is // armed by applyAbility and read by the shared resolution primitives, so // both engines honour them; the turn-based engine additionally round-trips @@ -649,6 +658,19 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase } } + // Spiritual Weapon strike + if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc { + swDmg := player.Mods.SpiritWeaponDmg + st.roll(5) + st.enemyHP = max(0, st.enemyHP-swDmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike", + Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if enemyDown(st, phaseName) { + return true + } + } + // Misty heal if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc { healAmt := player.Mods.MistyHealAmt diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 2a7c750..1454f56 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -236,6 +236,12 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "pet_attack": return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage) + case "spirit_weapon_strike": + return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage) + + case "concentration_tick": + return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage) + case "pet_deflect": return pickRand(narrativePetDeflect) @@ -326,9 +332,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "survive_at_1": return pickRand(narrativeSurvive) case "stat_drain": - return pickRand(narrativeStatDrain) + return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage) case "debuff": - return pickRand(narrativeDebuff) + return fmt.Sprintf(pickRand(narrativeDebuff), e.Damage) case "max_hp_drain": return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage) @@ -346,7 +352,7 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "fear_resist": return pickRand(narrativeFearResist) case "ally_buff": - return pickRand(narrativeAllyBuff) + return fmt.Sprintf(pickRand(narrativeAllyBuff), e.Damage) case "timeout": return pickRand(narrativeTimeout) @@ -526,6 +532,20 @@ var narrativePetAttack = []string{ "🐾 Your faithful companion lands a hit for %d damage. More faithful than accurate, but today both applied.", } +var narrativeSpiritWeapon = []string{ + "✨ The spectral mace swings on its own and lands for %d damage. Floating menace, well-balanced.", + "✨ Your spiritual weapon hovers, picks an angle, strikes — %d damage. No grip, all conviction.", + "✨ A glowing weapon arcs in from beside you. %d damage. The enemy keeps trying to track it. Cannot.", + "✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.", +} + +var narrativeConcentrationTick = []string{ + "🌀 The lingering aura grinds the enemy down — %d damage. Still humming. Still hungry.", + "🌀 Your spell hasn't let go: the spirits sweep through again for %d damage.", + "🌀 The radiant field pulses once more — %d damage. Concentration holds.", + "🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.", +} + var narrativePetDeflect = []string{ "🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.", "🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.", diff --git a/internal/plugin/combat_narrative_drain_test.go b/internal/plugin/combat_narrative_drain_test.go new file mode 100644 index 0000000..94c9813 --- /dev/null +++ b/internal/plugin/combat_narrative_drain_test.go @@ -0,0 +1,24 @@ +package plugin + +import ( + "strings" + "testing" +) + +// TestRenderEvent_StatefulDrainsFormatMagnitude is a regression test for the +// leaked "%d" in stateful enemy-effect narration. stat_drain, debuff and +// ally_buff carry their magnitude in CombatEvent.Damage and their flavor +// pools contain a %d placeholder; renderEvent must fmt.Sprintf them rather +// than emit the template raw (which surfaced "-%d damage" to players). +func TestRenderEvent_StatefulDrainsFormatMagnitude(t *testing.T) { + for _, action := range []string{"stat_drain", "debuff", "ally_buff"} { + e := CombatEvent{Actor: "enemy", Action: action, Damage: 4} + out := renderEvent(e, "Rurina", "Shadow", CombatResult{}, newActionPicker()) + if strings.Contains(out, "%") { + t.Errorf("%s: leaked format placeholder in output: %q", action, out) + } + if !strings.Contains(out, "4") { + t.Errorf("%s: expected magnitude 4 in output: %q", action, out) + } + } +} diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 77618f0..9967ace 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -65,14 +65,6 @@ type CombatStatuses struct { // combatState, so the flag must survive the commit between them. EnemySkipNext bool `json:"enemy_skip_next,omitempty"` - // PetProcReady is the per-fight pet-attack outcome. Auto-resolve rolls the - // pet proc every round; a manual fight can run many rounds, so the roll is - // decided once at fight start (rollCombatSessionPetProc) and parked here. - // The pet then lands a single hit on the player's first acting turn, which - // clears the flag — persisted so a suspend/resume or reaper auto-play sees - // the same outcome. - PetProcReady bool `json:"pet_proc_ready,omitempty"` - // Fight-scoped depleting resources — mirror the combatState charges that // genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by // a mid-fight !cast / !consume, restored into combatState on every resume, @@ -85,6 +77,15 @@ type CombatStatuses struct { ArcaneWardHP int `json:"arcane_ward_hp,omitempty"` HealChargesLeft int `json:"heal_charges_left,omitempty"` + // ConcentrationDmg is the per-round damage of the player's active + // concentration AOE (Spirit Guardians, Spike Growth, Call Lightning, + // Flaming Sphere…). A one-shot !cast lands its burst the casting round, + // then this re-ticks the aura at round_end every round until the fight + // ends or another concentration spell overwrites it — the lingering half + // of the spell the engine used to drop on the floor, which left clerics + // and druids with no sustained DPS once their burst landed. + ConcentrationDmg int `json:"concentration_dmg,omitempty"` + // Once-per-fight class/race/subclass one-shots: the "already used" flags. // Without persistence these reset every round on resume, letting a Halfling // reroll a nat 1 or an Orc rage every single round of a turn-based fight. @@ -129,6 +130,8 @@ type CombatStatuses struct { BuffDamageBonus float64 `json:"buff_damage_bonus,omitempty"` BuffPetProc float64 `json:"buff_pet_proc,omitempty"` BuffDamageReductMul float64 `json:"buff_damage_reduct_mul,omitempty"` + BuffSpiritProc float64 `json:"buff_spirit_proc,omitempty"` + BuffSpiritDmg int `json:"buff_spirit_dmg,omitempty"` } // applyBuffDelta folds one resolved buff (the result of a !cast / !consume @@ -142,6 +145,8 @@ func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) { s.BuffCritRate += d.dCrit s.BuffDamageBonus += d.dDmgBonus s.BuffPetProc += d.dPetProc + s.BuffSpiritProc += d.dSpiritProc + s.BuffSpiritDmg += d.dSpiritDmg if d.dReductMul > 0 && d.dReductMul != 1 { if s.BuffDamageReductMul == 0 { s.BuffDamageReductMul = d.dReductMul diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index 6735ae3..25b643b 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -149,6 +149,8 @@ func applySessionBuffs(player *Combatant, s CombatStatuses) { player.Mods.DamageBonus += s.BuffDamageBonus player.Mods.PetAttackProc += s.BuffPetProc player.Mods.PetAttackDmg += s.BuffPetDmg + player.Mods.SpiritWeaponProc += s.BuffSpiritProc + player.Mods.SpiritWeaponDmg += s.BuffSpiritDmg if s.BuffDamageReductMul > 0 { player.Mods.DamageReduct *= s.BuffDamageReductMul } diff --git a/internal/plugin/combat_session_test.go b/internal/plugin/combat_session_test.go index d882014..6f4b293 100644 --- a/internal/plugin/combat_session_test.go +++ b/internal/plugin/combat_session_test.go @@ -263,6 +263,74 @@ func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) { } } +func TestTurnEngine_ConcentrationTickAtRoundEnd(t *testing.T) { + sess := turnSession(CombatPhaseRoundEnd, 50, 80) + sess.Statuses = CombatStatuses{ConcentrationDmg: 12} + player, enemy := basePlayer(), baseEnemy() + + events, err := stepEngine(sess, &player, &enemy, PlayerAction{}) + if err != nil { + t.Fatal(err) + } + if sess.EnemyHP != 68 { + t.Errorf("enemy_hp = %d, want 68 (80 - 12 aura)", sess.EnemyHP) + } + if sess.Statuses.ConcentrationDmg != 12 { + t.Errorf("concentration_dmg = %d, want 12 (aura persists)", sess.Statuses.ConcentrationDmg) + } + if len(events) != 1 || events[0].Action != "concentration_tick" || events[0].Damage != 12 { + t.Errorf("expected one concentration_tick event, got %+v", events) + } + if sess.Round != 2 || sess.Phase != CombatPhasePlayerTurn { + t.Errorf("round=%d phase=%q, want 2/player_turn", sess.Round, sess.Phase) + } +} + +func TestTurnEngine_ConcentrationTickCanBeLethal(t *testing.T) { + sess := turnSession(CombatPhaseRoundEnd, 50, 9) + sess.Statuses = CombatStatuses{ConcentrationDmg: 12} + player, enemy := basePlayer(), baseEnemy() + + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil { + t.Fatal(err) + } + if sess.Status != CombatStatusWon || sess.Phase != CombatPhaseOver { + t.Errorf("status=%q phase=%q, want won/over (aura dropped enemy)", sess.Status, sess.Phase) + } + if sess.EnemyHP != 0 { + t.Errorf("enemy_hp = %d, want 0", sess.EnemyHP) + } +} + +// A concentration damage cast lands its burst this round AND arms the aura so +// it re-ticks at every subsequent round_end. +func TestTurnEngine_ConcentrationCastArmsAura(t *testing.T) { + sess := turnSession(CombatPhasePlayerTurn, 50, 200) + player, enemy := basePlayer(), baseEnemy() + + eff := &turnActionEffect{ + Label: "Spirit Guardians", Action: "spell_cast", + EnemyDamage: 15, ConcentrationDmg: 15, + } + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff}); err != nil { + t.Fatal(err) + } + if sess.EnemyHP != 185 { + t.Errorf("enemy_hp = %d, want 185 (200 - 15 burst)", sess.EnemyHP) + } + if sess.Statuses.ConcentrationDmg != 15 { + t.Fatalf("concentration_dmg = %d, want 15 (aura armed)", sess.Statuses.ConcentrationDmg) + } + // Drive to round_end and confirm the aura bites again. + sess.Phase = CombatPhaseRoundEnd + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil { + t.Fatal(err) + } + if sess.EnemyHP != 170 { + t.Errorf("enemy_hp = %d, want 170 (185 - 15 aura tick)", sess.EnemyHP) + } +} + func TestTurnEngine_Flee(t *testing.T) { sess := turnSession(CombatPhasePlayerTurn, 50, 50) player, enemy := basePlayer(), baseEnemy() @@ -425,72 +493,120 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) { } } -// ── Pet proc (per-fight) ─────────────────────────────────────────────────── +// ── Pet proc (per-round) ─────────────────────────────────────────────────── -func TestRollCombatSessionPetProc(t *testing.T) { - // No pet proc on the player → never rolls true, never touches statuses. - none := &CombatSession{SessionID: "no-pet"} - if rollCombatSessionPetProc(none, CombatModifiers{}) || none.Statuses.PetProcReady { - t.Error("zero PetAttackProc should not arm a pet strike") - } - // A guaranteed proc arms the flag; the draw is deterministic per session id. - sure := &CombatSession{SessionID: "pet-fight"} - if !rollCombatSessionPetProc(sure, CombatModifiers{PetAttackProc: 1.0}) || !sure.Statuses.PetProcReady { - t.Error("PetAttackProc 1.0 should always arm a pet strike") - } - again := &CombatSession{SessionID: "pet-fight"} - rollCombatSessionPetProc(again, CombatModifiers{PetAttackProc: 1.0}) - if again.Statuses.PetProcReady != sure.Statuses.PetProcReady { - t.Error("same session id should roll the pet proc deterministically") - } -} - -// TestTurnEngine_PetStrike confirms an armed pet lands exactly one hit on the -// player's first acting turn, then never again — the per-fight roll model. +// TestTurnEngine_PetStrike confirms a pet with a guaranteed proc strikes on +// every player-acting turn — the per-round roll model, matching auto-resolve. func TestTurnEngine_PetStrike(t *testing.T) { // Pools huge so no phase can end the fight; isolate the pet behaviour. sess := turnSession(CombatPhasePlayerTurn, 100000, 100000) - sess.Statuses.PetProcReady = true player, enemy := basePlayer(), baseEnemy() + player.Mods.PetAttackProc = 1.0 player.Mods.PetAttackDmg = 8 - events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}) - if err != nil { - t.Fatal(err) - } - petHits := 0 - for _, e := range events { - if e.Action == "pet_attack" { - petHits++ - if e.Damage <= 0 { - t.Errorf("pet_attack damage = %d, want > 0", e.Damage) + petHitsOnPlayerTurn := func() int { + events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}) + if err != nil { + t.Fatal(err) + } + hits := 0 + for _, e := range events { + if e.Action == "pet_attack" { + hits++ + if e.Damage <= 0 { + t.Errorf("pet_attack damage = %d, want > 0", e.Damage) + } } } - } - if petHits != 1 { - t.Fatalf("pet_attack events = %d on first player turn, want 1", petHits) - } - if sess.Statuses.PetProcReady { - t.Error("PetProcReady should clear after the pet strikes") + return hits } - // Cycle back to the next player turn — the pet must not strike again. + if got := petHitsOnPlayerTurn(); got != 1 { + t.Fatalf("pet_attack events = %d on first player turn, want 1", got) + } + + // Cycle back to the next player turn — the pet must strike again. for sess.Phase != CombatPhasePlayerTurn { if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil { t.Fatal(err) } } - events, err = stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}) + if got := petHitsOnPlayerTurn(); got != 1 { + t.Errorf("pet_attack events = %d on second player turn, want 1 (per-round)", got) + } +} + +// TestTurnEngine_PetNoProc confirms a player without the pet proc never sees a +// pet strike. +func TestTurnEngine_PetNoProc(t *testing.T) { + sess := turnSession(CombatPhasePlayerTurn, 100000, 100000) + player, enemy := basePlayer(), baseEnemy() + player.Mods.PetAttackProc = 0 + + events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}) if err != nil { t.Fatal(err) } for _, e := range events { if e.Action == "pet_attack" { - t.Error("pet struck twice — the roll is per fight, not per round") + t.Error("pet struck with zero PetAttackProc") } } } +// TestTurnEngine_PetWhiff confirms a guaranteed whiff makes the enemy's turn a +// total miss — a pet_whiff event fires and the player takes no damage. +func TestTurnEngine_PetWhiff(t *testing.T) { + sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000) + player, enemy := basePlayer(), baseEnemy() + player.Mods.PetWhiffProc = 1.0 + enemy.Stats.AttackBonus = 50 // would connect easily if not whiffed + startHP := sess.PlayerHP + + events, err := stepEngine(sess, &player, &enemy, PlayerAction{}) + if err != nil { + t.Fatal(err) + } + whiffs := 0 + for _, e := range events { + if e.Action == "pet_whiff" { + whiffs++ + } + if e.Actor == "enemy" && e.Action == "hit" { + t.Error("enemy landed a hit despite a guaranteed pet whiff") + } + } + if whiffs == 0 { + t.Error("expected a pet_whiff event with PetWhiffProc 1.0") + } + if sess.PlayerHP != startHP { + t.Errorf("player took %d damage on a whiffed turn, want 0", startHP-sess.PlayerHP) + } +} + +// TestTurnEngine_PetDeflect confirms a guaranteed deflect emits a pet_deflect +// event on a connecting enemy attack. +func TestTurnEngine_PetDeflect(t *testing.T) { + sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000) + player, enemy := basePlayer(), baseEnemy() + player.Mods.PetDeflectProc = 1.0 + enemy.Stats.AttackBonus = 50 // guarantee the swing connects + + events, err := stepEngine(sess, &player, &enemy, PlayerAction{}) + if err != nil { + t.Fatal(err) + } + deflects := 0 + for _, e := range events { + if e.Action == "pet_deflect" { + deflects++ + } + } + if deflects == 0 { + t.Error("expected a pet_deflect event with PetDeflectProc 1.0 on a connecting hit") + } +} + func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) { sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn} a := combatSessionRNG(sess) diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index c83e334..448202c 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -64,6 +64,12 @@ type turnActionEffect struct { EnemyDamage int PlayerHeal int EnemySkip bool // control spell: enemy forfeits its attack this round + // ConcentrationDmg arms a per-round aura tick when a concentration damage + // spell is cast: EnemyDamage is the burst that lands this round, this is + // what re-ticks at every round_end after. Zero for one-shot spells; a + // non-zero value overwrites any aura already running (5e: one + // concentration at a time). + ConcentrationDmg int } // turnEngine wraps a combatState reconstructed from a persisted CombatSession @@ -116,7 +122,6 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R armorBroken: sess.Statuses.ArmorBroken, armorBreakAmt: sess.Statuses.ArmorBreakAmt, enemySkipFirst: sess.Statuses.EnemySkipNext, - petProcReady: sess.Statuses.PetProcReady, // Fight-scoped depleting resources + once-per-fight one-shots: restored // from the persisted statuses so a charge or "already used" flag can't // reset across a suspend/resume. commit writes the updated values back. @@ -126,6 +131,7 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R autoCrit: sess.Statuses.AutoCritFirst, arcaneWardHP: sess.Statuses.ArcaneWardHP, healChargesLeft: sess.Statuses.HealChargesLeft, + concentrationDmg: sess.Statuses.ConcentrationDmg, deathSaveUsed: sess.Statuses.DeathSaveUsed, luckyUsed: sess.Statuses.LuckyUsed, raged: sess.Statuses.Raged, @@ -220,22 +226,26 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) { te.finish(CombatStatusWon) return } + if te.spiritWeaponStrike() { + te.finish(CombatStatusWon) + return + } te.sess.Phase = CombatPhaseEnemyTurn } -// petStrike resolves the player's pet attack for a turn-based fight. Whether -// the pet lands a hit was decided once at fight start (rollCombatSessionPetProc) -// and parked on the session; the pet then strikes a single time on the player's -// first acting turn — this clears the flag so it never repeats. Damage reuses -// the auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries -// any mid-fight buff delta via applySessionBuffs. Returns true if the strike -// dropped the enemy. +// petStrike resolves the player's pet attack for a turn-based fight. The pet +// rolls fresh on every player-acting turn (PetAttackProc), mirroring the +// auto-resolve engine's per-round chance rather than a once-per-fight strike. +// The roll rides the per-(round,phase) step RNG, so a suspend/resume or reaper +// auto-play of the same turn reproduces the same outcome. Damage reuses the +// auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries any +// mid-fight buff delta via applySessionBuffs. Returns true if the strike dropped +// the enemy. func (te *turnEngine) petStrike() bool { st := te.st - if !st.petProcReady { + if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc { return false } - st.petProcReady = false petDmg := te.player.Mods.PetAttackDmg + st.roll(5) st.enemyHP = max(0, st.enemyHP-petDmg) st.events = append(st.events, CombatEvent{ @@ -245,30 +255,22 @@ func (te *turnEngine) petStrike() bool { return enemyDown(st, turnCombatPhase.Name) } -// rollCombatSessionPetProc makes the one-and-only per-fight pet-attack roll and -// parks the result on the session. Called once at fight start. The draw is -// deterministic — seeded off the session id on a stream distinct from the -// per-(round,phase) combat streams — so a reaper auto-play of an abandoned -// fight reproduces the same outcome. Returns true if the pet will attack (so -// the caller can decide whether the session needs persisting). -// -// Note: only the base PetAttackProc (class/race/subclass passives) is rolled -// here — a pet-proc buff cast mid-fight gets no fresh roll, consistent with the -// per-fight rule. Such a buff still raises PetAttackDmg if the pet does strike. -func rollCombatSessionPetProc(sess *CombatSession, playerMods CombatModifiers) bool { - if playerMods.PetAttackProc <= 0 { +// spiritWeaponStrike resolves the spell's bonus-action attack each round when +// the spiritual_weapon buff is active. Same per-turn cadence as petStrike, but +// rolls and narrates on its own channel so the spectral mace doesn't borrow +// pet flavor on a petless caster. Returns true if the strike dropped the enemy. +func (te *turnEngine) spiritWeaponStrike() bool { + st := te.st + if te.player.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= te.player.Mods.SpiritWeaponProc { return false } - var seed uint64 = 1469598103934665603 - for _, c := range sess.SessionID { - seed = (seed ^ uint64(c)) * 1099511628211 - } - rng := rand.New(rand.NewPCG(seed, 0x9E3779B97F4A7C15)) - if rngFloat(rng) < playerMods.PetAttackProc { - sess.Statuses.PetProcReady = true - return true - } - return false + dmg := te.player.Mods.SpiritWeaponDmg + st.roll(5) + st.enemyHP = max(0, st.enemyHP-dmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: turnCombatPhase.Name, Actor: "spirit_weapon", Action: "spirit_weapon_strike", + Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + return enemyDown(st, turnCombatPhase.Name) } // stepPlayerActionEffect resolves a !cast / !consume turn: the command handler @@ -304,6 +306,12 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain) st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) } + // Arm / replace the concentration aura. A new concentration cast overwrites + // the old one (5e: one concentration at a time); non-concentration casts + // leave any running aura alone. + if eff.ConcentrationDmg > 0 { + st.concentrationDmg = eff.ConcentrationDmg + } st.events = append(st.events, CombatEvent{ Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action, Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label, @@ -322,6 +330,10 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { te.finish(CombatStatusWon) return } + if te.spiritWeaponStrike() { + te.finish(CombatStatusWon) + return + } if eff.EnemySkip { // fear_immune enemies shrug off control spells — the skip never arms. if enemyImmuneToControl(te.enemy, st) { @@ -375,17 +387,32 @@ func (te *turnEngine) stepEnemyTurn() { } if !abilityDealtDamage { + // Pet defensive procs are a single proc per enemy turn: roll once, then + // spend it on the first swing only. Whiff makes that one swing a + // guaranteed miss; deflect halves its damage. Against a multiattack the + // remaining swings resolve normally — a single proc shouldn't nullify a + // boss's whole multiattack round. (This deliberately diverges from the + // auto-resolve engine's apply-to-all model.) + petWhiff := te.player.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.player.Mods.PetWhiffProc + petDeflect := te.player.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.player.Mods.PetDeflectProc + if petDeflect { + te.result.PetDeflected = true + } + // SRD multiattack: each profile entry is one attack roll resolved // through the shared primitive. A registered elite/boss swings its full // profile; everyone else gets a single attack from the template stats. // resolveEnemyAttack returns true when the fight is decided — either the // player went down without a death save, or a reflect consumable killed // the enemy. Disambiguate by inspecting HP. - for _, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) { + for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) { swing := *te.enemy swing.Stats.Attack = atk.Damage swing.Stats.AttackBonus = atk.AttackBonus - decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, false, false, false) + // Spend the proc on the first swing only; later swings see false. + swingWhiff := petWhiff && i == 0 + swingDeflect := petDeflect && i == 0 + decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false) if te.st.playerHP <= 0 { te.finish(CombatStatusLost) return @@ -440,6 +467,20 @@ func (te *turnEngine) stepRoundEnd() { } } } + // Concentration aura (Spirit Guardians et al.): the lingering spell bites + // the enemy each round it stays up. Ticks before enemy regen so a lethal + // pulse settles the fight before the enemy knits its wounds back. + if st.concentrationDmg > 0 && st.enemyHP > 0 { + st.enemyHP = max(0, st.enemyHP-st.concentrationDmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick", + Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if st.enemyHP <= 0 { + te.finish(CombatStatusWon) + return + } + } // Regenerate (monster ability): the enemy knits its wounds at round end. if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP { st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen) @@ -479,13 +520,13 @@ func (te *turnEngine) commit() { s.ArmorBroken = st.armorBroken s.ArmorBreakAmt = st.armorBreakAmt s.EnemySkipNext = st.enemySkipFirst - s.PetProcReady = st.petProcReady s.WardCharges = st.wardCharges s.SporeRounds = st.sporeRounds s.ReflectFrac = st.reflectFrac s.AutoCritFirst = st.autoCrit s.ArcaneWardHP = st.arcaneWardHP s.HealChargesLeft = st.healChargesLeft + s.ConcentrationDmg = st.concentrationDmg s.DeathSaveUsed = st.deathSaveUsed s.LuckyUsed = st.luckyUsed s.Raged = st.raged diff --git a/internal/plugin/dnd.go b/internal/plugin/dnd.go index 6f6f18d..e9a0cf1 100644 --- a/internal/plugin/dnd.go +++ b/internal/plugin/dnd.go @@ -161,6 +161,20 @@ func abilityModifier(score int) int { // startup to refresh stale rows. const phase5BHPMult = 1.5 +// casterHPMult is the J3 D8-d-fix caster durability lift. T4 sim showed +// caster HPMax sat ~30% below martial (~95 vs 141 at L10) and the AC-floor +// lift alone wasn't enough to crack the wall. Applied multiplicatively in +// computeMaxHP on top of phase5BHPMult so the existing Phase 5-B floor +// stays intact for martials. Refreshed for existing rows by +// bootstrapCasterHPRefresh (job key caster_hp_refresh_v1). +func casterHPMult(class DnDClass) float64 { + switch class { + case ClassCleric, ClassDruid, ClassBard, ClassWarlock, ClassMage, ClassSorcerer: + return 1.25 + } + return 1.0 +} + // computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1). // Per level after: average HP die (roundup of die/2 + 1) + CON mod. // The result is scaled by phase5BHPMult — see that constant for the @@ -181,8 +195,8 @@ func computeMaxHP(class DnDClass, conMod, level int) int { } hp += gain } - // Phase 5-B player power floor — round to nearest int. - scaled := int(float64(hp)*phase5BHPMult + 0.5) + // Phase 5-B player power floor + J3 D8-d-fix caster lift — round to nearest int. + scaled := int(float64(hp)*phase5BHPMult*casterHPMult(class) + 0.5) if scaled < 1 { scaled = 1 } @@ -197,10 +211,16 @@ func computeAC(class DnDClass, dexMod int) int { switch class { case ClassFighter, ClassPaladin: floor = 6 // heavy/medium-armor baseline - case ClassCleric, ClassRanger, ClassDruid: + case ClassCleric, ClassDruid: + floor = 5 + case ClassRanger: floor = 3 - case ClassRogue, ClassBard, ClassWarlock: + case ClassBard, ClassWarlock: + floor = 3 + case ClassRogue: floor = 1 + case ClassMage, ClassSorcerer: + floor = 2 } return 10 + dexMod + floor } diff --git a/internal/plugin/dnd_audit_phase_R7_test.go b/internal/plugin/dnd_audit_phase_R7_test.go index d26c78e..57ab4f9 100644 --- a/internal/plugin/dnd_audit_phase_R7_test.go +++ b/internal/plugin/dnd_audit_phase_R7_test.go @@ -42,6 +42,145 @@ func TestZoneRun_AutoAbandonsAfter24h(t *testing.T) { } } +// A run reaped by the idle timeout must also terminate the wrapping active +// expedition, or the expedition is orphaned 'active' over a dead run and the +// player soft-locks (the original feywild "stuck, can't route" bug). +func TestZoneRun_IdleTimeoutExtractsWrappingExpedition(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@orphan-run:example") + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID, + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatalf("startExpedition: %v", err) + } + if err := setExpeditionRunID(exp.ID, run.RunID); err != nil { + t.Fatalf("link run: %v", err) + } + // Backdate the run past the 24h stale threshold. + if _, err := dbExecZoneRunBackdate(run.RunID, 25*time.Hour); err != nil { + t.Fatalf("backdate: %v", err) + } + + got, err := getActiveZoneRun(uid) + if err != nil { + t.Fatalf("getActiveZoneRun: %v", err) + } + if got != nil { + t.Errorf("expected nil after timeout, got run %q", got.RunID) + } + // The wrapping expedition must no longer be active. + after, err := getExpedition(exp.ID) + if err != nil { + t.Fatalf("getExpedition: %v", err) + } + if after == nil { + t.Fatal("expedition row vanished") + } + if after.Status == ExpeditionStatusActive { + t.Errorf("expedition still active after run idle-timeout; status=%q", after.Status) + } +} + +// A stale background fork auto-picks the first unlocked route so the +// expedition keeps moving instead of idling out to the reaper. +func TestAutoPickStaleFork_TakesAvailableRoute(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@stale-fork:example") + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID, + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatalf("startExpedition: %v", err) + } + if err := setExpeditionRunID(exp.ID, run.RunID); err != nil { + t.Fatalf("link run: %v", err) + } + pf := pendingFork{ + PendingAt: "goblin_warrens.cavern_junction", + Options: []pendingChoice{ + {Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post", + Unlocked: false, Lock: "perception_check", Reason: "Perception 8 vs DC 14"}, + {Index: 2, To: "goblin_warrens.kennel_path", Label: "Kennel Path", + Unlocked: true, Lock: "none"}, + }, + } + if err := writePendingFork(run.RunID, pf); err != nil { + t.Fatalf("writePendingFork: %v", err) + } + r2, _ := getZoneRun(run.RunID) + got, _ := decodePendingFork(r2.NodeChoices) + if got == nil { + t.Fatal("fork not persisted") + } + + p := &AdventurePlugin{} + if ok := p.autoPickStaleFork(exp, r2, got); !ok { + t.Fatal("expected auto-pick to commit a route") + } + after, _ := getZoneRun(run.RunID) + if after.CurrentNode != "goblin_warrens.kennel_path" { + t.Errorf("did not advance to the unlocked route: current_node=%q", after.CurrentNode) + } + if pf2, _ := decodePendingFork(after.NodeChoices); pf2 != nil { + t.Errorf("pending fork not cleared after auto-pick") + } +} + +// When every option is locked there's nothing safe to auto-pick — leave the +// fork intact for the player (or the reaper). +func TestAutoPickStaleFork_AllLockedLeavesForkIntact(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@locked-fork:example") + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID, + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatalf("startExpedition: %v", err) + } + _ = setExpeditionRunID(exp.ID, run.RunID) + pf := pendingFork{ + PendingAt: "goblin_warrens.cavern_junction", + Options: []pendingChoice{ + {Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post", + Unlocked: false, Lock: "perception_check", Reason: "DC 14"}, + }, + } + if err := writePendingFork(run.RunID, pf); err != nil { + t.Fatalf("writePendingFork: %v", err) + } + r2, _ := getZoneRun(run.RunID) + before := r2.CurrentNode + got, _ := decodePendingFork(r2.NodeChoices) + + p := &AdventurePlugin{} + if ok := p.autoPickStaleFork(exp, r2, got); ok { + t.Fatal("expected no auto-pick when every option is locked") + } + after, _ := getZoneRun(run.RunID) + if after.CurrentNode != before { + t.Errorf("current_node moved on an all-locked fork: %q → %q", before, after.CurrentNode) + } + if pf2, _ := decodePendingFork(after.NodeChoices); pf2 == nil { + t.Errorf("pending fork was cleared on an all-locked fork") + } +} + func TestZoneRun_FreshRunNotAutoAbandoned(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@fresh-run:example") @@ -168,9 +307,7 @@ func TestDeliverBriefing_StarvationForcesExtraction(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil { - t.Fatalf("backdate: %v", err) - } + rewindToLegacyAnchor(t, exp) p := &AdventurePlugin{} now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC) diff --git a/internal/plugin/dnd_bestiary.go b/internal/plugin/dnd_bestiary.go index 3e91739..cd3cf3b 100644 --- a/internal/plugin/dnd_bestiary.go +++ b/internal/plugin/dnd_bestiary.go @@ -256,7 +256,7 @@ var _ = func() bool { }, "green_hag": { ID: "green_hag", Name: "Green Hag", - CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12, + CR: 3, HP: 105, AC: 18, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"}, XPValue: 700, @@ -463,7 +463,7 @@ var _ = func() bool { }, "drow_elite_warrior": { ID: "drow_elite_warrior", Name: "Drow Elite Warrior", - CR: 5, HP: 71, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12, + CR: 5, HP: 95, AC: 19, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.20, Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"}, XPValue: 1800, @@ -471,23 +471,23 @@ var _ = func() bool { }, "drow_mage": { ID: "drow_mage", Name: "Drow Mage", - CR: 7, HP: 45, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12, + CR: 7, HP: 65, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12, BlockRate: 0.05, - Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"}, + Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.25, Effect: "aoe"}, XPValue: 2900, Notes: "Spells: Lightning Bolt, Fly, Darkness, Faerie Fire. Magic Resistance.", }, "mind_flayer": { ID: "mind_flayer", Name: "Mind Flayer", - CR: 7, HP: 71, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12, + CR: 7, HP: 90, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12, BlockRate: 0.10, - Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.55, Effect: "stun"}, + Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.30, Effect: "stun"}, XPValue: 2900, Notes: "Mind Blast AoE psychic INT DC 15 or Stunned; Extract Brain instakills stunned; telepathy.", }, "hook_horror": { ID: "hook_horror", Name: "Hook Horror", - CR: 3, HP: 75, AC: 15, Attack: 6, AttackBonus: 6, Speed: 10, + CR: 3, HP: 95, AC: 16, Attack: 6, AttackBonus: 6, Speed: 10, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"}, XPValue: 700, @@ -495,15 +495,15 @@ var _ = func() bool { }, "roper": { ID: "roper", Name: "Roper", - CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4, + CR: 5, HP: 115, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4, BlockRate: 0.20, - Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.50, Effect: "stun"}, + Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.30, Effect: "stun"}, XPValue: 1800, Notes: "Elite. 6 tendrils grapple+restrain; Reel pulls 25 ft; False Appearance.", }, "boss_ilvaras_xunyl": { ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess", - CR: 12, HP: 162, AC: 16, Attack: 19, AttackBonus: 9, Speed: 12, + CR: 12, HP: 210, AC: 18, Attack: 19, AttackBonus: 9, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"}, XPValue: 8400, @@ -512,7 +512,7 @@ var _ = func() bool { // ── Feywild Crossing ───────────────────────────────────────────── "redcap": { ID: "redcap", Name: "Redcap", - CR: 3, HP: 45, AC: 13, Attack: 6, AttackBonus: 6, Speed: 14, + CR: 3, HP: 60, AC: 15, Attack: 6, AttackBonus: 6, Speed: 14, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"}, XPValue: 700, @@ -520,7 +520,7 @@ var _ = func() bool { }, "will_o_wisp": { ID: "will_o_wisp", Name: "Will-o'-Wisp", - CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14, + CR: 2, HP: 30, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14, BlockRate: 0.0, Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"}, XPValue: 450, @@ -528,7 +528,7 @@ var _ = func() bool { }, "quickling": { ID: "quickling", Name: "Quickling", - CR: 1, HP: 10, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18, + CR: 1, HP: 16, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"}, XPValue: 200, @@ -536,7 +536,7 @@ var _ = func() bool { }, "night_hag": { ID: "night_hag", Name: "Night Hag", - CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12, + CR: 5, HP: 135, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"}, XPValue: 1800, @@ -544,7 +544,7 @@ var _ = func() bool { }, "fomorian": { ID: "fomorian", Name: "Fomorian", - CR: 8, HP: 149, AC: 14, Attack: 13, AttackBonus: 9, Speed: 12, + CR: 8, HP: 180, AC: 16, Attack: 13, AttackBonus: 9, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"}, XPValue: 3900, @@ -552,7 +552,7 @@ var _ = func() bool { }, "boss_thornmother": { ID: "boss_thornmother", Name: "The Thornmother", - CR: 11, HP: 187, AC: 17, Attack: 18, AttackBonus: 8, Speed: 12, + CR: 11, HP: 235, AC: 18, Attack: 18, AttackBonus: 8, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"}, XPValue: 7200, @@ -602,9 +602,18 @@ var _ = func() bool { }, "boss_infernax": { ID: "boss_infernax", Name: "Infernax the Undying", - CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 11, Speed: 18, + // T5 lift (D11): dragons_lair was an impossible wall — at L15-16 + // (mid-range) leaders cleared 0-2%, 100% TPK at the boss (it has + // 546 HP / AC22 / 49-dmg multiattack / near-guaranteed stun, a + // CR24 block fought ~9 levels under its CR). Nerfed to land + // martial leaders in the 60-75% band: HP 546→405, AC 22→20, + // frightful-presence stun 0.80→0.40, multiattack 49→42 (srd). + // (Pass 1's 360/AC20/36 over-nerfed → 98% leaders; Pass 2's + // 460/AC21/42 over-corrected → ~40%; this Pass 3 405/AC20/42 + // lands leaders in band.) + CR: 24, HP: 405, AC: 20, Attack: 38, AttackBonus: 11, Speed: 18, BlockRate: 0.15, - Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.80, Effect: "stun"}, + Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.40, Effect: "stun"}, XPValue: 62000, Notes: "Dragon's Lair boss. Ancient Red Dragon. Legendary Actions; Lair Actions; phase 2 below 50% HP — fire ignores resistance.", FireAttacker: true, @@ -652,7 +661,15 @@ var _ = func() bool { }, "boss_belaxath": { ID: "boss_belaxath", Name: "Belaxath the Undivided", - CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 11, Speed: 14, + // T5 lift (D11): abyss_portal was a faceroll for leaders — at its + // own floor (L15) fighter/ranger cleared 88-92% (above the 60-75% + // band), killing the 262-HP boss in 6-13 rounds with HP to spare. + // Buffed to bring leaders into band: HP 262→300, AC 19→20, + // multiattack 40→41 (srd). Elites (nalfeshnee/marilith) already + // wall the trailers, so this only moves the leaders. (Pass 1's + // 360/AC20/45 over-buffed → 19-40% leaders; this is the Pass 2 + // correction back down toward band.) + CR: 19, HP: 300, AC: 20, Attack: 31, AttackBonus: 11, Speed: 14, BlockRate: 0.20, Ability: &MonsterAbility{Name: "Lightning Discharge", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"}, XPValue: 22000, diff --git a/internal/plugin/dnd_class_balance.go b/internal/plugin/dnd_class_balance.go index 467df3f..e6b5395 100644 --- a/internal/plugin/dnd_class_balance.go +++ b/internal/plugin/dnd_class_balance.go @@ -272,9 +272,19 @@ func spellExpectedDamage(s SpellDefinition, slot, charLevel int) float64 { } avgFace := (float64(faces) + 1) / 2 avg := float64(dice)*avgFace + float64(flat) - // Auto-damage (Magic Missile) doesn't roll to hit — count its - // expected-on-table value at face. Attack/save spells roll, and the - // engine will resolve hit chance at cast time. + // Concentration damage spells (heat_metal, spirit_guardians, + // flaming_sphere, call_lightning, spike_growth, cloud_of_daggers, …) + // re-tick each round while concentration holds. Without this factor + // the picker scores them as one-shots and they lose to higher-tier + // blasts on every comparison. Conservative ×3 = roughly the median + // fight length the picker can hope to keep concentration up; tier- + // scaled would be more correct but adds noise here. + // EffectDamageAttack is excluded — single-target attack-roll spells + // aren't generally concentration; the rare ones (hex-style) get + // their lift from mods, not this score. + if s.Concentration && (s.Effect == EffectDamageSave || s.Effect == EffectDamageAuto) { + avg *= 3 + } return avg } diff --git a/internal/plugin/dnd_class_balance_test.go b/internal/plugin/dnd_class_balance_test.go index f14cfe3..71be777 100644 --- a/internal/plugin/dnd_class_balance_test.go +++ b/internal/plugin/dnd_class_balance_test.go @@ -408,3 +408,45 @@ func TestClassBalance_Phase1_FullMatrix(t *testing.T) { } } } + +// D8-c: concentration damage spells re-tick each round; the picker has to +// score them above one-shots of similar level or it'll never pick them. +func TestSpellExpectedDamage_ConcentrationMultiplier(t *testing.T) { + const concentrationFactor = 3.0 + // 2d8 base: avg = 9. + const baseAvg = 9.0 + + cases := []struct { + name string + sp SpellDefinition + want float64 + }{ + {"oneshot save (shatter-like)", + SpellDefinition{Level: 2, Effect: EffectDamageSave, DamageDice: "2d8"}, + baseAvg}, + {"oneshot attack", + SpellDefinition{Level: 2, Effect: EffectDamageAttack, DamageDice: "2d8"}, + baseAvg}, + {"concentration save (spirit_guardians-like)", + SpellDefinition{Level: 2, Effect: EffectDamageSave, Concentration: true, DamageDice: "2d8"}, + baseAvg * concentrationFactor}, + {"concentration auto (spike_growth-like)", + SpellDefinition{Level: 2, Effect: EffectDamageAuto, Concentration: true, DamageDice: "2d8"}, + baseAvg * concentrationFactor}, + {"concentration attack stays unmultiplied", + SpellDefinition{Level: 2, Effect: EffectDamageAttack, Concentration: true, DamageDice: "2d8"}, + baseAvg}, + } + for _, tc := range cases { + got := spellExpectedDamage(tc.sp, tc.sp.Level, 10) + if got != tc.want { + t.Errorf("%s: expDmg=%.2f want %.2f", tc.name, got, tc.want) + } + } + + // Upcast still applies before the multiplier: 2d8 at slot 3 = 3d8 = 13.5, ×3 = 40.5. + sp := SpellDefinition{Level: 2, Effect: EffectDamageSave, Concentration: true, DamageDice: "2d8"} + if got := spellExpectedDamage(sp, 3, 10); got != 13.5*concentrationFactor { + t.Errorf("upcast + concentration: got %.2f want %.2f", got, 13.5*concentrationFactor) + } +} diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index 5d7f1f1..b1870d3 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -53,6 +53,15 @@ type CampState struct { RoomIndex int `json:"room_index"` EstablishedAt time.Time `json:"established_at"` NightEvents []string `json:"night_events"` + // RestApplied is set when the long-rest effects (HP refill, spell slots, + // threat -5 etc.) have already been applied at pitch time. processOvernightCamp + // uses it to skip re-applying so the night cycle just breaks the camp. + RestApplied bool `json:"rest_applied,omitempty"` + // AutoPitched is set when the long-expedition autopilot pitched this + // camp. The autorun ticker breaks an auto-pitched camp itself after a + // minimum dwell so the walk can keep moving; player-pitched camps stay + // up until the player breaks them (or moves on). + AutoPitched bool `json:"auto_pitched,omitempty"` } // ThreatEvent — §8.4. @@ -433,6 +442,34 @@ func appendExpeditionLog(expID string, day int, entryType, summary, flavor strin return err } +// dayExpeditionLog returns every log entry recorded against the given +// (expedition, day) pair, oldest first. Used by the D4-a end-of-day +// digest to bundle a single rollup DM at night-camp pitch. +func dayExpeditionLog(expID string, day int) ([]ExpeditionEntry, error) { + rows, err := db.Get().Query(` + SELECT entry_id, expedition_id, day, timestamp, entry_type, summary, flavor + FROM dnd_expedition_log + WHERE expedition_id = ? + AND day = ? + ORDER BY entry_id`, expID, day) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ExpeditionEntry + for rows.Next() { + var e ExpeditionEntry + if err := rows.Scan( + &e.EntryID, &e.ExpeditionID, &e.Day, &e.Timestamp, + &e.Type, &e.Summary, &e.Flavor, + ); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + // recentExpeditionLog returns the last `limit` entries, newest first. func recentExpeditionLog(expID string, limit int) ([]ExpeditionEntry, error) { if limit <= 0 { diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index 6031ee5..e309a09 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -2,6 +2,7 @@ package plugin import ( "fmt" + "log/slog" "math/rand/v2" "strings" "time" @@ -79,12 +80,9 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error { case "rough", "standard": // allowed in E1e case "fortified": - // E2d: §5.1 — boss-cleared room or cache site. Cache sites - // are zone-specific waypoints (E3+), so for now we require the - // expedition's boss to have been defeated. if !exp.BossDefeated { return p.SendDM(ctx.Sender, - "Fortified camps require a boss-cleared room or cache site. Defeat the zone boss (or find a cache) first.") + "Fortified camps require a defeated zone boss. Clear the zone first.") } case "base": // E4d: §11.1 — base camps unlock per region after the region @@ -119,10 +117,10 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error { func campHelpText(exp *Expedition) string { var b strings.Builder - b.WriteString("**!camp ** — establish camp during an expedition.\n\n") + b.WriteString("**!camp ** — _override._ Autopilot pitches camp at night automatically; reach for this only to force a rest right now.\n\n") b.WriteString("`!camp rough` — partial rest (any location, +0.5 SU, high night risk)\n") b.WriteString("`!camp standard` — full long rest (cleared room, +1 SU, medium risk)\n") - b.WriteString("`!camp fortified` — long rest + bonus (boss-cleared room, +2 SU, low risk)\n") + b.WriteString("`!camp fortified` — long rest + bonus (zone boss defeated, +2 SU, low risk)\n") b.WriteString("`!camp base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n") b.WriteString("`!camp break` — break camp\n\n") if exp.Camp != nil && exp.Camp.Active { @@ -146,10 +144,10 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st return p.SendDM(ctx.Sender, problem) } if !cleared && kind == CampTypeStandard { - // §5.2: non-cleared room forces rough. - kind = CampTypeRough - _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", - "intended standard camp; downgraded to rough (room not cleared)", "") + // §5.2: standard camp requires a cleared room. Reject explicitly + // rather than silently downgrading — the player should make the call. + return p.SendDM(ctx.Sender, + "Standard camp needs a cleared room. This room isn't cleared yet — clear it first, or `!camp rough` for a partial rest here.") } cost, ok := campSupplyCost[kind] @@ -162,6 +160,30 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st cost, exp.Supplies.Current)) } + // D2-b: event-anchored manual !camp counts as the night camp if it's + // the first camp since the last rollover. Burn supplies *before* the + // camp cost so the burn lands against pre-pitch supplies (matches the + // legacy morning-burn ordering), then pitch, then drift. + nightCamp := false + var nightBurn float32 + var nightRoll nightRolloverResult + if isEventAnchored(exp) { + var since time.Duration + if exp.LastBriefingAt != nil { + since = time.Now().UTC().Sub(*exp.LastBriefingAt) + } else { + since = time.Now().UTC().Sub(exp.StartDate) + } + if since >= nightCampWindow { + nightCamp = true + burn, err := p.nightRolloverBurn(exp) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't burn night supplies: "+err.Error()) + } + nightBurn = burn + } + } + exp.Supplies.Current -= cost if err := updateSupplies(exp.ID, exp.Supplies); err != nil { return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error()) @@ -176,6 +198,20 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st if err := updateCamp(exp.ID, camp); err != nil { return p.SendDM(ctx.Sender, "Couldn't pitch camp: "+err.Error()) } + exp.Camp = camp + + // Apply the long-rest effects immediately so the player isn't waiting + // until the next 06:00 UTC briefing for HP and spell slots to come + // back. The flag tells processOvernightCamp not to re-apply at briefing. + restSummary := applyCampRest(exp, kind) + if nightCamp { + nightRoll = p.nightRolloverDrift(exp, time.Now().UTC()) + nightRoll.Burn = nightBurn + } + camp.RestApplied = true + if err := updateCamp(exp.ID, camp); err != nil { + slog.Warn("camp: mark rest applied", "expedition", exp.ID, "err", err) + } // E4d: pick the BaseCampEstablished pool for base camps; otherwise // the generic camp pool. Both already handle [N] day interpolation @@ -206,40 +242,33 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st if line != "" { b.WriteString("\n" + line + "\n") } + if restSummary != "" { + b.WriteString("\n" + restSummary + "\n") + } switch kind { - case CampTypeRough: - b.WriteString("\n_Rough camp — partial rest. HP recovers to 50%, no spell slots restored._") - case CampTypeFortified: - b.WriteString("\n_Fortified camp — long rest + 1d6 HP bonus on wake; threat clock −5; wandering rolls −4._") case CampTypeBase: - b.WriteString("\n_Base camp — long rest + 1d6 HP bonus; threat clock −5; wandering rolls −6. **Waypoint persisted** — camp here again at no eligibility cost on later returns._") - default: - b.WriteString("\n_Standard camp — full long rest at the next morning briefing._") + b.WriteString("\n_Base camp — **waypoint persisted**. Camp here again at no eligibility cost on later returns._") + } + if nightCamp { + b.WriteString(fmt.Sprintf("\n\n🌙 **Day %d.** _Overnight burn: %.1f SU._\n", exp.CurrentDay, nightRoll.Burn)) + for _, tl := range nightRoll.TemporalLines { + b.WriteString("\n🌀 " + tl + "\n") + } + for _, ml := range nightRoll.MilestoneLines { + b.WriteString("\n" + ml) + } } return p.SendDM(ctx.Sender, b.String()) } -// processOvernightCamp applies the overnight long-rest effects of an -// active camp at briefing time (§3, §5.1, §8.1). Auto-breaks the camp -// after the rest. Returns a one-line summary for the briefing body. -// -// Effects: -// - rough: HP recovered to at least 50% of max. -// - standard: HP fully restored, spell slots refreshed, exhaustion -1. -// - fortified: standard + 1d6 HP bonus on top, threat -5. -// - base: same as fortified for the rest itself; persistent waypoint -// mechanics land in E4. -// -// Returns "" if the expedition wasn't camped overnight. -func processOvernightCamp(e *Expedition) string { - if e.Camp == nil || !e.Camp.Active { - return "" - } +// applyCampRest runs the long-rest effects (HP/spells/threat/heat) for the +// given camp kind and returns a player-facing summary. Shared by camp pitch +// (immediate rest) and overnight rollover (legacy deferred path). Does NOT +// break the camp — callers decide that. +func applyCampRest(e *Expedition, kind string) string { uid := id.UserID(e.UserID) c, _ := LoadDnDCharacter(uid) if c == nil { - // No character to apply HP/spells to; just break the camp. - _ = updateCamp(e.ID, nil) return "" } // Babysit safe-rest: an active subscription promotes a Standard camp @@ -247,7 +276,6 @@ func processOvernightCamp(e *Expedition) string { // Rough/Base are unchanged — Rough still implies no shelter, and Base // already exceeds Fortified. babysitUpgraded := false - kind := e.Camp.Type if kind == CampTypeStandard && BabysitSafeRest(uid) { kind = CampTypeFortified babysitUpgraded = true @@ -299,11 +327,6 @@ func processOvernightCamp(e *Expedition) string { heatReduced = before - after } - // Auto-break the camp now that the rest has been applied. - _ = updateCamp(e.ID, nil) - e.Camp = nil - - // Pretty summary for the briefing body. switch kind { case CampTypeRough: if c.HPCurrent > prevHP { @@ -327,6 +350,24 @@ func processOvernightCamp(e *Expedition) string { return "" } +// processOvernightCamp handles the briefing-time half of the camp lifecycle: +// auto-breaks the camp, and applies the long rest only if it wasn't already +// applied at pitch time. Returns a one-line summary for the briefing body, or +// "" if there was nothing to do. +func processOvernightCamp(e *Expedition) string { + if e.Camp == nil || !e.Camp.Active { + return "" + } + kind := e.Camp.Type + alreadyApplied := e.Camp.RestApplied + _ = updateCamp(e.ID, nil) + e.Camp = nil + if alreadyApplied { + return "" + } + return applyCampRest(e, kind) +} + func (p *AdventurePlugin) campBreak(ctx MessageContext, exp *Expedition) error { if exp.Camp == nil || !exp.Camp.Active { return p.SendDM(ctx.Sender, "No camp to break.") @@ -359,15 +400,66 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) { case RoomTrap: return false, "You can't camp in a trap room — even a disarmed one." } - // Active-enemy detection requires combat-state lookup; defer to E2. - cleared = false for _, idx := range run.RoomsCleared { if idx == run.CurrentRoom { - cleared = true - break + return true, "" } } - return cleared, "" + // Not yet advanced-past, but the only thing that bars a rest is a live + // fight. Forward-only navigation means players pause right after a kill + // before advancing, and peaceful/exploration/loot rooms never spawn an + // encounter at all — both are safe to rest in. The "cleared" flag would + // otherwise reject standard camp here with a misleading "clear it first". + encID := encounterIDForRoom(run.CurrentRoom) + sess, err := getCombatSessionForEncounter(run.RunID, encID) + if err != nil { + // Can't read the encounter's combat state. Fail open like the + // run-lookup path above rather than blocking standard camp with an + // empty (misleading "not cleared") rejection — a DB hiccup shouldn't + // strand a player who only wants to rest. + slog.Warn("camp: combat session lookup failed; allowing camp", + "run", run.RunID, "encounter", encID, "err", err) + return true, "" + } + if sess != nil && sess.Status == CombatStatusActive { + return false, "You can't camp mid-fight — finish the encounter first." + } + return true, "" +} + +// autoBreakCampOnMove strikes an active camp when the player has moved +// to a different room than the one camp was pitched in. Camp is a +// stationary intent (long-rest at this spot until the next briefing); +// once the party walks on — whether by autopilot, manual !advance, or +// fork pick — the tent stops mattering. Overnight rest effects are not +// applied here (those only land at briefing time via +// processOvernightCamp). Returns the camp type that was struck, or "" +// if no break happened. Safe to call when there's no expedition. +func autoBreakCampOnMove(userID id.UserID) string { + exp, err := getActiveExpedition(userID) + if err != nil || exp == nil { + return "" + } + if exp.Camp == nil || !exp.Camp.Active { + return "" + } + if exp.RunID == "" { + return "" + } + run, err := getZoneRun(exp.RunID) + if err != nil || run == nil { + return "" + } + if run.CurrentRoom == exp.Camp.RoomIndex { + return "" + } + kind := exp.Camp.Type + if err := updateCamp(exp.ID, nil); err != nil { + slog.Warn("camp: auto-break on move failed", "expedition", exp.ID, "err", err) + return "" + } + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "camp struck (party moved on)", "") + return kind } // campCurrentRoomIndex returns 0 (entry) when no room context exists. diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index ba63c7c..b01ce9f 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -2,6 +2,7 @@ package plugin import ( "fmt" + "log/slog" "math" "strconv" "strings" @@ -91,18 +92,23 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string func expeditionHelpText() string { var b strings.Builder b.WriteString("**!expedition** — multi-day dungeon expeditions.\n\n") - b.WriteString("`!expedition list` — show zones available at your level\n") - b.WriteString("`!expedition start [Ns] [Md]` — outfit & begin\n") - b.WriteString(" `Ns` = N standard packs (10 SU, 50 coins, max 3)\n") - b.WriteString(" `Md` = M deluxe packs (20 SU, 90 coins, max 1)\n") - b.WriteString(" default: `1s`\n") - b.WriteString("`!expedition run` — autopilot: walk rooms until something needs you (alias `!explore`)\n") - b.WriteString("`!expedition status` — current expedition snapshot\n") + b.WriteString("**The shape:** pick a zone, pick a supply pack, watch it play out. ") + b.WriteString("Autopilot walks rooms, pitches camp at night, and DMs you when something needs a decision (a fork, a boss, low HP).\n\n") + b.WriteString("**Run an expedition:**\n") + b.WriteString("`!expedition list` — zones available at your level\n") + b.WriteString("`!expedition start ` — prompts a loadout: `lean` / `balanced` / `heavy`\n") + b.WriteString("`!expedition run` — start (or resume) the autopilot walk (alias `!explore`)\n") + b.WriteString("`!expedition status` — day, rooms, supplies, recent events\n\n") + b.WriteString("**Mid-expedition:**\n") + b.WriteString("`!extract` — bail safely (resumable for 7 days)\n") + b.WriteString("`!resume [loadout]` — resume an extracted expedition\n") b.WriteString("`!expedition log` — last 5 log entries\n") - b.WriteString("`!expedition abandon` — end the expedition (no rewards)\n") - b.WriteString("`!extract` — voluntary extraction (1 day, resumable for 7 days)\n") - b.WriteString("`!resume [Ns] [Md]` — resume an extracted expedition\n") - b.WriteString("`!map` — region/room ASCII map") + b.WriteString("`!expedition abandon` — end without rewards\n") + b.WriteString("`!map` — region/room ASCII map\n\n") + b.WriteString("**Overrides** _(autopilot covers these — only reach for them if you want manual control)_:\n") + b.WriteString("`!fight` — engage an Elite/Boss the autopilot paused at\n") + b.WriteString("`!camp` — force a rest right now (see `!camp` for types)\n") + b.WriteString("`!expedition start Ns Md` — raw pack counts instead of a preset") return b.String() } @@ -178,6 +184,66 @@ func parseSupplyArgs(rest string) (SupplyPurchase, error) { return p, nil } +// resolveLoadoutOrParse first tries a single-token preset (lean/balanced/ +// heavy and short forms); on miss it falls back to raw `Ns Md` parsing. +// Tier is needed because preset purchase counts scale by zone tier. +func resolveLoadoutOrParse(tok string, tier ZoneTier) (SupplyPurchase, error) { + trimmed := strings.TrimSpace(tok) + if !strings.ContainsAny(trimmed, " \t") { + if l, ok := parseLoadoutToken(trimmed); ok { + return loadoutPurchase(tier, l), nil + } + } + return parseSupplyArgs(tok) +} + +// renderLoadoutPrompt formats the "pick your loadout" DM. The resume +// command and start command share it; cmdHint tells the player which +// command to type back with the chosen preset. +func renderLoadoutPrompt(zone ZoneDefinition, cmdHint string) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("🎒 **Pick a loadout — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier))) + for _, l := range []SupplyLoadout{LoadoutLean, LoadoutBalanced, LoadoutHeavy} { + pp := loadoutPurchase(zone.Tier, l) + b.WriteString(fmt.Sprintf(" `%s` — %s — %.0f SU, %d coins — %s\n", + loadoutName(l), packBreakdown(pp), pp.Total(), pp.Cost(), loadoutBlurb(l))) + } + b.WriteString(fmt.Sprintf("\nType `!%s %s` (or `lean` / `heavy`).\n", cmdHint, loadoutName(LoadoutBalanced))) + b.WriteString("Advanced: raw pack counts like `2s 1d`.") + return b.String() +} + +func loadoutName(l SupplyLoadout) string { + switch l { + case LoadoutLean: + return "lean" + case LoadoutHeavy: + return "heavy" + } + return "balanced" +} + +func loadoutBlurb(l SupplyLoadout) string { + switch l { + case LoadoutLean: + return "covers the intended run at calm burn; thin if things go sideways" + case LoadoutHeavy: + return "max cap; rides out harsh stretches and overruns" + } + return "recommended; absorbs a harsh patch or two" +} + +func packBreakdown(p SupplyPurchase) string { + switch { + case p.StandardPacks > 0 && p.DeluxePacks > 0: + return fmt.Sprintf("%d standard + %d deluxe", p.StandardPacks, p.DeluxePacks) + case p.DeluxePacks > 0: + return fmt.Sprintf("%d deluxe", p.DeluxePacks) + default: + return fmt.Sprintf("%d standard", p.StandardPacks) + } +} + func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter, rest string) error { if rest == "" { return p.SendDM(ctx.Sender, @@ -195,11 +261,17 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter return p.SendDM(ctx.Sender, "Unknown zone for your level. Try `!expedition list`.") } - purchase, err := parseSupplyArgs(packTok) + zoneForCaps, _ := getZone(zoneID) + // D5-b: prompt for a preset loadout on empty args. Raw `Ns Md` syntax + // still works as the advanced override. + if strings.TrimSpace(packTok) == "" { + return p.SendDM(ctx.Sender, renderLoadoutPrompt(zoneForCaps, "expedition start "+string(zoneID))) + } + purchase, err := resolveLoadoutOrParse(packTok, zoneForCaps.Tier) if err != nil { return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) } - if err := purchase.Validate(); err != nil { + if err := purchase.Validate(zoneForCaps.Tier); err != nil { return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) } cost := float64(purchase.Cost()) @@ -226,9 +298,10 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter zone.Display)) } - zone, _ := getZone(zoneID) + zone := zoneForCaps // Holiday perk: a complimentary standard pack is added to the supplies - // snapshot without inflating the coin cost. + // snapshot without inflating the coin cost. Bypasses the per-tier cap + // on purpose — it's a freebie on top of whatever the player bought. suppliesPurchase := purchase if isHol, _ := isHolidayToday(); isHol { suppliesPurchase.StandardPacks++ @@ -261,6 +334,7 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter // Log the start with prewritten flavor. startLine := flavor.Pick(flavor.ExpeditionStart) _ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine) + markActedToday(ctx.Sender) var b strings.Builder b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier))) @@ -330,8 +404,9 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool zone, _ := getZone(exp.ZoneID) c, _ := LoadDnDCharacter(ctx.Sender) + target := expeditionTargetDays(zone.Tier) var b strings.Builder - b.WriteString(fmt.Sprintf("🎭 **TwinBee — Status, Day %d**\n\n", exp.CurrentDay)) + b.WriteString(fmt.Sprintf("🎭 **TwinBee — Status, Day %d / ~%d expected**\n\n", exp.CurrentDay, target)) b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier))) if r, ok := CurrentRegion(exp); ok { cleared := IsRegionCleared(exp, r.ID) @@ -342,6 +417,10 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n", r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker)) } + if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil && run.TotalRooms > 0 { + b.WriteString(fmt.Sprintf("🚪 **Rooms:** %d / %d in this region\n", + run.CurrentRoom+1, run.TotalRooms)) + } if c != nil { b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax)) } @@ -376,6 +455,19 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool depletionLabel(state))) } } + if entries, lerr := recentExpeditionLog(exp.ID, 3); lerr == nil && len(entries) > 0 { + b.WriteString("\n**Recent:**\n") + for _, e := range entries { + line := e.Summary + if line == "" { + line = e.Flavor + } + if line == "" { + continue + } + b.WriteString(fmt.Sprintf(" · _Day %d_ — %s\n", e.Day, line)) + } + } b.WriteString(fmt.Sprintf("\nStarted: %s Last activity: %s", exp.StartDate.Format("2006-01-02 15:04"), exp.LastActivity.Format("2006-01-02 15:04"))) @@ -484,11 +576,17 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error { if err := abandonExpedition(ctx.Sender); err != nil { return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error()) } + markActedToday(ctx.Sender) _ = retireAllRegionRuns(exp) _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "") - return p.SendDM(ctx.Sender, fmt.Sprintf( + if err := p.SendDM(ctx.Sender, fmt.Sprintf( "Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.", - zone.Display, exp.CurrentDay)) + zone.Display, exp.CurrentDay)); err != nil { + return err + } + // Emergence seam: see maybeRollPetArrivalOnEmerge. + p.maybeRollPetArrivalOnEmerge(ctx.Sender) + return nil } // helper: ensure we don't shadow id.UserID import in test harness. @@ -545,11 +643,23 @@ 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) } - return p.streamFlow(ctx.Sender, r.stream, r.finalMsg) + // Emergence seam: a natural run-complete (boss down / dead-end node) + // surfaces the player alive just like an extract or abandon — roll pet + // arrival here too. The roll lives in the real callers, not in + // runAutopilotWalk, so the sim path (which calls the walk directly) + // never fires arrival DMs. See maybeRollPetArrivalOnEmerge. Defer it + // behind the paced stream so the "animal in your house" DM lands after + // the "Run complete" beat, not before it. + var after func() + if r.reason == stopComplete { + uid := ctx.Sender + after = func() { p.maybeRollPetArrivalOnEmerge(uid) } + } + return p.streamFlowThen(ctx.Sender, r.stream, r.finalMsg, after) } // runAutopilotWalk runs the autopilot loop up to maxRooms times and @@ -558,7 +668,46 @@ 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 { +// forkAutoPickTimeout — how long a background fork may sit unanswered +// before the autopilot picks an available route itself. Short enough that +// the expedition keeps moving rather than idling out to the 24h stale-run +// reaper; long enough that a player away for the evening still gets first +// say on a genuine fork. +const forkAutoPickTimeout = 8 * time.Hour + +// autoPickStaleFork commits the first unlocked option of a stale background +// fork, advancing the run to that node exactly as `!zone go ` would +// (advanceZoneRunNode + region-transition hook). Returns false — no pick — +// when every option is locked, so the caller re-emits the prompt and the +// run idles on toward the reaper. The choice is logged as a narrative entry +// so the end-of-day digest can surface the decision the player missed. +func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool { + var chosen *pendingChoice + for i := range pf.Options { + if pf.Options[i].Unlocked { + chosen = &pf.Options[i] + break + } + } + if chosen == nil { + return false // nothing unlocked — leave it for the player / reaper + } + if err := advanceZoneRunNode(run.RunID, chosen.To); err != nil { + slog.Warn("expedition: auto-pick stale fork", + "user", run.UserID, "run", run.RunID, "err", err) + return false + } + g, _ := loadZoneGraph(run.ZoneID) + fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To]) + if exp != nil { + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", + fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s", + int(forkAutoPickTimeout.Hours()), chosen.Label), "") + } + return true +} + +func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult { exp, err := getActiveExpedition(ctx.Sender) if err != nil { return autopilotWalkResult{initErr: "Couldn't read expedition state: " + err.Error()} @@ -570,6 +719,31 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."} } + // Already standing at a pending fork. The autopilot can't pick for the + // player, so a fresh fork re-emits the prompt with rooms=0 (background + // DM suppression keeps quiet; the rooms counter doesn't tick on a no-op + // walk). But a background fork left unanswered past forkAutoPickTimeout + // would otherwise idle all the way to the 24h stale-run reaper and end + // the expedition — so once it's stale, auto-pick the first available + // (unlocked) route and keep walking instead of stalling out. + if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil { + if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil { + picked := compact && + time.Since(run.LastActionAt) > forkAutoPickTimeout && + p.autoPickStaleFork(exp, run, pf) + if !picked { + zone := zoneOrFallback(run.ZoneID) + return autopilotWalkResult{ + finalMsg: renderForkPrompt(zone, *pf), + rooms: 0, + reason: stopFork, + } + } + // Auto-picked: the run now points at the chosen node. Fall + // through into the walk loop so this same tick advances it. + } + } + var stream []string var finalMsg string rooms := 0 @@ -593,7 +767,7 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com } } - res, aerr := p.advanceOnceWithOpts(ctx, compact) + res, aerr := p.advanceOnceWithOpts(ctx, compact, inlineBossCombat) if aerr != nil { return autopilotWalkResult{initErr: aerr.Error()} } @@ -608,10 +782,52 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com // Doorway/blocked stops fire *before* the current room actually // resolved — those don't count as a walked room. Everything else // (OK, fork after a clear, ended after combat, complete) does. - if res.reason != stopBlocked && res.reason != stopElite && res.reason != stopBoss { + // stopBossSafety also fires at the doorway (compact autopilot + // bailed before engaging), so it doesn't count either. + if res.reason != stopBlocked && res.reason != stopElite && res.reason != stopBoss && res.reason != stopBossSafety { rooms++ } + // Multi-region auto-advance: a mid-zone region clear completes the + // region's run (stopComplete) but leaves the wrapping expedition + // active. Rather than dead-stopping the walk at every region + // boundary, cross into the next region — burning the transit day + + // supplies exactly like manual `!region travel` — and keep walking + // within the remaining room budget. A full zone clear instead flips + // the expedition to 'complete' (getActiveExpedition → nil) and falls + // through to the normal stop below. + if res.reason == stopComplete { + if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil && + IsMultiRegionZone(fresh.ZoneID) { + if cur, ok := CurrentRegion(fresh); ok { + if next, ok := nextRegion(fresh.ZoneID, cur.ID); ok { + // A region crossing burns a transit day + supplies and + // draws unprotected wandering damage. On the background + // walk, don't cross while the player is weak — preflight + // HP/SU and hand the crossing back to a foreground + // `!region travel` / `!expedition run` if either is low. + if compact { + if msg, stop := autopilotPreflight(ctx.Sender, fresh); stop { + finalMsg = res.final + "\n\n" + msg + reason = stopPreflight + break + } + } + stream = append(stream, res.final) + transit, terr := p.advanceToNextRegion(ctx.Sender, fresh, cur, next) + if terr != nil { + finalMsg = res.final + "\n\n⏸ **Autopilot paused — region transit failed.** `!region travel` to cross over manually." + reason = stopComplete + break + } + stream = append(stream, transit) + exp = fresh + continue + } + } + } + } + if res.reason != stopOK { footer := autopilotFooter(res.reason, rooms) if footer != "" { @@ -632,15 +848,16 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com exp = fresh } - // Arrived at a Boss doorway: stop here. The "Room X/Y — Boss. - // !fight when ready." line in res.final already tells the player - // what to do; another loop iteration would just hit the gate and - // emit a duplicate "Room X/Y — Boss" message. + // Arrived at an Elite/Boss doorway. Foreground stops here so the + // player can decide; the "Room X/Y — Boss. !fight when ready." + // line in res.final already tells them what to do. // - // For Elite + non-compact, do the same. In compact mode we let - // the next iteration run because the gate will auto-resolve the - // elite inline (which is the whole point of compact mode). - if res.nextRoomType == RoomBoss || (res.nextRoomType == RoomElite && !compact) { + // In compact mode (background autopilot, long-expedition D2/D3) + // we let the next iteration run because the gate will auto- + // resolve the encounter inline — elite always, boss when the + // safety check passes (otherwise the gate returns stopBossSafety + // and the autorun ticker pitches a rest camp). + if !compact && (res.nextRoomType == RoomBoss || res.nextRoomType == RoomElite) { r := stopBoss if res.nextRoomType == RoomElite { r = stopElite @@ -749,6 +966,8 @@ func autopilotFooter(reason stopReason, rooms int) string { return fmt.Sprintf("⏸ **Autopilot paused — elite ahead** (after %s). `!fight` when ready, then `!expedition run` to continue.", roomsStr) case stopBoss: return fmt.Sprintf("⏸ **Autopilot paused — boss ahead** (after %s). `!fight` when ready.", roomsStr) + case stopBossSafety: + return "" // res.final already carries the held-back-from-boss line case stopEnded: return "" // death narration is the final; no footer case stopComplete: diff --git a/internal/plugin/dnd_expedition_cmd_test.go b/internal/plugin/dnd_expedition_cmd_test.go index 97d9883..acec0ce 100644 --- a/internal/plugin/dnd_expedition_cmd_test.go +++ b/internal/plugin/dnd_expedition_cmd_test.go @@ -66,6 +66,76 @@ func TestParseSupplyArgs_Combinations(t *testing.T) { } } +func TestResolveLoadout_PresetTokens(t *testing.T) { + cases := []struct { + tok string + tier ZoneTier + std, dlx int + }{ + {"lean", ZoneTierBeginner, 1, 0}, + {"balanced", ZoneTierJourneyman, 3, 0}, + {"heavy", ZoneTierLegendary, 7, 2}, + {"h", ZoneTierVeteran, 5, 1}, + {"L", ZoneTierLegendary, 5, 0}, + } + for _, c := range cases { + got, err := resolveLoadoutOrParse(c.tok, c.tier) + if err != nil { + t.Errorf("%q@T%d: %v", c.tok, c.tier, err) + continue + } + if got.StandardPacks != c.std || got.DeluxePacks != c.dlx { + t.Errorf("%q@T%d: got %+v, want std=%d dlx=%d", c.tok, c.tier, got, c.std, c.dlx) + } + } +} + +func TestResolveLoadout_FallsBackToRawParse(t *testing.T) { + got, err := resolveLoadoutOrParse("2s 1d", ZoneTierJourneyman) + if err != nil { + t.Fatal(err) + } + if got.StandardPacks != 2 || got.DeluxePacks != 1 { + t.Errorf("raw parse: got %+v", got) + } +} + +func TestLoadoutPurchase_HeavyMatchesCaps(t *testing.T) { + for _, tier := range []ZoneTier{ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman, ZoneTierVeteran, ZoneTierLegendary} { + p := loadoutPurchase(tier, LoadoutHeavy) + std, dlx := supplyPackCaps(tier) + if p.StandardPacks != std || p.DeluxePacks != dlx { + t.Errorf("T%d heavy %+v, want std=%d dlx=%d", tier, p, std, dlx) + } + if err := p.Validate(tier); err != nil { + t.Errorf("T%d heavy fails own validation: %v", tier, err) + } + } +} + +func TestExpeditionCmd_StartNoArgsPromptsLoadout(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@exp-cmd-prompt:example") + expeditionCmdTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + euro := &EuroPlugin{} + euro.ensureBalance(uid) + euro.Credit(uid, 500, "test") + p := &AdventurePlugin{euro: euro} + + // Empty pack args: should prompt, NOT start. + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil { + t.Fatal(err) + } + if exp, _ := getActiveExpedition(uid); exp != nil { + t.Error("expedition started on empty-arg prompt; should have waited for preset choice") + } + if bal := euro.GetBalance(uid); bal != 500 { + t.Errorf("coins moved on prompt: %v", bal) + } +} + func TestThreatThresholdLabel_Bands(t *testing.T) { cases := []struct { level int @@ -119,8 +189,8 @@ func TestExpeditionCmd_StartAbandonRoundtrip(t *testing.T) { euro.Credit(uid, 200, "test setup") p := &AdventurePlugin{euro: euro} - // Default 1 standard pack = 50 coins. Should land an active expedition. - if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil { + // Lean preset = 1 standard pack at T1 = 50 coins. Should land an active expedition. + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens lean"); err != nil { t.Fatal(err) } exp, err := getActiveExpedition(uid) @@ -219,7 +289,7 @@ func TestExpeditionCmd_RunWalksRooms(t *testing.T) { euro.Credit(uid, 200, "test setup") p := &AdventurePlugin{euro: euro} - if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil { + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens lean"); err != nil { t.Fatal(err) } exp, _ := getActiveExpedition(uid) @@ -310,6 +380,7 @@ func TestAutopilotFooter_Reasons(t *testing.T) { {stopEnded, "", false}, {stopComplete, "", false}, {stopBlocked, "", false}, + {stopBossSafety, "", false}, // res.final carries the held-back line } for _, c := range cases { got := autopilotFooter(c.r, 3) @@ -323,6 +394,59 @@ func TestAutopilotFooter_Reasons(t *testing.T) { } } +// TestBossSafetyGate covers all three trip conditions (HP, supplies, +// exhaustion) and the all-clear case. The gate is the D3 boss carve-out +// replacement — compact autopilot asks it before engaging the boss. +func TestBossSafetyGate(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@exp-boss-safety-gate:example") + expeditionCmdTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + healthyExp := &Expedition{ + Supplies: ExpeditionSupplies{Current: 10, DailyBurn: 1}, + } + + // All-clear baseline: full HP, supplies fat, no exhaustion → no block. + c, _ := LoadDnDCharacter(uid) + c.HPCurrent = c.HPMax + c.Exhaustion = 0 + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + if msg, blocked := bossSafetyGate(uid, healthyExp); blocked { + t.Fatalf("expected healthy party to pass gate, blocked with %q", msg) + } + + // HP below 80% → block. + c.HPCurrent = int(float64(c.HPMax) * 0.5) + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + if msg, blocked := bossSafetyGate(uid, healthyExp); !blocked || !strings.Contains(msg, "HP") { + t.Errorf("HP gate: blocked=%v msg=%q", blocked, msg) + } + + // HP healed, but supplies under one day's burn → block. + c.HPCurrent = c.HPMax + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + lowSU := &Expedition{Supplies: ExpeditionSupplies{Current: 0.5, DailyBurn: 1.5}} + if msg, blocked := bossSafetyGate(uid, lowSU); !blocked || !strings.Contains(msg, "supplies") { + t.Errorf("SU gate: blocked=%v msg=%q", blocked, msg) + } + + // Supplies refilled, but exhaustion ≥ 3 → block. + c.Exhaustion = 3 + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + if msg, blocked := bossSafetyGate(uid, healthyExp); !blocked || !strings.Contains(msg, "exhaustion") { + t.Errorf("exhaustion gate: blocked=%v msg=%q", blocked, msg) + } +} + func TestExpeditionCmd_ListWithoutCharBlocked(t *testing.T) { setupAuditTestDB(t) uid := id.UserID("@exp-cmd-nochar:example") diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index f706174..1695921 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -32,8 +32,146 @@ import ( const ( expeditionBriefingHour = 6 expeditionRecapHour = 21 + + // nightSafetyNet — if an event-anchored expedition's last rollover is + // older than this, the 06:00 UTC briefing ticker force-fires + // processNightCamp itself. Without this, an expedition whose autopilot + // stalled (long combat, missed tick, manual halt) would drift forever + // without burning supplies or advancing days. 28h gives the autopilot + // 4h of slop past a normal day before we step in. + nightSafetyNet = 28 * time.Hour ) +// briefingIdleSkipWindow — D4-b: an event-anchored expedition skips its +// 06:00 UTC re-engagement DM when the player's last_activity is older than +// the new day's briefing threshold (i.e. they haven't moved since before +// the day rolled). The briefing then fires lazily from OnMessage on the +// next inbound message via maybeDeliverDeferredBriefing. The safety-net +// force-fire path still wins past nightSafetyNet so stalled autopilots +// never sit forever waiting on a silent player. + +// eventAnchoredCutoff — expeditions started at or after this timestamp +// use the D2-b event-anchored day-rollover model: day++/burn/threat-drift +// fire when the autopilot (or a player !camp) pitches a night camp, and +// the 06:00 UTC briefing becomes a re-engagement DM with a safety-net +// force. Expeditions started before this stay on the legacy UTC-anchored +// briefing rollover until they end. +var eventAnchoredCutoff = time.Date(2026, 5, 27, 18, 0, 0, 0, time.UTC) + +// isEventAnchored — true when this expedition uses the D2-b model. +func isEventAnchored(e *Expedition) bool { + if e == nil { + return false + } + return !e.StartDate.Before(eventAnchoredCutoff) +} + +// nightRolloverResult — the side effects processNightCamp produced, so +// callers (autopilot pitch, manual !camp, safety-net briefing) can render +// the same numbers into their own DM block. +type nightRolloverResult struct { + Burn float32 + TemporalLines []string + MilestoneLines []string + Starved bool +} + +// nightRolloverBurn — stage 1 of the day rollover: zone temporal pre-burn +// + daily supply burn + current_day++. Returns the burn applied. Callers +// follow this with applyCampRest (if pitching) and then nightRolloverDrift +// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp +// between the two so a fortified camp's −5 lands before drift's +3. +func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) { + // D5-c: Ranger forage runs before the daily burn so the +SU lands on + // today's supplies, not tomorrow's. Logged so the end-of-day digest + // can surface the gain; pure no-op for non-Ranger characters. + if c, err := LoadDnDCharacter(id.UserID(e.UserID)); err == nil && c != nil { + if gain := applyRangerForage(e, c, nil); gain > 0 { + _ = appendExpeditionLog(e.ID, e.CurrentDay, "forage", + fmt.Sprintf("ranger forage +%g SU", gain), + flavor.Pick(flavor.HarvestForageSuccess)) + } + } + burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1) + var newSupplies ExpeditionSupplies + var burn float32 + if burnOverride.Multiplier > 0 { + burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(phase5BDailyBurnRatePct) / 100 + newSupplies = e.Supplies + newSupplies.Current -= burn + if newSupplies.Current < 0 { + newSupplies.Current = 0 + } + newSupplies.ForagedToday = false + } else { + harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e) + newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode) + } + if err := updateSupplies(e.ID, newSupplies); err != nil { + return 0, err + } + if err := advanceExpeditionDay(e.ID); err != nil { + return 0, err + } + e.Supplies = newSupplies + e.CurrentDay++ + return burn, nil +} + +// nightRolloverDrift — stage 2: daily threat drift, zone temporal post- +// rollover, starvation check, max-threat record, milestones. Stamps +// last_briefing_at = now so the UTC briefing ticker treats today as +// already-rolled. `now` is the wallclock to stamp; callers that already +// did the stamp via a CAS (deliverBriefing) pass time.Time{} to skip. +func (p *AdventurePlugin) nightRolloverDrift(e *Expedition, now time.Time) nightRolloverResult { + var out nightRolloverResult + if _, _, err := applyDailyThreatDrift(e); err != nil { + slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err) + } + out.TemporalLines = applyZoneTemporalPostRollover(e) + + if supplyDepletion(e.Supplies) == SupplyStarvation && e.Status == ExpeditionStatusActive { + _, _, _ = forcedExtractExpedition(e.ID, "starvation") + e.Status = ExpeditionStatusAbandoned + line := flavor.Pick(flavor.ExtractionForced) + _ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative", + "forced extraction: starvation", line) + out.Starved = true + } + if e.Status == ExpeditionStatusAbandoned && p.euro != nil { + tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac) + if tax > 0 { + p.euro.Debit(id.UserID(e.UserID), float64(tax), + "forced extraction tax") + } + } + _ = recordMaxThreat(e) + out.MilestoneLines = p.checkDailyMilestones(e) + + if !now.IsZero() { + if _, err := db.Get().Exec(` + UPDATE dnd_expedition + SET last_briefing_at = ? + WHERE expedition_id = ?`, now, e.ID); err == nil { + e.LastBriefingAt = &now + } + } + return out +} + +// processNightCamp — burn + drift in one go, no rest in between. Used by +// callers (autopilot pitch, manual !camp, event-anchored safety net) that +// either apply their own rest separately or don't apply one at all. +func (p *AdventurePlugin) processNightCamp(e *Expedition) (nightRolloverResult, error) { + burn, err := p.nightRolloverBurn(e) + if err != nil { + return nightRolloverResult{}, err + } + out := p.nightRolloverDrift(e, time.Now().UTC()) + out.Burn = burn + return out, nil +} + // expeditionBriefingTicker — 06:00 UTC daily briefing. func (p *AdventurePlugin) expeditionBriefingTicker() { ticker := time.NewTicker(1 * time.Minute) @@ -91,6 +229,24 @@ func (p *AdventurePlugin) fireExpeditionBriefings(now time.Time) { slog.Info("expedition: briefing deferred — player in combat session", "expedition", e.ID, "user", e.UserID) continue } + // D4-b: skip the ticker DM for event-anchored expeditions whose + // player has been idle past the new day's threshold. The safety- + // net force path (handled inside deliverBriefingEventAnchored) + // still has to run when the autopilot stalled past nightSafetyNet, + // so only skip when both the player is idle AND we're not in the + // safety-net window. + if isEventAnchored(e) && e.LastActivity.Before(threshold) { + var since time.Duration + if e.LastBriefingAt != nil { + since = now.Sub(*e.LastBriefingAt) + } else { + since = now.Sub(e.StartDate) + } + if since <= nightSafetyNet { + slog.Info("expedition: briefing deferred — player idle, awaiting next inbound", "expedition", e.ID, "user", e.UserID) + continue + } + } if err := p.deliverBriefing(e, now); err != nil { slog.Error("expedition: briefing", "expedition", e.ID, "err", err) } @@ -176,14 +332,17 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) { return out, rows.Err() } -// deliverBriefing rolls a day forward, applies supply burn, posts the -// morning briefing DM, appends a log entry, and stamps last_briefing_at. +// deliverBriefing posts the morning briefing DM. For legacy UTC-anchored +// expeditions it also drives the day rollover (supply burn, day++, threat +// drift) via processNightCamp. For event-anchored expeditions (D2-b) the +// rollover is owned by the autopilot's night-camp pitch; the briefing +// ticker only re-engages the player and force-fires the rollover after a +// safety-net window. // -// Idempotency: the first thing we do is an atomic compare-and-set on -// last_briefing_at. If another invocation (clock skew, restart, double -// fire) already claimed today's rollover, rowsAffected == 0 and we bail -// without re-applying supply burn / day++ / threat drift. +// Idempotency: atomic compare-and-set on last_briefing_at gates the body. +// A double-fire on the same expedition is a no-op. func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { + priorBriefing := e.LastBriefingAt threshold := time.Date(now.Year(), now.Month(), now.Day(), expeditionBriefingHour, 0, 0, 0, time.UTC) res, err := db.Get().Exec(` @@ -200,45 +359,24 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { if n, _ := res.RowsAffected(); n == 0 { return nil // already delivered for this day } + e.LastBriefingAt = &now - // E3: zone-specific temporal events fire BEFORE applyDailyBurn and - // can override the entire burn calculation with a fixed multiplier - // (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.). - burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1) - - // Advance day + supply burn happen together at the morning rollover. - var newSupplies ExpeditionSupplies - var burn float32 - if burnOverride.Multiplier > 0 { - // Phase 5-B: temporal overrides bypass applyDailyBurn, so apply - // the same global burn-rate multiplier explicitly here. Without - // this, tidal/unraveling days would burn at pre-Phase-5-B rates - // while normal days burn at half — an inconsistency the user - // would feel as "tidal days are now disproportionately harsh." - burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(phase5BDailyBurnRatePct) / 100 - newSupplies = e.Supplies - newSupplies.Current -= burn - if newSupplies.Current < 0 { - newSupplies.Current = 0 - } - newSupplies.ForagedToday = false - } else { - harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e) - newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode) + // D2-b: event-anchored expeditions own the day rollover via the + // autopilot night camp. The 06:00 ticker either posts a re-engagement + // DM (rollover happened recently) or force-fires processNightCamp + // itself (safety net for stalled autopilots). + if isEventAnchored(e) { + return p.deliverBriefingEventAnchored(e, priorBriefing) } - if err := updateSupplies(e.ID, newSupplies); err != nil { + + burn, err := p.nightRolloverBurn(e) + if err != nil { return err } - if err := advanceExpeditionDay(e.ID); err != nil { - return err - } - e.Supplies = newSupplies - e.CurrentDay++ - // E2d: overnight camp rest effects (HP/spells/threat). Auto-breaks - // the camp after applying. Run before threat drift so a fortified - // camp's −5 lands first and a same-day +3 drift can't push back over - // a threshold the rest just dropped. + // E2d: overnight camp rest. Runs after burn/day++ but before drift so + // a fortified camp's −5 lands first and a same-day +3 drift can't push + // back over a threshold the rest just dropped. restSummary := processOvernightCamp(e) if restSummary != "" { if fresh, err := getExpedition(e.ID); err == nil && fresh != nil { @@ -248,61 +386,51 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { } } - // E2a: daily threat drift (+3 base, GM-mood modifier). No-op after - // boss kill. May cross a threshold and append a flavor-bearing log. - if _, _, err := applyDailyThreatDrift(e); err != nil { - slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err) - } - - // E3: zone temporal events post-rollover narration (after the day - // has advanced, so e.CurrentDay reflects the new day). - temporalLines := applyZoneTemporalPostRollover(e) - - // §4.3: starvation triggers forced extraction. With no CON tracking - // in this layer, the briefing-time check is the practical equivalent - // of "CON reaches 0" — a starvation morning means the player can't - // reasonably press on. Apply the §10.2 coin tax and flip status. - if supplyDepletion(e.Supplies) == SupplyStarvation && e.Status == ExpeditionStatusActive { - _, _, _ = forcedExtractExpedition(e.ID, "starvation") - e.Status = ExpeditionStatusAbandoned - line := flavor.Pick(flavor.ExtractionForced) - _ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative", - "forced extraction: starvation", line) - } - - // E5b: if a temporal event (or starvation above) forced extraction, - // apply the §10.2 coin tax. The temporal layer flips the row to - // 'abandoned'; the cycle holds the euro handle to do the debit. - if e.Status == ExpeditionStatusAbandoned && p.euro != nil { - tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac) - if tax > 0 { - p.euro.Debit(id.UserID(e.UserID), float64(tax), - "forced extraction tax") - } - } - - // E6b: sample today's threat into RegionState["max_threat_seen"] before - // any milestone check reads it; then award daily milestones reached by - // the new day count (First Night day 2, Week One day 8, Two Weeks day 15). - _ = recordMaxThreat(e) - milestoneLines := p.checkDailyMilestones(e) + // Pass time.Time{} — the CAS at the top of deliverBriefing already + // stamped last_briefing_at with the synthetic now; don't overwrite it. + roll := p.nightRolloverDrift(e, time.Time{}) + roll.Burn = burn line := pickMorningBriefing(e.CurrentDay) body := renderMorningBriefing(e, line, burn) if restSummary != "" { body += "\n💤 _" + restSummary + "_\n" } - for _, tl := range temporalLines { + for _, tl := range roll.TemporalLines { body += "\n🌀 " + tl + "\n" } - for _, ml := range milestoneLines { + for _, ml := range roll.MilestoneLines { body += "\n" + ml } if uid := id.UserID(e.UserID); uid != "" { + // The legacy overworld morning DM is skipped while underground, so + // its 25% morning pet event fires here instead, granting the one-day + // defense buff (reset at midnight via resetAllPetMorningDefense). + // Pet *arrival* is handled separately on the emergence seam below — + // not queued here — so we only roll the morning event. + if pet, perr := loadPetState(uid); perr == nil { + if petEvent := petMorningEvent(pet); petEvent != "" { + if char, cerr := loadAdvCharacter(uid); cerr == nil { + char.PetMorningDefense = true + if serr := saveAdvCharacter(char); serr != nil { + slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr) + } + } + body = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body) + } + } if err := p.SendDM(uid, body); err != nil { slog.Warn("expedition: send briefing DM", "user", uid, "err", err) } + // Emergence seam: a briefing-time forced extraction (starvation / + // abyss collapse) surfaces the player alive — roll pet arrival. + // Combat/patrol deaths never reach deliverBriefing (the row is + // already abandoned), so an abandoned status here means a survived + // emergence; those death paths roll on respawn instead. + if e.Status == ExpeditionStatusAbandoned { + p.maybeRollPetArrivalOnEmerge(uid) + } } if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing", fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil { @@ -311,6 +439,130 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { return nil } +// maybeDeliverDeferredBriefing — D4-b lazy-delivery hook. When the 06:00 +// UTC ticker skips an event-anchored expedition because the player was +// idle, the morning DM is posted here on their next inbound message. +// Cheap fast paths (no expedition, not event-anchored, briefing already +// stamped past today's threshold) keep the per-message cost to one +// indexed row lookup. Idempotency rides on deliverBriefing's CAS. +func (p *AdventurePlugin) maybeDeliverDeferredBriefing(uid id.UserID, now time.Time) { + if uid == "" { + return + } + exp, err := getActiveExpedition(uid) + if err != nil || exp == nil || !isEventAnchored(exp) { + return + } + // Stamp presence: per-D4-b, any inbound message in any room counts as + // "the player is here." The ticker's idle-skip reads last_activity to + // decide whether to suppress the 06:00 DM, so we update it on every + // message — not just bot commands. + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET last_activity = ? WHERE expedition_id = ?`, + now, exp.ID); err != nil { + slog.Warn("expedition: stamp activity", "user", uid, "err", err) + } + // Only lazy-deliver when a briefing is actually owed: a previous + // briefing exists (so we know the cadence is live) or the autopilot + // has rolled past day 1 without one (so a rollover happened in the + // player's absence). Day-1 inbounds shouldn't trigger a briefing + // before the first night camp has even happened. + if exp.LastBriefingAt == nil && exp.CurrentDay <= 1 { + return + } + // Don't lazy-deliver before today's 06:00 UTC threshold. The + // deliverBriefing CAS keys off the same threshold, so a pre-06:00 + // fire would double-emit when the 06:00 ticker sweep arrives. + threshold := time.Date(now.Year(), now.Month(), now.Day(), + expeditionBriefingHour, 0, 0, 0, time.UTC) + if now.Before(threshold) { + return + } + if exp.LastBriefingAt != nil && !exp.LastBriefingAt.Before(threshold) { + return + } + if hasActiveCombatSession(uid) { + return + } + if err := p.deliverBriefing(exp, now); err != nil { + slog.Warn("expedition: deferred briefing", "user", uid, "err", err) + } +} + +// deliverBriefingEventAnchored — D2-b 06:00 UTC ticker for event-anchored +// expeditions. The autopilot's night-camp pitch owns day++/burn/threat- +// drift; the ticker just re-engages the player. If the autopilot has +// stalled past nightSafetyNet we force-fire processNightCamp ourselves so +// the expedition doesn't sit frozen. +// +// priorBriefing is the last_briefing_at value as of entry into deliverBriefing +// (before the CAS clobbered it). nil means day-1 or genuinely never rolled. +func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBriefing *time.Time) error { + now := time.Now().UTC() + var since time.Duration + if priorBriefing != nil { + since = now.Sub(*priorBriefing) + } else { + since = now.Sub(e.StartDate) + } + forced := since > nightSafetyNet + + var ( + burn float32 + temporalLines []string + mileLines []string + ) + if forced { + roll, err := p.processNightCamp(e) + if err != nil { + return err + } + burn = roll.Burn + temporalLines = roll.TemporalLines + mileLines = roll.MilestoneLines + } + + line := pickMorningBriefing(e.CurrentDay) + body := renderMorningBriefing(e, line, burn) + if forced { + body += "\n_The autopilot stalled overnight; the day rolled over without rest._\n" + } + for _, tl := range temporalLines { + body += "\n🌀 " + tl + "\n" + } + for _, ml := range mileLines { + body += "\n" + ml + } + + if uid := id.UserID(e.UserID); uid != "" { + if pet, perr := loadPetState(uid); perr == nil { + if petEvent := petMorningEvent(pet); petEvent != "" { + if char, cerr := loadAdvCharacter(uid); cerr == nil { + char.PetMorningDefense = true + if serr := saveAdvCharacter(char); serr != nil { + slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr) + } + } + body = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body) + } + } + if err := p.SendDM(uid, body); err != nil { + slog.Warn("expedition: send briefing DM", "user", uid, "err", err) + } + if forced && e.Status == ExpeditionStatusAbandoned { + p.maybeRollPetArrivalOnEmerge(uid) + } + } + summary := "morning re-engagement" + if forced { + summary = fmt.Sprintf("safety-net rollover — %.1f SU consumed overnight", burn) + } + if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing", summary, line); err != nil { + return err + } + return nil +} + // deliverRecap posts the evening recap DM, appends a log entry, and stamps // last_recap_at. No supply burn here — that's the briefing's job. // Idempotency: same pattern as deliverBriefing — claim last_recap_at first. diff --git a/internal/plugin/dnd_expedition_cycle_test.go b/internal/plugin/dnd_expedition_cycle_test.go index da10d90..b2bd358 100644 --- a/internal/plugin/dnd_expedition_cycle_test.go +++ b/internal/plugin/dnd_expedition_cycle_test.go @@ -14,6 +14,20 @@ import ( // synthetic "now" and verify state transitions. Ticker scheduling is a // thin wrapper over those. +// rewindToLegacyAnchor backdates an expedition's start_date to before +// eventAnchoredCutoff so deliverBriefing exercises the legacy UTC-anchored +// mutator path. Tests of the D2-b event-anchored path should NOT call this. +func rewindToLegacyAnchor(t *testing.T, exp *Expedition) { + t.Helper() + before := eventAnchoredCutoff.Add(-24 * time.Hour) + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`, + before, exp.ID); err != nil { + t.Fatal(err) + } + exp.StartDate = before +} + func TestPickMorningBriefing_DayBands(t *testing.T) { cases := []struct { day int @@ -57,6 +71,7 @@ func TestDeliverBriefing_AdvancesDayAndBurnsSupplies(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) p := &AdventurePlugin{} now := time.Now().UTC() if err := p.deliverBriefing(exp, now); err != nil { @@ -87,6 +102,174 @@ func TestDeliverBriefing_AdvancesDayAndBurnsSupplies(t *testing.T) { } } +// useEventAnchored fences the eventAnchoredCutoff to before the given +// expedition's start_date so the D2-b path is taken. Test-scoped via t.Cleanup. +func useEventAnchored(t *testing.T, exp *Expedition) { + t.Helper() + saved := eventAnchoredCutoff + eventAnchoredCutoff = exp.StartDate.Add(-time.Minute) + t.Cleanup(func() { eventAnchoredCutoff = saved }) +} + +func TestDeliverBriefing_EventAnchoredSkipsMutators(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-evt-skip:example") + 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) + + // last_briefing_at is NULL and start_date is "now-ish", so the safety + // net should NOT fire — sub-28h since start. + p := &AdventurePlugin{} + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.CurrentDay != 1 { + t.Errorf("day = %d, want 1 (event-anchored briefing should not advance day)", got.CurrentDay) + } + if got.Supplies.Current != 10 { + t.Errorf("supplies = %v, want 10 (event-anchored briefing should not burn)", got.Supplies.Current) + } +} + +func TestDeliverBriefing_EventAnchoredSafetyNetForces(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-evt-safety:example") + 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) + + // Backdate start_date so > nightSafetyNet has elapsed with no rollover. + before := time.Now().UTC().Add(-(nightSafetyNet + time.Hour)) + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`, + before, exp.ID); err != nil { + t.Fatal(err) + } + exp.StartDate = before + + p := &AdventurePlugin{} + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.CurrentDay != 2 { + t.Errorf("day = %d, want 2 (safety net should force rollover)", got.CurrentDay) + } + if got.Supplies.Current >= 10 { + t.Errorf("supplies = %v, want < 10 (safety net should burn)", got.Supplies.Current) + } +} + +// D4-b: the 06:00 ticker skips event-anchored expeditions whose player +// hasn't moved since before today's threshold; the briefing lands lazily +// on the next inbound message via maybeDeliverDeferredBriefing. +func TestFireBriefings_EventAnchoredIdleSkip(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-d4b-idle:example") + 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) + + // Synthetic now pinned past today's 06:00 UTC so the test outcome is + // independent of wall-clock time of day. + wall := time.Now().UTC() + // Synthetic now is today's 06:30 UTC — just past the ticker threshold, + // but well inside the 28h nightSafetyNet window so the safety-net + // force path doesn't kick in. + now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC) + threshold := time.Date(now.Year(), now.Month(), now.Day(), + expeditionBriefingHour, 0, 0, 0, time.UTC) + idleActivity := threshold.Add(-2 * time.Hour) + priorBriefing := now.Add(-24 * time.Hour) + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET current_day = 3 WHERE expedition_id = ?`, + exp.ID); err != nil { + t.Fatal(err) + } + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`, + idleActivity, priorBriefing, exp.ID); err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{} + p.fireExpeditionBriefings(now) + + got, _ := getExpedition(exp.ID) + if got.LastBriefingAt == nil || !got.LastBriefingAt.Equal(priorBriefing) { + t.Fatalf("idle ticker should not have stamped last_briefing_at: got %v want %v", + got.LastBriefingAt, priorBriefing) + } + + // Simulate inbound message: lazy delivery should fire now. + p.maybeDeliverDeferredBriefing(uid, now) + got, _ = getExpedition(exp.ID) + if got.LastBriefingAt == nil || got.LastBriefingAt.Equal(priorBriefing) { + t.Fatalf("deferred delivery should have stamped a fresh last_briefing_at: got %v", + got.LastBriefingAt) + } +} + +// D4-b: an active player (last_activity >= today's threshold) still gets +// the morning DM on the regular ticker — no idle skip. +func TestFireBriefings_EventAnchoredActivePlayerDelivers(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-d4b-active:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + wall := time.Now().UTC() + now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC) + threshold := time.Date(now.Year(), now.Month(), now.Day(), + expeditionBriefingHour, 0, 0, 0, time.UTC) + activeActivity := threshold.Add(15 * time.Minute) + priorBriefing := now.Add(-24 * time.Hour) + // Pin start_date before today's threshold. Left at the default (real + // time.Now()), a suite run after 06:00 UTC lands start_date past the + // threshold and loadExpeditionsNeedingBriefing (start_date < threshold) + // filters the row out — a wall-clock-of-day flake. useEventAnchored runs + // after, so its cutoff tracks the backdated start and the run stays + // event-anchored. + startAt := now.Add(-24 * time.Hour) + exp.StartDate = startAt + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET start_date = ?, last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`, + startAt, activeActivity, priorBriefing, exp.ID); err != nil { + t.Fatal(err) + } + useEventAnchored(t, exp) + + p := &AdventurePlugin{} + p.fireExpeditionBriefings(now) + + got, _ := getExpedition(exp.ID) + if got.LastBriefingAt == nil || got.LastBriefingAt.Equal(priorBriefing) { + t.Fatalf("active player should have received briefing: last_briefing_at=%v", + got.LastBriefingAt) + } +} + func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@exp-cycle-harsh:example") @@ -97,6 +280,7 @@ func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) // Force harsh conditions via threat clock above 60. if err := applyThreatDelta(exp.ID, 70, "test"); err != nil { t.Fatal(err) diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 401f988..6296bee 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -110,6 +110,89 @@ func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) { } } +// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down, +// no outgoing edges) into expedition completion — the success-path twin of +// forceExtractExpeditionForRunLoss. When the cleared run is the active +// expedition's current run AND the clear finishes the whole zone (a +// single-region zone, or the zone-boss region of a multi-region zone), it +// flips the expedition to 'complete', records boss_defeated, and awards +// completion milestones. Returns the rendered milestone lines for the caller +// to append to the run-complete message. +// +// No-op (nil) for standalone runs and for mid-zone region clears, which +// leave the expedition active so inter-region travel can continue. Without +// this, a cleared zone leaves the expedition 'active' forever and the +// ambient ticker keeps DMing about a dungeon the player already finished. +func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID string) []string { + exp, err := getActiveExpedition(userID) + if err != nil || exp == nil { + return nil + } + if exp.RunID != runID { + return nil // the completed run isn't this expedition's current run + } + + if IsMultiRegionZone(exp.ZoneID) { + region, ok := CurrentRegion(exp) + if !ok { + return nil + } + if _, err := MarkRegionBossDefeated(exp, region.ID); err != nil { + slog.Warn("expedition: mark region boss defeated", + "user", userID, "expedition", exp.ID, "region", region.ID, "err", err) + } + if !region.IsZoneBoss { + return nil // region cleared; expedition continues to the next region + } + } else { + // Single-region zone: there's no region registry to flip through + // MarkRegionBossDefeated, so set the zone-level flag directly. + exp.BossDefeated = true + if _, err := db.Get().Exec(` + UPDATE dnd_expedition + SET boss_defeated = 1, + last_activity = CURRENT_TIMESTAMP + WHERE expedition_id = ?`, exp.ID); err != nil { + slog.Warn("expedition: set boss defeated on zone clear", + "user", userID, "expedition", exp.ID, "err", err) + } + } + + // completeExpedition must run before AwardCompletionMilestones — the + // latter gates on status == 'complete'. + if err := completeExpedition(exp.ID, ExpeditionStatusComplete); err != nil { + slog.Warn("expedition: complete on zone clear", + "user", userID, "expedition", exp.ID, "err", err) + return nil + } + exp.Status = ExpeditionStatusComplete + _ = retireAllRegionRuns(exp) + return p.AwardCompletionMilestones(exp, false) +} + +// midZoneRegionClear reports whether the just-completed run (runID) is the +// active expedition's current run AND finishes a non-boss region of a +// multi-region zone — i.e. a region clear that leaves the expedition active +// with a next region to cross into. Returns the cleared region, the next +// region, and true in that case; zero-values + false for a full zone clear, +// a standalone run, or any read error. Used to word the run-complete message +// (region-cleared vs zone-cleared) without re-deriving region state inline. +func midZoneRegionClear(userID id.UserID, runID string) (cur, next ExpeditionRegion, ok bool) { + exp, err := getActiveExpedition(userID) + if err != nil || exp == nil || exp.RunID != runID || !IsMultiRegionZone(exp.ZoneID) { + return ExpeditionRegion{}, ExpeditionRegion{}, false + } + region, found := CurrentRegion(exp) + if !found || region.IsZoneBoss { + return ExpeditionRegion{}, ExpeditionRegion{}, false + } + nxt, found := nextRegion(exp.ZoneID, region.ID) + if !found { + return ExpeditionRegion{}, ExpeditionRegion{}, false + } + return region, nxt, true +} + // getResumableExpedition returns the most recent 'extracting' row for the // user, regardless of age (caller checks the 7-day window). func getResumableExpedition(userID id.UserID) (*Expedition, error) { @@ -169,6 +252,7 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { line := flavor.Pick(flavor.ExtractionVoluntary) _ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative", "voluntary extraction", line) + markActedToday(ctx.Sender) var b strings.Builder b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n", @@ -179,7 +263,13 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { } b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.", (time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST"))) - return p.SendDM(ctx.Sender, b.String()) + if err := p.SendDM(ctx.Sender, b.String()); err != nil { + return err + } + // Emergence seam: surfacing from a run is when an animal may have moved + // into the empty house. + p.maybeRollPetArrivalOnEmerge(ctx.Sender) + return nil } // ── !resume command ───────────────────────────────────────────────────────── @@ -218,11 +308,16 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error "That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.") } - purchase, err := parseSupplyArgs(strings.TrimSpace(args)) + resumeZone, _ := getZone(exp.ZoneID) + // D5-b: prompt for a preset loadout on empty args. + if strings.TrimSpace(args) == "" { + return p.SendDM(ctx.Sender, renderLoadoutPrompt(resumeZone, "resume")) + } + purchase, err := resolveLoadoutOrParse(strings.TrimSpace(args), resumeZone.Tier) if err != nil { return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) } - if err := purchase.Validate(); err != nil { + if err := purchase.Validate(resumeZone.Tier); err != nil { return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) } cost := float64(purchase.Cost()) diff --git a/internal/plugin/dnd_expedition_map.go b/internal/plugin/dnd_expedition_map.go index a5c4e75..68777cf 100644 --- a/internal/plugin/dnd_expedition_map.go +++ b/internal/plugin/dnd_expedition_map.go @@ -119,6 +119,9 @@ func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) e b.WriteString(renderZoneGraphMap(g, run)) b.WriteString("\n```\n") b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_") + if path := renderVisitedPath(g, run); path != "" { + b.WriteString("\n**Path:** " + path) + } if chain != "" { b.WriteString("\n_Regions: ") b.WriteString(renderRegionLegend(exp)) diff --git a/internal/plugin/dnd_expedition_region_cmd.go b/internal/plugin/dnd_expedition_region_cmd.go index 84d0b1e..417a626 100644 --- a/internal/plugin/dnd_expedition_region_cmd.go +++ b/internal/plugin/dnd_expedition_region_cmd.go @@ -6,6 +6,8 @@ import ( "strings" "gogobee/internal/flavor" + + "maunium.net/go/mautrix/id" ) // Phase 12 E4c — !region command surface and inter-region travel. @@ -120,24 +122,41 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e "You're already in **%s** — the final region of this zone. Defeat the zone boss to complete the expedition.", cur.Name)) } + narrative, err := p.advanceToNextRegion(ctx.Sender, exp, cur, next) + if err != nil { + return p.SendDM(ctx.Sender, "Region transit failed: "+err.Error()) + } + return p.SendDM(ctx.Sender, narrative) +} + +// advanceToNextRegion performs an inter-region transition: burns the transit +// day + supplies, fires the transit wandering check, retires the outgoing +// region's DungeonRun, bumps CurrentRegion + the visited list, and spawns the +// incoming region's run (pinning exp.RunID). Returns the rendered transit +// narrative block. Shared by the manual `!region travel` command and the +// autopilot walk's mid-zone auto-advance — keeping the transit cost identical +// on both paths. +func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) { // Burn one day of supplies (transit day). siege := exp.SiegeMode harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp) newSupplies, burned := applyDailyBurn(exp.Supplies, harsh, siege) exp.Supplies = newSupplies if err := updateSupplies(exp.ID, exp.Supplies); err != nil { - return p.SendDM(ctx.Sender, "Couldn't apply transit supply burn: "+err.Error()) + return "", fmt.Errorf("apply transit supply burn: %w", err) } if err := advanceExpeditionDay(exp.ID); err != nil { - return p.SendDM(ctx.Sender, "Couldn't advance the expedition day: "+err.Error()) + return "", fmt.Errorf("advance expedition day: %w", err) } exp.CurrentDay++ // Transit wandering check (no camp protection — campMod = 0). - c, _ := LoadDnDCharacter(ctx.Sender) + c, _ := LoadDnDCharacter(userID) var charClass DnDClass + charLevel := 1 if c != nil { charClass = c.Class + charLevel = c.Level } tc := resolveTransitWanderingCheck(exp, charClass, nil) _ = processTransitWanderingCheck(exp, tc) @@ -145,24 +164,20 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e // R2 — retire the outgoing region's DungeonRun before mutating // CurrentRegion so retireRegionRun keys the right region. if err := retireRegionRun(exp, cur.ID); err != nil { - return p.SendDM(ctx.Sender, "Couldn't retire previous region run: "+err.Error()) + return "", fmt.Errorf("retire previous region run: %w", err) } // Update CurrentRegion + visited list. if err := setCurrentRegion(exp, next.ID); err != nil { - return p.SendDM(ctx.Sender, "Couldn't update current region: "+err.Error()) + return "", fmt.Errorf("update current region: %w", err) } if _, err := MarkRegionVisited(exp, next.ID); err != nil { - return p.SendDM(ctx.Sender, "Couldn't mark region visited: "+err.Error()) + return "", fmt.Errorf("mark region visited: %w", err) } // Spawn the new region's DungeonRun and pin exp.RunID. - charLevel := 1 - if c != nil { - charLevel = c.Level - } if _, err := ensureRegionRun(exp, charLevel); err != nil { - return p.SendDM(ctx.Sender, "Couldn't outfit the new region: "+err.Error()) + return "", fmt.Errorf("outfit the new region: %w", err) } // Log + flavor. @@ -189,7 +204,7 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e if next.IsZoneBoss { b.WriteString("\n_★ Final region — the zone boss is here._") } - return p.SendDM(ctx.Sender, b.String()) + return b.String(), nil } // resolveTransitWanderingCheck mirrors resolveWanderingCheck (§6.1) but diff --git a/internal/plugin/dnd_expedition_supplies.go b/internal/plugin/dnd_expedition_supplies.go index 353d767..fa3f0fe 100644 --- a/internal/plugin/dnd_expedition_supplies.go +++ b/internal/plugin/dnd_expedition_supplies.go @@ -2,6 +2,8 @@ package plugin import ( "fmt" + "math/rand/v2" + "strings" ) // Phase 12 E1b — Supply procurement, daily burn, and depletion effects. @@ -11,15 +13,115 @@ import ( const ( SupplyPackStandardSU = 10 SupplyPackStandardCoins = 50 - SupplyPackStandardMax = 3 // per expedition SupplyPackDeluxeSU = 20 SupplyPackDeluxeCoins = 90 - SupplyPackDeluxeMax = 1 // per expedition SupplyForageMaxSU = 4 // 1d4 cap (Ranger, WIS DC 12) — §4.2 ) +// supplyPackCaps returns the per-tier maximum standard and deluxe pack +// counts a player can buy for an expedition. D5-a: caps now scale by +// zone tier so a T5 loadout actually clears DailyBurn(raw) × intended +// days × harsh-multiplier — see gogobee_long_expedition_plan.md §D5. +// Intended-day anchors come from the §2 target table (T1=2 → T5=7). +func supplyPackCaps(tier ZoneTier) (standard, deluxe int) { + switch tier { + case ZoneTierBeginner: + return 2, 1 + case ZoneTierApprentice: + return 2, 1 + case ZoneTierJourneyman: + return 3, 1 + case ZoneTierVeteran: + return 5, 1 + case ZoneTierLegendary: + return 7, 2 + } + return 3, 1 +} + +// expeditionTargetDays returns the §2 target-duration anchor for a zone +// tier — what the supply-cap math, balanced loadout, and the +// "Day X / ~Y expected" line in !expedition status are all sized against. +func expeditionTargetDays(tier ZoneTier) int { + switch tier { + case ZoneTierBeginner: + return 2 + case ZoneTierApprentice: + return 3 + case ZoneTierJourneyman: + return 4 + case ZoneTierVeteran: + return 5 + case ZoneTierLegendary: + return 7 + } + return 4 +} + +// SupplyLoadout names a tier-scaled preset purchase. D5-b: empty-arg +// `!expedition start ` now prompts the player to pick one of these +// rather than silently defaulting to 1 standard pack. Raw `Ns Md` syntax +// remains the advanced override. +type SupplyLoadout int + +const ( + LoadoutLean SupplyLoadout = iota + LoadoutBalanced + LoadoutHeavy +) + +// loadoutPurchase returns the SupplyPurchase for a preset at a tier. +// Lean: covers intended days at raw daily burn (cheap, no harsh buffer). +// Balanced: ~harsh-ready for an intended-length run (recommended). +// Heavy: equals supplyPackCaps — the harsh×3 ceiling D5-a sized for. +func loadoutPurchase(tier ZoneTier, l SupplyLoadout) SupplyPurchase { + switch l { + case LoadoutHeavy: + std, dlx := supplyPackCaps(tier) + return SupplyPurchase{StandardPacks: std, DeluxePacks: dlx} + case LoadoutBalanced: + switch tier { + case ZoneTierBeginner, ZoneTierApprentice: + return SupplyPurchase{StandardPacks: 2} + case ZoneTierJourneyman: + return SupplyPurchase{StandardPacks: 3} + case ZoneTierVeteran: + return SupplyPurchase{StandardPacks: 5} + case ZoneTierLegendary: + return SupplyPurchase{StandardPacks: 5, DeluxePacks: 1} + } + return SupplyPurchase{StandardPacks: 2} + default: // LoadoutLean + switch tier { + case ZoneTierBeginner, ZoneTierApprentice: + return SupplyPurchase{StandardPacks: 1} + case ZoneTierJourneyman: + return SupplyPurchase{StandardPacks: 2} + case ZoneTierVeteran: + return SupplyPurchase{StandardPacks: 3} + case ZoneTierLegendary: + return SupplyPurchase{StandardPacks: 5} + } + return SupplyPurchase{StandardPacks: 1} + } +} + +// parseLoadoutToken returns the preset selected by tok, if any. Accepts +// short forms (l/b/h) and a couple of synonyms. Empty/unknown → false. +func parseLoadoutToken(tok string) (SupplyLoadout, bool) { + switch strings.ToLower(strings.TrimSpace(tok)) { + case "lean", "l", "light": + return LoadoutLean, true + case "balanced", "b", "bal", "standard": + return LoadoutBalanced, true + case "heavy", "h", "max", "full": + return LoadoutHeavy, true + } + return 0, false +} + // supplyDailyBurn returns the base SU/day for a zone tier (§4.1). // Tier 1: 1, Tier 2: 1.5, Tier 3: 2, Tier 4: 3, Tier 5: 4. func supplyDailyBurn(tier ZoneTier) float32 { @@ -56,6 +158,45 @@ func supplyHarshMultiplier(tier ZoneTier) float32 { return 1.0 } +// applyRangerForage grants the Ranger's daily 1d4-SU forage yield (§4.2, +// wired in D5-c). Returns the SU added — 0 for non-Rangers, when the +// player has already foraged today, or when supplies are already at Max. +// The grant is headroom-capped so a Heavy loadout doesn't overflow its +// purchased ceiling. rng is the test-injectable [0,n) source; nil falls +// back to math/rand. Caller owns the persistence. +// +// Sizing rationale: with D5-a caps so generous (Lean T5 = 50 SU vs ~14 SU +// burned over a 7-day intended run at phase5B×0.5), this perk operates +// as a quiet Lean-loadout cushion, not a Heavy multiplier — average +2.5 +// SU/day off a Ranger is roughly one extra day of late-stage T5 burn +// across a full expedition. +func applyRangerForage(e *Expedition, c *DnDCharacter, rng func(int) int) float32 { + if e == nil || c == nil || c.Class != ClassRanger { + return 0 + } + if e.Supplies.ForagedToday { + return 0 + } + headroom := e.Supplies.Max - e.Supplies.Current + if headroom <= 0 { + e.Supplies.ForagedToday = true + return 0 + } + var roll int + if rng != nil { + roll = rng(SupplyForageMaxSU) + 1 + } else { + roll = rand.IntN(SupplyForageMaxSU) + 1 + } + gain := float32(roll) + if gain > headroom { + gain = headroom + } + e.Supplies.Current += gain + e.Supplies.ForagedToday = true + return gain +} + // SupplyDepletionState classifies remaining supply ratio (§4.3). type SupplyDepletionState int @@ -122,18 +263,19 @@ func (p SupplyPurchase) Cost() int { return p.StandardPacks*SupplyPackStandardCoins + p.DeluxePacks*SupplyPackDeluxeCoins } -// Validate enforces §4.2 caps (max 3 standard, max 1 deluxe, no negatives, -// at least one pack purchased — an expedition without supplies is not a -// legal start). -func (p SupplyPurchase) Validate() error { +// Validate enforces §4.2 caps (no negatives, at least one pack +// purchased — an expedition without supplies is not a legal start) and +// the per-tier maximums from supplyPackCaps. +func (p SupplyPurchase) Validate(tier ZoneTier) error { if p.StandardPacks < 0 || p.DeluxePacks < 0 { return fmt.Errorf("supply pack counts must be non-negative") } - if p.StandardPacks > SupplyPackStandardMax { - return fmt.Errorf("standard packs capped at %d (got %d)", SupplyPackStandardMax, p.StandardPacks) + stdCap, dlxCap := supplyPackCaps(tier) + if p.StandardPacks > stdCap { + return fmt.Errorf("standard packs capped at %d for T%d (got %d)", stdCap, int(tier), p.StandardPacks) } - if p.DeluxePacks > SupplyPackDeluxeMax { - return fmt.Errorf("deluxe packs capped at %d (got %d)", SupplyPackDeluxeMax, p.DeluxePacks) + if p.DeluxePacks > dlxCap { + return fmt.Errorf("deluxe packs capped at %d for T%d (got %d)", dlxCap, int(tier), p.DeluxePacks) } if p.StandardPacks == 0 && p.DeluxePacks == 0 { return fmt.Errorf("expedition requires at least one supply pack") diff --git a/internal/plugin/dnd_expedition_supplies_test.go b/internal/plugin/dnd_expedition_supplies_test.go index 02d2657..15f27fd 100644 --- a/internal/plugin/dnd_expedition_supplies_test.go +++ b/internal/plugin/dnd_expedition_supplies_test.go @@ -92,19 +92,52 @@ func TestSupplyAllowsLongRest(t *testing.T) { func TestSupplyPurchase_Validate(t *testing.T) { cases := []struct { p SupplyPurchase + tier ZoneTier wantErr bool }{ - {SupplyPurchase{StandardPacks: 1}, false}, - {SupplyPurchase{StandardPacks: 3, DeluxePacks: 1}, false}, // max - {SupplyPurchase{StandardPacks: 4}, true}, // over standard cap - {SupplyPurchase{DeluxePacks: 2}, true}, // over deluxe cap - {SupplyPurchase{StandardPacks: -1}, true}, - {SupplyPurchase{}, true}, // none purchased + // T3 is the only tier whose caps are unchanged from the pre-D5 + // shape (3 standard / 1 deluxe); use it to anchor the + // happy-path / over-cap parity assertions. + {SupplyPurchase{StandardPacks: 1}, ZoneTierJourneyman, false}, + {SupplyPurchase{StandardPacks: 3, DeluxePacks: 1}, ZoneTierJourneyman, false}, + {SupplyPurchase{StandardPacks: 4}, ZoneTierJourneyman, true}, + {SupplyPurchase{DeluxePacks: 2}, ZoneTierJourneyman, true}, + {SupplyPurchase{StandardPacks: -1}, ZoneTierJourneyman, true}, + {SupplyPurchase{}, ZoneTierJourneyman, true}, // none purchased + + // D5-a per-tier caps. T1/T2 tighten to (2,1); T4 widens to (5,1); + // T5 widens to (7,2). A T3-legal 3-standard loadout fails on T1. + {SupplyPurchase{StandardPacks: 2, DeluxePacks: 1}, ZoneTierBeginner, false}, + {SupplyPurchase{StandardPacks: 3}, ZoneTierBeginner, true}, + {SupplyPurchase{StandardPacks: 5, DeluxePacks: 1}, ZoneTierVeteran, false}, + {SupplyPurchase{StandardPacks: 6}, ZoneTierVeteran, true}, + {SupplyPurchase{StandardPacks: 7, DeluxePacks: 2}, ZoneTierLegendary, false}, + {SupplyPurchase{StandardPacks: 7, DeluxePacks: 3}, ZoneTierLegendary, true}, } for _, c := range cases { - err := c.p.Validate() + err := c.p.Validate(c.tier) if (err != nil) != c.wantErr { - t.Errorf("%+v: err = %v, wantErr=%v", c.p, err, c.wantErr) + t.Errorf("%+v T%d: err = %v, wantErr=%v", c.p, int(c.tier), err, c.wantErr) + } + } +} + +func TestSupplyPackCaps_PerTier(t *testing.T) { + cases := []struct { + tier ZoneTier + wantStd int + wantDlx int + }{ + {ZoneTierBeginner, 2, 1}, + {ZoneTierApprentice, 2, 1}, + {ZoneTierJourneyman, 3, 1}, + {ZoneTierVeteran, 5, 1}, + {ZoneTierLegendary, 7, 2}, + } + for _, c := range cases { + s, d := supplyPackCaps(c.tier) + if s != c.wantStd || d != c.wantDlx { + t.Errorf("T%d caps: got (%d,%d), want (%d,%d)", int(c.tier), s, d, c.wantStd, c.wantDlx) } } } @@ -133,6 +166,67 @@ func TestMakeSupplies_FillsFromTier(t *testing.T) { } } +func TestApplyRangerForage(t *testing.T) { + ranger := &DnDCharacter{Class: ClassRanger} + fighter := &DnDCharacter{Class: ClassFighter} + det := func(n int) int { return n - 1 } // always rolls the max (1d4 = 4) + + // Ranger, fresh day, plenty of headroom: max 1d4 = 4 SU added, flag set. + exp := &Expedition{Supplies: ExpeditionSupplies{Current: 10, Max: 50}} + if gain := applyRangerForage(exp, ranger, det); gain != 4 { + t.Errorf("ranger forage gain = %v, want 4", gain) + } + if exp.Supplies.Current != 14 { + t.Errorf("current after forage = %v, want 14", exp.Supplies.Current) + } + if !exp.Supplies.ForagedToday { + t.Error("ForagedToday should be set after a successful grant") + } + + // Same day, second call: no-op (already foraged). + if gain := applyRangerForage(exp, ranger, det); gain != 0 { + t.Errorf("repeat forage gain = %v, want 0", gain) + } + if exp.Supplies.Current != 14 { + t.Errorf("current after repeat = %v, want 14", exp.Supplies.Current) + } + + // Non-Ranger: never grants, never sets the flag. + exp2 := &Expedition{Supplies: ExpeditionSupplies{Current: 10, Max: 50}} + if gain := applyRangerForage(exp2, fighter, det); gain != 0 { + t.Errorf("non-ranger gain = %v, want 0", gain) + } + if exp2.Supplies.ForagedToday { + t.Error("non-ranger should not stamp ForagedToday") + } + + // Headroom cap: 2 SU short of Max → grant clamps to 2 even on a max roll. + exp3 := &Expedition{Supplies: ExpeditionSupplies{Current: 48, Max: 50}} + if gain := applyRangerForage(exp3, ranger, det); gain != 2 { + t.Errorf("headroom-capped gain = %v, want 2", gain) + } + if exp3.Supplies.Current != 50 { + t.Errorf("current should clamp to Max, got %v", exp3.Supplies.Current) + } + + // Already at Max: no grant, but flag still set so the day's roll is spent. + exp4 := &Expedition{Supplies: ExpeditionSupplies{Current: 50, Max: 50}} + if gain := applyRangerForage(exp4, ranger, det); gain != 0 { + t.Errorf("full-bag gain = %v, want 0", gain) + } + if !exp4.Supplies.ForagedToday { + t.Error("full-bag should still consume the day's forage attempt") + } + + // Nil character / nil expedition: never panics, returns 0. + if gain := applyRangerForage(exp, nil, det); gain != 0 { + t.Errorf("nil char gain = %v, want 0", gain) + } + if gain := applyRangerForage(nil, ranger, det); gain != 0 { + t.Errorf("nil exp gain = %v, want 0", gain) + } +} + func TestApplyDailyBurn_DrainsAndClamps(t *testing.T) { // Phase 5-B (shipped): applyDailyBurn now scales by // phase5BDailyBurnRatePct = 50 by default — every expected value diff --git a/internal/plugin/dnd_expedition_temporal_test.go b/internal/plugin/dnd_expedition_temporal_test.go index edff260..9aff30c 100644 --- a/internal/plugin/dnd_expedition_temporal_test.go +++ b/internal/plugin/dnd_expedition_temporal_test.go @@ -701,6 +701,7 @@ func TestAbyss_DailyInstabilityIncrements(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) p := &AdventurePlugin{} // Three briefings → instability should hit 15. for i := 0; i < 3; i++ { @@ -732,6 +733,7 @@ func TestAbyss_UnravelingDoublesBurn(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) // Set instability to 85 (unravel band). if _, err := db.Get().Exec( `UPDATE dnd_expedition SET temporal_stack = 85 WHERE expedition_id = ?`, @@ -760,6 +762,7 @@ func TestAbyss_CollapseFailsExpedition(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) // Set instability to 95 — next daily +5 lands on 100 (collapse). if _, err := db.Get().Exec( `UPDATE dnd_expedition SET temporal_stack = 95 WHERE expedition_id = ?`, diff --git a/internal/plugin/dnd_expedition_threat_test.go b/internal/plugin/dnd_expedition_threat_test.go index b8abbe3..33d2f33 100644 --- a/internal/plugin/dnd_expedition_threat_test.go +++ b/internal/plugin/dnd_expedition_threat_test.go @@ -150,6 +150,7 @@ func TestDeliverBriefing_AppliesThreatDrift(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) p := &AdventurePlugin{} if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { t.Fatal(err) diff --git a/internal/plugin/dnd_expedition_zone_clear_test.go b/internal/plugin/dnd_expedition_zone_clear_test.go new file mode 100644 index 0000000..0a052ef --- /dev/null +++ b/internal/plugin/dnd_expedition_zone_clear_test.go @@ -0,0 +1,116 @@ +package plugin + +import ( + "testing" + + "maunium.net/go/mautrix/id" +) + +// Single-region zone: clearing the run completes the wrapping expedition +// and flips BossDefeated. +func TestFinalizeExpeditionOnZoneClear_SingleRegionCompletes(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@zclear-single:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneSunkenTemple, "run-1", + ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + if exp.RunID != "run-1" { + t.Fatalf("setup: RunID = %q", exp.RunID) + } + + p := &AdventurePlugin{} + p.finalizeExpeditionOnZoneClear(uid, "run-1") + + if act, _ := getActiveExpedition(uid); act != nil { + t.Fatalf("expedition still active after zone clear: status=%s", act.Status) + } + loaded, _ := getExpedition(exp.ID) + if loaded.Status != ExpeditionStatusComplete { + t.Errorf("status = %q, want %q", loaded.Status, ExpeditionStatusComplete) + } + if !loaded.BossDefeated { + t.Error("BossDefeated not set after single-region zone clear") + } +} + +// A run that isn't the expedition's current run must not complete it. +func TestFinalizeExpeditionOnZoneClear_RunMismatchNoop(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@zclear-mismatch:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneSunkenTemple, "run-1", + ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{} + p.finalizeExpeditionOnZoneClear(uid, "some-other-run") + + loaded, _ := getExpedition(exp.ID) + if loaded.Status != ExpeditionStatusActive { + t.Errorf("status = %q, want active (mismatched run should be no-op)", loaded.Status) + } +} + +// Multi-region zone: clearing a non-boss region marks it cleared but leaves +// the expedition active so inter-region travel can continue. +func TestFinalizeExpeditionOnZoneClear_MidZoneRegionStaysActive(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@zclear-midzone:example.org") + defer cleanupExpeditions(uid) + + // CurrentRegion defaults to the first region (underdark_surface_tunnels, + // not the zone boss). + exp, err := startExpedition(uid, ZoneUnderdark, "run-1", + ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{} + p.finalizeExpeditionOnZoneClear(uid, "run-1") + + loaded, _ := getExpedition(exp.ID) + if loaded.Status != ExpeditionStatusActive { + t.Errorf("status = %q, want active after non-boss region clear", loaded.Status) + } + if !IsRegionCleared(loaded, "underdark_surface_tunnels") { + t.Error("first region not marked cleared") + } + if loaded.BossDefeated { + t.Error("BossDefeated set on a non-boss region clear") + } +} + +// Multi-region zone: clearing the zone-boss region completes the expedition. +func TestFinalizeExpeditionOnZoneClear_ZoneBossRegionCompletes(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@zclear-zoneboss:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneUnderdark, "run-1", + ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + if err := setCurrentRegion(exp, "underdark_deep_throne"); err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{} + p.finalizeExpeditionOnZoneClear(uid, "run-1") + + loaded, _ := getExpedition(exp.ID) + if loaded.Status != ExpeditionStatusComplete { + t.Errorf("status = %q, want complete after zone-boss region clear", loaded.Status) + } + if !loaded.BossDefeated { + t.Error("BossDefeated not set after zone-boss region clear") + } +} diff --git a/internal/plugin/dnd_rest.go b/internal/plugin/dnd_rest.go index d57f324..6146f13 100644 --- a/internal/plugin/dnd_rest.go +++ b/internal/plugin/dnd_rest.go @@ -6,6 +6,8 @@ import ( "sort" "strings" "time" + + "maunium.net/go/mautrix/id" ) // !rest short / !rest long. @@ -45,6 +47,20 @@ func restingLockoutRemaining(c *DnDCharacter) time.Duration { return remaining } +// restBlockedReason returns a player-facing message when the character +// cannot rest right now because they're mid-fight or mid-expedition. An +// empty string means rest is allowed. Both !rest short and !rest long +// honor this — the dungeon shouldn't be a campfire. +func restBlockedReason(uid id.UserID) string { + if hasActiveCombatSession(uid) { + return "You're mid-fight. Finish it (or `!flee`) before resting." + } + if exp, _ := getActiveExpedition(uid); exp != nil { + return "You can't rest while on an expedition. Use `!expedition extract` first." + } + return "" +} + func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) error { args = strings.TrimSpace(strings.ToLower(args)) switch args { @@ -77,6 +93,9 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Couldn't load your character.") } + if msg := restBlockedReason(ctx.Sender); msg != "" { + return p.SendDM(ctx.Sender, msg) + } if c.ShortRestCharges <= 0 { return p.SendDM(ctx.Sender, "You're out of short rest charges. Take a `!rest long` to restore them.") @@ -114,6 +133,7 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error { if err := SaveDnDCharacter(c); err != nil { return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error()) } + markActedToday(ctx.Sender) var msg string if c.HPCurrent > before { @@ -169,6 +189,9 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.") } + if msg := restBlockedReason(ctx.Sender); msg != "" { + return p.SendDM(ctx.Sender, msg) + } if c.LastLongRestAt != nil { elapsed := time.Since(*c.LastLongRestAt) if elapsed < dndLongRestCooldown { @@ -208,6 +231,7 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { if err := SaveDnDCharacter(c); err != nil { return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error()) } + markActedToday(ctx.Sender) _ = refreshAllResources(ctx.Sender) // Phase 9: spell slots refresh on long rest. _ = refreshSpellSlots(ctx.Sender) diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index 7348cf5..ad79f73 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -258,7 +258,13 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error { // Initial D&D level seeded from existing combat_level (v1.1 §4.1). // combat_level "freezes" thereafter — dnd_level is canonical. - advChar, _ := loadAdvCharacter(ctx.Sender) + // + // ensureCharacter (not a bare loadAdvCharacter) so the canonical + // player_meta seed row + tier-0 equipment exist for D&D-only players + // who reach !setup confirm without ever touching the legacy adventure + // path. Without this seed, loadAdvCharacter returns "sql: no rows in + // result set" on every legacy-layer command (arena, npcs, events, …). + advChar, _, _ := p.ensureCharacter(ctx.Sender) startLevel := 1 if advChar != nil { startLevel = dndLevelFromCombatLevel(advChar.CombatLevel) diff --git a/internal/plugin/dnd_spell_combat.go b/internal/plugin/dnd_spell_combat.go index 8046f5b..4b36172 100644 --- a/internal/plugin/dnd_spell_combat.go +++ b/internal/plugin/dnd_spell_combat.go @@ -268,12 +268,22 @@ func applySpellBuff(spell SpellDefinition, c *DnDCharacter, stats *CombatStats, stats.AttackBonus += 1 mods.DamageBonus += 0.05 case "spiritual_weapon": - // Spectral bonus-action attack each round. Reuse pet-attack channel. - if mods.PetAttackProc < 0.5 { - mods.PetAttackProc = 0.5 + // Spectral bonus-action attack each round on its own channel so the + // narration doesn't borrow pet flavor (the cleric may have no pet). + // 1d8 + spell mod base, +1d8 per 2 slot levels above 2nd; the engine + // rolls the d5 variance, so Dmg carries the average + upcast bump. + base := 4 + spellAttackBonus(c) + if slot > 2 { + base += 4 * ((slot - 2) / 2) } - if mods.PetAttackDmg < 6 { - mods.PetAttackDmg = 6 + if base < 4 { + base = 4 + } + if mods.SpiritWeaponProc < 0.5 { + mods.SpiritWeaponProc = 0.5 + } + if mods.SpiritWeaponDmg < base { + mods.SpiritWeaponDmg = base } case "mirror_image": mods.WardCharges += 2 @@ -422,6 +432,8 @@ type turnBuffDelta struct { reflect float64 autoCrit, enemySkip bool heal int // a MaxHP-raise (Aid) collapses to an immediate heal + dSpiritProc float64 + dSpiritDmg int } // statComponent reports whether the buff has a re-applicable persistent stat @@ -429,6 +441,7 @@ type turnBuffDelta struct { func (d turnBuffDelta) statComponent() bool { return d.dAC != 0 || d.dAtk != 0 || d.dSpeed != 0 || d.dPetDmg != 0 || d.dCrit != 0 || d.dDmgBonus != 0 || d.dPetProc != 0 || + d.dSpiritProc != 0 || d.dSpiritDmg != 0 || (d.dReductMul > 0 && d.dReductMul != 1) } @@ -460,6 +473,8 @@ func diffTurnBuff(bs, as CombatStats, bm, am CombatModifiers) turnBuffDelta { autoCrit: am.AutoCritFirst && !bm.AutoCritFirst, enemySkip: am.SpellEnemySkipFirst && !bm.SpellEnemySkipFirst, heal: as.MaxHP - bs.MaxHP, + dSpiritProc: am.SpiritWeaponProc - bm.SpiritWeaponProc, + dSpiritDmg: am.SpiritWeaponDmg - bm.SpiritWeaponDmg, } if bm.DamageReduct > 0 { d.dReductMul = am.DamageReduct / bm.DamageReduct diff --git a/internal/plugin/dnd_spells.go b/internal/plugin/dnd_spells.go index ec5c387..dfb3ec9 100644 --- a/internal/plugin/dnd_spells.go +++ b/internal/plugin/dnd_spells.go @@ -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") diff --git a/internal/plugin/dnd_spells_data.go b/internal/plugin/dnd_spells_data.go index 3c7e787..2b252c6 100644 --- a/internal/plugin/dnd_spells_data.go +++ b/internal/plugin/dnd_spells_data.go @@ -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"}, diff --git a/internal/plugin/dnd_test.go b/internal/plugin/dnd_test.go index 4250fa0..3fc8efe 100644 --- a/internal/plugin/dnd_test.go +++ b/internal/plugin/dnd_test.go @@ -70,10 +70,10 @@ func TestComputeMaxHP_MageLevel5(t *testing.T) { // Mage d6, CON +1 // L1: 6 + 1 = 7 // L2-5: 4 levels × (avg 4 + 1) = 4 × 5 = 20 - // Raw total: 27; Phase 5-B: 27 × 1.5 = 40.5 → 41 (round half-up). + // Raw total: 27; Phase 5-B × casterHPMult: 27 × 1.5 × 1.25 = 50.625 → 51. got := computeMaxHP(ClassMage, 1, 5) - if got != 41 { - t.Errorf("Mage L5 (CON+1) = %d, want 41 (27 raw × phase5BHPMult)", got) + if got != 51 { + t.Errorf("Mage L5 (CON+1) = %d, want 51 (27 raw × phase5BHPMult × casterHPMult)", got) } } @@ -94,8 +94,12 @@ func TestComputeAC(t *testing.T) { {ClassFighter, 0, 16}, // 10 + 0 + 6 {ClassFighter, 2, 18}, // 10 + 2 + 6 {ClassRogue, 3, 14}, // 10 + 3 + 1 - {ClassMage, 0, 10}, // 10 + 0 + 0 - {ClassCleric, 1, 14}, // 10 + 1 + 3 + {ClassMage, 0, 12}, // 10 + 0 + 2 (D8-d-fix caster floor) + {ClassSorcerer, 1, 13}, // 10 + 1 + 2 + {ClassCleric, 1, 16}, // 10 + 1 + 5 (D8-d-fix caster floor) + {ClassDruid, 0, 15}, // 10 + 0 + 5 + {ClassBard, 2, 15}, // 10 + 2 + 3 (D8-d-fix caster floor) + {ClassWarlock, 0, 13}, // 10 + 0 + 3 {ClassRanger, 2, 15}, // 10 + 2 + 3 } for _, c := range cases { diff --git a/internal/plugin/dnd_zone.go b/internal/plugin/dnd_zone.go index eac1af3..3c5690e 100644 --- a/internal/plugin/dnd_zone.go +++ b/internal/plugin/dnd_zone.go @@ -214,8 +214,8 @@ func zoneGoblinWarrens() ZoneDefinition { Faction: "Goblins, Hobgoblins", Atmosphere: "Low ceilings, torchlight, crude traps, cackling in the dark.", Hook: "A network of fetid tunnels burrowed beneath the Merchant's Road. The smell arrives before the sounds — smoke, rot, and something worse. I advise keeping one hand on your blade.", - MinRooms: 6, - MaxRooms: 7, + MinRooms: 12, + MaxRooms: 14, Enemies: []ZoneEnemy{ {BestiaryID: "goblin_sneak", SpawnWeight: 7}, {BestiaryID: "goblin_archer", SpawnWeight: 6}, @@ -258,8 +258,8 @@ func zoneCryptValdris() ZoneDefinition { Faction: "Undead", Atmosphere: "Stone corridors, dripping water, candles that shouldn't still be burning.", Hook: "The iron gate hangs open — someone left in a hurry. Carved into the stone above: \"HERE LIES VALDRIS. DO NOT.\" The rest has been chiseled away. I decline to speculate.", - MinRooms: 6, - MaxRooms: 7, + MinRooms: 12, + MaxRooms: 14, Enemies: []ZoneEnemy{ // Phase 4-B (outlier fix): the live roster was the only // dual-killer-elite zone (wight + flameskull, both CR3+ on @@ -314,8 +314,8 @@ func zoneForestShadows() ZoneDefinition { Faction: "Beasts, Fey-corrupted creatures, Bandits", Atmosphere: "Ancient forest, twisted paths, eerie silence, bioluminescent fungi, things in the canopy.", Hook: "The forest was beautiful once. Travelers still say so, usually right before they stop saying anything at all. The trees lean in when you're not looking. I have noted this is not a metaphor.", - MinRooms: 6, - MaxRooms: 8, + MinRooms: 16, + MaxRooms: 20, Enemies: []ZoneEnemy{ // Phase 4-B (outlier fix): standard pool was carrying two // real killers — Displacer Beast (38% win as a standard @@ -377,8 +377,8 @@ func zoneSunkenTemple() ZoneDefinition { Faction: "Kuo-toa, Water Elementals, Aboleth-touched", Atmosphere: "Flooded stone chambers, barnacled pillars, salt smell, alien glyphs, things that swim in the dark water.", Hook: "The tide went out thirty years ago and never fully came back. The temple stayed wet anyway. Something down there keeps it that way. I suggest waterproofing your spellbook.", - MinRooms: 6, - MaxRooms: 8, + MinRooms: 16, + MaxRooms: 20, Enemies: []ZoneEnemy{ {BestiaryID: "kuo_toa", SpawnWeight: 7}, {BestiaryID: "kuo_toa_whip", SpawnWeight: 4}, @@ -423,8 +423,8 @@ func zoneManorBlackspire() ZoneDefinition { Faction: "Undead, Shadows, Vampiric", Atmosphere: "Victorian decay, impossible architecture, portraits whose eyes follow movement, cold spots, locked rooms that weren't locked before.", Hook: "The manor has been for sale for eleven years. Every buyer has either left immediately or not left at all. The real estate listing describes it as 'full of character.' I find this accurate.", - MinRooms: 7, - MaxRooms: 9, + MinRooms: 22, + MaxRooms: 26, Enemies: []ZoneEnemy{ // Phase 4-B (outlier fix): Wraith was the dominant // standard-pool killer (45 hp loss/win, 85 attributed @@ -491,8 +491,8 @@ func zoneUnderforge() ZoneDefinition { Faction: "Fire Elementals, Constructs, Salamanders, Azers", Atmosphere: "Volcanic caverns, rivers of cooling lava, ancient dwarven stonework, the constant bass note of something very large moving below.", Hook: "The dwarven forge-city of Kharak Dûn was not abandoned. It was sealed from the outside. I do not have information on what they were sealing in.", - MinRooms: 7, - MaxRooms: 9, + MinRooms: 22, + MaxRooms: 26, Enemies: []ZoneEnemy{ // Phase 5-C: underforge trailed at 49.5% (band 55-75) // under the shipped HP×1.5/+3 floor. Trace named Fire @@ -548,8 +548,8 @@ func zoneUnderdark() ZoneDefinition { Faction: "Drow, Mind Flayers, Beholders (far), Ropers, Hook Horrors", Atmosphere: "Absolute darkness, phosphorescent mushroom groves, vast underground seas, carved drow cities in the distance, things older than the surface world.", Hook: "There is a world below the world. It has its own cities, its own wars, its own sky — which is stone, and has never once been kind. I speak more quietly here. Something might be listening.", - MinRooms: 8, - MaxRooms: 10, + MinRooms: 28, + MaxRooms: 34, Enemies: []ZoneEnemy{ // Phase 5-C: underdark ran 88% (band 45-65, way over) // at the T4 centerline — its sibling feywild sat at @@ -605,8 +605,8 @@ func zoneFeywildCrossing() ZoneDefinition { Faction: "Hags, Redcaps, Will-o-Wisps, Fomorians, Unseelie Fey", Atmosphere: "Impossible beauty, treacherous whimsy, time distortion, rules that change without notice, bargains with terrible fine print.", Hook: "The veil between worlds is thin here. Colors are too saturated. The mushrooms are too large. A small creature made of starlight just offered you a deal. I advise extreme caution regarding deals.", - MinRooms: 8, - MaxRooms: 10, + MinRooms: 28, + MaxRooms: 34, Enemies: []ZoneEnemy{ // Phase 5-C: feywild trailed at 54% (band 45-65, but // the design goal was to close the 30pp gap with its @@ -663,8 +663,8 @@ func zoneDragonsLair() ZoneDefinition { Faction: "Kobolds, Drakes, Young Dragons, Wyrm", Atmosphere: "Scorched stone, rivers of gold coins half-melted into the floor, kobold warrens as outer defenses, growing heat, the unmistakable smell of something ancient and enormous.", Hook: "The mountain has not erupted in forty years. The locals say it is dormant. The locals are wrong about what lives in mountains. I have prepared an unusually long entry description for this one.", - MinRooms: 9, - MaxRooms: 10, + MinRooms: 36, + MaxRooms: 44, Enemies: []ZoneEnemy{ {BestiaryID: "kobold", SpawnWeight: 7}, {BestiaryID: "guard_drake", SpawnWeight: 5}, @@ -711,8 +711,8 @@ func zoneAbyssPortal() ZoneDefinition { Faction: "Demons, Fiends, Corrupted Celestials", Atmosphere: "Reality fractures, impossible geometry, constant low psychic pressure, the feeling of being watched by something that has no eyes.", Hook: "Someone opened a door they should not have opened. The door is still open. Things are still coming through. I am not making jokes about this one.", - MinRooms: 9, - MaxRooms: 10, + MinRooms: 36, + MaxRooms: 44, Enemies: []ZoneEnemy{ // Phase 4-B (outlier fix): Nalfeshnee was mis-classified // as a standard at T5 — Phase 4-A measured 2.8% win rate diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index f5514f7..6aba28c 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -308,6 +308,9 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error { b.WriteString(renderZoneGraphMap(g, run)) b.WriteString("\n```\n") b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_") + if path := renderVisitedPath(g, run); path != "" { + b.WriteString("\n**Path:** " + path) + } return p.SendDM(ctx.Sender, b.String()) } // No registered graph (defensive — every zone has one post-G8). @@ -377,17 +380,48 @@ func roomGlyph(rt RoomType) string { type stopReason int const ( - stopOK stopReason = iota // walked to next room; loop may continue - stopFork // advanceTransitionGraph returned a forkMsg - stopElite // standing at an Elite doorway; needs !fight - stopBoss // standing at a Boss doorway; needs !fight - stopEnded // patrol or room resolution killed the player - stopComplete // run cleared (boss down, no outgoing edges) - stopBlocked // an active CombatSession blocks the advance - stopHarvestCombat // auto-harvest pulled into combat that resolved short of death - stopPreflight // pre-iteration preflight tripped (low HP / low SU) + stopOK stopReason = iota // walked to next room; loop may continue + stopFork // advanceTransitionGraph returned a forkMsg + stopElite // standing at an Elite doorway; needs !fight + stopBoss // standing at a Boss doorway; needs !fight + stopEnded // patrol or room resolution killed the player + stopComplete // run cleared (boss down, no outgoing edges) + stopBlocked // an active CombatSession blocks the advance + stopHarvestCombat // auto-harvest pulled into combat that resolved short of death + stopPreflight // pre-iteration preflight tripped (low HP / low SU) + stopBossSafety // compact autopilot bailed before boss (HP/SU/exhaustion gate) — caller pitches a rest camp ) +// bossSafetyHPPct — compact-autopilot won't engage a boss while current HP +// is at or below this fraction of max. 0.80 ≫ autopilotLowHPPct (0.30) so +// the gate fires well before the player is in real danger; the boss is +// the run's climax beat and we'd rather rest first than chip-trade into it. +const bossSafetyHPPct = 0.80 + +// bossSafetyExhaustion — gate trips at this level or above. 3 is the 5e +// "disadvantage on attack rolls and saving throws" tier; engaging a boss +// past that is a coin-flip TPK. A standard rest decrements exhaustion by 1, +// so two rest cycles clears a stack of 3 even without a long rest. +const bossSafetyExhaustion = 3 + +// bossSafetyGate reports whether the compact autopilot should pause before +// engaging the boss. Returns (player-facing reason, true) when blocked. +// Plumbed through the boss/elite branch of advanceOnceWithOpts so the +// scheduler can pitch a rest camp in response (see tryAutoRun). +func bossSafetyGate(userID id.UserID, exp *Expedition) (string, bool) { + cur, max := dndHPSnapshot(userID) + if max > 0 && float64(cur) <= float64(max)*bossSafetyHPPct { + return fmt.Sprintf("HP %d/%d — below %.0f%% boss-engage threshold", cur, max, bossSafetyHPPct*100), true + } + if exp != nil && exp.Supplies.DailyBurn > 0 && exp.Supplies.Current < exp.Supplies.DailyBurn { + return fmt.Sprintf("supplies %.1f/%.1f SU — under a day's burn", exp.Supplies.Current, exp.Supplies.DailyBurn), true + } + if c, _ := LoadDnDCharacter(userID); c != nil && c.Exhaustion >= bossSafetyExhaustion { + return fmt.Sprintf("exhaustion %d — too worn to fight clean", c.Exhaustion), true + } + return "", false +} + // advanceResult bundles the staged narration + dispatch shape of one // advanceOnce step. preStream/intro/phases/final mirror the streamOrSend // contract — phases nil means "no per-step pacing required". reason tells @@ -438,10 +472,19 @@ 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 +// legacy inline auto-resolve (SimulateCombat — fast, but ignores enemy +// multiattack). false returns stopBoss/stopElite after the safety gate so +// a turn-based driver — autoDriveCombat / pickAutoCombatAction — handles +// the fight via the regular !fight / !attack engine. Both the headless sim +// and the production autorun (long-expedition D8-f) now pass false so the +// real engine (with multiattack) resolves the encounter and simPickSpell +// actually fires; D8-prereq re-wired this seam, D8-f flipped prod onto it. +func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) { run, err := getActiveZoneRun(ctx.Sender) if err != nil { return advanceResult{}, fmt.Errorf("Couldn't read run state: %s", err.Error()) @@ -457,6 +500,23 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) } _ = applyMoodDecayIfStale(run) zone := zoneOrFallback(run.ZoneID) + // A pending fork means advanceTransitionGraph already cleared the + // current room and stopped — re-running resolveRoom would re-fire + // combat and re-drop loot on the same room. Re-emit the fork prompt + // and let the caller surface it; the player commits via !zone go . + // This returns *before* crediting the daily streak: spamming `!zone + // advance` at a fork resolves no room, so it must not keep the streak alive. + if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil { + return advanceResult{ + final: renderForkPrompt(zone, *pf), + reason: stopFork, + }, nil + } + // compact==true is the background auto-walk path; only credit + // player-initiated advances toward the daily streak. + if !compact { + markActedToday(ctx.Sender) + } prev := run.CurrentRoomType() prevIdx := run.CurrentRoom @@ -467,9 +527,12 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) // means the fight's done; fall through so the graph clears the room. // // compact==true (background autopilot) auto-resolves elite rooms via - // the same forward-sim engine used for exploration combat. Boss still - // pauses regardless — the boss is the run's climax beat and shouldn't - // be settled while the player isn't paying attention. + // the same forward-sim engine used for exploration combat. Long- + // expedition D3 extends the same path to boss rooms — gated by a + // safety check (HP/SU/exhaustion). When the gate trips the walk + // returns stopBossSafety; the autorun layer pitches a rest camp in + // response (see tryAutoRun). Foreground (!compact) still parks the + // player at the doorway for a manual !fight. var eliteAutoIntro string var eliteAutoPhases []string var eliteAutoOutcome string @@ -481,7 +544,7 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) return advanceResult{}, fmt.Errorf("Couldn't read combat state: %s", serr.Error()) } if sess == nil || sess.Status != CombatStatusWon { - if prev == RoomBoss || !compact { + if !compact { kind := "Elite" r := stopElite if prev == RoomBoss { @@ -494,10 +557,40 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) reason: r, }, nil } - // Compact-mode elite auto-resolve. - ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, true, true) + if prev == RoomBoss { + // Cheap extra read — only fires on the boss doorway tick. + exp, _ := getActiveExpedition(ctx.Sender) + if reason, blocked := bossSafetyGate(ctx.Sender, exp); blocked { + return advanceResult{ + final: fmt.Sprintf( + "⏸ **Autopilot held back from the boss** — %s. Pitching a rest camp; will re-engage after recovery.", + reason), + reason: stopBossSafety, + }, nil + } + } + if !inlineBossCombat { + // Background caller wants to drive the fight itself via the + // turn engine (autoDriveCombat / pickAutoCombatAction). + // Surface the doorway like the foreground path does, after + // the safety gate has had a chance to defer the engagement. + kind := "Elite" + r := stopElite + if prev == RoomBoss { + kind = "Boss" + r = stopBoss + } + return advanceResult{ + final: fmt.Sprintf("**Room %d/%d — %s.** Type `!fight` to engage.", + prevIdx+1, run.TotalRooms, kind), + reason: r, + }, nil + } + // Compact-mode elite/boss auto-resolve. resolveCombatRoom + // selects monster + label by run.CurrentRoomType(). + ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, prev == RoomElite, true) if aerr != nil { - return advanceResult{}, fmt.Errorf("Couldn't auto-resolve elite: %s", aerr.Error()) + return advanceResult{}, fmt.Errorf("Couldn't auto-resolve %s: %s", strings.ToLower(string(prev)), aerr.Error()) } if aended { return advanceResult{ @@ -586,6 +679,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) if gerr != nil { return advanceResult{}, fmt.Errorf("Couldn't advance: %s", gerr.Error()) } + var campStruck string + if kind := autoBreakCampOnMove(ctx.Sender); kind != "" { + campStruck = fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind) + } if complete { _, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete) var b strings.Builder @@ -593,7 +690,19 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) b.WriteString(outcome) b.WriteString("\n\n") } - b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display)) + if campStruck != "" { + b.WriteString(campStruck) + } + // A "complete" run is only a full zone clear when it isn't a mid-zone + // region clear of a multi-region zone. For the latter, name the region + // and point at the next one — "Cleared {zone}. Run complete." reads + // wrong right before the auto-advance transit block (and is shared with + // manual `!region travel`, which advances next). + if region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID); midZone { + b.WriteString(fmt.Sprintf("🏁 **Cleared %s.** The way to %s opens ahead.\n\n", region.Name, next.Name)) + } else { + b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display)) + } if line := twinBeeLine(zone.ID, DMZoneComplete, run.RunID, prevIdx); line != "" { b.WriteString(line) b.WriteString("\n\n") @@ -604,6 +713,16 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) b.WriteString("• " + id + "\n") } } + // Success-path expedition close-out: flip the wrapping expedition to + // 'complete' (when this clear finishes the whole zone) and surface any + // completion milestones. No-op for standalone runs / mid-zone region + // clears. + if lines := p.finalizeExpeditionOnZoneClear(ctx.Sender, run.RunID); len(lines) > 0 { + b.WriteString("\n") + for _, line := range lines { + b.WriteString(line) + } + } return advanceResult{ preStream: preStream, intro: intro, @@ -619,6 +738,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) b.WriteString("\n\n") } b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev))) + if campStruck != "" { + b.WriteString(campStruck) + } b.WriteString(forkMsg) return advanceResult{ preStream: preStream, @@ -629,6 +751,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) }, nil } finalMsg := p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next) + if campStruck != "" { + finalMsg = campStruck + finalMsg + } // H2 — auto-harvest the room the player just walked into. Only fires // for Exploration rooms (Entry/Trap/Elite/Boss self-skip via @@ -734,7 +859,7 @@ func (p *AdventurePlugin) formatNextRoomMessage(run *DungeonRun, zone ZoneDefini case RoomElite: b.WriteString("`!fight` when ready.") default: - b.WriteString("`!zone advance` to continue.") + b.WriteString(continueHint(id.UserID(run.UserID))) } return b.String() } @@ -778,6 +903,30 @@ func (p *AdventurePlugin) streamFlow(userID id.UserID, phaseMessages []string, f return nil } +// streamFlowThen behaves like streamFlow but runs after() once the final +// message has actually been delivered. Emergence follow-ups (e.g. the pet +// arrival DM) must land strictly *after* the paced run narration — rolling +// them before streamFlow returns races the streamer's goroutine and surfaces +// "there's an animal in your house" ahead of the "Run complete" beat the +// player is still waiting on. after may be nil. +func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []string, finalMessage string, after func()) error { + if len(phaseMessages) == 0 { + err := p.SendDM(userID, finalMessage) + if after != nil { + after() + } + return err + } + done := p.sendZoneCombatMessages(userID, phaseMessages, finalMessage) + if after != nil { + go func() { + <-done + after() + }() + } + return nil +} + // resolveRoom dispatches to the per-room-type resolver. Returns staged // messages (intro, phases, outcome) so combat rooms can be paced with // inter-phase delays — see resolveCombatRoom for the contract. For @@ -806,6 +955,7 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo // resolveCombatRoom spawns one roster enemy (elite filter optional), // runs combat, persists side effects, fires nat-1/nat-20 mood deltas, // and renders the staged narration. Returns: +// // intro — pre-combat block (TwinBee combat-start + monster stat block) // phases — RenderCombatLog output, streamed with delays by the caller // outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary @@ -814,9 +964,28 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo // Phases will be nil only on a "no roster" skip — caller treats that as a // non-paced fallthrough. func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, elite, compact bool) (intro string, phases []string, outcome string, ended bool, err error) { - monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, elite) + // Long-expedition D3 — compact autopilot now auto-resolves boss rooms + // too. The room-type drives monster selection (boss room → zone.Boss + // bestiary entry; exploration/elite → roster pick). Foreground boss + // combat is still the manual !fight path; resolveRoom() doesn't + // dispatch for RoomBoss outside compact. + isBoss := run.CurrentRoomType() == RoomBoss + var monster DnDMonsterTemplate + var ok bool + if isBoss { + monster, ok = dndBestiary[zone.Boss.BestiaryID] + } else { + monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, elite) + } if !ok { - outcome = fmt.Sprintf("_(No %s roster entry — skipping.)_", map[bool]string{true: "elite", false: "exploration"}[elite]) + kind := "exploration" + switch { + case isBoss: + kind = "boss" + case elite: + kind = "elite" + } + outcome = fmt.Sprintf("_(No %s roster entry — skipping.)_", kind) return } preHP, _ := dndHPSnapshot(userID) @@ -834,7 +1003,10 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z hpDelta := preHP - postHP var ob strings.Builder label := monster.Name - if elite { + switch { + case isBoss: + label = "👑 Boss — " + monster.Name + case elite: label = "Elite " + monster.Name } ob.WriteString(fmt.Sprintf("⚔️ **%s** down — HP %d→%d", label, preHP, postHP)) @@ -843,8 +1015,8 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z } ob.WriteString(".") recordZoneKillForUser(userID, monster.ID) - applyRoomCombatThreatForUser(userID, elite) - if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" { + applyRoomCombatThreatForUser(userID, elite || isBoss) + if drop := p.dropZoneLoot(userID, zone.ID, monster, isBoss); drop != "" { ob.WriteString(" ") ob.WriteString(drop) } @@ -970,7 +1142,7 @@ type BossOutcomeInputs struct { Result CombatResult PreHP, PostHP, MaxHP int PhaseTwoAt float64 // fraction of MaxHP; 0 disables phase-two narration - Nat20s, Nat1s int // pre-counted by scanMoodEventsFromCombat + Nat20s, Nat1s int // pre-counted by scanMoodEventsFromCombat // Caller-supplied headlines so arena and zone read in their own voice. // DefeatHeadline is the full sentence shown after the PlayerDeath @@ -1118,4 +1290,3 @@ func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error { "🚪 Abandoned **%s** at room %d/%d. No rewards.", zone.Display, run.CurrentRoom+1, run.TotalRooms)) } - diff --git a/internal/plugin/dnd_zone_cmd_graph.go b/internal/plugin/dnd_zone_cmd_graph.go index 3d2e600..3b99dd1 100644 --- a/internal/plugin/dnd_zone_cmd_graph.go +++ b/internal/plugin/dnd_zone_cmd_graph.go @@ -169,7 +169,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error()) } if pf == nil { - return p.SendDM(ctx.Sender, "No fork pending. Use `!zone advance` to continue.") + return p.SendDM(ctx.Sender, "No fork pending. Use "+continueHint(ctx.Sender)) } rest = strings.TrimSpace(rest) if rest == "" { @@ -187,6 +187,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil { return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error()) } + markActedToday(ctx.Sender) g, _ := loadZoneGraph(run.ZoneID) zone := zoneOrFallback(run.ZoneID) fromNode := g.Nodes[run.CurrentNode] @@ -195,6 +196,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { nextRoom := nodeKindToRoomType(nextNode.Kind) nextIdx := run.CurrentRoom + 1 var b strings.Builder + if kind := autoBreakCampOnMove(ctx.Sender); kind != "" { + b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind)) + } b.WriteString(fmt.Sprintf("➡ You take the path: **%s**.\n\n", chosen.Label)) if nextRoom == RoomBoss { if line := composeBossEntry(zone.ID, run.RunID, nextIdx); line != "" { @@ -207,7 +211,14 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { b.WriteString("\n\n") } } - b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** `!zone advance` to continue.", - nextIdx+1, run.TotalRooms, prettyRoomType(nextRoom))) + b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", nextIdx+1, run.TotalRooms, prettyRoomType(nextRoom))) + switch nextRoom { + case RoomBoss: + b.WriteString("`!fight` when you're ready for the boss.") + case RoomElite: + b.WriteString("`!fight` when ready.") + default: + b.WriteString(continueHint(ctx.Sender)) + } return p.SendDM(ctx.Sender, b.String()) } diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index 55aa869..6e32dee 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -108,11 +108,21 @@ func (r *DungeonRun) CurrentRoomType() RoomType { // generateRoomSequence builds the deterministic-but-seeded room layout // for a run of the given zone. The boss room is always last; one Entry // is always first; one Trap and one Elite room sit between explorations. -// Total length is sampled in [zone.MinRooms, zone.MaxRooms]. +// +// Total length tracks the zone's *graph* longest entry→boss path so the +// "Room X/Y" display lines up with what the player actually walks. If +// the graph hasn't been authored / can't be loaded, we fall back to a +// dice roll within [zone.MinRooms, zone.MaxRooms] (the pre-graph shape). func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType { - total := zone.MinRooms - if zone.MaxRooms > zone.MinRooms { - total += rng.IntN(zone.MaxRooms - zone.MinRooms + 1) + total := 0 + if g, ok := loadZoneGraph(zone.ID); ok { + total = graphLongestPath(g) + } + if total == 0 { + total = zone.MinRooms + if zone.MaxRooms > zone.MinRooms { + total += rng.IntN(zone.MaxRooms - zone.MinRooms + 1) + } } // Fixed slots: Entry + Trap + Elite + Boss = 4. Remaining = explorations. const fixed = 4 @@ -280,6 +290,17 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) { } if time.Since(r.LastActionAt) > zoneRunInactivityTimeout { _ = abandonZoneRunByID(r.RunID) + // A run reaped by the §4.3 idle timeout must also terminate the + // wrapping active expedition. Without this, the expedition is left + // status='active' pointing at a now-abandoned run: the autopilot's + // runAutopilotWalk reads run==nil and bails, but the briefing/recap + // ambient tickers keep firing — the player soft-locks at the last + // fork, "stuck" with no way to route on. Mirror the run-loss seam, + // but only when this run is the active expedition's current run so + // a standalone (non-expedition) stale run still reaps cleanly. + if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID { + forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)") + } return nil, nil } return r, nil diff --git a/internal/plugin/dnd_zone_test.go b/internal/plugin/dnd_zone_test.go index 877d0a0..5508a5c 100644 --- a/internal/plugin/dnd_zone_test.go +++ b/internal/plugin/dnd_zone_test.go @@ -126,9 +126,12 @@ func TestZoneRegistry_LootDropChances(t *testing.T) { } func TestZoneRegistry_RoomCountSane(t *testing.T) { + // Long-expedition plan §2 widens the bands per tier: T1 12–14 up to + // T5 ~36–44. The guard floor stays at 5 (no zone should ever drop + // below that) and the ceiling tracks the T5 target. for _, z := range allZones() { - if z.MinRooms < 5 || z.MaxRooms > 10 || z.MinRooms > z.MaxRooms { - t.Errorf("zone %s rooms %d-%d outside design (5-10, min<=max)", + if z.MinRooms < 5 || z.MaxRooms > 44 || z.MinRooms > z.MaxRooms { + t.Errorf("zone %s rooms %d-%d outside design (5-44, min<=max)", z.ID, z.MinRooms, z.MaxRooms) } } diff --git a/internal/plugin/expedition_ambient.go b/internal/plugin/expedition_ambient.go index 185e78c..2e28e64 100644 --- a/internal/plugin/expedition_ambient.go +++ b/internal/plugin/expedition_ambient.go @@ -35,9 +35,9 @@ import ( const ( // ambientCooldown — minimum time between ambient events for one - // expedition. Tuned so a player offline for a workday sees ~3 hits, - // not a flood, but a multi-day expedition feels alive. - ambientCooldown = 3 * time.Hour + // expedition. Tuned so a player offline for a workday sees ~1–2 hits, + // not a flood, but a multi-day expedition still feels alive. + ambientCooldown = 6 * time.Hour // ambientNearScheduleWindow — skip if we're within this many minutes // of a scheduled briefing (06:00 UTC) or recap (21:00 UTC). diff --git a/internal/plugin/expedition_autocamp.go b/internal/plugin/expedition_autocamp.go new file mode 100644 index 0000000..55c6f67 --- /dev/null +++ b/internal/plugin/expedition_autocamp.go @@ -0,0 +1,435 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "gogobee/internal/flavor" + "maunium.net/go/mautrix/id" +) + +// Long-expedition D2-a — autopilot camp scheduler. +// +// The autorun ticker (expedition_autorun.go) calls into here after the +// walk loop returns so the dungeon can pitch its own camp when the party +// is low on HP, or to drop a base-camp waypoint after a region clear. +// Day-rollover semantics are still UTC-anchored in D2-a; D2-b moves +// day++/burn into a night-camp helper this scheduler will eventually +// trigger. +// +// An auto-pitched camp is treated as a transient rest stop: the next +// autorun tick past minAutoCampDwell breaks it itself so the walk can +// continue. Player-pitched camps (`!camp`) never get broken by the +// scheduler — those are an explicit player intent and stay up until +// the player moves on or breaks them. + +const ( + // minAutoCampDwell — how long an auto-pitched camp stays up before + // the autorun ticker breaks it on its own. The autorun cooldown is + // 2h, so a dwell > 2h guarantees the camp spans at least one full + // tick of "the player is resting" before the walk resumes. + minAutoCampDwell = 4 * time.Hour + + // autoCampHPPct — pitch a rest camp when current HP is at or below + // this fraction of max. Set above autopilotLowHPPct (0.30) so the + // scheduler camps *before* the walk-preflight gives up. + autoCampHPPct = 0.55 + + // nightCampWindow — D2-b. Event-anchored expeditions roll the day on + // autopilot night-camp pitch. After this many hours since the last + // rollover, the next eligible camp is treated as the night camp + // (Night=true) and triggers processNightCamp. 16h gives the player + // most of an active day before the engine starts wanting to bed + // down; a healthy party with no HP pressure still camps at the end + // of the day rather than walking forever. + nightCampWindow = 16 * time.Hour +) + +// autoCampInputs is the minimal snapshot decideAutopilotCamp needs. +// Pulled out so the decision is pure / cheap to test without DB setup. +type autoCampInputs struct { + Camp *CampState + Run *DungeonRun + Multi bool + RegionCleared bool + BaseSite bool + BaseAlready bool + HPCur, HPMax int + Supplies ExpeditionSupplies + // D2-b: event-anchored rollover inputs. + Now time.Time + EventAnchored bool + LastBriefingAt *time.Time + StartDate time.Time +} + +// autoCampDecision — what to pitch and why. Reason is a short log-line +// fragment ("region boss cleared", "HP low"). Night=true means the +// caller should run processNightCamp as part of the pitch so the day++/ +// supply burn / threat drift happen alongside the rest. +type autoCampDecision struct { + Kind string + Reason string + Night bool +} + +// decideAutopilotCamp returns the camp to pitch (or ok=false). Pure; +// the executor does the DB work. +func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) { + if in.Camp != nil && in.Camp.Active { + return autoCampDecision{}, false + } + if in.Run == nil { + return autoCampDecision{}, false + } + rt := in.Run.CurrentRoomType() + if rt == RoomBoss || rt == RoomTrap { + return autoCampDecision{}, false + } + cleared := false + for _, idx := range in.Run.RoomsCleared { + if idx == in.Run.CurrentRoom { + cleared = true + break + } + } + + // D2-b: night-camp window — for event-anchored expeditions, the + // autopilot pitches the day's rollover camp when enough real time + // has elapsed since the last rollover. A flag, not a separate + // branch, so each pitch below can become a night camp. + night := false + if in.EventAnchored { + var since time.Duration + if in.LastBriefingAt != nil { + since = in.Now.Sub(*in.LastBriefingAt) + } else { + since = in.Now.Sub(in.StartDate) + } + night = since >= nightCampWindow + } + + // Heuristic — region base-camp waypoint. Eager: pitch once per + // eligible region after its boss is down. BaseAlready stops the + // next tick from re-pitching the same waypoint. + if in.Multi && in.RegionCleared && in.BaseSite && !in.BaseAlready && cleared { + if in.Supplies.Current >= campSupplyCost[CampTypeBase] { + return autoCampDecision{ + Kind: CampTypeBase, Night: night, + Reason: "region boss cleared — pitching base camp waypoint", + }, true + } + } + + // Heuristic — HP-low rest OR end-of-day night camp. Standard if + // cleared, rough otherwise. LowSU as a *trigger* would just dig + // the hole deeper; the walk's preflight pauses on low-SU instead. + lowHP := in.HPMax > 0 && float64(in.HPCur) <= float64(in.HPMax)*autoCampHPPct + if !lowHP && !night { + return autoCampDecision{}, false + } + kind := CampTypeRough + if cleared { + kind = CampTypeStandard + } + cost := campSupplyCost[kind] + if in.Supplies.Current < cost { + if kind == CampTypeStandard && in.Supplies.Current >= campSupplyCost[CampTypeRough] { + kind = CampTypeRough + } else { + return autoCampDecision{}, false + } + } + reason := "HP low — pitching rest camp" + switch { + case night && lowHP: + reason = "HP low + end of day — pitching night camp" + case night: + reason = "end of day — pitching night camp" + } + return autoCampDecision{Kind: kind, Reason: reason, Night: night}, true +} + +// maybeAutoCamp gathers DB state, calls decideAutopilotCamp, and pitches +// when the decision says to. Returns the player-facing camp block (or +// "" 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. +// +// 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 + if exp.RunID != "" { + if r, err := getZoneRun(exp.RunID); err == nil { + run = r + } + } + + multi := IsMultiRegionZone(exp.ZoneID) + regionCleared := false + baseSite := false + baseAlready := false + if multi { + if region, ok := CurrentRegion(exp); ok { + regionCleared = IsRegionCleared(exp, region.ID) + baseSite = region.BaseCampSite + baseAlready = HasBaseCampAt(exp, region.ID) + } + } + + hpCur, hpMax := dndHPSnapshot(uid) + in := autoCampInputs{ + Camp: exp.Camp, + Run: run, + Multi: multi, + RegionCleared: regionCleared, + BaseSite: baseSite, + BaseAlready: baseAlready, + HPCur: hpCur, + HPMax: hpMax, + Supplies: exp.Supplies, + Now: now, + EventAnchored: isEventAnchored(exp), + LastBriefingAt: exp.LastBriefingAt, + StartDate: exp.StartDate, + } + d, ok := decideAutopilotCamp(in) + if !ok { + return "", autoCampDecision{}, false + } + 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 + } + return block, d, true +} + +// pitchAutopilotCamp performs the same state mutations as the player +// !camp path (supply debit, camp row, applyCampRest, log entry, base- +// camp waypoint persist) without DMing — the autorun ticker bundles +// the returned block into the single auto-walk DM. When d.Night is true +// (D2-b event-anchored rollover), the day++/burn/threat-drift fire here +// 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, now time.Time) (string, error) { + var ( + nightBurn float32 + nightTemp []string + nightMile []string + nightDid bool + nightForce bool + ) + if d.Night { + burn, err := p.nightRolloverBurn(exp) + if err != nil { + return "", err + } + nightBurn = burn + nightDid = true + if exp.Status != ExpeditionStatusActive { + // 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, now) + nightTemp = drift.TemporalLines + nightMile = drift.MilestoneLines + nightForce = true + return renderAutoCampBlock(exp, d, 0, "", "", nightBurn, nightTemp, nightMile, nightDid, nightForce), nil + } + } + cost := campSupplyCost[d.Kind] + if exp.Supplies.Current < cost { + return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost) + } + exp.Supplies.Current -= cost + if err := updateSupplies(exp.ID, exp.Supplies); err != nil { + return "", err + } + camp := &CampState{ + Active: true, + Type: d.Kind, + RoomIndex: campCurrentRoomIndex(exp), + EstablishedAt: now, + NightEvents: []string{}, + AutoPitched: true, + } + if err := updateCamp(exp.ID, camp); err != nil { + return "", err + } + exp.Camp = camp + + restSummary := applyCampRest(exp, d.Kind) + camp.RestApplied = true + if err := updateCamp(exp.ID, camp); err != nil { + slog.Warn("autopilot camp: mark rest applied", "expedition", exp.ID, "err", err) + } + + var line string + if d.Kind == CampTypeBase { + line = flavor.Pick(flavor.BaseCampEstablished) + line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", exp.CurrentDay)) + } else { + line = flavor.Pick(flavor.CampEstablished) + } + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "rest", + fmt.Sprintf("autopilot camp pitched (%s) — %s — %.1f SU consumed", d.Kind, d.Reason, cost), line) + + if d.Kind == CampTypeBase { + if region, ok := CurrentRegion(exp); ok { + if _, err := addRegionListEntry(exp, regionStateBaseCampKey, region.ID); err != nil { + slog.Warn("autopilot camp: persist base camp waypoint", "expedition", exp.ID, "err", err) + } + } + } + + if d.Night { + drift := p.nightRolloverDrift(exp, now) + nightTemp = drift.TemporalLines + nightMile = drift.MilestoneLines + } + + return renderAutoCampBlock(exp, d, cost, line, restSummary, + nightBurn, nightTemp, nightMile, nightDid, nightForce), nil +} + +// renderAutoCampBlock formats the autopilot-camp section appended to +// the auto-walk DM. Kept short — the autorun DM already opens with the +// walk narration. nightBurn/nightTemp/nightMile carry the D2-b rollover +// side effects when the pitch was a night camp. +func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flavorLine, restSummary string, + nightBurn float32, nightTemp []string, nightMile []string, nightDid bool, nightForce bool) string { + if nightForce { + out := fmt.Sprintf("\n\n🌙 **Day %d.** _Supplies burned (−%.1f SU); the rollover fired but the camp couldn't pitch._\n", exp.CurrentDay, nightBurn) + for _, tl := range nightTemp { + out += "\n🌀 " + tl + "\n" + } + for _, ml := range nightMile { + out += "\n" + ml + } + return out + } + out := fmt.Sprintf("\n\n⛺ **Autopilot camp — %s.** _%s_\n", d.Kind, d.Reason) + out += fmt.Sprintf("Supplies: %.1f / %.1f SU (−%.1f).\n", + exp.Supplies.Current, exp.Supplies.Max, cost) + if flavorLine != "" { + out += "\n" + flavorLine + "\n" + } + if restSummary != "" { + out += "\n" + restSummary + "\n" + } + if d.Kind == CampTypeBase { + out += "\n_Base camp — **waypoint persisted**._" + } + if nightDid { + out += fmt.Sprintf("\n\n🌙 **Day %d.** _Overnight burn: %.1f SU._\n", exp.CurrentDay, nightBurn) + for _, tl := range nightTemp { + out += "\n🌀 " + tl + "\n" + } + for _, ml := range nightMile { + out += "\n" + ml + } + } + return out +} + +// pitchBossSafetyCamp force-pitches a rest camp after the compact +// autopilot bailed out before the boss (stopBossSafety). Bypasses the +// decideAutopilotCamp HP threshold and its RoomBoss room-type block — +// the boss doorway is *exactly* where this camp belongs. Picks the best +// kind the supplies will pay for (Standard > Rough); returns "" if even +// Rough is too expensive (extract-soon territory). The returned block is +// appended to the autorun DM by the caller. +// +// Day-rollover handling mirrors decideAutopilotCamp: when enough real +// 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, now time.Time) (string, autoCampDecision, bool) { + if exp == nil || exp.Status != ExpeditionStatusActive { + return "", autoCampDecision{}, false + } + if exp.Camp != nil && exp.Camp.Active { + // Already resting — nothing to pitch. The dwell window will pass + // and the next autorun tick will retry the boss engagement. + return "", autoCampDecision{}, false + } + kind := CampTypeStandard + if exp.Supplies.Current < campSupplyCost[kind] { + kind = CampTypeRough + } + if exp.Supplies.Current < campSupplyCost[kind] { + // No SU even for Rough — autopilot can't help here. The walk's + // preflight will surface low-SU on the next tick if the player + // doesn't extract. + return "", autoCampDecision{}, false + } + night := false + if isEventAnchored(exp) { + var since time.Duration + if exp.LastBriefingAt != nil { + since = now.Sub(*exp.LastBriefingAt) + } else { + since = now.Sub(exp.StartDate) + } + night = since >= nightCampWindow + } + d := autoCampDecision{ + Kind: kind, + Reason: "boss-safety hold — resting before re-engaging", + Night: night, + } + 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 + } + return block, d, true +} + +// breakAutoCampIfDue tears down an auto-pitched camp once it has dwelled +// for minAutoCampDwell. Returns true when it broke a camp (so the +// caller knows the walk should proceed this tick). Player-pitched camps +// are left untouched. +func breakAutoCampIfDue(exp *Expedition, now time.Time) bool { + if exp.Camp == nil || !exp.Camp.Active || !exp.Camp.AutoPitched { + return false + } + if now.Sub(exp.Camp.EstablishedAt) < minAutoCampDwell { + return false + } + if err := updateCamp(exp.ID, nil); err != nil { + slog.Warn("autopilot camp: break failed", "expedition", exp.ID, "err", err) + return false + } + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", + "autopilot broke camp (dwell elapsed)", "") + exp.Camp = nil + return true +} + +// shouldSkipAutoRunForCamp reports whether the autorun ticker should +// skip this expedition entirely because an auto- or player-pitched +// camp is still within its dwell window. Returns true when the ticker +// should bail before walking. +func shouldSkipAutoRunForCamp(exp *Expedition, now time.Time) bool { + if exp.Camp == nil || !exp.Camp.Active { + return false + } + // Player camps are sticky — never walked through by autopilot until + // the player explicitly breaks or moves. + if !exp.Camp.AutoPitched { + return true + } + // Auto-pitched camps: skip while still in dwell; the next tick past + // the window will break + walk in breakAutoCampIfDue. + return now.Sub(exp.Camp.EstablishedAt) < minAutoCampDwell +} diff --git a/internal/plugin/expedition_autocamp_test.go b/internal/plugin/expedition_autocamp_test.go new file mode 100644 index 0000000..5299b0d --- /dev/null +++ b/internal/plugin/expedition_autocamp_test.go @@ -0,0 +1,343 @@ +package plugin + +import ( + "testing" + "time" + + "maunium.net/go/mautrix/id" +) + +func TestDecideAutopilotCamp_SkipsWhenCamped(t *testing.T) { + in := autoCampInputs{ + Camp: &CampState{Active: true, Type: CampTypeRough}, + Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, + HPCur: 1, HPMax: 20, // very low — would otherwise camp + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + if _, ok := decideAutopilotCamp(in); ok { + t.Errorf("expected no camp when already camped") + } +} + +func TestDecideAutopilotCamp_SkipsHealthyHP(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, + HPCur: 20, HPMax: 20, + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + if _, ok := decideAutopilotCamp(in); ok { + t.Errorf("expected no camp when HP full") + } +} + +func TestDecideAutopilotCamp_LowHPClearedPitchesStandard(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, + HPCur: 5, HPMax: 20, // 25% — below 55% threshold + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + d, ok := decideAutopilotCamp(in) + if !ok || d.Kind != CampTypeStandard { + t.Errorf("expected standard camp, got %+v ok=%v", d, ok) + } +} + +func TestDecideAutopilotCamp_LowHPUnclearedPitchesRough(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 2, RoomsCleared: []int{0, 1}}, + HPCur: 5, HPMax: 20, + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + d, ok := decideAutopilotCamp(in) + if !ok || d.Kind != CampTypeRough { + t.Errorf("expected rough camp, got %+v ok=%v", d, ok) + } +} + +func TestDecideAutopilotCamp_DowngradesWhenSuppliesTight(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, + HPCur: 5, HPMax: 20, + Supplies: ExpeditionSupplies{Current: 0.6, Max: 5, DailyBurn: 1}, // can't afford 1.0 + } + d, ok := decideAutopilotCamp(in) + if !ok || d.Kind != CampTypeRough { + t.Errorf("expected downgrade to rough, got %+v ok=%v", d, ok) + } +} + +func TestDecideAutopilotCamp_SkipsBossRoom(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{ + CurrentRoom: 0, RoomsCleared: []int{}, + RoomSeq: []RoomType{RoomBoss}, + }, + HPCur: 1, HPMax: 20, + } + if _, ok := decideAutopilotCamp(in); ok { + t.Errorf("expected no camp in boss room") + } +} + +func TestDecideAutopilotCamp_BaseCampWhenRegionCleared(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, + Multi: true, + RegionCleared: true, + BaseSite: true, + BaseAlready: false, + HPCur: 20, HPMax: 20, // healthy — still pitch base waypoint + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + d, ok := decideAutopilotCamp(in) + if !ok || d.Kind != CampTypeBase { + t.Errorf("expected base camp, got %+v ok=%v", d, ok) + } +} + +func TestDecideAutopilotCamp_BaseCampSkippedIfAlreadyPersisted(t *testing.T) { + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, + Multi: true, + RegionCleared: true, + BaseSite: true, + BaseAlready: true, + HPCur: 20, HPMax: 20, + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + } + if _, ok := decideAutopilotCamp(in); ok { + t.Errorf("expected no second base-camp pitch") + } +} + +func TestShouldSkipAutoRunForCamp(t *testing.T) { + now := time.Now().UTC() + stickyPlayerCamp := &Expedition{Camp: &CampState{ + Active: true, Type: CampTypeStandard, AutoPitched: false, + EstablishedAt: now.Add(-10 * time.Hour), + }} + if !shouldSkipAutoRunForCamp(stickyPlayerCamp, now) { + t.Error("player camp should always block autorun") + } + + freshAuto := &Expedition{Camp: &CampState{ + Active: true, Type: CampTypeStandard, AutoPitched: true, + EstablishedAt: now.Add(-1 * time.Hour), + }} + if !shouldSkipAutoRunForCamp(freshAuto, now) { + t.Error("auto camp within dwell window should skip") + } + + staleAuto := &Expedition{Camp: &CampState{ + Active: true, Type: CampTypeStandard, AutoPitched: true, + EstablishedAt: now.Add(-(minAutoCampDwell + time.Minute)), + }} + if shouldSkipAutoRunForCamp(staleAuto, now) { + t.Error("auto camp past dwell should let walk proceed") + } + + none := &Expedition{} + if shouldSkipAutoRunForCamp(none, now) { + t.Error("no camp should not skip") + } +} + +func TestPitchAutopilotCamp_DeductsSuppliesAndRestores(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@autocamp-pitch:example") + campTestCharacter(t, uid, 1) + // Damage the character so the standard rest can heal them. + c, _ := LoadDnDCharacter(uid) + c.HPCurrent = 4 + _ = SaveDnDCharacter(c) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + p := &AdventurePlugin{} + block, err := p.pitchAutopilotCamp(exp, autoCampDecision{ + Kind: CampTypeStandard, Reason: "test pitch", + }, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if block == "" { + t.Error("expected non-empty camp block") + } + + fresh, _ := getActiveExpedition(uid) + if fresh.Camp == nil || !fresh.Camp.Active { + t.Fatal("expected camp pitched") + } + if !fresh.Camp.AutoPitched { + t.Error("expected AutoPitched flag set") + } + if !fresh.Camp.RestApplied { + t.Error("expected RestApplied flag set") + } + if fresh.Supplies.Current != 4.0 { + t.Errorf("supplies = %v, want 4.0 after standard pitch", fresh.Supplies.Current) + } + cc, _ := LoadDnDCharacter(uid) + if cc.HPCurrent != cc.HPMax { + t.Errorf("HP = %d/%d, want full after standard rest", cc.HPCurrent, cc.HPMax) + } +} + +func TestBreakAutoCampIfDue_RespectsDwellWindow(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@autocamp-break:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + exp.Camp = &CampState{ + Active: true, Type: CampTypeRough, AutoPitched: true, + EstablishedAt: now.Add(-1 * time.Hour), + } + if err := updateCamp(exp.ID, exp.Camp); err != nil { + t.Fatal(err) + } + if breakAutoCampIfDue(exp, now) { + t.Error("camp inside dwell window should not be broken") + } + + exp.Camp.EstablishedAt = now.Add(-(minAutoCampDwell + time.Minute)) + if err := updateCamp(exp.ID, exp.Camp); err != nil { + t.Fatal(err) + } + if !breakAutoCampIfDue(exp, now) { + t.Error("camp past dwell window should be broken") + } + fresh, _ := getActiveExpedition(uid) + if fresh.Camp != nil && fresh.Camp.Active { + t.Errorf("expected camp cleared, got %+v", fresh.Camp) + } +} + +func TestDecideAutopilotCamp_NightTriggerOnEventAnchored(t *testing.T) { + now := time.Now().UTC() + stale := now.Add(-(nightCampWindow + time.Hour)) + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, + HPCur: 20, HPMax: 20, // healthy — only the night window should pitch + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + Now: now, + EventAnchored: true, + LastBriefingAt: &stale, + } + d, ok := decideAutopilotCamp(in) + if !ok { + t.Fatal("expected night camp pitch") + } + if !d.Night { + t.Errorf("Night = false, want true (end of day)") + } + if d.Kind != CampTypeStandard { + t.Errorf("Kind = %s, want standard (cleared room)", d.Kind) + } +} + +func TestDecideAutopilotCamp_NightSkippedOnLegacy(t *testing.T) { + now := time.Now().UTC() + stale := now.Add(-24 * time.Hour) + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, + HPCur: 20, HPMax: 20, + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + Now: now, + EventAnchored: false, // legacy — only HP-low triggers + LastBriefingAt: &stale, + } + if _, ok := decideAutopilotCamp(in); ok { + t.Error("legacy expedition should not pitch a night camp on time alone") + } +} + +func TestDecideAutopilotCamp_NightFlagSetEvenOnHPLow(t *testing.T) { + now := time.Now().UTC() + stale := now.Add(-(nightCampWindow + time.Hour)) + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, + HPCur: 5, HPMax: 20, // also low + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + Now: now, + EventAnchored: true, + LastBriefingAt: &stale, + } + d, ok := decideAutopilotCamp(in) + if !ok || !d.Night { + t.Errorf("expected Night pitch even when HP also low, got %+v ok=%v", d, ok) + } +} + +func TestPitchAutopilotCamp_NightRunsProcessNightCamp(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@autocamp-night:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + // Force event-anchored for this test. + saved := eventAnchoredCutoff + eventAnchoredCutoff = exp.StartDate.Add(-time.Minute) + defer func() { eventAnchoredCutoff = saved }() + + startDay := exp.CurrentDay + startSU := exp.Supplies.Current + p := &AdventurePlugin{} + _, err = p.pitchAutopilotCamp(exp, autoCampDecision{ + Kind: CampTypeStandard, Reason: "night-camp test", Night: true, + }, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + got, _ := getActiveExpedition(uid) + if got.CurrentDay != startDay+1 { + t.Errorf("day = %d, want %d (night camp advances day)", got.CurrentDay, startDay+1) + } + // Night burn (0.5 SU) + camp cost (1.0 SU) = 1.5 SU; 5 - 1.5 = 3.5. + wantSU := startSU - 0.5 - campSupplyCost[CampTypeStandard] + if got.Supplies.Current != wantSU { + t.Errorf("supplies = %v, want %v (night burn + camp cost)", got.Supplies.Current, wantSU) + } + if got.LastBriefingAt == nil { + t.Error("LastBriefingAt should be stamped by night rollover") + } +} + +func TestBreakAutoCampIfDue_LeavesPlayerCampAlone(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@autocamp-playercamp:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + exp.Camp = &CampState{ + Active: true, Type: CampTypeStandard, AutoPitched: false, + EstablishedAt: now.Add(-10 * time.Hour), + } + if err := updateCamp(exp.ID, exp.Camp); err != nil { + t.Fatal(err) + } + if breakAutoCampIfDue(exp, now) { + t.Error("player camp must not be auto-broken") + } +} diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index 52f7bcb..47f85f8 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -1,6 +1,7 @@ package plugin import ( + "fmt" "log/slog" "strings" "time" @@ -20,13 +21,15 @@ import ( // quiet window, or on a very-fresh expedition (give them a beat). // • Walk up to autoRunRoomCap rooms per tick. Smaller than foreground's // cap so background DMs stay digestible. -// • DM-suppression rules: -// - 0 rooms walked → silent (still stuck at a fork / blocked / etc.; -// the player already knows, no point spamming). -// - rooms > 0 with stopOK (hit room cap) → silent; we'll keep walking -// on the next tick. Cadence handles pacing; no "stretch complete" -// footer churn. -// - Any other reason with rooms > 0 → DM the bundled walk. +// • DM-suppression rules (D4-a — long expedition bundling): +// - Surface ONLY for: fork (player decision), death, run-complete, +// boss-safety camp pitch (explicit hold), or a Night=true camp +// pitch (end-of-day digest). +// - Everything else — uneventful walks, preflight pauses, harvest +// interrupts, mid-day rest camps — goes silent. The accumulated +// day reads as one EoD digest DM when the autopilot night-camps. +// - Each successful background walk logs a `walk` entry so the EoD +// digest can count rooms walked without persisting raw narration. // • Idempotency: CAS-claim last_autorun_at before doing any work. A // double-fire on the same expedition is a no-op. @@ -34,12 +37,13 @@ const ( // autoRunTickInterval — how often the ticker wakes. The per-expedition // cooldown is what actually paces; the tick just has to be frequent // enough that the cooldown is enforced cleanly. - autoRunTickInterval = 5 * time.Minute + autoRunTickInterval = 15 * time.Minute // autoRunCooldown — minimum gap between background walks for one - // expedition. 15 min keeps the dungeon moving while leaving room for - // the player to step in and steer if they want. - autoRunCooldown = 15 * time.Minute + // expedition. 2h is the slow cadence: the dungeon still moves on its + // own while the player is away, but the auto-walk DM stays a + // once-in-a-while ping instead of a steady drip. + autoRunCooldown = 2 * time.Hour // autoRunMinExpeditionAge — don't auto-walk a brand-new expedition; // let the player walk the first beat manually so the opening reads @@ -93,6 +97,14 @@ func (p *AdventurePlugin) fireExpeditionAutoRuns(now time.Time) { if hasActiveCombatSession(uid) { continue } + // Honor the rest lockout — short/long rest set RestingUntil and + // foreground !expedition run checks it; background must too, or + // the auto-walk ticker fires through a long rest. + if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil { + if restingLockoutRemaining(c) > 0 { + continue + } + } if err := p.tryAutoRun(e, now); err != nil { slog.Warn("expedition: autorun step", "expedition", e.ID, "err", err) } @@ -126,12 +138,99 @@ func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error) return ids, rows.Err() } +// autoRunCombatSegmentCap bounds the walk→fight→walk ping-pong inside a +// single background tick. With autoRunRoomCap == 3 rooms/tick the loop +// can realistically only hit a couple doorways; the cap is a backstop +// against a pathological state where a fight wins but the next walk +// re-presents the same doorway. +const autoRunCombatSegmentCap = 8 + +// runAutopilotWalkDriven runs the compact background walk and, when it +// halts at a boss/elite doorway, drives that fight through the real turn +// engine (manual `!fight` parity — long-expedition D8-f) before resuming +// the walk. It loops walk→fight→walk so one tick still covers up to +// maxRooms rooms, exactly as the old inline-boss path did, but bosses now +// face the player's full kit against the enemy's full multiattack profile +// instead of the rosier inline SimulateCombat path. +// +// Combat narration is suppressed via a silent ctx — the day digest +// summarizes the outcome, matching the rest of the compact autopilot +// surface. The returned result carries the cumulative room count and the +// reason of whichever non-combat stop ended the loop. +func (p *AdventurePlugin) runAutopilotWalkDriven(ctx MessageContext, maxRooms int) autopilotWalkResult { + silent := ctx + silent.Silent = true + total := 0 + for seg := 0; seg < autoRunCombatSegmentCap; seg++ { + budget := maxRooms - total + if budget <= 0 { + return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)} + } + r := p.runAutopilotWalk(ctx, budget, true, false) + if r.initErr != "" { + return r + } + total += r.rooms + r.rooms = total + if r.reason != stopBoss && r.reason != stopElite { + return r + } + // Standing at an elite/boss doorway — drive the fight on the turn + // engine. handleFightCmd opens the session at the current doorway; + // autoDriveCombat loops until it resolves. + won, err := p.autoDriveCombat(silent) + if err != nil { + slog.Warn("expedition: autopilot turn-engine combat", "user", ctx.Sender, "err", err) + // Leave the doorway stop in place; the next tick retries the + // engagement after the cooldown. + return r + } + if won { + // The won session is recorded; the next walk advances the now- + // cleared room (a boss win surfaces as stopComplete, an elite as + // a normal continue). Loop. + continue + } + // Lost: the turn engine never voluntarily flees, so a non-win means + // the party fell. finishCombatSession (CombatStatusLost) already + // abandoned the run and force-extracted the expedition; surface it + // as a death so the digest + pet-emergence seam fire. + if c, _ := LoadDnDCharacter(ctx.Sender); c == nil || c.HPCurrent <= 0 { + r.reason = stopEnded + r.finalMsg = fmt.Sprintf("💀 The party fell in battle after %s. The expedition is over.", roomsWalkedPhrase(total)) + return r + } + // Alive but the session didn't open / resolve to a win (rare — + // bestiary miss or stall). Leave the doorway stop; retry next tick. + return r + } + // Segment cap hit — stop cleanly rather than spin. + return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)} +} + +// roomsWalkedPhrase renders "N room(s)" for autopilot footers. +func roomsWalkedPhrase(rooms int) string { + if rooms == 1 { + return "1 room" + } + return fmt.Sprintf("%d rooms", rooms) +} + // tryAutoRun claims the slot for this expedition, runs one background // walk, and DMs the result if the suppression rules say to. The CAS- // update is the only persistent side effect on the autorun column — // supplies/threat/run-graph state are mutated by the walk itself, just // as they would be in a foreground !expedition run. func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error { + // Long-expedition D2-a — camp dwell gate. A camp (player or auto) + // still inside its dwell window means the party is resting; skip + // the walk entirely. An auto-pitched camp past dwell gets broken + // here so this tick can proceed. + if shouldSkipAutoRunForCamp(e, now) { + return nil + } + autoCampBroken := breakAutoCampIfDue(e, now) + cutoff := now.Add(-autoRunCooldown) res, err := db.Get().Exec(` UPDATE dnd_expedition @@ -149,40 +248,129 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error { } uid := id.UserID(e.UserID) - r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true) + // D8-f — boss/elite encounters route through the real turn engine for + // manual `!fight` parity (enemy multiattack included). Trash mobs still + // auto-resolve on the fast inline path inside the walk. + r := p.runAutopilotWalkDriven(MessageContext{Sender: uid}, autoRunRoomCap) if r.initErr != "" { // "no expedition" / "no run" — race with abandon/extract. Silent. return nil } - if !shouldDMAutoRun(r) { - return nil + + // D4-a — drop a `walk` log entry per successful background walk so + // the EoD digest can count rooms walked from structured logs without + // persisting the raw stream narration we used to DM. + if r.rooms > 0 { + _ = appendExpeditionLog(e.ID, e.CurrentDay, "walk", + fmt.Sprintf("auto-walk: %d room(s)", r.rooms), "") } - body := renderAutoRunDM(r) - if err := p.SendDM(uid, body); err != nil { - slog.Warn("expedition: autorun DM", "user", uid, "err", err) + // Long-expedition D2-a — post-walk camp scheduler. After the walk + // settles, see if the autopilot should pitch a rest camp (HP low) + // or a base-camp waypoint (region boss just cleared). The walk's + // own preflight handles low-SU pauses; the scheduler stays out of + // fork/combat/death/complete branches by checking r.reason. + campBlock := "" + var campDecision autoCampDecision + campPitched := false + if r.reason == stopBossSafety { + // D3 — the boss-engage gate tripped. Force-pitch a rest camp + // regardless of decideAutopilotCamp's normal HP threshold and + // 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, 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, now) + } + } + _ = autoCampBroken // hint reserved for downstream digest tweaks + _ = campPitched // surfaced via campBlock != ""; kept for readability + + // D4-a DM dispatch. The old per-tick auto-walk DM is retired in compact + // mode: a Night-camp pitch flushes the accumulated day as a digest; + // every other quiet path stays silent until something interactive fires. + if body, ok := buildAutoRunDM(e.ID, r, campBlock, campDecision); ok { + if err := p.SendDM(uid, body); err != nil { + slog.Warn("expedition: autorun DM", "user", uid, "err", err) + } + } + + // Emergence seam: a run-complete reached by the background ticker is + // still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge. + // Deferred until after the run-summary DM below so the "animal in your + // house" prompt lands after the summary, not ahead of it. + if r.reason == stopComplete { + p.maybeRollPetArrivalOnEmerge(uid) } return nil } -// shouldDMAutoRun applies the background suppression rules. See the -// file-top design block for the rationale. -func shouldDMAutoRun(r autopilotWalkResult) bool { - if r.rooms == 0 { - return false +// buildAutoRunDM applies D4-a's surface rules and returns the DM body to +// send. ok=false means the tick is silent. Inputs: +// - expID: the expedition row id (for the EoD digest log fetch). +// - r: walk result, including reason + accumulated stream. +// - camp: rendered camp block from maybeAutoCamp / pitchBossSafetyCamp, +// or "" when no camp was pitched this tick. +// - dec: the camp decision; dec.Night is the trigger for the EoD +// digest variant. Zero-value when no pitch happened. +// +// Surface rules: +// - stopFork / stopEnded / stopComplete → render the walk DM. These +// are the interactive / climax beats and stay their own messages. +// - Night camp pitched → render the EoD digest + +// camp block. Walk stream is dropped (the digest summarizes the day). +// - Boss-safety camp pitched → short hold notice + camp +// block; walk stream dropped (compact bail was deliberate). +// - Anything else → silent. +func buildAutoRunDM(expID string, r autopilotWalkResult, camp string, dec autoCampDecision) (string, bool) { + switch r.reason { + case stopFork, stopEnded, stopComplete: + body := renderAutoRunWalkDM(r) + if camp != "" { + body += camp + } + return body, true } - if r.reason == stopOK { - // Hit the per-tick room cap. Next tick will continue; no need to - // post a "stretch complete" filler DM. - return false + if camp == "" { + return "", false } - return true + if dec.Night { + // EoD digest. The camp pitch already bumped current_day in + // nightRolloverBurn, so the day-that-just-ended is CurrentDay-1. + // digest is the day rollup, then the camp block lays out the rest. + fresh, ferr := getExpedition(expID) + prevDay := 0 + if ferr == nil && fresh != nil { + prevDay = fresh.CurrentDay - 1 + } + digest := "" + if prevDay > 0 { + digest = renderEndOfDayDigest(expID, prevDay) + } + if digest == "" { + // No structured day yet — fall back to a thin header so the + // camp block isn't dropped on the player without context. + digest = "🌙 *The day winds down.*\n\n" + } + return digest + camp, true + } + if dec.Reason == "boss-safety hold — resting before re-engaging" { + return "⏸ *Holding before the boss — pitching a rest camp.*\n" + camp, true + } + // Non-night auto-camp (mid-day rest / base camp waypoint). Surface a + // short notice so the player can see the dungeon's decision; full + // digest waits for the night pitch. + return camp, true } -// renderAutoRunDM bundles the staged walk narration into a single DM. -// Background can't pace via streamFlow, so we concatenate phases with a -// blank line between each beat and tack on the final message. -func renderAutoRunDM(r autopilotWalkResult) string { +// renderAutoRunWalkDM is the legacy concat-the-stream renderer, kept for +// the surfaces D4-a still DMs (fork / death / run-complete). +func renderAutoRunWalkDM(r autopilotWalkResult) string { var b strings.Builder b.WriteString("🚶 *Auto-walk*\n\n") for _, s := range r.stream { diff --git a/internal/plugin/expedition_autorun_digest.go b/internal/plugin/expedition_autorun_digest.go new file mode 100644 index 0000000..0d3f244 --- /dev/null +++ b/internal/plugin/expedition_autorun_digest.go @@ -0,0 +1,130 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" +) + +// Long-expedition D4-a — end-of-day digest renderer. +// +// When the autopilot pitches a Night=true camp the dungeon has effectively +// closed the day. Instead of dripping per-tick auto-walk DMs through the +// player's channel, D4-a suppresses those mid-day surfaces and rolls them +// into a single rollup posted alongside the night-camp block. +// +// Data source: dnd_expedition_log. The autorun ticker now writes a `walk` +// entry per successful background walk; combined with the existing +// harvest / interrupt / threat / milestone / narrative entries this gives +// the digest enough structure to summarize the day in counts + a small +// number of headline lines, without re-rendering the full per-room +// narration that the per-tick stream used to carry. +// +// Tone: terse, TwinBee first-person (feedback_twinbee_voice), no trailing +// recap paragraph (feedback_skip_recaps). The night-camp block follows +// immediately after. + +// renderEndOfDayDigest pulls every log entry for (expID, prevDay) and +// returns a markdown rollup. Returns "" when prevDay has no entries — +// the caller should fall back to the bare camp block in that case. +func renderEndOfDayDigest(expID string, prevDay int) string { + entries, err := dayExpeditionLog(expID, prevDay) + if err != nil { + slog.Warn("expedition: digest log fetch", "expedition", expID, "day", prevDay, "err", err) + return "" + } + if len(entries) == 0 { + return "" + } + var ( + walks int + harvests int + interrupts int + forageLines []string + threatLines []string + milestoneLine []string + narrativeBits []string + ) + for _, e := range entries { + switch e.Type { + case "walk": + walks++ + case "harvest": + // Only count successful gathers — failed rolls / errors are noise. + if strings.Contains(e.Summary, "success") { + harvests++ + } + case "forage": + forageLines = append(forageLines, e.Summary) + case "interrupt": + interrupts++ + case "threat": + if e.Flavor != "" { + threatLines = append(threatLines, e.Flavor) + } else if e.Summary != "" { + threatLines = append(threatLines, e.Summary) + } + case "milestone": + milestoneLine = append(milestoneLine, e.Summary) + case "narrative": + // Surface real narrative beats (camp broken / extraction prompts), + // skip housekeeping ones. + if strings.Contains(e.Summary, "autopilot broke camp") { + continue + } + narrativeBits = append(narrativeBits, e.Summary) + } + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("📜 *Day %d wraps up.*\n\n", prevDay)) + bulleted := false + if walks > 0 { + b.WriteString(fmt.Sprintf("• Walked **%d** auto-tick%s through the dark.\n", walks, pluralS(walks))) + bulleted = true + } + if interrupts > 0 { + b.WriteString(fmt.Sprintf("• Handled **%d** interrupt%s along the way.\n", interrupts, pluralS(interrupts))) + bulleted = true + } + if harvests > 0 { + b.WriteString(fmt.Sprintf("• Came back with **%d** harvest%s.\n", harvests, pluralS(harvests))) + bulleted = true + } + for _, f := range forageLines { + b.WriteString("• ") + b.WriteString(f) + b.WriteString("\n") + bulleted = true + } + for _, t := range threatLines { + b.WriteString("• ") + b.WriteString(t) + b.WriteString("\n") + bulleted = true + } + for _, m := range milestoneLine { + b.WriteString("• ") + b.WriteString(m) + b.WriteString("\n") + bulleted = true + } + for _, n := range narrativeBits { + b.WriteString("• ") + b.WriteString(n) + b.WriteString("\n") + bulleted = true + } + if !bulleted { + // All entries were filtered out — fall back to the bare camp block. + return "" + } + return b.String() +} + +func pluralS(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/internal/plugin/expedition_autorun_digest_test.go b/internal/plugin/expedition_autorun_digest_test.go new file mode 100644 index 0000000..9b15fbf --- /dev/null +++ b/internal/plugin/expedition_autorun_digest_test.go @@ -0,0 +1,124 @@ +package plugin + +import ( + "strings" + "testing" + + "maunium.net/go/mautrix/id" +) + +// Long-expedition D4-a — verifies the EoD digest renderer pulls only +// the prior day's structured entries and that buildAutoRunDM's surface +// rules suppress / surface the right ticks. + +func TestRenderEndOfDayDigest_GroupsByType(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@digest:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + day := exp.CurrentDay + for _, e := range []struct{ kind, summary, flavor string }{ + {"walk", "auto-walk: 3 room(s)", ""}, + {"walk", "auto-walk: 2 room(s)", ""}, + {"harvest", "autopilot harvest oak success", ""}, + {"harvest", "autopilot harvest iron failure", ""}, // not counted + {"interrupt", "noise alarm total=1", ""}, + {"threat", "drift", "The dungeon stirs awake."}, + {"milestone", "milestone awarded: first-night", ""}, + {"narrative", "autopilot broke camp (dwell elapsed)", ""}, // filtered + {"narrative", "voluntary extraction prompt", ""}, + } { + if err := appendExpeditionLog(exp.ID, day, e.kind, e.summary, e.flavor); err != nil { + t.Fatal(err) + } + } + // Entry from a different day must NOT bleed in. + if err := appendExpeditionLog(exp.ID, day+1, "walk", "next day", ""); err != nil { + t.Fatal(err) + } + + got := renderEndOfDayDigest(exp.ID, day) + if got == "" { + t.Fatal("expected non-empty digest") + } + wantSubs := []string{ + "Walked **2**", + "Handled **1** interrupt", + "**1** harvest", + "dungeon stirs awake", + "milestone awarded: first-night", + "voluntary extraction prompt", + } + for _, s := range wantSubs { + if !strings.Contains(got, s) { + t.Errorf("digest missing %q\n--- got ---\n%s", s, got) + } + } + if strings.Contains(got, "autopilot broke camp") { + t.Errorf("digest should filter housekeeping narrative\n%s", got) + } + if strings.Contains(got, "next day") { + t.Errorf("digest leaked entry from a later day\n%s", got) + } +} + +func TestRenderEndOfDayDigest_EmptyDayReturnsEmpty(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@digest-empty:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + if got := renderEndOfDayDigest(exp.ID, exp.CurrentDay); got != "" { + t.Errorf("expected empty digest for a day with no entries, got %q", got) + } +} + +func TestBuildAutoRunDM_SuppressesQuietTick(t *testing.T) { + r := autopilotWalkResult{rooms: 3, reason: stopOK, stream: []string{"…walked…"}} + body, ok := buildAutoRunDM("expid", r, "", autoCampDecision{}) + if ok || body != "" { + t.Errorf("quiet stopOK tick should be silent, got ok=%v body=%q", ok, body) + } +} + +func TestBuildAutoRunDM_ForkSurfaces(t *testing.T) { + r := autopilotWalkResult{rooms: 1, reason: stopFork, finalMsg: "pick a path"} + body, ok := buildAutoRunDM("expid", r, "", autoCampDecision{}) + if !ok || !strings.Contains(body, "pick a path") { + t.Errorf("fork should surface, got ok=%v body=%q", ok, body) + } +} + +func TestBuildAutoRunDM_NonNightCampSurfaces(t *testing.T) { + r := autopilotWalkResult{rooms: 2, reason: stopPreflight, finalMsg: "low hp"} + camp := "\n\n⛺ **Autopilot camp**" + body, ok := buildAutoRunDM("expid", r, camp, autoCampDecision{Kind: CampTypeRough}) + if !ok || !strings.Contains(body, "Autopilot camp") { + t.Errorf("mid-day camp pitch should surface camp block, got ok=%v body=%q", ok, body) + } + // Walk stream/final message dropped — D4-a suppresses the walk narration. + if strings.Contains(body, "low hp") { + t.Errorf("non-night camp DM should not carry walk finalMsg, got %q", body) + } +} + +func TestBuildAutoRunDM_BossSafetyHasHoldHeader(t *testing.T) { + r := autopilotWalkResult{rooms: 0, reason: stopBossSafety} + camp := "\n\n⛺ **Autopilot camp — Standard.**" + dec := autoCampDecision{Kind: CampTypeStandard, Reason: "boss-safety hold — resting before re-engaging"} + body, ok := buildAutoRunDM("expid", r, camp, dec) + if !ok || !strings.Contains(body, "Holding before the boss") { + t.Errorf("boss-safety camp should prepend hold header, got ok=%v body=%q", ok, body) + } +} diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 11add7c..04a4c4b 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -14,6 +14,7 @@ package plugin import ( "encoding/json" "fmt" + "os" "sort" "time" @@ -22,6 +23,12 @@ import ( "maunium.net/go/mautrix/id" ) +// simInlineBossCombat is a D8-e DIAGNOSTIC toggle. When GOGOBEE_SIM_INLINE_BOSS=1 +// the sim routes boss/elite doorways through the inline SimulateCombat path +// (production autopilot behavior) instead of the turn-based !fight engine. +// Used to A/B the martial T4/T5 regression. NOT for production. +func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") == "1" } + // SimRunner owns a temp-DB AdventurePlugin + EuroPlugin pair and exposes // just enough surface to drive synthetic players end-to-end. type SimRunner struct { @@ -60,6 +67,11 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D if err := createAdvCharacter(uid, "sim_"+string(class)); err != nil { return nil, fmt.Errorf("createAdvCharacter: %w", err) } + if simPetLevel > 0 { + if err := attachSimPet(uid, simPetLevel); err != nil { + return nil, fmt.Errorf("attachSimPet: %w", err) + } + } c := &DnDCharacter{ UserID: uid, Race: RaceHuman, @@ -111,7 +123,7 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D // stockSimConsumables drops a small tier-appropriate bundle of potions // + a couple offensive items into the synthetic player's inventory so // SelectConsumables / setupAutoHealFromInventory have something to fire -// during autoResolveCombat. Counts are deliberately modest — a real +// during autoDriveCombat. Counts are deliberately modest — a real // L7+ player typically carries 3-6 heals plus a couple of buffs; we // mirror that band rather than max-stocking, which would mask class // power gaps. @@ -170,6 +182,26 @@ func simConsumableBundle(tier int) map[string]int { // L7-9 → T3, L10-12 → T4, L13+ → T5). It's a "kitted-out at expected // difficulty" baseline, not a min-max — players past the appropriate // shop visit should be at or above this band. +// attachSimPet stamps a base housing pet (Massive Dog, no armor) at the +// given level onto the synthetic character via the normal adv-char save +// path, so combat's DerivePlayerStats sees HasPet()==true. Dog vs cat is +// numerically identical today, so type is arbitrary; armor tier stays 0 to +// model the plain "base pet" rather than a kitted one. +func attachSimPet(uid id.UserID, level int) error { + char, err := loadAdvCharacter(uid) + if err != nil { + return err + } + char.PetType = "dog" + char.PetName = "SimDog" + char.PetLevel = level + char.PetArmorTier = 0 + char.PetArrived = true + char.PetChasedAway = false + char.PetXP = 0 + return saveAdvCharacter(char) +} + func outfitSimCharacter(uid id.UserID, level int) error { tier := simGearTierForLevel(level) equip, err := loadAdvEquipment(uid) @@ -249,15 +281,33 @@ type SimResult struct { // resource). YieldsByName breaks that total down by resource name // for tier/per-resource calibration. Both are read from // adventure_inventory at end-of-run. - YieldCount int - YieldsByName map[string]int + YieldCount int + YieldsByName map[string]int // Combats holds a per-combat trace for every fight the synthetic // player entered during the expedition (boss + elites + patrols). // Used by post-hoc analysis to dig into class-survival walls // 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 @@ -300,6 +350,20 @@ var simIncludeTrace = false // room). Callers should flip this on before BuildCharacter / RunExpedition. func SetSimIncludeTrace(on bool) { simIncludeTrace = on } +// simPetLevel, when > 0, attaches a base housing pet (Massive Dog, no +// armor) at that level to every synthetic character. 0 (the default) leaves +// the character petless, matching prod char-creation. Pets are otherwise +// unreachable in the sim — synthetic chars never trigger the arrival flow — +// so this is the only way to exercise the per-round pet attack / deflect / +// whiff path for balance measurement. Combat reads pet stats off the +// AdventureCharacter (see DerivePlayerStats), so BuildCharacter stamps the +// fields there, not on the DnDCharacter. +var simPetLevel = 0 + +// SetSimPetLevel attaches a base pet at the given level (1-10) to sim +// characters. 0 disables. Flip before BuildCharacter. +func SetSimPetLevel(level int) { simPetLevel = level } + // SimLogEntry is a JSONL-friendly projection of one dnd_expedition_log // row. We expose just the fields a post-hoc analyzer needs without // pulling the full ExpeditionEntry type. @@ -311,17 +375,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) @@ -336,7 +413,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 " 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) @@ -345,9 +425,18 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S return res, fmt.Errorf("expedition did not persist after start") } 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, simInlineBossCombat()) if walk.initErr != "" { res.Outcome = "halted" res.StopCode = "init:" + walk.initErr @@ -363,12 +452,35 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S res.Outcome = "tpk" i = walkCap // exit case stopComplete: + // A stopComplete that reaches the sim is normally a full zone + // clear (the walk auto-advances mid-zone region boundaries + // internally). But a mid-zone region clear can still surface + // here — e.g. the walk's auto-advance hit a transit error and + // returned stopComplete. Mirror runAutopilotWalk: if the + // expedition is still active in a multi-region zone with a + // next region, cross into it and keep simulating instead of + // scoring a premature "cleared". + if fresh, ferr := getActiveExpedition(uid); ferr == nil && fresh != nil && + IsMultiRegionZone(fresh.ZoneID) { + if cur, ok := CurrentRegion(fresh); ok { + if next, ok := nextRegion(fresh.ZoneID, cur.ID); ok { + if _, terr := s.P.advanceToNextRegion(uid, fresh, cur, next); terr != nil { + res.Outcome = "halted" + res.StopCode = "region_transit:" + terr.Error() + i = walkCap + break + } + exp = fresh + break // continue the outer walk loop in the next region + } + } + } res.Outcome = "cleared" i = walkCap case stopBoss, stopElite: // Auto-resolve the encounter: !fight to open, then !attack // per round until the session resolves (won / lost / fled). - killed, err := s.autoResolveCombat(ctx) + killed, err := s.P.autoDriveCombat(ctx) if err != nil { res.Outcome = "halted" res.StopCode = "combat:" + err.Error() @@ -394,9 +506,22 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S // Boss kill closes the run via combat resolution → continue // the loop so the next walk picks up the cleared-run state. case stopFork: - // Deterministic sim policy: always take path 1. Real players - // pick based on intent; the sim just needs to make progress. - if err := s.P.handleDnDExpeditionCmd(ctx, "go 1"); err != nil { + // Deterministic sim policy: take the first UNLOCKED path. The + // old blind "go 1" stalled forever on all-skill-check forks + // (feywild fork1) — resolveForkChoice rejects a locked edge but + // zoneCmdGo swallows it as a sent-DM with a nil return, so the + // run never advanced and burned every walk at the same node. A + // real player reads the menu and picks a passable path; mirror + // that. choice==0 means every edge is locked (a graph soft-lock + // the author must fix) — halt loudly rather than spin. + choice := s.firstUnlockedForkChoice(ctx.Sender) + if choice == 0 { + res.Outcome = "halted" + res.StopCode = "fork_all_locked" + i = walkCap + break + } + if err := s.P.handleDnDExpeditionCmd(ctx, fmt.Sprintf("go %d", choice)); err != nil { res.Outcome = "halted" res.StopCode = "fork:" + err.Error() i = walkCap @@ -405,28 +530,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 == "" { @@ -452,9 +624,58 @@ 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 } +// firstUnlockedForkChoice returns the 1-based index of the first +// traversable option at the pending fork, or 0 if every edge is locked +// (a graph soft-lock — see feywild fork1, which had no LockNone exit). +func (s *SimRunner) firstUnlockedForkChoice(uid id.UserID) int { + run, err := getActiveZoneRun(uid) + if err != nil || run == nil { + return 1 + } + pf, err := decodePendingFork(run.NodeChoices) + if err != nil || pf == nil { + return 1 + } + for _, o := range pf.Options { + if o.Unlocked { + return o.Index + } + } + return 0 +} + +// captureDaySnapshot appends a SimDaySnapshot reflecting current state. +// HP is read from the live character row; SU/threat/day from the live +// expedition. Rooms is the running res.Rooms counter. +func (s *SimRunner) captureDaySnapshot(res *SimResult, exp *Expedition, uid id.UserID) { + snap := SimDaySnapshot{ + Day: exp.CurrentDay, + Supplies: exp.Supplies.Current, + Threat: exp.ThreatLevel, + Rooms: res.Rooms, + } + if c, _ := LoadDnDCharacter(uid); c != nil { + snap.HPCurrent = c.HPCurrent + snap.HPMax = c.HPMax + } + res.DaySnapshots = append(res.DaySnapshots, snap) +} + // simCombatSummaries pulls every combat_sessions row for uid and folds // its TurnLog into a SimCombatSummary. AC values are inferred from the // RollAgainst column on attack events (the engine writes the defender's @@ -554,16 +775,24 @@ func simMaterialYields(uid id.UserID) (int, map[string]int) { return total, out } -// autoResolveCombat dispatches !fight at the current elite/boss gate, -// then loops !attack until the combat session resolves. Returns true -// when the player won (enemy dead, room cleared), false when the -// player lost or fled. autoCombatRoundCap is a safety cap against +// autoDriveCombat dispatches !fight at the current elite/boss gate, +// then loops !attack/!cast/!consume until the combat session resolves. +// Returns true when the player won (enemy dead, room cleared), false when +// the player lost or fled. autoCombatRoundCap is a safety cap against // pathological stalemates (shouldn't trigger in practice — combat is // strictly monotone in HP). +// +// Shared by the headless sim and the production background autopilot +// (long-expedition D8-f): a silent ctx (ctx.Silent) suppresses the +// per-round DM narration so the autorun digest can summarize the fight +// without spamming the player a message per round. Driving the real +// !fight/!attack engine here is what gives autopilot bosses true parity +// with manual `!fight` — including enemy multiattack, which the old +// inline SimulateCombat path ignored. const autoCombatRoundCap = 200 -func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) { - if err := s.P.handleFightCmd(ctx); err != nil { +func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) { + if err := p.handleFightCmd(ctx); err != nil { return false, fmt.Errorf("fight: %w", err) } sess, err := getActiveCombatSession(ctx.Sender) @@ -591,15 +820,15 @@ func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) { case CombatStatusLost, CombatStatusFled: return false, nil } - kind, arg := s.simPickCombatAction(ctx.Sender, cur) + kind, arg := p.pickAutoCombatAction(ctx.Sender, cur) var dispatchErr error switch kind { case "consume": - dispatchErr = s.P.handleConsumeCmd(ctx, arg) + dispatchErr = p.handleConsumeCmd(ctx, arg) case "cast": - dispatchErr = s.P.handleCombatCastCmd(ctx, arg) + dispatchErr = p.handleCombatCastCmd(ctx, arg) default: - dispatchErr = s.P.handleAttackCmd(ctx) + dispatchErr = p.handleAttackCmd(ctx) } if dispatchErr != nil { return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr) @@ -647,7 +876,7 @@ func (s *SimRunner) maybeShortRest(ctx MessageContext, uid id.UserID) { // one more big hit, not so early that a 1-HP scratch burns a potion. const simHealHPThresholdPct = 40 -// simPickCombatAction is the sim's per-turn decision tree, mirroring +// pickAutoCombatAction is the per-turn decision tree, mirroring // what a competent prod player would type: // // 1. If HP is below simHealHPThresholdPct and the inventory has a heal @@ -666,14 +895,14 @@ const simHealHPThresholdPct = 40 // // Pre-J2a the sim looped !attack only, which underweighted every caster // class — see sim_results/j2_findings.md for the trace evidence. -func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) { +func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) { c, _ := LoadDnDCharacter(uid) if c == nil || sess == nil { return "attack", "" } lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct if lowHP { - inv := s.P.loadConsumableInventory(uid) + inv := p.loadConsumableInventory(uid) for _, it := range inv { if it.Def.Effect == EffectHeal { return "consume", it.Def.Name @@ -681,7 +910,21 @@ func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kin } } if isSpellcaster(c) && !simMartialFirstClass(c.Class) { - if id := simPickSpell(c, uid); id != "" { + // Cleric: Spiritual Weapon is a BuffSelf that fires a spectral + // bonus-action attack each round via SpiritWeaponProc/Dmg mods — + // simPickSpell skips BuffSelf entries by design, so a cleric + // otherwise never spends an L2 slot on it. Force the pick once + // per fight (BuffSpiritProc==0) so the picker doesn't pretend + // it's not a damage option. + if id := simPickSpiritualWeapon(c, uid, sess); id != "" { + return "cast", id + } + // Once a concentration aura is up, a competent caster maintains it and + // attacks (or casts a non-concentration spell) rather than burning a + // slot to re-arm the same aura — so the picker excludes concentration + // spells while one is active. + auraActive := sess.Statuses.ConcentrationDmg > 0 + if id := simPickSpell(c, uid, auraActive); id != "" { return "cast", id } } @@ -704,8 +947,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 `" --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 @@ -713,22 +1006,24 @@ 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 { +func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string { known, err := listKnownSpells(uid) if err != nil || len(known) == 0 { return "" } 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 { @@ -747,6 +1042,11 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string { if sp.CastTime == CastReaction { continue } + // An aura is already ticking — don't re-arm it; prefer attacks or a + // non-concentration spell this turn. + if auraActive && sp.Concentration { + continue + } onList := false for _, cl := range sp.Classes { if cl == c.Class { @@ -757,24 +1057,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 @@ -785,6 +1142,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") @@ -806,8 +1170,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 @@ -815,6 +1185,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) { diff --git a/internal/plugin/expedition_sim_test.go b/internal/plugin/expedition_sim_test.go new file mode 100644 index 0000000..960abc9 --- /dev/null +++ b/internal/plugin/expedition_sim_test.go @@ -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) + } +} diff --git a/internal/plugin/forex.go b/internal/plugin/forex.go index 301bd4a..1589603 100644 --- a/internal/plugin/forex.go +++ b/internal/plugin/forex.go @@ -122,6 +122,10 @@ const fxHelpText = "**Forex Commands**\n\n" + // ── Command Handlers ──────────────────────────────────────────────────────── func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error { + if msg := fxValidateAnalysisArgs(args); msg != "" { + return p.SendReply(ctx.RoomID, ctx.EventID, msg) + } + // Check for cross-pair syntax like EUR/USD or USD/JPY if pair := fxParsePair(args, false); pair != nil { return p.cmdPairRateOrReport(ctx, pair, false) @@ -277,6 +281,9 @@ func (p *ForexPlugin) fxPerUSD(cur string, fiatRates map[string]float64) (float6 } func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error { + if msg := fxValidateAnalysisArgs(args); msg != "" { + return p.SendReply(ctx.RoomID, ctx.EventID, msg) + } if pair := fxParsePair(args, false); pair != nil { return p.cmdPairRateOrReport(ctx, pair, true) } @@ -404,6 +411,34 @@ func (p *ForexPlugin) checkAlerts(rates map[string]float64) { // ── Helpers ───────────────────────────────────────────────────────────────── +// fxValidateAnalysisArgs vets the currency tokens passed to the fiat-only +// rate/report path. It returns a player-facing error message if any token is +// unusable here, or "" when the args are fine (including empty args, which fall +// back to the default tracked set). Crypto tokens get a dedicated nudge since +// crypto only works on the conversion path; anything else is just invalid. +func fxValidateAnalysisArgs(args []string) string { + for _, a := range args { + // Split pair syntax (BTC/USD) into its sides so each is checked. + raw := strings.ToUpper(a) + toks := []string{raw} + for _, sep := range []string{"/", "|", "-"} { + if strings.Contains(raw, sep) { + toks = strings.SplitN(raw, sep, 2) + break + } + } + for _, t := range toks { + if fxIsCrypto(t) { + return "Silly rabbit. Crypto is for kids! (In other words.. that's an invalid command — crypto only works for conversions like `!fx 100 USD to BTC`.)" + } + if !fxIsSupported(t) { + return fmt.Sprintf("Don't know the currency `%s`. Try `!fx help`.", t) + } + } + } + return "" +} + func fxParseCurrencies(args []string) []string { var out []string for _, a := range args { diff --git a/internal/plugin/forex_crypto.go b/internal/plugin/forex_crypto.go index e71c094..29b3595 100644 --- a/internal/plugin/forex_crypto.go +++ b/internal/plugin/forex_crypto.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" + "net/url" "strings" "sync" "time" @@ -82,8 +84,11 @@ func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) { ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() - url := fmt.Sprintf("%s/simple/price?ids=%s&vs_currencies=usd", coinGeckoBaseURL, info.CoinGeckoID) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + q := url.Values{} + q.Set("ids", info.CoinGeckoID) + q.Set("vs_currencies", "usd") + reqURL := coinGeckoBaseURL + "/simple/price?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return 0, err } @@ -99,7 +104,7 @@ func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) { } var data map[string]map[string]float64 - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&data); err != nil { return 0, fmt.Errorf("coingecko decode error: %w", err) } entry, ok := data[info.CoinGeckoID] diff --git a/internal/plugin/holdem_tip_scenarios_test.go b/internal/plugin/holdem_tip_scenarios_test.go index e42ef34..a1ebb82 100644 --- a/internal/plugin/holdem_tip_scenarios_test.go +++ b/internal/plugin/holdem_tip_scenarios_test.go @@ -5,6 +5,7 @@ import ( "os" "strings" "testing" + "time" "github.com/chehsunliu/poker" ) @@ -30,6 +31,11 @@ func loadSolverFixture() { func TestMain(m *testing.M) { loadSolverFixture() + // D2-b: shove eventAnchoredCutoff far into the future so existing tests + // keep exercising the legacy UTC-anchored briefing mutator path by + // default. New event-anchored tests opt in by overriding the cutoff + // (see useEventAnchored helpers). + eventAnchoredCutoff = time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC) os.Exit(m.Run()) } diff --git a/internal/plugin/link_thumbnail.go b/internal/plugin/link_thumbnail.go new file mode 100644 index 0000000..e247e46 --- /dev/null +++ b/internal/plugin/link_thumbnail.go @@ -0,0 +1,184 @@ +package plugin + +import ( + "bytes" + "context" + "fmt" + "image" + _ "image/gif" // register GIF decoder for DecodeConfig + _ "image/jpeg" // register JPEG decoder for DecodeConfig + _ "image/png" // register PNG decoder for DecodeConfig + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "gogobee/internal/safehttp" + + _ "golang.org/x/image/webp" // register WebP decoder for DecodeConfig + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// thumbnailUserAgent identifies the bot when probing and downloading the +// preview image for a posted link. +const thumbnailUserAgent = "GogoBee/1.0 (+link thumbnail fetch)" + +// thumbnailClient validates and downloads link-supplied image URLs. It routes +// through safehttp so a posted link can't steer fetches at loopback / RFC1918 / +// cloud-metadata IPs — the dial-time guard re-checks every redirect target too. +// The 10 MiB download ceiling below bounds memory regardless. +var thumbnailClient = safehttp.NewClient(15 * time.Second) + +// resolveURL turns a possibly-relative ref into an absolute URL against base. +func resolveURL(base, ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" { + return "" + } + b, err := url.Parse(base) + if err != nil { + return ref + } + r, err := url.Parse(ref) + if err != nil { + return ref + } + return b.ResolveReference(r).String() +} + +// normalizeImageURL rewrites known CDN thumbnail URLs to a higher-resolution +// variant. Currently handles The Guardian's i.guim.co.uk, whose pages hand out +// narrow thumbnails. Signed URLs are left alone (re-signing isn't possible), +// as are unrecognized hosts. +func normalizeImageURL(raw string) string { + if raw == "" { + return raw + } + u, err := url.Parse(raw) + if err != nil || u.Host != "i.guim.co.uk" { + return raw + } + q := u.Query() + if q.Get("width") == "" || q.Get("s") != "" { + return raw + } + q.Set("width", "1200") + u.RawQuery = q.Encode() + return u.String() +} + +// validateImageURL HEAD-probes an image URL: it must be http(s), return 200, +// have an image/* content type, and (if a length is declared) exceed 5 KiB so +// tracking pixels are filtered. Returns false with no error on any failure. +func validateImageURL(rawURL string) bool { + if rawURL == "" || safehttp.ValidateURL(rawURL) != nil { + return false + } + req, err := http.NewRequest("HEAD", rawURL, nil) + if err != nil { + return false + } + req.Header.Set("User-Agent", thumbnailUserAgent) + + resp, err := thumbnailClient.Do(req) + if err != nil { + slog.Debug("thumbnail: image HEAD failed", "url", rawURL, "err", err) + return false + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false + } + if !strings.HasPrefix(resp.Header.Get("Content-Type"), "image/") { + return false + } + if cl := resp.Header.Get("Content-Length"); cl != "" { + if size, err := strconv.ParseInt(cl, 10, 64); err == nil && size <= 5120 { + return false // tracking pixel + } + } + return true +} + +// SendImageFromURL downloads imageURL, uploads it to Matrix, and posts it as an +// m.image event in roomID. Returns an error on any failure so callers can fall +// back to a text-only preview. The fetch is SSRF-guarded and size-capped. +func (b *Base) SendImageFromURL(roomID id.RoomID, imageURL string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil) + if err != nil { + return fmt.Errorf("create image request: %w", err) + } + req.Header.Set("User-Agent", thumbnailUserAgent) + + resp, err := thumbnailClient.Do(req) + if err != nil { + return fmt.Errorf("download image: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("image download status %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) + if err != nil { + return fmt.Errorf("read image: %w", err) + } + + contentType := resp.Header.Get("Content-Type") + if i := strings.IndexByte(contentType, ';'); i >= 0 { + contentType = strings.TrimSpace(contentType[:i]) + } + if contentType == "" { + contentType = "image/jpeg" + } + if !strings.HasPrefix(contentType, "image/") { + return fmt.Errorf("not an image: %s", contentType) + } + + // Decode dimensions so clients render the image inline rather than as a + // downloadable file attachment. + var width, height int + if cfg, _, decErr := image.DecodeConfig(bytes.NewReader(data)); decErr == nil { + width, height = cfg.Width, cfg.Height + } + + ext := ".jpg" + switch contentType { + case "image/png": + ext = ".png" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + } + filename := "thumbnail" + ext + + uri, err := b.UploadContent(data, contentType, filename) + if err != nil { + return fmt.Errorf("upload image: %w", err) + } + + content := &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: filename, + FileName: filename, + URL: uri.CUString(), + Info: &event.FileInfo{ + MimeType: contentType, + Size: len(data), + Width: width, + Height: height, + }, + } + _, err = b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) + return err +} diff --git a/internal/plugin/link_thumbnail_test.go b/internal/plugin/link_thumbnail_test.go new file mode 100644 index 0000000..094e343 --- /dev/null +++ b/internal/plugin/link_thumbnail_test.go @@ -0,0 +1,40 @@ +package plugin + +import "testing" + +func TestResolveURL(t *testing.T) { + cases := []struct { + base, ref, want string + }{ + {"https://e.com/news/story", "https://cdn.e.com/a.jpg", "https://cdn.e.com/a.jpg"}, + {"https://e.com/news/story", "//cdn.e.com/a.jpg", "https://cdn.e.com/a.jpg"}, + {"https://e.com/news/story", "/img/a.jpg", "https://e.com/img/a.jpg"}, + {"https://e.com/news/story", "a.jpg", "https://e.com/news/a.jpg"}, + {"https://e.com/news/story", "", ""}, + } + for _, c := range cases { + if got := resolveURL(c.base, c.ref); got != c.want { + t.Errorf("resolveURL(%q, %q) = %q, want %q", c.base, c.ref, got, c.want) + } + } +} + +func TestNormalizeImageURL(t *testing.T) { + cases := []struct { + name, in, want string + }{ + {"non-guardian passthrough", "https://e.com/a.jpg?width=140", "https://e.com/a.jpg?width=140"}, + {"signed left alone", "https://i.guim.co.uk/x.jpg?width=140&s=abc", "https://i.guim.co.uk/x.jpg?width=140&s=abc"}, + {"no width passthrough", "https://i.guim.co.uk/x.jpg", "https://i.guim.co.uk/x.jpg"}, + {"empty", "", ""}, + } + for _, c := range cases { + if got := normalizeImageURL(c.in); got != c.want { + t.Errorf("%s: normalizeImageURL(%q) = %q, want %q", c.name, c.in, got, c.want) + } + } + // Unsigned Guardian thumbnail gets bumped to width=1200. + if got := normalizeImageURL("https://i.guim.co.uk/x.jpg?width=140"); got != "https://i.guim.co.uk/x.jpg?width=1200" { + t.Errorf("normalizeImageURL unsigned = %q, want width=1200", got) + } +} diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index b92cfb7..2304eef 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -909,6 +909,19 @@ func resetAllPlayerMetaDailyActions() error { return err } +// resetAllPetMorningDefense clears the one-day pet morning-defense buff for +// every player. The buff is a fresh morning grant (25% roll in the briefing / +// overworld DM), so it must not survive past the midnight rollover. The flag +// lives inside pet_flags_json (see petFlagsJSON), so patch that key in place; +// only rows where it's currently set are touched. +func resetAllPetMorningDefense() error { + _, err := db.Get().Exec(` + UPDATE player_meta + SET pet_flags_json = json_set(pet_flags_json, '$.morning_defense', json('false')) + WHERE json_extract(pet_flags_json, '$.morning_defense') = 1`) + return err +} + // DeathState mirrors player_meta's death-state columns. Phase L5e ports // these fields off AdvCharacter (gogobee_legacy_migration.md §7.3 L5e). // Mutation surface is large (~50 saveAdvCharacter sites touch death diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 1c949fe..26ef770 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -41,6 +41,13 @@ type MessageContext struct { // routes DM commands to the player's game room). When set, sender-private // replies should go here instead of the rewritten RoomID. OriginRoomID id.RoomID + // Silent suppresses player-facing replies for handlers that honor it + // (currently the turn-engine combat commands via replyDM). Set by the + // background autopilot when it drives a boss/elite fight through the + // real !fight/!attack engine for manual parity (long-expedition D8-f) — + // the day digest summarizes the outcome, so the per-round narration is + // dropped rather than DM'd round-by-round. + Silent bool } // ReactionContext holds the context for a reaction event. diff --git a/internal/plugin/scenario_proddb_test.go b/internal/plugin/scenario_proddb_test.go new file mode 100644 index 0000000..024584d --- /dev/null +++ b/internal/plugin/scenario_proddb_test.go @@ -0,0 +1,429 @@ +package plugin + +// Scenario tests run against a copy of the prod DB (data/gogobee.db). +// Gated on GOGOBEE_PRODDB_SCENARIOS=1 so they don't run on default +// `go test ./...` invocations. Pattern mirrors setupAuditTestDB. +// +// Run with: GOGOBEE_PRODDB_SCENARIOS=1 go test -run TestScenario_ -v \ +// ./internal/plugin/ + +import ( + "os" + "regexp" + "strings" + "testing" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +func requireScenarioEnv(t *testing.T) { + t.Helper() + if os.Getenv("GOGOBEE_PRODDB_SCENARIOS") != "1" { + t.Skip("scenario tests gated on GOGOBEE_PRODDB_SCENARIOS=1") + } +} + +// ── Scenario: Josie caster-aid bootstraps ────────────────────────────────── +// +// Verifies (against a copy of the live DB) that the two 2026-06-18 caster-aid +// bootstraps land for @holymachina: the spell backfill adds inflict_wounds to +// her known+prepared book, and the pet gift grants a L10 dog mirrored into +// player_meta. Both must be idempotent on a second run. +func TestScenario_JosieCasterAid(t *testing.T) { + requireScenarioEnv(t) + setupAuditTestDB(t) + + const uid = id.UserID("@holymachina:parodia.dev") + + hasInflict := func() bool { + known, err := listKnownSpells(uid) + if err != nil { + t.Fatalf("list known: %v", err) + } + for _, k := range known { + if k.SpellID == "inflict_wounds" { + return k.Prepared + } + } + return false + } + + if hasInflict() { + t.Fatal("precondition failed: target already knows inflict_wounds") + } + char, err := loadAdvCharacter(uid) + if err != nil || char == nil { + t.Fatalf("load target char: %v", err) + } + if char.PetArrived || char.PetType != "" { + t.Fatal("precondition failed: target already has a pet") + } + + // Run twice to prove idempotency. + for i := 0; i < 2; i++ { + bootstrapCasterSpellBackfill() + bootstrapGrantStarterPet() + } + + if !hasInflict() { + t.Error("spell backfill did not add inflict_wounds as prepared") + } + + got, err := loadAdvCharacter(uid) + if err != nil || got == nil { + t.Fatalf("reload target char: %v", err) + } + if !got.PetArrived || got.PetType != "dog" || got.PetLevel != 10 { + t.Errorf("pet grant: arrived=%v type=%q level=%d, want true/dog/10", + got.PetArrived, got.PetType, got.PetLevel) + } + pet, err := loadPetState(uid) + if err != nil { + t.Fatalf("load pet state: %v", err) + } + if !pet.HasPet() || pet.Level != 10 { + t.Errorf("player_meta pet mirror: hasPet=%v level=%d, want true/10", pet.HasPet(), pet.Level) + } +} + +// ── Scenario 1: Phase 5-B HP bootstrap ───────────────────────────────────── +// +// Expected: bootstrapPhase5BHPRefresh() walks dnd_character rows where +// dnd_level > 0, refreshes hp_max upward toward computeMaxHP (which +// applies phase5BHPMult=1.5), bumps hp_current by the same delta, and +// marks the daily_prefetch job key so reruns are no-ops. +func TestScenario_Phase5BHPBootstrap(t *testing.T) { + requireScenarioEnv(t) + setupAuditTestDB(t) + + type charRow struct { + userID string + class string + level int + conScore int + hpMax int + hpCurrent int + } + snapshot := func() map[string]charRow { + rows, err := db.Get().Query(` + SELECT user_id, class, dnd_level, con_score, hp_max, hp_current + FROM dnd_character WHERE dnd_level > 0`) + if err != nil { + t.Fatalf("query: %v", err) + } + defer rows.Close() + out := map[string]charRow{} + for rows.Next() { + var r charRow + if err := rows.Scan(&r.userID, &r.class, &r.level, &r.conScore, &r.hpMax, &r.hpCurrent); err != nil { + t.Fatalf("scan: %v", err) + } + out[r.userID] = r + } + return out + } + + before := snapshot() + t.Logf("[pre-bootstrap] %d characters with dnd_level > 0", len(before)) + for _, r := range before { + t.Logf(" %s class=%q L%d con=%d hp=%d/%d", + r.userID, r.class, r.level, r.conScore, r.hpCurrent, r.hpMax) + } + + if db.JobCompleted("phase5b_hp_refresh_v1", "once") { + t.Fatalf("job already marked completed before bootstrap — snapshot was post-bootstrap?") + } + + bootstrapPhase5BHPRefresh() + + after := snapshot() + if !db.JobCompleted("phase5b_hp_refresh_v1", "once") { + t.Errorf("expected JobCompleted=true after bootstrap") + } + + refreshed := 0 + for uid, b := range before { + a := after[uid] + conMod := abilityModifier(b.conScore) + _, ok := classInfo(DnDClass(b.class)) + var expectedMax int + if !ok { + expectedMax = 1 // computeMaxHP returns 1 for unknown class. + } else { + expectedMax = computeMaxHP(DnDClass(b.class), conMod, b.level) + } + // Bootstrap skips rows where newMax <= oldMax (never lowers HP). + if expectedMax <= b.hpMax { + if a.hpMax != b.hpMax { + t.Errorf("%s: expected hp_max unchanged (%d), got %d", uid, b.hpMax, a.hpMax) + } + continue + } + delta := expectedMax - b.hpMax + expectedCurrent := b.hpCurrent + delta + if expectedCurrent > expectedMax { + expectedCurrent = expectedMax + } + if expectedCurrent < 1 { + expectedCurrent = 1 + } + if a.hpMax != expectedMax { + t.Errorf("%s: hp_max want %d got %d", uid, expectedMax, a.hpMax) + } + if a.hpCurrent != expectedCurrent { + t.Errorf("%s: hp_current want %d (was %d, +delta %d) got %d", + uid, expectedCurrent, b.hpCurrent, delta, a.hpCurrent) + } + // Wound-preservation invariant: absolute wound (max-current) stays + // constant unless clamped at floor 1 or at the new ceiling. + preWound := b.hpMax - b.hpCurrent + postWound := a.hpMax - a.hpCurrent + if preWound != postWound && expectedCurrent != 1 && expectedCurrent != expectedMax { + t.Errorf("%s: wound size changed pre=%d post=%d (no clamp expected)", + uid, preWound, postWound) + } + refreshed++ + t.Logf("[refreshed] %s: hp_max %d→%d (+%d), hp_current %d→%d", + uid, b.hpMax, a.hpMax, delta, b.hpCurrent, a.hpCurrent) + } + t.Logf("[post-bootstrap] %d/%d characters refreshed", refreshed, len(before)) + + // Idempotency: second call is a no-op. + bootstrapPhase5BHPRefresh() + after2 := snapshot() + for uid, a := range after { + if after2[uid].hpMax != a.hpMax || after2[uid].hpCurrent != a.hpCurrent { + t.Errorf("%s: second bootstrap call mutated HP", uid) + } + } +} + +// ── Scenario 2: Magic-item plumbing ──────────────────────────────────────── +// +// Expected: +// - magic_item_equipped table exists (Phase 5 migration). +// - magicItemRegistry is non-empty; rarity index covers every rarity. +// - Slot classifier output (baked into magic_items_srd_data.go via gen) +// puts known edge-case items in the right slot per the UX S4 fix. +// - dailyCuriosStock() returns a non-empty rotating shelf. +func TestScenario_MagicItemPlumbing(t *testing.T) { + requireScenarioEnv(t) + setupAuditTestDB(t) + + // (a) Migration created the table. + var n int + err := db.Get().QueryRow(` + SELECT COUNT(*) FROM sqlite_master + WHERE type='table' AND name='magic_item_equipped'`).Scan(&n) + if err != nil || n != 1 { + t.Fatalf("magic_item_equipped table missing (n=%d err=%v)", n, err) + } + + // (b) Schema sanity — required columns. + cols, err := db.Get().Query(`PRAGMA table_info(magic_item_equipped)`) + if err != nil { + t.Fatalf("pragma: %v", err) + } + defer cols.Close() + have := map[string]bool{} + for cols.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt any + _ = cols.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk) + have[name] = true + } + for _, want := range []string{"user_id", "item_id", "slot"} { + if !have[want] { + t.Errorf("magic_item_equipped missing column %q (have: %v)", want, have) + } + } + + // (c) Registry populated and rarity index covers every rarity. + if len(magicItemRegistry) == 0 { + t.Fatalf("magicItemRegistry is empty") + } + t.Logf("magicItemRegistry: %d items", len(magicItemRegistry)) + byRarity := magicItemsByRarity() + for _, r := range []DnDRarity{RarityCommon, RarityUncommon, RarityRare, RarityVeryRare, RarityLegendary} { + if len(byRarity[r]) == 0 { + t.Errorf("rarity %q has no items in index", r) + } else { + t.Logf(" rarity %s: %d items", r, len(byRarity[r])) + } + } + + // (d) Slot baking — known edge-case items from UX S4 B4 should land + // in the slot the fix intended. Slots are baked into the generated + // data file by the importer's classifier; the lookup is a fixed table. + type slotCheck struct { + id string + wantSlot DnDSlot + mustNotBeRingForSubstring string // sanity vs word-boundary regressions + } + checks := []slotCheck{ + {"ring_of_protection", DnDSlotRing1, ""}, + {"boots_of_striding_and_springing", DnDSlotFeet, "springing"}, + {"gloves_of_missile_snaring", DnDSlotHands, "snaring"}, + {"bag_of_devouring", "", "devouring"}, // wondrous w/ no carryable noun + {"cloak_of_displacement", DnDSlotCloak, ""}, + } + for _, c := range checks { + item, ok := magicItemRegistry[c.id] + if !ok { + t.Errorf("registry missing %q", c.id) + continue + } + if c.wantSlot != "" && item.Slot != c.wantSlot { + t.Errorf("%s: slot=%q want %q", c.id, item.Slot, c.wantSlot) + } + if item.Slot == DnDSlotRing1 || item.Slot == DnDSlotRing2 { + if c.mustNotBeRingForSubstring != "" { + t.Errorf("%s: misclassified as ring (substring trap %q)", + c.id, c.mustNotBeRingForSubstring) + } + } + t.Logf(" %s → kind=%s slot=%q rarity=%s attune=%v", + c.id, item.Kind, item.Slot, item.Rarity, item.Attunement) + } + + // (e) Daily curios shelf rotates and returns a non-empty list. + shelf := dailyCuriosStock() + if len(shelf) == 0 { + t.Errorf("dailyCuriosStock returned empty") + } else { + t.Logf("dailyCuriosStock: %d items (first: %s @ %d, rarity %s)", + len(shelf), shelf[0].Name, shelf[0].Value, shelf[0].Rarity) + } +} + +// ── Scenario 3: Expedition autopilot plumbing ────────────────────────────── +// +// Expected: +// - dnd_expedition.last_ambient_at column exists (Phase 3 migration). +// - autopilotFooter renders non-empty paused-state copy for pause +// reasons; renders empty for terminal/already-narrated reasons. +// - Ambient event pool has positive weights and non-empty flavor pools. +func TestScenario_ExpeditionAutopilotPlumbing(t *testing.T) { + requireScenarioEnv(t) + setupAuditTestDB(t) + + // (a) Migration column present. + cols, err := db.Get().Query(`PRAGMA table_info(dnd_expedition)`) + if err != nil { + t.Fatalf("pragma: %v", err) + } + defer cols.Close() + have := map[string]bool{} + for cols.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt any + _ = cols.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk) + have[name] = true + } + if !have["last_ambient_at"] { + t.Errorf("dnd_expedition.last_ambient_at missing — Phase 3 migration didn't run") + } + + // (b) Stop-reason footers — pause reasons render copy, terminal + // reasons render empty (death narration / completion block / etc. + // is the final). + type footerCheck struct { + reason stopReason + wantText bool + } + for _, c := range []footerCheck{ + {stopFork, true}, + {stopElite, true}, + {stopBoss, true}, + {stopHarvestCombat, true}, + {stopOK, true}, // hit room cap → "stretch complete" + {stopEnded, false}, + {stopComplete, false}, + {stopBlocked, false}, + {stopBossSafety, false}, // res.final carries the held-back line + } { + got := autopilotFooter(c.reason, 3) + if c.wantText && got == "" { + t.Errorf("stop reason %v: expected non-empty footer, got empty", c.reason) + } + if !c.wantText && got != "" { + t.Errorf("stop reason %v: expected empty footer, got %q", c.reason, got) + } + t.Logf(" %v (3 rooms) → %q", c.reason, got) + } + + // (c) Ambient event pool — every event has a positive weight and a + // non-empty flavor pool. Build a temporary Expedition to satisfy + // pickAmbientEvent's eligibility predicates without persisting. + events := ambientEvents() + if len(events) == 0 { + t.Fatalf("ambientEvents() empty") + } + t.Logf("ambient pool: %d events", len(events)) + for _, ev := range events { + if ev.Weight <= 0 { + t.Errorf("ambient event %q has non-positive weight %d", ev.Kind, ev.Weight) + } + if len(ev.Pool) == 0 { + t.Errorf("ambient event %q has empty pool", ev.Kind) + } else { + t.Logf(" %-22s weight=%d pool=%d sample=%q", + ev.Kind, ev.Weight, len(ev.Pool), ev.Pool[0]) + } + } +} + +// ── Scenario 4: Spell/help jargon regression ─────────────────────────────── +// +// Expected: live merged spell registry has no banned-phrase leaks across +// Description. Mirrors TestSpellDescriptionsAreJargonFree at runtime so +// it shows up in this pass. +func TestScenario_SpellJargonRegression(t *testing.T) { + requireScenarioEnv(t) + setupAuditTestDB(t) + + // Mirror dnd_spells_prose_test's TestSpellDescriptionsAreJargonFree: + // substring matches for jargon, regex for the SRD-importer placeholder + // signature ("Whatever " + lowercase, distinct from legit contractions). + bannedSubstrings := []string{ + "saving throw", "Saving Throw", + "spell slot of", "Spell Slot of", + "ability modifier", "Ability Modifier", + "hit points equal to", + } + bannedRegexes := []*regexp.Regexp{ + regexp.MustCompile(`\bd(4|6|8|10|12|20|100)\b`), + regexp.MustCompile(`\b\d+d\d+\b`), + regexp.MustCompile(`\bWhatever [a-z]`), // placeholder signature + regexp.MustCompile(`(?:\.\.\.|…)\s*$`), // trailing ellipsis + regexp.MustCompile(`\b[a-z]{1,3}(?:\.\.\.|…)\s*$`), // truncated word + regexp.MustCompile(`(?i)\bno larger than in any\b`), + } + totalSpells := 0 + leaks := 0 + for id, s := range dndSpellRegistry { + totalSpells++ + desc := s.Description + if desc == "" { + continue + } + for _, b := range bannedSubstrings { + if strings.Contains(desc, b) { + t.Errorf("spell %q leaks banned phrase %q: %q", id, b, desc) + leaks++ + } + } + for _, re := range bannedRegexes { + if re.MatchString(desc) { + t.Errorf("spell %q matches banned pattern %v: %q", id, re, desc) + leaks++ + } + } + } + t.Logf("scanned %d spells; %d jargon leaks", totalSpells, leaks) +} diff --git a/internal/plugin/urls.go b/internal/plugin/urls.go index 1366666..89772ad 100644 --- a/internal/plugin/urls.go +++ b/internal/plugin/urls.go @@ -10,6 +10,7 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/safehttp" "github.com/PuerkitoBio/goquery" "maunium.net/go/mautrix" @@ -40,9 +41,10 @@ func NewURLsPlugin(client *mautrix.Client) *URLsPlugin { Base: NewBase(client), enabled: enabled, ignoreUsers: ignore, - httpClient: &http.Client{ - Timeout: 3 * time.Second, - }, + // Route page scrapes through safehttp so a posted link can't steer the + // fetch at loopback / RFC1918 / cloud-metadata IPs (re-checked on every + // redirect), and can't OOM the parser by streaming an unbounded body. + httpClient: safehttp.NewClient(8 * time.Second), } } @@ -90,12 +92,23 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error { } func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) { - title, desc, err := p.fetchPreview(targetURL) + title, desc, image, err := p.fetchPreview(targetURL) if err != nil { slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err) return } + if title == "" && desc == "" && image == "" { + return + } + + // Post the thumbnail first (best-effort), so it renders above the text. + if image != "" && validateImageURL(image) { + if err := p.SendImageFromURL(ctx.RoomID, image); err != nil { + slog.Debug("urls: thumbnail post failed", "url", targetURL, "image", image, "err", err) + } + } + if title == "" && desc == "" { return } @@ -120,63 +133,69 @@ func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) { } } -// fetchPreview retrieves og:title and og:description, checking cache first. -func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) { +// fetchPreview retrieves og:title, og:description and og:image, checking cache first. +func (p *URLsPlugin) fetchPreview(rawURL string) (title, desc, image string, err error) { d := db.Get() now := time.Now().UTC().Unix() cacheTTL := int64(24 * 60 * 60) // Check cache - var title, desc string var cachedAt int64 - err := d.QueryRow( - `SELECT title, description, cached_at FROM url_cache WHERE url = ?`, rawURL, - ).Scan(&title, &desc, &cachedAt) + err = d.QueryRow( + `SELECT title, description, image_url, cached_at FROM url_cache WHERE url = ?`, rawURL, + ).Scan(&title, &desc, &image, &cachedAt) if err == nil && now-cachedAt < cacheTTL { - return title, desc, nil + return title, desc, image, nil } // Fetch from web - title, desc, err = p.scrapeOG(rawURL) + title, desc, image, err = p.scrapeOG(rawURL) if err != nil { - return "", "", err + return "", "", "", err } // Cache the result db.Exec("urls: cache write", - `INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?) - ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`, - rawURL, title, desc, now, title, desc, now, + `INSERT INTO url_cache (url, title, description, image_url, cached_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, image_url = ?, cached_at = ?`, + rawURL, title, desc, image, now, title, desc, image, now, ) - return title, desc, nil + return title, desc, image, nil } -// scrapeOG fetches a URL and extracts og:title and og:description. -func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) { +// scrapeOG fetches a URL and extracts og:title, og:description and og:image. +func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, string, error) { + if err := safehttp.ValidateURL(rawURL); err != nil { + return "", "", "", err + } + req, err := http.NewRequest("GET", rawURL, nil) if err != nil { - return "", "", fmt.Errorf("create request: %w", err) + return "", "", "", fmt.Errorf("create request: %w", err) } req.Header.Set("User-Agent", "GogoBee Bot/1.0") + req.Header.Set("Accept", "text/html,application/xhtml+xml") resp, err := p.httpClient.Do(req) if err != nil { - return "", "", fmt.Errorf("fetch: %w", err) + return "", "", "", fmt.Errorf("fetch: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("status %d", resp.StatusCode) + return "", "", "", fmt.Errorf("status %d", resp.StatusCode) } - doc, err := goquery.NewDocumentFromReader(resp.Body) + // Cap the parsed body at 2 MiB — og: tags live in , near the top. + doc, err := goquery.NewDocumentFromReader(safehttp.LimitedBody(resp.Body, 2*1024*1024)) if err != nil { - return "", "", fmt.Errorf("parse HTML: %w", err) + return "", "", "", fmt.Errorf("parse HTML: %w", err) } title := "" desc := "" + image := "" doc.Find("meta").Each(func(_ int, s *goquery.Selection) { prop, _ := s.Attr("property") @@ -186,6 +205,10 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) { title = content case "og:description": desc = content + case "og:image:secure_url", "og:image:url", "og:image": + if image == "" && strings.TrimSpace(content) != "" { + image = content + } } }) @@ -205,5 +228,9 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) { }) } - return strings.TrimSpace(title), strings.TrimSpace(desc), nil + if image != "" { + image = normalizeImageURL(resolveURL(rawURL, strings.TrimSpace(image))) + } + + return strings.TrimSpace(title), strings.TrimSpace(desc), image, nil } diff --git a/internal/plugin/wotd.go b/internal/plugin/wotd.go index be50a29..5f05319 100644 --- a/internal/plugin/wotd.go +++ b/internal/plugin/wotd.go @@ -247,8 +247,17 @@ func (p *WOTDPlugin) prefetchWord(force bool) error { if _, delErr := d.Exec(`DELETE FROM wotd_log WHERE date = ?`, today); delErr != nil { slog.Error("wotd: force delete failed", "err", delErr) } - // Also clear job-completed flags so PostWOTD will re-post - d.Exec(`DELETE FROM job_completed WHERE job_name = 'wotd' AND job_key LIKE ?`, today+"%") + // Also clear job-completed flags so PostWOTD will re-post. The + // per-room dedup keys are ":" in daily_prefetch, so + // match every room's flag for today. (Was targeting a non-existent + // job_completed/job_key table — a silent no-op that left forced + // re-posts blocked by the stale dedup row.) + if _, delErr := d.Exec( + `DELETE FROM daily_prefetch WHERE job_name = 'wotd' AND date LIKE ?`, + today+"%", + ); delErr != nil { + slog.Error("wotd: force clear dedup flags failed", "err", delErr) + } } _, err := d.Exec( diff --git a/internal/plugin/zone_graph.go b/internal/plugin/zone_graph.go index ce78b08..dedd49f 100644 --- a/internal/plugin/zone_graph.go +++ b/internal/plugin/zone_graph.go @@ -360,6 +360,45 @@ func roomTypeToNodeKind(rt RoomType) ZoneNodeKind { return NodeKindExploration } +// graphLongestPath returns the number of nodes on the longest simple +// path from Entry to Boss. Used by generateRoomSequence so the +// "Room X/Y" display tracks the actual graph traversal length rather +// than a dice-rolled stub. Returns 0 if the graph has no entry/boss. +func graphLongestPath(g ZoneGraph) int { + if g.Entry == "" || g.Boss == "" { + return 0 + } + memo := map[string]int{} + var dfs func(node string, onPath map[string]bool) int + dfs = func(node string, onPath map[string]bool) int { + if node == g.Boss { + return 1 + } + if v, ok := memo[node]; ok && !onPath[node] { + // memo is safe only when the cached subpath doesn't + // revisit a node currently on the active path. With DAG + // zones (the norm) this is always safe; the onPath guard + // keeps us correct if a future zone authors a cycle. + return v + } + best := 0 + for _, e := range g.Edges[node] { + if onPath[e.To] { + continue + } + onPath[e.To] = true + sub := dfs(e.To, onPath) + delete(onPath, e.To) + if sub > 0 && sub+1 > best { + best = sub + 1 + } + } + memo[node] = best + return best + } + return dfs(g.Entry, map[string]bool{g.Entry: true}) +} + // loadZoneGraph returns the graph for a zone. Registered (hand-authored) // graphs take precedence; otherwise the legacy linear compiler is used. // Returns ok=false only for unknown zone IDs. diff --git a/internal/plugin/zone_graph_abyss_portal.go b/internal/plugin/zone_graph_abyss_portal.go index f815db1..8ce6e18 100644 --- a/internal/plugin/zone_graph_abyss_portal.go +++ b/internal/plugin/zone_graph_abyss_portal.go @@ -1,88 +1,292 @@ package plugin -// Phase G8h — The Abyss Portal branching graph. +// The Abyss Portal branching graph — multi-region. // -// T5 zone. Shape: three sequential forks at increasing depth — binary, -// binary, ternary capstone. The player makes more decisions in this -// zone than in any other (3 separate fork prompts), matching the -// "things keep getting worse" thematic descent. +// Long-expedition plan D1-e: extended from the original 13-node sketch +// to the new T5 length band (36–44 rooms traversed). Topology preserves +// the G8h design intent — three sequential forks at increasing depth +// (binary, binary, ternary capstone) and CON stat-check coverage — and +// adds the linear depth each region now needs to feel like its own +// sub-dungeon. D1-e also backfills the missing RegionID authoring per +// dnd_expedition_region.go: every node carries a valid RegionID +// matching the registry. // -// entry → fractured_threshold → fork1 (binary) -// ├─[unlocked]── burning_wastes ─┐ -// └─[Perception DC 16]── silent_chambers ─┤ -// ↓ -// fork2_a (binary) -// ├─[unlocked]── vrock_aerie (elite marilith) ─┐ -// └─[CON DC 16]── mind_corridor ───────────────┤ -// ↓ -// fork3 (3-way capstone) -// ├─[unlocked]── direct_assault → boss -// ├─[CHA DC 18]── usurper_throne → boss -// └─[Perception DC 18]── reality_seam (secret) → boss +// The original "fork3 capstone leaves go straight to boss" shape is +// adjusted so the three capstones now converge at a belaxath_doors +// MERGE in R4, then walk a short tear-approach to boss. Three distinct +// paths to boss are preserved (each via its own capstone node). // -// First authored zone with three sequential forks AND first use of -// LockStatCheck CON — completing the full ability roster (STR/DEX/ -// CON/INT/WIS/CHA all appear in shipping zones by G8h). reality_seam -// LootBias 3.0 reflects the Abyss capstone secret. +// R1 outer_rift preamble (10 nodes): +// entry → shattered_path → fractured_threshold → screaming_passage +// → ember_walk → outer_rift_descent → tear_in_stone → ruined_arch +// → quasit_outpost → fork1 +// +// R2 demon_assembly region: +// fork1 — binary, both spurs converge in R2: +// burning_wastes spur (unlocked): +// burning_wastes → cinder_field → obsidian_plain +// silent_chambers spur (Perception DC 16): +// silent_chambers → hush_corridor → listening_room +// R2 buildup to fork2 (6 nodes): +// demon_assembly_gate → assembly_outskirts → nalfeshnee_corridor +// → vrock_pillars → echo_atrium → assembly_descent → fork2 +// +// R3 wardens_post region: +// fork2 — binary, both spurs converge in R3: +// vrock_aerie spur (unlocked, ELITE): +// vrock_approach → vrock_aerie (ELITE) → vrock_descent +// mind_corridor spur (CON DC 16): +// mind_threshold → mind_corridor → echo_passage +// R3 buildup to fork3 (6 nodes): +// wardens_outer_post → wardens_hall → wardens_chapel → +// wardens_descent → tear_approach → wardens_threshold → fork3 +// +// R4 the_tear — capstone 3-way fork: +// direct_assault spur (unlocked): +// void_charge → direct_assault → fissure_walk +// usurper_throne spur (CHA DC 18): +// throne_walk → usurper_throne → claimed_path +// reality_seam spur (Perception DC 18, SECRET LootBias 3.0): +// seam_threshold → reality_seam (SECRET) → through_the_seam +// +// R4 final approach (MERGE + boss; walk 6 after capstone spur): +// belaxath_doors (MERGE) → outer_tear → inner_tear → tear_steps → +// final_threshold → boss +// +// Longest entry→boss walk: 10 (R1) + 10 (R2) + 10 (R3) + 10 (R4) = 40 +// nodes, inside [36, 44]. All four meaningful walks (fork1 × fork2 × +// capstone) hit the same node count by construction. +// +// D10 anchor variety (in-place kind swaps, no length change): the Abyss +// shipped with NO trap node and only the fork2 vrock ELITE, so D10 adds +// two traps and a region-guardian elite — +// - Hush Corridor (fork1 silent_chambers Perception-loot spur) and +// Seam Threshold (fork3 reality_seam SECRET spur) become TRAPs, so +// the two loot/secret branches each carry a hazard. +// - Warden's Hall (R3 wardens_post buildup, main path) becomes an +// ELITE — the region literally named for its wardens finally has +// one, giving every walk a mid-zone region-guardian. +// The burning_wastes / mind_corridor / void_charge / usurper_throne +// routes stay clean, preserving the safe-vs-loot fork trade-off. func zoneAbyssPortalGraph() ZoneGraph { + r1 := "abyss_outer_rift" + r2 := "abyss_demon_assembly" + r3 := "abyss_wardens_post" + r4 := "abyss_the_tear" + nodes := []ZoneNode{ - {NodeID: "abyss_portal.entry", Kind: NodeKindEntry, IsEntry: true, + // R1 outer_rift preamble. + {NodeID: "abyss_portal.entry", Kind: NodeKindEntry, IsEntry: true, RegionID: r1, Label: "The Open Door", PosX: 0, PosY: 2}, - {NodeID: "abyss_portal.fractured_threshold", Kind: NodeKindExploration, - Label: "Fractured Threshold", PosX: 1, PosY: 2}, - {NodeID: "abyss_portal.fork1", Kind: NodeKindFork, - Label: "First Reality-Break", PosX: 2, PosY: 2}, - {NodeID: "abyss_portal.burning_wastes", Kind: NodeKindExploration, - Label: "Burning Wastes", PosX: 3, PosY: 1}, - {NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, - Label: "Silent Chambers", PosX: 3, PosY: 3}, - {NodeID: "abyss_portal.fork2", Kind: NodeKindFork, - Label: "Second Reality-Break", PosX: 4, PosY: 2}, - {NodeID: "abyss_portal.vrock_aerie", Kind: NodeKindElite, - Label: "Marilith's Aerie", PosX: 5, PosY: 1}, - {NodeID: "abyss_portal.mind_corridor", Kind: NodeKindExploration, - Label: "Corridor of Whispers", PosX: 5, PosY: 3}, - {NodeID: "abyss_portal.fork3", Kind: NodeKindFork, - Label: "Third Reality-Break", PosX: 6, PosY: 2}, - {NodeID: "abyss_portal.direct_assault", Kind: NodeKindExploration, - Label: "Direct Assault", PosX: 7, PosY: 1}, - {NodeID: "abyss_portal.usurper_throne", Kind: NodeKindExploration, - Label: "Usurper's Approach", PosX: 7, PosY: 2}, - {NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, - Label: "Reality Seam", PosX: 7, PosY: 3, + {NodeID: "abyss_portal.shattered_path", Kind: NodeKindExploration, RegionID: r1, + Label: "Shattered Path", PosX: 1, PosY: 2}, + {NodeID: "abyss_portal.fractured_threshold", Kind: NodeKindExploration, RegionID: r1, + Label: "Fractured Threshold", PosX: 2, PosY: 2}, + {NodeID: "abyss_portal.screaming_passage", Kind: NodeKindExploration, RegionID: r1, + Label: "Screaming Passage", PosX: 3, PosY: 2}, + {NodeID: "abyss_portal.ember_walk", Kind: NodeKindExploration, RegionID: r1, + Label: "Ember Walk", PosX: 4, PosY: 2}, + {NodeID: "abyss_portal.outer_rift_descent", Kind: NodeKindExploration, RegionID: r1, + Label: "Rift Descent", PosX: 5, PosY: 2}, + {NodeID: "abyss_portal.tear_in_stone", Kind: NodeKindExploration, RegionID: r1, + Label: "Tear in Stone", PosX: 6, PosY: 2}, + {NodeID: "abyss_portal.ruined_arch", Kind: NodeKindExploration, RegionID: r1, + Label: "Ruined Arch", PosX: 7, PosY: 2}, + {NodeID: "abyss_portal.quasit_outpost", Kind: NodeKindExploration, RegionID: r1, + Label: "Quasit Outpost", PosX: 8, PosY: 2}, + {NodeID: "abyss_portal.fork1", Kind: NodeKindFork, RegionID: r1, + Label: "First Reality-Break", PosX: 9, PosY: 2}, + + // R2 demon_assembly — fork1 spurs. + {NodeID: "abyss_portal.burning_wastes", Kind: NodeKindExploration, RegionID: r2, + Label: "Burning Wastes", PosX: 10, PosY: 1}, + {NodeID: "abyss_portal.cinder_field", Kind: NodeKindExploration, RegionID: r2, + Label: "Cinder Field", PosX: 11, PosY: 1}, + {NodeID: "abyss_portal.obsidian_plain", Kind: NodeKindExploration, RegionID: r2, + Label: "Obsidian Plain", PosX: 12, PosY: 1}, + + {NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, RegionID: r2, + Label: "Silent Chambers", PosX: 10, PosY: 3}, + {NodeID: "abyss_portal.hush_corridor", Kind: NodeKindTrap, RegionID: r2, + Label: "Hush Corridor", PosX: 11, PosY: 3}, + {NodeID: "abyss_portal.listening_room", Kind: NodeKindExploration, RegionID: r2, + Label: "Listening Room", PosX: 12, PosY: 3}, + + // R2 buildup to fork2. + {NodeID: "abyss_portal.demon_assembly_gate", Kind: NodeKindExploration, RegionID: r2, + Label: "Demon Assembly Gate", PosX: 13, PosY: 2}, + {NodeID: "abyss_portal.assembly_outskirts", Kind: NodeKindExploration, RegionID: r2, + Label: "Assembly Outskirts", PosX: 14, PosY: 2}, + {NodeID: "abyss_portal.nalfeshnee_corridor", Kind: NodeKindExploration, RegionID: r2, + Label: "Nalfeshnee Corridor", PosX: 15, PosY: 2}, + {NodeID: "abyss_portal.vrock_pillars", Kind: NodeKindExploration, RegionID: r2, + Label: "Vrock Pillars", PosX: 16, PosY: 2}, + {NodeID: "abyss_portal.echo_atrium", Kind: NodeKindExploration, RegionID: r2, + Label: "Echo Atrium", PosX: 17, PosY: 2}, + {NodeID: "abyss_portal.assembly_descent", Kind: NodeKindExploration, RegionID: r2, + Label: "Assembly Descent", PosX: 18, PosY: 2}, + {NodeID: "abyss_portal.fork2", Kind: NodeKindFork, RegionID: r2, + Label: "Second Reality-Break", PosX: 19, PosY: 2}, + + // R3 wardens_post — fork2 spurs. + {NodeID: "abyss_portal.vrock_approach", Kind: NodeKindExploration, RegionID: r3, + Label: "Vrock Approach", PosX: 20, PosY: 1}, + {NodeID: "abyss_portal.vrock_aerie", Kind: NodeKindElite, RegionID: r3, + Label: "Marilith's Aerie", PosX: 21, PosY: 1}, + {NodeID: "abyss_portal.vrock_descent", Kind: NodeKindExploration, RegionID: r3, + Label: "Vrock Descent", PosX: 22, PosY: 1}, + + {NodeID: "abyss_portal.mind_threshold", Kind: NodeKindExploration, RegionID: r3, + Label: "Mind Threshold", PosX: 20, PosY: 3}, + {NodeID: "abyss_portal.mind_corridor", Kind: NodeKindExploration, RegionID: r3, + Label: "Corridor of Whispers", PosX: 21, PosY: 3}, + {NodeID: "abyss_portal.echo_passage", Kind: NodeKindExploration, RegionID: r3, + Label: "Echo Passage", PosX: 22, PosY: 3}, + + // R3 buildup to fork3. + {NodeID: "abyss_portal.wardens_outer_post", Kind: NodeKindExploration, RegionID: r3, + Label: "Outer Warden Post", PosX: 23, PosY: 2}, + {NodeID: "abyss_portal.wardens_hall", Kind: NodeKindElite, RegionID: r3, + Label: "Warden's Hall", PosX: 24, PosY: 2}, + {NodeID: "abyss_portal.wardens_chapel", Kind: NodeKindExploration, RegionID: r3, + Label: "Warden's Chapel", PosX: 25, PosY: 2}, + {NodeID: "abyss_portal.wardens_descent", Kind: NodeKindExploration, RegionID: r3, + Label: "Warden's Descent", PosX: 26, PosY: 2}, + {NodeID: "abyss_portal.tear_approach", Kind: NodeKindExploration, RegionID: r3, + Label: "Tear Approach", PosX: 27, PosY: 2}, + {NodeID: "abyss_portal.wardens_threshold", Kind: NodeKindExploration, RegionID: r3, + Label: "Warden's Threshold", PosX: 28, PosY: 2}, + {NodeID: "abyss_portal.fork3", Kind: NodeKindFork, RegionID: r3, + Label: "Third Reality-Break", PosX: 29, PosY: 2}, + + // R4 capstone spurs. + {NodeID: "abyss_portal.void_charge", Kind: NodeKindExploration, RegionID: r4, + Label: "Void Charge", PosX: 30, PosY: 1}, + {NodeID: "abyss_portal.direct_assault", Kind: NodeKindExploration, RegionID: r4, + Label: "Direct Assault", PosX: 31, PosY: 1}, + {NodeID: "abyss_portal.fissure_walk", Kind: NodeKindExploration, RegionID: r4, + Label: "Fissure Walk", PosX: 32, PosY: 1}, + + {NodeID: "abyss_portal.throne_walk", Kind: NodeKindExploration, RegionID: r4, + Label: "Throne Walk", PosX: 30, PosY: 2}, + {NodeID: "abyss_portal.usurper_throne", Kind: NodeKindExploration, RegionID: r4, + Label: "Usurper's Approach", PosX: 31, PosY: 2}, + {NodeID: "abyss_portal.claimed_path", Kind: NodeKindExploration, RegionID: r4, + Label: "Claimed Path", PosX: 32, PosY: 2}, + + {NodeID: "abyss_portal.seam_threshold", Kind: NodeKindTrap, RegionID: r4, + Label: "Seam Threshold", PosX: 30, PosY: 3}, + {NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, RegionID: r4, + Label: "Reality Seam", PosX: 31, PosY: 3, Content: ZoneNodeContent{LootBias: 3.0}}, - {NodeID: "abyss_portal.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Belaxath's Throne", PosX: 8, PosY: 2}, + {NodeID: "abyss_portal.through_the_seam", Kind: NodeKindExploration, RegionID: r4, + Label: "Through the Seam", PosX: 32, PosY: 3}, + + // R4 final approach. + {NodeID: "abyss_portal.belaxath_doors", Kind: NodeKindMerge, RegionID: r4, + Label: "The Belaxath Doors", PosX: 33, PosY: 2}, + {NodeID: "abyss_portal.outer_tear", Kind: NodeKindExploration, RegionID: r4, + Label: "Outer Tear", PosX: 34, PosY: 2}, + {NodeID: "abyss_portal.inner_tear", Kind: NodeKindExploration, RegionID: r4, + Label: "Inner Tear", PosX: 35, PosY: 2}, + {NodeID: "abyss_portal.tear_steps", Kind: NodeKindExploration, RegionID: r4, + Label: "Tear Steps", PosX: 36, PosY: 2}, + {NodeID: "abyss_portal.final_threshold", Kind: NodeKindExploration, RegionID: r4, + Label: "Final Threshold", PosX: 37, PosY: 2}, + {NodeID: "abyss_portal.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4, + Label: "Belaxath's Throne", PosX: 38, PosY: 2}, } edges := []ZoneEdge{ - {From: "abyss_portal.entry", To: "abyss_portal.fractured_threshold", Lock: LockNone}, - {From: "abyss_portal.fractured_threshold", To: "abyss_portal.fork1", Lock: LockNone}, - // Fork1 — perception side path. + // R1 preamble. + {From: "abyss_portal.entry", To: "abyss_portal.shattered_path", Lock: LockNone}, + {From: "abyss_portal.shattered_path", To: "abyss_portal.fractured_threshold", Lock: LockNone}, + {From: "abyss_portal.fractured_threshold", To: "abyss_portal.screaming_passage", Lock: LockNone}, + {From: "abyss_portal.screaming_passage", To: "abyss_portal.ember_walk", Lock: LockNone}, + {From: "abyss_portal.ember_walk", To: "abyss_portal.outer_rift_descent", Lock: LockNone}, + {From: "abyss_portal.outer_rift_descent", To: "abyss_portal.tear_in_stone", Lock: LockNone}, + {From: "abyss_portal.tear_in_stone", To: "abyss_portal.ruined_arch", Lock: LockNone}, + {From: "abyss_portal.ruined_arch", To: "abyss_portal.quasit_outpost", Lock: LockNone}, + {From: "abyss_portal.quasit_outpost", To: "abyss_portal.fork1", Lock: LockNone}, + + // Fork1 — binary (R1 → R2). {From: "abyss_portal.fork1", To: "abyss_portal.burning_wastes", Lock: LockNone, Weight: 1}, {From: "abyss_portal.fork1", To: "abyss_portal.silent_chambers", Lock: LockPerception, LockData: map[string]any{"dc": 16}, Hint: "a silence so complete you can hear your own pulse — and something else's", Weight: 2}, - {From: "abyss_portal.burning_wastes", To: "abyss_portal.fork2", Lock: LockNone}, - {From: "abyss_portal.silent_chambers", To: "abyss_portal.fork2", Lock: LockNone}, - // Fork2 — CON wall (psychic pressure). - {From: "abyss_portal.fork2", To: "abyss_portal.vrock_aerie", Lock: LockNone, Weight: 1}, - {From: "abyss_portal.fork2", To: "abyss_portal.mind_corridor", + + // burning_wastes spur → demon_assembly_gate. + {From: "abyss_portal.burning_wastes", To: "abyss_portal.cinder_field", Lock: LockNone}, + {From: "abyss_portal.cinder_field", To: "abyss_portal.obsidian_plain", Lock: LockNone}, + {From: "abyss_portal.obsidian_plain", To: "abyss_portal.demon_assembly_gate", Lock: LockNone}, + + // silent_chambers spur → demon_assembly_gate. + {From: "abyss_portal.silent_chambers", To: "abyss_portal.hush_corridor", Lock: LockNone}, + {From: "abyss_portal.hush_corridor", To: "abyss_portal.listening_room", Lock: LockNone}, + {From: "abyss_portal.listening_room", To: "abyss_portal.demon_assembly_gate", Lock: LockNone}, + + // R2 buildup to fork2. + {From: "abyss_portal.demon_assembly_gate", To: "abyss_portal.assembly_outskirts", Lock: LockNone}, + {From: "abyss_portal.assembly_outskirts", To: "abyss_portal.nalfeshnee_corridor", Lock: LockNone}, + {From: "abyss_portal.nalfeshnee_corridor", To: "abyss_portal.vrock_pillars", Lock: LockNone}, + {From: "abyss_portal.vrock_pillars", To: "abyss_portal.echo_atrium", Lock: LockNone}, + {From: "abyss_portal.echo_atrium", To: "abyss_portal.assembly_descent", Lock: LockNone}, + {From: "abyss_portal.assembly_descent", To: "abyss_portal.fork2", Lock: LockNone}, + + // Fork2 — binary (R2 → R3). + {From: "abyss_portal.fork2", To: "abyss_portal.vrock_approach", Lock: LockNone, Weight: 1}, + {From: "abyss_portal.fork2", To: "abyss_portal.mind_threshold", Lock: LockStatCheck, LockData: map[string]any{"stat": "CON", "dc": 16}, Hint: "the air thickens — your bones know the wrong direction is also down", Weight: 2}, - {From: "abyss_portal.vrock_aerie", To: "abyss_portal.fork3", Lock: LockNone}, - {From: "abyss_portal.mind_corridor", To: "abyss_portal.fork3", Lock: LockNone}, - // Fork3 — capstone 3-way. - {From: "abyss_portal.fork3", To: "abyss_portal.direct_assault", Lock: LockNone, Weight: 1}, - {From: "abyss_portal.fork3", To: "abyss_portal.usurper_throne", + + // vrock_aerie spur → wardens_outer_post. + {From: "abyss_portal.vrock_approach", To: "abyss_portal.vrock_aerie", Lock: LockNone}, + {From: "abyss_portal.vrock_aerie", To: "abyss_portal.vrock_descent", Lock: LockNone}, + {From: "abyss_portal.vrock_descent", To: "abyss_portal.wardens_outer_post", Lock: LockNone}, + + // mind_corridor spur → wardens_outer_post. + {From: "abyss_portal.mind_threshold", To: "abyss_portal.mind_corridor", Lock: LockNone}, + {From: "abyss_portal.mind_corridor", To: "abyss_portal.echo_passage", Lock: LockNone}, + {From: "abyss_portal.echo_passage", To: "abyss_portal.wardens_outer_post", Lock: LockNone}, + + // R3 buildup to fork3. + {From: "abyss_portal.wardens_outer_post", To: "abyss_portal.wardens_hall", Lock: LockNone}, + {From: "abyss_portal.wardens_hall", To: "abyss_portal.wardens_chapel", Lock: LockNone}, + {From: "abyss_portal.wardens_chapel", To: "abyss_portal.wardens_descent", Lock: LockNone}, + {From: "abyss_portal.wardens_descent", To: "abyss_portal.tear_approach", Lock: LockNone}, + {From: "abyss_portal.tear_approach", To: "abyss_portal.wardens_threshold", Lock: LockNone}, + {From: "abyss_portal.wardens_threshold", To: "abyss_portal.fork3", Lock: LockNone}, + + // Fork3 — capstone 3-way (R3 → R4). + {From: "abyss_portal.fork3", To: "abyss_portal.void_charge", Lock: LockNone, Weight: 1}, + {From: "abyss_portal.fork3", To: "abyss_portal.throne_walk", Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 18}, Hint: "Belaxath has not noticed you yet — claim authority before he does", Weight: 2}, - {From: "abyss_portal.fork3", To: "abyss_portal.reality_seam", + {From: "abyss_portal.fork3", To: "abyss_portal.seam_threshold", Lock: LockPerception, LockData: map[string]any{"dc": 18}, Hint: "a hairline crack in the air itself — somewhere there is a place that is not here", Weight: 3}, - {From: "abyss_portal.direct_assault", To: "abyss_portal.boss", Lock: LockNone}, - {From: "abyss_portal.usurper_throne", To: "abyss_portal.boss", Lock: LockNone}, - {From: "abyss_portal.reality_seam", To: "abyss_portal.boss", Lock: LockNone}, + + // direct_assault spur → merge. + {From: "abyss_portal.void_charge", To: "abyss_portal.direct_assault", Lock: LockNone}, + {From: "abyss_portal.direct_assault", To: "abyss_portal.fissure_walk", Lock: LockNone}, + {From: "abyss_portal.fissure_walk", To: "abyss_portal.belaxath_doors", Lock: LockNone}, + + // usurper_throne spur → merge. + {From: "abyss_portal.throne_walk", To: "abyss_portal.usurper_throne", Lock: LockNone}, + {From: "abyss_portal.usurper_throne", To: "abyss_portal.claimed_path", Lock: LockNone}, + {From: "abyss_portal.claimed_path", To: "abyss_portal.belaxath_doors", Lock: LockNone}, + + // reality_seam spur → merge. + {From: "abyss_portal.seam_threshold", To: "abyss_portal.reality_seam", Lock: LockNone}, + {From: "abyss_portal.reality_seam", To: "abyss_portal.through_the_seam", Lock: LockNone}, + {From: "abyss_portal.through_the_seam", To: "abyss_portal.belaxath_doors", Lock: LockNone}, + + // R4 final approach. + {From: "abyss_portal.belaxath_doors", To: "abyss_portal.outer_tear", Lock: LockNone}, + {From: "abyss_portal.outer_tear", To: "abyss_portal.inner_tear", Lock: LockNone}, + {From: "abyss_portal.inner_tear", To: "abyss_portal.tear_steps", Lock: LockNone}, + {From: "abyss_portal.tear_steps", To: "abyss_portal.final_threshold", Lock: LockNone}, + {From: "abyss_portal.final_threshold", To: "abyss_portal.boss", Lock: LockNone}, } return BuildGraph(ZoneAbyssPortal, nodes, edges) } diff --git a/internal/plugin/zone_graph_abyss_portal_test.go b/internal/plugin/zone_graph_abyss_portal_test.go index bcd64e0..0d932c5 100644 --- a/internal/plugin/zone_graph_abyss_portal_test.go +++ b/internal/plugin/zone_graph_abyss_portal_test.go @@ -7,8 +7,59 @@ func TestAbyssPortalGraph_Registered(t *testing.T) { if !ok { t.Fatal("zoneAbyssPortalGraph not registered") } - if len(g.Nodes) != 13 { - t.Errorf("nodes = %d, want 13", len(g.Nodes)) + // Long-expedition D1-e widened this zone from 13 → 51 nodes so the + // longest entry→boss walk lands in the T5 [36,44] traversal band. + if len(g.Nodes) != 51 { + t.Errorf("nodes = %d, want 51", len(g.Nodes)) + } +} + +func TestAbyssPortalGraph_LongestPathInBand(t *testing.T) { + g := zoneAbyssPortalGraph() + got := graphLongestPath(g) + if got < 36 || got > 44 { + t.Errorf("longest path = %d, want in T5 band [36,44]", got) + } +} + +// TestAbyssPortalGraph_AllNodesHaveRegion confirms D1-e backfilled the +// missing RegionID authoring per dnd_expedition_region.go: every node +// carries a non-empty RegionID matching the registry. +func TestAbyssPortalGraph_AllNodesHaveRegion(t *testing.T) { + g := zoneAbyssPortalGraph() + validRegions := map[string]bool{ + "abyss_outer_rift": true, + "abyss_demon_assembly": true, + "abyss_wardens_post": true, + "abyss_the_tear": true, + } + for id, n := range g.Nodes { + if n.RegionID == "" { + t.Errorf("node %s has empty RegionID — D1-e requires region authoring on every node", id) + } + if !validRegions[n.RegionID] { + t.Errorf("node %s RegionID = %q, not in dnd_expedition_region.go registry", id, n.RegionID) + } + } +} + +// TestAbyssPortalGraph_AllFourRegionsRepresented confirms each authored +// region has at least one node. +func TestAbyssPortalGraph_AllFourRegionsRepresented(t *testing.T) { + g := zoneAbyssPortalGraph() + regions := map[string]int{} + for _, n := range g.Nodes { + regions[n.RegionID]++ + } + for _, r := range []string{ + "abyss_outer_rift", + "abyss_demon_assembly", + "abyss_wardens_post", + "abyss_the_tear", + } { + if regions[r] == 0 { + t.Errorf("region %q has no nodes — multi-region invariant broken", r) + } } } @@ -68,3 +119,34 @@ func TestAbyssPortalGraph_RealitySeamHighestBias(t *testing.T) { t.Errorf("reality_seam LootBias = %v, want >= 3.0 (Abyss capstone)", seam.Content.LootBias) } } + +// TestAbyssPortalGraph_D10Anchors verifies the D10 anchor-variety pass. +// The Abyss shipped with no Trap node and a single fork2 Elite, so D10 +// adds two branch Traps (Hush Corridor, Seam Threshold) and a main-path +// region-guardian Elite (Warden's Hall). Counts: 2 traps, 2 elites. +func TestAbyssPortalGraph_D10Anchors(t *testing.T) { + g := zoneAbyssPortalGraph() + var trapCount, eliteCount int + for _, n := range g.Nodes { + switch n.Kind { + case NodeKindTrap: + trapCount++ + case NodeKindElite: + eliteCount++ + } + } + if trapCount != 2 { + t.Errorf("trap nodes = %d, want 2 (D10 hush_corridor + seam_threshold)", trapCount) + } + if eliteCount != 2 { + t.Errorf("elite nodes = %d, want 2 (vrock_aerie + D10 wardens_hall)", eliteCount) + } + if g.Nodes["abyss_portal.wardens_hall"].Kind != NodeKindElite { + t.Error("D10: wardens_hall (R3 region-guardian) should be an Elite") + } + for _, id := range []string{"abyss_portal.hush_corridor", "abyss_portal.seam_threshold"} { + if g.Nodes[id].Kind != NodeKindTrap { + t.Errorf("D10: %s should be a Trap", id) + } + } +} diff --git a/internal/plugin/zone_graph_crypt_valdris.go b/internal/plugin/zone_graph_crypt_valdris.go index dfa2c90..6c1076c 100644 --- a/internal/plugin/zone_graph_crypt_valdris.go +++ b/internal/plugin/zone_graph_crypt_valdris.go @@ -1,54 +1,101 @@ package plugin -// Phase G7 — Crypt of Valdris POC graph. +// Crypt of Valdris branching graph. // -// First hand-authored branching graph. Topology per -// gogobee_branching_zones_plan.md §3: +// Long-expedition plan D1-b: extended from the original 8-node POC sketch +// to the new T1 length band (12–14 rooms traversed). Topology preserves +// the original diamond — main_hall (elite) vs side_chapel (perception +// fork), with a Perception-DC-15 secret dangling off side_chapel — and +// adds the missing Trap anchor + extra exploration on both arms so the +// longest entry→boss walk lands at 13. // -// entry → corridor → fork -// ├──[unlocked]── main_hall (elite) ──┐ -// │ ├── antechamber → boss -// └──[Perception DC 12]── side_chapel ─┘ -// │ -// └──[Perception DC 15]── secret_chamber → antechamber +// entry → processional_stair → bone_corridor → +// grave_dust_trap (TRAP) → ossuary_walk → fork +// ├──[unlocked]── main_hall (ELITE) +// │ → reliquary_hall → ash_passage ──┐ +// │ ├── antechamber +// └──[Perception DC 12]── side_chapel │ → choir_landing +// │ → catacomb_passage │ → throne_steps +// │ → vault_landing ──────────┘ → boss +// └──[Perception DC 15]── secret_chamber (SECRET) → antechamber // -// Two paths to the boss (main_hall vs side_chapel); secret_chamber -// dangles off side_chapel for an extra Perception gate with loot. +// Both main and side arms walk 3 mid-nodes after the fork; secret stays a +// 1-node side dangle off side_chapel that merges back at antechamber, so +// the Perception-DC-15 path is a loot dip rather than a length detour. func zoneCryptValdrisGraph() ZoneGraph { nodes := []ZoneNode{ {NodeID: "crypt_valdris.entry", Kind: NodeKindEntry, IsEntry: true, Label: "Crypt Threshold", PosX: 0, PosY: 1}, - {NodeID: "crypt_valdris.corridor", Kind: NodeKindExploration, - Label: "Bone Corridor", PosX: 1, PosY: 1}, + {NodeID: "crypt_valdris.processional_stair", Kind: NodeKindExploration, + Label: "Processional Stair", PosX: 1, PosY: 1}, + {NodeID: "crypt_valdris.bone_corridor", Kind: NodeKindExploration, + Label: "Bone Corridor", PosX: 2, PosY: 1}, + {NodeID: "crypt_valdris.grave_dust_trap", Kind: NodeKindTrap, + Label: "Grave-Dust Cascade", PosX: 3, PosY: 1}, + {NodeID: "crypt_valdris.ossuary_walk", Kind: NodeKindExploration, + Label: "Ossuary Walk", PosX: 4, PosY: 1}, {NodeID: "crypt_valdris.fork", Kind: NodeKindFork, - Label: "Branching Passage", PosX: 2, PosY: 1}, + Label: "Branching Passage", PosX: 5, PosY: 1}, + + // Left arm — default, contains the elite. {NodeID: "crypt_valdris.main_hall", Kind: NodeKindElite, - Label: "Main Hall", PosX: 3, PosY: 0}, + Label: "Main Hall", PosX: 6, PosY: 0}, + {NodeID: "crypt_valdris.reliquary_hall", Kind: NodeKindExploration, + Label: "Reliquary Hall", PosX: 7, PosY: 0}, + {NodeID: "crypt_valdris.ash_passage", Kind: NodeKindExploration, + Label: "Ash Passage", PosX: 8, PosY: 0}, + + // Right arm — perception-gated, elite-free. {NodeID: "crypt_valdris.side_chapel", Kind: NodeKindExploration, - Label: "Side Chapel", PosX: 3, PosY: 2}, + Label: "Side Chapel", PosX: 6, PosY: 2}, + {NodeID: "crypt_valdris.catacomb_passage", Kind: NodeKindExploration, + Label: "Catacomb Passage", PosX: 7, PosY: 2}, + {NodeID: "crypt_valdris.vault_landing", Kind: NodeKindExploration, + Label: "Vault Landing", PosX: 8, PosY: 2}, + + // Secret dangle off side_chapel — high-DC Perception, loot-biased. {NodeID: "crypt_valdris.secret_chamber", Kind: NodeKindSecret, - Label: "Hidden Reliquary", PosX: 4, PosY: 3, + Label: "Hidden Reliquary", PosX: 7, PosY: 3, Content: ZoneNodeContent{LootBias: 2.0}}, + + // Merge + approach. {NodeID: "crypt_valdris.antechamber", Kind: NodeKindMerge, - Label: "Antechamber", PosX: 5, PosY: 1}, + Label: "Antechamber", PosX: 9, PosY: 1}, + {NodeID: "crypt_valdris.choir_landing", Kind: NodeKindExploration, + Label: "Choir Landing", PosX: 10, PosY: 1}, + {NodeID: "crypt_valdris.throne_steps", Kind: NodeKindExploration, + Label: "Throne Steps", PosX: 11, PosY: 1}, {NodeID: "crypt_valdris.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Valdris's Sanctum", PosX: 6, PosY: 1}, + Label: "Valdris's Sanctum", PosX: 12, PosY: 1}, } edges := []ZoneEdge{ - {From: "crypt_valdris.entry", To: "crypt_valdris.corridor", Lock: LockNone}, - {From: "crypt_valdris.corridor", To: "crypt_valdris.fork", Lock: LockNone}, + {From: "crypt_valdris.entry", To: "crypt_valdris.processional_stair", Lock: LockNone}, + {From: "crypt_valdris.processional_stair", To: "crypt_valdris.bone_corridor", Lock: LockNone}, + {From: "crypt_valdris.bone_corridor", To: "crypt_valdris.grave_dust_trap", Lock: LockNone}, + {From: "crypt_valdris.grave_dust_trap", To: "crypt_valdris.ossuary_walk", Lock: LockNone}, + {From: "crypt_valdris.ossuary_walk", To: "crypt_valdris.fork", Lock: LockNone}, + {From: "crypt_valdris.fork", To: "crypt_valdris.main_hall", Lock: LockNone, Weight: 1}, {From: "crypt_valdris.fork", To: "crypt_valdris.side_chapel", Lock: LockPerception, LockData: map[string]any{"dc": 12}, Hint: "a faint draft from a crack in the wall", Weight: 2}, - {From: "crypt_valdris.main_hall", To: "crypt_valdris.antechamber", Lock: LockNone}, - {From: "crypt_valdris.side_chapel", To: "crypt_valdris.antechamber", Lock: LockNone, Weight: 1}, + + {From: "crypt_valdris.main_hall", To: "crypt_valdris.reliquary_hall", Lock: LockNone}, + {From: "crypt_valdris.reliquary_hall", To: "crypt_valdris.ash_passage", Lock: LockNone}, + {From: "crypt_valdris.ash_passage", To: "crypt_valdris.antechamber", Lock: LockNone}, + + {From: "crypt_valdris.side_chapel", To: "crypt_valdris.catacomb_passage", Lock: LockNone, Weight: 1}, {From: "crypt_valdris.side_chapel", To: "crypt_valdris.secret_chamber", Lock: LockPerception, LockData: map[string]any{"dc": 15}, Hint: "a faint scratching behind the altar", Weight: 2}, + {From: "crypt_valdris.catacomb_passage", To: "crypt_valdris.vault_landing", Lock: LockNone}, + {From: "crypt_valdris.vault_landing", To: "crypt_valdris.antechamber", Lock: LockNone}, {From: "crypt_valdris.secret_chamber", To: "crypt_valdris.antechamber", Lock: LockNone}, - {From: "crypt_valdris.antechamber", To: "crypt_valdris.boss", Lock: LockNone}, + + {From: "crypt_valdris.antechamber", To: "crypt_valdris.choir_landing", Lock: LockNone}, + {From: "crypt_valdris.choir_landing", To: "crypt_valdris.throne_steps", Lock: LockNone}, + {From: "crypt_valdris.throne_steps", To: "crypt_valdris.boss", Lock: LockNone}, } return BuildGraph(ZoneCryptValdris, nodes, edges) } diff --git a/internal/plugin/zone_graph_crypt_valdris_test.go b/internal/plugin/zone_graph_crypt_valdris_test.go index 2cdb173..ffec2e5 100644 --- a/internal/plugin/zone_graph_crypt_valdris_test.go +++ b/internal/plugin/zone_graph_crypt_valdris_test.go @@ -18,8 +18,10 @@ func TestCryptValdrisGraph_Registered(t *testing.T) { if g.Boss != "crypt_valdris.boss" { t.Errorf("boss node = %q, want crypt_valdris.boss", g.Boss) } - if len(g.Nodes) != 8 { - t.Errorf("nodes = %d, want 8", len(g.Nodes)) + // Long-expedition D1-b widened this zone from 8 → 17 nodes so the + // longest entry→boss walk lands in the T1 [12,14] traversal band. + if len(g.Nodes) != 17 { + t.Errorf("nodes = %d, want 17", len(g.Nodes)) } } diff --git a/internal/plugin/zone_graph_dragons_lair.go b/internal/plugin/zone_graph_dragons_lair.go index d5de585..8c9338d 100644 --- a/internal/plugin/zone_graph_dragons_lair.go +++ b/internal/plugin/zone_graph_dragons_lair.go @@ -1,81 +1,269 @@ package plugin -// Phase G8g — Dragon's Lair (Infernus Peak) branching graph. +// Dragon's Lair (Infernus Peak) branching graph — multi-region. // -// T5 zone. Shape: long approach + mid-zone fork (converging at the -// elite) + capstone 3-way fork at the boss antechamber. Combines two -// distinct fork styles in one zone: a binary-converging fork and a -// triple-spread capstone — escalating the player's decision-weight as -// they descend. +// Long-expedition plan D1-e: extended from the original 12-node sketch +// to the new T5 length band (36–44 rooms traversed). Topology preserves +// the G8g design intent — long approach, mid-zone binary-converging +// fork, capstone 3-way fork — and adds the linear depth each region +// now needs to feel like its own sub-dungeon. D1-e also backfills the +// missing RegionID authoring per dnd_expedition_region.go: every node +// carries a valid RegionID matching the registry. // -// entry → kobold_warrens → drake_pens → fork1 (binary, converges) -// ├─[unlocked]── ash_bridge ────┐ -// └─[Perception DC 15]── treasure_vault ─┤ -// ↓ -// wyrmlings_nest (elite young red dragon) -// ↓ -// fork2 (capstone, 3-way) -// ├─[unlocked]── direct_confrontation → boss -// ├─[CHA DC 16]── dragon_bargain → boss -// └─[Perception DC 17]── hoard_pillar (secret) → boss +// The original "fork2 capstone leaves go straight to boss" shape is +// adjusted so the three capstones now converge at an infernax_doors +// MERGE in R4, then walk a short throne approach to boss. Three +// distinct paths to boss are preserved (each via its own capstone +// node); the merge just consolidates the long final hall instead of +// triplicating it. // -// First authored zone with two distinct fork stages where the first -// converges (diamond-style) and the second spreads (3-way capstone). -// Earlier zones used either-or: stacked equal-width hubs (Manor) or -// late-only fork (Underforge). The escalation pattern ("commit, then -// commit harder") is unique to T5. +// R1 kobold_warrens preamble (10 nodes): +// entry → kobold_lookout → kobold_warrens → smoke_choked_hall → +// ash_chapel → kobold_warchief_camp → ember_corridor → +// obsidian_steps → cinder_passage → kobold_descent +// +// R2 drake_pens region (13 nodes; walk length 10): +// drake_pens → drake_grooming_pit → drake_holding_run → +// drake_yards → drake_handlers_hall → drake_armory → fork1 +// +// R2 fork1 — binary, converges at wyrmlings_nest (R3 boundary): +// ash_bridge spur (unlocked, TRAP): +// ash_bridge (TRAP) → burning_span → cinder_walk +// treasure_vault spur (Perception DC 15, denser loot): +// treasure_vault → coin_strewn_hall → vault_passage +// +// R3 the_vault region (9 nodes; walk length 9): +// wyrmlings_nest (ELITE) → hoard_outer → coin_river → hoard_arch → +// flame_corridor → infernax_threshold → molten_steps → inferno_walk +// → fork2 +// +// R4 infernax_chamber — capstone 3-way fork: +// direct_confrontation spur (unlocked): +// charge_walk → direct_confrontation → broken_chamber +// dragon_bargain spur (CHA DC 16): +// speakers_step → dragon_bargain → audience_hall +// hoard_pillar spur (Perception DC 17, SECRET LootBias 2.5): +// hidden_passage → hoard_pillar (SECRET) → secret_arch +// +// R4 final approach (MERGE + boss; walk 6 after capstone spur): +// infernax_doors (MERGE) → throne_ascension → +// molten_throne_approach → crown_steps → final_step → boss +// +// Longest entry→boss walk: 10 (R1) + 10 (R2) + 9 (R3) + 10 (R4) = 39 +// nodes, inside [36, 44]. The two R2 spurs and three R4 capstones each +// reach the boss in the same node count by construction. +// +// D10 anchor variety (in-place kind swaps, no length change): the two +// loot-leaning branches each gain a guard, so "the richer route costs +// more" — +// - Coin-Strewn Hall (treasure_vault spur) becomes an ELITE; the +// Perception loot route is now guarded, mirroring the ash_bridge +// spur's TRAP so both fork1 branches carry an anchor. +// - Hidden Passage (hoard_pillar SECRET capstone spur) becomes a +// TRAP guarding the densest loot in the zone. +// The direct-confrontation and dragon-bargain routes stay clean, so the +// fork choice trades safety for loot. func zoneDragonsLairGraph() ZoneGraph { + r1 := "dragons_lair_kobold_warrens" + r2 := "dragons_lair_drake_pens" + r3 := "dragons_lair_the_vault" + r4 := "dragons_lair_infernax_chamber" + nodes := []ZoneNode{ - {NodeID: "dragons_lair.entry", Kind: NodeKindEntry, IsEntry: true, + // R1 kobold_warrens preamble. + {NodeID: "dragons_lair.entry", Kind: NodeKindEntry, IsEntry: true, RegionID: r1, Label: "Mountain Pass", PosX: 0, PosY: 1}, - {NodeID: "dragons_lair.kobold_warrens", Kind: NodeKindExploration, - Label: "Kobold Warrens", PosX: 1, PosY: 1}, - {NodeID: "dragons_lair.drake_pens", Kind: NodeKindExploration, - Label: "Drake Pens", PosX: 2, PosY: 1}, - {NodeID: "dragons_lair.fork1", Kind: NodeKindFork, - Label: "The Cinder Crossing", PosX: 3, PosY: 1}, - {NodeID: "dragons_lair.ash_bridge", Kind: NodeKindTrap, - Label: "Ash Bridge", PosX: 4, PosY: 0}, - {NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration, - Label: "Treasure Vault", PosX: 4, PosY: 2}, - {NodeID: "dragons_lair.wyrmlings_nest", Kind: NodeKindElite, - Label: "Wyrmling's Nest", PosX: 5, PosY: 1}, - {NodeID: "dragons_lair.fork2", Kind: NodeKindFork, - Label: "Hoard Approach", PosX: 6, PosY: 1}, - {NodeID: "dragons_lair.direct_confrontation", Kind: NodeKindExploration, - Label: "Direct Approach", PosX: 7, PosY: 0}, - {NodeID: "dragons_lair.dragon_bargain", Kind: NodeKindExploration, - Label: "Words With Infernax", PosX: 7, PosY: 1}, - {NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret, - Label: "Hidden Hoard Pillar", PosX: 7, PosY: 2, + {NodeID: "dragons_lair.kobold_lookout", Kind: NodeKindExploration, RegionID: r1, + Label: "Kobold Lookout", PosX: 1, PosY: 1}, + {NodeID: "dragons_lair.kobold_warrens", Kind: NodeKindExploration, RegionID: r1, + Label: "Kobold Warrens", PosX: 2, PosY: 1}, + {NodeID: "dragons_lair.smoke_choked_hall", Kind: NodeKindExploration, RegionID: r1, + Label: "Smoke-Choked Hall", PosX: 3, PosY: 1}, + {NodeID: "dragons_lair.ash_chapel", Kind: NodeKindExploration, RegionID: r1, + Label: "Ash Chapel", PosX: 4, PosY: 1}, + {NodeID: "dragons_lair.kobold_warchief_camp", Kind: NodeKindExploration, RegionID: r1, + Label: "Warchief's Camp", PosX: 5, PosY: 1}, + {NodeID: "dragons_lair.ember_corridor", Kind: NodeKindExploration, RegionID: r1, + Label: "Ember Corridor", PosX: 6, PosY: 1}, + {NodeID: "dragons_lair.obsidian_steps", Kind: NodeKindExploration, RegionID: r1, + Label: "Obsidian Steps", PosX: 7, PosY: 1}, + {NodeID: "dragons_lair.cinder_passage", Kind: NodeKindExploration, RegionID: r1, + Label: "Cinder Passage", PosX: 8, PosY: 1}, + {NodeID: "dragons_lair.kobold_descent", Kind: NodeKindExploration, RegionID: r1, + Label: "Kobold Descent", PosX: 9, PosY: 1}, + + // R2 drake_pens region. + {NodeID: "dragons_lair.drake_pens", Kind: NodeKindExploration, RegionID: r2, + Label: "Drake Pens", PosX: 10, PosY: 1}, + {NodeID: "dragons_lair.drake_grooming_pit", Kind: NodeKindExploration, RegionID: r2, + Label: "Grooming Pit", PosX: 11, PosY: 1}, + {NodeID: "dragons_lair.drake_holding_run", Kind: NodeKindExploration, RegionID: r2, + Label: "Holding Run", PosX: 12, PosY: 1}, + {NodeID: "dragons_lair.drake_yards", Kind: NodeKindExploration, RegionID: r2, + Label: "Drake Yards", PosX: 13, PosY: 1}, + {NodeID: "dragons_lair.drake_handlers_hall", Kind: NodeKindExploration, RegionID: r2, + Label: "Handler's Hall", PosX: 14, PosY: 1}, + {NodeID: "dragons_lair.drake_armory", Kind: NodeKindExploration, RegionID: r2, + Label: "Drake Armory", PosX: 15, PosY: 1}, + {NodeID: "dragons_lair.fork1", Kind: NodeKindFork, RegionID: r2, + Label: "The Cinder Crossing", PosX: 16, PosY: 1}, + + // R2 ash_bridge spur (TRAP, unlocked). + {NodeID: "dragons_lair.ash_bridge", Kind: NodeKindTrap, RegionID: r2, + Label: "Ash Bridge", PosX: 17, PosY: 0}, + {NodeID: "dragons_lair.burning_span", Kind: NodeKindExploration, RegionID: r2, + Label: "Burning Span", PosX: 18, PosY: 0}, + {NodeID: "dragons_lair.cinder_walk", Kind: NodeKindExploration, RegionID: r2, + Label: "Cinder Walk", PosX: 19, PosY: 0}, + + // R2 treasure_vault spur (Perception 15, loot). + {NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration, RegionID: r2, + Label: "Treasure Vault", PosX: 17, PosY: 2, + Content: ZoneNodeContent{LootBias: 1.5}}, + {NodeID: "dragons_lair.coin_strewn_hall", Kind: NodeKindElite, RegionID: r2, + Label: "Coin-Strewn Hall", PosX: 18, PosY: 2}, + {NodeID: "dragons_lair.vault_passage", Kind: NodeKindExploration, RegionID: r2, + Label: "Vault Passage", PosX: 19, PosY: 2}, + + // R3 the_vault region. + {NodeID: "dragons_lair.wyrmlings_nest", Kind: NodeKindElite, RegionID: r3, + Label: "Wyrmling's Nest", PosX: 20, PosY: 1}, + {NodeID: "dragons_lair.hoard_outer", Kind: NodeKindExploration, RegionID: r3, + Label: "Outer Hoard", PosX: 21, PosY: 1}, + {NodeID: "dragons_lair.coin_river", Kind: NodeKindExploration, RegionID: r3, + Label: "Coin River", PosX: 22, PosY: 1}, + {NodeID: "dragons_lair.hoard_arch", Kind: NodeKindExploration, RegionID: r3, + Label: "Hoard Arch", PosX: 23, PosY: 1}, + {NodeID: "dragons_lair.flame_corridor", Kind: NodeKindExploration, RegionID: r3, + Label: "Flame Corridor", PosX: 24, PosY: 1}, + {NodeID: "dragons_lair.infernax_threshold", Kind: NodeKindExploration, RegionID: r3, + Label: "Infernax's Threshold", PosX: 25, PosY: 1}, + {NodeID: "dragons_lair.molten_steps", Kind: NodeKindExploration, RegionID: r3, + Label: "Molten Steps", PosX: 26, PosY: 1}, + {NodeID: "dragons_lair.inferno_walk", Kind: NodeKindExploration, RegionID: r3, + Label: "Inferno Walk", PosX: 27, PosY: 1}, + {NodeID: "dragons_lair.fork2", Kind: NodeKindFork, RegionID: r3, + Label: "Hoard Approach", PosX: 28, PosY: 1}, + + // R4 direct_confrontation spur. + {NodeID: "dragons_lair.charge_walk", Kind: NodeKindExploration, RegionID: r4, + Label: "Charge Walk", PosX: 29, PosY: 0}, + {NodeID: "dragons_lair.direct_confrontation", Kind: NodeKindExploration, RegionID: r4, + Label: "Direct Approach", PosX: 30, PosY: 0}, + {NodeID: "dragons_lair.broken_chamber", Kind: NodeKindExploration, RegionID: r4, + Label: "Broken Chamber", PosX: 31, PosY: 0}, + + // R4 dragon_bargain spur (CHA 16). + {NodeID: "dragons_lair.speakers_step", Kind: NodeKindExploration, RegionID: r4, + Label: "Speaker's Step", PosX: 29, PosY: 1}, + {NodeID: "dragons_lair.dragon_bargain", Kind: NodeKindExploration, RegionID: r4, + Label: "Words With Infernax", PosX: 30, PosY: 1}, + {NodeID: "dragons_lair.audience_hall", Kind: NodeKindExploration, RegionID: r4, + Label: "Audience Hall", PosX: 31, PosY: 1}, + + // R4 hoard_pillar spur (Perception 17, SECRET). + {NodeID: "dragons_lair.hidden_passage", Kind: NodeKindTrap, RegionID: r4, + Label: "Hidden Passage", PosX: 29, PosY: 2}, + {NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret, RegionID: r4, + Label: "Hidden Hoard Pillar", PosX: 30, PosY: 2, Content: ZoneNodeContent{LootBias: 2.5}}, - {NodeID: "dragons_lair.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Infernax's Crown", PosX: 8, PosY: 1}, + {NodeID: "dragons_lair.secret_arch", Kind: NodeKindExploration, RegionID: r4, + Label: "Secret Arch", PosX: 31, PosY: 2}, + + // R4 final approach. + {NodeID: "dragons_lair.infernax_doors", Kind: NodeKindMerge, RegionID: r4, + Label: "The Infernax Doors", PosX: 32, PosY: 1}, + {NodeID: "dragons_lair.throne_ascension", Kind: NodeKindExploration, RegionID: r4, + Label: "Throne Ascension", PosX: 33, PosY: 1}, + {NodeID: "dragons_lair.molten_throne_approach", Kind: NodeKindExploration, RegionID: r4, + Label: "Molten Throne Approach", PosX: 34, PosY: 1}, + {NodeID: "dragons_lair.crown_steps", Kind: NodeKindExploration, RegionID: r4, + Label: "Crown Steps", PosX: 35, PosY: 1}, + {NodeID: "dragons_lair.final_step", Kind: NodeKindExploration, RegionID: r4, + Label: "Final Step", PosX: 36, PosY: 1}, + {NodeID: "dragons_lair.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4, + Label: "Infernax's Crown", PosX: 37, PosY: 1}, } edges := []ZoneEdge{ - {From: "dragons_lair.entry", To: "dragons_lair.kobold_warrens", Lock: LockNone}, - {From: "dragons_lair.kobold_warrens", To: "dragons_lair.drake_pens", Lock: LockNone}, - {From: "dragons_lair.drake_pens", To: "dragons_lair.fork1", Lock: LockNone}, - // Fork1 — converges at wyrmlings_nest. + // R1 preamble. + {From: "dragons_lair.entry", To: "dragons_lair.kobold_lookout", Lock: LockNone}, + {From: "dragons_lair.kobold_lookout", To: "dragons_lair.kobold_warrens", Lock: LockNone}, + {From: "dragons_lair.kobold_warrens", To: "dragons_lair.smoke_choked_hall", Lock: LockNone}, + {From: "dragons_lair.smoke_choked_hall", To: "dragons_lair.ash_chapel", Lock: LockNone}, + {From: "dragons_lair.ash_chapel", To: "dragons_lair.kobold_warchief_camp", Lock: LockNone}, + {From: "dragons_lair.kobold_warchief_camp", To: "dragons_lair.ember_corridor", Lock: LockNone}, + {From: "dragons_lair.ember_corridor", To: "dragons_lair.obsidian_steps", Lock: LockNone}, + {From: "dragons_lair.obsidian_steps", To: "dragons_lair.cinder_passage", Lock: LockNone}, + {From: "dragons_lair.cinder_passage", To: "dragons_lair.kobold_descent", Lock: LockNone}, + + // R1 → R2 transition. + {From: "dragons_lair.kobold_descent", To: "dragons_lair.drake_pens", Lock: LockNone}, + + // R2 buildup to fork1. + {From: "dragons_lair.drake_pens", To: "dragons_lair.drake_grooming_pit", Lock: LockNone}, + {From: "dragons_lair.drake_grooming_pit", To: "dragons_lair.drake_holding_run", Lock: LockNone}, + {From: "dragons_lair.drake_holding_run", To: "dragons_lair.drake_yards", Lock: LockNone}, + {From: "dragons_lair.drake_yards", To: "dragons_lair.drake_handlers_hall", Lock: LockNone}, + {From: "dragons_lair.drake_handlers_hall", To: "dragons_lair.drake_armory", Lock: LockNone}, + {From: "dragons_lair.drake_armory", To: "dragons_lair.fork1", Lock: LockNone}, + + // Fork1 — binary, converges at wyrmlings_nest (R3 boundary). {From: "dragons_lair.fork1", To: "dragons_lair.ash_bridge", Lock: LockNone, Weight: 1}, {From: "dragons_lair.fork1", To: "dragons_lair.treasure_vault", Lock: LockPerception, LockData: map[string]any{"dc": 15}, Hint: "a draft from a side passage — and the dull glint of gold beyond it", Weight: 2}, - {From: "dragons_lair.ash_bridge", To: "dragons_lair.wyrmlings_nest", Lock: LockNone}, - {From: "dragons_lair.treasure_vault", To: "dragons_lair.wyrmlings_nest", Lock: LockNone}, - {From: "dragons_lair.wyrmlings_nest", To: "dragons_lair.fork2", Lock: LockNone}, - // Fork2 — capstone 3-way. - {From: "dragons_lair.fork2", To: "dragons_lair.direct_confrontation", Lock: LockNone, Weight: 1}, - {From: "dragons_lair.fork2", To: "dragons_lair.dragon_bargain", + + // ash_bridge spur. + {From: "dragons_lair.ash_bridge", To: "dragons_lair.burning_span", Lock: LockNone}, + {From: "dragons_lair.burning_span", To: "dragons_lair.cinder_walk", Lock: LockNone}, + {From: "dragons_lair.cinder_walk", To: "dragons_lair.wyrmlings_nest", Lock: LockNone}, + + // treasure_vault spur. + {From: "dragons_lair.treasure_vault", To: "dragons_lair.coin_strewn_hall", Lock: LockNone}, + {From: "dragons_lair.coin_strewn_hall", To: "dragons_lair.vault_passage", Lock: LockNone}, + {From: "dragons_lair.vault_passage", To: "dragons_lair.wyrmlings_nest", Lock: LockNone}, + + // R3 the_vault buildup to fork2. + {From: "dragons_lair.wyrmlings_nest", To: "dragons_lair.hoard_outer", Lock: LockNone}, + {From: "dragons_lair.hoard_outer", To: "dragons_lair.coin_river", Lock: LockNone}, + {From: "dragons_lair.coin_river", To: "dragons_lair.hoard_arch", Lock: LockNone}, + {From: "dragons_lair.hoard_arch", To: "dragons_lair.flame_corridor", Lock: LockNone}, + {From: "dragons_lair.flame_corridor", To: "dragons_lair.infernax_threshold", Lock: LockNone}, + {From: "dragons_lair.infernax_threshold", To: "dragons_lair.molten_steps", Lock: LockNone}, + {From: "dragons_lair.molten_steps", To: "dragons_lair.inferno_walk", Lock: LockNone}, + {From: "dragons_lair.inferno_walk", To: "dragons_lair.fork2", Lock: LockNone}, + + // Fork2 — capstone 3-way (R3 → R4). + {From: "dragons_lair.fork2", To: "dragons_lair.charge_walk", Lock: LockNone, Weight: 1}, + {From: "dragons_lair.fork2", To: "dragons_lair.speakers_step", Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 16}, Hint: "Infernax speaks. The voice fills the chamber. You could speak back.", Weight: 2}, - {From: "dragons_lair.fork2", To: "dragons_lair.hoard_pillar", + {From: "dragons_lair.fork2", To: "dragons_lair.hidden_passage", Lock: LockPerception, LockData: map[string]any{"dc": 17}, Hint: "a single coin standing on edge — a draft, not a tremor", Weight: 3}, - {From: "dragons_lair.direct_confrontation", To: "dragons_lair.boss", Lock: LockNone}, - {From: "dragons_lair.dragon_bargain", To: "dragons_lair.boss", Lock: LockNone}, - {From: "dragons_lair.hoard_pillar", To: "dragons_lair.boss", Lock: LockNone}, + + // direct_confrontation spur → merge. + {From: "dragons_lair.charge_walk", To: "dragons_lair.direct_confrontation", Lock: LockNone}, + {From: "dragons_lair.direct_confrontation", To: "dragons_lair.broken_chamber", Lock: LockNone}, + {From: "dragons_lair.broken_chamber", To: "dragons_lair.infernax_doors", Lock: LockNone}, + + // dragon_bargain spur → merge. + {From: "dragons_lair.speakers_step", To: "dragons_lair.dragon_bargain", Lock: LockNone}, + {From: "dragons_lair.dragon_bargain", To: "dragons_lair.audience_hall", Lock: LockNone}, + {From: "dragons_lair.audience_hall", To: "dragons_lair.infernax_doors", Lock: LockNone}, + + // hoard_pillar spur → merge. + {From: "dragons_lair.hidden_passage", To: "dragons_lair.hoard_pillar", Lock: LockNone}, + {From: "dragons_lair.hoard_pillar", To: "dragons_lair.secret_arch", Lock: LockNone}, + {From: "dragons_lair.secret_arch", To: "dragons_lair.infernax_doors", Lock: LockNone}, + + // R4 final approach. + {From: "dragons_lair.infernax_doors", To: "dragons_lair.throne_ascension", Lock: LockNone}, + {From: "dragons_lair.throne_ascension", To: "dragons_lair.molten_throne_approach", Lock: LockNone}, + {From: "dragons_lair.molten_throne_approach", To: "dragons_lair.crown_steps", Lock: LockNone}, + {From: "dragons_lair.crown_steps", To: "dragons_lair.final_step", Lock: LockNone}, + {From: "dragons_lair.final_step", To: "dragons_lair.boss", Lock: LockNone}, } return BuildGraph(ZoneDragonsLair, nodes, edges) } diff --git a/internal/plugin/zone_graph_dragons_lair_test.go b/internal/plugin/zone_graph_dragons_lair_test.go index cd47013..8d5f5fc 100644 --- a/internal/plugin/zone_graph_dragons_lair_test.go +++ b/internal/plugin/zone_graph_dragons_lair_test.go @@ -7,19 +7,70 @@ func TestDragonsLairGraph_Registered(t *testing.T) { if !ok { t.Fatal("zoneDragonsLairGraph not registered") } - if len(g.Nodes) != 12 { - t.Errorf("nodes = %d, want 12", len(g.Nodes)) + // Long-expedition D1-e widened this zone from 12 → 47 nodes so the + // longest entry→boss walk lands in the T5 [36,44] traversal band. + if len(g.Nodes) != 47 { + t.Errorf("nodes = %d, want 47", len(g.Nodes)) } } -// TestDragonsLairGraph_Fork1Converges verifies the binary mid-fork -// converges at wyrmlings_nest. +func TestDragonsLairGraph_LongestPathInBand(t *testing.T) { + g := zoneDragonsLairGraph() + got := graphLongestPath(g) + if got < 36 || got > 44 { + t.Errorf("longest path = %d, want in T5 band [36,44]", got) + } +} + +// TestDragonsLairGraph_AllNodesHaveRegion confirms D1-e backfilled the +// missing RegionID authoring per dnd_expedition_region.go: every node +// carries a non-empty RegionID matching the registry. +func TestDragonsLairGraph_AllNodesHaveRegion(t *testing.T) { + g := zoneDragonsLairGraph() + validRegions := map[string]bool{ + "dragons_lair_kobold_warrens": true, + "dragons_lair_drake_pens": true, + "dragons_lair_the_vault": true, + "dragons_lair_infernax_chamber": true, + } + for id, n := range g.Nodes { + if n.RegionID == "" { + t.Errorf("node %s has empty RegionID — D1-e requires region authoring on every node", id) + } + if !validRegions[n.RegionID] { + t.Errorf("node %s RegionID = %q, not in dnd_expedition_region.go registry", id, n.RegionID) + } + } +} + +// TestDragonsLairGraph_AllFourRegionsRepresented confirms each authored +// region has at least one node. +func TestDragonsLairGraph_AllFourRegionsRepresented(t *testing.T) { + g := zoneDragonsLairGraph() + regions := map[string]int{} + for _, n := range g.Nodes { + regions[n.RegionID]++ + } + for _, r := range []string{ + "dragons_lair_kobold_warrens", + "dragons_lair_drake_pens", + "dragons_lair_the_vault", + "dragons_lair_infernax_chamber", + } { + if regions[r] == 0 { + t.Errorf("region %q has no nodes — multi-region invariant broken", r) + } + } +} + +// TestDragonsLairGraph_Fork1Converges verifies the binary mid-fork's +// two spurs both converge at wyrmlings_nest (R3 boundary). func TestDragonsLairGraph_Fork1Converges(t *testing.T) { g := zoneDragonsLairGraph() - for _, mid := range []string{"dragons_lair.ash_bridge", "dragons_lair.treasure_vault"} { - outs := g.outgoingEdges(mid) + for _, spurTail := range []string{"dragons_lair.cinder_walk", "dragons_lair.vault_passage"} { + outs := g.outgoingEdges(spurTail) if len(outs) != 1 || outs[0].To != "dragons_lair.wyrmlings_nest" { - t.Errorf("%s outs = %+v, want single edge to wyrmlings_nest", mid, outs) + t.Errorf("%s outs = %+v, want single edge to wyrmlings_nest", spurTail, outs) } } } @@ -43,9 +94,9 @@ func TestDragonsLairGraph_Fork2Capstone(t *testing.T) { } // Locks escalate: open / CHA 16 / Perception 17 (T5 secret bias). for _, e := range outs { - if e.To == "dragons_lair.hoard_pillar" { + if e.To == "dragons_lair.hidden_passage" { if dc := lockDataInt(e.LockData, "dc", 0); dc < 17 { - t.Errorf("hoard_pillar DC = %d, want >= 17 (T5)", dc) + t.Errorf("hoard_pillar spur DC = %d, want >= 17 (T5)", dc) } } } @@ -60,3 +111,32 @@ func TestDragonsLairGraph_LootBiasEscalation(t *testing.T) { t.Errorf("hoard_pillar LootBias = %v, want >= 2.5 (T5 secret)", hoard.Content.LootBias) } } + +// TestDragonsLairGraph_D10Anchors verifies the D10 anchor-variety pass: +// the treasure_vault spur gains a guarding Elite (Coin-Strewn Hall) to +// mirror the ash_bridge spur's Trap, and the hoard_pillar SECRET capstone +// spur gains a Trap (Hidden Passage). Counts: 2 traps, 2 elites. +func TestDragonsLairGraph_D10Anchors(t *testing.T) { + g := zoneDragonsLairGraph() + var trapCount, eliteCount int + for _, n := range g.Nodes { + switch n.Kind { + case NodeKindTrap: + trapCount++ + case NodeKindElite: + eliteCount++ + } + } + if trapCount != 2 { + t.Errorf("trap nodes = %d, want 2 (ash_bridge + D10 hidden_passage)", trapCount) + } + if eliteCount != 2 { + t.Errorf("elite nodes = %d, want 2 (wyrmlings_nest + D10 coin_strewn_hall)", eliteCount) + } + if g.Nodes["dragons_lair.coin_strewn_hall"].Kind != NodeKindElite { + t.Error("D10: coin_strewn_hall (treasure_vault spur) should be an Elite") + } + if g.Nodes["dragons_lair.hidden_passage"].Kind != NodeKindTrap { + t.Error("D10: hidden_passage (hoard_pillar spur) should be a Trap") + } +} diff --git a/internal/plugin/zone_graph_feywild_crossing.go b/internal/plugin/zone_graph_feywild_crossing.go index f90efe5..f876e54 100644 --- a/internal/plugin/zone_graph_feywild_crossing.go +++ b/internal/plugin/zone_graph_feywild_crossing.go @@ -1,77 +1,291 @@ package plugin -// Phase G8f — Feywild Crossing branching graph. +// Feywild Crossing branching graph. // -// T4 zone. Shape: woven forks — two first-stage forks each lead to a -// second-stage fork; the inner nodes partially overlap so the player's -// first choice does not fully determine which second-stage rooms they -// can reach. Captures the Feywild theme of "the path you walked is -// not the path you arrived on." +// Long-expedition plan D1-d: extended from the original 9-node sketch +// to the new T4 length band (28–34 rooms traversed). Topology preserves +// the G8f design intent — woven first-stage forks (CHA vs Perception, +// no LockNone), hag_circle shared between the two first-stage paths, +// time_eddy grove-exclusive, illusion_garden marsh-exclusive — and +// adds the linear depth each arm now needs. // -// entry → twilight_path → fork1 -// ├─[CHA DC 14]── glamoured_grove ─┬─ hag_circle (elite) ─┐ -// │ └─[DEX DC 15]── time_eddy ─┤ -// │ ├── boss -// └─[Perception DC 14]── wisp_marsh ─┬─ hag_circle ──────────┤ -// └─[Perception DC 16]── illusion_garden (secret) ─┘ +// New in D1-d: a single fae_court MERGE node gathers all three second- +// stage endings (hag_circle / time_eddy / illusion_garden) before the +// final walk to boss. The Court of the Antlered Queen is one venue, so +// the convergence reads thematically; this also avoids triplicating the +// long pre-boss approach. // -// hag_circle is reachable from BOTH first-stage paths — the elite -// encounter is the "you'll meet her either way" room. time_eddy is -// only reachable via the grove (CHA path); illusion_garden is only -// reachable via the marsh (Perception path). +// Also new: cursed_thicket TRAP anchor in the preamble — every walk +// hits it. The original G8f graph had no Trap node. // -// First authored zone using LockStatCheck CHA — every prior secret/ -// gate has been Perception/STR/INT/DEX. Both fork1 entries are locked -// (no LockNone option from fork1) which is the first instance of "no -// free choice"; the player must commit to the lock-check style they're -// stronger at. +// D10 anchor variety (in-place kind swaps, no length change): the two +// first-stage approaches each gain one distinct anchor so the fork +// choice carries a flavor, not just a skill gate — +// - Grove approach: Singing Orchard becomes an ELITE (the CHA-bonus +// route is guarded; you pay for the bargain in blood). +// - Marsh approach: Mire Steps becomes a TRAP (the free route is +// hazardous footing rather than a fight). +// The time_eddy / illusion_garden exclusive endings stay anchor-light so +// they keep their "skip the hag elite for loot" trade-off. +// +// Preamble (10 nodes, ending in fork1): +// entry → twilight_path → veiled_glade → faerie_lights → +// cursed_thicket (TRAP) → revel_road → moonshadow_bridge → +// bargain_walk → fey_market → fork1 +// +// Fork1 → marsh (free default) | grove (CHA DC 14 bonus — the bargain): +// +// Grove approach (8 nodes, CHA DC 14 bonus): +// grove_threshold → starlight_path → singing_orchard → mirror_pond +// → prismatic_arbor → moonpetal_clearing → dawnglow_walk → +// glamoured_grove (fork2a) +// +// Marsh approach (8 nodes, Perception DC 14): +// marsh_threshold → wisp_lights → fog_basin → moaning_reeds → +// mire_steps → submerged_stones → drowned_grove → wisp_marsh +// (fork2b) +// +// Fork2a (glamoured_grove) → +// LockNone: hag_corridor_a → dryad_promenade → moonbridge_a → hag_circle +// LockStatCheck DEX 15: eddy_corridor → splintered_minute → unwoven_step → time_eddy +// +// Fork2b (wisp_marsh) → +// LockNone: hag_corridor_b → fen_walk → moonbridge_b → hag_circle (SHARED) +// LockPerception 16: garden_approach → mirror_walk → unseen_path → illusion_garden +// +// Post-elite/secret tails to fae_court MERGE (2 mid-nodes each): +// hag_circle → courtly_walk → hawthorn_arch → fae_court +// time_eddy → backward_corridor → reverberation_hall → fae_court +// illusion_garden → false_arbor → unseen_atrium → fae_court +// +// Final approach (4 mid-nodes + boss): +// fae_court → antlered_approach → queens_promenade → throne_steps → +// glamoured_dais → boss +// +// Longest entry→boss walk lands at 30 nodes (e.g. preamble 10 + grove +// approach 8 + eddy spur 3 + time_eddy 1 + eddy tail 2 + fae_court 1 + +// final approach 4 + boss 1 = 30), inside [28,34]. All four meaningful +// walks (grove/marsh × hag/exclusive) hit the same 30-node target by +// construction. func zoneFeywildCrossingGraph() ZoneGraph { nodes := []ZoneNode{ + // Preamble. {NodeID: "feywild_crossing.entry", Kind: NodeKindEntry, IsEntry: true, Label: "Veil's Edge", PosX: 0, PosY: 2}, {NodeID: "feywild_crossing.twilight_path", Kind: NodeKindExploration, Label: "Twilight Path", PosX: 1, PosY: 2}, + {NodeID: "feywild_crossing.veiled_glade", Kind: NodeKindExploration, + Label: "Veiled Glade", PosX: 2, PosY: 2}, + {NodeID: "feywild_crossing.faerie_lights", Kind: NodeKindExploration, + Label: "Faerie Lights", PosX: 3, PosY: 2}, + {NodeID: "feywild_crossing.cursed_thicket", Kind: NodeKindTrap, + Label: "Cursed Thicket", PosX: 4, PosY: 2}, + {NodeID: "feywild_crossing.revel_road", Kind: NodeKindExploration, + Label: "The Revel Road", PosX: 5, PosY: 2}, + {NodeID: "feywild_crossing.moonshadow_bridge", Kind: NodeKindExploration, + Label: "Moonshadow Bridge", PosX: 6, PosY: 2}, + {NodeID: "feywild_crossing.bargain_walk", Kind: NodeKindExploration, + Label: "The Bargain Walk", PosX: 7, PosY: 2}, + {NodeID: "feywild_crossing.fey_market", Kind: NodeKindExploration, + Label: "Hush Market", PosX: 8, PosY: 2}, {NodeID: "feywild_crossing.fork1", Kind: NodeKindFork, - Label: "The Bargain Crossroads", PosX: 2, PosY: 2}, + Label: "The Bargain Crossroads", PosX: 9, PosY: 2}, + + // Grove approach (CHA route to glamoured_grove). + {NodeID: "feywild_crossing.grove_threshold", Kind: NodeKindExploration, + Label: "Grove Threshold", PosX: 10, PosY: 0}, + {NodeID: "feywild_crossing.starlight_path", Kind: NodeKindExploration, + Label: "Starlight Path", PosX: 11, PosY: 0}, + {NodeID: "feywild_crossing.singing_orchard", Kind: NodeKindElite, + Label: "Singing Orchard", PosX: 12, PosY: 0}, + {NodeID: "feywild_crossing.mirror_pond", Kind: NodeKindExploration, + Label: "Mirror Pond", PosX: 13, PosY: 0}, + {NodeID: "feywild_crossing.prismatic_arbor", Kind: NodeKindExploration, + Label: "Prismatic Arbor", PosX: 14, PosY: 0}, + {NodeID: "feywild_crossing.moonpetal_clearing", Kind: NodeKindExploration, + Label: "Moonpetal Clearing", PosX: 15, PosY: 0}, + {NodeID: "feywild_crossing.dawnglow_walk", Kind: NodeKindExploration, + Label: "Dawnglow Walk", PosX: 16, PosY: 0}, {NodeID: "feywild_crossing.glamoured_grove", Kind: NodeKindFork, - Label: "Glamoured Grove", PosX: 3, PosY: 1}, + Label: "Glamoured Grove", PosX: 17, PosY: 0}, + + // Marsh approach (Perception route to wisp_marsh). + {NodeID: "feywild_crossing.marsh_threshold", Kind: NodeKindExploration, + Label: "Marsh Threshold", PosX: 10, PosY: 4}, + {NodeID: "feywild_crossing.wisp_lights", Kind: NodeKindExploration, + Label: "Wisp Lights", PosX: 11, PosY: 4}, + {NodeID: "feywild_crossing.fog_basin", Kind: NodeKindExploration, + Label: "Fog Basin", PosX: 12, PosY: 4}, + {NodeID: "feywild_crossing.moaning_reeds", Kind: NodeKindExploration, + Label: "Moaning Reeds", PosX: 13, PosY: 4}, + {NodeID: "feywild_crossing.mire_steps", Kind: NodeKindTrap, + Label: "Mire Steps", PosX: 14, PosY: 4}, + {NodeID: "feywild_crossing.submerged_stones", Kind: NodeKindExploration, + Label: "Submerged Stones", PosX: 15, PosY: 4}, + {NodeID: "feywild_crossing.drowned_grove", Kind: NodeKindExploration, + Label: "Drowned Grove", PosX: 16, PosY: 4}, {NodeID: "feywild_crossing.wisp_marsh", Kind: NodeKindFork, - Label: "Wisp Marsh", PosX: 3, PosY: 3}, + Label: "Wisp Marsh", PosX: 17, PosY: 4}, + + // Grove → hag_circle spur. + {NodeID: "feywild_crossing.hag_corridor_a", Kind: NodeKindExploration, + Label: "Whispering Hedge", PosX: 18, PosY: 1}, + {NodeID: "feywild_crossing.dryad_promenade", Kind: NodeKindExploration, + Label: "Dryad Promenade", PosX: 19, PosY: 1}, + {NodeID: "feywild_crossing.moonbridge_a", Kind: NodeKindExploration, + Label: "Moonbridge", PosX: 20, PosY: 1}, + + // Grove → time_eddy spur. + {NodeID: "feywild_crossing.eddy_corridor", Kind: NodeKindExploration, + Label: "Eddy Corridor", PosX: 18, PosY: 0}, + {NodeID: "feywild_crossing.splintered_minute", Kind: NodeKindExploration, + Label: "Splintered Minute", PosX: 19, PosY: 0}, + {NodeID: "feywild_crossing.unwoven_step", Kind: NodeKindExploration, + Label: "Unwoven Step", PosX: 20, PosY: 0}, {NodeID: "feywild_crossing.time_eddy", Kind: NodeKindExploration, - Label: "Time Eddy", PosX: 4, PosY: 0}, - {NodeID: "feywild_crossing.hag_circle", Kind: NodeKindElite, - Label: "Hag Circle", PosX: 4, PosY: 2}, + Label: "Time Eddy", PosX: 21, PosY: 0}, + + // Marsh → hag_circle spur. + {NodeID: "feywild_crossing.hag_corridor_b", Kind: NodeKindExploration, + Label: "Sunken Causeway", PosX: 18, PosY: 3}, + {NodeID: "feywild_crossing.fen_walk", Kind: NodeKindExploration, + Label: "Fen Walk", PosX: 19, PosY: 3}, + {NodeID: "feywild_crossing.moonbridge_b", Kind: NodeKindExploration, + Label: "Drowned Moonbridge", PosX: 20, PosY: 3}, + + // Marsh → illusion_garden spur. + {NodeID: "feywild_crossing.garden_approach", Kind: NodeKindExploration, + Label: "Garden Approach", PosX: 18, PosY: 4}, + {NodeID: "feywild_crossing.mirror_walk", Kind: NodeKindExploration, + Label: "Mirror Walk", PosX: 19, PosY: 4}, + {NodeID: "feywild_crossing.unseen_path", Kind: NodeKindExploration, + Label: "Unseen Path", PosX: 20, PosY: 4}, {NodeID: "feywild_crossing.illusion_garden", Kind: NodeKindSecret, - Label: "Illusion Garden", PosX: 4, PosY: 4, + Label: "Illusion Garden", PosX: 21, PosY: 4, Content: ZoneNodeContent{LootBias: 2.0}}, + + // hag_circle (shared elite). + {NodeID: "feywild_crossing.hag_circle", Kind: NodeKindElite, + Label: "Hag Circle", PosX: 21, PosY: 2}, + + // Hag tail. + {NodeID: "feywild_crossing.courtly_walk", Kind: NodeKindExploration, + Label: "Courtly Walk", PosX: 22, PosY: 2}, + {NodeID: "feywild_crossing.hawthorn_arch", Kind: NodeKindExploration, + Label: "Hawthorn Arch", PosX: 23, PosY: 2}, + + // Eddy tail. + {NodeID: "feywild_crossing.backward_corridor", Kind: NodeKindExploration, + Label: "Backward Corridor", PosX: 22, PosY: 0}, + {NodeID: "feywild_crossing.reverberation_hall", Kind: NodeKindExploration, + Label: "Reverberation Hall", PosX: 23, PosY: 0}, + + // Garden tail. + {NodeID: "feywild_crossing.false_arbor", Kind: NodeKindExploration, + Label: "False Arbor", PosX: 22, PosY: 4}, + {NodeID: "feywild_crossing.unseen_atrium", Kind: NodeKindExploration, + Label: "Unseen Atrium", PosX: 23, PosY: 4}, + + // Final approach. + {NodeID: "feywild_crossing.fae_court", Kind: NodeKindMerge, + Label: "Fae Court", PosX: 24, PosY: 2}, + {NodeID: "feywild_crossing.antlered_approach", Kind: NodeKindExploration, + Label: "Antlered Approach", PosX: 25, PosY: 2}, + {NodeID: "feywild_crossing.queens_promenade", Kind: NodeKindExploration, + Label: "Queen's Promenade", PosX: 26, PosY: 2}, + {NodeID: "feywild_crossing.throne_steps", Kind: NodeKindExploration, + Label: "Throne Steps", PosX: 27, PosY: 2}, + {NodeID: "feywild_crossing.glamoured_dais", Kind: NodeKindExploration, + Label: "Glamoured Dais", PosX: 28, PosY: 2}, {NodeID: "feywild_crossing.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Court of the Antlered Queen", PosX: 5, PosY: 2}, + Label: "Court of the Antlered Queen", PosX: 29, PosY: 2}, } edges := []ZoneEdge{ + // Preamble. {From: "feywild_crossing.entry", To: "feywild_crossing.twilight_path", Lock: LockNone}, - {From: "feywild_crossing.twilight_path", To: "feywild_crossing.fork1", Lock: LockNone}, - // Fork1 — both options are locked (CHA vs. Perception). - {From: "feywild_crossing.fork1", To: "feywild_crossing.glamoured_grove", + {From: "feywild_crossing.twilight_path", To: "feywild_crossing.veiled_glade", Lock: LockNone}, + {From: "feywild_crossing.veiled_glade", To: "feywild_crossing.faerie_lights", Lock: LockNone}, + {From: "feywild_crossing.faerie_lights", To: "feywild_crossing.cursed_thicket", Lock: LockNone}, + {From: "feywild_crossing.cursed_thicket", To: "feywild_crossing.revel_road", Lock: LockNone}, + {From: "feywild_crossing.revel_road", To: "feywild_crossing.moonshadow_bridge", Lock: LockNone}, + {From: "feywild_crossing.moonshadow_bridge", To: "feywild_crossing.bargain_walk", Lock: LockNone}, + {From: "feywild_crossing.bargain_walk", To: "feywild_crossing.fey_market", Lock: LockNone}, + {From: "feywild_crossing.fey_market", To: "feywild_crossing.fork1", Lock: LockNone}, + + // Fork1 — marsh is the free default path; grove is a CHA-gated + // bonus route (the fey bargain). (Both were skill-locked originally, + // with no LockNone exit — a soft-lock: any character failing both + // the CHA and Perception checks was permanently stranded here, since + // fork rolls are deterministic with no retry. Every other zone fork + // has a free path; freeing marsh restores that invariant while + // keeping the signature CHA fey-bargain route as a bonus.) + {From: "feywild_crossing.fork1", To: "feywild_crossing.grove_threshold", Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14}, Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1}, - {From: "feywild_crossing.fork1", To: "feywild_crossing.wisp_marsh", - Lock: LockPerception, LockData: map[string]any{"dc": 14}, - Hint: "wisp-light flickering between two trees — they look like the same tree", Weight: 1}, - // Grove → hag_circle (open) or time_eddy (DEX). - {From: "feywild_crossing.glamoured_grove", To: "feywild_crossing.hag_circle", Lock: LockNone, Weight: 1}, - {From: "feywild_crossing.glamoured_grove", To: "feywild_crossing.time_eddy", + {From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold", + Lock: LockNone, + Hint: "wisp-light flickering between two trees — pick your way through", Weight: 1}, + + // Grove approach. + {From: "feywild_crossing.grove_threshold", To: "feywild_crossing.starlight_path", Lock: LockNone}, + {From: "feywild_crossing.starlight_path", To: "feywild_crossing.singing_orchard", Lock: LockNone}, + {From: "feywild_crossing.singing_orchard", To: "feywild_crossing.mirror_pond", Lock: LockNone}, + {From: "feywild_crossing.mirror_pond", To: "feywild_crossing.prismatic_arbor", Lock: LockNone}, + {From: "feywild_crossing.prismatic_arbor", To: "feywild_crossing.moonpetal_clearing", Lock: LockNone}, + {From: "feywild_crossing.moonpetal_clearing", To: "feywild_crossing.dawnglow_walk", Lock: LockNone}, + {From: "feywild_crossing.dawnglow_walk", To: "feywild_crossing.glamoured_grove", Lock: LockNone}, + + // Marsh approach. + {From: "feywild_crossing.marsh_threshold", To: "feywild_crossing.wisp_lights", Lock: LockNone}, + {From: "feywild_crossing.wisp_lights", To: "feywild_crossing.fog_basin", Lock: LockNone}, + {From: "feywild_crossing.fog_basin", To: "feywild_crossing.moaning_reeds", Lock: LockNone}, + {From: "feywild_crossing.moaning_reeds", To: "feywild_crossing.mire_steps", Lock: LockNone}, + {From: "feywild_crossing.mire_steps", To: "feywild_crossing.submerged_stones", Lock: LockNone}, + {From: "feywild_crossing.submerged_stones", To: "feywild_crossing.drowned_grove", Lock: LockNone}, + {From: "feywild_crossing.drowned_grove", To: "feywild_crossing.wisp_marsh", Lock: LockNone}, + + // glamoured_grove (fork2a) → hag spur (open) | eddy spur (DEX). + {From: "feywild_crossing.glamoured_grove", To: "feywild_crossing.hag_corridor_a", Lock: LockNone, Weight: 1}, + {From: "feywild_crossing.glamoured_grove", To: "feywild_crossing.eddy_corridor", Lock: LockStatCheck, LockData: map[string]any{"stat": "DEX", "dc": 15}, Hint: "the seconds run sideways here — sidestep at the right one", Weight: 2}, - // Marsh → hag_circle (shared) or illusion_garden (high Perception secret). - {From: "feywild_crossing.wisp_marsh", To: "feywild_crossing.hag_circle", Lock: LockNone, Weight: 1}, - {From: "feywild_crossing.wisp_marsh", To: "feywild_crossing.illusion_garden", + {From: "feywild_crossing.hag_corridor_a", To: "feywild_crossing.dryad_promenade", Lock: LockNone}, + {From: "feywild_crossing.dryad_promenade", To: "feywild_crossing.moonbridge_a", Lock: LockNone}, + {From: "feywild_crossing.moonbridge_a", To: "feywild_crossing.hag_circle", Lock: LockNone}, + {From: "feywild_crossing.eddy_corridor", To: "feywild_crossing.splintered_minute", Lock: LockNone}, + {From: "feywild_crossing.splintered_minute", To: "feywild_crossing.unwoven_step", Lock: LockNone}, + {From: "feywild_crossing.unwoven_step", To: "feywild_crossing.time_eddy", Lock: LockNone}, + + // wisp_marsh (fork2b) → hag spur (open) | garden spur (Perception secret). + {From: "feywild_crossing.wisp_marsh", To: "feywild_crossing.hag_corridor_b", Lock: LockNone, Weight: 1}, + {From: "feywild_crossing.wisp_marsh", To: "feywild_crossing.garden_approach", Lock: LockPerception, LockData: map[string]any{"dc": 16}, Hint: "a garden visible only when you don't look directly at it", Weight: 2}, - // All three second-stage nodes terminate at boss. - {From: "feywild_crossing.hag_circle", To: "feywild_crossing.boss", Lock: LockNone}, - {From: "feywild_crossing.time_eddy", To: "feywild_crossing.boss", Lock: LockNone}, - {From: "feywild_crossing.illusion_garden", To: "feywild_crossing.boss", Lock: LockNone}, + {From: "feywild_crossing.hag_corridor_b", To: "feywild_crossing.fen_walk", Lock: LockNone}, + {From: "feywild_crossing.fen_walk", To: "feywild_crossing.moonbridge_b", Lock: LockNone}, + {From: "feywild_crossing.moonbridge_b", To: "feywild_crossing.hag_circle", Lock: LockNone}, + {From: "feywild_crossing.garden_approach", To: "feywild_crossing.mirror_walk", Lock: LockNone}, + {From: "feywild_crossing.mirror_walk", To: "feywild_crossing.unseen_path", Lock: LockNone}, + {From: "feywild_crossing.unseen_path", To: "feywild_crossing.illusion_garden", Lock: LockNone}, + + // Post-second-stage tails into fae_court merge. + {From: "feywild_crossing.hag_circle", To: "feywild_crossing.courtly_walk", Lock: LockNone}, + {From: "feywild_crossing.courtly_walk", To: "feywild_crossing.hawthorn_arch", Lock: LockNone}, + {From: "feywild_crossing.hawthorn_arch", To: "feywild_crossing.fae_court", Lock: LockNone}, + {From: "feywild_crossing.time_eddy", To: "feywild_crossing.backward_corridor", Lock: LockNone}, + {From: "feywild_crossing.backward_corridor", To: "feywild_crossing.reverberation_hall", Lock: LockNone}, + {From: "feywild_crossing.reverberation_hall", To: "feywild_crossing.fae_court", Lock: LockNone}, + {From: "feywild_crossing.illusion_garden", To: "feywild_crossing.false_arbor", Lock: LockNone}, + {From: "feywild_crossing.false_arbor", To: "feywild_crossing.unseen_atrium", Lock: LockNone}, + {From: "feywild_crossing.unseen_atrium", To: "feywild_crossing.fae_court", Lock: LockNone}, + + // Final approach. + {From: "feywild_crossing.fae_court", To: "feywild_crossing.antlered_approach", Lock: LockNone}, + {From: "feywild_crossing.antlered_approach", To: "feywild_crossing.queens_promenade", Lock: LockNone}, + {From: "feywild_crossing.queens_promenade", To: "feywild_crossing.throne_steps", Lock: LockNone}, + {From: "feywild_crossing.throne_steps", To: "feywild_crossing.glamoured_dais", Lock: LockNone}, + {From: "feywild_crossing.glamoured_dais", To: "feywild_crossing.boss", Lock: LockNone}, } return BuildGraph(ZoneFeywildCrossing, nodes, edges) } diff --git a/internal/plugin/zone_graph_feywild_crossing_test.go b/internal/plugin/zone_graph_feywild_crossing_test.go index d503fca..e321730 100644 --- a/internal/plugin/zone_graph_feywild_crossing_test.go +++ b/internal/plugin/zone_graph_feywild_crossing_test.go @@ -7,8 +7,18 @@ func TestFeywildCrossingGraph_Registered(t *testing.T) { if !ok { t.Fatal("zoneFeywildCrossingGraph not registered") } - if len(g.Nodes) != 9 { - t.Errorf("nodes = %d, want 9", len(g.Nodes)) + // Long-expedition D1-d widened this zone from 9 → 53 nodes so the + // longest entry→boss walk lands in the T4 [28,34] traversal band. + if len(g.Nodes) != 53 { + t.Errorf("nodes = %d, want 53", len(g.Nodes)) + } +} + +func TestFeywildCrossingGraph_LongestPathInBand(t *testing.T) { + g := zoneFeywildCrossingGraph() + got := graphLongestPath(g) + if got < 28 || got > 34 { + t.Errorf("longest path = %d, want in T4 band [28,34]", got) } } @@ -45,16 +55,23 @@ func TestFeywildCrossingGraph_PartialOverlap(t *testing.T) { } } -// TestFeywildCrossingGraph_NoFreeChoiceAtFork1 captures the design -// intent: both fork1 outgoing edges are locked. The player must succeed -// at CHA or Perception to enter; no LockNone fallback. -func TestFeywildCrossingGraph_NoFreeChoiceAtFork1(t *testing.T) { +// TestFeywildCrossingGraph_Fork1HasFreePath guards the no-soft-lock +// invariant: fork1 must offer at least one LockNone exit. The original +// design locked BOTH edges (CHA + Perception) with no fallback — fork +// rolls are deterministic with no retry, so a character failing both was +// permanently stranded (D8-f part 2 found this stranded ~60% of runs). +// Every other zone fork has a free path; fork1 must too. +func TestFeywildCrossingGraph_Fork1HasFreePath(t *testing.T) { g := zoneFeywildCrossingGraph() + free := 0 for _, e := range g.outgoingEdges("feywild_crossing.fork1") { - if e.Lock == LockNone { - t.Errorf("fork1 has unlocked edge to %s — expected all locked", e.To) + if e.Lock == LockNone || e.Lock == "" { + free++ } } + if free == 0 { + t.Error("fork1 has no free (LockNone) exit — soft-lock: a player failing every skill check is permanently stranded") + } } // TestFeywildCrossingGraph_FirstCHALock confirms this zone uses @@ -73,3 +90,62 @@ func TestFeywildCrossingGraph_FirstCHALock(t *testing.T) { t.Error("expected at least one LockStatCheck CHA edge — Feywild theme") } } + +// TestFeywildCrossingGraph_TrapAnchor verifies D1-d added the missing +// Trap node. Original G8f graph had elite/secret but no trap. +// TestFeywildCrossingGraph_TrapAnchor verifies the preamble trap plus +// the D10 marsh-branch trap (Mire Steps). +func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) { + g := zoneFeywildCrossingGraph() + var trapCount int + for _, n := range g.Nodes { + if n.Kind == NodeKindTrap { + trapCount++ + } + } + if trapCount != 2 { + t.Errorf("trap nodes = %d, want 2 (cursed_thicket + D10 mire_steps)", trapCount) + } + if g.Nodes["feywild_crossing.mire_steps"].Kind != NodeKindTrap { + t.Error("D10: mire_steps (marsh branch) should be a Trap") + } +} + +// TestFeywildCrossingGraph_EliteAnchors verifies the shared hag_circle +// elite plus the D10 grove-branch elite (Singing Orchard), so the first +// fork carries a per-branch anchor (grove=elite, marsh=trap). +func TestFeywildCrossingGraph_EliteAnchors(t *testing.T) { + g := zoneFeywildCrossingGraph() + var eliteCount int + for _, n := range g.Nodes { + if n.Kind == NodeKindElite { + eliteCount++ + } + } + if eliteCount != 2 { + t.Errorf("elite nodes = %d, want 2 (hag_circle + D10 singing_orchard)", eliteCount) + } + if g.Nodes["feywild_crossing.singing_orchard"].Kind != NodeKindElite { + t.Error("D10: singing_orchard (grove branch) should be an Elite") + } +} + +// TestFeywildCrossingGraph_FaeCourtMerge verifies all three second- +// stage endings (hag_circle, time_eddy, illusion_garden) converge at +// the fae_court merge before the final boss approach. D1-d added this +// merge to avoid triplicating the long pre-boss walk. +func TestFeywildCrossingGraph_FaeCourtMerge(t *testing.T) { + g := zoneFeywildCrossingGraph() + for _, ending := range []string{ + "feywild_crossing.hag_circle", + "feywild_crossing.time_eddy", + "feywild_crossing.illusion_garden", + } { + if !reachable(g, ending, "feywild_crossing.fae_court") { + t.Errorf("%s does not reach fae_court merge", ending) + } + } + if !reachable(g, "feywild_crossing.fae_court", "feywild_crossing.boss") { + t.Error("fae_court → boss path missing") + } +} diff --git a/internal/plugin/zone_graph_forest_shadows.go b/internal/plugin/zone_graph_forest_shadows.go index 88f07c2..00a6371 100644 --- a/internal/plugin/zone_graph_forest_shadows.go +++ b/internal/plugin/zone_graph_forest_shadows.go @@ -1,64 +1,111 @@ package plugin -// Phase G8b — Forest of Shadows branching graph. +// Forest of Shadows branching graph. // -// T2 zone. Per plan §G8 ("two forks for T2+") and the G8 design -// session: deliberately a different shape from the Crypt diamond. +// Long-expedition plan D1-b: extended from the original 9-node sketch to +// the new T2 length band (16–20 rooms traversed). Topology preserves the +// G8 design intent — asymmetric main fork (long branch carries the +// elite) + WIS-gated secret hanging pre-boss — and adds the missing Trap +// anchor + extra exploration on both arms and the boss approach. // -// Shape: asymmetric main fork + WIS-gated secret pre-boss. +// entry → mossy_threshold → twisted_path → root_snare (TRAP) → +// creeping_brush → dappled_hollow → witchlight_clearing → fork1 +// ├─[unlocked, w=1]── grove_descent → +// │ dryad_circle (ELITE) → torn_meadow ──┐ +// │ ├── fork2 +// └─[Perception DC 13, w=2]── thorn_tunnel │ +// → briar_warren ────────────────────────┘ +// │ +// ┌─[unlocked, w=1]── ritual_glade → │ +// │ antler_path → hollow_steps → boss ─┘ +// │ +// └─[WIS DC 14, w=2]── sapling_shrine (SECRET) → boss // -// entry → twisted_path → fork1 -// ├─[unlocked, w=1]── grove_descent → dryad_circle (elite) ─┐ -// │ ├── fork2 -// └─[Perception DC 13, w=2]── thorn_tunnel ──────────────────┘ -// │ -// ┌─[unlocked, w=1]── boss ────────────────┘ -// │ -// └─[WIS DC 14, w=2]── sapling_shrine (secret) → boss -// -// Asymmetry: long branch is two mid-nodes (grove_descent → dryad_circle -// (elite)), short branch is one (thorn_tunnel). Map renders the long -// branch as a "+1 column" arm so the topology reads at a glance. -// Secret uses LockStatCheck WIS (not Perception) to exercise the -// stat-check lock kind — Crypt's secret is Perception, so this is the -// first authored zone using stat_check. +// Asymmetry preserved: long branch is 3 mid-nodes (grove_descent → +// dryad_circle (elite) → torn_meadow), short branch is 2 (thorn_tunnel → +// briar_warren). Longest entry→boss walk = 16 (long branch path). func zoneForestShadowsGraph() ZoneGraph { nodes := []ZoneNode{ {NodeID: "forest_shadows.entry", Kind: NodeKindEntry, IsEntry: true, Label: "Forest Edge", PosX: 0, PosY: 1}, + {NodeID: "forest_shadows.mossy_threshold", Kind: NodeKindExploration, + Label: "Mossy Threshold", PosX: 1, PosY: 1}, {NodeID: "forest_shadows.twisted_path", Kind: NodeKindExploration, - Label: "Twisted Path", PosX: 1, PosY: 1}, + Label: "Twisted Path", PosX: 2, PosY: 1}, + {NodeID: "forest_shadows.root_snare", Kind: NodeKindTrap, + Label: "Root Snare", PosX: 3, PosY: 1}, + {NodeID: "forest_shadows.creeping_brush", Kind: NodeKindExploration, + Label: "Creeping Brush", PosX: 4, PosY: 1}, + {NodeID: "forest_shadows.dappled_hollow", Kind: NodeKindExploration, + Label: "Dappled Hollow", PosX: 5, PosY: 1}, + {NodeID: "forest_shadows.witchlight_clearing", Kind: NodeKindExploration, + Label: "Witchlight Clearing", PosX: 6, PosY: 1}, {NodeID: "forest_shadows.fork1", Kind: NodeKindFork, - Label: "Splintered Trail", PosX: 2, PosY: 1}, + Label: "Splintered Trail", PosX: 7, PosY: 1}, + + // Long branch (3 mid-nodes, carries the elite). {NodeID: "forest_shadows.grove_descent", Kind: NodeKindExploration, - Label: "Grove Descent", PosX: 3, PosY: 0}, + Label: "Grove Descent", PosX: 8, PosY: 0}, {NodeID: "forest_shadows.dryad_circle", Kind: NodeKindElite, - Label: "Dryad's Circle", PosX: 4, PosY: 0}, + Label: "Dryad's Circle", PosX: 9, PosY: 0}, + {NodeID: "forest_shadows.torn_meadow", Kind: NodeKindExploration, + Label: "Torn Meadow", PosX: 10, PosY: 0}, + + // Short branch (2 mid-nodes, perception-gated). {NodeID: "forest_shadows.thorn_tunnel", Kind: NodeKindExploration, - Label: "Thorn Tunnel", PosX: 3, PosY: 2}, + Label: "Thorn Tunnel", PosX: 8, PosY: 2}, + {NodeID: "forest_shadows.briar_warren", Kind: NodeKindExploration, + Label: "Briar Warren", PosX: 9, PosY: 2}, + {NodeID: "forest_shadows.fork2", Kind: NodeKindFork, - Label: "Hollow King's Approach", PosX: 5, PosY: 1}, + Label: "Hollow King's Approach", PosX: 11, PosY: 1}, + + // Post-fork2 main approach. + {NodeID: "forest_shadows.ritual_glade", Kind: NodeKindExploration, + Label: "Ritual Glade", PosX: 12, PosY: 1}, + {NodeID: "forest_shadows.antler_path", Kind: NodeKindExploration, + Label: "Antler Path", PosX: 13, PosY: 1}, + {NodeID: "forest_shadows.hollow_steps", Kind: NodeKindExploration, + Label: "Hollow Steps", PosX: 14, PosY: 1}, + + // Secret dangle off fork2 — WIS gate, loot-biased, shortcut to boss. {NodeID: "forest_shadows.sapling_shrine", Kind: NodeKindSecret, - Label: "Sapling Shrine", PosX: 6, PosY: 2, + Label: "Sapling Shrine", PosX: 13, PosY: 2, Content: ZoneNodeContent{LootBias: 2.0}}, + {NodeID: "forest_shadows.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Throne of Antlers", PosX: 7, PosY: 1}, + Label: "Throne of Antlers", PosX: 15, PosY: 1}, } edges := []ZoneEdge{ - {From: "forest_shadows.entry", To: "forest_shadows.twisted_path", Lock: LockNone}, - {From: "forest_shadows.twisted_path", To: "forest_shadows.fork1", Lock: LockNone}, + {From: "forest_shadows.entry", To: "forest_shadows.mossy_threshold", Lock: LockNone}, + {From: "forest_shadows.mossy_threshold", To: "forest_shadows.twisted_path", Lock: LockNone}, + {From: "forest_shadows.twisted_path", To: "forest_shadows.root_snare", Lock: LockNone}, + {From: "forest_shadows.root_snare", To: "forest_shadows.creeping_brush", Lock: LockNone}, + {From: "forest_shadows.creeping_brush", To: "forest_shadows.dappled_hollow", Lock: LockNone}, + {From: "forest_shadows.dappled_hollow", To: "forest_shadows.witchlight_clearing", Lock: LockNone}, + {From: "forest_shadows.witchlight_clearing", To: "forest_shadows.fork1", Lock: LockNone}, + {From: "forest_shadows.fork1", To: "forest_shadows.grove_descent", Lock: LockNone, Weight: 1}, {From: "forest_shadows.fork1", To: "forest_shadows.thorn_tunnel", Lock: LockPerception, LockData: map[string]any{"dc": 13}, Hint: "a deer-track winding into thicker brush", Weight: 2}, + {From: "forest_shadows.grove_descent", To: "forest_shadows.dryad_circle", Lock: LockNone}, - {From: "forest_shadows.dryad_circle", To: "forest_shadows.fork2", Lock: LockNone}, - {From: "forest_shadows.thorn_tunnel", To: "forest_shadows.fork2", Lock: LockNone}, - {From: "forest_shadows.fork2", To: "forest_shadows.boss", Lock: LockNone, Weight: 1}, + {From: "forest_shadows.dryad_circle", To: "forest_shadows.torn_meadow", Lock: LockNone}, + {From: "forest_shadows.torn_meadow", To: "forest_shadows.fork2", Lock: LockNone}, + + {From: "forest_shadows.thorn_tunnel", To: "forest_shadows.briar_warren", Lock: LockNone}, + {From: "forest_shadows.briar_warren", To: "forest_shadows.fork2", Lock: LockNone}, + + {From: "forest_shadows.fork2", To: "forest_shadows.ritual_glade", Lock: LockNone, Weight: 1}, {From: "forest_shadows.fork2", To: "forest_shadows.sapling_shrine", Lock: LockStatCheck, LockData: map[string]any{"stat": "WIS", "dc": 14}, Hint: "a humming you can almost place — antlers, somewhere", Weight: 2}, + + {From: "forest_shadows.ritual_glade", To: "forest_shadows.antler_path", Lock: LockNone}, + {From: "forest_shadows.antler_path", To: "forest_shadows.hollow_steps", Lock: LockNone}, + {From: "forest_shadows.hollow_steps", To: "forest_shadows.boss", Lock: LockNone}, {From: "forest_shadows.sapling_shrine", To: "forest_shadows.boss", Lock: LockNone}, } return BuildGraph(ZoneForestShadows, nodes, edges) diff --git a/internal/plugin/zone_graph_forest_shadows_test.go b/internal/plugin/zone_graph_forest_shadows_test.go index 1fe7a4a..8cdc7a7 100644 --- a/internal/plugin/zone_graph_forest_shadows_test.go +++ b/internal/plugin/zone_graph_forest_shadows_test.go @@ -13,8 +13,10 @@ func TestForestShadowsGraph_Registered(t *testing.T) { if g.Boss != "forest_shadows.boss" { t.Errorf("boss node = %q, want forest_shadows.boss", g.Boss) } - if len(g.Nodes) != 9 { - t.Errorf("nodes = %d, want 9", len(g.Nodes)) + // Long-expedition D1-b widened this zone from 9 → 19 nodes so the + // longest entry→boss walk lands in the T2 [16,20] traversal band. + if len(g.Nodes) != 19 { + t.Errorf("nodes = %d, want 19", len(g.Nodes)) } } @@ -42,11 +44,13 @@ func TestForestShadowsGraph_AsymmetricMainFork(t *testing.T) { g := zoneForestShadowsGraph() longLen := bfsHops(g, "forest_shadows.fork1", "forest_shadows.fork2", "forest_shadows.grove_descent") shortLen := bfsHops(g, "forest_shadows.fork1", "forest_shadows.fork2", "forest_shadows.thorn_tunnel") - if longLen != 3 { - t.Errorf("long branch hops = %d, want 3 (grove_descent → dryad_circle → fork2)", longLen) + // D1-b: long branch is now 3 mid-nodes (grove_descent → dryad_circle + // → torn_meadow), short is 2 (thorn_tunnel → briar_warren). + if longLen != 4 { + t.Errorf("long branch hops = %d, want 4 (grove_descent → dryad_circle → torn_meadow → fork2)", longLen) } - if shortLen != 2 { - t.Errorf("short branch hops = %d, want 2 (thorn_tunnel → fork2)", shortLen) + if shortLen != 3 { + t.Errorf("short branch hops = %d, want 3 (thorn_tunnel → briar_warren → fork2)", shortLen) } if longLen <= shortLen { t.Errorf("expected asymmetric branches (long > short); got long=%d short=%d", longLen, shortLen) diff --git a/internal/plugin/zone_graph_goblin_warrens.go b/internal/plugin/zone_graph_goblin_warrens.go index b810b0b..4b2d83d 100644 --- a/internal/plugin/zone_graph_goblin_warrens.go +++ b/internal/plugin/zone_graph_goblin_warrens.go @@ -1,49 +1,91 @@ package plugin -// Phase G8a — Goblin Warrens branching graph. +// Goblin Warrens branching graph. // -// First non-POC zone. T1 design constraint per -// gogobee_branching_zones_plan.md §G8: one fork per zone for T1, no -// secret (the secret pattern is exercised by Crypt of Valdris; Goblin -// Warrens deliberately validates the bare single-fork shape on a -// non-POC zone so we can confirm legacy compileLegacyZoneGraph fully -// hands off to the authored graph end-to-end). +// Long-expedition plan D1: extended from the original 7-node sketch to +// the new T1 length band (12–14 rooms traversed). Topology preserves the +// original single-fork "Perception skips the elite" shape — the warband +// branch is the default path and houses the zone's only Elite; the +// collapsed-shaft branch is hint-gated, elite-free, and roughly equal in +// node count so the two routes feel balanced. // -// entry → guard_post → fork -// ├──[unlocked]── warband_pit (elite) ──┐ -// │ ├── warchief_hall → boss -// └──[Perception DC 12]── collapsed_shaft ┘ +// entry → tunnel_descent → guard_post → snare_trap (TRAP) → +// patrol_route → cavern_junction (FORK) +// ├──[unlocked]── warband_pit (ELITE) +// │ → trophy_chamber → kennel_path ──┐ +// │ ├── warchief_hall +// └──[Perception DC 12]── collapsed_shaft │ → ritual_dais +// → fungus_grotto → service_kitchens ──────────┘ → throne_approach +// → boss // -// Both branches reach the boss; Perception side is hinted (per plan: -// "Locked paths should always have a hint — cruel design otherwise"). +// Per-tier room budget: 16 graph nodes total; longest-path traversal is +// 13 (entry-side 5 + fork 1 + branch 3 + post-merge 4), which lands in +// the §2 T1 band [12,14] for both branches. func zoneGoblinWarrensGraph() ZoneGraph { nodes := []ZoneNode{ {NodeID: "goblin_warrens.entry", Kind: NodeKindEntry, IsEntry: true, Label: "Tunnel Mouth", PosX: 0, PosY: 1}, + {NodeID: "goblin_warrens.tunnel_descent", Kind: NodeKindExploration, + Label: "Slick Descent", PosX: 1, PosY: 1}, {NodeID: "goblin_warrens.guard_post", Kind: NodeKindExploration, - Label: "Goblin Guard Post", PosX: 1, PosY: 1}, - {NodeID: "goblin_warrens.fork", Kind: NodeKindFork, - Label: "Cavern Junction", PosX: 2, PosY: 1}, + Label: "Goblin Guard Post", PosX: 2, PosY: 1}, + {NodeID: "goblin_warrens.snare_trap", Kind: NodeKindTrap, + Label: "Tripwire Snare", PosX: 3, PosY: 1}, + {NodeID: "goblin_warrens.patrol_route", Kind: NodeKindExploration, + Label: "Patrol Route", PosX: 4, PosY: 1}, + {NodeID: "goblin_warrens.cavern_junction", Kind: NodeKindFork, + Label: "Cavern Junction", PosX: 5, PosY: 1}, + + // Left branch — default, contains the elite. {NodeID: "goblin_warrens.warband_pit", Kind: NodeKindElite, - Label: "Warband Pit", PosX: 3, PosY: 0}, + Label: "Warband Pit", PosX: 6, PosY: 0}, + {NodeID: "goblin_warrens.trophy_chamber", Kind: NodeKindExploration, + Label: "Trophy Chamber", PosX: 7, PosY: 0}, + {NodeID: "goblin_warrens.kennel_path", Kind: NodeKindExploration, + Label: "Wolf Kennel Path", PosX: 8, PosY: 0}, + + // Right branch — perception-gated, elite-free. {NodeID: "goblin_warrens.collapsed_shaft", Kind: NodeKindExploration, - Label: "Collapsed Shaft", PosX: 3, PosY: 2}, + Label: "Collapsed Shaft", PosX: 6, PosY: 2}, + {NodeID: "goblin_warrens.fungus_grotto", Kind: NodeKindExploration, + Label: "Fungus Grotto", PosX: 7, PosY: 2}, + {NodeID: "goblin_warrens.service_kitchens", Kind: NodeKindExploration, + Label: "Goblin Kitchens", PosX: 8, PosY: 2}, + + // Post-merge approach. {NodeID: "goblin_warrens.warchief_hall", Kind: NodeKindMerge, - Label: "Warchief's Hall", PosX: 4, PosY: 1}, + Label: "Warchief's Antechamber", PosX: 9, PosY: 1}, + {NodeID: "goblin_warrens.ritual_dais", Kind: NodeKindExploration, + Label: "Ritual Dais", PosX: 10, PosY: 1}, + {NodeID: "goblin_warrens.throne_approach", Kind: NodeKindExploration, + Label: "Throne Approach", PosX: 11, PosY: 1}, {NodeID: "goblin_warrens.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Grol's Throne", PosX: 5, PosY: 1}, + Label: "Grol's Throne", PosX: 12, PosY: 1}, } edges := []ZoneEdge{ - {From: "goblin_warrens.entry", To: "goblin_warrens.guard_post", Lock: LockNone}, - {From: "goblin_warrens.guard_post", To: "goblin_warrens.fork", Lock: LockNone}, - {From: "goblin_warrens.fork", To: "goblin_warrens.warband_pit", Lock: LockNone, Weight: 1}, - {From: "goblin_warrens.fork", To: "goblin_warrens.collapsed_shaft", + {From: "goblin_warrens.entry", To: "goblin_warrens.tunnel_descent", Lock: LockNone}, + {From: "goblin_warrens.tunnel_descent", To: "goblin_warrens.guard_post", Lock: LockNone}, + {From: "goblin_warrens.guard_post", To: "goblin_warrens.snare_trap", Lock: LockNone}, + {From: "goblin_warrens.snare_trap", To: "goblin_warrens.patrol_route", Lock: LockNone}, + {From: "goblin_warrens.patrol_route", To: "goblin_warrens.cavern_junction", Lock: LockNone}, + + {From: "goblin_warrens.cavern_junction", To: "goblin_warrens.warband_pit", Lock: LockNone, Weight: 1}, + {From: "goblin_warrens.cavern_junction", To: "goblin_warrens.collapsed_shaft", Lock: LockPerception, LockData: map[string]any{"dc": 12}, Hint: "a muffled scrape behind a heap of fallen timbers", Weight: 2}, - {From: "goblin_warrens.warband_pit", To: "goblin_warrens.warchief_hall", Lock: LockNone}, - {From: "goblin_warrens.collapsed_shaft", To: "goblin_warrens.warchief_hall", Lock: LockNone}, - {From: "goblin_warrens.warchief_hall", To: "goblin_warrens.boss", Lock: LockNone}, + + {From: "goblin_warrens.warband_pit", To: "goblin_warrens.trophy_chamber", Lock: LockNone}, + {From: "goblin_warrens.trophy_chamber", To: "goblin_warrens.kennel_path", Lock: LockNone}, + {From: "goblin_warrens.kennel_path", To: "goblin_warrens.warchief_hall", Lock: LockNone}, + + {From: "goblin_warrens.collapsed_shaft", To: "goblin_warrens.fungus_grotto", Lock: LockNone}, + {From: "goblin_warrens.fungus_grotto", To: "goblin_warrens.service_kitchens", Lock: LockNone}, + {From: "goblin_warrens.service_kitchens", To: "goblin_warrens.warchief_hall", Lock: LockNone}, + + {From: "goblin_warrens.warchief_hall", To: "goblin_warrens.ritual_dais", Lock: LockNone}, + {From: "goblin_warrens.ritual_dais", To: "goblin_warrens.throne_approach", Lock: LockNone}, + {From: "goblin_warrens.throne_approach", To: "goblin_warrens.boss", Lock: LockNone}, } return BuildGraph(ZoneGoblinWarrens, nodes, edges) } diff --git a/internal/plugin/zone_graph_goblin_warrens_test.go b/internal/plugin/zone_graph_goblin_warrens_test.go index fde79cd..7e8a8fa 100644 --- a/internal/plugin/zone_graph_goblin_warrens_test.go +++ b/internal/plugin/zone_graph_goblin_warrens_test.go @@ -13,8 +13,10 @@ func TestGoblinWarrensGraph_Registered(t *testing.T) { if g.Boss != "goblin_warrens.boss" { t.Errorf("boss node = %q, want goblin_warrens.boss", g.Boss) } - if len(g.Nodes) != 7 { - t.Errorf("nodes = %d, want 7", len(g.Nodes)) + // Long-expedition D1 widened this zone from 7 → 16 nodes so both + // branches land in the T1 [12,14] traversal band. + if len(g.Nodes) != 16 { + t.Errorf("nodes = %d, want 16", len(g.Nodes)) } } @@ -30,7 +32,7 @@ func TestGoblinWarrensGraph_BothPathsReachBoss(t *testing.T) { func TestGoblinWarrensGraph_ForkLayout(t *testing.T) { g := zoneGoblinWarrensGraph() - outs := g.outgoingEdges("goblin_warrens.fork") + outs := g.outgoingEdges("goblin_warrens.cavern_junction") if len(outs) != 2 { t.Fatalf("fork outgoing edges = %d, want 2", len(outs)) } diff --git a/internal/plugin/zone_graph_manor_blackspire.go b/internal/plugin/zone_graph_manor_blackspire.go index 2087a82..e711d72 100644 --- a/internal/plugin/zone_graph_manor_blackspire.go +++ b/internal/plugin/zone_graph_manor_blackspire.go @@ -1,68 +1,172 @@ package plugin -// Phase G8d — Haunted Manor of Blackspire branching graph. +// Haunted Manor of Blackspire branching graph. // -// T3 zone. Shape: stacked 3-way "hub" forks. The plan calls T2+ for -// "two forks", and a Manor of corridors and locked doors fits a wide- -// fork hub-and-spoke pattern more naturally than a binary fork. Two -// hub nodes (great_hall, upper_hall), each with three options: +// Long-expedition plan D1-c: extended from the original 11-node sketch to +// the new T3 length band (22–26 rooms traversed). Topology preserves the +// G8d design intent — two stacked 3-way forks ("first zone where the +// player picks twice in a row from a wide menu") with full lock-kind +// coverage (LockNone, LockPerception, LockStatCheck, LockLevelMin) — and +// adds the missing Trap anchor + extends every branch to 3 mid-nodes so +// the longest entry→boss walk lands at 23. // -// entry → foyer → great_hall (3-way) -// ├─[unlocked]── portrait_gallery ─┐ -// ├─[Perception DC 14]── study ────┼── upper_hall (3-way) -// └─[INT DC 14]── library ─────────┘ ├─[unlocked]── master_bedroom (elite) → boss -// ├─[Perception DC 15]── hidden_oratory (secret) → boss -// └─[LevelMin 7]── tower_observatory → boss +// entry → grounds_walk → carriage_path → manor_gate → foyer → +// drawing_room → gallery_corridor → cursed_threshold (TRAP) → +// grand_staircase → great_hall (3-way fork) +// ├─[unlocked]── portrait_gallery → cold_solarium → west_landing ─┐ +// ├─[Perception DC 14]── locked_study → study_archive → │ +// │ secretary_hall ─────────────────────── ├── second_floor_landing +// └─[INT DC 14]── forbidden_library → library_loft → │ +// reading_rotunda ────────────────────────────── ┘ +// │ +// → portrait_landing → upper_hall (3-way fork) │ +// ├─[unlocked]── master_bedroom (ELITE) → grim_balcony → │ +// │ moonlit_passage ─────────────────────────────── ┐ +// ├─[Perception DC 15]── hidden_oratory (SECRET) → choir_balcony │ +// │ → reliquary_dust ───────────────────── ├── spire_corridor +// └─[LevelMin 7]── tower_observatory → spiral_ascent → │ +// telescope_attic ───────────────────────────── ┘ +// → ancestral_gallery → sanctum_threshold → boss // -// Two consecutive 3-way fork prompts make this the first zone where -// the player picks twice in a row from a wide menu — distinct from the -// binary forks of G8a/b and the parallel Y-trees of G8c. Exercises -// LockLevelMin (first authored use) so all four non-trivial lock kinds -// (Perception, StatCheck, LevelMin, plus implicit LockNone) appear by -// G8d. +// All six fork branches are 3 mid-nodes long so any route through the +// manor lands at the same 23-node traversal length — the player's choice +// is about loot/encounter character, not shortcut economics. The ELITE +// hangs off the open spoke of upper_hall (cheapest gate, hardest fight); +// the SECRET sits behind the highest-DC Perception with its loot bias +// preserved. func zoneManorBlackspireGraph() ZoneGraph { nodes := []ZoneNode{ + // Pre-fork preamble (path positions 1–10). {NodeID: "manor_blackspire.entry", Kind: NodeKindEntry, IsEntry: true, Label: "Manor Gate", PosX: 0, PosY: 2}, + {NodeID: "manor_blackspire.grounds_walk", Kind: NodeKindExploration, + Label: "Overgrown Grounds", PosX: 1, PosY: 2}, + {NodeID: "manor_blackspire.carriage_path", Kind: NodeKindExploration, + Label: "Carriage Path", PosX: 2, PosY: 2}, + {NodeID: "manor_blackspire.manor_gate", Kind: NodeKindExploration, + Label: "Iron Gate", PosX: 3, PosY: 2}, {NodeID: "manor_blackspire.foyer", Kind: NodeKindExploration, - Label: "Foyer", PosX: 1, PosY: 2}, + Label: "Foyer", PosX: 4, PosY: 2}, + {NodeID: "manor_blackspire.drawing_room", Kind: NodeKindExploration, + Label: "Drawing Room", PosX: 5, PosY: 2}, + {NodeID: "manor_blackspire.gallery_corridor", Kind: NodeKindExploration, + Label: "Gallery Corridor", PosX: 6, PosY: 2}, + {NodeID: "manor_blackspire.cursed_threshold", Kind: NodeKindTrap, + Label: "Cursed Threshold", PosX: 7, PosY: 2}, + {NodeID: "manor_blackspire.grand_staircase", Kind: NodeKindExploration, + Label: "Grand Staircase", PosX: 8, PosY: 2}, {NodeID: "manor_blackspire.great_hall", Kind: NodeKindFork, - Label: "Great Hall", PosX: 2, PosY: 2}, + Label: "Great Hall", PosX: 9, PosY: 2}, + + // great_hall — open spoke (portrait gallery). {NodeID: "manor_blackspire.portrait_gallery", Kind: NodeKindExploration, - Label: "Portrait Gallery", PosX: 3, PosY: 1}, - {NodeID: "manor_blackspire.study", Kind: NodeKindExploration, - Label: "Locked Study", PosX: 3, PosY: 2}, - {NodeID: "manor_blackspire.library", Kind: NodeKindExploration, - Label: "Forbidden Library", PosX: 3, PosY: 3}, + Label: "Portrait Gallery", PosX: 10, PosY: 0}, + {NodeID: "manor_blackspire.cold_solarium", Kind: NodeKindExploration, + Label: "Cold Solarium", PosX: 11, PosY: 0}, + {NodeID: "manor_blackspire.west_landing", Kind: NodeKindExploration, + Label: "West Landing", PosX: 12, PosY: 0}, + + // great_hall — perception spoke (locked study). + {NodeID: "manor_blackspire.locked_study", Kind: NodeKindExploration, + Label: "Locked Study", PosX: 10, PosY: 2}, + {NodeID: "manor_blackspire.study_archive", Kind: NodeKindExploration, + Label: "Study Archive", PosX: 11, PosY: 2}, + {NodeID: "manor_blackspire.secretary_hall", Kind: NodeKindExploration, + Label: "Secretary's Hall", PosX: 12, PosY: 2}, + + // great_hall — INT spoke (forbidden library). + {NodeID: "manor_blackspire.forbidden_library", Kind: NodeKindExploration, + Label: "Forbidden Library", PosX: 10, PosY: 4}, + {NodeID: "manor_blackspire.library_loft", Kind: NodeKindExploration, + Label: "Library Loft", PosX: 11, PosY: 4}, + {NodeID: "manor_blackspire.reading_rotunda", Kind: NodeKindExploration, + Label: "Reading Rotunda", PosX: 12, PosY: 4}, + + // Merge + bridge to upper_hall. + {NodeID: "manor_blackspire.second_floor_landing", Kind: NodeKindMerge, + Label: "Second-Floor Landing", PosX: 13, PosY: 2}, + {NodeID: "manor_blackspire.portrait_landing", Kind: NodeKindExploration, + Label: "Portrait Landing", PosX: 14, PosY: 2}, {NodeID: "manor_blackspire.upper_hall", Kind: NodeKindFork, - Label: "Upper Hall", PosX: 4, PosY: 2}, + Label: "Upper Hall", PosX: 15, PosY: 2}, + + // upper_hall — open spoke (elite). {NodeID: "manor_blackspire.master_bedroom", Kind: NodeKindElite, - Label: "Master Bedroom", PosX: 5, PosY: 1}, - {NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration, - Label: "Tower Observatory", PosX: 5, PosY: 2}, + Label: "Master Bedroom", PosX: 16, PosY: 0}, + {NodeID: "manor_blackspire.grim_balcony", Kind: NodeKindExploration, + Label: "Grim Balcony", PosX: 17, PosY: 0}, + {NodeID: "manor_blackspire.moonlit_passage", Kind: NodeKindExploration, + Label: "Moonlit Passage", PosX: 18, PosY: 0}, + + // upper_hall — perception spoke (secret). {NodeID: "manor_blackspire.hidden_oratory", Kind: NodeKindSecret, - Label: "Hidden Oratory", PosX: 5, PosY: 3, + Label: "Hidden Oratory", PosX: 16, PosY: 2, Content: ZoneNodeContent{LootBias: 2.0}}, + {NodeID: "manor_blackspire.choir_balcony", Kind: NodeKindExploration, + Label: "Choir Balcony", PosX: 17, PosY: 2}, + {NodeID: "manor_blackspire.reliquary_dust", Kind: NodeKindExploration, + Label: "Reliquary Dust", PosX: 18, PosY: 2}, + + // upper_hall — level-min spoke (tower). + {NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration, + Label: "Tower Observatory", PosX: 16, PosY: 4}, + {NodeID: "manor_blackspire.spiral_ascent", Kind: NodeKindExploration, + Label: "Spiral Ascent", PosX: 17, PosY: 4}, + {NodeID: "manor_blackspire.telescope_attic", Kind: NodeKindExploration, + Label: "Telescope Attic", PosX: 18, PosY: 4}, + + // Merge + boss approach. + {NodeID: "manor_blackspire.spire_corridor", Kind: NodeKindMerge, + Label: "Spire Corridor", PosX: 19, PosY: 2}, + {NodeID: "manor_blackspire.ancestral_gallery", Kind: NodeKindExploration, + Label: "Ancestral Gallery", PosX: 20, PosY: 2}, + {NodeID: "manor_blackspire.sanctum_threshold", Kind: NodeKindExploration, + Label: "Sanctum Threshold", PosX: 21, PosY: 2}, {NodeID: "manor_blackspire.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Aldric's Sanctum", PosX: 6, PosY: 2}, + Label: "Aldric's Sanctum", PosX: 22, PosY: 2}, } edges := []ZoneEdge{ - {From: "manor_blackspire.entry", To: "manor_blackspire.foyer", Lock: LockNone}, - {From: "manor_blackspire.foyer", To: "manor_blackspire.great_hall", Lock: LockNone}, - // Great Hall — 3-way: portrait (open), study (perception), library (intelligence) + // Preamble (linear). + {From: "manor_blackspire.entry", To: "manor_blackspire.grounds_walk", Lock: LockNone}, + {From: "manor_blackspire.grounds_walk", To: "manor_blackspire.carriage_path", Lock: LockNone}, + {From: "manor_blackspire.carriage_path", To: "manor_blackspire.manor_gate", Lock: LockNone}, + {From: "manor_blackspire.manor_gate", To: "manor_blackspire.foyer", Lock: LockNone}, + {From: "manor_blackspire.foyer", To: "manor_blackspire.drawing_room", Lock: LockNone}, + {From: "manor_blackspire.drawing_room", To: "manor_blackspire.gallery_corridor", Lock: LockNone}, + {From: "manor_blackspire.gallery_corridor", To: "manor_blackspire.cursed_threshold", Lock: LockNone}, + {From: "manor_blackspire.cursed_threshold", To: "manor_blackspire.grand_staircase", Lock: LockNone}, + {From: "manor_blackspire.grand_staircase", To: "manor_blackspire.great_hall", Lock: LockNone}, + + // great_hall fork — three spokes (open, perception 14, INT 14). {From: "manor_blackspire.great_hall", To: "manor_blackspire.portrait_gallery", Lock: LockNone, Weight: 1}, - {From: "manor_blackspire.great_hall", To: "manor_blackspire.study", + {From: "manor_blackspire.great_hall", To: "manor_blackspire.locked_study", Lock: LockPerception, LockData: map[string]any{"dc": 14}, Hint: "an out-of-place draft from a doorframe", Weight: 2}, - {From: "manor_blackspire.great_hall", To: "manor_blackspire.library", + {From: "manor_blackspire.great_hall", To: "manor_blackspire.forbidden_library", Lock: LockStatCheck, LockData: map[string]any{"stat": "INT", "dc": 14}, - Hint: "a runed door — you'd need to read it", Weight: 2}, - // All three converge at upper_hall. - {From: "manor_blackspire.portrait_gallery", To: "manor_blackspire.upper_hall", Lock: LockNone}, - {From: "manor_blackspire.study", To: "manor_blackspire.upper_hall", Lock: LockNone}, - {From: "manor_blackspire.library", To: "manor_blackspire.upper_hall", Lock: LockNone}, - // Upper Hall — 3-way: master_bedroom (open elite), hidden_oratory (high perception secret), tower (level-gated) + Hint: "a runed door — you'd need to read it", Weight: 3}, + + // Portrait spoke. + {From: "manor_blackspire.portrait_gallery", To: "manor_blackspire.cold_solarium", Lock: LockNone}, + {From: "manor_blackspire.cold_solarium", To: "manor_blackspire.west_landing", Lock: LockNone}, + {From: "manor_blackspire.west_landing", To: "manor_blackspire.second_floor_landing", Lock: LockNone}, + + // Study spoke. + {From: "manor_blackspire.locked_study", To: "manor_blackspire.study_archive", Lock: LockNone}, + {From: "manor_blackspire.study_archive", To: "manor_blackspire.secretary_hall", Lock: LockNone}, + {From: "manor_blackspire.secretary_hall", To: "manor_blackspire.second_floor_landing", Lock: LockNone}, + + // Library spoke. + {From: "manor_blackspire.forbidden_library", To: "manor_blackspire.library_loft", Lock: LockNone}, + {From: "manor_blackspire.library_loft", To: "manor_blackspire.reading_rotunda", Lock: LockNone}, + {From: "manor_blackspire.reading_rotunda", To: "manor_blackspire.second_floor_landing", Lock: LockNone}, + + // Bridge to upper_hall. + {From: "manor_blackspire.second_floor_landing", To: "manor_blackspire.portrait_landing", Lock: LockNone}, + {From: "manor_blackspire.portrait_landing", To: "manor_blackspire.upper_hall", Lock: LockNone}, + + // upper_hall fork — three spokes (open elite, perception 15 secret, level-min 7). {From: "manor_blackspire.upper_hall", To: "manor_blackspire.master_bedroom", Lock: LockNone, Weight: 1}, {From: "manor_blackspire.upper_hall", To: "manor_blackspire.hidden_oratory", Lock: LockPerception, LockData: map[string]any{"dc": 15}, @@ -70,9 +174,26 @@ func zoneManorBlackspireGraph() ZoneGraph { {From: "manor_blackspire.upper_hall", To: "manor_blackspire.tower_observatory", Lock: LockLevelMin, LockData: map[string]any{"min_level": 7}, Hint: "a spiral stair that creaks ominously — climb only if you trust your footing", Weight: 3}, - {From: "manor_blackspire.master_bedroom", To: "manor_blackspire.boss", Lock: LockNone}, - {From: "manor_blackspire.hidden_oratory", To: "manor_blackspire.boss", Lock: LockNone}, - {From: "manor_blackspire.tower_observatory", To: "manor_blackspire.boss", Lock: LockNone}, + + // Master Bedroom spoke (elite). + {From: "manor_blackspire.master_bedroom", To: "manor_blackspire.grim_balcony", Lock: LockNone}, + {From: "manor_blackspire.grim_balcony", To: "manor_blackspire.moonlit_passage", Lock: LockNone}, + {From: "manor_blackspire.moonlit_passage", To: "manor_blackspire.spire_corridor", Lock: LockNone}, + + // Hidden Oratory spoke (secret). + {From: "manor_blackspire.hidden_oratory", To: "manor_blackspire.choir_balcony", Lock: LockNone}, + {From: "manor_blackspire.choir_balcony", To: "manor_blackspire.reliquary_dust", Lock: LockNone}, + {From: "manor_blackspire.reliquary_dust", To: "manor_blackspire.spire_corridor", Lock: LockNone}, + + // Tower spoke. + {From: "manor_blackspire.tower_observatory", To: "manor_blackspire.spiral_ascent", Lock: LockNone}, + {From: "manor_blackspire.spiral_ascent", To: "manor_blackspire.telescope_attic", Lock: LockNone}, + {From: "manor_blackspire.telescope_attic", To: "manor_blackspire.spire_corridor", Lock: LockNone}, + + // Boss approach. + {From: "manor_blackspire.spire_corridor", To: "manor_blackspire.ancestral_gallery", Lock: LockNone}, + {From: "manor_blackspire.ancestral_gallery", To: "manor_blackspire.sanctum_threshold", Lock: LockNone}, + {From: "manor_blackspire.sanctum_threshold", To: "manor_blackspire.boss", Lock: LockNone}, } return BuildGraph(ZoneManorBlackspire, nodes, edges) } diff --git a/internal/plugin/zone_graph_manor_blackspire_test.go b/internal/plugin/zone_graph_manor_blackspire_test.go index 6c1c686..ba55308 100644 --- a/internal/plugin/zone_graph_manor_blackspire_test.go +++ b/internal/plugin/zone_graph_manor_blackspire_test.go @@ -10,8 +10,10 @@ func TestManorBlackspireGraph_Registered(t *testing.T) { if g.Entry != "manor_blackspire.entry" { t.Errorf("entry node = %q", g.Entry) } - if len(g.Nodes) != 11 { - t.Errorf("nodes = %d, want 11", len(g.Nodes)) + // Long-expedition D1-c widened this zone from 11 → 35 nodes so the + // longest entry→boss walk lands in the T3 [22,26] traversal band. + if len(g.Nodes) != 35 { + t.Errorf("nodes = %d, want 35", len(g.Nodes)) } } @@ -33,12 +35,14 @@ func TestManorBlackspireGraph_TwoStackedThreeWayForks(t *testing.T) { func TestManorBlackspireGraph_AllSpokesReachBoss(t *testing.T) { g := zoneManorBlackspireGraph() for _, leaf := range []string{ - "manor_blackspire.master_bedroom", - "manor_blackspire.tower_observatory", - "manor_blackspire.hidden_oratory", + // great_hall spokes (entry of each 3-node branch). "manor_blackspire.portrait_gallery", - "manor_blackspire.study", - "manor_blackspire.library", + "manor_blackspire.locked_study", + "manor_blackspire.forbidden_library", + // upper_hall spokes. + "manor_blackspire.master_bedroom", + "manor_blackspire.hidden_oratory", + "manor_blackspire.tower_observatory", } { if !reachable(g, leaf, "manor_blackspire.boss") { t.Errorf("%s unreachable to boss", leaf) @@ -46,9 +50,9 @@ func TestManorBlackspireGraph_AllSpokesReachBoss(t *testing.T) { } } -// TestManorBlackspireGraph_LockLevelMinFirstUse verifies this zone is -// the first to author LockLevelMin, completing lock-kind coverage -// (Perception, StatCheck, LevelMin, LockNone) by G8d. +// TestManorBlackspireGraph_LockLevelMinFirstUse verifies the tower spoke +// still carries the LockLevelMin gate so all four lock kinds (None, +// Perception, StatCheck, LevelMin) remain authored within this zone. func TestManorBlackspireGraph_LockLevelMinFirstUse(t *testing.T) { g := zoneManorBlackspireGraph() var levelMinEdge *ZoneEdge @@ -69,3 +73,29 @@ func TestManorBlackspireGraph_LockLevelMinFirstUse(t *testing.T) { t.Error("level-gated edge missing hint") } } + +// TestManorBlackspireGraph_SymmetricBranches locks in the D1-c design +// intent: all six fork spokes are 3 mid-nodes long so any route walks +// the same 23-room length — the choice is loot/encounter, not shortcut. +func TestManorBlackspireGraph_SymmetricBranches(t *testing.T) { + g := zoneManorBlackspireGraph() + checks := []struct { + from, to, via string + want int + }{ + // great_hall → second_floor_landing, hops via each spoke. + {"manor_blackspire.great_hall", "manor_blackspire.second_floor_landing", "manor_blackspire.portrait_gallery", 4}, + {"manor_blackspire.great_hall", "manor_blackspire.second_floor_landing", "manor_blackspire.locked_study", 4}, + {"manor_blackspire.great_hall", "manor_blackspire.second_floor_landing", "manor_blackspire.forbidden_library", 4}, + // upper_hall → spire_corridor, hops via each spoke. + {"manor_blackspire.upper_hall", "manor_blackspire.spire_corridor", "manor_blackspire.master_bedroom", 4}, + {"manor_blackspire.upper_hall", "manor_blackspire.spire_corridor", "manor_blackspire.hidden_oratory", 4}, + {"manor_blackspire.upper_hall", "manor_blackspire.spire_corridor", "manor_blackspire.tower_observatory", 4}, + } + for _, c := range checks { + got := bfsHops(g, c.from, c.to, c.via) + if got != c.want { + t.Errorf("hops %s → %s via %s = %d, want %d", c.from, c.to, c.via, got, c.want) + } + } +} diff --git a/internal/plugin/zone_graph_map.go b/internal/plugin/zone_graph_map.go index 8edbd80..9390f31 100644 --- a/internal/plugin/zone_graph_map.go +++ b/internal/plugin/zone_graph_map.go @@ -172,6 +172,28 @@ func nodeGlyph(n ZoneNode) string { return "·" } +// renderVisitedPath renders a one-line numbered breadcrumb of the +// player's path so they can reference rooms by index (e.g. !revisit 2). +// Numbers are 1-indexed to match every other surface ("Room 2/7", etc.). +func renderVisitedPath(g ZoneGraph, run *DungeonRun) string { + if run == nil || len(run.VisitedNodes) == 0 { + return "" + } + parts := make([]string, 0, len(run.VisitedNodes)) + for i, nodeID := range run.VisitedNodes { + glyph := "·" + if n, ok := g.Nodes[nodeID]; ok { + glyph = nodeGlyph(n) + } + mark := "✓" + if nodeID == run.CurrentNode { + mark = "▶" + } + parts = append(parts, fmt.Sprintf("[%d]%s%s", i+1, glyph, mark)) + } + return strings.Join(parts, " → ") +} + // debugDump (test-only crutch) prints the raw column layout. Currently // unused outside potential debugging — kept off the unused-warning list // by routing fmt through it. diff --git a/internal/plugin/zone_graph_sunken_temple.go b/internal/plugin/zone_graph_sunken_temple.go index f3891e2..8009b59 100644 --- a/internal/plugin/zone_graph_sunken_temple.go +++ b/internal/plugin/zone_graph_sunken_temple.go @@ -1,23 +1,30 @@ package plugin -// Phase G8c — Sunken Temple of Dar'eth branching graph. +// Sunken Temple of Dar'eth branching graph. // -// T2 zone. Shape: sequential forks (no mid-path merge). Four distinct -// paths to the boss; the player commits at fork1 (dry vs. wet) and -// commits again at fork2a/fork2b. Path is unique up to the boss room. +// Long-expedition plan D1-b: extended from the original 10-node sketch to +// the new T2 length band (16–20 rooms traversed). Topology preserves the +// G8c design intent — sequential forks with no mid-path merge — and adds +// the missing Trap anchor + deeper pre-fork and per-leaf chains. The +// four leaf chains still terminate at the single boss room (validator +// requires exactly one boss). // -// entry → flooded_atrium → fork1 -// ├─[unlocked, w=1]── fork2a (dry) -// │ ├─[unlocked]── kuo_toa_pen (elite) → boss -// │ └─[STR DC 13]── glyph_chamber → boss -// └─[Perception DC 13, w=2]── fork2b (wet) -// ├─[unlocked]── aboleth_thralls → boss -// └─[Perception DC 15]── coral_reliquary (secret) → boss +// entry → flooded_atrium → tide_passage → kelp_trap (TRAP) → +// barnacled_steps → drowned_atrium → sunken_nave → fork1 +// ├─[unlocked, w=1]── dry_corridor → silent_columns → +// │ echoing_vault → fork2a +// │ ├─[unlocked]── kuo_toa_pen (ELITE) → +// │ │ trophy_pool → idol_court → boss +// │ └─[STR DC 13]── glyph_chamber → silt_arch → boss +// └─[Perception DC 13, w=2]── submerged_passage → +// coral_lattice → drowned_nave → fork2b +// ├─[unlocked]── aboleth_thralls → +// │ thrall_basin → sodden_hall → boss +// └─[Perception DC 15]── coral_reliquary (SECRET) → boss // -// Distinct from the Crypt diamond and the Forest asymmetric-diamond: -// no two paths share a mid-node, so the !zone map paints two parallel -// "Y" subtrees instead of a converging branch. The four leaves all -// terminate at the boss (validator requires exactly one boss node). +// Distinct from the Crypt diamond and the Forest asymmetric-diamond: no +// two paths share a mid-node, so the !zone map paints two parallel "Y" +// subtrees instead of a converging branch. Longest entry→boss = 16. func zoneSunkenTempleGraph() ZoneGraph { nodes := []ZoneNode{ @@ -25,42 +32,104 @@ func zoneSunkenTempleGraph() ZoneGraph { Label: "Tide-Stained Threshold", PosX: 0, PosY: 2}, {NodeID: "sunken_temple.flooded_atrium", Kind: NodeKindExploration, Label: "Flooded Atrium", PosX: 1, PosY: 2}, + {NodeID: "sunken_temple.tide_passage", Kind: NodeKindExploration, + Label: "Tide Passage", PosX: 2, PosY: 2}, + {NodeID: "sunken_temple.kelp_trap", Kind: NodeKindTrap, + Label: "Kelp-Snare", PosX: 3, PosY: 2}, + {NodeID: "sunken_temple.barnacled_steps", Kind: NodeKindExploration, + Label: "Barnacled Steps", PosX: 4, PosY: 2}, + {NodeID: "sunken_temple.drowned_atrium", Kind: NodeKindExploration, + Label: "Drowned Atrium", PosX: 5, PosY: 2}, + {NodeID: "sunken_temple.sunken_nave", Kind: NodeKindExploration, + Label: "Sunken Nave", PosX: 6, PosY: 2}, {NodeID: "sunken_temple.fork1", Kind: NodeKindFork, - Label: "Split Stair", PosX: 2, PosY: 2}, + Label: "Split Stair", PosX: 7, PosY: 2}, + + // Dry subtree. + {NodeID: "sunken_temple.dry_corridor", Kind: NodeKindExploration, + Label: "Dry Corridor", PosX: 8, PosY: 0}, + {NodeID: "sunken_temple.silent_columns", Kind: NodeKindExploration, + Label: "Silent Columns", PosX: 9, PosY: 0}, + {NodeID: "sunken_temple.echoing_vault", Kind: NodeKindExploration, + Label: "Echoing Vault", PosX: 10, PosY: 0}, {NodeID: "sunken_temple.fork2a", Kind: NodeKindFork, - Label: "Dry Crossing", PosX: 3, PosY: 0}, - {NodeID: "sunken_temple.fork2b", Kind: NodeKindFork, - Label: "Submerged Crossing", PosX: 3, PosY: 4}, + Label: "Dry Crossing", PosX: 11, PosY: 0}, {NodeID: "sunken_temple.kuo_toa_pen", Kind: NodeKindElite, - Label: "Kuo-toa Pen", PosX: 4, PosY: 0}, + Label: "Kuo-toa Pen", PosX: 12, PosY: -1}, + {NodeID: "sunken_temple.trophy_pool", Kind: NodeKindExploration, + Label: "Trophy Pool", PosX: 13, PosY: -1}, + {NodeID: "sunken_temple.idol_court", Kind: NodeKindExploration, + Label: "Idol Court", PosX: 14, PosY: -1}, {NodeID: "sunken_temple.glyph_chamber", Kind: NodeKindExploration, - Label: "Glyph Chamber", PosX: 4, PosY: 1}, + Label: "Glyph Chamber", PosX: 12, PosY: 1}, + {NodeID: "sunken_temple.silt_arch", Kind: NodeKindExploration, + Label: "Silt Arch", PosX: 13, PosY: 1}, + + // Wet subtree. + {NodeID: "sunken_temple.submerged_passage", Kind: NodeKindExploration, + Label: "Submerged Passage", PosX: 8, PosY: 4}, + {NodeID: "sunken_temple.coral_lattice", Kind: NodeKindExploration, + Label: "Coral Lattice", PosX: 9, PosY: 4}, + {NodeID: "sunken_temple.drowned_nave", Kind: NodeKindExploration, + Label: "Drowned Nave", PosX: 10, PosY: 4}, + {NodeID: "sunken_temple.fork2b", Kind: NodeKindFork, + Label: "Submerged Crossing", PosX: 11, PosY: 4}, {NodeID: "sunken_temple.aboleth_thralls", Kind: NodeKindExploration, - Label: "Thrall Pool", PosX: 4, PosY: 3}, + Label: "Thrall Pool", PosX: 12, PosY: 3}, + {NodeID: "sunken_temple.thrall_basin", Kind: NodeKindExploration, + Label: "Thrall Basin", PosX: 13, PosY: 3}, + {NodeID: "sunken_temple.sodden_hall", Kind: NodeKindExploration, + Label: "Sodden Hall", PosX: 14, PosY: 3}, {NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret, - Label: "Coral Reliquary", PosX: 4, PosY: 4, + Label: "Coral Reliquary", PosX: 12, PosY: 5, Content: ZoneNodeContent{LootBias: 1.8}}, + {NodeID: "sunken_temple.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "Aboleth's Pool", PosX: 5, PosY: 2}, + Label: "Aboleth's Pool", PosX: 15, PosY: 2}, } edges := []ZoneEdge{ {From: "sunken_temple.entry", To: "sunken_temple.flooded_atrium", Lock: LockNone}, - {From: "sunken_temple.flooded_atrium", To: "sunken_temple.fork1", Lock: LockNone}, - {From: "sunken_temple.fork1", To: "sunken_temple.fork2a", Lock: LockNone, Weight: 1}, - {From: "sunken_temple.fork1", To: "sunken_temple.fork2b", + {From: "sunken_temple.flooded_atrium", To: "sunken_temple.tide_passage", Lock: LockNone}, + {From: "sunken_temple.tide_passage", To: "sunken_temple.kelp_trap", Lock: LockNone}, + {From: "sunken_temple.kelp_trap", To: "sunken_temple.barnacled_steps", Lock: LockNone}, + {From: "sunken_temple.barnacled_steps", To: "sunken_temple.drowned_atrium", Lock: LockNone}, + {From: "sunken_temple.drowned_atrium", To: "sunken_temple.sunken_nave", Lock: LockNone}, + {From: "sunken_temple.sunken_nave", To: "sunken_temple.fork1", Lock: LockNone}, + + {From: "sunken_temple.fork1", To: "sunken_temple.dry_corridor", Lock: LockNone, Weight: 1}, + {From: "sunken_temple.fork1", To: "sunken_temple.submerged_passage", Lock: LockPerception, LockData: map[string]any{"dc": 13}, Hint: "wet stone glistens down a side passage", Weight: 2}, + + {From: "sunken_temple.dry_corridor", To: "sunken_temple.silent_columns", Lock: LockNone}, + {From: "sunken_temple.silent_columns", To: "sunken_temple.echoing_vault", Lock: LockNone}, + {From: "sunken_temple.echoing_vault", To: "sunken_temple.fork2a", Lock: LockNone}, + {From: "sunken_temple.fork2a", To: "sunken_temple.kuo_toa_pen", Lock: LockNone, Weight: 1}, {From: "sunken_temple.fork2a", To: "sunken_temple.glyph_chamber", Lock: LockStatCheck, LockData: map[string]any{"stat": "STR", "dc": 13}, Hint: "a stone door wedged half-shut by silt", Weight: 2}, + + {From: "sunken_temple.kuo_toa_pen", To: "sunken_temple.trophy_pool", Lock: LockNone}, + {From: "sunken_temple.trophy_pool", To: "sunken_temple.idol_court", Lock: LockNone}, + {From: "sunken_temple.idol_court", To: "sunken_temple.boss", Lock: LockNone}, + + {From: "sunken_temple.glyph_chamber", To: "sunken_temple.silt_arch", Lock: LockNone}, + {From: "sunken_temple.silt_arch", To: "sunken_temple.boss", Lock: LockNone}, + + {From: "sunken_temple.submerged_passage", To: "sunken_temple.coral_lattice", Lock: LockNone}, + {From: "sunken_temple.coral_lattice", To: "sunken_temple.drowned_nave", Lock: LockNone}, + {From: "sunken_temple.drowned_nave", To: "sunken_temple.fork2b", Lock: LockNone}, + {From: "sunken_temple.fork2b", To: "sunken_temple.aboleth_thralls", Lock: LockNone, Weight: 1}, {From: "sunken_temple.fork2b", To: "sunken_temple.coral_reliquary", Lock: LockPerception, LockData: map[string]any{"dc": 15}, Hint: "a coral arch glittering under the surface", Weight: 2}, - {From: "sunken_temple.kuo_toa_pen", To: "sunken_temple.boss", Lock: LockNone}, - {From: "sunken_temple.glyph_chamber", To: "sunken_temple.boss", Lock: LockNone}, - {From: "sunken_temple.aboleth_thralls", To: "sunken_temple.boss", Lock: LockNone}, + + {From: "sunken_temple.aboleth_thralls", To: "sunken_temple.thrall_basin", Lock: LockNone}, + {From: "sunken_temple.thrall_basin", To: "sunken_temple.sodden_hall", Lock: LockNone}, + {From: "sunken_temple.sodden_hall", To: "sunken_temple.boss", Lock: LockNone}, + {From: "sunken_temple.coral_reliquary", To: "sunken_temple.boss", Lock: LockNone}, } return BuildGraph(ZoneSunkenTemple, nodes, edges) diff --git a/internal/plugin/zone_graph_sunken_temple_test.go b/internal/plugin/zone_graph_sunken_temple_test.go index 329ecf4..4360cfd 100644 --- a/internal/plugin/zone_graph_sunken_temple_test.go +++ b/internal/plugin/zone_graph_sunken_temple_test.go @@ -13,8 +13,12 @@ func TestSunkenTempleGraph_Registered(t *testing.T) { if g.Boss != "sunken_temple.boss" { t.Errorf("boss node = %q", g.Boss) } - if len(g.Nodes) != 10 { - t.Errorf("nodes = %d, want 10", len(g.Nodes)) + // Long-expedition D1-b widened this zone from 10 → 26 nodes so the + // longest entry→boss walk lands in the T2 [16,20] traversal band. + // (Sunken Temple is wide by design — no mid-merge means each leaf + // owns its own chain to the boss.) + if len(g.Nodes) != 26 { + t.Errorf("nodes = %d, want 26", len(g.Nodes)) } } diff --git a/internal/plugin/zone_graph_test.go b/internal/plugin/zone_graph_test.go index e56c42e..2b676d5 100644 --- a/internal/plugin/zone_graph_test.go +++ b/internal/plugin/zone_graph_test.go @@ -190,6 +190,40 @@ func TestCompileLegacyZoneGraph_AllRegistered(t *testing.T) { } } +// TestZoneGraphs_NoSoftLockedFork guards the invariant that every fork +// node offers at least one traversable (LockNone) exit. A fork whose +// every edge is skill-locked strands any player who fails all the checks +// — fork rolls are deterministic with no retry. D8-f part 2 found +// feywild fork1 violating this (it stranded ~60% of runs). Catch any +// future author who locks every exit of a fork. +func TestZoneGraphs_NoSoftLockedFork(t *testing.T) { + for _, z := range allZones() { + g, ok := loadZoneGraph(z.ID) + if !ok { + continue + } + for nodeID, node := range g.Nodes { + if node.Kind != NodeKindFork { + continue + } + outs := g.outgoingEdges(nodeID) + if len(outs) == 0 { + continue + } + free := false + for _, e := range outs { + if e.Lock == LockNone || e.Lock == "" { + free = true + break + } + } + if !free { + t.Errorf("zone %q fork %q: every exit is locked — soft-lock (a player failing all checks is stranded; add a LockNone fallback)", z.ID, nodeID) + } + } + } +} + func TestDeriveLegacyNodeID_StableShape(t *testing.T) { got := deriveLegacyNodeID("crypt_valdris", 0) want := "crypt_valdris.r1" diff --git a/internal/plugin/zone_graph_underdark.go b/internal/plugin/zone_graph_underdark.go index 9422198..5dbb2aa 100644 --- a/internal/plugin/zone_graph_underdark.go +++ b/internal/plugin/zone_graph_underdark.go @@ -1,31 +1,66 @@ package plugin -// Phase G8i — The Underdark branching graph (multi-region). +// The Underdark branching graph (multi-region). // -// T4 zone. Shape: 3-way regional fork — fork1 routes the player into -// one of three distinct regions (R1 deep, R2 drow, R3 illithid), each -// with its own internal mid-node, all converging at R4 (deep_throne). +// Long-expedition plan D1-d: extended from the original 10-node sketch +// to the new T4 length band (28–34 rooms traversed). Topology preserves +// the G8i design intent — three regional arms converge at the deep +// throne — and adds the linear depth each arm now needs to feel like a +// region in its own right rather than a single side-room. // -// R1 surface_tunnels: entry → tunnel_descent → fork1 -// ┌─[CON DC 15]── deep_chasm (R1, rich harvest) ──────────┐ -// │ │ -// ├─[unlocked]── drow_patrol (R2) → drow_captain (R2 elite)─┤ -// │ ├── throne_approach (R4) → boss (R4) -// └─[Perception DC 16]── psionic_corridor (R3) → mind_flayer (R3 elite)─┘ +// R1 surface_tunnels preamble (8 nodes): +// entry → tunnel_descent → moss_corridor → fungal_grove → +// collapsed_arch (TRAP) → ledge_walk → shrine_to_lolth → fork1 // -// Each colored arm crosses a region boundary, exercising the G6 -// fireGraphRegionTransition hook end-to-end. The four authored regions -// match dnd_expedition_region.go: -// R1 = underdark_surface_tunnels -// R2 = underdark_drow_outpost -// R3 = underdark_illithid_warren -// R4 = underdark_deep_throne +// Fork1 → three regional arms (each crosses a region boundary except +// the deep_chasm arm, which intentionally stays in R1 — same as G8i): // -// Distinct from prior zones in two ways: -// 1. First (and only) authored zone with non-empty RegionID on every -// node. Region transitions fire on fork1→{drow_patrol|psionic_corridor} -// and on the elite→throne_approach edges. -// 2. Convergent triangle: three colored arms, one merge. +// R2 drow_outpost arm (12 nodes): +// drow_patrol → drow_picket → corridor_of_eyes → spider_warren → +// web_passage → drow_chapel → drow_armory → drow_captain (ELITE) → +// captain_quarters → drow_descent → drow_passage → drow_gate +// +// R3 illithid_warren arm (12 nodes): +// psionic_corridor → whispering_hall → silenced_chamber → +// mind_tank_room → thrall_pens → broodling_chamber → +// mind_flayer (ELITE) → flayer_sanctum → illithid_descent → +// illithid_passage → illithid_gate → illithid_threshold +// +// R1 deep_chasm spur (4 nodes — short arm, the “rich-harvest if you +// can take the CON-15 climb” route): +// deep_chasm (HARVEST) → chasm_floor → chasm_bridge → chasm_ascent +// +// R4 deep_throne tail (10 nodes including boss): +// throne_approach (MERGE) → throne_stairs → throne_corridor → +// throne_antechamber → throne_guard_post → throne_doors → +// throne_gallery → throne_inner_hall → throne_steps → boss +// +// Longest entry→boss walk is via either R2 or R3 arm + R4 tail: +// 8 (preamble) + 12 (arm) + 10 (tail) = 30 nodes, inside [28,34]. +// The deep_chasm spur reaches the boss in 8 + 4 + 10 = 22 nodes — a +// faster route that trades the elite encounter for the harvest spur, +// preserving the original “high-CON cost, no elite, denser loot” +// trade-off. +// +// Per-region totals (single-traverse): R1 surface_tunnels = 8 preamble, +// R1 chasm spur = 4 (only one is on any given walk), R2 = 12, R3 = 12, +// R4 = 10. Boundary transitions remain at fork1 → arm-entry and +// arm-tail → throne_approach; the G6 fireGraphRegionTransition hook +// still fires exactly once per fork choice (or twice if the player +// later !region-travels into a non-walked arm). +// +// Trap anchor (Collapsed Arch) is new — the original G8i graph had no +// Trap node. Placed in the R1 preamble so every walk hits it. +// +// D10 anchor variety (in-place kind swaps, no length change): +// - Silenced Chamber (R3 illithid arm) becomes a TRAP — the second +// trap, on a fork branch so the illithid route reads riskier than +// the drow route. +// - Drow Gate Garrison (R2 arm tail, at the R2→R4 boundary) becomes a +// region-guardian ELITE, giving the drow arm two elites (Captain + +// Garrison) and the multi-region transition a teeth-y interrupt. +// The deep_chasm spur stays anchor-light (harvest, no second trap/elite) +// so the CON-gated climb keeps its "denser loot, less fighting" identity. func zoneUnderdarkGraph() ZoneGraph { r1 := "underdark_surface_tunnels" @@ -34,32 +69,120 @@ func zoneUnderdarkGraph() ZoneGraph { r4 := "underdark_deep_throne" nodes := []ZoneNode{ + // R1 preamble. {NodeID: "underdark.entry", Kind: NodeKindEntry, IsEntry: true, RegionID: r1, Label: "Cave Mouth", PosX: 0, PosY: 2}, {NodeID: "underdark.tunnel_descent", Kind: NodeKindExploration, RegionID: r1, Label: "Tunnel Descent", PosX: 1, PosY: 2}, + {NodeID: "underdark.moss_corridor", Kind: NodeKindExploration, RegionID: r1, + Label: "Glowmoss Corridor", PosX: 2, PosY: 2}, + {NodeID: "underdark.fungal_grove", Kind: NodeKindExploration, RegionID: r1, + Label: "Fungal Grove", PosX: 3, PosY: 2}, + {NodeID: "underdark.collapsed_arch", Kind: NodeKindTrap, RegionID: r1, + Label: "Collapsed Arch", PosX: 4, PosY: 2}, + {NodeID: "underdark.ledge_walk", Kind: NodeKindExploration, RegionID: r1, + Label: "Ledge Walk", PosX: 5, PosY: 2}, + {NodeID: "underdark.shrine_to_lolth", Kind: NodeKindExploration, RegionID: r1, + Label: "Defaced Shrine", PosX: 6, PosY: 2}, {NodeID: "underdark.fork1", Kind: NodeKindFork, RegionID: r1, - Label: "Three-Way Pass", PosX: 2, PosY: 2}, + Label: "Three-Way Pass", PosX: 7, PosY: 2}, + + // R1 deep_chasm spur (HARVEST, stays in surface_tunnels). {NodeID: "underdark.deep_chasm", Kind: NodeKindHarvest, RegionID: r1, - Label: "Deep Chasm", PosX: 3, PosY: 2, + Label: "Deep Chasm", PosX: 8, PosY: 4, Content: ZoneNodeContent{LootBias: 1.8}}, + {NodeID: "underdark.chasm_floor", Kind: NodeKindExploration, RegionID: r1, + Label: "Chasm Floor", PosX: 9, PosY: 4}, + {NodeID: "underdark.chasm_bridge", Kind: NodeKindExploration, RegionID: r1, + Label: "Stone-Web Bridge", PosX: 10, PosY: 4}, + {NodeID: "underdark.chasm_ascent", Kind: NodeKindExploration, RegionID: r1, + Label: "Chasm Ascent", PosX: 11, PosY: 4}, + + // R2 drow_outpost arm. {NodeID: "underdark.drow_patrol", Kind: NodeKindExploration, RegionID: r2, - Label: "Drow Patrol", PosX: 3, PosY: 1}, + Label: "Drow Patrol", PosX: 8, PosY: 0}, + {NodeID: "underdark.drow_picket", Kind: NodeKindExploration, RegionID: r2, + Label: "Sentry Picket", PosX: 9, PosY: 0}, + {NodeID: "underdark.corridor_of_eyes", Kind: NodeKindExploration, RegionID: r2, + Label: "Corridor of Eyes", PosX: 10, PosY: 0}, + {NodeID: "underdark.spider_warren", Kind: NodeKindExploration, RegionID: r2, + Label: "Spider Warren", PosX: 11, PosY: 0}, + {NodeID: "underdark.web_passage", Kind: NodeKindExploration, RegionID: r2, + Label: "Web Passage", PosX: 12, PosY: 0}, + {NodeID: "underdark.drow_chapel", Kind: NodeKindExploration, RegionID: r2, + Label: "Lolth's Chapel", PosX: 13, PosY: 0}, + {NodeID: "underdark.drow_armory", Kind: NodeKindExploration, RegionID: r2, + Label: "Drow Armory", PosX: 14, PosY: 0}, {NodeID: "underdark.drow_captain", Kind: NodeKindElite, RegionID: r2, - Label: "Drow Captain's Camp", PosX: 4, PosY: 1}, + Label: "Drow Captain's Camp", PosX: 15, PosY: 0}, + {NodeID: "underdark.captain_quarters", Kind: NodeKindExploration, RegionID: r2, + Label: "Captain's Quarters", PosX: 16, PosY: 0}, + {NodeID: "underdark.drow_descent", Kind: NodeKindExploration, RegionID: r2, + Label: "Drow Descent", PosX: 17, PosY: 0}, + {NodeID: "underdark.drow_passage", Kind: NodeKindExploration, RegionID: r2, + Label: "Lower Passage", PosX: 18, PosY: 0}, + {NodeID: "underdark.drow_gate", Kind: NodeKindElite, RegionID: r2, + Label: "Drow Gate Garrison", PosX: 19, PosY: 0}, + + // R3 illithid_warren arm. {NodeID: "underdark.psionic_corridor", Kind: NodeKindExploration, RegionID: r3, - Label: "Psionic Corridor", PosX: 3, PosY: 3}, + Label: "Psionic Corridor", PosX: 8, PosY: 2}, + {NodeID: "underdark.whispering_hall", Kind: NodeKindExploration, RegionID: r3, + Label: "Whispering Hall", PosX: 9, PosY: 2}, + {NodeID: "underdark.silenced_chamber", Kind: NodeKindTrap, RegionID: r3, + Label: "Silenced Chamber", PosX: 10, PosY: 2}, + {NodeID: "underdark.mind_tank_room", Kind: NodeKindExploration, RegionID: r3, + Label: "Brine Tanks", PosX: 11, PosY: 2}, + {NodeID: "underdark.thrall_pens", Kind: NodeKindExploration, RegionID: r3, + Label: "Thrall Pens", PosX: 12, PosY: 2}, + {NodeID: "underdark.broodling_chamber", Kind: NodeKindExploration, RegionID: r3, + Label: "Broodling Chamber", PosX: 13, PosY: 2}, {NodeID: "underdark.mind_flayer", Kind: NodeKindElite, RegionID: r3, - Label: "Mind Flayer Elder", PosX: 4, PosY: 3}, + Label: "Mind Flayer Elder", PosX: 14, PosY: 2}, + {NodeID: "underdark.flayer_sanctum", Kind: NodeKindExploration, RegionID: r3, + Label: "Flayer Sanctum", PosX: 15, PosY: 2}, + {NodeID: "underdark.illithid_descent", Kind: NodeKindExploration, RegionID: r3, + Label: "Illithid Descent", PosX: 16, PosY: 2}, + {NodeID: "underdark.illithid_passage", Kind: NodeKindExploration, RegionID: r3, + Label: "Slime Corridor", PosX: 17, PosY: 2}, + {NodeID: "underdark.illithid_gate", Kind: NodeKindExploration, RegionID: r3, + Label: "Illithid Gate", PosX: 18, PosY: 2}, + {NodeID: "underdark.illithid_threshold", Kind: NodeKindExploration, RegionID: r3, + Label: "Threshold of Thought", PosX: 19, PosY: 2}, + + // R4 deep_throne tail. {NodeID: "underdark.throne_approach", Kind: NodeKindMerge, RegionID: r4, - Label: "Throne Approach", PosX: 5, PosY: 2}, + Label: "Throne Approach", PosX: 20, PosY: 2}, + {NodeID: "underdark.throne_stairs", Kind: NodeKindExploration, RegionID: r4, + Label: "Obsidian Stairs", PosX: 21, PosY: 2}, + {NodeID: "underdark.throne_corridor", Kind: NodeKindExploration, RegionID: r4, + Label: "Throne Corridor", PosX: 22, PosY: 2}, + {NodeID: "underdark.throne_antechamber", Kind: NodeKindExploration, RegionID: r4, + Label: "Antechamber", PosX: 23, PosY: 2}, + {NodeID: "underdark.throne_guard_post", Kind: NodeKindExploration, RegionID: r4, + Label: "Guard Post", PosX: 24, PosY: 2}, + {NodeID: "underdark.throne_doors", Kind: NodeKindExploration, RegionID: r4, + Label: "Riven Doors", PosX: 25, PosY: 2}, + {NodeID: "underdark.throne_gallery", Kind: NodeKindExploration, RegionID: r4, + Label: "Throne Gallery", PosX: 26, PosY: 2}, + {NodeID: "underdark.throne_inner_hall", Kind: NodeKindExploration, RegionID: r4, + Label: "Inner Hall", PosX: 27, PosY: 2}, + {NodeID: "underdark.throne_steps", Kind: NodeKindExploration, RegionID: r4, + Label: "Throne Steps", PosX: 28, PosY: 2}, {NodeID: "underdark.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4, - Label: "Deep Throne", PosX: 6, PosY: 2}, + Label: "Deep Throne", PosX: 29, PosY: 2}, } edges := []ZoneEdge{ + // R1 preamble. {From: "underdark.entry", To: "underdark.tunnel_descent", Lock: LockNone}, - {From: "underdark.tunnel_descent", To: "underdark.fork1", Lock: LockNone}, - // Fork1 — three regional arms. + {From: "underdark.tunnel_descent", To: "underdark.moss_corridor", Lock: LockNone}, + {From: "underdark.moss_corridor", To: "underdark.fungal_grove", Lock: LockNone}, + {From: "underdark.fungal_grove", To: "underdark.collapsed_arch", Lock: LockNone}, + {From: "underdark.collapsed_arch", To: "underdark.ledge_walk", Lock: LockNone}, + {From: "underdark.ledge_walk", To: "underdark.shrine_to_lolth", Lock: LockNone}, + {From: "underdark.shrine_to_lolth", To: "underdark.fork1", Lock: LockNone}, + + // Fork1 — three regional arms (unchanged lock/hint identity). {From: "underdark.fork1", To: "underdark.drow_patrol", Lock: LockNone, Weight: 1}, {From: "underdark.fork1", To: "underdark.psionic_corridor", Lock: LockPerception, LockData: map[string]any{"dc": 16}, @@ -67,15 +190,51 @@ func zoneUnderdarkGraph() ZoneGraph { {From: "underdark.fork1", To: "underdark.deep_chasm", Lock: LockStatCheck, LockData: map[string]any{"stat": "CON", "dc": 15}, Hint: "a vertical shaft humming with cold air — the climb will hurt", Weight: 3}, + + // R1 spur. + {From: "underdark.deep_chasm", To: "underdark.chasm_floor", Lock: LockNone}, + {From: "underdark.chasm_floor", To: "underdark.chasm_bridge", Lock: LockNone}, + {From: "underdark.chasm_bridge", To: "underdark.chasm_ascent", Lock: LockNone}, + {From: "underdark.chasm_ascent", To: "underdark.throne_approach", Lock: LockNone}, + // R2 arm. - {From: "underdark.drow_patrol", To: "underdark.drow_captain", Lock: LockNone}, - {From: "underdark.drow_captain", To: "underdark.throne_approach", Lock: LockNone}, + {From: "underdark.drow_patrol", To: "underdark.drow_picket", Lock: LockNone}, + {From: "underdark.drow_picket", To: "underdark.corridor_of_eyes", Lock: LockNone}, + {From: "underdark.corridor_of_eyes", To: "underdark.spider_warren", Lock: LockNone}, + {From: "underdark.spider_warren", To: "underdark.web_passage", Lock: LockNone}, + {From: "underdark.web_passage", To: "underdark.drow_chapel", Lock: LockNone}, + {From: "underdark.drow_chapel", To: "underdark.drow_armory", Lock: LockNone}, + {From: "underdark.drow_armory", To: "underdark.drow_captain", Lock: LockNone}, + {From: "underdark.drow_captain", To: "underdark.captain_quarters", Lock: LockNone}, + {From: "underdark.captain_quarters", To: "underdark.drow_descent", Lock: LockNone}, + {From: "underdark.drow_descent", To: "underdark.drow_passage", Lock: LockNone}, + {From: "underdark.drow_passage", To: "underdark.drow_gate", Lock: LockNone}, + {From: "underdark.drow_gate", To: "underdark.throne_approach", Lock: LockNone}, + // R3 arm. - {From: "underdark.psionic_corridor", To: "underdark.mind_flayer", Lock: LockNone}, - {From: "underdark.mind_flayer", To: "underdark.throne_approach", Lock: LockNone}, - // R1 deep arm — single node back to merge. - {From: "underdark.deep_chasm", To: "underdark.throne_approach", Lock: LockNone}, - {From: "underdark.throne_approach", To: "underdark.boss", Lock: LockNone}, + {From: "underdark.psionic_corridor", To: "underdark.whispering_hall", Lock: LockNone}, + {From: "underdark.whispering_hall", To: "underdark.silenced_chamber", Lock: LockNone}, + {From: "underdark.silenced_chamber", To: "underdark.mind_tank_room", Lock: LockNone}, + {From: "underdark.mind_tank_room", To: "underdark.thrall_pens", Lock: LockNone}, + {From: "underdark.thrall_pens", To: "underdark.broodling_chamber", Lock: LockNone}, + {From: "underdark.broodling_chamber", To: "underdark.mind_flayer", Lock: LockNone}, + {From: "underdark.mind_flayer", To: "underdark.flayer_sanctum", Lock: LockNone}, + {From: "underdark.flayer_sanctum", To: "underdark.illithid_descent", Lock: LockNone}, + {From: "underdark.illithid_descent", To: "underdark.illithid_passage", Lock: LockNone}, + {From: "underdark.illithid_passage", To: "underdark.illithid_gate", Lock: LockNone}, + {From: "underdark.illithid_gate", To: "underdark.illithid_threshold", Lock: LockNone}, + {From: "underdark.illithid_threshold", To: "underdark.throne_approach", Lock: LockNone}, + + // R4 tail. + {From: "underdark.throne_approach", To: "underdark.throne_stairs", Lock: LockNone}, + {From: "underdark.throne_stairs", To: "underdark.throne_corridor", Lock: LockNone}, + {From: "underdark.throne_corridor", To: "underdark.throne_antechamber", Lock: LockNone}, + {From: "underdark.throne_antechamber", To: "underdark.throne_guard_post", Lock: LockNone}, + {From: "underdark.throne_guard_post", To: "underdark.throne_doors", Lock: LockNone}, + {From: "underdark.throne_doors", To: "underdark.throne_gallery", Lock: LockNone}, + {From: "underdark.throne_gallery", To: "underdark.throne_inner_hall", Lock: LockNone}, + {From: "underdark.throne_inner_hall", To: "underdark.throne_steps", Lock: LockNone}, + {From: "underdark.throne_steps", To: "underdark.boss", Lock: LockNone}, } return BuildGraph(ZoneUnderdark, nodes, edges) } diff --git a/internal/plugin/zone_graph_underdark_test.go b/internal/plugin/zone_graph_underdark_test.go index 0ce61b4..a86bc9b 100644 --- a/internal/plugin/zone_graph_underdark_test.go +++ b/internal/plugin/zone_graph_underdark_test.go @@ -7,8 +7,18 @@ func TestUnderdarkGraph_Registered(t *testing.T) { if !ok { t.Fatal("zoneUnderdarkGraph not registered") } - if len(g.Nodes) != 10 { - t.Errorf("nodes = %d, want 10", len(g.Nodes)) + // Long-expedition D1-d widened this zone from 10 → 46 nodes so the + // longest entry→boss walk lands in the T4 [28,34] traversal band. + if len(g.Nodes) != 46 { + t.Errorf("nodes = %d, want 46", len(g.Nodes)) + } +} + +func TestUnderdarkGraph_LongestPathInBand(t *testing.T) { + g := zoneUnderdarkGraph() + got := graphLongestPath(g) + if got < 28 || got > 34 { + t.Errorf("longest path = %d, want in T4 band [28,34]", got) } } @@ -18,10 +28,10 @@ func TestUnderdarkGraph_Registered(t *testing.T) { func TestUnderdarkGraph_AllNodesHaveRegion(t *testing.T) { g := zoneUnderdarkGraph() validRegions := map[string]bool{ - "underdark_surface_tunnels": true, - "underdark_drow_outpost": true, - "underdark_illithid_warren": true, - "underdark_deep_throne": true, + "underdark_surface_tunnels": true, + "underdark_drow_outpost": true, + "underdark_illithid_warren": true, + "underdark_deep_throne": true, } for id, n := range g.Nodes { if n.RegionID == "" { @@ -95,3 +105,41 @@ func TestUnderdarkGraph_AllArmsReachBoss(t *testing.T) { } } } + +// TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing R1 Trap +// (collapsed_arch) and D10 added the illithid-arm Trap (silenced_chamber). +func TestUnderdarkGraph_TrapAnchor(t *testing.T) { + g := zoneUnderdarkGraph() + var trapCount int + for _, n := range g.Nodes { + if n.Kind == NodeKindTrap { + trapCount++ + } + } + if trapCount != 2 { + t.Errorf("trap nodes = %d, want 2 (collapsed_arch + D10 silenced_chamber)", trapCount) + } + if g.Nodes["underdark.silenced_chamber"].Kind != NodeKindTrap { + t.Error("D10: silenced_chamber (illithid arm) should be a Trap") + } +} + +// TestUnderdarkGraph_EliteAnchors verifies the per-arm elites (drow +// Captain, illithid Mind Flayer) plus the D10 region-guardian elite at +// the drow→throne boundary (drow_gate), so the drow arm carries two +// elites while the chasm spur stays anchor-light. +func TestUnderdarkGraph_EliteAnchors(t *testing.T) { + g := zoneUnderdarkGraph() + var eliteCount int + for _, n := range g.Nodes { + if n.Kind == NodeKindElite { + eliteCount++ + } + } + if eliteCount != 3 { + t.Errorf("elite nodes = %d, want 3 (drow_captain + mind_flayer + D10 drow_gate)", eliteCount) + } + if g.Nodes["underdark.drow_gate"].Kind != NodeKindElite { + t.Error("D10: drow_gate (R2→R4 boundary) should be a region-guardian Elite") + } +} diff --git a/internal/plugin/zone_graph_underforge.go b/internal/plugin/zone_graph_underforge.go index 92708e7..0b1dec3 100644 --- a/internal/plugin/zone_graph_underforge.go +++ b/internal/plugin/zone_graph_underforge.go @@ -1,67 +1,155 @@ package plugin -// Phase G8e — The Underforge branching graph. +// The Underforge branching graph. // -// T3 zone. Shape: pre-boss gauntlet — long linear preamble through the -// forge-city, then a single 3-way antechamber fork right before the -// boss. The plan §G8 calls for "two forks for T2+"; this zone bends -// that to a single late fork with three options because the gauntlet -// shape is the design intent (Kharak Dûn is a one-way descent — there -// is no scenic route, only the approach to what was sealed in). The -// triple-option antechamber preserves player agency at the -// commitment moment. +// Long-expedition plan D1-c: extended from the original 10-node sketch +// to the new T3 length band (22–26 rooms traversed). Topology preserves +// the G8e design intent — a long one-way descent into Kharak Dûn with a +// single late 3-way antechamber fork — and adds the linear gauntlet +// length the new band asks for. Trap (Cooling River) and Elite (Magma +// Chamber) both sit on the descent; the 3-way fork is still the only +// decision in the zone, deferred to the room before the boss. // -// entry → sealed_gate → forge_descent → cooling_river → magma_chamber (elite) → antechamber (3-way) -// ├─[unlocked]── direct_assault → boss -// ├─[DEX DC 14]── catwalks → boss -// └─[Perception DC 15]── forge_vault (secret) → boss +// entry → sealed_threshold → wardstone_arch → forge_descent → +// vapor_steps → slag_warrens → cooling_river (TRAP) → bellows_chamber +// → cinder_walk → magma_chamber (ELITE) → emberforge_hall → +// ashfall_corridor → smelting_vault → foreman_landing → +// broken_crucible → great_anvil → resonance_passage → antechamber +// (3-way fork) +// ├─[unlocked]── direct_assault → forge_throat → ember_steps → +// │ sealed_doors → boss +// ├─[DEX DC 14]── catwalks → high_traverse → cinder_balcony → +// │ vault_door → boss +// └─[Perception DC 15]── forge_vault (SECRET) → secret_passage → +// makers_walk → vault_keyhole → boss // -// Distinct from prior zones in two ways: -// 1. Five-node linear preamble (no zone yet has > 2 linear preamble nodes). -// 2. Fork is delayed to the final node before boss; the !zone map -// therefore renders as a long horizontal stem with a 3-leaf fan at -// the right edge. +// The 17-node linear preamble is the zone's identity ("Kharak Dûn is a +// one-way descent — there is no scenic route, only the approach to what +// was sealed in"). The autopilot pitches 3 night-camps over the T3 band, +// breaking the descent into rest beats; the player still only picks the +// final fork. All three antechamber spokes are 4 mid-nodes so route +// choice is loot/encounter character, not shortcut economics. func zoneUnderforgeGraph() ZoneGraph { nodes := []ZoneNode{ + // Linear descent (path positions 1–18). {NodeID: "underforge.entry", Kind: NodeKindEntry, IsEntry: true, Label: "Sealed Threshold", PosX: 0, PosY: 1}, - {NodeID: "underforge.sealed_gate", Kind: NodeKindExploration, - Label: "Sealed Gate", PosX: 1, PosY: 1}, + {NodeID: "underforge.sealed_threshold", Kind: NodeKindExploration, + Label: "Outer Wards", PosX: 1, PosY: 1}, + {NodeID: "underforge.wardstone_arch", Kind: NodeKindExploration, + Label: "Wardstone Arch", PosX: 2, PosY: 1}, {NodeID: "underforge.forge_descent", Kind: NodeKindExploration, - Label: "Forge Descent", PosX: 2, PosY: 1}, + Label: "Forge Descent", PosX: 3, PosY: 1}, + {NodeID: "underforge.vapor_steps", Kind: NodeKindExploration, + Label: "Vapor Steps", PosX: 4, PosY: 1}, + {NodeID: "underforge.slag_warrens", Kind: NodeKindExploration, + Label: "Slag Warrens", PosX: 5, PosY: 1}, {NodeID: "underforge.cooling_river", Kind: NodeKindTrap, - Label: "Cooling River", PosX: 3, PosY: 1}, + Label: "Cooling River", PosX: 6, PosY: 1}, + {NodeID: "underforge.bellows_chamber", Kind: NodeKindExploration, + Label: "Bellows Chamber", PosX: 7, PosY: 1}, + {NodeID: "underforge.cinder_walk", Kind: NodeKindExploration, + Label: "Cinder Walk", PosX: 8, PosY: 1}, {NodeID: "underforge.magma_chamber", Kind: NodeKindElite, - Label: "Magma Chamber", PosX: 4, PosY: 1}, + Label: "Magma Chamber", PosX: 9, PosY: 1}, + {NodeID: "underforge.emberforge_hall", Kind: NodeKindExploration, + Label: "Emberforge Hall", PosX: 10, PosY: 1}, + {NodeID: "underforge.ashfall_corridor", Kind: NodeKindExploration, + Label: "Ashfall Corridor", PosX: 11, PosY: 1}, + {NodeID: "underforge.smelting_vault", Kind: NodeKindExploration, + Label: "Smelting Vault", PosX: 12, PosY: 1}, + {NodeID: "underforge.foreman_landing", Kind: NodeKindExploration, + Label: "Foreman's Landing", PosX: 13, PosY: 1}, + {NodeID: "underforge.broken_crucible", Kind: NodeKindExploration, + Label: "Broken Crucible", PosX: 14, PosY: 1}, + {NodeID: "underforge.great_anvil", Kind: NodeKindExploration, + Label: "Great Anvil", PosX: 15, PosY: 1}, + {NodeID: "underforge.resonance_passage", Kind: NodeKindExploration, + Label: "Resonance Passage", PosX: 16, PosY: 1}, {NodeID: "underforge.antechamber", Kind: NodeKindFork, - Label: "Antechamber of Kharak Dûn", PosX: 5, PosY: 1}, + Label: "Antechamber of Kharak Dûn", PosX: 17, PosY: 1}, + + // Antechamber — open spoke (direct assault). {NodeID: "underforge.direct_assault", Kind: NodeKindExploration, - Label: "Direct Assault", PosX: 6, PosY: 0}, + Label: "Direct Assault", PosX: 18, PosY: 0}, + {NodeID: "underforge.forge_throat", Kind: NodeKindExploration, + Label: "Forge Throat", PosX: 19, PosY: 0}, + {NodeID: "underforge.ember_steps", Kind: NodeKindExploration, + Label: "Ember Steps", PosX: 20, PosY: 0}, + {NodeID: "underforge.sealed_doors", Kind: NodeKindExploration, + Label: "Sealed Doors", PosX: 21, PosY: 0}, + + // Antechamber — DEX spoke (catwalks). {NodeID: "underforge.catwalks", Kind: NodeKindExploration, - Label: "Forge Catwalks", PosX: 6, PosY: 1}, + Label: "Forge Catwalks", PosX: 18, PosY: 1}, + {NodeID: "underforge.high_traverse", Kind: NodeKindExploration, + Label: "High Traverse", PosX: 19, PosY: 1}, + {NodeID: "underforge.cinder_balcony", Kind: NodeKindExploration, + Label: "Cinder Balcony", PosX: 20, PosY: 1}, + {NodeID: "underforge.vault_door", Kind: NodeKindExploration, + Label: "Vault Door", PosX: 21, PosY: 1}, + + // Antechamber — secret spoke (forge vault). {NodeID: "underforge.forge_vault", Kind: NodeKindSecret, - Label: "Forge Vault", PosX: 6, PosY: 2, + Label: "Forge Vault", PosX: 18, PosY: 2, Content: ZoneNodeContent{LootBias: 2.0}}, + {NodeID: "underforge.secret_passage", Kind: NodeKindExploration, + Label: "Secret Passage", PosX: 19, PosY: 2}, + {NodeID: "underforge.makers_walk", Kind: NodeKindExploration, + Label: "Maker's Walk", PosX: 20, PosY: 2}, + {NodeID: "underforge.vault_keyhole", Kind: NodeKindExploration, + Label: "Vault Keyhole", PosX: 21, PosY: 2}, + {NodeID: "underforge.boss", Kind: NodeKindBoss, IsBoss: true, - Label: "The Sealed Hall", PosX: 7, PosY: 1}, + Label: "The Sealed Hall", PosX: 22, PosY: 1}, } edges := []ZoneEdge{ - {From: "underforge.entry", To: "underforge.sealed_gate", Lock: LockNone}, - {From: "underforge.sealed_gate", To: "underforge.forge_descent", Lock: LockNone}, - {From: "underforge.forge_descent", To: "underforge.cooling_river", Lock: LockNone}, - {From: "underforge.cooling_river", To: "underforge.magma_chamber", Lock: LockNone}, - {From: "underforge.magma_chamber", To: "underforge.antechamber", Lock: LockNone}, + // Linear descent. + {From: "underforge.entry", To: "underforge.sealed_threshold", Lock: LockNone}, + {From: "underforge.sealed_threshold", To: "underforge.wardstone_arch", Lock: LockNone}, + {From: "underforge.wardstone_arch", To: "underforge.forge_descent", Lock: LockNone}, + {From: "underforge.forge_descent", To: "underforge.vapor_steps", Lock: LockNone}, + {From: "underforge.vapor_steps", To: "underforge.slag_warrens", Lock: LockNone}, + {From: "underforge.slag_warrens", To: "underforge.cooling_river", Lock: LockNone}, + {From: "underforge.cooling_river", To: "underforge.bellows_chamber", Lock: LockNone}, + {From: "underforge.bellows_chamber", To: "underforge.cinder_walk", Lock: LockNone}, + {From: "underforge.cinder_walk", To: "underforge.magma_chamber", Lock: LockNone}, + {From: "underforge.magma_chamber", To: "underforge.emberforge_hall", Lock: LockNone}, + {From: "underforge.emberforge_hall", To: "underforge.ashfall_corridor", Lock: LockNone}, + {From: "underforge.ashfall_corridor", To: "underforge.smelting_vault", Lock: LockNone}, + {From: "underforge.smelting_vault", To: "underforge.foreman_landing", Lock: LockNone}, + {From: "underforge.foreman_landing", To: "underforge.broken_crucible", Lock: LockNone}, + {From: "underforge.broken_crucible", To: "underforge.great_anvil", Lock: LockNone}, + {From: "underforge.great_anvil", To: "underforge.resonance_passage", Lock: LockNone}, + {From: "underforge.resonance_passage", To: "underforge.antechamber", Lock: LockNone}, + + // Antechamber 3-way fork. {From: "underforge.antechamber", To: "underforge.direct_assault", Lock: LockNone, Weight: 1}, {From: "underforge.antechamber", To: "underforge.catwalks", Lock: LockStatCheck, LockData: map[string]any{"stat": "DEX", "dc": 14}, Hint: "rusted catwalks above the magma — quick footing required", Weight: 2}, {From: "underforge.antechamber", To: "underforge.forge_vault", Lock: LockPerception, LockData: map[string]any{"dc": 15}, - Hint: "a draft from a stone seam, where no draft should be", Weight: 2}, - {From: "underforge.direct_assault", To: "underforge.boss", Lock: LockNone}, - {From: "underforge.catwalks", To: "underforge.boss", Lock: LockNone}, - {From: "underforge.forge_vault", To: "underforge.boss", Lock: LockNone}, + Hint: "a draft from a stone seam, where no draft should be", Weight: 3}, + + // Direct assault spoke. + {From: "underforge.direct_assault", To: "underforge.forge_throat", Lock: LockNone}, + {From: "underforge.forge_throat", To: "underforge.ember_steps", Lock: LockNone}, + {From: "underforge.ember_steps", To: "underforge.sealed_doors", Lock: LockNone}, + {From: "underforge.sealed_doors", To: "underforge.boss", Lock: LockNone}, + + // Catwalks spoke. + {From: "underforge.catwalks", To: "underforge.high_traverse", Lock: LockNone}, + {From: "underforge.high_traverse", To: "underforge.cinder_balcony", Lock: LockNone}, + {From: "underforge.cinder_balcony", To: "underforge.vault_door", Lock: LockNone}, + {From: "underforge.vault_door", To: "underforge.boss", Lock: LockNone}, + + // Forge vault spoke (secret). + {From: "underforge.forge_vault", To: "underforge.secret_passage", Lock: LockNone}, + {From: "underforge.secret_passage", To: "underforge.makers_walk", Lock: LockNone}, + {From: "underforge.makers_walk", To: "underforge.vault_keyhole", Lock: LockNone}, + {From: "underforge.vault_keyhole", To: "underforge.boss", Lock: LockNone}, } return BuildGraph(ZoneUnderforge, nodes, edges) } diff --git a/internal/plugin/zone_graph_underforge_test.go b/internal/plugin/zone_graph_underforge_test.go index b7992f4..ee81178 100644 --- a/internal/plugin/zone_graph_underforge_test.go +++ b/internal/plugin/zone_graph_underforge_test.go @@ -7,23 +7,39 @@ func TestUnderforgeGraph_Registered(t *testing.T) { if !ok { t.Fatal("zoneUnderforgeGraph not registered") } - if len(g.Nodes) != 10 { - t.Errorf("nodes = %d, want 10", len(g.Nodes)) + // Long-expedition D1-c widened this zone from 10 → 31 nodes so the + // longest entry→boss walk lands in the T3 [22,26] traversal band. + if len(g.Nodes) != 31 { + t.Errorf("nodes = %d, want 31", len(g.Nodes)) } } -// TestUnderforgeGraph_LinearPreamble locks in the gauntlet shape: -// the first five nodes after entry must each have exactly one outgoing -// edge (linear chain). If a future edit splits the preamble, this test -// catches it — that change should re-author the shape comment too. +// TestUnderforgeGraph_LinearPreamble locks in the gauntlet identity: +// every node from entry through resonance_passage has exactly one +// outgoing edge — the only decision in the zone is the antechamber +// 3-way at the very end. D1-c extends the chain but preserves the +// "one-way descent" intent ("Kharak Dûn is a one-way descent — there +// is no scenic route"). func TestUnderforgeGraph_LinearPreamble(t *testing.T) { g := zoneUnderforgeGraph() for _, id := range []string{ "underforge.entry", - "underforge.sealed_gate", + "underforge.sealed_threshold", + "underforge.wardstone_arch", "underforge.forge_descent", + "underforge.vapor_steps", + "underforge.slag_warrens", "underforge.cooling_river", + "underforge.bellows_chamber", + "underforge.cinder_walk", "underforge.magma_chamber", + "underforge.emberforge_hall", + "underforge.ashfall_corridor", + "underforge.smelting_vault", + "underforge.foreman_landing", + "underforge.broken_crucible", + "underforge.great_anvil", + "underforge.resonance_passage", } { outs := g.outgoingEdges(id) if len(outs) != 1 { diff --git a/internal/safehttp/safehttp.go b/internal/safehttp/safehttp.go new file mode 100644 index 0000000..0f8ccfe --- /dev/null +++ b/internal/safehttp/safehttp.go @@ -0,0 +1,146 @@ +// Package safehttp provides an http.Client hardened against SSRF and +// memory-DoS via hostile upstreams. Every outbound fetch the bot makes +// against feed-supplied URLs (RSS articles, image hosts) should go through +// one of these clients so a malicious feed can't steer the bot at loopback, +// link-local, RFC1918, or cloud metadata IPs, and can't OOM the process by +// streaming an unbounded body. +package safehttp + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// ErrBlockedHost is returned when a URL resolves to a non-public IP. +var ErrBlockedHost = errors.New("safehttp: blocked non-public host") + +// AllowPrivate, when true, disables the loopback/RFC1918 dial guard. It +// exists for tests that spin up httptest.NewServer on 127.0.0.1 — never +// set this in production. +var AllowPrivate bool + +// safeDialContext refuses connections to non-public IPs. It runs after +// DNS resolution, so a hostile DNS rebinding that returns 127.0.0.1 +// still gets blocked at dial time. +func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := (&net.Resolver{}).LookupIP(ctx, "ip", host) + if err != nil { + return nil, err + } + var allowed net.IP + for _, ip := range ips { + if AllowPrivate || isPublicIP(ip) { + allowed = ip + break + } + } + if allowed == nil { + return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host) + } + d := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second} + return d.DialContext(ctx, network, net.JoinHostPort(allowed.String(), port)) +} + +// isPublicIP reports whether ip is a globally routable unicast address. +// Rejects loopback, link-local, multicast, RFC1918, CGNAT, and the +// AWS/GCP/Azure metadata IPs 169.254.169.254 / fd00:ec2::254 (these +// already fall under link-local but spell it out for clarity). +func isPublicIP(ip net.IP) bool { + if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsMulticast() || ip.IsPrivate() { + return false + } + // 100.64.0.0/10 (CGNAT) is not covered by IsPrivate on older Go. + if v4 := ip.To4(); v4 != nil { + if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 { + return false + } + // 0.0.0.0/8 and friends. + if v4[0] == 0 { + return false + } + } + return true +} + +// ValidateURL returns nil if the URL is http(s) and parseable. It does +// not resolve DNS — the dial step does that — but it does reject bare +// schemes (file://, gopher://, etc.) before we even open a connection. +func ValidateURL(raw string) error { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return err + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("safehttp: unsupported scheme %q", u.Scheme) + } + if u.Host == "" { + return errors.New("safehttp: empty host") + } + return nil +} + +// NewClient returns an http.Client whose transport blocks non-public +// destinations at dial time, caps redirects at 5, and re-validates each +// redirect target's scheme. timeout is the per-request overall budget. +func NewClient(timeout time.Duration) *http.Client { + tr := &http.Transport{ + DialContext: safeDialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 32, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + } + return &http.Client{ + Transport: tr, + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("safehttp: stopped after 5 redirects") + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return fmt.Errorf("safehttp: unsupported redirect scheme %q", req.URL.Scheme) + } + return nil + }, + } +} + +// LimitedBody wraps r in a reader that errors once more than max bytes have +// been read. Use to cap how much of a response body downstream parsers +// (goquery, image.Decode) will ever see — a hostile origin streaming an +// endless body otherwise OOMs the process. +func LimitedBody(r io.Reader, max int64) io.Reader { + return &limitedReader{R: r, N: max} +} + +type limitedReader struct { + R io.Reader + N int64 +} + +func (l *limitedReader) Read(p []byte) (int, error) { + if l.N <= 0 { + return 0, fmt.Errorf("safehttp: response body exceeded cap") + } + if int64(len(p)) > l.N { + p = p[:l.N] + } + n, err := l.R.Read(p) + l.N -= int64(n) + return n, err +} diff --git a/main.go b/main.go index da8a03a..5d78995 100644 --- a/main.go +++ b/main.go @@ -406,8 +406,8 @@ func setupScheduledJobs( } }) - // WOTD post at 08:00 - if strings.ToLower(os.Getenv("DISABLE_WOTD_POST")) != "true" { + // WOTD post at 08:00 (disabled by default; opt in via ENABLE_WOTD_POST=true) + if strings.ToLower(os.Getenv("ENABLE_WOTD_POST")) == "true" { c.AddFunc("0 8 * * *", func() { slog.Info("scheduler: posting WOTD") for _, r := range rooms { @@ -415,7 +415,7 @@ func setupScheduledJobs( } }) } else { - slog.Info("scheduler: WOTD daily post disabled via DISABLE_WOTD_POST") + slog.Info("scheduler: WOTD daily post disabled (set ENABLE_WOTD_POST=true to enable)") } // Game releases Monday 09:00