14 Commits

Author SHA1 Message Date
prosolis
6c4b14e113 Long expeditions D6: player-facing surface cleanup
Rewrite !expedition help around the autopilot loop, frame !camp/!fight
as overrides, and add Day X / ~Y expected + rooms/total + last-3-events
to !expedition status.
2026-05-27 20:01:50 -07:00
prosolis
9be85ba954 Long expeditions D5-c: wire Ranger forage SU
§4.2's "Ranger, 1d4 SU/day" perk had been dead since Phase 12 E1b —
SupplyForageMaxSU was defined but unreferenced, ForagedToday was only
ever reset to false. New applyRangerForage helper grants 1d4 SU once
per day (headroom-capped, Ranger-only), fires at the top of
nightRolloverBurn, and surfaces as a "forage" line in the end-of-day
digest. No DC roll — accessibility over crunch, and the D5-a caps
already give all loadouts comfortable headroom.
2026-05-27 19:48:44 -07:00
prosolis
26cda148fb Long expeditions D5-b: loadout preset prompt at launch
`!expedition start <zone>` (and `!resume`) with no pack arg now DMs a
Lean / Balanced / Heavy menu sized per zone tier. Player replies with
the preset name (short forms l/b/h also work). Raw `Ns Md` syntax stays
as the advanced override.

Heavy always equals supplyPackCaps (the D5-a harsh×3 ceiling); Lean
covers intended days at raw burn; Balanced sits between.
2026-05-27 19:41:49 -07:00
prosolis
040cfba514 Long expeditions D5-a: per-tier supply pack caps
Today's global SupplyPackStandardMax=3 / SupplyPackDeluxeMax=1 were
a 2-day shape; with D1's longer room budgets and D2-b's event-anchored
night burns, a T4/T5 player can't legally buy enough supplies for the
intended duration. supplyPackCaps(tier) now returns (std,dlx) per
tier — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2) —
sized to clear DailyBurn(raw) × intendedDays × 1.3 even with the
harsh×3 multiplier layered on. Validate takes a tier; both call sites
(!expedition start, !resume) pass the resolved zone's tier. Holiday
+1 standard pack still bypasses the cap on purpose. DailyBurn /
phase5BDailyBurnRatePct unchanged; that's a D7 lever once the sim
can measure event-anchored rollovers.
2026-05-27 19:33:57 -07:00
prosolis
aaec0ba225 Long expeditions D4-b: activity-anchored morning briefing
Event-anchored expeditions no longer pin their re-engagement DM to 06:00
UTC. The ticker skips idle players (last_activity older than today's
threshold, inside the 28h safety net); maybeDeliverDeferredBriefing fires
the owed briefing on the next inbound message in any room. The OnMessage
hook also stamps last_activity so chat presence (not just bot commands)
counts toward "the player is here."
2026-05-27 19:21:49 -07:00
prosolis
5a6e395805 Long expeditions D4-a: autopilot DM bundling + EoD digest
Suppress per-tick auto-walk DMs in compact mode. tryAutoRun now only
surfaces for fork / death / run-complete / boss-safety camp / Night-camp
pitch. A Night-camp pitch flushes the day as an EoD digest (counts of
walk/harvest/interrupt plus threat/milestone/narrative bullets, built
from dnd_expedition_log) followed by the camp block. Each successful
background walk writes a `walk` log entry so the digest can count rooms
without persisting raw stream narration. maybeAutoCamp and
pitchBossSafetyCamp now return the autoCampDecision so callers can
branch on dec.Night.
2026-05-27 19:07:37 -07:00
prosolis
68ed8e7c60 Long expeditions D3: compact autopilot auto-resolves boss rooms
Drops the boss carve-out in the compact (background autorun) path so
boss rooms resolve through the same forward-sim engine elites already
use. A `bossSafetyGate` (HP < 80% / supplies < daily burn /
exhaustion >= 3) guards the engage; when it trips, the walk returns
`stopBossSafety` and the autorun ticker force-pitches a rest camp via
`pitchBossSafetyCamp` (bypasses the normal scheduler's HP threshold
and its RoomBoss room-type block; keeps event-anchored night handling).

`resolveCombatRoom` now selects monster + label + loot drop by
`run.CurrentRoomType()` so the same callsite handles boss kills
(zone.Boss bestiary, "Boss — name down", boss-loot drop, elite-tier
threat bump). The walk loop only breaks at elite/boss doorways when
`!compact`; compact lets the next iteration auto-resolve.

Foreground `!fight` and `!expedition run` are unchanged. Sim path is
unaffected — stopBossSafety falls into the default soft-stop branch.
2026-05-27 18:56:52 -07:00
prosolis
c729433353 Long expeditions D2-b: event-anchored day rollover
Splits the legacy briefing body into nightRolloverBurn + nightRolloverDrift,
plus a processNightCamp convenience. For expeditions started after the new
eventAnchoredCutoff, the 06:00 UTC briefing stops mutating: it posts a
re-engagement DM, and only force-fires processNightCamp itself after a 28h
safety-net window. Day++/burn/threat-drift now ride along the autopilot
night-camp pitch (decideAutopilotCamp sets Night=true when ≥16h since the
last rollover) and on the first player !camp since the last rollover. The
legacy UTC-anchored flow still works via the same staged helpers, with
processOvernightCamp interleaved to preserve the rest-before-drift ordering.

Tests pin eventAnchoredCutoff to year 9999 in TestMain so the existing
legacy assertions still run; new tests cover the night decision, the
night-camp pitch advancing the day, the event-anchored skip, and the
safety-net force-rollover.
2026-05-27 18:42:57 -07:00
prosolis
7115c536ef Long expeditions D2-a: autopilot camp scheduler
New expedition_autocamp.go: pure decideAutopilotCamp + pitchAutopilotCamp
+ dwell-window lifecycle. Wired into tryAutoRun so the background ticker
pitches a rest camp on low HP and a base-camp waypoint on region-boss
clear; auto-pitched camps last minAutoCampDwell (4h) before the next tick
breaks them and walks. CampState.AutoPitched separates auto- vs player-
pitched camp lifetimes so a player !camp stays sticky.

Day-rollover semantics unchanged — still UTC-anchored; D2-b moves
day++/burn/threat-drift onto the camp-pitch event.
2026-05-27 18:23:26 -07:00
prosolis
42fb805ee0 Long expeditions D1-e: T5 zones to new 36-44 room band
Closes D1. Both T5 zones follow the D1-a/b/c/d pattern: extend the
graph so the longest entry→boss walk lands in band, keep the zone's
authored topology intact. Also backfills the missing per-node
RegionID authoring against dnd_expedition_region.go — the D1-d
deferral note flagged this as a prerequisite for T5.

  dragons_lair (T5): 12 → 47 nodes, longest 38 (band 36-44)
  abyss_portal (T5): 13 → 51 nodes, longest 39 (band 36-44)

Topology preserved per zone:
- Dragon's Lair: 4-region authoring (every node carries a valid
  RegionID); binary-converging fork1 (ash_bridge TRAP / treasure_vault
  Perception 15) still converges at the R3 wyrmlings_nest elite;
  capstone fork2 still 3-way (LockNone / CHA 16 / Perception 17
  SECRET LootBias 2.5) with each spur preserving its own named
  capstone node. New R4 infernax_doors MERGE consolidates the long
  final hall instead of triplicating it.
- Abyss Portal: 4-region authoring; three sequential forks preserved
  (binary / binary / ternary) — fork1 Perception 16, fork2 CON 16
  (full STR/DEX/CON/INT/WIS/CHA roster still covered), fork3 capstone
  3-way with reality_seam SECRET LootBias 3.0. New R4 belaxath_doors
  MERGE consolidates the final tear-approach.

MinRooms/MaxRooms re-pitched 9-10 → 36-44 so the dice fallback also
lands in band for any future graphless variant.
2026-05-27 18:13:33 -07:00
prosolis
d99f975074 Long expeditions D1-d: T4 zones to new 28-34 room band
Both T4 zones follow the D1-a/b/c pattern: extend the graph so the
longest entry→boss walk lands in band, keep the zone's authored
topology intact.

  underdark         (T4): 10 → 46 nodes, longest 30 (band 28-34)
  feywild_crossing  (T4):  9 → 53 nodes, longest 30 (band 28-34)

Topology preserved per zone:
- Underdark: 4-region authoring intact (every node carries a valid
  RegionID); 3-way fork1 (drow R2 / illithid R3 / deep_chasm R1)
  with LockNone / LockPerception / LockStatCheck CON; deep_chasm
  intentionally stays R1; all three arms converge at the R4
  throne_approach merge. Adds the missing Trap anchor (Collapsed
  Arch) in the R1 preamble.
- Feywild Crossing: woven forks intact — hag_circle still has two
  incoming edges (one per first-stage path), time_eddy stays
  grove-exclusive, illusion_garden stays marsh-exclusive, fork1
  remains CHA-vs-Perception with no LockNone option. Adds the
  missing Trap anchor (Cursed Thicket) and a fae_court MERGE so
  the three second-stage endings converge into one final boss
  approach instead of triplicating it.

MinRooms/MaxRooms re-pitched 8-10 → 28-34 so the dice fallback also
lands in band for any future graphless variant.

T5 zones (dragons_lair, abyss_portal) deferred to D1-e — those
need a RegionID backfill on top of the length extension since their
regionsByZone entries are not currently honored by their graphs.
2026-05-27 18:02:05 -07:00
prosolis
bbc25fe958 Long expeditions D1-c: T3 zones to new 22-26 room band
Both T3 zones follow the D1-a/b pattern: extend the graph so the longest
entry→boss walk lands in band, keep the zone's authored topology intact.

  manor_blackspire (T3): 11 → 35 nodes, longest 23 (band 22-26)
  underforge       (T3): 10 → 31 nodes, longest 23 (band 22-26)

Topology preserved per zone:
- Manor: two-stacked-3-way forks identity + full lock-kind coverage
  (LockNone, LockPerception, LockStatCheck, LockLevelMin). Adds the
  missing Trap anchor (Cursed Threshold). All six spokes are 3 mid-nodes
  so route choice is loot/encounter character, not shortcut economics.
- Underforge: one-way-descent gauntlet identity preserved — 17-node
  linear preamble through the existing Cooling River (TRAP) and Magma
  Chamber (ELITE), single 3-way antechamber fork before boss. All three
  antechamber spokes are 4 mid-nodes.

MinRooms/MaxRooms re-pitched 7-9 → 22-26 so the dice fallback also lands
in band for any future graphless variant.
2026-05-27 17:46:38 -07:00
prosolis
89edc76829 Long expeditions D1-b: crypt_valdris + T2 zones to new room bands
Three more zones follow the goblin_warrens pattern from D1-a: add the
missing Trap anchor and deepen the graph so the longest entry→boss walk
lands in its tier's target band.

  crypt_valdris  (T1):  8 → 17 nodes, longest 13 (band 12–14)
  forest_shadows (T2):  9 → 19 nodes, longest 16 (band 16–20)
  sunken_temple  (T2): 10 → 26 nodes, longest 16 (band 16–20)

Topology preserved per zone:
- Crypt diamond + Perception-DC-15 secret off side_chapel.
- Forest asymmetric main fork (long branch carries the elite) +
  WIS-DC-14 secret pre-boss.
- Sunken Temple sequential forks with no mid-path merge (four leaves
  all terminate at the single boss room).

MinRooms/MaxRooms re-pitched to the §2 bands so the dice fallback also
lands in band for any future graphless zone.
2026-05-27 17:32:29 -07:00
prosolis
4999368031 Long expeditions D1-a: graph-driven TotalRooms; goblin_warrens 7→16
Length is now sourced from the zone graph's longest entry→boss path,
not the dice roll, so "Room X/Y" matches what the player walks. The
dice fallback stays for graphless zones.

Goblin Warrens grows to the T1 12–14 band (13-node traversal): adds the
missing Trap anchor and deepens both fork branches + the post-merge
approach. Pattern reference for the remaining zones in D1-b…d.
2026-05-27 17:24:36 -07:00
46 changed files with 4372 additions and 648 deletions

View File

@@ -0,0 +1,175 @@
# 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.
## 1. Why the current shape is "too short"
| Tier | Zones | Rooms | Real-time exit |
|------|-------|------|----------------|
| T1 | Goblin Warrens, Crypt Valdris | 67 | usually <1 calendar day |
| T2 | Forest Shadows, Sunken Temple | 78 | usually 1 day |
| T3 | Manor Blackspire, Underforge | 79 | 12 days |
| T4 | Underdark, Feywild Crossing | 810 (×regions) | 23 days |
| T5 | Dragon's Lair, Abyss Portal | 910 (×regions) | 24 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 T1T3 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 | 1214 | one autopilot-camp |
| T2 | 3 days | 1620 | two camps |
| T3 | 4 days | 2226 | three camps; threat starts to bite |
| T4 | 56 days | 2834 (split across 34 regions) | base camp emerges |
| T5 | 7 days | 3644 (split across 34 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 T4T5 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
- `cmd/expedition-sim` extensions: multi-day mode, autopilot camp, autopilot boss.
- Re-run the class win-rate corpus (cross-link `project_class_balance`, `project_j2_sim_artifact`).
- Re-check trailers (bard/cleric per `project_j1_post_sweep_findings`) — autopilot camp pacing may relieve their HP-cliff issue.
- New balance memory entry once corpus stabilizes.
## 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 57 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**: always-on, opt-out, or opt-in? My lean: always-on with a per-expedition opt-out flag; revisit after D3 reads.
3. **Per-tier room counts**: the table in §2 is a starting point. Worth a sim pass before D1 commits — or fine to ship a guess and refine in D7?
4. **Extra anchor rooms**: T4/T5 with one elite + one trap may feel thin spread over 30+ rooms. Add a second elite mid-zone, or rely on multi-region + patrols + threat for variety?
5. **Mobile/AFK friendliness**: should the autopilot-DM cadence aim for "1 DM per real-time evening" regardless of room count, or "1 DM per in-game day" (which could be hours apart if the player set the pacing fast)?
## 7. Sequence
D1 → D2 → D3 (independent of D4/D5) → D5 → D4 → D6 → D7.
D2 and D3 can land in either order; D4 needs the volume problem to be real (so after D2/D3 at minimum). D5 wants D1's new lengths but can run in parallel with D2/D3. D7 always last.
Estimated sessions: 57. D2 and D5 are the biggest single-session chunks.

View File

@@ -221,10 +221,9 @@ func TestAdv2Scenario_ExpeditionCryptValdris(t *testing.T) {
t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter) t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter)
} }
// Backdate start so deliverBriefing's same-day guard passes. // Backdate start so deliverBriefing's same-day guard passes, and to
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil { // before eventAnchoredCutoff so the legacy mutator path still fires.
t.Fatalf("backdate: %v", err) rewindToLegacyAnchor(t, exp)
}
// Drive 3 daily briefings — verify supply burn + day advance. // Drive 3 daily briefings — verify supply burn + day advance.
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC) 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} p := &AdventurePlugin{euro: euro}
// Forage-friendly expedition. // 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) t.Fatalf("expedition start: %v", err)
} }
exp, _ := getActiveExpedition(uid) exp, _ := getActiveExpedition(uid)

View File

@@ -279,6 +279,12 @@ func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }
// ── Message Dispatch ───────────────────────────────────────────────────────── // ── Message Dispatch ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { 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) // 0. D&D layer commands (Phase 1 — work in rooms and DMs)
if p.IsCommand(ctx.Body, "setup") { if p.IsCommand(ctx.Body, "setup") {
return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup")) return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup"))

View File

@@ -168,9 +168,7 @@ func TestDeliverBriefing_StarvationForcesExtraction(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil { rewindToLegacyAnchor(t, exp)
t.Fatalf("backdate: %v", err)
}
p := &AdventurePlugin{} p := &AdventurePlugin{}
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)

View File

@@ -57,6 +57,11 @@ type CampState struct {
// threat -5 etc.) have already been applied at pitch time. processOvernightCamp // 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. // uses it to skip re-applying so the night cycle just breaks the camp.
RestApplied bool `json:"rest_applied,omitempty"` 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. // ThreatEvent — §8.4.
@@ -437,6 +442,34 @@ func appendExpeditionLog(expID string, day int, entryType, summary, flavor strin
return err 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. // recentExpeditionLog returns the last `limit` entries, newest first.
func recentExpeditionLog(expID string, limit int) ([]ExpeditionEntry, error) { func recentExpeditionLog(expID string, limit int) ([]ExpeditionEntry, error) {
if limit <= 0 { if limit <= 0 {

View File

@@ -117,7 +117,7 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
func campHelpText(exp *Expedition) string { func campHelpText(exp *Expedition) string {
var b strings.Builder 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 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 standard` — full long rest (cleared room, +1 SU, medium risk)\n")
b.WriteString("`!camp fortified` — long rest + bonus (zone boss defeated, +2 SU, low risk)\n") b.WriteString("`!camp fortified` — long rest + bonus (zone boss defeated, +2 SU, low risk)\n")
@@ -160,6 +160,30 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
cost, exp.Supplies.Current)) 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 exp.Supplies.Current -= cost
if err := updateSupplies(exp.ID, exp.Supplies); err != nil { if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
@@ -180,6 +204,10 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
// until the next 06:00 UTC briefing for HP and spell slots to come // 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. // back. The flag tells processOvernightCamp not to re-apply at briefing.
restSummary := applyCampRest(exp, kind) restSummary := applyCampRest(exp, kind)
if nightCamp {
nightRoll = p.nightRolloverDrift(exp, time.Now().UTC())
nightRoll.Burn = nightBurn
}
camp.RestApplied = true camp.RestApplied = true
if err := updateCamp(exp.ID, camp); err != nil { if err := updateCamp(exp.ID, camp); err != nil {
slog.Warn("camp: mark rest applied", "expedition", exp.ID, "err", err) slog.Warn("camp: mark rest applied", "expedition", exp.ID, "err", err)
@@ -221,6 +249,15 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
case CampTypeBase: case CampTypeBase:
b.WriteString("\n_Base camp — **waypoint persisted**. Camp here again at no eligibility cost on later returns._") 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()) return p.SendDM(ctx.Sender, b.String())
} }

View File

@@ -91,18 +91,23 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
func expeditionHelpText() string { func expeditionHelpText() string {
var b strings.Builder var b strings.Builder
b.WriteString("**!expedition** — multi-day dungeon expeditions.\n\n") b.WriteString("**!expedition** — multi-day dungeon expeditions.\n\n")
b.WriteString("`!expedition list` — show zones available at your level\n") b.WriteString("**The shape:** pick a zone, pick a supply pack, watch it play out. ")
b.WriteString("`!expedition start <zone> [Ns] [Md]` — outfit & begin\n") 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(" `Ns` = N standard packs (10 SU, 50 coins, max 3)\n") b.WriteString("**Run an expedition:**\n")
b.WriteString(" `Md` = M deluxe packs (20 SU, 90 coins, max 1)\n") b.WriteString("`!expedition list` — zones available at your level\n")
b.WriteString(" default: `1s`\n") b.WriteString("`!expedition start <zone>` — prompts a loadout: `lean` / `balanced` / `heavy`\n")
b.WriteString("`!expedition run` — autopilot: walk rooms until something needs you (alias `!explore`)\n") b.WriteString("`!expedition run` — start (or resume) the autopilot walk (alias `!explore`)\n")
b.WriteString("`!expedition status` — current expedition snapshot\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 log` — last 5 log entries\n")
b.WriteString("`!expedition abandon` — end the expedition (no rewards)\n") b.WriteString("`!expedition abandon` — end without rewards\n")
b.WriteString("`!extract` — voluntary extraction (1 day, resumable for 7 days)\n") b.WriteString("`!map` — region/room ASCII map\n\n")
b.WriteString("`!resume [Ns] [Md]` — resume an extracted expedition\n") b.WriteString("**Overrides** _(autopilot covers these — only reach for them if you want manual control)_:\n")
b.WriteString("`!map` — region/room ASCII map") 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() return b.String()
} }
@@ -178,6 +183,66 @@ func parseSupplyArgs(rest string) (SupplyPurchase, error) {
return p, nil 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 { func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter, rest string) error {
if rest == "" { if rest == "" {
return p.SendDM(ctx.Sender, return p.SendDM(ctx.Sender,
@@ -195,11 +260,17 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
return p.SendDM(ctx.Sender, return p.SendDM(ctx.Sender,
"Unknown zone for your level. Try `!expedition list`.") "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 { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) 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()) return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
} }
cost := float64(purchase.Cost()) cost := float64(purchase.Cost())
@@ -226,9 +297,10 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
zone.Display)) zone.Display))
} }
zone, _ := getZone(zoneID) zone := zoneForCaps
// Holiday perk: a complimentary standard pack is added to the supplies // 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 suppliesPurchase := purchase
if isHol, _ := isHolidayToday(); isHol { if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++ suppliesPurchase.StandardPacks++
@@ -331,8 +403,9 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool
zone, _ := getZone(exp.ZoneID) zone, _ := getZone(exp.ZoneID)
c, _ := LoadDnDCharacter(ctx.Sender) c, _ := LoadDnDCharacter(ctx.Sender)
target := expeditionTargetDays(zone.Tier)
var b strings.Builder 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))) b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier)))
if r, ok := CurrentRegion(exp); ok { if r, ok := CurrentRegion(exp); ok {
cleared := IsRegionCleared(exp, r.ID) cleared := IsRegionCleared(exp, r.ID)
@@ -343,6 +416,10 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool
b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n", b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n",
r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker)) 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 { if c != nil {
b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax)) b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax))
} }
@@ -377,6 +454,19 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool
depletionLabel(state))) 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", b.WriteString(fmt.Sprintf("\nStarted: %s Last activity: %s",
exp.StartDate.Format("2006-01-02 15:04"), exp.StartDate.Format("2006-01-02 15:04"),
exp.LastActivity.Format("2006-01-02 15:04"))) exp.LastActivity.Format("2006-01-02 15:04")))
@@ -642,7 +732,9 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
// Doorway/blocked stops fire *before* the current room actually // Doorway/blocked stops fire *before* the current room actually
// resolved — those don't count as a walked room. Everything else // resolved — those don't count as a walked room. Everything else
// (OK, fork after a clear, ended after combat, complete) does. // (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++ rooms++
} }
@@ -706,15 +798,16 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
exp = fresh exp = fresh
} }
// Arrived at a Boss doorway: stop here. The "Room X/Y — Boss. // Arrived at an Elite/Boss doorway. Foreground stops here so the
// !fight when ready." line in res.final already tells the player // player can decide; the "Room X/Y — Boss. !fight when ready."
// what to do; another loop iteration would just hit the gate and // line in res.final already tells them what to do.
// emit a duplicate "Room X/Y — Boss" message.
// //
// For Elite + non-compact, do the same. In compact mode we let // In compact mode (background autopilot, long-expedition D2/D3)
// the next iteration run because the gate will auto-resolve the // we let the next iteration run because the gate will auto-
// elite inline (which is the whole point of compact mode). // resolve the encounter inline — elite always, boss when the
if res.nextRoomType == RoomBoss || (res.nextRoomType == RoomElite && !compact) { // 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 r := stopBoss
if res.nextRoomType == RoomElite { if res.nextRoomType == RoomElite {
r = stopElite r = stopElite
@@ -823,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) return fmt.Sprintf("⏸ **Autopilot paused — elite ahead** (after %s). `!fight` when ready, then `!expedition run` to continue.", roomsStr)
case stopBoss: case stopBoss:
return fmt.Sprintf("⏸ **Autopilot paused — boss ahead** (after %s). `!fight` when ready.", roomsStr) 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: case stopEnded:
return "" // death narration is the final; no footer return "" // death narration is the final; no footer
case stopComplete: case stopComplete:

View File

@@ -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) { func TestThreatThresholdLabel_Bands(t *testing.T) {
cases := []struct { cases := []struct {
level int level int
@@ -119,8 +189,8 @@ func TestExpeditionCmd_StartAbandonRoundtrip(t *testing.T) {
euro.Credit(uid, 200, "test setup") euro.Credit(uid, 200, "test setup")
p := &AdventurePlugin{euro: euro} p := &AdventurePlugin{euro: euro}
// Default 1 standard pack = 50 coins. Should land an active expedition. // Lean preset = 1 standard pack at T1 = 50 coins. Should land an active expedition.
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) t.Fatal(err)
} }
exp, err := getActiveExpedition(uid) exp, err := getActiveExpedition(uid)
@@ -219,7 +289,7 @@ func TestExpeditionCmd_RunWalksRooms(t *testing.T) {
euro.Credit(uid, 200, "test setup") euro.Credit(uid, 200, "test setup")
p := &AdventurePlugin{euro: euro} 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) t.Fatal(err)
} }
exp, _ := getActiveExpedition(uid) exp, _ := getActiveExpedition(uid)
@@ -310,6 +380,7 @@ func TestAutopilotFooter_Reasons(t *testing.T) {
{stopEnded, "", false}, {stopEnded, "", false},
{stopComplete, "", false}, {stopComplete, "", false},
{stopBlocked, "", false}, {stopBlocked, "", false},
{stopBossSafety, "", false}, // res.final carries the held-back line
} }
for _, c := range cases { for _, c := range cases {
got := autopilotFooter(c.r, 3) 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) { func TestExpeditionCmd_ListWithoutCharBlocked(t *testing.T) {
setupAuditTestDB(t) setupAuditTestDB(t)
uid := id.UserID("@exp-cmd-nochar:example") uid := id.UserID("@exp-cmd-nochar:example")

View File

@@ -32,8 +32,146 @@ import (
const ( const (
expeditionBriefingHour = 6 expeditionBriefingHour = 6
expeditionRecapHour = 21 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. // expeditionBriefingTicker — 06:00 UTC daily briefing.
func (p *AdventurePlugin) expeditionBriefingTicker() { func (p *AdventurePlugin) expeditionBriefingTicker() {
ticker := time.NewTicker(1 * time.Minute) 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) slog.Info("expedition: briefing deferred — player in combat session", "expedition", e.ID, "user", e.UserID)
continue 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 { if err := p.deliverBriefing(e, now); err != nil {
slog.Error("expedition: briefing", "expedition", e.ID, "err", err) slog.Error("expedition: briefing", "expedition", e.ID, "err", err)
} }
@@ -176,14 +332,17 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
return out, rows.Err() return out, rows.Err()
} }
// deliverBriefing rolls a day forward, applies supply burn, posts the // deliverBriefing posts the morning briefing DM. For legacy UTC-anchored
// morning briefing DM, appends a log entry, and stamps last_briefing_at. // 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 // Idempotency: atomic compare-and-set on last_briefing_at gates the body.
// last_briefing_at. If another invocation (clock skew, restart, double // A double-fire on the same expedition is a no-op.
// fire) already claimed today's rollover, rowsAffected == 0 and we bail
// without re-applying supply burn / day++ / threat drift.
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
priorBriefing := e.LastBriefingAt
threshold := time.Date(now.Year(), now.Month(), now.Day(), threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionBriefingHour, 0, 0, 0, time.UTC) expeditionBriefingHour, 0, 0, 0, time.UTC)
res, err := db.Get().Exec(` 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 { if n, _ := res.RowsAffected(); n == 0 {
return nil // already delivered for this day return nil // already delivered for this day
} }
e.LastBriefingAt = &now
// E3: zone-specific temporal events fire BEFORE applyDailyBurn and // D2-b: event-anchored expeditions own the day rollover via the
// can override the entire burn calculation with a fixed multiplier // autopilot night camp. The 06:00 ticker either posts a re-engagement
// (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.). // DM (rollover happened recently) or force-fires processNightCamp
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1) // itself (safety net for stalled autopilots).
if isEventAnchored(e) {
// Advance day + supply burn happen together at the morning rollover. return p.deliverBriefingEventAnchored(e, priorBriefing)
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)
} }
if err := updateSupplies(e.ID, newSupplies); err != nil {
burn, err := p.nightRolloverBurn(e)
if err != nil {
return err 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 // E2d: overnight camp rest. Runs after burn/day++ but before drift so
// the camp after applying. Run before threat drift so a fortified // a fortified camp's 5 lands first and a same-day +3 drift can't push
// camp's 5 lands first and a same-day +3 drift can't push back over // back over a threshold the rest just dropped.
// a threshold the rest just dropped.
restSummary := processOvernightCamp(e) restSummary := processOvernightCamp(e)
if restSummary != "" { if restSummary != "" {
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil { if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
@@ -248,54 +386,20 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
} }
} }
// E2a: daily threat drift (+3 base, GM-mood modifier). No-op after // Pass time.Time{} — the CAS at the top of deliverBriefing already
// boss kill. May cross a threshold and append a flavor-bearing log. // stamped last_briefing_at with the synthetic now; don't overwrite it.
if _, _, err := applyDailyThreatDrift(e); err != nil { roll := p.nightRolloverDrift(e, time.Time{})
slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err) roll.Burn = burn
}
// 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)
line := pickMorningBriefing(e.CurrentDay) line := pickMorningBriefing(e.CurrentDay)
body := renderMorningBriefing(e, line, burn) body := renderMorningBriefing(e, line, burn)
if restSummary != "" { if restSummary != "" {
body += "\n💤 _" + restSummary + "_\n" body += "\n💤 _" + restSummary + "_\n"
} }
for _, tl := range temporalLines { for _, tl := range roll.TemporalLines {
body += "\n🌀 " + tl + "\n" body += "\n🌀 " + tl + "\n"
} }
for _, ml := range milestoneLines { for _, ml := range roll.MilestoneLines {
body += "\n" + ml body += "\n" + ml
} }
@@ -335,6 +439,130 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
return nil 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 // 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. // last_recap_at. No supply burn here — that's the briefing's job.
// Idempotency: same pattern as deliverBriefing — claim last_recap_at first. // Idempotency: same pattern as deliverBriefing — claim last_recap_at first.

View File

@@ -14,6 +14,20 @@ import (
// synthetic "now" and verify state transitions. Ticker scheduling is a // synthetic "now" and verify state transitions. Ticker scheduling is a
// thin wrapper over those. // 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) { func TestPickMorningBriefing_DayBands(t *testing.T) {
cases := []struct { cases := []struct {
day int day int
@@ -57,6 +71,7 @@ func TestDeliverBriefing_AdvancesDayAndBurnsSupplies(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
rewindToLegacyAnchor(t, exp)
p := &AdventurePlugin{} p := &AdventurePlugin{}
now := time.Now().UTC() now := time.Now().UTC()
if err := p.deliverBriefing(exp, now); err != nil { 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) { func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) {
setupZoneRunTestDB(t) setupZoneRunTestDB(t)
uid := id.UserID("@exp-cycle-harsh:example") uid := id.UserID("@exp-cycle-harsh:example")
@@ -97,6 +273,7 @@ func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
rewindToLegacyAnchor(t, exp)
// Force harsh conditions via threat clock above 60. // Force harsh conditions via threat clock above 60.
if err := applyThreatDelta(exp.ID, 70, "test"); err != nil { if err := applyThreatDelta(exp.ID, 70, "test"); err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -308,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.") "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 { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) 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()) return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
} }
cost := float64(purchase.Cost()) cost := float64(purchase.Cost())

View File

@@ -2,6 +2,8 @@ package plugin
import ( import (
"fmt" "fmt"
"math/rand/v2"
"strings"
) )
// Phase 12 E1b — Supply procurement, daily burn, and depletion effects. // Phase 12 E1b — Supply procurement, daily burn, and depletion effects.
@@ -11,15 +13,115 @@ import (
const ( const (
SupplyPackStandardSU = 10 SupplyPackStandardSU = 10
SupplyPackStandardCoins = 50 SupplyPackStandardCoins = 50
SupplyPackStandardMax = 3 // per expedition
SupplyPackDeluxeSU = 20 SupplyPackDeluxeSU = 20
SupplyPackDeluxeCoins = 90 SupplyPackDeluxeCoins = 90
SupplyPackDeluxeMax = 1 // per expedition
SupplyForageMaxSU = 4 // 1d4 cap (Ranger, WIS DC 12) — §4.2 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). // 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. // Tier 1: 1, Tier 2: 1.5, Tier 3: 2, Tier 4: 3, Tier 5: 4.
func supplyDailyBurn(tier ZoneTier) float32 { func supplyDailyBurn(tier ZoneTier) float32 {
@@ -56,6 +158,45 @@ func supplyHarshMultiplier(tier ZoneTier) float32 {
return 1.0 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). // SupplyDepletionState classifies remaining supply ratio (§4.3).
type SupplyDepletionState int type SupplyDepletionState int
@@ -122,18 +263,19 @@ func (p SupplyPurchase) Cost() int {
return p.StandardPacks*SupplyPackStandardCoins + p.DeluxePacks*SupplyPackDeluxeCoins return p.StandardPacks*SupplyPackStandardCoins + p.DeluxePacks*SupplyPackDeluxeCoins
} }
// Validate enforces §4.2 caps (max 3 standard, max 1 deluxe, no negatives, // Validate enforces §4.2 caps (no negatives, at least one pack
// at least one pack purchased — an expedition without supplies is not a // purchased — an expedition without supplies is not a legal start) and
// legal start). // the per-tier maximums from supplyPackCaps.
func (p SupplyPurchase) Validate() error { func (p SupplyPurchase) Validate(tier ZoneTier) error {
if p.StandardPacks < 0 || p.DeluxePacks < 0 { if p.StandardPacks < 0 || p.DeluxePacks < 0 {
return fmt.Errorf("supply pack counts must be non-negative") return fmt.Errorf("supply pack counts must be non-negative")
} }
if p.StandardPacks > SupplyPackStandardMax { stdCap, dlxCap := supplyPackCaps(tier)
return fmt.Errorf("standard packs capped at %d (got %d)", SupplyPackStandardMax, p.StandardPacks) 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 { if p.DeluxePacks > dlxCap {
return fmt.Errorf("deluxe packs capped at %d (got %d)", SupplyPackDeluxeMax, p.DeluxePacks) 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 { if p.StandardPacks == 0 && p.DeluxePacks == 0 {
return fmt.Errorf("expedition requires at least one supply pack") return fmt.Errorf("expedition requires at least one supply pack")

View File

@@ -92,19 +92,52 @@ func TestSupplyAllowsLongRest(t *testing.T) {
func TestSupplyPurchase_Validate(t *testing.T) { func TestSupplyPurchase_Validate(t *testing.T) {
cases := []struct { cases := []struct {
p SupplyPurchase p SupplyPurchase
tier ZoneTier
wantErr bool wantErr bool
}{ }{
{SupplyPurchase{StandardPacks: 1}, false}, // T3 is the only tier whose caps are unchanged from the pre-D5
{SupplyPurchase{StandardPacks: 3, DeluxePacks: 1}, false}, // max // shape (3 standard / 1 deluxe); use it to anchor the
{SupplyPurchase{StandardPacks: 4}, true}, // over standard cap // happy-path / over-cap parity assertions.
{SupplyPurchase{DeluxePacks: 2}, true}, // over deluxe cap {SupplyPurchase{StandardPacks: 1}, ZoneTierJourneyman, false},
{SupplyPurchase{StandardPacks: -1}, true}, {SupplyPurchase{StandardPacks: 3, DeluxePacks: 1}, ZoneTierJourneyman, false},
{SupplyPurchase{}, true}, // none purchased {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 { for _, c := range cases {
err := c.p.Validate() err := c.p.Validate(c.tier)
if (err != nil) != c.wantErr { 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) { func TestApplyDailyBurn_DrainsAndClamps(t *testing.T) {
// Phase 5-B (shipped): applyDailyBurn now scales by // Phase 5-B (shipped): applyDailyBurn now scales by
// phase5BDailyBurnRatePct = 50 by default — every expected value // phase5BDailyBurnRatePct = 50 by default — every expected value

View File

@@ -701,6 +701,7 @@ func TestAbyss_DailyInstabilityIncrements(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
rewindToLegacyAnchor(t, exp)
p := &AdventurePlugin{} p := &AdventurePlugin{}
// Three briefings → instability should hit 15. // Three briefings → instability should hit 15.
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
@@ -732,6 +733,7 @@ func TestAbyss_UnravelingDoublesBurn(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
rewindToLegacyAnchor(t, exp)
// Set instability to 85 (unravel band). // Set instability to 85 (unravel band).
if _, err := db.Get().Exec( if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET temporal_stack = 85 WHERE expedition_id = ?`, `UPDATE dnd_expedition SET temporal_stack = 85 WHERE expedition_id = ?`,
@@ -760,6 +762,7 @@ func TestAbyss_CollapseFailsExpedition(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
rewindToLegacyAnchor(t, exp)
// Set instability to 95 — next daily +5 lands on 100 (collapse). // Set instability to 95 — next daily +5 lands on 100 (collapse).
if _, err := db.Get().Exec( if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET temporal_stack = 95 WHERE expedition_id = ?`, `UPDATE dnd_expedition SET temporal_stack = 95 WHERE expedition_id = ?`,

View File

@@ -150,6 +150,7 @@ func TestDeliverBriefing_AppliesThreatDrift(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
rewindToLegacyAnchor(t, exp)
p := &AdventurePlugin{} p := &AdventurePlugin{}
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -214,8 +214,8 @@ func zoneGoblinWarrens() ZoneDefinition {
Faction: "Goblins, Hobgoblins", Faction: "Goblins, Hobgoblins",
Atmosphere: "Low ceilings, torchlight, crude traps, cackling in the dark.", 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.", 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, MinRooms: 12,
MaxRooms: 7, MaxRooms: 14,
Enemies: []ZoneEnemy{ Enemies: []ZoneEnemy{
{BestiaryID: "goblin_sneak", SpawnWeight: 7}, {BestiaryID: "goblin_sneak", SpawnWeight: 7},
{BestiaryID: "goblin_archer", SpawnWeight: 6}, {BestiaryID: "goblin_archer", SpawnWeight: 6},
@@ -258,8 +258,8 @@ func zoneCryptValdris() ZoneDefinition {
Faction: "Undead", Faction: "Undead",
Atmosphere: "Stone corridors, dripping water, candles that shouldn't still be burning.", 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.", 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, MinRooms: 12,
MaxRooms: 7, MaxRooms: 14,
Enemies: []ZoneEnemy{ Enemies: []ZoneEnemy{
// Phase 4-B (outlier fix): the live roster was the only // Phase 4-B (outlier fix): the live roster was the only
// dual-killer-elite zone (wight + flameskull, both CR3+ on // dual-killer-elite zone (wight + flameskull, both CR3+ on
@@ -314,8 +314,8 @@ func zoneForestShadows() ZoneDefinition {
Faction: "Beasts, Fey-corrupted creatures, Bandits", Faction: "Beasts, Fey-corrupted creatures, Bandits",
Atmosphere: "Ancient forest, twisted paths, eerie silence, bioluminescent fungi, things in the canopy.", 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.", 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, MinRooms: 16,
MaxRooms: 8, MaxRooms: 20,
Enemies: []ZoneEnemy{ Enemies: []ZoneEnemy{
// Phase 4-B (outlier fix): standard pool was carrying two // Phase 4-B (outlier fix): standard pool was carrying two
// real killers — Displacer Beast (38% win as a standard // real killers — Displacer Beast (38% win as a standard
@@ -377,8 +377,8 @@ func zoneSunkenTemple() ZoneDefinition {
Faction: "Kuo-toa, Water Elementals, Aboleth-touched", Faction: "Kuo-toa, Water Elementals, Aboleth-touched",
Atmosphere: "Flooded stone chambers, barnacled pillars, salt smell, alien glyphs, things that swim in the dark water.", 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.", 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, MinRooms: 16,
MaxRooms: 8, MaxRooms: 20,
Enemies: []ZoneEnemy{ Enemies: []ZoneEnemy{
{BestiaryID: "kuo_toa", SpawnWeight: 7}, {BestiaryID: "kuo_toa", SpawnWeight: 7},
{BestiaryID: "kuo_toa_whip", SpawnWeight: 4}, {BestiaryID: "kuo_toa_whip", SpawnWeight: 4},
@@ -423,8 +423,8 @@ func zoneManorBlackspire() ZoneDefinition {
Faction: "Undead, Shadows, Vampiric", Faction: "Undead, Shadows, Vampiric",
Atmosphere: "Victorian decay, impossible architecture, portraits whose eyes follow movement, cold spots, locked rooms that weren't locked before.", 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.", 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, MinRooms: 22,
MaxRooms: 9, MaxRooms: 26,
Enemies: []ZoneEnemy{ Enemies: []ZoneEnemy{
// Phase 4-B (outlier fix): Wraith was the dominant // Phase 4-B (outlier fix): Wraith was the dominant
// standard-pool killer (45 hp loss/win, 85 attributed // standard-pool killer (45 hp loss/win, 85 attributed
@@ -491,8 +491,8 @@ func zoneUnderforge() ZoneDefinition {
Faction: "Fire Elementals, Constructs, Salamanders, Azers", 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.", 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.", 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, MinRooms: 22,
MaxRooms: 9, MaxRooms: 26,
Enemies: []ZoneEnemy{ Enemies: []ZoneEnemy{
// Phase 5-C: underforge trailed at 49.5% (band 55-75) // Phase 5-C: underforge trailed at 49.5% (band 55-75)
// under the shipped HP×1.5/+3 floor. Trace named Fire // 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", 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.", 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.", 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, MinRooms: 28,
MaxRooms: 10, MaxRooms: 34,
Enemies: []ZoneEnemy{ Enemies: []ZoneEnemy{
// Phase 5-C: underdark ran 88% (band 45-65, way over) // Phase 5-C: underdark ran 88% (band 45-65, way over)
// at the T4 centerline — its sibling feywild sat at // 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", 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.", 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.", 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, MinRooms: 28,
MaxRooms: 10, MaxRooms: 34,
Enemies: []ZoneEnemy{ Enemies: []ZoneEnemy{
// Phase 5-C: feywild trailed at 54% (band 45-65, but // Phase 5-C: feywild trailed at 54% (band 45-65, but
// the design goal was to close the 30pp gap with its // the design goal was to close the 30pp gap with its
@@ -663,8 +663,8 @@ func zoneDragonsLair() ZoneDefinition {
Faction: "Kobolds, Drakes, Young Dragons, Wyrm", 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.", 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.", 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, MinRooms: 36,
MaxRooms: 10, MaxRooms: 44,
Enemies: []ZoneEnemy{ Enemies: []ZoneEnemy{
{BestiaryID: "kobold", SpawnWeight: 7}, {BestiaryID: "kobold", SpawnWeight: 7},
{BestiaryID: "guard_drake", SpawnWeight: 5}, {BestiaryID: "guard_drake", SpawnWeight: 5},
@@ -711,8 +711,8 @@ func zoneAbyssPortal() ZoneDefinition {
Faction: "Demons, Fiends, Corrupted Celestials", 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.", 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.", 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, MinRooms: 36,
MaxRooms: 10, MaxRooms: 44,
Enemies: []ZoneEnemy{ Enemies: []ZoneEnemy{
// Phase 4-B (outlier fix): Nalfeshnee was mis-classified // Phase 4-B (outlier fix): Nalfeshnee was mis-classified
// as a standard at T5 — Phase 4-A measured 2.8% win rate // as a standard at T5 — Phase 4-A measured 2.8% win rate

View File

@@ -389,8 +389,39 @@ const (
stopBlocked // an active CombatSession blocks the advance stopBlocked // an active CombatSession blocks the advance
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
stopPreflight // pre-iteration preflight tripped (low HP / low SU) 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 // advanceResult bundles the staged narration + dispatch shape of one
// advanceOnce step. preStream/intro/phases/final mirror the streamOrSend // advanceOnce step. preStream/intro/phases/final mirror the streamOrSend
// contract — phases nil means "no per-step pacing required". reason tells // contract — phases nil means "no per-step pacing required". reason tells
@@ -487,9 +518,12 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
// means the fight's done; fall through so the graph clears the room. // means the fight's done; fall through so the graph clears the room.
// //
// compact==true (background autopilot) auto-resolves elite rooms via // compact==true (background autopilot) auto-resolves elite rooms via
// the same forward-sim engine used for exploration combat. Boss still // the same forward-sim engine used for exploration combat. Long-
// pauses regardless — the boss is the run's climax beat and shouldn't // expedition D3 extends the same path to boss rooms — gated by a
// be settled while the player isn't paying attention. // 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 eliteAutoIntro string
var eliteAutoPhases []string var eliteAutoPhases []string
var eliteAutoOutcome string var eliteAutoOutcome string
@@ -501,7 +535,7 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
return advanceResult{}, fmt.Errorf("Couldn't read combat state: %s", serr.Error()) return advanceResult{}, fmt.Errorf("Couldn't read combat state: %s", serr.Error())
} }
if sess == nil || sess.Status != CombatStatusWon { if sess == nil || sess.Status != CombatStatusWon {
if prev == RoomBoss || !compact { if !compact {
kind := "Elite" kind := "Elite"
r := stopElite r := stopElite
if prev == RoomBoss { if prev == RoomBoss {
@@ -514,10 +548,23 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
reason: r, reason: r,
}, nil }, nil
} }
// Compact-mode elite auto-resolve. if prev == RoomBoss {
ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, true, true) // 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
}
}
// 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 { 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 { if aended {
return advanceResult{ return advanceResult{
@@ -890,9 +937,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 // Phases will be nil only on a "no roster" skip — caller treats that as a
// non-paced fallthrough. // 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) { 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 { 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 return
} }
preHP, _ := dndHPSnapshot(userID) preHP, _ := dndHPSnapshot(userID)
@@ -910,7 +976,10 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
hpDelta := preHP - postHP hpDelta := preHP - postHP
var ob strings.Builder var ob strings.Builder
label := monster.Name label := monster.Name
if elite { switch {
case isBoss:
label = "👑 Boss — " + monster.Name
case elite:
label = "Elite " + monster.Name label = "Elite " + monster.Name
} }
ob.WriteString(fmt.Sprintf("⚔️ **%s** down — HP %d→%d", label, preHP, postHP)) ob.WriteString(fmt.Sprintf("⚔️ **%s** down — HP %d→%d", label, preHP, postHP))
@@ -919,8 +988,8 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
} }
ob.WriteString(".") ob.WriteString(".")
recordZoneKillForUser(userID, monster.ID) recordZoneKillForUser(userID, monster.ID)
applyRoomCombatThreatForUser(userID, elite) applyRoomCombatThreatForUser(userID, elite || isBoss)
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" { if drop := p.dropZoneLoot(userID, zone.ID, monster, isBoss); drop != "" {
ob.WriteString(" ") ob.WriteString(" ")
ob.WriteString(drop) ob.WriteString(drop)
} }

View File

@@ -108,11 +108,21 @@ func (r *DungeonRun) CurrentRoomType() RoomType {
// generateRoomSequence builds the deterministic-but-seeded room layout // generateRoomSequence builds the deterministic-but-seeded room layout
// for a run of the given zone. The boss room is always last; one Entry // 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. // 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 { func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType {
total := zone.MinRooms total := 0
if zone.MaxRooms > zone.MinRooms { if g, ok := loadZoneGraph(zone.ID); ok {
total += rng.IntN(zone.MaxRooms - zone.MinRooms + 1) 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. // Fixed slots: Entry + Trap + Elite + Boss = 4. Remaining = explorations.
const fixed = 4 const fixed = 4

View File

@@ -126,9 +126,12 @@ func TestZoneRegistry_LootDropChances(t *testing.T) {
} }
func TestZoneRegistry_RoomCountSane(t *testing.T) { func TestZoneRegistry_RoomCountSane(t *testing.T) {
// Long-expedition plan §2 widens the bands per tier: T1 1214 up to
// T5 ~3644. The guard floor stays at 5 (no zone should ever drop
// below that) and the ceiling tracks the T5 target.
for _, z := range allZones() { for _, z := range allZones() {
if z.MinRooms < 5 || z.MaxRooms > 10 || z.MinRooms > z.MaxRooms { if z.MinRooms < 5 || z.MaxRooms > 44 || z.MinRooms > z.MaxRooms {
t.Errorf("zone %s rooms %d-%d outside design (5-10, min<=max)", t.Errorf("zone %s rooms %d-%d outside design (5-44, min<=max)",
z.ID, z.MinRooms, z.MaxRooms) z.ID, z.MinRooms, z.MaxRooms)
} }
} }

View File

@@ -0,0 +1,430 @@
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.
func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (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: time.Now().UTC(),
EventAnchored: isEventAnchored(exp),
LastBriefingAt: exp.LastBriefingAt,
StartDate: exp.StartDate,
}
d, ok := decideAutopilotCamp(in)
if !ok {
return "", autoCampDecision{}, false
}
block, err := p.pitchAutopilotCamp(exp, d)
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) (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, time.Now().UTC())
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: time.Now().UTC(),
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, time.Now().UTC())
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) (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 = time.Since(*exp.LastBriefingAt)
} else {
since = time.Since(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)
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
}

View 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",
})
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,
})
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")
}
}

View File

@@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"fmt"
"log/slog" "log/slog"
"strings" "strings"
"time" "time"
@@ -20,13 +21,15 @@ import (
// quiet window, or on a very-fresh expedition (give them a beat). // quiet window, or on a very-fresh expedition (give them a beat).
// • Walk up to autoRunRoomCap rooms per tick. Smaller than foreground's // • Walk up to autoRunRoomCap rooms per tick. Smaller than foreground's
// cap so background DMs stay digestible. // cap so background DMs stay digestible.
// • DM-suppression rules: // • DM-suppression rules (D4-a — long expedition bundling):
// - 0 rooms walked → silent (still stuck at a fork / blocked / etc.; // - Surface ONLY for: fork (player decision), death, run-complete,
// the player already knows, no point spamming). // boss-safety camp pitch (explicit hold), or a Night=true camp
// - rooms > 0 with stopOK (hit room cap) → silent; we'll keep walking // pitch (end-of-day digest).
// on the next tick. Cadence handles pacing; no "stretch complete" // - Everything else — uneventful walks, preflight pauses, harvest
// footer churn. // interrupts, mid-day rest camps — goes silent. The accumulated
// - Any other reason with rooms > 0 → DM the bundled walk. // 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 // • Idempotency: CAS-claim last_autorun_at before doing any work. A
// double-fire on the same expedition is a no-op. // double-fire on the same expedition is a no-op.
@@ -141,6 +144,15 @@ func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error)
// supplies/threat/run-graph state are mutated by the walk itself, just // supplies/threat/run-graph state are mutated by the walk itself, just
// as they would be in a foreground !expedition run. // as they would be in a foreground !expedition run.
func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error { 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) cutoff := now.Add(-autoRunCooldown)
res, err := db.Get().Exec(` res, err := db.Get().Exec(`
UPDATE dnd_expedition UPDATE dnd_expedition
@@ -163,40 +175,121 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// "no expedition" / "no run" — race with abandon/extract. Silent. // "no expedition" / "no run" — race with abandon/extract. Silent.
return nil return nil
} }
// Emergence seam: a run-complete reached by the background ticker is
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge. // D4-adrop a `walk` log entry per successful background walk so
// Deferred until after the run-summary DM below so the "animal in your // the EoD digest can count rooms walked from structured logs without
// house" prompt lands after the summary, not ahead of it. // persisting the raw stream narration we used to DM.
if shouldDMAutoRun(r) { if r.rooms > 0 {
body := renderAutoRunDM(r) _ = appendExpeditionLog(e.ID, e.CurrentDay, "walk",
fmt.Sprintf("auto-walk: %d room(s)", r.rooms), "")
}
// 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)
}
} 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)
}
}
_ = 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 { if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: autorun DM", "user", uid, "err", err) 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 { if r.reason == stopComplete {
p.maybeRollPetArrivalOnEmerge(uid) p.maybeRollPetArrivalOnEmerge(uid)
} }
return nil return nil
} }
// shouldDMAutoRun applies the background suppression rules. See the // buildAutoRunDM applies D4-a's surface rules and returns the DM body to
// file-top design block for the rationale. // send. ok=false means the tick is silent. Inputs:
func shouldDMAutoRun(r autopilotWalkResult) bool { // - expID: the expedition row id (for the EoD digest log fetch).
if r.rooms == 0 { // - r: walk result, including reason + accumulated stream.
return false // - 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 { if camp == "" {
// Hit the per-tick room cap. Next tick will continue; no need to return "", false
// post a "stretch complete" filler DM.
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. // renderAutoRunWalkDM is the legacy concat-the-stream renderer, kept for
// Background can't pace via streamFlow, so we concatenate phases with a // the surfaces D4-a still DMs (fork / death / run-complete).
// blank line between each beat and tack on the final message. func renderAutoRunWalkDM(r autopilotWalkResult) string {
func renderAutoRunDM(r autopilotWalkResult) string {
var b strings.Builder var b strings.Builder
b.WriteString("🚶 *Auto-walk*\n\n") b.WriteString("🚶 *Auto-walk*\n\n")
for _, s := range r.stream { for _, s := range r.stream {

View 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"
}

View 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)
}
}

View File

@@ -5,6 +5,7 @@ import (
"os" "os"
"strings" "strings"
"testing" "testing"
"time"
"github.com/chehsunliu/poker" "github.com/chehsunliu/poker"
) )
@@ -30,6 +31,11 @@ func loadSolverFixture() {
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
loadSolverFixture() 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()) os.Exit(m.Run())
} }

View File

@@ -360,6 +360,45 @@ func roomTypeToNodeKind(rt RoomType) ZoneNodeKind {
return NodeKindExploration 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) // loadZoneGraph returns the graph for a zone. Registered (hand-authored)
// graphs take precedence; otherwise the legacy linear compiler is used. // graphs take precedence; otherwise the legacy linear compiler is used.
// Returns ok=false only for unknown zone IDs. // Returns ok=false only for unknown zone IDs.

View File

@@ -1,88 +1,280 @@
package plugin 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, // Long-expedition plan D1-e: extended from the original 13-node sketch
// binary, ternary capstone. The player makes more decisions in this // to the new T5 length band (3644 rooms traversed). Topology preserves
// zone than in any other (3 separate fork prompts), matching the // the G8h design intent — three sequential forks at increasing depth
// "things keep getting worse" thematic descent. // (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) // The original "fork3 capstone leaves go straight to boss" shape is
// ├─[unlocked]── burning_wastes ─┐ // adjusted so the three capstones now converge at a belaxath_doors
// └─[Perception DC 16]── silent_chambers ─┤ // MERGE in R4, then walk a short tear-approach to boss. Three distinct
// // paths to boss are preserved (each via its own capstone node).
// 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
// //
// First authored zone with three sequential forks AND first use of // R1 outer_rift preamble (10 nodes):
// LockStatCheck CON — completing the full ability roster (STR/DEX/ // entry → shattered_path → fractured_threshold → screaming_passage
// CON/INT/WIS/CHA all appear in shipping zones by G8h). reality_seam // → ember_walk → outer_rift_descent → tear_in_stone → ruined_arch
// LootBias 3.0 reflects the Abyss capstone secret. // → 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 { func zoneAbyssPortalGraph() ZoneGraph {
r1 := "abyss_outer_rift"
r2 := "abyss_demon_assembly"
r3 := "abyss_wardens_post"
r4 := "abyss_the_tear"
nodes := []ZoneNode{ 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}, Label: "The Open Door", PosX: 0, PosY: 2},
{NodeID: "abyss_portal.fractured_threshold", Kind: NodeKindExploration, {NodeID: "abyss_portal.shattered_path", Kind: NodeKindExploration, RegionID: r1,
Label: "Fractured Threshold", PosX: 1, PosY: 2}, Label: "Shattered Path", PosX: 1, PosY: 2},
{NodeID: "abyss_portal.fork1", Kind: NodeKindFork, {NodeID: "abyss_portal.fractured_threshold", Kind: NodeKindExploration, RegionID: r1,
Label: "First Reality-Break", PosX: 2, PosY: 2}, Label: "Fractured Threshold", PosX: 2, PosY: 2},
{NodeID: "abyss_portal.burning_wastes", Kind: NodeKindExploration, {NodeID: "abyss_portal.screaming_passage", Kind: NodeKindExploration, RegionID: r1,
Label: "Burning Wastes", PosX: 3, PosY: 1}, Label: "Screaming Passage", PosX: 3, PosY: 2},
{NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, {NodeID: "abyss_portal.ember_walk", Kind: NodeKindExploration, RegionID: r1,
Label: "Silent Chambers", PosX: 3, PosY: 3}, Label: "Ember Walk", PosX: 4, PosY: 2},
{NodeID: "abyss_portal.fork2", Kind: NodeKindFork, {NodeID: "abyss_portal.outer_rift_descent", Kind: NodeKindExploration, RegionID: r1,
Label: "Second Reality-Break", PosX: 4, PosY: 2}, Label: "Rift Descent", PosX: 5, PosY: 2},
{NodeID: "abyss_portal.vrock_aerie", Kind: NodeKindElite, {NodeID: "abyss_portal.tear_in_stone", Kind: NodeKindExploration, RegionID: r1,
Label: "Marilith's Aerie", PosX: 5, PosY: 1}, Label: "Tear in Stone", PosX: 6, PosY: 2},
{NodeID: "abyss_portal.mind_corridor", Kind: NodeKindExploration, {NodeID: "abyss_portal.ruined_arch", Kind: NodeKindExploration, RegionID: r1,
Label: "Corridor of Whispers", PosX: 5, PosY: 3}, Label: "Ruined Arch", PosX: 7, PosY: 2},
{NodeID: "abyss_portal.fork3", Kind: NodeKindFork, {NodeID: "abyss_portal.quasit_outpost", Kind: NodeKindExploration, RegionID: r1,
Label: "Third Reality-Break", PosX: 6, PosY: 2}, Label: "Quasit Outpost", PosX: 8, PosY: 2},
{NodeID: "abyss_portal.direct_assault", Kind: NodeKindExploration, {NodeID: "abyss_portal.fork1", Kind: NodeKindFork, RegionID: r1,
Label: "Direct Assault", PosX: 7, PosY: 1}, Label: "First Reality-Break", PosX: 9, PosY: 2},
{NodeID: "abyss_portal.usurper_throne", Kind: NodeKindExploration,
Label: "Usurper's Approach", PosX: 7, PosY: 2}, // R2 demon_assembly — fork1 spurs.
{NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, {NodeID: "abyss_portal.burning_wastes", Kind: NodeKindExploration, RegionID: r2,
Label: "Reality Seam", PosX: 7, PosY: 3, 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}}, Content: ZoneNodeContent{LootBias: 3.0}},
{NodeID: "abyss_portal.boss", Kind: NodeKindBoss, IsBoss: true, {NodeID: "abyss_portal.through_the_seam", Kind: NodeKindExploration, RegionID: r4,
Label: "Belaxath's Throne", PosX: 8, PosY: 2}, 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{ edges := []ZoneEdge{
{From: "abyss_portal.entry", To: "abyss_portal.fractured_threshold", Lock: LockNone}, // R1 preamble.
{From: "abyss_portal.fractured_threshold", To: "abyss_portal.fork1", Lock: LockNone}, {From: "abyss_portal.entry", To: "abyss_portal.shattered_path", Lock: LockNone},
// Fork1 — perception side path. {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.burning_wastes", Lock: LockNone, Weight: 1},
{From: "abyss_portal.fork1", To: "abyss_portal.silent_chambers", {From: "abyss_portal.fork1", To: "abyss_portal.silent_chambers",
Lock: LockPerception, LockData: map[string]any{"dc": 16}, 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}, 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}, // burning_wastes spur → demon_assembly_gate.
// Fork2 — CON wall (psychic pressure). {From: "abyss_portal.burning_wastes", To: "abyss_portal.cinder_field", Lock: LockNone},
{From: "abyss_portal.fork2", To: "abyss_portal.vrock_aerie", Lock: LockNone, Weight: 1}, {From: "abyss_portal.cinder_field", To: "abyss_portal.obsidian_plain", Lock: LockNone},
{From: "abyss_portal.fork2", To: "abyss_portal.mind_corridor", {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}, 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}, 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}, // vrock_aerie spur → wardens_outer_post.
// Fork3 — capstone 3-way. {From: "abyss_portal.vrock_approach", To: "abyss_portal.vrock_aerie", Lock: LockNone},
{From: "abyss_portal.fork3", To: "abyss_portal.direct_assault", Lock: LockNone, Weight: 1}, {From: "abyss_portal.vrock_aerie", To: "abyss_portal.vrock_descent", Lock: LockNone},
{From: "abyss_portal.fork3", To: "abyss_portal.usurper_throne", {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}, Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 18},
Hint: "Belaxath has not noticed you yet — claim authority before he does", Weight: 2}, 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}, 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}, 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}, // direct_assault spur → merge.
{From: "abyss_portal.reality_seam", To: "abyss_portal.boss", Lock: LockNone}, {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) return BuildGraph(ZoneAbyssPortal, nodes, edges)
} }

View File

@@ -7,8 +7,59 @@ func TestAbyssPortalGraph_Registered(t *testing.T) {
if !ok { if !ok {
t.Fatal("zoneAbyssPortalGraph not registered") t.Fatal("zoneAbyssPortalGraph not registered")
} }
if len(g.Nodes) != 13 { // Long-expedition D1-e widened this zone from 13 → 51 nodes so the
t.Errorf("nodes = %d, want 13", len(g.Nodes)) // 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)
}
} }
} }

View File

@@ -1,54 +1,101 @@
package plugin package plugin
// Phase G7 — Crypt of Valdris POC graph. // Crypt of Valdris branching graph.
// //
// First hand-authored branching graph. Topology per // Long-expedition plan D1-b: extended from the original 8-node POC sketch
// gogobee_branching_zones_plan.md §3: // to the new T1 length band (1214 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 // entry → processional_stair → bone_corridor →
// ├──[unlocked]── main_hall (elite) ──┐ // grave_dust_trap (TRAP) → ossuary_walk → fork
// │ ├── antechamber → boss // ├──[unlocked]── main_hall (ELITE)
// └──[Perception DC 12]── side_chapel ─┘ // │ → reliquary_hall → ash_passage ──┐
// // │ ├── antechamber
// └──[Perception DC 15]── secret_chamber → 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 // Both main and side arms walk 3 mid-nodes after the fork; secret stays a
// dangles off side_chapel for an extra Perception gate with loot. // 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 { func zoneCryptValdrisGraph() ZoneGraph {
nodes := []ZoneNode{ nodes := []ZoneNode{
{NodeID: "crypt_valdris.entry", Kind: NodeKindEntry, IsEntry: true, {NodeID: "crypt_valdris.entry", Kind: NodeKindEntry, IsEntry: true,
Label: "Crypt Threshold", PosX: 0, PosY: 1}, Label: "Crypt Threshold", PosX: 0, PosY: 1},
{NodeID: "crypt_valdris.corridor", Kind: NodeKindExploration, {NodeID: "crypt_valdris.processional_stair", Kind: NodeKindExploration,
Label: "Bone Corridor", PosX: 1, PosY: 1}, 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, {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, {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, {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, {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}}, Content: ZoneNodeContent{LootBias: 2.0}},
// Merge + approach.
{NodeID: "crypt_valdris.antechamber", Kind: NodeKindMerge, {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, {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{ edges := []ZoneEdge{
{From: "crypt_valdris.entry", To: "crypt_valdris.corridor", Lock: LockNone}, {From: "crypt_valdris.entry", To: "crypt_valdris.processional_stair", Lock: LockNone},
{From: "crypt_valdris.corridor", To: "crypt_valdris.fork", 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.main_hall", Lock: LockNone, Weight: 1},
{From: "crypt_valdris.fork", To: "crypt_valdris.side_chapel", {From: "crypt_valdris.fork", To: "crypt_valdris.side_chapel",
Lock: LockPerception, LockData: map[string]any{"dc": 12}, Lock: LockPerception, LockData: map[string]any{"dc": 12},
Hint: "a faint draft from a crack in the wall", Weight: 2}, 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", {From: "crypt_valdris.side_chapel", To: "crypt_valdris.secret_chamber",
Lock: LockPerception, LockData: map[string]any{"dc": 15}, Lock: LockPerception, LockData: map[string]any{"dc": 15},
Hint: "a faint scratching behind the altar", Weight: 2}, 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.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) return BuildGraph(ZoneCryptValdris, nodes, edges)
} }

View File

@@ -18,8 +18,10 @@ func TestCryptValdrisGraph_Registered(t *testing.T) {
if g.Boss != "crypt_valdris.boss" { if g.Boss != "crypt_valdris.boss" {
t.Errorf("boss node = %q, want crypt_valdris.boss", g.Boss) t.Errorf("boss node = %q, want crypt_valdris.boss", g.Boss)
} }
if len(g.Nodes) != 8 { // Long-expedition D1-b widened this zone from 8 → 17 nodes so the
t.Errorf("nodes = %d, want 8", len(g.Nodes)) // 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))
} }
} }

View File

@@ -1,81 +1,258 @@
package plugin 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 // Long-expedition plan D1-e: extended from the original 12-node sketch
// elite) + capstone 3-way fork at the boss antechamber. Combines two // to the new T5 length band (3644 rooms traversed). Topology preserves
// distinct fork styles in one zone: a binary-converging fork and a // the G8g design intent — long approach, mid-zone binary-converging
// triple-spread capstone — escalating the player's decision-weight as // fork, capstone 3-way fork — and adds the linear depth each region
// they descend. // 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) // The original "fork2 capstone leaves go straight to boss" shape is
// ├─[unlocked]── ash_bridge ────┐ // adjusted so the three capstones now converge at an infernax_doors
// └─[Perception DC 15]── treasure_vault ─┤ // MERGE in R4, then walk a short throne approach to boss. Three
// // distinct paths to boss are preserved (each via its own capstone
// wyrmlings_nest (elite young red dragon) // node); the merge just consolidates the long final hall instead of
// // triplicating it.
// fork2 (capstone, 3-way)
// ├─[unlocked]── direct_confrontation → boss
// ├─[CHA DC 16]── dragon_bargain → boss
// └─[Perception DC 17]── hoard_pillar (secret) → boss
// //
// First authored zone with two distinct fork stages where the first // R1 kobold_warrens preamble (10 nodes):
// converges (diamond-style) and the second spreads (3-way capstone). // entry → kobold_lookout → kobold_warrens → smoke_choked_hall →
// Earlier zones used either-or: stacked equal-width hubs (Manor) or // ash_chapel → kobold_warchief_camp → ember_corridor →
// late-only fork (Underforge). The escalation pattern ("commit, then // obsidian_steps → cinder_passage → kobold_descent
// commit harder") is unique to T5. //
// 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 { 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{ 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}, Label: "Mountain Pass", PosX: 0, PosY: 1},
{NodeID: "dragons_lair.kobold_warrens", Kind: NodeKindExploration, {NodeID: "dragons_lair.kobold_lookout", Kind: NodeKindExploration, RegionID: r1,
Label: "Kobold Warrens", PosX: 1, PosY: 1}, Label: "Kobold Lookout", PosX: 1, PosY: 1},
{NodeID: "dragons_lair.drake_pens", Kind: NodeKindExploration, {NodeID: "dragons_lair.kobold_warrens", Kind: NodeKindExploration, RegionID: r1,
Label: "Drake Pens", PosX: 2, PosY: 1}, Label: "Kobold Warrens", PosX: 2, PosY: 1},
{NodeID: "dragons_lair.fork1", Kind: NodeKindFork, {NodeID: "dragons_lair.smoke_choked_hall", Kind: NodeKindExploration, RegionID: r1,
Label: "The Cinder Crossing", PosX: 3, PosY: 1}, Label: "Smoke-Choked Hall", PosX: 3, PosY: 1},
{NodeID: "dragons_lair.ash_bridge", Kind: NodeKindTrap, {NodeID: "dragons_lair.ash_chapel", Kind: NodeKindExploration, RegionID: r1,
Label: "Ash Bridge", PosX: 4, PosY: 0}, Label: "Ash Chapel", PosX: 4, PosY: 1},
{NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration, {NodeID: "dragons_lair.kobold_warchief_camp", Kind: NodeKindExploration, RegionID: r1,
Label: "Treasure Vault", PosX: 4, PosY: 2}, Label: "Warchief's Camp", PosX: 5, PosY: 1},
{NodeID: "dragons_lair.wyrmlings_nest", Kind: NodeKindElite, {NodeID: "dragons_lair.ember_corridor", Kind: NodeKindExploration, RegionID: r1,
Label: "Wyrmling's Nest", PosX: 5, PosY: 1}, Label: "Ember Corridor", PosX: 6, PosY: 1},
{NodeID: "dragons_lair.fork2", Kind: NodeKindFork, {NodeID: "dragons_lair.obsidian_steps", Kind: NodeKindExploration, RegionID: r1,
Label: "Hoard Approach", PosX: 6, PosY: 1}, Label: "Obsidian Steps", PosX: 7, PosY: 1},
{NodeID: "dragons_lair.direct_confrontation", Kind: NodeKindExploration, {NodeID: "dragons_lair.cinder_passage", Kind: NodeKindExploration, RegionID: r1,
Label: "Direct Approach", PosX: 7, PosY: 0}, Label: "Cinder Passage", PosX: 8, PosY: 1},
{NodeID: "dragons_lair.dragon_bargain", Kind: NodeKindExploration, {NodeID: "dragons_lair.kobold_descent", Kind: NodeKindExploration, RegionID: r1,
Label: "Words With Infernax", PosX: 7, PosY: 1}, Label: "Kobold Descent", PosX: 9, PosY: 1},
{NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret,
Label: "Hidden Hoard Pillar", PosX: 7, PosY: 2, // 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}}, Content: ZoneNodeContent{LootBias: 2.5}},
{NodeID: "dragons_lair.boss", Kind: NodeKindBoss, IsBoss: true, {NodeID: "dragons_lair.secret_arch", Kind: NodeKindExploration, RegionID: r4,
Label: "Infernax's Crown", PosX: 8, PosY: 1}, 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{ edges := []ZoneEdge{
{From: "dragons_lair.entry", To: "dragons_lair.kobold_warrens", Lock: LockNone}, // R1 preamble.
{From: "dragons_lair.kobold_warrens", To: "dragons_lair.drake_pens", Lock: LockNone}, {From: "dragons_lair.entry", To: "dragons_lair.kobold_lookout", Lock: LockNone},
{From: "dragons_lair.drake_pens", To: "dragons_lair.fork1", Lock: LockNone}, {From: "dragons_lair.kobold_lookout", To: "dragons_lair.kobold_warrens", Lock: LockNone},
// Fork1 — converges at wyrmlings_nest. {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.ash_bridge", Lock: LockNone, Weight: 1},
{From: "dragons_lair.fork1", To: "dragons_lair.treasure_vault", {From: "dragons_lair.fork1", To: "dragons_lair.treasure_vault",
Lock: LockPerception, LockData: map[string]any{"dc": 15}, 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}, 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}, // ash_bridge spur.
{From: "dragons_lair.wyrmlings_nest", To: "dragons_lair.fork2", Lock: LockNone}, {From: "dragons_lair.ash_bridge", To: "dragons_lair.burning_span", Lock: LockNone},
// Fork2 — capstone 3-way. {From: "dragons_lair.burning_span", To: "dragons_lair.cinder_walk", Lock: LockNone},
{From: "dragons_lair.fork2", To: "dragons_lair.direct_confrontation", Lock: LockNone, Weight: 1}, {From: "dragons_lair.cinder_walk", To: "dragons_lair.wyrmlings_nest", Lock: LockNone},
{From: "dragons_lair.fork2", To: "dragons_lair.dragon_bargain",
// 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}, Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 16},
Hint: "Infernax speaks. The voice fills the chamber. You could speak back.", Weight: 2}, 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}, Lock: LockPerception, LockData: map[string]any{"dc": 17},
Hint: "a single coin standing on edge — a draft, not a tremor", Weight: 3}, 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}, // direct_confrontation spur → merge.
{From: "dragons_lair.hoard_pillar", To: "dragons_lair.boss", Lock: LockNone}, {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) return BuildGraph(ZoneDragonsLair, nodes, edges)
} }

View File

@@ -7,19 +7,70 @@ func TestDragonsLairGraph_Registered(t *testing.T) {
if !ok { if !ok {
t.Fatal("zoneDragonsLairGraph not registered") t.Fatal("zoneDragonsLairGraph not registered")
} }
if len(g.Nodes) != 12 { // Long-expedition D1-e widened this zone from 12 → 47 nodes so the
t.Errorf("nodes = %d, want 12", len(g.Nodes)) // 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 func TestDragonsLairGraph_LongestPathInBand(t *testing.T) {
// converges at wyrmlings_nest. 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) { func TestDragonsLairGraph_Fork1Converges(t *testing.T) {
g := zoneDragonsLairGraph() g := zoneDragonsLairGraph()
for _, mid := range []string{"dragons_lair.ash_bridge", "dragons_lair.treasure_vault"} { for _, spurTail := range []string{"dragons_lair.cinder_walk", "dragons_lair.vault_passage"} {
outs := g.outgoingEdges(mid) outs := g.outgoingEdges(spurTail)
if len(outs) != 1 || outs[0].To != "dragons_lair.wyrmlings_nest" { 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). // Locks escalate: open / CHA 16 / Perception 17 (T5 secret bias).
for _, e := range outs { 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 { 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)
} }
} }
} }

View File

@@ -1,77 +1,275 @@
package plugin 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 // Long-expedition plan D1-d: extended from the original 9-node sketch
// second-stage fork; the inner nodes partially overlap so the player's // to the new T4 length band (2834 rooms traversed). Topology preserves
// first choice does not fully determine which second-stage rooms they // the G8f design intent — woven first-stage forks (CHA vs Perception,
// can reach. Captures the Feywild theme of "the path you walked is // no LockNone), hag_circle shared between the two first-stage paths,
// not the path you arrived on." // time_eddy grove-exclusive, illusion_garden marsh-exclusive — and
// adds the linear depth each arm now needs.
// //
// entry → twilight_path → fork1 // New in D1-d: a single fae_court MERGE node gathers all three second-
// ├─[CHA DC 14]── glamoured_grove ─┬─ hag_circle (elite) ─┐ // stage endings (hag_circle / time_eddy / illusion_garden) before the
// │ └─[DEX DC 15]── time_eddy ─┤ // final walk to boss. The Court of the Antlered Queen is one venue, so
// │ ├── boss // the convergence reads thematically; this also avoids triplicating the
// └─[Perception DC 14]── wisp_marsh ─┬─ hag_circle ──────────┤ // long pre-boss approach.
// └─[Perception DC 16]── illusion_garden (secret) ─┘
// //
// hag_circle is reachable from BOTH first-stage paths — the elite // Also new: cursed_thicket TRAP anchor in the preamble — every walk
// encounter is the "you'll meet her either way" room. time_eddy is // hits it. The original G8f graph had no Trap node.
// only reachable via the grove (CHA path); illusion_garden is only
// reachable via the marsh (Perception path).
// //
// First authored zone using LockStatCheck CHA — every prior secret/ // Preamble (10 nodes, ending in fork1):
// gate has been Perception/STR/INT/DEX. Both fork1 entries are locked // entry → twilight_path → veiled_glade → faerie_lights →
// (no LockNone option from fork1) which is the first instance of "no // cursed_thicket (TRAP) → revel_road → moonshadow_bridge →
// free choice"; the player must commit to the lock-check style they're // bargain_walk → fey_market → fork1
// stronger at. //
// Fork1 → both options locked (CHA vs Perception, no free choice):
//
// Grove approach (8 nodes, CHA DC 14):
// 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 { func zoneFeywildCrossingGraph() ZoneGraph {
nodes := []ZoneNode{ nodes := []ZoneNode{
// Preamble.
{NodeID: "feywild_crossing.entry", Kind: NodeKindEntry, IsEntry: true, {NodeID: "feywild_crossing.entry", Kind: NodeKindEntry, IsEntry: true,
Label: "Veil's Edge", PosX: 0, PosY: 2}, Label: "Veil's Edge", PosX: 0, PosY: 2},
{NodeID: "feywild_crossing.twilight_path", Kind: NodeKindExploration, {NodeID: "feywild_crossing.twilight_path", Kind: NodeKindExploration,
Label: "Twilight Path", PosX: 1, PosY: 2}, 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, {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, {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, {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, {NodeID: "feywild_crossing.time_eddy", Kind: NodeKindExploration,
Label: "Time Eddy", PosX: 4, PosY: 0}, Label: "Time Eddy", PosX: 21, PosY: 0},
{NodeID: "feywild_crossing.hag_circle", Kind: NodeKindElite,
Label: "Hag Circle", PosX: 4, PosY: 2}, // 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, {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}}, 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, {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{ edges := []ZoneEdge{
// Preamble.
{From: "feywild_crossing.entry", To: "feywild_crossing.twilight_path", Lock: LockNone}, {From: "feywild_crossing.entry", To: "feywild_crossing.twilight_path", Lock: LockNone},
{From: "feywild_crossing.twilight_path", To: "feywild_crossing.fork1", Lock: LockNone}, {From: "feywild_crossing.twilight_path", To: "feywild_crossing.veiled_glade", Lock: LockNone},
// Fork1 — both options are locked (CHA vs. Perception). {From: "feywild_crossing.veiled_glade", To: "feywild_crossing.faerie_lights", Lock: LockNone},
{From: "feywild_crossing.fork1", To: "feywild_crossing.glamoured_grove", {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 — both options locked (CHA vs. Perception, no LockNone).
{From: "feywild_crossing.fork1", To: "feywild_crossing.grove_threshold",
Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14}, Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14},
Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1}, Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1},
{From: "feywild_crossing.fork1", To: "feywild_crossing.wisp_marsh", {From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold",
Lock: LockPerception, LockData: map[string]any{"dc": 14}, Lock: LockPerception, LockData: map[string]any{"dc": 14},
Hint: "wisp-light flickering between two trees — they look like the same tree", Weight: 1}, 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}, // Grove approach.
{From: "feywild_crossing.glamoured_grove", To: "feywild_crossing.time_eddy", {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}, Lock: LockStatCheck, LockData: map[string]any{"stat": "DEX", "dc": 15},
Hint: "the seconds run sideways here — sidestep at the right one", Weight: 2}, 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.hag_corridor_a", To: "feywild_crossing.dryad_promenade", Lock: LockNone},
{From: "feywild_crossing.wisp_marsh", To: "feywild_crossing.hag_circle", Lock: LockNone, Weight: 1}, {From: "feywild_crossing.dryad_promenade", To: "feywild_crossing.moonbridge_a", Lock: LockNone},
{From: "feywild_crossing.wisp_marsh", To: "feywild_crossing.illusion_garden", {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}, Lock: LockPerception, LockData: map[string]any{"dc": 16},
Hint: "a garden visible only when you don't look directly at it", Weight: 2}, 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_corridor_b", To: "feywild_crossing.fen_walk", Lock: LockNone},
{From: "feywild_crossing.hag_circle", To: "feywild_crossing.boss", Lock: LockNone}, {From: "feywild_crossing.fen_walk", To: "feywild_crossing.moonbridge_b", Lock: LockNone},
{From: "feywild_crossing.time_eddy", To: "feywild_crossing.boss", Lock: LockNone}, {From: "feywild_crossing.moonbridge_b", To: "feywild_crossing.hag_circle", Lock: LockNone},
{From: "feywild_crossing.illusion_garden", To: "feywild_crossing.boss", 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) return BuildGraph(ZoneFeywildCrossing, nodes, edges)
} }

View File

@@ -7,8 +7,18 @@ func TestFeywildCrossingGraph_Registered(t *testing.T) {
if !ok { if !ok {
t.Fatal("zoneFeywildCrossingGraph not registered") t.Fatal("zoneFeywildCrossingGraph not registered")
} }
if len(g.Nodes) != 9 { // Long-expedition D1-d widened this zone from 9 → 53 nodes so the
t.Errorf("nodes = %d, want 9", len(g.Nodes)) // 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)
} }
} }
@@ -73,3 +83,38 @@ func TestFeywildCrossingGraph_FirstCHALock(t *testing.T) {
t.Error("expected at least one LockStatCheck CHA edge — Feywild theme") 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")
}
}

View File

@@ -1,64 +1,111 @@
package plugin 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 // Long-expedition plan D1-b: extended from the original 9-node sketch to
// session: deliberately a different shape from the Crypt diamond. // the new T2 length band (1620 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 // Asymmetry preserved: long branch is 3 mid-nodes (grove_descent →
// ├─[unlocked, w=1]── grove_descent → dryad_circle (elite) ─┐ // dryad_circle (elite) → torn_meadow), short branch is 2 (thorn_tunnel →
// │ ├── fork2 // briar_warren). Longest entry→boss walk = 16 (long branch path).
// └─[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.
func zoneForestShadowsGraph() ZoneGraph { func zoneForestShadowsGraph() ZoneGraph {
nodes := []ZoneNode{ nodes := []ZoneNode{
{NodeID: "forest_shadows.entry", Kind: NodeKindEntry, IsEntry: true, {NodeID: "forest_shadows.entry", Kind: NodeKindEntry, IsEntry: true,
Label: "Forest Edge", PosX: 0, PosY: 1}, 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, {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, {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, {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, {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, {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, {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, {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}}, Content: ZoneNodeContent{LootBias: 2.0}},
{NodeID: "forest_shadows.boss", Kind: NodeKindBoss, IsBoss: true, {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{ edges := []ZoneEdge{
{From: "forest_shadows.entry", To: "forest_shadows.twisted_path", Lock: LockNone}, {From: "forest_shadows.entry", To: "forest_shadows.mossy_threshold", Lock: LockNone},
{From: "forest_shadows.twisted_path", To: "forest_shadows.fork1", 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.grove_descent", Lock: LockNone, Weight: 1},
{From: "forest_shadows.fork1", To: "forest_shadows.thorn_tunnel", {From: "forest_shadows.fork1", To: "forest_shadows.thorn_tunnel",
Lock: LockPerception, LockData: map[string]any{"dc": 13}, Lock: LockPerception, LockData: map[string]any{"dc": 13},
Hint: "a deer-track winding into thicker brush", Weight: 2}, 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.grove_descent", To: "forest_shadows.dryad_circle", Lock: LockNone},
{From: "forest_shadows.dryad_circle", To: "forest_shadows.fork2", Lock: LockNone}, {From: "forest_shadows.dryad_circle", To: "forest_shadows.torn_meadow", Lock: LockNone},
{From: "forest_shadows.thorn_tunnel", To: "forest_shadows.fork2", Lock: LockNone}, {From: "forest_shadows.torn_meadow", To: "forest_shadows.fork2", Lock: LockNone},
{From: "forest_shadows.fork2", To: "forest_shadows.boss", Lock: LockNone, Weight: 1},
{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", {From: "forest_shadows.fork2", To: "forest_shadows.sapling_shrine",
Lock: LockStatCheck, LockData: map[string]any{"stat": "WIS", "dc": 14}, Lock: LockStatCheck, LockData: map[string]any{"stat": "WIS", "dc": 14},
Hint: "a humming you can almost place — antlers, somewhere", Weight: 2}, 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}, {From: "forest_shadows.sapling_shrine", To: "forest_shadows.boss", Lock: LockNone},
} }
return BuildGraph(ZoneForestShadows, nodes, edges) return BuildGraph(ZoneForestShadows, nodes, edges)

View File

@@ -13,8 +13,10 @@ func TestForestShadowsGraph_Registered(t *testing.T) {
if g.Boss != "forest_shadows.boss" { if g.Boss != "forest_shadows.boss" {
t.Errorf("boss node = %q, want forest_shadows.boss", g.Boss) t.Errorf("boss node = %q, want forest_shadows.boss", g.Boss)
} }
if len(g.Nodes) != 9 { // Long-expedition D1-b widened this zone from 9 → 19 nodes so the
t.Errorf("nodes = %d, want 9", len(g.Nodes)) // 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() g := zoneForestShadowsGraph()
longLen := bfsHops(g, "forest_shadows.fork1", "forest_shadows.fork2", "forest_shadows.grove_descent") 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") shortLen := bfsHops(g, "forest_shadows.fork1", "forest_shadows.fork2", "forest_shadows.thorn_tunnel")
if longLen != 3 { // D1-b: long branch is now 3 mid-nodes (grove_descent → dryad_circle
t.Errorf("long branch hops = %d, want 3 (grove_descent → dryad_circle → fork2)", longLen) // → 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 { if shortLen != 3 {
t.Errorf("short branch hops = %d, want 2 (thorn_tunnel → fork2)", shortLen) t.Errorf("short branch hops = %d, want 3 (thorn_tunnel → briar_warren → fork2)", shortLen)
} }
if longLen <= shortLen { if longLen <= shortLen {
t.Errorf("expected asymmetric branches (long > short); got long=%d short=%d", longLen, shortLen) t.Errorf("expected asymmetric branches (long > short); got long=%d short=%d", longLen, shortLen)

View File

@@ -1,49 +1,91 @@
package plugin package plugin
// Phase G8a — Goblin Warrens branching graph. // Goblin Warrens branching graph.
// //
// First non-POC zone. T1 design constraint per // Long-expedition plan D1: extended from the original 7-node sketch to
// gogobee_branching_zones_plan.md §G8: one fork per zone for T1, no // the new T1 length band (1214 rooms traversed). Topology preserves the
// secret (the secret pattern is exercised by Crypt of Valdris; Goblin // original single-fork "Perception skips the elite" shape — the warband
// Warrens deliberately validates the bare single-fork shape on a // branch is the default path and houses the zone's only Elite; the
// non-POC zone so we can confirm legacy compileLegacyZoneGraph fully // collapsed-shaft branch is hint-gated, elite-free, and roughly equal in
// hands off to the authored graph end-to-end). // node count so the two routes feel balanced.
// //
// entry → guard_post → fork // entry → tunnel_descent → guard_post → snare_trap (TRAP) →
// ├──[unlocked]── warband_pit (elite) ──┐ // patrol_route → cavern_junction (FORK)
// │ ├── warchief_hall → boss // ├──[unlocked]── warband_pit (ELITE)
// └──[Perception DC 12]── collapsed_shaft ┘ // │ → 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: // Per-tier room budget: 16 graph nodes total; longest-path traversal is
// "Locked paths should always have a hint — cruel design otherwise"). // 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 { func zoneGoblinWarrensGraph() ZoneGraph {
nodes := []ZoneNode{ nodes := []ZoneNode{
{NodeID: "goblin_warrens.entry", Kind: NodeKindEntry, IsEntry: true, {NodeID: "goblin_warrens.entry", Kind: NodeKindEntry, IsEntry: true,
Label: "Tunnel Mouth", PosX: 0, PosY: 1}, 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, {NodeID: "goblin_warrens.guard_post", Kind: NodeKindExploration,
Label: "Goblin Guard Post", PosX: 1, PosY: 1}, Label: "Goblin Guard Post", PosX: 2, PosY: 1},
{NodeID: "goblin_warrens.fork", Kind: NodeKindFork, {NodeID: "goblin_warrens.snare_trap", Kind: NodeKindTrap,
Label: "Cavern Junction", PosX: 2, PosY: 1}, 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, {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, {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, {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, {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{ edges := []ZoneEdge{
{From: "goblin_warrens.entry", To: "goblin_warrens.guard_post", Lock: LockNone}, {From: "goblin_warrens.entry", To: "goblin_warrens.tunnel_descent", Lock: LockNone},
{From: "goblin_warrens.guard_post", To: "goblin_warrens.fork", Lock: LockNone}, {From: "goblin_warrens.tunnel_descent", To: "goblin_warrens.guard_post", Lock: LockNone},
{From: "goblin_warrens.fork", To: "goblin_warrens.warband_pit", Lock: LockNone, Weight: 1}, {From: "goblin_warrens.guard_post", To: "goblin_warrens.snare_trap", Lock: LockNone},
{From: "goblin_warrens.fork", To: "goblin_warrens.collapsed_shaft", {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}, Lock: LockPerception, LockData: map[string]any{"dc": 12},
Hint: "a muffled scrape behind a heap of fallen timbers", Weight: 2}, 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.warband_pit", To: "goblin_warrens.trophy_chamber", Lock: LockNone},
{From: "goblin_warrens.warchief_hall", To: "goblin_warrens.boss", 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) return BuildGraph(ZoneGoblinWarrens, nodes, edges)
} }

View File

@@ -13,8 +13,10 @@ func TestGoblinWarrensGraph_Registered(t *testing.T) {
if g.Boss != "goblin_warrens.boss" { if g.Boss != "goblin_warrens.boss" {
t.Errorf("boss node = %q, want goblin_warrens.boss", g.Boss) t.Errorf("boss node = %q, want goblin_warrens.boss", g.Boss)
} }
if len(g.Nodes) != 7 { // Long-expedition D1 widened this zone from 7 → 16 nodes so both
t.Errorf("nodes = %d, want 7", len(g.Nodes)) // 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) { func TestGoblinWarrensGraph_ForkLayout(t *testing.T) {
g := zoneGoblinWarrensGraph() g := zoneGoblinWarrensGraph()
outs := g.outgoingEdges("goblin_warrens.fork") outs := g.outgoingEdges("goblin_warrens.cavern_junction")
if len(outs) != 2 { if len(outs) != 2 {
t.Fatalf("fork outgoing edges = %d, want 2", len(outs)) t.Fatalf("fork outgoing edges = %d, want 2", len(outs))
} }

View File

@@ -1,68 +1,172 @@
package plugin 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 // Long-expedition plan D1-c: extended from the original 11-node sketch to
// "two forks", and a Manor of corridors and locked doors fits a wide- // the new T3 length band (2226 rooms traversed). Topology preserves the
// fork hub-and-spoke pattern more naturally than a binary fork. Two // G8d design intent — two stacked 3-way forks ("first zone where the
// hub nodes (great_hall, upper_hall), each with three options: // 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) // entry → grounds_walk → carriage_path → manor_gate → foyer →
// ├─[unlocked]── portrait_gallery ─┐ // drawing_room → gallery_corridor → cursed_threshold (TRAP) →
// ├─[Perception DC 14]── study ────┼── upper_hall (3-way) // grand_staircase → great_hall (3-way fork)
// └─[INT DC 14]── library ─────────┘ ├─[unlocked]── master_bedroom (elite) → boss // ├─[unlocked]── portrait_gallery → cold_solarium → west_landing ─┐
// ├─[Perception DC 15]── hidden_oratory (secret) → boss // ├─[Perception DC 14]── locked_study → study_archive → │
// └─[LevelMin 7]── tower_observatory → boss // 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 // All six fork branches are 3 mid-nodes long so any route through the
// the player picks twice in a row from a wide menu — distinct from the // manor lands at the same 23-node traversal length — the player's choice
// binary forks of G8a/b and the parallel Y-trees of G8c. Exercises // is about loot/encounter character, not shortcut economics. The ELITE
// LockLevelMin (first authored use) so all four non-trivial lock kinds // hangs off the open spoke of upper_hall (cheapest gate, hardest fight);
// (Perception, StatCheck, LevelMin, plus implicit LockNone) appear by // the SECRET sits behind the highest-DC Perception with its loot bias
// G8d. // preserved.
func zoneManorBlackspireGraph() ZoneGraph { func zoneManorBlackspireGraph() ZoneGraph {
nodes := []ZoneNode{ nodes := []ZoneNode{
// Pre-fork preamble (path positions 110).
{NodeID: "manor_blackspire.entry", Kind: NodeKindEntry, IsEntry: true, {NodeID: "manor_blackspire.entry", Kind: NodeKindEntry, IsEntry: true,
Label: "Manor Gate", PosX: 0, PosY: 2}, 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, {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, {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, {NodeID: "manor_blackspire.portrait_gallery", Kind: NodeKindExploration,
Label: "Portrait Gallery", PosX: 3, PosY: 1}, Label: "Portrait Gallery", PosX: 10, PosY: 0},
{NodeID: "manor_blackspire.study", Kind: NodeKindExploration, {NodeID: "manor_blackspire.cold_solarium", Kind: NodeKindExploration,
Label: "Locked Study", PosX: 3, PosY: 2}, Label: "Cold Solarium", PosX: 11, PosY: 0},
{NodeID: "manor_blackspire.library", Kind: NodeKindExploration, {NodeID: "manor_blackspire.west_landing", Kind: NodeKindExploration,
Label: "Forbidden Library", PosX: 3, PosY: 3}, 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, {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, {NodeID: "manor_blackspire.master_bedroom", Kind: NodeKindElite,
Label: "Master Bedroom", PosX: 5, PosY: 1}, Label: "Master Bedroom", PosX: 16, PosY: 0},
{NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration, {NodeID: "manor_blackspire.grim_balcony", Kind: NodeKindExploration,
Label: "Tower Observatory", PosX: 5, PosY: 2}, 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, {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}}, 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, {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{ edges := []ZoneEdge{
{From: "manor_blackspire.entry", To: "manor_blackspire.foyer", Lock: LockNone}, // Preamble (linear).
{From: "manor_blackspire.foyer", To: "manor_blackspire.great_hall", Lock: LockNone}, {From: "manor_blackspire.entry", To: "manor_blackspire.grounds_walk", Lock: LockNone},
// Great Hall — 3-way: portrait (open), study (perception), library (intelligence) {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.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}, Lock: LockPerception, LockData: map[string]any{"dc": 14},
Hint: "an out-of-place draft from a doorframe", Weight: 2}, 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}, Lock: LockStatCheck, LockData: map[string]any{"stat": "INT", "dc": 14},
Hint: "a runed door — you'd need to read it", Weight: 2}, Hint: "a runed door — you'd need to read it", Weight: 3},
// All three converge at upper_hall.
{From: "manor_blackspire.portrait_gallery", To: "manor_blackspire.upper_hall", Lock: LockNone}, // Portrait spoke.
{From: "manor_blackspire.study", To: "manor_blackspire.upper_hall", Lock: LockNone}, {From: "manor_blackspire.portrait_gallery", To: "manor_blackspire.cold_solarium", Lock: LockNone},
{From: "manor_blackspire.library", To: "manor_blackspire.upper_hall", Lock: LockNone}, {From: "manor_blackspire.cold_solarium", To: "manor_blackspire.west_landing", Lock: LockNone},
// Upper Hall — 3-way: master_bedroom (open elite), hidden_oratory (high perception secret), tower (level-gated) {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.master_bedroom", Lock: LockNone, Weight: 1},
{From: "manor_blackspire.upper_hall", To: "manor_blackspire.hidden_oratory", {From: "manor_blackspire.upper_hall", To: "manor_blackspire.hidden_oratory",
Lock: LockPerception, LockData: map[string]any{"dc": 15}, 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", {From: "manor_blackspire.upper_hall", To: "manor_blackspire.tower_observatory",
Lock: LockLevelMin, LockData: map[string]any{"min_level": 7}, 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}, 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}, // Master Bedroom spoke (elite).
{From: "manor_blackspire.tower_observatory", To: "manor_blackspire.boss", Lock: LockNone}, {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) return BuildGraph(ZoneManorBlackspire, nodes, edges)
} }

View File

@@ -10,8 +10,10 @@ func TestManorBlackspireGraph_Registered(t *testing.T) {
if g.Entry != "manor_blackspire.entry" { if g.Entry != "manor_blackspire.entry" {
t.Errorf("entry node = %q", g.Entry) t.Errorf("entry node = %q", g.Entry)
} }
if len(g.Nodes) != 11 { // Long-expedition D1-c widened this zone from 11 → 35 nodes so the
t.Errorf("nodes = %d, want 11", len(g.Nodes)) // 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) { func TestManorBlackspireGraph_AllSpokesReachBoss(t *testing.T) {
g := zoneManorBlackspireGraph() g := zoneManorBlackspireGraph()
for _, leaf := range []string{ for _, leaf := range []string{
"manor_blackspire.master_bedroom", // great_hall spokes (entry of each 3-node branch).
"manor_blackspire.tower_observatory",
"manor_blackspire.hidden_oratory",
"manor_blackspire.portrait_gallery", "manor_blackspire.portrait_gallery",
"manor_blackspire.study", "manor_blackspire.locked_study",
"manor_blackspire.library", "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") { if !reachable(g, leaf, "manor_blackspire.boss") {
t.Errorf("%s unreachable to boss", leaf) t.Errorf("%s unreachable to boss", leaf)
@@ -46,9 +50,9 @@ func TestManorBlackspireGraph_AllSpokesReachBoss(t *testing.T) {
} }
} }
// TestManorBlackspireGraph_LockLevelMinFirstUse verifies this zone is // TestManorBlackspireGraph_LockLevelMinFirstUse verifies the tower spoke
// the first to author LockLevelMin, completing lock-kind coverage // still carries the LockLevelMin gate so all four lock kinds (None,
// (Perception, StatCheck, LevelMin, LockNone) by G8d. // Perception, StatCheck, LevelMin) remain authored within this zone.
func TestManorBlackspireGraph_LockLevelMinFirstUse(t *testing.T) { func TestManorBlackspireGraph_LockLevelMinFirstUse(t *testing.T) {
g := zoneManorBlackspireGraph() g := zoneManorBlackspireGraph()
var levelMinEdge *ZoneEdge var levelMinEdge *ZoneEdge
@@ -69,3 +73,29 @@ func TestManorBlackspireGraph_LockLevelMinFirstUse(t *testing.T) {
t.Error("level-gated edge missing hint") 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)
}
}
}

View File

@@ -1,23 +1,30 @@
package plugin 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 // Long-expedition plan D1-b: extended from the original 10-node sketch to
// paths to the boss; the player commits at fork1 (dry vs. wet) and // the new T2 length band (1620 rooms traversed). Topology preserves the
// commits again at fork2a/fork2b. Path is unique up to the boss room. // 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 // entry → flooded_atrium → tide_passage → kelp_trap (TRAP) →
// ├─[unlocked, w=1]── fork2a (dry) // barnacled_steps → drowned_atrium → sunken_nave → fork1
// │ ├─[unlocked]── kuo_toa_pen (elite) → boss // ├─[unlocked, w=1]── dry_corridor → silent_columns →
// │ └─[STR DC 13]── glyph_chamber → boss // │ echoing_vault → fork2a
// └─[Perception DC 13, w=2]── fork2b (wet) // ├─[unlocked]── kuo_toa_pen (ELITE) →
// ├─[unlocked]── aboleth_thralls → boss // trophy_pool → idol_court → boss
// └─[Perception DC 15]── coral_reliquary (secret) → 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: // Distinct from the Crypt diamond and the Forest asymmetric-diamond: no
// no two paths share a mid-node, so the !zone map paints two parallel // two paths share a mid-node, so the !zone map paints two parallel "Y"
// "Y" subtrees instead of a converging branch. The four leaves all // subtrees instead of a converging branch. Longest entry→boss = 16.
// terminate at the boss (validator requires exactly one boss node).
func zoneSunkenTempleGraph() ZoneGraph { func zoneSunkenTempleGraph() ZoneGraph {
nodes := []ZoneNode{ nodes := []ZoneNode{
@@ -25,42 +32,104 @@ func zoneSunkenTempleGraph() ZoneGraph {
Label: "Tide-Stained Threshold", PosX: 0, PosY: 2}, Label: "Tide-Stained Threshold", PosX: 0, PosY: 2},
{NodeID: "sunken_temple.flooded_atrium", Kind: NodeKindExploration, {NodeID: "sunken_temple.flooded_atrium", Kind: NodeKindExploration,
Label: "Flooded Atrium", PosX: 1, PosY: 2}, 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, {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, {NodeID: "sunken_temple.fork2a", Kind: NodeKindFork,
Label: "Dry Crossing", PosX: 3, PosY: 0}, Label: "Dry Crossing", PosX: 11, PosY: 0},
{NodeID: "sunken_temple.fork2b", Kind: NodeKindFork,
Label: "Submerged Crossing", PosX: 3, PosY: 4},
{NodeID: "sunken_temple.kuo_toa_pen", Kind: NodeKindElite, {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, {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, {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, {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}}, Content: ZoneNodeContent{LootBias: 1.8}},
{NodeID: "sunken_temple.boss", Kind: NodeKindBoss, IsBoss: true, {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{ edges := []ZoneEdge{
{From: "sunken_temple.entry", To: "sunken_temple.flooded_atrium", Lock: LockNone}, {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.flooded_atrium", To: "sunken_temple.tide_passage", Lock: LockNone},
{From: "sunken_temple.fork1", To: "sunken_temple.fork2a", Lock: LockNone, Weight: 1}, {From: "sunken_temple.tide_passage", To: "sunken_temple.kelp_trap", Lock: LockNone},
{From: "sunken_temple.fork1", To: "sunken_temple.fork2b", {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}, Lock: LockPerception, LockData: map[string]any{"dc": 13},
Hint: "wet stone glistens down a side passage", Weight: 2}, 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.kuo_toa_pen", Lock: LockNone, Weight: 1},
{From: "sunken_temple.fork2a", To: "sunken_temple.glyph_chamber", {From: "sunken_temple.fork2a", To: "sunken_temple.glyph_chamber",
Lock: LockStatCheck, LockData: map[string]any{"stat": "STR", "dc": 13}, Lock: LockStatCheck, LockData: map[string]any{"stat": "STR", "dc": 13},
Hint: "a stone door wedged half-shut by silt", Weight: 2}, 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.aboleth_thralls", Lock: LockNone, Weight: 1},
{From: "sunken_temple.fork2b", To: "sunken_temple.coral_reliquary", {From: "sunken_temple.fork2b", To: "sunken_temple.coral_reliquary",
Lock: LockPerception, LockData: map[string]any{"dc": 15}, Lock: LockPerception, LockData: map[string]any{"dc": 15},
Hint: "a coral arch glittering under the surface", Weight: 2}, 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.thrall_basin", Lock: LockNone},
{From: "sunken_temple.aboleth_thralls", To: "sunken_temple.boss", 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}, {From: "sunken_temple.coral_reliquary", To: "sunken_temple.boss", Lock: LockNone},
} }
return BuildGraph(ZoneSunkenTemple, nodes, edges) return BuildGraph(ZoneSunkenTemple, nodes, edges)

View File

@@ -13,8 +13,12 @@ func TestSunkenTempleGraph_Registered(t *testing.T) {
if g.Boss != "sunken_temple.boss" { if g.Boss != "sunken_temple.boss" {
t.Errorf("boss node = %q", g.Boss) t.Errorf("boss node = %q", g.Boss)
} }
if len(g.Nodes) != 10 { // Long-expedition D1-b widened this zone from 10 → 26 nodes so the
t.Errorf("nodes = %d, want 10", len(g.Nodes)) // 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))
} }
} }

View File

@@ -1,31 +1,56 @@
package plugin 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 // Long-expedition plan D1-d: extended from the original 10-node sketch
// one of three distinct regions (R1 deep, R2 drow, R3 illithid), each // to the new T4 length band (2834 rooms traversed). Topology preserves
// with its own internal mid-node, all converging at R4 (deep_throne). // 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 // R1 surface_tunnels preamble (8 nodes):
// ┌─[CON DC 15]── deep_chasm (R1, rich harvest) ──────────┐ // entry → tunnel_descent → moss_corridor → fungal_grove →
// // collapsed_arch (TRAP) → ledge_walk → shrine_to_lolth → fork1
// ├─[unlocked]── drow_patrol (R2) → drow_captain (R2 elite)─┤
// │ ├── throne_approach (R4) → boss (R4)
// └─[Perception DC 16]── psionic_corridor (R3) → mind_flayer (R3 elite)─┘
// //
// Each colored arm crosses a region boundary, exercising the G6 // Fork1 → three regional arms (each crosses a region boundary except
// fireGraphRegionTransition hook end-to-end. The four authored regions // the deep_chasm arm, which intentionally stays in R1 — same as G8i):
// match dnd_expedition_region.go:
// R1 = underdark_surface_tunnels
// R2 = underdark_drow_outpost
// R3 = underdark_illithid_warren
// R4 = underdark_deep_throne
// //
// Distinct from prior zones in two ways: // R2 drow_outpost arm (12 nodes):
// 1. First (and only) authored zone with non-empty RegionID on every // drow_patrol → drow_picket → corridor_of_eyes → spider_warren →
// node. Region transitions fire on fork1→{drow_patrol|psionic_corridor} // web_passage → drow_chapel → drow_armory → drow_captain (ELITE) →
// and on the elite→throne_approach edges. // captain_quarters → drow_descent → drow_passage → drow_gate
// 2. Convergent triangle: three colored arms, one merge. //
// 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 { func zoneUnderdarkGraph() ZoneGraph {
r1 := "underdark_surface_tunnels" r1 := "underdark_surface_tunnels"
@@ -34,32 +59,120 @@ func zoneUnderdarkGraph() ZoneGraph {
r4 := "underdark_deep_throne" r4 := "underdark_deep_throne"
nodes := []ZoneNode{ nodes := []ZoneNode{
// R1 preamble.
{NodeID: "underdark.entry", Kind: NodeKindEntry, IsEntry: true, RegionID: r1, {NodeID: "underdark.entry", Kind: NodeKindEntry, IsEntry: true, RegionID: r1,
Label: "Cave Mouth", PosX: 0, PosY: 2}, Label: "Cave Mouth", PosX: 0, PosY: 2},
{NodeID: "underdark.tunnel_descent", Kind: NodeKindExploration, RegionID: r1, {NodeID: "underdark.tunnel_descent", Kind: NodeKindExploration, RegionID: r1,
Label: "Tunnel Descent", PosX: 1, PosY: 2}, 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, {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, {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}}, 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, {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, {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, {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, {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, {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, {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{ edges := []ZoneEdge{
// R1 preamble.
{From: "underdark.entry", To: "underdark.tunnel_descent", Lock: LockNone}, {From: "underdark.entry", To: "underdark.tunnel_descent", Lock: LockNone},
{From: "underdark.tunnel_descent", To: "underdark.fork1", Lock: LockNone}, {From: "underdark.tunnel_descent", To: "underdark.moss_corridor", Lock: LockNone},
// Fork1 — three regional arms. {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.drow_patrol", Lock: LockNone, Weight: 1},
{From: "underdark.fork1", To: "underdark.psionic_corridor", {From: "underdark.fork1", To: "underdark.psionic_corridor",
Lock: LockPerception, LockData: map[string]any{"dc": 16}, Lock: LockPerception, LockData: map[string]any{"dc": 16},
@@ -67,15 +180,51 @@ func zoneUnderdarkGraph() ZoneGraph {
{From: "underdark.fork1", To: "underdark.deep_chasm", {From: "underdark.fork1", To: "underdark.deep_chasm",
Lock: LockStatCheck, LockData: map[string]any{"stat": "CON", "dc": 15}, Lock: LockStatCheck, LockData: map[string]any{"stat": "CON", "dc": 15},
Hint: "a vertical shaft humming with cold air — the climb will hurt", Weight: 3}, 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. // R2 arm.
{From: "underdark.drow_patrol", To: "underdark.drow_captain", Lock: LockNone}, {From: "underdark.drow_patrol", To: "underdark.drow_picket", Lock: LockNone},
{From: "underdark.drow_captain", To: "underdark.throne_approach", 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. // R3 arm.
{From: "underdark.psionic_corridor", To: "underdark.mind_flayer", Lock: LockNone}, {From: "underdark.psionic_corridor", To: "underdark.whispering_hall", Lock: LockNone},
{From: "underdark.mind_flayer", To: "underdark.throne_approach", Lock: LockNone}, {From: "underdark.whispering_hall", To: "underdark.silenced_chamber", Lock: LockNone},
// R1 deep arm — single node back to merge. {From: "underdark.silenced_chamber", To: "underdark.mind_tank_room", Lock: LockNone},
{From: "underdark.deep_chasm", To: "underdark.throne_approach", Lock: LockNone}, {From: "underdark.mind_tank_room", To: "underdark.thrall_pens", Lock: LockNone},
{From: "underdark.throne_approach", To: "underdark.boss", 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) return BuildGraph(ZoneUnderdark, nodes, edges)
} }

View File

@@ -7,8 +7,18 @@ func TestUnderdarkGraph_Registered(t *testing.T) {
if !ok { if !ok {
t.Fatal("zoneUnderdarkGraph not registered") t.Fatal("zoneUnderdarkGraph not registered")
} }
if len(g.Nodes) != 10 { // Long-expedition D1-d widened this zone from 10 → 46 nodes so the
t.Errorf("nodes = %d, want 10", len(g.Nodes)) // 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) { func TestUnderdarkGraph_AllNodesHaveRegion(t *testing.T) {
g := zoneUnderdarkGraph() g := zoneUnderdarkGraph()
validRegions := map[string]bool{ validRegions := map[string]bool{
"underdark_surface_tunnels": true, "underdark_surface_tunnels": true,
"underdark_drow_outpost": true, "underdark_drow_outpost": true,
"underdark_illithid_warren": true, "underdark_illithid_warren": true,
"underdark_deep_throne": true, "underdark_deep_throne": true,
} }
for id, n := range g.Nodes { for id, n := range g.Nodes {
if n.RegionID == "" { 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)
}
}

View File

@@ -1,67 +1,155 @@
package plugin package plugin
// Phase G8e — The Underforge branching graph. // The Underforge branching graph.
// //
// T3 zone. Shape: pre-boss gauntlet — long linear preamble through the // Long-expedition plan D1-c: extended from the original 10-node sketch
// forge-city, then a single 3-way antechamber fork right before the // to the new T3 length band (2226 rooms traversed). Topology preserves
// boss. The plan §G8 calls for "two forks for T2+"; this zone bends // the G8e design intent — a long one-way descent into Kharak Dûn with a
// that to a single late fork with three options because the gauntlet // single late 3-way antechamber fork — and adds the linear gauntlet
// shape is the design intent (Kharak Dûn is a one-way descent — there // length the new band asks for. Trap (Cooling River) and Elite (Magma
// is no scenic route, only the approach to what was sealed in). The // Chamber) both sit on the descent; the 3-way fork is still the only
// triple-option antechamber preserves player agency at the // decision in the zone, deferred to the room before the boss.
// commitment moment.
// //
// entry → sealed_gate → forge_descent → cooling_river → magma_chamber (elite) → antechamber (3-way) // entry → sealed_threshold → wardstone_arch → forge_descent →
// ├─[unlocked]── direct_assault → boss // vapor_steps → slag_warrens → cooling_river (TRAP) → bellows_chamber
// ├─[DEX DC 14]── catwalks → boss // → cinder_walk → magma_chamber (ELITE) → emberforge_hall →
// └─[Perception DC 15]── forge_vault (secret) → boss // 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: // The 17-node linear preamble is the zone's identity ("Kharak Dûn is a
// 1. Five-node linear preamble (no zone yet has > 2 linear preamble nodes). // one-way descent — there is no scenic route, only the approach to what
// 2. Fork is delayed to the final node before boss; the !zone map // was sealed in"). The autopilot pitches 3 night-camps over the T3 band,
// therefore renders as a long horizontal stem with a 3-leaf fan at // breaking the descent into rest beats; the player still only picks the
// the right edge. // final fork. All three antechamber spokes are 4 mid-nodes so route
// choice is loot/encounter character, not shortcut economics.
func zoneUnderforgeGraph() ZoneGraph { func zoneUnderforgeGraph() ZoneGraph {
nodes := []ZoneNode{ nodes := []ZoneNode{
// Linear descent (path positions 118).
{NodeID: "underforge.entry", Kind: NodeKindEntry, IsEntry: true, {NodeID: "underforge.entry", Kind: NodeKindEntry, IsEntry: true,
Label: "Sealed Threshold", PosX: 0, PosY: 1}, Label: "Sealed Threshold", PosX: 0, PosY: 1},
{NodeID: "underforge.sealed_gate", Kind: NodeKindExploration, {NodeID: "underforge.sealed_threshold", Kind: NodeKindExploration,
Label: "Sealed Gate", PosX: 1, PosY: 1}, 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, {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, {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, {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, {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, {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, {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, {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}}, 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, {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{ edges := []ZoneEdge{
{From: "underforge.entry", To: "underforge.sealed_gate", Lock: LockNone}, // Linear descent.
{From: "underforge.sealed_gate", To: "underforge.forge_descent", Lock: LockNone}, {From: "underforge.entry", To: "underforge.sealed_threshold", Lock: LockNone},
{From: "underforge.forge_descent", To: "underforge.cooling_river", Lock: LockNone}, {From: "underforge.sealed_threshold", To: "underforge.wardstone_arch", Lock: LockNone},
{From: "underforge.cooling_river", To: "underforge.magma_chamber", Lock: LockNone}, {From: "underforge.wardstone_arch", To: "underforge.forge_descent", Lock: LockNone},
{From: "underforge.magma_chamber", To: "underforge.antechamber", 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.direct_assault", Lock: LockNone, Weight: 1},
{From: "underforge.antechamber", To: "underforge.catwalks", {From: "underforge.antechamber", To: "underforge.catwalks",
Lock: LockStatCheck, LockData: map[string]any{"stat": "DEX", "dc": 14}, Lock: LockStatCheck, LockData: map[string]any{"stat": "DEX", "dc": 14},
Hint: "rusted catwalks above the magma — quick footing required", Weight: 2}, Hint: "rusted catwalks above the magma — quick footing required", Weight: 2},
{From: "underforge.antechamber", To: "underforge.forge_vault", {From: "underforge.antechamber", To: "underforge.forge_vault",
Lock: LockPerception, LockData: map[string]any{"dc": 15}, Lock: LockPerception, LockData: map[string]any{"dc": 15},
Hint: "a draft from a stone seam, where no draft should be", Weight: 2}, Hint: "a draft from a stone seam, where no draft should be", Weight: 3},
{From: "underforge.direct_assault", To: "underforge.boss", Lock: LockNone},
{From: "underforge.catwalks", To: "underforge.boss", Lock: LockNone}, // Direct assault spoke.
{From: "underforge.forge_vault", To: "underforge.boss", Lock: LockNone}, {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) return BuildGraph(ZoneUnderforge, nodes, edges)
} }

View File

@@ -7,23 +7,39 @@ func TestUnderforgeGraph_Registered(t *testing.T) {
if !ok { if !ok {
t.Fatal("zoneUnderforgeGraph not registered") t.Fatal("zoneUnderforgeGraph not registered")
} }
if len(g.Nodes) != 10 { // Long-expedition D1-c widened this zone from 10 → 31 nodes so the
t.Errorf("nodes = %d, want 10", len(g.Nodes)) // 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: // TestUnderforgeGraph_LinearPreamble locks in the gauntlet identity:
// the first five nodes after entry must each have exactly one outgoing // every node from entry through resonance_passage has exactly one
// edge (linear chain). If a future edit splits the preamble, this test // outgoing edge — the only decision in the zone is the antechamber
// catches it — that change should re-author the shape comment too. // 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) { func TestUnderforgeGraph_LinearPreamble(t *testing.T) {
g := zoneUnderforgeGraph() g := zoneUnderforgeGraph()
for _, id := range []string{ for _, id := range []string{
"underforge.entry", "underforge.entry",
"underforge.sealed_gate", "underforge.sealed_threshold",
"underforge.wardstone_arch",
"underforge.forge_descent", "underforge.forge_descent",
"underforge.vapor_steps",
"underforge.slag_warrens",
"underforge.cooling_river", "underforge.cooling_river",
"underforge.bellows_chamber",
"underforge.cinder_walk",
"underforge.magma_chamber", "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) outs := g.outgoingEdges(id)
if len(outs) != 1 { if len(outs) != 1 {