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

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.
Files changed (78) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +182 -0
  9. package/HOOKS.md +1256 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +41 -49
  12. package/dungeons/technical/anonymous-users.js +38 -36
  13. package/dungeons/technical/array-of-object-lookup.js +136 -153
  14. package/dungeons/technical/datagen-v15-verify.js +87 -0
  15. package/dungeons/technical/experiments.js +42 -40
  16. package/dungeons/technical/foobar.js +114 -118
  17. package/dungeons/technical/group-analytics.js +42 -40
  18. package/dungeons/technical/hook-helpers-verify.js +69 -50
  19. package/dungeons/technical/identity-model-verify.js +22 -12
  20. package/dungeons/technical/mirror-strategies.js +37 -39
  21. package/dungeons/technical/nested-objects.js +119 -118
  22. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  23. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  24. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  25. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  26. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  27. package/dungeons/technical/retention-cadence.js +115 -112
  28. package/dungeons/technical/sanity.js +86 -80
  29. package/dungeons/technical/scale-test.js +34 -38
  30. package/dungeons/technical/scd.js +111 -128
  31. package/dungeons/technical/simple.js +134 -141
  32. package/dungeons/technical/simplest.js +111 -65
  33. package/dungeons/technical/text-generation.js +110 -146
  34. package/dungeons/vertical/ai-platform.js +300 -333
  35. package/dungeons/vertical/community.js +290 -255
  36. package/dungeons/vertical/crypto.js +400 -391
  37. package/dungeons/vertical/dating.js +421 -375
  38. package/dungeons/vertical/devtools.js +346 -298
  39. package/dungeons/vertical/ecommerce.js +322 -394
  40. package/dungeons/vertical/education.js +380 -325
  41. package/dungeons/vertical/fintech.js +371 -325
  42. package/dungeons/vertical/fitness.js +345 -291
  43. package/dungeons/vertical/food-delivery.js +352 -307
  44. package/dungeons/vertical/gaming.js +490 -444
  45. package/dungeons/vertical/healthcare.js +311 -262
  46. package/dungeons/vertical/insurance-application.js +437 -409
  47. package/dungeons/vertical/logistics.js +278 -252
  48. package/dungeons/vertical/marketplace.js +340 -323
  49. package/dungeons/vertical/media.js +390 -335
  50. package/dungeons/vertical/real-estate.js +402 -347
  51. package/dungeons/vertical/sass.js +331 -333
  52. package/dungeons/vertical/social.js +377 -316
  53. package/dungeons/vertical/travel.js +302 -295
  54. package/index.js +64 -7
  55. package/lib/core/config-validator.js +378 -17
  56. package/lib/core/dungeon-loader.js +2 -5
  57. package/lib/generators/events.js +12 -13
  58. package/lib/generators/funnels.js +76 -2
  59. package/lib/hook-helpers/index.js +1 -0
  60. package/lib/hook-helpers/inject.js +95 -0
  61. package/lib/orchestrators/mixpanel-sender.js +7 -0
  62. package/lib/orchestrators/user-loop.js +598 -48
  63. package/lib/templates/defaults.js +59 -59
  64. package/lib/templates/macro-presets.js +53 -11
  65. package/lib/utils/dataset-context.js +103 -0
  66. package/lib/utils/retention-curve.js +140 -0
  67. package/lib/utils/utils.js +157 -109
  68. package/lib/verify/counting.js +360 -0
  69. package/lib/verify/emulate-breakdown.js +531 -108
  70. package/lib/verify/funnel-engine.js +539 -0
  71. package/lib/verify/identity.js +78 -0
  72. package/lib/verify/index.js +20 -0
  73. package/lib/verify/schema-validator.js +3 -1
  74. package/lib/verify/verify-dungeon.js +58 -0
  75. package/package.json +14 -3
  76. package/scripts/run-dungeon.mjs +12 -1
  77. package/types.d.ts +353 -4
  78. package/scripts/smoke-test-all.mjs +0 -162
@@ -0,0 +1,158 @@
1
+ ---
2
+ name: analyze-soup
3
+ description: Use when investigating TimeSoup parameters, diagnosing event-distribution shape, or comparing soup configs — runs a dungeon locally and analyzes time distribution at week/day/hour/minute granularities, producing a soup-analysis.md diagnostic report.
4
+ argument-hint: [dungeon path, e.g. dungeons/soup-test.js]
5
+ model: claude-opus-4-6
6
+ effort: max
7
+ ---
8
+
9
+ # Analyze TimeSoup Distribution
10
+
11
+ Run a dungeon and analyze the time distribution of generated events to evaluate TimeSoup parameters.
12
+
13
+ **Dungeon file:** `$ARGUMENTS` (default: `dungeons/soup-test.js`)
14
+
15
+ ## Step 1: Run the Dungeon
16
+
17
+ Run the dungeon with forced local-only settings:
18
+
19
+ ```bash
20
+ npm run prune
21
+ node -e "
22
+ import generate from './index.js';
23
+ import config from './$ARGUMENTS';
24
+ const result = await generate({ ...config, writeToDisk: true, format: 'json', token: '', verbose: true, name: 'soup-analysis' });
25
+ console.log('Events:', result.eventCount, 'Users:', result.userCount);
26
+ "
27
+ ```
28
+
29
+ Wait for generation to complete. Note the event count and EPS.
30
+
31
+ ## Step 2: Query with DuckDB
32
+
33
+ Run these DuckDB queries against the generated JSONL file. Use `duckdb` CLI.
34
+
35
+ ### 2a. Week over Week
36
+ ```bash
37
+ duckdb -c "
38
+ SELECT date_trunc('week', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as week,
39
+ count(*) as events
40
+ FROM read_json_auto('./data/soup-analysis-EVENTS.json')
41
+ GROUP BY 1 ORDER BY 1;
42
+ "
43
+ ```
44
+
45
+ ### 2b. Day over Day
46
+ ```bash
47
+ duckdb -c "
48
+ SELECT date_trunc('day', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as day,
49
+ count(*) as events
50
+ FROM read_json_auto('./data/soup-analysis-EVENTS.json')
51
+ GROUP BY 1 ORDER BY 1;
52
+ "
53
+ ```
54
+
55
+ ### 2c. Hour over Hour (last 7 days only)
56
+ ```bash
57
+ duckdb -c "
58
+ SELECT date_trunc('hour', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as hour,
59
+ count(*) as events
60
+ FROM read_json_auto('./data/soup-analysis-EVENTS.json')
61
+ WHERE (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles') > (SELECT max((time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) - interval '7 days' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
62
+ GROUP BY 1 ORDER BY 1;
63
+ "
64
+ ```
65
+
66
+ ### 2d. Minute over Minute (last 24 hours only)
67
+ ```bash
68
+ duckdb -c "
69
+ SELECT date_trunc('minute', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as minute,
70
+ count(*) as events
71
+ FROM read_json_auto('./data/soup-analysis-EVENTS.json')
72
+ WHERE (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles') > (SELECT max((time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) - interval '1 day' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
73
+ GROUP BY 1 ORDER BY 1;
74
+ "
75
+ ```
76
+
77
+ ### 2e. Distribution Statistics
78
+ ```bash
79
+ duckdb -c "
80
+ WITH daily AS (
81
+ SELECT date_trunc('day', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as day, count(*) as events
82
+ FROM read_json_auto('./data/soup-analysis-EVENTS.json')
83
+ GROUP BY 1
84
+ ),
85
+ hourly AS (
86
+ SELECT date_trunc('hour', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as hour, count(*) as events
87
+ FROM read_json_auto('./data/soup-analysis-EVENTS.json')
88
+ WHERE (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles') > (SELECT max((time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) - interval '7 days' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
89
+ GROUP BY 1
90
+ )
91
+ SELECT 'daily' as granularity,
92
+ count(*) as buckets,
93
+ round(avg(events), 1) as avg_events,
94
+ min(events) as min_events,
95
+ max(events) as max_events,
96
+ round(max(events)::float / nullif(avg(events), 0), 2) as max_to_avg_ratio,
97
+ round(stddev(events) / nullif(avg(events), 0), 3) as cv
98
+ FROM daily
99
+ UNION ALL
100
+ SELECT 'hourly',
101
+ count(*),
102
+ round(avg(events), 1),
103
+ min(events),
104
+ max(events),
105
+ round(max(events)::float / nullif(avg(events), 0), 2),
106
+ round(stddev(events) / nullif(avg(events), 0), 3)
107
+ FROM hourly;
108
+ "
109
+ ```
110
+
111
+ ### 2f. Spike Detection
112
+ ```bash
113
+ duckdb -c "
114
+ WITH daily AS (
115
+ SELECT date_trunc('day', (time::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')) as day, count(*) as events
116
+ FROM read_json_auto('./data/soup-analysis-EVENTS.json')
117
+ GROUP BY 1
118
+ )
119
+ SELECT
120
+ 'last_day_vs_avg' as check,
121
+ CASE WHEN last_events > avg_events * 2.0 THEN '❌ FAIL (>2x avg)'
122
+ WHEN last_events > avg_events * 1.5 THEN '⚠️ WARN (>1.5x avg)'
123
+ ELSE '✅ PASS' END as result,
124
+ last_events,
125
+ round(avg_events, 0) as avg_events,
126
+ round(last_events::float / avg_events, 2) as ratio
127
+ FROM (
128
+ SELECT
129
+ (SELECT events FROM daily ORDER BY day DESC LIMIT 1) as last_events,
130
+ (SELECT avg(events) FROM daily) as avg_events
131
+ );
132
+ "
133
+ ```
134
+
135
+ ## Step 3: Write Report
136
+
137
+ Create `soup-analysis.md` in the project root with:
138
+
139
+ 1. **Config**: The soup parameters used (peaks, deviation, mean, numDays)
140
+ 2. **Summary stats**: Total events, event count, avg EPS
141
+ 3. **Distribution tables**: Week/Day/Hour/Minute tables from Step 2
142
+ 4. **Statistics**: CV, max-to-avg ratio at each granularity
143
+ 5. **Spike detection**: Pass/fail for last day and last hour
144
+ 6. **Assessment**: Overall quality judgment and recommendations
145
+
146
+ ### Quality Criteria
147
+ - **Daily CV**: 0.2-0.6 is ideal (some variation, not flat or spiky)
148
+ - **Max-to-avg ratio**: < 2.0 at daily level, < 3.0 at hourly level
149
+ - **Last day spike**: < 1.5x average = PASS, < 2x = WARN, > 2x = FAIL
150
+ - **Hourly pattern**: Should show visible peaks but no single hour > 5x average
151
+
152
+ ## Step 4: Cleanup
153
+
154
+ ```bash
155
+ npm run prune
156
+ ```
157
+
158
+ Remove `soup-analysis.md` only if the user asks. It's meant to persist for comparison across runs.
@@ -0,0 +1,464 @@
1
+ ---
2
+ name: create-dungeon
3
+ description: Use when authoring a new dungeon-master config from an app description — designs events, funnels, properties, identity model, and macro/soup. SCHEMA ONLY; engineered story trends + hooks are added separately by write-hooks.
4
+ argument-hint: [free-text app description, e.g. "AI meeting assistant" or "B2B logistics platform"]
5
+ model: claude-opus-4-6
6
+ effort: max
7
+ ---
8
+
9
+ # Create a Dungeon (schema only)
10
+
11
+ Design and write a complete dungeon-master dungeon for: **$ARGUMENTS**
12
+
13
+ ## Scope
14
+
15
+ This skill produces a **realistic baseline schema** that runs cleanly out of the
16
+ box. It does **NOT** engineer story trends or magic numbers — those are the
17
+ `write-hooks` skill's job. After this skill produces a dungeon, the next call
18
+ should be:
19
+
20
+ ```
21
+ /write-hooks dungeons/user/<your-dungeon>.js "describe the trends to engineer"
22
+ ```
23
+
24
+ In scope here:
25
+ - Event names, weights, properties (with realistic value distributions)
26
+ - `superProps` and `userProps` (consistent across users)
27
+ - Funnels with `sequence`, `conversionRate`, `timeToConvert`, `weight`,
28
+ `isFirstFunnel`, `attempts`
29
+ - Event flags: `isAuthEvent`, `isAttributionEvent`, `isFirstEvent`,
30
+ `isStrictEvent`, `isChurnEvent`, `isSessionStartEvent`
31
+ - Top-level: `datasetStart`, `datasetEnd`, `numUsers`, `avgEventsPerUserPerDay`,
32
+ `seed`, `format`, device flags, `avgDevicePerUser`, `hasLocation`,
33
+ `hasCampaigns`, `hasSessionIds`, `hasAvatar`, `macro`, `soup`
34
+ - Surviving advanced entities: `personas`, `worldEvents`, `engagementDecay`,
35
+ `dataQuality` — use sparingly
36
+
37
+ Out of scope (hand off to `write-hooks`):
38
+ - The `hook` function (default to no hook OR a tiny stub that stamps superProps
39
+ on injected events)
40
+ - Engineered patterns (magic numbers, A/B effects, time-bomb regressions, etc.)
41
+
42
+ For an encyclopedia of hook patterns, recipes, and real-world examples:
43
+ see `HOOKS.md` at the project root.
44
+
45
+ These config keys are silently ignored — DO NOT use them: `subscription`, `attribution`, `geo`, `features`, `anomalies`. Recreate with hooks via `write-hooks`.
46
+
47
+ ## Reference reading
48
+
49
+ Before writing any code, scan:
50
+
51
+ - `types.d.ts` — the complete API reference. Every Dungeon field, EventConfig
52
+ flag, Funnel option, AttemptsConfig, and Hook meta interface is documented
53
+ with full JSDoc. **Treat this as the source of truth.**
54
+ - `lib/utils/utils.js` — `pickAWinner`, `weighNumRange`, `initChance`, `exhaust`,
55
+ `takeSome` for property value distributions
56
+ - `dungeons/vertical/sass.js` — B2B reference dungeon with full identity model
57
+ - `dungeons/user/my-buddy.js` — consumer-app reference (gitignored)
58
+ - `dungeons/technical/identity-model-verify.js` — minimal identity-model fixture
59
+
60
+ ## File structure
61
+
62
+ ```javascript
63
+ // ── TWEAK THESE ──
64
+ const SEED = "dm4-VERTICAL";
65
+ const num_days = 120;
66
+ const num_users = 5_000;
67
+ const avg_events_per_user_per_day = 1.2;
68
+ let token = "your-mixpanel-token";
69
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
70
+
71
+ import dayjs from "dayjs";
72
+ import "dotenv/config";
73
+ import * as u from "../../lib/utils/utils.js";
74
+ import * as v from "ak-tools";
75
+
76
+ const chance = u.initChance(SEED);
77
+
78
+ /** @typedef {import("../../types").Dungeon} Config */
79
+
80
+ // Generate consistent IDs at module level
81
+ const productIds = v.range(1, 200).map(n => `prod_${v.uid(8)}`);
82
+
83
+ /**
84
+ * ═══════════════════════════════════════════════════════════════
85
+ * DATASET OVERVIEW
86
+ * ═══════════════════════════════════════════════════════════════
87
+ *
88
+ * App Name — what it models, the core user loop, monetization.
89
+ * - N users over M days, ~X events
90
+ * - Key entities and relationships
91
+ * - Why these events/properties were chosen
92
+ *
93
+ * NO STORY TRENDS YET — schema only. Pass to /write-hooks for engineering.
94
+ */
95
+
96
+ /** @type {Config} */
97
+ const config = {
98
+ token, seed: SEED,
99
+ numDays: num_days,
100
+ avgEventsPerUserPerDay: avg_events_per_user_per_day,
101
+ numUsers: num_users,
102
+
103
+ // Identity model — see "Identity guidelines" below
104
+ hasAnonIds: true,
105
+ avgDevicePerUser: 2,
106
+ hasSessionIds: true,
107
+
108
+ // I/O
109
+ format: "json", gzip: true, writeToDisk: false, concurrency: 1,
110
+
111
+ // Realistic platform
112
+ hasLocation: true, hasAndroidDevices: false, hasIOSDevices: false,
113
+ hasDesktopDevices: true, hasBrowser: true, hasAvatar: true,
114
+
115
+ funnels: [ /* see "Funnels" below */ ],
116
+ events: [ /* see "Events" below */ ],
117
+ superProps: { /* see "SuperProps" below */ },
118
+ userProps: { /* see "UserProps" below */ },
119
+ scdProps: { /* see "SCDs" below */ },
120
+ groupKeys: [ /* see "Groups" below */ ],
121
+
122
+ // No hook — schema only.
123
+ };
124
+
125
+ export default config;
126
+ ```
127
+
128
+ ## Required components
129
+
130
+ ### 1. Events (~15–20)
131
+
132
+ 15–20 distinct event types covering the app's core loop. Each event:
133
+
134
+ - `event` — name in **lowercase with spaces** (Mixpanel convention)
135
+ - `weight` — relative frequency 1–10 (clamped)
136
+ - `properties` — flat property map. Values can be arrays (random pick) or
137
+ utility calls like `u.weighNumRange(1, 100, 0.5, 20)`
138
+
139
+ **Event flags** (see `types.d.ts` `EventConfig`):
140
+
141
+ - `isFirstEvent: true` — the user's first-ever event (e.g., "sign up")
142
+ - `isAuthEvent: true` — marks the identity stitch moment. See "Identity
143
+ guidelines" below. Multiple events may carry it; the engine looks at the
144
+ first occurrence in the user's stream when stamping inside an `isFirstFunnel`.
145
+ - `isAttributionEvent: true` — when `hasCampaigns: true`, only flagged events
146
+ get UTMs (~25% of them). Without flags, ~25% of all events get UTMs (legacy).
147
+ - `isStrictEvent: true` — exclude from auto-generated catch-all funnels. Use
148
+ for funnel-only events (Sign Up, Onboarding Question) so they don't bleed
149
+ into the standalone weighted picker.
150
+ - `isChurnEvent: true` + `returnLikelihood` — fire-and-stop semantics
151
+ - `isSessionStartEvent: true` — auto-prepended 15s before each funnel sequence
152
+
153
+ When using experiments, include `$experiment_started` in the events array with
154
+ `isStrictEvent: true` so the engine schema includes its properties:
155
+ ```js
156
+ { event: "$experiment_started", weight: 1, isStrictEvent: true, properties: {
157
+ "Experiment name": ["My Experiment"],
158
+ "Variant name": ["Control", "Variant A", "Variant B"],
159
+ }}
160
+ ```
161
+
162
+ ### 2. Funnels (3–6)
163
+
164
+ - First funnel: includes `isFirstEvent` AND has `isAuthEvent: true` on the
165
+ identity-transition step.
166
+ - Usage funnels: ordinary sequences without `isFirstFunnel`. Optionally use
167
+ `attempts` for repeat-usage modeling (abandon-cart pattern).
168
+ - Pick `conversionRate` between 30 and 80; `timeToConvert` in hours.
169
+
170
+ Funnel `props` stamp constant properties on all events in that funnel run.
171
+ Use for funnel-level context (checkout flow variant, onboarding version):
172
+
173
+ ```js
174
+ {
175
+ sequence: ["View Item", "Add to Cart", "Checkout"],
176
+ conversionRate: 40,
177
+ timeToConvert: 2,
178
+ conversionWindowDays: 7, // cap how late "Checkout" can fire
179
+ props: {
180
+ checkout_version: ["v1", "v2"], // random per funnel run
181
+ payment_method: ["card", "paypal"],
182
+ },
183
+ }
184
+ ```
185
+
186
+ **`conversionWindowDays`:** explicit Mixpanel-style conversion window
187
+ cap, in days. Default 30 (Mixpanel UI default). Hard cap 180 (Mixpanel max).
188
+ The validator auto-bumps to `min(180, ceil(timeToConvert/24 * 1.5))` if your
189
+ `timeToConvert` exceeds 30 days. Set explicitly to silence the warning. The
190
+ verifier (`verifyDungeon`) reads this field automatically.
191
+
192
+ **`isStrictEvent` auto-promote:** if you list a funnel-step event in
193
+ `events[]`, the validator auto-sets `isStrictEvent: true` for you and warns.
194
+ This heals the silent-corruption footgun where the greedy funnel engine
195
+ consumed standalone instances as funnel matches. Set `isStrictEvent: false`
196
+ explicitly only when you intend mixed funnel/standalone semantics for that
197
+ event.
198
+
199
+ **Mark hook-readable funnel-step events with `isStrictEvent: false`.** When a hook reads `event === 'X'` and `X` is also a funnel step, the validator's auto-promote turns it into `isStrictEvent: true` and the engine stops emitting standalone occurrences — cohort goes empty. Identify these candidates at schema time so `write-hooks` doesn't have to re-thread the schema. Common candidates: login, page view, search, add to cart, swap, deposit — anything that's both a funnel step AND a recurring user behavior hooks will likely cohort on.
200
+
201
+ **Funnels representing loops need `reentry: true`.** Any funnel named "X loop" / "X cycle" / "session" / per-instance recurring behavior must declare `reentry: true`. Without it, the engine emits one sequence per user and downstream "power user" / "daily active" cohorts have no behavioral signal to bin on.
202
+
203
+ ### 3. SuperProps (2–3)
204
+
205
+ Properties present on EVERY event. Common picks: `Plan`, `Region`, `Platform`,
206
+ `App Version`. Values must come from same enumerations used in `userProps` for
207
+ consistency.
208
+
209
+ ### 4. UserProps (4–8)
210
+
211
+ User profile properties. Set once per user. Use enumerations whose values
212
+ match `superProps` for any overlapping keys (so per-event Region matches per-user
213
+ Region).
214
+
215
+ ### 5. Groups (0–2)
216
+
217
+ Use `groupKeys: [["company_id", 250]]` for B2B SaaS. Add `groupProps` for
218
+ group attributes. Skip for B2C apps.
219
+
220
+ ### 6. SCDs (0–2)
221
+
222
+ Slowly-changing dimensions for plan tier, role, etc. JSDoc on `SCDProp` covers
223
+ type/frequency/timing/values/max.
224
+
225
+ ### 7. Hook function — DO NOT WRITE
226
+
227
+ Skip the `hook:` field entirely (engine defaults to pass-through). The
228
+ `write-hooks` skill picks this up and engineers the trends.
229
+
230
+ If you absolutely need a stub for downstream stamping consistency, leave a
231
+ 1-liner that returns the record unchanged.
232
+
233
+ ## Identity guidelines
234
+
235
+ The identity model has three knobs:
236
+
237
+ ### `avgDevicePerUser` (whole number, default 0)
238
+
239
+ | App type | Recommended | Why |
240
+ |----------|-------------|-----|
241
+ | B2C consumer app, web/mobile single device | 1 | Sticky single device; ratio of user_id to device_id is 1:1 |
242
+ | B2B SaaS engineer / knowledge worker | 2 | Laptop + work-from-home laptop; per-session sticky pick |
243
+ | Multi-device-heavy product (streaming, fitness) | 2–3 | TV + phone + tablet sessions distinguishable |
244
+ | Server / API-only product | 0 | No client device concept |
245
+
246
+ `hasAnonIds: true` aliases to `avgDevicePerUser: 1`. Set both for clarity.
247
+
248
+ ### `isAuthEvent` placement
249
+
250
+ Flag the event that represents "user becomes identified". Put it in the
251
+ `isFirstFunnel` sequence. Common picks:
252
+
253
+ - Consumer apps: `Sign Up`, `Login`
254
+ - B2B SaaS: `workspace created`, `account created`
255
+ - Marketplaces: `Account Activated`
256
+
257
+ The engine stamps user_id+device_id on this event; pre-auth funnel steps get
258
+ device_id only; post-auth funnel steps get user_id only.
259
+
260
+ ### `attempts` (per-funnel, optional)
261
+
262
+ ```js
263
+ {
264
+ sequence: ["Land", "Onboarding Question", "Sign Up"],
265
+ isFirstFunnel: true,
266
+ conversionRate: 70,
267
+ attempts: { min: 0, max: 2 }, // 0–2 failed priors → 1–3 total passes
268
+ }
269
+ ```
270
+
271
+ Typical ranges:
272
+
273
+ - Direct-acquisition first funnels: `{ min: 0, max: 0 }` (single attempt) or omit
274
+ - Shared-link / friction-heavy onboarding: `{ min: 0, max: 2 }` (some retry)
275
+ - Re-engagement / abandon-cart usage funnels: `{ min: 0, max: 3 }`
276
+
277
+ When set, `attempts.conversionRate` (optional) overrides `funnel.conversionRate`
278
+ on the FINAL attempt. Failed prior attempts truncate before the first
279
+ `isAuthEvent` step (no stitch fires for those attempts).
280
+
281
+ ### Experiments (per-funnel, optional)
282
+
283
+ Funnels can run A/B/C experiments with `experiment: true` (3 default variants) or
284
+ a rich `ExperimentConfig`:
285
+
286
+ ```js
287
+ {
288
+ sequence: ["Create Agenda", "Agenda Generated"],
289
+ conversionRate: 60,
290
+ timeToConvert: 0.5,
291
+ name: "Collaborative Agenda",
292
+ experiment: {
293
+ name: "Collaborative Agenda",
294
+ variants: [
295
+ { name: "Control (No Collab)" },
296
+ { name: "Variant A (Ask User)", conversionMultiplier: 1.15, ttcMultiplier: 0.9 },
297
+ { name: "Variant B (Assume + Confirm)", conversionMultiplier: 1.35, ttcMultiplier: 0.7 },
298
+ ],
299
+ startDaysBeforeEnd: 30,
300
+ },
301
+ }
302
+ ```
303
+
304
+ Key fields (see `ExperimentConfig` in `types.d.ts`):
305
+ - `variants[]` — custom names, conversion/TTC multipliers, distribution weights
306
+ - `startDaysBeforeEnd` — temporal gating (experiment activates N days before dataset end)
307
+ - Variant assignment is **deterministic per user** (hash-based, not random per run)
308
+ - Engine injects `$experiment_started` with "Experiment name" and "Variant name" properties
309
+ - Add `$experiment_started` to the events array with `isStrictEvent: true` so the schema includes it
310
+
311
+ Variant-specific downstream effects (e.g., "Variant B boosts downstream event X") go in the `write-hooks` skill via `funnel-post` hooks that check `meta.experiment.variantName`.
312
+
313
+ ### World Events (optional)
314
+
315
+ Shared temporal events affecting all users. Good for modeling outages, campaigns,
316
+ or launches that create visible inflection points:
317
+
318
+ ```js
319
+ worldEvents: [
320
+ {
321
+ name: "Black Friday Sale",
322
+ startDay: 55,
323
+ duration: 3,
324
+ affectsEvents: ["Purchase", "Add to Cart"],
325
+ volumeMultiplier: 2.5,
326
+ conversionModifier: 1.3,
327
+ injectProps: { promo_active: true },
328
+ },
329
+ {
330
+ name: "API Outage",
331
+ startDay: 30,
332
+ duration: 1,
333
+ affectsEvents: "*",
334
+ volumeMultiplier: 0.3,
335
+ },
336
+ ]
337
+ ```
338
+
339
+ World events stamp `injectProps` on matching events and modulate volume via
340
+ accept/reject sampling. `conversionModifier` affects funnel conversion rates.
341
+ See `types.d.ts` `ResolvedWorldEvent` for the full interface.
342
+
343
+ ## Trend shape — `macro` and `soup`
344
+
345
+ Default to NOT setting either. Defaults: `macro: "flat"` (preset born=12,
346
+ bias=0, uniform pre-existing spread) + `soup: "growth"` (standard intra-week /
347
+ intra-day rhythm). Only override when you have a specific reason:
348
+
349
+ - Use `macro: "growth"` if you want a mild acquisition trend (visible births
350
+ over the window, 30% born-in-dataset).
351
+ - Use `macro: "viral"` only if the app has a hockey-stick acquisition story.
352
+ Pair with `personas` so the late entrants behave differently.
353
+ - Use `macro: "decline"` for sunsetting products. Pair with `engagementDecay`
354
+ or a churn cohort to get a real downtrend (the macro alone produces a flat
355
+ shape — see `lib/templates/macro-presets.js` decline JSDoc).
356
+ - Use `soup: "spiky"` for products with dramatic peaks / valleys (gaming
357
+ weekends, financial market hours).
358
+ - Use `soup: "global"` to flatten all DOW/HOD weights (24/7 server-side products).
359
+
360
+ ### Macro × born% / bias compatibility (strict clamps)
361
+
362
+ When you set `macro` AND `percentUsersBornInDataset` explicitly, the validator
363
+ clamps born% to the macro's preset value: flat=12, steady=12, growth=30,
364
+ viral=55, decline=5. Same for `bornRecentBias` outside `[-0.5, 0.5]`. If you
365
+ need higher born% (e.g., "this app launched mid-window — every user is in the
366
+ dataset"), switch macros first (flat → growth → viral) instead of pushing the
367
+ preset's value. Setting born% without a macro keeps legacy behavior (no clamp).
368
+
369
+ The clamp warning explains why and points to safe alternatives — read it.
370
+
371
+ ⚠️ **Don't set `percentUsersBornInDataset > 60`** with macro=flat / steady /
372
+ decline. Cumulative-acquisition right-edge explosion is the inevitable result
373
+ on a no-hook dungeon. The strict clamps will rescue the run, but the chart
374
+ won't look like what you asked for.
375
+
376
+ See [CLAUDE.md "Engine guarantees"](../../../CLAUDE.md#engine-guarantees) for the full safe-range table.
377
+
378
+ ### `avgActiveDaysPerUser` (concentrator)
379
+
380
+ Top-level optional knob. Sets the mean number of distinct UTC days each user
381
+ fires events on. Total event count is preserved (still `rate × numDays`),
382
+ but events cluster onto fewer days. Per-user count drawn from
383
+ `normal(mean=N, sd=N/3)`, clamped to `[1, userActiveDays]`.
384
+
385
+ Default: undefined (legacy — events spread across the whole window via TimeSoup).
386
+
387
+ **Concentrator semantic — per-active-day rate inflates.** Example:
388
+
389
+ ```
390
+ avgEventsPerUserPerDay: 4
391
+ avgActiveDaysPerUser: 2
392
+ numDays: 30
393
+ → 120 events per user, concentrated onto 2 days = 60 events/active day
394
+ ```
395
+
396
+ The validator warns when the implied per-active-day rate > 50. To reduce
397
+ total events, lower `avgEventsPerUserPerDay`, NOT this knob.
398
+
399
+ **Incompatibility with `engagementDecay`:** these two erosive primitives
400
+ combine destructively — decay drops events from late picked days, eroding
401
+ the effective active-day count below the configured target. Use one or the
402
+ other in a dungeon. If you need both, set `avgActiveDaysPerUser` and write
403
+ the decay as an `everything` hook scoped to specific cohorts (the `write-hooks`
404
+ skill handles this).
405
+
406
+ ### `maxTouchpointsPerUser` (attribution cap)
407
+
408
+ Top-level optional knob. Caps UTM stamping at this many events per user
409
+ (default 10, matching Mixpanel `TOUCHPOINTS_LIMIT`). When `hasCampaigns: true`
410
+ and a user has more eligible events than the cap, the engine takes a
411
+ uniform-random sample across the user's lifetime and stamps UTMs on the
412
+ sampled events only. Sampling across lifetime (NOT first-N) preserves
413
+ realistic touch shape — Mixpanel's last-10-window then gives meaningful
414
+ first/last-touch attribution. Set to `Infinity` to disable the cap.
415
+
416
+ ## SuperProp consistency rule
417
+
418
+ If `superProps` and `userProps` both define a property like `Plan`, the
419
+ enumeration must match exactly. Otherwise users with `userProps.Plan = 'pro'`
420
+ will fire events with `superProps.Plan = 'free'` — Mixpanel will see broken
421
+ breakdowns.
422
+
423
+ ```js
424
+ const PLANS = ["Free", "Free", "Free", "Pro", "Pro", "Enterprise"];
425
+ // ...
426
+ superProps: { Plan: PLANS, Region: REGIONS },
427
+ userProps: { Plan: PLANS, Region: REGIONS, Role: ROLES, ... },
428
+ ```
429
+
430
+ ## Verification
431
+
432
+ After writing the file:
433
+
434
+ 1. Smoke-test: `node scripts/verify-runner.mjs dungeons/user/<file>.js verify-<file> --small`. Confirm zero errors.
435
+ 2. Hand to the next skill: `/write-hooks dungeons/user/<file>.js "describe trends"`.
436
+ 3. After hooks land: `/verify-dungeon dungeons/user/<file>.js`.
437
+
438
+ ## Property Type Reference
439
+
440
+ Use the correct helper for each Mixpanel property data type. All helpers are imported from `@ak--47/dungeon-master/utils` (already available as `u` in dungeon files).
441
+
442
+ | Mixpanel Type | Helper | Example |
443
+ |---|---|---|
444
+ | **String** | Array of options | `["Basic", "Pro", "Enterprise"]` |
445
+ | **Numeric** | `weighNumRange()` or array | `u.weighNumRange(1, 100)` or `[10, 20, 50, 100]` |
446
+ | **Boolean** | Boolean array | `[true, false, false]` (weighted 33/67) |
447
+ | **Date** | `dateRange()` | `dateRange()` (dataset window) or `dateRange('2023-01-01', '2024-01-01')` |
448
+ | **List** | `listOf()` | `listOf(["tag1", "tag2", "tag3"], {min: 1, max: 3})` |
449
+ | **Object** | Plain object | `{tier: "premium", seats: 5}` |
450
+ | **List of Objects** | `objectList()` | `objectList({sku: u.weighNumRange(1000,9999), qty: [1,2,3]}, {min:1, max:4})` |
451
+
452
+ When designing event properties, always consider which Mixpanel type best represents the data:
453
+ - Tags, genres, interests → `listOf()`
454
+ - Cart items, line items, participants → `objectList()`
455
+ - Subscription start, trial end, next billing → `dateRange()`
456
+ - Status, tier, category → string array
457
+
458
+ ## Output
459
+
460
+ Write the file to `dungeons/user/<descriptive-name>.js`. Do NOT inject hooks.
461
+ Do NOT use `subscription`, `attribution`, `geo`, `features`, or `anomalies`
462
+ (the engine will silently strip them and warn).
463
+
464
+ When done, tell the user the next skill to run.