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