@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.
- package/.claude/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/powertools/SKILL.md +75 -0
- package/.claude/skills/powertools/pt.mjs +91 -0
- package/.claude/skills/powertools/snapshot-project.mjs +124 -0
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +224 -0
- package/HOOKS.md +566 -112
- package/README.md +30 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +7 -3
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +151 -59
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +298 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- 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;
|