@ak--47/dungeon-master 1.4.5 → 1.5.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 +158 -0
- package/.claude/skills/create-dungeon/SKILL.md +464 -0
- package/.claude/skills/verify-dungeon/SKILL.md +157 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
- package/.claude/skills/write-hooks/SKILL.md +468 -0
- package/CHANGELOG.md +182 -0
- package/HOOKS.md +1256 -597
- package/README.md +140 -5
- 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 +87 -0
- 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 +111 -65
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +300 -333
- package/dungeons/vertical/community.js +290 -255
- package/dungeons/vertical/crypto.js +400 -391
- package/dungeons/vertical/dating.js +421 -375
- package/dungeons/vertical/devtools.js +346 -298
- package/dungeons/vertical/ecommerce.js +322 -394
- package/dungeons/vertical/education.js +380 -325
- package/dungeons/vertical/fintech.js +371 -325
- package/dungeons/vertical/fitness.js +345 -291
- package/dungeons/vertical/food-delivery.js +352 -307
- package/dungeons/vertical/gaming.js +490 -444
- package/dungeons/vertical/healthcare.js +311 -262
- package/dungeons/vertical/insurance-application.js +437 -409
- package/dungeons/vertical/logistics.js +278 -252
- package/dungeons/vertical/marketplace.js +340 -323
- package/dungeons/vertical/media.js +390 -335
- package/dungeons/vertical/real-estate.js +402 -347
- package/dungeons/vertical/sass.js +331 -333
- package/dungeons/vertical/social.js +377 -316
- package/dungeons/vertical/travel.js +302 -295
- package/index.js +64 -7
- package/lib/core/config-validator.js +378 -17
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +76 -2
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +598 -48
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +53 -11
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +157 -109
- package/lib/verify/counting.js +360 -0
- package/lib/verify/emulate-breakdown.js +531 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +20 -0
- package/lib/verify/schema-validator.js +3 -1
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +14 -3
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +353 -4
- package/scripts/smoke-test-all.mjs +0 -162
|
@@ -1,64 +1,51 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "dm4-travel";
|
|
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
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
10
|
+
// ── OVERVIEW ──
|
|
11
|
+
/*
|
|
12
|
+
* NAME: StayQuest
|
|
13
|
+
* APP: Hotel booking platform for business and leisure travelers.
|
|
14
|
+
* Users search destinations, compare hotels, book rooms, and leave
|
|
15
|
+
* reviews. Revenue from commission per booking plus premium loyalty
|
|
16
|
+
* membership. Four traveler archetypes: business, leisure family,
|
|
17
|
+
* luxury, budget.
|
|
18
|
+
* SCALE: 10,000 users, ~600K events, 121 days (2026-01-01 → 2026-05-01)
|
|
19
|
+
* CORE LOOP: search → view hotel → compare → book → stay → review
|
|
20
|
+
*
|
|
21
|
+
* EVENTS (17):
|
|
22
|
+
* destination searched (8) > hotel viewed (7) > app session (7) > notification received (5)
|
|
23
|
+
* > price compared (4) > amenity used (4) > booking completed (3) > wishlist updated (3)
|
|
24
|
+
* > room upgrade selected (2) > booking cancelled (2) > check in completed (2)
|
|
25
|
+
* > review submitted (2) > price alert set (2) > account created (1)
|
|
26
|
+
* > loyalty points redeemed (1) > support contacted (1) > account deactivated (1)
|
|
27
|
+
*
|
|
28
|
+
* FUNNELS (5):
|
|
29
|
+
* - Onboarding to First Booking: account created → destination searched → hotel viewed → booking completed (35%)
|
|
30
|
+
* - Search to Book: destination searched → hotel viewed → price compared → booking completed (25%)
|
|
31
|
+
* - Full Stay Journey: booking completed → check in completed → amenity used → review submitted (30%)
|
|
32
|
+
* - Loyalty Engagement: booking completed → loyalty points redeemed → review submitted (15%)
|
|
33
|
+
* - Upsell Path: hotel viewed → booking completed → room upgrade selected (20%)
|
|
34
|
+
*
|
|
35
|
+
* USER PROPS: customer_segment, travel_frequency, company_name, preferred_destination,
|
|
36
|
+
* avg_budget_per_night, Platform, membership_tier
|
|
37
|
+
* SUPER PROPS: Platform, membership_tier
|
|
38
|
+
* SCD PROPS: membership_tier (member/silver/gold/platinum, monthly fuzzy, max 8)
|
|
39
|
+
* GROUPS: none
|
|
44
40
|
*/
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
49
|
-
*
|
|
42
|
+
// ── HOOK STORIES ──
|
|
43
|
+
/*
|
|
50
44
|
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
|
|
51
45
|
* raw-prop breakdowns (booking_window, day, segment) or behavioral cohorts.
|
|
52
46
|
*
|
|
53
|
-
* Adds 9. BOOKING TIME-TO-CONVERT (Business 1.35x faster, Budget 1.25x slower)
|
|
54
|
-
* [funnel-post: visible only in Mixpanel funnel median TTC; cross-event
|
|
55
|
-
* MIN→MIN SQL queries do NOT show this]
|
|
56
|
-
* and 10. HOTEL-VIEWED MAGIC NUMBER (sweet 5-10 → +30% nightly_rate;
|
|
57
|
-
* over 11+ → drop 35% of bookings).
|
|
58
|
-
* ═══════════════════════════════════════════════════════════════
|
|
59
|
-
*
|
|
60
47
|
* ───────────────────────────────────────────────────────────────
|
|
61
|
-
* 1. WEEKEND LEISURE SURGE (
|
|
48
|
+
* 1. WEEKEND LEISURE SURGE (everything)
|
|
62
49
|
* ───────────────────────────────────────────────────────────────
|
|
63
50
|
* PATTERN: Weekend bookings (Fri-Sun) get 1.3x higher nightly_rate
|
|
64
51
|
* due to leisure demand pricing.
|
|
@@ -75,7 +62,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
75
62
|
* weekend rates driven by leisure traveler demand.
|
|
76
63
|
*
|
|
77
64
|
* ───────────────────────────────────────────────────────────────
|
|
78
|
-
* 2. ADVANCE BOOKING DISCOUNT (
|
|
65
|
+
* 2. ADVANCE BOOKING DISCOUNT (everything)
|
|
79
66
|
* ───────────────────────────────────────────────────────────────
|
|
80
67
|
* PATTERN: Bookings made > 21 days before the dataset end get 0.8x
|
|
81
68
|
* nightly_rate (advance purchase discount). Last-minute bookings
|
|
@@ -93,7 +80,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
93
80
|
* premiums for last-minute availability.
|
|
94
81
|
*
|
|
95
82
|
* ───────────────────────────────────────────────────────────────
|
|
96
|
-
* 3. LOYALTY TIER UPGRADE PATH (everything
|
|
83
|
+
* 3. LOYALTY TIER UPGRADE PATH (everything)
|
|
97
84
|
* ───────────────────────────────────────────────────────────────
|
|
98
85
|
* PATTERN: Users with 5+ bookings get boosted loyalty_points on all
|
|
99
86
|
* booking events (3x the baseline).
|
|
@@ -110,7 +97,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
110
97
|
* for frequent guests, creating a flywheel.
|
|
111
98
|
*
|
|
112
99
|
* ───────────────────────────────────────────────────────────────
|
|
113
|
-
* 4. CANCELLATION BY BOOKING WINDOW (everything
|
|
100
|
+
* 4. CANCELLATION BY BOOKING WINDOW (everything)
|
|
114
101
|
* ───────────────────────────────────────────────────────────────
|
|
115
102
|
* PATTERN: Users who book last-minute (< 7 days out) have 60% of
|
|
116
103
|
* their "booking cancelled" events dropped — they rarely cancel.
|
|
@@ -127,7 +114,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
127
114
|
* advance bookers have more flexible cancellation policies.
|
|
128
115
|
*
|
|
129
116
|
* ───────────────────────────────────────────────────────────────
|
|
130
|
-
* 5. UPSELL SUCCESS BY SEGMENT (everything
|
|
117
|
+
* 5. UPSELL SUCCESS BY SEGMENT (everything)
|
|
131
118
|
* ───────────────────────────────────────────────────────────────
|
|
132
119
|
* PATTERN: After a booking, luxury_seeker users get cloned "room
|
|
133
120
|
* upgrade selected" events injected. Budget users rarely see upsells.
|
|
@@ -144,7 +131,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
144
131
|
* upsells (suite upgrades, spa packages).
|
|
145
132
|
*
|
|
146
133
|
* ───────────────────────────────────────────────────────────────
|
|
147
|
-
* 6. REVIEW QUALITY BY STAY RATING (everything
|
|
134
|
+
* 6. REVIEW QUALITY BY STAY RATING (everything)
|
|
148
135
|
* ───────────────────────────────────────────────────────────────
|
|
149
136
|
* PATTERN: Users whose avg stay_rating is >= 4 have longer review_length
|
|
150
137
|
* (1.5x words). Low-rating users write shorter, negative reviews.
|
|
@@ -161,7 +148,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
161
148
|
* reviews; dissatisfied guests write brief complaints.
|
|
162
149
|
*
|
|
163
150
|
* ───────────────────────────────────────────────────────────────
|
|
164
|
-
* 7. BUSINESS TRAVELER PROFILE (user
|
|
151
|
+
* 7. BUSINESS TRAVELER PROFILE (user)
|
|
165
152
|
* ───────────────────────────────────────────────────────────────
|
|
166
153
|
* PATTERN: Users in business_traveler segment get company_name set
|
|
167
154
|
* to a realistic company and travel_frequency to "weekly".
|
|
@@ -177,7 +164,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
177
164
|
* with consistent, high-frequency booking patterns.
|
|
178
165
|
*
|
|
179
166
|
* ───────────────────────────────────────────────────────────────
|
|
180
|
-
* 8. REPEAT DESTINATION CLUSTERING (everything
|
|
167
|
+
* 8. REPEAT DESTINATION CLUSTERING (everything — event filtering)
|
|
181
168
|
* ───────────────────────────────────────────────────────────────
|
|
182
169
|
* PATTERN: Non-business/luxury users have ~25% of "booking completed"
|
|
183
170
|
* events dropped, simulating lower funnel conversion for casual segments.
|
|
@@ -193,7 +180,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
193
180
|
* repeatedly, leading to faster, more confident conversions.
|
|
194
181
|
*
|
|
195
182
|
* ───────────────────────────────────────────────────────────────
|
|
196
|
-
* 9. BOOKING TIME-TO-CONVERT (funnel-post
|
|
183
|
+
* 9. BOOKING TIME-TO-CONVERT (funnel-post)
|
|
197
184
|
* ───────────────────────────────────────────────────────────────
|
|
198
185
|
*
|
|
199
186
|
* PATTERN: Business travelers complete the Search-to-Book funnel
|
|
@@ -224,7 +211,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
224
211
|
* comparing options and waiting for deals.
|
|
225
212
|
*
|
|
226
213
|
* ───────────────────────────────────────────────────────────────
|
|
227
|
-
* 10. HOTEL-VIEWED MAGIC NUMBER (everything
|
|
214
|
+
* 10. HOTEL-VIEWED MAGIC NUMBER (everything)
|
|
228
215
|
* ───────────────────────────────────────────────────────────────
|
|
229
216
|
*
|
|
230
217
|
* PATTERN: Users who viewed 5-10 hotels sit in a "decisive comparison
|
|
@@ -279,31 +266,261 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
279
266
|
* Hotel-Viewed Magic Num | over bookings/user | 1x | 0.65x | -35%
|
|
280
267
|
*/
|
|
281
268
|
|
|
269
|
+
// ── SCALE ──
|
|
270
|
+
const SEED = "dm4-travel";
|
|
271
|
+
const NUM_USERS = 10_000;
|
|
272
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
273
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
274
|
+
const EVENTS_PER_DAY = 1.2;
|
|
275
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
276
|
+
|
|
277
|
+
const chance = u.initChance(SEED);
|
|
278
|
+
|
|
279
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
280
|
+
const WEEKEND_RATE_BOOST = 1.3;
|
|
281
|
+
const ADVANCE_DAYS_THRESHOLD = 21;
|
|
282
|
+
const ADVANCE_RATE_FACTOR = 0.8;
|
|
283
|
+
const LAST_MINUTE_DAYS_THRESHOLD = 3;
|
|
284
|
+
const LAST_MINUTE_RATE_FACTOR = 1.4;
|
|
285
|
+
const LOYALTY_BOOKING_THRESHOLD = 5;
|
|
286
|
+
const LOYALTY_POINT_BASE_MULT = 2.5;
|
|
287
|
+
const LOYALTY_POINT_VARIANCE = 1.0;
|
|
288
|
+
const REPEAT_DEST_DROP_LIKELIHOOD = 25;
|
|
289
|
+
const CANCEL_LAST_MINUTE_DROP_LIKELIHOOD = 60;
|
|
290
|
+
const UPSELL_LUXURY_LIKELIHOOD = 50;
|
|
291
|
+
const REVIEW_HIGH_RATING_THRESHOLD = 4;
|
|
292
|
+
const REVIEW_LOW_RATING_THRESHOLD = 2;
|
|
293
|
+
const REVIEW_HIGH_LENGTH_MULT = 1.5;
|
|
294
|
+
const REVIEW_LOW_LENGTH_MULT = 0.5;
|
|
295
|
+
const HOTEL_SWEET_MIN = 5;
|
|
296
|
+
const HOTEL_SWEET_MAX = 10;
|
|
297
|
+
const HOTEL_OVER_THRESHOLD = 11;
|
|
298
|
+
const HOTEL_SWEET_RATE_BOOST = 1.3;
|
|
299
|
+
const HOTEL_OVER_BOOKING_DROP_LIKELIHOOD = 35;
|
|
300
|
+
const TTC_BUSINESS_FACTOR = 0.74;
|
|
301
|
+
const TTC_LEISURE_BUDGET_FACTOR = 1.25;
|
|
302
|
+
|
|
303
|
+
// ── DATA ARRAYS ──
|
|
304
|
+
const hotelIds = v.range(1, 200).map(() => `HTL_${v.uid(6)}`);
|
|
305
|
+
const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona", "Dubai", "Sydney", "Rome", "Bangkok", "Cancun", "Bali", "Amsterdam", "Miami", "Singapore", "Lisbon"];
|
|
306
|
+
|
|
307
|
+
// ── HELPER FUNCTIONS ──
|
|
308
|
+
function handleUserHooks(record) {
|
|
309
|
+
// H7: BUSINESS TRAVELER PROFILE
|
|
310
|
+
if (record.customer_segment === "business_traveler") {
|
|
311
|
+
record.company_name = chance.pickone(["Acme Corp", "GlobalTech", "Initech", "Prestige Consulting", "Summit Partners", "Atlas Industries"]);
|
|
312
|
+
record.travel_frequency = "weekly";
|
|
313
|
+
} else if (record.customer_segment === "luxury_seeker") {
|
|
314
|
+
record.avg_budget_per_night = chance.integer({ min: 250, max: 500 });
|
|
315
|
+
} else if (record.customer_segment === "budget_hunter") {
|
|
316
|
+
record.avg_budget_per_night = chance.integer({ min: 50, max: 120 });
|
|
317
|
+
}
|
|
318
|
+
return record;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function handleFunnelPostHooks(record, meta) {
|
|
322
|
+
// H9: BOOKING TIME-TO-CONVERT — business 1.35x faster (0.74),
|
|
323
|
+
// leisure_family/budget 1.25x slower.
|
|
324
|
+
const segment = meta?.profile?.customer_segment;
|
|
325
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
326
|
+
const factor = (
|
|
327
|
+
segment === "business_traveler" ? TTC_BUSINESS_FACTOR :
|
|
328
|
+
segment === "budget_hunter" || segment === "leisure_family" ? TTC_LEISURE_BUDGET_FACTOR :
|
|
329
|
+
1.0
|
|
330
|
+
);
|
|
331
|
+
if (factor !== 1.0) {
|
|
332
|
+
for (let i = 1; i < record.length; i++) {
|
|
333
|
+
const prev = dayjs(record[i - 1].time);
|
|
334
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
335
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return record;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function handleEverythingHooks(record, meta) {
|
|
343
|
+
const events = record;
|
|
344
|
+
if (!events.length) return record;
|
|
345
|
+
|
|
346
|
+
const profile = meta.profile;
|
|
347
|
+
|
|
348
|
+
// Stamp superProps from profile (consistent per user)
|
|
349
|
+
const stampPlatform = profile && profile.Platform ? profile.Platform : undefined;
|
|
350
|
+
const stampTier = profile && profile.membership_tier ? profile.membership_tier : undefined;
|
|
351
|
+
if (stampPlatform || stampTier) {
|
|
352
|
+
events.forEach(e => {
|
|
353
|
+
if (stampPlatform) e.Platform = stampPlatform;
|
|
354
|
+
if (stampTier) e.membership_tier = stampTier;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// H1: WEEKEND LEISURE SURGE — Fri/Sat/Sun bookings get +30% rate
|
|
359
|
+
events.forEach(e => {
|
|
360
|
+
if (e.event === "booking completed") {
|
|
361
|
+
const dayOfWeek = new Date(e.time).getUTCDay();
|
|
362
|
+
// Friday=5, Saturday=6, Sunday=0
|
|
363
|
+
if (dayOfWeek === 0 || dayOfWeek === 5 || dayOfWeek === 6) {
|
|
364
|
+
e.nightly_rate = Math.floor((e.nightly_rate || 150) * WEEKEND_RATE_BOOST);
|
|
365
|
+
e.total_cost = Math.floor((e.total_cost || 450) * WEEKEND_RATE_BOOST);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// H2: ADVANCE BOOKING DISCOUNT
|
|
371
|
+
const datasetEndForBooking = dayjs.unix(meta.datasetEnd);
|
|
372
|
+
events.forEach(e => {
|
|
373
|
+
if (e.event === "booking completed") {
|
|
374
|
+
const eventTime = dayjs(e.time);
|
|
375
|
+
const daysUntilEnd = datasetEndForBooking.diff(eventTime, "days");
|
|
376
|
+
if (daysUntilEnd > ADVANCE_DAYS_THRESHOLD) {
|
|
377
|
+
e.booking_window = "advance";
|
|
378
|
+
e.nightly_rate = Math.floor((e.nightly_rate || 150) * ADVANCE_RATE_FACTOR);
|
|
379
|
+
} else if (daysUntilEnd < LAST_MINUTE_DAYS_THRESHOLD) {
|
|
380
|
+
e.booking_window = "last_minute";
|
|
381
|
+
e.nightly_rate = Math.floor((e.nightly_rate || 150) * LAST_MINUTE_RATE_FACTOR);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// H8: REPEAT DESTINATION CLUSTERING — drop ~25% of "booking completed"
|
|
387
|
+
// for non-business/luxury users.
|
|
388
|
+
const segment = profile && profile.customer_segment;
|
|
389
|
+
if (segment !== "business_traveler" && segment !== "luxury_seeker"
|
|
390
|
+
&& chance.bool({ likelihood: REPEAT_DEST_DROP_LIKELIHOOD })) {
|
|
391
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
392
|
+
if (events[i].event === "booking completed") {
|
|
393
|
+
events.splice(i, 1);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// H3: LOYALTY TIER UPGRADE PATH — 5+ bookings → ~3x loyalty_points
|
|
399
|
+
let bookingCount = 0;
|
|
400
|
+
events.forEach(e => { if (e.event === "booking completed") bookingCount++; });
|
|
401
|
+
if (bookingCount >= LOYALTY_BOOKING_THRESHOLD) {
|
|
402
|
+
events.forEach(e => {
|
|
403
|
+
if (e.event === "booking completed" && e.loyalty_points) {
|
|
404
|
+
e.loyalty_points = Math.floor(e.loyalty_points * (LOYALTY_POINT_BASE_MULT + chance.floating({ min: 0, max: LOYALTY_POINT_VARIANCE })));
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// H4: CANCELLATION BY BOOKING WINDOW — copy nearest preceding booking's
|
|
410
|
+
// booking_window onto each cancellation, then drop 60% of last-minute cancels.
|
|
411
|
+
const bookingsByTime = events
|
|
412
|
+
.filter(e => e.event === "booking completed")
|
|
413
|
+
.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
414
|
+
events.forEach(e => {
|
|
415
|
+
if (e.event === "booking cancelled" && bookingsByTime.length > 0) {
|
|
416
|
+
const cancelTime = new Date(e.time).getTime();
|
|
417
|
+
let matched = bookingsByTime[0];
|
|
418
|
+
for (let b = bookingsByTime.length - 1; b >= 0; b--) {
|
|
419
|
+
if (new Date(bookingsByTime[b].time).getTime() <= cancelTime) {
|
|
420
|
+
matched = bookingsByTime[b];
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
e.booking_window = matched.booking_window;
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
428
|
+
if (events[i].event === "booking cancelled" && events[i].booking_window === "last_minute") {
|
|
429
|
+
if (chance.bool({ likelihood: CANCEL_LAST_MINUTE_DROP_LIKELIHOOD })) {
|
|
430
|
+
events.splice(i, 1);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// H5: UPSELL SUCCESS BY SEGMENT — luxury_seeker users get cloned upgrades
|
|
436
|
+
if (profile && profile.customer_segment === "luxury_seeker") {
|
|
437
|
+
const templateUpgrade = events.find(e => e.event === "room upgrade selected");
|
|
438
|
+
if (templateUpgrade) {
|
|
439
|
+
const bookings = events.filter(e => e.event === "booking completed");
|
|
440
|
+
bookings.forEach(booking => {
|
|
441
|
+
if (chance.bool({ likelihood: UPSELL_LUXURY_LIKELIHOOD })) {
|
|
442
|
+
events.push({
|
|
443
|
+
...templateUpgrade,
|
|
444
|
+
time: dayjs(booking.time).add(chance.integer({ min: 1, max: 30 }), "minutes").toISOString(),
|
|
445
|
+
user_id: booking.user_id,
|
|
446
|
+
upgrade_cost: chance.integer({ min: 75, max: 200 }),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// H6: REVIEW QUALITY BY STAY RATING — high avg → 1.5x review_length;
|
|
454
|
+
// low avg → 0.5x.
|
|
455
|
+
let totalRating = 0;
|
|
456
|
+
let ratingCount = 0;
|
|
457
|
+
events.forEach(e => {
|
|
458
|
+
if (e.event === "review submitted" && e.stay_rating) {
|
|
459
|
+
totalRating += e.stay_rating;
|
|
460
|
+
ratingCount++;
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
const avgRating = ratingCount > 0 ? totalRating / ratingCount : 3;
|
|
464
|
+
events.forEach(e => {
|
|
465
|
+
if (e.event === "review submitted") {
|
|
466
|
+
if (avgRating >= REVIEW_HIGH_RATING_THRESHOLD) {
|
|
467
|
+
e.review_length = Math.floor((e.review_length || 120) * REVIEW_HIGH_LENGTH_MULT);
|
|
468
|
+
} else if (avgRating <= REVIEW_LOW_RATING_THRESHOLD) {
|
|
469
|
+
e.review_length = Math.floor((e.review_length || 120) * REVIEW_LOW_LENGTH_MULT);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// H10: HOTEL-VIEWED MAGIC NUMBER — sweet 5-10 → +30% nightly_rate;
|
|
475
|
+
// over 11+ → drop 35% of bookings.
|
|
476
|
+
const hotelViews = events.filter(e => e.event === "hotel viewed").length;
|
|
477
|
+
if (hotelViews >= HOTEL_SWEET_MIN && hotelViews <= HOTEL_SWEET_MAX) {
|
|
478
|
+
events.forEach(e => {
|
|
479
|
+
if (e.event === "booking completed" && typeof e.nightly_rate === "number") {
|
|
480
|
+
e.nightly_rate = Math.round(e.nightly_rate * HOTEL_SWEET_RATE_BOOST);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
} else if (hotelViews >= HOTEL_OVER_THRESHOLD) {
|
|
484
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
485
|
+
if (events[i].event === "booking completed" && chance.bool({ likelihood: HOTEL_OVER_BOOKING_DROP_LIKELIHOOD })) {
|
|
486
|
+
events.splice(i, 1);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return record;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── CONFIG ──
|
|
282
495
|
/** @type {Config} */
|
|
283
496
|
const config = {
|
|
284
497
|
version: 2,
|
|
285
|
-
token,
|
|
286
498
|
seed: SEED,
|
|
287
|
-
datasetStart:
|
|
288
|
-
datasetEnd:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
numUsers: num_users,
|
|
292
|
-
hasAnonIds: true,
|
|
293
|
-
avgDevicePerUser: 2,
|
|
294
|
-
hasSessionIds: true,
|
|
499
|
+
datasetStart: DATASET_START,
|
|
500
|
+
datasetEnd: DATASET_END,
|
|
501
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
502
|
+
numUsers: NUM_USERS,
|
|
295
503
|
format: "json",
|
|
296
504
|
gzip: true,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
505
|
+
credentials: {
|
|
506
|
+
token,
|
|
507
|
+
},
|
|
508
|
+
switches: {
|
|
509
|
+
hasSessionIds: true,
|
|
510
|
+
alsoInferFunnels: false,
|
|
511
|
+
hasLocation: true,
|
|
512
|
+
hasAndroidDevices: true,
|
|
513
|
+
hasIOSDevices: true,
|
|
514
|
+
hasDesktopDevices: true,
|
|
515
|
+
hasBrowser: false,
|
|
516
|
+
hasCampaigns: false,
|
|
517
|
+
isAnonymous: false,
|
|
518
|
+
hasAdSpend: false,
|
|
519
|
+
hasAvatar: true,
|
|
520
|
+
},
|
|
521
|
+
identity: {
|
|
522
|
+
avgDevicePerUser: 2,
|
|
523
|
+
},
|
|
307
524
|
concurrency: 1,
|
|
308
525
|
writeToDisk: false,
|
|
309
526
|
scdProps: {
|
|
@@ -317,7 +534,6 @@ const config = {
|
|
|
317
534
|
mirrorProps: {},
|
|
318
535
|
lookupTables: [],
|
|
319
536
|
|
|
320
|
-
// ── Events (17) ──────────────────────────────────────────
|
|
321
537
|
events: [
|
|
322
538
|
{
|
|
323
539
|
event: "account created",
|
|
@@ -342,6 +558,7 @@ const config = {
|
|
|
342
558
|
{
|
|
343
559
|
event: "hotel viewed",
|
|
344
560
|
weight: 7,
|
|
561
|
+
isStrictEvent: false,
|
|
345
562
|
properties: {
|
|
346
563
|
hotel_id: chance.pickone.bind(chance, hotelIds),
|
|
347
564
|
destination: chance.pickone.bind(chance, destinationCities),
|
|
@@ -364,6 +581,7 @@ const config = {
|
|
|
364
581
|
{
|
|
365
582
|
event: "booking completed",
|
|
366
583
|
weight: 3,
|
|
584
|
+
isStrictEvent: false,
|
|
367
585
|
properties: {
|
|
368
586
|
hotel_id: chance.pickone.bind(chance, hotelIds),
|
|
369
587
|
destination: chance.pickone.bind(chance, destinationCities),
|
|
@@ -379,6 +597,7 @@ const config = {
|
|
|
379
597
|
{
|
|
380
598
|
event: "room upgrade selected",
|
|
381
599
|
weight: 2,
|
|
600
|
+
isStrictEvent: false,
|
|
382
601
|
properties: {
|
|
383
602
|
upgrade_type: ["suite", "ocean_view", "executive_floor", "club_lounge", "premium_king"],
|
|
384
603
|
upgrade_cost: u.weighNumRange(25, 200, 0.4, 75),
|
|
@@ -415,6 +634,7 @@ const config = {
|
|
|
415
634
|
{
|
|
416
635
|
event: "review submitted",
|
|
417
636
|
weight: 2,
|
|
637
|
+
isStrictEvent: false,
|
|
418
638
|
properties: {
|
|
419
639
|
hotel_id: chance.pickone.bind(chance, hotelIds),
|
|
420
640
|
stay_rating: u.weighNumRange(1, 5, 0.7, 4),
|
|
@@ -486,7 +706,6 @@ const config = {
|
|
|
486
706
|
},
|
|
487
707
|
],
|
|
488
708
|
|
|
489
|
-
// ── Funnels (5) ──────────────────────────────────────────
|
|
490
709
|
funnels: [
|
|
491
710
|
{
|
|
492
711
|
name: "Onboarding to First Booking",
|
|
@@ -504,6 +723,7 @@ const config = {
|
|
|
504
723
|
order: "sequential",
|
|
505
724
|
timeToConvert: 48,
|
|
506
725
|
weight: 5,
|
|
726
|
+
reentry: true,
|
|
507
727
|
},
|
|
508
728
|
{
|
|
509
729
|
name: "Full Stay Journey",
|
|
@@ -531,13 +751,11 @@ const config = {
|
|
|
531
751
|
},
|
|
532
752
|
],
|
|
533
753
|
|
|
534
|
-
// ── SuperProps ──────────────────────────────────────────
|
|
535
754
|
superProps: {
|
|
536
755
|
Platform: ["ios", "android", "web", "web"],
|
|
537
756
|
membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
|
|
538
757
|
},
|
|
539
758
|
|
|
540
|
-
// ── UserProps ──────────────────────────────────────────
|
|
541
759
|
userProps: {
|
|
542
760
|
customer_segment: ["leisure_family"],
|
|
543
761
|
travel_frequency: ["occasional", "occasional", "monthly", "weekly"],
|
|
@@ -548,7 +766,6 @@ const config = {
|
|
|
548
766
|
membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
|
|
549
767
|
},
|
|
550
768
|
|
|
551
|
-
// ── Personas ──────────────────────────────────
|
|
552
769
|
personas: [
|
|
553
770
|
{
|
|
554
771
|
name: "business_traveler",
|
|
@@ -584,7 +801,6 @@ const config = {
|
|
|
584
801
|
},
|
|
585
802
|
],
|
|
586
803
|
|
|
587
|
-
// ── World Events ──────────────────────────────
|
|
588
804
|
worldEvents: [
|
|
589
805
|
{
|
|
590
806
|
name: "summer_sale",
|
|
@@ -615,7 +831,6 @@ const config = {
|
|
|
615
831
|
},
|
|
616
832
|
],
|
|
617
833
|
|
|
618
|
-
// ── Engagement Decay ──────────────────────────
|
|
619
834
|
engagementDecay: {
|
|
620
835
|
model: "exponential",
|
|
621
836
|
halfLife: 90,
|
|
@@ -623,7 +838,6 @@ const config = {
|
|
|
623
838
|
reactivationChance: 0.02,
|
|
624
839
|
},
|
|
625
840
|
|
|
626
|
-
// ── Attribution ──────────────────────────────
|
|
627
841
|
attribution: {
|
|
628
842
|
model: "last_touch",
|
|
629
843
|
window: 7,
|
|
@@ -659,7 +873,6 @@ const config = {
|
|
|
659
873
|
organicRate: 0.35,
|
|
660
874
|
},
|
|
661
875
|
|
|
662
|
-
// ── Geo ──────────────────────────────────────
|
|
663
876
|
geo: {
|
|
664
877
|
sticky: true,
|
|
665
878
|
regions: [
|
|
@@ -687,7 +900,6 @@ const config = {
|
|
|
687
900
|
],
|
|
688
901
|
},
|
|
689
902
|
|
|
690
|
-
// ── Anomalies ──────────────────────────────────
|
|
691
903
|
anomalies: [
|
|
692
904
|
{
|
|
693
905
|
type: "extreme_value",
|
|
@@ -707,215 +919,10 @@ const config = {
|
|
|
707
919
|
},
|
|
708
920
|
],
|
|
709
921
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
if (type === "
|
|
714
|
-
if (record.customer_segment === "business_traveler") {
|
|
715
|
-
record.company_name = chance.pickone(["Acme Corp", "GlobalTech", "Initech", "Prestige Consulting", "Summit Partners", "Atlas Industries"]);
|
|
716
|
-
record.travel_frequency = "weekly";
|
|
717
|
-
} else if (record.customer_segment === "luxury_seeker") {
|
|
718
|
-
record.avg_budget_per_night = chance.integer({ min: 250, max: 500 });
|
|
719
|
-
} else if (record.customer_segment === "budget_hunter") {
|
|
720
|
-
record.avg_budget_per_night = chance.integer({ min: 50, max: 120 });
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// HOOK 9 (T2C): BOOKING TIME-TO-CONVERT (funnel-post)
|
|
725
|
-
// Business travelers complete the Search-to-Book funnel 1.35x faster
|
|
726
|
-
// (factor 0.74); leisure_family/budget customers 1.25x slower (1.25).
|
|
727
|
-
if (type === "funnel-post") {
|
|
728
|
-
const segment = meta?.profile?.customer_segment;
|
|
729
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
730
|
-
const factor = (
|
|
731
|
-
segment === "business_traveler" ? 0.74 :
|
|
732
|
-
segment === "budget_hunter" || segment === "leisure_family" ? 1.25 :
|
|
733
|
-
1.0
|
|
734
|
-
);
|
|
735
|
-
if (factor !== 1.0) {
|
|
736
|
-
for (let i = 1; i < record.length; i++) {
|
|
737
|
-
const prev = dayjs(record[i - 1].time);
|
|
738
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
739
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// ── HOOK 1: WEEKEND LEISURE SURGE ────────────
|
|
746
|
-
// Moved to everything hook (sessionization reassigns times after event hook)
|
|
747
|
-
|
|
748
|
-
// ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
|
|
749
|
-
// Moved to everything hook (sessionization reassigns times after event hook)
|
|
750
|
-
if (type === "event") {
|
|
751
|
-
// no-op: DOW-dependent hooks moved to everything hook
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// ── EVERYTHING HOOKS ─────────────────────────────────
|
|
755
|
-
if (type === "everything") {
|
|
756
|
-
const events = record;
|
|
757
|
-
if (!events.length) return record;
|
|
758
|
-
|
|
759
|
-
const profile = meta.profile;
|
|
760
|
-
|
|
761
|
-
// ─── Stamp superProps from profile (consistent per user) ───
|
|
762
|
-
const stampPlatform = profile && profile.Platform ? profile.Platform : undefined;
|
|
763
|
-
const stampTier = profile && profile.membership_tier ? profile.membership_tier : undefined;
|
|
764
|
-
if (stampPlatform || stampTier) {
|
|
765
|
-
events.forEach(e => {
|
|
766
|
-
if (stampPlatform) e.Platform = stampPlatform;
|
|
767
|
-
if (stampTier) e.membership_tier = stampTier;
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// ── HOOK 1: WEEKEND LEISURE SURGE ────────────
|
|
772
|
-
// Apply AFTER sessionization has finalized times
|
|
773
|
-
events.forEach(e => {
|
|
774
|
-
if (e.event === "booking completed") {
|
|
775
|
-
const dayOfWeek = new Date(e.time).getUTCDay();
|
|
776
|
-
// Friday=5, Saturday=6, Sunday=0
|
|
777
|
-
if (dayOfWeek === 0 || dayOfWeek === 5 || dayOfWeek === 6) {
|
|
778
|
-
e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.3);
|
|
779
|
-
e.total_cost = Math.floor((e.total_cost || 450) * 1.3);
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
});
|
|
783
|
-
|
|
784
|
-
// ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
|
|
785
|
-
const datasetEndForBooking = dayjs.unix(meta.datasetEnd);
|
|
786
|
-
events.forEach(e => {
|
|
787
|
-
if (e.event === "booking completed") {
|
|
788
|
-
const eventTime = dayjs(e.time);
|
|
789
|
-
const daysUntilEnd = datasetEndForBooking.diff(eventTime, "days");
|
|
790
|
-
if (daysUntilEnd > 21) {
|
|
791
|
-
e.booking_window = "advance";
|
|
792
|
-
e.nightly_rate = Math.floor((e.nightly_rate || 150) * 0.8);
|
|
793
|
-
} else if (daysUntilEnd < 3) {
|
|
794
|
-
e.booking_window = "last_minute";
|
|
795
|
-
e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.4);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
// ─── Bug 2 fix: Repeat destination clustering conversion filtering ───
|
|
801
|
-
// Drop ~25% of "booking completed" events for users who are NOT
|
|
802
|
-
// business_traveler or luxury_seeker to simulate their lower funnel
|
|
803
|
-
// conversion (was conversionRate * 1.3 / 1.15 in funnel-pre)
|
|
804
|
-
const segment = profile && profile.customer_segment;
|
|
805
|
-
if (segment !== "business_traveler" && segment !== "luxury_seeker"
|
|
806
|
-
&& chance.bool({ likelihood: 25 })) {
|
|
807
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
808
|
-
if (events[i].event === "booking completed") {
|
|
809
|
-
events.splice(i, 1);
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// ── HOOK 3: LOYALTY TIER UPGRADE PATH ────────────
|
|
815
|
-
let bookingCount = 0;
|
|
816
|
-
events.forEach(e => { if (e.event === "booking completed") bookingCount++; });
|
|
817
|
-
if (bookingCount >= 5) {
|
|
818
|
-
events.forEach(e => {
|
|
819
|
-
if (e.event === "booking completed" && e.loyalty_points) {
|
|
820
|
-
e.loyalty_points = Math.floor(e.loyalty_points * (2.5 + chance.floating({ min: 0, max: 1.0 })));
|
|
821
|
-
}
|
|
822
|
-
});
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
// ── HOOK 4: CANCELLATION BY BOOKING WINDOW ───────
|
|
826
|
-
// Match each cancellation to its nearest preceding booking
|
|
827
|
-
// and copy the booking's booking_window. This replaces the
|
|
828
|
-
// random booking_window that the event config assigns to
|
|
829
|
-
// cancellation events independently.
|
|
830
|
-
const bookingsByTime = events
|
|
831
|
-
.filter(e => e.event === "booking completed")
|
|
832
|
-
.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
833
|
-
events.forEach(e => {
|
|
834
|
-
if (e.event === "booking cancelled" && bookingsByTime.length > 0) {
|
|
835
|
-
const cancelTime = new Date(e.time).getTime();
|
|
836
|
-
// Find the nearest preceding booking (or the first one)
|
|
837
|
-
let matched = bookingsByTime[0];
|
|
838
|
-
for (let b = bookingsByTime.length - 1; b >= 0; b--) {
|
|
839
|
-
if (new Date(bookingsByTime[b].time).getTime() <= cancelTime) {
|
|
840
|
-
matched = bookingsByTime[b];
|
|
841
|
-
break;
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
e.booking_window = matched.booking_window;
|
|
845
|
-
}
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
// Last-minute bookers rarely cancel — drop 60%.
|
|
849
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
850
|
-
if (events[i].event === "booking cancelled" && events[i].booking_window === "last_minute") {
|
|
851
|
-
if (chance.bool({ likelihood: 60 })) {
|
|
852
|
-
events.splice(i, 1);
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
// ── HOOK 5: UPSELL SUCCESS BY SEGMENT ────────────
|
|
858
|
-
if (profile && profile.customer_segment === "luxury_seeker") {
|
|
859
|
-
const templateUpgrade = events.find(e => e.event === "room upgrade selected");
|
|
860
|
-
if (templateUpgrade) {
|
|
861
|
-
const bookings = events.filter(e => e.event === "booking completed");
|
|
862
|
-
bookings.forEach(booking => {
|
|
863
|
-
if (chance.bool({ likelihood: 50 })) {
|
|
864
|
-
events.push({
|
|
865
|
-
...templateUpgrade,
|
|
866
|
-
time: dayjs(booking.time).add(chance.integer({ min: 1, max: 30 }), "minutes").toISOString(),
|
|
867
|
-
user_id: booking.user_id,
|
|
868
|
-
upgrade_cost: chance.integer({ min: 75, max: 200 }),
|
|
869
|
-
});
|
|
870
|
-
}
|
|
871
|
-
});
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// HOOK 6: REVIEW QUALITY BY STAY RATING — high avg ratings get
|
|
876
|
-
// review_length 1.5x; low avg ratings get 0.5x. Mutates raw prop.
|
|
877
|
-
let totalRating = 0;
|
|
878
|
-
let ratingCount = 0;
|
|
879
|
-
events.forEach(e => {
|
|
880
|
-
if (e.event === "review submitted" && e.stay_rating) {
|
|
881
|
-
totalRating += e.stay_rating;
|
|
882
|
-
ratingCount++;
|
|
883
|
-
}
|
|
884
|
-
});
|
|
885
|
-
const avgRating = ratingCount > 0 ? totalRating / ratingCount : 3;
|
|
886
|
-
events.forEach(e => {
|
|
887
|
-
if (e.event === "review submitted") {
|
|
888
|
-
if (avgRating >= 4) {
|
|
889
|
-
e.review_length = Math.floor((e.review_length || 120) * 1.5);
|
|
890
|
-
} else if (avgRating <= 2) {
|
|
891
|
-
e.review_length = Math.floor((e.review_length || 120) * 0.5);
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
});
|
|
895
|
-
|
|
896
|
-
// HOOK 10: HOTEL-VIEWED MAGIC NUMBER (in-funnel, no flags)
|
|
897
|
-
// Sweet 5-10 hotel-viewed events → +30% on booking nightly_rate
|
|
898
|
-
// (decisive comparison shoppers book higher-tier rooms).
|
|
899
|
-
// Over 11+ → drop 35% of booking-completed events (analysis
|
|
900
|
-
// paralysis blocks conversion).
|
|
901
|
-
const hotelViews = events.filter(e => e.event === "hotel viewed").length;
|
|
902
|
-
if (hotelViews >= 5 && hotelViews <= 10) {
|
|
903
|
-
events.forEach(e => {
|
|
904
|
-
if (e.event === "booking completed" && typeof e.nightly_rate === "number") {
|
|
905
|
-
e.nightly_rate = Math.round(e.nightly_rate * 1.3);
|
|
906
|
-
}
|
|
907
|
-
});
|
|
908
|
-
} else if (hotelViews >= 11) {
|
|
909
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
910
|
-
if (events[i].event === "booking completed" && chance.bool({ likelihood: 35 })) {
|
|
911
|
-
events.splice(i, 1);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
return record;
|
|
917
|
-
}
|
|
918
|
-
|
|
922
|
+
hook(record, type, meta) {
|
|
923
|
+
if (type === "user") return handleUserHooks(record);
|
|
924
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
925
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
919
926
|
return record;
|
|
920
927
|
},
|
|
921
928
|
};
|