maze-test 0.1.0 → 0.2.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/index.js CHANGED
@@ -1,6 +1,1243 @@
1
+ // src/coordinates.ts
2
+ function cell(row, col) {
3
+ return { row, col };
4
+ }
5
+ function cellKey(value) {
6
+ return `${value.row},${value.col}`;
7
+ }
8
+ function sameCell(a, b) {
9
+ return a?.row === b?.row && a?.col === b?.col;
10
+ }
11
+ function columnLabel(col) {
12
+ if (!Number.isInteger(col) || col < 1) throw new Error(`Invalid column number: ${col}`);
13
+ let value = col;
14
+ let label = "";
15
+ while (value > 0) {
16
+ value -= 1;
17
+ label = String.fromCharCode(65 + value % 26) + label;
18
+ value = Math.floor(value / 26);
19
+ }
20
+ return label;
21
+ }
22
+ function letterNumberCoordinate(value) {
23
+ return `${columnLabel(value.col)}${value.row}`;
24
+ }
25
+ function rowColumnCoordinate(value) {
26
+ return `\u7B2C${value.row}\u884C\u7B2C${value.col}\u683C`;
27
+ }
28
+ function neighbors(value, rows, cols) {
29
+ return [
30
+ cell(value.row - 1, value.col),
31
+ cell(value.row + 1, value.col),
32
+ cell(value.row, value.col - 1),
33
+ cell(value.row, value.col + 1)
34
+ ].filter((item) => item.row >= 1 && item.row <= rows && item.col >= 1 && item.col <= cols);
35
+ }
36
+
37
+ // src/maze.ts
38
+ function generateSolidCellMaze(options = {}) {
39
+ const rows = oddDimension(options.rows ?? 15, "rows");
40
+ const cols = oddDimension(options.cols ?? 15, "cols");
41
+ const seed = integerOption(options.seed ?? 1, "seed", 0);
42
+ const requestedSeed = integerOption(options.requestedSeed ?? seed, "requestedSeed", 0);
43
+ const braid = numberOption(options.braid ?? 0, "braid", 0, 1);
44
+ const rng = mulberry32(seed);
45
+ const grid = Array.from({ length: rows }, () => Array(cols).fill("#"));
46
+ const logicalCells = [];
47
+ for (let row = 1; row < rows - 1; row += 2) {
48
+ for (let col = 1; col < cols - 1; col += 2) logicalCells.push({ row, col });
49
+ }
50
+ const start = logicalCells[randomInt(rng, logicalCells.length)];
51
+ if (!start) throw new Error("The maze has no logical cells.");
52
+ const visited = /* @__PURE__ */ new Set([cellKey(start)]);
53
+ const stack = [start];
54
+ grid[start.row][start.col] = ".";
55
+ while (stack.length > 0) {
56
+ const current = stack.at(-1);
57
+ if (!current) break;
58
+ const choices = shuffle(
59
+ rng,
60
+ logicalNeighbors(current, rows, cols).filter((item) => !visited.has(cellKey(item)))
61
+ );
62
+ const next = choices[0];
63
+ if (!next) {
64
+ stack.pop();
65
+ continue;
66
+ }
67
+ grid[(current.row + next.row) / 2][(current.col + next.col) / 2] = ".";
68
+ grid[next.row][next.col] = ".";
69
+ visited.add(cellKey(next));
70
+ stack.push(next);
71
+ }
72
+ braidDeadEnds(grid, braid, rng);
73
+ const terrain = grid.map((row) => row.join(""));
74
+ const first = farthestFloor(cell(2, 2), terrain).cell;
75
+ const second = farthestFloor(first, terrain).cell;
76
+ const maze = {
77
+ schemaVersion: "solid-cell-maze-v3@1",
78
+ id: options.id ?? `solid-maze-${rows}x${cols}-${requestedSeed}`,
79
+ seed,
80
+ requestedSeed,
81
+ rows,
82
+ cols,
83
+ braid,
84
+ coordinateSystem: {
85
+ origin: "top-left",
86
+ columns: "letters-left-to-right",
87
+ rows: "numbers-top-to-bottom"
88
+ },
89
+ terrain,
90
+ entry: first,
91
+ goal: second,
92
+ doors: [],
93
+ objects: [],
94
+ metrics: emptyMetrics(rows, cols)
95
+ };
96
+ maze.metrics = analyzeSolidCellMaze(maze);
97
+ return maze;
98
+ }
99
+ function decorateSolidCellMaze(maze, options = {}) {
100
+ const result = structuredClone(maze);
101
+ const decorationSeed = integerOption(options.seed ?? maze.seed + 71311, "decoration seed", 0);
102
+ const rng = mulberry32(decorationSeed);
103
+ const mainPath = shortestPath(result, result.entry, result.goal);
104
+ const occupied = /* @__PURE__ */ new Set([cellKey(result.entry), cellKey(result.goal)]);
105
+ const doorCount = integerOption(
106
+ options.doorCount ?? Math.max(1, Math.floor(result.metrics.floorCells / 120)),
107
+ "doorCount",
108
+ 0
109
+ );
110
+ const chestCount = integerOption(options.chestCount ?? 2, "chestCount", 0);
111
+ const trapCount = integerOption(options.trapCount ?? 2, "trapCount", 0);
112
+ const medicineCount = integerOption(options.medicineCount ?? 2, "medicineCount", 0);
113
+ const doorIndices = selectGatingDoorIndices(result, mainPath, doorCount);
114
+ result.doors = doorIndices.map((index, doorIndex) => {
115
+ const position = mainPath[index];
116
+ if (!position) throw new Error(`No path cell exists at door index ${index}.`);
117
+ occupied.add(cellKey(position));
118
+ return { id: `door-${doorIndex + 1}`, position, state: "closed" };
119
+ });
120
+ const objects = [];
121
+ for (let index = 0; index < result.doors.length; index += 1) {
122
+ const doorIndex = doorIndices[index];
123
+ if (doorIndex === void 0) throw new Error("Door index mismatch.");
124
+ const preferredIndex = Math.max(1, Math.floor(doorIndex * 0.55));
125
+ const position = freePathCellBefore(mainPath, preferredIndex, occupied);
126
+ occupied.add(cellKey(position));
127
+ const key = { id: `key-${index + 1}`, type: "key", position };
128
+ objects.push(key);
129
+ }
130
+ const degrees = degreeMap(result);
131
+ const deadEnds = shuffle(
132
+ rng,
133
+ floorCells(result).filter(
134
+ (item) => degrees.get(cellKey(item)) === 1 && !occupied.has(cellKey(item))
135
+ )
136
+ );
137
+ for (let index = 0; index < chestCount; index += 1) {
138
+ const position = takeFree(deadEnds, result, occupied, rng, "chest");
139
+ occupied.add(cellKey(position));
140
+ const chest = {
141
+ id: `chest-${index + 1}`,
142
+ type: "chest",
143
+ position,
144
+ treasures: 1 + index % 3
145
+ };
146
+ objects.push(chest);
147
+ }
148
+ const trapCandidates = shuffle(rng, mainPath.slice(2, -2));
149
+ for (let index = 0; index < trapCount; index += 1) {
150
+ const position = takeFree(trapCandidates, result, occupied, rng, "trap");
151
+ occupied.add(cellKey(position));
152
+ const trap = {
153
+ id: `trap-${index + 1}`,
154
+ type: "trap",
155
+ position,
156
+ damage: 1
157
+ };
158
+ objects.push(trap);
159
+ }
160
+ const medicineCandidates = shuffle(rng, floorCells(result));
161
+ for (let index = 0; index < medicineCount; index += 1) {
162
+ const position = takeFree(medicineCandidates, result, occupied, rng, "medicine");
163
+ occupied.add(cellKey(position));
164
+ const medicine = {
165
+ id: `medicine-${index + 1}`,
166
+ type: "medicine",
167
+ position,
168
+ recovery: 1
169
+ };
170
+ objects.push(medicine);
171
+ }
172
+ result.objects = objects;
173
+ result.metrics = analyzeSolidCellMaze(result);
174
+ return result;
175
+ }
176
+ function analyzeSolidCellMaze(maze) {
177
+ assertShape(maze);
178
+ const floors = floorCells(maze);
179
+ const graph = adjacency(maze);
180
+ const distances = bfsDistances(maze.entry, graph);
181
+ const degreeValues = [...graph.values()].map((items) => items.length);
182
+ const edgeCount = degreeValues.reduce((sum, value) => sum + value, 0) / 2;
183
+ const mainPath = shortestPath(maze, maze.entry, maze.goal);
184
+ const objectCounts = {};
185
+ for (const object of maze.objects ?? []) {
186
+ objectCounts[object.type] = (objectCounts[object.type] ?? 0) + 1;
187
+ }
188
+ return {
189
+ totalCells: maze.rows * maze.cols,
190
+ wallCells: maze.rows * maze.cols - floors.length,
191
+ floorCells: floors.length,
192
+ connectedFloorCells: distances.size,
193
+ connected: distances.size === floors.length,
194
+ floorAdjacencyEdges: edgeCount,
195
+ independentCycles: edgeCount - floors.length + 1,
196
+ deadEnds: degreeValues.filter((value) => value === 1).length,
197
+ junctions: degreeValues.filter((value) => value >= 3).length,
198
+ entryGoalDistance: mainPath.length > 0 ? mainPath.length - 1 : null,
199
+ doorCount: maze.doors.length,
200
+ gatingDoorCount: maze.doors.filter(
201
+ (door) => shortestPath(maze, maze.entry, maze.goal, door.position).length === 0
202
+ ).length,
203
+ objectCounts
204
+ };
205
+ }
206
+ function validateSolidCellMaze(maze) {
207
+ const errors = [];
208
+ try {
209
+ assertShape(maze);
210
+ } catch (error) {
211
+ return {
212
+ valid: false,
213
+ errors: [error instanceof Error ? error.message : String(error)],
214
+ metrics: null
215
+ };
216
+ }
217
+ for (let row = 1; row <= maze.rows; row += 1) {
218
+ for (let col = 1; col <= maze.cols; col += 1) {
219
+ const value = terrainAt(maze, cell(row, col));
220
+ if (value !== "#" && value !== ".") errors.push(`Invalid terrain at ${row},${col}: ${value}`);
221
+ if ((row === 1 || row === maze.rows || col === 1 || col === maze.cols) && value !== "#") {
222
+ errors.push(`The outer boundary is not a wall at ${row},${col}.`);
223
+ }
224
+ }
225
+ }
226
+ if (!isFloor(maze, maze.entry)) errors.push("The entry is not on a floor cell.");
227
+ if (!isFloor(maze, maze.goal)) errors.push("The goal is not on a floor cell.");
228
+ if (sameCell(maze.entry, maze.goal)) errors.push("The entry and goal overlap.");
229
+ const occupied = /* @__PURE__ */ new Set([cellKey(maze.entry), cellKey(maze.goal)]);
230
+ for (const door of maze.doors) {
231
+ if (!isFloor(maze, door.position)) errors.push(`${door.id} is not on a floor cell.`);
232
+ const key = cellKey(door.position);
233
+ if (occupied.has(key)) errors.push(`${door.id} overlaps another feature.`);
234
+ occupied.add(key);
235
+ }
236
+ for (const object of maze.objects) {
237
+ if (!isFloor(maze, object.position)) errors.push(`${object.id} is not on a floor cell.`);
238
+ const key = cellKey(object.position);
239
+ if (occupied.has(key)) errors.push(`${object.id} overlaps another feature.`);
240
+ occupied.add(key);
241
+ }
242
+ const metrics = analyzeSolidCellMaze(maze);
243
+ if (!metrics.connected) {
244
+ errors.push(`Floor cells are disconnected: ${metrics.connectedFloorCells}/${metrics.floorCells}.`);
245
+ }
246
+ if (metrics.deadEnds === 0) errors.push("The maze has no dead ends.");
247
+ if (metrics.junctions === 0) errors.push("The maze has no junctions.");
248
+ if (metrics.gatingDoorCount !== metrics.doorCount) {
249
+ errors.push(`${metrics.doorCount - metrics.gatingDoorCount} door(s) can be bypassed.`);
250
+ }
251
+ const path = shortestPath(maze, maze.entry, maze.goal);
252
+ const pathIndex = new Map(path.map((item, index) => [cellKey(item), index]));
253
+ for (let index = 0; index < maze.doors.length; index += 1) {
254
+ const door = maze.doors[index];
255
+ if (!door) continue;
256
+ const key = maze.objects.find((item) => item.id === `key-${index + 1}`);
257
+ const doorIndex = pathIndex.get(cellKey(door.position));
258
+ const keyIndex = key ? pathIndex.get(cellKey(key.position)) : void 0;
259
+ if (!key) errors.push(`Missing a key for ${door.id}.`);
260
+ else if (keyIndex === void 0 || doorIndex === void 0 || keyIndex >= doorIndex) {
261
+ errors.push(`${key.id} is not before ${door.id} on the main path.`);
262
+ }
263
+ }
264
+ return { valid: errors.length === 0, errors, metrics };
265
+ }
266
+ function shortestPath(maze, start, goal, excludedCell = null) {
267
+ if (excludedCell && (sameCell(start, excludedCell) || sameCell(goal, excludedCell))) return [];
268
+ const graph = adjacency(maze, excludedCell);
269
+ const queue = [start];
270
+ const parent = /* @__PURE__ */ new Map([[cellKey(start), null]]);
271
+ const values = /* @__PURE__ */ new Map([[cellKey(start), start]]);
272
+ let queueIndex = 0;
273
+ while (queueIndex < queue.length) {
274
+ const current = queue[queueIndex];
275
+ queueIndex += 1;
276
+ if (!current) break;
277
+ if (sameCell(current, goal)) break;
278
+ for (const next of graph.get(cellKey(current)) ?? []) {
279
+ const key = cellKey(next);
280
+ if (parent.has(key)) continue;
281
+ parent.set(key, cellKey(current));
282
+ values.set(key, next);
283
+ queue.push(next);
284
+ }
285
+ }
286
+ if (!parent.has(cellKey(goal))) return [];
287
+ const path = [];
288
+ let cursor = cellKey(goal);
289
+ while (cursor !== null) {
290
+ const value = values.get(cursor);
291
+ if (!value) throw new Error(`Could not reconstruct path at ${cursor}.`);
292
+ path.push(value);
293
+ cursor = parent.get(cursor) ?? null;
294
+ }
295
+ return path.reverse();
296
+ }
297
+ function terrainAt(maze, value) {
298
+ if (value.row < 1 || value.row > maze.rows || value.col < 1 || value.col > maze.cols) {
299
+ return null;
300
+ }
301
+ return maze.terrain[value.row - 1]?.[value.col - 1] ?? null;
302
+ }
303
+ function isFloor(maze, value) {
304
+ return terrainAt(maze, value) === ".";
305
+ }
306
+ function braidDeadEnds(grid, braid, rng) {
307
+ if (braid <= 0) return;
308
+ const rows = grid.length;
309
+ const cols = grid[0]?.length ?? 0;
310
+ const logicalCells = [];
311
+ for (let row = 1; row < rows - 1; row += 2) {
312
+ for (let col = 1; col < cols - 1; col += 2) logicalCells.push({ row, col });
313
+ }
314
+ const deadEnds = shuffle(
315
+ rng,
316
+ logicalCells.filter((value) => openLogicalNeighbors(grid, value).length === 1)
317
+ );
318
+ for (const current of deadEnds) {
319
+ if (rng() > braid) continue;
320
+ const closed = shuffle(
321
+ rng,
322
+ logicalNeighbors(current, rows, cols).filter(
323
+ (next2) => grid[(current.row + next2.row) / 2]?.[(current.col + next2.col) / 2] === "#"
324
+ )
325
+ );
326
+ const next = closed[0];
327
+ if (!next) continue;
328
+ grid[(current.row + next.row) / 2][(current.col + next.col) / 2] = ".";
329
+ }
330
+ }
331
+ function openLogicalNeighbors(grid, current) {
332
+ return logicalNeighbors(current, grid.length, grid[0]?.length ?? 0).filter(
333
+ (next) => grid[(current.row + next.row) / 2]?.[(current.col + next.col) / 2] === "."
334
+ );
335
+ }
336
+ function logicalNeighbors(value, rows, cols) {
337
+ return [
338
+ { row: value.row - 2, col: value.col },
339
+ { row: value.row + 2, col: value.col },
340
+ { row: value.row, col: value.col - 2 },
341
+ { row: value.row, col: value.col + 2 }
342
+ ].filter((item) => item.row >= 1 && item.row < rows - 1 && item.col >= 1 && item.col < cols - 1);
343
+ }
344
+ function farthestFloor(start, terrain) {
345
+ const maze = { rows: terrain.length, cols: terrain[0]?.length ?? 0, terrain };
346
+ const graph = adjacency(maze);
347
+ const distances = bfsDistances(start, graph);
348
+ let farthest = start;
349
+ let distance = -1;
350
+ for (const [key, value] of distances) {
351
+ if (value <= distance) continue;
352
+ distance = value;
353
+ farthest = parseCellKey(key);
354
+ }
355
+ return { cell: farthest, distance };
356
+ }
357
+ function floorCells(maze) {
358
+ const values = [];
359
+ for (let row = 1; row <= maze.rows; row += 1) {
360
+ for (let col = 1; col <= maze.cols; col += 1) {
361
+ if (isFloor(maze, cell(row, col))) values.push(cell(row, col));
362
+ }
363
+ }
364
+ return values;
365
+ }
366
+ function adjacency(maze, excludedCell = null) {
367
+ const floors = floorCells(maze).filter((item) => !excludedCell || !sameCell(item, excludedCell));
368
+ const floorKeys = new Set(floors.map(cellKey));
369
+ return new Map(
370
+ floors.map((item) => [
371
+ cellKey(item),
372
+ neighbors(item, maze.rows, maze.cols).filter((next) => floorKeys.has(cellKey(next)))
373
+ ])
374
+ );
375
+ }
376
+ function bfsDistances(start, graph) {
377
+ const distances = /* @__PURE__ */ new Map([[cellKey(start), 0]]);
378
+ const queue = [start];
379
+ let queueIndex = 0;
380
+ while (queueIndex < queue.length) {
381
+ const current = queue[queueIndex];
382
+ queueIndex += 1;
383
+ if (!current) break;
384
+ const distance = distances.get(cellKey(current));
385
+ if (distance === void 0) continue;
386
+ for (const next of graph.get(cellKey(current)) ?? []) {
387
+ const key = cellKey(next);
388
+ if (distances.has(key)) continue;
389
+ distances.set(key, distance + 1);
390
+ queue.push(next);
391
+ }
392
+ }
393
+ return distances;
394
+ }
395
+ function degreeMap(maze) {
396
+ return new Map([...adjacency(maze)].map(([key, values]) => [key, values.length]));
397
+ }
398
+ function selectGatingDoorIndices(maze, mainPath, count) {
399
+ if (count === 0) return [];
400
+ const degrees = degreeMap(maze);
401
+ const candidates = [];
402
+ for (let index = 3; index < mainPath.length - 3; index += 1) {
403
+ const pathCell = mainPath[index];
404
+ if (!pathCell || (degrees.get(cellKey(pathCell)) ?? 0) !== 2) continue;
405
+ if (shortestPath(maze, maze.entry, maze.goal, pathCell).length === 0) candidates.push(index);
406
+ }
407
+ if (candidates.length < count) {
408
+ throw new Error(`Only ${candidates.length} non-bypassable path cells are available for ${count} doors.`);
409
+ }
410
+ const targets = spacedIndices(mainPath.length - 1, count, 0.28, 0.78);
411
+ const selected = [];
412
+ for (const target of targets) {
413
+ const available = candidates.filter((index) => !selected.includes(index));
414
+ available.sort(
415
+ (left, right) => Math.abs(left - target) - Math.abs(right - target) || left - right
416
+ );
417
+ const candidate = available[0];
418
+ if (candidate === void 0) throw new Error(`Could not place ${count} doors.`);
419
+ selected.push(candidate);
420
+ }
421
+ if (selected.length !== count) throw new Error(`Could not space ${count} doors along the main path.`);
422
+ return selected.sort((left, right) => left - right);
423
+ }
424
+ function freePathCellBefore(mainPath, preferredIndex, occupied) {
425
+ for (let distance = 0; distance < mainPath.length; distance += 1) {
426
+ for (const index of [preferredIndex - distance, preferredIndex + distance]) {
427
+ if (index <= 0 || index >= mainPath.length - 1) continue;
428
+ const candidate = mainPath[index];
429
+ if (candidate && !occupied.has(cellKey(candidate))) return candidate;
430
+ }
431
+ }
432
+ throw new Error("There is no free path cell on which to place a key.");
433
+ }
434
+ function takeFree(candidates, maze, occupied, rng, type) {
435
+ while (candidates.length > 0) {
436
+ const value = candidates.shift();
437
+ if (value && !occupied.has(cellKey(value))) return value;
438
+ }
439
+ const fallback = shuffle(rng, floorCells(maze)).find((item) => !occupied.has(cellKey(item)));
440
+ if (!fallback) throw new Error(`There are not enough free floor cells to place ${type}.`);
441
+ return fallback;
442
+ }
443
+ function spacedIndices(edgeCount, count, startRatio, endRatio) {
444
+ if (count === 0) return [];
445
+ const values = [];
446
+ for (let index = 0; index < count; index += 1) {
447
+ const ratio = count === 1 ? (startRatio + endRatio) / 2 : startRatio + (endRatio - startRatio) * index / (count - 1);
448
+ values.push(Math.max(2, Math.min(edgeCount - 2, Math.floor(edgeCount * ratio))));
449
+ }
450
+ return [...new Set(values)];
451
+ }
452
+ function parseCellKey(key) {
453
+ const [row, col] = key.split(",").map(Number);
454
+ if (row === void 0 || col === void 0) throw new Error(`Invalid cell key: ${key}`);
455
+ return cell(row, col);
456
+ }
457
+ function assertShape(maze) {
458
+ oddDimension(maze.rows, "rows");
459
+ oddDimension(maze.cols, "cols");
460
+ if (!Array.isArray(maze.terrain) || maze.terrain.length !== maze.rows) {
461
+ throw new Error("The terrain row count does not match rows.");
462
+ }
463
+ if (maze.terrain.some((row) => typeof row !== "string" || row.length !== maze.cols)) {
464
+ throw new Error("A terrain row does not match cols.");
465
+ }
466
+ }
467
+ function oddDimension(value, name) {
468
+ integerOption(value, name, 7);
469
+ if (value % 2 === 0) throw new Error(`${name} must be odd.`);
470
+ return value;
471
+ }
472
+ function integerOption(value, name, minimum) {
473
+ if (!Number.isInteger(value) || value < minimum) {
474
+ throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
475
+ }
476
+ return value;
477
+ }
478
+ function numberOption(value, name, minimum, maximum) {
479
+ if (!Number.isFinite(value) || value < minimum || value > maximum) {
480
+ throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
481
+ }
482
+ return value;
483
+ }
484
+ function randomInt(rng, maximum) {
485
+ return Math.floor(rng() * maximum);
486
+ }
487
+ function shuffle(rng, input) {
488
+ const values = [...input];
489
+ for (let index = values.length - 1; index > 0; index -= 1) {
490
+ const target = randomInt(rng, index + 1);
491
+ const prior = values[index];
492
+ const replacement = values[target];
493
+ if (prior === void 0 || replacement === void 0) continue;
494
+ values[index] = replacement;
495
+ values[target] = prior;
496
+ }
497
+ return values;
498
+ }
499
+ function mulberry32(seed) {
500
+ let value = seed >>> 0;
501
+ return () => {
502
+ value += 1831565813;
503
+ let mixed = value;
504
+ mixed = Math.imul(mixed ^ mixed >>> 15, mixed | 1);
505
+ mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61);
506
+ return ((mixed ^ mixed >>> 14) >>> 0) / 4294967296;
507
+ };
508
+ }
509
+ function emptyMetrics(rows, cols) {
510
+ return {
511
+ totalCells: rows * cols,
512
+ wallCells: rows * cols,
513
+ floorCells: 0,
514
+ connectedFloorCells: 0,
515
+ connected: false,
516
+ floorAdjacencyEdges: 0,
517
+ independentCycles: 0,
518
+ deadEnds: 0,
519
+ junctions: 0,
520
+ entryGoalDistance: null,
521
+ doorCount: 0,
522
+ gatingDoorCount: 0,
523
+ objectCounts: {}
524
+ };
525
+ }
526
+
527
+ // src/renderer.ts
528
+ var SYMBOLS = { key: "K", chest: "B", trap: "T", medicine: "H" };
529
+ function renderCharacterMaze(maze, language = "en") {
530
+ const rowWidth = String(maze.rows).length;
531
+ const prefix = " ".repeat(rowWidth + 1);
532
+ const objects = new Map(
533
+ maze.objects.map((item) => [cellKey(item.position), SYMBOLS[item.type]])
534
+ );
535
+ const doors = new Set(maze.doors.map((item) => cellKey(item.position)));
536
+ const heading = Array.from(
537
+ { length: maze.cols },
538
+ (_, index) => center(columnLabel(index + 1), 2)
539
+ ).join("");
540
+ const lines = [`${prefix}${heading}`.trimEnd()];
541
+ for (let row = 1; row <= maze.rows; row += 1) {
542
+ let line = `${String(row).padStart(rowWidth, " ")} `;
543
+ for (let col = 1; col <= maze.cols; col += 1) {
544
+ const position = cell(row, col);
545
+ let symbol = " ";
546
+ if (terrainAt(maze, position) === "#") symbol = "\u2588";
547
+ else if (sameCell(position, maze.entry)) symbol = "S";
548
+ else if (sameCell(position, maze.goal)) symbol = "G";
549
+ else if (doors.has(cellKey(position))) symbol = "D";
550
+ else symbol = objects.get(cellKey(position)) ?? " ";
551
+ line += symbol === "\u2588" ? "\u2588\u2588" : `${symbol} `;
552
+ }
553
+ lines.push(line.trimEnd());
554
+ }
555
+ lines.push("");
556
+ lines.push(
557
+ language === "zh" ? "\u56FE\u4F8B\uFF1A\u5B9E\u5FC3\u65B9\u5757\u4E3A\u5899\u683C\uFF1B\u7A7A\u767D\u4E3A\u901A\u8DEF\u683C\uFF1BS\u5165\u53E3\uFF1BG\u7EC8\u70B9\uFF1BD\u95E8\uFF1BK\u94A5\u5319\uFF1BB\u5B9D\u7BB1\uFF1BT\u9677\u9631\uFF1BH\u836F\u54C1\u623F\u3002\u5DE6\u4E0A\u89D2\u4E3AA1\u3002" : "Legend: solid blocks are walls; blank cells are passages; S entry; G goal; D door; K key; B chest; T trap; H medicine room. The top-left cell is A1."
558
+ );
559
+ return `${lines.join("\n")}
560
+ `;
561
+ }
562
+ function center(value, width) {
563
+ const left = Math.floor((width - value.length) / 2);
564
+ return `${" ".repeat(Math.max(0, left))}${value}${" ".repeat(
565
+ Math.max(0, width - value.length - left)
566
+ )}`;
567
+ }
568
+
569
+ // src/simulator.ts
570
+ var DELTAS = {
571
+ up: [-1, 0],
572
+ down: [1, 0],
573
+ left: [0, -1],
574
+ right: [0, 1]
575
+ };
576
+ function simulateTrial(maze, actions, initial = {}) {
577
+ const health = initial.health ?? 5;
578
+ const state = {
579
+ position: structuredClone(maze.entry),
580
+ health,
581
+ healthMax: initial.healthMax ?? health,
582
+ keys: initial.keys ?? 0,
583
+ treasures: initial.treasures ?? 0,
584
+ alive: health > 0,
585
+ reachedGoal: false,
586
+ openedDoors: [],
587
+ collectedKeys: [],
588
+ openedChests: [],
589
+ triggeredTraps: [],
590
+ usedMedicines: []
591
+ };
592
+ const trace = [];
593
+ for (let index = 0; index < actions.length; index += 1) {
594
+ const direction = actions[index];
595
+ if (!direction) continue;
596
+ trace.push(executeStep(maze, state, direction, index + 1));
597
+ }
598
+ return {
599
+ state: structuredClone(state),
600
+ trace,
601
+ answers: {
602
+ finalPosition: letterNumberCoordinate(state.position),
603
+ keys: state.keys,
604
+ treasures: state.treasures,
605
+ health: state.health,
606
+ alive: state.alive,
607
+ reachedGoal: state.reachedGoal,
608
+ openedDoors: state.openedDoors.length,
609
+ openedChests: state.openedChests.length,
610
+ triggeredTraps: state.triggeredTraps.length,
611
+ usedMedicines: state.usedMedicines.length,
612
+ blockedMoves: trace.filter((item) => !item.moved).length,
613
+ blockedAfterDeath: trace.filter((item) => item.reason === "dead").length
614
+ }
615
+ };
616
+ }
617
+ function executeStep(maze, state, direction, step) {
618
+ const before = structuredClone(state);
619
+ const events = [];
620
+ if (!state.alive) return record(step, direction, before, state, false, "dead", events);
621
+ const [dr, dc] = DELTAS[direction];
622
+ const target = cell(state.position.row + dr, state.position.col + dc);
623
+ if (terrainAt(maze, target) !== ".") {
624
+ return record(step, direction, before, state, false, "wall-or-outside", events);
625
+ }
626
+ const door = maze.doors.find((item) => sameCell(item.position, target));
627
+ if (door && !state.openedDoors.includes(door.id)) {
628
+ if (state.keys <= 0) {
629
+ return record(
630
+ step,
631
+ direction,
632
+ before,
633
+ state,
634
+ false,
635
+ "closed-door-without-key",
636
+ events
637
+ );
638
+ }
639
+ state.keys -= 1;
640
+ state.openedDoors.push(door.id);
641
+ events.push({ type: "open-door", id: door.id, keyCost: 1 });
642
+ }
643
+ state.position = target;
644
+ const object = maze.objects.find((item) => sameCell(item.position, target));
645
+ if (object?.type === "key" && !state.collectedKeys.includes(object.id)) {
646
+ state.collectedKeys.push(object.id);
647
+ state.keys += 1;
648
+ events.push({ type: "collect-key", id: object.id });
649
+ }
650
+ if (object?.type === "chest" && !state.openedChests.includes(object.id)) {
651
+ if (state.keys > 0) {
652
+ state.keys -= 1;
653
+ state.openedChests.push(object.id);
654
+ state.treasures += object.treasures;
655
+ events.push({
656
+ type: "open-chest",
657
+ id: object.id,
658
+ keyCost: 1,
659
+ treasures: object.treasures
660
+ });
661
+ } else {
662
+ events.push({ type: "pass-closed-chest", id: object.id });
663
+ }
664
+ }
665
+ if (object?.type === "trap" && !state.triggeredTraps.includes(object.id)) {
666
+ state.triggeredTraps.push(object.id);
667
+ state.health = Math.max(0, state.health - object.damage);
668
+ events.push({ type: "trigger-trap", id: object.id, damage: object.damage });
669
+ }
670
+ if (object?.type === "medicine" && !state.usedMedicines.includes(object.id)) {
671
+ state.usedMedicines.push(object.id);
672
+ const prior = state.health;
673
+ state.health = Math.min(state.healthMax, state.health + object.recovery);
674
+ events.push({ type: "use-medicine", id: object.id, recovery: state.health - prior });
675
+ }
676
+ if (state.health <= 0 && state.alive) {
677
+ state.alive = false;
678
+ events.push({ type: "die" });
679
+ }
680
+ if (sameCell(state.position, maze.goal) && !state.reachedGoal) {
681
+ state.reachedGoal = true;
682
+ events.push({ type: "reach-goal" });
683
+ }
684
+ return record(step, direction, before, state, true, null, events);
685
+ }
686
+ function record(step, direction, before, state, moved, reason, events) {
687
+ return {
688
+ step,
689
+ direction,
690
+ before,
691
+ moved,
692
+ reason,
693
+ events,
694
+ after: structuredClone(state)
695
+ };
696
+ }
697
+ function stateKey(state) {
698
+ return [
699
+ cellKey(state.position),
700
+ state.health,
701
+ state.healthMax,
702
+ state.keys,
703
+ state.treasures,
704
+ state.alive,
705
+ state.reachedGoal,
706
+ [...state.openedDoors].sort().join(","),
707
+ [...state.collectedKeys].sort().join(","),
708
+ [...state.openedChests].sort().join(","),
709
+ [...state.triggeredTraps].sort().join(","),
710
+ [...state.usedMedicines].sort().join(",")
711
+ ].join("|");
712
+ }
713
+
714
+ // src/trial.ts
715
+ var DIRECTIONS = {
716
+ up: { en: "up", zh: "\u4E0A" },
717
+ down: { en: "down", zh: "\u4E0B" },
718
+ left: { en: "left", zh: "\u5DE6" },
719
+ right: { en: "right", zh: "\u53F3" }
720
+ };
721
+ var ENGLISH_RULES = [
722
+ "The explorer can move only one cell at a time to an orthogonally adjacent cell. An instruction to move several cells is executed as that many consecutive one-cell moves.",
723
+ "If the next cell is a wall or outside the map, the explorer stays in place, and the remaining actions continue.",
724
+ "A closed door occupies a whole cell. To enter it, the explorer must spend one key; the door then stays open. Without a key, the explorer stays in place.",
725
+ "The first time the explorer enters a cell containing a key, they collect it. A key cannot be collected twice.",
726
+ "The first time the explorer enters a cell containing an unopened chest, one key is spent to open it if a key is available. Without a key, the explorer may pass through but does not open the chest.",
727
+ "A trap removes one health point the first time it is entered and has no effect afterward.",
728
+ "A medicine room restores one health point the first time it is entered, without exceeding maximum health, and has no effect afterward.",
729
+ "When health reaches zero, the explorer dies. All remaining moves are still read but cannot change any state.",
730
+ "Once the explorer enters the goal cell, reached-goal remains true even if the explorer later leaves it."
731
+ ];
732
+ var CHINESE_RULES = [
733
+ "\u63A2\u9669\u8005\u6BCF\u6B21\u53EA\u80FD\u5411\u4E0A\u3001\u4E0B\u3001\u5DE6\u3001\u53F3\u76F8\u90BB\u7684\u4E00\u683C\u79FB\u52A8\uFF1B\u201C\u5411\u67D0\u65B9\u5411\u79FB\u52A8\u82E5\u5E72\u683C\u201D\u4F9D\u6B21\u6267\u884C\u76F8\u5E94\u6B21\u6570\u7684\u5355\u683C\u79FB\u52A8\u3002",
734
+ "\u5982\u679C\u4E00\u6B21\u79FB\u52A8\u7684\u76EE\u6807\u662F\u5899\u683C\u6216\u8D85\u51FA\u5730\u56FE\uFF0C\u63A2\u9669\u8005\u505C\u5728\u539F\u5730\uFF0C\u4F46\u540E\u7EED\u884C\u52A8\u4ECD\u7136\u7EE7\u7EED\u3002",
735
+ "\u95E8\u5360\u636E\u4E00\u4E2A\u5B8C\u6574\u683C\u5B50\u3002\u76EE\u6807\u683C\u662F\u5173\u95ED\u7684\u95E8\u65F6\uFF0C\u63A2\u9669\u8005\u82E5\u6301\u6709\u94A5\u5319\uFF0C\u5C31\u6D88\u8017\u4E00\u628A\u94A5\u5319\u3001\u6253\u5F00\u8BE5\u95E8\u5E76\u8FDB\u5165\u95E8\u683C\uFF1B\u6CA1\u6709\u94A5\u5319\u5219\u505C\u5728\u539F\u5730\u3002\u5DF2\u7ECF\u6253\u5F00\u7684\u95E8\u53EF\u4EE5\u76F4\u63A5\u8FDB\u5165\u3002",
736
+ "\u63A2\u9669\u8005\u7B2C\u4E00\u6B21\u8FDB\u5165\u67D0\u628A\u94A5\u5319\u6240\u5728\u7684\u683C\u5B50\u65F6\u53D6\u5F97\u8BE5\u94A5\u5319\uFF0C\u94A5\u5319\u6570\u589E\u52A0\u4E00\uFF1B\u540C\u4E00\u628A\u94A5\u5319\u4E0D\u80FD\u91CD\u590D\u53D6\u5F97\u3002",
737
+ "\u63A2\u9669\u8005\u7B2C\u4E00\u6B21\u8FDB\u5165\u5C1A\u672A\u6253\u5F00\u7684\u5B9D\u7BB1\u683C\u4E14\u6301\u6709\u94A5\u5319\u65F6\uFF0C\u6D88\u8017\u4E00\u628A\u94A5\u5319\u3001\u6253\u5F00\u5B9D\u7BB1\u5E76\u53D6\u5F97\u5176\u4E2D\u5168\u90E8\u5B9D\u7269\uFF1B\u6CA1\u6709\u94A5\u5319\u65F6\u53EF\u4EE5\u7ECF\u8FC7\u8BE5\u683C\uFF0C\u4F46\u4E0D\u80FD\u6253\u5F00\u5B9D\u7BB1\u3002",
738
+ "\u63A2\u9669\u8005\u7B2C\u4E00\u6B21\u8FDB\u5165\u67D0\u4E2A\u9677\u9631\u683C\u65F6\u51CF\u5C11\u4E00\u70B9\u5065\u5EB7\u503C\uFF1B\u540C\u4E00\u9677\u9631\u4EE5\u540E\u4E0D\u518D\u751F\u6548\u3002",
739
+ "\u4ECD\u7136\u5B58\u6D3B\u7684\u63A2\u9669\u8005\u7B2C\u4E00\u6B21\u8FDB\u5165\u67D0\u4E2A\u836F\u54C1\u623F\u65F6\u6062\u590D\u4E00\u70B9\u5065\u5EB7\u503C\uFF0C\u4F46\u4E0D\u5F97\u8D85\u8FC7\u5065\u5EB7\u4E0A\u9650\uFF1B\u540C\u4E00\u836F\u54C1\u623F\u4EE5\u540E\u4E0D\u518D\u751F\u6548\u3002",
740
+ "\u5065\u5EB7\u503C\u964D\u4E3A\u96F6\u65F6\u63A2\u9669\u8005\u6B7B\u4EA1\u3002\u6B7B\u4EA1\u4EE5\u540E\uFF0C\u6240\u6709\u5C1A\u672A\u6267\u884C\u7684\u79FB\u52A8\u90FD\u4E0D\u518D\u6539\u53D8\u4EFB\u4F55\u72B6\u6001\u3002",
741
+ "\u63A2\u9669\u8005\u4E00\u65E6\u8FDB\u5165\u7EC8\u70B9\u683C\uFF0C\u5C31\u628A\u201C\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\u201D\u8BB0\u5F55\u4E3A\u771F\uFF1B\u4EE5\u540E\u5373\u4F7F\u79BB\u5F00\u7EC8\u70B9\uFF0C\u8BE5\u8BB0\u5F55\u4ECD\u7136\u4FDD\u6301\u4E3A\u771F\u3002"
742
+ ];
743
+ var ENGLISH_QUESTIONS = [
744
+ "Which cell is the explorer in after all actions are complete?",
745
+ "How many keys does the explorer have at the end?",
746
+ "How many treasures has the explorer collected at the end?",
747
+ "How many health points does the explorer have at the end?",
748
+ "Is the explorer alive at the end?",
749
+ "Did the explorer ever reach the goal?",
750
+ "How many doors were opened?",
751
+ "How many chests were opened?",
752
+ "How many traps were triggered?",
753
+ "How many medicine rooms were used?",
754
+ "How many individual moves did not change the explorer's position?"
755
+ ];
756
+ var CHINESE_QUESTIONS = [
757
+ "\u5168\u90E8\u884C\u52A8\u7ED3\u675F\u540E\uFF0C\u63A2\u9669\u8005\u4F4D\u4E8E\u54EA\u4E00\u683C\uFF1F",
758
+ "\u63A2\u9669\u8005\u6700\u540E\u6301\u6709\u51E0\u628A\u94A5\u5319\uFF1F",
759
+ "\u63A2\u9669\u8005\u6700\u540E\u53D6\u5F97\u4E86\u51E0\u4EF6\u5B9D\u7269\uFF1F",
760
+ "\u63A2\u9669\u8005\u6700\u540E\u8FD8\u5269\u51E0\u70B9\u5065\u5EB7\u503C\uFF1F",
761
+ "\u63A2\u9669\u8005\u6700\u540E\u662F\u5426\u4ECD\u7136\u5B58\u6D3B\uFF1F",
762
+ "\u884C\u52A8\u8FC7\u7A0B\u4E2D\uFF0C\u63A2\u9669\u8005\u662F\u5426\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\uFF1F",
763
+ "\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u6253\u5F00\u4E86\u51E0\u9053\u95E8\uFF1F",
764
+ "\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u6253\u5F00\u4E86\u51E0\u53EA\u5B9D\u7BB1\uFF1F",
765
+ "\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u89E6\u53D1\u4E86\u51E0\u4E2A\u9677\u9631\uFF1F",
766
+ "\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u4F7F\u7528\u4E86\u51E0\u4E2A\u836F\u54C1\u623F\uFF1F",
767
+ "\u884C\u52A8\u8FC7\u7A0B\u4E2D\u5171\u6709\u591A\u5C11\u6B21\u79FB\u52A8\u6CA1\u6709\u6539\u53D8\u63A2\u9669\u8005\u7684\u4F4D\u7F6E\uFF1F"
768
+ ];
769
+ function generateTrial(options = {}) {
770
+ const resolved = resolveTrialOptions(options);
771
+ const maze = selectMaze(resolved);
772
+ const plan = buildScenarioPlan(maze, resolved.scenario);
773
+ const result = simulateTrial(maze, plan.actions, plan.initialState);
774
+ verifyScenario(resolved.scenario, result.answers, maze);
775
+ const sections = {
776
+ map: renderMapDescription(maze, plan.initialState, resolved.language, resolved.style),
777
+ rules: renderRules(resolved.language),
778
+ actions: renderActionDescription(plan.actions, resolved.language, resolved.style),
779
+ questions: renderQuestions(resolved.language)
780
+ };
781
+ const question = renderQuestion(resolved, maze, sections);
782
+ const answer = renderAnswer(resolved, maze, result.answers, plan.actions.length);
783
+ return {
784
+ schemaVersion: "maze-test-trial@1",
785
+ options: resolved,
786
+ maze,
787
+ initialState: plan.initialState,
788
+ actions: plan.actions,
789
+ sections,
790
+ question,
791
+ answer,
792
+ result
793
+ };
794
+ }
795
+ function generateQuestion(options = {}) {
796
+ return generateTrial(options).question;
797
+ }
798
+ function generateAnswer(options = {}) {
799
+ return generateTrial(options).answer;
800
+ }
801
+ function resolveTrialOptions(options = {}) {
802
+ const scenario = options.scenario ?? "success";
803
+ const language = options.language ?? "en";
804
+ if (!["success", "treasure-and-leave", "death-and-stop"].includes(scenario)) {
805
+ throw new Error(`Unknown scenario: ${scenario}`);
806
+ }
807
+ if (language !== "en" && language !== "zh") throw new Error(`Unknown language: ${language}`);
808
+ const resolved = {
809
+ seed: integer(options.seed ?? 1, "seed", 0),
810
+ rows: oddInteger(options.rows ?? 15, "rows", 7),
811
+ cols: oddInteger(options.cols ?? 15, "cols", 7),
812
+ braid: finiteNumber(options.braid ?? 0, "braid", 0, 1),
813
+ doorCount: integer(options.doorCount ?? 1, "doorCount", 0),
814
+ chestCount: integer(options.chestCount ?? 2, "chestCount", 0),
815
+ trapCount: integer(options.trapCount ?? 2, "trapCount", 0),
816
+ medicineCount: integer(options.medicineCount ?? 2, "medicineCount", 0),
817
+ scenario,
818
+ language,
819
+ style: integer(options.style ?? 0, "style", 0),
820
+ minDistance: integer(options.minDistance ?? 0, "minDistance", 0),
821
+ maxAttempts: integer(options.maxAttempts ?? 500, "maxAttempts", 1)
822
+ };
823
+ if (scenario === "treasure-and-leave" && resolved.chestCount < 1) {
824
+ throw new Error("The treasure-and-leave scenario requires at least one chest.");
825
+ }
826
+ if (scenario === "treasure-and-leave" && resolved.medicineCount < 1) {
827
+ throw new Error("The treasure-and-leave scenario requires at least one medicine room.");
828
+ }
829
+ if (scenario === "death-and-stop" && resolved.trapCount < 1) {
830
+ throw new Error("The death-and-stop scenario requires at least one trap.");
831
+ }
832
+ return resolved;
833
+ }
834
+ function selectMaze(options) {
835
+ let lastError = null;
836
+ for (let offset = 0; offset < options.maxAttempts; offset += 1) {
837
+ const effectiveSeed = options.seed + offset;
838
+ try {
839
+ const base = generateSolidCellMaze({
840
+ rows: options.rows,
841
+ cols: options.cols,
842
+ braid: options.braid,
843
+ seed: effectiveSeed,
844
+ requestedSeed: options.seed,
845
+ id: `maze-test-${options.rows}x${options.cols}-${options.seed}`
846
+ });
847
+ const maze = decorateSolidCellMaze(base, {
848
+ seed: effectiveSeed + 8e4,
849
+ doorCount: options.doorCount,
850
+ chestCount: options.chestCount,
851
+ trapCount: options.trapCount,
852
+ medicineCount: options.medicineCount
853
+ });
854
+ const validation = validateSolidCellMaze(maze);
855
+ if (!validation.valid) throw new Error(validation.errors.join(" "));
856
+ if ((validation.metrics?.entryGoalDistance ?? 0) < options.minDistance) {
857
+ throw new Error(`The entry-goal distance is below ${options.minDistance}.`);
858
+ }
859
+ const plan = buildScenarioPlan(maze, options.scenario);
860
+ const result = simulateTrial(maze, plan.actions, plan.initialState);
861
+ verifyScenario(options.scenario, result.answers, maze);
862
+ return maze;
863
+ } catch (error) {
864
+ lastError = error;
865
+ }
866
+ }
867
+ const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : "";
868
+ throw new Error(
869
+ `Could not generate a valid trial in ${options.maxAttempts} deterministic attempts.${detail}`
870
+ );
871
+ }
872
+ function buildScenarioPlan(maze, scenario) {
873
+ if (scenario === "success") {
874
+ return {
875
+ initialState: { health: 5, healthMax: 5, keys: 0, treasures: 0 },
876
+ actions: pathToActions(shortestPath(maze, maze.entry, maze.goal))
877
+ };
878
+ }
879
+ if (scenario === "treasure-and-leave") {
880
+ const medicine = maze.objects.find((item) => item.type === "medicine");
881
+ const chest = maze.objects.find((item) => item.type === "chest");
882
+ if (!medicine || !chest) throw new Error("This scenario requires a medicine room and a chest.");
883
+ const path1 = shortestPath(maze, maze.entry, medicine.position);
884
+ const path2 = shortestPath(maze, medicine.position, chest.position);
885
+ const path3 = shortestPath(maze, chest.position, maze.goal);
886
+ const leavePath = [...path3].reverse().slice(0, Math.min(10, path3.length));
887
+ let actions = [
888
+ ...pathToActions(path1),
889
+ ...pathToActions(path2),
890
+ ...pathToActions(path3),
891
+ ...pathToActions(leavePath)
892
+ ];
893
+ const initialState = {
894
+ health: 3,
895
+ healthMax: 5,
896
+ keys: maze.doors.length + 2,
897
+ treasures: 0
898
+ };
899
+ const beforeBump = simulateTrial(maze, actions, initialState);
900
+ const bump = wallDirection(maze, beforeBump.state.position);
901
+ if (bump) actions = [...actions, bump, bump];
902
+ return { initialState, actions };
903
+ }
904
+ const trap = maze.objects.find((item) => item.type === "trap");
905
+ if (!trap) throw new Error("This scenario requires a trap.");
906
+ const toTrap = shortestPath(maze, maze.entry, trap.position);
907
+ const afterTrap = shortestPath(maze, trap.position, maze.goal).slice(0, 14);
908
+ return {
909
+ initialState: {
910
+ health: 1,
911
+ healthMax: 1,
912
+ keys: maze.doors.length + 1,
913
+ treasures: 0
914
+ },
915
+ actions: [...pathToActions(toTrap), ...pathToActions(afterTrap)]
916
+ };
917
+ }
918
+ function verifyScenario(scenario, answers, maze) {
919
+ if (scenario === "success" && (!answers.reachedGoal || !answers.alive || answers.finalPosition !== letterNumberCoordinate(maze.goal))) {
920
+ throw new Error("The success scenario did not finish alive at the goal.");
921
+ }
922
+ if (scenario === "treasure-and-leave" && (!answers.reachedGoal || answers.finalPosition === letterNumberCoordinate(maze.goal) || answers.openedChests < 1 || answers.usedMedicines < 1 || answers.blockedMoves < 2)) {
923
+ throw new Error("The treasure-and-leave scenario did not meet its invariants.");
924
+ }
925
+ if (scenario === "death-and-stop" && (answers.alive || answers.blockedAfterDeath < 5)) {
926
+ throw new Error("The death-and-stop scenario did not meet its invariants.");
927
+ }
928
+ }
929
+ function renderQuestion(options, maze, sections) {
930
+ const isZh = options.language === "zh";
931
+ const title = isZh ? "\u8FF7\u5BAB\u8BD5\u9898" : "Maze Trial";
932
+ const labels = isZh ? ["\u5730\u56FE\u63CF\u8FF0", "\u673A\u5236\u63CF\u8FF0", "\u884C\u52A8\u63CF\u8FF0", "\u95EE\u9898"] : ["Map", "Rules", "Actions", "Questions"];
933
+ const seedLine = maze.seed === options.seed ? `${isZh ? "\u79CD\u5B50" : "Seed"}: ${options.seed}` : `${isZh ? "\u79CD\u5B50" : "Seed"}: ${options.seed} (${isZh ? "\u6709\u6548\u8FF7\u5BAB\u79CD\u5B50" : "effective maze seed"}: ${maze.seed})`;
934
+ return [
935
+ `# ${title}`,
936
+ "",
937
+ seedLine,
938
+ "",
939
+ `## 1. ${labels[0]}`,
940
+ "",
941
+ sections.map,
942
+ "",
943
+ `## 2. ${labels[1]}`,
944
+ "",
945
+ sections.rules,
946
+ "",
947
+ `## 3. ${labels[2]}`,
948
+ "",
949
+ sections.actions,
950
+ "",
951
+ `## 4. ${labels[3]}`,
952
+ "",
953
+ sections.questions,
954
+ ""
955
+ ].join("\n");
956
+ }
957
+ function renderAnswer(options, maze, answers, actionCount) {
958
+ const zh = options.language === "zh";
959
+ const rows = zh ? [
960
+ ["\u6700\u7EC8\u4F4D\u7F6E", answers.finalPosition],
961
+ ["\u6700\u7EC8\u94A5\u5319\u6570", answers.keys],
962
+ ["\u6700\u7EC8\u5B9D\u7269\u6570", answers.treasures],
963
+ ["\u6700\u7EC8\u5065\u5EB7\u503C", answers.health],
964
+ ["\u662F\u5426\u5B58\u6D3B", answers.alive ? "\u662F" : "\u5426"],
965
+ ["\u662F\u5426\u66FE\u5230\u8FBE\u7EC8\u70B9", answers.reachedGoal ? "\u662F" : "\u5426"],
966
+ ["\u6253\u5F00\u7684\u95E8", answers.openedDoors],
967
+ ["\u6253\u5F00\u7684\u5B9D\u7BB1", answers.openedChests],
968
+ ["\u89E6\u53D1\u7684\u9677\u9631", answers.triggeredTraps],
969
+ ["\u4F7F\u7528\u7684\u836F\u54C1\u623F", answers.usedMedicines],
970
+ ["\u672A\u6539\u53D8\u4F4D\u7F6E\u7684\u79FB\u52A8", answers.blockedMoves]
971
+ ] : [
972
+ ["Final position", answers.finalPosition],
973
+ ["Keys remaining", answers.keys],
974
+ ["Treasures collected", answers.treasures],
975
+ ["Health remaining", answers.health],
976
+ ["Alive", answers.alive ? "Yes" : "No"],
977
+ ["Ever reached the goal", answers.reachedGoal ? "Yes" : "No"],
978
+ ["Doors opened", answers.openedDoors],
979
+ ["Chests opened", answers.openedChests],
980
+ ["Traps triggered", answers.triggeredTraps],
981
+ ["Medicine rooms used", answers.usedMedicines],
982
+ ["Moves that did not change position", answers.blockedMoves]
983
+ ];
984
+ return [
985
+ `# ${zh ? "\u6807\u51C6\u7B54\u6848" : "Answer Key"}`,
986
+ "",
987
+ `${zh ? "\u79CD\u5B50" : "Seed"}: ${options.seed}`,
988
+ ...maze.seed === options.seed ? [] : [
989
+ `${zh ? "\u6709\u6548\u8FF7\u5BAB\u79CD\u5B50" : "Effective maze seed"}: ${maze.seed}`
990
+ ],
991
+ "",
992
+ `| ${zh ? "\u95EE\u9898" : "Item"} | ${zh ? "\u7B54\u6848" : "Answer"} |`,
993
+ "|---|---|",
994
+ ...rows.map(([label, value]) => `| ${label} | ${String(value)} |`),
995
+ "",
996
+ `${zh ? "\u539F\u5B50\u884C\u52A8\u6570" : "Atomic action count"}: ${actionCount}`,
997
+ ""
998
+ ].join("\n");
999
+ }
1000
+ function renderMapDescription(maze, initial, language, style) {
1001
+ const terrain = renderTerrainDescription(maze, language, style);
1002
+ const objects = renderObjectDescription(maze, language);
1003
+ if (language === "zh") {
1004
+ return [
1005
+ `\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`,
1006
+ `\u5165\u53E3\u4F4D\u4E8E${letterNumberCoordinate(maze.entry)}\uFF0C\u7EC8\u70B9\u4F4D\u4E8E${letterNumberCoordinate(maze.goal)}\u3002`,
1007
+ "",
1008
+ terrain,
1009
+ "",
1010
+ "\u8FF7\u5BAB\u4E2D\u7684\u95E8\u548C\u5176\u4ED6\u5BF9\u8C61\u5206\u5E03\u5982\u4E0B\u3002\u95E8\u672C\u8EAB\u5360\u636E\u4E00\u4E2A\u5B8C\u6574\u683C\u5B50\u3002",
1011
+ objects,
1012
+ "",
1013
+ `\u8FF7\u5BAB\u4E2D\u6709\u4E00\u540D\u63A2\u9669\u8005\u3002\u4ED6\u4ECE\u5165\u53E3\u51FA\u53D1\uFF0C\u521D\u59CB\u5065\u5EB7\u503C\u4E3A${numberZh(initial.health)}\u70B9\uFF0C\u5065\u5EB7\u4E0A\u9650\u4E3A${numberZh(initial.healthMax)}\u70B9\uFF1B${initialPossessions(initial, language)}\uFF0C\u5C1A\u672A\u5230\u8FBE\u7EC8\u70B9\u3002`
1014
+ ].join("\n");
1015
+ }
1016
+ return [
1017
+ `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}.`,
1018
+ `The entry is at ${letterNumberCoordinate(maze.entry)}, and the goal is at ${letterNumberCoordinate(maze.goal)}.`,
1019
+ "",
1020
+ terrain,
1021
+ "",
1022
+ "Doors and other objects are distributed as follows. Each door occupies a whole cell.",
1023
+ objects,
1024
+ "",
1025
+ `An explorer starts at the entry with ${initial.health} health point${plural(initial.health)}, a maximum health of ${initial.healthMax}, ${initialPossessions(initial, language)}, and reached-goal set to false.`
1026
+ ].join("\n");
1027
+ }
1028
+ function renderTerrainDescription(maze, language, style) {
1029
+ const clauses = Array.from({ length: maze.rows }, (_, index) => index + 1).map((row) => {
1030
+ const kinds = Array.from(
1031
+ { length: maze.cols },
1032
+ (_, index) => terrainAt(maze, cell(row, index + 1)) === "." ? "floor" : "wall"
1033
+ );
1034
+ const label = language === "zh" ? [`\u7B2C${row}\u884C`, `${row}\u3001`, `\uFF08${row}\uFF09`][style % 3] : `Row ${row}: `;
1035
+ return `${label ?? `\u7B2C${row}\u884C`}${describeTerrainLine(kinds, language)}`;
1036
+ });
1037
+ 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");
1038
+ }
1039
+ function describeTerrainLine(kinds, language) {
1040
+ const walls = [];
1041
+ const floors = [];
1042
+ for (let index = 0; index < kinds.length; index += 1) {
1043
+ (kinds[index] === "wall" ? walls : floors).push(index + 1);
1044
+ }
1045
+ if (language === "zh") {
1046
+ if (walls.length === 0) return "\u90FD\u662F\u901A\u8DEF";
1047
+ if (floors.length === 0) return "\u90FD\u662F\u5899\u58C1";
1048
+ const limit2 = Math.floor(kinds.length / 3);
1049
+ if (walls.length <= limit2) return `\u9664\u4E86${positionList(walls, language)}\u662F\u5899\u58C1\u4EE5\u5916\uFF0C\u5176\u4F59\u90FD\u662F\u901A\u8DEF`;
1050
+ if (floors.length <= limit2) return `\u9664\u4E86${positionList(floors, language)}\u662F\u901A\u8DEF\u4EE5\u5916\uFF0C\u5176\u4F59\u90FD\u662F\u5899\u58C1`;
1051
+ return `${positionList(walls, language)}\u662F\u5899\u58C1\uFF0C${positionList(floors, language)}\u662F\u901A\u8DEF`;
1052
+ }
1053
+ if (walls.length === 0) return "all cells are passages";
1054
+ if (floors.length === 0) return "all cells are walls";
1055
+ const limit = Math.floor(kinds.length / 3);
1056
+ if (walls.length <= limit) return `all cells are passages except ${positionList(walls, language)}, which ${walls.length === 1 ? "is a wall" : "are walls"}`;
1057
+ if (floors.length <= limit) return `all cells are walls except ${positionList(floors, language)}, which ${floors.length === 1 ? "is a passage" : "are passages"}`;
1058
+ return `${positionList(walls, language)} ${walls.length === 1 ? "is a wall" : "are walls"}; ${positionList(floors, language)} ${floors.length === 1 ? "is a passage" : "are passages"}`;
1059
+ }
1060
+ function positionList(indices, language) {
1061
+ const ranges = [];
1062
+ let start = indices[0];
1063
+ let end = indices[0];
1064
+ if (start === void 0 || end === void 0) return "";
1065
+ for (const value of indices.slice(1)) {
1066
+ if (value === end + 1) {
1067
+ end = value;
1068
+ continue;
1069
+ }
1070
+ ranges.push([start, end]);
1071
+ start = value;
1072
+ end = value;
1073
+ }
1074
+ ranges.push([start, end]);
1075
+ const parts = ranges.map(
1076
+ ([from, to]) => language === "zh" ? from === to ? `\u7B2C${from}\u683C` : `\u7B2C${from}\u683C\u81F3\u7B2C${to}\u683C` : from === to ? `cell ${from}` : `cells ${from}-${to}`
1077
+ );
1078
+ if (parts.length === 1) return parts[0] ?? "";
1079
+ const last = parts.at(-1);
1080
+ return language === "zh" ? `${parts.slice(0, -1).join("\u3001")}\u548C${last}` : `${parts.slice(0, -1).join(", ")} and ${last}`;
1081
+ }
1082
+ function renderObjectDescription(maze, language) {
1083
+ const sentences = [];
1084
+ const doorPositions = maze.doors.map((item) => letterNumberCoordinate(item.position));
1085
+ const keys = maze.objects.filter((item) => item.type === "key");
1086
+ const chests = maze.objects.filter((item) => item.type === "chest");
1087
+ const traps = maze.objects.filter((item) => item.type === "trap");
1088
+ const medicines = maze.objects.filter((item) => item.type === "medicine");
1089
+ if (language === "zh") {
1090
+ if (doorPositions.length > 0) sentences.push(`${coordinateList(doorPositions, language)}${doorPositions.length > 1 ? "\u5404" : ""}\u6709\u4E00\u9053\u521D\u59CB\u5173\u95ED\u7684\u95E8\u3002`);
1091
+ const keyPositions = keys.map((item) => letterNumberCoordinate(item.position));
1092
+ if (keyPositions.length > 0) sentences.push(`${coordinateList(keyPositions, language)}${keyPositions.length > 1 ? "\u5404" : ""}\u653E\u7740\u4E00\u628A\u94A5\u5319\u3002`);
1093
+ for (const chest of chests) sentences.push(`${letterNumberCoordinate(chest.position)}\u653E\u7740\u4E00\u53EA\u521D\u59CB\u5173\u95ED\u7684\u5B9D\u7BB1\uFF0C\u7BB1\u5185\u6709${numberZh(chest.treasures)}\u4EF6\u5B9D\u7269\u3002`);
1094
+ const trapPositions = traps.map((item) => letterNumberCoordinate(item.position));
1095
+ if (trapPositions.length > 0) sentences.push(`${coordinateList(trapPositions, language)}${trapPositions.length > 1 ? "\u5404" : ""}\u8BBE\u6709\u4E00\u4E2A\u5C1A\u672A\u89E6\u53D1\u7684\u9677\u9631\u3002`);
1096
+ const medicinePositions = medicines.map((item) => letterNumberCoordinate(item.position));
1097
+ if (medicinePositions.length > 0) sentences.push(`${coordinateList(medicinePositions, language)}${medicinePositions.length > 1 ? "\u5404" : ""}\u6709\u4E00\u4E2A\u5C1A\u672A\u4F7F\u7528\u7684\u836F\u54C1\u623F\u3002`);
1098
+ } else {
1099
+ if (doorPositions.length > 0) sentences.push(`${coordinateList(doorPositions, language)} ${doorPositions.length === 1 ? "contains a closed door" : "each contain a closed door"}.`);
1100
+ const keyPositions = keys.map((item) => letterNumberCoordinate(item.position));
1101
+ if (keyPositions.length > 0) sentences.push(`${coordinateList(keyPositions, language)} ${keyPositions.length === 1 ? "contains a key" : "each contain a key"}.`);
1102
+ for (const chest of chests) sentences.push(`${letterNumberCoordinate(chest.position)} contains a closed chest with ${chest.treasures} treasure${plural(chest.treasures)}.`);
1103
+ const trapPositions = traps.map((item) => letterNumberCoordinate(item.position));
1104
+ if (trapPositions.length > 0) sentences.push(`${coordinateList(trapPositions, language)} ${trapPositions.length === 1 ? "contains an untriggered trap" : "each contain an untriggered trap"}.`);
1105
+ const medicinePositions = medicines.map((item) => letterNumberCoordinate(item.position));
1106
+ if (medicinePositions.length > 0) sentences.push(`${coordinateList(medicinePositions, language)} ${medicinePositions.length === 1 ? "contains an unused medicine room" : "each contain an unused medicine room"}.`);
1107
+ }
1108
+ return sentences.length > 0 ? sentences.join("\n") : language === "zh" ? "\u6CA1\u6709\u95E8\u6216\u5176\u4ED6\u5BF9\u8C61\u3002" : "There are no doors or other objects.";
1109
+ }
1110
+ function renderRules(language) {
1111
+ return (language === "zh" ? CHINESE_RULES : ENGLISH_RULES).join("\n");
1112
+ }
1113
+ function renderQuestions(language) {
1114
+ return (language === "zh" ? CHINESE_QUESTIONS : ENGLISH_QUESTIONS).join("\n");
1115
+ }
1116
+ function renderActionDescription(actions, language, style) {
1117
+ const runs = compressActions(actions);
1118
+ if (language === "zh") {
1119
+ const connectors = ["\u968F\u540E", "\u63A5\u7740", "\u7136\u540E", "\u518D", "\u4E4B\u540E"];
1120
+ const clauses2 = runs.map((run, index) => {
1121
+ const connector = index === 0 ? "\u63A2\u9669\u8005\u5148" : connectors[(style + index) % connectors.length];
1122
+ return `${connector ?? "\u7136\u540E"}\u5411${DIRECTIONS[run.direction].zh}\u79FB\u52A8${numberZh(run.count)}\u683C`;
1123
+ });
1124
+ return paragraphize(clauses2, "\uFF0C", "\u3002");
1125
+ }
1126
+ const clauses = runs.map((run, index) => {
1127
+ const connector = index === 0 ? "First, the explorer moves" : index % 4 === 0 ? "The explorer then moves" : "then moves";
1128
+ const directionForms = {
1129
+ up: ["up", "upward"],
1130
+ down: ["down", "downward"],
1131
+ left: ["left", "to the left"],
1132
+ right: ["right", "to the right"]
1133
+ };
1134
+ const forms = directionForms[run.direction];
1135
+ const direction = forms[(style + index) % forms.length] ?? DIRECTIONS[run.direction].en;
1136
+ return `${connector} ${direction} ${run.count} cell${plural(run.count)}`;
1137
+ });
1138
+ return paragraphize(clauses, ", ", ".");
1139
+ }
1140
+ function paragraphize(clauses, separator, terminator) {
1141
+ const sentences = [];
1142
+ for (let index = 0; index < clauses.length; index += 4) {
1143
+ sentences.push(`${clauses.slice(index, index + 4).join(separator)}${terminator}`);
1144
+ }
1145
+ return sentences.join("\n");
1146
+ }
1147
+ function compressActions(actions) {
1148
+ const runs = [];
1149
+ for (const direction of actions) {
1150
+ const prior = runs.at(-1);
1151
+ if (prior?.direction === direction) prior.count += 1;
1152
+ else runs.push({ direction, count: 1 });
1153
+ }
1154
+ return runs;
1155
+ }
1156
+ function pathToActions(path) {
1157
+ const actions = [];
1158
+ for (let index = 1; index < path.length; index += 1) {
1159
+ const current = path[index];
1160
+ const prior = path[index - 1];
1161
+ if (!current || !prior) continue;
1162
+ const dr = current.row - prior.row;
1163
+ const dc = current.col - prior.col;
1164
+ actions.push(dr === -1 ? "up" : dr === 1 ? "down" : dc === -1 ? "left" : "right");
1165
+ }
1166
+ return actions;
1167
+ }
1168
+ function wallDirection(maze, position) {
1169
+ const candidates = [
1170
+ ["up", cell(position.row - 1, position.col)],
1171
+ ["down", cell(position.row + 1, position.col)],
1172
+ ["left", cell(position.row, position.col - 1)],
1173
+ ["right", cell(position.row, position.col + 1)]
1174
+ ];
1175
+ return candidates.find(([, target]) => terrainAt(maze, target) !== ".")?.[0] ?? null;
1176
+ }
1177
+ function coordinateList(values, language) {
1178
+ if (values.length <= 1) return values[0] ?? "";
1179
+ const last = values.at(-1);
1180
+ return language === "zh" ? `${values.slice(0, -1).join("\u3001")}\u548C${last}` : `${values.slice(0, -1).join(", ")} and ${last}`;
1181
+ }
1182
+ function initialPossessions(state, language) {
1183
+ if (language === "zh") {
1184
+ const keys2 = state.keys > 0 ? `\u8D77\u521D\u6301\u6709${numberZh(state.keys)}\u628A\u94A5\u5319` : "\u8D77\u521D\u6CA1\u6709\u94A5\u5319";
1185
+ const treasures2 = state.treasures > 0 ? `\u6301\u6709${numberZh(state.treasures)}\u4EF6\u5B9D\u7269` : "\u6CA1\u6709\u5B9D\u7269";
1186
+ return `${keys2}\uFF0C${treasures2}`;
1187
+ }
1188
+ const keys = state.keys > 0 ? `${state.keys} key${plural(state.keys)}` : "no keys";
1189
+ const treasures = state.treasures > 0 ? `${state.treasures} treasure${plural(state.treasures)}` : "no treasures";
1190
+ return `${keys} and ${treasures}`;
1191
+ }
1192
+ function numberZh(value) {
1193
+ const forms = ["\u96F6", "\u4E00", "\u4E24", "\u4E09", "\u56DB", "\u4E94", "\u516D", "\u4E03", "\u516B", "\u4E5D", "\u5341"];
1194
+ return forms[value] ?? String(value);
1195
+ }
1196
+ function plural(value) {
1197
+ return value === 1 ? "" : "s";
1198
+ }
1199
+ function integer(value, name, minimum) {
1200
+ if (!Number.isInteger(value) || value < minimum) {
1201
+ throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
1202
+ }
1203
+ return value;
1204
+ }
1205
+ function oddInteger(value, name, minimum) {
1206
+ integer(value, name, minimum);
1207
+ if (value % 2 === 0) throw new Error(`${name} must be odd.`);
1208
+ return value;
1209
+ }
1210
+ function finiteNumber(value, name, minimum, maximum) {
1211
+ if (!Number.isFinite(value) || value < minimum || value > maximum) {
1212
+ throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
1213
+ }
1214
+ return value;
1215
+ }
1216
+
1
1217
  // src/index.ts
2
1218
  var packageName = "maze-test";
3
1219
  export {
4
- packageName
1220
+ analyzeSolidCellMaze,
1221
+ cell,
1222
+ cellKey,
1223
+ columnLabel,
1224
+ decorateSolidCellMaze,
1225
+ generateAnswer,
1226
+ generateQuestion,
1227
+ generateSolidCellMaze,
1228
+ generateTrial,
1229
+ isFloor,
1230
+ letterNumberCoordinate,
1231
+ neighbors,
1232
+ packageName,
1233
+ renderCharacterMaze,
1234
+ resolveTrialOptions,
1235
+ rowColumnCoordinate,
1236
+ sameCell,
1237
+ shortestPath,
1238
+ simulateTrial,
1239
+ stateKey,
1240
+ terrainAt,
1241
+ validateSolidCellMaze
5
1242
  };
6
1243
  //# sourceMappingURL=index.js.map