@ak--47/dungeon-master 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/create-dungeon/SKILL.md +139 -46
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
- package/.claude/skills/write-hooks/SKILL.md +31 -3
- package/CHANGELOG.md +85 -0
- package/HOOKS.md +13 -0
- package/dungeons/technical/ad-spend.js +41 -49
- package/dungeons/technical/anonymous-users.js +38 -36
- package/dungeons/technical/array-of-object-lookup.js +136 -153
- package/dungeons/technical/datagen-v15-verify.js +24 -11
- package/dungeons/technical/experiments.js +42 -40
- package/dungeons/technical/foobar.js +114 -118
- package/dungeons/technical/group-analytics.js +42 -40
- package/dungeons/technical/hook-helpers-verify.js +69 -50
- package/dungeons/technical/identity-model-verify.js +22 -12
- package/dungeons/technical/mirror-strategies.js +37 -39
- package/dungeons/technical/nested-objects.js +119 -118
- package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
- package/dungeons/technical/pattern-attributed-by-source.js +23 -9
- package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
- package/dungeons/technical/pattern-funnel-frequency.js +30 -15
- package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
- package/dungeons/technical/retention-cadence.js +115 -112
- package/dungeons/technical/sanity.js +86 -80
- package/dungeons/technical/scale-test.js +34 -38
- package/dungeons/technical/scd.js +111 -128
- package/dungeons/technical/simple.js +134 -141
- package/dungeons/technical/simplest.js +54 -62
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +296 -333
- package/dungeons/vertical/community.js +284 -255
- package/dungeons/vertical/crypto.js +395 -391
- package/dungeons/vertical/dating.js +411 -378
- package/dungeons/vertical/devtools.js +336 -298
- package/dungeons/vertical/ecommerce.js +316 -394
- package/dungeons/vertical/education.js +369 -325
- package/dungeons/vertical/fintech.js +358 -325
- package/dungeons/vertical/fitness.js +335 -291
- package/dungeons/vertical/food-delivery.js +343 -307
- package/dungeons/vertical/gaming.js +480 -444
- package/dungeons/vertical/healthcare.js +306 -262
- package/dungeons/vertical/insurance-application.js +427 -409
- package/dungeons/vertical/logistics.js +271 -252
- package/dungeons/vertical/marketplace.js +333 -323
- package/dungeons/vertical/media.js +382 -335
- package/dungeons/vertical/real-estate.js +395 -346
- package/dungeons/vertical/sass.js +319 -333
- package/dungeons/vertical/social.js +368 -316
- package/dungeons/vertical/travel.js +297 -295
- package/index.js +46 -4
- package/lib/core/config-validator.js +126 -28
- package/lib/generators/funnels.js +4 -1
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +132 -31
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +14 -2
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +149 -38
- package/lib/verify/counting.js +40 -0
- package/lib/verify/emulate-breakdown.js +20 -1
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +3 -1
- package/package.json +11 -2
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +117 -1
|
@@ -1,62 +1,49 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "questforge";
|
|
3
|
-
const num_days = 120;
|
|
4
|
-
const num_users = 10_000;
|
|
5
|
-
const avg_events_per_user_per_day = 1.2;
|
|
6
|
-
let token = "your-mixpanel-token";
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
11
2
|
import dayjs from "dayjs";
|
|
12
3
|
import utc from "dayjs/plugin/utc.js";
|
|
4
|
+
dayjs.extend(utc);
|
|
13
5
|
import "dotenv/config";
|
|
14
6
|
import * as u from "../../lib/utils/utils.js";
|
|
15
7
|
import * as v from "ak-tools";
|
|
16
|
-
|
|
17
|
-
dayjs.extend(utc);
|
|
18
|
-
const chance = u.initChance(SEED);
|
|
19
8
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
20
9
|
|
|
10
|
+
// ── OVERVIEW ──
|
|
21
11
|
/*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
25
19
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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)
|
|
30
27
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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%)
|
|
37
36
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* - Dungeons: enter → find treasure → exit (50 dungeons, party-based, 3 outcomes)
|
|
45
|
-
* - Combat: initiate → complete (6 enemy types), boss fights, attack/defend
|
|
46
|
-
* - Economy: item purchase/sell, real money purchases, vendor types
|
|
47
|
-
* - Progression: level up (1-50), stat points, level-scaled rewards
|
|
48
|
-
* - Social: guild join/leave (5-100 members, guild levels 1-20)
|
|
49
|
-
* - Monetization: premium currency, lucky charms, legendary chests, season pass
|
|
50
|
-
* - Subscriptions: Free / Premium ($9.99/mo) / Elite ($19.99/mo)
|
|
51
|
-
* - Experiments: A/B/C/Control variants across "fast leveling", "tension
|
|
52
|
-
* economy", "free trial"
|
|
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)
|
|
53
43
|
*/
|
|
54
44
|
|
|
45
|
+
// ── HOOK STORIES ──
|
|
55
46
|
/*
|
|
56
|
-
* ═══════════════════════════════════════════════════════════════════════════════
|
|
57
|
-
* ANALYTICS HOOKS (13 hooks)
|
|
58
|
-
* ═══════════════════════════════════════════════════════════════════════════════
|
|
59
|
-
*
|
|
60
47
|
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
|
|
61
48
|
* behavioral cohorts (count event per user), raw-prop breakdowns
|
|
62
49
|
* (treasure_type, subscription_tier, day-of-user-life), or funnel time-to-convert.
|
|
@@ -299,37 +286,461 @@ const chance = u.initChance(SEED);
|
|
|
299
286
|
* Combat-Prep Magic Num | over boss victory | 1x | 0.75x | -25%
|
|
300
287
|
*/
|
|
301
288
|
|
|
289
|
+
// ── SCALE ──
|
|
290
|
+
const SEED = "questforge";
|
|
291
|
+
const NUM_USERS = 10_000;
|
|
292
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
293
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
294
|
+
const EVENTS_PER_DAY = 1.2;
|
|
295
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
296
|
+
|
|
297
|
+
const chance = u.initChance(SEED);
|
|
298
|
+
|
|
299
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
300
|
+
const COMPASS_REWARD_MULT = 1.5;
|
|
301
|
+
const COMPASS_BONUS_QUEST_LIKELIHOOD = 40;
|
|
302
|
+
|
|
303
|
+
const CURSED_WEEK_START_DAY = 40;
|
|
304
|
+
const CURSED_WEEK_END_DAY = 47;
|
|
305
|
+
const CURSED_DEATH_INJECTION_FACTOR = 0.6;
|
|
306
|
+
|
|
307
|
+
const EARLY_GUILD_DAYS = 3;
|
|
308
|
+
const EARLY_GUILD_COMBAT_CLONE_LIKELIHOOD = 60;
|
|
309
|
+
|
|
310
|
+
const DEATH_SPIRAL_EARLY_DAYS = 7;
|
|
311
|
+
const DEATH_SPIRAL_DROP_LIKELIHOOD = 80;
|
|
312
|
+
|
|
313
|
+
const LUCKY_CHARM_PRICE_MULT = 2.5;
|
|
314
|
+
const LUCKY_CHARM_BONUS_PURCHASE_LIKELIHOOD = 35;
|
|
315
|
+
|
|
316
|
+
const STRATEGIC_COMPLETION_LIKELIHOOD = 85;
|
|
317
|
+
const STRATEGIC_TREASURE_MULT = 2;
|
|
318
|
+
|
|
319
|
+
const LEGENDARY_RELEASE_DAY = 45;
|
|
320
|
+
const LEGENDARY_DROP_LIKELIHOOD = 2;
|
|
321
|
+
const LEGENDARY_TREASURE_VALUE = 50000;
|
|
322
|
+
const LEGENDARY_WIN_LIKELIHOOD = 90;
|
|
323
|
+
const LEGENDARY_DUNGEON_SPEED_MULT = 0.6;
|
|
324
|
+
|
|
325
|
+
const PREMIUM_WIN_LIKELIHOOD = 50;
|
|
326
|
+
const PREMIUM_REWARD_MULT = 1.4;
|
|
327
|
+
const PREMIUM_COMPLETION_LIKELIHOOD = 45;
|
|
328
|
+
const PREMIUM_DUNGEON_SPEED_MULT = 0.85;
|
|
329
|
+
const PREMIUM_TREASURE_MULT = 1.5;
|
|
330
|
+
const PREMIUM_SURVIVAL_LIKELIHOOD = 30;
|
|
331
|
+
|
|
332
|
+
const ELITE_WIN_LIKELIHOOD = 70;
|
|
333
|
+
const ELITE_REWARD_MULT = 1.8;
|
|
334
|
+
const ELITE_COMPLETION_LIKELIHOOD = 65;
|
|
335
|
+
const ELITE_DUNGEON_SPEED_MULT = 0.7;
|
|
336
|
+
const ELITE_TREASURE_MULT = 2.0;
|
|
337
|
+
const ELITE_SURVIVAL_LIKELIHOOD = 50;
|
|
338
|
+
const ELITE_BONUS_TREASURE_LIKELIHOOD = 5;
|
|
339
|
+
|
|
340
|
+
const LEVEL_GOLD_SCALING = 0.15;
|
|
341
|
+
|
|
342
|
+
const WHALE_PRICE_MULT = 1.8;
|
|
343
|
+
|
|
344
|
+
const TTC_ELITE_FACTOR = 0.30;
|
|
345
|
+
const TTC_PREMIUM_FACTOR = 0.70;
|
|
346
|
+
const TTC_FREE_FACTOR = 1.40;
|
|
347
|
+
|
|
348
|
+
const PREP_SWEET_MIN = 3;
|
|
349
|
+
const PREP_SWEET_MAX = 6;
|
|
350
|
+
const PREP_OVER_THRESHOLD = 7;
|
|
351
|
+
const PREP_TREASURE_BOOST = 1.3;
|
|
352
|
+
const PREP_BOSS_FLIP_LIKELIHOOD = 25;
|
|
353
|
+
|
|
354
|
+
// ── DATA ARRAYS ──
|
|
302
355
|
// Generate consistent item/location IDs for lookup tables
|
|
303
356
|
const dungeonIds = v.range(1, 51).map(n => `dungeon_${v.uid(6)}`);
|
|
304
357
|
const questIds = v.range(1, 201).map(n => `quest_${v.uid(8)}`);
|
|
305
358
|
const itemIds = v.range(1, 301).map(n => `item_${v.uid(7)}`);
|
|
306
359
|
|
|
360
|
+
// ── HELPER FUNCTIONS ──
|
|
361
|
+
function handleUserHooks(record) {
|
|
362
|
+
// Hook #11: ALIGNMENT ARCHETYPE — derive archetype on user profile
|
|
363
|
+
if (record.alignment === "Chaotic Evil" || record.alignment === "Neutral Evil") {
|
|
364
|
+
record.archetype = "villain";
|
|
365
|
+
} else if (record.alignment === "Lawful Good" || record.alignment === "Neutral Good") {
|
|
366
|
+
record.archetype = "hero";
|
|
367
|
+
} else {
|
|
368
|
+
record.archetype = "neutral";
|
|
369
|
+
}
|
|
370
|
+
return record;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function handleEverythingHooks(record, meta) {
|
|
374
|
+
const userEvents = record;
|
|
375
|
+
const profile = meta.profile;
|
|
376
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
377
|
+
const LEGENDARY_WEAPON_RELEASE = datasetStart.add(LEGENDARY_RELEASE_DAY, 'days');
|
|
378
|
+
|
|
379
|
+
// Stamp superProps from profile for consistency
|
|
380
|
+
userEvents.forEach(e => {
|
|
381
|
+
e.Platform = profile.Platform;
|
|
382
|
+
e.graphics_quality = profile.graphics_quality;
|
|
383
|
+
e.subscription_tier = profile.subscription_tier;
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Hook #7: TIMED RELEASE — Shadowmourne Legendary post-d45.
|
|
387
|
+
// Runs in everything hook so timestamps are post-bunchIntoSessions.
|
|
388
|
+
userEvents.forEach(e => {
|
|
389
|
+
if (e.event === "find treasure" && dayjs(e.time).isAfter(LEGENDARY_WEAPON_RELEASE) && chance.bool({ likelihood: LEGENDARY_DROP_LIKELIHOOD })) {
|
|
390
|
+
e.treasure_type = "Shadowmourne Legendary";
|
|
391
|
+
e.treasure_value = LEGENDARY_TREASURE_VALUE;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Hook #12: COMBAT T2C — scale time gaps in combat funnel
|
|
396
|
+
// sequences (combat initiated → combat completed → use item).
|
|
397
|
+
// Elite ~0.30x (faster), Premium ~0.70x, Free ~1.40x (slower).
|
|
398
|
+
// Finds all 3-step sequences and shifts step 2/3 timestamps.
|
|
399
|
+
const tier = profile.subscription_tier;
|
|
400
|
+
const t2cFactor = (
|
|
401
|
+
tier === "Elite" ? TTC_ELITE_FACTOR :
|
|
402
|
+
tier === "Premium" ? TTC_PREMIUM_FACTOR :
|
|
403
|
+
tier === "Free" ? TTC_FREE_FACTOR :
|
|
404
|
+
1.0
|
|
405
|
+
);
|
|
406
|
+
if (t2cFactor !== 1.0) {
|
|
407
|
+
// Collect indices for each combat funnel step
|
|
408
|
+
const combatInitiated = [];
|
|
409
|
+
const combatCompleted = [];
|
|
410
|
+
const useItem = [];
|
|
411
|
+
for (let i = 0; i < userEvents.length; i++) {
|
|
412
|
+
const e = userEvents[i];
|
|
413
|
+
if (e.event === "combat initiated") combatInitiated.push(i);
|
|
414
|
+
else if (e.event === "combat completed") combatCompleted.push(i);
|
|
415
|
+
else if (e.event === "use item") useItem.push(i);
|
|
416
|
+
}
|
|
417
|
+
// Match sequences: for each combat initiated, find next
|
|
418
|
+
// combat completed after it, then next use item after that
|
|
419
|
+
const matched = new Set();
|
|
420
|
+
for (const ciIdx of combatInitiated) {
|
|
421
|
+
const ccIdx = combatCompleted.find(j => j > ciIdx && !matched.has(j));
|
|
422
|
+
if (ccIdx === undefined) continue;
|
|
423
|
+
const uiIdx = useItem.find(j => j > ccIdx && !matched.has(j));
|
|
424
|
+
if (uiIdx === undefined) continue;
|
|
425
|
+
matched.add(ccIdx);
|
|
426
|
+
matched.add(uiIdx);
|
|
427
|
+
// Scale gap between step 1→2 and 2→3
|
|
428
|
+
const t0 = dayjs(userEvents[ciIdx].time);
|
|
429
|
+
const t1 = dayjs(userEvents[ccIdx].time);
|
|
430
|
+
const t2 = dayjs(userEvents[uiIdx].time);
|
|
431
|
+
const gap1 = t1.diff(t0);
|
|
432
|
+
const gap2 = t2.diff(t1);
|
|
433
|
+
userEvents[ccIdx].time = t0.add(Math.round(gap1 * t2cFactor), 'milliseconds').toISOString();
|
|
434
|
+
userEvents[uiIdx].time = dayjs(userEvents[ccIdx].time).add(Math.round(gap2 * t2cFactor), 'milliseconds').toISOString();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
|
|
439
|
+
const userLevel = profile.level || 1;
|
|
440
|
+
|
|
441
|
+
// Track user behaviors
|
|
442
|
+
let usedAncientCompass = false;
|
|
443
|
+
let boughtLuckyCharm = false;
|
|
444
|
+
let joinedGuildEarly = false;
|
|
445
|
+
let earlyDeaths = 0;
|
|
446
|
+
let hasLegendaryWeapon = false;
|
|
447
|
+
let inspectedBeforeDungeon = false;
|
|
448
|
+
let searchedBeforeDungeon = false;
|
|
449
|
+
let subscriptionTier = "Free";
|
|
450
|
+
|
|
451
|
+
// Hook #10: Whale segmentation — deterministic via user_id hash (~33%)
|
|
452
|
+
const userId = userEvents.length > 0 ? (userEvents[0].user_id || userEvents[0].distinct_id || "") : "";
|
|
453
|
+
const isWhale = userId.length > 0 && userId.charCodeAt(0) % 3 === 0;
|
|
454
|
+
|
|
455
|
+
// First pass: identify user patterns
|
|
456
|
+
userEvents.forEach((event) => {
|
|
457
|
+
const eventTime = dayjs(event.time);
|
|
458
|
+
const daysSinceStart = firstEventTime ? eventTime.diff(firstEventTime, 'days', true) : 0;
|
|
459
|
+
|
|
460
|
+
if (event.subscription_tier) {
|
|
461
|
+
subscriptionTier = event.subscription_tier;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (event.event === "use item" && event.item_type === "Ancient Compass") {
|
|
465
|
+
usedAncientCompass = true;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (event.event === "real money purchase" && event.product === "Lucky Charm Pack") {
|
|
469
|
+
boughtLuckyCharm = true;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (event.event === "guild joined" && daysSinceStart < EARLY_GUILD_DAYS) {
|
|
473
|
+
joinedGuildEarly = true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (event.event === "player death" && daysSinceStart < DEATH_SPIRAL_EARLY_DAYS) {
|
|
477
|
+
earlyDeaths++;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (event.event === "find treasure" && event.treasure_type === "Shadowmourne Legendary") {
|
|
481
|
+
hasLegendaryWeapon = true;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (event.event === "inspect") {
|
|
485
|
+
inspectedBeforeDungeon = true;
|
|
486
|
+
}
|
|
487
|
+
if (event.event === "search for clues") {
|
|
488
|
+
searchedBeforeDungeon = true;
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Second pass: raw mutations + cloning, no flag stamping
|
|
493
|
+
userEvents.forEach((event, idx) => {
|
|
494
|
+
const eventTime = dayjs(event.time);
|
|
495
|
+
|
|
496
|
+
// Hook 9: PROGRESSION SCALING — Quest gold scales with level.
|
|
497
|
+
// Power curve so bucket-averaged high-level/low-level ratio >= 2.0x.
|
|
498
|
+
if (event.event === "quest turned in") {
|
|
499
|
+
const baseGold = event.reward_gold || 100;
|
|
500
|
+
event.reward_gold = Math.floor(baseGold * (1 + userLevel * LEVEL_GOLD_SCALING));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Hook 1: CONVERSION — Ancient Compass users earn 1.5x quest
|
|
504
|
+
// rewards plus 40% chance of bonus cloned quest.
|
|
505
|
+
if (usedAncientCompass && event.event === "quest turned in") {
|
|
506
|
+
event.reward_gold = Math.floor((event.reward_gold || 100) * COMPASS_REWARD_MULT);
|
|
507
|
+
event.reward_xp = Math.floor((event.reward_xp || 500) * COMPASS_REWARD_MULT);
|
|
508
|
+
|
|
509
|
+
if (chance.bool({ likelihood: COMPASS_BONUS_QUEST_LIKELIHOOD })) {
|
|
510
|
+
userEvents.splice(idx + 1, 0, {
|
|
511
|
+
...event,
|
|
512
|
+
time: eventTime.add(chance.integer({ min: 10, max: 120 }), 'minutes').toISOString(),
|
|
513
|
+
quest_id: chance.pickone(questIds),
|
|
514
|
+
reward_gold: chance.integer({ min: 100, max: 500 }),
|
|
515
|
+
reward_xp: chance.integer({ min: 500, max: 2000 }),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Hook 5: PURCHASE VALUE — Lucky charm buyers see 2x prices
|
|
521
|
+
// and 35% chance of bonus high-value cloned purchase.
|
|
522
|
+
if (boughtLuckyCharm) {
|
|
523
|
+
if (event.event === "real money purchase" && event.price_usd) {
|
|
524
|
+
event.price_usd = Math.round(event.price_usd * LUCKY_CHARM_PRICE_MULT * 100) / 100;
|
|
525
|
+
}
|
|
526
|
+
if (event.event === "item purchased" && chance.bool({ likelihood: LUCKY_CHARM_BONUS_PURCHASE_LIKELIHOOD })) {
|
|
527
|
+
const purchaseTemplate = userEvents.find(e => e.event === "real money purchase");
|
|
528
|
+
if (purchaseTemplate) {
|
|
529
|
+
userEvents.splice(idx + 1, 0, {
|
|
530
|
+
...purchaseTemplate,
|
|
531
|
+
time: eventTime.add(chance.integer({ min: 1, max: 3 }), 'days').toISOString(),
|
|
532
|
+
user_id: event.user_id,
|
|
533
|
+
product: chance.pickone(["Premium Currency (5000)", "Legendary Weapon Chest", "Season Pass"]),
|
|
534
|
+
price_usd: chance.pickone([19.99, 49.99, 99.99]),
|
|
535
|
+
payment_method: chance.pickone(["Credit Card", "PayPal"]),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Hook 10: WHALE PURCHASES — 1.8x price for whale cohort.
|
|
542
|
+
if (isWhale && event.event === "real money purchase" && event.price_usd) {
|
|
543
|
+
event.price_usd = Math.round(event.price_usd * WHALE_PRICE_MULT * 100) / 100;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Hook 6: BEHAVIORS TOGETHER — inspect+search dungeons get
|
|
547
|
+
// 85% completion rate + 2x treasure value.
|
|
548
|
+
if (inspectedBeforeDungeon && searchedBeforeDungeon) {
|
|
549
|
+
if (event.event === "exit dungeon" && event.completion_status !== "completed" && chance.bool({ likelihood: STRATEGIC_COMPLETION_LIKELIHOOD })) {
|
|
550
|
+
event.completion_status = "completed";
|
|
551
|
+
}
|
|
552
|
+
if (event.event === "find treasure") {
|
|
553
|
+
event.treasure_value = Math.floor((event.treasure_value || 50) * STRATEGIC_TREASURE_MULT);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Hook 7: TIMED RELEASE — Legendary owners get 90% combat
|
|
558
|
+
// wins + 0.6x dungeon time.
|
|
559
|
+
if (hasLegendaryWeapon) {
|
|
560
|
+
if (event.event === "combat completed" && event.outcome !== "Victory" && chance.bool({ likelihood: LEGENDARY_WIN_LIKELIHOOD })) {
|
|
561
|
+
event.outcome = "Victory";
|
|
562
|
+
}
|
|
563
|
+
if (event.event === "exit dungeon") {
|
|
564
|
+
event.completion_status = "completed";
|
|
565
|
+
event.time_spent_mins = Math.floor((event.time_spent_mins || 60) * LEGENDARY_DUNGEON_SPEED_MULT);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Hook 8: SUBSCRIPTION TIER — Premium/Elite get win+reward+
|
|
570
|
+
// completion+treasure boosts. Reads tier from profile.
|
|
571
|
+
if (subscriptionTier === "Premium" || subscriptionTier === "Elite") {
|
|
572
|
+
const isElite = subscriptionTier === "Elite";
|
|
573
|
+
if (event.event === "combat completed" && event.outcome !== "Victory") {
|
|
574
|
+
const winBoost = isElite ? ELITE_WIN_LIKELIHOOD : PREMIUM_WIN_LIKELIHOOD;
|
|
575
|
+
if (chance.bool({ likelihood: winBoost })) {
|
|
576
|
+
event.outcome = "Victory";
|
|
577
|
+
event.loot_gained = true;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (event.event === "quest turned in") {
|
|
581
|
+
const rewardMultiplier = isElite ? ELITE_REWARD_MULT : PREMIUM_REWARD_MULT;
|
|
582
|
+
event.reward_gold = Math.floor((event.reward_gold || 100) * rewardMultiplier);
|
|
583
|
+
event.reward_xp = Math.floor((event.reward_xp || 500) * rewardMultiplier);
|
|
584
|
+
}
|
|
585
|
+
if (event.event === "exit dungeon") {
|
|
586
|
+
if (event.completion_status !== "completed") {
|
|
587
|
+
const completionBoost = isElite ? ELITE_COMPLETION_LIKELIHOOD : PREMIUM_COMPLETION_LIKELIHOOD;
|
|
588
|
+
if (chance.bool({ likelihood: completionBoost })) {
|
|
589
|
+
event.completion_status = "completed";
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (event.completion_status === "completed") {
|
|
593
|
+
const speedBoost = isElite ? ELITE_DUNGEON_SPEED_MULT : PREMIUM_DUNGEON_SPEED_MULT;
|
|
594
|
+
event.time_spent_mins = Math.floor((event.time_spent_mins || 60) * speedBoost);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (event.event === "find treasure") {
|
|
598
|
+
const treasureBoost = isElite ? ELITE_TREASURE_MULT : PREMIUM_TREASURE_MULT;
|
|
599
|
+
event.treasure_value = Math.floor((event.treasure_value || 50) * treasureBoost);
|
|
600
|
+
}
|
|
601
|
+
if (event.event === "player death") {
|
|
602
|
+
const survivalLikelihood = isElite ? ELITE_SURVIVAL_LIKELIHOOD : PREMIUM_SURVIVAL_LIKELIHOOD;
|
|
603
|
+
if (chance.bool({ likelihood: survivalLikelihood })) {
|
|
604
|
+
event.event = "combat completed";
|
|
605
|
+
event.outcome = "Victory";
|
|
606
|
+
event.loot_gained = true;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (isElite && chance.bool({ likelihood: ELITE_BONUS_TREASURE_LIKELIHOOD })) {
|
|
610
|
+
if (event.event === "quest turned in" || event.event === "exit dungeon") {
|
|
611
|
+
const treasureTemplate = userEvents.find(e => e.event === "find treasure");
|
|
612
|
+
if (treasureTemplate) {
|
|
613
|
+
const treasureTypes = ["Rare Artifact", "Gold", "Weapon", "Armor"];
|
|
614
|
+
userEvents.splice(idx + 1, 0, {
|
|
615
|
+
...treasureTemplate,
|
|
616
|
+
time: eventTime.add(chance.integer({ min: 5, max: 30 }), 'minutes').toISOString(),
|
|
617
|
+
user_id: event.user_id,
|
|
618
|
+
treasure_type: chance.pickone(treasureTypes),
|
|
619
|
+
treasure_value: chance.integer({ min: 200, max: 800 }),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Hook 2: CURSED WEEK — inject extra deaths in days 40-47 of
|
|
628
|
+
// user's timeline. Cause_of_death set to "Curse" on injected.
|
|
629
|
+
// Discover via line-chart of player-death by day-of-user-life.
|
|
630
|
+
if (firstEventTime) {
|
|
631
|
+
const deathTemplate = userEvents.find(e => e.event === "player death");
|
|
632
|
+
if (deathTemplate) {
|
|
633
|
+
const cursedStart = firstEventTime.add(CURSED_WEEK_START_DAY, 'days');
|
|
634
|
+
const cursedEnd = firstEventTime.add(CURSED_WEEK_END_DAY, 'days');
|
|
635
|
+
const cursedEvents = userEvents.filter(e => {
|
|
636
|
+
const t = dayjs(e.time);
|
|
637
|
+
return t.isAfter(cursedStart) && t.isBefore(cursedEnd);
|
|
638
|
+
});
|
|
639
|
+
const deathsToInject = Math.floor(cursedEvents.length * CURSED_DEATH_INJECTION_FACTOR);
|
|
640
|
+
for (let d = 0; d < deathsToInject; d++) {
|
|
641
|
+
const sourceEvent = cursedEvents[d % cursedEvents.length];
|
|
642
|
+
userEvents.push({
|
|
643
|
+
...deathTemplate,
|
|
644
|
+
time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 30 }), 'minutes').toISOString(),
|
|
645
|
+
user_id: sourceEvent.user_id,
|
|
646
|
+
event: "player death",
|
|
647
|
+
cause_of_death: "Curse",
|
|
648
|
+
player_level: chance.integer({ min: 1, max: 50 }),
|
|
649
|
+
resurrection_used: chance.bool({ likelihood: 80 }),
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Hook 3 RETENTION + Hook 4 CHURN — early guild-joiners get
|
|
656
|
+
// extra cloned combat events; non-joiners with 3+ deaths in
|
|
657
|
+
// first week lose 70% of post-week events. No flag.
|
|
658
|
+
const shouldChurn = (!joinedGuildEarly && earlyDeaths >= 2) || (earlyDeaths >= 4);
|
|
659
|
+
if (shouldChurn) {
|
|
660
|
+
const firstWeekEnd = firstEventTime ? firstEventTime.add(DEATH_SPIRAL_EARLY_DAYS, 'days') : null;
|
|
661
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
662
|
+
if (firstWeekEnd && dayjs(userEvents[i].time).isAfter(firstWeekEnd) && chance.bool({ likelihood: DEATH_SPIRAL_DROP_LIKELIHOOD })) {
|
|
663
|
+
userEvents.splice(i, 1);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
} else if (joinedGuildEarly) {
|
|
667
|
+
const lastEvent = userEvents[userEvents.length - 1];
|
|
668
|
+
const combatTemplate = userEvents.find(e => e.event === "combat completed");
|
|
669
|
+
if (lastEvent && combatTemplate && chance.bool({ likelihood: EARLY_GUILD_COMBAT_CLONE_LIKELIHOOD })) {
|
|
670
|
+
userEvents.push({
|
|
671
|
+
...combatTemplate,
|
|
672
|
+
time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 5 }), 'days').toISOString(),
|
|
673
|
+
user_id: lastEvent.user_id,
|
|
674
|
+
outcome: "Victory",
|
|
675
|
+
loot_gained: true,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// HOOK 13: COMBAT-PREP MAGIC NUMBER (in-funnel, no flags)
|
|
681
|
+
// Sweet 3-6 inspect+search events between quest accepted and
|
|
682
|
+
// fight boss → +30% loot/treasure_value on find-treasure events.
|
|
683
|
+
// Over 7+ → drop 25% of fight-boss completion (over-prep
|
|
684
|
+
// signals analysis paralysis). No flag.
|
|
685
|
+
const questAccept = userEvents.find(e => e.event === "quest accepted");
|
|
686
|
+
const bossFight = userEvents.find(e => e.event === "fight boss");
|
|
687
|
+
if (questAccept && bossFight) {
|
|
688
|
+
const aTime = dayjs(questAccept.time);
|
|
689
|
+
const bTime = dayjs(bossFight.time);
|
|
690
|
+
const prepCount = userEvents.filter(e =>
|
|
691
|
+
(e.event === "inspect" || e.event === "search for clues") &&
|
|
692
|
+
dayjs(e.time).isAfter(aTime) &&
|
|
693
|
+
dayjs(e.time).isBefore(bTime)
|
|
694
|
+
).length;
|
|
695
|
+
if (prepCount >= PREP_SWEET_MIN && prepCount <= PREP_SWEET_MAX) {
|
|
696
|
+
userEvents.forEach(e => {
|
|
697
|
+
if (e.event === "find treasure" && typeof e.treasure_value === "number") {
|
|
698
|
+
e.treasure_value = Math.round(e.treasure_value * PREP_TREASURE_BOOST);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
} else if (prepCount >= PREP_OVER_THRESHOLD) {
|
|
702
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
703
|
+
const ev = userEvents[i];
|
|
704
|
+
if (ev.event === "fight boss" && ev.victory === true && chance.bool({ likelihood: PREP_BOSS_FLIP_LIKELIHOOD })) {
|
|
705
|
+
ev.victory = false;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return record;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ── CONFIG ──
|
|
307
715
|
/** @type {Config} */
|
|
308
716
|
const config = {
|
|
309
717
|
version: 2,
|
|
310
|
-
token,
|
|
311
718
|
seed: SEED,
|
|
312
|
-
datasetStart:
|
|
313
|
-
datasetEnd:
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
numUsers: num_users,
|
|
317
|
-
hasAnonIds: true,
|
|
318
|
-
avgDevicePerUser: 2,
|
|
319
|
-
hasSessionIds: true,
|
|
719
|
+
datasetStart: DATASET_START,
|
|
720
|
+
datasetEnd: DATASET_END,
|
|
721
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
722
|
+
numUsers: NUM_USERS,
|
|
320
723
|
format: "json",
|
|
321
724
|
gzip: true,
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
725
|
+
credentials: {
|
|
726
|
+
token,
|
|
727
|
+
},
|
|
728
|
+
switches: {
|
|
729
|
+
hasSessionIds: true,
|
|
730
|
+
alsoInferFunnels: false,
|
|
731
|
+
hasLocation: true,
|
|
732
|
+
hasAndroidDevices: true,
|
|
733
|
+
hasIOSDevices: true,
|
|
734
|
+
hasDesktopDevices: true,
|
|
735
|
+
hasBrowser: false,
|
|
736
|
+
hasCampaigns: false,
|
|
737
|
+
isAnonymous: false,
|
|
738
|
+
hasAdSpend: false,
|
|
739
|
+
hasAvatar: true,
|
|
740
|
+
},
|
|
741
|
+
identity: {
|
|
742
|
+
avgDevicePerUser: 2,
|
|
743
|
+
},
|
|
333
744
|
|
|
334
745
|
concurrency: 1,
|
|
335
746
|
writeToDisk: false,
|
|
@@ -699,386 +1110,11 @@ const config = {
|
|
|
699
1110
|
|
|
700
1111
|
lookupTables: [],
|
|
701
1112
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
* 1. CONVERSION: Ancient Compass users have 3x quest completion + 1.5x rewards
|
|
706
|
-
* 2. TIME-BASED: "Cursed Week" (days 40-47) has 5x death rates
|
|
707
|
-
* 3. RETENTION: Early guild joiners (first 3 days) have 80% D30 retention vs 20%
|
|
708
|
-
* 4. CHURN: Players with 3+ deaths in first week have 70% churn rate
|
|
709
|
-
* 5. PURCHASE VALUE: Lucky Charm buyers spend 5x more (LTV pattern)
|
|
710
|
-
* 6. BEHAVIORS TOGETHER: inspect + search before dungeon = 85% completion vs 45%
|
|
711
|
-
* 7. TIMED RELEASE: Legendary weapon released day 45, early adopters dominate
|
|
712
|
-
* 8. SUBSCRIPTION TIER: Premium/Elite users have higher engagement and success
|
|
713
|
-
* 9. PROGRESSION SCALING: Quest gold scales with player level (1 + level * 0.15)
|
|
714
|
-
* 10. WHALE PURCHASES: ~33% of users via deterministic hash spend 1.8x more
|
|
715
|
-
* 11. ALIGNMENT ARCHETYPE: Good=hero, Evil=villain, other=neutral (user hook)
|
|
716
|
-
* 12. COMBAT FUNNEL TTC: Elite 0.30x faster, Free 1.40x slower combat funnel T2C
|
|
717
|
-
* 13. COMBAT-PREP MAGIC NUMBER: 3-6 prep events = +30% treasure; 7+ = -25% boss wins
|
|
718
|
-
*/
|
|
719
|
-
hook: function (record, type, meta) {
|
|
720
|
-
// Hook #11: ALIGNMENT ARCHETYPE — derive archetype on user profile
|
|
721
|
-
if (type === "user") {
|
|
722
|
-
if (record.alignment === "Chaotic Evil" || record.alignment === "Neutral Evil") {
|
|
723
|
-
record.archetype = "villain";
|
|
724
|
-
} else if (record.alignment === "Lawful Good" || record.alignment === "Neutral Good") {
|
|
725
|
-
record.archetype = "hero";
|
|
726
|
-
} else {
|
|
727
|
-
record.archetype = "neutral";
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// Hook #2 event-level part removed — cursed week now handled in everything hook
|
|
732
|
-
|
|
733
|
-
// Hook #7: TIMED RELEASE — Legendary Weapon
|
|
734
|
-
// (moved to everything hook — event hook fires before bunchIntoSessions
|
|
735
|
-
// reshuffles timestamps, causing tagged events to leak across the d45 boundary)
|
|
736
|
-
|
|
737
|
-
// HOOK 12 (T2C): moved to everything hook — funnel-post effects
|
|
738
|
-
// were invisible to SQL verification.
|
|
739
|
-
|
|
740
|
-
// Hooks #1, #3, #4, #5, #6, #8, #9, #10: per-user behavioral patterns
|
|
741
|
-
if (type === "everything") {
|
|
742
|
-
const userEvents = record;
|
|
743
|
-
const profile = meta.profile;
|
|
744
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
745
|
-
const LEGENDARY_WEAPON_RELEASE = datasetStart.add(45, 'days');
|
|
746
|
-
|
|
747
|
-
// Stamp superProps from profile for consistency
|
|
748
|
-
userEvents.forEach(e => {
|
|
749
|
-
e.Platform = profile.Platform;
|
|
750
|
-
e.graphics_quality = profile.graphics_quality;
|
|
751
|
-
e.subscription_tier = profile.subscription_tier;
|
|
752
|
-
});
|
|
753
|
-
|
|
754
|
-
// Hook #7: TIMED RELEASE — Shadowmourne Legendary post-d45.
|
|
755
|
-
// Runs in everything hook so timestamps are post-bunchIntoSessions.
|
|
756
|
-
userEvents.forEach(e => {
|
|
757
|
-
if (e.event === "find treasure" && dayjs(e.time).isAfter(LEGENDARY_WEAPON_RELEASE) && chance.bool({ likelihood: 2 })) {
|
|
758
|
-
e.treasure_type = "Shadowmourne Legendary";
|
|
759
|
-
e.treasure_value = 50000;
|
|
760
|
-
}
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
// Hook #12: COMBAT T2C — scale time gaps in combat funnel
|
|
764
|
-
// sequences (combat initiated → combat completed → use item).
|
|
765
|
-
// Elite ~0.30x (faster), Premium ~0.70x, Free ~1.40x (slower).
|
|
766
|
-
// Finds all 3-step sequences and shifts step 2/3 timestamps.
|
|
767
|
-
const tier = profile.subscription_tier;
|
|
768
|
-
const t2cFactor = (
|
|
769
|
-
tier === "Elite" ? 0.30 :
|
|
770
|
-
tier === "Premium" ? 0.70 :
|
|
771
|
-
tier === "Free" ? 1.40 :
|
|
772
|
-
1.0
|
|
773
|
-
);
|
|
774
|
-
if (t2cFactor !== 1.0) {
|
|
775
|
-
// Collect indices for each combat funnel step
|
|
776
|
-
const combatInitiated = [];
|
|
777
|
-
const combatCompleted = [];
|
|
778
|
-
const useItem = [];
|
|
779
|
-
for (let i = 0; i < userEvents.length; i++) {
|
|
780
|
-
const e = userEvents[i];
|
|
781
|
-
if (e.event === "combat initiated") combatInitiated.push(i);
|
|
782
|
-
else if (e.event === "combat completed") combatCompleted.push(i);
|
|
783
|
-
else if (e.event === "use item") useItem.push(i);
|
|
784
|
-
}
|
|
785
|
-
// Match sequences: for each combat initiated, find next
|
|
786
|
-
// combat completed after it, then next use item after that
|
|
787
|
-
const matched = new Set();
|
|
788
|
-
for (const ciIdx of combatInitiated) {
|
|
789
|
-
const ccIdx = combatCompleted.find(j => j > ciIdx && !matched.has(j));
|
|
790
|
-
if (ccIdx === undefined) continue;
|
|
791
|
-
const uiIdx = useItem.find(j => j > ccIdx && !matched.has(j));
|
|
792
|
-
if (uiIdx === undefined) continue;
|
|
793
|
-
matched.add(ccIdx);
|
|
794
|
-
matched.add(uiIdx);
|
|
795
|
-
// Scale gap between step 1→2 and 2→3
|
|
796
|
-
const t0 = dayjs(userEvents[ciIdx].time);
|
|
797
|
-
const t1 = dayjs(userEvents[ccIdx].time);
|
|
798
|
-
const t2 = dayjs(userEvents[uiIdx].time);
|
|
799
|
-
const gap1 = t1.diff(t0);
|
|
800
|
-
const gap2 = t2.diff(t1);
|
|
801
|
-
userEvents[ccIdx].time = t0.add(Math.round(gap1 * t2cFactor), 'milliseconds').toISOString();
|
|
802
|
-
userEvents[uiIdx].time = dayjs(userEvents[ccIdx].time).add(Math.round(gap2 * t2cFactor), 'milliseconds').toISOString();
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
|
|
807
|
-
const userLevel = profile.level || 1;
|
|
808
|
-
|
|
809
|
-
// Track user behaviors
|
|
810
|
-
let usedAncientCompass = false;
|
|
811
|
-
let boughtLuckyCharm = false;
|
|
812
|
-
let joinedGuildEarly = false;
|
|
813
|
-
let earlyDeaths = 0;
|
|
814
|
-
let hasLegendaryWeapon = false;
|
|
815
|
-
let inspectedBeforeDungeon = false;
|
|
816
|
-
let searchedBeforeDungeon = false;
|
|
817
|
-
let subscriptionTier = "Free";
|
|
818
|
-
|
|
819
|
-
// Hook #10: Whale segmentation — deterministic via user_id hash (~33%)
|
|
820
|
-
const userId = userEvents.length > 0 ? (userEvents[0].user_id || userEvents[0].distinct_id || "") : "";
|
|
821
|
-
const isWhale = userId.length > 0 && userId.charCodeAt(0) % 3 === 0;
|
|
822
|
-
|
|
823
|
-
// First pass: identify user patterns
|
|
824
|
-
userEvents.forEach((event) => {
|
|
825
|
-
const eventTime = dayjs(event.time);
|
|
826
|
-
const daysSinceStart = firstEventTime ? eventTime.diff(firstEventTime, 'days', true) : 0;
|
|
827
|
-
|
|
828
|
-
if (event.subscription_tier) {
|
|
829
|
-
subscriptionTier = event.subscription_tier;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
if (event.event === "use item" && event.item_type === "Ancient Compass") {
|
|
833
|
-
usedAncientCompass = true;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
if (event.event === "real money purchase" && event.product === "Lucky Charm Pack") {
|
|
837
|
-
boughtLuckyCharm = true;
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
if (event.event === "guild joined" && daysSinceStart < 3) {
|
|
841
|
-
joinedGuildEarly = true;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
if (event.event === "player death" && daysSinceStart < 7) {
|
|
845
|
-
earlyDeaths++;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
if (event.event === "find treasure" && event.treasure_type === "Shadowmourne Legendary") {
|
|
849
|
-
hasLegendaryWeapon = true;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
if (event.event === "inspect") {
|
|
853
|
-
inspectedBeforeDungeon = true;
|
|
854
|
-
}
|
|
855
|
-
if (event.event === "search for clues") {
|
|
856
|
-
searchedBeforeDungeon = true;
|
|
857
|
-
}
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
// Second pass: raw mutations + cloning, no flag stamping
|
|
861
|
-
userEvents.forEach((event, idx) => {
|
|
862
|
-
const eventTime = dayjs(event.time);
|
|
863
|
-
|
|
864
|
-
// Hook 9: PROGRESSION SCALING — Quest gold scales with level.
|
|
865
|
-
// Power curve so bucket-averaged high-level/low-level ratio >= 2.0x.
|
|
866
|
-
if (event.event === "quest turned in") {
|
|
867
|
-
const baseGold = event.reward_gold || 100;
|
|
868
|
-
event.reward_gold = Math.floor(baseGold * (1 + userLevel * 0.15));
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
// Hook 1: CONVERSION — Ancient Compass users earn 1.5x quest
|
|
872
|
-
// rewards plus 40% chance of bonus cloned quest.
|
|
873
|
-
if (usedAncientCompass && event.event === "quest turned in") {
|
|
874
|
-
event.reward_gold = Math.floor((event.reward_gold || 100) * 1.5);
|
|
875
|
-
event.reward_xp = Math.floor((event.reward_xp || 500) * 1.5);
|
|
876
|
-
|
|
877
|
-
if (chance.bool({ likelihood: 40 })) {
|
|
878
|
-
userEvents.splice(idx + 1, 0, {
|
|
879
|
-
...event,
|
|
880
|
-
time: eventTime.add(chance.integer({ min: 10, max: 120 }), 'minutes').toISOString(),
|
|
881
|
-
quest_id: chance.pickone(questIds),
|
|
882
|
-
reward_gold: chance.integer({ min: 100, max: 500 }),
|
|
883
|
-
reward_xp: chance.integer({ min: 500, max: 2000 }),
|
|
884
|
-
});
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// Hook 5: PURCHASE VALUE — Lucky charm buyers see 2x prices
|
|
889
|
-
// and 35% chance of bonus high-value cloned purchase.
|
|
890
|
-
if (boughtLuckyCharm) {
|
|
891
|
-
if (event.event === "real money purchase" && event.price_usd) {
|
|
892
|
-
event.price_usd = Math.round(event.price_usd * 2.5 * 100) / 100;
|
|
893
|
-
}
|
|
894
|
-
if (event.event === "item purchased" && chance.bool({ likelihood: 35 })) {
|
|
895
|
-
const purchaseTemplate = userEvents.find(e => e.event === "real money purchase");
|
|
896
|
-
if (purchaseTemplate) {
|
|
897
|
-
userEvents.splice(idx + 1, 0, {
|
|
898
|
-
...purchaseTemplate,
|
|
899
|
-
time: eventTime.add(chance.integer({ min: 1, max: 3 }), 'days').toISOString(),
|
|
900
|
-
user_id: event.user_id,
|
|
901
|
-
product: chance.pickone(["Premium Currency (5000)", "Legendary Weapon Chest", "Season Pass"]),
|
|
902
|
-
price_usd: chance.pickone([19.99, 49.99, 99.99]),
|
|
903
|
-
payment_method: chance.pickone(["Credit Card", "PayPal"]),
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// Hook 10: WHALE PURCHASES — 1.8x price for whale cohort.
|
|
910
|
-
if (isWhale && event.event === "real money purchase" && event.price_usd) {
|
|
911
|
-
event.price_usd = Math.round(event.price_usd * 1.8 * 100) / 100;
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// Hook 6: BEHAVIORS TOGETHER — inspect+search dungeons get
|
|
915
|
-
// 85% completion rate + 2x treasure value.
|
|
916
|
-
if (inspectedBeforeDungeon && searchedBeforeDungeon) {
|
|
917
|
-
if (event.event === "exit dungeon" && event.completion_status !== "completed" && chance.bool({ likelihood: 85 })) {
|
|
918
|
-
event.completion_status = "completed";
|
|
919
|
-
}
|
|
920
|
-
if (event.event === "find treasure") {
|
|
921
|
-
event.treasure_value = Math.floor((event.treasure_value || 50) * 2);
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
// Hook 7: TIMED RELEASE — Legendary owners get 90% combat
|
|
926
|
-
// wins + 0.6x dungeon time.
|
|
927
|
-
if (hasLegendaryWeapon) {
|
|
928
|
-
if (event.event === "combat completed" && event.outcome !== "Victory" && chance.bool({ likelihood: 90 })) {
|
|
929
|
-
event.outcome = "Victory";
|
|
930
|
-
}
|
|
931
|
-
if (event.event === "exit dungeon") {
|
|
932
|
-
event.completion_status = "completed";
|
|
933
|
-
event.time_spent_mins = Math.floor((event.time_spent_mins || 60) * 0.6);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
// Hook 8: SUBSCRIPTION TIER — Premium/Elite get win+reward+
|
|
938
|
-
// completion+treasure boosts. Reads tier from profile.
|
|
939
|
-
if (subscriptionTier === "Premium" || subscriptionTier === "Elite") {
|
|
940
|
-
const isElite = subscriptionTier === "Elite";
|
|
941
|
-
if (event.event === "combat completed" && event.outcome !== "Victory") {
|
|
942
|
-
const winBoost = isElite ? 70 : 50;
|
|
943
|
-
if (chance.bool({ likelihood: winBoost })) {
|
|
944
|
-
event.outcome = "Victory";
|
|
945
|
-
event.loot_gained = true;
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
if (event.event === "quest turned in") {
|
|
949
|
-
const rewardMultiplier = isElite ? 1.8 : 1.4;
|
|
950
|
-
event.reward_gold = Math.floor((event.reward_gold || 100) * rewardMultiplier);
|
|
951
|
-
event.reward_xp = Math.floor((event.reward_xp || 500) * rewardMultiplier);
|
|
952
|
-
}
|
|
953
|
-
if (event.event === "exit dungeon") {
|
|
954
|
-
if (event.completion_status !== "completed") {
|
|
955
|
-
const completionBoost = isElite ? 65 : 45;
|
|
956
|
-
if (chance.bool({ likelihood: completionBoost })) {
|
|
957
|
-
event.completion_status = "completed";
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
if (event.completion_status === "completed") {
|
|
961
|
-
const speedBoost = isElite ? 0.7 : 0.85;
|
|
962
|
-
event.time_spent_mins = Math.floor((event.time_spent_mins || 60) * speedBoost);
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
if (event.event === "find treasure") {
|
|
966
|
-
const treasureBoost = isElite ? 2.0 : 1.5;
|
|
967
|
-
event.treasure_value = Math.floor((event.treasure_value || 50) * treasureBoost);
|
|
968
|
-
}
|
|
969
|
-
if (event.event === "player death") {
|
|
970
|
-
const survivalLikelihood = isElite ? 50 : 30;
|
|
971
|
-
if (chance.bool({ likelihood: survivalLikelihood })) {
|
|
972
|
-
event.event = "combat completed";
|
|
973
|
-
event.outcome = "Victory";
|
|
974
|
-
event.loot_gained = true;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
if (isElite && chance.bool({ likelihood: 5 })) {
|
|
978
|
-
if (event.event === "quest turned in" || event.event === "exit dungeon") {
|
|
979
|
-
const treasureTemplate = userEvents.find(e => e.event === "find treasure");
|
|
980
|
-
if (treasureTemplate) {
|
|
981
|
-
const treasureTypes = ["Rare Artifact", "Gold", "Weapon", "Armor"];
|
|
982
|
-
userEvents.splice(idx + 1, 0, {
|
|
983
|
-
...treasureTemplate,
|
|
984
|
-
time: eventTime.add(chance.integer({ min: 5, max: 30 }), 'minutes').toISOString(),
|
|
985
|
-
user_id: event.user_id,
|
|
986
|
-
treasure_type: chance.pickone(treasureTypes),
|
|
987
|
-
treasure_value: chance.integer({ min: 200, max: 800 }),
|
|
988
|
-
});
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
// Hook 2: CURSED WEEK — inject extra deaths in days 40-47 of
|
|
996
|
-
// user's timeline. Cause_of_death set to "Curse" on injected.
|
|
997
|
-
// Discover via line-chart of player-death by day-of-user-life.
|
|
998
|
-
if (firstEventTime) {
|
|
999
|
-
const deathTemplate = userEvents.find(e => e.event === "player death");
|
|
1000
|
-
if (deathTemplate) {
|
|
1001
|
-
const cursedStart = firstEventTime.add(40, 'days');
|
|
1002
|
-
const cursedEnd = firstEventTime.add(47, 'days');
|
|
1003
|
-
const cursedEvents = userEvents.filter(e => {
|
|
1004
|
-
const t = dayjs(e.time);
|
|
1005
|
-
return t.isAfter(cursedStart) && t.isBefore(cursedEnd);
|
|
1006
|
-
});
|
|
1007
|
-
const deathsToInject = Math.floor(cursedEvents.length * 0.6);
|
|
1008
|
-
for (let d = 0; d < deathsToInject; d++) {
|
|
1009
|
-
const sourceEvent = cursedEvents[d % cursedEvents.length];
|
|
1010
|
-
userEvents.push({
|
|
1011
|
-
...deathTemplate,
|
|
1012
|
-
time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 30 }), 'minutes').toISOString(),
|
|
1013
|
-
user_id: sourceEvent.user_id,
|
|
1014
|
-
event: "player death",
|
|
1015
|
-
cause_of_death: "Curse",
|
|
1016
|
-
player_level: chance.integer({ min: 1, max: 50 }),
|
|
1017
|
-
resurrection_used: chance.bool({ likelihood: 80 }),
|
|
1018
|
-
});
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// Hook 3 RETENTION + Hook 4 CHURN — early guild-joiners get
|
|
1024
|
-
// extra cloned combat events; non-joiners with 3+ deaths in
|
|
1025
|
-
// first week lose 70% of post-week events. No flag.
|
|
1026
|
-
const shouldChurn = (!joinedGuildEarly && earlyDeaths >= 2) || (earlyDeaths >= 4);
|
|
1027
|
-
if (shouldChurn) {
|
|
1028
|
-
const firstWeekEnd = firstEventTime ? firstEventTime.add(7, 'days') : null;
|
|
1029
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
1030
|
-
if (firstWeekEnd && dayjs(userEvents[i].time).isAfter(firstWeekEnd) && chance.bool({ likelihood: 80 })) {
|
|
1031
|
-
userEvents.splice(i, 1);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
} else if (joinedGuildEarly) {
|
|
1035
|
-
const lastEvent = userEvents[userEvents.length - 1];
|
|
1036
|
-
const combatTemplate = userEvents.find(e => e.event === "combat completed");
|
|
1037
|
-
if (lastEvent && combatTemplate && chance.bool({ likelihood: 60 })) {
|
|
1038
|
-
userEvents.push({
|
|
1039
|
-
...combatTemplate,
|
|
1040
|
-
time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 5 }), 'days').toISOString(),
|
|
1041
|
-
user_id: lastEvent.user_id,
|
|
1042
|
-
outcome: "Victory",
|
|
1043
|
-
loot_gained: true,
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
// HOOK 13: COMBAT-PREP MAGIC NUMBER (in-funnel, no flags)
|
|
1049
|
-
// Sweet 3-6 inspect+search events between quest accepted and
|
|
1050
|
-
// fight boss → +30% loot/treasure_value on find-treasure events.
|
|
1051
|
-
// Over 7+ → drop 25% of fight-boss completion (over-prep
|
|
1052
|
-
// signals analysis paralysis). No flag.
|
|
1053
|
-
const questAccept = userEvents.find(e => e.event === "quest accepted");
|
|
1054
|
-
const bossFight = userEvents.find(e => e.event === "fight boss");
|
|
1055
|
-
if (questAccept && bossFight) {
|
|
1056
|
-
const aTime = dayjs(questAccept.time);
|
|
1057
|
-
const bTime = dayjs(bossFight.time);
|
|
1058
|
-
const prepCount = userEvents.filter(e =>
|
|
1059
|
-
(e.event === "inspect" || e.event === "search for clues") &&
|
|
1060
|
-
dayjs(e.time).isAfter(aTime) &&
|
|
1061
|
-
dayjs(e.time).isBefore(bTime)
|
|
1062
|
-
).length;
|
|
1063
|
-
if (prepCount >= 3 && prepCount <= 6) {
|
|
1064
|
-
userEvents.forEach(e => {
|
|
1065
|
-
if (e.event === "find treasure" && typeof e.treasure_value === "number") {
|
|
1066
|
-
e.treasure_value = Math.round(e.treasure_value * 1.3);
|
|
1067
|
-
}
|
|
1068
|
-
});
|
|
1069
|
-
} else if (prepCount >= 7) {
|
|
1070
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
1071
|
-
const ev = userEvents[i];
|
|
1072
|
-
if (ev.event === "fight boss" && ev.victory === true && chance.bool({ likelihood: 25 })) {
|
|
1073
|
-
ev.victory = false;
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1113
|
+
hook(record, type, meta) {
|
|
1114
|
+
if (type === "user") return handleUserHooks(record);
|
|
1115
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
1080
1116
|
return record;
|
|
1081
|
-
}
|
|
1117
|
+
},
|
|
1082
1118
|
};
|
|
1083
1119
|
|
|
1084
1120
|
export default config;
|