mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Compare commits
49 Commits
forex-cryp
...
4934383a9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4934383a9a | ||
|
|
a46b773750 | ||
|
|
b80de43db1 | ||
|
|
81dda51907 | ||
|
|
d7ced471a1 | ||
|
|
63ad423b79 | ||
|
|
da94d51857 | ||
|
|
151d6abc01 | ||
|
|
631764bbbd | ||
|
|
ad2a8258ba | ||
|
|
2fdb280477 | ||
|
|
4576c75722 | ||
|
|
3b29d10461 | ||
|
|
29cad7972a | ||
|
|
a2992ea06c | ||
|
|
6c4b14e113 | ||
|
|
9be85ba954 | ||
|
|
26cda148fb | ||
|
|
040cfba514 | ||
|
|
aaec0ba225 | ||
|
|
5a6e395805 | ||
|
|
68ed8e7c60 | ||
|
|
c729433353 | ||
|
|
7115c536ef | ||
|
|
42fb805ee0 | ||
|
|
d99f975074 | ||
|
|
bbc25fe958 | ||
|
|
89edc76829 | ||
|
|
4999368031 | ||
|
|
92b99a0399 | ||
|
|
0f72484653 | ||
|
|
2e6274c1b7 | ||
|
|
9eed921e4b | ||
|
|
2ce56cf76a | ||
|
|
7dbfa0b56f | ||
|
|
b167882e3e | ||
|
|
95e0995c7f | ||
|
|
56f896b941 | ||
|
|
ef8fbe5496 | ||
|
|
bcd4a873a5 | ||
|
|
01d2329993 | ||
|
|
e4518c9c39 | ||
|
|
20b0d027b2 | ||
|
|
28a90292f0 | ||
|
|
5d7c76fb20 | ||
|
|
100a4f1054 | ||
|
|
f6a457ae84 | ||
|
|
513cf32e42 | ||
|
|
978dc5e25f |
@@ -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) {
|
||||
|
||||
280
gogobee_long_expedition_plan.md
Normal file
280
gogobee_long_expedition_plan.md
Normal file
@@ -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 <kind>`, `!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 <type>` 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 <type>` 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 <zone>` 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 <zone> 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 <zone>` now returns the loadout prompt instead of outfitting, so the sim was halting before persisting any expedition; harness now passes the `heavy` preset. Corpus: `sim_results/d7d_corpus.jsonl` (n=100 × 10 classes × 5 zones × L10 = 5000 runs, `heavy` preset, `-cap 300`). Analysis: `sim_results/d7d_analyze.py`. Writeup: `sim_results/d7d_findings.md`. **Key reads:** (i) leaderboard mirrors [[project_j2_sim_artifact]] — martials 78–82%, casters 21–42%, ~40pp cluster gap unchanged by long-expedition mechanics; (ii) bard/cleric trailers **not** relieved by autopilot camp pacing — cleric actually regressed at L10 (21% vs J2b L12 39%) on spell-pool richness, J3-territory; (iii) T3 hits target 4-day duration (median 3), T4 hits 5–6 days (median 5), T5 hits 5 of 7 (only 21 clears); (iv) **D5-d retune: no change.** `phase5BDailyBurnRatePct=50` + per-tier `DailyBurn` stay as-is — heavy-preset cleared runs end with 78–98% SU remaining; supply economy is not a binding constraint and players lose to HP zero, not starvation. New memory entry: [[project_d7d_baseline]].
|
||||
|
||||
**Remaining work (D7-e+):** none in the core D7 scope. Two follow-up phases (D9 room-count bump, D10 T4/T5 anchor variety) were opened by the 2026-05-28 §6 resolution and are sequenced after D8. See §9 below.
|
||||
|
||||
## 8. J3 caster-picker rework (next session)
|
||||
|
||||
**Why split out:** D7-d corpus confirmed the martial/caster gap is class-roster + sim-picker driven, not autopilot/burn/pacing driven. Long-expedition mechanics are settled; the trailer fix lives in the sim picker, not the expedition shell.
|
||||
|
||||
**D8-a (shipped 2026-05-27, this session):** quick data-layer cleanup. Bard L1 prepared list gained `thunderwave`; L2 gained `heat_metal`. Cleric L1 gained `inflict_wounds`. `shatter` overlay Classes broadened to mage/bard/sorcerer (overlay was already unioning via `mergeClassList`, so this is defensive). New `simPickSpiritualWeapon` runs before `simPickSpell` in `simPickCombatAction`: cleric with an L2 slot + spiritual_weapon prepared + `sess.Statuses.BuffSpiritProc == 0` opens the fight with it, picking up the existing `spiritWeaponStrike` per-round bonus-action attack. **Measured impact (L10, n=100/zone, heavy preset):** cleric 21.0 → 23.2% (+2.2pp), bard 34.2 → 32.8% (within noise). T3+ wall unchanged — the data fixes are correct but the picker math is what's binding. Files touched: `internal/plugin/dnd_spells_data.go:192`, `internal/plugin/dnd_spells.go:822,881,884`, `internal/plugin/expedition_sim.go:simPickCombatAction + new simPickSpiritualWeapon`. All green.
|
||||
|
||||
**D8-b (implemented 2026-05-27, **inert** — see D8-prereq below): sim picker upcasting.** `simPickSpell` (`internal/plugin/expedition_sim.go:933`) now enumerates one candidate per available slot ≥ native for every prepared damage spell, scored via `spellExpectedDamage(sp, slotLevel, c.Level)`. Cantrips contribute a single slot-0 candidate. Tie-break: highest slot first, then highest expDmg. When the winning slot exceeds the spell's native level, the picker returns `"<id> --upcast N"` so `parseCombatCast` upcasts in the engine. Build + tests green.
|
||||
|
||||
**Measured impact: nothing.** d8b corpus (`sim_results/d8b_corpus.jsonl`, same matrix as d7d) lands every class within ±1.5pp of the d7d baseline — bard −1.0, cleric −0.4, mage −1.2, sorcerer +1.4, warlock −0.8, druid +0.6, martials ±0.2. Pure noise band. Expected bard +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.
|
||||
@@ -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)
|
||||
|
||||
@@ -220,6 +220,10 @@ 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()
|
||||
// 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 +283,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 +863,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
|
||||
}
|
||||
|
||||
@@ -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"+
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -329,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()
|
||||
@@ -340,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.")
|
||||
}
|
||||
|
||||
|
||||
51
internal/plugin/adventure_pets_dispatch_test.go
Normal file
51
internal/plugin/adventure_pets_dispatch_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -396,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)
|
||||
@@ -405,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,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)
|
||||
|
||||
145
internal/plugin/adventure_scheduler_test.go
Normal file
145
internal/plugin/adventure_scheduler_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -83,9 +83,10 @@ 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},
|
||||
@@ -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.
|
||||
|
||||
84
internal/plugin/bootstrap_caster_hp.go
Normal file
84
internal/plugin/bootstrap_caster_hp.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// bootstrapCasterHPRefresh recomputes hp_max for caster characters after
|
||||
// the J3 D8-d-fix caster HP multiplier (casterHPMult in dnd.go) shipped.
|
||||
// Without this, existing caster rows keep their pre-lift hp_max until
|
||||
// the next computeMaxHP recall (level-up / reset). Mirrors
|
||||
// bootstrapPhase5BHPRefresh — only ever raises hp_max, preserves the
|
||||
// absolute wound (delta added to hp_current, capped at new max). Run
|
||||
// once per startup, idempotent via db.JobCompleted.
|
||||
func bootstrapCasterHPRefresh() {
|
||||
const jobName = "caster_hp_refresh_v1"
|
||||
if db.JobCompleted(jobName, "once") {
|
||||
return
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT user_id, class, con_score, dnd_level, hp_max, hp_current
|
||||
FROM dnd_character WHERE dnd_level > 0`)
|
||||
if err != nil {
|
||||
slog.Error("caster hp refresh: enumerate failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type row struct {
|
||||
userID string
|
||||
class DnDClass
|
||||
conScore int
|
||||
level int
|
||||
hpMax int
|
||||
hpCurrent int
|
||||
}
|
||||
var batch []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
var classStr string
|
||||
if err := rows.Scan(&r.userID, &classStr, &r.conScore, &r.level, &r.hpMax, &r.hpCurrent); err != nil {
|
||||
slog.Warn("caster hp refresh: scan failed", "err", err)
|
||||
continue
|
||||
}
|
||||
r.class = DnDClass(classStr)
|
||||
batch = append(batch, r)
|
||||
}
|
||||
|
||||
refreshed := 0
|
||||
for _, r := range batch {
|
||||
if casterHPMult(r.class) == 1.0 {
|
||||
continue
|
||||
}
|
||||
conMod := abilityModifier(r.conScore)
|
||||
newMax := computeMaxHP(r.class, conMod, r.level)
|
||||
if newMax <= r.hpMax {
|
||||
continue
|
||||
}
|
||||
delta := newMax - r.hpMax
|
||||
newCurrent := r.hpCurrent + delta
|
||||
if newCurrent > newMax {
|
||||
newCurrent = newMax
|
||||
}
|
||||
if newCurrent < 1 {
|
||||
newCurrent = 1
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE dnd_character
|
||||
SET hp_max = ?, hp_current = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ?`,
|
||||
newMax, newCurrent, r.userID); err != nil {
|
||||
slog.Warn("caster hp refresh: update failed", "user", r.userID, "err", err)
|
||||
continue
|
||||
}
|
||||
refreshed++
|
||||
}
|
||||
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
if refreshed > 0 {
|
||||
slog.Info("caster hp refresh: refreshed caster HPMax to D8-d-fix floor", "count", refreshed)
|
||||
}
|
||||
}
|
||||
@@ -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 <id>` first.")
|
||||
return p.replyDM(ctx, "No active zone run. Use `!zone enter <id>` 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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,34 +81,32 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
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.)_")
|
||||
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 +131,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 +151,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 +215,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 +264,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 +285,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 +406,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 <item>` 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 +446,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 +458,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 +471,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,
|
||||
@@ -458,9 +491,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 +564,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 <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
|
||||
return p.replyDM(ctx, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
|
||||
}
|
||||
|
||||
inv := p.loadConsumableInventory(ctx.Sender)
|
||||
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 <item>`. You're carrying: "+strings.Join(names, ", ")+".")
|
||||
return p.replyDM(ctx, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
|
||||
}
|
||||
|
||||
item, ambig := matchConsumable(inv, args)
|
||||
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 +613,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 +628,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 +636,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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -649,6 +650,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
|
||||
|
||||
@@ -236,6 +236,9 @@ 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 "pet_deflect":
|
||||
return pickRand(narrativePetDeflect)
|
||||
|
||||
@@ -326,9 +329,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 +349,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 +529,13 @@ 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 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.",
|
||||
|
||||
24
internal/plugin/combat_narrative_drain_test.go
Normal file
24
internal/plugin/combat_narrative_drain_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -129,6 +121,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 +136,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -425,72 +425,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)
|
||||
|
||||
@@ -116,7 +116,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.
|
||||
@@ -220,22 +219,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 +248,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
|
||||
@@ -322,6 +317,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 +374,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
|
||||
@@ -479,7 +493,6 @@ 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -168,9 +168,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <type>** — establish camp during an expedition.\n\n")
|
||||
b.WriteString("**!camp <type>** — _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.
|
||||
|
||||
@@ -91,18 +91,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 <zone> [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 <zone>` — 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 <zone> Ns Md` — raw pack counts instead of a preset")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -178,6 +183,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 +260,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 +297,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 +333,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 +403,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 +416,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 +454,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,6 +575,7 @@ 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", "")
|
||||
if err := p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
@@ -550,11 +642,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
|
||||
@@ -563,7 +667,7 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
// run graph / harvest tally / supplies / threat — same as before, just
|
||||
// no streamFlow here. compact==true switches the underlying combat
|
||||
// narration into terse mode and auto-resolves elite (not boss) rooms.
|
||||
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact bool) autopilotWalkResult {
|
||||
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
if err != nil {
|
||||
return autopilotWalkResult{initErr: "Couldn't read expedition state: " + err.Error()}
|
||||
@@ -575,6 +679,21 @@ 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: autopilot can't pick for the
|
||||
// player. Re-emit the prompt with rooms=0 so the background DM
|
||||
// suppression keeps quiet and we don't tick the rooms counter on a
|
||||
// no-op walk.
|
||||
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
|
||||
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
return autopilotWalkResult{
|
||||
finalMsg: renderForkPrompt(zone, *pf),
|
||||
rooms: 0,
|
||||
reason: stopFork,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stream []string
|
||||
var finalMsg string
|
||||
rooms := 0
|
||||
@@ -598,7 +717,7 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
||||
}
|
||||
}
|
||||
|
||||
res, aerr := p.advanceOnceWithOpts(ctx, compact)
|
||||
res, aerr := p.advanceOnceWithOpts(ctx, compact, inlineBossCombat)
|
||||
if aerr != nil {
|
||||
return autopilotWalkResult{initErr: aerr.Error()}
|
||||
}
|
||||
@@ -613,10 +732,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 != "" {
|
||||
@@ -637,15 +798,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
|
||||
@@ -754,6 +916,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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,58 +386,40 @@ 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)
|
||||
}
|
||||
@@ -319,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.
|
||||
|
||||
@@ -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,167 @@ 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)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
|
||||
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)
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`,
|
||||
activeActivity, 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("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 +273,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)
|
||||
|
||||
@@ -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",
|
||||
@@ -224,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())
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <zone>` 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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ?`,
|
||||
|
||||
@@ -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)
|
||||
|
||||
116
internal/plugin/dnd_expedition_zone_clear_test.go
Normal file
116
internal/plugin/dnd_expedition_zone_clear_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -819,7 +819,7 @@ func defaultKnownSpells(class DnDClass, level int) []string {
|
||||
case ClassCleric:
|
||||
out := []string{"sacred_flame", "guidance", "mending"}
|
||||
if level >= 1 {
|
||||
out = append(out, "cure_wounds", "healing_word", "bless", "guiding_bolt", "shield_of_faith")
|
||||
out = append(out, "cure_wounds", "healing_word", "bless", "guiding_bolt", "inflict_wounds", "shield_of_faith")
|
||||
}
|
||||
if maxSlot >= 2 {
|
||||
out = append(out, "spiritual_weapon", "lesser_restoration", "aid")
|
||||
@@ -878,10 +878,10 @@ func defaultKnownSpells(class DnDClass, level int) []string {
|
||||
case ClassBard:
|
||||
out := []string{"vicious_mockery", "minor_illusion", "message"}
|
||||
if level >= 1 {
|
||||
out = append(out, "cure_wounds", "healing_word", "heroism", "hideous_laughter", "faerie_fire")
|
||||
out = append(out, "cure_wounds", "healing_word", "heroism", "hideous_laughter", "faerie_fire", "thunderwave")
|
||||
}
|
||||
if maxSlot >= 2 {
|
||||
out = append(out, "hold_person", "shatter", "invisibility", "lesser_restoration")
|
||||
out = append(out, "hold_person", "shatter", "heat_metal", "invisibility", "lesser_restoration")
|
||||
}
|
||||
if maxSlot >= 3 {
|
||||
out = append(out, "hypnotic_pattern", "dispel_magic", "fear")
|
||||
|
||||
@@ -190,7 +190,7 @@ func buildSpellList() []SpellDefinition {
|
||||
Description: "A foe locks rigid mid-step. Anything you hit them with next lands devastatingly well.",
|
||||
Upcast: "+1 target per slot above 2nd"},
|
||||
{ID: "shatter", Name: "Shatter", Level: 2, School: "evocation",
|
||||
Classes: mage, Effect: EffectDamageSave, CastTime: CastAction,
|
||||
Classes: []DnDClass{ClassMage, ClassBard, ClassSorcerer}, Effect: EffectDamageSave, CastTime: CastAction,
|
||||
SaveStat: "CON", DamageDice: "3d8", DamageType: "thunder", AOE: true,
|
||||
Description: "An invisible chord rings, then snaps — everything brittle nearby cracks at once.",
|
||||
Upcast: "louder per slot above 2nd"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <n>.
|
||||
// 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
435
internal/plugin/expedition_autocamp.go
Normal file
435
internal/plugin/expedition_autocamp.go
Normal file
@@ -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
|
||||
}
|
||||
343
internal/plugin/expedition_autocamp_test.go
Normal file
343
internal/plugin/expedition_autocamp_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
130
internal/plugin/expedition_autorun_digest.go
Normal file
130
internal/plugin/expedition_autorun_digest.go
Normal file
@@ -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"
|
||||
}
|
||||
124
internal/plugin/expedition_autorun_digest_test.go
Normal file
124
internal/plugin/expedition_autorun_digest_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <zone>" return the loadout prompt without
|
||||
// outfitting. Force the tier-max "heavy" preset so multi-day runs
|
||||
// have enough supplies to actually exercise [[project-sim-event-anchored-broken]] rollovers.
|
||||
if err := s.P.handleDnDExpeditionCmd(ctx, "start "+string(zoneID)+" heavy"); err != nil {
|
||||
return res, fmt.Errorf("expedition start: %w", err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
@@ -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,6 +910,15 @@ func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kin
|
||||
}
|
||||
}
|
||||
if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
|
||||
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
|
||||
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
|
||||
// simPickSpell skips BuffSelf entries by design, so a cleric
|
||||
// otherwise never spends an L2 slot on it. Force the pick once
|
||||
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
|
||||
// it's not a damage option.
|
||||
if id := simPickSpiritualWeapon(c, uid, sess); id != "" {
|
||||
return "cast", id
|
||||
}
|
||||
if id := simPickSpell(c, uid); id != "" {
|
||||
return "cast", id
|
||||
}
|
||||
@@ -704,8 +942,58 @@ func simMartialFirstClass(class DnDClass) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// simPickSpell returns the spell ID a competent player would cast this
|
||||
// turn, or "" when no usable spell is available (forcing a !attack).
|
||||
// simPickSpiritualWeapon returns a !cast argument for Spiritual Weapon
|
||||
// when a cleric should open the fight with it: the buff is not already
|
||||
// active on the session, the spell is prepared, and some slot ≥ L2 is
|
||||
// available. Returns "" otherwise. The buff path in combat_cmd.go folds
|
||||
// into BuffSpiritProc/Dmg via applyBuffDelta, which the turn engine fires
|
||||
// each round via spiritWeaponStrike — so one cast is worth more than a
|
||||
// single L2 damage spell across a multi-round fight.
|
||||
//
|
||||
// Slot pick: lowest available slot ≥ 2. Upcasting is +1d8 per 2 slots
|
||||
// above 2nd, so spending a precious L5 to add a single d8 to the proc is
|
||||
// not worth burning the bigger slot's damage potential elsewhere; sim
|
||||
// behaves like a competent player and saves the high slot.
|
||||
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession) string {
|
||||
if c == nil || c.Class != ClassCleric || sess == nil {
|
||||
return ""
|
||||
}
|
||||
if sess.Statuses.BuffSpiritProc > 0 {
|
||||
return ""
|
||||
}
|
||||
known, err := listKnownSpells(uid)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
prepared := false
|
||||
for _, k := range known {
|
||||
if k.SpellID == "spiritual_weapon" && k.Prepared {
|
||||
prepared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !prepared {
|
||||
return ""
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
const simMaxSlot = 5
|
||||
for sl := 2; sl <= simMaxSlot; sl++ {
|
||||
pair, ok := slots[sl]
|
||||
if !ok || pair[0]-pair[1] <= 0 {
|
||||
continue
|
||||
}
|
||||
if sl == 2 {
|
||||
return "spiritual_weapon"
|
||||
}
|
||||
return fmt.Sprintf("spiritual_weapon --upcast %d", sl)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// simPickSpell returns the spell argument a competent player would pass
|
||||
// to !cast this turn, or "" when no usable spell is available (forcing a
|
||||
// !attack). The return value is fed straight to handleCombatCastCmd, so
|
||||
// upcast picks come back as `"<spell_id> --upcast N"`.
|
||||
// Selection rules:
|
||||
// - Only damage-effect spells (damage_attack / damage_save / damage_auto).
|
||||
// Control/buff/heal are out (J2c sweep showed control scoring at
|
||||
@@ -713,12 +1001,13 @@ func simMartialFirstClass(class DnDClass) bool {
|
||||
// no headroom worth the complexity). Healing is handled by the
|
||||
// consumable-first branch in simPickCombatAction.
|
||||
// - Reaction-cast spells are excluded (engine rejects them).
|
||||
// - Non-cantrips require an available slot at their native level (no
|
||||
// upcasting — preserves high slots for high-level spells).
|
||||
// - Among feasible candidates, prefer higher slot level; tie-break on
|
||||
// - For each prepared leveled spell, enumerate one candidate per
|
||||
// available slot at level ≥ native (D8-b, aggressive upcasting).
|
||||
// spellExpectedDamage handles the +1-die-per-slot-above-native
|
||||
// scaling. Cantrips contribute one slot-0 candidate.
|
||||
// - Among feasible candidates, prefer higher slot level (preserves
|
||||
// high-slot supremacy and burns the big slots first); tie-break on
|
||||
// expected damage from the dice string.
|
||||
//
|
||||
// Returns the spell ID for handleCombatCastCmd.
|
||||
func simPickSpell(c *DnDCharacter, uid id.UserID) string {
|
||||
known, err := listKnownSpells(uid)
|
||||
if err != nil || len(known) == 0 {
|
||||
@@ -726,9 +1015,10 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
type cand struct {
|
||||
id string
|
||||
level int
|
||||
expDmg float64
|
||||
id string
|
||||
slot int
|
||||
nativeLevel int
|
||||
expDmg float64
|
||||
}
|
||||
var cands []cand
|
||||
for _, k := range known {
|
||||
@@ -757,24 +1047,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 +1132,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 +1160,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 +1175,38 @@ func (s *SimRunner) TickDay(exp *Expedition) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tickEventAnchoredRollover mirrors pitchAutopilotCamp with Night=true
|
||||
// for the sim: burn → optional Standard rest (if supplies cover it) →
|
||||
// drift → stamp last_briefing_at. No DM, no camp row left active (rest
|
||||
// is applied and immediately cleared the way pitchAutopilotCamp does
|
||||
// via applyCampRest + the next walk-tick break). On the no-rest branch
|
||||
// we still want burn/drift so supplies drain — matches a stalled
|
||||
// autopilot which D2-b's safety net force-fires via processNightCamp.
|
||||
func (s *SimRunner) tickEventAnchoredRollover(exp *Expedition, briefAt time.Time) error {
|
||||
burn, err := s.P.nightRolloverBurn(exp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("nightRolloverBurn: %w", err)
|
||||
}
|
||||
// Pitch a Standard rest if affordable, else skip (autopilot would
|
||||
// also bail in low-SU; the burn already landed). Rough is the
|
||||
// fallback so the sim isn't stuck at zero healing on tight budgets.
|
||||
kind := CampTypeStandard
|
||||
if exp.Supplies.Current < campSupplyCost[kind] {
|
||||
kind = CampTypeRough
|
||||
}
|
||||
if exp.Supplies.Current >= campSupplyCost[kind] {
|
||||
exp.Supplies.Current -= campSupplyCost[kind]
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
return fmt.Errorf("updateSupplies: %w", err)
|
||||
}
|
||||
applyCampRest(exp, kind)
|
||||
}
|
||||
drift := s.P.nightRolloverDrift(exp, briefAt)
|
||||
_ = burn
|
||||
_ = drift
|
||||
return nil
|
||||
}
|
||||
|
||||
// simLogEntries returns every dnd_expedition_log row for expID, oldest
|
||||
// first, projected into SimLogEntry.
|
||||
func simLogEntries(expID string) ([]SimLogEntry, error) {
|
||||
|
||||
343
internal/plugin/expedition_sim_test.go
Normal file
343
internal/plugin/expedition_sim_test.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// D7-b: applyAutoCamp drives maybeAutoCamp under the sim's synthetic
|
||||
// clock. When simNow is past the nightCampWindow on an event-anchored
|
||||
// expedition, the scheduler pitches a Night camp — current_day++,
|
||||
// supplies burn, last_briefing_at stamped. The helper then advances
|
||||
// simNow past minAutoCampDwell and breaks the camp so the next walk
|
||||
// proceeds.
|
||||
func TestSimRunner_ApplyAutoCamp_NightCampAdvancesDay(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@sim-applycamp-night:example")
|
||||
campTestCharacter(t, uid, 3)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("startZoneRun: %v", err)
|
||||
}
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
||||
ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 2, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
|
||||
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||
// Park simNow past the night-camp window so decideAutopilotCamp
|
||||
// flags Night=true on a healthy character.
|
||||
simNow := exp.StartDate.Add(nightCampWindow + time.Hour)
|
||||
startDay := exp.CurrentDay
|
||||
startSU := exp.Supplies.Current
|
||||
before := simNow
|
||||
|
||||
night, err := sim.applyAutoCamp(exp, &simNow)
|
||||
if err != nil {
|
||||
t.Fatalf("applyAutoCamp: %v", err)
|
||||
}
|
||||
if !night {
|
||||
t.Fatal("expected Night=true beyond nightCampWindow")
|
||||
}
|
||||
if simNow.Sub(before) < minAutoCampDwell {
|
||||
t.Errorf("simNow advanced %v, want >= %v", simNow.Sub(before), minAutoCampDwell)
|
||||
}
|
||||
|
||||
got, _ := getExpedition(exp.ID)
|
||||
if got == nil {
|
||||
t.Fatal("expedition missing after night camp")
|
||||
}
|
||||
if got.CurrentDay != startDay+1 {
|
||||
t.Errorf("CurrentDay = %d, want %d", got.CurrentDay, startDay+1)
|
||||
}
|
||||
if got.Supplies.Current >= startSU {
|
||||
t.Errorf("Supplies.Current = %v, want < %v (burn should have landed)", got.Supplies.Current, startSU)
|
||||
}
|
||||
if got.Camp != nil && got.Camp.Active {
|
||||
t.Errorf("camp should have been broken after dwell, got %+v", got.Camp)
|
||||
}
|
||||
}
|
||||
|
||||
// HP-low mid-day rest: under nightCampWindow with a hurt character,
|
||||
// maybeAutoCamp pitches a Standard camp (cleared room → standard) but
|
||||
// doesn't advance the day. HP gets restored by applyCampRest; DayTicks
|
||||
// stays untouched.
|
||||
func TestSimRunner_ApplyAutoCamp_HPLowMidDayRest(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@sim-applycamp-hp:example")
|
||||
campTestCharacter(t, uid, 3)
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
c.HPCurrent = 1 // far below autoCampHPPct (55%)
|
||||
_ = SaveDnDCharacter(c)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("startZoneRun: %v", err)
|
||||
}
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
|
||||
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||
// simNow well inside the active-day window — no Night pitch.
|
||||
simNow := exp.StartDate.Add(2 * time.Hour)
|
||||
startDay := exp.CurrentDay
|
||||
startHP := c.HPCurrent
|
||||
|
||||
night, err := sim.applyAutoCamp(exp, &simNow)
|
||||
if err != nil {
|
||||
t.Fatalf("applyAutoCamp: %v", err)
|
||||
}
|
||||
if night {
|
||||
t.Error("expected non-Night pitch within active-day window")
|
||||
}
|
||||
|
||||
got, _ := getExpedition(exp.ID)
|
||||
if got == nil {
|
||||
t.Fatal("expedition missing")
|
||||
}
|
||||
if got.CurrentDay != startDay {
|
||||
t.Errorf("CurrentDay = %d, want %d (no day advance on HP rest)", got.CurrentDay, startDay)
|
||||
}
|
||||
c2, _ := LoadDnDCharacter(uid)
|
||||
if c2 == nil || c2.HPCurrent <= startHP {
|
||||
t.Errorf("HPCurrent = %v, want > %v (rest should heal)", c2.HPCurrent, startHP)
|
||||
}
|
||||
if got.Camp != nil && got.Camp.Active {
|
||||
t.Errorf("camp should have been broken after dwell, got %+v", got.Camp)
|
||||
}
|
||||
}
|
||||
|
||||
// stopBossSafety: pitchBossSafetyCamp force-pitches a camp regardless
|
||||
// of the regular HP threshold or the boss-room block in decideAutopilotCamp.
|
||||
// applyAutoCampBossSafety wraps that and runs the same dwell/break dance.
|
||||
func TestSimRunner_ApplyAutoCampBossSafety_PitchesAndHeals(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@sim-bosssafety:example")
|
||||
campTestCharacter(t, uid, 3)
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
// HP at 80% — above autoCampHPPct so maybeAutoCamp would NOT fire,
|
||||
// but the boss-safety path should still pitch.
|
||||
c.HPCurrent = (c.HPMax * 80) / 100
|
||||
if c.HPCurrent < 1 {
|
||||
c.HPCurrent = 1
|
||||
}
|
||||
_ = SaveDnDCharacter(c)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 3, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("startZoneRun: %v", err)
|
||||
}
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
||||
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
|
||||
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||
simNow := exp.StartDate.Add(2 * time.Hour)
|
||||
startSU := exp.Supplies.Current
|
||||
startHP := c.HPCurrent
|
||||
before := simNow
|
||||
|
||||
if _, err := sim.applyAutoCampBossSafety(exp, &simNow); err != nil {
|
||||
t.Fatalf("applyAutoCampBossSafety: %v", err)
|
||||
}
|
||||
if simNow.Sub(before) < minAutoCampDwell {
|
||||
t.Errorf("simNow advanced %v, want >= %v", simNow.Sub(before), minAutoCampDwell)
|
||||
}
|
||||
|
||||
got, _ := getExpedition(exp.ID)
|
||||
if got == nil {
|
||||
t.Fatal("expedition missing after boss-safety pitch")
|
||||
}
|
||||
if got.Supplies.Current >= startSU {
|
||||
t.Errorf("Supplies.Current = %v, want < %v (camp cost should have debited)", got.Supplies.Current, startSU)
|
||||
}
|
||||
c2, _ := LoadDnDCharacter(uid)
|
||||
if c2 == nil || c2.HPCurrent <= startHP {
|
||||
t.Errorf("HPCurrent = %v, want > %v (rest should heal)", c2.HPCurrent, startHP)
|
||||
}
|
||||
if got.Camp != nil && got.Camp.Active {
|
||||
t.Errorf("camp should have been broken after dwell, got %+v", got.Camp)
|
||||
}
|
||||
}
|
||||
|
||||
// No trigger → no pitch: healthy HP, fresh simNow, no region cleared.
|
||||
// maybeAutoCamp should bail and applyAutoCamp returns (false, nil).
|
||||
func TestSimRunner_ApplyAutoCamp_NoTriggerIsNoop(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@sim-applycamp-noop:example")
|
||||
campTestCharacter(t, uid, 3)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
|
||||
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||
simNow := exp.StartDate.Add(time.Hour)
|
||||
before := simNow
|
||||
|
||||
night, err := sim.applyAutoCamp(exp, &simNow)
|
||||
if err != nil {
|
||||
t.Fatalf("applyAutoCamp: %v", err)
|
||||
}
|
||||
if night {
|
||||
t.Error("expected no Night pitch from no-op call")
|
||||
}
|
||||
if !simNow.Equal(before) {
|
||||
t.Errorf("simNow advanced %v on no-op call", simNow.Sub(before))
|
||||
}
|
||||
}
|
||||
|
||||
// D7-a: SimRunner.TickDay must advance event-anchored expeditions. The
|
||||
// production deliverBriefing branch reads wall-clock time for its safety-
|
||||
// net check, so the sim path short-circuits to processNightCamp + a
|
||||
// Standard rest. Without this fix, CurrentDay never increments under
|
||||
// D2-b and supply-burn baselining is impossible.
|
||||
func TestSimRunner_TickDay_EventAnchoredAdvancesDay(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@sim-tickday-evt:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 2, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
|
||||
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||
for i := 0; i < 3; i++ {
|
||||
fresh, _ := getExpedition(exp.ID)
|
||||
if fresh == nil {
|
||||
t.Fatalf("expedition vanished after %d ticks", i)
|
||||
}
|
||||
if err := sim.TickDay(fresh); err != nil {
|
||||
t.Fatalf("TickDay #%d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, _ := getExpedition(exp.ID)
|
||||
if got == nil {
|
||||
t.Fatal("expedition missing after ticks")
|
||||
}
|
||||
if got.CurrentDay != 4 {
|
||||
t.Errorf("CurrentDay = %d, want 4 after 3 ticks (started at 1)", got.CurrentDay)
|
||||
}
|
||||
if got.Supplies.Current >= 20 {
|
||||
t.Errorf("Supplies.Current = %v, want < 20 (burn should have landed)", got.Supplies.Current)
|
||||
}
|
||||
if got.LastBriefingAt == nil {
|
||||
t.Fatal("LastBriefingAt nil — drift stamp didn't fire")
|
||||
}
|
||||
// Each tick stamps last_briefing_at at the synthetic briefAt
|
||||
// (start_date + N days at 06:00:30). After 3 ticks the stamp should
|
||||
// be at least 2 days past start_date.
|
||||
if got.LastBriefingAt.Sub(exp.StartDate) < 48*time.Hour {
|
||||
t.Errorf("LastBriefingAt %v not advanced enough vs start %v",
|
||||
got.LastBriefingAt, exp.StartDate)
|
||||
}
|
||||
}
|
||||
|
||||
// Low-SU branch: a tight supplies budget shouldn't crash the sim — the
|
||||
// rest is skipped, but burn/drift still fire so the day advances. This
|
||||
// mirrors prod where a stalled autopilot in a low-SU state has its
|
||||
// rollover force-fired by the safety net without a rest.
|
||||
func TestSimRunner_TickDay_EventAnchoredLowSupplies(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@sim-tickday-lowsu:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
// 0.5 SU is below Rough camp cost (1 SU) but above zero, so the
|
||||
// rest branch is skipped while burn still proceeds.
|
||||
ExpeditionSupplies{Current: 0.5, Max: 20, DailyBurn: 0.2, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useEventAnchored(t, exp)
|
||||
// Pin supplies low after start (startExpedition may reset them).
|
||||
exp.Supplies.Current = 0.5
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||
if err := sim.TickDay(exp); err != nil {
|
||||
t.Fatalf("TickDay: %v", err)
|
||||
}
|
||||
got, _ := getExpedition(exp.ID)
|
||||
if got == nil {
|
||||
// Forced extraction from starvation is acceptable — the day still
|
||||
// advanced before extraction, which is what matters for the sim.
|
||||
return
|
||||
}
|
||||
if got.CurrentDay < 2 {
|
||||
t.Errorf("CurrentDay = %d, want >= 2", got.CurrentDay)
|
||||
}
|
||||
}
|
||||
|
||||
// D7-c: captureDaySnapshot must record the current expedition's day,
|
||||
// supplies, and threat plus the character's live HP into res.DaySnapshots.
|
||||
// Used by RunExpedition to trace state at every day rollover for
|
||||
// long-expedition baselining; verified here in isolation since
|
||||
// RunExpedition end-to-end isn't covered by package tests.
|
||||
func TestSimRunner_CaptureDaySnapshot_PopulatesFields(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@sim-snap:example")
|
||||
campTestCharacter(t, uid, 3)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 12, Max: 20, DailyBurn: 2, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp.CurrentDay = 3
|
||||
exp.ThreatLevel = 7
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
|
||||
sim := &SimRunner{P: &AdventurePlugin{}}
|
||||
res := &SimResult{Rooms: 9}
|
||||
sim.captureDaySnapshot(res, exp, uid)
|
||||
|
||||
if len(res.DaySnapshots) != 1 {
|
||||
t.Fatalf("DaySnapshots len = %d, want 1", len(res.DaySnapshots))
|
||||
}
|
||||
snap := res.DaySnapshots[0]
|
||||
if snap.Day != 3 {
|
||||
t.Errorf("Day = %d, want 3", snap.Day)
|
||||
}
|
||||
if snap.Supplies != 12 {
|
||||
t.Errorf("Supplies = %v, want 12", snap.Supplies)
|
||||
}
|
||||
if snap.Threat != 7 {
|
||||
t.Errorf("Threat = %d, want 7", snap.Threat)
|
||||
}
|
||||
if snap.Rooms != 9 {
|
||||
t.Errorf("Rooms = %d, want 9", snap.Rooms)
|
||||
}
|
||||
if snap.HPCurrent != c.HPCurrent || snap.HPMax != c.HPMax {
|
||||
t.Errorf("HP = %d/%d, want %d/%d", snap.HPCurrent, snap.HPMax, c.HPCurrent, c.HPMax)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,88 +1,280 @@
|
||||
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.
|
||||
|
||||
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: NodeKindExploration, 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: NodeKindExploration, 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: NodeKindExploration, 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +1,258 @@
|
||||
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.
|
||||
|
||||
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: NodeKindExploration, 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: NodeKindExploration, 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,281 @@
|
||||
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.
|
||||
// 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: NodeKindExploration,
|
||||
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: NodeKindExploration,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,38 @@ 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.
|
||||
func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) {
|
||||
g := zoneFeywildCrossingGraph()
|
||||
var trapCount int
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == NodeKindTrap {
|
||||
trapCount++
|
||||
}
|
||||
}
|
||||
if trapCount != 1 {
|
||||
t.Errorf("trap nodes = %d, want 1", trapCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
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.
|
||||
|
||||
func zoneUnderdarkGraph() ZoneGraph {
|
||||
r1 := "underdark_surface_tunnels"
|
||||
@@ -34,32 +59,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: NodeKindExploration, RegionID: r2,
|
||||
Label: "Drow Gate", 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: NodeKindExploration, 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 +180,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)
|
||||
}
|
||||
|
||||
@@ -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,18 @@ func TestUnderdarkGraph_AllArmsReachBoss(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing Trap
|
||||
// node. Original G8i graph had elite/boss/harvest but no trap.
|
||||
func TestUnderdarkGraph_TrapAnchor(t *testing.T) {
|
||||
g := zoneUnderdarkGraph()
|
||||
var trapCount int
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == NodeKindTrap {
|
||||
trapCount++
|
||||
}
|
||||
}
|
||||
if trapCount != 1 {
|
||||
t.Errorf("trap nodes = %d, want 1", trapCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user