@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,60 +1,54 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "harness-fintech";
|
|
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 { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
|
|
16
|
-
|
|
17
|
-
dayjs.extend(utc);
|
|
18
|
-
const chance = u.initChance(SEED);
|
|
19
8
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
20
9
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* -
|
|
42
|
-
* -
|
|
43
|
-
* -
|
|
44
|
-
* -
|
|
45
|
-
* -
|
|
46
|
-
* -
|
|
10
|
+
// ── OVERVIEW ──
|
|
11
|
+
/*
|
|
12
|
+
* NAME: NexBank
|
|
13
|
+
* APP: Chime/Revolut-style neobank app. Users open accounts (personal or
|
|
14
|
+
* business), transact across 7 merchant categories, send transfers,
|
|
15
|
+
* pay bills, set budgets, invest, apply for loans, and earn
|
|
16
|
+
* tier-scaled rewards. Core loop runs from onboarding through daily
|
|
17
|
+
* banking, financial planning, investments, and rewards.
|
|
18
|
+
* SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
|
|
19
|
+
* CORE LOOP: account opened → app session → balance checked → transaction completed
|
|
20
|
+
*
|
|
21
|
+
* EVENTS (19):
|
|
22
|
+
* app session (20) > transaction completed (18) > balance checked (15)
|
|
23
|
+
* > notification opened (10) > transfer sent (8) > bill paid (6)
|
|
24
|
+
* > investment made (4) > reward redeemed (4) > budget alert (4)
|
|
25
|
+
* > budget created (3) > savings goal set (3) > support contacted (3)
|
|
26
|
+
* > card locked (2) > dispute filed (2) > loan applied (2) > premium upgraded (2)
|
|
27
|
+
* > account opened (1) > loan approved (1) > bill payment missed (1)
|
|
28
|
+
*
|
|
29
|
+
* FUNNELS (8):
|
|
30
|
+
* - Onboarding: account opened → app session → balance checked (85%)
|
|
31
|
+
* - Daily Banking: app session → balance checked → transaction completed (80%)
|
|
32
|
+
* - Transfers: app session → transfer sent → notification opened (50%)
|
|
33
|
+
* - Bill Payment: app session → bill paid → notification opened (60%)
|
|
34
|
+
* - Financial Planning: budget created → budget alert → savings goal set (40%)
|
|
35
|
+
* - Investment: balance checked → investment made → reward redeemed (30%)
|
|
36
|
+
* - Support: support contacted → card locked → dispute filed (35%)
|
|
37
|
+
* - Lending: loan applied → loan approved → premium upgraded (25%)
|
|
38
|
+
*
|
|
39
|
+
* USER PROPS: account_tier, Platform, credit_score_range, income_bracket, account_age_months, total_balance, has_direct_deposit, account_segment, employee_count, annual_revenue, industry, age_range, life_stage
|
|
40
|
+
* SUPER PROPS: account_tier, Platform
|
|
41
|
+
* SCD PROPS: account_tier (basic/plus/premium, monthly fuzzy, max 6), risk_category (low/medium/high/critical, household_id-scoped, monthly fixed, max 8)
|
|
42
|
+
* GROUPS: household_id (500 households)
|
|
47
43
|
*/
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
52
|
-
* ===================================================================
|
|
53
|
-
*
|
|
45
|
+
// ── HOOK STORIES ──
|
|
46
|
+
/*
|
|
54
47
|
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
|
|
55
48
|
* via behavioral cohorts, raw-prop breakdowns (date, account_tier),
|
|
56
49
|
* or funnel time-to-convert.
|
|
57
50
|
*
|
|
51
|
+
* ---------------------------------------------------------------
|
|
58
52
|
* 1. PERSONAL VS BUSINESS ACCOUNTS (user)
|
|
59
53
|
*
|
|
60
54
|
* PATTERN: 20% of accounts are business (employee_count, revenue,
|
|
@@ -214,7 +208,7 @@ const chance = u.initChance(SEED);
|
|
|
214
208
|
* payment cadence that virtually eliminates missed bills.
|
|
215
209
|
*
|
|
216
210
|
* ---------------------------------------------------------------
|
|
217
|
-
* 7. PREMIUM TIER VALUE (
|
|
211
|
+
* 7. PREMIUM TIER VALUE (everything)
|
|
218
212
|
*
|
|
219
213
|
* PATTERN: Premium-tier users get 3x reward values and 2x sell
|
|
220
214
|
* returns on investments. Plus tier gets 1.5x rewards. No flag —
|
|
@@ -266,23 +260,6 @@ const chance = u.initChance(SEED);
|
|
|
266
260
|
* REAL-WORLD ANALOGUE: Users obsessively check balances at month
|
|
267
261
|
* end as bills hit and runway tightens.
|
|
268
262
|
*
|
|
269
|
-
* ===================================================================
|
|
270
|
-
* ADVANCED ANALYSIS IDEAS
|
|
271
|
-
* ===================================================================
|
|
272
|
-
*
|
|
273
|
-
* Cross-hook patterns:
|
|
274
|
-
* - Budget + Low Balance: Do budget creators avoid low-balance churn?
|
|
275
|
-
* - Premium + Auto-Pay: Do premium users adopt auto-pay more?
|
|
276
|
-
* - Fraud + Churn: Do fraud victims churn more? Does resolution help?
|
|
277
|
-
* - Payday + Month-End: Do payday spenders run out by month-end?
|
|
278
|
-
* - Business vs Personal Fraud: Are business accounts more targeted?
|
|
279
|
-
*
|
|
280
|
-
* Cohort analysis:
|
|
281
|
-
* - By account_tier: upgrade paths, value realization
|
|
282
|
-
* - By signup_channel: referral retention vs organic
|
|
283
|
-
* - By income_bracket: feature adoption by income
|
|
284
|
-
* - By credit_score_range: loan approvals, tier adoption
|
|
285
|
-
*
|
|
286
263
|
* ---------------------------------------------------------------
|
|
287
264
|
* 9. ONBOARDING TIME-TO-CONVERT (everything)
|
|
288
265
|
*
|
|
@@ -345,33 +322,327 @@ const chance = u.initChance(SEED);
|
|
|
345
322
|
* Txn-Count Magic Num | over premium upgrades | 1x | 0.8x | -20%
|
|
346
323
|
*/
|
|
347
324
|
|
|
325
|
+
// ── SCALE ──
|
|
326
|
+
const SEED = "harness-fintech";
|
|
327
|
+
const NUM_USERS = 10_000;
|
|
328
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
329
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
330
|
+
const EVENTS_PER_DAY = 1.2;
|
|
331
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
332
|
+
|
|
333
|
+
const chance = u.initChance(SEED);
|
|
334
|
+
|
|
335
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
336
|
+
// H1: Personal vs Business
|
|
337
|
+
const BUSINESS_LIKELIHOOD = 20;
|
|
338
|
+
const BUSINESS_TXN_MULT = 4;
|
|
339
|
+
|
|
340
|
+
// H2: Payday Patterns
|
|
341
|
+
const PAYDAY_DEPOSIT_MULT = 3;
|
|
342
|
+
const PAYDAY_TRANSFER_MULT = 2.0;
|
|
343
|
+
const PAYDAY_TRANSFER_LIKELIHOOD = 60;
|
|
344
|
+
|
|
345
|
+
// H3: Fraud Detection
|
|
346
|
+
const FRAUD_LIKELIHOOD = 15;
|
|
347
|
+
const FRAUD_BURST_MIN = 3;
|
|
348
|
+
const FRAUD_BURST_MAX = 5;
|
|
349
|
+
const FRAUD_AMOUNT_MIN = 500;
|
|
350
|
+
const FRAUD_AMOUNT_MAX = 3000;
|
|
351
|
+
|
|
352
|
+
// H4: Low Balance Churn
|
|
353
|
+
const LOW_BALANCE_THRESHOLD = 15000;
|
|
354
|
+
const LOW_BALANCE_CHECK_THRESHOLD = 3;
|
|
355
|
+
const LOW_BALANCE_CHURN_CUTOFF_DAYS = 30;
|
|
356
|
+
const LOW_BALANCE_DROP_LIKELIHOOD = 50;
|
|
357
|
+
|
|
358
|
+
// H5: Budget Discipline
|
|
359
|
+
const BUDGET_SAVINGS_MULT = 2;
|
|
360
|
+
const BUDGET_INVESTMENT_MULT = 1.5;
|
|
361
|
+
const BUDGET_CLONE_LIKELIHOOD = 50;
|
|
362
|
+
|
|
363
|
+
// H6: Auto-Pay Loyalty
|
|
364
|
+
const MISSED_BILL_LIKELIHOOD = 30;
|
|
365
|
+
|
|
366
|
+
// H7: Premium Tier Value
|
|
367
|
+
const PREMIUM_REWARD_MULT = 3;
|
|
368
|
+
const PLUS_REWARD_MULT = 1.5;
|
|
369
|
+
const PREMIUM_INVEST_SELL_MULT = 2;
|
|
370
|
+
|
|
371
|
+
// H8: Month-End Anxiety
|
|
372
|
+
const MONTH_END_DAY_THRESHOLD = 28;
|
|
373
|
+
const MONTH_END_SESSION_MULT = 1.4;
|
|
374
|
+
const MONTH_END_BALANCE_MULT = 0.7;
|
|
375
|
+
|
|
376
|
+
// H9: Onboarding TTC
|
|
377
|
+
const TTC_PREMIUM_FACTOR = 0.67;
|
|
378
|
+
const TTC_BASIC_FACTOR = 1.33;
|
|
379
|
+
const TTC_MAX_GAP_MINUTES = 60 * 24 * 30; // 30-day max gap between steps
|
|
380
|
+
|
|
381
|
+
// H10: Transaction-Count Magic Number
|
|
382
|
+
const TXN_SWEET_MIN = 6;
|
|
383
|
+
const TXN_SWEET_MAX = 10;
|
|
384
|
+
const TXN_OVER_THRESHOLD = 11;
|
|
385
|
+
const TXN_INVESTMENT_BOOST = 1.4;
|
|
386
|
+
const TXN_PREMIUM_DROP_LIKELIHOOD = 20;
|
|
387
|
+
|
|
388
|
+
// ── HELPER FUNCTIONS ──
|
|
389
|
+
function handleUserHooks(record) {
|
|
390
|
+
// H1: PERSONAL VS BUSINESS ACCOUNTS — role-based attrs.
|
|
391
|
+
const isBusiness = chance.bool({ likelihood: BUSINESS_LIKELIHOOD });
|
|
392
|
+
if (isBusiness) {
|
|
393
|
+
record.account_segment = "business";
|
|
394
|
+
record.employee_count = chance.integer({ min: 5, max: 500 });
|
|
395
|
+
record.annual_revenue = chance.integer({ min: 100000, max: 10000000 });
|
|
396
|
+
record.industry = chance.pickone(["tech", "retail", "food", "services", "healthcare"]);
|
|
397
|
+
} else {
|
|
398
|
+
record.account_segment = "personal";
|
|
399
|
+
record.age_range = `${chance.pickone([18, 25, 35, 45, 55])}-${chance.pickone([24, 34, 44, 54, 65])}`;
|
|
400
|
+
record.life_stage = chance.pickone(["student", "early_career", "established", "pre_retirement", "retired"]);
|
|
401
|
+
}
|
|
402
|
+
return record;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function handleEventHooks(record) {
|
|
406
|
+
// H6: AUTO-PAY LOYALTY — manual bill-paid events have 30% chance of
|
|
407
|
+
// becoming "bill payment missed". Mutates event name.
|
|
408
|
+
if (record.event === "bill paid" && record.auto_pay !== true && chance.bool({ likelihood: MISSED_BILL_LIKELIHOOD })) {
|
|
409
|
+
record.event = "bill payment missed";
|
|
410
|
+
}
|
|
411
|
+
return record;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function handleEverythingHooks(record, meta) {
|
|
415
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
416
|
+
const userEvents = record;
|
|
417
|
+
const profile = meta.profile;
|
|
418
|
+
|
|
419
|
+
userEvents.forEach(e => {
|
|
420
|
+
e.account_tier = profile.account_tier;
|
|
421
|
+
e.Platform = profile.Platform;
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// H9: ONBOARDING TIME-TO-CONVERT — Premium 1.5x faster (factor 0.67);
|
|
425
|
+
// Basic 1.33x slower (factor 1.33). Finds first onboarding sequence
|
|
426
|
+
// and scales the inter-step gaps.
|
|
427
|
+
{
|
|
428
|
+
const ttcFactor = (
|
|
429
|
+
profile.account_tier === "premium" ? TTC_PREMIUM_FACTOR :
|
|
430
|
+
profile.account_tier === "basic" ? TTC_BASIC_FACTOR :
|
|
431
|
+
1.0
|
|
432
|
+
);
|
|
433
|
+
if (ttcFactor !== 1.0) {
|
|
434
|
+
const onboardingSeq = findFirstSequence(
|
|
435
|
+
userEvents,
|
|
436
|
+
["account opened", "app session", "balance checked"],
|
|
437
|
+
TTC_MAX_GAP_MINUTES
|
|
438
|
+
);
|
|
439
|
+
if (onboardingSeq) {
|
|
440
|
+
scaleFunnelTTC(onboardingSeq, ttcFactor);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// H1B: PERSONAL VS BUSINESS — business segment txns 4x larger
|
|
446
|
+
// (per Report 2 in JSDoc: business ~ $200, personal ~ $50).
|
|
447
|
+
if (profile.account_segment === "business") {
|
|
448
|
+
userEvents.forEach(e => {
|
|
449
|
+
if (e.event === "transaction completed" && typeof e.amount === "number") {
|
|
450
|
+
e.amount = Math.floor(e.amount * BUSINESS_TXN_MULT);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// H2: PAYDAY PATTERNS — 1st & 15th: direct_deposit amount 3x.
|
|
456
|
+
// Days 1-3 and 15-17: 60% of transfers get amount 2x. No flag.
|
|
457
|
+
for (const e of userEvents) {
|
|
458
|
+
const dayOfMonth = new Date(e.time).getUTCDate();
|
|
459
|
+
if (e.event === "transaction completed" && e.transaction_type === "direct_deposit") {
|
|
460
|
+
if (dayOfMonth === 1 || dayOfMonth === 15) {
|
|
461
|
+
e.amount = Math.floor((e.amount || 50) * PAYDAY_DEPOSIT_MULT);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (e.event === "transfer sent") {
|
|
465
|
+
const isPaydayWindow = (dayOfMonth >= 1 && dayOfMonth <= 3) || (dayOfMonth >= 15 && dayOfMonth <= 17);
|
|
466
|
+
if (isPaydayWindow && chance.bool({ likelihood: PAYDAY_TRANSFER_LIKELIHOOD })) {
|
|
467
|
+
e.amount = Math.floor((e.amount || 200) * PAYDAY_TRANSFER_MULT);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// H8: MONTH-END ANXIETY — days >= 28: app_session duration 1.4x;
|
|
473
|
+
// balance_checked account_balance 0.7x. Mutates raw props.
|
|
474
|
+
for (const e of userEvents) {
|
|
475
|
+
const dayOfMonth = new Date(e.time).getUTCDate();
|
|
476
|
+
if (dayOfMonth >= MONTH_END_DAY_THRESHOLD) {
|
|
477
|
+
if (e.event === "app session") {
|
|
478
|
+
e.session_duration_sec = Math.floor((e.session_duration_sec || 60) * MONTH_END_SESSION_MULT);
|
|
479
|
+
}
|
|
480
|
+
if (e.event === "balance checked") {
|
|
481
|
+
e.account_balance = Math.floor((e.account_balance || 2500) * MONTH_END_BALANCE_MULT);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// H7: PREMIUM TIER VALUE — Premium 3x reward value + 2x investment-sell
|
|
487
|
+
// amount; Plus 1.5x reward value. Reads tier from profile. No flag.
|
|
488
|
+
const tier = profile.account_tier;
|
|
489
|
+
userEvents.forEach(e => {
|
|
490
|
+
if (e.event === "reward redeemed") {
|
|
491
|
+
if (tier === "premium") e.value = Math.floor((e.value || 10) * PREMIUM_REWARD_MULT);
|
|
492
|
+
else if (tier === "plus") e.value = Math.floor((e.value || 10) * PLUS_REWARD_MULT);
|
|
493
|
+
}
|
|
494
|
+
if (e.event === "investment made" && e.action === "sell" && tier === "premium") {
|
|
495
|
+
e.amount = Math.floor((e.amount || 250) * PREMIUM_INVEST_SELL_MULT);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// H3: FRAUD DETECTION — 15% of users get fraud burst (3-5 rapid
|
|
500
|
+
// high-value transactions + card locked + dispute + support contacted)
|
|
501
|
+
// at timeline midpoint. No flag — discover via cohort builder on users
|
|
502
|
+
// with card-locked + dispute-filed.
|
|
503
|
+
if (chance.bool({ likelihood: FRAUD_LIKELIHOOD }) && userEvents.length >= 2) {
|
|
504
|
+
const midIdx = Math.floor(userEvents.length / 2);
|
|
505
|
+
const midEvent = userEvents[midIdx];
|
|
506
|
+
const midTime = dayjs(midEvent.time);
|
|
507
|
+
const distinctId = midEvent.user_id;
|
|
508
|
+
const burstCount = chance.integer({ min: FRAUD_BURST_MIN, max: FRAUD_BURST_MAX });
|
|
509
|
+
const fraudEvents = [];
|
|
510
|
+
const txnTemplate = userEvents.find(e => e.event === "transaction completed");
|
|
511
|
+
const cardTemplate = userEvents.find(e => e.event === "card locked");
|
|
512
|
+
const disputeTemplate = userEvents.find(e => e.event === "dispute filed");
|
|
513
|
+
const supportTemplate = userEvents.find(e => e.event === "support contacted");
|
|
514
|
+
|
|
515
|
+
for (let i = 0; i < burstCount; i++) {
|
|
516
|
+
if (txnTemplate) {
|
|
517
|
+
fraudEvents.push({
|
|
518
|
+
...txnTemplate,
|
|
519
|
+
time: midTime.add(i * 10, "minutes").toISOString(),
|
|
520
|
+
user_id: distinctId,
|
|
521
|
+
transaction_type: "purchase",
|
|
522
|
+
amount: chance.integer({ min: FRAUD_AMOUNT_MIN, max: FRAUD_AMOUNT_MAX }),
|
|
523
|
+
merchant_category: chance.pickone(["online", "retail"]),
|
|
524
|
+
payment_method: "credit",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (cardTemplate) fraudEvents.push({
|
|
529
|
+
...cardTemplate,
|
|
530
|
+
time: midTime.add(burstCount * 10 + 5, "minutes").toISOString(),
|
|
531
|
+
user_id: distinctId,
|
|
532
|
+
reason: "suspicious_activity",
|
|
533
|
+
});
|
|
534
|
+
if (disputeTemplate) fraudEvents.push({
|
|
535
|
+
...disputeTemplate,
|
|
536
|
+
time: midTime.add(burstCount * 10 + 30, "minutes").toISOString(),
|
|
537
|
+
user_id: distinctId,
|
|
538
|
+
dispute_amount: chance.integer({ min: FRAUD_AMOUNT_MIN, max: FRAUD_AMOUNT_MAX }),
|
|
539
|
+
reason: "unauthorized",
|
|
540
|
+
});
|
|
541
|
+
if (supportTemplate) fraudEvents.push({
|
|
542
|
+
...supportTemplate,
|
|
543
|
+
time: midTime.add(burstCount * 10 + 45, "minutes").toISOString(),
|
|
544
|
+
user_id: distinctId,
|
|
545
|
+
channel: "phone",
|
|
546
|
+
issue_type: "card",
|
|
547
|
+
resolved: true,
|
|
548
|
+
});
|
|
549
|
+
userEvents.splice(midIdx + 1, 0, ...fraudEvents);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// H4: LOW BALANCE CHURN — users with 3+ balance checks under $15K lose
|
|
553
|
+
// 50% of post-day-30 events. No flag.
|
|
554
|
+
const lowBalanceChecks = userEvents.filter(e =>
|
|
555
|
+
e.event === "balance checked" && (e.account_balance || 0) < LOW_BALANCE_THRESHOLD
|
|
556
|
+
).length;
|
|
557
|
+
if (lowBalanceChecks >= LOW_BALANCE_CHECK_THRESHOLD) {
|
|
558
|
+
const dayCutoff = datasetStart.add(LOW_BALANCE_CHURN_CUTOFF_DAYS, "days");
|
|
559
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
560
|
+
if (dayjs(userEvents[i].time).isAfter(dayCutoff) && chance.bool({ likelihood: LOW_BALANCE_DROP_LIKELIHOOD })) {
|
|
561
|
+
userEvents.splice(i, 1);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// H5: BUDGET DISCIPLINE — users with any budget-created event get
|
|
567
|
+
// savings 2x, investment amounts 1.5x, and extra cloned savings-goal
|
|
568
|
+
// events. No flag.
|
|
569
|
+
const hasBudget = userEvents.some(e => e.event === "budget created");
|
|
570
|
+
if (hasBudget) {
|
|
571
|
+
userEvents.forEach((event, idx) => {
|
|
572
|
+
const eventTime = dayjs(event.time);
|
|
573
|
+
if (event.event === "savings goal set") {
|
|
574
|
+
event.monthly_contribution = Math.floor((event.monthly_contribution || 200) * BUDGET_SAVINGS_MULT);
|
|
575
|
+
}
|
|
576
|
+
if (event.event === "investment made") {
|
|
577
|
+
event.amount = Math.floor((event.amount || 250) * BUDGET_INVESTMENT_MULT);
|
|
578
|
+
}
|
|
579
|
+
if (event.event === "budget created" && chance.bool({ likelihood: BUDGET_CLONE_LIKELIHOOD })) {
|
|
580
|
+
const savingsTemplate = userEvents.find(e => e.event === "savings goal set");
|
|
581
|
+
if (savingsTemplate) {
|
|
582
|
+
userEvents.splice(idx + 1, 0, {
|
|
583
|
+
...savingsTemplate,
|
|
584
|
+
time: eventTime.add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
|
|
585
|
+
user_id: event.user_id,
|
|
586
|
+
goal_type: chance.pickone(["emergency", "vacation", "car", "home"]),
|
|
587
|
+
target_amount: chance.integer({ min: 1000, max: 20000 }),
|
|
588
|
+
monthly_contribution: chance.integer({ min: 100, max: 800 }),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// H10: TRANSACTION-COUNT MAGIC NUMBER (no flags)
|
|
596
|
+
// Sweet 6-10 transactions/user → +40% on investment_made amount.
|
|
597
|
+
// Over 11+ → drop 20% of premium-upgraded events.
|
|
598
|
+
const txnCount = userEvents.filter(e => e.event === "transaction completed").length;
|
|
599
|
+
if (txnCount >= TXN_SWEET_MIN && txnCount <= TXN_SWEET_MAX) {
|
|
600
|
+
userEvents.forEach(e => {
|
|
601
|
+
if (e.event === "investment made" && typeof e.amount === "number") {
|
|
602
|
+
e.amount = Math.round(e.amount * TXN_INVESTMENT_BOOST);
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
} else if (txnCount >= TXN_OVER_THRESHOLD) {
|
|
606
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
607
|
+
if (userEvents[i].event === "premium upgraded" && chance.bool({ likelihood: TXN_PREMIUM_DROP_LIKELIHOOD })) {
|
|
608
|
+
userEvents.splice(i, 1);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return record;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── CONFIG ──
|
|
348
617
|
/** @type {Config} */
|
|
349
618
|
const config = {
|
|
350
619
|
version: 2,
|
|
351
|
-
token,
|
|
352
620
|
seed: SEED,
|
|
353
|
-
datasetStart:
|
|
354
|
-
datasetEnd:
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
numUsers: num_users,
|
|
358
|
-
hasAnonIds: true,
|
|
359
|
-
avgDevicePerUser: 2,
|
|
360
|
-
hasSessionIds: true,
|
|
621
|
+
datasetStart: DATASET_START,
|
|
622
|
+
datasetEnd: DATASET_END,
|
|
623
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
624
|
+
numUsers: NUM_USERS,
|
|
361
625
|
format: "json",
|
|
362
626
|
gzip: true,
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
627
|
+
credentials: {
|
|
628
|
+
token,
|
|
629
|
+
},
|
|
630
|
+
switches: {
|
|
631
|
+
hasSessionIds: true,
|
|
632
|
+
alsoInferFunnels: false,
|
|
633
|
+
hasLocation: true,
|
|
634
|
+
hasAndroidDevices: true,
|
|
635
|
+
hasIOSDevices: true,
|
|
636
|
+
hasDesktopDevices: true,
|
|
637
|
+
hasBrowser: false,
|
|
638
|
+
hasCampaigns: false,
|
|
639
|
+
isAnonymous: false,
|
|
640
|
+
hasAdSpend: false,
|
|
641
|
+
hasAvatar: true,
|
|
642
|
+
},
|
|
643
|
+
identity: {
|
|
644
|
+
avgDevicePerUser: 2,
|
|
645
|
+
},
|
|
375
646
|
concurrency: 1,
|
|
376
647
|
writeToDisk: false,
|
|
377
648
|
|
|
@@ -664,248 +935,10 @@ const config = {
|
|
|
664
935
|
|
|
665
936
|
lookupTables: [],
|
|
666
937
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
*
|
|
672
|
-
* 1. PERSONAL VS BUSINESS: Business accounts get employee_count, revenue; personal get age_range, life_stage
|
|
673
|
-
* 2. PAYDAY PATTERNS: Transactions spike on 1st/15th with bigger deposits and post-payday spending (everything hook — runs after sessionization)
|
|
674
|
-
* 3. FRAUD DETECTION: 3% of users experience a fraud burst (rapid high-value txns -> card lock -> dispute -> support)
|
|
675
|
-
* 4. LOW BALANCE CHURN: Users with chronic low balances (<$15K) lose 50% of activity after day 30
|
|
676
|
-
* 5. BUDGET DISCIPLINE: Budget creators save 2x more and invest 1.5x more
|
|
677
|
-
* 6. AUTO-PAY LOYALTY: Auto-pay users never miss bills; manual payers miss 30%
|
|
678
|
-
* 7. PREMIUM TIER VALUE: Premium users get 3x rewards; Plus users get 1.5x; Premium investors get 2x returns
|
|
679
|
-
* 8. MONTH-END ANXIETY: Last 3 days of month see 40% longer sessions and 30% lower balances (everything hook — runs after sessionization)
|
|
680
|
-
*/
|
|
681
|
-
hook: function (record, type, meta) {
|
|
682
|
-
// HOOK 1: PERSONAL VS BUSINESS ACCOUNTS (user) — role-based attrs.
|
|
683
|
-
if (type === "user") {
|
|
684
|
-
const isBusiness = chance.bool({ likelihood: 20 });
|
|
685
|
-
if (isBusiness) {
|
|
686
|
-
record.account_segment = "business";
|
|
687
|
-
record.employee_count = chance.integer({ min: 5, max: 500 });
|
|
688
|
-
record.annual_revenue = chance.integer({ min: 100000, max: 10000000 });
|
|
689
|
-
record.industry = chance.pickone(["tech", "retail", "food", "services", "healthcare"]);
|
|
690
|
-
} else {
|
|
691
|
-
record.account_segment = "personal";
|
|
692
|
-
record.age_range = `${chance.pickone([18, 25, 35, 45, 55])}-${chance.pickone([24, 34, 44, 54, 65])}`;
|
|
693
|
-
record.life_stage = chance.pickone(["student", "early_career", "established", "pre_retirement", "retired"]);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// HOOK 6: AUTO-PAY LOYALTY (event) — manual bill-paid events have
|
|
698
|
-
// 30% chance of becoming "bill payment missed". Mutates event name.
|
|
699
|
-
if (type === "event") {
|
|
700
|
-
if (record.event === "bill paid" && record.auto_pay !== true && chance.bool({ likelihood: 30 })) {
|
|
701
|
-
record.event = "bill payment missed";
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
if (type === "everything") {
|
|
706
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
707
|
-
const userEvents = record;
|
|
708
|
-
const profile = meta.profile;
|
|
709
|
-
|
|
710
|
-
userEvents.forEach(e => {
|
|
711
|
-
e.account_tier = profile.account_tier;
|
|
712
|
-
e.Platform = profile.Platform;
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
// HOOK 9 (T2C): ONBOARDING TIME-TO-CONVERT (everything)
|
|
716
|
-
// Premium tier completes Onboarding funnel 1.5x faster (factor 0.67);
|
|
717
|
-
// Basic users 1.33x slower (factor 1.33). Finds the first occurrence
|
|
718
|
-
// of the onboarding sequence in the user's events and scales the gaps.
|
|
719
|
-
{
|
|
720
|
-
const ttcFactor = (
|
|
721
|
-
profile.account_tier === "premium" ? 0.67 :
|
|
722
|
-
profile.account_tier === "basic" ? 1.33 :
|
|
723
|
-
1.0
|
|
724
|
-
);
|
|
725
|
-
if (ttcFactor !== 1.0) {
|
|
726
|
-
const onboardingSeq = findFirstSequence(
|
|
727
|
-
userEvents,
|
|
728
|
-
["account opened", "app session", "balance checked"],
|
|
729
|
-
60 * 24 * 30 // 30-day max gap between steps
|
|
730
|
-
);
|
|
731
|
-
if (onboardingSeq) {
|
|
732
|
-
scaleFunnelTTC(onboardingSeq, ttcFactor);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// HOOK 1B: PERSONAL VS BUSINESS — business segment txns 4x larger
|
|
738
|
-
// (per Report 2 in JSDoc: business ~ $200, personal ~ $50).
|
|
739
|
-
if (profile.account_segment === "business") {
|
|
740
|
-
userEvents.forEach(e => {
|
|
741
|
-
if (e.event === "transaction completed" && typeof e.amount === "number") {
|
|
742
|
-
e.amount = Math.floor(e.amount * 4);
|
|
743
|
-
}
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// HOOK 2: PAYDAY PATTERNS — 1st & 15th: direct_deposit amount 3x.
|
|
748
|
-
// Days 1-3 and 15-17: 60% of transfers get amount 2x. No flag.
|
|
749
|
-
for (const e of userEvents) {
|
|
750
|
-
const dayOfMonth = new Date(e.time).getUTCDate();
|
|
751
|
-
if (e.event === "transaction completed" && e.transaction_type === "direct_deposit") {
|
|
752
|
-
if (dayOfMonth === 1 || dayOfMonth === 15) {
|
|
753
|
-
e.amount = Math.floor((e.amount || 50) * 3);
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
if (e.event === "transfer sent") {
|
|
757
|
-
const isPaydayWindow = (dayOfMonth >= 1 && dayOfMonth <= 3) || (dayOfMonth >= 15 && dayOfMonth <= 17);
|
|
758
|
-
if (isPaydayWindow && chance.bool({ likelihood: 60 })) {
|
|
759
|
-
e.amount = Math.floor((e.amount || 200) * 2.0);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// HOOK 8: MONTH-END ANXIETY — days >= 28: app_session duration
|
|
765
|
-
// 1.4x; balance_checked account_balance 0.7x. Mutates raw props.
|
|
766
|
-
for (const e of userEvents) {
|
|
767
|
-
const dayOfMonth = new Date(e.time).getUTCDate();
|
|
768
|
-
if (dayOfMonth >= 28) {
|
|
769
|
-
if (e.event === "app session") {
|
|
770
|
-
e.session_duration_sec = Math.floor((e.session_duration_sec || 60) * 1.4);
|
|
771
|
-
}
|
|
772
|
-
if (e.event === "balance checked") {
|
|
773
|
-
e.account_balance = Math.floor((e.account_balance || 2500) * 0.7);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// HOOK 7: PREMIUM TIER VALUE — Premium 3x reward value + 2x
|
|
779
|
-
// investment-sell amount; Plus 1.5x reward value. Reads tier
|
|
780
|
-
// from profile. No flag.
|
|
781
|
-
const tier = profile.account_tier;
|
|
782
|
-
userEvents.forEach(e => {
|
|
783
|
-
if (e.event === "reward redeemed") {
|
|
784
|
-
if (tier === "premium") e.value = Math.floor((e.value || 10) * 3);
|
|
785
|
-
else if (tier === "plus") e.value = Math.floor((e.value || 10) * 1.5);
|
|
786
|
-
}
|
|
787
|
-
if (e.event === "investment made" && e.action === "sell" && tier === "premium") {
|
|
788
|
-
e.amount = Math.floor((e.amount || 250) * 2);
|
|
789
|
-
}
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
// HOOK 3: FRAUD DETECTION — 3% of users get fraud burst
|
|
793
|
-
// (3-5 rapid high-value transactions + card locked + dispute +
|
|
794
|
-
// support contacted) at timeline midpoint. No flag — discover
|
|
795
|
-
// via cohort builder on users with card-locked + dispute-filed.
|
|
796
|
-
if (chance.bool({ likelihood: 15 }) && userEvents.length >= 2) {
|
|
797
|
-
const midIdx = Math.floor(userEvents.length / 2);
|
|
798
|
-
const midEvent = userEvents[midIdx];
|
|
799
|
-
const midTime = dayjs(midEvent.time);
|
|
800
|
-
const distinctId = midEvent.user_id;
|
|
801
|
-
const burstCount = chance.integer({ min: 3, max: 5 });
|
|
802
|
-
const fraudEvents = [];
|
|
803
|
-
const txnTemplate = userEvents.find(e => e.event === "transaction completed");
|
|
804
|
-
const cardTemplate = userEvents.find(e => e.event === "card locked");
|
|
805
|
-
const disputeTemplate = userEvents.find(e => e.event === "dispute filed");
|
|
806
|
-
const supportTemplate = userEvents.find(e => e.event === "support contacted");
|
|
807
|
-
|
|
808
|
-
for (let i = 0; i < burstCount; i++) {
|
|
809
|
-
if (txnTemplate) {
|
|
810
|
-
fraudEvents.push({
|
|
811
|
-
...txnTemplate,
|
|
812
|
-
time: midTime.add(i * 10, "minutes").toISOString(),
|
|
813
|
-
user_id: distinctId,
|
|
814
|
-
transaction_type: "purchase",
|
|
815
|
-
amount: chance.integer({ min: 500, max: 3000 }),
|
|
816
|
-
merchant_category: chance.pickone(["online", "retail"]),
|
|
817
|
-
payment_method: "credit",
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
if (cardTemplate) fraudEvents.push({
|
|
822
|
-
...cardTemplate,
|
|
823
|
-
time: midTime.add(burstCount * 10 + 5, "minutes").toISOString(),
|
|
824
|
-
user_id: distinctId,
|
|
825
|
-
reason: "suspicious_activity",
|
|
826
|
-
});
|
|
827
|
-
if (disputeTemplate) fraudEvents.push({
|
|
828
|
-
...disputeTemplate,
|
|
829
|
-
time: midTime.add(burstCount * 10 + 30, "minutes").toISOString(),
|
|
830
|
-
user_id: distinctId,
|
|
831
|
-
dispute_amount: chance.integer({ min: 500, max: 3000 }),
|
|
832
|
-
reason: "unauthorized",
|
|
833
|
-
});
|
|
834
|
-
if (supportTemplate) fraudEvents.push({
|
|
835
|
-
...supportTemplate,
|
|
836
|
-
time: midTime.add(burstCount * 10 + 45, "minutes").toISOString(),
|
|
837
|
-
user_id: distinctId,
|
|
838
|
-
channel: "phone",
|
|
839
|
-
issue_type: "card",
|
|
840
|
-
resolved: true,
|
|
841
|
-
});
|
|
842
|
-
userEvents.splice(midIdx + 1, 0, ...fraudEvents);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// HOOK 4: LOW BALANCE CHURN — users with 3+ balance checks
|
|
846
|
-
// under $15K lose 50% of post-day-30 events. No flag.
|
|
847
|
-
const lowBalanceChecks = userEvents.filter(e =>
|
|
848
|
-
e.event === "balance checked" && (e.account_balance || 0) < 15000
|
|
849
|
-
).length;
|
|
850
|
-
if (lowBalanceChecks >= 3) {
|
|
851
|
-
const day30 = datasetStart.add(30, "days");
|
|
852
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
853
|
-
if (dayjs(userEvents[i].time).isAfter(day30) && chance.bool({ likelihood: 50 })) {
|
|
854
|
-
userEvents.splice(i, 1);
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// HOOK 5: BUDGET DISCIPLINE — users with any budget-created event
|
|
860
|
-
// get savings 2x, investment amounts 1.5x, and extra cloned
|
|
861
|
-
// savings-goal events. No flag.
|
|
862
|
-
const hasBudget = userEvents.some(e => e.event === "budget created");
|
|
863
|
-
if (hasBudget) {
|
|
864
|
-
userEvents.forEach((event, idx) => {
|
|
865
|
-
const eventTime = dayjs(event.time);
|
|
866
|
-
if (event.event === "savings goal set") {
|
|
867
|
-
event.monthly_contribution = Math.floor((event.monthly_contribution || 200) * 2);
|
|
868
|
-
}
|
|
869
|
-
if (event.event === "investment made") {
|
|
870
|
-
event.amount = Math.floor((event.amount || 250) * 1.5);
|
|
871
|
-
}
|
|
872
|
-
if (event.event === "budget created" && chance.bool({ likelihood: 50 })) {
|
|
873
|
-
const savingsTemplate = userEvents.find(e => e.event === "savings goal set");
|
|
874
|
-
if (savingsTemplate) {
|
|
875
|
-
userEvents.splice(idx + 1, 0, {
|
|
876
|
-
...savingsTemplate,
|
|
877
|
-
time: eventTime.add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
|
|
878
|
-
user_id: event.user_id,
|
|
879
|
-
goal_type: chance.pickone(["emergency", "vacation", "car", "home"]),
|
|
880
|
-
target_amount: chance.integer({ min: 1000, max: 20000 }),
|
|
881
|
-
monthly_contribution: chance.integer({ min: 100, max: 800 }),
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
});
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// HOOK 10: TRANSACTION-COUNT MAGIC NUMBER (no flags)
|
|
889
|
-
// Sweet 6-10 transactions/user → +40% on investment_made amount
|
|
890
|
-
// (engaged transactor compounds wealth). Over 11+ → drop 20% of
|
|
891
|
-
// premium-upgraded events (already engaged; less upgrade pressure).
|
|
892
|
-
const txnCount = userEvents.filter(e => e.event === "transaction completed").length;
|
|
893
|
-
if (txnCount >= 6 && txnCount <= 10) {
|
|
894
|
-
userEvents.forEach(e => {
|
|
895
|
-
if (e.event === "investment made" && typeof e.amount === "number") {
|
|
896
|
-
e.amount = Math.round(e.amount * 1.4);
|
|
897
|
-
}
|
|
898
|
-
});
|
|
899
|
-
} else if (txnCount >= 11) {
|
|
900
|
-
// Drop 20% of premium-upgraded events for heavy transactors
|
|
901
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
902
|
-
if (userEvents[i].event === "premium upgraded" && chance.bool({ likelihood: 20 })) {
|
|
903
|
-
userEvents.splice(i, 1);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
|
|
938
|
+
hook(record, type, meta) {
|
|
939
|
+
if (type === "user") return handleUserHooks(record);
|
|
940
|
+
if (type === "event") return handleEventHooks(record);
|
|
941
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
909
942
|
return record;
|
|
910
943
|
}
|
|
911
944
|
};
|