@ak--47/dungeon-master 1.2.3 → 1.3.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 (89) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +51 -13
  3. package/dungeons/technical/ad-spend.js +2 -2
  4. package/dungeons/technical/anonymous-users.js +2 -2
  5. package/dungeons/technical/array-of-object-lookup.js +2 -4
  6. package/dungeons/technical/experiments.js +2 -2
  7. package/dungeons/technical/foobar.js +2 -2
  8. package/dungeons/technical/group-analytics.js +2 -2
  9. package/dungeons/technical/mirror-strategies.js +2 -2
  10. package/dungeons/technical/nested-objects.js +2 -2
  11. package/dungeons/technical/retention-cadence.js +2 -3
  12. package/dungeons/technical/sanity.js +2 -2
  13. package/dungeons/technical/scale-test.js +2 -2
  14. package/dungeons/technical/scd.js +2 -2
  15. package/dungeons/technical/simple.js +5 -6
  16. package/dungeons/technical/simplest-schema.json +5 -0
  17. package/dungeons/technical/simplest.js +2 -2
  18. package/dungeons/technical/text-generation.js +3 -3
  19. package/dungeons/vertical/ai-platform.js +858 -0
  20. package/dungeons/vertical/community.js +84 -40
  21. package/dungeons/vertical/crypto.js +830 -0
  22. package/dungeons/vertical/dating.js +744 -0
  23. package/dungeons/vertical/devtools.js +175 -69
  24. package/dungeons/vertical/ecommerce.js +242 -94
  25. package/dungeons/vertical/education.js +330 -313
  26. package/dungeons/vertical/fintech.js +442 -313
  27. package/dungeons/vertical/fitness.js +143 -61
  28. package/dungeons/vertical/food-delivery.js +327 -353
  29. package/dungeons/vertical/gaming.js +912 -382
  30. package/dungeons/vertical/healthcare.js +142 -63
  31. package/dungeons/vertical/insurance-application.js +170 -76
  32. package/dungeons/vertical/logistics.js +115 -20
  33. package/dungeons/vertical/marketplace.js +152 -58
  34. package/dungeons/vertical/media.js +248 -384
  35. package/dungeons/vertical/real-estate.js +781 -0
  36. package/dungeons/vertical/sass.js +255 -266
  37. package/dungeons/vertical/social.js +264 -206
  38. package/dungeons/vertical/travel.js +117 -41
  39. package/index.js +17 -17
  40. package/lib/core/config-validator.js +159 -31
  41. package/lib/core/context.js +10 -24
  42. package/lib/core/storage.js +6 -1
  43. package/lib/generators/events.js +11 -14
  44. package/lib/generators/funnels.js +12 -4
  45. package/lib/generators/mirror.js +3 -2
  46. package/lib/generators/product-names.js +1 -1
  47. package/lib/generators/scd.js +2 -1
  48. package/lib/generators/text.js +1 -1
  49. package/lib/orchestrators/user-loop.js +81 -47
  50. package/lib/templates/macro-presets.js +111 -0
  51. package/lib/templates/soup-presets.js +19 -36
  52. package/lib/utils/utils.js +71 -39
  53. package/package.json +8 -2
  54. package/scripts/smoke-test-all.mjs +162 -0
  55. package/scripts/verify-runner.mjs +72 -24
  56. package/types.d.ts +251 -51
  57. package/dungeons/technical/ad-spend-schema.json +0 -128
  58. package/dungeons/technical/anonymous-users-schema.json +0 -92
  59. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  60. package/dungeons/technical/experiments-schema.json +0 -203
  61. package/dungeons/technical/foobar-schema.json +0 -362
  62. package/dungeons/technical/group-analytics-schema.json +0 -241
  63. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  64. package/dungeons/technical/nested-objects-schema.json +0 -145
  65. package/dungeons/technical/retention-cadence-schema.json +0 -37
  66. package/dungeons/technical/sanity-schema.json +0 -185
  67. package/dungeons/technical/scale-test-schema.json +0 -70
  68. package/dungeons/technical/scd-schema.json +0 -467
  69. package/dungeons/technical/simple-schema.json +0 -362
  70. package/dungeons/technical/text-generation-schema.json +0 -1062
  71. package/dungeons/user/.gitkeep +0 -0
  72. package/dungeons/vertical/community-schema.json +0 -579
  73. package/dungeons/vertical/devtools-schema.json +0 -601
  74. package/dungeons/vertical/ecommerce-schema.json +0 -604
  75. package/dungeons/vertical/education-schema.json +0 -5686
  76. package/dungeons/vertical/fintech-schema.json +0 -630
  77. package/dungeons/vertical/fitness-schema.json +0 -530
  78. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  79. package/dungeons/vertical/gaming-schema.json +0 -438
  80. package/dungeons/vertical/healthcare-schema.json +0 -549
  81. package/dungeons/vertical/insurance-application-schema.json +0 -485
  82. package/dungeons/vertical/logistics-schema.json +0 -574
  83. package/dungeons/vertical/marketplace-schema.json +0 -533
  84. package/dungeons/vertical/media-schema.json +0 -4749
  85. package/dungeons/vertical/rpg-schema.json +0 -2491
  86. package/dungeons/vertical/rpg.js +0 -976
  87. package/dungeons/vertical/sass-schema.json +0 -3128
  88. package/dungeons/vertical/social-schema.json +0 -620
  89. package/dungeons/vertical/travel-schema.json +0 -580
@@ -0,0 +1,781 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "homenest";
3
+ const num_days = 150;
4
+ const num_users = 6_000;
5
+ const avg_events_per_user_per_day = 0.53;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
11
+ import dayjs from "dayjs";
12
+ import utc from "dayjs/plugin/utc.js";
13
+ import "dotenv/config";
14
+ import * as u from "../../lib/utils/utils.js";
15
+ import * as v from "ak-tools";
16
+
17
+ dayjs.extend(utc);
18
+ const chance = u.initChance(SEED);
19
+
20
+ /** @typedef {import("../../types").Dungeon} Config */
21
+
22
+ /**
23
+ * ===================================================================
24
+ * DATASET OVERVIEW
25
+ * ===================================================================
26
+ *
27
+ * HomeNest — a property listings and agent CRM platform
28
+ * (Zillow meets Compass). Buyers search listings, save properties,
29
+ * schedule tours, get pre-approved for mortgages, and submit offers.
30
+ * Agents list properties, manage clients, and close deals.
31
+ *
32
+ * Scale: 6,000 users · 480K events · 150 days · 17 event types
33
+ * Groups: 200 brokerages
34
+ * User types: Buyer (60%), Agent (20%), Both (20%)
35
+ * Agent tiers: Standard (75%) / Premier (25%)
36
+ *
37
+ * Core loops:
38
+ * Buyer: account created → property search → property viewed →
39
+ * property saved → tour scheduled → offer submitted →
40
+ * offer accepted / offer rejected
41
+ *
42
+ * Agent: account created → property listed → open house attended →
43
+ * agent contacted → property sold
44
+ *
45
+ * Funnels:
46
+ * - Buyer journey: account created → property search → property viewed (80%)
47
+ * - Tour funnel: property viewed → tour scheduled → offer submitted (40%)
48
+ * - Seller funnel: property listed → open house attended → property sold (35%)
49
+ *
50
+ * ===================================================================
51
+ * ANALYTICS HOOKS (10 hooks)
52
+ *
53
+ * Adds 10. TOUR FUNNEL TIME-TO-CONVERT: Premier agents 0.71x faster, Standard
54
+ * 1.3x slower (funnel-post). Discover via Tour Funnel median TTC by agent_tier.
55
+ * NOTE (funnel-post measurement): visible only via Mixpanel funnel median TTC.
56
+ * Cross-event MIN→MIN SQL queries on raw events do NOT show this.
57
+ * ===================================================================
58
+ *
59
+ * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
60
+ * only via behavioral cohorts (count event per user, then measure
61
+ * downstream) or raw-prop breakdowns (date, agent_tier).
62
+ *
63
+ * -------------------------------------------------------------------
64
+ * 1. SPRING BUYING SEASON (event)
65
+ * -------------------------------------------------------------------
66
+ *
67
+ * PATTERN: Days 30-60, tour-scheduled duration_mins boosted 3x and
68
+ * offer-submitted offer_price boosted 2.5x. Mutates raw props. No flag.
69
+ *
70
+ * HOW TO FIND IT IN MIXPANEL:
71
+ *
72
+ * Report 1: Avg Offer Price by Day
73
+ * - Report type: Insights
74
+ * - Event: "offer submitted"
75
+ * - Measure: Average of "offer_price"
76
+ * - Line chart by day
77
+ * - Expected: visible bulge days 30-60 (~ 2.5x baseline)
78
+ *
79
+ * REAL-WORLD ANALOGUE: Spring buying season elevates prices and tour intent.
80
+ *
81
+ * -------------------------------------------------------------------
82
+ * 2. MORTGAGE RATE SHOCK (event + everything)
83
+ * -------------------------------------------------------------------
84
+ *
85
+ * PATTERN: Days 75-89, mortgage_rate forced to 7.5 on pre-approval
86
+ * events. In everything hook, 45% of post-day-75 offer-submitted events
87
+ * dropped. No flag.
88
+ *
89
+ * HOW TO FIND IT IN MIXPANEL:
90
+ *
91
+ * Report 1: Mortgage Rate Step Change
92
+ * - Report type: Insights
93
+ * - Event: "mortgage pre-approval"
94
+ * - Measure: Average of "mortgage_rate"
95
+ * - Line chart by day
96
+ * - Expected: clear step change from ~ 6.5 to 7.5 at day 75
97
+ *
98
+ * Report 2: Offer Volume Drop After Rate Hike
99
+ * - Report type: Insights
100
+ * - Event: "offer submitted"
101
+ * - Measure: Total
102
+ * - Line chart by day
103
+ * - Expected: ~ 45% volume drop after day 75
104
+ *
105
+ * REAL-WORLD ANALOGUE: Buyer demand is sensitive to mortgage rates.
106
+ *
107
+ * -------------------------------------------------------------------
108
+ * 3. SAVED-SEARCH RETENTION (everything)
109
+ * -------------------------------------------------------------------
110
+ *
111
+ * PATTERN: Users who create saved-search-created in first 7 days get
112
+ * extra cloned property-viewed + tour-scheduled events. Non-savers lose
113
+ * 80% of post-day-30 events. No flag — discover via cohort builder.
114
+ *
115
+ * HOW TO FIND IT IN MIXPANEL:
116
+ *
117
+ * Report 1: Retention by Saved-Search Cohort
118
+ * - Report type: Retention
119
+ * - Cohort A: users with >= 1 "saved search created" in first 7 days
120
+ * - Cohort B: rest
121
+ * - Expected: A sustains higher retention through dataset
122
+ *
123
+ * REAL-WORLD ANALOGUE: Saved searches indicate buyer intent.
124
+ *
125
+ * -------------------------------------------------------------------
126
+ * 4. PRE-APPROVED BUYER CONVERSION (everything)
127
+ * -------------------------------------------------------------------
128
+ *
129
+ * PATTERN: Users with a mortgage-pre-approval event get 4-6 extra
130
+ * cloned offer-submitted events. No flag — discover via cohort.
131
+ *
132
+ * HOW TO FIND IT IN MIXPANEL:
133
+ *
134
+ * Report 1: Offers per User by Pre-Approval
135
+ * - Report type: Insights (with cohort)
136
+ * - Cohort A: users with >= 1 "mortgage pre-approval"
137
+ * - Cohort B: rest
138
+ * - Event: "offer submitted"
139
+ * - Measure: Total per user
140
+ * - Expected: A ~ 5x B
141
+ *
142
+ * REAL-WORLD ANALOGUE: Pre-approval is the strongest buyer indicator.
143
+ *
144
+ * -------------------------------------------------------------------
145
+ * 5. TOP-TIER AGENT ADVANTAGE (everything)
146
+ * -------------------------------------------------------------------
147
+ *
148
+ * PATTERN: Premier-tier agents get 2 extra cloned listings per existing
149
+ * (3x rate) and 1 extra cloned sale per existing (2x rate). Mutates
150
+ * cloned events with raw values; reads agent_tier from profile (existing
151
+ * SCD prop). Discover via agent_tier breakdown.
152
+ *
153
+ * HOW TO FIND IT IN MIXPANEL:
154
+ *
155
+ * Report 1: Listings per Agent by Tier
156
+ * - Report type: Insights
157
+ * - Event: "property listed"
158
+ * - Measure: Total per user
159
+ * - Breakdown: user "agent_tier"
160
+ * - Expected: Premier ~ 3x Standard
161
+ *
162
+ * REAL-WORLD ANALOGUE: Top producers list and close more.
163
+ *
164
+ * -------------------------------------------------------------------
165
+ * 6. TOUR-TO-OFFER POWER USERS (everything)
166
+ * -------------------------------------------------------------------
167
+ *
168
+ * PATTERN: Users with both virtual-tour AND in-person-tour events get
169
+ * 5-7 extra cloned offer-submitted events. No flag — discover via
170
+ * compound cohort builder.
171
+ *
172
+ * HOW TO FIND IT IN MIXPANEL:
173
+ *
174
+ * Report 1: Offers per User by Dual-Tour Cohort
175
+ * - Report type: Insights (with cohort)
176
+ * - Cohort A: users with both >= 1 "virtual tour" AND >= 1 "in-person tour"
177
+ * - Cohort B: rest
178
+ * - Event: "offer submitted"
179
+ * - Measure: Total per user
180
+ * - Expected: A ~ 6x B
181
+ *
182
+ * REAL-WORLD ANALOGUE: Dual-tour buyers self-qualify into serious bidders.
183
+ *
184
+ * -------------------------------------------------------------------
185
+ * 7. LUXURY LISTING RELEASE (event + everything)
186
+ * -------------------------------------------------------------------
187
+ *
188
+ * PATTERN: After day 50, 3% of property-listed events get listing_price
189
+ * set to $5M-$15M (raw mutation). ~3% of users (by id hash) get extra
190
+ * luxury cloned property-viewed events. No flag — discover via line
191
+ * chart by day on listing_price.
192
+ *
193
+ * HOW TO FIND IT IN MIXPANEL:
194
+ *
195
+ * Report 1: Listing Price Distribution Over Time
196
+ * - Report type: Insights
197
+ * - Event: "property listed"
198
+ * - Measure: Distribution of "listing_price"
199
+ * - Line chart by week
200
+ * - Expected: $5M+ outliers appear only after day 50
201
+ *
202
+ * REAL-WORLD ANALOGUE: Luxury inventory drop attracts HNW shoppers.
203
+ *
204
+ * -------------------------------------------------------------------
205
+ * 8. COLD-LEAD CHURN (everything)
206
+ * -------------------------------------------------------------------
207
+ *
208
+ * PATTERN: Users who view (property viewed) but never save (property
209
+ * saved) in the first 14 days lose 90% of events after day 14. No flag.
210
+ *
211
+ * HOW TO FIND IT IN MIXPANEL:
212
+ *
213
+ * Report 1: Cold-Lead Retention
214
+ * - Report type: Retention
215
+ * - Cohort A: users with >= 1 "property viewed" but 0 "property saved" in first 14 days
216
+ * - Cohort B: rest
217
+ * - Expected: A retention drops to near-zero after day 14
218
+ *
219
+ * REAL-WORLD ANALOGUE: Browsers who never save are window-shoppers.
220
+ *
221
+ * -------------------------------------------------------------------
222
+ * 9. PROPERTY-VIEWED MAGIC NUMBER (everything)
223
+ * -------------------------------------------------------------------
224
+ *
225
+ * PATTERN: Users in the 6-12 property-viewed sweet spot get +30% on
226
+ * offer_price for offer-submitted events. Users with 13+ views are
227
+ * tire-kickers; 35% of their offer-submitted events drop. No flag.
228
+ *
229
+ * HOW TO FIND IT IN MIXPANEL:
230
+ *
231
+ * Report 1: Avg Offer Price by View Bucket
232
+ * - Report type: Insights (with cohort)
233
+ * - Cohort A: users with 6-12 "property viewed"
234
+ * - Cohort B: users with 0-5
235
+ * - Event: "offer submitted"
236
+ * - Measure: Average of "offer_price"
237
+ * - Expected: A ~ 1.3x B
238
+ *
239
+ * Report 2: Offers per User on Heavy Viewers
240
+ * - Report type: Insights (with cohort)
241
+ * - Cohort C: users with >= 13 "property viewed"
242
+ * - Cohort A: users with 6-12
243
+ * - Event: "offer submitted"
244
+ * - Measure: Total per user
245
+ * - Expected: C ~ 35% fewer offers per user vs A
246
+ *
247
+ * REAL-WORLD ANALOGUE: Focused buyers commit; obsessive browsers
248
+ * tire-kick and never make the leap.
249
+ *
250
+ * ===================================================================
251
+ * EXPECTED METRICS SUMMARY
252
+ * ===================================================================
253
+ *
254
+ * Hook | Metric | Baseline | Effect | Ratio
255
+ * -------------------------|------------------------|----------|---------|------
256
+ * Spring Buying Season | Avg offer_price d30-60 | 1x | 2.5x | 2.5x
257
+ * Mortgage Rate Shock | Offer vol post-day-75 | 1x | 0.55x | -45%
258
+ * Saved-Search Retention | non-saver post-day-30 | 1x | 0.2x | -80%
259
+ * Pre-Approved Conversion | offers/user | 1x | ~ 5x | 5x
260
+ * Premier Agent Advantage | listings/user | 1x | ~ 3x | 3x
261
+ * Dual-Tour Power Users | offers/user | 1x | ~ 6x | 6x
262
+ * Luxury Listing Release | $5M+ listings | 0% | ~ 3% | d50+
263
+ * Cold-Lead Churn | non-save post-day-14 | 1x | 0.1x | -90%
264
+ * View-Count Magic Number | sweet offer_price | 1x | 1.3x | 1.3x
265
+ * View-Count Magic Number | over offers/user | 1x | 0.65x | -35%
266
+ */
267
+
268
+ /** @type {Config} */
269
+ const config = {
270
+ token,
271
+ seed: SEED,
272
+ datasetStart: "2026-01-01T00:00:00Z",
273
+ datasetEnd: "2026-04-28T23:59:59Z",
274
+ // numDays: num_days,
275
+ avgEventsPerUserPerDay: avg_events_per_user_per_day,
276
+ numUsers: num_users,
277
+ hasAnonIds: false,
278
+ hasSessionIds: true,
279
+ format: "json",
280
+ gzip: true,
281
+ alsoInferFunnels: false,
282
+ hasLocation: true,
283
+ hasAndroidDevices: true,
284
+ hasIOSDevices: true,
285
+ hasDesktopDevices: true,
286
+ hasBrowser: true,
287
+ hasCampaigns: false,
288
+ isAnonymous: false,
289
+ hasAdSpend: false,
290
+
291
+ hasAvatar: true,
292
+
293
+ concurrency: 1,
294
+ writeToDisk: false,
295
+
296
+ soup: "growth",
297
+
298
+ scdProps: {
299
+ agent_tier: {
300
+ values: ["Standard", "Standard", "Standard", "Premier"],
301
+ frequency: "month",
302
+ timing: "fuzzy",
303
+ max: 4
304
+ }
305
+ },
306
+
307
+ funnels: [
308
+ {
309
+ sequence: ["account created", "property search", "property viewed"],
310
+ isFirstFunnel: true,
311
+ conversionRate: 80,
312
+ timeToConvert: 0.5,
313
+ name: "Buyer Journey"
314
+ },
315
+ {
316
+ sequence: ["property viewed", "tour scheduled", "offer submitted"],
317
+ conversionRate: 40,
318
+ timeToConvert: 24,
319
+ weight: 5,
320
+ name: "Tour Funnel"
321
+ },
322
+ {
323
+ sequence: ["property listed", "open house attended", "property sold"],
324
+ conversionRate: 35,
325
+ timeToConvert: 48,
326
+ weight: 3,
327
+ name: "Seller Funnel"
328
+ }
329
+ ],
330
+
331
+ events: [
332
+ {
333
+ event: "account created",
334
+ weight: 1,
335
+ isFirstEvent: true,
336
+ properties: {
337
+ signup_method: ["email", "google", "apple", "facebook"],
338
+ }
339
+ },
340
+ {
341
+ event: "property search",
342
+ weight: 10,
343
+ properties: {
344
+ location: ["Manhattan", "Brooklyn", "Austin", "Miami", "San Francisco", "Denver", "Seattle", "Chicago", "Nashville", "Portland"],
345
+ price_range_min: u.weighNumRange(100000, 500000, 0.5, 40),
346
+ price_range_max: u.weighNumRange(300000, 2000000, 0.5, 40),
347
+ bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
348
+ property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
349
+ }
350
+ },
351
+ {
352
+ event: "property viewed",
353
+ weight: 12,
354
+ properties: {
355
+ listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
356
+ listing_price: u.weighNumRange(150000, 1500000, 0.4, 60),
357
+ property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
358
+ bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
359
+ square_feet: u.weighNumRange(600, 5000, 0.5, 50),
360
+ }
361
+ },
362
+ {
363
+ event: "property saved",
364
+ weight: 5,
365
+ properties: {
366
+ listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
367
+ listing_price: u.weighNumRange(150000, 1500000, 0.4, 40),
368
+ }
369
+ },
370
+ {
371
+ event: "saved search created",
372
+ weight: 3,
373
+ properties: {
374
+ location: ["Manhattan", "Brooklyn", "Austin", "Miami", "San Francisco", "Denver", "Seattle", "Chicago", "Nashville", "Portland"],
375
+ criteria_count: u.weighNumRange(2, 8, 0.8, 10),
376
+ }
377
+ },
378
+ {
379
+ event: "virtual tour",
380
+ weight: 4,
381
+ properties: {
382
+ listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
383
+ duration_mins: u.weighNumRange(5, 45, 0.5, 30),
384
+ }
385
+ },
386
+ {
387
+ event: "in-person tour",
388
+ weight: 3,
389
+ properties: {
390
+ listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
391
+ agent_id: () => `AGT-${chance.integer({ min: 1000, max: 9999 })}`,
392
+ }
393
+ },
394
+ {
395
+ event: "tour scheduled",
396
+ weight: 4,
397
+ properties: {
398
+ listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
399
+ agent_id: () => `AGT-${chance.integer({ min: 1000, max: 9999 })}`,
400
+ duration_mins: u.weighNumRange(5, 90, 0.5, 30),
401
+ }
402
+ },
403
+ {
404
+ event: "mortgage pre-approval",
405
+ weight: 2,
406
+ properties: {
407
+ lender: ["Chase", "Wells Fargo", "Bank of America", "Rocket Mortgage", "United Wholesale", "loanDepot"],
408
+ approved_amount: u.weighNumRange(200000, 1500000, 0.4, 40),
409
+ mortgage_rate: u.weighNumRange(5.5, 7.5, 0.8, 20),
410
+ }
411
+ },
412
+ {
413
+ event: "offer submitted",
414
+ weight: 2,
415
+ properties: {
416
+ listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
417
+ offer_price: u.weighNumRange(200000, 1500000, 0.4, 40),
418
+ listing_price: u.weighNumRange(200000, 1500000, 0.4, 40),
419
+ }
420
+ },
421
+ {
422
+ event: "offer accepted",
423
+ weight: 1,
424
+ properties: {
425
+ listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
426
+ final_price: u.weighNumRange(200000, 1500000, 0.4, 40),
427
+ }
428
+ },
429
+ {
430
+ event: "offer rejected",
431
+ weight: 1,
432
+ properties: {
433
+ listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
434
+ reason: ["price too low", "competing offer", "seller changed mind", "inspection issues", "financing fell through"],
435
+ }
436
+ },
437
+ {
438
+ event: "property listed",
439
+ weight: 3,
440
+ properties: {
441
+ listing_price: u.weighNumRange(200000, 1500000, 0.4, 50),
442
+ property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
443
+ bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
444
+ listing_status: ["active", "pending", "coming soon"],
445
+ }
446
+ },
447
+ {
448
+ event: "property sold",
449
+ weight: 1,
450
+ properties: {
451
+ sale_price: u.weighNumRange(200000, 1500000, 0.4, 40),
452
+ days_on_market: u.weighNumRange(5, 180, 0.4, 30),
453
+ }
454
+ },
455
+ {
456
+ event: "agent contacted",
457
+ weight: 4,
458
+ properties: {
459
+ contact_method: ["phone", "email", "in-app message", "text"],
460
+ }
461
+ },
462
+ {
463
+ event: "open house attended",
464
+ weight: 2,
465
+ properties: {
466
+ listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
467
+ attendees: u.weighNumRange(2, 30, 0.5, 15),
468
+ }
469
+ },
470
+ {
471
+ event: "review submitted",
472
+ weight: 2,
473
+ properties: {
474
+ rating: [1, 2, 3, 3, 4, 4, 4, 5, 5, 5],
475
+ review_type: ["agent", "property", "platform"],
476
+ }
477
+ }
478
+ ],
479
+
480
+ superProps: {
481
+ user_type: ["Buyer", "Buyer", "Buyer", "Agent", "Both"],
482
+ preferred_location: ["Urban", "Suburban", "Rural"],
483
+ property_preference: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
484
+ },
485
+
486
+ userProps: {
487
+ user_type: ["Buyer", "Buyer", "Buyer", "Agent", "Both"],
488
+ preferred_location: ["Urban", "Suburban", "Rural"],
489
+ property_preference: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
490
+ budget_max: u.weighNumRange(200000, 2000000, 0.4, 50),
491
+ agent_tier: ["Standard", "Standard", "Standard", "Premier"],
492
+ total_properties_viewed: u.weighNumRange(0, 100, 0.5, 30),
493
+ pre_approval_status: ["none"],
494
+ },
495
+
496
+ groupKeys: [
497
+ ["brokerage_id", 200, ["property listed", "property sold", "agent contacted", "open house attended"]],
498
+ ],
499
+
500
+ groupProps: {
501
+ brokerage_id: {
502
+ brokerage_name: ["Compass", "RE/MAX", "Keller Williams", "Coldwell Banker", "Century 21", "Sotheby's", "eXp Realty", "Redfin"],
503
+ agent_count: u.weighNumRange(5, 200, 0.4, 30),
504
+ region: ["Northeast", "Southeast", "Midwest", "Southwest", "West Coast", "Pacific Northwest"],
505
+ avg_listing_price: u.weighNumRange(200000, 1500000, 0.4, 30),
506
+ }
507
+ },
508
+
509
+ lookupTables: [],
510
+
511
+ hook: function (record, type, meta) {
512
+ // HOOK 10 (T2C): TOUR FUNNEL TIME-TO-CONVERT (funnel-post)
513
+ // Premier agents move users through Tour funnel 1.4x faster
514
+ // (factor 0.71); Standard agents 1.3x slower (factor 1.3).
515
+ if (type === "funnel-post") {
516
+ const segment = meta?.profile?.agent_tier;
517
+ if (Array.isArray(record) && record.length > 1) {
518
+ const factor = (
519
+ segment === "Premier" ? 0.71 :
520
+ segment === "Standard" ? 1.3 :
521
+ 1.0
522
+ );
523
+ if (factor !== 1.0) {
524
+ for (let i = 1; i < record.length; i++) {
525
+ const prev = dayjs(record[i - 1].time);
526
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
527
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
528
+ }
529
+ }
530
+ }
531
+ }
532
+
533
+ // HOOKS 1, 2, 7 (time-window event-level effects) moved to everything hook —
534
+ // event hook fires before bunchIntoSessions reshuffles timestamps, so day-window
535
+ // tags leak across boundaries.
536
+
537
+ if (type === "everything") {
538
+ const datasetStart = dayjs.unix(meta.datasetStart);
539
+ const userEvents = record;
540
+ const profile = meta.profile;
541
+
542
+ // Stamp superProps from profile for consistency
543
+ userEvents.forEach(e => {
544
+ e.user_type = profile.user_type;
545
+ e.preferred_location = profile.preferred_location;
546
+ e.property_preference = profile.property_preference;
547
+ });
548
+
549
+ // HOOK 1: SPRING BUYING SEASON — d30-60 tour duration 3x, offer_price 2.5x
550
+ // HOOK 2: MORTGAGE RATE SHOCK — d75-89 mortgage_rate pinned 7.5
551
+ // HOOK 7: LUXURY LISTING RELEASE — post-d50, 3% of listings priced $5M-$15M
552
+ const springStart = datasetStart.add(30, "days");
553
+ const springEnd = datasetStart.add(60, "days");
554
+ const shockStart = datasetStart.add(75, "days");
555
+ const shockEnd = datasetStart.add(89, "days");
556
+ const luxuryStart = datasetStart.add(50, "days");
557
+ userEvents.forEach(e => {
558
+ const t = dayjs.utc(e.time);
559
+ const inSpring = (t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd);
560
+ if (inSpring && e.event === "tour scheduled" && typeof e.duration_mins === "number") {
561
+ e.duration_mins = Math.round(e.duration_mins * 3);
562
+ }
563
+ if (inSpring && e.event === "offer submitted") {
564
+ e.offer_price = Math.floor((e.offer_price || 400000) * 2.5);
565
+ }
566
+ if (e.event === "mortgage pre-approval") {
567
+ if ((t.isAfter(shockStart) || t.isSame(shockStart)) && t.isBefore(shockEnd)) {
568
+ e.mortgage_rate = 7.5;
569
+ }
570
+ }
571
+ if (e.event === "property listed" && t.isAfter(luxuryStart) && chance.bool({ likelihood: 3 })) {
572
+ e.listing_price = chance.integer({ min: 5000000, max: 15000000 });
573
+ }
574
+ });
575
+
576
+ const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
577
+
578
+ // HOOK 2 (cont): MORTGAGE RATE SHOCK — drop 45% of post-day-75
579
+ // offer-submitted events. No flag.
580
+ const day75 = datasetStart.add(75, "days");
581
+ for (let i = userEvents.length - 1; i >= 0; i--) {
582
+ const evt = userEvents[i];
583
+ if (evt.event === "offer submitted" && dayjs(evt.time).isAfter(day75) && chance.bool({ likelihood: 45 })) {
584
+ userEvents.splice(i, 1);
585
+ }
586
+ }
587
+
588
+ // HOOK 3: SAVED-SEARCH RETENTION — early savers (saved-search-created
589
+ // in first 7 days) get extra cloned view + tour events. Non-savers
590
+ // lose 80% of events after day 30. No flag.
591
+ const day7 = firstEventTime ? firstEventTime.add(7, "days") : null;
592
+ const day30 = datasetStart.add(30, "days");
593
+ const hasSavedSearch = day7 ? userEvents.some(e =>
594
+ e.event === "saved search created" && dayjs(e.time).isBefore(day7)
595
+ ) : false;
596
+
597
+ if (hasSavedSearch) {
598
+ const viewTemplate = userEvents.find(e => e.event === "property viewed");
599
+ const tourTemplate = userEvents.find(e => e.event === "tour scheduled");
600
+ if (viewTemplate) {
601
+ const extras = chance.integer({ min: 3, max: 8 });
602
+ for (let i = 0; i < extras; i++) {
603
+ const offset = chance.integer({ min: 10, max: num_days - 10 });
604
+ userEvents.push({
605
+ ...viewTemplate,
606
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
607
+ user_id: viewTemplate.user_id,
608
+ listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
609
+ listing_price: chance.integer({ min: 200000, max: 1200000 }),
610
+ });
611
+ }
612
+ }
613
+ if (tourTemplate) {
614
+ const extras = chance.integer({ min: 1, max: 3 });
615
+ for (let i = 0; i < extras; i++) {
616
+ const offset = chance.integer({ min: 15, max: num_days - 10 });
617
+ userEvents.push({
618
+ ...tourTemplate,
619
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
620
+ user_id: tourTemplate.user_id,
621
+ listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
622
+ });
623
+ }
624
+ }
625
+ } else {
626
+ for (let i = userEvents.length - 1; i >= 0; i--) {
627
+ const evt = userEvents[i];
628
+ if (dayjs(evt.time).isAfter(day30) && chance.bool({ likelihood: 80 })) {
629
+ userEvents.splice(i, 1);
630
+ }
631
+ }
632
+ }
633
+
634
+ // HOOK 4: PRE-APPROVED BUYER CONVERSION — clone 4-6 extra
635
+ // offer-submitted events for users with a mortgage pre-approval
636
+ // event. No flag.
637
+ const hasPreApproval = userEvents.some(e => e.event === "mortgage pre-approval");
638
+ if (hasPreApproval) {
639
+ profile.pre_approval_status = "approved";
640
+ const offerTemplate = userEvents.find(e => e.event === "offer submitted");
641
+ if (offerTemplate) {
642
+ const extras = chance.integer({ min: 4, max: 6 });
643
+ for (let i = 0; i < extras; i++) {
644
+ const offset = chance.integer({ min: 20, max: num_days - 5 });
645
+ userEvents.push({
646
+ ...offerTemplate,
647
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
648
+ user_id: offerTemplate.user_id,
649
+ listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
650
+ offer_price: chance.integer({ min: 250000, max: 1200000 }),
651
+ listing_price: chance.integer({ min: 250000, max: 1200000 }),
652
+ });
653
+ }
654
+ }
655
+ }
656
+
657
+ // HOOK 5: TOP-TIER AGENT ADVANTAGE — Premier agents get 2 extra
658
+ // cloned listings per existing (3x rate) and 1 extra clone per
659
+ // sale (2x rate). Reads agent_tier from profile. No flag.
660
+ if (profile.agent_tier === "Premier") {
661
+ const listTemplate = userEvents.find(e => e.event === "property listed");
662
+ const soldTemplate = userEvents.find(e => e.event === "property sold");
663
+
664
+ if (listTemplate) {
665
+ const existingListings = userEvents.filter(e => e.event === "property listed").length;
666
+ const extras = existingListings * 2;
667
+ for (let i = 0; i < extras; i++) {
668
+ const offset = chance.integer({ min: 5, max: num_days - 5 });
669
+ userEvents.push({
670
+ ...listTemplate,
671
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
672
+ user_id: listTemplate.user_id,
673
+ listing_price: chance.integer({ min: 300000, max: 1500000 }),
674
+ listing_status: chance.pickone(["active", "pending", "coming soon"]),
675
+ });
676
+ }
677
+ }
678
+ if (soldTemplate) {
679
+ const existingSales = userEvents.filter(e => e.event === "property sold").length;
680
+ for (let i = 0; i < existingSales; i++) {
681
+ const offset = chance.integer({ min: 10, max: num_days - 5 });
682
+ userEvents.push({
683
+ ...soldTemplate,
684
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 9, max: 17 }), "hours").toISOString(),
685
+ user_id: soldTemplate.user_id,
686
+ sale_price: chance.integer({ min: 300000, max: 1500000 }),
687
+ days_on_market: chance.integer({ min: 5, max: 90 }),
688
+ });
689
+ }
690
+ }
691
+ }
692
+
693
+ // HOOK 6: TOUR-TO-OFFER POWER USERS — users with both virtual
694
+ // tour AND in-person tour get 5-7 extra cloned offers. No flag.
695
+ const hasVirtualTour = userEvents.some(e => e.event === "virtual tour");
696
+ const hasInPersonTour = userEvents.some(e => e.event === "in-person tour");
697
+ if (hasVirtualTour && hasInPersonTour) {
698
+ const offerTemplate = userEvents.find(e => e.event === "offer submitted");
699
+ if (offerTemplate) {
700
+ const extras = chance.integer({ min: 5, max: 7 });
701
+ for (let i = 0; i < extras; i++) {
702
+ const offset = chance.integer({ min: 15, max: num_days - 5 });
703
+ userEvents.push({
704
+ ...offerTemplate,
705
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
706
+ user_id: offerTemplate.user_id,
707
+ listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
708
+ offer_price: chance.integer({ min: 300000, max: 1500000 }),
709
+ listing_price: chance.integer({ min: 300000, max: 1500000 }),
710
+ });
711
+ }
712
+ }
713
+ }
714
+
715
+ // HOOK 7 (cont): LUXURY LISTING RELEASE — ~3% of users (by hash)
716
+ // get 3-6 extra luxury property-viewed events after day 50. No flag.
717
+ if (userEvents.length > 0) {
718
+ const userId = userEvents[0].user_id || "";
719
+ const isLuxuryBrowser = typeof userId === "string" && userId.length > 0 && userId.charCodeAt(0) % 33 === 0;
720
+ if (isLuxuryBrowser) {
721
+ const viewTemplate = userEvents.find(e => e.event === "property viewed");
722
+ if (viewTemplate) {
723
+ const extras = chance.integer({ min: 3, max: 6 });
724
+ for (let i = 0; i < extras; i++) {
725
+ const offset = chance.integer({ min: 50, max: num_days - 5 });
726
+ userEvents.push({
727
+ ...viewTemplate,
728
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 9, max: 21 }), "hours").toISOString(),
729
+ user_id: viewTemplate.user_id,
730
+ listing_id: `LST-${chance.integer({ min: 90000, max: 99999 })}`,
731
+ listing_price: chance.integer({ min: 5000000, max: 15000000 }),
732
+ property_type: chance.pickone(["Single Family", "Condo"]),
733
+ bedrooms: chance.integer({ min: 4, max: 8 }),
734
+ square_feet: chance.integer({ min: 4000, max: 15000 }),
735
+ });
736
+ }
737
+ }
738
+ }
739
+ }
740
+
741
+ // HOOK 8: COLD-LEAD CHURN — users who view but never save in
742
+ // first 14 days lose 90% of post-day-14 events. No flag.
743
+ const day14 = firstEventTime ? firstEventTime.add(14, "days") : null;
744
+ if (day14) {
745
+ const earlyEvents = userEvents.filter(e => dayjs(e.time).isBefore(day14));
746
+ const hasView = earlyEvents.some(e => e.event === "property viewed");
747
+ const hasSave = earlyEvents.some(e => e.event === "property saved");
748
+ if (hasView && !hasSave) {
749
+ for (let i = userEvents.length - 1; i >= 0; i--) {
750
+ const evt = userEvents[i];
751
+ if (dayjs(evt.time).isAfter(day14) && chance.bool({ likelihood: 90 })) {
752
+ userEvents.splice(i, 1);
753
+ }
754
+ }
755
+ }
756
+ }
757
+
758
+ // HOOK 9: PROPERTY-VIEWED MAGIC NUMBER (no flags)
759
+ // Sweet 6-12 views → +30% offer_price on offer submitted events.
760
+ // Over 13+ → drop 35% of offer submitted events (tire-kickers).
761
+ const viewCount = userEvents.filter(e => e.event === "property viewed").length;
762
+ if (viewCount >= 6 && viewCount <= 12) {
763
+ userEvents.forEach(e => {
764
+ if (e.event === "offer submitted" && typeof e.offer_price === "number") {
765
+ e.offer_price = Math.round(e.offer_price * 1.3);
766
+ }
767
+ });
768
+ } else if (viewCount >= 13) {
769
+ for (let i = userEvents.length - 1; i >= 0; i--) {
770
+ if (userEvents[i].event === "offer submitted" && chance.bool({ likelihood: 35 })) {
771
+ userEvents.splice(i, 1);
772
+ }
773
+ }
774
+ }
775
+ }
776
+
777
+ return record;
778
+ }
779
+ };
780
+
781
+ export default config;