@ak--47/dungeon-master 1.4.2 → 1.4.4

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.
@@ -12,6 +12,7 @@ import dayjs from "dayjs";
12
12
  import utc from "dayjs/plugin/utc.js";
13
13
  import "dotenv/config";
14
14
  import * as u from "../../lib/utils/utils.js";
15
+ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
15
16
 
16
17
  dayjs.extend(utc);
17
18
  const chance = u.initChance(SEED);
@@ -283,10 +284,13 @@ const chance = u.initChance(SEED);
283
284
  * - By credit_score_range: loan approvals, tier adoption
284
285
  *
285
286
  * ---------------------------------------------------------------
286
- * 9. ONBOARDING TIME-TO-CONVERT (funnel-post)
287
+ * 9. ONBOARDING TIME-TO-CONVERT (everything)
287
288
  *
288
289
  * PATTERN: Premium tier users complete the Onboarding funnel 1.5x
289
290
  * faster (factor 0.67); Basic users 1.33x slower (factor 1.33).
291
+ * Applied in the everything hook via findFirstSequence + scaleFunnelTTC,
292
+ * so the effect is visible in both Mixpanel funnels and cross-event
293
+ * MIN→MIN SQL queries.
290
294
  *
291
295
  * HOW TO FIND IT IN MIXPANEL:
292
296
  *
@@ -296,11 +300,6 @@ const chance = u.initChance(SEED);
296
300
  * - Breakdown: account_tier
297
301
  * - Expected: premium ~ 0.67x baseline; basic ~ 1.33x
298
302
  *
299
- * NOTE (funnel-post measurement): visible only via Mixpanel funnel
300
- * median TTC. Cross-event MIN→MIN SQL queries on raw events do NOT
301
- * show this — funnel-post adjusts gaps within funnel instances, not
302
- * across the user's full event history.
303
- *
304
303
  * ---------------------------------------------------------------
305
304
  * 10. TRANSACTION-COUNT MAGIC NUMBER (everything)
306
305
  *
@@ -341,7 +340,7 @@ const chance = u.initChance(SEED);
341
340
  * Auto-Pay Loyalty | Bill completion rate | 100% | 70% | -30%
342
341
  * Premium Tier Value | Reward value (Premium)| 1x | 3x | 3x
343
342
  * Month-End Anxiety | Session duration d28+ | 1x | 1.4x | 1.4x
344
- * Onboarding T2C | median min by tier | 1x | 0.67/1.33x| 2x range
343
+ * Onboarding T2C (H9) | median min by tier | 1x | 0.67/1.33x| 2x range
345
344
  * Txn-Count Magic Num | sweet investment amt | 1x | 1.4x | 1.4x
346
345
  * Txn-Count Magic Num | over premium upgrades | 1x | 0.8x | -20%
347
346
  */
@@ -690,27 +689,6 @@ const config = {
690
689
  }
691
690
  }
692
691
 
693
- // HOOK 9 (T2C): ONBOARDING TIME-TO-CONVERT (funnel-post)
694
- // Premium tier completes Onboarding funnel 1.5x faster (factor 0.67);
695
- // Basic users 1.33x slower (factor 1.33).
696
- if (type === "funnel-post") {
697
- const segment = meta?.profile?.account_tier;
698
- if (Array.isArray(record) && record.length > 1) {
699
- const factor = (
700
- segment === "premium" ? 0.67 :
701
- segment === "basic" ? 1.33 :
702
- 1.0
703
- );
704
- if (factor !== 1.0) {
705
- for (let i = 1; i < record.length; i++) {
706
- const prev = dayjs(record[i - 1].time);
707
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
708
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
709
- }
710
- }
711
- }
712
- }
713
-
714
692
  if (type === "everything") {
715
693
  const datasetStart = dayjs.unix(meta.datasetStart);
716
694
  const userEvents = record;
@@ -721,6 +699,28 @@ const config = {
721
699
  e.Platform = profile.Platform;
722
700
  });
723
701
 
702
+ // HOOK 9 (T2C): ONBOARDING TIME-TO-CONVERT (everything)
703
+ // Premium tier completes Onboarding funnel 1.5x faster (factor 0.67);
704
+ // Basic users 1.33x slower (factor 1.33). Finds the first occurrence
705
+ // of the onboarding sequence in the user's events and scales the gaps.
706
+ {
707
+ const ttcFactor = (
708
+ profile.account_tier === "premium" ? 0.67 :
709
+ profile.account_tier === "basic" ? 1.33 :
710
+ 1.0
711
+ );
712
+ if (ttcFactor !== 1.0) {
713
+ const onboardingSeq = findFirstSequence(
714
+ userEvents,
715
+ ["account opened", "app session", "balance checked"],
716
+ 60 * 24 * 30 // 30-day max gap between steps
717
+ );
718
+ if (onboardingSeq) {
719
+ scaleFunnelTTC(onboardingSeq, ttcFactor);
720
+ }
721
+ }
722
+ }
723
+
724
724
  // HOOK 1B: PERSONAL VS BUSINESS — business segment txns 4x larger
725
725
  // (per Report 2 in JSDoc: business ~ $200, personal ~ $50).
726
726
  if (profile.account_segment === "business") {
@@ -103,9 +103,11 @@ const chance = u.initChance(SEED);
103
103
  * 3. STREAK RETENTION (everything hook)
104
104
  * ───────────────────────────────────────────────────────────────
105
105
  *
106
- * PATTERN: Users with >10 workout events get streak_days set
106
+ * PATTERN: Users with >=2 workout events get streak_days set
107
107
  * to their actual workout count on their profile, and receive
108
- * cloned "achievement unlocked" events for milestone streaks.
108
+ * achievement clones with super-linear scaling: 1 per workout
109
+ * for workouts 2-4, then 4 per workout beyond that. This
110
+ * amplifies the gap so athlete/casual ratio reaches 2x+.
109
111
  *
110
112
  * HOW TO FIND IT IN MIXPANEL:
111
113
  *
@@ -264,25 +266,27 @@ const chance = u.initChance(SEED);
264
266
  * 10. WORKOUT-COUNT MAGIC NUMBER (everything)
265
267
  * ───────────────────────────────────────────────────────────────
266
268
  *
267
- * PATTERN: Sweet 12-20 workouts/user → +35% on workout
268
- * duration_minutes (peak progression). Over 21+ → drop 30% of
269
- * post-day-30 events (overtraining churn). No flag.
269
+ * PATTERN: Sweet 12-14 workouts/user → +35% on workout
270
+ * duration_minutes (peak progression). Over 15+ → drop 65% of
271
+ * post-day-30 non-workout, non-progress events (overtraining
272
+ * churn). Preserves workout + progress events so H8 funnel
273
+ * lift isn't diluted. No flag.
270
274
  *
271
275
  * HOW TO FIND IT IN MIXPANEL:
272
276
  *
273
277
  * Report 1: Avg Workout Duration by Workout-Count Bucket
274
- * - Cohort A: users with 12-20 "workout completed"
278
+ * - Cohort A: users with 12-14 "workout completed"
275
279
  * - Cohort B: users with 0-11
276
280
  * - Event: "workout completed"
277
281
  * - Measure: Average of "duration_minutes"
278
282
  * - Expected: A ~ 1.35x B
279
283
  *
280
284
  * Report 2: D30+ Activity on Heavy Workout Cohort
281
- * - Cohort C: users with >= 21 "workout completed"
282
- * - Cohort A: users with 12-20
285
+ * - Cohort C: users with >= 15 "workout completed"
286
+ * - Cohort A: users with 12-14
283
287
  * - Event: any event
284
- * - Measure: Total per user, post-day-30
285
- * - Expected: C ~ 30% fewer post-day-30 events per user
288
+ * - Measure: post-d30/pre-d30 ratio per user
289
+ * - Expected: C ~ 70% lower post/pre ratio than A (overtraining churn)
286
290
  *
287
291
  * REAL-WORLD ANALOGUE: Sweet-spot training drives progression;
288
292
  * over-training causes injury and burnout.
@@ -303,7 +307,7 @@ const chance = u.initChance(SEED);
303
307
  * Annual Funnel Lift | funnel conversion | 45% | 63% | 1.4x
304
308
  * Workout Loop T2C | median min by tier | 1x | 0.77/1.25x| ~ 1.6x range
305
309
  * Workout Magic Number | sweet duration_min | 1x | 1.35x | 1.35x
306
- * Workout Magic Number | over D30+ activity | 1x | 0.7x | -30%
310
+ * Workout Magic Number | over D30+ post/pre | 1x | 0.29x | -71%
307
311
  */
308
312
 
309
313
  /** @type {Config} */
@@ -766,31 +770,38 @@ const config = {
766
770
  }
767
771
 
768
772
  // ── HOOK 3: STREAK RETENTION ─────────────────────
769
- // Users with >10 workouts get streak_days updated and
770
- // cloned achievement events for milestones.
773
+ // Users with >=2 workouts get streak_days updated and
774
+ // cloned achievement events. Achievements scale super-
775
+ // linearly: 1 per workout for workouts 2-4, then 4 per
776
+ // workout beyond that. This amplifies the gap between
777
+ // high-workout segments (athlete/coach) and casual users.
771
778
  const workoutEvents = events.filter(e => e.event === "workout completed");
772
- if (workoutEvents.length > 10) {
779
+ if (workoutEvents.length >= 2) {
773
780
  // Update profile streak_days via a profile update event
774
781
  if (meta && meta.profile) {
775
782
  meta.profile.streak_days = workoutEvents.length;
776
783
  }
777
784
 
778
- // Clone achievement events for streak milestones
785
+ // Super-linear achievement scaling:
786
+ // workouts 2-4: 1 achievement each
787
+ // workouts 5+: 4 achievements each
779
788
  const templateAchievement = events.find(e => e.event === "achievement unlocked");
780
789
  if (templateAchievement) {
781
- const milestones = [10, 25, 50, 75, 100];
782
- milestones.forEach(m => {
783
- if (workoutEvents.length >= m) {
784
- const sourceEvent = workoutEvents[Math.min(m - 1, workoutEvents.length - 1)];
785
- events.push({
786
- ...templateAchievement,
787
- time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 30 }), "minutes").toISOString(),
788
- user_id: sourceEvent.user_id,
789
- achievement_type: "streak_milestone",
790
- streak_days_at_unlock: m,
791
- });
792
- }
793
- });
790
+ let achievementCount = Math.min(workoutEvents.length - 1, 3); // 1 each for workouts 2-4
791
+ if (workoutEvents.length > 4) {
792
+ achievementCount += (workoutEvents.length - 4) * 4; // 4 each for workouts 5+
793
+ }
794
+ for (let a = 0; a < achievementCount; a++) {
795
+ const srcIdx = Math.min(a, workoutEvents.length - 1);
796
+ const sourceEvent = workoutEvents[srcIdx];
797
+ events.push({
798
+ ...templateAchievement,
799
+ time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
800
+ user_id: sourceEvent.user_id,
801
+ achievement_type: "streak_milestone",
802
+ streak_days_at_unlock: a + 2,
803
+ });
804
+ }
794
805
  }
795
806
  }
796
807
 
@@ -838,20 +849,23 @@ const config = {
838
849
  }
839
850
 
840
851
  // HOOK 10: WORKOUT-COUNT MAGIC NUMBER (no flags)
841
- // Sweet 12-20 workouts → +35% on workout duration_minutes (peak
842
- // progression). Over 21+ → drop 30% of post-day-30 events
843
- // (overtraining → churn).
852
+ // Sweet 12-14 workouts → +35% on workout duration_minutes (peak
853
+ // progression). Over 15+ → drop 65% of post-day-30 non-workout
854
+ // events (overtraining → churn). Workout events are preserved
855
+ // so the bucket categorization stays consistent.
844
856
  const workoutCount = events.filter(e => e.event === "workout completed").length;
845
- if (workoutCount >= 12 && workoutCount <= 20) {
857
+ if (workoutCount >= 12 && workoutCount <= 14) {
846
858
  events.forEach(e => {
847
859
  if (e.event === "workout completed" && typeof e.duration_minutes === "number") {
848
860
  e.duration_minutes = Math.round(e.duration_minutes * 1.35);
849
861
  }
850
862
  });
851
- } else if (workoutCount >= 21) {
863
+ } else if (workoutCount >= 15) {
852
864
  const day30 = datasetStart.add(30, "days");
865
+ const preserveEvents = new Set(["workout completed", "progress checked"]);
853
866
  for (let i = events.length - 1; i >= 0; i--) {
854
- if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 30 })) {
867
+ if (!preserveEvents.has(events[i].event) &&
868
+ dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 65 })) {
855
869
  events.splice(i, 1);
856
870
  }
857
871
  }
@@ -13,6 +13,7 @@ import utc from "dayjs/plugin/utc.js";
13
13
  import "dotenv/config";
14
14
  import * as u from "../../lib/utils/utils.js";
15
15
  import * as v from "ak-tools";
16
+ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
16
17
 
17
18
  dayjs.extend(utc);
18
19
  const chance = u.initChance(SEED);
@@ -49,12 +50,11 @@ const chance = u.initChance(SEED);
49
50
 
50
51
  /*
51
52
  * ═══════════════════════════════════════════════════════════════════════════════
52
- * ANALYTICS HOOKS (9 hooks)
53
+ * ANALYTICS HOOKS (10 hooks)
53
54
  *
54
- * Adds 9. ORDER LIFECYCLE TIME-TO-CONVERT: QuickBite+ 0.74x faster, Free 1.3x
55
- * slower (funnel-post). Discover via order funnel median TTC by subscription_tier.
56
- * NOTE (funnel-post measurement): visible only via Mixpanel funnel median TTC.
57
- * Cross-event MIN→MIN SQL queries on raw events do NOT show this.
55
+ * Adds 9. ORDER LIFECYCLE TIME-TO-CONVERT: QuickBite+ 0.67x delivery times,
56
+ * Free 1.4x slower (everything hook, property scaling). Discover via
57
+ * avg(actual_delivery_mins) on "order delivered" by subscription_tier.
58
58
  * ═══════════════════════════════════════════════════════════════════════════════
59
59
  *
60
60
  * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable only via
@@ -212,8 +212,8 @@ const chance = u.initChance(SEED);
212
212
  * ───────────────────────────────────────────────────────────────────────────────
213
213
  *
214
214
  * PATTERN: Users in the 4-8 order-placed sweet spot get +40% on order_total.
215
- * Users with 9+ orders are over-engaged; 35% of their order-placed events
216
- * drop. No flag — discover by binning users on order count.
215
+ * Users with 9+ orders are over-engaged; their order_total is reduced to
216
+ * 0.65x (basket fatigue). No flag — discover by binning users on order count.
217
217
  *
218
218
  * HOW TO FIND IT IN MIXPANEL:
219
219
  *
@@ -225,18 +225,66 @@ const chance = u.initChance(SEED);
225
225
  * - Measure: Average of "order_total"
226
226
  * - Expected: A ~ 1.4x B
227
227
  *
228
- * Report 2: Orders per User on Heavy Orderers
228
+ * Report 2: Avg Order Total on Heavy Orderers
229
229
  * - Report type: Insights (with cohort)
230
230
  * - Cohort C: users with >= 9 "order placed"
231
231
  * - Cohort A: users with 4-8
232
232
  * - Event: "order placed"
233
- * - Measure: Total per user
234
- * - Expected: C ~ 35% fewer orders per user vs A
233
+ * - Measure: Average of "order_total"
234
+ * - Expected: C ~ 0.65x order_total vs A (basket fatigue)
235
235
  *
236
236
  * REAL-WORLD ANALOGUE: Engaged orderers lift basket size; over-orderers
237
237
  * hit fatigue and slow down.
238
238
  *
239
239
  * ───────────────────────────────────────────────────────────────────────────────
240
+ * 9. ORDER LIFECYCLE TTC (everything)
241
+ * ───────────────────────────────────────────────────────────────────────────────
242
+ *
243
+ * PATTERN: QuickBite+ users get delivery timing properties scaled 0.67x
244
+ * (faster), Free users get 1.4x (slower). Affects actual_delivery_mins,
245
+ * eta_mins, delivery_time_est_mins. No flag — discover via property avg
246
+ * breakdown by subscription_tier.
247
+ *
248
+ * HOW TO FIND IT IN MIXPANEL:
249
+ *
250
+ * Report 1: Avg Delivery Time by Subscription Tier
251
+ * - Report type: Insights
252
+ * - Event: "order delivered"
253
+ * - Measure: Average of "actual_delivery_mins"
254
+ * - Breakdown: "subscription_tier"
255
+ * - Expected: QuickBite+ ~ 0.67x Free
256
+ *
257
+ * Report 2: Avg ETA by Subscription Tier
258
+ * - Report type: Insights
259
+ * - Event: "order tracked"
260
+ * - Measure: Average of "eta_mins"
261
+ * - Breakdown: "subscription_tier"
262
+ * - Expected: QuickBite+ ~ 0.67x Free
263
+ *
264
+ * REAL-WORLD ANALOGUE: Premium subscribers get priority dispatch and faster
265
+ * delivery routing.
266
+ *
267
+ * ───────────────────────────────────────────────────────────────────────────────
268
+ * 10. CITY DENSITY REORDER BOOST (funnel-pre)
269
+ * ───────────────────────────────────────────────────────────────────────────────
270
+ *
271
+ * PATTERN: On the reorder funnel (order delivered → order rated → reorder
272
+ * initiated), dense cities (SF, NYC) convert at 1.4x; sprawl cities
273
+ * (Houston, Phoenix) at 0.7x. Scoped to the funnel containing
274
+ * "reorder initiated".
275
+ *
276
+ * HOW TO FIND IT IN MIXPANEL:
277
+ *
278
+ * Report 1: Reorder Funnel Conversion by City
279
+ * - Report type: Funnels
280
+ * - Steps: "order delivered" → "order rated" → "reorder initiated"
281
+ * - Breakdown: "city"
282
+ * - Expected: SF / NYC ~ 1.4x baseline; Houston / Phoenix ~ 0.7x
283
+ *
284
+ * REAL-WORLD ANALOGUE: Dense cities have more restaurant choice and
285
+ * faster delivery, driving higher repeat ordering behavior.
286
+ *
287
+ * ───────────────────────────────────────────────────────────────────────────────
240
288
  * EXPECTED METRICS SUMMARY
241
289
  * ───────────────────────────────────────────────────────────────────────────────
242
290
  *
@@ -250,7 +298,11 @@ const chance = u.initChance(SEED);
250
298
  * Trial Conversion | post-day-14 activity | 1x | ~ 0.4x | -60%
251
299
  * First Order Bonus | returning conversion | 1x | 0.7x | -30%
252
300
  * Order-Count Magic Num | sweet order_total | 1x | 1.4x | 1.4x
253
- * Order-Count Magic Num | over orders/user | 1x | 0.65x | -35%
301
+ * Order-Count Magic Num | over order_total | 1x | 0.65x | -35%
302
+ * Order Lifecycle TTC | QB+ delivery_mins | 1x | 0.67x | -33%
303
+ * Order Lifecycle TTC | Free delivery_mins | 1x | 1.4x | +40%
304
+ * City Density Reorder | SF/NYC reorder conv | 1x | 1.4x | 1.4x
305
+ * City Density Reorder | HOU/PHX reorder conv | 1x | 0.7x | -30%
254
306
  */
255
307
 
256
308
  // Generate consistent IDs for lookup tables and event properties
@@ -593,23 +645,17 @@ const config = {
593
645
  lookupTables: [],
594
646
 
595
647
  hook: function (record, type, meta) {
596
- // HOOK 9 (T2C): ORDER LIFECYCLE TIME-TO-CONVERT (funnel-post)
597
- // QuickBite+ subscribers complete order funnels 1.35x faster
598
- // (factor 0.74); Free users 1.3x slower (factor 1.3).
599
- if (type === "funnel-post") {
600
- const segment = meta?.profile?.subscription_tier;
601
- if (Array.isArray(record) && record.length > 1) {
602
- const factor = (
603
- segment === "QuickBite+" ? 0.74 :
604
- segment === "Free" ? 1.3 :
605
- 1.0
606
- );
607
- if (factor !== 1.0) {
608
- for (let i = 1; i < record.length; i++) {
609
- const prev = dayjs(record[i - 1].time);
610
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
611
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
612
- }
648
+ // HOOK 10: CITY DENSITY REORDER BOOST (funnel-pre)
649
+ // Dense cities (SF, NYC) convert 1.4x on the reorder funnel;
650
+ // sprawl cities (Houston, Phoenix) at 0.7x.
651
+ if (type === "funnel-pre") {
652
+ const isReorderFunnel = meta.funnel?.sequence?.includes("reorder initiated");
653
+ if (isReorderFunnel) {
654
+ const city = meta.profile?.city;
655
+ if (city === "San Francisco" || city === "New York") {
656
+ record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.4));
657
+ } else if (city === "Houston" || city === "Phoenix") {
658
+ record.conversionRate = Math.round(record.conversionRate * 0.7);
613
659
  }
614
660
  }
615
661
  }
@@ -630,6 +676,42 @@ const config = {
630
676
  });
631
677
  }
632
678
 
679
+ // HOOK 9 (TTC): ORDER LIFECYCLE TIME-TO-CONVERT (everything)
680
+ // QuickBite+ users experience faster delivery times (0.67x);
681
+ // Free users experience slower delivery times (1.4x).
682
+ // Scales timing properties: actual_delivery_mins, eta_mins,
683
+ // delivery_time_est_mins. Discover via avg(actual_delivery_mins)
684
+ // on "order delivered" broken down by subscription_tier.
685
+ if (profile) {
686
+ const tier = profile.subscription_tier;
687
+ const ttcFactor = (
688
+ tier === "QuickBite+" ? 0.67 :
689
+ tier === "Free" ? 1.4 :
690
+ 1.0
691
+ );
692
+ if (ttcFactor !== 1.0) {
693
+ // Timestamp shift: affects Mixpanel funnel TTC
694
+ const orderSeq = findFirstSequence(
695
+ userEvents,
696
+ ["checkout started", "order placed", "order tracked", "order delivered"],
697
+ 60 * 24 * 7
698
+ );
699
+ if (orderSeq) scaleFunnelTTC(orderSeq, ttcFactor);
700
+ // Property scale: affects Insights AVG reports
701
+ userEvents.forEach(e => {
702
+ if (typeof e.actual_delivery_mins === "number") {
703
+ e.actual_delivery_mins = Math.round(e.actual_delivery_mins * ttcFactor);
704
+ }
705
+ if (typeof e.eta_mins === "number") {
706
+ e.eta_mins = Math.round(e.eta_mins * ttcFactor);
707
+ }
708
+ if (typeof e.delivery_time_est_mins === "number") {
709
+ e.delivery_time_est_mins = Math.round(e.delivery_time_est_mins * ttcFactor);
710
+ }
711
+ });
712
+ }
713
+ }
714
+
633
715
  // HOOK 3: LATE NIGHT MUNCHIES — 10PM-2AM UTC, 70% of restaurant
634
716
  // views/cart additions get cuisine flipped to American, 1.3x
635
717
  // item_price. Mutates existing props. No flag — discover via
@@ -694,7 +776,7 @@ const config = {
694
776
  }
695
777
  }
696
778
 
697
- // HOOK 8: FIRST ORDER BONUS — hash-based ~50% of users (returning)
779
+ // HOOK 7: FIRST ORDER BONUS — hash-based ~50% of users (returning)
698
780
  // drop 30% of order delivered events. New users keep all.
699
781
  // Discover via cohort builder on hash bucket vs conversion.
700
782
  const hashUser = userEvents[0] && userEvents[0].user_id;
@@ -782,7 +864,7 @@ const config = {
782
864
  });
783
865
  if (rainyDuplicates.length > 0) userEvents.push(...rainyDuplicates);
784
866
 
785
- // HOOK 9: ORDER-COUNT MAGIC NUMBER (no flags)
867
+ // HOOK 8: ORDER-COUNT MAGIC NUMBER (no flags)
786
868
  // Sweet 4-8 orders → +40% on order_total. Over 9+ → drop 35% of
787
869
  // order placed events (oversaturated; analyst sees inverted-U).
788
870
  if (orderPlacedCount >= 4 && orderPlacedCount <= 8) {
@@ -172,8 +172,8 @@ const chance = u.initChance(SEED);
172
172
  * 9. GOLD REWARD BY LEVEL (PROGRESSION SCALING — everything)
173
173
  *
174
174
  * PATTERN: Quest gold reward scales with player level using
175
- * reward_gold *= (1 + level * 0.1). Level-10 earns 2x, level-20 earns
176
- * 3x vs level-1. No flag — discover via user-property level breakdown.
175
+ * reward_gold *= (1 + level * 0.15). Level-10 earns ~2.5x, level-20
176
+ * earns ~4x vs level-1. No flag — discover via user-property level breakdown.
177
177
  *
178
178
  * HOW TO FIND IT IN MIXPANEL:
179
179
  *
@@ -182,7 +182,7 @@ const chance = u.initChance(SEED);
182
182
  * - Event: "quest turned in"
183
183
  * - Measure: Average of "reward_gold"
184
184
  * - Breakdown: user property "level" (bucketed)
185
- * - Expected: linear ramp; level-10 ~ 2x, level-20 ~ 3x vs level-1
185
+ * - Expected: linear ramp; level-10 ~ 2.5x, level-20 ~ 4x vs level-1
186
186
  *
187
187
  * REAL-WORLD ANALOGUE: Quest economies scale rewards with player level
188
188
  * so high-level zones remain meaningfully lucrative.
@@ -235,10 +235,12 @@ const chance = u.initChance(SEED);
235
235
  * audience segmentation lens for narrative design and content tuning.
236
236
  *
237
237
  * ───────────────────────────────────────────────────────────────────────────────
238
- * 12. COMBAT FUNNEL TIME-TO-CONVERT (funnel-post)
238
+ * 12. COMBAT FUNNEL TIME-TO-CONVERT (everything)
239
239
  *
240
- * PATTERN: Elite tier completes the Combat funnel 1.4x faster (factor
241
- * 0.71); Free tier 1.25x slower (factor 1.25). Mutates funnel timestamps.
240
+ * PATTERN: Elite tier completes the Combat funnel ~3.3x faster (factor
241
+ * 0.30); Free tier ~1.4x slower (factor 1.40). Finds combat funnel
242
+ * sequences (combat initiated → combat completed → use item) and scales
243
+ * the inter-step time gaps in the everything hook.
242
244
  *
243
245
  * HOW TO FIND IT IN MIXPANEL:
244
246
  *
@@ -246,14 +248,7 @@ const chance = u.initChance(SEED);
246
248
  * - Funnels > "combat initiated" -> "combat completed" -> "use item"
247
249
  * - Measure: Median time to convert
248
250
  * - Breakdown: subscription_tier
249
- * - Expected: Elite ~ 0.71x; Free ~ 1.25x
250
- *
251
- * NOTE (funnel-post measurement): visible only via Mixpanel funnel
252
- * median TTC. Cross-event MIN→MIN SQL queries on raw events do NOT
253
- * show this — funnel-post adjusts gaps within funnel instances, not
254
- * across the user's full event history. (This dungeon also has an
255
- * everything-hook companion that compresses the cross-event Elite gap
256
- * between earliest combat_initiated and earliest use_item.)
251
+ * - Expected: Elite ~ 0.30x; Free ~ 1.40x vs Premium baseline
257
252
  *
258
253
  * ───────────────────────────────────────────────────────────────────────────────
259
254
  * 13. COMBAT-PREP MAGIC NUMBER (in-funnel, everything)
@@ -296,10 +291,10 @@ const chance = u.initChance(SEED);
296
291
  * Legendary Weapon | Combat win rate | 60% | 90% | 1.5x
297
292
  * Premium Tier | Quest reward | 1x | 1.4x | 1.4x
298
293
  * Elite Tier | Quest reward | 1x | 1.8x | 1.8x
299
- * Gold Scaling (lvl 10) | Avg quest gold | ~ 100 | ~ 200 | 2x
294
+ * Gold Scaling (lvl 10) | Avg quest gold | ~ 100 | ~ 250 | 2.5x
300
295
  * Whale Purchases | Avg real money spend | 1x | 1.8x | 1.8x
301
296
  * Hero/Villain/Neutral | User share | -- | 22/22/56% | n/a
302
- * Combat T2C | median min by tier | 1x | 0.71/1.25x | ~ 1.8x range
297
+ * Combat T2C | median min by tier | 1x | 0.30/1.40x | ~ 4.7x range
303
298
  * Combat-Prep Magic Num | sweet treasure_value | 1x | 1.3x | 1.3x
304
299
  * Combat-Prep Magic Num | over boss victory | 1x | 0.75x | -25%
305
300
  */
@@ -695,7 +690,7 @@ const config = {
695
690
  lookupTables: [],
696
691
 
697
692
  /**
698
- * 🎯 ARCHITECTED ANALYTICS HOOKS — 11 patterns
693
+ * 🎯 ARCHITECTED ANALYTICS HOOKS — 13 patterns
699
694
  *
700
695
  * 1. CONVERSION: Ancient Compass users have 3x quest completion + 1.5x rewards
701
696
  * 2. TIME-BASED: "Cursed Week" (days 40-47) has 5x death rates
@@ -705,9 +700,11 @@ const config = {
705
700
  * 6. BEHAVIORS TOGETHER: inspect + search before dungeon = 85% completion vs 45%
706
701
  * 7. TIMED RELEASE: Legendary weapon released day 45, early adopters dominate
707
702
  * 8. SUBSCRIPTION TIER: Premium/Elite users have higher engagement and success
708
- * 9. PROGRESSION SCALING: Quest gold scales with player level (1 + level * 0.1)
703
+ * 9. PROGRESSION SCALING: Quest gold scales with player level (1 + level * 0.15)
709
704
  * 10. WHALE PURCHASES: ~33% of users via deterministic hash spend 1.8x more
710
705
  * 11. ALIGNMENT ARCHETYPE: Good=hero, Evil=villain, other=neutral (user hook)
706
+ * 12. COMBAT FUNNEL TTC: Elite 0.30x faster, Free 1.40x slower combat funnel T2C
707
+ * 13. COMBAT-PREP MAGIC NUMBER: 3-6 prep events = +30% treasure; 7+ = -25% boss wins
711
708
  */
712
709
  hook: function (record, type, meta) {
713
710
  // Hook #11: ALIGNMENT ARCHETYPE — derive archetype on user profile
@@ -727,27 +724,8 @@ const config = {
727
724
  // (moved to everything hook — event hook fires before bunchIntoSessions
728
725
  // reshuffles timestamps, causing tagged events to leak across the d45 boundary)
729
726
 
730
- // HOOK 12 (T2C): COMBAT FUNNEL TIME-TO-CONVERT (funnel-post)
731
- // Elite tier completes Combat funnel 1.4x faster (factor 0.71);
732
- // Free tier 1.25x slower (factor 1.25).
733
- if (type === "funnel-post") {
734
- const segment = meta?.profile?.subscription_tier;
735
- if (Array.isArray(record) && record.length > 1) {
736
- const factor = (
737
- segment === "Elite" ? 0.30 :
738
- segment === "Premium" ? 0.70 :
739
- segment === "Free" ? 1.40 :
740
- 1.0
741
- );
742
- if (factor !== 1.0) {
743
- for (let i = 1; i < record.length; i++) {
744
- const prev = dayjs(record[i - 1].time);
745
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
746
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
747
- }
748
- }
749
- }
750
- }
727
+ // HOOK 12 (T2C): moved to everything hook — funnel-post effects
728
+ // were invisible to SQL verification.
751
729
 
752
730
  // Hooks #1, #3, #4, #5, #6, #8, #9, #10: per-user behavioral patterns
753
731
  if (type === "everything") {
@@ -772,10 +750,10 @@ const config = {
772
750
  }
773
751
  });
774
752
 
775
- // Hook #12 (everything-hook companion): COMBAT T2C — also compress
776
- // gap between earliest combat_initiated and earliest use_item across
777
- // the user's full event history (the funnel-post hook only adjusts
778
- // within-funnel-instance gaps; this catches cross-funnel measurement).
753
+ // Hook #12: COMBAT T2C — scale time gaps in combat funnel
754
+ // sequences (combat initiated combat completed use item).
755
+ // Elite ~0.30x (faster), Premium ~0.70x, Free ~1.40x (slower).
756
+ // Finds all 3-step sequences and shifts step 2/3 timestamps.
779
757
  const tier = profile.subscription_tier;
780
758
  const t2cFactor = (
781
759
  tier === "Elite" ? 0.30 :
@@ -784,23 +762,34 @@ const config = {
784
762
  1.0
785
763
  );
786
764
  if (t2cFactor !== 1.0) {
787
- let firstCombatTime = null;
788
- let firstUseItemIdx = -1;
765
+ // Collect indices for each combat funnel step
766
+ const combatInitiated = [];
767
+ const combatCompleted = [];
768
+ const useItem = [];
789
769
  for (let i = 0; i < userEvents.length; i++) {
790
770
  const e = userEvents[i];
791
- if (e.event === "combat initiated" && firstCombatTime === null) {
792
- firstCombatTime = dayjs(e.time);
793
- }
794
- if (e.event === "use item" && firstUseItemIdx === -1) {
795
- firstUseItemIdx = i;
796
- }
771
+ if (e.event === "combat initiated") combatInitiated.push(i);
772
+ else if (e.event === "combat completed") combatCompleted.push(i);
773
+ else if (e.event === "use item") useItem.push(i);
797
774
  }
798
- if (firstCombatTime !== null && firstUseItemIdx !== -1) {
799
- const useItemTime = dayjs(userEvents[firstUseItemIdx].time);
800
- if (useItemTime.isAfter(firstCombatTime)) {
801
- const newGap = Math.round(useItemTime.diff(firstCombatTime) * t2cFactor);
802
- userEvents[firstUseItemIdx].time = firstCombatTime.add(newGap, "milliseconds").toISOString();
803
- }
775
+ // Match sequences: for each combat initiated, find next
776
+ // combat completed after it, then next use item after that
777
+ const matched = new Set();
778
+ for (const ciIdx of combatInitiated) {
779
+ const ccIdx = combatCompleted.find(j => j > ciIdx && !matched.has(j));
780
+ if (ccIdx === undefined) continue;
781
+ const uiIdx = useItem.find(j => j > ccIdx && !matched.has(j));
782
+ if (uiIdx === undefined) continue;
783
+ matched.add(ccIdx);
784
+ matched.add(uiIdx);
785
+ // Scale gap between step 1→2 and 2→3
786
+ const t0 = dayjs(userEvents[ciIdx].time);
787
+ const t1 = dayjs(userEvents[ccIdx].time);
788
+ const t2 = dayjs(userEvents[uiIdx].time);
789
+ const gap1 = t1.diff(t0);
790
+ const gap2 = t2.diff(t1);
791
+ userEvents[ccIdx].time = t0.add(Math.round(gap1 * t2cFactor), 'milliseconds').toISOString();
792
+ userEvents[uiIdx].time = dayjs(userEvents[ccIdx].time).add(Math.round(gap2 * t2cFactor), 'milliseconds').toISOString();
804
793
  }
805
794
  }
806
795
 
@@ -863,9 +852,10 @@ const config = {
863
852
  const eventTime = dayjs(event.time);
864
853
 
865
854
  // Hook 9: PROGRESSION SCALING — Quest gold scales with level.
855
+ // Power curve so bucket-averaged high-level/low-level ratio >= 2.0x.
866
856
  if (event.event === "quest turned in") {
867
857
  const baseGold = event.reward_gold || 100;
868
- event.reward_gold = Math.floor(baseGold * (1 + userLevel * 0.1));
858
+ event.reward_gold = Math.floor(baseGold * (1 + userLevel * 0.15));
869
859
  }
870
860
 
871
861
  // Hook 1: CONVERSION — Ancient Compass users earn 1.5x quest