@ak--47/dungeon-master 1.5.0 → 1.5.2

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 (66) hide show
  1. package/.claude/skills/create-dungeon/SKILL.md +139 -46
  2. package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
  3. package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
  4. package/.claude/skills/write-hooks/SKILL.md +31 -3
  5. package/CHANGELOG.md +85 -0
  6. package/HOOKS.md +13 -0
  7. package/dungeons/technical/ad-spend.js +41 -49
  8. package/dungeons/technical/anonymous-users.js +38 -36
  9. package/dungeons/technical/array-of-object-lookup.js +136 -153
  10. package/dungeons/technical/datagen-v15-verify.js +24 -11
  11. package/dungeons/technical/experiments.js +42 -40
  12. package/dungeons/technical/foobar.js +114 -118
  13. package/dungeons/technical/group-analytics.js +42 -40
  14. package/dungeons/technical/hook-helpers-verify.js +69 -50
  15. package/dungeons/technical/identity-model-verify.js +22 -12
  16. package/dungeons/technical/mirror-strategies.js +37 -39
  17. package/dungeons/technical/nested-objects.js +119 -118
  18. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  19. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  20. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  21. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  22. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  23. package/dungeons/technical/retention-cadence.js +115 -112
  24. package/dungeons/technical/sanity.js +86 -80
  25. package/dungeons/technical/scale-test.js +34 -38
  26. package/dungeons/technical/scd.js +111 -128
  27. package/dungeons/technical/simple.js +134 -141
  28. package/dungeons/technical/simplest.js +54 -62
  29. package/dungeons/technical/text-generation.js +110 -146
  30. package/dungeons/vertical/ai-platform.js +296 -333
  31. package/dungeons/vertical/community.js +284 -255
  32. package/dungeons/vertical/crypto.js +395 -391
  33. package/dungeons/vertical/dating.js +411 -378
  34. package/dungeons/vertical/devtools.js +336 -298
  35. package/dungeons/vertical/ecommerce.js +316 -394
  36. package/dungeons/vertical/education.js +369 -325
  37. package/dungeons/vertical/fintech.js +358 -325
  38. package/dungeons/vertical/fitness.js +335 -291
  39. package/dungeons/vertical/food-delivery.js +343 -307
  40. package/dungeons/vertical/gaming.js +480 -444
  41. package/dungeons/vertical/healthcare.js +306 -262
  42. package/dungeons/vertical/insurance-application.js +427 -409
  43. package/dungeons/vertical/logistics.js +271 -252
  44. package/dungeons/vertical/marketplace.js +333 -323
  45. package/dungeons/vertical/media.js +382 -335
  46. package/dungeons/vertical/real-estate.js +395 -346
  47. package/dungeons/vertical/sass.js +319 -333
  48. package/dungeons/vertical/social.js +368 -316
  49. package/dungeons/vertical/travel.js +297 -295
  50. package/index.js +46 -4
  51. package/lib/core/config-validator.js +126 -28
  52. package/lib/generators/funnels.js +4 -1
  53. package/lib/orchestrators/mixpanel-sender.js +7 -0
  54. package/lib/orchestrators/user-loop.js +132 -31
  55. package/lib/templates/defaults.js +59 -59
  56. package/lib/templates/macro-presets.js +14 -2
  57. package/lib/utils/dataset-context.js +103 -0
  58. package/lib/utils/retention-curve.js +140 -0
  59. package/lib/utils/utils.js +149 -38
  60. package/lib/verify/counting.js +40 -0
  61. package/lib/verify/emulate-breakdown.js +20 -1
  62. package/lib/verify/index.js +1 -0
  63. package/lib/verify/schema-validator.js +3 -1
  64. package/package.json +11 -2
  65. package/scripts/run-dungeon.mjs +12 -1
  66. package/types.d.ts +117 -1
@@ -1,66 +1,44 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "dm4-logistics";
3
- const num_days = 120;
4
- const num_users = 10_000;
5
- const avg_events_per_user_per_day = 1.2;
6
- let token = "your-mixpanel-token";
7
-
8
- // ── env overrides ──
9
- if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
-
1
+ // ── IMPORTS ──
11
2
  import dayjs from "dayjs";
12
3
  import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
13
5
  import "dotenv/config";
14
6
  import * as u from "../../lib/utils/utils.js";
15
7
  import * as v from "ak-tools";
16
-
17
- dayjs.extend(utc);
18
- const chance = u.initChance(SEED);
19
8
  /** @typedef {import("../../types").Dungeon} Config */
20
9
 
21
- // Generate consistent warehouse/supplier IDs at module level
22
- const warehouseIds = v.range(1, 80).map(() => `WH_${v.uid(6)}`);
23
- const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
24
-
25
- /**
26
- * ===================================================================
27
- * DATASET OVERVIEW
28
- * ===================================================================
29
- *
30
- * SupplyStack -- a B2B warehouse/inventory management SaaS for
31
- * businesses to track stock levels, purchase orders, suppliers,
32
- * shipments, and quality inspections across multiple warehouses.
33
- *
34
- * - 10,000 users over 120 days
35
- * - Multi-tier system: enterprise (15%), mid-market (35%), small business (40%), trial (10%)
36
- * - Core loop: sign up -> check inventory -> create PO -> receive order -> track shipment
37
- * - Revenue: free_trial / starter ($49) / professional ($199) / enterprise ($599)
38
- *
39
- * Advanced Features:
40
- * - Personas: 4 archetypes (enterprise_ops, mid_market, small_business, trial_explorer)
41
- * - World Events: supply_chain_disruption (day 35), holiday_surge (day 70)
42
- * - Subscription: 4-tier revenue lifecycle with 30-day trial
43
- * - Geo: US/EU/APAC with currency and regional properties
44
- * - Features: predictive_reorder (day 40) and supplier_portal (day 65)
45
- * - Anomalies: extreme stockout spikes, coordinated holiday prep burst
46
- *
47
- * Key entities:
48
- * - warehouse_id: specific warehouse location
49
- * - supplier_id: vendor supplying goods
50
- * - sku_category: product category being managed
51
- * - priority: urgency level for orders and alerts
10
+ // ── OVERVIEW ──
11
+ /*
12
+ * NAME: SupplyStack
13
+ * APP: B2B warehouse/inventory management SaaS for businesses to track
14
+ * stock levels, purchase orders, suppliers, shipments, and quality
15
+ * inspections across multiple warehouses. Multi-tier system with
16
+ * enterprise, mid-market, small business, and trial customers.
17
+ * SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
18
+ * CORE LOOP: account created → inventory checked → purchase order → order received → shipment tracked
19
+ *
20
+ * EVENTS (17):
21
+ * inventory checked (8) > app session (7) > shipment tracked (6) > notification received (6)
22
+ * > stockout alert (5) > purchase order created (5) > order received (4) > report generated (4)
23
+ * > supplier contacted (3) > warehouse transfer (3) > invoice processed (3)
24
+ * > integration connected (2) > alert configured (2) > quality inspection (2)
25
+ * > account created (1) > support ticket (1) > account deactivated (1)
26
+ *
27
+ * FUNNELS (5):
28
+ * - Onboarding: account created → inventory checked → integration connected → report generated (40%)
29
+ * - Order Fulfillment: inventory checked purchase order created → order received → shipment tracked (35%, reentry)
30
+ * - Supplier Management: supplier contacted purchase order created → invoice processed (45%)
31
+ * - Integration Setup: integration connected report generated → alert configured (30%)
32
+ * - Alert Response: stockout alert purchase order created → order received (50%)
33
+ *
34
+ * USER PROPS: company_tier, warehouse_count, employee_count, industry, integration_count, Platform, subscription_plan
35
+ * SUPER PROPS: Platform, subscription_plan
36
+ * SCD PROPS: company_tier (trial/starter/professional/enterprise, monthly fuzzy, max 6)
37
+ * GROUPS: none
52
38
  */
53
39
 
54
- /**
55
- * ===================================================================
56
- * ANALYTICS HOOKS (10 hooks)
57
- *
58
- * Adds 10. ONBOARDING TIME-TO-CONVERT: enterprise 0.71x faster, small_business
59
- * 1.3x slower (funnel-post). Discover via Onboarding funnel median TTC by company_tier.
60
- * NOTE (funnel-post measurement): visible only via Mixpanel funnel median TTC.
61
- * Cross-event MIN→MIN SQL queries on raw events do NOT show this.
62
- * ===================================================================
63
- *
40
+ // ── HOOK STORIES ──
41
+ /*
64
42
  * -------------------------------------------------------------------
65
43
  * 1. MONTH-END REPORTING SURGE (event hook)
66
44
  * -------------------------------------------------------------------
@@ -339,31 +317,246 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
339
317
  * Onboarding TTC | funnel median TTC | 1x | 0.71x | 1.4x faster (enterprise)
340
318
  */
341
319
 
320
+ // ── SCALE ──
321
+ const SEED = "dm4-logistics";
322
+ const NUM_USERS = 10_000;
323
+ const DATASET_START = "2026-01-01T00:00:00Z";
324
+ const DATASET_END = "2026-05-01T23:59:59Z";
325
+ const EVENTS_PER_DAY = 1.2;
326
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
327
+
328
+ const chance = u.initChance(SEED);
329
+
330
+ // ── KNOBS (tweak these to reshape stories) ──
331
+ const MONTH_END_DAY_THRESHOLD = 28;
332
+ const MONTH_END_PAGES_MULT = 2.5;
333
+
334
+ const RUSH_ORDER_COST_MULT = 1.5;
335
+
336
+ const ENTERPRISE_STOCKOUT_DROP_LIKELIHOOD = 10;
337
+
338
+ const INTEGRATION_THRESHOLD = 3;
339
+ const INTEGRATION_REPORT_CLONE_LIKELIHOOD = 65;
340
+
341
+ const ALERT_FATIGUE_THRESHOLD = 30;
342
+ const ALERT_FATIGUE_START_IDX = 20;
343
+ const ALERT_FATIGUE_BASE_MULT = 1.5;
344
+ const ALERT_FATIGUE_RAMP_MULT = 1.5;
345
+
346
+ const TRIAL_CHURN_EVENT_THRESHOLD = 10;
347
+ const TRIAL_CHURN_CUTOFF_DAYS = 7;
348
+ const TRIAL_CHURN_DROP_LIKELIHOOD = 50;
349
+
350
+ const SMB_ALERT_DROP_LIKELIHOOD = 35;
351
+
352
+ const INVENTORY_SWEET_MIN = 5;
353
+ const INVENTORY_SWEET_MAX = 15;
354
+ const INVENTORY_OVER_THRESHOLD = 16;
355
+ const INVENTORY_PO_QUANTITY_BOOST = 1.4;
356
+ const INVENTORY_OVER_PO_DROP_LIKELIHOOD = 60;
357
+
358
+ const TTC_ENTERPRISE_FACTOR = 0.71;
359
+ const TTC_SMB_FACTOR = 1.3;
360
+
361
+ // ── DATA ARRAYS ──
362
+ const warehouseIds = v.range(1, 80).map(() => `WH_${v.uid(6)}`);
363
+ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
364
+
365
+ // ── HELPER FUNCTIONS ──
366
+ function handleUserHooks(record) {
367
+ // H7: ENTERPRISE PROFILES — large warehouse_count + employee_count by tier.
368
+ if (record.company_tier === "enterprise") {
369
+ record.warehouse_count = chance.integer({ min: 5, max: 15 });
370
+ record.employee_count = chance.integer({ min: 200, max: 2000 });
371
+ } else if (record.company_tier === "mid_market") {
372
+ record.warehouse_count = chance.integer({ min: 2, max: 6 });
373
+ record.employee_count = chance.integer({ min: 20, max: 200 });
374
+ } else if (record.company_tier === "small_business") {
375
+ record.warehouse_count = chance.integer({ min: 1, max: 3 });
376
+ record.employee_count = chance.integer({ min: 5, max: 80 });
377
+ } else if (record.company_tier === "trial") {
378
+ record.warehouse_count = 1;
379
+ record.employee_count = chance.integer({ min: 1, max: 10 });
380
+ }
381
+ return record;
382
+ }
383
+
384
+ function handleEventHooks(record) {
385
+ // H2: RUSH ORDER PREMIUM — urgent purchase orders get 1.5x unit_cost.
386
+ if (record.event === "purchase order created" && record.priority === "urgent") {
387
+ record.unit_cost = Math.floor((record.unit_cost || 50) * RUSH_ORDER_COST_MULT);
388
+ }
389
+ return record;
390
+ }
391
+
392
+ function handleFunnelPostHooks(record, meta) {
393
+ // H10: ONBOARDING TIME-TO-CONVERT — enterprise 1.4x faster (0.71);
394
+ // small_business + trial 1.3x slower (1.3).
395
+ const segment = meta?.profile?.company_tier;
396
+ if (Array.isArray(record) && record.length > 1) {
397
+ const factor = (
398
+ segment === "enterprise" ? TTC_ENTERPRISE_FACTOR :
399
+ segment === "small_business" || segment === "trial" ? TTC_SMB_FACTOR :
400
+ 1.0
401
+ );
402
+ if (factor !== 1.0) {
403
+ for (let i = 1; i < record.length; i++) {
404
+ const prev = dayjs(record[i - 1].time);
405
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
406
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
407
+ }
408
+ }
409
+ }
410
+ return record;
411
+ }
412
+
413
+ function handleEverythingHooks(record, meta) {
414
+ if (!record.length) return record;
415
+ const profile = meta.profile;
416
+
417
+ // ── SUPER-PROP STAMPING ──
418
+ // Stamp superProps from profile so they are consistent per-user.
419
+ if (profile) {
420
+ const plat = profile.Platform;
421
+ const plan = profile.subscription_plan;
422
+ record.forEach(e => {
423
+ if (plat) e.Platform = plat;
424
+ if (plan) e.subscription_plan = plan;
425
+ });
426
+ }
427
+
428
+ // H1: MONTH-END REPORTING SURGE — reports on calendar days 28-31 get
429
+ // 2.5x report_pages. Runs in everything (post-sessionization) so the
430
+ // day-of-month tag matches the final timestamp.
431
+ for (const e of record) {
432
+ if (e.event === "report generated") {
433
+ const dayOfMonth = new Date(e.time).getUTCDate();
434
+ if (dayOfMonth >= MONTH_END_DAY_THRESHOLD) {
435
+ e.report_pages = Math.floor((e.report_pages || 20) * MONTH_END_PAGES_MULT);
436
+ }
437
+ }
438
+ }
439
+
440
+ // H8: SMALL-BUSINESS CONVERSION DROP — small_business users lose
441
+ // ~35% of "alert configured" events (last step of Integration Setup
442
+ // funnel), simulating lower conversion without dedicated IT.
443
+ if (profile && profile.company_tier === "small_business") {
444
+ record = record.filter(e => {
445
+ if (e.event === "alert configured" && chance.bool({ likelihood: SMB_ALERT_DROP_LIKELIHOOD })) {
446
+ return false;
447
+ }
448
+ return true;
449
+ });
450
+ }
451
+
452
+ // H3: REORDER ACCURACY BY TIER — enterprise users get 10% of
453
+ // stockout alerts removed (better forecasting + safety stock).
454
+ if (profile && profile.company_tier === "enterprise") {
455
+ for (let i = record.length - 1; i >= 0; i--) {
456
+ if (record[i].event === "stockout alert" && chance.bool({ likelihood: ENTERPRISE_STOCKOUT_DROP_LIKELIHOOD })) {
457
+ record.splice(i, 1);
458
+ }
459
+ }
460
+ }
461
+
462
+ // H4: INTEGRATION COMPLETION DRIVES RETENTION — users with 3+
463
+ // "integration connected" events get cloned "report generated" events.
464
+ const integrationCount = record.filter(e => e.event === "integration connected").length;
465
+ if (integrationCount >= INTEGRATION_THRESHOLD) {
466
+ const templateReport = record.find(e => e.event === "report generated");
467
+ if (templateReport) {
468
+ const integrationEvents = record.filter(e => e.event === "integration connected");
469
+ integrationEvents.forEach(ie => {
470
+ if (chance.bool({ likelihood: INTEGRATION_REPORT_CLONE_LIKELIHOOD })) {
471
+ record.push({
472
+ ...templateReport,
473
+ time: dayjs(ie.time).add(chance.integer({ min: 1, max: 5 }), "days").toISOString(),
474
+ user_id: ie.user_id,
475
+ report_type: "integration_summary",
476
+ report_pages: chance.integer({ min: 5, max: 25 }),
477
+ });
478
+ }
479
+ });
480
+ }
481
+ }
482
+
483
+ // H5: ALERT FATIGUE — users with >30 stockout alerts get increasing
484
+ // response_time_hours starting at the 20th alert.
485
+ const alertEvents = record.filter(e => e.event === "stockout alert");
486
+ if (alertEvents.length > ALERT_FATIGUE_THRESHOLD) {
487
+ alertEvents.forEach((alert, idx) => {
488
+ if (idx >= ALERT_FATIGUE_START_IDX) {
489
+ const fatigueMultiplier = ALERT_FATIGUE_BASE_MULT + ((idx - ALERT_FATIGUE_START_IDX) / alertEvents.length) * ALERT_FATIGUE_RAMP_MULT;
490
+ alert.response_time_hours = Math.floor((alert.response_time_hours || 4) * fatigueMultiplier);
491
+ }
492
+ });
493
+ }
494
+
495
+ // H6: TRIAL CHURN — users with <10 total events lose 50% after day 7.
496
+ if (record.length < TRIAL_CHURN_EVENT_THRESHOLD) {
497
+ const userFirstEvent = record[0];
498
+ if (userFirstEvent) {
499
+ const firstTime = dayjs(userFirstEvent.time);
500
+ const cutoff = firstTime.add(TRIAL_CHURN_CUTOFF_DAYS, "days");
501
+ for (let i = record.length - 1; i >= 0; i--) {
502
+ if (dayjs(record[i].time).isAfter(cutoff) && chance.bool({ likelihood: TRIAL_CHURN_DROP_LIKELIHOOD })) {
503
+ record.splice(i, 1);
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ // H9: INVENTORY-CHECK MAGIC NUMBER (no flags) — sweet 5-15 inventory
510
+ // checks → +25% PO quantity (1.4x to overcome dilution); over 16+ →
511
+ // drop 60% of PO created events.
512
+ const invCheckCount = record.filter(e => e.event === "inventory checked").length;
513
+ if (invCheckCount >= INVENTORY_SWEET_MIN && invCheckCount <= INVENTORY_SWEET_MAX) {
514
+ record.forEach(e => {
515
+ if (e.event === "purchase order created" && typeof e.quantity === "number") {
516
+ e.quantity = Math.round(e.quantity * INVENTORY_PO_QUANTITY_BOOST);
517
+ }
518
+ });
519
+ } else if (invCheckCount >= INVENTORY_OVER_THRESHOLD) {
520
+ for (let i = record.length - 1; i >= 0; i--) {
521
+ if (record[i].event === "purchase order created" && chance.bool({ likelihood: INVENTORY_OVER_PO_DROP_LIKELIHOOD })) {
522
+ record.splice(i, 1);
523
+ }
524
+ }
525
+ }
526
+
527
+ return record;
528
+ }
529
+
530
+ // ── CONFIG ──
342
531
  /** @type {Config} */
343
532
  const config = {
344
533
  version: 2,
345
- token,
346
534
  seed: SEED,
347
- datasetStart: "2026-01-01T00:00:00Z",
348
- datasetEnd: "2026-05-01T23:59:59Z",
349
- // numDays: num_days,
350
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
351
- numUsers: num_users,
352
- hasAnonIds: true,
353
- avgDevicePerUser: 2,
354
- hasSessionIds: true,
535
+ datasetStart: DATASET_START,
536
+ datasetEnd: DATASET_END,
537
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
538
+ numUsers: NUM_USERS,
355
539
  format: "json",
356
540
  gzip: true,
357
- alsoInferFunnels: false,
358
- hasLocation: true,
359
- hasAndroidDevices: false,
360
- hasIOSDevices: false,
361
- hasDesktopDevices: true,
362
- hasBrowser: true,
363
- hasCampaigns: false,
364
- isAnonymous: false,
365
- hasAdSpend: false,
366
- hasAvatar: true,
541
+ credentials: {
542
+ token,
543
+ },
544
+ switches: {
545
+ hasSessionIds: true,
546
+ alsoInferFunnels: false,
547
+ hasLocation: true,
548
+ hasAndroidDevices: false,
549
+ hasIOSDevices: false,
550
+ hasDesktopDevices: true,
551
+ hasBrowser: true,
552
+ hasCampaigns: false,
553
+ isAnonymous: false,
554
+ hasAdSpend: false,
555
+ hasAvatar: true,
556
+ },
557
+ identity: {
558
+ avgDevicePerUser: 2,
559
+ },
367
560
  concurrency: 1,
368
561
  writeToDisk: false,
369
562
  scdProps: {
@@ -781,185 +974,11 @@ const config = {
781
974
  },
782
975
  ],
783
976
 
784
- // -- Hook Function ------------------------------------------------
785
- hook: function (record, type, meta) {
786
- // HOOK 10 (T2C): ONBOARDING TIME-TO-CONVERT (funnel-post)
787
- // Enterprise tier completes Onboarding funnel 1.4x faster (factor 0.71);
788
- // small_business 1.3x slower (factor 1.3).
789
- if (type === "funnel-post") {
790
- const segment = meta?.profile?.company_tier;
791
- if (Array.isArray(record) && record.length > 1) {
792
- const factor = (
793
- segment === "enterprise" ? 0.71 :
794
- segment === "small_business" || segment === "trial" ? 1.3 :
795
- 1.0
796
- );
797
- if (factor !== 1.0) {
798
- for (let i = 1; i < record.length; i++) {
799
- const prev = dayjs(record[i - 1].time);
800
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
801
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
802
- }
803
- }
804
- }
805
- }
806
-
807
- // -- HOOK 7: ENTERPRISE PROFILES (user) -----------------------
808
- // Enterprise ops get large warehouse_count and employee_count.
809
- if (type === "user") {
810
- if (record.company_tier === "enterprise") {
811
- record.warehouse_count = chance.integer({ min: 5, max: 15 });
812
- record.employee_count = chance.integer({ min: 200, max: 2000 });
813
- } else if (record.company_tier === "mid_market") {
814
- record.warehouse_count = chance.integer({ min: 2, max: 6 });
815
- record.employee_count = chance.integer({ min: 20, max: 200 });
816
- } else if (record.company_tier === "small_business") {
817
- record.warehouse_count = chance.integer({ min: 1, max: 3 });
818
- record.employee_count = chance.integer({ min: 5, max: 80 });
819
- } else if (record.company_tier === "trial") {
820
- record.warehouse_count = 1;
821
- record.employee_count = chance.integer({ min: 1, max: 10 });
822
- }
823
- }
824
-
825
- // -- HOOK 8: ENTERPRISE INTEGRATION FUNNEL LIFT (funnel-pre) --
826
- // (conversionRate boost removed — filtering applied in everything hook instead)
827
- if (type === "funnel-pre") {
828
- // no-op: conversion differentiation handled via event filtering below
829
- }
830
-
831
- // -- HOOK 1: MONTH-END REPORTING SURGE ------------------------
832
- // Moved to everything hook (after sessionization) so day-of-month
833
- // tags match final timestamps. See everything hook below.
834
-
835
- // -- HOOK 2: RUSH ORDER PREMIUM (event) -------------------
836
- // Urgent purchase orders get 1.5x unit_cost.
837
- if (type === "event") {
838
- if (record.event === "purchase order created" && record.priority === "urgent") {
839
- record.unit_cost = Math.floor((record.unit_cost || 50) * 1.5);
840
- }
841
- }
842
-
843
- // -- EVERYTHING HOOKS -----------------------------------------
844
- if (type === "everything") {
845
- if (!record.length) return record;
846
- const profile = meta.profile;
847
-
848
- // -- SUPER-PROP STAMPING ----------------------------------
849
- // Stamp superProps from profile so they are consistent per-user.
850
- if (profile) {
851
- const plat = profile.Platform;
852
- const plan = profile.subscription_plan;
853
- record.forEach(e => {
854
- if (plat) e.Platform = plat;
855
- if (plan) e.subscription_plan = plan;
856
- });
857
- }
858
-
859
- // -- HOOK 1: MONTH-END REPORTING SURGE --------------------
860
- // Reports on days 28-31 get 2x report_pages.
861
- // Runs after sessionization so day-of-month matches final timestamps.
862
- for (const e of record) {
863
- if (e.event === 'report generated') {
864
- const dayOfMonth = new Date(e.time).getUTCDate();
865
- if (dayOfMonth >= 28) {
866
- e.report_pages = Math.floor((e.report_pages || 20) * 2.5);
867
- }
868
- }
869
- }
870
-
871
- // -- HOOK 8: SMALL-BUSINESS CONVERSION DROP ---------------
872
- // Small-business users lose ~35% of "alert configured" events
873
- // (last step of Integration Setup funnel), simulating lower
874
- // conversion for smaller teams without dedicated IT resources.
875
- if (profile && profile.company_tier === "small_business") {
876
- record = record.filter(e => {
877
- if (e.event === "alert configured" && chance.bool({ likelihood: 35 })) {
878
- return false;
879
- }
880
- return true;
881
- });
882
- }
883
-
884
- // -- HOOK 3: REORDER ACCURACY BY TIER ---------------------
885
- // Enterprise users get 10% of stockout alerts removed.
886
- if (profile && profile.company_tier === "enterprise") {
887
- for (let i = record.length - 1; i >= 0; i--) {
888
- if (record[i].event === "stockout alert" && chance.bool({ likelihood: 10 })) {
889
- record.splice(i, 1);
890
- }
891
- }
892
- }
893
-
894
- // -- HOOK 4: INTEGRATION COMPLETION DRIVES RETENTION ------
895
- // Users with 3+ integration events get cloned report events.
896
- const integrationCount = record.filter(e => e.event === "integration connected").length;
897
- if (integrationCount >= 3) {
898
- const templateReport = record.find(e => e.event === "report generated");
899
- if (templateReport) {
900
- const integrationEvents = record.filter(e => e.event === "integration connected");
901
- integrationEvents.forEach(ie => {
902
- if (chance.bool({ likelihood: 65 })) {
903
- record.push({
904
- ...templateReport,
905
- time: dayjs(ie.time).add(chance.integer({ min: 1, max: 5 }), "days").toISOString(),
906
- user_id: ie.user_id,
907
- report_type: "integration_summary",
908
- report_pages: chance.integer({ min: 5, max: 25 }),
909
- });
910
- }
911
- });
912
- }
913
- }
914
-
915
- // -- HOOK 5: ALERT FATIGUE --------------------------------
916
- // Users with >30 stockout alerts get increasing response times.
917
- const alertEvents = record.filter(e => e.event === "stockout alert");
918
- if (alertEvents.length > 30) {
919
- alertEvents.forEach((alert, idx) => {
920
- if (idx >= 20) {
921
- const fatigueMultiplier = 1.5 + ((idx - 20) / alertEvents.length) * 1.5;
922
- alert.response_time_hours = Math.floor((alert.response_time_hours || 4) * fatigueMultiplier);
923
- }
924
- });
925
- }
926
-
927
- // -- HOOK 6: TRIAL CHURN ----------------------------------
928
- // Users with <10 events lose 50% after day 7.
929
- if (record.length < 10) {
930
- const userFirstEvent = record[0];
931
- if (userFirstEvent) {
932
- const firstTime = dayjs(userFirstEvent.time);
933
- const cutoff = firstTime.add(7, "days");
934
- for (let i = record.length - 1; i >= 0; i--) {
935
- if (dayjs(record[i].time).isAfter(cutoff) && chance.bool({ likelihood: 50 })) {
936
- record.splice(i, 1);
937
- }
938
- }
939
- }
940
- }
941
-
942
- // -- HOOK 9: INVENTORY-CHECK MAGIC NUMBER (no flags) ------
943
- // Sweet 5-15 inventory checks → +25% PO quantity.
944
- // Over 16+ → drop 60% of PO created events (high rate to overcome persona event multiplier dilution).
945
- const invCheckCount = record.filter(e => e.event === 'inventory checked').length;
946
- if (invCheckCount >= 5 && invCheckCount <= 15) {
947
- record.forEach(e => {
948
- if (e.event === 'purchase order created' && typeof e.quantity === 'number') {
949
- e.quantity = Math.round(e.quantity * 1.4);
950
- }
951
- });
952
- } else if (invCheckCount >= 16) {
953
- for (let i = record.length - 1; i >= 0; i--) {
954
- if (record[i].event === 'purchase order created' && chance.bool({ likelihood: 60 })) {
955
- record.splice(i, 1);
956
- }
957
- }
958
- }
959
-
960
- return record;
961
- }
962
-
977
+ hook(record, type, meta) {
978
+ if (type === "user") return handleUserHooks(record);
979
+ if (type === "event") return handleEventHooks(record);
980
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
981
+ if (type === "everything") return handleEverythingHooks(record, meta);
963
982
  return record;
964
983
  },
965
984
  };