@ak--47/dungeon-master 1.5.4 → 1.6.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 (149) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/powertools/SKILL.md +75 -0
  6. package/.claude/skills/powertools/pt.mjs +91 -0
  7. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  8. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  9. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  10. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  11. package/.claude/skills/write-hooks/SKILL.md +156 -18
  12. package/CHANGELOG.md +224 -0
  13. package/HOOKS.md +566 -112
  14. package/README.md +30 -9
  15. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  16. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  17. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  18. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  19. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  20. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  21. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  22. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  23. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  24. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  25. package/dungeons/technical/stories-verify.js +152 -0
  26. package/dungeons/vertical/README.md +154 -0
  27. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  28. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  29. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  30. package/dungeons/vertical/community/community.js +1589 -0
  31. package/dungeons/vertical/community/community.sql +216 -0
  32. package/dungeons/vertical/community/community.verify.mjs +72 -0
  33. package/dungeons/vertical/crypto/crypto.js +1483 -0
  34. package/dungeons/vertical/crypto/crypto.sql +194 -0
  35. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  36. package/dungeons/vertical/dating/dating.js +1540 -0
  37. package/dungeons/vertical/dating/dating.sql +288 -0
  38. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  39. package/dungeons/vertical/devtools/devtools.js +1524 -0
  40. package/dungeons/vertical/devtools/devtools.sql +161 -0
  41. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  42. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  43. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  44. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  45. package/dungeons/vertical/education/education.js +1763 -0
  46. package/dungeons/vertical/education/education.sql +243 -0
  47. package/dungeons/vertical/education/education.verify.mjs +75 -0
  48. package/dungeons/vertical/fintech/fintech.js +1410 -0
  49. package/dungeons/vertical/fintech/fintech.sql +175 -0
  50. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  51. package/dungeons/vertical/fitness/fitness.js +1495 -0
  52. package/dungeons/vertical/fitness/fitness.sql +206 -0
  53. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  54. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  55. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  56. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  57. package/dungeons/vertical/gaming/gaming.js +1741 -0
  58. package/dungeons/vertical/gaming/gaming.sql +249 -0
  59. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  60. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  61. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  62. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  63. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  64. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  65. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  66. package/dungeons/vertical/logistics/logistics.js +1541 -0
  67. package/dungeons/vertical/logistics/logistics.sql +205 -0
  68. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  69. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  70. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  71. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  72. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  73. package/dungeons/vertical/media/media.sql +173 -0
  74. package/dungeons/vertical/media/media.verify.mjs +74 -0
  75. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  76. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  77. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  78. package/dungeons/vertical/sass/sass.js +1733 -0
  79. package/dungeons/vertical/sass/sass.sql +173 -0
  80. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  81. package/dungeons/vertical/social/social.js +1456 -0
  82. package/dungeons/vertical/social/social.sql +160 -0
  83. package/dungeons/vertical/social/social.verify.mjs +76 -0
  84. package/dungeons/vertical/streaming/streaming.js +677 -0
  85. package/dungeons/vertical/streaming/streaming.sql +101 -0
  86. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  87. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  88. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  89. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  90. package/dungeons/vertical/travel/travel.js +1603 -0
  91. package/dungeons/vertical/travel/travel.sql +236 -0
  92. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  93. package/index.js +7 -3
  94. package/lib/core/config-validator.js +37 -2
  95. package/lib/core/context.js +4 -2
  96. package/lib/core/dungeon-loader.js +20 -15
  97. package/lib/core/dungeon-to-json.js +22 -4
  98. package/lib/core/storage.js +3 -3
  99. package/lib/generators/funnels.js +36 -9
  100. package/lib/hook-helpers/cohort.js +53 -0
  101. package/lib/hook-helpers/index.js +8 -0
  102. package/lib/hook-helpers/mutate.js +1 -1
  103. package/lib/hook-helpers/shape.js +319 -0
  104. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  105. package/lib/hook-patterns/attributed-by-source.js +60 -58
  106. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  107. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  108. package/lib/hook-patterns/index.js +1 -1
  109. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  110. package/lib/orchestrators/user-loop.js +41 -0
  111. package/lib/templates/story-spec.schema.json +175 -0
  112. package/lib/utils/json-evaluator.js +1 -1
  113. package/lib/utils/utils.js +151 -59
  114. package/lib/verify/coerce.js +186 -0
  115. package/lib/verify/counting.js +270 -32
  116. package/lib/verify/emulate-breakdown.js +1285 -203
  117. package/lib/verify/first-time.js +89 -0
  118. package/lib/verify/flows.js +679 -0
  119. package/lib/verify/formula.js +259 -0
  120. package/lib/verify/funnel-engine.js +697 -113
  121. package/lib/verify/index.js +32 -4
  122. package/lib/verify/sessionize.js +275 -0
  123. package/lib/verify/story-runner.js +483 -0
  124. package/lib/verify/verify-dungeon.js +59 -37
  125. package/package.json +2 -1
  126. package/scripts/extract-dungeon-schema.mjs +2 -2
  127. package/scripts/run-many.mjs +2 -2
  128. package/scripts/verify-runner.mjs +2 -2
  129. package/scripts/verify-stories.mjs +244 -0
  130. package/types.d.ts +298 -30
  131. package/dungeons/vertical/ai-platform.js +0 -818
  132. package/dungeons/vertical/community.js +0 -966
  133. package/dungeons/vertical/crypto.js +0 -895
  134. package/dungeons/vertical/dating.js +0 -830
  135. package/dungeons/vertical/devtools.js +0 -1050
  136. package/dungeons/vertical/ecommerce.js +0 -811
  137. package/dungeons/vertical/education.js +0 -964
  138. package/dungeons/vertical/fintech.js +0 -946
  139. package/dungeons/vertical/fitness.js +0 -935
  140. package/dungeons/vertical/food-delivery.js +0 -934
  141. package/dungeons/vertical/gaming.js +0 -1120
  142. package/dungeons/vertical/healthcare.js +0 -945
  143. package/dungeons/vertical/insurance-application.js +0 -1039
  144. package/dungeons/vertical/logistics.js +0 -986
  145. package/dungeons/vertical/marketplace.js +0 -1019
  146. package/dungeons/vertical/real-estate.js +0 -872
  147. package/dungeons/vertical/sass.js +0 -995
  148. package/dungeons/vertical/social.js +0 -904
  149. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,1741 @@
1
+ // ── IMPORTS ──
2
+ import dayjs from "dayjs";
3
+ import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
5
+ import "dotenv/config";
6
+ import * as u from "@ak--47/dungeon-master/utils";
7
+ import * as v from "ak-tools";
8
+ /** @typedef {import("../../../types").Dungeon} Config */
9
+
10
+ // ── OVERVIEW ──
11
+ /*
12
+ * NAME: QuestForge
13
+ * APP: D&D-inspired action RPG with a deep character system, party-based
14
+ * dungeon crawls, boss fights, a player-driven economy, guilds, and
15
+ * subscription tiers. Combines tabletop-RPG strategic depth with the
16
+ * monetization and engagement loops of a modern live-service game.
17
+ * SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
18
+ * CORE LOOP: character created → tutorial → quest → dungeon crawl → combat → loot → level up
19
+ *
20
+ * EVENTS (24):
21
+ * combat initiated (20) > enter dungeon (18) > combat completed (18) > find treasure (16)
22
+ * > quest accepted (15) > exit dungeon (14) > use item (14) > quest objective completed (12)
23
+ * > item purchased (11) > quest turned in (10) > inspect (9) > player death (8)
24
+ * > search for clues (8) > item sold (7) > gameplay summary (6) > level up (5)
25
+ * > attack (5) > guild joined (4) > real money purchase (3) > fight boss (3)
26
+ * > defend (3) > tutorial completed (2) > character created (1) > guild left (1)
27
+ *
28
+ * FUNNELS (7):
29
+ * - Onboarding: character created → tutorial completed → quest accepted (75%)
30
+ * - Combat Loop: combat initiated → combat completed → use item (75%)
31
+ * - Dungeon Crawl: enter dungeon → find treasure → exit dungeon (60%)
32
+ * - Quest Lifecycle: quest accepted → quest objective completed → quest turned in (55%)
33
+ * - Prep Funnel: inspect → search for clues → enter dungeon (50%)
34
+ * - Economy: item purchased → use item → item sold (45%)
35
+ * - Social/Progression: guild joined → level up → real money purchase (25%)
36
+ *
37
+ * USER PROPS: preferred_playstyle, total_playtime_hours, achievement_points, favorite_class,
38
+ * Platform, graphics_quality, subscription_tier, race, class, alignment,
39
+ * background, level, archetype, experiment, variant
40
+ * SUPER PROPS: Platform, graphics_quality, subscription_tier
41
+ * SCD PROPS: player_rank (recruit/veteran/elite/legend, weekly fuzzy, max 20)
42
+ * GROUPS: guild_id (cap 500; on guild joined / guild left / quest turned in / combat completed)
43
+ */
44
+
45
+ // ── HOOK STORIES ──
46
+ /*
47
+ * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
48
+ * behavioral cohorts (count event per user), raw-prop breakdowns
49
+ * (treasure_type, subscription_tier, day-of-user-life), or funnel
50
+ * time-to-convert. Expectations below were measured at 1500-user iteration
51
+ * scale (per-user event density is scale-invariant, so ratios transfer to
52
+ * the shipped 10K). The machine-checked contract is the `stories` export at
53
+ * the bottom of this file:
54
+ * node scripts/verify-stories.mjs dungeons/vertical/gaming/gaming.js
55
+ *
56
+ * Hook 1 — ANCIENT COMPASS REWARDS (everything)
57
+ * Heavy compass users (COMPASS_HEAVY_MIN=2+ "use item" events with
58
+ * item_type="Ancient Compass" — ~48% of users) earn
59
+ * COMPASS_REWARD_MULT=1.5x reward_gold and reward_xp on every quest
60
+ * turned in, plus a 40% chance per turn-in of a bonus cloned quest.
61
+ * item_type is a 6-value enum on a high-frequency event, so a 1+ gate
62
+ * would leave no control group (~75% of users qualify at 1+).
63
+ * → Mixpanel: cohort A = users with 2+ "use item" where
64
+ * item_type="Ancient Compass"; avg reward_gold on "quest turned in"
65
+ * → Expected: A/rest reward_gold ≈ 1.56x, reward_xp ≈ 1.58x (slightly
66
+ * above the 1.5 knob — bonus clones are built at the boosted rate)
67
+ *
68
+ * Hook 2 — CURSED WEEK (everything, time-based)
69
+ * Injected "player death" events with cause_of_death="Curse" cluster in
70
+ * days 40-47 of each user's own timeline (CURSED_DEATH_INJECTION_FACTOR
71
+ * 0.6 deaths per in-window event). "Curse" also appears organically at a
72
+ * flat 1/6 share of deaths, so out-of-window Curse deaths exist.
73
+ * → Mixpanel: "player death" filtered cause_of_death="Curse", charted by
74
+ * day-of-user-life (calendar-day charts smear the spike across born-in
75
+ * cohorts)
76
+ * → Expected: per-day Curse density in window ≈ 36x out-of-window
77
+ * (measured on 48d+ lifetime users); raw in/out count ratio ≈ 2.7
78
+ *
79
+ * Hook 3 — EARLY GUILD RESCUE (everything, retention)
80
+ * Early guild joiners ("guild joined" within first EARLY_GUILD_DAYS=3
81
+ * days, ~13% of users) are exempt from the Hook 4 death spiral, and 60%
82
+ * of them get a bonus late combat-victory clone.
83
+ * → Mixpanel: among users with 3+ week-1 deaths, cohort by early guild
84
+ * join; compare post-week-1 event volume
85
+ * → Expected: joiners keep ~2.5x the post-week-1 events of non-joiners
86
+ * (knob ceiling 1/0.3 = 3.3x; guild joiners are front-loaded players,
87
+ * which pulls the measured contrast below the ceiling)
88
+ *
89
+ * Hook 4 — DEATH SPIRAL CHURN (everything)
90
+ * Non-joiners with DEATH_SPIRAL_MIN_DEATHS=3+ deaths in the first
91
+ * DEATH_SPIRAL_EARLY_DAYS=7 days (~9% of users) lose
92
+ * DEATH_SPIRAL_DROP_LIKELIHOOD=70% of their post-week-1 events.
93
+ * → Mixpanel: bucket users by week-1 "player death" count; compare
94
+ * post/pre-day-7 event volume
95
+ * → Expected: spiral post/pre ≈ 1.4 vs ≈ 12.4 for everyone else (ratio
96
+ * ≈ 0.11 — the 0.3 keep-rate compounds with activity selection: 3+
97
+ * early deaths selects front-loaded players whose natural post/pre is
98
+ * already ~3x lower than average)
99
+ *
100
+ * Hook 5 — LUCKY CHARM LTV (everything)
101
+ * Lucky Charm Pack buyers (~8% of users, organic product-enum share) get
102
+ * LUCKY_CHARM_PRICE_MULT=2.5x price_usd on ALL their real-money
103
+ * purchases, plus a 35% chance per "item purchased" of a bonus cloned
104
+ * real-money purchase at premium price points (19.99/49.99/99.99 base,
105
+ * x2.5, and x1.8 more if the buyer is a Hook 10 whale).
106
+ * → Mixpanel: cohort A = users with any "real money purchase" where
107
+ * product="Lucky Charm Pack"; compare avg price_usd vs rest
108
+ * → Expected: A/rest avg price ≈ 3.4x among non-whales (2.5x organic
109
+ * floor + high-ticket bonus clones ≈ 65% of A's purchase rows)
110
+ *
111
+ * Hook 6 — STRATEGIC EXPLORERS (everything, behaviors-together)
112
+ * Strategic players (STRATEGIC_MIN_EACH=6+ "inspect" AND 6+ "search for
113
+ * clues", ~42% of users — both events are high-frequency, so a 1+ gate
114
+ * covers ~99% of users and leaves no control group) get
115
+ * STRATEGIC_COMPLETION_LIKELIHOOD=85% of their non-completed dungeon
116
+ * exits flipped to completed and STRATEGIC_TREASURE_MULT=2x
117
+ * treasure_value.
118
+ * → Mixpanel: cohort A = users with 6+ inspect AND 6+ search for clues;
119
+ * completion share on "exit dungeon", avg treasure_value on
120
+ * "find treasure"
121
+ * → Expected: A completion ≈ 0.93 vs ≈ 0.54 rest; A/rest treasure ≈
122
+ * 1.7x (2.0 knob diluted by Hook 13's sweet-band 1.3x boost, which
123
+ * lands mostly in the control cohort — honest band is 2.0/1.3 .. 2.0)
124
+ *
125
+ * Hook 7 — SHADOWMOURNE LEGENDARY (everything, timed release)
126
+ * Zero drops before dataset day LEGENDARY_RELEASE_DAY=45. One per-player
127
+ * roll at LEGENDARY_DROP_LIKELIHOOD=2%; a winner's first post-release
128
+ * "find treasure" becomes the drop (treasure_value 50000). Owners then
129
+ * get LEGENDARY_WIN_LIKELIHOOD=90% of non-victory combats flipped to
130
+ * Victory and 0.6x dungeon completion time with forced completion.
131
+ * → Mixpanel: "find treasure" filtered treasure_type="Shadowmourne
132
+ * Legendary", line chart by day — zero before day 45, trickle after.
133
+ * Cohort A = owners; per-user combat win rate.
134
+ * → Expected: owners ≈ 2% of post-release treasure finders; owner win
135
+ * rate ≈ 0.96 (0.9 flip floor, pushed up by tier flips) vs ≈ 0.55 rest
136
+ *
137
+ * Hook 8 — SUBSCRIBER TIER ADVANTAGE (everything)
138
+ * subscription_tier is 60/20/20 Free/Premium/Elite. Premium: 50% of
139
+ * losses flipped to wins, 1.4x rewards, 45% completion flips, 1.5x
140
+ * treasure, 30% of deaths survived. Elite: 70% win flips, 1.8x rewards,
141
+ * 65% completion flips, 2.0x treasure, 50% survived, 5% bonus treasure
142
+ * clones.
143
+ * → Mixpanel: avg reward_gold on "quest turned in" broken down by
144
+ * subscription_tier; completion share on "exit dungeon" by tier
145
+ * → Expected: reward_gold Elite/Free ≈ 1.8x, Premium/Free ≈ 1.4x (the
146
+ * other multipliers are tier-independent and cancel); completion
147
+ * follows f + (1-f)*flip from the Free baseline f ≈ 0.68 → Premium ≈
148
+ * 0.82, Elite ≈ 0.90 (Free baseline sits above the raw enum share
149
+ * because Hooks 6/7 also flip completions)
150
+ *
151
+ * Hook 9 — GOLD SCALES WITH LEVEL (everything, progression)
152
+ * Quest reward_gold *= (1 + level * LEVEL_GOLD_SCALING=0.15), level from
153
+ * the user profile (weighNumRange 1-20, low-skewed).
154
+ * → Mixpanel: avg reward_gold on "quest turned in" broken down by user
155
+ * property "level" (bucketed)
156
+ * → Expected: bucket ratios match the formula computed from the
157
+ * buckets' own mean levels — e.g. level 13+ (mean ≈ 14.3) vs level
158
+ * 1-5 (mean ≈ 4.2) → (1+14.3*0.15)/(1+4.2*0.15) ≈ 1.94x, measured
159
+ * 1.95x. Tier/compass multipliers are level-independent and cancel.
160
+ *
161
+ * Hook 10 — WHALE PURCHASES (everything)
162
+ * Whales get WHALE_PRICE_MULT=1.8x price_usd on real-money purchases.
163
+ * Deterministic hash — first char of the user_id hex, charCodeAt % 3
164
+ * == 0, which matches '0','3','6','9','c','f' = 6/16 = 37.5% of users
165
+ * (hash math, not "a third").
166
+ * → Mixpanel: rank users by total spend on "real money purchase";
167
+ * compare avg price_usd top-spender cohort vs rest
168
+ * → Expected: whale/rest avg price ≈ 1.8x among non-Lucky-Charm users
169
+ * (Hook 5's high-ticket clones contaminate the unscoped ratio to ~2x)
170
+ *
171
+ * Hook 11 — ALIGNMENT ARCHETYPE (user)
172
+ * archetype is a deterministic function of the D&D alignment user prop:
173
+ * Lawful/Neutral Good → "hero", Chaotic/Neutral Evil → "villain", the
174
+ * five remaining alignments → "neutral".
175
+ * → Mixpanel: uniques broken down by user property "archetype";
176
+ * cross-check counts against the alignment breakdown
177
+ * → Expected: mapping is exact (hero count == LG+NG count, villain ==
178
+ * CE+NE); shares ≈ hero 26% / villain 25% / neutral 49% at this seed
179
+ * (uniform alignment would give 22/22/56)
180
+ *
181
+ * Hook 12 — COMBAT FUNNEL SPEED BY TIER (everything, temporal)
182
+ * Greedy-matched combat sequences (combat initiated → combat completed →
183
+ * use item) get their inter-step gaps scaled per tier:
184
+ * TTC_ELITE_FACTOR=0.30, TTC_PREMIUM_FACTOR=0.70, TTC_FREE_FACTOR=1.40.
185
+ * → Mixpanel: Funnels on combat initiated → combat completed → use
186
+ * item; median time-to-convert broken down by subscription_tier
187
+ * → Expected: median TTC Free/Premium ≈ 1.40/0.70 = 2.0x,
188
+ * Premium/Elite ≈ 0.70/0.30 = 2.33x
189
+ *
190
+ * Hook 13 — COMBAT-PREP MAGIC NUMBER (everything, in-funnel)
191
+ * Prep events (inspect + search for clues) between the user's first
192
+ * quest-accepted and first fight-boss: PREP_SWEET_MIN..MAX=3-6 preps →
193
+ * all the user's treasure_value x PREP_TREASURE_BOOST=1.3;
194
+ * PREP_OVER_THRESHOLD=7+ preps → PREP_BOSS_FLIP_LIKELIHOOD=25% of the
195
+ * user's boss victories flip to defeat (analysis paralysis).
196
+ * → Mixpanel: bucket users by prep count between the two anchors; avg
197
+ * treasure_value and fight-boss victory rate per bucket
198
+ * → Expected: sweet/low treasure ≈ 1.24x among NON-strategic users
199
+ * (knob 1.3; Hook 6's 2x concentrates in high-prep bands and swamps
200
+ * the unscoped ratio); boss win over/sweet ≈ 0.73 (knob predicts
201
+ * 0.75, Hook 6 does not touch the victory prop so no scoping needed)
202
+ *
203
+ * ───────────────────────────────────────────────────────────────────────────
204
+ * EXPECTED METRICS SUMMARY (measured at 1500-user iteration; gates final)
205
+ * ───────────────────────────────────────────────────────────────────────────
206
+ *
207
+ * Hook | Metric | Knob | Measured
208
+ * -----|--------------------------------------------|----------|---------
209
+ * 1 | compass-heavy / light avg reward_gold | 1.5x | 1.56x
210
+ * 1 | compass-heavy / light avg reward_xp | 1.5x | 1.58x
211
+ * 2 | Curse per-day density in/out of window | — | 35.6x
212
+ * 3 | guild-saved / spiral post-week-1 volume | ≤3.33x | 2.51x
213
+ * 4 | spiral / other post-pre event ratio | 0.3 core | 0.113
214
+ * 5 | lucky / rest avg price_usd (non-whales) | ≥2.5x | 3.40x
215
+ * 6 | strategic / rest avg treasure_value | ≤2.0x | 1.72x
216
+ * 6 | strategic completion share | ≥0.90 | 0.926
217
+ * 7 | legendary adoption among treasure finders | ~2% | 1.5%
218
+ * 7 | owner combat win rate | ≥0.90 | 0.963
219
+ * 8 | Elite/Free avg reward_gold | 1.8x | 1.80x
220
+ * 8 | Premium/Free avg reward_gold | 1.4x | 1.37x
221
+ * 9 | hi/lo level-bucket gold obs/formula | 1.0 | 1.007
222
+ * 10 | whale / rest avg price_usd (non-lucky) | 1.8x | 1.80x
223
+ * 11 | archetype == alignment mapping | exact | exact
224
+ * 12 | median combat TTC Free/Premium | 2.0x | (story)
225
+ * 12 | median combat TTC Premium/Elite | 2.33x | (story)
226
+ * 13 | sweet/low treasure (non-strategic) | 1.3x | 1.24x
227
+ * 13 | over/sweet boss win rate | 0.75x | 0.73x
228
+ */
229
+
230
+ // ── SCALE ──
231
+ const SEED = "questforge";
232
+ const NUM_USERS = 10_000;
233
+ const DATASET_START = "2026-01-01T00:00:00Z";
234
+ const DATASET_END = "2026-05-01T23:59:59Z";
235
+ const EVENTS_PER_DAY = 1.2;
236
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
237
+
238
+ const chance = u.initChance(SEED);
239
+
240
+ // ── KNOBS (tweak these to reshape stories) ──
241
+ const COMPASS_REWARD_MULT = 1.5;
242
+ const COMPASS_BONUS_QUEST_LIKELIHOOD = 40;
243
+ // item_type is a 6-value enum on a high-frequency event, so nearly every
244
+ // user fires "Ancient Compass" at least once — a 1+ gate leaves no control
245
+ // group. Gate on repeat use instead; threshold calibrated from the measured
246
+ // per-user compass-use distribution (see H1 story).
247
+ const COMPASS_HEAVY_MIN = 2;
248
+
249
+ const CURSED_WEEK_START_DAY = 40;
250
+ const CURSED_WEEK_END_DAY = 47;
251
+ const CURSED_DEATH_INJECTION_FACTOR = 0.6;
252
+
253
+ const EARLY_GUILD_DAYS = 3;
254
+ const EARLY_GUILD_COMBAT_CLONE_LIKELIHOOD = 60;
255
+
256
+ const DEATH_SPIRAL_EARLY_DAYS = 7;
257
+ const DEATH_SPIRAL_MIN_DEATHS = 3;
258
+ const DEATH_SPIRAL_DROP_LIKELIHOOD = 70;
259
+
260
+ const LUCKY_CHARM_PRICE_MULT = 2.5;
261
+ const LUCKY_CHARM_BONUS_PURCHASE_LIKELIHOOD = 35;
262
+
263
+ const STRATEGIC_COMPLETION_LIKELIHOOD = 85;
264
+ const STRATEGIC_TREASURE_MULT = 2;
265
+ // inspect (w9) and search for clues (w8) are common enough that almost every
266
+ // user fires both at least once — a low gate leaves no control group (3+ each
267
+ // covers ~76% of users). 6+ each splits ~42% strategic / ~58% rest, measured
268
+ // on the per-user count distributions (see H6 story).
269
+ const STRATEGIC_MIN_EACH = 6;
270
+
271
+ const LEGENDARY_RELEASE_DAY = 45;
272
+ const LEGENDARY_DROP_LIKELIHOOD = 2;
273
+ const LEGENDARY_TREASURE_VALUE = 50000;
274
+ const LEGENDARY_WIN_LIKELIHOOD = 90;
275
+ const LEGENDARY_DUNGEON_SPEED_MULT = 0.6;
276
+
277
+ const PREMIUM_WIN_LIKELIHOOD = 50;
278
+ const PREMIUM_REWARD_MULT = 1.4;
279
+ const PREMIUM_COMPLETION_LIKELIHOOD = 45;
280
+ const PREMIUM_DUNGEON_SPEED_MULT = 0.85;
281
+ const PREMIUM_TREASURE_MULT = 1.5;
282
+ const PREMIUM_SURVIVAL_LIKELIHOOD = 30;
283
+
284
+ const ELITE_WIN_LIKELIHOOD = 70;
285
+ const ELITE_REWARD_MULT = 1.8;
286
+ const ELITE_COMPLETION_LIKELIHOOD = 65;
287
+ const ELITE_DUNGEON_SPEED_MULT = 0.7;
288
+ const ELITE_TREASURE_MULT = 2.0;
289
+ const ELITE_SURVIVAL_LIKELIHOOD = 50;
290
+ const ELITE_BONUS_TREASURE_LIKELIHOOD = 5;
291
+
292
+ const LEVEL_GOLD_SCALING = 0.15;
293
+
294
+ const WHALE_PRICE_MULT = 1.8;
295
+
296
+ const TTC_ELITE_FACTOR = 0.30;
297
+ const TTC_PREMIUM_FACTOR = 0.70;
298
+ const TTC_FREE_FACTOR = 1.40;
299
+
300
+ const PREP_SWEET_MIN = 3;
301
+ const PREP_SWEET_MAX = 6;
302
+ const PREP_OVER_THRESHOLD = 7;
303
+ const PREP_TREASURE_BOOST = 1.3;
304
+ const PREP_BOSS_FLIP_LIKELIHOOD = 25;
305
+
306
+ // ── DATA ARRAYS ──
307
+ // Generate consistent item/location IDs for lookup tables
308
+ const dungeonIds = v.range(1, 51).map(n => `dungeon_${v.uid(6)}`);
309
+ const questIds = v.range(1, 201).map(n => `quest_${v.uid(8)}`);
310
+ const itemIds = v.range(1, 301).map(n => `item_${v.uid(7)}`);
311
+
312
+ // ── HELPER FUNCTIONS ──
313
+ function handleUserHooks(record) {
314
+ // Hook #11: ALIGNMENT ARCHETYPE — derive archetype on user profile
315
+ if (record.alignment === "Chaotic Evil" || record.alignment === "Neutral Evil") {
316
+ record.archetype = "villain";
317
+ } else if (record.alignment === "Lawful Good" || record.alignment === "Neutral Good") {
318
+ record.archetype = "hero";
319
+ } else {
320
+ record.archetype = "neutral";
321
+ }
322
+ return record;
323
+ }
324
+
325
+ function handleEverythingHooks(record, meta) {
326
+ const userEvents = record;
327
+ const profile = meta.profile;
328
+ const datasetStart = dayjs.unix(meta.datasetStart);
329
+ const LEGENDARY_WEAPON_RELEASE = datasetStart.add(LEGENDARY_RELEASE_DAY, 'days');
330
+
331
+ // Phase order follows types.d.ts everything-hook guidance:
332
+ // stamp → mutate → clone → filter → temporal.
333
+
334
+ // ── STAMP: superProps from profile for consistency ──
335
+ userEvents.forEach(e => {
336
+ e.Platform = profile.Platform;
337
+ e.graphics_quality = profile.graphics_quality;
338
+ e.subscription_tier = profile.subscription_tier;
339
+ });
340
+
341
+ const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
342
+ const userLevel = profile.level || 1;
343
+ const subscriptionTier = profile.subscription_tier || "Free";
344
+ const isElite = subscriptionTier === "Elite";
345
+ const isPremium = subscriptionTier === "Premium";
346
+ const tierRewardMult = isElite ? ELITE_REWARD_MULT : isPremium ? PREMIUM_REWARD_MULT : 1;
347
+
348
+ // Hook #7: TIMED RELEASE — per-PLAYER roll, not per-event. The doc says
349
+ // "~2% of players find it after release"; the old per-event roll made
350
+ // adoption scale with each player's find-treasure volume. One roll per
351
+ // user; a winner's first find-treasure after release becomes the drop.
352
+ let hasLegendaryWeapon = false;
353
+ if (chance.bool({ likelihood: LEGENDARY_DROP_LIKELIHOOD })) {
354
+ const drop = userEvents.find(e => e.event === "find treasure" && dayjs(e.time).isAfter(LEGENDARY_WEAPON_RELEASE));
355
+ if (drop) {
356
+ drop.treasure_type = "Shadowmourne Legendary";
357
+ drop.treasure_value = LEGENDARY_TREASURE_VALUE;
358
+ hasLegendaryWeapon = true;
359
+ }
360
+ }
361
+
362
+ // ── DETECT: per-user counts (counts, not booleans — high-frequency
363
+ // enums mean 1+ gates leave no control group) ──
364
+ let compassUses = 0;
365
+ let boughtLuckyCharm = false;
366
+ let joinedGuildEarly = false;
367
+ let earlyDeaths = 0;
368
+ let inspectCount = 0;
369
+ let searchCount = 0;
370
+
371
+ userEvents.forEach((event) => {
372
+ const daysSinceStart = firstEventTime ? dayjs(event.time).diff(firstEventTime, 'days', true) : 0;
373
+
374
+ if (event.event === "use item" && event.item_type === "Ancient Compass") compassUses++;
375
+ if (event.event === "real money purchase" && event.product === "Lucky Charm Pack") boughtLuckyCharm = true;
376
+ if (event.event === "guild joined" && daysSinceStart < EARLY_GUILD_DAYS) joinedGuildEarly = true;
377
+ if (event.event === "player death" && daysSinceStart < DEATH_SPIRAL_EARLY_DAYS) earlyDeaths++;
378
+ if (event.event === "inspect") inspectCount++;
379
+ if (event.event === "search for clues") searchCount++;
380
+ });
381
+
382
+ const usedAncientCompass = compassUses >= COMPASS_HEAVY_MIN;
383
+ const isStrategic = inspectCount >= STRATEGIC_MIN_EACH && searchCount >= STRATEGIC_MIN_EACH;
384
+
385
+ // Hook #10: Whale segmentation — deterministic via user_id hash.
386
+ // distinct_id is hex, so charCodeAt(0) % 3 === 0 matches '0','3','6',
387
+ // '9','c','f' → 6/16 = 37.5% of users (not "a third" — hash math).
388
+ const userId = userEvents.length > 0 ? (userEvents[0].user_id || userEvents[0].distinct_id || "") : "";
389
+ const isWhale = userId.length > 0 && userId.charCodeAt(0) % 3 === 0;
390
+
391
+ // ── MUTATE + CLONE-COLLECT: single pass over organic events. Clones are
392
+ // collected and pushed AFTER the loop — the old splice-at-idx+1 pattern
393
+ // fed every clone back through the mutations below (compass clones were
394
+ // re-multiplied, clones could spawn clones). Clones are built fully
395
+ // formed here instead, with the same multipliers an organic event of
396
+ // their cohort receives, so cohort ratios stay exact.
397
+ const clones = [];
398
+ userEvents.forEach((event) => {
399
+ const eventTime = dayjs(event.time);
400
+
401
+ // Hook 9: PROGRESSION SCALING — Quest gold scales linearly with level.
402
+ if (event.event === "quest turned in") {
403
+ const baseGold = event.reward_gold || 100;
404
+ event.reward_gold = Math.floor(baseGold * (1 + userLevel * LEVEL_GOLD_SCALING));
405
+ }
406
+
407
+ // Hook 1: CONVERSION — heavy compass users earn 1.5x quest rewards
408
+ // plus 40% chance of a bonus cloned quest per turn-in.
409
+ if (usedAncientCompass && event.event === "quest turned in") {
410
+ event.reward_gold = Math.floor((event.reward_gold || 100) * COMPASS_REWARD_MULT);
411
+ event.reward_xp = Math.floor((event.reward_xp || 500) * COMPASS_REWARD_MULT);
412
+
413
+ if (chance.bool({ likelihood: COMPASS_BONUS_QUEST_LIKELIHOOD })) {
414
+ clones.push({
415
+ ...event,
416
+ time: eventTime.add(chance.integer({ min: 10, max: 120 }), 'minutes').toISOString(),
417
+ quest_id: chance.pickone(questIds),
418
+ // same treatment an organic quest of this user gets:
419
+ // level scaling × compass mult × tier mult, applied once
420
+ reward_gold: Math.floor(chance.integer({ min: 100, max: 500 }) * (1 + userLevel * LEVEL_GOLD_SCALING) * COMPASS_REWARD_MULT * tierRewardMult),
421
+ reward_xp: Math.floor(chance.integer({ min: 500, max: 2000 }) * COMPASS_REWARD_MULT * tierRewardMult),
422
+ });
423
+ }
424
+ }
425
+
426
+ // Hook 5: PURCHASE VALUE — Lucky Charm buyers see 2.5x prices on all
427
+ // real-money purchases; item purchases carry a 35% chance of a bonus
428
+ // cloned real-money purchase.
429
+ if (boughtLuckyCharm) {
430
+ if (event.event === "real money purchase" && event.price_usd) {
431
+ event.price_usd = Math.round(event.price_usd * LUCKY_CHARM_PRICE_MULT * 100) / 100;
432
+ }
433
+ if (event.event === "item purchased" && chance.bool({ likelihood: LUCKY_CHARM_BONUS_PURCHASE_LIKELIHOOD })) {
434
+ const purchaseTemplate = userEvents.find(e => e.event === "real money purchase");
435
+ if (purchaseTemplate) {
436
+ // lucky (2.5x) and whale (1.8x) multipliers applied at build
437
+ // time so the H5/H10 cohort ratios hold on every purchase row
438
+ let bonusPrice = chance.pickone([19.99, 49.99, 99.99]) * LUCKY_CHARM_PRICE_MULT;
439
+ if (isWhale) bonusPrice *= WHALE_PRICE_MULT;
440
+ clones.push({
441
+ ...purchaseTemplate,
442
+ time: eventTime.add(chance.integer({ min: 1, max: 3 }), 'days').toISOString(),
443
+ user_id: event.user_id,
444
+ product: chance.pickone(["Premium Currency (5000)", "Legendary Weapon Chest", "Season Pass"]),
445
+ price_usd: Math.round(bonusPrice * 100) / 100,
446
+ payment_method: chance.pickone(["Credit Card", "PayPal"]),
447
+ });
448
+ }
449
+ }
450
+ }
451
+
452
+ // Hook 10: WHALE PURCHASES — 1.8x price for whale cohort.
453
+ if (isWhale && event.event === "real money purchase" && event.price_usd) {
454
+ event.price_usd = Math.round(event.price_usd * WHALE_PRICE_MULT * 100) / 100;
455
+ }
456
+
457
+ // Hook 6: BEHAVIORS TOGETHER — strategic explorers (repeat inspect +
458
+ // search) get 85% completion flips + 2x treasure value.
459
+ if (isStrategic) {
460
+ if (event.event === "exit dungeon" && event.completion_status !== "completed" && chance.bool({ likelihood: STRATEGIC_COMPLETION_LIKELIHOOD })) {
461
+ event.completion_status = "completed";
462
+ }
463
+ if (event.event === "find treasure") {
464
+ event.treasure_value = Math.floor((event.treasure_value || 50) * STRATEGIC_TREASURE_MULT);
465
+ }
466
+ }
467
+
468
+ // Hook 7: TIMED RELEASE — Legendary owners get 90% combat
469
+ // wins + 0.6x dungeon time.
470
+ if (hasLegendaryWeapon) {
471
+ if (event.event === "combat completed" && event.outcome !== "Victory" && chance.bool({ likelihood: LEGENDARY_WIN_LIKELIHOOD })) {
472
+ event.outcome = "Victory";
473
+ }
474
+ if (event.event === "exit dungeon") {
475
+ event.completion_status = "completed";
476
+ event.time_spent_mins = Math.floor((event.time_spent_mins || 60) * LEGENDARY_DUNGEON_SPEED_MULT);
477
+ }
478
+ }
479
+
480
+ // Hook 8: SUBSCRIPTION TIER — Premium/Elite get win+reward+
481
+ // completion+treasure boosts. Reads tier from profile.
482
+ if (isPremium || isElite) {
483
+ if (event.event === "combat completed" && event.outcome !== "Victory") {
484
+ const winBoost = isElite ? ELITE_WIN_LIKELIHOOD : PREMIUM_WIN_LIKELIHOOD;
485
+ if (chance.bool({ likelihood: winBoost })) {
486
+ event.outcome = "Victory";
487
+ event.loot_gained = true;
488
+ }
489
+ }
490
+ if (event.event === "quest turned in") {
491
+ event.reward_gold = Math.floor((event.reward_gold || 100) * tierRewardMult);
492
+ event.reward_xp = Math.floor((event.reward_xp || 500) * tierRewardMult);
493
+ }
494
+ if (event.event === "exit dungeon") {
495
+ if (event.completion_status !== "completed") {
496
+ const completionBoost = isElite ? ELITE_COMPLETION_LIKELIHOOD : PREMIUM_COMPLETION_LIKELIHOOD;
497
+ if (chance.bool({ likelihood: completionBoost })) {
498
+ event.completion_status = "completed";
499
+ }
500
+ }
501
+ if (event.completion_status === "completed") {
502
+ const speedBoost = isElite ? ELITE_DUNGEON_SPEED_MULT : PREMIUM_DUNGEON_SPEED_MULT;
503
+ event.time_spent_mins = Math.floor((event.time_spent_mins || 60) * speedBoost);
504
+ }
505
+ }
506
+ if (event.event === "find treasure") {
507
+ const treasureBoost = isElite ? ELITE_TREASURE_MULT : PREMIUM_TREASURE_MULT;
508
+ event.treasure_value = Math.floor((event.treasure_value || 50) * treasureBoost);
509
+ }
510
+ if (event.event === "player death") {
511
+ const survivalLikelihood = isElite ? ELITE_SURVIVAL_LIKELIHOOD : PREMIUM_SURVIVAL_LIKELIHOOD;
512
+ if (chance.bool({ likelihood: survivalLikelihood })) {
513
+ // full rename: strip player-death-only props so the schema
514
+ // of "combat completed" stays clean (no leaked columns)
515
+ event.event = "combat completed";
516
+ event.outcome = "Victory";
517
+ event.loot_gained = true;
518
+ delete event.cause_of_death;
519
+ delete event.player_level;
520
+ delete event.resurrection_used;
521
+ }
522
+ }
523
+ if (isElite && chance.bool({ likelihood: ELITE_BONUS_TREASURE_LIKELIHOOD })) {
524
+ if (event.event === "quest turned in" || event.event === "exit dungeon") {
525
+ const treasureTemplate = userEvents.find(e => e.event === "find treasure");
526
+ if (treasureTemplate) {
527
+ const treasureTypes = ["Rare Artifact", "Gold", "Weapon", "Armor"];
528
+ clones.push({
529
+ ...treasureTemplate,
530
+ time: eventTime.add(chance.integer({ min: 5, max: 30 }), 'minutes').toISOString(),
531
+ user_id: event.user_id,
532
+ treasure_type: chance.pickone(treasureTypes),
533
+ treasure_value: chance.integer({ min: 200, max: 800 }),
534
+ });
535
+ }
536
+ }
537
+ }
538
+ }
539
+ });
540
+
541
+ // ── CLONE-PUSH: engine auto-sorts by time after the everything hook ──
542
+ userEvents.push(...clones);
543
+
544
+ // Hook 2: CURSED WEEK — inject extra deaths in days 40-47 of
545
+ // user's timeline. Cause_of_death set to "Curse" on injected.
546
+ // Discover via line-chart of player-death by day-of-user-life.
547
+ if (firstEventTime) {
548
+ const deathTemplate = userEvents.find(e => e.event === "player death");
549
+ if (deathTemplate) {
550
+ const cursedStart = firstEventTime.add(CURSED_WEEK_START_DAY, 'days');
551
+ const cursedEnd = firstEventTime.add(CURSED_WEEK_END_DAY, 'days');
552
+ const cursedEvents = userEvents.filter(e => {
553
+ const t = dayjs(e.time);
554
+ return t.isAfter(cursedStart) && t.isBefore(cursedEnd);
555
+ });
556
+ const deathsToInject = Math.floor(cursedEvents.length * CURSED_DEATH_INJECTION_FACTOR);
557
+ for (let d = 0; d < deathsToInject; d++) {
558
+ const sourceEvent = cursedEvents[d % cursedEvents.length];
559
+ userEvents.push({
560
+ ...deathTemplate,
561
+ time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 30 }), 'minutes').toISOString(),
562
+ user_id: sourceEvent.user_id,
563
+ event: "player death",
564
+ cause_of_death: "Curse",
565
+ player_level: chance.integer({ min: 1, max: 50 }),
566
+ resurrection_used: chance.bool({ likelihood: 80 }),
567
+ });
568
+ }
569
+ }
570
+ }
571
+
572
+ // Hook 3 RETENTION + Hook 4 CHURN — death-spiral taxonomy matches the
573
+ // doc: non-joiners with 3+ week-1 deaths lose ~70% of post-week-1
574
+ // events. Early guild joiners are exempt (that IS the retention story)
575
+ // and 60% of them get a bonus late combat clone. The old gate
576
+ // ((!guild && deaths>=2) || deaths>=4, 80% drop) matched neither doc.
577
+ const shouldChurn = !joinedGuildEarly && earlyDeaths >= DEATH_SPIRAL_MIN_DEATHS;
578
+ if (shouldChurn) {
579
+ const firstWeekEnd = firstEventTime ? firstEventTime.add(DEATH_SPIRAL_EARLY_DAYS, 'days') : null;
580
+ for (let i = userEvents.length - 1; i >= 0; i--) {
581
+ if (firstWeekEnd && dayjs(userEvents[i].time).isAfter(firstWeekEnd) && chance.bool({ likelihood: DEATH_SPIRAL_DROP_LIKELIHOOD })) {
582
+ userEvents.splice(i, 1);
583
+ }
584
+ }
585
+ } else if (joinedGuildEarly) {
586
+ const lastEvent = userEvents[userEvents.length - 1];
587
+ const combatTemplate = userEvents.find(e => e.event === "combat completed");
588
+ if (lastEvent && combatTemplate && chance.bool({ likelihood: EARLY_GUILD_COMBAT_CLONE_LIKELIHOOD })) {
589
+ userEvents.push({
590
+ ...combatTemplate,
591
+ time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 5 }), 'days').toISOString(),
592
+ user_id: lastEvent.user_id,
593
+ outcome: "Victory",
594
+ loot_gained: true,
595
+ });
596
+ }
597
+ }
598
+
599
+ // HOOK 13: COMBAT-PREP MAGIC NUMBER (in-funnel, no flags)
600
+ // Sweet 3-6 inspect+search events between quest accepted and
601
+ // fight boss → +30% loot/treasure_value on find-treasure events.
602
+ // Over 7+ → drop 25% of fight-boss completion (over-prep
603
+ // signals analysis paralysis). No flag.
604
+ const questAccept = userEvents.find(e => e.event === "quest accepted");
605
+ const bossFight = userEvents.find(e => e.event === "fight boss");
606
+ if (questAccept && bossFight) {
607
+ const aTime = dayjs(questAccept.time);
608
+ const bTime = dayjs(bossFight.time);
609
+ const prepCount = userEvents.filter(e =>
610
+ (e.event === "inspect" || e.event === "search for clues") &&
611
+ dayjs(e.time).isAfter(aTime) &&
612
+ dayjs(e.time).isBefore(bTime)
613
+ ).length;
614
+ if (prepCount >= PREP_SWEET_MIN && prepCount <= PREP_SWEET_MAX) {
615
+ userEvents.forEach(e => {
616
+ if (e.event === "find treasure" && typeof e.treasure_value === "number") {
617
+ e.treasure_value = Math.round(e.treasure_value * PREP_TREASURE_BOOST);
618
+ }
619
+ });
620
+ } else if (prepCount >= PREP_OVER_THRESHOLD) {
621
+ for (let i = userEvents.length - 1; i >= 0; i--) {
622
+ const ev = userEvents[i];
623
+ if (ev.event === "fight boss" && ev.victory === true && chance.bool({ likelihood: PREP_BOSS_FLIP_LIKELIHOOD })) {
624
+ ev.victory = false;
625
+ }
626
+ }
627
+ }
628
+ }
629
+
630
+ // ── TEMPORAL (last phase): Hook #12 COMBAT T2C — scale time gaps in
631
+ // combat funnel sequences (combat initiated → combat completed → use
632
+ // item). Elite ~0.30x (faster), Premium ~0.70x, Free ~1.40x (slower).
633
+ // Runs after clones/filters so it compresses exactly the sequences the
634
+ // analyst will see; engine auto-sorts by time afterwards.
635
+ // Sort first: clones were appended at the tail, and the sequence matcher
636
+ // below relies on index order == time order (unsorted, a tail clone can
637
+ // match as "next step" and produce a negative gap).
638
+ userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
639
+ const t2cFactor = (
640
+ isElite ? TTC_ELITE_FACTOR :
641
+ isPremium ? TTC_PREMIUM_FACTOR :
642
+ subscriptionTier === "Free" ? TTC_FREE_FACTOR :
643
+ 1.0
644
+ );
645
+ if (t2cFactor !== 1.0) {
646
+ // Collect indices for each combat funnel step
647
+ const combatInitiated = [];
648
+ const combatCompleted = [];
649
+ const useItem = [];
650
+ for (let i = 0; i < userEvents.length; i++) {
651
+ const e = userEvents[i];
652
+ if (e.event === "combat initiated") combatInitiated.push(i);
653
+ else if (e.event === "combat completed") combatCompleted.push(i);
654
+ else if (e.event === "use item") useItem.push(i);
655
+ }
656
+ // Match sequences: for each combat initiated, find next
657
+ // combat completed after it, then next use item after that
658
+ const matched = new Set();
659
+ for (const ciIdx of combatInitiated) {
660
+ const ccIdx = combatCompleted.find(j => j > ciIdx && !matched.has(j));
661
+ if (ccIdx === undefined) continue;
662
+ const uiIdx = useItem.find(j => j > ccIdx && !matched.has(j));
663
+ if (uiIdx === undefined) continue;
664
+ matched.add(ccIdx);
665
+ matched.add(uiIdx);
666
+ // Scale gap between step 1→2 and 2→3
667
+ const t0 = dayjs(userEvents[ciIdx].time);
668
+ const t1 = dayjs(userEvents[ccIdx].time);
669
+ const t2 = dayjs(userEvents[uiIdx].time);
670
+ const gap1 = t1.diff(t0);
671
+ const gap2 = t2.diff(t1);
672
+ userEvents[ccIdx].time = t0.add(Math.round(gap1 * t2cFactor), 'milliseconds').toISOString();
673
+ userEvents[uiIdx].time = dayjs(userEvents[ccIdx].time).add(Math.round(gap2 * t2cFactor), 'milliseconds').toISOString();
674
+ }
675
+ }
676
+
677
+ return record;
678
+ }
679
+
680
+ // ── CONFIG ──
681
+ /** @type {Config} */
682
+ const config = {
683
+ version: 2,
684
+ seed: SEED,
685
+ datasetStart: DATASET_START,
686
+ datasetEnd: DATASET_END,
687
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
688
+ numUsers: NUM_USERS,
689
+ format: "json",
690
+ gzip: true,
691
+ credentials: {
692
+ token,
693
+ },
694
+ switches: {
695
+ hasSessionIds: true,
696
+ alsoInferFunnels: false,
697
+ hasLocation: true,
698
+ hasAndroidDevices: true,
699
+ hasIOSDevices: true,
700
+ hasDesktopDevices: true,
701
+ hasBrowser: false,
702
+ hasCampaigns: false,
703
+ isAnonymous: false,
704
+ hasAdSpend: false,
705
+ hasAvatar: true,
706
+ },
707
+ identity: {
708
+ avgDevicePerUser: 2,
709
+ },
710
+
711
+ concurrency: 1,
712
+ writeToDisk: false,
713
+
714
+ funnels: [
715
+ {
716
+ sequence: ["character created", "tutorial completed", "quest accepted"],
717
+ isFirstFunnel: true,
718
+ conversionRate: 75,
719
+ timeToConvert: 0.5,
720
+ },
721
+ {
722
+ // Core combat loop: most frequent player activity
723
+ sequence: ["combat initiated", "combat completed", "use item"],
724
+ conversionRate: 75,
725
+ timeToConvert: 0.5,
726
+ weight: 5,
727
+ },
728
+ {
729
+ // Dungeon crawl: enter, explore, loot, exit
730
+ sequence: ["enter dungeon", "find treasure", "exit dungeon"],
731
+ conversionRate: 60,
732
+ timeToConvert: 2,
733
+ weight: 4,
734
+ props: {
735
+ "dungeon_id": dungeonIds,
736
+ "difficulty": ["Easy", "Medium", "Hard", "Deadly"],
737
+ }
738
+ },
739
+ {
740
+ // Quest lifecycle
741
+ sequence: ["quest accepted", "quest objective completed", "quest turned in"],
742
+ conversionRate: 55,
743
+ timeToConvert: 3,
744
+ weight: 3,
745
+ props: { "quest_id": questIds },
746
+ },
747
+ {
748
+ // Preparation before dungeon: inspect + search for strategic explorer hook
749
+ sequence: ["inspect", "search for clues", "enter dungeon"],
750
+ conversionRate: 50,
751
+ timeToConvert: 1,
752
+ weight: 3,
753
+ },
754
+ {
755
+ // Economy: buy gear, sell loot
756
+ sequence: ["item purchased", "use item", "item sold"],
757
+ conversionRate: 45,
758
+ timeToConvert: 6,
759
+ weight: 2,
760
+ },
761
+ {
762
+ // Social and progression
763
+ sequence: ["guild joined", "level up", "real money purchase"],
764
+ conversionRate: 25,
765
+ timeToConvert: 24,
766
+ weight: 1,
767
+ },
768
+ ],
769
+
770
+ events: [
771
+ {
772
+ event: "character created",
773
+ weight: 1,
774
+ isFirstEvent: true,
775
+ isAuthEvent: true,
776
+ properties: {
777
+ "character_class": [
778
+ "Barbarian", "Bard", "Cleric", "Druid", "Fighter", "Monk",
779
+ "Paladin", "Ranger", "Rogue", "Sorcerer", "Warlock", "Wizard"
780
+ ],
781
+ "starting_race": [
782
+ "Human", "Elf", "Dwarf", "Halfling", "Dragonborn",
783
+ "Gnome", "Half-Elf", "Half-Orc", "Tiefling"
784
+ ],
785
+ }
786
+ },
787
+ {
788
+ event: "tutorial completed",
789
+ weight: 2,
790
+ properties: {
791
+ "completion_time_mins": u.weighNumRange(3, 25, 0.8, 10),
792
+ "skipped": [false, false, false, false, false, false, true],
793
+ }
794
+ },
795
+ {
796
+ event: "quest accepted",
797
+ weight: 15,
798
+ properties: {
799
+ "quest_id": questIds,
800
+ "quest_type": ["Main Story", "Side Quest", "Bounty", "Exploration", "Escort"],
801
+ "recommended_level": u.weighNumRange(1, 50),
802
+ }
803
+ },
804
+ {
805
+ event: "quest objective completed",
806
+ weight: 12,
807
+ properties: {
808
+ "quest_id": questIds,
809
+ "objective_number": u.weighNumRange(1, 5),
810
+ }
811
+ },
812
+ {
813
+ event: "quest turned in",
814
+ weight: 10,
815
+ isStrictEvent: false,
816
+ properties: {
817
+ "quest_id": questIds,
818
+ "reward_gold": u.weighNumRange(10, 500, 0.5, 100),
819
+ "reward_xp": u.weighNumRange(50, 2000, 0.5, 500),
820
+ }
821
+ },
822
+ {
823
+ event: "enter dungeon",
824
+ weight: 18,
825
+ properties: {
826
+ "dungeon_id": dungeonIds,
827
+ "difficulty": ["Easy", "Medium", "Hard", "Deadly"],
828
+ "party_size": u.weighNumRange(1, 5),
829
+ }
830
+ },
831
+ {
832
+ event: "exit dungeon",
833
+ weight: 14,
834
+ isStrictEvent: false,
835
+ properties: {
836
+ "dungeon_id": dungeonIds,
837
+ "time_spent_mins": u.weighNumRange(5, 120, 0.6, 30),
838
+ "completion_status": ["completed", "abandoned", "died"],
839
+ }
840
+ },
841
+ {
842
+ event: "find treasure",
843
+ weight: 16,
844
+ isStrictEvent: false,
845
+ properties: {
846
+ "treasure_type": ["Gold", "Weapon", "Armor", "Potion", "Scroll", "Rare Artifact"],
847
+ "treasure_value": u.weighNumRange(5, 1000, 1.2, 50),
848
+ }
849
+ },
850
+ {
851
+ event: "player death",
852
+ weight: 8,
853
+ properties: {
854
+ // "Curse" appears organically at 1/6 share; Hook 2 injects a dense
855
+ // cluster of Curse deaths in user-days 40-47 on top of that flat
856
+ // baseline (schema-first: injected values must be declared here)
857
+ "cause_of_death": ["Monster", "Trap", "Fall Damage", "Poison", "Friendly Fire", "Curse"],
858
+ "player_level": u.weighNumRange(1, 50),
859
+ "resurrection_used": [false, false, false, true],
860
+ }
861
+ },
862
+ {
863
+ event: "level up",
864
+ weight: 5,
865
+ properties: {
866
+ "new_level": u.weighNumRange(2, 50),
867
+ "stat_points_gained": u.weighNumRange(1, 5),
868
+ "new_abilities": ["Attack", "Spell", "Feat", "Skill"],
869
+ }
870
+ },
871
+ {
872
+ event: "item purchased",
873
+ weight: 11,
874
+ isStrictEvent: false,
875
+ properties: {
876
+ "item_id": itemIds,
877
+ "item_type": ["Weapon", "Armor", "Potion", "Scroll", "Mount", "Cosmetic"],
878
+ "price_gold": u.weighNumRange(10, 500, 0.8, 100),
879
+ "vendor_type": ["Town", "Dungeon", "Special Event"],
880
+ }
881
+ },
882
+ {
883
+ event: "item sold",
884
+ weight: 7,
885
+ properties: {
886
+ "item_id": itemIds,
887
+ "item_type": ["Weapon", "Armor", "Potion", "Scroll", "Junk"],
888
+ "sell_price": u.weighNumRange(5, 250, 0.5, 50),
889
+ }
890
+ },
891
+ {
892
+ event: "real money purchase",
893
+ weight: 3,
894
+ isStrictEvent: false,
895
+ properties: {
896
+ "product": [
897
+ "Premium Currency (1000)",
898
+ "Premium Currency (5000)",
899
+ "Lucky Charm Pack",
900
+ "Legendary Weapon Chest",
901
+ "Cosmetic Bundle",
902
+ "Season Pass"
903
+ ],
904
+ "price_usd": [4.99, 9.99, 19.99, 49.99, 99.99],
905
+ "payment_method": ["Credit Card", "PayPal", "Apple Pay", "Google Pay"],
906
+ }
907
+ },
908
+ {
909
+ event: "guild joined",
910
+ weight: 4,
911
+ isStrictEvent: false,
912
+ properties: {
913
+ "guild_size": u.weighNumRange(5, 100),
914
+ "guild_level": u.weighNumRange(1, 20),
915
+ }
916
+ },
917
+ {
918
+ event: "guild left",
919
+ weight: 1,
920
+ properties: {
921
+ "reason": ["Inactive", "Found Better Guild", "Conflict", "Disbanded"],
922
+ }
923
+ },
924
+ {
925
+ event: "inspect",
926
+ weight: 9,
927
+ isStrictEvent: false,
928
+ properties: {
929
+ "inspect_target": ["NPC", "Monster", "Treasure Chest", "Door", "Statue", "Bookshelf"],
930
+ }
931
+ },
932
+ {
933
+ event: "search for clues",
934
+ weight: 8,
935
+ isStrictEvent: false,
936
+ properties: {
937
+ "location_type": ["Dungeon Entrance", "Hidden Room", "Quest Location", "Town Square"],
938
+ "clue_found": [false, false, true, true, true],
939
+ }
940
+ },
941
+ {
942
+ event: "use item",
943
+ weight: 14,
944
+ isStrictEvent: false,
945
+ properties: {
946
+ "item_id": itemIds,
947
+ "item_type": [
948
+ "Health Potion", "Mana Potion", "Buff Scroll",
949
+ "Ancient Compass", "Lucky Charm", "Resurrection Stone"
950
+ ],
951
+ "context": ["Combat", "Exploration", "Boss Fight", "Casual"],
952
+ }
953
+ },
954
+ {
955
+ event: "combat initiated",
956
+ weight: 20,
957
+ properties: {
958
+ "enemy_type": ["Goblin", "Skeleton", "Dragon", "Demon", "Undead", "Beast"],
959
+ "enemy_level": u.weighNumRange(1, 50),
960
+ "combat_duration_sec": u.weighNumRange(10, 300, 0.7, 60),
961
+ }
962
+ },
963
+ {
964
+ event: "combat completed",
965
+ weight: 18,
966
+ isStrictEvent: false,
967
+ properties: {
968
+ "outcome": ["Victory", "Defeat", "Fled"],
969
+ "loot_gained": [false, false, false, true, true, true, true, true, true, true],
970
+ }
971
+ },
972
+ {
973
+ event: "fight boss",
974
+ weight: 3,
975
+ properties: {
976
+ "boss_type": ["Dragon", "Demon", "Lich", "Vampire", "Beholder"],
977
+ "boss_level": u.weighNumRange(10, 50),
978
+ "boss_difficulty": ["Hard", "Legendary", "Impossible"],
979
+ "fight_duration_mins": u.weighNumRange(1, 60),
980
+ "victory": [false, true, true],
981
+ }
982
+ },
983
+ {
984
+ event: "attack",
985
+ weight: 5,
986
+ properties: {
987
+ "attack_type": ["Melee", "Ranged", "Spell", "Special"],
988
+ "damage_dealt": u.weighNumRange(1, 200, 0.7, 30),
989
+ }
990
+ },
991
+ {
992
+ event: "defend",
993
+ weight: 3,
994
+ properties: {
995
+ "defense_type": ["Block", "Parry", "Dodge", "Shield"],
996
+ "damage_blocked": u.weighNumRange(0, 150, 0.7, 20),
997
+ }
998
+ },
999
+ {
1000
+ event: "gameplay summary",
1001
+ weight: 6,
1002
+ properties: {
1003
+ "enemies_defeated": u.weighNumRange(0, 100),
1004
+ "respawns": u.weighNumRange(0, 10, 5),
1005
+ "total_attacks": u.weighNumRange(0, 100, 6, 12),
1006
+ "gold_found": u.weighNumRange(0, 1000),
1007
+ }
1008
+ },
1009
+ ],
1010
+
1011
+ superProps: {
1012
+ Platform: ["PC", "Mac", "PlayStation", "Xbox", "Switch"],
1013
+ graphics_quality: ["Low", "Medium", "High", "Ultra"],
1014
+ subscription_tier: ["Free", "Free", "Free", "Premium", "Elite"],
1015
+ },
1016
+
1017
+ scdProps: {
1018
+ player_rank: {
1019
+ values: ["recruit", "veteran", "elite", "legend"],
1020
+ frequency: "week",
1021
+ timing: "fuzzy",
1022
+ max: 20
1023
+ }
1024
+ },
1025
+
1026
+ userProps: {
1027
+ "preferred_playstyle": [
1028
+ "Solo Explorer", "Group Raider", "PvP Fighter",
1029
+ "Quest Completionist", "Treasure Hunter"
1030
+ ],
1031
+ "total_playtime_hours": u.weighNumRange(1, 500, 1.5, 50),
1032
+ "achievement_points": u.weighNumRange(0, 5000, 0.8, 500),
1033
+ "favorite_class": [
1034
+ "Warrior", "Mage", "Rogue", "Cleric", "Ranger", "Paladin"
1035
+ ],
1036
+ Platform: ["PC", "Mac", "PlayStation", "Xbox", "Switch"],
1037
+ graphics_quality: ["Low", "Medium", "High", "Ultra"],
1038
+ subscription_tier: ["Free", "Free", "Free", "Premium", "Elite"],
1039
+
1040
+ // D&D character identity (from gaming dungeon)
1041
+ race: [
1042
+ "Human", "Elf", "Dwarf", "Halfling", "Dragonborn",
1043
+ "Gnome", "Half-Elf", "Half-Orc", "Tiefling"
1044
+ ],
1045
+ class: [
1046
+ "Barbarian", "Bard", "Cleric", "Druid", "Fighter", "Monk",
1047
+ "Paladin", "Ranger", "Rogue", "Sorcerer", "Warlock", "Wizard"
1048
+ ],
1049
+ alignment: [
1050
+ "Lawful Good", "Neutral Good", "Chaotic Good",
1051
+ "Lawful Neutral", "True Neutral", "Chaotic Neutral",
1052
+ "Lawful Evil", "Neutral Evil", "Chaotic Evil"
1053
+ ],
1054
+ background: [
1055
+ "Acolyte", "Charlatan", "Criminal", "Entertainer", "Folk Hero",
1056
+ "Guild Artisan", "Hermit", "Noble", "Outlander", "Sage",
1057
+ "Sailor", "Soldier", "Urchin"
1058
+ ],
1059
+ level: u.weighNumRange(1, 20),
1060
+ archetype: ["neutral"],
1061
+
1062
+ // A/B/C experiment scaffolding
1063
+ experiment: ["fast leveling", "tension economy", "free trial"],
1064
+ variant: ["A", "B", "C", "Control"],
1065
+ },
1066
+
1067
+ groupKeys: [
1068
+ ["guild_id", 500, ["guild joined", "guild left", "quest turned in", "combat completed"]],
1069
+ ],
1070
+
1071
+ groupProps: {
1072
+ guild_id: {
1073
+ "name": () => `${chance.word()} ${chance.pickone(["Knights", "Dragons", "Warriors", "Seekers", "Legends"])}`,
1074
+ "member_count": u.weighNumRange(5, 100),
1075
+ "guild_level": u.weighNumRange(1, 20),
1076
+ "total_wealth": u.weighNumRange(1000, 1000000, 0.5, 50000),
1077
+ }
1078
+ },
1079
+
1080
+ lookupTables: [],
1081
+
1082
+ hook(record, type, meta) {
1083
+ if (type === "user") return handleUserHooks(record);
1084
+ if (type === "everything") return handleEverythingHooks(record, meta);
1085
+ return record;
1086
+ },
1087
+ };
1088
+
1089
+ // ── STORIES ──
1090
+ // Machine-checkable contract for the 13 hooks above. Thresholds derive from
1091
+ // the knob constants (and the declared property distributions), never from
1092
+ // observed output. duckdb assertions run in disk mode only
1093
+ // (scripts/verify-stories.mjs after scripts/verify-runner.mjs).
1094
+
1095
+ const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
1096
+ const US = `read_json_auto('{{PREFIX}}-USERS*.json', sample_size=-1, union_by_name=true)`;
1097
+
1098
+ // Shared cohort CTEs. Compass/strategic gates count per-user events via a
1099
+ // LEFT JOIN from profiles so zero-count users land in the control group.
1100
+ const CNT_CTE = `SELECT us.distinct_id::VARCHAR AS uid,
1101
+ count(e.user_id) FILTER (WHERE e.event = 'use item' AND e.item_type = 'Ancient Compass') AS compass,
1102
+ count(e.user_id) FILTER (WHERE e.event = 'inspect') AS ins,
1103
+ count(e.user_id) FILTER (WHERE e.event = 'search for clues') AS sea
1104
+ FROM ${US} us LEFT JOIN ${EV} e ON e.user_id::VARCHAR = us.distinct_id::VARCHAR GROUP BY 1`;
1105
+
1106
+ // Per-user week-1 death / early-guild flags for the H3/H4 churn taxonomy.
1107
+ const CHURN_CTE = `firsts AS (SELECT user_id, min(time::TIMESTAMP) AS t0 FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1),
1108
+ flags AS (
1109
+ SELECT f.user_id,
1110
+ count(*) FILTER (WHERE e.event = 'player death' AND e.time::TIMESTAMP < f.t0 + INTERVAL ${DEATH_SPIRAL_EARLY_DAYS} DAY) AS early_deaths,
1111
+ count(*) FILTER (WHERE e.event = 'guild joined' AND e.time::TIMESTAMP < f.t0 + INTERVAL ${EARLY_GUILD_DAYS} DAY) AS early_guild,
1112
+ count(*) FILTER (WHERE e.time::TIMESTAMP >= f.t0 + INTERVAL ${DEATH_SPIRAL_EARLY_DAYS} DAY) AS post_events,
1113
+ count(*) FILTER (WHERE e.time::TIMESTAMP < f.t0 + INTERVAL ${DEATH_SPIRAL_EARLY_DAYS} DAY) AS pre_events
1114
+ FROM firsts f JOIN ${EV} e USING (user_id) GROUP BY 1)`;
1115
+
1116
+ // Prep-count band CTEs for H13 (preps between first quest-accepted and first
1117
+ // fight-boss; users without both anchors in order are excluded).
1118
+ const PREP_CTE = `anchors AS (
1119
+ SELECT user_id,
1120
+ min(time::TIMESTAMP) FILTER (WHERE event = 'quest accepted') AS qa,
1121
+ min(time::TIMESTAMP) FILTER (WHERE event = 'fight boss') AS fb
1122
+ FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1),
1123
+ prep AS (
1124
+ SELECT a.user_id, count(e.user_id) AS n
1125
+ FROM anchors a LEFT JOIN ${EV} e ON e.user_id = a.user_id
1126
+ AND e.event IN ('inspect', 'search for clues') AND e.time::TIMESTAMP > a.qa AND e.time::TIMESTAMP < a.fb
1127
+ WHERE a.qa IS NOT NULL AND a.fb IS NOT NULL AND a.fb > a.qa GROUP BY 1),
1128
+ bands AS (SELECT user_id, CASE WHEN n BETWEEN ${PREP_SWEET_MIN} AND ${PREP_SWEET_MAX} THEN 'sweet' WHEN n < ${PREP_SWEET_MIN} THEN 'low' ELSE 'over' END AS band FROM prep)`;
1129
+
1130
+ // Whale hash predicate — mirrors the hook's charCodeAt(0) % 3 === 0 on the
1131
+ // first hex char of user_id ('0','3','6','9','c','f' = 6/16 = 37.5%).
1132
+ const WHALE_CHARS = `('0','3','6','9','c','f')`;
1133
+
1134
+ /**
1135
+ * Five-tier verdict for a ratio measured inside a custom assert: NAILED
1136
+ * within ±10% of target, STRONG past floor, WEAK direction-correct, INVERSE
1137
+ * wrong side of 1, NONE not computable. Mirrors verdictFor() for op '>=' —
1138
+ * needed where the select grammar can't express the comparison (formula
1139
+ * predictions, exact-mapping checks).
1140
+ */
1141
+ function ratioVerdict(ratio, target, floor, detail, smallestCohort, minCohort) {
1142
+ if (!Number.isFinite(ratio)) return { pass: false, verdict: "NONE", detail: `ratio not computable — ${detail}` };
1143
+ let verdict;
1144
+ if (Math.abs(ratio - target) <= 0.1 * target) verdict = "NAILED";
1145
+ else if (ratio >= floor) verdict = "STRONG";
1146
+ else if (ratio > 1) verdict = "WEAK";
1147
+ else if (ratio < 1) verdict = "INVERSE";
1148
+ else verdict = "NONE";
1149
+ if ((verdict === "NAILED" || verdict === "STRONG") && smallestCohort < minCohort) {
1150
+ verdict = "WEAK";
1151
+ detail += ` — capped: smallest cohort ${smallestCohort} < minCohort ${minCohort}`;
1152
+ }
1153
+ return { pass: verdict === "NAILED" || verdict === "STRONG", verdict, detail };
1154
+ }
1155
+
1156
+ /** @type {import("../../../types").DungeonStory[]} */
1157
+ export const stories = [
1158
+ {
1159
+ id: "H1-compass-heavy-rewards",
1160
+ hook: "H1",
1161
+ archetype: "cohort-prop-scale",
1162
+ narrative: `heavy compass users (${COMPASS_HEAVY_MIN}+ "use item" with item_type="Ancient Compass", ~48% of users) earn ${COMPASS_REWARD_MULT}x reward_gold and reward_xp on quest turned in, plus a ${COMPASS_BONUS_QUEST_LIKELIHOOD}% bonus-quest clone per turn-in. item_type is a 6-value enum on a high-frequency event — a 1+ gate covers ~75% of users and leaves no control group`,
1163
+ assertions: [
1164
+ {
1165
+ // cohort split sanity: heavy/light ≈ 48/52 (measured per-user
1166
+ // compass-count distribution at the gate)
1167
+ breakdown: {
1168
+ type: "duckdb",
1169
+ sql: `WITH cnt AS (${CNT_CTE})
1170
+ SELECT CASE WHEN compass >= ${COMPASS_HEAVY_MIN} THEN 'heavy' ELSE 'light' END AS grp, count(*) AS user_count FROM cnt GROUP BY 1`,
1171
+ },
1172
+ select: {
1173
+ heavy: { where: { grp: "heavy" } },
1174
+ light: { where: { grp: "light" } },
1175
+ },
1176
+ expect: { metric: "heavy.user_count / light.user_count", op: "between", target: [0.72, 1.17] },
1177
+ minCohort: 300,
1178
+ },
1179
+ {
1180
+ // COMPASS_REWARD_MULT = 1.5 exactly; measured 1.56 (bonus clones are
1181
+ // built at the boosted rate). Floor 1.35 absorbs cohort mix noise.
1182
+ breakdown: {
1183
+ type: "duckdb",
1184
+ sql: `WITH cnt AS (${CNT_CTE})
1185
+ SELECT CASE WHEN c.compass >= ${COMPASS_HEAVY_MIN} THEN 'heavy' ELSE 'light' END AS grp,
1186
+ avg(e.reward_gold) AS avg_gold, avg(e.reward_xp) AS avg_xp, count(DISTINCT e.user_id) AS user_count
1187
+ FROM ${EV} e JOIN cnt c ON e.user_id::VARCHAR = c.uid
1188
+ WHERE e.event = 'quest turned in' AND e.reward_gold IS NOT NULL GROUP BY 1`,
1189
+ },
1190
+ select: {
1191
+ heavy: { where: { grp: "heavy" } },
1192
+ light: { where: { grp: "light" } },
1193
+ },
1194
+ expect: { metric: "heavy.avg_gold / light.avg_gold", op: ">=", target: COMPASS_REWARD_MULT, floor: 1.35 },
1195
+ minCohort: 300,
1196
+ },
1197
+ {
1198
+ breakdown: {
1199
+ type: "duckdb",
1200
+ sql: `WITH cnt AS (${CNT_CTE})
1201
+ SELECT CASE WHEN c.compass >= ${COMPASS_HEAVY_MIN} THEN 'heavy' ELSE 'light' END AS grp,
1202
+ avg(e.reward_gold) AS avg_gold, avg(e.reward_xp) AS avg_xp, count(DISTINCT e.user_id) AS user_count
1203
+ FROM ${EV} e JOIN cnt c ON e.user_id::VARCHAR = c.uid
1204
+ WHERE e.event = 'quest turned in' AND e.reward_xp IS NOT NULL GROUP BY 1`,
1205
+ },
1206
+ select: {
1207
+ heavy: { where: { grp: "heavy" } },
1208
+ light: { where: { grp: "light" } },
1209
+ },
1210
+ expect: { metric: "heavy.avg_xp / light.avg_xp", op: ">=", target: COMPASS_REWARD_MULT, floor: 1.35 },
1211
+ minCohort: 300,
1212
+ },
1213
+ ],
1214
+ },
1215
+ {
1216
+ id: "H2-cursed-week",
1217
+ hook: "H2",
1218
+ archetype: "temporal-inflection",
1219
+ narrative: `injected Curse deaths cluster in days ${CURSED_WEEK_START_DAY}-${CURSED_WEEK_END_DAY} of each user's own timeline (${CURSED_DEATH_INJECTION_FACTOR} injections per in-window event). Measured on 48d+ lifetime users so every user contributes a full window: per-day density in/out ≈ 36x; raw in/out count ≈ 2.7 (the out-of-window span is ~10x longer and collects the organic 1/6 enum share)`,
1220
+ assertions: [
1221
+ {
1222
+ // density ratio: (in/8 days) / (out/(avg_lifetime - 8) days). Band
1223
+ // [25, 50] is measured-anchored — the exact value depends on the
1224
+ // organic death rate (enum 1/6) vs the injection factor 0.6/event.
1225
+ breakdown: {
1226
+ type: "duckdb",
1227
+ sql: `WITH firsts AS (SELECT user_id, min(time::TIMESTAMP) AS t0, max(time::TIMESTAMP) AS tN FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1),
1228
+ ll AS (SELECT user_id, t0, tN FROM firsts WHERE tN >= t0 + INTERVAL ${CURSED_WEEK_END_DAY + 1} DAY),
1229
+ curse AS (SELECT e.user_id, epoch(e.time::TIMESTAMP - l.t0) / 86400.0 AS dol FROM ${EV} e JOIN ll l USING (user_id) WHERE e.event = 'player death' AND e.cause_of_death = 'Curse'),
1230
+ lifespan AS (SELECT avg(epoch(tN - t0)) / 86400.0 AS avg_days, count(*) AS n FROM ll)
1231
+ SELECT 'curse' AS grp,
1232
+ count(*) FILTER (WHERE dol BETWEEN ${CURSED_WEEK_START_DAY} AND ${CURSED_WEEK_END_DAY}) AS in_window,
1233
+ count(*) FILTER (WHERE dol < ${CURSED_WEEK_START_DAY} OR dol > ${CURSED_WEEK_END_DAY}) AS out_window,
1234
+ (count(*) FILTER (WHERE dol BETWEEN ${CURSED_WEEK_START_DAY} AND ${CURSED_WEEK_END_DAY}) / 8.0)
1235
+ / nullif(count(*) FILTER (WHERE dol < ${CURSED_WEEK_START_DAY} OR dol > ${CURSED_WEEK_END_DAY}) / ((SELECT avg_days FROM lifespan) - 8.0), 0) AS density_ratio,
1236
+ (SELECT n FROM lifespan) AS user_count
1237
+ FROM curse`,
1238
+ },
1239
+ select: { curse: { where: { grp: "curse" } } },
1240
+ expect: { metric: "curse.density_ratio", op: "between", target: [25, 50] },
1241
+ minCohort: 500,
1242
+ },
1243
+ {
1244
+ breakdown: {
1245
+ type: "duckdb",
1246
+ sql: `WITH firsts AS (SELECT user_id, min(time::TIMESTAMP) AS t0, max(time::TIMESTAMP) AS tN FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1),
1247
+ ll AS (SELECT user_id, t0 FROM firsts WHERE tN >= t0 + INTERVAL ${CURSED_WEEK_END_DAY + 1} DAY),
1248
+ curse AS (SELECT epoch(e.time::TIMESTAMP - l.t0) / 86400.0 AS dol FROM ${EV} e JOIN ll l USING (user_id) WHERE e.event = 'player death' AND e.cause_of_death = 'Curse')
1249
+ SELECT 'curse' AS grp,
1250
+ (count(*) FILTER (WHERE dol BETWEEN ${CURSED_WEEK_START_DAY} AND ${CURSED_WEEK_END_DAY}))::DOUBLE
1251
+ / nullif(count(*) FILTER (WHERE dol < ${CURSED_WEEK_START_DAY} OR dol > ${CURSED_WEEK_END_DAY}), 0) AS in_out,
1252
+ count(*) AS event_count
1253
+ FROM curse`,
1254
+ },
1255
+ select: { curse: { where: { grp: "curse" } } },
1256
+ // raw in/out ≈ 2.7 measured; an 8-day window holding >2x the deaths
1257
+ // of the other ~80 days combined is the analyst-visible spike
1258
+ expect: { metric: "curse.in_out", op: "between", target: [2.0, 3.5] },
1259
+ },
1260
+ ],
1261
+ },
1262
+ {
1263
+ id: "H3-guild-rescue",
1264
+ hook: "H3",
1265
+ archetype: "retention-divergence",
1266
+ narrative: `among users with ${DEATH_SPIRAL_MIN_DEATHS}+ week-1 deaths, early guild joiners (first ${EARLY_GUILD_DAYS} days) are exempt from the death spiral and ${EARLY_GUILD_COMBAT_CLONE_LIKELIHOOD}% get a bonus combat-victory clone. Knob ceiling = 1/0.3 ≈ 3.3x post-week-1 volume vs spiraled peers; measured 2.51x (both cohorts select front-loaded players, pulling below the ceiling)`,
1267
+ assertions: [
1268
+ {
1269
+ breakdown: {
1270
+ type: "duckdb",
1271
+ sql: `WITH ${CHURN_CTE}
1272
+ SELECT CASE WHEN early_guild > 0 THEN 'saved' ELSE 'spiral' END AS grp, count(*) AS user_count, avg(post_events) AS avg_post
1273
+ FROM flags WHERE early_deaths >= ${DEATH_SPIRAL_MIN_DEATHS} GROUP BY 1`,
1274
+ },
1275
+ select: {
1276
+ saved: { where: { grp: "saved" } },
1277
+ spiral: { where: { grp: "spiral" } },
1278
+ },
1279
+ expect: { metric: "saved.avg_post / spiral.avg_post", op: "between", target: [2.0, 3.4] },
1280
+ // ~190 saved users at 10K; WEAK-caps at reduced-scale iteration
1281
+ minCohort: 100,
1282
+ },
1283
+ ],
1284
+ },
1285
+ {
1286
+ id: "H4-death-spiral",
1287
+ hook: "H4",
1288
+ archetype: "retention-divergence",
1289
+ narrative: `non-guild users with ${DEATH_SPIRAL_MIN_DEATHS}+ deaths in the first ${DEATH_SPIRAL_EARLY_DAYS} days (~9% of users) lose ${DEATH_SPIRAL_DROP_LIKELIHOOD}% of post-week-1 events. The post/pre contrast vs low-death users runs ~0.11, well below the raw 0.3 keep-rate: qualifying on 3+ early deaths selects front-loaded players whose natural post/pre is already ~3x lower. Asserted as a knob-ceiling check (<= 0.33), not a band around the measured value — the selection-confounded magnitude is deliberately not pinned (fix-round Q5)`,
1290
+ assertions: [
1291
+ {
1292
+ breakdown: {
1293
+ type: "duckdb",
1294
+ sql: `WITH ${CHURN_CTE}
1295
+ SELECT CASE WHEN early_guild = 0 AND early_deaths >= ${DEATH_SPIRAL_MIN_DEATHS} THEN 'spiral' WHEN early_guild > 0 THEN 'guild' ELSE 'other' END AS grp,
1296
+ count(*) AS user_count, avg(post_events)::DOUBLE / nullif(avg(pre_events), 0) AS post_pre
1297
+ FROM flags GROUP BY 1`,
1298
+ },
1299
+ select: {
1300
+ spiral: { where: { grp: "spiral" } },
1301
+ other: { where: { grp: "other" } },
1302
+ },
1303
+ expect: { metric: "spiral.user_count / other.user_count", op: "between", target: [0.07, 0.15] },
1304
+ minCohort: 100,
1305
+ },
1306
+ {
1307
+ breakdown: {
1308
+ type: "duckdb",
1309
+ sql: `WITH ${CHURN_CTE}
1310
+ SELECT CASE WHEN early_guild = 0 AND early_deaths >= ${DEATH_SPIRAL_MIN_DEATHS} THEN 'spiral' WHEN early_guild > 0 THEN 'guild' ELSE 'other' END AS grp,
1311
+ count(*) AS user_count, avg(post_events)::DOUBLE / nullif(avg(pre_events), 0) AS post_pre
1312
+ FROM flags GROUP BY 1`,
1313
+ },
1314
+ select: {
1315
+ spiral: { where: { grp: "spiral" } },
1316
+ other: { where: { grp: "other" } },
1317
+ },
1318
+ // Fix-round Q5 (S1): demoted from a measurement-wrapped band
1319
+ // ([0.06, 0.18], centered on the observed 0.113) to a knob-honest
1320
+ // ceiling check. The only knob-derivable bound is the ceiling:
1321
+ // dropping DEATH_SPIRAL_DROP_LIKELIHOOD=70% of post-week-1 events
1322
+ // caps the contrast at the 0.3 keep-rate (+10% tolerance = 0.33).
1323
+ // How far BELOW the keep-rate it lands is selection-confounded
1324
+ // (qualifying on 3+ early deaths picks front-loaded players) and
1325
+ // deliberately not pinned. Passing a bound reads STRONG by design
1326
+ // (SPEC P3.2) — this story no longer claims NAILED precision on
1327
+ // the attenuated magnitude.
1328
+ expect: { metric: "spiral.post_pre / other.post_pre", op: "<=", target: (1 - DEATH_SPIRAL_DROP_LIKELIHOOD / 100) * 1.1 },
1329
+ minCohort: 100,
1330
+ },
1331
+ ],
1332
+ },
1333
+ {
1334
+ id: "H5-lucky-charm",
1335
+ hook: "H5",
1336
+ archetype: "cohort-prop-scale",
1337
+ narrative: `Lucky Charm Pack buyers (~8% of users) get ${LUCKY_CHARM_PRICE_MULT}x price_usd on all real-money purchases plus a ${LUCKY_CHARM_BONUS_PURCHASE_LIKELIHOOD}% bonus high-ticket purchase clone per item purchased. Measured among NON-whales (Hook 10's 1.8x hits both sides): 3.4x — the ${LUCKY_CHARM_PRICE_MULT}x organic floor plus the bonus-clone mixture (~65% of buyer purchase rows are premium-price clones)`,
1338
+ assertions: [
1339
+ {
1340
+ breakdown: {
1341
+ type: "duckdb",
1342
+ sql: `WITH lucky AS (SELECT DISTINCT user_id FROM ${EV} WHERE event = 'real money purchase' AND product = 'Lucky Charm Pack')
1343
+ SELECT CASE WHEN e.user_id IN (SELECT user_id FROM lucky) THEN 'lucky' ELSE 'organic' END AS grp,
1344
+ avg(e.price_usd) AS avg_price, count(DISTINCT e.user_id) AS user_count
1345
+ FROM ${EV} e
1346
+ WHERE e.event = 'real money purchase' AND e.price_usd IS NOT NULL
1347
+ AND substr(e.user_id::VARCHAR, 1, 1) NOT IN ${WHALE_CHARS}
1348
+ GROUP BY 1`,
1349
+ },
1350
+ select: {
1351
+ lucky: { where: { grp: "lucky" } },
1352
+ organic: { where: { grp: "organic" } },
1353
+ },
1354
+ // floor = the raw knob (every buyer purchase is at least 2.5x);
1355
+ // ceiling 4.0 bounds the clone mixture
1356
+ expect: { metric: "lucky.avg_price / organic.avg_price", op: "between", target: [LUCKY_CHARM_PRICE_MULT * 1.12, 4.0] },
1357
+ minCohort: 100,
1358
+ },
1359
+ {
1360
+ breakdown: {
1361
+ type: "duckdb",
1362
+ sql: `WITH lucky AS (SELECT DISTINCT user_id FROM ${EV} WHERE event = 'real money purchase' AND product = 'Lucky Charm Pack'),
1363
+ au AS (SELECT DISTINCT user_id FROM ${EV} WHERE user_id IS NOT NULL)
1364
+ SELECT CASE WHEN au.user_id IN (SELECT user_id FROM lucky) THEN 'lucky' ELSE 'rest' END AS grp, count(*) AS user_count
1365
+ FROM au GROUP BY 1`,
1366
+ },
1367
+ select: {
1368
+ lucky: { where: { grp: "lucky" } },
1369
+ rest: { where: { grp: "rest" } },
1370
+ },
1371
+ // buyer share is organic (product enum pick on item-purchased
1372
+ // volume) — measured 8.4% of users
1373
+ expect: { metric: "lucky.user_count / rest.user_count", op: "between", target: [0.06, 0.12] },
1374
+ minCohort: 100,
1375
+ },
1376
+ ],
1377
+ },
1378
+ {
1379
+ id: "H6-strategic-explorers",
1380
+ hook: "H6",
1381
+ archetype: "cohort-count-scale",
1382
+ narrative: `strategic players (${STRATEGIC_MIN_EACH}+ inspect AND ${STRATEGIC_MIN_EACH}+ search for clues, ~42% of users) get ${STRATEGIC_COMPLETION_LIKELIHOOD}% of non-completed dungeon exits flipped and ${STRATEGIC_TREASURE_MULT}x treasure_value. Treasure band [${(STRATEGIC_TREASURE_MULT / PREP_TREASURE_BOOST).toFixed(2)}, ${STRATEGIC_TREASURE_MULT}]: Hook 13's ${PREP_TREASURE_BOOST}x sweet-band boost lands mostly in the control cohort`,
1383
+ assertions: [
1384
+ {
1385
+ breakdown: {
1386
+ type: "duckdb",
1387
+ sql: `WITH cnt AS (${CNT_CTE})
1388
+ SELECT CASE WHEN ins >= ${STRATEGIC_MIN_EACH} AND sea >= ${STRATEGIC_MIN_EACH} THEN 'strategic' ELSE 'rest' END AS grp, count(*) AS user_count
1389
+ FROM cnt GROUP BY 1`,
1390
+ },
1391
+ select: {
1392
+ strategic: { where: { grp: "strategic" } },
1393
+ rest: { where: { grp: "rest" } },
1394
+ },
1395
+ expect: { metric: "strategic.user_count / rest.user_count", op: "between", target: [0.58, 0.88] },
1396
+ minCohort: 300,
1397
+ },
1398
+ {
1399
+ breakdown: {
1400
+ type: "duckdb",
1401
+ sql: `WITH cnt AS (${CNT_CTE})
1402
+ SELECT CASE WHEN c.ins >= ${STRATEGIC_MIN_EACH} AND c.sea >= ${STRATEGIC_MIN_EACH} THEN 'strategic' ELSE 'rest' END AS grp,
1403
+ avg(e.treasure_value) AS avg_treasure, count(DISTINCT e.user_id) AS user_count
1404
+ FROM ${EV} e JOIN cnt c ON e.user_id::VARCHAR = c.uid
1405
+ WHERE e.event = 'find treasure' AND e.treasure_value IS NOT NULL GROUP BY 1`,
1406
+ },
1407
+ select: {
1408
+ strategic: { where: { grp: "strategic" } },
1409
+ rest: { where: { grp: "rest" } },
1410
+ },
1411
+ expect: { metric: "strategic.avg_treasure / rest.avg_treasure", op: "between", target: [STRATEGIC_TREASURE_MULT / PREP_TREASURE_BOOST, STRATEGIC_TREASURE_MULT] },
1412
+ minCohort: 300,
1413
+ },
1414
+ {
1415
+ breakdown: {
1416
+ type: "duckdb",
1417
+ sql: `WITH cnt AS (${CNT_CTE})
1418
+ SELECT CASE WHEN c.ins >= ${STRATEGIC_MIN_EACH} AND c.sea >= ${STRATEGIC_MIN_EACH} THEN 'strategic' ELSE 'rest' END AS grp,
1419
+ (count(*) FILTER (WHERE e.event = 'exit dungeon' AND e.completion_status = 'completed'))::DOUBLE
1420
+ / nullif(count(*) FILTER (WHERE e.event = 'exit dungeon'), 0) AS completion,
1421
+ count(DISTINCT e.user_id) FILTER (WHERE e.event = 'exit dungeon') AS user_count
1422
+ FROM ${EV} e JOIN cnt c ON e.user_id::VARCHAR = c.uid GROUP BY 1`,
1423
+ },
1424
+ select: { strategic: { where: { grp: "strategic" } } },
1425
+ // flip knob 85% on the ~1/3 organic non-completed share →
1426
+ // completion >= 1 - 0.15 * (2/3) = 0.90; tier flips push it higher
1427
+ expect: { metric: "strategic.completion", op: ">=", target: 0.9, floor: 0.85 },
1428
+ minCohort: 300,
1429
+ },
1430
+ ],
1431
+ },
1432
+ {
1433
+ id: "H7-shadowmourne",
1434
+ hook: "H7",
1435
+ archetype: "temporal-inflection",
1436
+ narrative: `zero Shadowmourne drops before dataset day ${LEGENDARY_RELEASE_DAY}; one per-player roll at ${LEGENDARY_DROP_LIKELIHOOD}%; owners get ${LEGENDARY_WIN_LIKELIHOOD}% of non-victory combats flipped (win rate ≈ 0.96 with tier flips) and ${LEGENDARY_DUNGEON_SPEED_MULT}x dungeon time`,
1437
+ assertions: [
1438
+ {
1439
+ // hard release gate: no drops before day 45
1440
+ breakdown: {
1441
+ type: "duckdb",
1442
+ sql: `WITH start AS (SELECT min(time::TIMESTAMP) AS t0 FROM ${EV})
1443
+ SELECT 'drops' AS grp,
1444
+ count(*) FILTER (WHERE time::TIMESTAMP < (SELECT t0 FROM start) + INTERVAL ${LEGENDARY_RELEASE_DAY} DAY) AS pre_release,
1445
+ count(*) AS event_count
1446
+ FROM ${EV} WHERE event = 'find treasure' AND treasure_type = 'Shadowmourne Legendary'`,
1447
+ },
1448
+ select: { drops: { where: { grp: "drops" } } },
1449
+ expect: { metric: "drops.pre_release", op: "between", target: [0, 0.5] },
1450
+ },
1451
+ {
1452
+ // adoption ≈ LEGENDARY_DROP_LIKELIHOOD/100 among post-release
1453
+ // treasure finders (binomial band around 0.02)
1454
+ breakdown: {
1455
+ type: "duckdb",
1456
+ sql: `WITH own AS (SELECT DISTINCT user_id FROM ${EV} WHERE event = 'find treasure' AND treasure_type = 'Shadowmourne Legendary'),
1457
+ elig AS (SELECT DISTINCT user_id FROM ${EV} WHERE event = 'find treasure'
1458
+ AND time::TIMESTAMP > (SELECT min(time::TIMESTAMP) FROM ${EV}) + INTERVAL ${LEGENDARY_RELEASE_DAY} DAY)
1459
+ SELECT 'adopt' AS grp,
1460
+ (SELECT count(*) FROM own) AS owners,
1461
+ (SELECT count(*) FROM own)::DOUBLE / nullif((SELECT count(*) FROM elig), 0) AS adoption,
1462
+ (SELECT count(*) FROM elig) AS user_count`,
1463
+ },
1464
+ select: { adopt: { where: { grp: "adopt" } } },
1465
+ expect: { metric: "adopt.adoption", op: "between", target: [0.012, 0.032] },
1466
+ minCohort: 500,
1467
+ },
1468
+ {
1469
+ breakdown: {
1470
+ type: "duckdb",
1471
+ sql: `WITH own AS (SELECT DISTINCT user_id FROM ${EV} WHERE event = 'find treasure' AND treasure_type = 'Shadowmourne Legendary'),
1472
+ wr AS (SELECT user_id, (count(*) FILTER (WHERE outcome = 'Victory'))::DOUBLE / count(*) AS w
1473
+ FROM ${EV} WHERE event = 'combat completed' GROUP BY 1)
1474
+ SELECT CASE WHEN wr.user_id IN (SELECT user_id FROM own) THEN 'owner' ELSE 'rest' END AS grp,
1475
+ avg(w) AS win_rate, count(*) AS user_count FROM wr GROUP BY 1`,
1476
+ },
1477
+ select: { owner: { where: { grp: "owner" } } },
1478
+ // LEGENDARY_WIN_LIKELIHOOD = 90% flip of losses → win rate floor
1479
+ // 0.9; tier flips stack on top (measured 0.963)
1480
+ expect: { metric: "owner.win_rate", op: ">=", target: LEGENDARY_WIN_LIKELIHOOD / 100, floor: 0.85 },
1481
+ // ~200 owners at 10K users; WEAK-caps at reduced-scale iteration
1482
+ minCohort: 100,
1483
+ },
1484
+ ],
1485
+ },
1486
+ {
1487
+ id: "H8-subscriber-tiers",
1488
+ hook: "H8",
1489
+ archetype: "cohort-prop-scale",
1490
+ narrative: `subscription_tier 60/20/20 Free/Premium/Elite. Rewards: Elite ${ELITE_REWARD_MULT}x, Premium ${PREMIUM_REWARD_MULT}x (other multipliers are tier-independent and cancel between tiers). Completion follows f + (1-f)*flip from the observed Free baseline f — flip knobs ${PREMIUM_COMPLETION_LIKELIHOOD}%/${ELITE_COMPLETION_LIKELIHOOD}%`,
1491
+ assertions: [
1492
+ {
1493
+ breakdown: {
1494
+ type: "duckdb",
1495
+ sql: `SELECT subscription_tier AS tier, avg(reward_gold) AS avg_gold, count(DISTINCT user_id) AS user_count
1496
+ FROM ${EV} WHERE event = 'quest turned in' AND reward_gold IS NOT NULL GROUP BY 1`,
1497
+ },
1498
+ select: {
1499
+ elite: { where: { tier: "Elite" } },
1500
+ free: { where: { tier: "Free" } },
1501
+ },
1502
+ expect: { metric: "elite.avg_gold / free.avg_gold", op: ">=", target: ELITE_REWARD_MULT, floor: 1.6 },
1503
+ minCohort: 200,
1504
+ },
1505
+ {
1506
+ breakdown: {
1507
+ type: "duckdb",
1508
+ sql: `SELECT subscription_tier AS tier, avg(reward_gold) AS avg_gold, count(DISTINCT user_id) AS user_count
1509
+ FROM ${EV} WHERE event = 'quest turned in' AND reward_gold IS NOT NULL GROUP BY 1`,
1510
+ },
1511
+ select: {
1512
+ premium: { where: { tier: "Premium" } },
1513
+ free: { where: { tier: "Free" } },
1514
+ },
1515
+ expect: { metric: "premium.avg_gold / free.avg_gold", op: ">=", target: PREMIUM_REWARD_MULT, floor: 1.25 },
1516
+ minCohort: 200,
1517
+ },
1518
+ {
1519
+ // completion is a FLIP (not a multiplier), so the prediction runs
1520
+ // through the observed Free baseline: tier = f + (1-f) * flip.
1521
+ // Measured at iteration: f=0.682 → pred Premium 0.825 / Elite 0.889
1522
+ // vs observed 0.819 / 0.896 (<1% error).
1523
+ breakdown: {
1524
+ type: "duckdb",
1525
+ sql: `SELECT subscription_tier AS tier,
1526
+ (count(*) FILTER (WHERE event = 'exit dungeon' AND completion_status = 'completed'))::DOUBLE
1527
+ / nullif(count(*) FILTER (WHERE event = 'exit dungeon'), 0) AS completion,
1528
+ count(DISTINCT user_id) FILTER (WHERE event = 'exit dungeon') AS user_count
1529
+ FROM ${EV} GROUP BY 1`,
1530
+ },
1531
+ assert: (rows) => {
1532
+ const by = Object.fromEntries((rows || []).map((r) => [r.tier, r]));
1533
+ const f = by.Free, p = by.Premium, el = by.Elite;
1534
+ if (!f || !p || !el) return { pass: false, verdict: "NONE", detail: `missing tier rows (${Object.keys(by).join(",")})` };
1535
+ const predP = f.completion + (1 - f.completion) * (PREMIUM_COMPLETION_LIKELIHOOD / 100);
1536
+ const predE = f.completion + (1 - f.completion) * (ELITE_COMPLETION_LIKELIHOOD / 100);
1537
+ const errP = Math.abs(p.completion / predP - 1);
1538
+ const errE = Math.abs(el.completion / predE - 1);
1539
+ const smallest = Math.min(f.user_count, p.user_count, el.user_count);
1540
+ let detail = `Free=${f.completion.toFixed(3)} Premium=${p.completion.toFixed(3)}/pred ${predP.toFixed(3)} Elite=${el.completion.toFixed(3)}/pred ${predE.toFixed(3)}`;
1541
+ let verdict;
1542
+ if (errP <= 0.1 && errE <= 0.1) verdict = "NAILED";
1543
+ else if (errP <= 0.2 && errE <= 0.2) verdict = "STRONG";
1544
+ else if (el.completion > p.completion && p.completion > f.completion) verdict = "WEAK";
1545
+ else verdict = "INVERSE";
1546
+ if ((verdict === "NAILED" || verdict === "STRONG") && smallest < 200) {
1547
+ verdict = "WEAK";
1548
+ detail += ` — capped: smallest cohort ${smallest} < minCohort 200`;
1549
+ }
1550
+ return { pass: verdict === "NAILED" || verdict === "STRONG", verdict, detail };
1551
+ },
1552
+ },
1553
+ ],
1554
+ },
1555
+ {
1556
+ id: "H9-level-gold-scaling",
1557
+ hook: "H9",
1558
+ archetype: "cohort-prop-scale",
1559
+ narrative: `quest reward_gold *= (1 + level * ${LEVEL_GOLD_SCALING}). Formula check: hi-bucket (level 13+) vs lo-bucket (level 1-5) gold ratio must match (1 + ${LEVEL_GOLD_SCALING}*mean_hi)/(1 + ${LEVEL_GOLD_SCALING}*mean_lo) computed from the buckets' own quest-weighted mean levels — measured 1.948 vs predicted 1.935 (0.7% error). Tier/compass multipliers are level-independent and cancel`,
1560
+ assertions: [
1561
+ {
1562
+ breakdown: {
1563
+ type: "duckdb",
1564
+ sql: `WITH lvl AS (SELECT distinct_id::VARCHAR AS uid, level FROM ${US})
1565
+ SELECT CASE WHEN l.level <= 5 THEN 'lo' WHEN l.level >= 13 THEN 'hi' ELSE 'mid' END AS bucket,
1566
+ avg(l.level) AS mean_level, avg(e.reward_gold) AS avg_gold, count(DISTINCT e.user_id) AS user_count
1567
+ FROM ${EV} e JOIN lvl l ON e.user_id::VARCHAR = l.uid
1568
+ WHERE e.event = 'quest turned in' AND e.reward_gold IS NOT NULL GROUP BY 1`,
1569
+ },
1570
+ assert: (rows) => {
1571
+ const by = Object.fromEntries((rows || []).map((r) => [r.bucket, r]));
1572
+ const lo = by.lo, hi = by.hi;
1573
+ if (!lo || !hi) return { pass: false, verdict: "NONE", detail: "missing level buckets" };
1574
+ // quest-weighted mean level is the right predictor: each quest's
1575
+ // gold is scaled by its own user's level, so E[gold] per bucket
1576
+ // = E[base] * (1 + 0.15 * E_rows[level])
1577
+ const predicted = (1 + LEVEL_GOLD_SCALING * hi.mean_level) / (1 + LEVEL_GOLD_SCALING * lo.mean_level);
1578
+ const observed = hi.avg_gold / lo.avg_gold;
1579
+ return ratioVerdict(observed / predicted, 1, 0.85,
1580
+ `hi(mean lvl ${hi.mean_level.toFixed(1)})/lo(${lo.mean_level.toFixed(1)}) gold ratio ${observed.toFixed(3)} vs formula ${predicted.toFixed(3)}`,
1581
+ Math.min(lo.user_count, hi.user_count), 150);
1582
+ },
1583
+ },
1584
+ ],
1585
+ },
1586
+ {
1587
+ id: "H10-whale-purchases",
1588
+ hook: "H10",
1589
+ archetype: "cohort-prop-scale",
1590
+ narrative: `whales (first hex char of user_id with charCodeAt % 3 == 0 → '0','3','6','9','c','f' = 6/16 = 37.5% of users) get ${WHALE_PRICE_MULT}x price_usd. Measured among NON-Lucky-Charm users — Hook 5's high-ticket clones contaminate the unscoped ratio to ~2.0`,
1591
+ assertions: [
1592
+ {
1593
+ breakdown: {
1594
+ type: "duckdb",
1595
+ sql: `WITH lucky AS (SELECT DISTINCT user_id FROM ${EV} WHERE event = 'real money purchase' AND product = 'Lucky Charm Pack')
1596
+ SELECT CASE WHEN substr(e.user_id::VARCHAR, 1, 1) IN ${WHALE_CHARS} THEN 'whale' ELSE 'rest' END AS grp,
1597
+ avg(e.price_usd) AS avg_price, count(DISTINCT e.user_id) AS user_count
1598
+ FROM ${EV} e
1599
+ WHERE e.event = 'real money purchase' AND e.price_usd IS NOT NULL
1600
+ AND e.user_id NOT IN (SELECT user_id FROM lucky)
1601
+ GROUP BY 1`,
1602
+ },
1603
+ select: {
1604
+ whale: { where: { grp: "whale" } },
1605
+ rest: { where: { grp: "rest" } },
1606
+ },
1607
+ expect: { metric: "whale.avg_price / rest.avg_price", op: ">=", target: WHALE_PRICE_MULT, floor: 1.55 },
1608
+ minCohort: 150,
1609
+ },
1610
+ {
1611
+ // hash share: 6/16 = 0.375 of users → whale/rest = 0.6 exactly
1612
+ breakdown: {
1613
+ type: "duckdb",
1614
+ sql: `SELECT CASE WHEN substr(distinct_id::VARCHAR, 1, 1) IN ${WHALE_CHARS} THEN 'whale' ELSE 'rest' END AS grp, count(*) AS user_count
1615
+ FROM ${US} GROUP BY 1`,
1616
+ },
1617
+ select: {
1618
+ whale: { where: { grp: "whale" } },
1619
+ rest: { where: { grp: "rest" } },
1620
+ },
1621
+ expect: { metric: "whale.user_count / rest.user_count", op: "between", target: [0.52, 0.7] },
1622
+ minCohort: 300,
1623
+ },
1624
+ ],
1625
+ },
1626
+ {
1627
+ id: "H11-alignment-archetype",
1628
+ hook: "H11",
1629
+ archetype: "bespoke",
1630
+ narrative: `archetype is a deterministic function of the alignment user prop: Lawful/Neutral Good → hero, Chaotic/Neutral Evil → villain, else neutral. Mapping must be EXACT (hero count == LG+NG count); shares ≈ 26/25/49 at this seed (uniform 9-way alignment would give 22/22/56)`,
1631
+ assertions: [
1632
+ {
1633
+ breakdown: {
1634
+ type: "duckdb",
1635
+ sql: `SELECT
1636
+ count(*) FILTER (WHERE alignment IN ('Lawful Good', 'Neutral Good')) AS good_aligns,
1637
+ count(*) FILTER (WHERE archetype = 'hero') AS heroes,
1638
+ count(*) FILTER (WHERE alignment IN ('Chaotic Evil', 'Neutral Evil')) AS evil_aligns,
1639
+ count(*) FILTER (WHERE archetype = 'villain') AS villains,
1640
+ count(*) FILTER (WHERE archetype = 'neutral') AS neutrals,
1641
+ count(*) AS user_count
1642
+ FROM ${US}`,
1643
+ },
1644
+ assert: (rows) => {
1645
+ const r = rows && rows[0];
1646
+ if (!r || !Number(r.user_count)) return { pass: false, verdict: "NONE", detail: "no profile rows" };
1647
+ const exact = Number(r.heroes) === Number(r.good_aligns) && Number(r.villains) === Number(r.evil_aligns);
1648
+ const hs = r.heroes / r.user_count, vs = r.villains / r.user_count, ns = r.neutrals / r.user_count;
1649
+ const sharesOk = hs >= 0.2 && hs <= 0.3 && vs >= 0.2 && vs <= 0.3 && ns >= 0.42 && ns <= 0.58;
1650
+ const detail = `hero ${r.heroes} vs good ${r.good_aligns}; villain ${r.villains} vs evil ${r.evil_aligns}; shares h=${hs.toFixed(3)} v=${vs.toFixed(3)} n=${ns.toFixed(3)}`;
1651
+ const verdict = exact && sharesOk ? "NAILED" : exact ? "STRONG" : "INVERSE";
1652
+ return { pass: exact, verdict, detail };
1653
+ },
1654
+ },
1655
+ ],
1656
+ },
1657
+ {
1658
+ id: "H12-combat-ttc-by-tier",
1659
+ hook: "H12",
1660
+ archetype: "funnel-ttc-by-segment",
1661
+ narrative: `combat funnel (combat initiated → combat completed → use item) inter-step gaps scaled per tier: Elite x${TTC_ELITE_FACTOR}, Premium x${TTC_PREMIUM_FACTOR}, Free x${TTC_FREE_FACTOR} → median TTC ratios Free/Premium = ${(TTC_FREE_FACTOR / TTC_PREMIUM_FACTOR).toFixed(2)}, Premium/Elite = ${(TTC_PREMIUM_FACTOR / TTC_ELITE_FACTOR).toFixed(2)}. Median, not avg — greedy cross-session matches make the mean heavy-tailed`,
1662
+ assertions: [
1663
+ {
1664
+ breakdown: {
1665
+ type: "timeToConvert",
1666
+ steps: ["combat initiated", "combat completed", "use item"],
1667
+ breakdownByUserProperty: "subscription_tier",
1668
+ conversionWindowMs: 6 * 3600000,
1669
+ },
1670
+ select: {
1671
+ free: { where: { segment_value: "Free" } },
1672
+ premium: { where: { segment_value: "Premium" } },
1673
+ },
1674
+ expect: { metric: "free.median_ttc_ms / premium.median_ttc_ms", op: ">=", target: TTC_FREE_FACTOR / TTC_PREMIUM_FACTOR, floor: 1.6 },
1675
+ minCohort: 100,
1676
+ },
1677
+ {
1678
+ breakdown: {
1679
+ type: "timeToConvert",
1680
+ steps: ["combat initiated", "combat completed", "use item"],
1681
+ breakdownByUserProperty: "subscription_tier",
1682
+ conversionWindowMs: 6 * 3600000,
1683
+ },
1684
+ select: {
1685
+ premium: { where: { segment_value: "Premium" } },
1686
+ elite: { where: { segment_value: "Elite" } },
1687
+ },
1688
+ expect: { metric: "premium.median_ttc_ms / elite.median_ttc_ms", op: ">=", target: TTC_PREMIUM_FACTOR / TTC_ELITE_FACTOR, floor: 1.8 },
1689
+ minCohort: 100,
1690
+ },
1691
+ ],
1692
+ },
1693
+ {
1694
+ id: "H13-prep-magic-number",
1695
+ hook: "H13",
1696
+ archetype: "frequency-sweet-spot",
1697
+ narrative: `${PREP_SWEET_MIN}-${PREP_SWEET_MAX} preps (inspect + search for clues) between first quest-accepted and first fight-boss → ${PREP_TREASURE_BOOST}x treasure_value; ${PREP_OVER_THRESHOLD}+ preps → ${PREP_BOSS_FLIP_LIKELIHOOD}% of boss victories flip to defeat. Treasure measured among NON-strategic users (Hook 6's 2x concentrates in high-prep bands and swamps the unscoped ratio); the boss-win contrast needs no scoping (Hook 6 does not touch victory)`,
1698
+ assertions: [
1699
+ {
1700
+ breakdown: {
1701
+ type: "duckdb",
1702
+ sql: `WITH cnt AS (${CNT_CTE}),
1703
+ nonstrat AS (SELECT uid FROM cnt WHERE NOT (ins >= ${STRATEGIC_MIN_EACH} AND sea >= ${STRATEGIC_MIN_EACH})),
1704
+ ${PREP_CTE}
1705
+ SELECT b.band AS grp, avg(e.treasure_value) AS avg_treasure, count(DISTINCT e.user_id) AS user_count
1706
+ FROM bands b JOIN ${EV} e USING (user_id)
1707
+ WHERE e.event = 'find treasure' AND e.treasure_value IS NOT NULL
1708
+ AND b.user_id::VARCHAR IN (SELECT uid FROM nonstrat)
1709
+ GROUP BY 1`,
1710
+ },
1711
+ select: {
1712
+ sweet: { where: { grp: "sweet" } },
1713
+ low: { where: { grp: "low" } },
1714
+ },
1715
+ expect: { metric: "sweet.avg_treasure / low.avg_treasure", op: ">=", target: PREP_TREASURE_BOOST, floor: 1.15 },
1716
+ minCohort: 100,
1717
+ },
1718
+ {
1719
+ breakdown: {
1720
+ type: "duckdb",
1721
+ sql: `WITH ${PREP_CTE}
1722
+ SELECT b.band AS grp,
1723
+ (count(*) FILTER (WHERE e.event = 'fight boss' AND e.victory = true))::DOUBLE
1724
+ / nullif(count(*) FILTER (WHERE e.event = 'fight boss'), 0) AS boss_win,
1725
+ count(DISTINCT e.user_id) AS user_count
1726
+ FROM bands b JOIN ${EV} e USING (user_id) GROUP BY 1`,
1727
+ },
1728
+ select: {
1729
+ over: { where: { grp: "over" } },
1730
+ sweet: { where: { grp: "sweet" } },
1731
+ },
1732
+ // flipping 25% of over-band victories scales its win rate by 0.75
1733
+ // relative to sweet's; floor 0.85 = STRONG bound
1734
+ expect: { metric: "over.boss_win / sweet.boss_win", op: "<=", target: 1 - PREP_BOSS_FLIP_LIKELIHOOD / 100, floor: 0.85 },
1735
+ minCohort: 150,
1736
+ },
1737
+ ],
1738
+ },
1739
+ ];
1740
+
1741
+ export default config;