@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,67 +1,49 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "dm4-marketplace";
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* - user_type: buyer vs seller role
|
|
10
|
+
// ── OVERVIEW ──
|
|
11
|
+
/*
|
|
12
|
+
* NAME: TradeNest
|
|
13
|
+
* APP: Two-sided marketplace connecting sellers who list products with
|
|
14
|
+
* buyers who search, purchase, and review. Sellers list items;
|
|
15
|
+
* buyers browse, message, negotiate, and buy. Revenue from
|
|
16
|
+
* marketplace fees and listing fees.
|
|
17
|
+
* SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
|
|
18
|
+
* CORE LOOP: search → view → add to cart → purchase → review
|
|
19
|
+
* (seller side: create listing → receive offers → accept → ship)
|
|
20
|
+
*
|
|
21
|
+
* EVENTS (18):
|
|
22
|
+
* item searched (8) > app session (8) > item viewed (7) > notification received (6)
|
|
23
|
+
* > add to cart (5) > message sent (5) > listing created (4) > purchase completed (3)
|
|
24
|
+
* > listing updated (3) > offer received (3) > shipping updated (3) > offer accepted (2)
|
|
25
|
+
* > review submitted (2) > seller rated (2) > profile updated (2) > account created (1)
|
|
26
|
+
* > refund requested (1) > account deactivated (1)
|
|
27
|
+
*
|
|
28
|
+
* FUNNELS (5):
|
|
29
|
+
* - Buyer Onboarding: account created → item searched → item viewed → add to cart (40%)
|
|
30
|
+
* - Browse to Purchase: item searched → item viewed → add to cart → purchase completed (30%)
|
|
31
|
+
* - Seller Listing Flow: listing created → listing updated → offer received → offer accepted (25%)
|
|
32
|
+
* - Offer Negotiation: offer received → message sent → offer accepted (35%)
|
|
33
|
+
* - Review After Purchase: purchase completed → shipping updated → review submitted (20%)
|
|
34
|
+
*
|
|
35
|
+
* USER PROPS: user_type, segment, seller_rating, total_transactions, response_time_hours,
|
|
36
|
+
* store_name, Platform, category
|
|
37
|
+
* SUPER PROPS: Platform, category
|
|
38
|
+
* SCD PROPS: seller_tier (new/verified/power/featured, monthly fuzzy, max 8)
|
|
39
|
+
* GROUPS: none
|
|
52
40
|
*/
|
|
53
41
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
57
|
-
*
|
|
42
|
+
// ── HOOK STORIES ──
|
|
43
|
+
/*
|
|
58
44
|
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
|
|
59
45
|
* raw-prop breakdowns (segment, day, category) or behavioral cohorts.
|
|
60
46
|
*
|
|
61
|
-
* Adds 9. BROWSE TO PURCHASE TIME-TO-CONVERT and 10. MESSAGE-COUNT MAGIC
|
|
62
|
-
* NUMBER on top of original 8.
|
|
63
|
-
* ═══════════════════════════════════════════════════════════════
|
|
64
|
-
*
|
|
65
47
|
* ───────────────────────────────────────────────────────────────
|
|
66
48
|
* 1. FEE CHANGE IMPACT (everything hook)
|
|
67
49
|
* ───────────────────────────────────────────────────────────────
|
|
@@ -238,6 +220,7 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
|
|
|
238
220
|
*
|
|
239
221
|
* ───────────────────────────────────────────────────────────────
|
|
240
222
|
* 9. BROWSE TO PURCHASE TIME-TO-CONVERT (funnel-post)
|
|
223
|
+
* ───────────────────────────────────────────────────────────────
|
|
241
224
|
*
|
|
242
225
|
* PATTERN: Power_seller and frequent_buyer users complete the
|
|
243
226
|
* Browse to Purchase funnel 1.4x faster (factor 0.71); window_shopper
|
|
@@ -258,6 +241,7 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
|
|
|
258
241
|
*
|
|
259
242
|
* ───────────────────────────────────────────────────────────────
|
|
260
243
|
* 10. MESSAGE-COUNT MAGIC NUMBER (in-funnel, everything)
|
|
244
|
+
* ───────────────────────────────────────────────────────────────
|
|
261
245
|
*
|
|
262
246
|
* PATTERN: Sweet 2-5 message-sent events between item-viewed and
|
|
263
247
|
* offer-received → +35% on offer_amount of offer-received events.
|
|
@@ -302,31 +286,305 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
|
|
|
302
286
|
* Message Magic Number | over purchases/user | 1x | 0.75x | -25%
|
|
303
287
|
*/
|
|
304
288
|
|
|
289
|
+
// ── SCALE ──
|
|
290
|
+
const SEED = "dm4-marketplace";
|
|
291
|
+
const NUM_USERS = 10_000;
|
|
292
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
293
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
294
|
+
const EVENTS_PER_DAY = 1.2;
|
|
295
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
296
|
+
|
|
297
|
+
const chance = u.initChance(SEED);
|
|
298
|
+
|
|
299
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
300
|
+
const FEE_CHANGE_DAY = 45;
|
|
301
|
+
const FEE_CHANGE_MULT = 1.3;
|
|
302
|
+
|
|
303
|
+
const WEEKEND_SPEND_MULT = 1.2;
|
|
304
|
+
|
|
305
|
+
const POWER_SELLER_CLONE_LIKELIHOOD = 65;
|
|
306
|
+
|
|
307
|
+
const ELECTRONICS_CLONE_LIKELIHOOD = 40;
|
|
308
|
+
const ELECTRONICS_MAX_CLONES = 3;
|
|
309
|
+
|
|
310
|
+
const FAST_RESPONDER_HASH_MOD = 5;
|
|
311
|
+
const FAST_RESPONDER_HASH_THRESHOLD = 2;
|
|
312
|
+
const FAST_OFFER_CLONE_COUNT = 2;
|
|
313
|
+
const FAST_OFFER_FROM_RECEIVED_LIKELIHOOD = 60;
|
|
314
|
+
const SLOW_OFFER_DROP_LIKELIHOOD = 60;
|
|
315
|
+
|
|
316
|
+
const NEW_SELLER_CUTOFF_DAYS = 14;
|
|
317
|
+
const NEW_SELLER_DROP_LIKELIHOOD = 50;
|
|
318
|
+
|
|
319
|
+
const FREQUENT_BUYER_DROP_LIKELIHOOD = 25;
|
|
320
|
+
|
|
321
|
+
const TTC_FAST_FACTOR = 0.71;
|
|
322
|
+
const TTC_SLOW_FACTOR = 1.4;
|
|
323
|
+
|
|
324
|
+
const MSG_SWEET_MIN = 2;
|
|
325
|
+
const MSG_SWEET_MAX = 5;
|
|
326
|
+
const MSG_OVER_THRESHOLD = 6;
|
|
327
|
+
const MSG_OFFER_BOOST = 1.35;
|
|
328
|
+
const MSG_PURCHASE_DROP_LIKELIHOOD = 25;
|
|
329
|
+
|
|
330
|
+
// ── DATA ARRAYS ──
|
|
331
|
+
// Generate consistent seller store and listing IDs at module level
|
|
332
|
+
const storeIds = v.range(1, 150).map(() => `STORE_${v.uid(6)}`);
|
|
333
|
+
const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
|
|
334
|
+
|
|
335
|
+
// ── HELPER FUNCTIONS ──
|
|
336
|
+
function handleUserHooks(record) {
|
|
337
|
+
// H7: Power seller profiles — enrich segment-tagged profiles
|
|
338
|
+
if (record.segment === "power_seller") {
|
|
339
|
+
record.total_transactions = chance.integer({ min: 100, max: 500 });
|
|
340
|
+
record.seller_rating = chance.floating({ min: 4.5, max: 5.0, fixed: 1 });
|
|
341
|
+
record.store_name = chance.pickone([
|
|
342
|
+
"TechVault Pro", "StyleHaven", "GearFactory", "PrimeFinds",
|
|
343
|
+
"TopShelf Goods", "EliteDeals", "QualityFirst Store", "BestBuy Resale",
|
|
344
|
+
]);
|
|
345
|
+
} else if (record.segment === "casual_seller") {
|
|
346
|
+
record.total_transactions = chance.integer({ min: 5, max: 50 });
|
|
347
|
+
record.seller_rating = chance.floating({ min: 3.0, max: 4.5, fixed: 1 });
|
|
348
|
+
record.store_name = chance.pickone([
|
|
349
|
+
"My Garage Sale", "Closet Cleanout", "Random Finds", "Weekend Seller",
|
|
350
|
+
]);
|
|
351
|
+
} else if (record.segment === "new_seller") {
|
|
352
|
+
record.total_transactions = chance.integer({ min: 0, max: 3 });
|
|
353
|
+
record.seller_rating = 0;
|
|
354
|
+
record.store_name = "New Store";
|
|
355
|
+
}
|
|
356
|
+
return record;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function handleFunnelPostHooks(record, meta) {
|
|
360
|
+
// H9: Browse-to-purchase TTC scaled by segment
|
|
361
|
+
const segment = meta?.profile?.segment;
|
|
362
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
363
|
+
const factor = (
|
|
364
|
+
segment === "power_seller" || segment === "frequent_buyer" ? TTC_FAST_FACTOR :
|
|
365
|
+
segment === "window_shopper" ? TTC_SLOW_FACTOR :
|
|
366
|
+
1.0
|
|
367
|
+
);
|
|
368
|
+
if (factor !== 1.0) {
|
|
369
|
+
for (let i = 1; i < record.length; i++) {
|
|
370
|
+
const prev = dayjs(record[i - 1].time);
|
|
371
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
372
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return record;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function handleEverythingHooks(record, meta) {
|
|
380
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
381
|
+
const FEE_CHANGE_CUTOFF = datasetStart.add(FEE_CHANGE_DAY, "days");
|
|
382
|
+
let events = record;
|
|
383
|
+
if (!events.length) return record;
|
|
384
|
+
|
|
385
|
+
const profile = meta.profile;
|
|
386
|
+
|
|
387
|
+
// Stamp superProps from profile so they are consistent per user.
|
|
388
|
+
if (profile) {
|
|
389
|
+
events.forEach(e => {
|
|
390
|
+
if (profile.Platform) e.Platform = profile.Platform;
|
|
391
|
+
if (profile.category) e.category = profile.category;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// H1: Fee change impact — listings after d45 get listing_fee 1.3x.
|
|
396
|
+
// In everything hook so timestamp comparison sees post-bunchIntoSessions times.
|
|
397
|
+
events.forEach(e => {
|
|
398
|
+
if (e.event === "listing created" && dayjs(e.time).isAfter(FEE_CHANGE_CUTOFF)) {
|
|
399
|
+
e.listing_fee = Math.floor((e.listing_fee || 15) * FEE_CHANGE_MULT);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// H8: Frequent buyer conversion filter — non-frequent-buyer users
|
|
404
|
+
// drop ~25% of "purchase completed" events.
|
|
405
|
+
if (profile && profile.segment !== "frequent_buyer") {
|
|
406
|
+
record = record.filter(e => {
|
|
407
|
+
if (e.event === "purchase completed" && chance.bool({ likelihood: FREQUENT_BUYER_DROP_LIKELIHOOD })) return false;
|
|
408
|
+
return true;
|
|
409
|
+
});
|
|
410
|
+
events = record;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// H3: Seller success → buyer trust — power sellers get 2x purchase events cloned.
|
|
414
|
+
if (profile && profile.segment === "power_seller") {
|
|
415
|
+
const purchases = events.filter(e => e.event === "purchase completed");
|
|
416
|
+
const templatePurchase = purchases[0];
|
|
417
|
+
if (templatePurchase && purchases.length > 0) {
|
|
418
|
+
purchases.forEach(p => {
|
|
419
|
+
if (chance.bool({ likelihood: POWER_SELLER_CLONE_LIKELIHOOD })) {
|
|
420
|
+
events.push({
|
|
421
|
+
...templatePurchase,
|
|
422
|
+
time: dayjs(p.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
423
|
+
user_id: p.user_id,
|
|
424
|
+
total_amount: chance.integer({ min: 15, max: 400 }),
|
|
425
|
+
item_count: chance.integer({ min: 1, max: 4 }),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// H4: Search-to-purchase by category — electronics gets cloned purchase events.
|
|
433
|
+
const hasElectronicsSearch = events.some(e =>
|
|
434
|
+
e.event === "item searched" && e.category === "electronics"
|
|
435
|
+
);
|
|
436
|
+
if (hasElectronicsSearch) {
|
|
437
|
+
const templatePurchase = events.find(e => e.event === "purchase completed");
|
|
438
|
+
if (templatePurchase) {
|
|
439
|
+
const electronicsSearches = events.filter(e =>
|
|
440
|
+
e.event === "item searched" && e.category === "electronics"
|
|
441
|
+
);
|
|
442
|
+
electronicsSearches.slice(0, ELECTRONICS_MAX_CLONES).forEach(search => {
|
|
443
|
+
if (chance.bool({ likelihood: ELECTRONICS_CLONE_LIKELIHOOD })) {
|
|
444
|
+
events.push({
|
|
445
|
+
...templatePurchase,
|
|
446
|
+
time: dayjs(search.time).add(chance.integer({ min: 1, max: 12 }), "hours").toISOString(),
|
|
447
|
+
user_id: search.user_id,
|
|
448
|
+
total_amount: chance.integer({ min: 50, max: 300 }),
|
|
449
|
+
category: "electronics",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// H5: Response time → conversion — deterministic fast/slow cohorts via user hash.
|
|
457
|
+
// Fast (~40%): set response_time_hours 1-4, clone offer_accepted.
|
|
458
|
+
// Slow (~60%): set 8-36, drop 60% of offer_accepted events.
|
|
459
|
+
const msgEvents = events.filter(e => e.event === "message sent");
|
|
460
|
+
if (msgEvents.length > 0) {
|
|
461
|
+
const uid = msgEvents[0].user_id || "";
|
|
462
|
+
const isFast = (uid.charCodeAt(0) + uid.charCodeAt(uid.length - 1)) % FAST_RESPONDER_HASH_MOD < FAST_RESPONDER_HASH_THRESHOLD;
|
|
463
|
+
msgEvents.forEach(m => {
|
|
464
|
+
m.response_time_hours = isFast
|
|
465
|
+
? chance.floating({ min: 0.5, max: 4, fixed: 1 })
|
|
466
|
+
: chance.floating({ min: 8, max: 36, fixed: 1 });
|
|
467
|
+
});
|
|
468
|
+
const templateOffer = events.find(e => e.event === "offer accepted");
|
|
469
|
+
if (isFast && templateOffer) {
|
|
470
|
+
// Fast responders: clone existing offer_accepted 2x each
|
|
471
|
+
const existingAccepts = events.filter(e => e.event === "offer accepted");
|
|
472
|
+
existingAccepts.forEach(accept => {
|
|
473
|
+
for (let c = 0; c < FAST_OFFER_CLONE_COUNT; c++) {
|
|
474
|
+
events.push({
|
|
475
|
+
...accept,
|
|
476
|
+
time: dayjs(accept.time).add(chance.integer({ min: 1, max: 8 }), "hours").toISOString(),
|
|
477
|
+
user_id: accept.user_id,
|
|
478
|
+
response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
// Also clone from offer_received → offer_accepted
|
|
483
|
+
const offers = events.filter(e => e.event === "offer received");
|
|
484
|
+
offers.forEach(offer => {
|
|
485
|
+
if (chance.bool({ likelihood: FAST_OFFER_FROM_RECEIVED_LIKELIHOOD })) {
|
|
486
|
+
events.push({
|
|
487
|
+
...templateOffer,
|
|
488
|
+
time: dayjs(offer.time).add(chance.integer({ min: 1, max: 6 }), "hours").toISOString(),
|
|
489
|
+
user_id: offer.user_id,
|
|
490
|
+
response_time_hours: chance.floating({ min: 0.1, max: 3, fixed: 1 }),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
} else if (!isFast) {
|
|
495
|
+
// Slow responders: drop 60% of offer_accepted events
|
|
496
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
497
|
+
if (events[i].event === "offer accepted" && chance.bool({ likelihood: SLOW_OFFER_DROP_LIKELIHOOD })) {
|
|
498
|
+
events.splice(i, 1);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// H6: New seller churn — drop 50% of events after user's first 14 days.
|
|
505
|
+
if (profile && profile.segment === "new_seller") {
|
|
506
|
+
const firstEventTime = events.length > 0 ? dayjs(events[0].time) : null;
|
|
507
|
+
if (firstEventTime) {
|
|
508
|
+
const userCutoff = firstEventTime.add(NEW_SELLER_CUTOFF_DAYS, "days");
|
|
509
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
510
|
+
if (dayjs(events[i].time).isAfter(userCutoff) && chance.bool({ likelihood: NEW_SELLER_DROP_LIKELIHOOD })) {
|
|
511
|
+
events.splice(i, 1);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// H2: Weekend shopping surge — Sat/Sun purchases get total_amount 1.2x.
|
|
518
|
+
events.forEach(e => {
|
|
519
|
+
if (e.event === "purchase completed") {
|
|
520
|
+
const dow = new Date(e.time).getUTCDay();
|
|
521
|
+
if (dow === 0 || dow === 6) {
|
|
522
|
+
e.total_amount = Math.floor((e.total_amount || 60) * WEEKEND_SPEND_MULT);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// H10: Message-count magic number (in-funnel, no flags)
|
|
528
|
+
// Sweet 2-5 messages between item-viewed and offer-received →
|
|
529
|
+
// +35% on offer-received offer_amount. Over 6+ → drop 25% of purchases.
|
|
530
|
+
const itemViewed = events.find(e => e.event === "item viewed");
|
|
531
|
+
const offerReceived = events.find(e => e.event === "offer received");
|
|
532
|
+
if (itemViewed && offerReceived) {
|
|
533
|
+
const aTime = dayjs(itemViewed.time);
|
|
534
|
+
const bTime = dayjs(offerReceived.time);
|
|
535
|
+
const msgBetween = events.filter(e =>
|
|
536
|
+
e.event === "message sent" &&
|
|
537
|
+
dayjs(e.time).isAfter(aTime) &&
|
|
538
|
+
dayjs(e.time).isBefore(bTime)
|
|
539
|
+
).length;
|
|
540
|
+
if (msgBetween >= MSG_SWEET_MIN && msgBetween <= MSG_SWEET_MAX) {
|
|
541
|
+
events.forEach(e => {
|
|
542
|
+
if (e.event === "offer received" && typeof e.offer_amount === "number") {
|
|
543
|
+
e.offer_amount = Math.round(e.offer_amount * MSG_OFFER_BOOST);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
} else if (msgBetween >= MSG_OVER_THRESHOLD) {
|
|
547
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
548
|
+
if (events[i].event === "purchase completed" && chance.bool({ likelihood: MSG_PURCHASE_DROP_LIKELIHOOD })) {
|
|
549
|
+
events.splice(i, 1);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return record;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── CONFIG ──
|
|
305
559
|
/** @type {Config} */
|
|
306
560
|
const config = {
|
|
307
561
|
version: 2,
|
|
308
|
-
token,
|
|
309
562
|
seed: SEED,
|
|
310
|
-
datasetStart:
|
|
311
|
-
datasetEnd:
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
numUsers: num_users,
|
|
315
|
-
hasAnonIds: true,
|
|
316
|
-
avgDevicePerUser: 2,
|
|
317
|
-
hasSessionIds: true,
|
|
563
|
+
datasetStart: DATASET_START,
|
|
564
|
+
datasetEnd: DATASET_END,
|
|
565
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
566
|
+
numUsers: NUM_USERS,
|
|
318
567
|
format: "json",
|
|
319
568
|
gzip: true,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
569
|
+
credentials: {
|
|
570
|
+
token,
|
|
571
|
+
},
|
|
572
|
+
switches: {
|
|
573
|
+
hasSessionIds: true,
|
|
574
|
+
alsoInferFunnels: false,
|
|
575
|
+
hasLocation: true,
|
|
576
|
+
hasAndroidDevices: true,
|
|
577
|
+
hasIOSDevices: true,
|
|
578
|
+
hasDesktopDevices: true,
|
|
579
|
+
hasBrowser: false,
|
|
580
|
+
hasCampaigns: false,
|
|
581
|
+
isAnonymous: false,
|
|
582
|
+
hasAdSpend: false,
|
|
583
|
+
hasAvatar: true,
|
|
584
|
+
},
|
|
585
|
+
identity: {
|
|
586
|
+
avgDevicePerUser: 2,
|
|
587
|
+
},
|
|
330
588
|
concurrency: 1,
|
|
331
589
|
writeToDisk: false,
|
|
332
590
|
scdProps: {
|
|
@@ -340,7 +598,6 @@ const config = {
|
|
|
340
598
|
mirrorProps: {},
|
|
341
599
|
lookupTables: [],
|
|
342
600
|
|
|
343
|
-
// ── Events (18) ──────────────────────────────────────────
|
|
344
601
|
events: [
|
|
345
602
|
{
|
|
346
603
|
event: "account created",
|
|
@@ -519,7 +776,6 @@ const config = {
|
|
|
519
776
|
},
|
|
520
777
|
],
|
|
521
778
|
|
|
522
|
-
// ── Funnels (5) ──────────────────────────────────────────
|
|
523
779
|
funnels: [
|
|
524
780
|
{
|
|
525
781
|
name: "Buyer Onboarding",
|
|
@@ -564,13 +820,11 @@ const config = {
|
|
|
564
820
|
},
|
|
565
821
|
],
|
|
566
822
|
|
|
567
|
-
// ── SuperProps ──────────────────────────────────────────
|
|
568
823
|
superProps: {
|
|
569
824
|
Platform: ["ios", "android", "web"],
|
|
570
825
|
category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
|
|
571
826
|
},
|
|
572
827
|
|
|
573
|
-
// ── UserProps ──────────────────────────────────────────
|
|
574
828
|
userProps: {
|
|
575
829
|
user_type: ["buyer"],
|
|
576
830
|
segment: ["window_shopper"],
|
|
@@ -582,7 +836,6 @@ const config = {
|
|
|
582
836
|
category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
|
|
583
837
|
},
|
|
584
838
|
|
|
585
|
-
// ── Personas ──────────────────────────────────
|
|
586
839
|
personas: [
|
|
587
840
|
{
|
|
588
841
|
name: "power_seller",
|
|
@@ -642,7 +895,6 @@ const config = {
|
|
|
642
895
|
},
|
|
643
896
|
],
|
|
644
897
|
|
|
645
|
-
// ── World Events ──────────────────────────────
|
|
646
898
|
worldEvents: [
|
|
647
899
|
{
|
|
648
900
|
name: "marketplace_fee_change",
|
|
@@ -662,14 +914,12 @@ const config = {
|
|
|
662
914
|
},
|
|
663
915
|
],
|
|
664
916
|
|
|
665
|
-
// ── Engagement Decay ──────────────────────────
|
|
666
917
|
engagementDecay: {
|
|
667
918
|
model: "exponential",
|
|
668
919
|
halfLife: 75,
|
|
669
920
|
floor: 0.1,
|
|
670
921
|
},
|
|
671
922
|
|
|
672
|
-
// ── Attribution ──────────────────────────────
|
|
673
923
|
attribution: {
|
|
674
924
|
model: "last_touch",
|
|
675
925
|
window: 7,
|
|
@@ -705,7 +955,6 @@ const config = {
|
|
|
705
955
|
organicRate: 0.30,
|
|
706
956
|
},
|
|
707
957
|
|
|
708
|
-
// ── Geo ──────────────────────────────────────
|
|
709
958
|
geo: {
|
|
710
959
|
sticky: true,
|
|
711
960
|
regions: [
|
|
@@ -740,7 +989,6 @@ const config = {
|
|
|
740
989
|
],
|
|
741
990
|
},
|
|
742
991
|
|
|
743
|
-
// ── Anomalies ──────────────────────────────────
|
|
744
992
|
anomalies: [
|
|
745
993
|
{
|
|
746
994
|
type: "extreme_value",
|
|
@@ -760,248 +1008,10 @@ const config = {
|
|
|
760
1008
|
},
|
|
761
1009
|
],
|
|
762
1010
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
// and a realistic store name. Casual sellers get mid-range stats.
|
|
768
|
-
if (type === "user") {
|
|
769
|
-
if (record.segment === "power_seller") {
|
|
770
|
-
record.total_transactions = chance.integer({ min: 100, max: 500 });
|
|
771
|
-
record.seller_rating = chance.floating({ min: 4.5, max: 5.0, fixed: 1 });
|
|
772
|
-
record.store_name = chance.pickone([
|
|
773
|
-
"TechVault Pro", "StyleHaven", "GearFactory", "PrimeFinds",
|
|
774
|
-
"TopShelf Goods", "EliteDeals", "QualityFirst Store", "BestBuy Resale",
|
|
775
|
-
]);
|
|
776
|
-
} else if (record.segment === "casual_seller") {
|
|
777
|
-
record.total_transactions = chance.integer({ min: 5, max: 50 });
|
|
778
|
-
record.seller_rating = chance.floating({ min: 3.0, max: 4.5, fixed: 1 });
|
|
779
|
-
record.store_name = chance.pickone([
|
|
780
|
-
"My Garage Sale", "Closet Cleanout", "Random Finds", "Weekend Seller",
|
|
781
|
-
]);
|
|
782
|
-
} else if (record.segment === "new_seller") {
|
|
783
|
-
record.total_transactions = chance.integer({ min: 0, max: 3 });
|
|
784
|
-
record.seller_rating = 0;
|
|
785
|
-
record.store_name = "New Store";
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// HOOK 9 (T2C): BROWSE TO PURCHASE TIME-TO-CONVERT (funnel-post)
|
|
790
|
-
// Power_seller users complete Browse to Purchase funnel 1.4x faster
|
|
791
|
-
// (factor 0.71); window_shopper 1.4x slower (factor 1.4).
|
|
792
|
-
if (type === "funnel-post") {
|
|
793
|
-
const segment = meta?.profile?.segment;
|
|
794
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
795
|
-
const factor = (
|
|
796
|
-
segment === "power_seller" || segment === "frequent_buyer" ? 0.71 :
|
|
797
|
-
segment === "window_shopper" ? 1.4 :
|
|
798
|
-
1.0
|
|
799
|
-
);
|
|
800
|
-
if (factor !== 1.0) {
|
|
801
|
-
for (let i = 1; i < record.length; i++) {
|
|
802
|
-
const prev = dayjs(record[i - 1].time);
|
|
803
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
804
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
// HOOK 1: FEE CHANGE IMPACT — moved to everything hook below.
|
|
811
|
-
// (event hook fires before bunchIntoSessions reshuffles timestamps)
|
|
812
|
-
|
|
813
|
-
// ── EVERYTHING HOOKS ─────────────────────────────────
|
|
814
|
-
if (type === "everything") {
|
|
815
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
816
|
-
const FEE_CHANGE_DAY = datasetStart.add(45, "days");
|
|
817
|
-
let events = record;
|
|
818
|
-
if (!events.length) return record;
|
|
819
|
-
|
|
820
|
-
const profile = meta.profile;
|
|
821
|
-
|
|
822
|
-
// ── SUPERPROP STAMPING ──────────────────────────
|
|
823
|
-
// Stamp superProps from profile so they are consistent per user.
|
|
824
|
-
if (profile) {
|
|
825
|
-
events.forEach(e => {
|
|
826
|
-
if (profile.Platform) e.Platform = profile.Platform;
|
|
827
|
-
if (profile.category) e.category = profile.category;
|
|
828
|
-
});
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// HOOK 1: FEE CHANGE IMPACT — listings after d45 get listing_fee 1.3x.
|
|
832
|
-
// In everything hook so timestamp comparison sees post-bunchIntoSessions times.
|
|
833
|
-
events.forEach(e => {
|
|
834
|
-
if (e.event === "listing created" && dayjs(e.time).isAfter(FEE_CHANGE_DAY)) {
|
|
835
|
-
e.listing_fee = Math.floor((e.listing_fee || 15) * 1.3);
|
|
836
|
-
}
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
// ── HOOK 8: FREQUENT BUYER CONVERSION FILTER ────
|
|
840
|
-
// Non-frequent-buyer users drop ~25% of "purchase completed"
|
|
841
|
-
// (last step of Browse to Purchase funnel) to simulate lower conversion.
|
|
842
|
-
if (profile && profile.segment !== "frequent_buyer") {
|
|
843
|
-
record = record.filter(e => {
|
|
844
|
-
if (e.event === "purchase completed" && chance.bool({ likelihood: 25 })) return false;
|
|
845
|
-
return true;
|
|
846
|
-
});
|
|
847
|
-
events = record;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// ── HOOK 3: SELLER SUCCESS → BUYER TRUST ─────────
|
|
851
|
-
// Power sellers get 2x purchase events (cloned from existing).
|
|
852
|
-
if (profile && profile.segment === "power_seller") {
|
|
853
|
-
const purchases = events.filter(e => e.event === "purchase completed");
|
|
854
|
-
const templatePurchase = purchases[0];
|
|
855
|
-
if (templatePurchase && purchases.length > 0) {
|
|
856
|
-
purchases.forEach(p => {
|
|
857
|
-
if (chance.bool({ likelihood: 65 })) {
|
|
858
|
-
events.push({
|
|
859
|
-
...templatePurchase,
|
|
860
|
-
time: dayjs(p.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
861
|
-
user_id: p.user_id,
|
|
862
|
-
total_amount: chance.integer({ min: 15, max: 400 }),
|
|
863
|
-
item_count: chance.integer({ min: 1, max: 4 }),
|
|
864
|
-
});
|
|
865
|
-
}
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// ── HOOK 4: SEARCH-TO-PURCHASE BY CATEGORY ───────
|
|
871
|
-
// Electronics category gets cloned purchase events (higher conversion).
|
|
872
|
-
const hasElectronicsSearch = events.some(e =>
|
|
873
|
-
e.event === "item searched" && e.category === "electronics"
|
|
874
|
-
);
|
|
875
|
-
if (hasElectronicsSearch) {
|
|
876
|
-
const templatePurchase = events.find(e => e.event === "purchase completed");
|
|
877
|
-
if (templatePurchase) {
|
|
878
|
-
const electronicsSearches = events.filter(e =>
|
|
879
|
-
e.event === "item searched" && e.category === "electronics"
|
|
880
|
-
);
|
|
881
|
-
electronicsSearches.slice(0, 3).forEach(search => {
|
|
882
|
-
if (chance.bool({ likelihood: 40 })) {
|
|
883
|
-
events.push({
|
|
884
|
-
...templatePurchase,
|
|
885
|
-
time: dayjs(search.time).add(chance.integer({ min: 1, max: 12 }), "hours").toISOString(),
|
|
886
|
-
user_id: search.user_id,
|
|
887
|
-
total_amount: chance.integer({ min: 50, max: 300 }),
|
|
888
|
-
category: "electronics",
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
});
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
// ── HOOK 5: RESPONSE TIME → CONVERSION ───────────
|
|
896
|
-
// Deterministic fast/slow cohorts using user hash.
|
|
897
|
-
// Fast responders (~40%): response_time_hours set to 1-4,
|
|
898
|
-
// extra offer_accepted clones. Slow (~60%): set to 8-36,
|
|
899
|
-
// lose 60% of offer_accepted events.
|
|
900
|
-
const msgEvents = events.filter(e => e.event === "message sent");
|
|
901
|
-
if (msgEvents.length > 0) {
|
|
902
|
-
const uid = msgEvents[0].user_id || "";
|
|
903
|
-
const isFast = (uid.charCodeAt(0) + uid.charCodeAt(uid.length - 1)) % 5 < 2; // ~40%
|
|
904
|
-
// Stamp response_time_hours to create the measurable correlation
|
|
905
|
-
msgEvents.forEach(m => {
|
|
906
|
-
m.response_time_hours = isFast
|
|
907
|
-
? chance.floating({ min: 0.5, max: 4, fixed: 1 })
|
|
908
|
-
: chance.floating({ min: 8, max: 36, fixed: 1 });
|
|
909
|
-
});
|
|
910
|
-
const templateOffer = events.find(e => e.event === "offer accepted");
|
|
911
|
-
if (isFast && templateOffer) {
|
|
912
|
-
// Fast responders: clone existing offer_accepted 2x each
|
|
913
|
-
const existingAccepts = events.filter(e => e.event === "offer accepted");
|
|
914
|
-
existingAccepts.forEach(accept => {
|
|
915
|
-
for (let c = 0; c < 2; c++) {
|
|
916
|
-
events.push({
|
|
917
|
-
...accept,
|
|
918
|
-
time: dayjs(accept.time).add(chance.integer({ min: 1, max: 8 }), "hours").toISOString(),
|
|
919
|
-
user_id: accept.user_id,
|
|
920
|
-
response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
});
|
|
924
|
-
// Also clone from offer_received → offer_accepted
|
|
925
|
-
const offers = events.filter(e => e.event === "offer received");
|
|
926
|
-
offers.forEach(offer => {
|
|
927
|
-
if (chance.bool({ likelihood: 60 })) {
|
|
928
|
-
events.push({
|
|
929
|
-
...templateOffer,
|
|
930
|
-
time: dayjs(offer.time).add(chance.integer({ min: 1, max: 6 }), "hours").toISOString(),
|
|
931
|
-
user_id: offer.user_id,
|
|
932
|
-
response_time_hours: chance.floating({ min: 0.1, max: 3, fixed: 1 }),
|
|
933
|
-
});
|
|
934
|
-
}
|
|
935
|
-
});
|
|
936
|
-
} else if (!isFast) {
|
|
937
|
-
// Slow responders: drop 60% of offer_accepted events
|
|
938
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
939
|
-
if (events[i].event === "offer accepted" && chance.bool({ likelihood: 60 })) {
|
|
940
|
-
events.splice(i, 1);
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
// ── HOOK 6: NEW SELLER CHURN ─────────────────────
|
|
947
|
-
// New sellers lose 50% of events after their first 14 days.
|
|
948
|
-
// Uses user's first event as anchor (not dataset start).
|
|
949
|
-
if (profile && profile.segment === "new_seller") {
|
|
950
|
-
const firstEventTime = events.length > 0 ? dayjs(events[0].time) : null;
|
|
951
|
-
if (firstEventTime) {
|
|
952
|
-
const userDay14 = firstEventTime.add(14, "days");
|
|
953
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
954
|
-
if (dayjs(events[i].time).isAfter(userDay14) && chance.bool({ likelihood: 50 })) {
|
|
955
|
-
events.splice(i, 1);
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// HOOK 2: WEEKEND SHOPPING SURGE — Sat/Sun purchases get
|
|
962
|
-
// total_amount 1.2x. Mutates raw prop. No flag.
|
|
963
|
-
events.forEach(e => {
|
|
964
|
-
if (e.event === "purchase completed") {
|
|
965
|
-
const dow = new Date(e.time).getUTCDay();
|
|
966
|
-
if (dow === 0 || dow === 6) {
|
|
967
|
-
e.total_amount = Math.floor((e.total_amount || 60) * 1.2);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
// HOOK 10: MESSAGE-COUNT MAGIC NUMBER (in-funnel, no flags)
|
|
973
|
-
// Sweet 2-5 message-sent events between item-viewed and
|
|
974
|
-
// offer-received → +35% on offer-received offer_amount/asking_price.
|
|
975
|
-
// Over 6+ → drop 25% of purchase-completed events (over-message
|
|
976
|
-
// signals haggling deadlock).
|
|
977
|
-
const itemViewed = events.find(e => e.event === "item viewed");
|
|
978
|
-
const offerReceived = events.find(e => e.event === "offer received");
|
|
979
|
-
if (itemViewed && offerReceived) {
|
|
980
|
-
const aTime = dayjs(itemViewed.time);
|
|
981
|
-
const bTime = dayjs(offerReceived.time);
|
|
982
|
-
const msgBetween = events.filter(e =>
|
|
983
|
-
e.event === "message sent" &&
|
|
984
|
-
dayjs(e.time).isAfter(aTime) &&
|
|
985
|
-
dayjs(e.time).isBefore(bTime)
|
|
986
|
-
).length;
|
|
987
|
-
if (msgBetween >= 2 && msgBetween <= 5) {
|
|
988
|
-
events.forEach(e => {
|
|
989
|
-
if (e.event === "offer received" && typeof e.offer_amount === "number") {
|
|
990
|
-
e.offer_amount = Math.round(e.offer_amount * 1.35);
|
|
991
|
-
}
|
|
992
|
-
});
|
|
993
|
-
} else if (msgBetween >= 6) {
|
|
994
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
995
|
-
if (events[i].event === "purchase completed" && chance.bool({ likelihood: 25 })) {
|
|
996
|
-
events.splice(i, 1);
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
return record;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1011
|
+
hook(record, type, meta) {
|
|
1012
|
+
if (type === "user") return handleUserHooks(record);
|
|
1013
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
1014
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
1005
1015
|
return record;
|
|
1006
1016
|
},
|
|
1007
1017
|
};
|