@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,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",
|
|
@@ -490,7 +706,6 @@ const config = {
|
|
|
490
706
|
},
|
|
491
707
|
],
|
|
492
708
|
|
|
493
|
-
// ── Funnels (5) ──────────────────────────────────────────
|
|
494
709
|
funnels: [
|
|
495
710
|
{
|
|
496
711
|
name: "Onboarding to First Booking",
|
|
@@ -536,13 +751,11 @@ const config = {
|
|
|
536
751
|
},
|
|
537
752
|
],
|
|
538
753
|
|
|
539
|
-
// ── SuperProps ──────────────────────────────────────────
|
|
540
754
|
superProps: {
|
|
541
755
|
Platform: ["ios", "android", "web", "web"],
|
|
542
756
|
membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
|
|
543
757
|
},
|
|
544
758
|
|
|
545
|
-
// ── UserProps ──────────────────────────────────────────
|
|
546
759
|
userProps: {
|
|
547
760
|
customer_segment: ["leisure_family"],
|
|
548
761
|
travel_frequency: ["occasional", "occasional", "monthly", "weekly"],
|
|
@@ -553,7 +766,6 @@ const config = {
|
|
|
553
766
|
membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
|
|
554
767
|
},
|
|
555
768
|
|
|
556
|
-
// ── Personas ──────────────────────────────────
|
|
557
769
|
personas: [
|
|
558
770
|
{
|
|
559
771
|
name: "business_traveler",
|
|
@@ -589,7 +801,6 @@ const config = {
|
|
|
589
801
|
},
|
|
590
802
|
],
|
|
591
803
|
|
|
592
|
-
// ── World Events ──────────────────────────────
|
|
593
804
|
worldEvents: [
|
|
594
805
|
{
|
|
595
806
|
name: "summer_sale",
|
|
@@ -620,7 +831,6 @@ const config = {
|
|
|
620
831
|
},
|
|
621
832
|
],
|
|
622
833
|
|
|
623
|
-
// ── Engagement Decay ──────────────────────────
|
|
624
834
|
engagementDecay: {
|
|
625
835
|
model: "exponential",
|
|
626
836
|
halfLife: 90,
|
|
@@ -628,7 +838,6 @@ const config = {
|
|
|
628
838
|
reactivationChance: 0.02,
|
|
629
839
|
},
|
|
630
840
|
|
|
631
|
-
// ── Attribution ──────────────────────────────
|
|
632
841
|
attribution: {
|
|
633
842
|
model: "last_touch",
|
|
634
843
|
window: 7,
|
|
@@ -664,7 +873,6 @@ const config = {
|
|
|
664
873
|
organicRate: 0.35,
|
|
665
874
|
},
|
|
666
875
|
|
|
667
|
-
// ── Geo ──────────────────────────────────────
|
|
668
876
|
geo: {
|
|
669
877
|
sticky: true,
|
|
670
878
|
regions: [
|
|
@@ -692,7 +900,6 @@ const config = {
|
|
|
692
900
|
],
|
|
693
901
|
},
|
|
694
902
|
|
|
695
|
-
// ── Anomalies ──────────────────────────────────
|
|
696
903
|
anomalies: [
|
|
697
904
|
{
|
|
698
905
|
type: "extreme_value",
|
|
@@ -712,215 +919,10 @@ const config = {
|
|
|
712
919
|
},
|
|
713
920
|
],
|
|
714
921
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
if (type === "
|
|
719
|
-
if (record.customer_segment === "business_traveler") {
|
|
720
|
-
record.company_name = chance.pickone(["Acme Corp", "GlobalTech", "Initech", "Prestige Consulting", "Summit Partners", "Atlas Industries"]);
|
|
721
|
-
record.travel_frequency = "weekly";
|
|
722
|
-
} else if (record.customer_segment === "luxury_seeker") {
|
|
723
|
-
record.avg_budget_per_night = chance.integer({ min: 250, max: 500 });
|
|
724
|
-
} else if (record.customer_segment === "budget_hunter") {
|
|
725
|
-
record.avg_budget_per_night = chance.integer({ min: 50, max: 120 });
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
// HOOK 9 (T2C): BOOKING TIME-TO-CONVERT (funnel-post)
|
|
730
|
-
// Business travelers complete the Search-to-Book funnel 1.35x faster
|
|
731
|
-
// (factor 0.74); leisure_family/budget customers 1.25x slower (1.25).
|
|
732
|
-
if (type === "funnel-post") {
|
|
733
|
-
const segment = meta?.profile?.customer_segment;
|
|
734
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
735
|
-
const factor = (
|
|
736
|
-
segment === "business_traveler" ? 0.74 :
|
|
737
|
-
segment === "budget_hunter" || segment === "leisure_family" ? 1.25 :
|
|
738
|
-
1.0
|
|
739
|
-
);
|
|
740
|
-
if (factor !== 1.0) {
|
|
741
|
-
for (let i = 1; i < record.length; i++) {
|
|
742
|
-
const prev = dayjs(record[i - 1].time);
|
|
743
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
744
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// ── HOOK 1: WEEKEND LEISURE SURGE ────────────
|
|
751
|
-
// Moved to everything hook (sessionization reassigns times after event hook)
|
|
752
|
-
|
|
753
|
-
// ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
|
|
754
|
-
// Moved to everything hook (sessionization reassigns times after event hook)
|
|
755
|
-
if (type === "event") {
|
|
756
|
-
// no-op: DOW-dependent hooks moved to everything hook
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// ── EVERYTHING HOOKS ─────────────────────────────────
|
|
760
|
-
if (type === "everything") {
|
|
761
|
-
const events = record;
|
|
762
|
-
if (!events.length) return record;
|
|
763
|
-
|
|
764
|
-
const profile = meta.profile;
|
|
765
|
-
|
|
766
|
-
// ─── Stamp superProps from profile (consistent per user) ───
|
|
767
|
-
const stampPlatform = profile && profile.Platform ? profile.Platform : undefined;
|
|
768
|
-
const stampTier = profile && profile.membership_tier ? profile.membership_tier : undefined;
|
|
769
|
-
if (stampPlatform || stampTier) {
|
|
770
|
-
events.forEach(e => {
|
|
771
|
-
if (stampPlatform) e.Platform = stampPlatform;
|
|
772
|
-
if (stampTier) e.membership_tier = stampTier;
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// ── HOOK 1: WEEKEND LEISURE SURGE ────────────
|
|
777
|
-
// Apply AFTER sessionization has finalized times
|
|
778
|
-
events.forEach(e => {
|
|
779
|
-
if (e.event === "booking completed") {
|
|
780
|
-
const dayOfWeek = new Date(e.time).getUTCDay();
|
|
781
|
-
// Friday=5, Saturday=6, Sunday=0
|
|
782
|
-
if (dayOfWeek === 0 || dayOfWeek === 5 || dayOfWeek === 6) {
|
|
783
|
-
e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.3);
|
|
784
|
-
e.total_cost = Math.floor((e.total_cost || 450) * 1.3);
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
// ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
|
|
790
|
-
const datasetEndForBooking = dayjs.unix(meta.datasetEnd);
|
|
791
|
-
events.forEach(e => {
|
|
792
|
-
if (e.event === "booking completed") {
|
|
793
|
-
const eventTime = dayjs(e.time);
|
|
794
|
-
const daysUntilEnd = datasetEndForBooking.diff(eventTime, "days");
|
|
795
|
-
if (daysUntilEnd > 21) {
|
|
796
|
-
e.booking_window = "advance";
|
|
797
|
-
e.nightly_rate = Math.floor((e.nightly_rate || 150) * 0.8);
|
|
798
|
-
} else if (daysUntilEnd < 3) {
|
|
799
|
-
e.booking_window = "last_minute";
|
|
800
|
-
e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.4);
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
// ─── Bug 2 fix: Repeat destination clustering conversion filtering ───
|
|
806
|
-
// Drop ~25% of "booking completed" events for users who are NOT
|
|
807
|
-
// business_traveler or luxury_seeker to simulate their lower funnel
|
|
808
|
-
// conversion (was conversionRate * 1.3 / 1.15 in funnel-pre)
|
|
809
|
-
const segment = profile && profile.customer_segment;
|
|
810
|
-
if (segment !== "business_traveler" && segment !== "luxury_seeker"
|
|
811
|
-
&& chance.bool({ likelihood: 25 })) {
|
|
812
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
813
|
-
if (events[i].event === "booking completed") {
|
|
814
|
-
events.splice(i, 1);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
// ── HOOK 3: LOYALTY TIER UPGRADE PATH ────────────
|
|
820
|
-
let bookingCount = 0;
|
|
821
|
-
events.forEach(e => { if (e.event === "booking completed") bookingCount++; });
|
|
822
|
-
if (bookingCount >= 5) {
|
|
823
|
-
events.forEach(e => {
|
|
824
|
-
if (e.event === "booking completed" && e.loyalty_points) {
|
|
825
|
-
e.loyalty_points = Math.floor(e.loyalty_points * (2.5 + chance.floating({ min: 0, max: 1.0 })));
|
|
826
|
-
}
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// ── HOOK 4: CANCELLATION BY BOOKING WINDOW ───────
|
|
831
|
-
// Match each cancellation to its nearest preceding booking
|
|
832
|
-
// and copy the booking's booking_window. This replaces the
|
|
833
|
-
// random booking_window that the event config assigns to
|
|
834
|
-
// cancellation events independently.
|
|
835
|
-
const bookingsByTime = events
|
|
836
|
-
.filter(e => e.event === "booking completed")
|
|
837
|
-
.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
838
|
-
events.forEach(e => {
|
|
839
|
-
if (e.event === "booking cancelled" && bookingsByTime.length > 0) {
|
|
840
|
-
const cancelTime = new Date(e.time).getTime();
|
|
841
|
-
// Find the nearest preceding booking (or the first one)
|
|
842
|
-
let matched = bookingsByTime[0];
|
|
843
|
-
for (let b = bookingsByTime.length - 1; b >= 0; b--) {
|
|
844
|
-
if (new Date(bookingsByTime[b].time).getTime() <= cancelTime) {
|
|
845
|
-
matched = bookingsByTime[b];
|
|
846
|
-
break;
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
e.booking_window = matched.booking_window;
|
|
850
|
-
}
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
// Last-minute bookers rarely cancel — drop 60%.
|
|
854
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
855
|
-
if (events[i].event === "booking cancelled" && events[i].booking_window === "last_minute") {
|
|
856
|
-
if (chance.bool({ likelihood: 60 })) {
|
|
857
|
-
events.splice(i, 1);
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// ── HOOK 5: UPSELL SUCCESS BY SEGMENT ────────────
|
|
863
|
-
if (profile && profile.customer_segment === "luxury_seeker") {
|
|
864
|
-
const templateUpgrade = events.find(e => e.event === "room upgrade selected");
|
|
865
|
-
if (templateUpgrade) {
|
|
866
|
-
const bookings = events.filter(e => e.event === "booking completed");
|
|
867
|
-
bookings.forEach(booking => {
|
|
868
|
-
if (chance.bool({ likelihood: 50 })) {
|
|
869
|
-
events.push({
|
|
870
|
-
...templateUpgrade,
|
|
871
|
-
time: dayjs(booking.time).add(chance.integer({ min: 1, max: 30 }), "minutes").toISOString(),
|
|
872
|
-
user_id: booking.user_id,
|
|
873
|
-
upgrade_cost: chance.integer({ min: 75, max: 200 }),
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
// HOOK 6: REVIEW QUALITY BY STAY RATING — high avg ratings get
|
|
881
|
-
// review_length 1.5x; low avg ratings get 0.5x. Mutates raw prop.
|
|
882
|
-
let totalRating = 0;
|
|
883
|
-
let ratingCount = 0;
|
|
884
|
-
events.forEach(e => {
|
|
885
|
-
if (e.event === "review submitted" && e.stay_rating) {
|
|
886
|
-
totalRating += e.stay_rating;
|
|
887
|
-
ratingCount++;
|
|
888
|
-
}
|
|
889
|
-
});
|
|
890
|
-
const avgRating = ratingCount > 0 ? totalRating / ratingCount : 3;
|
|
891
|
-
events.forEach(e => {
|
|
892
|
-
if (e.event === "review submitted") {
|
|
893
|
-
if (avgRating >= 4) {
|
|
894
|
-
e.review_length = Math.floor((e.review_length || 120) * 1.5);
|
|
895
|
-
} else if (avgRating <= 2) {
|
|
896
|
-
e.review_length = Math.floor((e.review_length || 120) * 0.5);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
});
|
|
900
|
-
|
|
901
|
-
// HOOK 10: HOTEL-VIEWED MAGIC NUMBER (in-funnel, no flags)
|
|
902
|
-
// Sweet 5-10 hotel-viewed events → +30% on booking nightly_rate
|
|
903
|
-
// (decisive comparison shoppers book higher-tier rooms).
|
|
904
|
-
// Over 11+ → drop 35% of booking-completed events (analysis
|
|
905
|
-
// paralysis blocks conversion).
|
|
906
|
-
const hotelViews = events.filter(e => e.event === "hotel viewed").length;
|
|
907
|
-
if (hotelViews >= 5 && hotelViews <= 10) {
|
|
908
|
-
events.forEach(e => {
|
|
909
|
-
if (e.event === "booking completed" && typeof e.nightly_rate === "number") {
|
|
910
|
-
e.nightly_rate = Math.round(e.nightly_rate * 1.3);
|
|
911
|
-
}
|
|
912
|
-
});
|
|
913
|
-
} else if (hotelViews >= 11) {
|
|
914
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
915
|
-
if (events[i].event === "booking completed" && chance.bool({ likelihood: 35 })) {
|
|
916
|
-
events.splice(i, 1);
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
return record;
|
|
922
|
-
}
|
|
923
|
-
|
|
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);
|
|
924
926
|
return record;
|
|
925
927
|
},
|
|
926
928
|
};
|