@ak--47/dungeon-master 1.5.4 → 1.6.0
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 +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
package/README.md
CHANGED
|
@@ -51,9 +51,9 @@ const result = await DUNGEON_MASTER('./dungeons/technical/simple-schema.json');
|
|
|
51
51
|
|
|
52
52
|
// run multiple dungeons
|
|
53
53
|
const results = await DUNGEON_MASTER([
|
|
54
|
-
'./dungeons/vertical/gaming.js',
|
|
55
|
-
'./dungeons/vertical/media.js',
|
|
56
|
-
'./dungeons/vertical/food-delivery.js'
|
|
54
|
+
'./dungeons/vertical/gaming/gaming.js',
|
|
55
|
+
'./dungeons/vertical/media/media.js',
|
|
56
|
+
'./dungeons/vertical/food-delivery/food-delivery.js'
|
|
57
57
|
]);
|
|
58
58
|
|
|
59
59
|
// pass raw javascript as a string
|
|
@@ -70,7 +70,7 @@ const result = await DUNGEON_MASTER(`
|
|
|
70
70
|
`);
|
|
71
71
|
|
|
72
72
|
// override any config when loading from files
|
|
73
|
-
const result = await DUNGEON_MASTER('./dungeons/vertical/fintech.js', {
|
|
73
|
+
const result = await DUNGEON_MASTER('./dungeons/vertical/fintech/fintech.js', {
|
|
74
74
|
numUsers: 100, // shrink for testing
|
|
75
75
|
writeToDisk: true,
|
|
76
76
|
verbose: true
|
|
@@ -95,9 +95,9 @@ console.log(result.importResults);
|
|
|
95
95
|
|
|
96
96
|
a dungeon is a javascript file that exports a configuration object. it defines your entire data model: events, funnels, user properties, group analytics, SCDs, and a hook function that engineers discoverable patterns into the data.
|
|
97
97
|
|
|
98
|
-
see `dungeons/vertical/` for customer-facing story dungeons (
|
|
98
|
+
see `dungeons/vertical/` for customer-facing story dungeons (one folder per vertical, each with engineered hooks and machine-checkable stories) and `dungeons/technical/` for feature-testing dungeons (mirrors, groups, scale, anonymous users).
|
|
99
99
|
|
|
100
|
-
every vertical dungeon ships with a verification proof at `
|
|
100
|
+
every vertical dungeon ships with a verification proof at `dungeons/vertical/<name>/<name>.{verify.mjs,sql}` plus a `stories` export evaluated mechanically by `scripts/verify-stories.mjs` — a CI-runnable assertion that the dungeon's documented hooks actually appear in the generated data at full fidelity. 22 dungeons, 212 machine-checkable stories. see [`dungeons/vertical/README.md`](dungeons/vertical/README.md).
|
|
101
101
|
|
|
102
102
|
```javascript
|
|
103
103
|
// dungeons/my-app.js
|
|
@@ -173,7 +173,7 @@ import DUNGEON_MASTER, {
|
|
|
173
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
174
|
|
|
175
175
|
```javascript
|
|
176
|
-
const json = await dungeonToJSON('./dungeons/vertical/ecommerce.js'); // creds stripped by default
|
|
176
|
+
const json = await dungeonToJSON('./dungeons/vertical/ecommerce/ecommerce.js'); // creds stripped by default
|
|
177
177
|
const config = parseJSONDungeon(json); // back to a runnable dungeon
|
|
178
178
|
```
|
|
179
179
|
|
|
@@ -535,6 +535,25 @@ funnels: [
|
|
|
535
535
|
|
|
536
536
|
ordering strategies: `sequential`, `random`, `first-fixed`, `last-fixed`, `first-and-last-fixed`, `middle-fixed`, `interrupted`
|
|
537
537
|
|
|
538
|
+
### experiments
|
|
539
|
+
|
|
540
|
+
experiments are a property of funnels. any funnel with `experiment` set fires a `$experiment_started` event (with `Experiment name` / `Variant name` properties) at the start of every qualifying pass, and the assigned variant's `conversionMultiplier` / `ttcMultiplier` modify that pass:
|
|
541
|
+
|
|
542
|
+
```javascript
|
|
543
|
+
experiment: true // shorthand: Variant A (worse) / Variant B (better) / Control
|
|
544
|
+
experiment: {
|
|
545
|
+
name: 'Checkout Redesign',
|
|
546
|
+
startDaysBeforeEnd: 30, // runs before this date skip the experiment entirely
|
|
547
|
+
sticky: true, // default — see below
|
|
548
|
+
variants: [
|
|
549
|
+
{ name: 'Control' },
|
|
550
|
+
{ name: 'New Checkout', conversionMultiplier: 1.25, ttcMultiplier: 0.8, weight: 1 },
|
|
551
|
+
]
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
variant assignment is **sticky by default**: a deterministic hash of `user_id` + experiment name, so a user keeps their variant across every funnel pass (matches Mixpanel experiment SDK bucketing and makes variant lift verifiable). set `sticky: false` to re-roll the variant on each pass with the seeded RNG. hooks see the resolved variant on `meta.experiment` in `funnel-pre` / `funnel-post`.
|
|
556
|
+
|
|
538
557
|
## user generation
|
|
539
558
|
|
|
540
559
|
users are generated with configurable birth distributions, normally controlled via the `macro` preset (see "time shape" above). these three knobs can also be set directly on the dungeon config — they override the preset's values.
|
|
@@ -609,7 +628,7 @@ styles: `support`, `review`, `search`, `feedback`, `chat`, `email`, `forum`, `co
|
|
|
609
628
|
## scripts
|
|
610
629
|
|
|
611
630
|
```bash
|
|
612
|
-
npm test # vitest test suite
|
|
631
|
+
npm test # full vitest test suite
|
|
613
632
|
npm run typecheck # typescript check
|
|
614
633
|
npm run dungeon:run # run a dungeon file locally
|
|
615
634
|
npm run dungeon:to-json # convert JS dungeon to JSON (for UI import)
|
|
@@ -649,7 +668,7 @@ npx vitest run tests/integration/features.test.js # single file
|
|
|
649
668
|
npx vitest tests/unit # watch mode
|
|
650
669
|
```
|
|
651
670
|
|
|
652
|
-
`tests/e2e/
|
|
671
|
+
`tests/e2e/engine-shape-full-sweep.test.js` skips itself unless `RUN_FULL_SWEEP=1` is set (it wraps the long-running 194-combo engine sweep).
|
|
653
672
|
|
|
654
673
|
### engine tests (direct-run, NOT vitest)
|
|
655
674
|
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# dungeon-master 1.3.0 — Upgrade Guide for Agents
|
|
2
|
+
|
|
3
|
+
This is the guide for AI agents (Claude, Cursor, etc.) writing or modifying `dungeon-master` configurations and code on or after the 1.3.0 release. Read this if you've been trained on 1.2.x patterns; the API has changed in non-breaking but important ways.
|
|
4
|
+
|
|
5
|
+
**TL;DR**:
|
|
6
|
+
1. Use `avgEventsPerUserPerDay` instead of `numEvents` when writing new dungeon configs.
|
|
7
|
+
2. Use `macro: "..."` for big-picture trend; `soup: "..."` is now intra-week / intra-day rhythm only.
|
|
8
|
+
3. The default trend is **flat** (no growth ramp). If you want a growth narrative, set `macro: "growth"` explicitly.
|
|
9
|
+
4. Hooks may not introduce events past `MAX_TIME` — the runtime drops them defensively, so plan around that.
|
|
10
|
+
|
|
11
|
+
## Why 1.3.0 exists
|
|
12
|
+
|
|
13
|
+
In 1.2.x and earlier, every dungeon's Mixpanel chart blew up at the right edge: a meteoric ramp in the final ~14 days that wasn't intentional. Three mechanisms compounded:
|
|
14
|
+
|
|
15
|
+
1. `avgEvPerUser = numEvents / numUsers` was fixed regardless of birth time, so a user born day 80 of 90 stuffed their whole event budget into 10 days (9× the per-day density of a pre-existing user).
|
|
16
|
+
2. The default `bornRecentBias = 0.3` (in old `growth` soup preset) skewed births toward recent.
|
|
17
|
+
3. Pre-existing users all started at `FIXED_BEGIN`, front-loading the early window.
|
|
18
|
+
|
|
19
|
+
1.3.0 fixes all three. The default behavior is now flat-with-weekly-cycle. Hooks layer the story on top.
|
|
20
|
+
|
|
21
|
+
See [`end-bunchiness.md`](end-bunchiness.md) for the full diagnosis, experiments, and final regression numbers.
|
|
22
|
+
|
|
23
|
+
## What changed at the API level
|
|
24
|
+
|
|
25
|
+
### NEW: `avgEventsPerUserPerDay` (top-level config field)
|
|
26
|
+
|
|
27
|
+
The canonical event-volume primitive. Each user's event budget is `rate × user_active_days`:
|
|
28
|
+
- Pre-existing user (created before window): full `numDays` of activity → `rate × numDays` events.
|
|
29
|
+
- Born-in-dataset user: from birth to now → `rate × remaining_days` events (floored at 1 day).
|
|
30
|
+
|
|
31
|
+
Before:
|
|
32
|
+
```javascript
|
|
33
|
+
const num_days = 100;
|
|
34
|
+
const num_users = 5_000;
|
|
35
|
+
const avg_events_per_user = 120; // per-user TOTAL
|
|
36
|
+
|
|
37
|
+
const config = {
|
|
38
|
+
numDays: num_days,
|
|
39
|
+
numEvents: num_users * avg_events_per_user,
|
|
40
|
+
numUsers: num_users,
|
|
41
|
+
// ...
|
|
42
|
+
};
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
After:
|
|
46
|
+
```javascript
|
|
47
|
+
const num_days = 100;
|
|
48
|
+
const num_users = 5_000;
|
|
49
|
+
const avg_events_per_user_per_day = 1.2; // per-user-PER-DAY rate
|
|
50
|
+
|
|
51
|
+
const config = {
|
|
52
|
+
numDays: num_days,
|
|
53
|
+
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
54
|
+
numUsers: num_users,
|
|
55
|
+
// ...
|
|
56
|
+
};
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`numEvents` still works as a **fallback**: if you only set `numEvents`, the validator derives `avgEventsPerUserPerDay = numEvents / numUsers / numDays`. Both fields end up populated in the resolved config. **Prefer the rate field in new dungeons** — it's the right unit for the proportional allocation.
|
|
60
|
+
|
|
61
|
+
### NEW: `macro` preset system (top-level config field)
|
|
62
|
+
|
|
63
|
+
Big-picture trend across the whole window. Default: `"flat"`.
|
|
64
|
+
|
|
65
|
+
| preset | bornRecentBias | percentUsersBornInDataset | preExistingSpread | shape |
|
|
66
|
+
|--------|----------------|---------------------------|-------------------|-------|
|
|
67
|
+
| `flat` (default) | 0 | 15 | uniform | tail ≈ 1.0 |
|
|
68
|
+
| `steady` | 0.1 | 10 | uniform | slight uptrend |
|
|
69
|
+
| `growth` | 0.3 | 25 | pinned | mild uptrend |
|
|
70
|
+
| `viral` | 0.6 | 50 | pinned | hockey-stick |
|
|
71
|
+
| `decline` | -0.3 | 5 | uniform | downtrend (pair with churn hooks) |
|
|
72
|
+
|
|
73
|
+
Three forms:
|
|
74
|
+
```javascript
|
|
75
|
+
macro: "growth" // preset string
|
|
76
|
+
macro: { preset: "growth", percentUsersBornInDataset: 40 } // preset + override
|
|
77
|
+
macro: { bornRecentBias: 0, percentUsersBornInDataset: 25, preExistingSpread: "uniform" } // custom
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Setting `bornRecentBias` / `percentUsersBornInDataset` / `preExistingSpread` directly on the dungeon config still works — those values **override** the macro preset. Migrating existing dungeons that used these fields directly does not require an explicit `macro` setting (the default `flat` is fine; the explicit overrides win).
|
|
81
|
+
|
|
82
|
+
### NEW: `preExistingSpread` (top-level config field)
|
|
83
|
+
|
|
84
|
+
How pre-existing users' first event time is placed:
|
|
85
|
+
- `"uniform"` (default in `flat` macro): sample uniformly from `[FIXED_BEGIN - 30 days, FIXED_BEGIN]`. Pre-existing users have varied "born before dataset" timestamps.
|
|
86
|
+
- `"pinned"` (legacy): all pre-existing users start exactly at `FIXED_BEGIN`. Used by `growth` and `viral` macros (intentional front-loading for those narratives).
|
|
87
|
+
|
|
88
|
+
### CHANGED: soup presets are now intra-week / intra-day only
|
|
89
|
+
|
|
90
|
+
Soup no longer carries `bornRecentBias` / `percentUsersBornInDataset`. Those fields moved to `macro`. Soup presets now only set `peaks`, `deviation`, `mean`, `dayOfWeekWeights`, `hourOfDayWeights`. The preset names (`growth`, `steady`, `spiky`, `seasonal`, `global`, `churny`, `chaotic`) are unchanged.
|
|
91
|
+
|
|
92
|
+
If you write `soup: "growth"`, you get the same intra-week rhythm as before, but you do NOT get the old `bornRecentBias=0.3` / `percentUsersBornInDataset=15` defaults — those come from `macro: "flat"` (the new default). To recreate the legacy behavior, set both: `soup: "growth", macro: "growth"`.
|
|
93
|
+
|
|
94
|
+
### NEW: future-event guard at end of user loop
|
|
95
|
+
|
|
96
|
+
`lib/orchestrators/user-loop.js` now drops any event whose timestamp landed past `context.MAX_TIME` after the `everything` hook runs. This catches hook-injected duplicates that overflow into the future. **Plan around it**: hooks that duplicate events with positive time offsets near the right edge of the window will silently lose those duplicates. If you need them, clamp the time yourself in the hook.
|
|
97
|
+
|
|
98
|
+
### RENAMED: `dungeons/vertical/questforge.js` → `dungeons/vertical/gaming.js`
|
|
99
|
+
|
|
100
|
+
The file name follows the **vertical** convention (`gaming`, `dating`, `fintech`, etc.). The product/app name (QuestForge) lives inside the file's comments.
|
|
101
|
+
|
|
102
|
+
## Migration checklist for an existing dungeon
|
|
103
|
+
|
|
104
|
+
If you're updating a 1.2.x dungeon to 1.3.0:
|
|
105
|
+
|
|
106
|
+
1. **Rename the local var**: `const avg_events_per_user = N` → `const avg_events_per_user_per_day = N / num_days` (rounded to 2 decimal places).
|
|
107
|
+
2. **Update the config field**: `numEvents: num_users * avg_events_per_user` → `avgEventsPerUserPerDay: avg_events_per_user_per_day`.
|
|
108
|
+
3. **Remove explicit `percentUsersBornInDataset` overrides** unless the dungeon needs a non-default value. The `flat` macro default (15%) is the new convention.
|
|
109
|
+
4. **Optionally set `macro: "growth"` / `"viral"` / `"decline"`** if the dungeon has an intentional growth/decline narrative. Otherwise omit `macro` and let the default `flat` take over.
|
|
110
|
+
5. **Audit hooks for time-injected events past `MAX_TIME`**: if your hook duplicates events with `time + N hours/days`, those duplicates may be silently dropped by the new guard. Clamp inside the hook if needed.
|
|
111
|
+
6. **Run `npx vitest run tests/dungeons.test.js`** to confirm the dungeon still parses and validates.
|
|
112
|
+
|
|
113
|
+
A bulk migration script lives at [`scripts/experiments/migrate-dungeons.mjs`](../scripts/experiments/migrate-dungeons.mjs). It's idempotent — safe to re-run.
|
|
114
|
+
|
|
115
|
+
## Writing a new dungeon in 1.3.0
|
|
116
|
+
|
|
117
|
+
Use this skeleton (from [`.claude/skills/create-dungeon/SKILL.md`](../.claude/skills/create-dungeon/SKILL.md)):
|
|
118
|
+
|
|
119
|
+
```javascript
|
|
120
|
+
// ── TWEAK THESE ──
|
|
121
|
+
const SEED = "dm4-VERTICAL";
|
|
122
|
+
const num_days = 100;
|
|
123
|
+
const num_users = 5_000;
|
|
124
|
+
const avg_events_per_user_per_day = 1.2; // events per user per ACTIVE day
|
|
125
|
+
let token = "your-mixpanel-token";
|
|
126
|
+
|
|
127
|
+
// ... imports ...
|
|
128
|
+
|
|
129
|
+
const config = {
|
|
130
|
+
token,
|
|
131
|
+
seed: SEED,
|
|
132
|
+
numDays: num_days,
|
|
133
|
+
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
134
|
+
numUsers: num_users,
|
|
135
|
+
// ... feature switches, scdProps, mirrorProps, lookupTables: [] ...
|
|
136
|
+
|
|
137
|
+
// Trend shape — leave both at default unless prompt asks otherwise.
|
|
138
|
+
// macro defaults to "flat"; soup defaults to "growth".
|
|
139
|
+
// macro: "flat", // also: "steady" | "growth" | "viral" | "decline"
|
|
140
|
+
// soup: "growth", // also: "steady" | "spiky" | "seasonal" | "global" | "churny" | "chaotic"
|
|
141
|
+
|
|
142
|
+
events: [ /* ... */ ],
|
|
143
|
+
funnels: [ /* ... */ ],
|
|
144
|
+
hook: function (record, type, meta) { /* ... */ return record; }
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export default config;
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Key rules** (unchanged from 1.2.x):
|
|
151
|
+
- Hooks shape data within the schema; they cannot invent properties not defined in `events` / `userProps` / `superProps`.
|
|
152
|
+
- All event properties are FLAT (`record.amount`, not `record.properties.amount`).
|
|
153
|
+
- Injected events must be cloned from existing events with `{...existingEvent, time: newTime}`.
|
|
154
|
+
- Use `dayjs` for time math inside hooks. Use the seeded `chance` instance.
|
|
155
|
+
|
|
156
|
+
**New rules in 1.3.0**:
|
|
157
|
+
- Use `avgEventsPerUserPerDay` not `numEvents`.
|
|
158
|
+
- Don't override `percentUsersBornInDataset` unless the dungeon has a specific narrative reason. Use `macro` instead.
|
|
159
|
+
- If a hook duplicates events with a positive time offset and the source event is near the right edge, expect the duplicate to be dropped by the future-event guard.
|
|
160
|
+
|
|
161
|
+
## Quick reference — config primitives
|
|
162
|
+
|
|
163
|
+
| field | type | default | what it does |
|
|
164
|
+
|-------|------|---------|--------------|
|
|
165
|
+
| `avgEventsPerUserPerDay` | number | derived from `numEvents` | per-user-per-active-day rate (canonical) |
|
|
166
|
+
| `numEvents` | number | 100_000 | legacy total target; populates `avgEventsPerUserPerDay` if unset |
|
|
167
|
+
| `numUsers` | number | 1000 | total user count |
|
|
168
|
+
| `numDays` | number | 30 | dataset window length |
|
|
169
|
+
| `macro` | string \| object | `"flat"` | big-picture trend preset |
|
|
170
|
+
| `soup` | string \| object | `"growth"` | intra-week / intra-day rhythm preset |
|
|
171
|
+
| `bornRecentBias` | number | 0 (from `flat`) | birth-time skew, -1..1 |
|
|
172
|
+
| `percentUsersBornInDataset` | number | 15 (from `flat`) | share of users born inside the window |
|
|
173
|
+
| `preExistingSpread` | `"uniform"` \| `"pinned"` | `"uniform"` (from `flat`) | placement of pre-existing users' first event |
|
|
174
|
+
|
|
175
|
+
## Where to look in the codebase
|
|
176
|
+
|
|
177
|
+
- [`lib/core/config-validator.js`](../lib/core/config-validator.js) — resolves both primitives, applies macro defaults
|
|
178
|
+
- [`lib/templates/macro-presets.js`](../lib/templates/macro-presets.js) — macro preset definitions + `resolveMacro`
|
|
179
|
+
- [`lib/templates/soup-presets.js`](../lib/templates/soup-presets.js) — soup preset definitions (no birth fields)
|
|
180
|
+
- [`lib/orchestrators/user-loop.js`](../lib/orchestrators/user-loop.js) — per-user-per-day allocation (search `userActiveDays`), pre-existing spread, future-event guard
|
|
181
|
+
- [`tests/macro-and-rate.test.js`](../tests/macro-and-rate.test.js) — usage examples + invariants
|
|
182
|
+
- [`research/end-bunchiness.md`](end-bunchiness.md) — full diagnosis + experiment data + before/after numbers
|
|
183
|
+
|
|
184
|
+
## Hook-writing learnings (from the 1.3.0 verify-hooks pass)
|
|
185
|
+
|
|
186
|
+
We re-verified every hook on every vertical dungeon at 1.3.0 (~160 hooks across 20 dungeons). The pass surfaced five recurring failure modes that aren't obvious from the hook API alone — codify them in any new dungeon you write.
|
|
187
|
+
|
|
188
|
+
### Five rules that prevent ~all observed hook failures
|
|
189
|
+
|
|
190
|
+
1. **Never read DOW / day-of-month from `record.time` inside an `event` hook when `hasSessionIds: true`.**
|
|
191
|
+
`bunchIntoSessions()` runs AFTER the event hook but BEFORE the everything hook. It reassigns timestamps to fit session windows, so any `dayjs(record.time).day()` you compute in the event hook becomes decorrelated from the actual stored timestamp. Move all DOW / day-of-month logic to the everything hook, where `e.time` is final.
|
|
192
|
+
```js
|
|
193
|
+
// ❌ WRONG — event hook fires before sessionization
|
|
194
|
+
if (type === 'event' && dayjs(record.time).day() === 0) record.weekend_surge = true;
|
|
195
|
+
|
|
196
|
+
// ✅ RIGHT — everything hook fires after sessionization
|
|
197
|
+
if (type === 'everything') {
|
|
198
|
+
for (const e of record) {
|
|
199
|
+
if (new Date(e.time).getUTCDay() === 0) e.weekend_surge = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
Note: also use `getUTCDay()` (or `dayjs.utc()`), not `.day()`. TimeSoup distributes events using UTC DOW; local-time DOW will skew the result for users in non-UTC timezones.
|
|
204
|
+
|
|
205
|
+
2. **Never read superProps inside an `event` hook — they aren't your user's value yet.**
|
|
206
|
+
At event-hook time, properties listed in `superProps` come from the random picker (one value per event). The everything hook later overwrites each event with the user's stamped value from `meta.profile`. So an event hook that branches on `record.account_tier` is making decisions on noise. Read `meta.profile.account_tier` in the everything hook instead.
|
|
207
|
+
```js
|
|
208
|
+
// ❌ WRONG — record.account_tier is a fresh random pick
|
|
209
|
+
if (type === 'event' && record.account_tier === 'premium') record.reward *= 3;
|
|
210
|
+
|
|
211
|
+
// ✅ RIGHT — meta.profile is the authoritative user profile
|
|
212
|
+
if (type === 'everything') {
|
|
213
|
+
for (const e of record) {
|
|
214
|
+
if (meta.profile.account_tier === 'premium') e.reward *= 3;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
3. **Use UTC consistently for relative-time windows.**
|
|
220
|
+
When you anchor a window like "days 20-27 of the dataset," compute both the anchor and the comparison in UTC. Mixing `dayjs()` (local) with timestamps from TimeSoup (UTC) shifts the window by the host's timezone offset.
|
|
221
|
+
```js
|
|
222
|
+
const DATASET_START = dayjs.utc().subtract(num_days, 'day');
|
|
223
|
+
const launchDay = DATASET_START.add(45, 'day');
|
|
224
|
+
if (dayjs.utc(record.time).isAfter(launchDay)) { /* ... */ }
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
4. **Apply value scaling AFTER cloning.**
|
|
228
|
+
If you clone events in the everything hook to inject duplicates (weekend surge, viral spread, etc.) AND you also scale values in the event hook, the event-hook scaling never touches the clones. Move both the cloning and the scaling into the everything hook so the multiplier applies after the dataset's final shape exists.
|
|
229
|
+
|
|
230
|
+
5. **Sanity-check thresholds at small scale.**
|
|
231
|
+
At 1K-user verification scale, hooks like "users with ≥5 errors in 7 days" or "sessions with <2 hours between events" frequently produce zero qualifying users. The hook works, but at small scale the threshold is too strict to register. Either:
|
|
232
|
+
- Relax the threshold so it passes verification (preferred for thresholds tuned on noise), OR
|
|
233
|
+
- Document in the dungeon comment that the hook needs production scale to fire (preferred for thresholds tied to real product semantics).
|
|
234
|
+
|
|
235
|
+
### What goes where (decision matrix)
|
|
236
|
+
|
|
237
|
+
| Logic type | event hook | everything hook |
|
|
238
|
+
|-----------|:---------:|:---------------:|
|
|
239
|
+
| Value scaling driven by an event property already on `record` | ✅ | ✅ |
|
|
240
|
+
| Value scaling driven by user profile / superProp | ❌ | ✅ (`meta.profile.X`) |
|
|
241
|
+
| DOW / day-of-month patterns (any dungeon) | ❌ | ✅ (`new Date(e.time).getUTCDay()`) |
|
|
242
|
+
| Closure-state Maps that track per-user context across events | ✅ | — |
|
|
243
|
+
| Relative-time windows (DATASET_START + N days) | ✅ (`dayjs.utc`) | ✅ |
|
|
244
|
+
| Event cloning / injection (weekend surge, viral, etc.) | ❌ | ✅ |
|
|
245
|
+
| Value scaling that must include cloned events | ❌ | ✅ |
|
|
246
|
+
| Event filtering (churn, drop-off) | ❌ | ✅ (`return record.filter(...)`) |
|
|
247
|
+
|
|
248
|
+
### Other things worth knowing
|
|
249
|
+
|
|
250
|
+
- **Hooks cannot invent new properties.** Every property a hook sets must already exist in the config (in `events[].properties`, `userProps`, or `superProps`) with a default value. If you need a flag like `payday`, add `payday: [false]` to the event's properties; the hook flips it to `true`.
|
|
251
|
+
- **Spliced/cloned events need `user_id` and `time`** — copy from the source: `{ ...source, time: newIso, user_id: source.user_id }`. Building events from scratch will fail validation.
|
|
252
|
+
- **Future-event guard drops time-shifted clones.** If your hook clones an event near the right edge of the window with `time + N hours`, the future-event guard at the end of the user loop will silently drop the clone. Clamp inside the hook if you need it.
|
|
253
|
+
- **Event property access is FLAT.** Use `record.amount`, never `record.properties.amount`.
|
|
254
|
+
|
|
255
|
+
These rules + the verify-hooks skill (`/verify-hooks`) caught 13 silent hook failures across the vertical pack; running it after any non-trivial hook change is the cheapest insurance against shipping a flat-line story.
|
|
256
|
+
|
|
257
|
+
## Backward compatibility
|
|
258
|
+
|
|
259
|
+
- 1.2.x dungeons that set `numEvents` directly continue to work — the rate is derived. They will, however, render with the new `flat` macro defaults (no growth bias, uniform pre-existing spread). If a 1.2.x dungeon depended on the implicit `bornRecentBias=0.3` from the old `growth` soup preset, add `macro: "growth"` to restore that behavior.
|
|
260
|
+
- 1.2.x dungeons that set `bornRecentBias` / `percentUsersBornInDataset` directly continue to work unchanged — those fields override the macro preset.
|
|
261
|
+
- Soup preset names are unchanged. Hook semantics are unchanged. Output file naming is unchanged.
|
|
262
|
+
- The `numEvents` config field is **not deprecated**. It remains the right primitive when you want a fixed total volume target rather than a per-day rate.
|