diff --git a/core/test.mjs b/core/test.mjs index e2163af..2d2bf7f 100644 --- a/core/test.mjs +++ b/core/test.mjs @@ -1,4 +1,4 @@ -import { readFileSync } from 'fs'; +import { readFileSync, readdirSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { Rng } from './rng.mjs'; @@ -230,5 +230,19 @@ import { renderWorksheet, renderPlay } from './render-authoring.mjs'; `play ${done.split('\n').length} vs worksheet ${renderWorksheet(d).split('\n').length} lines`); } +// A delve must carry the pack it came from. The Foundry adapter reads `params.theme` to decide +// which geometry to stage; when it trusted a boot-time global instead, every non-barrow delve got +// a barrow map. The adapter cannot be tested here, so the contract it depends on is tested here. +{ + const ids = readdirSync(dir).filter(f => f.endsWith('.json') && f !== 'index.json') + .map(f => f.replace('.json', '')); + const wrong = ids.filter(id => { + const p = JSON.parse(readFileSync(join(dir, `${id}.json`), 'utf8')); + return generateDelve({ pack: p, seed: `theme-${id}` }).params.theme !== id; + }); + t('every delve records the pack it was generated from', wrong.length === 0, + wrong.length ? `wrong: ${wrong.join(', ')}` : `${ids.length} packs`); +} + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); diff --git a/docs/drafts/APP-AND-RULES.md b/docs/drafts/APP-AND-RULES.md new file mode 100644 index 0000000..8d531da --- /dev/null +++ b/docs/drafts/APP-AND-RULES.md @@ -0,0 +1,356 @@ +# DELVE — the App and the Rules + +*Draft 1, 2026-08-05. Written against the code at `316716e`, module v0.6.1, generator 0.2.0.* +*Every number here was read out of the source, not remembered. Line references are live.* + +--- + +## 0. What this document is + +`DESIGN.md` is a design history — it records how DELVE was argued into existence, including the +parts that failed. It is the wrong thing to hand someone who asks "what is this and how do I run +it?" + +This is that document. Two halves: + +- **The App** — what the software is, the two surfaces, and the workflow at the desk and the table. +- **The Rules** — the in-play procedure a GM actually runs, stated as rules rather than as + architecture. + +It is a draft. §7 lists what I believe is wrong with it. + +--- + +## 1. Design intent + +1. **DELVE is an authoring system, not an adventure generator.** It gets a GM to a strong first + draft in a minute instead of an evening. It does not hand over something to run cold. +2. **It generates the fiction, the order and the pressure.** It does not generate read-aloud prose + or the climax, and it says so on the page where they are missing. +3. **The fiction coheres because it is projected from one root draw**, not assembled from four + independent tables. +4. **Generation and table dice stay apart.** The core decides *when* a roll is due and what it + means; the table rolls it. This is what makes the seed promise honest. +5. **It is deliberately not a balance engine.** It plans pressure; it does not guarantee a fair + fight. + +--- + +# PART ONE — THE APP + +## 2. The two surfaces and one working file + +Four independent reviews of generated output converged on the same verdict: *"the handwritten +delve still tells the GM what matters faster."* The density was the problem — and it turned out +not to be a bug to render away. **Everything on the page is a vice at the table and a virtue at +the desk.** + +So there are two surfaces over one file: + +| | For | Carries | +|---|---|---| +| **Worksheet** | the desk | everything, labelled, reroll commands beside each component, explicit prompts where only a human can write | +| **Play sheet** | the table | the GM's own prose, the live leverage point, the decision on one line, the numbers. Nothing else | + +**The JSON file is the document.** It carries an `authored` layer the generator never touches +([`authoring.mjs:22`](../../core/authoring.mjs)), so **rerolling a component cannot destroy +prose**. Lock what is good, reroll what is not, write the read-aloud, export the play sheet. + +- Authored fields: `readAloud`, `notes`, `nameOverride` +- Rerollable components: `situation`, `decision`, `temptation`, `feature`, `name` +- A reroll draws from a salted seed and avoids what the rest of the delve already uses; when the + pool is exhausted it duplicates another area rather than handing back the same thing, because + **a reroll that changes nothing looks broken** + +## 3. At the desk — the CLI + +`core/` is pure JavaScript with no Foundry dependencies, so the whole authoring loop runs in Node. + +```bash +node core/cli.mjs --new --seed=my-delve --theme=port --file=my.json +node core/cli.mjs --file=my.json # the worksheet (default) +node core/cli.mjs --file=my.json --reroll=3:situation +node core/cli.mjs --file=my.json --lock=3:decision +node core/cli.mjs --file=my.json --write=3:readAloud --text="They have been waiting." +node core/cli.mjs --file=my.json --todo # what still needs a human +node core/cli.mjs --file=my.json --play # the table-facing sheet +node core/cli.mjs --themes # list the 16 packs +``` + +**A fresh delve is not ready to play, and says so.** `outstanding()` returns the intro, one +read-aloud per area, the ending read-aloud, and — when the ending is `authored` — the climax +itself. A six-area delve starts with **9 outstanding items**. The play sheet prints a warning +banner until they are gone. + +## 4. At the table — the Foundry module + +Three tools in the scene controls: + +| | Tool | Does | +|---|---|---| +| ⛏ | `delve-forge` | raise a dungeon — opens the panel, generates or loads, stages it in the world | +| 🗑 | `delve-remove` | remove one — deletes exactly what it made and nothing else | +| ⏩ | `delve-next` | stage the next area of a delve you finished at the desk | + +**Entering an area** ([`vanity-delve.mjs:83`](../../foundry-module/module/vanity-delve.mjs)) does +five things in a fixed order: + +1. Builds the scene — `forge.stage({ populate: false, activate: true })` +2. Forges the encounter, if the area plans one — with `hoard: false` so loot is decoupled +3. Forges the hoard, if the area plans one — at its own tier +4. **Posts the read-aloud to everyone** — the GM's prose, or the cue fragments marked *(unwritten)* +5. **Posts the GM card, quietly** — situation, decision with resolution, foe block, temptation, + and a code line carrying the trigger, the Bane beat and the fallback route + +Players first, then the GM. That ordering is deliberate: the scene is up and the read-aloud is +what the table came for. + +**The seams.** DELVE requires VANITY **0.10.4+**. Below that floor the Forge's suppression options +do not exist and every delve emits 9 stray chat cards and 3 stray folders. The module detects this +at boot and warns. Requires Foundry v13+, verified on 14.365. + +## 5. What the seed promises + +State this plainly in any UI. **A partial seed sold as a full one is worse than no seed.** + +| Reproducible from the seed | Recorded only | Live at the table | +|---|---|---| +| Fiction, area order, heat, hoard tier, foreshadow chain, situations, decisions, temptations | Encounter composition, mood, hoard contents | The clock, the tab, player choices | + +The gap exists because VANITY's Forge uses `Math.random` internally (36 sites). Until an injectable +RNG lands, a seed replays the *delve* but not the *dungeon's population*. + +`GENERATOR_VERSION` (currently **0.2.0**) is stamped into every file. It tracks the shape of the +file and what a seed yields — **not** the module version. + +--- + +# PART TWO — THE RULES + +*This is the procedure. A GM who has read this section can run a delve.* + +## 6. The kernel — where a delve comes from + +One root draw, everything else projected: + +``` +APPETITE ──> motif ──> danger, attention trigger, every cue fragment + │ ──> prize kind + │ ──> who was conscripted + └─ + ACCOMMODATION ──> the transgression, the bottom problem, the primary faction +``` + +An **appetite** is a want — *to be looked at*, *never to age*, *to be first*. An +**accommodation** is the monstrous thing someone did to satisfy it — *had the court interred +alive*, *had the faces struck off*. Accommodations declare which appetites they fit, because +drawing freely produced legible nonsense. + +8 appetites × 6 accommodations × 7 claimants = **336 kernels from 21 authored lines**. Measured: +60 generated delves produced **51 distinct kernels**. + +**The GM needs one line of this at the table:** the transgression, the bottom problem, and what +the thing at the bottom is now unable to bear. + +## 7. The shape of a delve + +Areas are assigned **arc roles** by position, so a delve has a shape rather than a flat run of +rooms. Proportional — works at 3 areas or 12 ([`director.mjs:37`](../../core/director.mjs)): + +| Position | Role | Foreshadow facet | +|---|---|---| +| first 20% | approach | institution | +| to 45% | complication | ritual | +| to 65% | turn | demand | +| to 85% | descent | wound | +| last 15% | threshold | anchor | +| — | ending (separate slot) | demand | + +The facet column is the **foreshadow chain**, and it is why area 2's held bows and area 4's +scratched-out eyes point at the same faceless queen — they are facets of one motif, dealt in a +deliberate order. The progression was proposed independently and turned out to describe the +hand-authored paper delve exactly. + +**Approach areas stay quiet.** An empty area is what makes an occupied one land. + +## 8. An area, as the GM runs it + +Every area carries the same parts, in this order on the card: + +**1 · The situation.** Someone doing something, and what changes when the players walk in. +> *"The tide watch, turning a glass that has already run through — it asks what the hour is, and +> means the tide."* + +It comes with an **offer** — what the players can get out of it — and a **because**, the reason it +behaves that way. Situations are drawn from the motif, never a global pool. + +**2 · The decision.** One thing you can do about the situation. Always states three outcomes: + +`[Wits 2]` success · **miss** what failure costs · **or** the alternative that needs no roll + +Four test invariants police this: every decision states success **and** failure, offers an attempt +rather than a pick, and **never gates progress without an alternative**. Playtest 1 died on a gate +with no fallback. + +**3 · The clue.** Automatic on entry — the cue fragment, plus *"and it was done deliberately"*. +The roll buys extra: `[VANITY: Observation — 1 success]` buys **who** did it and **roughly when**. +Clues are never gated behind a roll; playtest 1 lost both its clues to single unopposed rolls and +the motif never reached the table. + +**4 · The encounter**, where one is planned. Foe counts and atk/def/Grit/Nerve in a table, plus +**"harmed by"** stated *before* initiative and **"avoidable"** spelled out. + +**5 · The temptation**, where there is a hoard. The decision lives at **use**, not acceptance — +both playtests took every free-acceptance item without discussion, so free acceptance is not a +temptation, it is an automatic yes. + +``` +benefit what it does +useCost usually +2 Bane +standingDrawback what it costs you for the rest of the delve +``` + +**6 · The failure case.** Every area states it. Ordinary areas: *"if they skip this, move its cue +fragment into the next area so the motif still completes."* The threshold: *"if they skip this, +the ending opens with them holding nothing the bottom problem wants."* + +**Nothing repeats inside one delve.** Situations, decisions, temptations and features are dealt +without replacement. Picking independently per area repeated a temptation in **75 of 80** test +delves. + +## 9. The appeasement move — the way through that is not violence + +The best mechanic in the hand-authored delve turned out to project *exactly* from the appetite. +Every appetite implies how it is fed: + +| Appetite | The move | Roll | +|---|---|---| +| to be looked at | look at it and say plainly what you see | `[Flair 2]` | +| to be first | stand aside and let it go ahead of you | `[Poise 2]` | +| never to age | tell it that it has not changed | `[Flair 2]` | +| never to be alone | sit down and stay a while | `[Poise 2]` | + +**+1 Vanity now, +1 Bane later. Repeatable, in every area.** + +> **Say the Bane aloud when it banks**, so the table can see the route is open. + +This is the delve's social spine. It is always thematically exact, and a party that appeases even +once reaches an ending that will *trade* rather than fight. + +## 10. The clock — Wandering the Dark + +**Triggers lead; the timer is a floor.** Playtest 1's clock fired 0 times in 4 rolls on a 3-Turn +timer, while the attention trigger fired on the first roll of pass 2. + +Every area emits at least one **attention trigger**: + +| Trigger | Means | +|---|---| +| `announced` | entered somewhere grandly, announcing yourself | +| `seenTwice` | reflected, echoed or witnessed twice over | +| `loud` | a raised voice, a broken door, a shout | +| `lingering` | stayed too long in one place | +| `disturbed` | struck or moved something that had not moved in years | + +**Procedure.** Roll `1d6` on any attention trigger, or when the timer comes round. +**1 — something comes.** Telegraph one Turn ahead — the GM voices a cue before it is due. When it +fires, roll **Reaction (2d6)** for its mood. + +| Pressure setting | Timer period | +|---|---| +| slow | every 4 Turns | +| standard | every 3 Turns | +| hunted | every 2 Turns | + +**Escalation.** If the clock has not fired by the halfway area, the trigger number widens from 1 +to 2 for the rest of the delve. A clock that never bites is decoration. + +> ⚠ **The escalation and the period do not currently reach the table.** See §7 of the features +> draft — this section describes the rule as designed and as implemented in `pressure.mjs`, not as +> the Foundry module currently behaves. + +## 11. The tab and the Reckoning + +**Banes are emitted by named beats, never hoped for.** Playtest 1 banked 1 Bane in ~40 rolls +because Stumbles are rare. The director offers a Bane beat when the tab falls behind pace — always +when behind, and only half the time when on pace, so it does not feel mechanical. + +| Source | Banks a Bane when you… | +|---|---| +| `flattery` | played to something's vanity to be received | +| `noticing` | caught your own reflection and looked | +| `relicUse` | spent a relic's favour | +| `announcing` | announced yourself rather than slipping in | +| `refusal` | refused an offer that would have cost you nothing yet | +| `push` | pushed a roll | +| `stumble` | stumbled — a failure with two or more ones | + +**Target: ~5 Banes by the ending. THE RECKONING falls at 6.** + +> ⚠ **The target is UNCALIBRATED.** Playtest 2 reached 3. It is a target, not a promise. + +Quiet areas offer only `noticing` and `announcing`; louder areas add `flattery`, `relicUse` and +`refusal`. + +## 12. Endings + +The ending is a **separate slot** after the last area, and by default it is **yours**. + +- **`authored`** (default) — DELVE writes the question the place asks and leaves the climax blank. + It says so on the card: *"the climax is unwritten. DELVE leaves this to you on purpose — + improvise, or stop and write it."* +- **`generated`** — the ending gets a real heat (one step above the delve's base) and a vault-tier + hoard. + +**Appeased even once → it listens, and will trade the prize.** That is the ending the social spine +buys. + +## 13. Parameters + +| Parameter | Range | Default | Confidence | +|---|---|---|---| +| Areas (excludes the ending slot) | 3–12 | 6 | working default — one theme, one table, two passes | +| Theme | 16 packs, 5 geometries | barrow | all validate; **only barrow is playtested** | +| Depth | 1–5 | 2 | | +| Party | 1–8 | 4 | ≤2 softens the heat, ≥7 hardens it | +| Deadliness | forgiving / standard / cruel | standard | **severity only** | +| Density | sparse / standard / infested | standard | **frequency only** — 0.20 / 0.34 / 0.55 of areas | +| Greed | lean / standard / glutted | standard | shifts the hoard tier one step | +| Pressure | slow / standard / hunted | standard | ⚠ **currently inert — see features draft** | +| Ending | authored / generated | authored | a default, not a law | + +**Heat** runs `skirmish → fight → battle → nightmare`; **hoards** run +`pocket → cache → chest → vault → kingly`. Descent and threshold areas run one step hotter. The +area before the ending always carries something — the threshold should have teeth. Treasure is not +only behind fights: unless greed is `lean`, one treasureless area gets a bonus hoard. + +## 14. What DELVE will not do + +- **No physically coherent tiled dungeon.** v1 is a pointcrawl. +- **No balance guarantee.** +- **No sheet writes.** DELVE offers; the players and the GM decide. +- **No system-generated climax by default.** +- **No full replay from seed** until the Forge's RNG seam lands. +- **No generated read-aloud prose.** Cue fragments only, labelled as fragments. This is the honest + ceiling, and faking prose would be the one dishonest thing this tool could do. + +--- + +## 15. What I think is wrong with this draft + +Flagging these rather than hiding them, since this is going out for review. + +1. **§10 and §13 document a rule the software does not run.** The pressure setting is inert end to + end and the escalation never reaches the table. I have written the rule as designed and flagged + it twice, but a rules document that describes unimplemented behaviour is a liability. The + alternative — documenting the degenerate behaviour — is worse. **The right fix is in the code, + not here.** +2. **Part Two is written for a GM; Part One is written for a developer.** They may want to be two + files. I kept them together because the seed promise and the two surfaces are rules-relevant. +3. **No worked example.** A rules document of this kind usually ends with one complete annotated + area. I have not included one because the samples in `samples/` are stamped generator 0.1.0 and + one lacks the authored layer. +4. **The Reckoning is underspecified here.** I state that it falls at 6 because that is what + `reckoningDue()` returns, but *what happens* at a Reckoning is VANITY's rule, not DELVE's, and I + have not cross-checked the rulebook. Someone should. +5. **Nothing here has been tested at a table.** Every claim is read from source or from two paper + playtests of a hand-written delve. diff --git a/docs/drafts/NEW-FEATURES.md b/docs/drafts/NEW-FEATURES.md new file mode 100644 index 0000000..3523d15 --- /dev/null +++ b/docs/drafts/NEW-FEATURES.md @@ -0,0 +1,237 @@ +# DELVE — proposed features + +*Draft 1, 2026-08-05. Against `316716e`, module v0.6.1, generator 0.2.0.* +*Ranked. Every "Evidence" line was verified in the source or by running the code, not recalled.* + +--- + +## The gate + +**DELVE has never been run at a live table.** Sixteen themes, 39 tests, six releases and two paper +playtests of a *hand-written* delve — but no session has ever used the generated article. + +That fact sets the order below. Items 1–4 are things I can justify without a session, because they +are defects or they are measurement. Items 5–9 are things I would not start until a table has run +one delve, because a session will change what they should be. **The most valuable thing that could +happen to this project is four hours and four players, not another feature.** + +--- + +# TIER 1 — defects wearing feature costumes + +*These should land before anything new is built. Both are the same root cause.* + +## 1. Wire the live Pressure engine to the table + +**What.** The Foundry module should instantiate `Pressure` from the loaded delve and drive the +clock through it, instead of reimplementing a degenerate version inline. + +**Why.** `pressure.mjs` is 130 lines that exist *because two playtests falsified the originals* — +escalation, telegraphing, and pace-checking were each added to fix a specific observed failure. +None of that reaches a table. The module's clock is: + +```js +const fired = roll.total <= 1; // vanity-delve.mjs:147 +``` + +A hardcoded 1. So: + +- **Escalation never happens.** `Pressure.escalate()` widens the trigger from 1 to 2 if the clock + has not fired by the halfway area. Playtest 1's central failure was a clock that rolled 1-in-6 + four times and never fired — the exact scenario escalation was written to prevent — and at the + table it still cannot fire. +- **The telegraph never fires.** `telegraphDue()` is never called, so the "voice a cue one Turn + ahead" rule is documentation only. +- **The timer never comes round.** `timerDue()` is never called. `st.turn` increments per area and + is otherwise unused. +- **Pace-checking is generation-only.** `onPace()` shapes which Bane beats get *planned*, but the + live tab (`st.tab`) never feeds back, so a table that banks Banes fast or slow changes nothing. + +**Evidence.** The module imports exactly three core modules — `rng`, `authoring`, `forge-app` +([`vanity-delve.mjs:17-19`](../../foundry-module/module/vanity-delve.mjs)). `Pressure`, +`timerDue`, `telegraphDue`, `escalate`, `onPace` and `triggerNumber` appear **nowhere** in +`foundry-module/`. The only hit for "Pressure" is an `

` in the panel. + +**Cost.** Small. `Pressure` is already pure and serialisable; the module already persists +`st.clock` and `st.tab` as world state. This is roughly: construct on load, replay the recorded +rolls, call `recordClock()` instead of comparing to a literal, and surface `triggerNumber` in the +card. + +**Risk.** Low, but it changes live behaviour mid-campaign for anyone with a delve loaded. Needs a +state migration or a version check on the stored state blob. + +**Done when.** A delve that reaches its halfway area without a clock firing shows "something comes +on 1–2" on the next card, and the escalation is visible to the GM. + +## 2. Make the pressure setting mean something + +**What.** Record the clock period in the delve file and derive the panel text from it. + +**Why.** `slow` / `standard` / `hunted` is offered in the UI, stored in `lastParams`, and passed +into generation — and then discarded. `Pressure.summary()` +([`pressure.mjs:119`](../../core/pressure.mjs)) emits `turn`, `clockRolls`, `clockFired`, +`triggerNumber`, `escalated`, `tab` and `target` — **but not `period`**. So the one number the +setting controls never leaves the constructor. + +**Evidence.** Generating the same seed at all three settings produces byte-identical pressure +blocks: + +``` +slow → {"turn":0,...,"triggerNumber":1,"escalated":false,"tab":6,"target":5} +standard → {"turn":0,...,"triggerNumber":1,"escalated":false,"tab":6,"target":5} +hunted → {"turn":0,...,"triggerNumber":1,"escalated":false,"tab":6,"target":5} +``` + +And the panel hardcodes the standard value regardless +([`forge-app.mjs:188`](../../foundry-module/module/forge-app.mjs)): + +> `Roll 1d6 on any attention trigger, or every 3 Turns.` + +A GM who picks `hunted` is told 3 Turns and gets 3 Turns. + +**Cost.** Trivial — add `period` to `summary()`, read it in the panel. It is item 1's prerequisite +and probably the same commit. + +**Risk.** None beyond item 1. Old delve files lack `period`; default to 3. + +--- + +# TIER 2 — build steps designed but never started + +## 3. The overlay catalog *(build step 5)* + +**What.** A curated encounter catalog layered over the Forge's output, with per-entry theme, +faction, role, uniqueness — plus two fields that must be **tests, not tags**: + +- **playability** — *can a hero with a mundane weapon damage this at all?* +- **survivability** — *can the party's weakest member survive one clean hit and two rounds of being + focused?* + +**Why.** Both tests come from a corpse. The Wraith's immunity made a finale literally unwinnable; a +5d6 armour-ignoring attack that drains max Grit, against 3 Grit, is not a fight. DELVE currently +plans *heat* and hands composition to the Forge, so it cannot promise either property. + +**Cost.** Large — it is authored content per theme plus a selection layer, and it interacts with +the roster data already in the packs. + +**Risk.** This is the item most likely to be wrong before a live session. Heat may turn out to be +sufficient at a real table, in which case a curated catalog is a lot of authoring for a problem +that only appeared in simulation. **I would hold this until after the session.** + +## 4. A Bane calibration harness + +**What.** A pure-core simulator: run N delves against a modelled table, count Banes at the ending, +report the distribution. Then set `baneTarget` from data instead of intuition. + +**Why.** The target has been marked **UNCALIBRATED** since draft 5. It is ~5; playtest 2 reached 3. +Every Bane-beat decision in `baneBeatFor()` keys off `onPace()`, which keys off that number — so an +uncalibrated target silently miscalibrates the whole tab engine. + +**Cost.** Small, and it needs no Foundry and no table. This is the cheapest real answer available +right now. + +**Risk.** A simulated table is not a table. The output is a *prior*, not a calibration, and should +be labelled as one — the same mistake as "6 areas: validated" would be easy to repeat here. + +**Done when.** DESIGN.md can state a target with a measured distribution behind it, and open +question 4 either closes or gets sharper. + +--- + +# TIER 3 — worth doing, but after a session + +## 5. The play sheet as a Foundry surface + +**What.** Render the play sheet in-world — a GM window carrying the current area's prose, the +decision, the numbers, and the live tab, instead of scrollback. + +**Why.** The play sheet is currently CLI markdown only. At the table the GM card is *chat*, and +chat scrolls away. The whole two-surface design exists because table-facing material must be +scannable; a surface you have to scroll back through is not. + +**Cost.** Medium. Renderer exists (`render-authoring.mjs`); this is a Foundry Application over it. + +## 6. In-world authoring + +**What.** Write the read-aloud, notes and title inside Foundry — the `authored` layer is already +the right shape for it. + +**Why.** Today the only authoring path is the CLI, which means a GM who wants to fix one area's +prose leaves the VTT, edits JSON, and reloads. The module can already *detect* unfinished work +(`outstanding()` is exposed on `game.delve`) but offers no way to resolve it. + +**Cost.** Medium. Needs a save-back path to `worlds//delves/`, which the module currently only +reads. + +**Risk.** Two writers on one file. Needs a clear rule about which side owns the file. + +## 7. A theme quality audit beyond Barrow + +**What.** Read one generated delve per theme against the barrow benchmark, and add whatever +invariant each failure implies. + +**Why.** Sixteen packs pass `validate-pack.mjs`, but validation is structural — it proves a pack +*can* fill six areas without repeating, not that the result is any good. Only barrow has ever been +read closely against a playtest. **A pack can pass every invariant and still be dull.** + +**Cost.** Medium, and mostly reading rather than coding. + +**Risk.** None. This is the item most likely to find something surprising per hour spent. + +## 8. The growing delve map *(build step 7)* + +**What.** A map that accumulates as areas are staged — the pointcrawl made visible. + +**Why.** v1 is explicitly a pointcrawl and players will ask where they are. + +**Cost.** Large. Browser-only by necessity (`forgeStage` needs `Image`/canvas/`XMLSerializer`), so +it cannot be tested in the CLI, which is where all 39 tests live. + +**Risk.** High. This is the feature most likely to consume a week and produce something a GM +sketches better on paper. **I would want a session to ask for it before building it.** + +## 9. Injectable RNG in the Forge → full replay + +**What.** Thread a seeded RNG through VANITY's Forge so a seed replays the population too. + +**Why.** It closes the honesty gap in the seed promise (§5 of the app draft). + +**Cost.** Large and it is in the *other* repo — 36 `Math.random` sites across four helpers. It was +explicitly deferred as not gating a first release, and that judgment still looks right. + +--- + +# TIER 4 — proposed and rejected + +**More themes.** Sixteen is already more than has been validated at a table. Another family adds +authored content behind an unmeasured quality bar. **Stop until item 7 runs.** + +**Multi-motif or two-faction delves.** The fiction model coheres *because* it has one root draw. +Two motifs is the most plausible way to break the thing that currently works best. + +**Generated read-aloud prose.** Named in DESIGN.md as the one dishonest thing this tool could do. +Still true. The cue-fragment ceiling is the honest one. + +**A balance guarantee.** Out of scope by design, and the overlay tests (item 3) are the correct +scoped version of this instinct. + +--- + +## Suggested sequence + +1. **Items 1 + 2** — one commit, small, fixes a rule that is documented but not running. +2. **Item 4** — cheap, no table needed, turns an admitted unknown into a number. +3. **Run a session.** Four hours, four players, one barrow delve, generated not hand-written. +4. **Item 7** while the session is fresh. +5. Re-rank 3, 5, 6, 8 against what the session actually showed. **I expect at least one of them to + look wrong afterwards, and I would rather find out which one before building it than after.** + +## Where I am least confident + +- **Item 3's priority.** I have ranked a designed-and-documented build step below a simulation + harness and a reading exercise. That is arguable, and the argument turns entirely on whether + heat-without-curation survives a real fight. +- **Whether items 5 and 6 are one feature.** A play-sheet window that cannot be edited may be half + a thing; shipping both at once may be the smaller total change. +- **The whole ranking assumes the session happens.** If it realistically will not happen soon, tier + 3 should be reordered to whatever makes the tool most useful at the desk — probably 6, then 5. diff --git a/foundry-module/module/forge-app.mjs b/foundry-module/module/forge-app.mjs index ace7b57..aedd119 100644 --- a/foundry-module/module/forge-app.mjs +++ b/foundry-module/module/forge-app.mjs @@ -18,6 +18,33 @@ const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s); const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? ''); const MOD = 'vanity-delve'; + +/** + * What a forged foe actually is. + * + * A pack's roster describes the encounter DELVE *planned* — "1 Ghoul and 2 Skeletons", with stats + * and, in some themes, "blessed, silvered or magical weapons ONLY". The Forge does not take a + * cast: it rolls its own monsters from the heat. So the roster and the actors in the world are two + * different lists, and printing the roster's numbers beside the Forge's actors told the GM to run + * a fight against foes that were never created. + * + * Read the numbers off the documents that exist. The plan is still worth showing — it carries + * authored tactical guidance — but it has to be labelled as the plan. + */ +export const foeStats = a => ({ + name: a.name, + uuid: a.uuid, + atk: a.system?.attack1?.pool ?? null, + def: a.system?.defence?.pool ?? null, + grit: a.system?.grit?.value ?? null, + nerve: a.system?.nerve ?? null, + trick: a.system?.trick ?? '', +}); + +/** One foe as a line of stats, in the roster's own vocabulary so the two read alike. */ +export const foeLine = f => + `@UUID[${f.uuid}]{${f.name}} — ${f.atk ?? '?'}/${f.def ?? '?'}/${f.grit ?? '?'}, Nerve ${f.nerve ?? '?'}${f.trick ? `. ${f.trick}` : ''}`; + let THEMES = [{ id: 'barrow', label: 'Barrow' }]; export function setThemes(list) { THEMES = list; } @@ -123,7 +150,7 @@ export async function raiseDungeon(params = {}) { heat: area.encounter.heat, forStage: area.name, ...(seams ? { hoard: false, post: false, folderId: folders.Actor.id } : {}), }).catch(e => { console.error('DELVE | encounter failed', e); return null; }); - area._foes = (enc?.actors ?? []).map(a => ({ name: a.name, uuid: a.uuid })); + area._foes = (enc?.actors ?? []).map(foeStats); } if (area.hoard) { const h = await game.vanity.forge.hoard({ size: area.hoard, ...quiet }) @@ -212,11 +239,12 @@ function buildPages(d, scenes) { ${rv.failure ? `
  • Miss → ${esc(rv.failure)}
  • ` : ''} ${rv.orElse ? `
  • Or ${esc(rv.orElse)}
  • ` : ''} - ${R ? `

    ${esc(cap(a.encounter.heat))} — ${esc(R.line)}. Harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — say so before initiative' : ''}. ${esc(R.avoid)}.

    + ${a._foes?.length ? `

    ${esc(cap(a.encounter.heat))} — in the world. These are the actors the Forge created; run the fight off these.

    - ${R.foes.map(f => ``).join('')} + ${a._foes.map(f => ``).join('')}
    FoeatkdefGritNerve
    ${f.n}× ${esc(f.name)}${f.atk}${f.def}${f.grit}${f.nerve}${esc(f.note)}
    @UUID[${f.uuid}]{${esc(f.name)}}${f.atk ?? '?'}${f.def ?? '?'}${f.grit ?? '?'}${f.nerve ?? '?'}${esc(f.trick)}
    ` : ''} - ${a._foes?.length ? `

    Rolled for you: ${a._foes.map(f => `@UUID[${f.uuid}]{${esc(f.name)}}`).join(' · ')}

    ` : ''} + ${R ? `

    DELVE planned ${esc(R.line)} — the Forge rolls its own foes, so the plan and the table above are different lists. + Its guidance still applies to the scene: harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — only true if you cast the fight yourself' : ''}. ${esc(R.avoid)}.

    ` : ''} ${a.temptation ? `

    ${esc(cap(a.temptation.id))} — ${esc(a.temptation.cue)}: ${esc(a.temptation.benefit)}.
    Using it costs ${a.temptation.useCost?.bane ? `+${a.temptation.useCost.bane} Bane` : '—'}. While carried, ${esc(a.temptation.standingDrawback)}.

    ` : ''} ${a._hoard?.length ? `

    Hoard.

    ` : ''} diff --git a/foundry-module/module/vanity-delve.mjs b/foundry-module/module/vanity-delve.mjs index 27aa901..345c6bd 100644 --- a/foundry-module/module/vanity-delve.mjs +++ b/foundry-module/module/vanity-delve.mjs @@ -16,7 +16,7 @@ */ import { coinSeed, Rng } from './core/rng.mjs'; import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs'; -import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs'; +import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes, foeStats, foeLine } from './forge-app.mjs'; const MOD = 'vanity-delve'; const FLAG = 'state'; @@ -27,6 +27,29 @@ let loadPack = async () => null; const getState = () => game.settings.get(MOD, FLAG) ?? null; const setState = async s => game.settings.set(MOD, FLAG, s); + +/** + * The pack a delve was actually authored against. + * + * PACK is only a boot-time default. A delve brought in from the desk may be any of the sixteen + * themes, and staging one with the wrong pack gives a harbour delve a barrow map — the fiction + * says quayside and the geometry says burial chamber. Resolved per call rather than once at load, + * because the global resets on a page reload while the staged delve in world state does not. + * + * `params.theme` is written from `pack.id` at generation time, so it is always present and always + * right, whatever the caller passed. + */ +async function packFor(d) { + const id = d?.params?.theme ?? 'barrow'; + if (PACK?.id === id) return PACK; + const p = await loadPack(id); + if (!p) { + ui.notifications.error(`DELVE: could not load the ${id} theme — refusing to stage, it would use the wrong geometry.`); + return null; + } + PACK = p; + return p; +} const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s); const list = items => ``; @@ -51,6 +74,7 @@ async function load(working) { if (!d?.skeleton || !Array.isArray(d.areas)) return ui.notifications.error('DELVE: that is not a delve file.'); const todo = outstanding(d); + if (!await packFor(d)) return; // before anything is created in the world const folder = await Folder.create({ name: `Delve — ${d.authored?.title ?? d.skeleton.placeName}`, type: 'Actor' }); await setState({ delve: d, folderId: folder.id, at: 0, turn: 0, tab: [], clock: [] }); @@ -75,7 +99,9 @@ async function loadFile(name) { /** Generate an unfinished draft in-world. Convenience only — the desk is the right place. */ async function draft(params = {}) { const seed = params.seed || coinSeed(new Rng(String(game.world.id))); - const d = newWorkingFile({ pack: PACK, ...params, seed }); + const pack = await packFor({ params }); + if (!pack) return; + const d = newWorkingFile({ pack, ...params, seed }); ui.notifications.warn('DELVE: unfinished draft. Write the read-aloud in the worksheet first.'); return load(d); } @@ -89,16 +115,18 @@ async function enter() { const area = d.areas[st.at]; const w = d.authored?.areas?.[area.index] ?? {}; const name = w.nameOverride ?? area.name; + const pack = await packFor(d); + if (!pack) return; ui.notifications.info(`DELVE: raising ${name}…`); const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {}; const stage = await game.vanity.forge.stage({ - type: PACK.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet, + type: pack.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet, }); - if (area.encounter) await game.vanity.forge.encounter({ + const enc = area.encounter ? await game.vanity.forge.encounter({ heat: area.encounter.heat, forStage: name, ...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}), - }); + }) : null; if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) }); // Players first — the scene is up and this is what they came for. @@ -106,13 +134,14 @@ async function enter() { // Then the GM, quietly. const R = area.encounter?.roster; + const foes = (enc?.actors ?? []).map(foeStats); const rv = area.decision?.resolve ?? {}; await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`, `${area.situation ? `

    Here: ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.
    They can: ${area.situation.offer}. ${cap(area.situation.because)}.

    ` : ''}

    ${cap(area.decision.cue)}${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `
    Miss: ${rv.failure}` : ''}${rv.orElse ? `
    Or: ${rv.orElse}` : ''}

    - ${R ? `

    ${cap(area.encounter.heat)}: ${R.line}

    ${list(R.foes.map(f => `${f.n}× ${f.name} — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. ${f.note}`))} -

    Harmed by ${R.harmedBy}. ${R.avoid}.

    ` : ''} + ${foes.length ? `

    ${cap(area.encounter.heat)} — in the world:

    ${list(foes.map(foeLine))}` : ''} + ${R ? `

    DELVE planned ${R.line} — the Forge rolled its own, so run the block above. ${R.avoid}.

    ` : ''} ${area.temptation ? `

    ${cap(area.temptation.id)}: ${area.temptation.benefit}. Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.

    ` : ''} ${w.notes ? `

    Your note: ${w.notes}

    ` : ''}

    ${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}

    `);