@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.
- package/.claude/skills/analyze-soup/SKILL.md +6 -1
- package/.claude/skills/create-dungeon/SKILL.md +159 -54
- package/.claude/skills/verify-dungeon/SKILL.md +28 -8
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +5 -7
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
- package/.claude/skills/write-hooks/SKILL.md +33 -5
- package/CHANGELOG.md +93 -0
- package/README.md +26 -0
- package/index.js +3 -1
- package/lib/core/dungeon-to-json.js +220 -0
- package/lib/core/extract-comments.js +120 -0
- package/package.json +2 -2
- package/scripts/dungeon-to-json.mjs +5 -124
- package/types.d.ts +73 -0
|
@@ -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
|
|
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`
|
|
200
|
-
`hasAnonIds: true
|
|
201
|
-
identity map merging pre-auth `device_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
|
+
}
|