@ak--47/dungeon-master 1.5.2 → 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.
@@ -134,7 +134,12 @@ FROM (
134
134
 
135
135
  ## Step 3: Write Report
136
136
 
137
- Create `soup-analysis.md` in the project root with:
137
+ Create `soup-analysis.md` with the structure below. For a user dungeon
138
+ (`dungeons/user/<name>/<name>.js`) write it into the dungeon's folder
139
+ (`dungeons/user/<name>/soup-analysis.md`) — everything about a dungeon lives in
140
+ its folder. Otherwise write it to the project root. (The generated
141
+ `./data/soup-analysis-EVENTS.json` is throwaway verification data — leave it in
142
+ `./data/`.) Contents:
138
143
 
139
144
  1. **Config**: The soup parameters used (peaks, deviation, mean, numDays)
140
145
  2. **Summary stats**: Total events, event count, avg EPS
@@ -18,7 +18,7 @@ box. It does **NOT** engineer story trends or magic numbers — those are the
18
18
  should be:
19
19
 
20
20
  ```
21
- /write-hooks dungeons/user/<your-dungeon>.js "describe the trends to engineer"
21
+ /write-hooks dungeons/user/<name>/<name>.js "describe the trends to engineer"
22
22
  ```
23
23
 
24
24
  In scope here:
@@ -63,7 +63,7 @@ Before writing any code, scan:
63
63
  - `lib/utils/utils.js` — `pickAWinner`, `weighNumRange`, `initChance`, `exhaust`,
64
64
  `takeSome` for property value distributions
65
65
  - `dungeons/vertical/sass.js` — B2B reference dungeon with full identity model
66
- - `dungeons/user/my-buddy.js` — consumer-app reference (gitignored)
66
+ - `dungeons/user/my-buddy/my-buddy.js` — consumer-app reference (gitignored)
67
67
  - `dungeons/technical/identity-model-verify.js` — minimal identity-model fixture
68
68
 
69
69
  ## File structure
@@ -524,9 +524,9 @@ userProps: { Plan: PLANS, Region: REGIONS, Role: ROLES, ... },
524
524
 
525
525
  After writing the file:
526
526
 
527
- 1. Smoke-test: `node scripts/verify-runner.mjs dungeons/user/<file>.js verify-<file> --small`. Confirm zero errors.
528
- 2. Hand to the next skill: `/write-hooks dungeons/user/<file>.js "describe trends"`.
529
- 3. After hooks land: `/verify-dungeon dungeons/user/<file>.js`.
527
+ 1. Smoke-test: `node scripts/verify-runner.mjs dungeons/user/<name>/<name>.js verify-<name> --small`. Confirm zero errors.
528
+ 2. Hand to the next skill: `/write-hooks dungeons/user/<name>/<name>.js "describe trends"`.
529
+ 3. After hooks land: `/verify-dungeon dungeons/user/<name>/<name>.js`.
530
530
 
531
531
  ## Property Type Reference
532
532
 
@@ -550,8 +550,20 @@ When designing event properties, always consider which Mixpanel type best repres
550
550
 
551
551
  ## Output
552
552
 
553
- Write the file to `dungeons/user/<descriptive-name>.js`. Do NOT inject hooks.
554
- Do NOT use `subscription`, `attribution`, `geo`, `features`, or `anomalies`
555
- (the engine will silently strip them and warn).
553
+ **One folder per customer/dungeon.** Pick a short, kebab-case `<name>` from the
554
+ app/customer, then **create `dungeons/user/<name>/` if it doesn't already exist**
555
+ (`mkdir -p dungeons/user/<name>`) and write the dungeon to
556
+ `dungeons/user/<name>/<name>.js` (e.g. `dungeons/user/acme/acme.js`). Folder and
557
+ file share the name, matching `kodiak/kodiak.js`, `my-buddy/my-buddy.js`.
558
+
559
+ This keeps `dungeons/user/` organized — EVERYTHING about this dungeon lives in
560
+ the same folder: `hook-results.md` + `hook-query-log.txt` +
561
+ `<name>-verifications.sql` (from `verify-dungeon`), `soup-analysis.md` (from
562
+ `analyze-soup`), briefs, schema CSV/JSON, example data. The only thing kept
563
+ outside is the throwaway verification data the runs write to `./data/` (cleaned
564
+ after).
565
+
566
+ Do NOT inject hooks. Do NOT use `subscription`, `attribution`, `geo`,
567
+ `features`, or `anomalies` (the engine will silently strip them and warn).
556
568
 
557
569
  When done, tell the user the next skill to run.
@@ -102,13 +102,33 @@ For emulator details, identity-model dungeons (must pass `profiles`), and time-s
102
102
  - Experiment invariants (variant distribution, exposure timing, deterministic assignment) when any funnel uses `experiment:`
103
103
  - SuperProp consistency, SuperProp/UserProp mirror, Mixpanel default-property casing, funnel-pre dilution
104
104
 
105
- ### Step 5: Stash query log (optional)
105
+ ### Artifact location
106
106
 
107
- If `./research/` exists locally, write every DuckDB query execution to `./research/hook-query-log.txt`. Format and conventions: see [report-format.md "Query log format"](references/report-format.md#query-log-format). If `./research/` does not exist, skip — do not create the directory.
107
+ **Everything about a dungeon lives in its folder.** When the dungeon being
108
+ verified is a user dungeon at `dungeons/user/<name>/<name>.js`, write ALL
109
+ generated artifacts into `dungeons/user/<name>/`:
110
+ - `hook-results.md` (Step 6)
111
+ - `hook-query-log.txt` (Step 5)
112
+ - `<name>-verifications.sql` (Step 6b)
108
113
 
109
- ### Step 6: Write `./research/hook-results.md`
114
+ The ONLY exception is the throwaway verification data the run writes to
115
+ `./data/` (`verify-*` event/user files) — that stays in `./data/` and is
116
+ deleted in Step 7.
110
117
 
111
- Use the templates in [report-format.md](references/report-format.md):
118
+ For non-user dungeons (technical/vertical) or batch runs across many dungeons,
119
+ fall back to `./research/` for `hook-results.md` / `hook-query-log.txt`.
120
+
121
+ ### Step 5: Stash query log
122
+
123
+ Write every DuckDB query execution to `hook-query-log.txt`:
124
+ - **User dungeon:** always write to `dungeons/user/<name>/hook-query-log.txt`.
125
+ - **Otherwise:** if `./research/` exists locally, write to `./research/hook-query-log.txt`; if it doesn't exist, skip — do not create the directory.
126
+
127
+ Format and conventions: see [report-format.md "Query log format"](references/report-format.md#query-log-format).
128
+
129
+ ### Step 6: Write `hook-results.md`
130
+
131
+ Write to `dungeons/user/<name>/hook-results.md` for a user dungeon, else `./research/hook-results.md`. Use the templates in [report-format.md](references/report-format.md):
112
132
  - Single-dungeon report structure
113
133
  - Multi-dungeon report structure (when batch mode)
114
134
  - Per-hook detail block
@@ -118,7 +138,7 @@ Use the templates in [report-format.md](references/report-format.md):
118
138
 
119
139
  ### Step 6b: Write verification SQL (mandatory for user dungeons)
120
140
 
121
- When verifying a dungeon in `dungeons/user/`, also write a standalone DuckDB SQL file at `dungeons/user/<name>-verifications.sql`. Vertical dungeons already have their SQL in `verification/verticals/`. Format: see [report-format.md "Verification SQL file"](references/report-format.md#verification-sql-file-mandatory-for-user-dungeons).
141
+ When verifying a dungeon in `dungeons/user/`, also write a standalone DuckDB SQL file alongside the dungeon in its folder at `dungeons/user/<name>/<name>-verifications.sql`. Vertical dungeons already have their SQL in `verification/verticals/`. Format: see [report-format.md "Verification SQL file"](references/report-format.md#verification-sql-file-mandatory-for-user-dungeons).
122
142
 
123
143
  ### Step 7: Cleanup
124
144
 
@@ -148,9 +168,9 @@ Return-value behavior:
148
168
  ## Final output
149
169
 
150
170
  Tell the user:
151
- 1. Report path: `./research/hook-results.md`
152
- 2. Verification SQL path (for user dungeons): `dungeons/user/<name>-verifications.sql`
153
- 3. Query log path (if written): `./research/hook-query-log.txt`
171
+ 1. Report path: `dungeons/user/<name>/hook-results.md` (user dungeon) or `./research/hook-results.md`
172
+ 2. Verification SQL path (for user dungeons): `dungeons/user/<name>/<name>-verifications.sql`
173
+ 3. Query log path (if written): alongside the report (`dungeons/user/<name>/hook-query-log.txt`, else `./research/hook-query-log.txt`)
154
174
  4. Pass/weak/fail counts (per dungeon if batch mode)
155
175
  5. One-line summary of the most interesting finding
156
176
 
@@ -1,6 +1,6 @@
1
1
  # Report Format
2
2
 
3
- Templates and conventions for writing `./research/hook-results.md` and per-dungeon verification SQL.
3
+ Templates and conventions for writing `hook-results.md` and per-dungeon verification SQL. For user dungeons these live in the dungeon's folder (`dungeons/user/<name>/`); otherwise in `./research/`. See [SKILL.md "Artifact location"](../SKILL.md).
4
4
 
5
5
  ## Verdict criteria (5-tier)
6
6
 
@@ -147,9 +147,7 @@ Each hook's detailed section follows this template (same for single and multi-du
147
147
 
148
148
  ## Query log format
149
149
 
150
- If `./research/` exists locally, write a plain-text log of every DuckDB query execution to `./research/hook-query-log.txt`. If `./research/` does not exist, skip this step entirely do not create the directory.
151
-
152
- Check with: `ls -d ./research/ 2>/dev/null`
150
+ Write a plain-text log of every DuckDB query execution to `hook-query-log.txt`. For a user dungeon, write it to the dungeon's folder (`dungeons/user/<name>/hook-query-log.txt`). Otherwise, only if `./research/` exists locally write `./research/hook-query-log.txt` — if it doesn't exist, skip this step entirely (don't create the directory; check with `ls -d ./research/ 2>/dev/null`).
153
151
 
154
152
  Use a consistent delimited format — one block per query, separated by a ruler line. DuckDB table output is preserved verbatim:
155
153
 
@@ -191,7 +189,7 @@ grep "^DUNGEON:" research/hook-query-log.txt # list of dungeons querie
191
189
 
192
190
  ## Verification SQL file (mandatory for user dungeons)
193
191
 
194
- When verifying a dungeon in `dungeons/user/`, write a standalone DuckDB SQL file alongside the dungeon at `dungeons/user/<name>-verifications.sql`. This file is the reproducible verification artifact — anyone can re-run it against fresh data.
192
+ When verifying a dungeon in `dungeons/user/`, write a standalone DuckDB SQL file alongside the dungeon in its folder at `dungeons/user/<name>/<name>-verifications.sql`. This file is the reproducible verification artifact — anyone can re-run it against fresh data.
195
193
 
196
194
  Follow the format in `verification/verticals/`:
197
195
 
@@ -200,8 +198,8 @@ Follow the format in `verification/verticals/`:
200
198
  -- <name>.js — Hook Verification SQL (N hooks)
201
199
  -- ============================================================================
202
200
  -- USAGE:
203
- -- 1. node scripts/verify-runner.mjs dungeons/user/<name>.js verify-<name>
204
- -- 2. duckdb < dungeons/user/<name>-verifications.sql
201
+ -- 1. node scripts/verify-runner.mjs dungeons/user/<name>/<name>.js verify-<name>
202
+ -- 2. duckdb < dungeons/user/<name>/<name>-verifications.sql
205
203
  -- 3. rm -f verify-<name>-*
206
204
  -- ============================================================================
207
205
 
@@ -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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
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
+
5
56
  ## 1.5.2 — 2026-05-21
6
57
 
7
58
  Docs-only patch. Aligns the `.claude/skills/` authoring + verification
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.2",
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
+ }
@@ -1,15 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Converts a JavaScript dungeon file to JSON that can be loaded into the UI
4
+ * Converts a JavaScript dungeon file to JSON that can be loaded into the UI.
5
+ * Thin CLI wrapper around the exported `dungeonToJSON` (lib/core/dungeon-to-json.js).
5
6
  * Usage: node scripts/dungeon-to-json.js <input.js> [output-name]
6
7
  */
7
8
 
8
9
  import { writeFileSync } from 'fs';
9
10
  import path from 'path';
10
- import { fileURLToPath } from 'url';
11
-
12
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ import { dungeonToJSON } from '../lib/core/dungeon-to-json.js';
13
12
 
14
13
  // Get command line arguments
15
14
  const args = process.argv.slice(2);
@@ -25,34 +24,10 @@ const inputPath = path.resolve(args[0]);
25
24
  const outputName = args[1] || path.basename(inputPath, '.js');
26
25
 
27
26
  try {
28
- // Import the JavaScript module
29
27
  console.log(`📖 Loading ${inputPath}...`);
30
- const module = await import(`file://${inputPath}`);
31
- const config = module.default;
32
-
33
- if (!config) {
34
- throw new Error('No default export found in the module');
35
- }
36
-
37
- // Extract hooks if they exist
38
- const hooksFunction = config.hook;
39
- const hooksString = hooksFunction ? hooksFunction.toString() : null;
40
-
41
- // Create a clean config without the hook function
42
- const cleanConfig = { ...config };
43
- delete cleanConfig.hook;
44
-
45
- // Convert to JSON-serializable format
46
28
  console.log('🔄 Converting to JSON format...');
47
- const schema = convertToJSON(cleanConfig);
48
-
49
- // Create the dungeon state object (same format as UI saves)
50
- const dungeonState = {
51
- schema,
52
- hooks: hooksString,
53
- timestamp: new Date().toISOString(),
54
- version: '4.0'
55
- };
29
+ // includeCredentials: true preserves the legacy UI round-trip behavior (full config).
30
+ const dungeonState = await dungeonToJSON(inputPath, { includeCredentials: true });
56
31
 
57
32
  // Write to JSON file
58
33
  const outputPath = path.join(path.dirname(inputPath), `${outputName}.json`);
@@ -67,97 +42,3 @@ try {
67
42
  console.error(error.stack);
68
43
  process.exit(1);
69
44
  }
70
-
71
- /**
72
- * Convert JavaScript config to JSON-serializable format
73
- * Detects functions and converts them to object representation
74
- */
75
- function convertToJSON(value) {
76
- // Null/undefined
77
- if (value === null || value === undefined) {
78
- return null;
79
- }
80
-
81
- // Primitives
82
- if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
83
- return value;
84
- }
85
-
86
- // Functions - convert to object representation
87
- if (typeof value === 'function') {
88
- return convertFunctionToObject(value);
89
- }
90
-
91
- // Arrays
92
- if (Array.isArray(value)) {
93
- return value.map(item => convertToJSON(item));
94
- }
95
-
96
- // Objects
97
- if (typeof value === 'object') {
98
- const result = {};
99
- for (const [key, val] of Object.entries(value)) {
100
- result[key] = convertToJSON(val);
101
- }
102
- return result;
103
- }
104
-
105
- // Fallback
106
- return null;
107
- }
108
-
109
- /**
110
- * Convert a function to its object representation
111
- * Tries to detect the function type and extract parameters
112
- */
113
- function convertFunctionToObject(func) {
114
- const funcString = func.toString();
115
-
116
- // Arrow function
117
- if (funcString.startsWith('(') || funcString.startsWith('_') || funcString.includes('=>')) {
118
- return {
119
- functionName: 'arrow',
120
- body: funcString
121
- };
122
- }
123
-
124
- // Bound chance methods (e.g., chance.name.bind(chance))
125
- if (funcString.includes('.bind(')) {
126
- const match = funcString.match(/chance\.(\w+)\.bind/);
127
- if (match) {
128
- return {
129
- functionName: `chance.${match[1]}`,
130
- args: []
131
- };
132
- }
133
- }
134
-
135
- // Try to detect common utility functions
136
- // This is a best-effort approach - some complex functions might not be detected
137
- const commonFunctions = [
138
- 'weighNumRange',
139
- 'weighArray',
140
- 'weighChoices',
141
- 'pickAWinner',
142
- 'date',
143
- 'integer',
144
- 'uid',
145
- 'comma'
146
- ];
147
-
148
- for (const fnName of commonFunctions) {
149
- if (funcString.includes(fnName)) {
150
- // Extract args (this is simplified - real parsing would be more complex)
151
- return {
152
- functionName: fnName,
153
- args: [] // Args would need to be extracted, but that's complex
154
- };
155
- }
156
- }
157
-
158
- // Generic function - just store as arrow function
159
- return {
160
- functionName: 'arrow',
161
- body: funcString
162
- };
163
- }
package/types.d.ts CHANGED
@@ -1508,6 +1508,79 @@ export declare function parseJSONDungeon(json: object): Dungeon;
1508
1508
  /** Validate that an object has the minimum shape of a dungeon config. Throws on shape violations. */
1509
1509
  export declare function validateDungeonShape(config: unknown): void;
1510
1510
 
1511
+ /**
1512
+ * The serialized form of a function found in a dungeon schema. Produced by `dungeonToJSON`
1513
+ * and revived by `parseJSONDungeon`.
1514
+ *
1515
+ * - `functionName: "arrow"` with a `body` — an inline/closure function, re-eval'd on revive.
1516
+ * - `functionName: "chance.<method>"` — a bound chance method, e.g. `chance.name.bind(chance)`.
1517
+ * - `functionName: "<utility>"` (weighNumRange, weighArray, …) — a detected utility; args are
1518
+ * not recoverable from the stringified source, so it revives to null (best effort).
1519
+ *
1520
+ * `dataType` records the function's sampled output type so the field's type is preserved even
1521
+ * when the generator itself can't be revived.
1522
+ */
1523
+ export interface SerializedFunction {
1524
+ /** "arrow", "chance.<method>", or a known utility name. */
1525
+ functionName: string;
1526
+ /** Stringified function source (present for arrow/closure forms). */
1527
+ body?: string;
1528
+ /** Captured call arguments (best effort; usually empty for detected utilities). */
1529
+ args?: unknown[];
1530
+ /**
1531
+ * Inferred output type, sampled at serialization time:
1532
+ * "number" | "string" | "boolean" | "date" | "object" | "<elementType>[]" | "array".
1533
+ * Omitted when sampling failed (e.g. the function threw).
1534
+ */
1535
+ dataType?: string;
1536
+ }
1537
+
1538
+ /**
1539
+ * The JSON/UI wrapper representation of a dungeon, as produced by `dungeonToJSON` and
1540
+ * consumed by `parseJSONDungeon`. Functions in `schema` are serialized to
1541
+ * {@link SerializedFunction} objects; everything else is plain JSON.
1542
+ */
1543
+ export interface DungeonJSON {
1544
+ /** The dungeon config with functions converted to {@link SerializedFunction} objects (hook excluded). */
1545
+ schema: Record<string, unknown>;
1546
+ /** The `hook` function stringified, or null if the dungeon has no hook. */
1547
+ hooks: string | null;
1548
+ /** ISO timestamp of when the JSON was produced. */
1549
+ timestamp: string;
1550
+ /** UI schema format version. */
1551
+ version: string;
1552
+ }
1553
+
1554
+ /** The doc blocks extracted from a dungeon's source by `extractComments`. */
1555
+ export interface DungeonComments {
1556
+ /** Cleaned text of the `// ── OVERVIEW ──` block, or null if absent. */
1557
+ overview: string | null;
1558
+ /** Cleaned text of the `// ── HOOK STORIES ──` block, or null if absent. */
1559
+ hookStories: string | null;
1560
+ /** Every `// ── LABEL ──` header followed by a block comment, keyed by exact label. */
1561
+ sections: Record<string, string>;
1562
+ }
1563
+
1564
+ /**
1565
+ * Convert a dungeon into its JSON representation (the inverse of `parseJSONDungeon`).
1566
+ * Accepts a config object, a file path, raw JS source, or an array of file paths
1567
+ * (returns an array). Credentials are stripped unless `includeCredentials` is set.
1568
+ * Best effort: arrow functions and `chance.*` methods round-trip; detected utility
1569
+ * calls lose their arguments. Always async.
1570
+ */
1571
+ export declare function dungeonToJSON(input: Dungeon, options?: { includeCredentials?: boolean }): Promise<DungeonJSON>;
1572
+ export declare function dungeonToJSON(input: string, options?: { includeCredentials?: boolean }): Promise<DungeonJSON>;
1573
+ export declare function dungeonToJSON(input: string[], options?: { includeCredentials?: boolean }): Promise<DungeonJSON[]>;
1574
+
1575
+ /**
1576
+ * Extract the human-readable doc blocks (OVERVIEW, HOOK STORIES, …) from a dungeon's
1577
+ * SOURCE. Operates on a file path or raw source string — never imports the dungeon
1578
+ * (importing discards comments). Best effort: relies on the canonical `// ── LABEL ──`
1579
+ * header + block-comment convention.
1580
+ */
1581
+ export declare function extractComments(input: string): DungeonComments;
1582
+ export declare function extractComments(input: string[]): DungeonComments[];
1583
+
1511
1584
  // ============= Text Generator Types =============
1512
1585
 
1513
1586
  /**