@ak--47/dungeon-master 1.5.4 → 1.6.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 +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/powertools/SKILL.md +75 -0
- package/.claude/skills/powertools/pt.mjs +91 -0
- package/.claude/skills/powertools/snapshot-project.mjs +124 -0
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +224 -0
- package/HOOKS.md +566 -112
- package/README.md +30 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +7 -3
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +151 -59
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +298 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
-- ============================================================
|
|
2
|
+
-- travel.js — v1.6 human-inspection queries (DuckDB)
|
|
3
|
+
--
|
|
4
|
+
-- Every query is keyed to a story id in travel.js's `stories` export;
|
|
5
|
+
-- the machine-checked verdicts come from:
|
|
6
|
+
-- node scripts/verify-stories.mjs dungeons/vertical/travel/travel.js --data-prefix verify-travel
|
|
7
|
+
-- Generate first:
|
|
8
|
+
-- node scripts/verify-runner.mjs dungeons/vertical/travel/travel.js verify-travel
|
|
9
|
+
-- Run this file:
|
|
10
|
+
-- duckdb -c ".read dungeons/vertical/travel/travel.sql"
|
|
11
|
+
-- ============================================================
|
|
12
|
+
|
|
13
|
+
-- ── identity-resolution prelude ─────────────────────────────
|
|
14
|
+
-- avgDevicePerUser: 2 + account created is both isAuthEvent and
|
|
15
|
+
-- isFirstEvent, so born users auth on their first event; the device-pool
|
|
16
|
+
-- resolve is belt-and-braces for any device-only edge.
|
|
17
|
+
CREATE OR REPLACE VIEW users AS
|
|
18
|
+
SELECT * FROM read_json_auto('data/verify-travel-USERS*.json', sample_size=-1, union_by_name=true);
|
|
19
|
+
|
|
20
|
+
CREATE OR REPLACE VIEW device_map AS
|
|
21
|
+
-- profiles store the device pool under the legacy "anonymousIds" key
|
|
22
|
+
SELECT unnest("anonymousIds") AS device_id, distinct_id FROM users;
|
|
23
|
+
|
|
24
|
+
CREATE OR REPLACE VIEW ev AS
|
|
25
|
+
-- ::VARCHAR casts — user_id sniffs as UUID, device_id as VARCHAR; DuckDB
|
|
26
|
+
-- refuses to coalesce mixed types
|
|
27
|
+
SELECT coalesce(m.distinct_id::VARCHAR, e.user_id::VARCHAR, e.device_id::VARCHAR) AS uid,
|
|
28
|
+
e.time::TIMESTAMP AS t,
|
|
29
|
+
e.*
|
|
30
|
+
FROM read_json_auto('data/verify-travel-EVENTS*.json', sample_size=-1, union_by_name=true) e
|
|
31
|
+
LEFT JOIN device_map m ON e.device_id = m.device_id;
|
|
32
|
+
|
|
33
|
+
-- Per-user counts. Bookings are only ever DELETED post-generation (H8
|
|
34
|
+
-- all-or-nothing per user, H10 post-day-60, future-time guard), so
|
|
35
|
+
-- booking-count cohorts are ONE-SIDED: output >= threshold implies
|
|
36
|
+
-- hook-time >= threshold. hotel viewed / searches / sessions / reviews /
|
|
37
|
+
-- upgrades are never deleted — exact hook-time recovery.
|
|
38
|
+
CREATE OR REPLACE VIEW per_user AS
|
|
39
|
+
SELECT uid,
|
|
40
|
+
count(*) FILTER (WHERE event = 'hotel viewed') AS hv,
|
|
41
|
+
count(*) FILTER (WHERE event = 'booking completed') AS bookings,
|
|
42
|
+
count(*) FILTER (WHERE event = 'destination searched') AS searches,
|
|
43
|
+
count(*) FILTER (WHERE event = 'app session') AS sessions,
|
|
44
|
+
count(*) FILTER (WHERE event = 'room upgrade selected') AS upgrades,
|
|
45
|
+
count(*) FILTER (WHERE event = 'review submitted') AS reviews
|
|
46
|
+
FROM ev GROUP BY 1;
|
|
47
|
+
|
|
48
|
+
CREATE OR REPLACE VIEW first_ev AS
|
|
49
|
+
SELECT uid, min(t) AS f FROM ev GROUP BY 1;
|
|
50
|
+
|
|
51
|
+
-- Calendar landmarks (dataset 2026-01-01 → 2026-05-01 23:59:59 UTC):
|
|
52
|
+
-- advance-stamped region: t < '2026-04-08 23:59:59' (H2, >21d before end + margin)
|
|
53
|
+
-- organic middle band: t in ['2026-04-11 23:59:59', '2026-04-27 23:59:59']
|
|
54
|
+
-- last_minute-stamped region: t >= '2026-04-29 00:59:59' (<3d before end + margin)
|
|
55
|
+
-- H10 fatigue cutoff (day 60): t >= '2026-03-02 00:00:00'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
-- ── H1-weekend-rate-surge ───────────────────────────────────
|
|
59
|
+
-- Fri/Sat/Sun 'booking completed' nightly_rate ×1.3 (floored). 'hotel
|
|
60
|
+
-- viewed' carries the same nightly_rate pool untouched — the placebo arm.
|
|
61
|
+
-- dayofweek(): Sunday = 0 (matches JS getUTCDay).
|
|
62
|
+
SELECT event,
|
|
63
|
+
CASE WHEN dayofweek(t) IN (0, 5, 6) THEN 'wkn' ELSE 'wkd' END AS bucket,
|
|
64
|
+
count(*) AS n, round(avg(nightly_rate), 1) AS avg_rate
|
|
65
|
+
FROM ev WHERE event IN ('booking completed', 'hotel viewed') AND nightly_rate IS NOT NULL
|
|
66
|
+
GROUP BY 1, 2 ORDER BY 1, 2;
|
|
67
|
+
-- read: booking wkn/wkd ≈ 1.3; hotel viewed ≈ 1.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
-- ── H2-booking-window ───────────────────────────────────────
|
|
71
|
+
-- H2 stamps by calendar distance to dataset end: advance (>21d, rate ×0.8),
|
|
72
|
+
-- last_minute (<3d, rate ×1.4). The 3-21d middle band keeps ORGANIC labels
|
|
73
|
+
-- (2/5 advance, 2/5 standard, 1/5 last_minute) and untouched rates — a
|
|
74
|
+
-- label breakdown DILUTES both treated labels; read by calendar REGION
|
|
75
|
+
-- with the middle band as baseline instead.
|
|
76
|
+
SELECT CASE WHEN t < TIMESTAMP '2026-04-08 23:59:59' THEN 'adv'
|
|
77
|
+
WHEN t >= TIMESTAMP '2026-04-29 00:59:59' THEN 'lm'
|
|
78
|
+
WHEN t >= TIMESTAMP '2026-04-11 23:59:59' AND t <= TIMESTAMP '2026-04-27 23:59:59' THEN 'mid' END AS region,
|
|
79
|
+
count(*) AS n, round(avg(nightly_rate), 1) AS avg_rate,
|
|
80
|
+
round(count(*) FILTER (WHERE booking_window = 'advance')::DOUBLE / count(*), 4) AS adv_share,
|
|
81
|
+
round(count(*) FILTER (WHERE booking_window = 'last_minute')::DOUBLE / count(*), 4) AS lm_share
|
|
82
|
+
FROM ev WHERE event = 'booking completed'
|
|
83
|
+
GROUP BY 1 ORDER BY 1;
|
|
84
|
+
-- read: adv/mid avg_rate ≈ 0.80 (weekend-mix corrected); lm/mid ≈ 1.36;
|
|
85
|
+
-- stamped regions label-pure (adv_share / lm_share = 1.0), middle
|
|
86
|
+
-- band keeps organic adv_share ≈ 0.4
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
-- ── H3-loyalty-boost ────────────────────────────────────────
|
|
90
|
+
-- >=5 bookings at hook time → loyalty_points ×(2.5 + U[0,1]) floored,
|
|
91
|
+
-- E[mult] = 3.0. Output >=5 IMPLIES treatment (deletions-only); control
|
|
92
|
+
-- 1-4 bookings AND hv <= 10 (H10 never drops their bookings).
|
|
93
|
+
SELECT CASE WHEN p.bookings >= 5 THEN 'big'
|
|
94
|
+
WHEN p.bookings BETWEEN 1 AND 4 AND p.hv <= 10 THEN 'small' END AS arm,
|
|
95
|
+
count(DISTINCT p.uid) AS users, round(avg(e.loyalty_points), 2) AS avg_lp
|
|
96
|
+
FROM per_user p JOIN ev e ON e.uid = p.uid AND e.event = 'booking completed'
|
|
97
|
+
WHERE p.bookings >= 5 OR (p.bookings BETWEEN 1 AND 4 AND p.hv <= 10)
|
|
98
|
+
GROUP BY 1 ORDER BY 1;
|
|
99
|
+
-- read: big/small avg_lp ≈ 3.0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
-- ── H4-cancel-by-window ─────────────────────────────────────
|
|
103
|
+
-- Each 'booking cancelled' is stamped with the booking_window of the
|
|
104
|
+
-- user's nearest PRECEDING booking, then 60% of last_minute-stamped
|
|
105
|
+
-- cancels are dropped. Replicate the matching with an ASOF JOIN; restrict
|
|
106
|
+
-- to matched bookings in the organic middle band (labels iid 0.4/0.4/0.2)
|
|
107
|
+
-- for hv <= 10 users (their output booking set = hook-time set).
|
|
108
|
+
WITH bk AS (SELECT uid, t, booking_window FROM ev WHERE event = 'booking completed'),
|
|
109
|
+
cn AS (
|
|
110
|
+
SELECT e.uid, e.t, e.booking_window AS stamped
|
|
111
|
+
FROM ev e JOIN per_user p ON p.uid = e.uid AND p.hv <= 10
|
|
112
|
+
WHERE e.event = 'booking cancelled'
|
|
113
|
+
),
|
|
114
|
+
m AS (
|
|
115
|
+
SELECT cn.uid, cn.stamped, bk.booking_window AS matched, bk.t AS bt
|
|
116
|
+
FROM cn ASOF JOIN bk ON cn.uid = bk.uid AND cn.t >= bk.t
|
|
117
|
+
)
|
|
118
|
+
SELECT matched AS label, count(*) AS n,
|
|
119
|
+
round(count(*) FILTER (WHERE stamped = matched)::DOUBLE / count(*), 4) AS agree
|
|
120
|
+
FROM m
|
|
121
|
+
WHERE bt >= TIMESTAMP '2026-04-11 23:59:59' AND bt <= TIMESTAMP '2026-04-27 23:59:59'
|
|
122
|
+
GROUP BY 1 ORDER BY 1;
|
|
123
|
+
-- read: keep = (lm_n / std_n) / 0.5 ≈ 0.4 (the 40% keep rate);
|
|
124
|
+
-- adv_n / std_n ≈ 1.0 (placebo); agree ≈ 1.0 (ASOF replicates hook)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
-- ── H5-luxury-upsell ────────────────────────────────────────
|
|
128
|
+
-- luxury_seeker with an upgrade template: 50% cloned 'room upgrade
|
|
129
|
+
-- selected' per booking → +0.5 upgrades-per-booking ADDITIVE. Both arms
|
|
130
|
+
-- restricted to template owners and PRE-day-60 counts (H10's post-day-60
|
|
131
|
+
-- booking drop would shrink over-viewers' denominators).
|
|
132
|
+
SELECT u.customer_segment AS seg, count(DISTINCT pr.uid) AS users,
|
|
133
|
+
round(sum(pr.upgrades_pre)::DOUBLE / sum(pr.bookings_pre), 4) AS upgrades_per_booking
|
|
134
|
+
FROM (
|
|
135
|
+
SELECT uid,
|
|
136
|
+
count(*) FILTER (WHERE event = 'booking completed' AND t < TIMESTAMP '2026-03-02') AS bookings_pre,
|
|
137
|
+
count(*) FILTER (WHERE event = 'room upgrade selected' AND t < TIMESTAMP '2026-03-02') AS upgrades_pre
|
|
138
|
+
FROM ev GROUP BY 1
|
|
139
|
+
) pr
|
|
140
|
+
JOIN per_user p ON p.uid = pr.uid AND p.upgrades >= 1
|
|
141
|
+
JOIN users u ON u.distinct_id::VARCHAR = pr.uid
|
|
142
|
+
WHERE u.customer_segment IN ('luxury_seeker', 'budget_hunter') AND pr.bookings_pre >= 1
|
|
143
|
+
GROUP BY 1 ORDER BY 1;
|
|
144
|
+
-- read: lux − budget ≈ +0.5 (plus small positive organic funnel-gap confound)
|
|
145
|
+
|
|
146
|
+
-- doc-level Insights view: upgrades per user across ALL users
|
|
147
|
+
SELECT u.customer_segment AS seg, count(*) AS users,
|
|
148
|
+
round(avg(coalesce(p.upgrades, 0)), 3) AS avg_upgrades
|
|
149
|
+
FROM users u LEFT JOIN per_user p ON p.uid = u.distinct_id::VARCHAR
|
|
150
|
+
WHERE u.customer_segment IN ('luxury_seeker', 'budget_hunter')
|
|
151
|
+
GROUP BY 1 ORDER BY 1;
|
|
152
|
+
-- read: lux/budget ≈ 12x — the +0.5 additive clone term dominates the thin
|
|
153
|
+
-- organic upsell base (~0.08 upb); the knob-clean read is the
|
|
154
|
+
-- upgrades-per-booking DIFFERENCE above
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
-- ── H6-review-quality ───────────────────────────────────────
|
|
158
|
+
-- Per-user avg stay_rating >= 4 → review_length ×1.5; <= 2 → ×0.5.
|
|
159
|
+
-- Reviews are never deleted/cloned → output avg EXACTLY reproduces the
|
|
160
|
+
-- hook-time classification (arm = the treatment variable itself).
|
|
161
|
+
WITH rv AS (SELECT uid, avg(stay_rating) AS avg_r FROM ev WHERE event = 'review submitted' GROUP BY 1)
|
|
162
|
+
SELECT CASE WHEN rv.avg_r >= 4 THEN 'high' WHEN rv.avg_r <= 2 THEN 'low' ELSE 'mid' END AS arm,
|
|
163
|
+
count(DISTINCT rv.uid) AS users, round(avg(e.review_length), 1) AS avg_len
|
|
164
|
+
FROM rv JOIN ev e ON e.uid = rv.uid AND e.event = 'review submitted'
|
|
165
|
+
GROUP BY 1 ORDER BY 1;
|
|
166
|
+
-- read: high/mid ≈ 1.5; low/mid ≈ 0.5
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
-- ── H7-business-profile ─────────────────────────────────────
|
|
170
|
+
-- user hook overwrites per segment: business gets company_name (never
|
|
171
|
+
-- 'none') + travel_frequency 'weekly'; luxury avg_budget U-int[250,500];
|
|
172
|
+
-- budget U-int[50,120]; everyone else keeps company_name 'none'.
|
|
173
|
+
SELECT customer_segment AS seg, count(*) AS users,
|
|
174
|
+
round(avg((travel_frequency = 'weekly')::INT), 4) AS weekly_share,
|
|
175
|
+
round(avg((company_name IS NOT NULL AND company_name <> 'none')::INT), 4) AS company_share,
|
|
176
|
+
min(avg_budget_per_night) AS min_budget, max(avg_budget_per_night) AS max_budget
|
|
177
|
+
FROM users GROUP BY 1 ORDER BY 1;
|
|
178
|
+
-- read: biz weekly_share = company_share = 1.0; others company_share = 0;
|
|
179
|
+
-- lux budget within [250, 500]; budget_hunter within [50, 120]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
-- ── H8-casual-booking-drop ──────────────────────────────────
|
|
183
|
+
-- leisure_family/budget_hunter: 25% per-user chance ALL bookings spliced
|
|
184
|
+
-- out. Among users with >= 10 searches (exact activity floor), treated
|
|
185
|
+
-- zero-booking share inflates by ~0.25 plus the organic conversion gap.
|
|
186
|
+
SELECT CASE WHEN u.customer_segment IN ('leisure_family', 'budget_hunter') THEN 'treated' ELSE 'control' END AS arm,
|
|
187
|
+
count(*) AS users, round(avg((p.bookings = 0)::INT), 4) AS zero_booking_share
|
|
188
|
+
FROM per_user p JOIN users u ON u.distinct_id::VARCHAR = p.uid
|
|
189
|
+
WHERE p.searches >= 10
|
|
190
|
+
GROUP BY 1 ORDER BY 1;
|
|
191
|
+
-- read: treated − control ≈ +0.22-0.30; control ≈ <= 0.12
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
-- ── H9-booking-ttc ──────────────────────────────────────────
|
|
195
|
+
-- funnel-post scales Search to Book gaps: business ×0.74, budget/leisure
|
|
196
|
+
-- ×1.25, luxury untouched (v1.6 scopes the hook to Search to Book only).
|
|
197
|
+
-- CAUTION: cross-event TTC SQL here is the documented greedy-single-pass
|
|
198
|
+
-- limitation — it pairs searches/bookings across funnel instances and
|
|
199
|
+
-- buries the signal. The story asserts TTC through the Mixpanel-aligned
|
|
200
|
+
-- emulator (timeToConvert, 60h window = 48h generative × 1.25 max
|
|
201
|
+
-- stretch); trust the story verdict, not ad-hoc pair SQL.
|
|
202
|
+
SELECT u.customer_segment AS seg, count(*) AS booking_events
|
|
203
|
+
FROM ev e JOIN users u ON u.distinct_id::VARCHAR = e.uid
|
|
204
|
+
WHERE e.event = 'booking completed' GROUP BY 1 ORDER BY 1;
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
-- ── H10-hotel-view-magic ────────────────────────────────────
|
|
208
|
+
-- Sweet spot 5-10 hotel views → nightly_rate ×1.3 on all bookings; 11+
|
|
209
|
+
-- views → 35% of bookings dropped on/after day 60 (2026-03-02). Value
|
|
210
|
+
-- read restricted to the ADVANCE region where H2's ×0.8 is constant on
|
|
211
|
+
-- both arms and cancels.
|
|
212
|
+
SELECT CASE WHEN p.hv BETWEEN 5 AND 10 THEN 'sweet' WHEN p.hv < 5 THEN 'low' END AS arm,
|
|
213
|
+
count(DISTINCT p.uid) AS users, round(avg(e.nightly_rate), 1) AS avg_rate
|
|
214
|
+
FROM per_user p JOIN ev e ON e.uid = p.uid AND e.event = 'booking completed'
|
|
215
|
+
WHERE (p.hv <= 10) AND e.t < TIMESTAMP '2026-04-08 23:59:59'
|
|
216
|
+
GROUP BY 1 ORDER BY 1;
|
|
217
|
+
-- read: sweet/low avg_rate ≈ 1.3
|
|
218
|
+
|
|
219
|
+
-- volume read: hotel-view count is intrinsically coupled to activity, so
|
|
220
|
+
-- NO cross-arm level comparison works. The day-60 calendar edge makes it
|
|
221
|
+
-- a difference-in-differences: each arm's own after/before
|
|
222
|
+
-- bookings-per-session ratio cancels its activity composition; worldEvents
|
|
223
|
+
-- (summer sale ×2, hurricane ×0.2) are arm-invariant and cancel cross-arm.
|
|
224
|
+
-- Users born before day 45 only, so both arms have real before-exposure.
|
|
225
|
+
SELECT CASE WHEN p.hv BETWEEN 5 AND 10 THEN 'sweet' WHEN p.hv >= 11 THEN 'over' END AS arm,
|
|
226
|
+
CASE WHEN e.t >= TIMESTAMP '2026-03-02' THEN 'after' ELSE 'before' END AS period,
|
|
227
|
+
count(DISTINCT p.uid) AS users,
|
|
228
|
+
count(*) FILTER (WHERE e.event = 'booking completed') AS bookings,
|
|
229
|
+
count(*) FILTER (WHERE e.event = 'app session') AS sessions
|
|
230
|
+
FROM per_user p
|
|
231
|
+
JOIN first_ev fe ON fe.uid = p.uid AND fe.f < TIMESTAMP '2026-02-15'
|
|
232
|
+
JOIN ev e ON e.uid = p.uid
|
|
233
|
+
WHERE p.hv >= 5
|
|
234
|
+
GROUP BY 1, 2 ORDER BY 1, 2;
|
|
235
|
+
-- read: DiD = (over after/before bookings-per-session) ÷ (sweet after/before)
|
|
236
|
+
-- ≈ 0.65 (the keep rate)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* travel.verify.mjs — thin wrapper around the story runner.
|
|
4
|
+
*
|
|
5
|
+
* All verification logic lives in the `stories` export of ./travel.js;
|
|
6
|
+
* this script just streams the shards and delegates. It is equivalent to:
|
|
7
|
+
*
|
|
8
|
+
* node scripts/verify-stories.mjs dungeons/vertical/travel/travel.js --data-prefix verify-travel
|
|
9
|
+
*
|
|
10
|
+
* Generate first:
|
|
11
|
+
* node scripts/verify-runner.mjs dungeons/vertical/travel/travel.js verify-travel
|
|
12
|
+
* Run:
|
|
13
|
+
* node dungeons/vertical/travel/travel.verify.mjs [prefix] # default verify-travel
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import readline from 'node:readline';
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
|
|
21
|
+
import config, { stories } from './travel.js';
|
|
22
|
+
|
|
23
|
+
const PREFIX = process.argv[2] || 'verify-travel';
|
|
24
|
+
const prefixPath = path.join('data', PREFIX);
|
|
25
|
+
|
|
26
|
+
async function loadShards(suffix) {
|
|
27
|
+
// streaming load: full-fidelity EVENTS shards exceed the readFileSync cap
|
|
28
|
+
const dir = path.dirname(prefixPath), base = path.basename(prefixPath);
|
|
29
|
+
const out = [];
|
|
30
|
+
if (!fs.existsSync(dir)) return out;
|
|
31
|
+
for (const f of fs.readdirSync(dir).filter(f => f.startsWith(`${base}-${suffix}`) && f.endsWith('.json')).sort()) {
|
|
32
|
+
const rl = readline.createInterface({ input: fs.createReadStream(path.join(dir, f)), crlfDelay: Infinity });
|
|
33
|
+
for await (const line of rl) {
|
|
34
|
+
if (line.trim()) out.push(JSON.parse(line));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const events = await loadShards('EVENTS');
|
|
41
|
+
const profiles = await loadShards('USERS');
|
|
42
|
+
if (!events.length) {
|
|
43
|
+
console.error(`no shards at ${prefixPath}-EVENTS*.json — run: node scripts/verify-runner.mjs dungeons/vertical/travel/travel.js ${PREFIX}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
console.log(`travel — events=${events.length} users=${profiles.length} (${prefixPath})`);
|
|
47
|
+
|
|
48
|
+
const execFileP = promisify(execFile);
|
|
49
|
+
const runSql = async (sql) => {
|
|
50
|
+
const { stdout } = await execFileP('duckdb', ['-json', '-c', sql.replaceAll('{{PREFIX}}', prefixPath)], { maxBuffer: 64 * 1024 * 1024 });
|
|
51
|
+
return stdout.trim() ? JSON.parse(stdout) : [];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// funnels passed raw (unvalidated) — the H8/H9 emulator stories carry their
|
|
55
|
+
// own explicit conversion windows (72h Onboarding; 48h Search to Book
|
|
56
|
+
// generative window × the 1.25 budget/leisure stretch = 60h, covering the
|
|
57
|
+
// stretched support), so no funnel-default threading is needed
|
|
58
|
+
const results = await evaluateStories(stories, events, {
|
|
59
|
+
profiles,
|
|
60
|
+
funnels: config.funnels,
|
|
61
|
+
identityMap: buildIdentityMap(profiles),
|
|
62
|
+
runSql,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let worst = 'NAILED';
|
|
66
|
+
for (const r of results) {
|
|
67
|
+
console.log(`${r.verdict.padEnd(7)} ${r.id}`);
|
|
68
|
+
for (const a of r.assertions) console.log(` ${a.verdict.padEnd(7)} ${a.detail}`);
|
|
69
|
+
if (VERDICT_RANK[r.verdict] < VERDICT_RANK[worst]) worst = r.verdict;
|
|
70
|
+
}
|
|
71
|
+
console.log(`\nworst verdict: ${worst}`);
|
|
72
|
+
process.exit(VERDICT_RANK[worst] >= VERDICT_RANK.STRONG ? 0 : 1);
|
package/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import { makeMirror } from './lib/generators/mirror.js';
|
|
|
27
27
|
import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
|
|
28
28
|
|
|
29
29
|
// Utilities
|
|
30
|
-
import { initChance, initUserChance, resetUserChance, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
|
|
30
|
+
import { initChance, initUserChance, resetUserChance, resetValueCaches, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
|
|
31
31
|
import { runWithDataset } from './lib/utils/dataset-context.js';
|
|
32
32
|
|
|
33
33
|
// External dependencies
|
|
@@ -68,7 +68,7 @@ dayjs.extend(utc);
|
|
|
68
68
|
*
|
|
69
69
|
* @example
|
|
70
70
|
* // multiple dungeons
|
|
71
|
-
* const results = await DUNGEON_MASTER(['./dungeons/vertical/gaming.js', './dungeons/vertical/media.js']);
|
|
71
|
+
* const results = await DUNGEON_MASTER(['./dungeons/vertical/gaming/gaming.js', './dungeons/vertical/media/media.js']);
|
|
72
72
|
*
|
|
73
73
|
* @example
|
|
74
74
|
* // raw JS text
|
|
@@ -130,6 +130,10 @@ async function runDungeon(config) {
|
|
|
130
130
|
// Initialize seeded RNG BEFORE validation — config-validator captures a
|
|
131
131
|
// chance reference for default userProps (spiritAnimal). If we init after,
|
|
132
132
|
// run 1 binds an unseeded instance while run 2 binds a stale one → non-deterministic.
|
|
133
|
+
// v1.6.1: clear per-run winner/weighted-array caches unconditionally —
|
|
134
|
+
// a prior in-process run must never leak its winners into this one.
|
|
135
|
+
// (initChance also clears them, but only fires when a seed is set.)
|
|
136
|
+
resetValueCaches();
|
|
133
137
|
if (config.seed) {
|
|
134
138
|
initChance(config.seed);
|
|
135
139
|
}
|
|
@@ -568,7 +572,7 @@ function extractFileInfo(storage) {
|
|
|
568
572
|
/**
|
|
569
573
|
* Count profiles that would be pushed to Mixpanel (anonymous non-converters carry
|
|
570
574
|
* `_drop: true` and are skipped by mixpanel-sender). v1.5.1.
|
|
571
|
-
* @param {
|
|
575
|
+
* @param {Iterable<{ _drop?: boolean }> | null | undefined} profilesContainer
|
|
572
576
|
* @returns {number}
|
|
573
577
|
*/
|
|
574
578
|
function countProfilesPushed(profilesContainer) {
|
|
@@ -378,7 +378,10 @@ function normalizeExperiments(funnels, datasetEndUnix) {
|
|
|
378
378
|
const startDays = Number.isFinite(raw.startDaysBeforeEnd) && raw.startDaysBeforeEnd > 0
|
|
379
379
|
? raw.startDaysBeforeEnd : 0;
|
|
380
380
|
const startUnix = startDays > 0 ? datasetEndUnix - startDays * 86400 : null;
|
|
381
|
-
|
|
381
|
+
// Sticky bucketing defaults true — the per-user hash was the only pre-1.6
|
|
382
|
+
// behavior, so existing dungeons stay byte-identical.
|
|
383
|
+
const sticky = raw.sticky === undefined ? true : !!raw.sticky;
|
|
384
|
+
f._experiment = { name, variants, startUnix, sticky };
|
|
382
385
|
}
|
|
383
386
|
}
|
|
384
387
|
|
|
@@ -568,7 +571,25 @@ export function validateDungeonConfig(config) {
|
|
|
568
571
|
// only avgEventsPerUserPerDay would never trigger auto-batch.
|
|
569
572
|
if (numEvents >= 2_000_000 && config.batchSize === undefined) {
|
|
570
573
|
batchSize = 1_000_000;
|
|
571
|
-
|
|
574
|
+
// Width-aware shrink: wide event schemas (many props/event) cost far more
|
|
575
|
+
// live memory per buffered event, so a 1M-event batch can exceed the default
|
|
576
|
+
// ~4GB heap and OOM mid-generation (batch mode is supposed to PREVENT that).
|
|
577
|
+
// Estimate a volume-weighted props/event and cap the batch so its peak buffer
|
|
578
|
+
// stays ~1.4GB. Narrow schemas keep the full 1M batch (no behavior change).
|
|
579
|
+
const evs = Array.isArray(config.events) ? config.events : [];
|
|
580
|
+
const superN = config.superProps ? Object.keys(config.superProps).length : 0;
|
|
581
|
+
let wSum = 0, wpSum = 0;
|
|
582
|
+
for (const e of evs) {
|
|
583
|
+
const w = (e && typeof e.weight === "number" && e.weight > 0) ? e.weight : 1;
|
|
584
|
+
const pN = (e && e.properties && typeof e.properties === "object") ? Object.keys(e.properties).length : 0;
|
|
585
|
+
wSum += w; wpSum += w * pN;
|
|
586
|
+
}
|
|
587
|
+
const avgOwnProps = wSum > 0 ? wpSum / wSum : 0;
|
|
588
|
+
const avgPropsPerEvent = avgOwnProps + superN + 10; // + core/identity/location/device estimate
|
|
589
|
+
const estBytesPerEvent = Math.max(400, Math.round(avgPropsPerEvent * 150));
|
|
590
|
+
const memSafeBatch = Math.max(100_000, Math.floor(1_400_000_000 / estBytesPerEvent));
|
|
591
|
+
if (avgPropsPerEvent > 18 && memSafeBatch < batchSize) batchSize = memSafeBatch;
|
|
592
|
+
if (verbose) console.warn(`⚠️ Auto-enabling batch mode: numEvents (${numEvents.toLocaleString()}) >= 2M (~${Math.round(avgPropsPerEvent)} props/event). Using batchSize of ${batchSize.toLocaleString()}.`);
|
|
572
593
|
}
|
|
573
594
|
|
|
574
595
|
// Resolve soup presets (intra-week / intra-day shape — must happen after numDays is computed)
|
|
@@ -1067,6 +1088,12 @@ function transformSCDPropsWithoutCredentials(config) {
|
|
|
1067
1088
|
|
|
1068
1089
|
// ── Advanced Feature Validation Functions ──
|
|
1069
1090
|
|
|
1091
|
+
// P2.5 (v1.6): churnRate / activeWindow / soupOverride are declared Persona
|
|
1092
|
+
// surface but unimplemented — nothing in lib/ reads them after validation.
|
|
1093
|
+
// Deprecated no-ops; warn once per process when a dungeon sets any of them.
|
|
1094
|
+
const DEAD_PERSONA_FIELDS = ['churnRate', 'activeWindow', 'soupOverride'];
|
|
1095
|
+
let warnedDeadPersonaFields = false;
|
|
1096
|
+
|
|
1070
1097
|
/**
|
|
1071
1098
|
* Validates persona configurations
|
|
1072
1099
|
* @param {import('../../types').Persona[]} personas
|
|
@@ -1074,14 +1101,22 @@ function transformSCDPropsWithoutCredentials(config) {
|
|
|
1074
1101
|
*/
|
|
1075
1102
|
function validatePersonas(personas) {
|
|
1076
1103
|
if (!Array.isArray(personas) || personas.length === 0) return null;
|
|
1104
|
+
const deadFieldsSet = new Set();
|
|
1077
1105
|
for (const p of personas) {
|
|
1078
1106
|
if (!p.name) throw new Error('Each persona must have a name');
|
|
1079
1107
|
if (typeof p.weight !== 'number' || p.weight <= 0) throw new Error(`Persona "${p.name}" must have a positive weight`);
|
|
1108
|
+
for (const field of DEAD_PERSONA_FIELDS) {
|
|
1109
|
+
if (p[field] !== undefined) deadFieldsSet.add(field);
|
|
1110
|
+
}
|
|
1080
1111
|
if (p.eventMultiplier === undefined) p.eventMultiplier = 1.0;
|
|
1081
1112
|
if (p.conversionModifier === undefined) p.conversionModifier = 1.0;
|
|
1082
1113
|
if (p.churnRate === undefined) p.churnRate = 0;
|
|
1083
1114
|
if (p.properties === undefined) p.properties = {};
|
|
1084
1115
|
}
|
|
1116
|
+
if (deadFieldsSet.size > 0 && !warnedDeadPersonaFields) {
|
|
1117
|
+
warnedDeadPersonaFields = true;
|
|
1118
|
+
console.warn(`[dungeon-master] Persona field(s) ${[...deadFieldsSet].join(', ')} are deprecated and unimplemented — they have no effect on generation. This warning fires once per process.`);
|
|
1119
|
+
}
|
|
1085
1120
|
return personas;
|
|
1086
1121
|
}
|
|
1087
1122
|
|
package/lib/core/context.js
CHANGED
|
@@ -109,10 +109,12 @@ function createProgressReporter(config) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
try {
|
|
112
|
-
|
|
112
|
+
// onProgress is declared `=> void`, but async callbacks returning a
|
|
113
|
+
// promise are supported — duck-type the thenable at runtime.
|
|
114
|
+
const result = /** @type {Promise<unknown> | undefined} */ (/** @type {unknown} */ (callback(update)));
|
|
113
115
|
totalUpdates++;
|
|
114
116
|
if (result && typeof result.then === 'function') {
|
|
115
|
-
result.then(undefined, (/** @type {
|
|
117
|
+
result.then(undefined, (/** @type {Error} */ err) => {
|
|
116
118
|
errorCount++;
|
|
117
119
|
if (verbose) console.warn(`[dungeon-master] onProgress async error (${errorCount}/3): ${err?.message || err}`);
|
|
118
120
|
if (errorCount >= 3) disabled = true;
|
|
@@ -11,8 +11,11 @@ import Chance from 'chance';
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Detect what kind of input was passed and normalize it
|
|
14
|
-
* @param {
|
|
15
|
-
* @returns {{ type: 'object'
|
|
14
|
+
* @param {unknown} input - The raw input to DUNGEON_MASTER
|
|
15
|
+
* @returns {{ type: 'object', value: import('../../types').Dungeon }
|
|
16
|
+
* | { type: 'file', value: string }
|
|
17
|
+
* | { type: 'files', value: string[] }
|
|
18
|
+
* | { type: 'text', value: string }}
|
|
16
19
|
*/
|
|
17
20
|
export function detectInputType(input) {
|
|
18
21
|
if (input === null || input === undefined) {
|
|
@@ -29,12 +32,12 @@ export function detectInputType(input) {
|
|
|
29
32
|
throw new Error(`dungeon-master: array items must be file path strings. got ${typeof item}.`);
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
|
-
return { type: 'files', value: input };
|
|
35
|
+
return { type: 'files', value: /** @type {string[]} */ (input) };
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
// Plain config object
|
|
36
39
|
if (typeof input === 'object') {
|
|
37
|
-
return { type: 'object', value: input };
|
|
40
|
+
return { type: 'object', value: /** @type {import('../../types').Dungeon} */ (input) };
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
// String: file path or raw JS text
|
|
@@ -128,7 +131,7 @@ export function parseJSONDungeon(json) {
|
|
|
128
131
|
const hooksString = json.hooks || null;
|
|
129
132
|
|
|
130
133
|
// Reconstruct the config from the JSON schema
|
|
131
|
-
const config = reviveJSONConfig(schema);
|
|
134
|
+
const config = /** @type {Record<string, unknown>} */ (reviveJSONConfig(schema));
|
|
132
135
|
|
|
133
136
|
// Attach hook if present
|
|
134
137
|
if (hooksString && typeof hooksString === 'string') {
|
|
@@ -136,14 +139,14 @@ export function parseJSONDungeon(json) {
|
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
validateDungeonShape(config);
|
|
139
|
-
return config;
|
|
142
|
+
return /** @type {import('../../types').Dungeon} */ (config);
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
/**
|
|
143
146
|
* Revive JSON config by converting function-call objects back to functions
|
|
144
147
|
* JSON dungeons store functions as { functionName: "...", body: "...", args: [...] }
|
|
145
|
-
* @param {
|
|
146
|
-
* @returns {
|
|
148
|
+
* @param {unknown} value
|
|
149
|
+
* @returns {unknown}
|
|
147
150
|
*/
|
|
148
151
|
function reviveJSONConfig(value) {
|
|
149
152
|
if (value === null || value === undefined) return value;
|
|
@@ -152,8 +155,8 @@ function reviveJSONConfig(value) {
|
|
|
152
155
|
if (typeof value !== 'object') return value;
|
|
153
156
|
|
|
154
157
|
// Function-call objects → actual functions
|
|
155
|
-
if (value.functionName) {
|
|
156
|
-
return reviveFunctionObject(value);
|
|
158
|
+
if ('functionName' in value && value.functionName) {
|
|
159
|
+
return reviveFunctionObject(/** @type {{ functionName: string, body?: string, args?: unknown[] }} */ (value));
|
|
157
160
|
}
|
|
158
161
|
|
|
159
162
|
// Arrays
|
|
@@ -162,6 +165,7 @@ function reviveJSONConfig(value) {
|
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
// Objects
|
|
168
|
+
/** @type {Record<string, unknown>} */
|
|
165
169
|
const result = {};
|
|
166
170
|
for (const [key, val] of Object.entries(value)) {
|
|
167
171
|
result[key] = reviveJSONConfig(val);
|
|
@@ -178,8 +182,8 @@ function reviveJSONConfig(value) {
|
|
|
178
182
|
* mostChosenIndex, etc.) that don't exist at revival time. These will fail to eval
|
|
179
183
|
* and fall back to null, which is handled gracefully by the config validator.
|
|
180
184
|
*
|
|
181
|
-
* @param {{ functionName: string, body?: string, args?:
|
|
182
|
-
* @returns {Function|
|
|
185
|
+
* @param {{ functionName: string, body?: string, args?: unknown[] }} obj
|
|
186
|
+
* @returns {Function|unknown[]|null}
|
|
183
187
|
*/
|
|
184
188
|
function reviveFunctionObject(obj) {
|
|
185
189
|
const { functionName, body, args = [] } = obj;
|
|
@@ -257,13 +261,14 @@ export async function loadFromText(code) {
|
|
|
257
261
|
/**
|
|
258
262
|
* Validate that a config object has the minimum shape of a dungeon
|
|
259
263
|
* This is a pre-flight check before passing to the full config validator
|
|
260
|
-
* @param {
|
|
264
|
+
* @param {unknown} input
|
|
261
265
|
* @throws {Error} if the config is clearly not a valid dungeon
|
|
262
266
|
*/
|
|
263
|
-
export function validateDungeonShape(
|
|
264
|
-
if (!
|
|
267
|
+
export function validateDungeonShape(input) {
|
|
268
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
265
269
|
throw new Error('dungeon-master: config must be a plain object.');
|
|
266
270
|
}
|
|
271
|
+
const config = /** @type {Record<string, unknown>} */ (input);
|
|
267
272
|
|
|
268
273
|
// Must have at least one recognizable dungeon property
|
|
269
274
|
const dungeonKeys = [
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* is sampled at serialization time (when its closure is still live) and the inferred
|
|
17
17
|
* output type is recorded as `dataType` (e.g. "number", "string", "boolean", "number[]").
|
|
18
18
|
* So a field that loses its `weighNumRange(1,10)` generator still records `dataType: "number"`.
|
|
19
|
+
*
|
|
20
|
+
* A dungeon's `stories` NAMED export (v1.6 story layer) is NOT carried into the JSON —
|
|
21
|
+
* only the default-export config is serialized. The .js file remains the source of
|
|
22
|
+
* record for stories; verify-stories.mjs reads them from the module directly.
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
25
|
import { detectInputType, loadFromFile, loadFromText } from './dungeon-loader.js';
|
|
@@ -75,18 +79,31 @@ function serializeConfig(config, includeCredentials) {
|
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
return {
|
|
78
|
-
|
|
82
|
+
// cleanConfig is an object, so convertToJSON returns the map arm of JSONReady.
|
|
83
|
+
schema: /** @type {Record<string, unknown>} */ (convertToJSON(cleanConfig)),
|
|
79
84
|
hooks,
|
|
80
85
|
timestamp: new Date().toISOString(),
|
|
81
86
|
version: '4.0'
|
|
82
87
|
};
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A JSON-serializable value: what `convertToJSON` produces. Functions become
|
|
92
|
+
* `SerializedFunction` markers; everything else is plain JSON.
|
|
93
|
+
* (Recursion goes through the named array/map aliases — TS rejects a JSDoc
|
|
94
|
+
* type alias that references itself directly in its own union.)
|
|
95
|
+
* @typedef {null | boolean | number | string
|
|
96
|
+
* | import('../../types').SerializedFunction
|
|
97
|
+
* | JSONReadyArray | JSONReadyMap} JSONReady
|
|
98
|
+
* @typedef {JSONReady[]} JSONReadyArray
|
|
99
|
+
* @typedef {{ [key: string]: JSONReady }} JSONReadyMap
|
|
100
|
+
*/
|
|
101
|
+
|
|
85
102
|
/**
|
|
86
103
|
* Convert a JavaScript value to a JSON-serializable form, turning functions into
|
|
87
104
|
* `{ functionName, body | args }` objects that `reviveJSONConfig` can revive.
|
|
88
|
-
* @param {
|
|
89
|
-
* @returns {
|
|
105
|
+
* @param {unknown} value
|
|
106
|
+
* @returns {JSONReady}
|
|
90
107
|
*/
|
|
91
108
|
export function convertToJSON(value) {
|
|
92
109
|
// Null/undefined
|
|
@@ -111,6 +128,7 @@ export function convertToJSON(value) {
|
|
|
111
128
|
|
|
112
129
|
// Objects
|
|
113
130
|
if (typeof value === 'object') {
|
|
131
|
+
/** @type {{ [key: string]: JSONReady }} */
|
|
114
132
|
const result = {};
|
|
115
133
|
for (const [key, val] of Object.entries(value)) {
|
|
116
134
|
result[key] = convertToJSON(val);
|
|
@@ -202,7 +220,7 @@ function inferDataType(func) {
|
|
|
202
220
|
/**
|
|
203
221
|
* Map a sampled value to a type label: "number" | "string" | "boolean" | "date" |
|
|
204
222
|
* "object" | "<elementType>[]" | "array". Returns undefined for null/undefined/functions.
|
|
205
|
-
* @param {
|
|
223
|
+
* @param {unknown} value
|
|
206
224
|
* @returns {string | undefined}
|
|
207
225
|
*/
|
|
208
226
|
function classifyValue(value) {
|
package/lib/core/storage.js
CHANGED
|
@@ -245,9 +245,9 @@ export async function createHookArray(arr = [], opts) {
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
// Enhance the array with our methods
|
|
249
|
-
|
|
250
|
-
const enrichedArray = /** @type {
|
|
248
|
+
// Enhance the array with our methods (plain array → HookedArray, methods
|
|
249
|
+
// attached just below — hence the through-unknown assertion)
|
|
250
|
+
const enrichedArray = /** @type {HookedArray} */ (/** @type {unknown} */ (arr));
|
|
251
251
|
enrichedArray.hookPush = transformThenPush;
|
|
252
252
|
enrichedArray.flush = flush;
|
|
253
253
|
enrichedArray.getWriteDir = getWriteDir;
|