@ak--47/dungeon-master 1.5.1 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,8 +36,8 @@ Out of scope:
36
36
  - `lib/hook-patterns/index.js` — high-level recipes (one per Mixpanel
37
37
  analysis type).
38
38
  - `lib/verify/emulate-breakdown.js` — what `verify-dungeon` will check.
39
- - `dungeons/user/my-buddy.js` — reference dungeon using a mix of atoms and
40
- hand-rolled logic.
39
+ - `dungeons/user/my-buddy/my-buddy.js` — reference dungeon using a mix of atoms
40
+ and hand-rolled logic.
41
41
  - `dungeons/technical/pattern-*.js` — five minimal pattern fixtures, one per
42
42
  recipe.
43
43
  - `HOOKS.md` — encyclopedia of hook recipes organized by story pattern. Contains
@@ -78,6 +78,10 @@ Inside `funnel-pre` and `funnel-post`:
78
78
  Inside `everything`:
79
79
  - `meta.authTime: number | null` — unix-ms of the stitch event, null if never authed
80
80
  - `meta.isPreAuth(event): boolean` — convenience predicate
81
+ - `meta.profile` — full profile object. Mutate or rescue here.
82
+ - `meta.userIsBornInDataset: boolean` — true when user was born inside the dataset window
83
+ - `meta.scd: { <key>: SCDEntry[] }` — SCD entries per key
84
+ - `meta.datasetStart, meta.datasetEnd: number` — unix-seconds bounds
81
85
 
82
86
  Pattern: gate trend logic on `meta.isFinalAttempt` so failed prior attempts
83
87
  don't get the same treatment as the converted attempt.
@@ -196,9 +200,10 @@ writing a custom hook:
196
200
  - **Time-series trends** ("conversion rises week over week") — wrap any
197
201
  breakdown with `timeBucket: 'week'`. Engineer via temporal-windowed hooks
198
202
  using `DATASET_START.add(N, 'days')`.
199
- - **Identity-model dungeons** — when `avgDevicePerUser > 0` or
200
- `hasAnonIds: true`, ALWAYS pass `profiles` to verification. Auto-builds
201
- identity map merging pre-auth `device_id` events with post-auth `user_id`.
203
+ - **Identity-model dungeons** — when `identity.avgDevicePerUser > 0`
204
+ (or the deprecated `hasAnonIds: true`), ALWAYS pass `profiles` to
205
+ verification. Auto-builds identity map merging pre-auth `device_id`
206
+ events with post-auth `user_id`.
202
207
 
203
208
  **Schema-first reminder:** exclusion events must be declared in `events[]`
204
209
  before referencing them as `Funnel.exclusionEvents` — the validator throws
@@ -234,6 +239,10 @@ record.was_dropped = false; // ❌ flag-stamping
234
239
  event.engineered_pattern_id = 5; // ❌ flag-stamping
235
240
  ```
236
241
 
242
+ (One narrow exception: `meta.profile._drop` is engine-recognized — see
243
+ the "Anonymous non-converter `_drop` rescue" section above. All OTHER
244
+ flags must live in `userProps`/event `properties` with a declared default.)
245
+
237
246
  DO WRITE:
238
247
  ```js
239
248
  record.amount *= 3; // ✅ scale existing numeric prop
@@ -259,6 +268,25 @@ hook: function(record, type, meta) {
259
268
  }
260
269
  ```
261
270
 
271
+ ### Anonymous non-converter `_drop` rescue (v1.5.1)
272
+
273
+ Born-in-dataset users who never reach an `isAuthEvent` step get
274
+ `_drop: true` stamped on their profile BEFORE the `everything` hook fires.
275
+ `mixpanel-sender` filters those before pushing to `/engage`. Hooks can
276
+ rescue a profile by deleting the flag:
277
+
278
+ ```js
279
+ if (type === 'everything' && meta.profile) {
280
+ // Rescue: keep some anonymous power-users in /engage even without sign_up.
281
+ const eventCount = record.length;
282
+ if (eventCount >= 20) delete meta.profile._drop;
283
+ }
284
+ ```
285
+
286
+ `_drop` is the ONE engine-recognized flag a hook may set/clear on a
287
+ profile — every other property must be declared in `userProps` first per
288
+ the anti-flag-stamping rule below.
289
+
262
290
  When a dungeon uses funnel `attempts`, hooks can reach into individual attempts
263
291
  via funnel-post meta:
264
292
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,99 @@
2
2
 
3
3
  All notable changes to `@ak--47/dungeon-master`.
4
4
 
5
+ ## 1.5.3 — 2026-06-04
6
+
7
+ Adds two JSON/source interop helpers to the public API. No breaking changes —
8
+ existing exports and behavior are untouched.
9
+
10
+ ### Added
11
+
12
+ - **`dungeonToJSON(input, options?)`** export. The inverse of `parseJSONDungeon`:
13
+ turns a dungeon into the `{ schema, hooks, timestamp, version }` JSON/UI wrapper
14
+ format. Accepts the same input flavors as the default export — a config object,
15
+ a `.js`/`.mjs`/`.json` file path, a raw JS source string, or an array of file
16
+ paths (returns an array). Output round-trips: `parseJSONDungeon(await
17
+ dungeonToJSON(x))` yields a runnable config. Best effort — arrow functions and
18
+ bound `chance.*` methods survive the round trip; detected utility calls
19
+ (`weighArray`, `weighNumRange`, …) are serialized by name without their
20
+ arguments and revive to `null` (handled gracefully by the validator). To keep
21
+ the field's **type** even when the generator can't be revived, every function
22
+ is sampled at serialization time (closures are still live) and its inferred
23
+ output type is recorded as `dataType` on the serialized object (e.g.
24
+ `{ functionName: "weighNumRange", args: [], dataType: "number" }`).
25
+ **Credentials (`token`, `serviceAccount`, `serviceSecret`, `projectId`,
26
+ `secret`) are stripped by default** so tokens never leak into JSON — pass
27
+ `{ includeCredentials: true }` to keep them.
28
+ - **`DungeonJSON`, `DungeonComments`, and `SerializedFunction` types** in
29
+ `types.d.ts` — the JSON-representation shapes are now formally specced.
30
+ - **`extractComments(input)`** export. Pulls the human-readable doc blocks out of
31
+ a dungeon's **source** — the `// ── OVERVIEW ──` and `// ── HOOK STORIES ──`
32
+ blocks plus every other `// ── LABEL ──` header that is immediately followed by
33
+ a block comment. Returns `{ overview, hookStories, sections }` with the comment
34
+ scaffolding (`// ──`, `/* */`, leading ` * `) stripped to readable prose.
35
+ Operates on a file path or raw source string — it never imports the dungeon,
36
+ since importing discards comments. Best effort: relies on the canonical
37
+ header + block-comment convention emitted by the `create-dungeon` /
38
+ `write-hooks` skills.
39
+
40
+ ### Changed
41
+
42
+ - **`scripts/dungeon-to-json.mjs`** is now a thin CLI wrapper over the exported
43
+ `dungeonToJSON` (passing `includeCredentials: true` to preserve its legacy
44
+ full-config UI round-trip output). The inline `convertToJSON` /
45
+ `convertFunctionToObject` logic moved into `lib/core/dungeon-to-json.js`.
46
+
47
+ ### Why
48
+
49
+ The package could ingest JSON dungeons (`parseJSONDungeon`, `loadFromFile`,
50
+ `loadFromText`) but had no exported way to go the other direction, and no way to
51
+ programmatically read a dungeon's OVERVIEW / HOOK STORIES documentation. Both
52
+ existed only as un-importable script internals. Exporting them completes
53
+ best-effort JSON interop and lets tools (UIs, LLM pipelines) read dungeon docs
54
+ directly.
55
+
56
+ ## 1.5.2 — 2026-05-21
57
+
58
+ Docs-only patch. Aligns the `.claude/skills/` authoring + verification
59
+ guides with the 1.5.1 engine + config API. No runtime changes.
60
+
61
+ ### Changed
62
+
63
+ - **`create-dungeon` skill** now emits the canonical dungeon layout
64
+ (IMPORTS / OVERVIEW / SCALE / DATA ARRAYS / CONFIG sections) and the
65
+ sub-object config API (`credentials` / `switches` / `identity`).
66
+ Removed the old `// ── TWEAK THESE ──` template + flat-key example.
67
+ - **`create-dungeon` skill** documents `hasAnonIds` as deprecated; nudges
68
+ authors to write `identity.avgDevicePerUser: 1` directly.
69
+ - **`create-dungeon` skill** adds sections for `retentionCurve`,
70
+ `userSeed`, anonymous-non-converter `_drop: true` semantics, and
71
+ flags the touchpoint-sampling generator/verifier asymmetry.
72
+ - **`write-hooks` skill** documents the `meta.profile._drop` rescue
73
+ pattern (the one engine-recognized flag a hook may set/clear on a
74
+ profile). Expanded `meta` interface listing for the `everything` hook.
75
+ - **`verify-dungeon/references/counting-semantics.md`** notes known
76
+ divergences from Mixpanel C++ (calendar vs rolling distinct-period
77
+ default, COMPOUNDED retention not implemented, touchpoint sampling
78
+ asymmetry, list-typed AVG/SUM no auto-flatten). All references to
79
+ `hasAnonIds: true` updated to the new `identity.avgDevicePerUser` shape.
80
+ - **`verify-dungeon/references/sql-recipes.md`** drops `Platform` from
81
+ the expected device-keys table (removed in 1.5.1; `os` covers the
82
+ signal). Updates casing check to drop `Platform`-vs-`platform` rule.
83
+ Adds an anonymous-non-converter `_drop` audit query as standard check
84
+ #0 for identity-model dungeons. Updates "Advanced feature verification"
85
+ to list only currently-supported features (`personas`, `worldEvents`,
86
+ `engagementDecay`, `dataQuality`); calls out the deprecated config
87
+ blocks (`subscription`, `attribution`, `geo`, `features`, `anomalies`)
88
+ the validator silently strips.
89
+
90
+ ### Why
91
+
92
+ Skills are how most dungeons get authored. Drifting between skill-emitted
93
+ output and 1.5.1 engine behavior would silently produce stale-shape
94
+ dungeons + missed coverage of new features (`retentionCurve`, `userSeed`,
95
+ `_drop` semantics, sub-object API). Patch keeps skill output and engine
96
+ behavior synchronized.
97
+
5
98
  ## 1.5.1 — 2026-05-20
6
99
 
7
100
  Quality + ergonomics release. No new analytical capabilities — fixes accumulated rough edges around concurrency, accuracy, profiles, and config ergonomics that surfaced after 1.5.0 shipped. Adds a generator-side retention shaper, exposes a config sub-object API for cleaner dungeon files, and restructures all 48 shipped dungeons to a canonical layout. Top-level keys keep working for back-compat.
package/README.md CHANGED
@@ -155,6 +155,32 @@ import { createTextGenerator, generateBatch } from '@ak--47/dungeon-master/text'
155
155
 
156
156
  these are the same functions used internally. `pickAWinner` creates weighted distributions, `weighNumRange` generates realistic numeric ranges with configurable skew, and the text generators produce organic-looking strings with sentiment analysis and keyword injection.
157
157
 
158
+ ### named exports
159
+
160
+ alongside the default `DUNGEON_MASTER` export, the package root exports loader + interop helpers:
161
+
162
+ ```javascript
163
+ import DUNGEON_MASTER, {
164
+ loadFromFile, // (path) → Promise<Dungeon> load+validate a .js/.mjs/.json dungeon
165
+ loadFromText, // (code) → Promise<Dungeon> load+validate a raw JS source string
166
+ parseJSONDungeon, // (json) → Dungeon revive a JSON dungeon into a runnable config
167
+ validateDungeonShape, // (config) → void throw if config isn't dungeon-shaped
168
+ dungeonToJSON, // (input, options?) → Promise<DungeonJSON> serialize a dungeon → JSON (inverse of parseJSONDungeon)
169
+ extractComments, // (input) → DungeonComments pull OVERVIEW / HOOK STORIES doc blocks from source
170
+ } from '@ak--47/dungeon-master';
171
+ ```
172
+
173
+ `dungeonToJSON` accepts a config object, a file path, raw JS source, or an array of paths, and returns the `{ schema, hooks, timestamp, version }` wrapper format. it round-trips with `parseJSONDungeon`:
174
+
175
+ ```javascript
176
+ const json = await dungeonToJSON('./dungeons/vertical/ecommerce.js'); // creds stripped by default
177
+ const config = parseJSONDungeon(json); // back to a runnable dungeon
178
+ ```
179
+
180
+ it's best effort — arrow functions and `chance.*` methods survive the round trip; detected utility calls (`weighArray`, `weighNumRange`, …) serialize by name without their args. pass `{ includeCredentials: true }` to keep `token` / `serviceAccount` / etc. in the output (stripped by default).
181
+
182
+ `extractComments` reads a dungeon's **source** (file path or raw text — never the imported module, since importing discards comments) and returns `{ overview, hookStories, sections }` with the comment scaffolding stripped to readable prose.
183
+
158
184
  ## how it works
159
185
 
160
186
  one call to `DUNGEON_MASTER(config)` runs through these phases in order:
package/index.js CHANGED
@@ -15,6 +15,8 @@ import { createContext, updateContextWithStorage } from './lib/core/context.js';
15
15
  import { validateDungeonConfig } from './lib/core/config-validator.js';
16
16
  import { StorageManager } from './lib/core/storage.js';
17
17
  import { detectInputType, loadFromFile, loadFromText, parseJSONDungeon, validateDungeonShape } from './lib/core/dungeon-loader.js';
18
+ import { dungeonToJSON } from './lib/core/dungeon-to-json.js';
19
+ import { extractComments } from './lib/core/extract-comments.js';
18
20
 
19
21
  // Orchestrators
20
22
  import { userLoop } from './lib/orchestrators/user-loop.js';
@@ -597,5 +599,5 @@ function extractStorageData(storage) {
597
599
 
598
600
  // ES Module exports
599
601
  export default DUNGEON_MASTER;
600
- export { parseJSONDungeon, validateDungeonShape, loadFromFile, loadFromText };
602
+ export { parseJSONDungeon, validateDungeonShape, loadFromFile, loadFromText, dungeonToJSON, extractComments };
601
603
 
@@ -0,0 +1,220 @@
1
+ /**
2
+ * dungeon → JSON serialization.
3
+ *
4
+ * The inverse of `parseJSONDungeon` (lib/core/dungeon-loader.js): turns a runnable
5
+ * dungeon into the UI/JSON wrapper format `{ schema, hooks, timestamp, version }`.
6
+ * Functions in the schema are serialized to `{ functionName, body }` / `{ functionName, args }`
7
+ * objects that `reviveJSONConfig` knows how to revive — so the output round-trips:
8
+ *
9
+ * parseJSONDungeon(await dungeonToJSON(cfg)) → runnable config
10
+ *
11
+ * This is BEST EFFORT. Arrow functions and bound `chance.*` methods round-trip cleanly;
12
+ * detected utility calls (weighArray, weighNumRange, …) are serialized without their
13
+ * arguments and revive to null, which the config validator handles gracefully.
14
+ *
15
+ * To preserve the field's TYPE even when the generator can't be revived, every function
16
+ * is sampled at serialization time (when its closure is still live) and the inferred
17
+ * output type is recorded as `dataType` (e.g. "number", "string", "boolean", "number[]").
18
+ * So a field that loses its `weighNumRange(1,10)` generator still records `dataType: "number"`.
19
+ */
20
+
21
+ import { detectInputType, loadFromFile, loadFromText } from './dungeon-loader.js';
22
+
23
+ /** Credential keys stripped from JSON output unless `includeCredentials` is set. */
24
+ const CREDENTIAL_KEYS = ['token', 'serviceAccount', 'serviceSecret', 'projectId', 'secret'];
25
+
26
+ /**
27
+ * Convert a dungeon into its JSON representation.
28
+ *
29
+ * Accepts the same input flavors as the default export: a config object, a path to a
30
+ * `.js`/`.mjs`/`.json` dungeon file, a raw JS source string (must `export default`), or
31
+ * an array of file paths (returns an array of results).
32
+ *
33
+ * @param {import('../../types').Dungeon | string | string[]} input
34
+ * @param {{ includeCredentials?: boolean }} [options]
35
+ * @returns {Promise<import('../../types').DungeonJSON | import('../../types').DungeonJSON[]>}
36
+ */
37
+ export async function dungeonToJSON(input, options = {}) {
38
+ const { includeCredentials = false } = options;
39
+ const { type, value } = detectInputType(input);
40
+
41
+ switch (type) {
42
+ case 'object':
43
+ return serializeConfig(value, includeCredentials);
44
+ case 'file':
45
+ return serializeConfig(await loadFromFile(value), includeCredentials);
46
+ case 'text':
47
+ return serializeConfig(await loadFromText(value), includeCredentials);
48
+ case 'files':
49
+ return Promise.all(
50
+ value.map(async (p) => serializeConfig(await loadFromFile(p), includeCredentials))
51
+ );
52
+ default:
53
+ throw new Error(`dungeon-master: dungeonToJSON cannot handle input type "${type}".`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Build the `{ schema, hooks, timestamp, version }` wrapper from a runnable config.
59
+ * @param {import('../../types').Dungeon} config
60
+ * @param {boolean} includeCredentials
61
+ * @returns {import('../../types').DungeonJSON}
62
+ */
63
+ function serializeConfig(config, includeCredentials) {
64
+ // Hook may be a live function (from .js/text/object) or already a string (from .json).
65
+ const hook = config.hook;
66
+ const hooks = typeof hook === 'function'
67
+ ? hook.toString()
68
+ : (typeof hook === 'string' ? hook : null);
69
+
70
+ // Strip the hook (serialized separately) and, by default, credentials (don't leak tokens).
71
+ const cleanConfig = { ...config };
72
+ delete cleanConfig.hook;
73
+ if (!includeCredentials) {
74
+ for (const key of CREDENTIAL_KEYS) delete cleanConfig[key];
75
+ }
76
+
77
+ return {
78
+ schema: convertToJSON(cleanConfig),
79
+ hooks,
80
+ timestamp: new Date().toISOString(),
81
+ version: '4.0'
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Convert a JavaScript value to a JSON-serializable form, turning functions into
87
+ * `{ functionName, body | args }` objects that `reviveJSONConfig` can revive.
88
+ * @param {any} value
89
+ * @returns {any}
90
+ */
91
+ export function convertToJSON(value) {
92
+ // Null/undefined
93
+ if (value === null || value === undefined) {
94
+ return null;
95
+ }
96
+
97
+ // Primitives
98
+ if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
99
+ return value;
100
+ }
101
+
102
+ // Functions - convert to object representation
103
+ if (typeof value === 'function') {
104
+ return convertFunctionToObject(value);
105
+ }
106
+
107
+ // Arrays
108
+ if (Array.isArray(value)) {
109
+ return value.map(item => convertToJSON(item));
110
+ }
111
+
112
+ // Objects
113
+ if (typeof value === 'object') {
114
+ const result = {};
115
+ for (const [key, val] of Object.entries(value)) {
116
+ result[key] = convertToJSON(val);
117
+ }
118
+ return result;
119
+ }
120
+
121
+ // Fallback
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Convert a function to its object representation.
127
+ * Arrow functions and bound `chance.*` methods round-trip cleanly; detected utility
128
+ * functions are stored by name without args (best effort). Every form also carries a
129
+ * sampled `dataType` so the field's output type survives even when the generator can't.
130
+ * @param {Function} func
131
+ * @returns {import('../../types').SerializedFunction}
132
+ */
133
+ function convertFunctionToObject(func) {
134
+ const funcString = func.toString();
135
+ // Sample now, while the closure is still live — the only reliable time to learn the type.
136
+ const dataType = inferDataType(func);
137
+
138
+ // Arrow function
139
+ if (funcString.startsWith('(') || funcString.startsWith('_') || funcString.includes('=>')) {
140
+ return withDataType({ functionName: 'arrow', body: funcString }, dataType);
141
+ }
142
+
143
+ // Bound chance methods (e.g., chance.name.bind(chance))
144
+ if (funcString.includes('.bind(')) {
145
+ const match = funcString.match(/chance\.(\w+)\.bind/);
146
+ if (match) {
147
+ return withDataType({ functionName: `chance.${match[1]}`, args: [] }, dataType);
148
+ }
149
+ }
150
+
151
+ // Try to detect common utility functions
152
+ // This is a best-effort approach - some complex functions might not be detected
153
+ const commonFunctions = [
154
+ 'weighNumRange',
155
+ 'weighArray',
156
+ 'weighChoices',
157
+ 'pickAWinner',
158
+ 'date',
159
+ 'integer',
160
+ 'uid',
161
+ 'comma'
162
+ ];
163
+
164
+ for (const fnName of commonFunctions) {
165
+ if (funcString.includes(fnName)) {
166
+ // Args can't be recovered from a stringified function, but dataType is captured.
167
+ return withDataType({ functionName: fnName, args: [] }, dataType);
168
+ }
169
+ }
170
+
171
+ // Generic function - just store as arrow function
172
+ return withDataType({ functionName: 'arrow', body: funcString }, dataType);
173
+ }
174
+
175
+ /**
176
+ * Attach a `dataType` field if one was inferred (omitted otherwise).
177
+ * @param {import('../../types').SerializedFunction} obj
178
+ * @param {string | undefined} dataType
179
+ * @returns {import('../../types').SerializedFunction}
180
+ */
181
+ function withDataType(obj, dataType) {
182
+ if (dataType) obj.dataType = dataType;
183
+ return obj;
184
+ }
185
+
186
+ /**
187
+ * Sample a function (no args) and classify its return type. Best effort — returns
188
+ * undefined if the call throws or yields an indeterminate value.
189
+ * @param {Function} func
190
+ * @returns {string | undefined}
191
+ */
192
+ function inferDataType(func) {
193
+ let value;
194
+ try {
195
+ value = func();
196
+ } catch {
197
+ return undefined;
198
+ }
199
+ return classifyValue(value);
200
+ }
201
+
202
+ /**
203
+ * Map a sampled value to a type label: "number" | "string" | "boolean" | "date" |
204
+ * "object" | "<elementType>[]" | "array". Returns undefined for null/undefined/functions.
205
+ * @param {any} value
206
+ * @returns {string | undefined}
207
+ */
208
+ function classifyValue(value) {
209
+ if (value === null || value === undefined) return undefined;
210
+ if (Array.isArray(value)) {
211
+ const el = value.find((v) => v !== null && v !== undefined);
212
+ const elType = el === undefined ? undefined : classifyValue(el);
213
+ return elType ? `${elType}[]` : 'array';
214
+ }
215
+ if (value instanceof Date) return 'date';
216
+ const t = typeof value;
217
+ if (t === 'number' || t === 'string' || t === 'boolean') return t;
218
+ if (t === 'object') return 'object';
219
+ return undefined;
220
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Best-effort extraction of the human-readable doc blocks from a dungeon's SOURCE.
3
+ *
4
+ * Generated/authored dungeons separate sections with box-drawing headers followed by a
5
+ * block comment, e.g.:
6
+ *
7
+ * // ── OVERVIEW ──
8
+ * /* ... *\/
9
+ * // ── HOOK STORIES ──
10
+ * /* ... *\/
11
+ *
12
+ * This pulls those blocks out as cleaned prose. It operates on RAW SOURCE TEXT — it never
13
+ * imports the dungeon, because importing a module discards its comments.
14
+ *
15
+ * Returns `{ overview, hookStories, sections }`:
16
+ * - `overview` — cleaned text of the OVERVIEW block (or null)
17
+ * - `hookStories` — cleaned text of the HOOK STORIES block (or null)
18
+ * - `sections` — every `// ── LABEL ──` header that is immediately followed by a
19
+ * block comment, keyed by the exact label as written.
20
+ */
21
+
22
+ import { readFileSync } from 'fs';
23
+ import { detectInputType } from './dungeon-loader.js';
24
+
25
+ // A `// ── LABEL ──` header line. Anchored to `//` at line start so that the inner
26
+ // ` * ───────` dividers inside a block comment are NOT mistaken for section headers.
27
+ const HEADER_RE = /^[ \t]*\/\/[ \t]*─+[ \t]*(.+?)[ \t]*─+[ \t]*$/gm;
28
+
29
+ /**
30
+ * @param {string | string[]} input - A dungeon file path, raw dungeon source, or array of paths.
31
+ * @returns {import('../../types').DungeonComments | import('../../types').DungeonComments[]}
32
+ */
33
+ export function extractComments(input) {
34
+ const { type, value } = detectInputType(input);
35
+
36
+ switch (type) {
37
+ case 'file':
38
+ return parseSource(readFileSync(value, 'utf-8'));
39
+ case 'text':
40
+ return parseSource(value);
41
+ case 'files':
42
+ return value.map((p) => parseSource(readFileSync(p, 'utf-8')));
43
+ case 'object':
44
+ throw new Error(
45
+ 'dungeon-master: extractComments needs source text or a file path, not a config object (comments are lost once a dungeon is imported).'
46
+ );
47
+ default:
48
+ throw new Error(`dungeon-master: extractComments cannot handle input type "${type}".`);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Parse a single source string into `{ overview, hookStories, sections }`.
54
+ * @param {string} source
55
+ * @returns {import('../../types').DungeonComments}
56
+ */
57
+ function parseSource(source) {
58
+ /** @type {Record<string, string>} */
59
+ const sections = {};
60
+
61
+ // Collect every header with its label and the position right after its line.
62
+ const headers = [];
63
+ HEADER_RE.lastIndex = 0;
64
+ let m;
65
+ while ((m = HEADER_RE.exec(source)) !== null) {
66
+ headers.push({ label: m[1].trim(), end: HEADER_RE.lastIndex });
67
+ }
68
+
69
+ for (let i = 0; i < headers.length; i++) {
70
+ const { label, end } = headers[i];
71
+ // Only look between this header and the next one.
72
+ const sliceEnd = i + 1 < headers.length ? headers[i + 1].end : source.length;
73
+ const slice = source.slice(end, sliceEnd);
74
+
75
+ // Accept the block only if a `/* ... */` comment is the first non-whitespace content
76
+ // after the header (so headers followed by code — IMPORTS, SCALE — are skipped).
77
+ const block = slice.match(/^\s*\/\*([\s\S]*?)\*\//);
78
+ if (block) {
79
+ sections[label] = cleanBlock(block[1]);
80
+ }
81
+ }
82
+
83
+ return {
84
+ overview: findSection(sections, ['OVERVIEW']),
85
+ hookStories: findSection(sections, ['HOOK STORIES', 'HOOK STORY']),
86
+ sections
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Strip block-comment scaffolding (` * `) and trim blank edges, leaving readable prose.
92
+ * @param {string} inner - The text between `/*` and `*\/`.
93
+ * @returns {string}
94
+ */
95
+ function cleanBlock(inner) {
96
+ const lines = inner
97
+ .split('\n')
98
+ // Drop a leading ` * ` (or bare ` *`) from each line; preserve content indentation.
99
+ .map((line) => line.replace(/^[ \t]*\*[ \t]?/, '').replace(/\s+$/, ''));
100
+
101
+ // Trim leading/trailing blank lines.
102
+ while (lines.length && lines[0] === '') lines.shift();
103
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
104
+
105
+ return lines.join('\n');
106
+ }
107
+
108
+ /**
109
+ * Case-insensitive lookup of the first matching label.
110
+ * @param {Record<string, string>} sections
111
+ * @param {string[]} candidates
112
+ * @returns {string | null}
113
+ */
114
+ function findSection(sections, candidates) {
115
+ const wanted = candidates.map((c) => c.toUpperCase());
116
+ for (const [label, text] of Object.entries(sections)) {
117
+ if (wanted.includes(label.toUpperCase())) return text;
118
+ }
119
+ return null;
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -107,4 +107,4 @@
107
107
  "tmp/"
108
108
  ]
109
109
  }
110
- }
110
+ }