@ak--47/dungeon-master 1.0.0 → 1.1.0

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.
Files changed (103) hide show
  1. package/README.md +7 -7
  2. package/dungeons/technical/ad-spend-schema.json +128 -0
  3. package/dungeons/technical/ad-spend.js +116 -0
  4. package/dungeons/technical/anonymous-users-schema.json +91 -0
  5. package/dungeons/technical/anonymous-users.js +102 -0
  6. package/dungeons/technical/array-of-object-lookup-schema.json +191 -0
  7. package/dungeons/{array-of-object-lookup.js → technical/array-of-object-lookup.js} +11 -4
  8. package/dungeons/technical/experiments-schema.json +197 -0
  9. package/dungeons/technical/experiments.js +168 -0
  10. package/dungeons/technical/foobar-schema.json +362 -0
  11. package/dungeons/{foobar.js → technical/foobar.js} +2 -2
  12. package/dungeons/technical/group-analytics-schema.json +149 -0
  13. package/dungeons/technical/group-analytics.js +151 -0
  14. package/dungeons/technical/mirror-strategies-schema.json +84 -0
  15. package/dungeons/technical/mirror-strategies.js +126 -0
  16. package/dungeons/technical/nested-objects-schema.json +145 -0
  17. package/dungeons/technical/nested-objects.js +207 -0
  18. package/dungeons/technical/retention-cadence-schema.json +37 -0
  19. package/dungeons/{retention-cadence.js → technical/retention-cadence.js} +1 -1
  20. package/dungeons/technical/sanity-schema.json +185 -0
  21. package/dungeons/{sanity.js → technical/sanity.js} +3 -2
  22. package/dungeons/technical/scale-test-schema.json +70 -0
  23. package/dungeons/technical/scale-test.js +90 -0
  24. package/dungeons/technical/scd-schema.json +270 -0
  25. package/dungeons/{scd.js → technical/scd.js} +19 -10
  26. package/dungeons/technical/simple-schema.json +278 -0
  27. package/dungeons/{simple.js → technical/simple.js} +6 -6
  28. package/dungeons/technical/simplest-schema.json +635 -0
  29. package/dungeons/{simplest.js → technical/simplest.js} +6 -6
  30. package/dungeons/technical/text-generation-schema.json +1059 -0
  31. package/dungeons/{text-generation.js → technical/text-generation.js} +23 -9
  32. package/dungeons/vertical/community-schema.json +557 -0
  33. package/dungeons/vertical/community.js +788 -0
  34. package/dungeons/vertical/devtools-schema.json +580 -0
  35. package/dungeons/vertical/devtools.js +837 -0
  36. package/dungeons/vertical/ecommerce-schema.json +586 -0
  37. package/dungeons/{ecommerce.js → vertical/ecommerce.js} +34 -3
  38. package/dungeons/vertical/education-schema.json +697 -0
  39. package/dungeons/{education.js → vertical/education.js} +54 -36
  40. package/dungeons/vertical/fintech-schema.json +563 -0
  41. package/dungeons/{fintech.js → vertical/fintech.js} +91 -53
  42. package/dungeons/vertical/fitness-schema.json +508 -0
  43. package/dungeons/vertical/fitness.js +802 -0
  44. package/dungeons/vertical/food-delivery-schema.json +678 -0
  45. package/dungeons/{food.js → vertical/food-delivery.js} +51 -23
  46. package/dungeons/vertical/gaming-schema.json +394 -0
  47. package/dungeons/{gaming.js → vertical/gaming.js} +9 -7
  48. package/dungeons/vertical/healthcare-schema.json +527 -0
  49. package/dungeons/vertical/healthcare.js +769 -0
  50. package/dungeons/vertical/insurance-application-schema.json +466 -0
  51. package/dungeons/{insurance-application.js → vertical/insurance-application.js} +33 -29
  52. package/dungeons/vertical/logistics-schema.json +552 -0
  53. package/dungeons/vertical/logistics.js +793 -0
  54. package/dungeons/vertical/marketplace-schema.json +511 -0
  55. package/dungeons/vertical/marketplace.js +844 -0
  56. package/dungeons/vertical/media-schema.json +787 -0
  57. package/dungeons/{media.js → vertical/media.js} +94 -16
  58. package/dungeons/vertical/rpg-schema.json +654 -0
  59. package/dungeons/{rpg.js → vertical/rpg.js} +78 -57
  60. package/dungeons/vertical/sass-schema.json +653 -0
  61. package/dungeons/{sass.js → vertical/sass.js} +49 -23
  62. package/dungeons/vertical/social-schema.json +552 -0
  63. package/dungeons/{social.js → vertical/social.js} +60 -14
  64. package/dungeons/vertical/travel-schema.json +558 -0
  65. package/dungeons/vertical/travel.js +736 -0
  66. package/index.js +19 -15
  67. package/lib/core/config-validator.js +286 -1
  68. package/lib/core/context.js +7 -5
  69. package/lib/generators/adspend.js +3 -3
  70. package/lib/generators/events.js +141 -14
  71. package/lib/generators/funnels.js +67 -7
  72. package/lib/generators/text.js +2 -2
  73. package/lib/orchestrators/user-loop.js +441 -22
  74. package/lib/templates/soup-presets.js +2 -2
  75. package/lib/utils/utils.js +4 -5
  76. package/package.json +4 -3
  77. package/scripts/dungeon-to-json.mjs +163 -0
  78. package/scripts/extract-dungeon-schema.mjs +297 -0
  79. package/scripts/json-to-dungeon.mjs +200 -0
  80. package/scripts/run-dungeon.mjs +76 -0
  81. package/scripts/run-many.mjs +118 -0
  82. package/scripts/verify-runner.mjs +42 -0
  83. package/types.d.ts +313 -1
  84. package/dungeons/array-of-object-lookup-schema.json +0 -327
  85. package/dungeons/ecommerce-schema.json +0 -462
  86. package/dungeons/education-schema.json +0 -2409
  87. package/dungeons/fintech-schema.json +0 -14034
  88. package/dungeons/foobar-schema.json +0 -403
  89. package/dungeons/food-delivery-schema.json +0 -192
  90. package/dungeons/food-delivery.js +0 -602
  91. package/dungeons/food-schema.json +0 -1152
  92. package/dungeons/gaming-schema.json +0 -1270
  93. package/dungeons/insurance-application-schema.json +0 -204
  94. package/dungeons/media-schema.json +0 -906
  95. package/dungeons/retention-cadence-schema.json +0 -78
  96. package/dungeons/rpg-schema.json +0 -4526
  97. package/dungeons/sanity-schema.json +0 -255
  98. package/dungeons/sass-schema.json +0 -1291
  99. package/dungeons/scd-schema.json +0 -919
  100. package/dungeons/simple-schema.json +0 -608
  101. package/dungeons/simplest-schema.json +0 -1418
  102. package/dungeons/social-schema.json +0 -1118
  103. package/dungeons/text-generation-schema.json +0 -3096
@@ -62,8 +62,8 @@ export const SOUP_PRESETS = {
62
62
  * Clear peaks and valleys, dramatic variation. Fewer Gaussian clusters + tight deviation.
63
63
  */
64
64
  spiky: {
65
- peaks: (numDays) => Math.max(5, Math.ceil(numDays / 10)),
66
- deviation: 3.5,
65
+ peaks: (numDays) => Math.max(4, Math.ceil(numDays / 15)),
66
+ deviation: 5,
67
67
  mean: 0,
68
68
  dayOfWeekWeights: REAL_DOW,
69
69
  hourOfDayWeights: REAL_HOD,
@@ -44,11 +44,10 @@ RNG
44
44
  * @returns {Chance}
45
45
  */
46
46
  function initChance(seed) {
47
- if (process.env.SEED) seed = process.env.SEED; // Override seed with environment variable if available
48
- if (!chanceInitialized) {
49
- globalChance = new Chance(seed);
50
- chanceInitialized = true;
51
- }
47
+ // Use env SEED only as fallback when no seed is explicitly passed
48
+ if (!seed && process.env.SEED) seed = process.env.SEED;
49
+ globalChance = new Chance(seed);
50
+ chanceInitialized = true;
52
51
  return globalChance;
53
52
  }
54
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -19,6 +19,7 @@
19
19
  "lib/",
20
20
  "dungeons/",
21
21
  "!dungeons/customers/",
22
+ "scripts/",
22
23
  "package.json",
23
24
  "README.md"
24
25
  ],
@@ -32,10 +33,10 @@
32
33
  "typecheck": "tsc --noEmit",
33
34
  "dev": "nodemon scratch.mjs --ignore ./data/*",
34
35
  "prune": "rm -f ./data/* && rm -f ./tmp/* && rm -f vscode-profile-*",
35
- "deps": "./scripts/update-deps.sh",
36
36
  "dungeon:run": "node ./scripts/run-dungeon.mjs",
37
37
  "dungeon:to-json": "node ./scripts/dungeon-to-json.mjs",
38
- "dungeon:from-json": "node ./scripts/json-to-dungeon.mjs"
38
+ "dungeon:from-json": "node ./scripts/json-to-dungeon.mjs",
39
+ "dungeon:schema": "node ./scripts/extract-dungeon-schema.mjs"
39
40
  },
40
41
  "repository": {
41
42
  "type": "git",
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Converts a JavaScript dungeon file to JSON that can be loaded into the UI
5
+ * Usage: node scripts/dungeon-to-json.js <input.js> [output-name]
6
+ */
7
+
8
+ import { writeFileSync } from 'fs';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ // Get command line arguments
15
+ const args = process.argv.slice(2);
16
+
17
+ if (args.length === 0) {
18
+ console.error('❌ Error: Please provide an input JavaScript file');
19
+ console.log('Usage: node scripts/dungeon-to-json.js <input.js> [output-name]');
20
+ console.log('Example: node scripts/dungeon-to-json.js ./dungeons/my-dungeon.js my-dungeon');
21
+ process.exit(1);
22
+ }
23
+
24
+ const inputPath = path.resolve(args[0]);
25
+ const outputName = args[1] || path.basename(inputPath, '.js');
26
+
27
+ try {
28
+ // Import the JavaScript module
29
+ console.log(`📖 Loading ${inputPath}...`);
30
+ const module = await import(`file://${inputPath}`);
31
+ const config = module.default;
32
+
33
+ if (!config) {
34
+ throw new Error('No default export found in the module');
35
+ }
36
+
37
+ // Extract hooks if they exist
38
+ const hooksFunction = config.hook;
39
+ const hooksString = hooksFunction ? hooksFunction.toString() : null;
40
+
41
+ // Create a clean config without the hook function
42
+ const cleanConfig = { ...config };
43
+ delete cleanConfig.hook;
44
+
45
+ // Convert to JSON-serializable format
46
+ console.log('🔄 Converting to JSON format...');
47
+ const schema = convertToJSON(cleanConfig);
48
+
49
+ // Create the dungeon state object (same format as UI saves)
50
+ const dungeonState = {
51
+ schema,
52
+ hooks: hooksString,
53
+ timestamp: new Date().toISOString(),
54
+ version: '4.0'
55
+ };
56
+
57
+ // Write to JSON file
58
+ const outputPath = path.join(path.dirname(inputPath), `${outputName}.json`);
59
+ writeFileSync(outputPath, JSON.stringify(dungeonState, null, 2), 'utf-8');
60
+
61
+ console.log(`✅ Successfully created ${outputPath}`);
62
+ console.log(`\n💡 You can now load this file in the dungeon-master UI:`);
63
+ console.log(` Click "Load" and select ${outputName}.json`);
64
+
65
+ } catch (error) {
66
+ console.error('❌ Error:', error.message);
67
+ console.error(error.stack);
68
+ process.exit(1);
69
+ }
70
+
71
+ /**
72
+ * Convert JavaScript config to JSON-serializable format
73
+ * Detects functions and converts them to object representation
74
+ */
75
+ function convertToJSON(value) {
76
+ // Null/undefined
77
+ if (value === null || value === undefined) {
78
+ return null;
79
+ }
80
+
81
+ // Primitives
82
+ if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
83
+ return value;
84
+ }
85
+
86
+ // Functions - convert to object representation
87
+ if (typeof value === 'function') {
88
+ return convertFunctionToObject(value);
89
+ }
90
+
91
+ // Arrays
92
+ if (Array.isArray(value)) {
93
+ return value.map(item => convertToJSON(item));
94
+ }
95
+
96
+ // Objects
97
+ if (typeof value === 'object') {
98
+ const result = {};
99
+ for (const [key, val] of Object.entries(value)) {
100
+ result[key] = convertToJSON(val);
101
+ }
102
+ return result;
103
+ }
104
+
105
+ // Fallback
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Convert a function to its object representation
111
+ * Tries to detect the function type and extract parameters
112
+ */
113
+ function convertFunctionToObject(func) {
114
+ const funcString = func.toString();
115
+
116
+ // Arrow function
117
+ if (funcString.startsWith('(') || funcString.startsWith('_') || funcString.includes('=>')) {
118
+ return {
119
+ functionName: 'arrow',
120
+ body: funcString
121
+ };
122
+ }
123
+
124
+ // Bound chance methods (e.g., chance.name.bind(chance))
125
+ if (funcString.includes('.bind(')) {
126
+ const match = funcString.match(/chance\.(\w+)\.bind/);
127
+ if (match) {
128
+ return {
129
+ functionName: `chance.${match[1]}`,
130
+ args: []
131
+ };
132
+ }
133
+ }
134
+
135
+ // Try to detect common utility functions
136
+ // This is a best-effort approach - some complex functions might not be detected
137
+ const commonFunctions = [
138
+ 'weighNumRange',
139
+ 'weighArray',
140
+ 'weighChoices',
141
+ 'pickAWinner',
142
+ 'date',
143
+ 'integer',
144
+ 'uid',
145
+ 'comma'
146
+ ];
147
+
148
+ for (const fnName of commonFunctions) {
149
+ if (funcString.includes(fnName)) {
150
+ // Extract args (this is simplified - real parsing would be more complex)
151
+ return {
152
+ functionName: fnName,
153
+ args: [] // Args would need to be extracted, but that's complex
154
+ };
155
+ }
156
+ }
157
+
158
+ // Generic function - just store as arrow function
159
+ return {
160
+ functionName: 'arrow',
161
+ body: funcString
162
+ };
163
+ }
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Extracts an abbreviated schema from a JavaScript dungeon file.
5
+ *
6
+ * Output contains only the event/property relationships, superProps, userProps,
7
+ * and funnels — no hooks, no config flags, no functions.
8
+ *
9
+ * Property values are simplified:
10
+ * - Plain arrays → deduplicated
11
+ * - weighNumRange(min,max) arrays → { "$range": [min, max] }
12
+ * - Functions (pickAWinner, weighChoices, etc.) → sampled and deduplicated
13
+ * - High-cardinality / complex values → omitted
14
+ *
15
+ * Usage:
16
+ * node scripts/extract-dungeon-schema.mjs <input.js> [output.json]
17
+ * node scripts/extract-dungeon-schema.mjs dungeons/vertical/*.js # batch mode
18
+ */
19
+
20
+ import { writeFileSync } from 'fs';
21
+ import path from 'path';
22
+ import { fileURLToPath } from 'url';
23
+ import { initChance } from '../lib/utils/utils.js';
24
+
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+ const args = process.argv.slice(2);
27
+
28
+ if (args.length === 0) {
29
+ console.error('Usage: node scripts/extract-dungeon-schema.mjs <dungeon.js> [output.json]');
30
+ console.error(' node scripts/extract-dungeon-schema.mjs dungeons/vertical/*.js');
31
+ process.exit(1);
32
+ }
33
+
34
+ // Batch mode: multiple files
35
+ if (args.length > 2 || (args.length > 1 && args[1].endsWith('.js'))) {
36
+ for (const file of args) {
37
+ if (!file.endsWith('.js') && !file.endsWith('.mjs')) continue;
38
+ try {
39
+ await processOne(file);
40
+ } catch (e) {
41
+ console.error(` ❌ ${path.basename(file)}: ${e.message}`);
42
+ }
43
+ }
44
+ } else {
45
+ await processOne(args[0], args[1]);
46
+ }
47
+
48
+ async function processOne(inputFile, outputFile) {
49
+ const inputPath = path.resolve(inputFile);
50
+ const baseName = path.basename(inputPath, '.js');
51
+ const outputPath = outputFile
52
+ ? path.resolve(outputFile)
53
+ : path.join(path.dirname(inputPath), `${baseName}-schema.json`);
54
+
55
+ // Seed RNG for deterministic sampling of function-based properties
56
+ initChance('schema-extraction');
57
+
58
+ const mod = await import(`file://${inputPath}`);
59
+ const config = mod.default;
60
+ if (!config) throw new Error('No default export');
61
+
62
+ const schema = {};
63
+
64
+ // Events
65
+ if (config.events?.length) {
66
+ schema.events = config.events.map(evt => {
67
+ const entry = { event: evt.event };
68
+ if (evt.weight && evt.weight !== 1) entry.weight = evt.weight;
69
+ if (evt.isFirstEvent) entry.isFirstEvent = true;
70
+ if (evt.properties && Object.keys(evt.properties).length) {
71
+ const props = extractProps(evt.properties);
72
+ if (Object.keys(props).length) entry.properties = props;
73
+ }
74
+ return entry;
75
+ });
76
+ }
77
+
78
+ // Super props
79
+ if (config.superProps && Object.keys(config.superProps).length) {
80
+ const sp = extractProps(config.superProps);
81
+ if (Object.keys(sp).length) schema.superProps = sp;
82
+ }
83
+
84
+ // User props
85
+ if (config.userProps && Object.keys(config.userProps).length) {
86
+ const up = extractProps(config.userProps);
87
+ if (Object.keys(up).length) schema.userProps = up;
88
+ }
89
+
90
+ // Funnels
91
+ if (config.funnels?.length) {
92
+ schema.funnels = config.funnels.map(f => {
93
+ const entry = { sequence: f.sequence };
94
+ if (f.isFirstFunnel) entry.isFirstFunnel = true;
95
+ if (f.conversionRate !== undefined) entry.conversionRate = f.conversionRate;
96
+ if (f.timeToConvert !== undefined) entry.timeToConvert = f.timeToConvert;
97
+ if (f.order && f.order !== 'sequential') entry.order = f.order;
98
+ if (f.weight && f.weight !== 1) entry.weight = f.weight;
99
+ if (f.requireRepeats) entry.requireRepeats = true;
100
+ if (f.experiment) entry.experiment = true;
101
+ if (f.props && Object.keys(f.props).length) {
102
+ const fp = extractProps(f.props);
103
+ if (Object.keys(fp).length) entry.props = fp;
104
+ }
105
+ return entry;
106
+ });
107
+ }
108
+
109
+ writeFileSync(outputPath, JSON.stringify(schema, null, 2) + '\n', 'utf-8');
110
+ console.log(` ✅ ${baseName}-schema.json`);
111
+ }
112
+
113
+ // ── Property extraction ──────────────────────────────────────────────
114
+
115
+ /**
116
+ * Extracts a simplified property map from a dungeon properties object.
117
+ * Returns { key: simplifiedValue } for each property.
118
+ * Keys are always preserved — complex values get a type hint instead of being omitted.
119
+ */
120
+ function extractProps(propsObj) {
121
+ const result = {};
122
+ for (const [key, value] of Object.entries(propsObj)) {
123
+ const simplified = simplifyValue(value);
124
+ if (simplified !== null) {
125
+ result[key] = simplified;
126
+ } else {
127
+ // Always keep the key — use a type hint for unresolvable values
128
+ result[key] = inferTypeHint(value);
129
+ }
130
+ }
131
+ return result;
132
+ }
133
+
134
+ /**
135
+ * Infer a type hint for a value we can't fully resolve.
136
+ * Tries calling the function first; falls back to string analysis.
137
+ */
138
+ function inferTypeHint(value) {
139
+ if (typeof value === 'function') {
140
+ // Try calling to detect actual output type
141
+ try {
142
+ const result = value();
143
+ if (typeof result === 'function') {
144
+ try {
145
+ const inner = result();
146
+ if (Array.isArray(inner)) {
147
+ // Drill through nested arrays to find the first object
148
+ let sample = inner;
149
+ while (Array.isArray(sample) && sample.length > 0 && Array.isArray(sample[0])) {
150
+ sample = sample[0];
151
+ }
152
+ if (sample.length > 0 && typeof sample[0] === 'object' && sample[0] !== null && !Array.isArray(sample[0])) {
153
+ return { "$type": "object[]", "$keys": Object.keys(sample[0]) };
154
+ }
155
+ return { "$type": "array" };
156
+ }
157
+ return { "$type": typeof inner };
158
+ } catch { return { "$type": "function" }; }
159
+ }
160
+ if (typeof result === 'string') return { "$type": "string" };
161
+ if (typeof result === 'number') return { "$type": "number" };
162
+ if (typeof result === 'boolean') return { "$type": "boolean" };
163
+ if (Array.isArray(result)) return { "$type": "array" };
164
+ return { "$type": typeof result };
165
+ } catch { /* can't call — fall through to string analysis */ }
166
+
167
+ // String analysis fallback
168
+ const fnStr = value.toString();
169
+ if (fnStr.includes('guid') || fnStr.includes('uuid')) return { "$type": "string" };
170
+ if (fnStr.includes('integer')) return { "$type": "number" };
171
+ return { "$type": "unknown" };
172
+ }
173
+ if (Array.isArray(value)) {
174
+ if (value.length > 0 && typeof value[0] === 'object') return { "$type": "object[]" };
175
+ return { "$type": "array" };
176
+ }
177
+ return { "$type": typeof value };
178
+ }
179
+
180
+ /**
181
+ * Simplify a property value to its schema representation.
182
+ * Returns null if the value is too complex or high-cardinality to represent.
183
+ */
184
+ function simplifyValue(value) {
185
+ // Primitives
186
+ if (typeof value === 'string' || typeof value === 'boolean') return [value];
187
+ if (typeof value === 'number') return [value];
188
+
189
+ // Arrays (plain values or weighNumRange output)
190
+ if (Array.isArray(value)) {
191
+ return simplifyArray(value);
192
+ }
193
+
194
+ // Functions: pickAWinner, weighChoices, chance bindings, custom generators
195
+ if (typeof value === 'function') {
196
+ return simplifyFunction(value);
197
+ }
198
+
199
+ return null;
200
+ }
201
+
202
+ /**
203
+ * Simplify an array value. Detects numeric ranges vs categorical arrays.
204
+ */
205
+ function simplifyArray(arr) {
206
+ if (arr.length === 0) return null;
207
+
208
+ // Check element types
209
+ const types = new Set(arr.map(v => typeof v));
210
+
211
+ // All numbers — likely weighNumRange output if large, or small explicit list
212
+ if (types.size === 1 && types.has('number')) {
213
+ if (arr.length > 10) {
214
+ // Numeric range (weighNumRange produces 50 elements)
215
+ const min = Math.min(...arr);
216
+ const max = Math.max(...arr);
217
+ // Round to clean integers if values are close to integers
218
+ const roundedMin = Number.isInteger(min) ? min : Math.round(min * 100) / 100;
219
+ const roundedMax = Number.isInteger(max) ? max : Math.round(max * 100) / 100;
220
+ return { "$range": [roundedMin, roundedMax] };
221
+ }
222
+ // Small explicit numeric list — deduplicate
223
+ return [...new Set(arr)];
224
+ }
225
+
226
+ // All strings — deduplicate
227
+ if (types.size === 1 && types.has('string')) {
228
+ return [...new Set(arr)];
229
+ }
230
+
231
+ // All booleans — deduplicate
232
+ if (types.size === 1 && types.has('boolean')) {
233
+ return [...new Set(arr)];
234
+ }
235
+
236
+ // Mixed primitives (e.g. [true, false, 0.75, 1.0]) — deduplicate
237
+ if ([...types].every(t => t === 'string' || t === 'number' || t === 'boolean')) {
238
+ return [...new Set(arr)];
239
+ }
240
+
241
+ // Contains objects/arrays (nested items like makeProducts)
242
+ if (arr.some(v => typeof v === 'object' && v !== null && !Array.isArray(v))) {
243
+ // Extract keys from first object to show the shape
244
+ const firstObj = arr.find(v => typeof v === 'object' && v !== null);
245
+ if (firstObj) {
246
+ const keys = Object.keys(firstObj);
247
+ return { "$type": "object[]", "$keys": keys };
248
+ }
249
+ }
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Simplify a function-based value by sampling it.
255
+ * Tries calling first; string analysis only for bound methods.
256
+ */
257
+ function simplifyFunction(fn) {
258
+ try {
259
+ // Try calling the function first
260
+ const result = fn();
261
+
262
+ // Double-wrapped function (e.g. makeProducts returns fn → fn → array)
263
+ if (typeof result === 'function') {
264
+ try {
265
+ const inner = result();
266
+ if (Array.isArray(inner)) {
267
+ if (inner.some(v => typeof v === 'object' && v !== null)) return null;
268
+ return simplifyArray(inner);
269
+ }
270
+ } catch { /* inner call failed */ }
271
+ return null;
272
+ }
273
+
274
+ // Function returned an array (pickAWinner, weighChoices)
275
+ if (Array.isArray(result)) {
276
+ return simplifyArray(result);
277
+ }
278
+
279
+ // Function returned a primitive (chance.animal, chance.profession, etc.)
280
+ if (typeof result === 'string') {
281
+ // Sample a few times to see if it's low-cardinality
282
+ const samples = new Set([result]);
283
+ for (let i = 0; i < 30; i++) {
284
+ samples.add(fn());
285
+ }
286
+ // If < 15 unique values in 30 samples, it's low cardinality — include
287
+ if (samples.size < 15) {
288
+ return [...samples].sort();
289
+ }
290
+ return null; // high cardinality
291
+ }
292
+
293
+ return null;
294
+ } catch {
295
+ return null; // function can't be called standalone — skip
296
+ }
297
+ }