maze-test 0.1.0 → 0.3.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.
package/dist/cli.cjs ADDED
@@ -0,0 +1,1591 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_node_process = __toESM(require("process"), 1);
28
+
29
+ // src/coordinates.ts
30
+ function cell(row, col) {
31
+ return { row, col };
32
+ }
33
+ function cellKey(value) {
34
+ return `${value.row},${value.col}`;
35
+ }
36
+ function sameCell(a, b) {
37
+ return a?.row === b?.row && a?.col === b?.col;
38
+ }
39
+ function columnLabel(col) {
40
+ if (!Number.isInteger(col) || col < 1) throw new Error(`Invalid column number: ${col}`);
41
+ let value = col;
42
+ let label = "";
43
+ while (value > 0) {
44
+ value -= 1;
45
+ label = String.fromCharCode(65 + value % 26) + label;
46
+ value = Math.floor(value / 26);
47
+ }
48
+ return label;
49
+ }
50
+ function letterNumberCoordinate(value) {
51
+ return `${columnLabel(value.col)}${value.row}`;
52
+ }
53
+ function neighbors(value, rows, cols) {
54
+ return [
55
+ cell(value.row - 1, value.col),
56
+ cell(value.row + 1, value.col),
57
+ cell(value.row, value.col - 1),
58
+ cell(value.row, value.col + 1)
59
+ ].filter((item) => item.row >= 1 && item.row <= rows && item.col >= 1 && item.col <= cols);
60
+ }
61
+
62
+ // src/mechanisms.ts
63
+ var MATERIALS = ["copper", "silver", "gold"];
64
+ var TREASURE_TYPES = ["treasure", "coin", "gem", "relic"];
65
+ var POTION_KINDS = ["healing", "poison", "antidote", "haste", "slow"];
66
+ function mechanismPreset(complexity) {
67
+ if (complexity === "basic") {
68
+ return {
69
+ complexity,
70
+ trapDamages: [1],
71
+ potionKinds: ["healing"],
72
+ keyMaterials: [],
73
+ treasureTypes: ["treasure"],
74
+ poisonDamage: 1,
75
+ poisonDuration: 3,
76
+ speedDuration: 4,
77
+ movementTime: { normal: 1, fast: 1, slow: 1 }
78
+ };
79
+ }
80
+ if (complexity === "intermediate") {
81
+ return {
82
+ complexity,
83
+ trapDamages: [1, 2, 3],
84
+ potionKinds: ["healing", "poison", "antidote"],
85
+ keyMaterials: [],
86
+ treasureTypes: ["coin", "gem", "relic"],
87
+ poisonDamage: 1,
88
+ poisonDuration: 3,
89
+ speedDuration: 4,
90
+ movementTime: { normal: 1, fast: 1, slow: 1 }
91
+ };
92
+ }
93
+ return {
94
+ complexity,
95
+ trapDamages: [1, 2, 3],
96
+ potionKinds: ["healing", "poison", "antidote", "haste", "slow"],
97
+ keyMaterials: ["copper", "silver", "gold"],
98
+ treasureTypes: ["coin", "gem", "relic"],
99
+ poisonDamage: 1,
100
+ poisonDuration: 3,
101
+ speedDuration: 4,
102
+ movementTime: { normal: 2, fast: 1, slow: 3 }
103
+ };
104
+ }
105
+ function resolveMechanisms(options) {
106
+ const complexity = options.complexity ?? "basic";
107
+ if (!isComplexity(complexity)) throw new Error(`Unknown complexity: ${String(complexity)}`);
108
+ const preset = mechanismPreset(complexity);
109
+ const trapDamages = options.trapDamages ?? preset.trapDamages;
110
+ const potionKinds = options.potionKinds ?? preset.potionKinds;
111
+ const keyMaterials = options.keyMaterials ?? preset.keyMaterials;
112
+ const treasureTypes = options.treasureTypes ?? preset.treasureTypes;
113
+ validatePositiveList(trapDamages, "trapDamages");
114
+ validateEnumList(potionKinds, POTION_KINDS, "potionKinds");
115
+ validateEnumList(keyMaterials, MATERIALS, "keyMaterials", true);
116
+ validateEnumList(treasureTypes, TREASURE_TYPES, "treasureTypes");
117
+ if (complexity !== "advanced" && potionKinds.some((kind) => kind === "haste" || kind === "slow")) {
118
+ throw new Error("Haste and slow potions require advanced complexity so their movement-time costs are defined.");
119
+ }
120
+ if (complexity === "advanced" && keyMaterials.length === 0) {
121
+ throw new Error("Advanced complexity requires at least one key material.");
122
+ }
123
+ return {
124
+ ...preset,
125
+ trapDamages: [...trapDamages],
126
+ potionKinds: [...potionKinds],
127
+ keyMaterials: [...keyMaterials],
128
+ treasureTypes: [...treasureTypes],
129
+ poisonDamage: positiveInteger(options.poisonDamage ?? preset.poisonDamage, "poisonDamage"),
130
+ poisonDuration: positiveInteger(
131
+ options.poisonDuration ?? preset.poisonDuration,
132
+ "poisonDuration"
133
+ ),
134
+ speedDuration: positiveInteger(options.speedDuration ?? preset.speedDuration, "speedDuration")
135
+ };
136
+ }
137
+ function defaultObjectCounts(complexity) {
138
+ if (complexity === "basic") return { doors: 1, chests: 2, traps: 2, potions: 2 };
139
+ if (complexity === "intermediate") return { doors: 2, chests: 3, traps: 3, potions: 3 };
140
+ return { doors: 3, chests: 3, traps: 3, potions: 5 };
141
+ }
142
+ function emptyMaterialCounts(values = {}) {
143
+ return {
144
+ copper: values.copper ?? 0,
145
+ silver: values.silver ?? 0,
146
+ gold: values.gold ?? 0
147
+ };
148
+ }
149
+ function emptyTreasureCounts(values = {}) {
150
+ return {
151
+ treasure: values.treasure ?? 0,
152
+ coin: values.coin ?? 0,
153
+ gem: values.gem ?? 0,
154
+ relic: values.relic ?? 0
155
+ };
156
+ }
157
+ function totalMaterialKeys(counts) {
158
+ return counts.copper + counts.silver + counts.gold;
159
+ }
160
+ function isComplexity(value) {
161
+ return value === "basic" || value === "intermediate" || value === "advanced";
162
+ }
163
+ function positiveInteger(value, name) {
164
+ if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`);
165
+ return value;
166
+ }
167
+ function validatePositiveList(values, name) {
168
+ if (values.length === 0) throw new Error(`${name} must not be empty.`);
169
+ for (const value of values) positiveInteger(value, name);
170
+ }
171
+ function validateEnumList(values, allowed, name, allowEmpty = false) {
172
+ if (!allowEmpty && values.length === 0) throw new Error(`${name} must not be empty.`);
173
+ for (const value of values) {
174
+ if (!allowed.includes(value)) throw new Error(`Unknown ${name} value: ${value}`);
175
+ }
176
+ }
177
+
178
+ // src/maze.ts
179
+ function generateSolidCellMaze(options = {}) {
180
+ const rows = oddDimension(options.rows ?? 15, "rows");
181
+ const cols = oddDimension(options.cols ?? 15, "cols");
182
+ const seed = integerOption(options.seed ?? 1, "seed", 0);
183
+ const requestedSeed = integerOption(options.requestedSeed ?? seed, "requestedSeed", 0);
184
+ const braid = numberOption(options.braid ?? 0, "braid", 0, 1);
185
+ const mechanisms = options.mechanisms ?? mechanismPreset("basic");
186
+ const rng = mulberry32(seed);
187
+ const grid = Array.from({ length: rows }, () => Array(cols).fill("#"));
188
+ const logicalCells = [];
189
+ for (let row = 1; row < rows - 1; row += 2) {
190
+ for (let col = 1; col < cols - 1; col += 2) logicalCells.push({ row, col });
191
+ }
192
+ const start = logicalCells[randomInt(rng, logicalCells.length)];
193
+ if (!start) throw new Error("The maze has no logical cells.");
194
+ const visited = /* @__PURE__ */ new Set([cellKey(start)]);
195
+ const stack = [start];
196
+ grid[start.row][start.col] = ".";
197
+ while (stack.length > 0) {
198
+ const current = stack.at(-1);
199
+ if (!current) break;
200
+ const choices = shuffle(
201
+ rng,
202
+ logicalNeighbors(current, rows, cols).filter((item) => !visited.has(cellKey(item)))
203
+ );
204
+ const next = choices[0];
205
+ if (!next) {
206
+ stack.pop();
207
+ continue;
208
+ }
209
+ grid[(current.row + next.row) / 2][(current.col + next.col) / 2] = ".";
210
+ grid[next.row][next.col] = ".";
211
+ visited.add(cellKey(next));
212
+ stack.push(next);
213
+ }
214
+ braidDeadEnds(grid, braid, rng);
215
+ const terrain = grid.map((row) => row.join(""));
216
+ const first = farthestFloor(cell(2, 2), terrain).cell;
217
+ const second = farthestFloor(first, terrain).cell;
218
+ const maze = {
219
+ schemaVersion: "solid-cell-maze-v4@1",
220
+ id: options.id ?? `solid-maze-${rows}x${cols}-${requestedSeed}`,
221
+ seed,
222
+ requestedSeed,
223
+ rows,
224
+ cols,
225
+ braid,
226
+ coordinateSystem: {
227
+ origin: "top-left",
228
+ columns: "letters-left-to-right",
229
+ rows: "numbers-top-to-bottom"
230
+ },
231
+ mechanisms: structuredClone(mechanisms),
232
+ terrain,
233
+ entry: first,
234
+ goal: second,
235
+ doors: [],
236
+ objects: [],
237
+ metrics: emptyMetrics(rows, cols)
238
+ };
239
+ maze.metrics = analyzeSolidCellMaze(maze);
240
+ return maze;
241
+ }
242
+ function decorateSolidCellMaze(maze, options = {}) {
243
+ const result = structuredClone(maze);
244
+ const mechanisms = options.mechanisms ?? maze.mechanisms ?? mechanismPreset("basic");
245
+ result.mechanisms = structuredClone(mechanisms);
246
+ const decorationSeed = integerOption(options.seed ?? maze.seed + 71311, "decoration seed", 0);
247
+ const rng = mulberry32(decorationSeed);
248
+ const mainPath = shortestPath(result, result.entry, result.goal);
249
+ const occupied = /* @__PURE__ */ new Set([cellKey(result.entry), cellKey(result.goal)]);
250
+ const doorCount = integerOption(
251
+ options.doorCount ?? Math.max(1, Math.floor(result.metrics.floorCells / 120)),
252
+ "doorCount",
253
+ 0
254
+ );
255
+ const chestCount = integerOption(options.chestCount ?? 2, "chestCount", 0);
256
+ const trapCount = integerOption(options.trapCount ?? 2, "trapCount", 0);
257
+ const potionCount = integerOption(
258
+ options.potionCount ?? options.medicineCount ?? 2,
259
+ "potionCount",
260
+ 0
261
+ );
262
+ const doorIndices = selectGatingDoorIndices(result, mainPath, doorCount);
263
+ result.doors = doorIndices.map((index, doorIndex) => {
264
+ const position = mainPath[index];
265
+ if (!position) throw new Error(`No path cell exists at door index ${index}.`);
266
+ occupied.add(cellKey(position));
267
+ const material = cyclicValue(mechanisms.keyMaterials, doorIndex + decorationSeed);
268
+ return {
269
+ id: `door-${doorIndex + 1}`,
270
+ position,
271
+ state: "closed",
272
+ ...material ? { material } : {}
273
+ };
274
+ });
275
+ const objects = [];
276
+ for (let index = 0; index < result.doors.length; index += 1) {
277
+ const doorIndex = doorIndices[index];
278
+ if (doorIndex === void 0) throw new Error("Door index mismatch.");
279
+ const preferredIndex = Math.max(1, Math.floor(doorIndex * 0.55));
280
+ const position = freePathCellBefore(mainPath, preferredIndex, occupied);
281
+ occupied.add(cellKey(position));
282
+ const door = result.doors[index];
283
+ const key = {
284
+ id: `key-${index + 1}`,
285
+ type: "key",
286
+ position,
287
+ ...door?.material ? { material: door.material } : {}
288
+ };
289
+ objects.push(key);
290
+ }
291
+ const degrees = degreeMap(result);
292
+ const deadEnds = shuffle(
293
+ rng,
294
+ floorCells(result).filter(
295
+ (item) => degrees.get(cellKey(item)) === 1 && !occupied.has(cellKey(item))
296
+ )
297
+ );
298
+ for (let index = 0; index < chestCount; index += 1) {
299
+ const position = takeFree(deadEnds, result, occupied, rng, "chest");
300
+ occupied.add(cellKey(position));
301
+ const treasureType = cyclicValue(mechanisms.treasureTypes, index + decorationSeed);
302
+ if (!treasureType) throw new Error("At least one treasure type is required.");
303
+ const count = 1 + index % 3;
304
+ const lockMaterial = cyclicValue(mechanisms.keyMaterials, index + decorationSeed + 1);
305
+ const chest = {
306
+ id: `chest-${index + 1}`,
307
+ type: "chest",
308
+ position,
309
+ treasures: count,
310
+ contents: [{ type: treasureType, count }],
311
+ ...lockMaterial ? { lockMaterial } : {}
312
+ };
313
+ objects.push(chest);
314
+ }
315
+ const trapCandidates = shuffle(rng, mainPath.slice(2, -2));
316
+ for (let index = 0; index < trapCount; index += 1) {
317
+ const position = takeFree(trapCandidates, result, occupied, rng, "trap");
318
+ occupied.add(cellKey(position));
319
+ const trap = {
320
+ id: `trap-${index + 1}`,
321
+ type: "trap",
322
+ position,
323
+ damage: mechanisms.trapDamages[index % mechanisms.trapDamages.length] ?? 1
324
+ };
325
+ objects.push(trap);
326
+ }
327
+ const potionCandidates = shuffle(rng, floorCells(result));
328
+ for (let index = 0; index < potionCount; index += 1) {
329
+ const position = takeFree(potionCandidates, result, occupied, rng, "potion");
330
+ occupied.add(cellKey(position));
331
+ if (mechanisms.complexity === "basic" && mechanisms.potionKinds.length === 1 && mechanisms.potionKinds[0] === "healing") {
332
+ const medicine = {
333
+ id: `medicine-${index + 1}`,
334
+ type: "medicine",
335
+ position,
336
+ recovery: 1
337
+ };
338
+ objects.push(medicine);
339
+ continue;
340
+ }
341
+ const kind = mechanisms.potionKinds[index % mechanisms.potionKinds.length];
342
+ if (!kind) throw new Error("At least one potion kind is required.");
343
+ const potion = {
344
+ id: `potion-${index + 1}`,
345
+ type: "potion",
346
+ position,
347
+ kind,
348
+ ...kind === "healing" ? { potency: 1 + index % 2 } : {},
349
+ ...kind === "poison" ? { potency: mechanisms.poisonDamage, duration: mechanisms.poisonDuration } : {},
350
+ ...kind === "haste" || kind === "slow" ? { duration: mechanisms.speedDuration } : {}
351
+ };
352
+ objects.push(potion);
353
+ }
354
+ result.objects = objects;
355
+ result.metrics = analyzeSolidCellMaze(result);
356
+ return result;
357
+ }
358
+ function analyzeSolidCellMaze(maze) {
359
+ assertShape(maze);
360
+ const floors = floorCells(maze);
361
+ const graph = adjacency(maze);
362
+ const distances = bfsDistances(maze.entry, graph);
363
+ const degreeValues = [...graph.values()].map((items) => items.length);
364
+ const edgeCount = degreeValues.reduce((sum, value) => sum + value, 0) / 2;
365
+ const mainPath = shortestPath(maze, maze.entry, maze.goal);
366
+ const objectCounts = {};
367
+ for (const object of maze.objects ?? []) {
368
+ objectCounts[object.type] = (objectCounts[object.type] ?? 0) + 1;
369
+ }
370
+ return {
371
+ totalCells: maze.rows * maze.cols,
372
+ wallCells: maze.rows * maze.cols - floors.length,
373
+ floorCells: floors.length,
374
+ connectedFloorCells: distances.size,
375
+ connected: distances.size === floors.length,
376
+ floorAdjacencyEdges: edgeCount,
377
+ independentCycles: edgeCount - floors.length + 1,
378
+ deadEnds: degreeValues.filter((value) => value === 1).length,
379
+ junctions: degreeValues.filter((value) => value >= 3).length,
380
+ entryGoalDistance: mainPath.length > 0 ? mainPath.length - 1 : null,
381
+ doorCount: maze.doors.length,
382
+ gatingDoorCount: maze.doors.filter(
383
+ (door) => shortestPath(maze, maze.entry, maze.goal, door.position).length === 0
384
+ ).length,
385
+ objectCounts
386
+ };
387
+ }
388
+ function validateSolidCellMaze(maze) {
389
+ const errors = [];
390
+ try {
391
+ assertShape(maze);
392
+ } catch (error) {
393
+ return {
394
+ valid: false,
395
+ errors: [error instanceof Error ? error.message : String(error)],
396
+ metrics: null
397
+ };
398
+ }
399
+ for (let row = 1; row <= maze.rows; row += 1) {
400
+ for (let col = 1; col <= maze.cols; col += 1) {
401
+ const value = terrainAt(maze, cell(row, col));
402
+ if (value !== "#" && value !== ".") errors.push(`Invalid terrain at ${row},${col}: ${value}`);
403
+ if ((row === 1 || row === maze.rows || col === 1 || col === maze.cols) && value !== "#") {
404
+ errors.push(`The outer boundary is not a wall at ${row},${col}.`);
405
+ }
406
+ }
407
+ }
408
+ if (!isFloor(maze, maze.entry)) errors.push("The entry is not on a floor cell.");
409
+ if (!isFloor(maze, maze.goal)) errors.push("The goal is not on a floor cell.");
410
+ if (sameCell(maze.entry, maze.goal)) errors.push("The entry and goal overlap.");
411
+ const occupied = /* @__PURE__ */ new Set([cellKey(maze.entry), cellKey(maze.goal)]);
412
+ for (const door of maze.doors) {
413
+ if (!isFloor(maze, door.position)) errors.push(`${door.id} is not on a floor cell.`);
414
+ const key = cellKey(door.position);
415
+ if (occupied.has(key)) errors.push(`${door.id} overlaps another feature.`);
416
+ occupied.add(key);
417
+ }
418
+ for (const object of maze.objects) {
419
+ if (!isFloor(maze, object.position)) errors.push(`${object.id} is not on a floor cell.`);
420
+ const key = cellKey(object.position);
421
+ if (occupied.has(key)) errors.push(`${object.id} overlaps another feature.`);
422
+ occupied.add(key);
423
+ }
424
+ const metrics = analyzeSolidCellMaze(maze);
425
+ if (!metrics.connected) {
426
+ errors.push(`Floor cells are disconnected: ${metrics.connectedFloorCells}/${metrics.floorCells}.`);
427
+ }
428
+ if (metrics.deadEnds === 0) errors.push("The maze has no dead ends.");
429
+ if (metrics.junctions === 0) errors.push("The maze has no junctions.");
430
+ if (metrics.gatingDoorCount !== metrics.doorCount) {
431
+ errors.push(`${metrics.doorCount - metrics.gatingDoorCount} door(s) can be bypassed.`);
432
+ }
433
+ const path = shortestPath(maze, maze.entry, maze.goal);
434
+ const pathIndex = new Map(path.map((item, index) => [cellKey(item), index]));
435
+ for (let index = 0; index < maze.doors.length; index += 1) {
436
+ const door = maze.doors[index];
437
+ if (!door) continue;
438
+ const key = maze.objects.find((item) => item.id === `key-${index + 1}`);
439
+ const doorIndex = pathIndex.get(cellKey(door.position));
440
+ const keyIndex = key ? pathIndex.get(cellKey(key.position)) : void 0;
441
+ if (!key) errors.push(`Missing a key for ${door.id}.`);
442
+ else if (keyIndex === void 0 || doorIndex === void 0 || keyIndex >= doorIndex) {
443
+ errors.push(`${key.id} is not before ${door.id} on the main path.`);
444
+ } else if (door.material !== (key.type === "key" ? key.material : void 0)) {
445
+ errors.push(`${key.id} does not match the material of ${door.id}.`);
446
+ }
447
+ }
448
+ return { valid: errors.length === 0, errors, metrics };
449
+ }
450
+ function shortestPath(maze, start, goal, excludedCell = null) {
451
+ if (excludedCell && (sameCell(start, excludedCell) || sameCell(goal, excludedCell))) return [];
452
+ const graph = adjacency(maze, excludedCell);
453
+ const queue = [start];
454
+ const parent = /* @__PURE__ */ new Map([[cellKey(start), null]]);
455
+ const values = /* @__PURE__ */ new Map([[cellKey(start), start]]);
456
+ let queueIndex = 0;
457
+ while (queueIndex < queue.length) {
458
+ const current = queue[queueIndex];
459
+ queueIndex += 1;
460
+ if (!current) break;
461
+ if (sameCell(current, goal)) break;
462
+ for (const next of graph.get(cellKey(current)) ?? []) {
463
+ const key = cellKey(next);
464
+ if (parent.has(key)) continue;
465
+ parent.set(key, cellKey(current));
466
+ values.set(key, next);
467
+ queue.push(next);
468
+ }
469
+ }
470
+ if (!parent.has(cellKey(goal))) return [];
471
+ const path = [];
472
+ let cursor = cellKey(goal);
473
+ while (cursor !== null) {
474
+ const value = values.get(cursor);
475
+ if (!value) throw new Error(`Could not reconstruct path at ${cursor}.`);
476
+ path.push(value);
477
+ cursor = parent.get(cursor) ?? null;
478
+ }
479
+ return path.reverse();
480
+ }
481
+ function terrainAt(maze, value) {
482
+ if (value.row < 1 || value.row > maze.rows || value.col < 1 || value.col > maze.cols) {
483
+ return null;
484
+ }
485
+ return maze.terrain[value.row - 1]?.[value.col - 1] ?? null;
486
+ }
487
+ function isFloor(maze, value) {
488
+ return terrainAt(maze, value) === ".";
489
+ }
490
+ function braidDeadEnds(grid, braid, rng) {
491
+ if (braid <= 0) return;
492
+ const rows = grid.length;
493
+ const cols = grid[0]?.length ?? 0;
494
+ const logicalCells = [];
495
+ for (let row = 1; row < rows - 1; row += 2) {
496
+ for (let col = 1; col < cols - 1; col += 2) logicalCells.push({ row, col });
497
+ }
498
+ const deadEnds = shuffle(
499
+ rng,
500
+ logicalCells.filter((value) => openLogicalNeighbors(grid, value).length === 1)
501
+ );
502
+ for (const current of deadEnds) {
503
+ if (rng() > braid) continue;
504
+ const closed = shuffle(
505
+ rng,
506
+ logicalNeighbors(current, rows, cols).filter(
507
+ (next2) => grid[(current.row + next2.row) / 2]?.[(current.col + next2.col) / 2] === "#"
508
+ )
509
+ );
510
+ const next = closed[0];
511
+ if (!next) continue;
512
+ grid[(current.row + next.row) / 2][(current.col + next.col) / 2] = ".";
513
+ }
514
+ }
515
+ function openLogicalNeighbors(grid, current) {
516
+ return logicalNeighbors(current, grid.length, grid[0]?.length ?? 0).filter(
517
+ (next) => grid[(current.row + next.row) / 2]?.[(current.col + next.col) / 2] === "."
518
+ );
519
+ }
520
+ function logicalNeighbors(value, rows, cols) {
521
+ return [
522
+ { row: value.row - 2, col: value.col },
523
+ { row: value.row + 2, col: value.col },
524
+ { row: value.row, col: value.col - 2 },
525
+ { row: value.row, col: value.col + 2 }
526
+ ].filter((item) => item.row >= 1 && item.row < rows - 1 && item.col >= 1 && item.col < cols - 1);
527
+ }
528
+ function farthestFloor(start, terrain) {
529
+ const maze = { rows: terrain.length, cols: terrain[0]?.length ?? 0, terrain };
530
+ const graph = adjacency(maze);
531
+ const distances = bfsDistances(start, graph);
532
+ let farthest = start;
533
+ let distance = -1;
534
+ for (const [key, value] of distances) {
535
+ if (value <= distance) continue;
536
+ distance = value;
537
+ farthest = parseCellKey(key);
538
+ }
539
+ return { cell: farthest, distance };
540
+ }
541
+ function floorCells(maze) {
542
+ const values = [];
543
+ for (let row = 1; row <= maze.rows; row += 1) {
544
+ for (let col = 1; col <= maze.cols; col += 1) {
545
+ if (isFloor(maze, cell(row, col))) values.push(cell(row, col));
546
+ }
547
+ }
548
+ return values;
549
+ }
550
+ function adjacency(maze, excludedCell = null) {
551
+ const floors = floorCells(maze).filter((item) => !excludedCell || !sameCell(item, excludedCell));
552
+ const floorKeys = new Set(floors.map(cellKey));
553
+ return new Map(
554
+ floors.map((item) => [
555
+ cellKey(item),
556
+ neighbors(item, maze.rows, maze.cols).filter((next) => floorKeys.has(cellKey(next)))
557
+ ])
558
+ );
559
+ }
560
+ function bfsDistances(start, graph) {
561
+ const distances = /* @__PURE__ */ new Map([[cellKey(start), 0]]);
562
+ const queue = [start];
563
+ let queueIndex = 0;
564
+ while (queueIndex < queue.length) {
565
+ const current = queue[queueIndex];
566
+ queueIndex += 1;
567
+ if (!current) break;
568
+ const distance = distances.get(cellKey(current));
569
+ if (distance === void 0) continue;
570
+ for (const next of graph.get(cellKey(current)) ?? []) {
571
+ const key = cellKey(next);
572
+ if (distances.has(key)) continue;
573
+ distances.set(key, distance + 1);
574
+ queue.push(next);
575
+ }
576
+ }
577
+ return distances;
578
+ }
579
+ function degreeMap(maze) {
580
+ return new Map([...adjacency(maze)].map(([key, values]) => [key, values.length]));
581
+ }
582
+ function selectGatingDoorIndices(maze, mainPath, count) {
583
+ if (count === 0) return [];
584
+ const degrees = degreeMap(maze);
585
+ const candidates = [];
586
+ for (let index = 3; index < mainPath.length - 3; index += 1) {
587
+ const pathCell = mainPath[index];
588
+ if (!pathCell || (degrees.get(cellKey(pathCell)) ?? 0) !== 2) continue;
589
+ if (shortestPath(maze, maze.entry, maze.goal, pathCell).length === 0) candidates.push(index);
590
+ }
591
+ if (candidates.length < count) {
592
+ throw new Error(`Only ${candidates.length} non-bypassable path cells are available for ${count} doors.`);
593
+ }
594
+ const targets = spacedIndices(mainPath.length - 1, count, 0.28, 0.78);
595
+ const selected = [];
596
+ for (const target of targets) {
597
+ const available = candidates.filter((index) => !selected.includes(index));
598
+ available.sort(
599
+ (left, right) => Math.abs(left - target) - Math.abs(right - target) || left - right
600
+ );
601
+ const candidate = available[0];
602
+ if (candidate === void 0) throw new Error(`Could not place ${count} doors.`);
603
+ selected.push(candidate);
604
+ }
605
+ if (selected.length !== count) throw new Error(`Could not space ${count} doors along the main path.`);
606
+ return selected.sort((left, right) => left - right);
607
+ }
608
+ function freePathCellBefore(mainPath, preferredIndex, occupied) {
609
+ for (let distance = 0; distance < mainPath.length; distance += 1) {
610
+ for (const index of [preferredIndex - distance, preferredIndex + distance]) {
611
+ if (index <= 0 || index >= mainPath.length - 1) continue;
612
+ const candidate = mainPath[index];
613
+ if (candidate && !occupied.has(cellKey(candidate))) return candidate;
614
+ }
615
+ }
616
+ throw new Error("There is no free path cell on which to place a key.");
617
+ }
618
+ function takeFree(candidates, maze, occupied, rng, type) {
619
+ while (candidates.length > 0) {
620
+ const value = candidates.shift();
621
+ if (value && !occupied.has(cellKey(value))) return value;
622
+ }
623
+ const fallback = shuffle(rng, floorCells(maze)).find((item) => !occupied.has(cellKey(item)));
624
+ if (!fallback) throw new Error(`There are not enough free floor cells to place ${type}.`);
625
+ return fallback;
626
+ }
627
+ function spacedIndices(edgeCount, count, startRatio, endRatio) {
628
+ if (count === 0) return [];
629
+ const values = [];
630
+ for (let index = 0; index < count; index += 1) {
631
+ const ratio = count === 1 ? (startRatio + endRatio) / 2 : startRatio + (endRatio - startRatio) * index / (count - 1);
632
+ values.push(Math.max(2, Math.min(edgeCount - 2, Math.floor(edgeCount * ratio))));
633
+ }
634
+ return [...new Set(values)];
635
+ }
636
+ function parseCellKey(key) {
637
+ const [row, col] = key.split(",").map(Number);
638
+ if (row === void 0 || col === void 0) throw new Error(`Invalid cell key: ${key}`);
639
+ return cell(row, col);
640
+ }
641
+ function assertShape(maze) {
642
+ oddDimension(maze.rows, "rows");
643
+ oddDimension(maze.cols, "cols");
644
+ if (!Array.isArray(maze.terrain) || maze.terrain.length !== maze.rows) {
645
+ throw new Error("The terrain row count does not match rows.");
646
+ }
647
+ if (maze.terrain.some((row) => typeof row !== "string" || row.length !== maze.cols)) {
648
+ throw new Error("A terrain row does not match cols.");
649
+ }
650
+ }
651
+ function oddDimension(value, name) {
652
+ integerOption(value, name, 7);
653
+ if (value % 2 === 0) throw new Error(`${name} must be odd.`);
654
+ return value;
655
+ }
656
+ function integerOption(value, name, minimum) {
657
+ if (!Number.isInteger(value) || value < minimum) {
658
+ throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
659
+ }
660
+ return value;
661
+ }
662
+ function numberOption(value, name, minimum, maximum) {
663
+ if (!Number.isFinite(value) || value < minimum || value > maximum) {
664
+ throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
665
+ }
666
+ return value;
667
+ }
668
+ function randomInt(rng, maximum) {
669
+ return Math.floor(rng() * maximum);
670
+ }
671
+ function shuffle(rng, input) {
672
+ const values = [...input];
673
+ for (let index = values.length - 1; index > 0; index -= 1) {
674
+ const target = randomInt(rng, index + 1);
675
+ const prior = values[index];
676
+ const replacement = values[target];
677
+ if (prior === void 0 || replacement === void 0) continue;
678
+ values[index] = replacement;
679
+ values[target] = prior;
680
+ }
681
+ return values;
682
+ }
683
+ function cyclicValue(values, index) {
684
+ if (values.length === 0) return void 0;
685
+ return values[(index % values.length + values.length) % values.length];
686
+ }
687
+ function mulberry32(seed) {
688
+ let value = seed >>> 0;
689
+ return () => {
690
+ value += 1831565813;
691
+ let mixed = value;
692
+ mixed = Math.imul(mixed ^ mixed >>> 15, mixed | 1);
693
+ mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61);
694
+ return ((mixed ^ mixed >>> 14) >>> 0) / 4294967296;
695
+ };
696
+ }
697
+ function emptyMetrics(rows, cols) {
698
+ return {
699
+ totalCells: rows * cols,
700
+ wallCells: rows * cols,
701
+ floorCells: 0,
702
+ connectedFloorCells: 0,
703
+ connected: false,
704
+ floorAdjacencyEdges: 0,
705
+ independentCycles: 0,
706
+ deadEnds: 0,
707
+ junctions: 0,
708
+ entryGoalDistance: null,
709
+ doorCount: 0,
710
+ gatingDoorCount: 0,
711
+ objectCounts: {}
712
+ };
713
+ }
714
+
715
+ // src/simulator.ts
716
+ var DELTAS = {
717
+ up: { row: -1, col: 0 },
718
+ down: { row: 1, col: 0 },
719
+ left: { row: 0, col: -1 },
720
+ right: { row: 0, col: 1 }
721
+ };
722
+ function simulateTrial(maze, actions, initial = {}) {
723
+ const mechanisms = maze.mechanisms ?? mechanismPreset("basic");
724
+ const health = initial.health ?? 5;
725
+ const state = {
726
+ position: structuredClone(maze.entry),
727
+ health,
728
+ healthMax: initial.healthMax ?? health,
729
+ keys: initial.keys ?? 0,
730
+ keysByMaterial: emptyMaterialCounts(initial.keysByMaterial),
731
+ treasures: initial.treasures ?? 0,
732
+ treasuresByType: emptyTreasureCounts(initial.treasuresByType),
733
+ elapsedTime: initial.elapsedTime ?? 0,
734
+ speed: initial.speed ?? "normal",
735
+ speedRemaining: initial.speedRemaining ?? 0,
736
+ poisonDamage: initial.poisonDamage ?? 0,
737
+ poisonRemaining: initial.poisonRemaining ?? 0,
738
+ alive: health > 0,
739
+ reachedGoal: false,
740
+ openedDoors: [],
741
+ collectedKeys: [],
742
+ openedChests: [],
743
+ triggeredTraps: [],
744
+ usedMedicines: [],
745
+ usedPotions: []
746
+ };
747
+ const trace = [];
748
+ for (let index = 0; index < actions.length; index += 1) {
749
+ const direction = actions[index];
750
+ if (!direction) continue;
751
+ const before = cloneState(state);
752
+ const events = [];
753
+ if (!state.alive) {
754
+ trace.push({ step: index + 1, direction, before, moved: false, reason: "dead", events, after: cloneState(state) });
755
+ continue;
756
+ }
757
+ const context = {
758
+ speedAtStart: state.speed,
759
+ speedRemainingAtStart: state.speedRemaining,
760
+ poisonDamageAtStart: state.poisonDamage,
761
+ poisonRemainingAtStart: state.poisonRemaining,
762
+ speedReplaced: false,
763
+ poisonReplaced: false
764
+ };
765
+ const timeCost = mechanisms.movementTime[context.speedAtStart];
766
+ state.elapsedTime += timeCost;
767
+ const delta = DELTAS[direction];
768
+ const target = cell(state.position.row + delta.row, state.position.col + delta.col);
769
+ let moved = false;
770
+ let reason = null;
771
+ if (terrainAt(maze, target) !== ".") {
772
+ reason = "wall-or-outside";
773
+ } else {
774
+ const door = maze.doors.find((item) => cellKey(item.position) === cellKey(target));
775
+ if (door && !state.openedDoors.includes(door.id)) {
776
+ if (door.material) {
777
+ if (state.keysByMaterial[door.material] < 1) {
778
+ reason = "closed-door-without-matching-key";
779
+ } else {
780
+ state.keysByMaterial[door.material] -= 1;
781
+ state.openedDoors.push(door.id);
782
+ events.push({ type: "open-door", id: door.id, material: door.material, keyCost: 1, timeCost });
783
+ }
784
+ } else if (state.keys < 1) {
785
+ reason = "closed-door-without-key";
786
+ } else {
787
+ state.keys -= 1;
788
+ state.openedDoors.push(door.id);
789
+ events.push({ type: "open-door", id: door.id, keyCost: 1, timeCost });
790
+ }
791
+ }
792
+ if (!reason) {
793
+ state.position = target;
794
+ moved = true;
795
+ applyCellObjects(maze, state, events, context);
796
+ if (cellKey(state.position) === cellKey(maze.goal) && !state.reachedGoal) {
797
+ state.reachedGoal = true;
798
+ events.push({ type: "reach-goal" });
799
+ }
800
+ }
801
+ }
802
+ finishStatuses(state, events, context);
803
+ trace.push({ step: index + 1, direction, before, moved, reason, events, after: cloneState(state) });
804
+ }
805
+ const blockedMoves = trace.filter((item) => !item.moved).length;
806
+ const blockedAfterDeath = trace.filter((item) => item.reason === "dead").length;
807
+ return {
808
+ state,
809
+ trace,
810
+ answers: {
811
+ finalPosition: letterNumberCoordinate(state.position),
812
+ keys: state.keys + totalMaterialKeys(state.keysByMaterial),
813
+ keysByMaterial: structuredClone(state.keysByMaterial),
814
+ treasures: state.treasures,
815
+ treasuresByType: structuredClone(state.treasuresByType),
816
+ health: state.health,
817
+ alive: state.alive,
818
+ reachedGoal: state.reachedGoal,
819
+ openedDoors: state.openedDoors.length,
820
+ openedChests: state.openedChests.length,
821
+ triggeredTraps: state.triggeredTraps.length,
822
+ usedMedicines: state.usedMedicines.length,
823
+ usedPotions: state.usedPotions.length,
824
+ elapsedTime: state.elapsedTime,
825
+ finalSpeed: state.speed,
826
+ speedRemaining: state.speedRemaining,
827
+ poisonDamage: state.poisonDamage,
828
+ poisonRemaining: state.poisonRemaining,
829
+ blockedMoves,
830
+ blockedAfterDeath
831
+ }
832
+ };
833
+ }
834
+ function applyCellObjects(maze, state, events, context) {
835
+ const object = maze.objects.find((item) => cellKey(item.position) === cellKey(state.position));
836
+ if (!object) return;
837
+ if (object.type === "key" && !state.collectedKeys.includes(object.id)) {
838
+ state.collectedKeys.push(object.id);
839
+ if (object.material) state.keysByMaterial[object.material] += 1;
840
+ else state.keys += 1;
841
+ events.push({
842
+ type: "collect-key",
843
+ id: object.id,
844
+ ...object.material ? { material: object.material } : {}
845
+ });
846
+ return;
847
+ }
848
+ if (object.type === "chest" && !state.openedChests.includes(object.id)) {
849
+ let canOpen = false;
850
+ if (object.lockMaterial) {
851
+ canOpen = state.keysByMaterial[object.lockMaterial] > 0;
852
+ if (canOpen) state.keysByMaterial[object.lockMaterial] -= 1;
853
+ } else {
854
+ canOpen = state.keys > 0;
855
+ if (canOpen) state.keys -= 1;
856
+ }
857
+ if (!canOpen) {
858
+ events.push({
859
+ type: "pass-closed-chest",
860
+ id: object.id,
861
+ ...object.lockMaterial ? { material: object.lockMaterial } : {}
862
+ });
863
+ return;
864
+ }
865
+ state.openedChests.push(object.id);
866
+ const contents = object.contents?.length > 0 ? object.contents : [{ type: "treasure", count: object.treasures }];
867
+ for (const content of contents) {
868
+ state.treasures += content.count;
869
+ state.treasuresByType[content.type] += content.count;
870
+ }
871
+ events.push({
872
+ type: "open-chest",
873
+ id: object.id,
874
+ ...object.lockMaterial ? { material: object.lockMaterial } : {},
875
+ keyCost: 1,
876
+ treasures: object.treasures,
877
+ contents: structuredClone(contents)
878
+ });
879
+ return;
880
+ }
881
+ if (object.type === "trap" && !state.triggeredTraps.includes(object.id)) {
882
+ state.triggeredTraps.push(object.id);
883
+ state.health = Math.max(0, state.health - object.damage);
884
+ events.push({ type: "trigger-trap", id: object.id, damage: object.damage });
885
+ return;
886
+ }
887
+ if (object.type === "medicine" && !state.usedMedicines.includes(object.id)) {
888
+ state.usedMedicines.push(object.id);
889
+ const before = state.health;
890
+ state.health = Math.min(state.healthMax, state.health + object.recovery);
891
+ events.push({ type: "use-medicine", id: object.id, recovery: state.health - before });
892
+ return;
893
+ }
894
+ if (object.type === "potion" && !state.usedPotions.includes(object.id)) {
895
+ state.usedPotions.push(object.id);
896
+ applyPotion(state, object, context);
897
+ const recovery = object.kind === "healing" ? object.potency ?? 1 : void 0;
898
+ const damage = object.kind === "poison" ? object.potency ?? maze.mechanisms.poisonDamage : void 0;
899
+ events.push({
900
+ type: "drink-potion",
901
+ id: object.id,
902
+ potionKind: object.kind,
903
+ ...recovery === void 0 ? {} : { recovery },
904
+ ...damage === void 0 ? {} : { damage },
905
+ ...object.duration === void 0 ? {} : { duration: object.duration }
906
+ });
907
+ }
908
+ }
909
+ function applyPotion(state, potion, context) {
910
+ if (potion.kind === "healing") {
911
+ state.health = Math.min(state.healthMax, state.health + (potion.potency ?? 1));
912
+ } else if (potion.kind === "poison") {
913
+ state.poisonDamage = potion.potency ?? 1;
914
+ state.poisonRemaining = potion.duration ?? 1;
915
+ context.poisonReplaced = true;
916
+ } else if (potion.kind === "antidote") {
917
+ state.poisonDamage = 0;
918
+ state.poisonRemaining = 0;
919
+ context.poisonReplaced = true;
920
+ } else if (potion.kind === "haste") {
921
+ state.speed = "fast";
922
+ state.speedRemaining = potion.duration ?? 1;
923
+ context.speedReplaced = true;
924
+ } else {
925
+ state.speed = "slow";
926
+ state.speedRemaining = potion.duration ?? 1;
927
+ context.speedReplaced = true;
928
+ }
929
+ }
930
+ function finishStatuses(state, events, context) {
931
+ if (!context.poisonReplaced && context.poisonRemainingAtStart > 0) {
932
+ state.health = Math.max(0, state.health - context.poisonDamageAtStart);
933
+ state.poisonRemaining = context.poisonRemainingAtStart - 1;
934
+ state.poisonDamage = state.poisonRemaining > 0 ? context.poisonDamageAtStart : 0;
935
+ events.push({ type: "poison-tick", damage: context.poisonDamageAtStart });
936
+ }
937
+ if (!context.speedReplaced && context.speedRemainingAtStart > 0) {
938
+ state.speedRemaining = context.speedRemainingAtStart - 1;
939
+ if (state.speedRemaining === 0) {
940
+ state.speed = "normal";
941
+ events.push({ type: "speed-expired" });
942
+ }
943
+ }
944
+ if (state.health <= 0 && state.alive) {
945
+ state.health = 0;
946
+ state.alive = false;
947
+ events.push({ type: "die" });
948
+ }
949
+ }
950
+ function cloneState(state) {
951
+ return structuredClone(state);
952
+ }
953
+
954
+ // src/trial.ts
955
+ var DIRECTIONS = {
956
+ up: { en: "up", zh: "\u4E0A" },
957
+ down: { en: "down", zh: "\u4E0B" },
958
+ left: { en: "left", zh: "\u5DE6" },
959
+ right: { en: "right", zh: "\u53F3" }
960
+ };
961
+ var MATERIAL_NAMES = {
962
+ copper: { en: "copper", zh: "\u94DC" },
963
+ silver: { en: "silver", zh: "\u94F6" },
964
+ gold: { en: "gold", zh: "\u91D1" }
965
+ };
966
+ var TREASURE_NAMES = {
967
+ treasure: { en: "treasure", zh: "\u5B9D\u7269" },
968
+ coin: { en: "coin", zh: "\u94B1\u5E01" },
969
+ gem: { en: "gem", zh: "\u5B9D\u77F3" },
970
+ relic: { en: "relic", zh: "\u9057\u7269" }
971
+ };
972
+ var POTION_NAMES = {
973
+ healing: { en: "healing potion", zh: "\u6CBB\u7597\u836F\u6C34" },
974
+ poison: { en: "poison potion", zh: "\u6BD2\u836F" },
975
+ antidote: { en: "antidote potion", zh: "\u89E3\u6BD2\u836F\u6C34" },
976
+ haste: { en: "haste potion", zh: "\u52A0\u901F\u836F\u6C34" },
977
+ slow: { en: "slow potion", zh: "\u51CF\u901F\u836F\u6C34" }
978
+ };
979
+ var SPEED_NAMES = {
980
+ normal: { en: "normal", zh: "\u6B63\u5E38" },
981
+ fast: { en: "fast", zh: "\u52A0\u901F" },
982
+ slow: { en: "slow", zh: "\u51CF\u901F" }
983
+ };
984
+ function generateTrial(options = {}) {
985
+ const resolved = resolveTrialOptions(options);
986
+ const maze = selectMaze(resolved);
987
+ const plan = buildScenarioPlan(maze, resolved.scenario);
988
+ const result = simulateTrial(maze, plan.actions, plan.initialState);
989
+ verifyScenario(resolved.scenario, result.answers, maze);
990
+ const sections = {
991
+ map: renderMapDescription(maze, plan.initialState, resolved.language, resolved.style),
992
+ rules: renderRules(maze, resolved.language),
993
+ actions: renderActionDescription(plan.actions, resolved.language, resolved.style),
994
+ questions: renderQuestions(maze, resolved.language)
995
+ };
996
+ return {
997
+ schemaVersion: "maze-test-trial@2",
998
+ options: resolved,
999
+ maze,
1000
+ initialState: plan.initialState,
1001
+ actions: plan.actions,
1002
+ sections,
1003
+ question: renderQuestion(resolved, maze, sections),
1004
+ answer: renderAnswer(resolved, maze, result.answers, plan.actions.length),
1005
+ result
1006
+ };
1007
+ }
1008
+ function resolveTrialOptions(options = {}) {
1009
+ const scenario = options.scenario ?? "success";
1010
+ const language = options.language ?? "en";
1011
+ if (!["success", "treasure-and-leave", "death-and-stop", "mechanism-tour"].includes(scenario)) throw new Error(`Unknown scenario: ${scenario}`);
1012
+ if (language !== "en" && language !== "zh") throw new Error(`Unknown language: ${language}`);
1013
+ const mechanisms = resolveMechanisms(options);
1014
+ const defaults = defaultObjectCounts(mechanisms.complexity);
1015
+ const resolved = {
1016
+ seed: integer(options.seed ?? 1, "seed", 0),
1017
+ rows: oddInteger(options.rows ?? 15, "rows", 7),
1018
+ cols: oddInteger(options.cols ?? 15, "cols", 7),
1019
+ braid: finiteNumber(options.braid ?? 0, "braid", 0, 1),
1020
+ doorCount: integer(options.doorCount ?? defaults.doors, "doorCount", 0),
1021
+ chestCount: integer(options.chestCount ?? defaults.chests, "chestCount", 0),
1022
+ trapCount: integer(options.trapCount ?? defaults.traps, "trapCount", 0),
1023
+ potionCount: integer(options.potionCount ?? options.medicineCount ?? defaults.potions, "potionCount", 0),
1024
+ mechanisms,
1025
+ scenario,
1026
+ language,
1027
+ style: integer(options.style ?? 0, "style", 0),
1028
+ minDistance: integer(options.minDistance ?? 0, "minDistance", 0),
1029
+ maxAttempts: integer(options.maxAttempts ?? 500, "maxAttempts", 1)
1030
+ };
1031
+ validateScenarioOptions(resolved);
1032
+ return resolved;
1033
+ }
1034
+ function validateScenarioOptions(options) {
1035
+ if (options.scenario === "treasure-and-leave") {
1036
+ if (options.chestCount < 1) throw new Error("The treasure-and-leave scenario requires at least one chest.");
1037
+ if (!options.mechanisms.potionKinds.includes("healing")) throw new Error("The treasure-and-leave scenario requires healing in potionKinds.");
1038
+ if (options.potionCount < 1) throw new Error("The treasure-and-leave scenario requires at least one potion.");
1039
+ }
1040
+ if (options.scenario === "death-and-stop" && options.trapCount < 1) throw new Error("The death-and-stop scenario requires at least one trap.");
1041
+ if (options.scenario === "mechanism-tour") {
1042
+ if (options.trapCount < options.mechanisms.trapDamages.length) throw new Error("The mechanism-tour scenario needs at least one trap for every configured trap damage.");
1043
+ if (options.potionCount < options.mechanisms.potionKinds.length) throw new Error("The mechanism-tour scenario needs at least one potion for every configured potion kind.");
1044
+ if (options.chestCount < options.mechanisms.treasureTypes.length) throw new Error("The mechanism-tour scenario needs at least one chest for every configured treasure type.");
1045
+ if (options.doorCount < options.mechanisms.keyMaterials.length) throw new Error("The mechanism-tour scenario needs at least one door for every configured key material.");
1046
+ }
1047
+ }
1048
+ function selectMaze(options) {
1049
+ let lastError = null;
1050
+ for (let offset = 0; offset < options.maxAttempts; offset += 1) {
1051
+ const effectiveSeed = options.seed + offset;
1052
+ try {
1053
+ const base = generateSolidCellMaze({ rows: options.rows, cols: options.cols, braid: options.braid, seed: effectiveSeed, requestedSeed: options.seed, mechanisms: options.mechanisms, id: `maze-test-${options.rows}x${options.cols}-${options.seed}` });
1054
+ const maze = decorateSolidCellMaze(base, { seed: effectiveSeed + 8e4, doorCount: options.doorCount, chestCount: options.chestCount, trapCount: options.trapCount, potionCount: options.potionCount, mechanisms: options.mechanisms });
1055
+ const validation = validateSolidCellMaze(maze);
1056
+ if (!validation.valid) throw new Error(validation.errors.join(" "));
1057
+ if ((validation.metrics?.entryGoalDistance ?? 0) < options.minDistance) throw new Error(`The entry-goal distance is below ${options.minDistance}.`);
1058
+ const plan = buildScenarioPlan(maze, options.scenario);
1059
+ const result = simulateTrial(maze, plan.actions, plan.initialState);
1060
+ verifyScenario(options.scenario, result.answers, maze);
1061
+ return maze;
1062
+ } catch (error) {
1063
+ lastError = error;
1064
+ }
1065
+ }
1066
+ const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : "";
1067
+ throw new Error(`Could not generate a valid trial in ${options.maxAttempts} deterministic attempts.${detail}`);
1068
+ }
1069
+ function buildScenarioPlan(maze, scenario) {
1070
+ const ample = ampleInitialState(maze);
1071
+ if (scenario === "success") {
1072
+ const damage = maze.objects.filter((item) => item.type === "trap").reduce((sum, item) => sum + item.damage, 0);
1073
+ const poisonBudget = maze.mechanisms.potionKinds.includes("poison") ? maze.mechanisms.poisonDamage * maze.mechanisms.poisonDuration : 0;
1074
+ const health = Math.max(5, damage + poisonBudget + 1);
1075
+ return { initialState: { health, healthMax: health, keys: 0, treasures: 0 }, actions: pathToActions(shortestPath(maze, maze.entry, maze.goal)) };
1076
+ }
1077
+ if (scenario === "treasure-and-leave") {
1078
+ const healing = maze.objects.find((item) => item.type === "medicine" || item.type === "potion" && item.kind === "healing");
1079
+ const chest = maze.objects.find((item) => item.type === "chest");
1080
+ if (!healing || !chest) throw new Error("This scenario requires a healing object and a chest.");
1081
+ let actions = routeThrough(maze, [maze.entry, healing.position, chest.position, maze.goal]);
1082
+ const leavePath = shortestPath(maze, maze.goal, chest.position).slice(0, 10);
1083
+ actions.push(...pathToActions(leavePath));
1084
+ const initialState = { ...ample, health: 50, healthMax: 100 };
1085
+ const bump = wallDirection(maze, simulateTrial(maze, actions, initialState).state.position);
1086
+ if (bump) actions.push(bump, bump);
1087
+ return { initialState, actions };
1088
+ }
1089
+ if (scenario === "death-and-stop") {
1090
+ const trap = maze.objects.find((item) => item.type === "trap");
1091
+ if (!trap) throw new Error("This scenario requires a trap.");
1092
+ const actions = [...pathToActions(shortestPath(maze, maze.entry, trap.position)), ...pathToActions(shortestPath(maze, trap.position, maze.goal).slice(0, 14))];
1093
+ for (let health = 1; health <= 200; health += 1) {
1094
+ const initialState = { ...ample, health, healthMax: health };
1095
+ const result = simulateTrial(maze, actions, initialState);
1096
+ if (result.trace.some((item) => item.events.some((event) => event.type === "trigger-trap" && event.id === trap.id) && item.events.some((event) => event.type === "die"))) return { initialState, actions };
1097
+ }
1098
+ throw new Error("Could not choose health that makes the death-and-stop scenario die on its target trap.");
1099
+ }
1100
+ return { initialState: { ...ample, health: 1e3, healthMax: 1e3 }, actions: routeThrough(maze, [maze.entry, ...maze.objects.map((item) => item.position), maze.goal]) };
1101
+ }
1102
+ function ampleInitialState(maze) {
1103
+ const material = emptyMaterialCounts();
1104
+ for (const door of maze.doors) if (door.material) material[door.material] += 1;
1105
+ for (const object of maze.objects) if (object.type === "chest" && object.lockMaterial) material[object.lockMaterial] += 1;
1106
+ return { health: 100, healthMax: 100, keys: maze.doors.filter((door) => !door.material).length + maze.objects.filter((item) => item.type === "chest" && !item.lockMaterial).length, keysByMaterial: material, treasures: 0 };
1107
+ }
1108
+ function verifyScenario(scenario, a, maze) {
1109
+ if (scenario === "success" && (!a.reachedGoal || !a.alive || a.finalPosition !== letterNumberCoordinate(maze.goal))) throw new Error("The success scenario did not finish alive at the goal.");
1110
+ if (scenario === "treasure-and-leave" && (!a.reachedGoal || a.finalPosition === letterNumberCoordinate(maze.goal) || a.openedChests < 1 || a.usedMedicines + a.usedPotions < 1 || a.blockedMoves < 2)) throw new Error("The treasure-and-leave scenario did not meet its invariants.");
1111
+ if (scenario === "death-and-stop" && (a.alive || a.triggeredTraps < 1 || a.blockedAfterDeath < 5)) throw new Error("The death-and-stop scenario did not meet its invariants.");
1112
+ if (scenario === "mechanism-tour") {
1113
+ const count = (type) => maze.objects.filter((item) => item.type === type).length;
1114
+ if (!a.alive || !a.reachedGoal || a.openedDoors !== maze.doors.length || a.openedChests !== count("chest") || a.triggeredTraps !== count("trap") || a.usedPotions !== count("potion") || a.usedMedicines !== count("medicine")) throw new Error("The mechanism-tour scenario did not exercise every generated mechanism object.");
1115
+ }
1116
+ }
1117
+ function renderQuestion(options, maze, sections) {
1118
+ const zh = options.language === "zh";
1119
+ const labels = zh ? ["\u5730\u56FE\u63CF\u8FF0", "\u673A\u5236\u63CF\u8FF0", "\u884C\u52A8\u63CF\u8FF0", "\u95EE\u9898"] : ["Map", "Rules", "Actions", "Questions"];
1120
+ const seedLine = maze.seed === options.seed ? `${zh ? "\u79CD\u5B50" : "Seed"}: ${options.seed}` : `${zh ? "\u79CD\u5B50" : "Seed"}: ${options.seed} (${zh ? "\u6709\u6548\u8FF7\u5BAB\u79CD\u5B50" : "effective maze seed"}: ${maze.seed})`;
1121
+ return [`# ${zh ? "\u8FF7\u5BAB\u8BD5\u9898" : "Maze Trial"}`, "", seedLine, `${zh ? "\u673A\u5236\u590D\u6742\u5EA6" : "Mechanism complexity"}: ${options.mechanisms.complexity}`, "", `## 1. ${labels[0]}`, "", sections.map, "", `## 2. ${labels[1]}`, "", sections.rules, "", `## 3. ${labels[2]}`, "", sections.actions, "", `## 4. ${labels[3]}`, "", sections.questions, ""].join("\n");
1122
+ }
1123
+ function renderAnswer(options, maze, a, actionCount) {
1124
+ const zh = options.language === "zh";
1125
+ const rows = zh ? [["\u6700\u7EC8\u4F4D\u7F6E", a.finalPosition], ["\u6700\u7EC8\u94A5\u5319\u603B\u6570", a.keys], ["\u6700\u7EC8\u5B9D\u7269\u603B\u6570", a.treasures], ["\u6700\u7EC8\u5065\u5EB7\u503C", a.health], ["\u662F\u5426\u5B58\u6D3B", a.alive ? "\u662F" : "\u5426"], ["\u662F\u5426\u66FE\u5230\u8FBE\u7EC8\u70B9", a.reachedGoal ? "\u662F" : "\u5426"], ["\u6253\u5F00\u7684\u95E8", a.openedDoors], ["\u6253\u5F00\u7684\u5B9D\u7BB1", a.openedChests], ["\u89E6\u53D1\u7684\u9677\u9631", a.triggeredTraps]] : [["Final position", a.finalPosition], ["Keys remaining in total", a.keys], ["Treasures collected in total", a.treasures], ["Health remaining", a.health], ["Alive", a.alive ? "Yes" : "No"], ["Ever reached the goal", a.reachedGoal ? "Yes" : "No"], ["Doors opened", a.openedDoors], ["Chests opened", a.openedChests], ["Traps triggered", a.triggeredTraps]];
1126
+ if (maze.objects.some((item) => item.type === "medicine")) rows.push([zh ? "\u4F7F\u7528\u7684\u836F\u54C1\u623F" : "Medicine rooms used", a.usedMedicines]);
1127
+ if (maze.objects.some((item) => item.type === "potion")) rows.push([zh ? "\u996E\u7528\u7684\u836F\u6C34" : "Potions drunk", a.usedPotions]);
1128
+ rows.push([zh ? "\u672A\u6539\u53D8\u4F4D\u7F6E\u7684\u539F\u5B50\u79FB\u52A8\u6307\u4EE4" : "Atomic movement instructions that did not change position", a.blockedMoves]);
1129
+ for (const material of maze.mechanisms.keyMaterials) rows.push([zh ? `\u5269\u4F59${MATERIAL_NAMES[material].zh}\u94A5\u5319` : `${capitalize(MATERIAL_NAMES[material].en)} keys remaining`, a.keysByMaterial[material]]);
1130
+ if (maze.mechanisms.treasureTypes.some((type) => type !== "treasure")) for (const type of maze.mechanisms.treasureTypes) rows.push([zh ? `${TREASURE_NAMES[type].zh}\u6570\u91CF` : `${capitalize(TREASURE_NAMES[type].en)}s collected`, a.treasuresByType[type]]);
1131
+ if (maze.mechanisms.complexity === "advanced") rows.push([zh ? "\u7D2F\u8BA1\u8017\u65F6\uFF08\u65F6\u95F4\u5355\u4F4D\uFF09" : "Elapsed time (time units)", a.elapsedTime], [zh ? "\u6700\u7EC8\u901F\u5EA6\u72B6\u6001" : "Final speed state", SPEED_NAMES[a.finalSpeed][zh ? "zh" : "en"]], [zh ? "\u901F\u5EA6\u6548\u679C\u5269\u4F59\u6307\u4EE4\u6570" : "Speed-effect instructions remaining", a.speedRemaining], [zh ? "\u4E2D\u6BD2\u5269\u4F59\u6307\u4EE4\u6570" : "Poison instructions remaining", a.poisonRemaining]);
1132
+ return [`# ${zh ? "\u6807\u51C6\u7B54\u6848" : "Answer Key"}`, "", `${zh ? "\u79CD\u5B50" : "Seed"}: ${options.seed}`, ...maze.seed === options.seed ? [] : [`${zh ? "\u6709\u6548\u8FF7\u5BAB\u79CD\u5B50" : "Effective maze seed"}: ${maze.seed}`], "", `| ${zh ? "\u95EE\u9898" : "Item"} | ${zh ? "\u7B54\u6848" : "Answer"} |`, "|---|---|", ...rows.map(([label, value]) => `| ${label} | ${String(value)} |`), "", `${zh ? "\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u6570" : "Atomic movement instruction count"}: ${actionCount}`, ""].join("\n");
1133
+ }
1134
+ function renderMapDescription(maze, initial, language, style) {
1135
+ const terrain = renderTerrainDescription(maze, language, style);
1136
+ const objects = renderObjectDescription(maze, language);
1137
+ if (language === "zh") return [`\u8FD9\u662F\u4E00\u5EA7${maze.rows}\u884C${maze.cols}\u5217\u7684\u8FF7\u5BAB\u3002\u5DE6\u4E0A\u89D2\u4E3AA1\uFF0C\u5217\u4ECE\u5DE6\u5411\u53F3\u4F9D\u6B21\u6807\u4E3AA\u81F3${columnLabel(maze.cols)}\uFF0C\u884C\u4ECE\u4E0A\u5411\u4E0B\u7F16\u53F7\u4E3A1\u81F3${maze.rows}\u3002`, `\u5165\u53E3\u4F4D\u4E8E${letterNumberCoordinate(maze.entry)}\uFF0C\u7EC8\u70B9\u4F4D\u4E8E${letterNumberCoordinate(maze.goal)}\u3002`, "", terrain, "", "\u95E8\u548C\u5BF9\u8C61\u7684\u4F4D\u7F6E\u5982\u4E0B\uFF1B\u95E8\u4E0E\u5BF9\u8C61\u6240\u5728\u683C\u4ECD\u662F\u53EF\u4EE5\u8FDB\u5165\u7684\u901A\u8DEF\u683C\u3002", objects, "", `\u63A2\u9669\u8005\u4ECE\u5165\u53E3\u51FA\u53D1\uFF0C\u521D\u59CB\u5065\u5EB7\u503C\u4E3A${initial.health}\uFF0C\u5065\u5EB7\u4E0A\u9650\u4E3A${initial.healthMax}\uFF1B${initialPossessions(initial, language)}\uFF1B\u521D\u59CB\u672A\u4E2D\u6BD2\u3001\u901F\u5EA6\u4E3A\u6B63\u5E38\uFF1B\u201C\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\u201D\u8BB0\u5F55\u4E3A\u5047\u3002`].join("\n");
1138
+ return [`This maze has ${maze.rows} rows and ${maze.cols} columns. The top-left cell is A1. Columns run left to right from A to ${columnLabel(maze.cols)}, and rows run top to bottom from 1 to ${maze.rows}.`, `The entry is at ${letterNumberCoordinate(maze.entry)}, and the goal is at ${letterNumberCoordinate(maze.goal)}.`, "", terrain, "", "Door and object locations follow. A cell containing a door or object is still a passage cell that may be entered, subject to the door rule.", objects, "", `The explorer starts at the entry with ${initial.health} health, a maximum health of ${initial.healthMax}, ${initialPossessions(initial, language)}, no poison, normal speed, and reached-goal set to false.`].join("\n");
1139
+ }
1140
+ function renderTerrainDescription(maze, language, style) {
1141
+ const clauses = Array.from({ length: maze.rows }, (_, index) => index + 1).map((row) => {
1142
+ const kinds = Array.from({ length: maze.cols }, (_, index) => terrainAt(maze, cell(row, index + 1)) === "." ? "floor" : "wall");
1143
+ const label = language === "zh" ? [`\u7B2C${row}\u884C`, `${row}\u3001`, `\uFF08${row}\uFF09`][style % 3] : `Row ${row}: `;
1144
+ return `${label ?? `\u7B2C${row}\u884C`}${describeTerrainLine(kinds, language)}`;
1145
+ });
1146
+ return language === "zh" ? ["\u6BCF\u884C\u5185\u90E8\u7684\u683C\u5B50\u5747\u4ECE\u5DE6\u5F80\u53F3\u8BA1\u6570\u3002\u4ECE\u4E0A\u5F80\u4E0B\uFF0C\u5404\u884C\u7684\u683C\u5B50\u60C5\u51B5\u5982\u4E0B\uFF1A", `${clauses.join("\uFF1B\n")}\u3002`].join("\n") : ["Within each row, cells are counted from left to right:", `${clauses.join(";\n")}.`].join("\n");
1147
+ }
1148
+ function describeTerrainLine(kinds, language) {
1149
+ const walls = [];
1150
+ const floors = [];
1151
+ for (let index = 0; index < kinds.length; index += 1) (kinds[index] === "wall" ? walls : floors).push(index + 1);
1152
+ if (language === "zh") {
1153
+ if (walls.length === 0) return "\u90FD\u662F\u901A\u8DEF";
1154
+ if (floors.length === 0) return "\u90FD\u662F\u5899\u58C1";
1155
+ const limit2 = Math.floor(kinds.length / 3);
1156
+ if (walls.length <= limit2) return `\u9664\u4E86${positionList(walls, language)}\u662F\u5899\u58C1\u4EE5\u5916\uFF0C\u5176\u4F59\u90FD\u662F\u901A\u8DEF`;
1157
+ if (floors.length <= limit2) return `\u9664\u4E86${positionList(floors, language)}\u662F\u901A\u8DEF\u4EE5\u5916\uFF0C\u5176\u4F59\u90FD\u662F\u5899\u58C1`;
1158
+ return `${positionList(walls, language)}\u662F\u5899\u58C1\uFF0C${positionList(floors, language)}\u662F\u901A\u8DEF`;
1159
+ }
1160
+ if (walls.length === 0) return "all cells are passages";
1161
+ if (floors.length === 0) return "all cells are walls";
1162
+ const limit = Math.floor(kinds.length / 3);
1163
+ if (walls.length <= limit) return `all cells are passages except ${positionList(walls, language)}, which ${walls.length === 1 ? "is a wall" : "are walls"}`;
1164
+ if (floors.length <= limit) return `all cells are walls except ${positionList(floors, language)}, which ${floors.length === 1 ? "is a passage" : "are passages"}`;
1165
+ return `${positionList(walls, language)} ${walls.length === 1 ? "is a wall" : "are walls"}; ${positionList(floors, language)} ${floors.length === 1 ? "is a passage" : "are passages"}`;
1166
+ }
1167
+ function positionList(indices, language) {
1168
+ const ranges = [];
1169
+ let start = indices[0];
1170
+ let end = indices[0];
1171
+ if (start === void 0 || end === void 0) return "";
1172
+ for (const value of indices.slice(1)) {
1173
+ if (value === end + 1) {
1174
+ end = value;
1175
+ continue;
1176
+ }
1177
+ ranges.push([start, end]);
1178
+ start = value;
1179
+ end = value;
1180
+ }
1181
+ ranges.push([start, end]);
1182
+ const parts = ranges.map(([from, to]) => language === "zh" ? from === to ? `\u7B2C${from}\u683C` : `\u7B2C${from}\u683C\u81F3\u7B2C${to}\u683C` : from === to ? `cell ${from}` : `cells ${from}-${to}`);
1183
+ if (parts.length === 1) return parts[0] ?? "";
1184
+ return language === "zh" ? `${parts.slice(0, -1).join("\u3001")}\u548C${parts.at(-1)}` : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
1185
+ }
1186
+ function renderObjectDescription(maze, language) {
1187
+ const lines = [];
1188
+ for (const door of maze.doors) {
1189
+ const at = letterNumberCoordinate(door.position);
1190
+ lines.push(language === "zh" ? `${at}\u6709\u4E00\u9053\u521D\u59CB\u5173\u95ED\u7684${door.material ? MATERIAL_NAMES[door.material].zh : "\u666E\u901A"}\u95E8\u3002` : `${at} contains an initially closed ${door.material ? `${MATERIAL_NAMES[door.material].en} ` : "ordinary "}door.`);
1191
+ }
1192
+ for (const object of maze.objects) {
1193
+ const at = letterNumberCoordinate(object.position);
1194
+ if (object.type === "key") lines.push(language === "zh" ? `${at}\u653E\u7740\u4E00\u628A${object.material ? MATERIAL_NAMES[object.material].zh : "\u666E\u901A"}\u94A5\u5319\u3002` : `${at} contains one ${object.material ? `${MATERIAL_NAMES[object.material].en} ` : "ordinary "}key.`);
1195
+ else if (object.type === "chest") {
1196
+ const contents = (object.contents ?? [{ type: "treasure", count: object.treasures }]).map((item) => language === "zh" ? `${item.count}${treasureUnit(item.type)}${TREASURE_NAMES[item.type].zh}` : `${item.count} ${TREASURE_NAMES[item.type].en}${plural(item.count)}`).join(language === "zh" ? "\u3001" : " and ");
1197
+ const lock = object.lockMaterial ? `a ${MATERIAL_NAMES[object.lockMaterial].en} lock` : "an ordinary lock";
1198
+ lines.push(language === "zh" ? `${at}\u6709\u4E00\u53EA\u521D\u59CB\u5173\u95ED\u7684${object.lockMaterial ? MATERIAL_NAMES[object.lockMaterial].zh : "\u666E\u901A"}\u9501\u5B9D\u7BB1\uFF0C\u5185\u6709${contents}\u3002` : `${at} contains an initially closed chest with ${lock}, containing ${contents}.`);
1199
+ } else if (object.type === "trap") lines.push(language === "zh" ? `${at}\u6709\u4E00\u4E2A\u5C1A\u672A\u89E6\u53D1\u7684\u9677\u9631\uFF0C\u4F24\u5BB3\u4E3A${object.damage}\u70B9\u3002` : `${at} contains an untriggered trap that deals ${object.damage} damage.`);
1200
+ else if (object.type === "medicine") lines.push(language === "zh" ? `${at}\u6709\u4E00\u4E2A\u5C1A\u672A\u4F7F\u7528\u7684\u836F\u54C1\u623F\uFF0C\u53EF\u6062\u590D${object.recovery}\u70B9\u5065\u5EB7\u503C\u3002` : `${at} contains an unused medicine room that restores ${object.recovery} health.`);
1201
+ else {
1202
+ const details = potionDetails(object.kind, object.potency, object.duration, language);
1203
+ lines.push(language === "zh" ? `${at}\u6709\u4E00\u74F6\u5C1A\u672A\u996E\u7528\u7684${POTION_NAMES[object.kind].zh}${details}\u3002` : `${at} contains an unused ${POTION_NAMES[object.kind].en}${details}.`);
1204
+ }
1205
+ }
1206
+ return lines.length > 0 ? lines.join("\n") : language === "zh" ? "\u6CA1\u6709\u95E8\u6216\u5176\u4ED6\u5BF9\u8C61\u3002" : "There are no doors or other objects.";
1207
+ }
1208
+ function potionDetails(kind, potency, duration, language) {
1209
+ if (language === "zh") {
1210
+ if (kind === "healing") return `\uFF0C\u6062\u590D${potency ?? 1}\u70B9\u5065\u5EB7\u503C\uFF0C\u4F46\u4E0D\u8D85\u8FC7\u5065\u5EB7\u4E0A\u9650`;
1211
+ if (kind === "poison") return `\uFF0C\u4F7F\u996E\u7528\u8005\u5728\u4E4B\u540E${duration ?? 1}\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u5404\u635F\u5931${potency ?? 1}\u70B9\u5065\u5EB7\u503C`;
1212
+ if (kind === "haste" || kind === "slow") return `\uFF0C\u6548\u679C\u6301\u7EED\u4E4B\u540E${duration ?? 1}\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4`;
1213
+ return "\uFF0C\u53EF\u7ACB\u5373\u6E05\u9664\u4E2D\u6BD2\u72B6\u6001";
1214
+ }
1215
+ if (kind === "healing") return ` that restores ${potency ?? 1} health without exceeding maximum health`;
1216
+ if (kind === "poison") return ` that causes ${potency ?? 1} damage after each of the next ${duration ?? 1} atomic movement instructions`;
1217
+ if (kind === "haste" || kind === "slow") return ` whose effect lasts for the next ${duration ?? 1} atomic movement instructions`;
1218
+ return " that immediately clears poison";
1219
+ }
1220
+ function renderRules(maze, language) {
1221
+ const m = maze.mechanisms;
1222
+ const usesPotionObjects = maze.objects.some((item) => item.type === "potion");
1223
+ const usesPoison = usesPotionObjects && m.potionKinds.some((kind) => kind === "poison" || kind === "antidote");
1224
+ const usesMaterials = m.keyMaterials.length > 0;
1225
+ const doorRuleZh = usesMaterials ? "\u76EE\u6807\u683C\u662F\u5173\u95ED\u7684\u95E8\u65F6\uFF0C\u5FC5\u987B\u6D88\u8017\u4E00\u628A\u4E0E\u95E8\u6750\u8D28\u76F8\u540C\u7684\u94A5\u5319\uFF1B\u94A5\u5319\u4E0D\u8DB3\u6216\u6750\u8D28\u4E0D\u7B26\u65F6\u505C\u5728\u539F\u5730\u3002\u94A5\u5319\u5339\u914D\u65F6\uFF0C\u5728\u540C\u4E00\u6761\u6307\u4EE4\u4E2D\u5F00\u95E8\u5E76\u8FDB\u5165\u95E8\u683C\u3002\u95E8\u4E00\u7ECF\u6253\u5F00\u4FBF\u6C38\u4E45\u4FDD\u6301\u6253\u5F00\u3002" : "\u76EE\u6807\u683C\u662F\u5173\u95ED\u7684\u666E\u901A\u95E8\u65F6\uFF0C\u5FC5\u987B\u6D88\u8017\u4E00\u628A\u666E\u901A\u94A5\u5319\uFF1B\u6CA1\u6709\u666E\u901A\u94A5\u5319\u65F6\u505C\u5728\u539F\u5730\u3002\u6709\u94A5\u5319\u65F6\uFF0C\u5728\u540C\u4E00\u6761\u6307\u4EE4\u4E2D\u5F00\u95E8\u5E76\u8FDB\u5165\u95E8\u683C\u3002\u95E8\u4E00\u7ECF\u6253\u5F00\u4FBF\u6C38\u4E45\u4FDD\u6301\u6253\u5F00\u3002";
1226
+ const doorRuleEn = usesMaterials ? "If the target cell contains a closed door, the explorer must spend one key whose material matches the door. Without a matching key, the explorer stays in place. With a matching key, the door opens and the explorer enters the door cell in the same instruction. Once opened, a door remains open." : "If the target cell contains a closed ordinary door, the explorer must spend one ordinary key. Without an ordinary key, the explorer stays in place. With one, the door opens and the explorer enters the door cell in the same instruction. Once opened, a door remains open.";
1227
+ const chestRuleZh = usesMaterials ? "\u6BCF\u6B21\u8FDB\u5165\u542B\u6709\u5C1A\u672A\u6253\u5F00\u5B9D\u7BB1\u7684\u683C\u5B50\u65F6\uFF0C\u82E5\u6301\u6709\u4E0E\u9501\u6750\u8D28\u76F8\u540C\u7684\u94A5\u5319\uFF0C\u5219\u6D88\u8017\u4E00\u628A\u8BE5\u94A5\u5319\u3001\u6253\u5F00\u5B9D\u7BB1\u5E76\u53D6\u5F97\u7BB1\u4E2D\u5168\u90E8\u7269\u54C1\uFF1B\u5426\u5219\u5B9D\u7BB1\u4FDD\u6301\u5173\u95ED\u3002\u5B9D\u7BB1\u683C\u65E0\u8BBA\u662F\u5426\u6253\u5F00\u90FD\u53EF\u4EE5\u8FDB\u5165\u6216\u7ECF\u8FC7\u3002\u7BB1\u5185\u6BCF\u4E00\u679A\u94B1\u5E01\u3001\u6BCF\u4E00\u9897\u5B9D\u77F3\u6216\u6BCF\u4E00\u4EF6\u9057\u7269\u90FD\u5206\u522B\u8BA1\u4E3A\u4E00\u4EF6\u5B9D\u7269\u3002" : "\u6BCF\u6B21\u8FDB\u5165\u542B\u6709\u5C1A\u672A\u6253\u5F00\u666E\u901A\u9501\u5B9D\u7BB1\u7684\u683C\u5B50\u65F6\uFF0C\u82E5\u6301\u6709\u666E\u901A\u94A5\u5319\uFF0C\u5219\u6D88\u8017\u4E00\u628A\u666E\u901A\u94A5\u5319\u3001\u6253\u5F00\u5B9D\u7BB1\u5E76\u53D6\u5F97\u7BB1\u4E2D\u5168\u90E8\u7269\u54C1\uFF1B\u5426\u5219\u5B9D\u7BB1\u4FDD\u6301\u5173\u95ED\u3002\u5B9D\u7BB1\u683C\u65E0\u8BBA\u662F\u5426\u6253\u5F00\u90FD\u53EF\u4EE5\u8FDB\u5165\u6216\u7ECF\u8FC7\u3002\u7BB1\u5185\u6BCF\u4EF6\u7269\u54C1\u8BA1\u4E3A\u4E00\u4EF6\u5B9D\u7269\u3002";
1228
+ const chestRuleEn = usesMaterials ? "Whenever the explorer enters a cell containing an unopened chest, if a key matching its lock is available, one such key is spent, the chest opens, and all its contents are collected; otherwise, the chest remains closed. A chest cell may be entered or crossed whether or not the chest is open. Each coin, gem, or relic counts as one treasure." : "Whenever the explorer enters a cell containing an unopened chest with an ordinary lock, if an ordinary key is available, one ordinary key is spent, the chest opens, and all its contents are collected; otherwise, the chest remains closed. A chest cell may be entered or crossed whether or not the chest is open. Each item inside counts as one treasure.";
1229
+ const orderRuleZh = m.complexity === "advanced" ? "\u4E00\u6761\u6307\u4EE4\u4E2D\uFF0C\u4F9D\u6B21\u7ED3\u7B97\u8017\u65F6\u3001\u79FB\u52A8\u4E0E\u76EE\u6807\u683C\u6548\u679C\u3001\u5230\u8FBE\u7EC8\u70B9\u8BB0\u5F55\u3001\u8BE5\u6307\u4EE4\u5F00\u59CB\u65F6\u5DF2\u6709\u7684\u4E2D\u6BD2\u4E0E\u901F\u5EA6\u72B6\u6001\uFF0C\u6700\u540E\u5224\u65AD\u662F\u5426\u6B7B\u4EA1\u3002" : usesPoison ? "\u4E00\u6761\u6307\u4EE4\u4E2D\uFF0C\u4F9D\u6B21\u7ED3\u7B97\u79FB\u52A8\u4E0E\u76EE\u6807\u683C\u6548\u679C\u3001\u5230\u8FBE\u7EC8\u70B9\u8BB0\u5F55\u3001\u8BE5\u6307\u4EE4\u5F00\u59CB\u65F6\u5DF2\u6709\u7684\u4E2D\u6BD2\u72B6\u6001\uFF0C\u6700\u540E\u5224\u65AD\u662F\u5426\u6B7B\u4EA1\u3002" : "\u4E00\u6761\u6307\u4EE4\u4E2D\uFF0C\u4F9D\u6B21\u7ED3\u7B97\u79FB\u52A8\u4E0E\u76EE\u6807\u683C\u6548\u679C\u3001\u5230\u8FBE\u7EC8\u70B9\u8BB0\u5F55\uFF0C\u6700\u540E\u5224\u65AD\u662F\u5426\u6B7B\u4EA1\u3002";
1230
+ const orderRuleEn = m.complexity === "advanced" ? "Within one instruction, resolve time cost first, then movement and target-cell effects, then the reached-goal record, then poison and speed states that were active at the instruction's start, and finally death." : usesPoison ? "Within one instruction, resolve movement and target-cell effects, then the reached-goal record, then poison that was active at the instruction's start, and finally death." : "Within one instruction, resolve movement and target-cell effects, then the reached-goal record, and finally death.";
1231
+ const poisonRuleZh = "\u6BD2\u836F\u4ECE\u996E\u7528\u540E\u7684\u4E0B\u4E00\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u5F00\u59CB\u751F\u6548\u3002\u6BCF\u6761\u6307\u4EE4\uFF08\u5305\u62EC\u56E0\u5899\u3001\u5730\u56FE\u8FB9\u754C\u6216\u95E8\u800C\u672A\u79FB\u52A8\u7684\u6307\u4EE4\uFF09\u7ED3\u675F\u65F6\u6263\u9664\u6307\u5B9A\u5065\u5EB7\u503C\u5E76\u51CF\u5C11\u4E00\u6B21\u5269\u4F59\u6B21\u6570\u3002\u996E\u7528\u65B0\u7684\u6BD2\u836F\u4F1A\u8986\u76D6\u539F\u4E2D\u6BD2\u72B6\u6001\uFF1B\u996E\u7528\u6BD2\u836F\u6216\u89E3\u6BD2\u836F\u6C34\u7684\u5F53\u524D\u6307\u4EE4\u90FD\u4E0D\u4F1A\u89E6\u53D1\u539F\u6709\u6BD2\u7D20\u3002";
1232
+ const poisonRuleEn = "Poison starts with the instruction after it is drunk. At the end of each such instruction\u2014including one blocked by a wall, map boundary, or door\u2014it deals the stated damage and consumes one remaining instruction. New poison replaces any previous poison; neither a poison potion nor an antidote potion lets the previous poison tick on the instruction in which it is drunk.";
1233
+ const speedRuleZh = `\u6BCF\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u5728\u5F00\u59CB\u65F6\u6309\u5F53\u65F6\u901F\u5EA6\u8BA1\u5165\u65F6\u95F4\u5355\u4F4D\uFF1A\u6B63\u5E38${m.movementTime.normal}\u3001\u52A0\u901F${m.movementTime.fast}\u3001\u51CF\u901F${m.movementTime.slow}\u3002\u996E\u7528\u52A0\u901F\u836F\u6C34\u4F1A\u628A\u901F\u5EA6\u8BBE\u4E3A\u52A0\u901F\uFF0C\u996E\u7528\u51CF\u901F\u836F\u6C34\u4F1A\u628A\u901F\u5EA6\u8BBE\u4E3A\u51CF\u901F\uFF1B\u4E24\u79CD\u6548\u679C\u90FD\u4ECE\u996E\u7528\u540E\u7684\u4E0B\u4E00\u6761\u6307\u4EE4\u5F00\u59CB\uFF0C\u6301\u7EED\u6307\u5B9A\u6570\u91CF\u7684\u6307\u4EE4\u3002\u649E\u5899\u6216\u88AB\u95E8\u6321\u4F4F\u4E5F\u4F1A\u6D88\u8017\u4E00\u6B21\u6301\u7EED\u6B21\u6570\u3002\u996E\u7528\u901F\u5EA6\u836F\u6C34\u7684\u5F53\u524D\u6307\u4EE4\u6309\u996E\u7528\u524D\u7684\u901F\u5EA6\u8BA1\u65F6\uFF1B\u65B0\u901F\u5EA6\u836F\u6C34\u8986\u76D6\u65E7\u901F\u5EA6\u72B6\u6001\uFF0C\u5F53\u524D\u6307\u4EE4\u4E0D\u6D88\u8017\u65E7\u72B6\u6001\u6216\u65B0\u72B6\u6001\u7684\u6301\u7EED\u6B21\u6570\u3002\u6301\u7EED\u6B21\u6570\u8017\u5C3D\u540E\uFF0C\u901F\u5EA6\u6062\u590D\u4E3A\u6B63\u5E38\uFF0C\u5269\u4F59\u6307\u4EE4\u6570\u4E3A0\u3002`;
1234
+ const speedRuleEn = `At the start of each atomic movement instruction, add time units according to the current speed: normal ${m.movementTime.normal}, fast ${m.movementTime.fast}, slow ${m.movementTime.slow}. A haste potion sets speed to fast, and a slow potion sets speed to slow; either effect starts with the instruction after it is drunk and lasts for its stated number of instructions. Blocked instructions also consume one duration. The instruction that drinks a speed potion uses the pre-drink speed for time. A new speed potion replaces the previous speed state, and that instruction consumes neither the old nor the new duration. When the duration is exhausted, speed returns to normal with 0 speed-effect instructions remaining.`;
1235
+ const rules = language === "zh" ? [
1236
+ "\u6BCF\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u53EA\u5C1D\u8BD5\u5411\u4E0A\u3001\u4E0B\u3001\u5DE6\u6216\u53F3\u76F8\u90BB\u7684\u4E00\u683C\u79FB\u52A8\u3002\u884C\u52A8\u63CF\u8FF0\u4E2D\u7684\u201C\u79FB\u52A8N\u683C\u201D\u8868\u793A\u8FDE\u7EED\u6267\u884CN\u6761\u540C\u65B9\u5411\u7684\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u3002\u521D\u59CB\u4F4D\u4E8E\u5165\u53E3\u4E0D\u89C6\u4E3A\u8FDB\u5165\u8BE5\u683C\uFF1B\u53EA\u6709\u6210\u529F\u79FB\u52A8\u8FDB\u5165\u76EE\u6807\u683C\u65F6\uFF0C\u624D\u7ED3\u7B97\u8BE5\u683C\u7684\u6548\u679C\u3002",
1237
+ "\u82E5\u76EE\u6807\u683C\u662F\u5899\u683C\u6216\u5728\u5730\u56FE\u5916\uFF0C\u63A2\u9669\u8005\u505C\u5728\u539F\u5730\uFF0C\u4F46\u8BE5\u6307\u4EE4\u4ECD\u7B97\u4F5C\u4E00\u6761\u5DF2\u6267\u884C\u7684\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\uFF0C\u4E4B\u540E\u7684\u6307\u4EE4\u7EE7\u7EED\u6267\u884C\u3002",
1238
+ doorRuleZh,
1239
+ "\u7B2C\u4E00\u6B21\u8FDB\u5165\u94A5\u5319\u683C\u65F6\u53D6\u5F97\u8BE5\u94A5\u5319\uFF1B\u540C\u4E00\u628A\u94A5\u5319\u4E0D\u80FD\u91CD\u590D\u53D6\u5F97\u3002",
1240
+ chestRuleZh,
1241
+ "\u7B2C\u4E00\u6B21\u8FDB\u5165\u9677\u9631\u683C\u65F6\u53D7\u5230\u8BE5\u9677\u9631\u6807\u660E\u7684\u4F24\u5BB3\uFF1B\u540C\u4E00\u9677\u9631\u4EE5\u540E\u4E0D\u518D\u751F\u6548\u3002",
1242
+ usesPotionObjects ? "\u836F\u6C34\u683C\u662F\u6307\u542B\u6709\u6CBB\u7597\u836F\u6C34\u3001\u6BD2\u836F\u3001\u89E3\u6BD2\u836F\u6C34\u3001\u52A0\u901F\u836F\u6C34\u6216\u51CF\u901F\u836F\u6C34\u7684\u683C\u5B50\u3002\u7B2C\u4E00\u6B21\u8FDB\u5165\u836F\u6C34\u683C\u65F6\u7ACB\u5373\u996E\u7528\u5176\u4E2D\u7684\u836F\u6C34\u5E76\u5E94\u7528\u5176\u6548\u679C\uFF1B\u540C\u4E00\u74F6\u836F\u6C34\u4E0D\u80FD\u91CD\u590D\u996E\u7528\u3002" : "\u7B2C\u4E00\u6B21\u8FDB\u5165\u836F\u54C1\u623F\u65F6\u6062\u590D\u5176\u6807\u660E\u7684\u5065\u5EB7\u503C\uFF0C\u4F46\u4E0D\u8D85\u8FC7\u5065\u5EB7\u4E0A\u9650\uFF1B\u540C\u4E00\u836F\u54C1\u623F\u4EE5\u540E\u4E0D\u518D\u751F\u6548\u3002",
1243
+ orderRuleZh,
1244
+ ...usesPoison ? [poisonRuleZh] : [],
1245
+ ...m.complexity === "advanced" ? [speedRuleZh] : [],
1246
+ "\u5065\u5EB7\u503C\u964D\u81F30\u6216\u4EE5\u4E0B\u65F6\uFF0C\u5C06\u5065\u5EB7\u503C\u8BB0\u4E3A0\uFF0C\u63A2\u9669\u8005\u6B7B\u4EA1\u3002\u6B7B\u4EA1\u540E\u5C1A\u672A\u6267\u884C\u7684\u6307\u4EE4\u4ECD\u88AB\u8BFB\u53D6\uFF0C\u4F46\u4E0D\u79FB\u52A8\u3001\u4E0D\u8017\u65F6\uFF0C\u4E5F\u4E0D\u6539\u53D8\u4EFB\u4F55\u72B6\u6001\u3002",
1247
+ "\u4E00\u65E6\u8FDB\u5165\u7EC8\u70B9\u683C\uFF0C\u201C\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\u201D\u6C38\u4E45\u8BB0\u4E3A\u771F\uFF0C\u5373\u4F7F\u540E\u6765\u79BB\u5F00\u6216\u6B7B\u4EA1\u4E5F\u4E0D\u64A4\u9500\u3002"
1248
+ ] : [
1249
+ "Each atomic movement instruction attempts to move exactly one cell up, down, left, or right. A direction followed by N cells means N consecutive atomic movement instructions in that direction. Starting at the entry does not count as entering its cell; target-cell effects are resolved only after a successful movement into that cell.",
1250
+ "If the target is a wall or outside the map, the explorer stays in place. The instruction still counts as executed, and later instructions continue.",
1251
+ doorRuleEn,
1252
+ "The first time the explorer enters a key cell, that key is collected. A key cannot be collected twice.",
1253
+ chestRuleEn,
1254
+ "The first time the explorer enters a trap cell, the trap reduces health by its stated amount. That trap has no effect afterward.",
1255
+ usesPotionObjects ? "A potion cell contains a healing, poison, antidote, haste, or slow potion. The first time the explorer enters a potion cell, its potion is drunk immediately and its effect is applied. A potion cannot be drunk twice." : "The first time the explorer enters a medicine room, it restores the stated health without exceeding maximum health. It has no effect afterward.",
1256
+ orderRuleEn,
1257
+ ...usesPoison ? [poisonRuleEn] : [],
1258
+ ...m.complexity === "advanced" ? [speedRuleEn] : [],
1259
+ "If health becomes 0 or less, set it to 0 and the explorer dies. Remaining instructions are still read but cause no movement, consume no time, and change no state.",
1260
+ "Once the explorer enters the goal, ever-reached-goal remains true even if the explorer later leaves or dies."
1261
+ ];
1262
+ return rules.map((rule, index) => `${index + 1}. ${rule}`).join("\n");
1263
+ }
1264
+ function renderQuestions(maze, language) {
1265
+ const materialKeys = maze.mechanisms.keyMaterials.length > 0;
1266
+ const questions = language === "zh" ? ["\u5168\u90E8\u6307\u4EE4\u5904\u7406\u5B8C\u6BD5\u540E\uFF0C\u63A2\u9669\u8005\u4F4D\u4E8E\u54EA\u4E00\u683C\uFF1F", materialKeys ? "\u6700\u540E\u6301\u6709\u7684\u94A5\u5319\u603B\u6570\uFF08\u6240\u6709\u6750\u8D28\u5408\u8BA1\uFF09\u662F\u591A\u5C11\uFF1F" : "\u6700\u540E\u6301\u6709\u51E0\u628A\u666E\u901A\u94A5\u5319\uFF1F", "\u6700\u540E\u53D6\u5F97\u7684\u5B9D\u7269\u603B\u6570\uFF08\u6240\u6709\u7C7B\u578B\u5408\u8BA1\uFF09\u662F\u591A\u5C11\uFF1F", "\u6700\u540E\u5269\u4F59\u591A\u5C11\u5065\u5EB7\u503C\uFF1F", "\u6700\u540E\u662F\u5426\u5B58\u6D3B\uFF1F", "\u662F\u5426\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\uFF1F", "\u5171\u6253\u5F00\u51E0\u9053\u95E8\uFF1F", "\u5171\u6253\u5F00\u51E0\u53EA\u5B9D\u7BB1\uFF1F", "\u5171\u89E6\u53D1\u51E0\u4E2A\u9677\u9631\uFF1F"] : ["Which cell contains the explorer after all instructions have been processed?", materialKeys ? "How many keys remain in total across all key materials?" : "How many ordinary keys remain?", "How many treasures were collected in total across all treasure types?", "How much health remains?", "Is the explorer alive at the end?", "Did the explorer ever reach the goal?", "How many doors were opened?", "How many chests were opened?", "How many traps were triggered?"];
1267
+ if (maze.objects.some((item) => item.type === "medicine")) questions.push(language === "zh" ? "\u5171\u4F7F\u7528\u51E0\u4E2A\u836F\u54C1\u623F\uFF1F" : "How many medicine rooms were used?");
1268
+ if (maze.objects.some((item) => item.type === "potion")) questions.push(language === "zh" ? "\u5171\u996E\u7528\u51E0\u74F6\u836F\u6C34\uFF1F" : "How many potions were drunk?");
1269
+ questions.push(language === "zh" ? "\u6309\u5168\u90E8\u5217\u51FA\u7684\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u8BA1\u6570\uFF08\u5305\u62EC\u6B7B\u4EA1\u540E\u4ECD\u88AB\u8BFB\u53D6\u7684\u6307\u4EE4\uFF09\uFF0C\u5176\u4E2D\u591A\u5C11\u6761\u6CA1\u6709\u6539\u53D8\u63A2\u9669\u8005\u7684\u4F4D\u7F6E\uFF1F" : "Counting every listed atomic movement instruction, including instructions read after death, how many did not change the explorer's position?");
1270
+ for (const material of maze.mechanisms.keyMaterials) questions.push(language === "zh" ? `\u6700\u540E\u5269\u4F59\u51E0\u628A${MATERIAL_NAMES[material].zh}\u94A5\u5319\uFF1F` : `How many ${MATERIAL_NAMES[material].en} keys remain?`);
1271
+ if (maze.mechanisms.treasureTypes.some((type) => type !== "treasure")) for (const type of maze.mechanisms.treasureTypes) questions.push(language === "zh" ? `\u5171\u53D6\u5F97\u591A\u5C11${treasureUnit(type)}${TREASURE_NAMES[type].zh}\uFF1F` : `How many ${TREASURE_NAMES[type].en}s were collected?`);
1272
+ if (maze.mechanisms.complexity === "advanced") questions.push(language === "zh" ? "\u7D2F\u8BA1\u8017\u65F6\u662F\u591A\u5C11\u4E2A\u65F6\u95F4\u5355\u4F4D\uFF1F" : "What is the total elapsed time in time units?", language === "zh" ? "\u6700\u7EC8\u901F\u5EA6\u72B6\u6001\uFF08\u6B63\u5E38\u3001\u52A0\u901F\u6216\u51CF\u901F\uFF09\u53CA\u901F\u5EA6\u6548\u679C\u5269\u4F59\u6307\u4EE4\u6570\u5206\u522B\u662F\u4EC0\u4E48\uFF1F" : "What are the final speed state (normal, fast, or slow) and the number of speed-effect instructions remaining?", language === "zh" ? "\u6700\u7EC8\u4E2D\u6BD2\u72B6\u6001\u8FD8\u5269\u51E0\u6761\u6307\u4EE4\uFF1F" : "How many poison instructions remain at the end?");
1273
+ return questions.map((item, index) => `${index + 1}. ${item}`).join("\n");
1274
+ }
1275
+ function renderActionDescription(actions, language, style) {
1276
+ const runs = compressActions(actions);
1277
+ if (language === "zh") {
1278
+ const connectors = ["\u968F\u540E", "\u63A5\u7740", "\u7136\u540E", "\u518D", "\u4E4B\u540E"];
1279
+ return paragraphize(runs.map((run, index) => `${index === 0 ? "\u63A2\u9669\u8005\u5148" : connectors[(style + index) % connectors.length] ?? "\u7136\u540E"}\u5411${DIRECTIONS[run.direction].zh}\u79FB\u52A8${run.count}\u683C`), "\uFF0C", "\u3002");
1280
+ }
1281
+ return paragraphize(runs.map((run, index) => `${index === 0 ? "First, the explorer moves" : index % 4 === 0 ? "The explorer then moves" : "then moves"} ${DIRECTIONS[run.direction].en} ${run.count} cell${plural(run.count)}`), ", ", ".");
1282
+ }
1283
+ function paragraphize(clauses, separator, terminator) {
1284
+ const sentences = [];
1285
+ for (let index = 0; index < clauses.length; index += 4) sentences.push(`${clauses.slice(index, index + 4).join(separator)}${terminator}`);
1286
+ return sentences.join("\n");
1287
+ }
1288
+ function compressActions(actions) {
1289
+ const runs = [];
1290
+ for (const direction of actions) {
1291
+ const prior = runs.at(-1);
1292
+ if (prior?.direction === direction) prior.count += 1;
1293
+ else runs.push({ direction, count: 1 });
1294
+ }
1295
+ return runs;
1296
+ }
1297
+ function routeThrough(maze, points) {
1298
+ const actions = [];
1299
+ for (let index = 1; index < points.length; index += 1) {
1300
+ const from = points[index - 1];
1301
+ const to = points[index];
1302
+ if (!from || !to) continue;
1303
+ const path = shortestPath(maze, from, to);
1304
+ if (path.length === 0) throw new Error("Could not connect scenario waypoints.");
1305
+ actions.push(...pathToActions(path));
1306
+ }
1307
+ return actions;
1308
+ }
1309
+ function pathToActions(path) {
1310
+ const actions = [];
1311
+ for (let index = 1; index < path.length; index += 1) {
1312
+ const current = path[index];
1313
+ const prior = path[index - 1];
1314
+ if (!current || !prior) continue;
1315
+ const dr = current.row - prior.row;
1316
+ const dc = current.col - prior.col;
1317
+ actions.push(dr === -1 ? "up" : dr === 1 ? "down" : dc === -1 ? "left" : "right");
1318
+ }
1319
+ return actions;
1320
+ }
1321
+ function wallDirection(maze, position) {
1322
+ const candidates = [["up", cell(position.row - 1, position.col)], ["down", cell(position.row + 1, position.col)], ["left", cell(position.row, position.col - 1)], ["right", cell(position.row, position.col + 1)]];
1323
+ return candidates.find(([, target]) => terrainAt(maze, target) !== ".")?.[0] ?? null;
1324
+ }
1325
+ function initialPossessions(state, language) {
1326
+ const materials = emptyMaterialCounts(state.keysByMaterial);
1327
+ const parts = Object.keys(materials).filter((key) => materials[key] > 0).map((key) => language === "zh" ? `${materials[key]}\u628A${MATERIAL_NAMES[key].zh}\u94A5\u5319` : `${materials[key]} ${MATERIAL_NAMES[key].en} key${plural(materials[key])}`);
1328
+ if (state.keys > 0) parts.unshift(language === "zh" ? `${state.keys}\u628A\u666E\u901A\u94A5\u5319` : `${state.keys} ordinary key${plural(state.keys)}`);
1329
+ const keys = parts.length > 0 ? parts.join(language === "zh" ? "\u3001" : ", ") : language === "zh" ? "\u6CA1\u6709\u94A5\u5319" : "no keys";
1330
+ const treasures = state.treasures > 0 ? language === "zh" ? `${state.treasures}\u4EF6\u5B9D\u7269` : `${state.treasures} treasure${plural(state.treasures)}` : language === "zh" ? "\u6CA1\u6709\u5B9D\u7269" : "no treasures";
1331
+ return language === "zh" ? `\u6301\u6709${keys}\uFF0C${treasures}` : `${keys} and ${treasures}`;
1332
+ }
1333
+ function treasureUnit(type) {
1334
+ return type === "coin" ? "\u679A" : type === "gem" ? "\u9897" : "\u4EF6";
1335
+ }
1336
+ function plural(value) {
1337
+ return value === 1 ? "" : "s";
1338
+ }
1339
+ function capitalize(value) {
1340
+ return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
1341
+ }
1342
+ function integer(value, name, minimum) {
1343
+ if (!Number.isInteger(value) || value < minimum) throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
1344
+ return value;
1345
+ }
1346
+ function oddInteger(value, name, minimum) {
1347
+ integer(value, name, minimum);
1348
+ if (value % 2 === 0) throw new Error(`${name} must be odd.`);
1349
+ return value;
1350
+ }
1351
+ function finiteNumber(value, name, minimum, maximum) {
1352
+ if (!Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
1353
+ return value;
1354
+ }
1355
+
1356
+ // src/cli.ts
1357
+ var VERSION = "0.3.0";
1358
+ try {
1359
+ const parsed = parseArguments(import_node_process.default.argv.slice(2));
1360
+ if (parsed === "help") {
1361
+ import_node_process.default.stdout.write(helpText());
1362
+ } else if (parsed === "version") {
1363
+ import_node_process.default.stdout.write(`${VERSION}
1364
+ `);
1365
+ } else {
1366
+ const trial = generateTrial(parsed.trial);
1367
+ if (parsed.format === "text") {
1368
+ import_node_process.default.stdout.write(parsed.command === "question" ? trial.question : trial.answer);
1369
+ } else if (parsed.command === "question") {
1370
+ import_node_process.default.stdout.write(
1371
+ `${JSON.stringify(
1372
+ {
1373
+ schemaVersion: trial.schemaVersion,
1374
+ kind: "question",
1375
+ options: trial.options,
1376
+ maze: {
1377
+ id: trial.maze.id,
1378
+ requestedSeed: trial.maze.requestedSeed,
1379
+ effectiveSeed: trial.maze.seed,
1380
+ metrics: trial.maze.metrics
1381
+ },
1382
+ sections: trial.sections,
1383
+ question: trial.question
1384
+ },
1385
+ null,
1386
+ 2
1387
+ )}
1388
+ `
1389
+ );
1390
+ } else {
1391
+ import_node_process.default.stdout.write(
1392
+ `${JSON.stringify(
1393
+ {
1394
+ schemaVersion: trial.schemaVersion,
1395
+ kind: "answer",
1396
+ options: trial.options,
1397
+ maze: {
1398
+ id: trial.maze.id,
1399
+ requestedSeed: trial.maze.requestedSeed,
1400
+ effectiveSeed: trial.maze.seed
1401
+ },
1402
+ answers: trial.result.answers,
1403
+ actionCount: trial.actions.length,
1404
+ answer: trial.answer
1405
+ },
1406
+ null,
1407
+ 2
1408
+ )}
1409
+ `
1410
+ );
1411
+ }
1412
+ }
1413
+ } catch (error) {
1414
+ const message = error instanceof Error ? error.message : String(error);
1415
+ import_node_process.default.stderr.write(`maze-test: ${message}
1416
+ Run "maze-test --help" for usage.
1417
+ `);
1418
+ import_node_process.default.exitCode = 1;
1419
+ }
1420
+ function parseArguments(args) {
1421
+ const values = [...args];
1422
+ let command = "question";
1423
+ const first = values[0];
1424
+ if (first && !first.startsWith("-")) {
1425
+ values.shift();
1426
+ if (first === "question" || first === "q") command = "question";
1427
+ else if (first === "answer" || first === "a") command = "answer";
1428
+ else throw new Error(`Unknown command: ${first}`);
1429
+ }
1430
+ const trial = {};
1431
+ let format = "text";
1432
+ for (let index = 0; index < values.length; index += 1) {
1433
+ const raw = values[index];
1434
+ if (!raw) continue;
1435
+ const equalIndex = raw.indexOf("=");
1436
+ const flag = equalIndex >= 0 ? raw.slice(0, equalIndex) : raw;
1437
+ const inline = equalIndex >= 0 ? raw.slice(equalIndex + 1) : void 0;
1438
+ if (flag === "--help" || flag === "-h") return "help";
1439
+ if (flag === "--version" || flag === "-v") return "version";
1440
+ if (flag === "--json") {
1441
+ format = "json";
1442
+ continue;
1443
+ }
1444
+ const value = inline ?? values[++index];
1445
+ if (value === void 0) throw new Error(`Missing value for ${flag}.`);
1446
+ switch (flag) {
1447
+ case "--seed":
1448
+ trial.seed = parseInteger(value, flag);
1449
+ break;
1450
+ case "--rows":
1451
+ trial.rows = parseInteger(value, flag);
1452
+ break;
1453
+ case "--cols":
1454
+ trial.cols = parseInteger(value, flag);
1455
+ break;
1456
+ case "--braid":
1457
+ trial.braid = parseNumber(value, flag);
1458
+ break;
1459
+ case "--doors":
1460
+ trial.doorCount = parseInteger(value, flag);
1461
+ break;
1462
+ case "--chests":
1463
+ trial.chestCount = parseInteger(value, flag);
1464
+ break;
1465
+ case "--traps":
1466
+ trial.trapCount = parseInteger(value, flag);
1467
+ break;
1468
+ case "--medicines":
1469
+ trial.medicineCount = parseInteger(value, flag);
1470
+ break;
1471
+ case "--potions":
1472
+ trial.potionCount = parseInteger(value, flag);
1473
+ break;
1474
+ case "--complexity":
1475
+ trial.complexity = value;
1476
+ break;
1477
+ case "--trap-damage":
1478
+ trial.trapDamages = parseIntegerList(value, flag);
1479
+ break;
1480
+ case "--potion-kinds":
1481
+ trial.potionKinds = parseList(value, flag);
1482
+ break;
1483
+ case "--key-materials":
1484
+ trial.keyMaterials = parseList(value, flag);
1485
+ break;
1486
+ case "--treasure-types":
1487
+ trial.treasureTypes = parseList(value, flag);
1488
+ break;
1489
+ case "--poison-damage":
1490
+ trial.poisonDamage = parseInteger(value, flag);
1491
+ break;
1492
+ case "--poison-duration":
1493
+ trial.poisonDuration = parseInteger(value, flag);
1494
+ break;
1495
+ case "--speed-duration":
1496
+ trial.speedDuration = parseInteger(value, flag);
1497
+ break;
1498
+ case "--scenario":
1499
+ trial.scenario = value;
1500
+ break;
1501
+ case "--lang":
1502
+ case "--language":
1503
+ trial.language = value;
1504
+ break;
1505
+ case "--style":
1506
+ trial.style = parseInteger(value, flag);
1507
+ break;
1508
+ case "--min-distance":
1509
+ trial.minDistance = parseInteger(value, flag);
1510
+ break;
1511
+ case "--max-attempts":
1512
+ trial.maxAttempts = parseInteger(value, flag);
1513
+ break;
1514
+ case "--format":
1515
+ if (value !== "text" && value !== "json") throw new Error(`Invalid format: ${value}`);
1516
+ format = value;
1517
+ break;
1518
+ default:
1519
+ throw new Error(`Unknown option: ${flag}`);
1520
+ }
1521
+ }
1522
+ return { command, format, trial };
1523
+ }
1524
+ function parseInteger(value, flag) {
1525
+ if (!/^-?\d+$/u.test(value)) throw new Error(`${flag} requires an integer.`);
1526
+ return Number(value);
1527
+ }
1528
+ function parseNumber(value, flag) {
1529
+ const parsed = Number(value);
1530
+ if (!Number.isFinite(parsed)) throw new Error(`${flag} requires a number.`);
1531
+ return parsed;
1532
+ }
1533
+ function parseList(value, flag) {
1534
+ const values = value.split(",").map((item) => item.trim()).filter(Boolean);
1535
+ if (values.length === 0) throw new Error(`${flag} requires a non-empty comma-separated list.`);
1536
+ return values;
1537
+ }
1538
+ function parseIntegerList(value, flag) {
1539
+ return parseList(value, flag).map((item) => parseInteger(item, flag));
1540
+ }
1541
+ function helpText() {
1542
+ return `maze-test ${VERSION}
1543
+
1544
+ Generate a deterministic maze question or its answer key.
1545
+
1546
+ Usage:
1547
+ maze-test [question] [options]
1548
+ maze-test answer [options]
1549
+
1550
+ Commands:
1551
+ question, q Generate the question (default)
1552
+ answer, a Generate the matching answer key
1553
+
1554
+ Options:
1555
+ --seed <integer> Root seed (default: 1)
1556
+ --rows <odd integer> Number of rows (default: 15)
1557
+ --cols <odd integer> Number of columns (default: 15)
1558
+ --braid <0..1> Chance to remove a dead end (default: 0)
1559
+ --complexity <level> basic | intermediate | advanced (default: basic)
1560
+ --doors <integer> Number of doors and keys (preset-dependent)
1561
+ --chests <integer> Number of chests (preset-dependent)
1562
+ --traps <integer> Number of traps (preset-dependent)
1563
+ --potions <integer> Number of potions (preset-dependent)
1564
+ --medicines <integer> Legacy alias for --potions
1565
+ --trap-damage <list> Comma-separated positive damage values
1566
+ --potion-kinds <list> healing,poison,antidote,haste,slow
1567
+ --key-materials <list> copper,silver,gold
1568
+ --treasure-types <list> treasure,coin,gem,relic
1569
+ --poison-damage <int> Damage per poison tick
1570
+ --poison-duration <int> Poisoned instruction count
1571
+ --speed-duration <int> Fast/slow instruction count
1572
+ --scenario <name> success | treasure-and-leave | death-and-stop | mechanism-tour
1573
+ (default: success)
1574
+ --lang <language> en | zh (default: en)
1575
+ --style <integer> Deterministic wording variation (default: 0)
1576
+ --min-distance <integer> Minimum entry-to-goal distance (default: 0)
1577
+ --max-attempts <integer> Deterministic search limit (default: 500)
1578
+ --format <format> text | json (default: text)
1579
+ --json Alias for --format json
1580
+ -h, --help Show help
1581
+ -v, --version Show version
1582
+
1583
+ Examples:
1584
+ npx maze-test question --seed 42 --rows 15 --cols 15
1585
+ npx maze-test answer --seed 42 --rows 15 --cols 15
1586
+ npx maze-test question --seed 42 --lang zh
1587
+ npx maze-test question --seed 42 --complexity advanced --scenario mechanism-tour
1588
+ npx maze-test answer --seed 42 --format json
1589
+ `;
1590
+ }
1591
+ //# sourceMappingURL=cli.cjs.map