maze-test 0.2.0 → 0.4.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-CN.md +189 -0
- package/README.md +85 -7
- package/dist/cli.cjs +700 -418
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +700 -418
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +684 -428
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +102 -9
- package/dist/index.d.ts +102 -9
- package/dist/index.js +678 -428
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -36,6 +36,122 @@ function neighbors(value, rows, cols) {
|
|
|
36
36
|
].filter((item) => item.row >= 1 && item.row <= rows && item.col >= 1 && item.col <= cols);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// src/mechanisms.ts
|
|
40
|
+
var MATERIALS = ["copper", "silver", "gold"];
|
|
41
|
+
var TREASURE_TYPES = ["treasure", "coin", "gem", "relic"];
|
|
42
|
+
var POTION_KINDS = ["healing", "poison", "antidote", "haste", "slow"];
|
|
43
|
+
function mechanismPreset(complexity) {
|
|
44
|
+
if (complexity === "basic") {
|
|
45
|
+
return {
|
|
46
|
+
complexity,
|
|
47
|
+
trapDamages: [1],
|
|
48
|
+
potionKinds: ["healing"],
|
|
49
|
+
keyMaterials: [],
|
|
50
|
+
treasureTypes: ["treasure"],
|
|
51
|
+
poisonDamage: 1,
|
|
52
|
+
poisonDuration: 3,
|
|
53
|
+
speedDuration: 4,
|
|
54
|
+
movementTime: { normal: 1, fast: 1, slow: 1 }
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (complexity === "intermediate") {
|
|
58
|
+
return {
|
|
59
|
+
complexity,
|
|
60
|
+
trapDamages: [1, 2, 3],
|
|
61
|
+
potionKinds: ["healing", "poison", "antidote"],
|
|
62
|
+
keyMaterials: [],
|
|
63
|
+
treasureTypes: ["coin", "gem", "relic"],
|
|
64
|
+
poisonDamage: 1,
|
|
65
|
+
poisonDuration: 3,
|
|
66
|
+
speedDuration: 4,
|
|
67
|
+
movementTime: { normal: 1, fast: 1, slow: 1 }
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
complexity,
|
|
72
|
+
trapDamages: [1, 2, 3],
|
|
73
|
+
potionKinds: ["healing", "poison", "antidote", "haste", "slow"],
|
|
74
|
+
keyMaterials: ["copper", "silver", "gold"],
|
|
75
|
+
treasureTypes: ["coin", "gem", "relic"],
|
|
76
|
+
poisonDamage: 1,
|
|
77
|
+
poisonDuration: 3,
|
|
78
|
+
speedDuration: 4,
|
|
79
|
+
movementTime: { normal: 2, fast: 1, slow: 3 }
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function resolveMechanisms(options) {
|
|
83
|
+
const complexity = options.complexity ?? "basic";
|
|
84
|
+
if (!isComplexity(complexity)) throw new Error(`Unknown complexity: ${String(complexity)}`);
|
|
85
|
+
const preset = mechanismPreset(complexity);
|
|
86
|
+
const trapDamages = options.trapDamages ?? preset.trapDamages;
|
|
87
|
+
const potionKinds = options.potionKinds ?? preset.potionKinds;
|
|
88
|
+
const keyMaterials = options.keyMaterials ?? preset.keyMaterials;
|
|
89
|
+
const treasureTypes = options.treasureTypes ?? preset.treasureTypes;
|
|
90
|
+
validatePositiveList(trapDamages, "trapDamages");
|
|
91
|
+
validateEnumList(potionKinds, POTION_KINDS, "potionKinds");
|
|
92
|
+
validateEnumList(keyMaterials, MATERIALS, "keyMaterials", true);
|
|
93
|
+
validateEnumList(treasureTypes, TREASURE_TYPES, "treasureTypes");
|
|
94
|
+
if (complexity !== "advanced" && potionKinds.some((kind) => kind === "haste" || kind === "slow")) {
|
|
95
|
+
throw new Error("Haste and slow potions require advanced complexity so their movement-time costs are defined.");
|
|
96
|
+
}
|
|
97
|
+
if (complexity === "advanced" && keyMaterials.length === 0) {
|
|
98
|
+
throw new Error("Advanced complexity requires at least one key material.");
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
...preset,
|
|
102
|
+
trapDamages: [...trapDamages],
|
|
103
|
+
potionKinds: [...potionKinds],
|
|
104
|
+
keyMaterials: [...keyMaterials],
|
|
105
|
+
treasureTypes: [...treasureTypes],
|
|
106
|
+
poisonDamage: positiveInteger(options.poisonDamage ?? preset.poisonDamage, "poisonDamage"),
|
|
107
|
+
poisonDuration: positiveInteger(
|
|
108
|
+
options.poisonDuration ?? preset.poisonDuration,
|
|
109
|
+
"poisonDuration"
|
|
110
|
+
),
|
|
111
|
+
speedDuration: positiveInteger(options.speedDuration ?? preset.speedDuration, "speedDuration")
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function defaultObjectCounts(complexity) {
|
|
115
|
+
if (complexity === "basic") return { doors: 1, chests: 2, traps: 2, potions: 2 };
|
|
116
|
+
if (complexity === "intermediate") return { doors: 2, chests: 3, traps: 3, potions: 3 };
|
|
117
|
+
return { doors: 3, chests: 3, traps: 3, potions: 5 };
|
|
118
|
+
}
|
|
119
|
+
function emptyMaterialCounts(values = {}) {
|
|
120
|
+
return {
|
|
121
|
+
copper: values.copper ?? 0,
|
|
122
|
+
silver: values.silver ?? 0,
|
|
123
|
+
gold: values.gold ?? 0
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function emptyTreasureCounts(values = {}) {
|
|
127
|
+
return {
|
|
128
|
+
treasure: values.treasure ?? 0,
|
|
129
|
+
coin: values.coin ?? 0,
|
|
130
|
+
gem: values.gem ?? 0,
|
|
131
|
+
relic: values.relic ?? 0
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function totalMaterialKeys(counts) {
|
|
135
|
+
return counts.copper + counts.silver + counts.gold;
|
|
136
|
+
}
|
|
137
|
+
function isComplexity(value) {
|
|
138
|
+
return value === "basic" || value === "intermediate" || value === "advanced";
|
|
139
|
+
}
|
|
140
|
+
function positiveInteger(value, name) {
|
|
141
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`);
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
function validatePositiveList(values, name) {
|
|
145
|
+
if (values.length === 0) throw new Error(`${name} must not be empty.`);
|
|
146
|
+
for (const value of values) positiveInteger(value, name);
|
|
147
|
+
}
|
|
148
|
+
function validateEnumList(values, allowed, name, allowEmpty = false) {
|
|
149
|
+
if (!allowEmpty && values.length === 0) throw new Error(`${name} must not be empty.`);
|
|
150
|
+
for (const value of values) {
|
|
151
|
+
if (!allowed.includes(value)) throw new Error(`Unknown ${name} value: ${value}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
39
155
|
// src/maze.ts
|
|
40
156
|
function generateSolidCellMaze(options = {}) {
|
|
41
157
|
const rows = oddDimension(options.rows ?? 15, "rows");
|
|
@@ -43,6 +159,7 @@ function generateSolidCellMaze(options = {}) {
|
|
|
43
159
|
const seed = integerOption(options.seed ?? 1, "seed", 0);
|
|
44
160
|
const requestedSeed = integerOption(options.requestedSeed ?? seed, "requestedSeed", 0);
|
|
45
161
|
const braid = numberOption(options.braid ?? 0, "braid", 0, 1);
|
|
162
|
+
const mechanisms = options.mechanisms ?? mechanismPreset("basic");
|
|
46
163
|
const rng = mulberry32(seed);
|
|
47
164
|
const grid = Array.from({ length: rows }, () => Array(cols).fill("#"));
|
|
48
165
|
const logicalCells = [];
|
|
@@ -76,7 +193,7 @@ function generateSolidCellMaze(options = {}) {
|
|
|
76
193
|
const first = farthestFloor(cell(2, 2), terrain).cell;
|
|
77
194
|
const second = farthestFloor(first, terrain).cell;
|
|
78
195
|
const maze = {
|
|
79
|
-
schemaVersion: "solid-cell-maze-
|
|
196
|
+
schemaVersion: "solid-cell-maze-v4@1",
|
|
80
197
|
id: options.id ?? `solid-maze-${rows}x${cols}-${requestedSeed}`,
|
|
81
198
|
seed,
|
|
82
199
|
requestedSeed,
|
|
@@ -88,6 +205,7 @@ function generateSolidCellMaze(options = {}) {
|
|
|
88
205
|
columns: "letters-left-to-right",
|
|
89
206
|
rows: "numbers-top-to-bottom"
|
|
90
207
|
},
|
|
208
|
+
mechanisms: structuredClone(mechanisms),
|
|
91
209
|
terrain,
|
|
92
210
|
entry: first,
|
|
93
211
|
goal: second,
|
|
@@ -100,6 +218,8 @@ function generateSolidCellMaze(options = {}) {
|
|
|
100
218
|
}
|
|
101
219
|
function decorateSolidCellMaze(maze, options = {}) {
|
|
102
220
|
const result = structuredClone(maze);
|
|
221
|
+
const mechanisms = options.mechanisms ?? maze.mechanisms ?? mechanismPreset("basic");
|
|
222
|
+
result.mechanisms = structuredClone(mechanisms);
|
|
103
223
|
const decorationSeed = integerOption(options.seed ?? maze.seed + 71311, "decoration seed", 0);
|
|
104
224
|
const rng = mulberry32(decorationSeed);
|
|
105
225
|
const mainPath = shortestPath(result, result.entry, result.goal);
|
|
@@ -111,13 +231,23 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
111
231
|
);
|
|
112
232
|
const chestCount = integerOption(options.chestCount ?? 2, "chestCount", 0);
|
|
113
233
|
const trapCount = integerOption(options.trapCount ?? 2, "trapCount", 0);
|
|
114
|
-
const
|
|
234
|
+
const potionCount = integerOption(
|
|
235
|
+
options.potionCount ?? options.medicineCount ?? 2,
|
|
236
|
+
"potionCount",
|
|
237
|
+
0
|
|
238
|
+
);
|
|
115
239
|
const doorIndices = selectGatingDoorIndices(result, mainPath, doorCount);
|
|
116
240
|
result.doors = doorIndices.map((index, doorIndex) => {
|
|
117
241
|
const position = mainPath[index];
|
|
118
242
|
if (!position) throw new Error(`No path cell exists at door index ${index}.`);
|
|
119
243
|
occupied.add(cellKey(position));
|
|
120
|
-
|
|
244
|
+
const material = cyclicValue(mechanisms.keyMaterials, doorIndex + decorationSeed);
|
|
245
|
+
return {
|
|
246
|
+
id: `door-${doorIndex + 1}`,
|
|
247
|
+
position,
|
|
248
|
+
state: "closed",
|
|
249
|
+
...material ? { material } : {}
|
|
250
|
+
};
|
|
121
251
|
});
|
|
122
252
|
const objects = [];
|
|
123
253
|
for (let index = 0; index < result.doors.length; index += 1) {
|
|
@@ -126,7 +256,13 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
126
256
|
const preferredIndex = Math.max(1, Math.floor(doorIndex * 0.55));
|
|
127
257
|
const position = freePathCellBefore(mainPath, preferredIndex, occupied);
|
|
128
258
|
occupied.add(cellKey(position));
|
|
129
|
-
const
|
|
259
|
+
const door = result.doors[index];
|
|
260
|
+
const key = {
|
|
261
|
+
id: `key-${index + 1}`,
|
|
262
|
+
type: "key",
|
|
263
|
+
position,
|
|
264
|
+
...door?.material ? { material: door.material } : {}
|
|
265
|
+
};
|
|
130
266
|
objects.push(key);
|
|
131
267
|
}
|
|
132
268
|
const degrees = degreeMap(result);
|
|
@@ -139,11 +275,17 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
139
275
|
for (let index = 0; index < chestCount; index += 1) {
|
|
140
276
|
const position = takeFree(deadEnds, result, occupied, rng, "chest");
|
|
141
277
|
occupied.add(cellKey(position));
|
|
278
|
+
const treasureType = cyclicValue(mechanisms.treasureTypes, index + decorationSeed);
|
|
279
|
+
if (!treasureType) throw new Error("At least one treasure type is required.");
|
|
280
|
+
const count = 1 + index % 3;
|
|
281
|
+
const lockMaterial = cyclicValue(mechanisms.keyMaterials, index + decorationSeed + 1);
|
|
142
282
|
const chest = {
|
|
143
283
|
id: `chest-${index + 1}`,
|
|
144
284
|
type: "chest",
|
|
145
285
|
position,
|
|
146
|
-
treasures:
|
|
286
|
+
treasures: count,
|
|
287
|
+
contents: [{ type: treasureType, count }],
|
|
288
|
+
...lockMaterial ? { lockMaterial } : {}
|
|
147
289
|
};
|
|
148
290
|
objects.push(chest);
|
|
149
291
|
}
|
|
@@ -155,21 +297,36 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
155
297
|
id: `trap-${index + 1}`,
|
|
156
298
|
type: "trap",
|
|
157
299
|
position,
|
|
158
|
-
damage: 1
|
|
300
|
+
damage: mechanisms.trapDamages[index % mechanisms.trapDamages.length] ?? 1
|
|
159
301
|
};
|
|
160
302
|
objects.push(trap);
|
|
161
303
|
}
|
|
162
|
-
const
|
|
163
|
-
for (let index = 0; index <
|
|
164
|
-
const position = takeFree(
|
|
304
|
+
const potionCandidates = shuffle(rng, floorCells(result));
|
|
305
|
+
for (let index = 0; index < potionCount; index += 1) {
|
|
306
|
+
const position = takeFree(potionCandidates, result, occupied, rng, "potion");
|
|
165
307
|
occupied.add(cellKey(position));
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
308
|
+
if (mechanisms.complexity === "basic" && mechanisms.potionKinds.length === 1 && mechanisms.potionKinds[0] === "healing") {
|
|
309
|
+
const medicine = {
|
|
310
|
+
id: `medicine-${index + 1}`,
|
|
311
|
+
type: "medicine",
|
|
312
|
+
position,
|
|
313
|
+
recovery: 1
|
|
314
|
+
};
|
|
315
|
+
objects.push(medicine);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const kind = mechanisms.potionKinds[index % mechanisms.potionKinds.length];
|
|
319
|
+
if (!kind) throw new Error("At least one potion kind is required.");
|
|
320
|
+
const potion = {
|
|
321
|
+
id: `potion-${index + 1}`,
|
|
322
|
+
type: "potion",
|
|
169
323
|
position,
|
|
170
|
-
|
|
324
|
+
kind,
|
|
325
|
+
...kind === "healing" ? { potency: 1 + index % 2 } : {},
|
|
326
|
+
...kind === "poison" ? { potency: mechanisms.poisonDamage, duration: mechanisms.poisonDuration } : {},
|
|
327
|
+
...kind === "haste" || kind === "slow" ? { duration: mechanisms.speedDuration } : {}
|
|
171
328
|
};
|
|
172
|
-
objects.push(
|
|
329
|
+
objects.push(potion);
|
|
173
330
|
}
|
|
174
331
|
result.objects = objects;
|
|
175
332
|
result.metrics = analyzeSolidCellMaze(result);
|
|
@@ -182,7 +339,8 @@ function analyzeSolidCellMaze(maze) {
|
|
|
182
339
|
const distances = bfsDistances(maze.entry, graph);
|
|
183
340
|
const degreeValues = [...graph.values()].map((items) => items.length);
|
|
184
341
|
const edgeCount = degreeValues.reduce((sum, value) => sum + value, 0) / 2;
|
|
185
|
-
const mainPath =
|
|
342
|
+
const mainPath = shortestPathInGraph(graph, maze.entry, maze.goal);
|
|
343
|
+
const entryGoalSeparators = separatingCellKeys(graph, maze.entry, maze.goal);
|
|
186
344
|
const objectCounts = {};
|
|
187
345
|
for (const object of maze.objects ?? []) {
|
|
188
346
|
objectCounts[object.type] = (objectCounts[object.type] ?? 0) + 1;
|
|
@@ -200,7 +358,7 @@ function analyzeSolidCellMaze(maze) {
|
|
|
200
358
|
entryGoalDistance: mainPath.length > 0 ? mainPath.length - 1 : null,
|
|
201
359
|
doorCount: maze.doors.length,
|
|
202
360
|
gatingDoorCount: maze.doors.filter(
|
|
203
|
-
(door) =>
|
|
361
|
+
(door) => entryGoalSeparators.has(cellKey(door.position))
|
|
204
362
|
).length,
|
|
205
363
|
objectCounts
|
|
206
364
|
};
|
|
@@ -261,6 +419,8 @@ function validateSolidCellMaze(maze) {
|
|
|
261
419
|
if (!key) errors.push(`Missing a key for ${door.id}.`);
|
|
262
420
|
else if (keyIndex === void 0 || doorIndex === void 0 || keyIndex >= doorIndex) {
|
|
263
421
|
errors.push(`${key.id} is not before ${door.id} on the main path.`);
|
|
422
|
+
} else if (door.material !== (key.type === "key" ? key.material : void 0)) {
|
|
423
|
+
errors.push(`${key.id} does not match the material of ${door.id}.`);
|
|
264
424
|
}
|
|
265
425
|
}
|
|
266
426
|
return { valid: errors.length === 0, errors, metrics };
|
|
@@ -268,6 +428,9 @@ function validateSolidCellMaze(maze) {
|
|
|
268
428
|
function shortestPath(maze, start, goal, excludedCell = null) {
|
|
269
429
|
if (excludedCell && (sameCell(start, excludedCell) || sameCell(goal, excludedCell))) return [];
|
|
270
430
|
const graph = adjacency(maze, excludedCell);
|
|
431
|
+
return shortestPathInGraph(graph, start, goal);
|
|
432
|
+
}
|
|
433
|
+
function shortestPathInGraph(graph, start, goal) {
|
|
271
434
|
const queue = [start];
|
|
272
435
|
const parent = /* @__PURE__ */ new Map([[cellKey(start), null]]);
|
|
273
436
|
const values = /* @__PURE__ */ new Map([[cellKey(start), start]]);
|
|
@@ -399,12 +562,14 @@ function degreeMap(maze) {
|
|
|
399
562
|
}
|
|
400
563
|
function selectGatingDoorIndices(maze, mainPath, count) {
|
|
401
564
|
if (count === 0) return [];
|
|
402
|
-
const
|
|
565
|
+
const graph = adjacency(maze);
|
|
566
|
+
const separators = separatingCellKeys(graph, maze.entry, maze.goal);
|
|
403
567
|
const candidates = [];
|
|
404
568
|
for (let index = 3; index < mainPath.length - 3; index += 1) {
|
|
405
569
|
const pathCell = mainPath[index];
|
|
406
|
-
if (!pathCell
|
|
407
|
-
|
|
570
|
+
if (!pathCell) continue;
|
|
571
|
+
const key = cellKey(pathCell);
|
|
572
|
+
if ((graph.get(key)?.length ?? 0) === 2 && separators.has(key)) candidates.push(index);
|
|
408
573
|
}
|
|
409
574
|
if (candidates.length < count) {
|
|
410
575
|
throw new Error(`Only ${candidates.length} non-bypassable path cells are available for ${count} doors.`);
|
|
@@ -423,6 +588,59 @@ function selectGatingDoorIndices(maze, mainPath, count) {
|
|
|
423
588
|
if (selected.length !== count) throw new Error(`Could not space ${count} doors along the main path.`);
|
|
424
589
|
return selected.sort((left, right) => left - right);
|
|
425
590
|
}
|
|
591
|
+
function separatingCellKeys(graph, start, goal) {
|
|
592
|
+
const startKey = cellKey(start);
|
|
593
|
+
const goalKey = cellKey(goal);
|
|
594
|
+
if (!graph.has(startKey) || !graph.has(goalKey)) return /* @__PURE__ */ new Set();
|
|
595
|
+
const discovered = /* @__PURE__ */ new Map();
|
|
596
|
+
const low = /* @__PURE__ */ new Map();
|
|
597
|
+
const parent = /* @__PURE__ */ new Map([[startKey, null]]);
|
|
598
|
+
const stack = [
|
|
599
|
+
{ key: startKey, nextNeighbor: 0 }
|
|
600
|
+
];
|
|
601
|
+
let order = 1;
|
|
602
|
+
discovered.set(startKey, order);
|
|
603
|
+
low.set(startKey, order);
|
|
604
|
+
while (stack.length > 0) {
|
|
605
|
+
const frame = stack.at(-1);
|
|
606
|
+
if (!frame) break;
|
|
607
|
+
const adjacent = graph.get(frame.key) ?? [];
|
|
608
|
+
const next = adjacent[frame.nextNeighbor];
|
|
609
|
+
if (next) {
|
|
610
|
+
frame.nextNeighbor += 1;
|
|
611
|
+
const nextKey = cellKey(next);
|
|
612
|
+
if (!discovered.has(nextKey)) {
|
|
613
|
+
order += 1;
|
|
614
|
+
discovered.set(nextKey, order);
|
|
615
|
+
low.set(nextKey, order);
|
|
616
|
+
parent.set(nextKey, frame.key);
|
|
617
|
+
stack.push({ key: nextKey, nextNeighbor: 0 });
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (nextKey !== parent.get(frame.key)) {
|
|
621
|
+
low.set(frame.key, Math.min(low.get(frame.key) ?? order, discovered.get(nextKey) ?? order));
|
|
622
|
+
}
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
stack.pop();
|
|
626
|
+
const parentKey = parent.get(frame.key);
|
|
627
|
+
if (parentKey) {
|
|
628
|
+
low.set(parentKey, Math.min(low.get(parentKey) ?? order, low.get(frame.key) ?? order));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (!discovered.has(goalKey)) return /* @__PURE__ */ new Set();
|
|
632
|
+
const separators = /* @__PURE__ */ new Set();
|
|
633
|
+
let childKey = goalKey;
|
|
634
|
+
while (childKey !== startKey) {
|
|
635
|
+
const parentKey = parent.get(childKey);
|
|
636
|
+
if (!parentKey) break;
|
|
637
|
+
if (parentKey !== startKey && (low.get(childKey) ?? Number.POSITIVE_INFINITY) >= (discovered.get(parentKey) ?? Number.NEGATIVE_INFINITY)) {
|
|
638
|
+
separators.add(parentKey);
|
|
639
|
+
}
|
|
640
|
+
childKey = parentKey;
|
|
641
|
+
}
|
|
642
|
+
return separators;
|
|
643
|
+
}
|
|
426
644
|
function freePathCellBefore(mainPath, preferredIndex, occupied) {
|
|
427
645
|
for (let distance = 0; distance < mainPath.length; distance += 1) {
|
|
428
646
|
for (const index of [preferredIndex - distance, preferredIndex + distance]) {
|
|
@@ -498,6 +716,10 @@ function shuffle(rng, input) {
|
|
|
498
716
|
}
|
|
499
717
|
return values;
|
|
500
718
|
}
|
|
719
|
+
function cyclicValue(values, index) {
|
|
720
|
+
if (values.length === 0) return void 0;
|
|
721
|
+
return values[(index % values.length + values.length) % values.length];
|
|
722
|
+
}
|
|
501
723
|
function mulberry32(seed) {
|
|
502
724
|
let value = seed >>> 0;
|
|
503
725
|
return () => {
|
|
@@ -528,40 +750,105 @@ function emptyMetrics(rows, cols) {
|
|
|
528
750
|
|
|
529
751
|
// src/simulator.ts
|
|
530
752
|
var DELTAS = {
|
|
531
|
-
up:
|
|
532
|
-
down:
|
|
533
|
-
left:
|
|
534
|
-
right:
|
|
753
|
+
up: { row: -1, col: 0 },
|
|
754
|
+
down: { row: 1, col: 0 },
|
|
755
|
+
left: { row: 0, col: -1 },
|
|
756
|
+
right: { row: 0, col: 1 }
|
|
535
757
|
};
|
|
536
758
|
function simulateTrial(maze, actions, initial = {}) {
|
|
759
|
+
const mechanisms = maze.mechanisms ?? mechanismPreset("basic");
|
|
537
760
|
const health = initial.health ?? 5;
|
|
538
761
|
const state = {
|
|
539
762
|
position: structuredClone(maze.entry),
|
|
540
763
|
health,
|
|
541
764
|
healthMax: initial.healthMax ?? health,
|
|
542
765
|
keys: initial.keys ?? 0,
|
|
766
|
+
keysByMaterial: emptyMaterialCounts(initial.keysByMaterial),
|
|
543
767
|
treasures: initial.treasures ?? 0,
|
|
768
|
+
treasuresByType: emptyTreasureCounts(initial.treasuresByType),
|
|
769
|
+
elapsedTime: initial.elapsedTime ?? 0,
|
|
770
|
+
speed: initial.speed ?? "normal",
|
|
771
|
+
speedRemaining: initial.speedRemaining ?? 0,
|
|
772
|
+
poisonDamage: initial.poisonDamage ?? 0,
|
|
773
|
+
poisonRemaining: initial.poisonRemaining ?? 0,
|
|
544
774
|
alive: health > 0,
|
|
545
775
|
reachedGoal: false,
|
|
546
776
|
openedDoors: [],
|
|
547
777
|
collectedKeys: [],
|
|
548
778
|
openedChests: [],
|
|
549
779
|
triggeredTraps: [],
|
|
550
|
-
usedMedicines: []
|
|
780
|
+
usedMedicines: [],
|
|
781
|
+
usedPotions: []
|
|
551
782
|
};
|
|
552
783
|
const trace = [];
|
|
553
784
|
for (let index = 0; index < actions.length; index += 1) {
|
|
554
785
|
const direction = actions[index];
|
|
555
786
|
if (!direction) continue;
|
|
556
|
-
|
|
787
|
+
const before = cloneState(state);
|
|
788
|
+
const events = [];
|
|
789
|
+
if (!state.alive) {
|
|
790
|
+
trace.push({ step: index + 1, direction, before, moved: false, reason: "dead", events, after: cloneState(state) });
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const context = {
|
|
794
|
+
speedAtStart: state.speed,
|
|
795
|
+
speedRemainingAtStart: state.speedRemaining,
|
|
796
|
+
poisonDamageAtStart: state.poisonDamage,
|
|
797
|
+
poisonRemainingAtStart: state.poisonRemaining,
|
|
798
|
+
speedReplaced: false,
|
|
799
|
+
poisonReplaced: false
|
|
800
|
+
};
|
|
801
|
+
const timeCost = mechanisms.movementTime[context.speedAtStart];
|
|
802
|
+
state.elapsedTime += timeCost;
|
|
803
|
+
const delta = DELTAS[direction];
|
|
804
|
+
const target = cell(state.position.row + delta.row, state.position.col + delta.col);
|
|
805
|
+
let moved = false;
|
|
806
|
+
let reason = null;
|
|
807
|
+
if (terrainAt(maze, target) !== ".") {
|
|
808
|
+
reason = "wall-or-outside";
|
|
809
|
+
} else {
|
|
810
|
+
const door = maze.doors.find((item) => cellKey(item.position) === cellKey(target));
|
|
811
|
+
if (door && !state.openedDoors.includes(door.id)) {
|
|
812
|
+
if (door.material) {
|
|
813
|
+
if (state.keysByMaterial[door.material] < 1) {
|
|
814
|
+
reason = "closed-door-without-matching-key";
|
|
815
|
+
} else {
|
|
816
|
+
state.keysByMaterial[door.material] -= 1;
|
|
817
|
+
state.openedDoors.push(door.id);
|
|
818
|
+
events.push({ type: "open-door", id: door.id, material: door.material, keyCost: 1, timeCost });
|
|
819
|
+
}
|
|
820
|
+
} else if (state.keys < 1) {
|
|
821
|
+
reason = "closed-door-without-key";
|
|
822
|
+
} else {
|
|
823
|
+
state.keys -= 1;
|
|
824
|
+
state.openedDoors.push(door.id);
|
|
825
|
+
events.push({ type: "open-door", id: door.id, keyCost: 1, timeCost });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (!reason) {
|
|
829
|
+
state.position = target;
|
|
830
|
+
moved = true;
|
|
831
|
+
applyCellObjects(maze, state, events, context);
|
|
832
|
+
if (cellKey(state.position) === cellKey(maze.goal) && !state.reachedGoal) {
|
|
833
|
+
state.reachedGoal = true;
|
|
834
|
+
events.push({ type: "reach-goal" });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
finishStatuses(state, events, context);
|
|
839
|
+
trace.push({ step: index + 1, direction, before, moved, reason, events, after: cloneState(state) });
|
|
557
840
|
}
|
|
841
|
+
const blockedMoves = trace.filter((item) => !item.moved).length;
|
|
842
|
+
const blockedAfterDeath = trace.filter((item) => item.reason === "dead").length;
|
|
558
843
|
return {
|
|
559
|
-
state
|
|
844
|
+
state,
|
|
560
845
|
trace,
|
|
561
846
|
answers: {
|
|
562
847
|
finalPosition: letterNumberCoordinate(state.position),
|
|
563
|
-
keys: state.keys,
|
|
848
|
+
keys: state.keys + totalMaterialKeys(state.keysByMaterial),
|
|
849
|
+
keysByMaterial: structuredClone(state.keysByMaterial),
|
|
564
850
|
treasures: state.treasures,
|
|
851
|
+
treasuresByType: structuredClone(state.treasuresByType),
|
|
565
852
|
health: state.health,
|
|
566
853
|
alive: state.alive,
|
|
567
854
|
reachedGoal: state.reachedGoal,
|
|
@@ -569,90 +856,135 @@ function simulateTrial(maze, actions, initial = {}) {
|
|
|
569
856
|
openedChests: state.openedChests.length,
|
|
570
857
|
triggeredTraps: state.triggeredTraps.length,
|
|
571
858
|
usedMedicines: state.usedMedicines.length,
|
|
572
|
-
|
|
573
|
-
|
|
859
|
+
usedPotions: state.usedPotions.length,
|
|
860
|
+
elapsedTime: state.elapsedTime,
|
|
861
|
+
finalSpeed: state.speed,
|
|
862
|
+
speedRemaining: state.speedRemaining,
|
|
863
|
+
poisonDamage: state.poisonDamage,
|
|
864
|
+
poisonRemaining: state.poisonRemaining,
|
|
865
|
+
blockedMoves,
|
|
866
|
+
blockedAfterDeath
|
|
574
867
|
}
|
|
575
868
|
};
|
|
576
869
|
}
|
|
577
|
-
function
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
if (
|
|
581
|
-
const [dr, dc] = DELTAS[direction];
|
|
582
|
-
const target = cell(state.position.row + dr, state.position.col + dc);
|
|
583
|
-
if (terrainAt(maze, target) !== ".") {
|
|
584
|
-
return record(step, direction, before, state, false, "wall-or-outside", events);
|
|
585
|
-
}
|
|
586
|
-
const door = maze.doors.find((item) => sameCell(item.position, target));
|
|
587
|
-
if (door && !state.openedDoors.includes(door.id)) {
|
|
588
|
-
if (state.keys <= 0) {
|
|
589
|
-
return record(
|
|
590
|
-
step,
|
|
591
|
-
direction,
|
|
592
|
-
before,
|
|
593
|
-
state,
|
|
594
|
-
false,
|
|
595
|
-
"closed-door-without-key",
|
|
596
|
-
events
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
state.keys -= 1;
|
|
600
|
-
state.openedDoors.push(door.id);
|
|
601
|
-
events.push({ type: "open-door", id: door.id, keyCost: 1 });
|
|
602
|
-
}
|
|
603
|
-
state.position = target;
|
|
604
|
-
const object = maze.objects.find((item) => sameCell(item.position, target));
|
|
605
|
-
if (object?.type === "key" && !state.collectedKeys.includes(object.id)) {
|
|
870
|
+
function applyCellObjects(maze, state, events, context) {
|
|
871
|
+
const object = maze.objects.find((item) => cellKey(item.position) === cellKey(state.position));
|
|
872
|
+
if (!object) return;
|
|
873
|
+
if (object.type === "key" && !state.collectedKeys.includes(object.id)) {
|
|
606
874
|
state.collectedKeys.push(object.id);
|
|
607
|
-
state.
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
875
|
+
if (object.material) state.keysByMaterial[object.material] += 1;
|
|
876
|
+
else state.keys += 1;
|
|
877
|
+
events.push({
|
|
878
|
+
type: "collect-key",
|
|
879
|
+
id: object.id,
|
|
880
|
+
...object.material ? { material: object.material } : {}
|
|
881
|
+
});
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (object.type === "chest" && !state.openedChests.includes(object.id)) {
|
|
885
|
+
let canOpen = false;
|
|
886
|
+
if (object.lockMaterial) {
|
|
887
|
+
canOpen = state.keysByMaterial[object.lockMaterial] > 0;
|
|
888
|
+
if (canOpen) state.keysByMaterial[object.lockMaterial] -= 1;
|
|
889
|
+
} else {
|
|
890
|
+
canOpen = state.keys > 0;
|
|
891
|
+
if (canOpen) state.keys -= 1;
|
|
892
|
+
}
|
|
893
|
+
if (!canOpen) {
|
|
615
894
|
events.push({
|
|
616
|
-
type: "
|
|
895
|
+
type: "pass-closed-chest",
|
|
617
896
|
id: object.id,
|
|
618
|
-
|
|
619
|
-
treasures: object.treasures
|
|
897
|
+
...object.lockMaterial ? { material: object.lockMaterial } : {}
|
|
620
898
|
});
|
|
621
|
-
|
|
622
|
-
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
state.openedChests.push(object.id);
|
|
902
|
+
const contents = object.contents?.length > 0 ? object.contents : [{ type: "treasure", count: object.treasures }];
|
|
903
|
+
for (const content of contents) {
|
|
904
|
+
state.treasures += content.count;
|
|
905
|
+
state.treasuresByType[content.type] += content.count;
|
|
623
906
|
}
|
|
907
|
+
events.push({
|
|
908
|
+
type: "open-chest",
|
|
909
|
+
id: object.id,
|
|
910
|
+
...object.lockMaterial ? { material: object.lockMaterial } : {},
|
|
911
|
+
keyCost: 1,
|
|
912
|
+
treasures: object.treasures,
|
|
913
|
+
contents: structuredClone(contents)
|
|
914
|
+
});
|
|
915
|
+
return;
|
|
624
916
|
}
|
|
625
|
-
if (object
|
|
917
|
+
if (object.type === "trap" && !state.triggeredTraps.includes(object.id)) {
|
|
626
918
|
state.triggeredTraps.push(object.id);
|
|
627
919
|
state.health = Math.max(0, state.health - object.damage);
|
|
628
920
|
events.push({ type: "trigger-trap", id: object.id, damage: object.damage });
|
|
921
|
+
return;
|
|
629
922
|
}
|
|
630
|
-
if (object
|
|
923
|
+
if (object.type === "medicine" && !state.usedMedicines.includes(object.id)) {
|
|
631
924
|
state.usedMedicines.push(object.id);
|
|
632
|
-
const
|
|
925
|
+
const before = state.health;
|
|
633
926
|
state.health = Math.min(state.healthMax, state.health + object.recovery);
|
|
634
|
-
events.push({ type: "use-medicine", id: object.id, recovery: state.health -
|
|
927
|
+
events.push({ type: "use-medicine", id: object.id, recovery: state.health - before });
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (object.type === "potion" && !state.usedPotions.includes(object.id)) {
|
|
931
|
+
state.usedPotions.push(object.id);
|
|
932
|
+
applyPotion(state, object, context);
|
|
933
|
+
const recovery = object.kind === "healing" ? object.potency ?? 1 : void 0;
|
|
934
|
+
const damage = object.kind === "poison" ? object.potency ?? maze.mechanisms.poisonDamage : void 0;
|
|
935
|
+
events.push({
|
|
936
|
+
type: "drink-potion",
|
|
937
|
+
id: object.id,
|
|
938
|
+
potionKind: object.kind,
|
|
939
|
+
...recovery === void 0 ? {} : { recovery },
|
|
940
|
+
...damage === void 0 ? {} : { damage },
|
|
941
|
+
...object.duration === void 0 ? {} : { duration: object.duration }
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
function applyPotion(state, potion, context) {
|
|
946
|
+
if (potion.kind === "healing") {
|
|
947
|
+
state.health = Math.min(state.healthMax, state.health + (potion.potency ?? 1));
|
|
948
|
+
} else if (potion.kind === "poison") {
|
|
949
|
+
state.poisonDamage = potion.potency ?? 1;
|
|
950
|
+
state.poisonRemaining = potion.duration ?? 1;
|
|
951
|
+
context.poisonReplaced = true;
|
|
952
|
+
} else if (potion.kind === "antidote") {
|
|
953
|
+
state.poisonDamage = 0;
|
|
954
|
+
state.poisonRemaining = 0;
|
|
955
|
+
context.poisonReplaced = true;
|
|
956
|
+
} else if (potion.kind === "haste") {
|
|
957
|
+
state.speed = "fast";
|
|
958
|
+
state.speedRemaining = potion.duration ?? 1;
|
|
959
|
+
context.speedReplaced = true;
|
|
960
|
+
} else {
|
|
961
|
+
state.speed = "slow";
|
|
962
|
+
state.speedRemaining = potion.duration ?? 1;
|
|
963
|
+
context.speedReplaced = true;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
function finishStatuses(state, events, context) {
|
|
967
|
+
if (!context.poisonReplaced && context.poisonRemainingAtStart > 0) {
|
|
968
|
+
state.health = Math.max(0, state.health - context.poisonDamageAtStart);
|
|
969
|
+
state.poisonRemaining = context.poisonRemainingAtStart - 1;
|
|
970
|
+
state.poisonDamage = state.poisonRemaining > 0 ? context.poisonDamageAtStart : 0;
|
|
971
|
+
events.push({ type: "poison-tick", damage: context.poisonDamageAtStart });
|
|
972
|
+
}
|
|
973
|
+
if (!context.speedReplaced && context.speedRemainingAtStart > 0) {
|
|
974
|
+
state.speedRemaining = context.speedRemainingAtStart - 1;
|
|
975
|
+
if (state.speedRemaining === 0) {
|
|
976
|
+
state.speed = "normal";
|
|
977
|
+
events.push({ type: "speed-expired" });
|
|
978
|
+
}
|
|
635
979
|
}
|
|
636
980
|
if (state.health <= 0 && state.alive) {
|
|
981
|
+
state.health = 0;
|
|
637
982
|
state.alive = false;
|
|
638
983
|
events.push({ type: "die" });
|
|
639
984
|
}
|
|
640
|
-
if (sameCell(state.position, maze.goal) && !state.reachedGoal) {
|
|
641
|
-
state.reachedGoal = true;
|
|
642
|
-
events.push({ type: "reach-goal" });
|
|
643
|
-
}
|
|
644
|
-
return record(step, direction, before, state, true, null, events);
|
|
645
985
|
}
|
|
646
|
-
function
|
|
647
|
-
return
|
|
648
|
-
step,
|
|
649
|
-
direction,
|
|
650
|
-
before,
|
|
651
|
-
moved,
|
|
652
|
-
reason,
|
|
653
|
-
events,
|
|
654
|
-
after: structuredClone(state)
|
|
655
|
-
};
|
|
986
|
+
function cloneState(state) {
|
|
987
|
+
return structuredClone(state);
|
|
656
988
|
}
|
|
657
989
|
|
|
658
990
|
// src/trial.ts
|
|
@@ -662,54 +994,29 @@ var DIRECTIONS = {
|
|
|
662
994
|
left: { en: "left", zh: "\u5DE6" },
|
|
663
995
|
right: { en: "right", zh: "\u53F3" }
|
|
664
996
|
};
|
|
665
|
-
var
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
var
|
|
677
|
-
"\
|
|
678
|
-
"\
|
|
679
|
-
"\
|
|
680
|
-
"\
|
|
681
|
-
"\
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
"\
|
|
685
|
-
"\
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
"Which cell is the explorer in after all actions are complete?",
|
|
689
|
-
"How many keys does the explorer have at the end?",
|
|
690
|
-
"How many treasures has the explorer collected at the end?",
|
|
691
|
-
"How many health points does the explorer have at the end?",
|
|
692
|
-
"Is the explorer alive at the end?",
|
|
693
|
-
"Did the explorer ever reach the goal?",
|
|
694
|
-
"How many doors were opened?",
|
|
695
|
-
"How many chests were opened?",
|
|
696
|
-
"How many traps were triggered?",
|
|
697
|
-
"How many medicine rooms were used?",
|
|
698
|
-
"How many individual moves did not change the explorer's position?"
|
|
699
|
-
];
|
|
700
|
-
var CHINESE_QUESTIONS = [
|
|
701
|
-
"\u5168\u90E8\u884C\u52A8\u7ED3\u675F\u540E\uFF0C\u63A2\u9669\u8005\u4F4D\u4E8E\u54EA\u4E00\u683C\uFF1F",
|
|
702
|
-
"\u63A2\u9669\u8005\u6700\u540E\u6301\u6709\u51E0\u628A\u94A5\u5319\uFF1F",
|
|
703
|
-
"\u63A2\u9669\u8005\u6700\u540E\u53D6\u5F97\u4E86\u51E0\u4EF6\u5B9D\u7269\uFF1F",
|
|
704
|
-
"\u63A2\u9669\u8005\u6700\u540E\u8FD8\u5269\u51E0\u70B9\u5065\u5EB7\u503C\uFF1F",
|
|
705
|
-
"\u63A2\u9669\u8005\u6700\u540E\u662F\u5426\u4ECD\u7136\u5B58\u6D3B\uFF1F",
|
|
706
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\uFF0C\u63A2\u9669\u8005\u662F\u5426\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\uFF1F",
|
|
707
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u6253\u5F00\u4E86\u51E0\u9053\u95E8\uFF1F",
|
|
708
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u6253\u5F00\u4E86\u51E0\u53EA\u5B9D\u7BB1\uFF1F",
|
|
709
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u89E6\u53D1\u4E86\u51E0\u4E2A\u9677\u9631\uFF1F",
|
|
710
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u4F7F\u7528\u4E86\u51E0\u4E2A\u836F\u54C1\u623F\uFF1F",
|
|
711
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u5171\u6709\u591A\u5C11\u6B21\u79FB\u52A8\u6CA1\u6709\u6539\u53D8\u63A2\u9669\u8005\u7684\u4F4D\u7F6E\uFF1F"
|
|
712
|
-
];
|
|
997
|
+
var MATERIAL_NAMES = {
|
|
998
|
+
copper: { en: "copper", zh: "\u94DC" },
|
|
999
|
+
silver: { en: "silver", zh: "\u94F6" },
|
|
1000
|
+
gold: { en: "gold", zh: "\u91D1" }
|
|
1001
|
+
};
|
|
1002
|
+
var TREASURE_NAMES = {
|
|
1003
|
+
treasure: { en: "treasure", zh: "\u5B9D\u7269" },
|
|
1004
|
+
coin: { en: "coin", zh: "\u94B1\u5E01" },
|
|
1005
|
+
gem: { en: "gem", zh: "\u5B9D\u77F3" },
|
|
1006
|
+
relic: { en: "relic", zh: "\u9057\u7269" }
|
|
1007
|
+
};
|
|
1008
|
+
var POTION_NAMES = {
|
|
1009
|
+
healing: { en: "healing potion", zh: "\u6CBB\u7597\u836F\u6C34" },
|
|
1010
|
+
poison: { en: "poison potion", zh: "\u6BD2\u836F" },
|
|
1011
|
+
antidote: { en: "antidote potion", zh: "\u89E3\u6BD2\u836F\u6C34" },
|
|
1012
|
+
haste: { en: "haste potion", zh: "\u52A0\u901F\u836F\u6C34" },
|
|
1013
|
+
slow: { en: "slow potion", zh: "\u51CF\u901F\u836F\u6C34" }
|
|
1014
|
+
};
|
|
1015
|
+
var SPEED_NAMES = {
|
|
1016
|
+
normal: { en: "normal", zh: "\u6B63\u5E38" },
|
|
1017
|
+
fast: { en: "fast", zh: "\u52A0\u901F" },
|
|
1018
|
+
slow: { en: "slow", zh: "\u51CF\u901F" }
|
|
1019
|
+
};
|
|
713
1020
|
function generateTrial(options = {}) {
|
|
714
1021
|
const resolved = resolveTrialOptions(options);
|
|
715
1022
|
const maze = selectMaze(resolved);
|
|
@@ -718,82 +1025,72 @@ function generateTrial(options = {}) {
|
|
|
718
1025
|
verifyScenario(resolved.scenario, result.answers, maze);
|
|
719
1026
|
const sections = {
|
|
720
1027
|
map: renderMapDescription(maze, plan.initialState, resolved.language, resolved.style),
|
|
721
|
-
rules: renderRules(resolved.language),
|
|
1028
|
+
rules: renderRules(maze, resolved.language),
|
|
722
1029
|
actions: renderActionDescription(plan.actions, resolved.language, resolved.style),
|
|
723
|
-
questions: renderQuestions(resolved.language)
|
|
1030
|
+
questions: renderQuestions(maze, resolved.language)
|
|
724
1031
|
};
|
|
725
|
-
const question = renderQuestion(resolved, maze, sections);
|
|
726
|
-
const answer = renderAnswer(resolved, maze, result.answers, plan.actions.length);
|
|
727
1032
|
return {
|
|
728
|
-
schemaVersion: "maze-test-trial@
|
|
1033
|
+
schemaVersion: "maze-test-trial@2",
|
|
729
1034
|
options: resolved,
|
|
730
1035
|
maze,
|
|
731
1036
|
initialState: plan.initialState,
|
|
732
1037
|
actions: plan.actions,
|
|
733
1038
|
sections,
|
|
734
|
-
question,
|
|
735
|
-
answer,
|
|
1039
|
+
question: renderQuestion(resolved, maze, sections),
|
|
1040
|
+
answer: renderAnswer(resolved, maze, result.answers, plan.actions.length),
|
|
736
1041
|
result
|
|
737
1042
|
};
|
|
738
1043
|
}
|
|
739
1044
|
function resolveTrialOptions(options = {}) {
|
|
740
1045
|
const scenario = options.scenario ?? "success";
|
|
741
1046
|
const language = options.language ?? "en";
|
|
742
|
-
if (!["success", "treasure-and-leave", "death-and-stop"].includes(scenario)) {
|
|
743
|
-
throw new Error(`Unknown scenario: ${scenario}`);
|
|
744
|
-
}
|
|
1047
|
+
if (!["success", "treasure-and-leave", "death-and-stop", "mechanism-tour"].includes(scenario)) throw new Error(`Unknown scenario: ${scenario}`);
|
|
745
1048
|
if (language !== "en" && language !== "zh") throw new Error(`Unknown language: ${language}`);
|
|
1049
|
+
const mechanisms = resolveMechanisms(options);
|
|
1050
|
+
const defaults = defaultObjectCounts(mechanisms.complexity);
|
|
746
1051
|
const resolved = {
|
|
747
1052
|
seed: integer(options.seed ?? 1, "seed", 0),
|
|
748
1053
|
rows: oddInteger(options.rows ?? 15, "rows", 7),
|
|
749
1054
|
cols: oddInteger(options.cols ?? 15, "cols", 7),
|
|
750
1055
|
braid: finiteNumber(options.braid ?? 0, "braid", 0, 1),
|
|
751
|
-
doorCount: integer(options.doorCount ??
|
|
752
|
-
chestCount: integer(options.chestCount ??
|
|
753
|
-
trapCount: integer(options.trapCount ??
|
|
754
|
-
|
|
1056
|
+
doorCount: integer(options.doorCount ?? defaults.doors, "doorCount", 0),
|
|
1057
|
+
chestCount: integer(options.chestCount ?? defaults.chests, "chestCount", 0),
|
|
1058
|
+
trapCount: integer(options.trapCount ?? defaults.traps, "trapCount", 0),
|
|
1059
|
+
potionCount: integer(options.potionCount ?? options.medicineCount ?? defaults.potions, "potionCount", 0),
|
|
1060
|
+
mechanisms,
|
|
755
1061
|
scenario,
|
|
756
1062
|
language,
|
|
757
1063
|
style: integer(options.style ?? 0, "style", 0),
|
|
758
1064
|
minDistance: integer(options.minDistance ?? 0, "minDistance", 0),
|
|
759
1065
|
maxAttempts: integer(options.maxAttempts ?? 500, "maxAttempts", 1)
|
|
760
1066
|
};
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1067
|
+
validateScenarioOptions(resolved);
|
|
1068
|
+
return resolved;
|
|
1069
|
+
}
|
|
1070
|
+
function validateScenarioOptions(options) {
|
|
1071
|
+
if (options.scenario === "treasure-and-leave") {
|
|
1072
|
+
if (options.chestCount < 1) throw new Error("The treasure-and-leave scenario requires at least one chest.");
|
|
1073
|
+
if (!options.mechanisms.potionKinds.includes("healing")) throw new Error("The treasure-and-leave scenario requires healing in potionKinds.");
|
|
1074
|
+
if (options.potionCount < 1) throw new Error("The treasure-and-leave scenario requires at least one potion.");
|
|
766
1075
|
}
|
|
767
|
-
if (scenario === "death-and-stop" &&
|
|
768
|
-
|
|
1076
|
+
if (options.scenario === "death-and-stop" && options.trapCount < 1) throw new Error("The death-and-stop scenario requires at least one trap.");
|
|
1077
|
+
if (options.scenario === "mechanism-tour") {
|
|
1078
|
+
if (options.trapCount < options.mechanisms.trapDamages.length) throw new Error("The mechanism-tour scenario needs at least one trap for every configured trap damage.");
|
|
1079
|
+
if (options.potionCount < options.mechanisms.potionKinds.length) throw new Error("The mechanism-tour scenario needs at least one potion for every configured potion kind.");
|
|
1080
|
+
if (options.chestCount < options.mechanisms.treasureTypes.length) throw new Error("The mechanism-tour scenario needs at least one chest for every configured treasure type.");
|
|
1081
|
+
if (options.doorCount < options.mechanisms.keyMaterials.length) throw new Error("The mechanism-tour scenario needs at least one door for every configured key material.");
|
|
769
1082
|
}
|
|
770
|
-
return resolved;
|
|
771
1083
|
}
|
|
772
1084
|
function selectMaze(options) {
|
|
773
1085
|
let lastError = null;
|
|
774
1086
|
for (let offset = 0; offset < options.maxAttempts; offset += 1) {
|
|
775
1087
|
const effectiveSeed = options.seed + offset;
|
|
776
1088
|
try {
|
|
777
|
-
const base = generateSolidCellMaze({
|
|
778
|
-
|
|
779
|
-
cols: options.cols,
|
|
780
|
-
braid: options.braid,
|
|
781
|
-
seed: effectiveSeed,
|
|
782
|
-
requestedSeed: options.seed,
|
|
783
|
-
id: `maze-test-${options.rows}x${options.cols}-${options.seed}`
|
|
784
|
-
});
|
|
785
|
-
const maze = decorateSolidCellMaze(base, {
|
|
786
|
-
seed: effectiveSeed + 8e4,
|
|
787
|
-
doorCount: options.doorCount,
|
|
788
|
-
chestCount: options.chestCount,
|
|
789
|
-
trapCount: options.trapCount,
|
|
790
|
-
medicineCount: options.medicineCount
|
|
791
|
-
});
|
|
1089
|
+
const base = generateSolidCellMaze({ rows: options.rows, cols: options.cols, braid: options.braid, seed: effectiveSeed, requestedSeed: options.seed, mechanisms: options.mechanisms, id: `maze-test-${options.rows}x${options.cols}-${options.seed}` });
|
|
1090
|
+
const maze = decorateSolidCellMaze(base, { seed: effectiveSeed + 8e4, doorCount: options.doorCount, chestCount: options.chestCount, trapCount: options.trapCount, potionCount: options.potionCount, mechanisms: options.mechanisms });
|
|
792
1091
|
const validation = validateSolidCellMaze(maze);
|
|
793
1092
|
if (!validation.valid) throw new Error(validation.errors.join(" "));
|
|
794
|
-
if ((validation.metrics?.entryGoalDistance ?? 0) < options.minDistance) {
|
|
795
|
-
throw new Error(`The entry-goal distance is below ${options.minDistance}.`);
|
|
796
|
-
}
|
|
1093
|
+
if ((validation.metrics?.entryGoalDistance ?? 0) < options.minDistance) throw new Error(`The entry-goal distance is below ${options.minDistance}.`);
|
|
797
1094
|
const plan = buildScenarioPlan(maze, options.scenario);
|
|
798
1095
|
const result = simulateTrial(maze, plan.actions, plan.initialState);
|
|
799
1096
|
verifyScenario(options.scenario, result.answers, maze);
|
|
@@ -803,172 +1100,82 @@ function selectMaze(options) {
|
|
|
803
1100
|
}
|
|
804
1101
|
}
|
|
805
1102
|
const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : "";
|
|
806
|
-
throw new Error(
|
|
807
|
-
`Could not generate a valid trial in ${options.maxAttempts} deterministic attempts.${detail}`
|
|
808
|
-
);
|
|
1103
|
+
throw new Error(`Could not generate a valid trial in ${options.maxAttempts} deterministic attempts.${detail}`);
|
|
809
1104
|
}
|
|
810
1105
|
function buildScenarioPlan(maze, scenario) {
|
|
1106
|
+
const ample = ampleInitialState(maze);
|
|
811
1107
|
if (scenario === "success") {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
};
|
|
1108
|
+
const damage = maze.objects.filter((item) => item.type === "trap").reduce((sum, item) => sum + item.damage, 0);
|
|
1109
|
+
const poisonBudget = maze.mechanisms.potionKinds.includes("poison") ? maze.mechanisms.poisonDamage * maze.mechanisms.poisonDuration : 0;
|
|
1110
|
+
const health = Math.max(5, damage + poisonBudget + 1);
|
|
1111
|
+
return { initialState: { health, healthMax: health, keys: 0, treasures: 0 }, actions: pathToActions(shortestPath(maze, maze.entry, maze.goal)) };
|
|
816
1112
|
}
|
|
817
1113
|
if (scenario === "treasure-and-leave") {
|
|
818
|
-
const
|
|
1114
|
+
const healing = maze.objects.find((item) => item.type === "medicine" || item.type === "potion" && item.kind === "healing");
|
|
819
1115
|
const chest = maze.objects.find((item) => item.type === "chest");
|
|
820
|
-
if (!
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
...pathToActions(path2),
|
|
828
|
-
...pathToActions(path3),
|
|
829
|
-
...pathToActions(leavePath)
|
|
830
|
-
];
|
|
831
|
-
const initialState = {
|
|
832
|
-
health: 3,
|
|
833
|
-
healthMax: 5,
|
|
834
|
-
keys: maze.doors.length + 2,
|
|
835
|
-
treasures: 0
|
|
836
|
-
};
|
|
837
|
-
const beforeBump = simulateTrial(maze, actions, initialState);
|
|
838
|
-
const bump = wallDirection(maze, beforeBump.state.position);
|
|
839
|
-
if (bump) actions = [...actions, bump, bump];
|
|
1116
|
+
if (!healing || !chest) throw new Error("This scenario requires a healing object and a chest.");
|
|
1117
|
+
let actions = routeThrough(maze, [maze.entry, healing.position, chest.position, maze.goal]);
|
|
1118
|
+
const leavePath = shortestPath(maze, maze.goal, chest.position).slice(0, 10);
|
|
1119
|
+
actions.push(...pathToActions(leavePath));
|
|
1120
|
+
const initialState = { ...ample, health: 50, healthMax: 100 };
|
|
1121
|
+
const bump = wallDirection(maze, simulateTrial(maze, actions, initialState).state.position);
|
|
1122
|
+
if (bump) actions.push(bump, bump);
|
|
840
1123
|
return { initialState, actions };
|
|
841
1124
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
},
|
|
853
|
-
actions: [...pathToActions(toTrap), ...pathToActions(afterTrap)]
|
|
854
|
-
};
|
|
855
|
-
}
|
|
856
|
-
function verifyScenario(scenario, answers, maze) {
|
|
857
|
-
if (scenario === "success" && (!answers.reachedGoal || !answers.alive || answers.finalPosition !== letterNumberCoordinate(maze.goal))) {
|
|
858
|
-
throw new Error("The success scenario did not finish alive at the goal.");
|
|
859
|
-
}
|
|
860
|
-
if (scenario === "treasure-and-leave" && (!answers.reachedGoal || answers.finalPosition === letterNumberCoordinate(maze.goal) || answers.openedChests < 1 || answers.usedMedicines < 1 || answers.blockedMoves < 2)) {
|
|
861
|
-
throw new Error("The treasure-and-leave scenario did not meet its invariants.");
|
|
1125
|
+
if (scenario === "death-and-stop") {
|
|
1126
|
+
const trap = maze.objects.find((item) => item.type === "trap");
|
|
1127
|
+
if (!trap) throw new Error("This scenario requires a trap.");
|
|
1128
|
+
const actions = [...pathToActions(shortestPath(maze, maze.entry, trap.position)), ...pathToActions(shortestPath(maze, trap.position, maze.goal).slice(0, 14))];
|
|
1129
|
+
for (let health = 1; health <= 200; health += 1) {
|
|
1130
|
+
const initialState = { ...ample, health, healthMax: health };
|
|
1131
|
+
const result = simulateTrial(maze, actions, initialState);
|
|
1132
|
+
if (result.trace.some((item) => item.events.some((event) => event.type === "trigger-trap" && event.id === trap.id) && item.events.some((event) => event.type === "die"))) return { initialState, actions };
|
|
1133
|
+
}
|
|
1134
|
+
throw new Error("Could not choose health that makes the death-and-stop scenario die on its target trap.");
|
|
862
1135
|
}
|
|
863
|
-
|
|
864
|
-
|
|
1136
|
+
return { initialState: { ...ample, health: 1e3, healthMax: 1e3 }, actions: routeThrough(maze, [maze.entry, ...maze.objects.map((item) => item.position), maze.goal]) };
|
|
1137
|
+
}
|
|
1138
|
+
function ampleInitialState(maze) {
|
|
1139
|
+
const material = emptyMaterialCounts();
|
|
1140
|
+
for (const door of maze.doors) if (door.material) material[door.material] += 1;
|
|
1141
|
+
for (const object of maze.objects) if (object.type === "chest" && object.lockMaterial) material[object.lockMaterial] += 1;
|
|
1142
|
+
return { health: 100, healthMax: 100, keys: maze.doors.filter((door) => !door.material).length + maze.objects.filter((item) => item.type === "chest" && !item.lockMaterial).length, keysByMaterial: material, treasures: 0 };
|
|
1143
|
+
}
|
|
1144
|
+
function verifyScenario(scenario, a, maze) {
|
|
1145
|
+
if (scenario === "success" && (!a.reachedGoal || !a.alive || a.finalPosition !== letterNumberCoordinate(maze.goal))) throw new Error("The success scenario did not finish alive at the goal.");
|
|
1146
|
+
if (scenario === "treasure-and-leave" && (!a.reachedGoal || a.finalPosition === letterNumberCoordinate(maze.goal) || a.openedChests < 1 || a.usedMedicines + a.usedPotions < 1 || a.blockedMoves < 2)) throw new Error("The treasure-and-leave scenario did not meet its invariants.");
|
|
1147
|
+
if (scenario === "death-and-stop" && (a.alive || a.triggeredTraps < 1 || a.blockedAfterDeath < 5)) throw new Error("The death-and-stop scenario did not meet its invariants.");
|
|
1148
|
+
if (scenario === "mechanism-tour") {
|
|
1149
|
+
const count = (type) => maze.objects.filter((item) => item.type === type).length;
|
|
1150
|
+
if (!a.alive || !a.reachedGoal || a.openedDoors !== maze.doors.length || a.openedChests !== count("chest") || a.triggeredTraps !== count("trap") || a.usedPotions !== count("potion") || a.usedMedicines !== count("medicine")) throw new Error("The mechanism-tour scenario did not exercise every generated mechanism object.");
|
|
865
1151
|
}
|
|
866
1152
|
}
|
|
867
1153
|
function renderQuestion(options, maze, sections) {
|
|
868
|
-
const isZh = options.language === "zh";
|
|
869
|
-
const title = isZh ? "\u8FF7\u5BAB\u8BD5\u9898" : "Maze Trial";
|
|
870
|
-
const labels = isZh ? ["\u5730\u56FE\u63CF\u8FF0", "\u673A\u5236\u63CF\u8FF0", "\u884C\u52A8\u63CF\u8FF0", "\u95EE\u9898"] : ["Map", "Rules", "Actions", "Questions"];
|
|
871
|
-
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})`;
|
|
872
|
-
return [
|
|
873
|
-
`# ${title}`,
|
|
874
|
-
"",
|
|
875
|
-
seedLine,
|
|
876
|
-
"",
|
|
877
|
-
`## 1. ${labels[0]}`,
|
|
878
|
-
"",
|
|
879
|
-
sections.map,
|
|
880
|
-
"",
|
|
881
|
-
`## 2. ${labels[1]}`,
|
|
882
|
-
"",
|
|
883
|
-
sections.rules,
|
|
884
|
-
"",
|
|
885
|
-
`## 3. ${labels[2]}`,
|
|
886
|
-
"",
|
|
887
|
-
sections.actions,
|
|
888
|
-
"",
|
|
889
|
-
`## 4. ${labels[3]}`,
|
|
890
|
-
"",
|
|
891
|
-
sections.questions,
|
|
892
|
-
""
|
|
893
|
-
].join("\n");
|
|
894
|
-
}
|
|
895
|
-
function renderAnswer(options, maze, answers, actionCount) {
|
|
896
1154
|
const zh = options.language === "zh";
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
] : [
|
|
910
|
-
|
|
911
|
-
["Keys remaining", answers.keys],
|
|
912
|
-
["Treasures collected", answers.treasures],
|
|
913
|
-
["Health remaining", answers.health],
|
|
914
|
-
["Alive", answers.alive ? "Yes" : "No"],
|
|
915
|
-
["Ever reached the goal", answers.reachedGoal ? "Yes" : "No"],
|
|
916
|
-
["Doors opened", answers.openedDoors],
|
|
917
|
-
["Chests opened", answers.openedChests],
|
|
918
|
-
["Traps triggered", answers.triggeredTraps],
|
|
919
|
-
["Medicine rooms used", answers.usedMedicines],
|
|
920
|
-
["Moves that did not change position", answers.blockedMoves]
|
|
921
|
-
];
|
|
922
|
-
return [
|
|
923
|
-
`# ${zh ? "\u6807\u51C6\u7B54\u6848" : "Answer Key"}`,
|
|
924
|
-
"",
|
|
925
|
-
`${zh ? "\u79CD\u5B50" : "Seed"}: ${options.seed}`,
|
|
926
|
-
...maze.seed === options.seed ? [] : [
|
|
927
|
-
`${zh ? "\u6709\u6548\u8FF7\u5BAB\u79CD\u5B50" : "Effective maze seed"}: ${maze.seed}`
|
|
928
|
-
],
|
|
929
|
-
"",
|
|
930
|
-
`| ${zh ? "\u95EE\u9898" : "Item"} | ${zh ? "\u7B54\u6848" : "Answer"} |`,
|
|
931
|
-
"|---|---|",
|
|
932
|
-
...rows.map(([label, value]) => `| ${label} | ${String(value)} |`),
|
|
933
|
-
"",
|
|
934
|
-
`${zh ? "\u539F\u5B50\u884C\u52A8\u6570" : "Atomic action count"}: ${actionCount}`,
|
|
935
|
-
""
|
|
936
|
-
].join("\n");
|
|
1155
|
+
const labels = zh ? ["\u5730\u56FE\u63CF\u8FF0", "\u673A\u5236\u63CF\u8FF0", "\u884C\u52A8\u63CF\u8FF0", "\u95EE\u9898"] : ["Map", "Rules", "Actions", "Questions"];
|
|
1156
|
+
const seedLine = maze.seed === options.seed ? `${zh ? "\u79CD\u5B50" : "Seed"}: ${options.seed}` : `${zh ? "\u79CD\u5B50" : "Seed"}: ${options.seed} (${zh ? "\u6709\u6548\u8FF7\u5BAB\u79CD\u5B50" : "effective maze seed"}: ${maze.seed})`;
|
|
1157
|
+
return [`# ${zh ? "\u8FF7\u5BAB\u8BD5\u9898" : "Maze Trial"}`, "", seedLine, `${zh ? "\u673A\u5236\u590D\u6742\u5EA6" : "Mechanism complexity"}: ${options.mechanisms.complexity}`, "", `## 1. ${labels[0]}`, "", sections.map, "", `## 2. ${labels[1]}`, "", sections.rules, "", `## 3. ${labels[2]}`, "", sections.actions, "", `## 4. ${labels[3]}`, "", sections.questions, ""].join("\n");
|
|
1158
|
+
}
|
|
1159
|
+
function renderAnswer(options, maze, a, actionCount) {
|
|
1160
|
+
const zh = options.language === "zh";
|
|
1161
|
+
const rows = zh ? [["\u6700\u7EC8\u4F4D\u7F6E", a.finalPosition], ["\u6700\u7EC8\u94A5\u5319\u603B\u6570", a.keys], ["\u6700\u7EC8\u5B9D\u7269\u603B\u6570", a.treasures], ["\u6700\u7EC8\u5065\u5EB7\u503C", a.health], ["\u662F\u5426\u5B58\u6D3B", a.alive ? "\u662F" : "\u5426"], ["\u662F\u5426\u66FE\u5230\u8FBE\u7EC8\u70B9", a.reachedGoal ? "\u662F" : "\u5426"], ["\u6253\u5F00\u7684\u95E8", a.openedDoors], ["\u6253\u5F00\u7684\u5B9D\u7BB1", a.openedChests], ["\u89E6\u53D1\u7684\u9677\u9631", a.triggeredTraps]] : [["Final position", a.finalPosition], ["Keys remaining in total", a.keys], ["Treasures collected in total", a.treasures], ["Health remaining", a.health], ["Alive", a.alive ? "Yes" : "No"], ["Ever reached the goal", a.reachedGoal ? "Yes" : "No"], ["Doors opened", a.openedDoors], ["Chests opened", a.openedChests], ["Traps triggered", a.triggeredTraps]];
|
|
1162
|
+
if (maze.objects.some((item) => item.type === "medicine")) rows.push([zh ? "\u4F7F\u7528\u7684\u836F\u54C1\u623F" : "Medicine rooms used", a.usedMedicines]);
|
|
1163
|
+
if (maze.objects.some((item) => item.type === "potion")) rows.push([zh ? "\u996E\u7528\u7684\u836F\u6C34" : "Potions drunk", a.usedPotions]);
|
|
1164
|
+
rows.push([zh ? "\u672A\u6539\u53D8\u4F4D\u7F6E\u7684\u539F\u5B50\u79FB\u52A8\u6307\u4EE4" : "Atomic movement instructions that did not change position", a.blockedMoves]);
|
|
1165
|
+
for (const material of maze.mechanisms.keyMaterials) rows.push([zh ? `\u5269\u4F59${MATERIAL_NAMES[material].zh}\u94A5\u5319` : `${capitalize(MATERIAL_NAMES[material].en)} keys remaining`, a.keysByMaterial[material]]);
|
|
1166
|
+
if (maze.mechanisms.treasureTypes.some((type) => type !== "treasure")) for (const type of maze.mechanisms.treasureTypes) rows.push([zh ? `${TREASURE_NAMES[type].zh}\u6570\u91CF` : `${capitalize(TREASURE_NAMES[type].en)}s collected`, a.treasuresByType[type]]);
|
|
1167
|
+
if (maze.mechanisms.complexity === "advanced") rows.push([zh ? "\u7D2F\u8BA1\u8017\u65F6\uFF08\u65F6\u95F4\u5355\u4F4D\uFF09" : "Elapsed time (time units)", a.elapsedTime], [zh ? "\u6700\u7EC8\u901F\u5EA6\u72B6\u6001" : "Final speed state", SPEED_NAMES[a.finalSpeed][zh ? "zh" : "en"]], [zh ? "\u901F\u5EA6\u6548\u679C\u5269\u4F59\u6307\u4EE4\u6570" : "Speed-effect instructions remaining", a.speedRemaining], [zh ? "\u4E2D\u6BD2\u5269\u4F59\u6307\u4EE4\u6570" : "Poison instructions remaining", a.poisonRemaining]);
|
|
1168
|
+
return [`# ${zh ? "\u6807\u51C6\u7B54\u6848" : "Answer Key"}`, "", `${zh ? "\u79CD\u5B50" : "Seed"}: ${options.seed}`, ...maze.seed === options.seed ? [] : [`${zh ? "\u6709\u6548\u8FF7\u5BAB\u79CD\u5B50" : "Effective maze seed"}: ${maze.seed}`], "", `| ${zh ? "\u95EE\u9898" : "Item"} | ${zh ? "\u7B54\u6848" : "Answer"} |`, "|---|---|", ...rows.map(([label, value]) => `| ${label} | ${String(value)} |`), "", `${zh ? "\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u6570" : "Atomic movement instruction count"}: ${actionCount}`, ""].join("\n");
|
|
937
1169
|
}
|
|
938
1170
|
function renderMapDescription(maze, initial, language, style) {
|
|
939
1171
|
const terrain = renderTerrainDescription(maze, language, style);
|
|
940
1172
|
const objects = renderObjectDescription(maze, language);
|
|
941
|
-
if (language === "zh") {
|
|
942
|
-
|
|
943
|
-
`\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`,
|
|
944
|
-
`\u5165\u53E3\u4F4D\u4E8E${letterNumberCoordinate(maze.entry)}\uFF0C\u7EC8\u70B9\u4F4D\u4E8E${letterNumberCoordinate(maze.goal)}\u3002`,
|
|
945
|
-
"",
|
|
946
|
-
terrain,
|
|
947
|
-
"",
|
|
948
|
-
"\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",
|
|
949
|
-
objects,
|
|
950
|
-
"",
|
|
951
|
-
`\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`
|
|
952
|
-
].join("\n");
|
|
953
|
-
}
|
|
954
|
-
return [
|
|
955
|
-
`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}.`,
|
|
956
|
-
`The entry is at ${letterNumberCoordinate(maze.entry)}, and the goal is at ${letterNumberCoordinate(maze.goal)}.`,
|
|
957
|
-
"",
|
|
958
|
-
terrain,
|
|
959
|
-
"",
|
|
960
|
-
"Doors and other objects are distributed as follows. Each door occupies a whole cell.",
|
|
961
|
-
objects,
|
|
962
|
-
"",
|
|
963
|
-
`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.`
|
|
964
|
-
].join("\n");
|
|
1173
|
+
if (language === "zh") return [`\u8FD9\u662F\u4E00\u5EA7${maze.rows}\u884C${maze.cols}\u5217\u7684\u8FF7\u5BAB\u3002\u5DE6\u4E0A\u89D2\u4E3AA1\uFF0C\u5217\u4ECE\u5DE6\u5411\u53F3\u4F9D\u6B21\u6807\u4E3AA\u81F3${columnLabel(maze.cols)}\uFF0C\u884C\u4ECE\u4E0A\u5411\u4E0B\u7F16\u53F7\u4E3A1\u81F3${maze.rows}\u3002`, `\u5165\u53E3\u4F4D\u4E8E${letterNumberCoordinate(maze.entry)}\uFF0C\u7EC8\u70B9\u4F4D\u4E8E${letterNumberCoordinate(maze.goal)}\u3002`, "", terrain, "", "\u95E8\u548C\u5BF9\u8C61\u7684\u4F4D\u7F6E\u5982\u4E0B\uFF1B\u95E8\u4E0E\u5BF9\u8C61\u6240\u5728\u683C\u4ECD\u662F\u53EF\u4EE5\u8FDB\u5165\u7684\u901A\u8DEF\u683C\u3002", objects, "", `\u63A2\u9669\u8005\u4ECE\u5165\u53E3\u51FA\u53D1\uFF0C\u521D\u59CB\u5065\u5EB7\u503C\u4E3A${initial.health}\uFF0C\u5065\u5EB7\u4E0A\u9650\u4E3A${initial.healthMax}\uFF1B${initialPossessions(initial, language)}\uFF1B\u521D\u59CB\u672A\u4E2D\u6BD2\u3001\u901F\u5EA6\u4E3A\u6B63\u5E38\uFF1B\u201C\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\u201D\u8BB0\u5F55\u4E3A\u5047\u3002`].join("\n");
|
|
1174
|
+
return [`This maze has ${maze.rows} rows and ${maze.cols} columns. The top-left cell is A1. Columns run left to right from A to ${columnLabel(maze.cols)}, and rows run top to bottom from 1 to ${maze.rows}.`, `The entry is at ${letterNumberCoordinate(maze.entry)}, and the goal is at ${letterNumberCoordinate(maze.goal)}.`, "", terrain, "", "Door and object locations follow. A cell containing a door or object is still a passage cell that may be entered, subject to the door rule.", objects, "", `The explorer starts at the entry with ${initial.health} health, a maximum health of ${initial.healthMax}, ${initialPossessions(initial, language)}, no poison, normal speed, and reached-goal set to false.`].join("\n");
|
|
965
1175
|
}
|
|
966
1176
|
function renderTerrainDescription(maze, language, style) {
|
|
967
1177
|
const clauses = Array.from({ length: maze.rows }, (_, index) => index + 1).map((row) => {
|
|
968
|
-
const kinds = Array.from(
|
|
969
|
-
{ length: maze.cols },
|
|
970
|
-
(_, index) => terrainAt(maze, cell(row, index + 1)) === "." ? "floor" : "wall"
|
|
971
|
-
);
|
|
1178
|
+
const kinds = Array.from({ length: maze.cols }, (_, index) => terrainAt(maze, cell(row, index + 1)) === "." ? "floor" : "wall");
|
|
972
1179
|
const label = language === "zh" ? [`\u7B2C${row}\u884C`, `${row}\u3001`, `\uFF08${row}\uFF09`][style % 3] : `Row ${row}: `;
|
|
973
1180
|
return `${label ?? `\u7B2C${row}\u884C`}${describeTerrainLine(kinds, language)}`;
|
|
974
1181
|
});
|
|
@@ -977,9 +1184,7 @@ function renderTerrainDescription(maze, language, style) {
|
|
|
977
1184
|
function describeTerrainLine(kinds, language) {
|
|
978
1185
|
const walls = [];
|
|
979
1186
|
const floors = [];
|
|
980
|
-
for (let index = 0; index < kinds.length; index += 1)
|
|
981
|
-
(kinds[index] === "wall" ? walls : floors).push(index + 1);
|
|
982
|
-
}
|
|
1187
|
+
for (let index = 0; index < kinds.length; index += 1) (kinds[index] === "wall" ? walls : floors).push(index + 1);
|
|
983
1188
|
if (language === "zh") {
|
|
984
1189
|
if (walls.length === 0) return "\u90FD\u662F\u901A\u8DEF";
|
|
985
1190
|
if (floors.length === 0) return "\u90FD\u662F\u5899\u58C1";
|
|
@@ -1010,76 +1215,110 @@ function positionList(indices, language) {
|
|
|
1010
1215
|
end = value;
|
|
1011
1216
|
}
|
|
1012
1217
|
ranges.push([start, end]);
|
|
1013
|
-
const parts = ranges.map(
|
|
1014
|
-
([from, to]) => language === "zh" ? from === to ? `\u7B2C${from}\u683C` : `\u7B2C${from}\u683C\u81F3\u7B2C${to}\u683C` : from === to ? `cell ${from}` : `cells ${from}-${to}`
|
|
1015
|
-
);
|
|
1218
|
+
const parts = ranges.map(([from, to]) => language === "zh" ? from === to ? `\u7B2C${from}\u683C` : `\u7B2C${from}\u683C\u81F3\u7B2C${to}\u683C` : from === to ? `cell ${from}` : `cells ${from}-${to}`);
|
|
1016
1219
|
if (parts.length === 1) return parts[0] ?? "";
|
|
1017
|
-
|
|
1018
|
-
return language === "zh" ? `${parts.slice(0, -1).join("\u3001")}\u548C${last}` : `${parts.slice(0, -1).join(", ")} and ${last}`;
|
|
1220
|
+
return language === "zh" ? `${parts.slice(0, -1).join("\u3001")}\u548C${parts.at(-1)}` : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
|
|
1019
1221
|
}
|
|
1020
1222
|
function renderObjectDescription(maze, language) {
|
|
1021
|
-
const
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
const traps = maze.objects.filter((item) => item.type === "trap");
|
|
1026
|
-
const medicines = maze.objects.filter((item) => item.type === "medicine");
|
|
1027
|
-
if (language === "zh") {
|
|
1028
|
-
if (doorPositions.length > 0) sentences.push(`${coordinateList(doorPositions, language)}${doorPositions.length > 1 ? "\u5404" : ""}\u6709\u4E00\u9053\u521D\u59CB\u5173\u95ED\u7684\u95E8\u3002`);
|
|
1029
|
-
const keyPositions = keys.map((item) => letterNumberCoordinate(item.position));
|
|
1030
|
-
if (keyPositions.length > 0) sentences.push(`${coordinateList(keyPositions, language)}${keyPositions.length > 1 ? "\u5404" : ""}\u653E\u7740\u4E00\u628A\u94A5\u5319\u3002`);
|
|
1031
|
-
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`);
|
|
1032
|
-
const trapPositions = traps.map((item) => letterNumberCoordinate(item.position));
|
|
1033
|
-
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`);
|
|
1034
|
-
const medicinePositions = medicines.map((item) => letterNumberCoordinate(item.position));
|
|
1035
|
-
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`);
|
|
1036
|
-
} else {
|
|
1037
|
-
if (doorPositions.length > 0) sentences.push(`${coordinateList(doorPositions, language)} ${doorPositions.length === 1 ? "contains a closed door" : "each contain a closed door"}.`);
|
|
1038
|
-
const keyPositions = keys.map((item) => letterNumberCoordinate(item.position));
|
|
1039
|
-
if (keyPositions.length > 0) sentences.push(`${coordinateList(keyPositions, language)} ${keyPositions.length === 1 ? "contains a key" : "each contain a key"}.`);
|
|
1040
|
-
for (const chest of chests) sentences.push(`${letterNumberCoordinate(chest.position)} contains a closed chest with ${chest.treasures} treasure${plural(chest.treasures)}.`);
|
|
1041
|
-
const trapPositions = traps.map((item) => letterNumberCoordinate(item.position));
|
|
1042
|
-
if (trapPositions.length > 0) sentences.push(`${coordinateList(trapPositions, language)} ${trapPositions.length === 1 ? "contains an untriggered trap" : "each contain an untriggered trap"}.`);
|
|
1043
|
-
const medicinePositions = medicines.map((item) => letterNumberCoordinate(item.position));
|
|
1044
|
-
if (medicinePositions.length > 0) sentences.push(`${coordinateList(medicinePositions, language)} ${medicinePositions.length === 1 ? "contains an unused medicine room" : "each contain an unused medicine room"}.`);
|
|
1223
|
+
const lines = [];
|
|
1224
|
+
for (const door of maze.doors) {
|
|
1225
|
+
const at = letterNumberCoordinate(door.position);
|
|
1226
|
+
lines.push(language === "zh" ? `${at}\u6709\u4E00\u9053\u521D\u59CB\u5173\u95ED\u7684${door.material ? MATERIAL_NAMES[door.material].zh : "\u666E\u901A"}\u95E8\u3002` : `${at} contains an initially closed ${door.material ? `${MATERIAL_NAMES[door.material].en} ` : "ordinary "}door.`);
|
|
1045
1227
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1228
|
+
for (const object of maze.objects) {
|
|
1229
|
+
const at = letterNumberCoordinate(object.position);
|
|
1230
|
+
if (object.type === "key") lines.push(language === "zh" ? `${at}\u653E\u7740\u4E00\u628A${object.material ? MATERIAL_NAMES[object.material].zh : "\u666E\u901A"}\u94A5\u5319\u3002` : `${at} contains one ${object.material ? `${MATERIAL_NAMES[object.material].en} ` : "ordinary "}key.`);
|
|
1231
|
+
else if (object.type === "chest") {
|
|
1232
|
+
const contents = (object.contents ?? [{ type: "treasure", count: object.treasures }]).map((item) => language === "zh" ? `${item.count}${treasureUnit(item.type)}${TREASURE_NAMES[item.type].zh}` : `${item.count} ${TREASURE_NAMES[item.type].en}${plural(item.count)}`).join(language === "zh" ? "\u3001" : " and ");
|
|
1233
|
+
const lock = object.lockMaterial ? `a ${MATERIAL_NAMES[object.lockMaterial].en} lock` : "an ordinary lock";
|
|
1234
|
+
lines.push(language === "zh" ? `${at}\u6709\u4E00\u53EA\u521D\u59CB\u5173\u95ED\u7684${object.lockMaterial ? MATERIAL_NAMES[object.lockMaterial].zh : "\u666E\u901A"}\u9501\u5B9D\u7BB1\uFF0C\u5185\u6709${contents}\u3002` : `${at} contains an initially closed chest with ${lock}, containing ${contents}.`);
|
|
1235
|
+
} else if (object.type === "trap") lines.push(language === "zh" ? `${at}\u6709\u4E00\u4E2A\u5C1A\u672A\u89E6\u53D1\u7684\u9677\u9631\uFF0C\u4F24\u5BB3\u4E3A${object.damage}\u70B9\u3002` : `${at} contains an untriggered trap that deals ${object.damage} damage.`);
|
|
1236
|
+
else if (object.type === "medicine") lines.push(language === "zh" ? `${at}\u6709\u4E00\u4E2A\u5C1A\u672A\u4F7F\u7528\u7684\u836F\u54C1\u623F\uFF0C\u53EF\u6062\u590D${object.recovery}\u70B9\u5065\u5EB7\u503C\u3002` : `${at} contains an unused medicine room that restores ${object.recovery} health.`);
|
|
1237
|
+
else {
|
|
1238
|
+
const details = potionDetails(object.kind, object.potency, object.duration, language);
|
|
1239
|
+
lines.push(language === "zh" ? `${at}\u6709\u4E00\u74F6\u5C1A\u672A\u996E\u7528\u7684${POTION_NAMES[object.kind].zh}${details}\u3002` : `${at} contains an unused ${POTION_NAMES[object.kind].en}${details}.`);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return lines.length > 0 ? lines.join("\n") : language === "zh" ? "\u6CA1\u6709\u95E8\u6216\u5176\u4ED6\u5BF9\u8C61\u3002" : "There are no doors or other objects.";
|
|
1050
1243
|
}
|
|
1051
|
-
function
|
|
1052
|
-
|
|
1244
|
+
function potionDetails(kind, potency, duration, language) {
|
|
1245
|
+
if (language === "zh") {
|
|
1246
|
+
if (kind === "healing") return `\uFF0C\u6062\u590D${potency ?? 1}\u70B9\u5065\u5EB7\u503C\uFF0C\u4F46\u4E0D\u8D85\u8FC7\u5065\u5EB7\u4E0A\u9650`;
|
|
1247
|
+
if (kind === "poison") return `\uFF0C\u4F7F\u996E\u7528\u8005\u5728\u4E4B\u540E${duration ?? 1}\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u5404\u635F\u5931${potency ?? 1}\u70B9\u5065\u5EB7\u503C`;
|
|
1248
|
+
if (kind === "haste" || kind === "slow") return `\uFF0C\u6548\u679C\u6301\u7EED\u4E4B\u540E${duration ?? 1}\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4`;
|
|
1249
|
+
return "\uFF0C\u53EF\u7ACB\u5373\u6E05\u9664\u4E2D\u6BD2\u72B6\u6001";
|
|
1250
|
+
}
|
|
1251
|
+
if (kind === "healing") return ` that restores ${potency ?? 1} health without exceeding maximum health`;
|
|
1252
|
+
if (kind === "poison") return ` that causes ${potency ?? 1} damage after each of the next ${duration ?? 1} atomic movement instructions`;
|
|
1253
|
+
if (kind === "haste" || kind === "slow") return ` whose effect lasts for the next ${duration ?? 1} atomic movement instructions`;
|
|
1254
|
+
return " that immediately clears poison";
|
|
1255
|
+
}
|
|
1256
|
+
function renderRules(maze, language) {
|
|
1257
|
+
const m = maze.mechanisms;
|
|
1258
|
+
const usesPotionObjects = maze.objects.some((item) => item.type === "potion");
|
|
1259
|
+
const usesPoison = usesPotionObjects && m.potionKinds.some((kind) => kind === "poison" || kind === "antidote");
|
|
1260
|
+
const usesMaterials = m.keyMaterials.length > 0;
|
|
1261
|
+
const doorRuleZh = usesMaterials ? "\u76EE\u6807\u683C\u662F\u5173\u95ED\u7684\u95E8\u65F6\uFF0C\u5FC5\u987B\u6D88\u8017\u4E00\u628A\u4E0E\u95E8\u6750\u8D28\u76F8\u540C\u7684\u94A5\u5319\uFF1B\u94A5\u5319\u4E0D\u8DB3\u6216\u6750\u8D28\u4E0D\u7B26\u65F6\u505C\u5728\u539F\u5730\u3002\u94A5\u5319\u5339\u914D\u65F6\uFF0C\u5728\u540C\u4E00\u6761\u6307\u4EE4\u4E2D\u5F00\u95E8\u5E76\u8FDB\u5165\u95E8\u683C\u3002\u95E8\u4E00\u7ECF\u6253\u5F00\u4FBF\u6C38\u4E45\u4FDD\u6301\u6253\u5F00\u3002" : "\u76EE\u6807\u683C\u662F\u5173\u95ED\u7684\u666E\u901A\u95E8\u65F6\uFF0C\u5FC5\u987B\u6D88\u8017\u4E00\u628A\u666E\u901A\u94A5\u5319\uFF1B\u6CA1\u6709\u666E\u901A\u94A5\u5319\u65F6\u505C\u5728\u539F\u5730\u3002\u6709\u94A5\u5319\u65F6\uFF0C\u5728\u540C\u4E00\u6761\u6307\u4EE4\u4E2D\u5F00\u95E8\u5E76\u8FDB\u5165\u95E8\u683C\u3002\u95E8\u4E00\u7ECF\u6253\u5F00\u4FBF\u6C38\u4E45\u4FDD\u6301\u6253\u5F00\u3002";
|
|
1262
|
+
const doorRuleEn = usesMaterials ? "If the target cell contains a closed door, the explorer must spend one key whose material matches the door. Without a matching key, the explorer stays in place. With a matching key, the door opens and the explorer enters the door cell in the same instruction. Once opened, a door remains open." : "If the target cell contains a closed ordinary door, the explorer must spend one ordinary key. Without an ordinary key, the explorer stays in place. With one, the door opens and the explorer enters the door cell in the same instruction. Once opened, a door remains open.";
|
|
1263
|
+
const chestRuleZh = usesMaterials ? "\u6BCF\u6B21\u8FDB\u5165\u542B\u6709\u5C1A\u672A\u6253\u5F00\u5B9D\u7BB1\u7684\u683C\u5B50\u65F6\uFF0C\u82E5\u6301\u6709\u4E0E\u9501\u6750\u8D28\u76F8\u540C\u7684\u94A5\u5319\uFF0C\u5219\u6D88\u8017\u4E00\u628A\u8BE5\u94A5\u5319\u3001\u6253\u5F00\u5B9D\u7BB1\u5E76\u53D6\u5F97\u7BB1\u4E2D\u5168\u90E8\u7269\u54C1\uFF1B\u5426\u5219\u5B9D\u7BB1\u4FDD\u6301\u5173\u95ED\u3002\u5B9D\u7BB1\u683C\u65E0\u8BBA\u662F\u5426\u6253\u5F00\u90FD\u53EF\u4EE5\u8FDB\u5165\u6216\u7ECF\u8FC7\u3002\u7BB1\u5185\u6BCF\u4E00\u679A\u94B1\u5E01\u3001\u6BCF\u4E00\u9897\u5B9D\u77F3\u6216\u6BCF\u4E00\u4EF6\u9057\u7269\u90FD\u5206\u522B\u8BA1\u4E3A\u4E00\u4EF6\u5B9D\u7269\u3002" : "\u6BCF\u6B21\u8FDB\u5165\u542B\u6709\u5C1A\u672A\u6253\u5F00\u666E\u901A\u9501\u5B9D\u7BB1\u7684\u683C\u5B50\u65F6\uFF0C\u82E5\u6301\u6709\u666E\u901A\u94A5\u5319\uFF0C\u5219\u6D88\u8017\u4E00\u628A\u666E\u901A\u94A5\u5319\u3001\u6253\u5F00\u5B9D\u7BB1\u5E76\u53D6\u5F97\u7BB1\u4E2D\u5168\u90E8\u7269\u54C1\uFF1B\u5426\u5219\u5B9D\u7BB1\u4FDD\u6301\u5173\u95ED\u3002\u5B9D\u7BB1\u683C\u65E0\u8BBA\u662F\u5426\u6253\u5F00\u90FD\u53EF\u4EE5\u8FDB\u5165\u6216\u7ECF\u8FC7\u3002\u7BB1\u5185\u6BCF\u4EF6\u7269\u54C1\u8BA1\u4E3A\u4E00\u4EF6\u5B9D\u7269\u3002";
|
|
1264
|
+
const chestRuleEn = usesMaterials ? "Whenever the explorer enters a cell containing an unopened chest, if a key matching its lock is available, one such key is spent, the chest opens, and all its contents are collected; otherwise, the chest remains closed. A chest cell may be entered or crossed whether or not the chest is open. Each coin, gem, or relic counts as one treasure." : "Whenever the explorer enters a cell containing an unopened chest with an ordinary lock, if an ordinary key is available, one ordinary key is spent, the chest opens, and all its contents are collected; otherwise, the chest remains closed. A chest cell may be entered or crossed whether or not the chest is open. Each item inside counts as one treasure.";
|
|
1265
|
+
const orderRuleZh = m.complexity === "advanced" ? "\u4E00\u6761\u6307\u4EE4\u4E2D\uFF0C\u4F9D\u6B21\u7ED3\u7B97\u8017\u65F6\u3001\u79FB\u52A8\u4E0E\u76EE\u6807\u683C\u6548\u679C\u3001\u5230\u8FBE\u7EC8\u70B9\u8BB0\u5F55\u3001\u8BE5\u6307\u4EE4\u5F00\u59CB\u65F6\u5DF2\u6709\u7684\u4E2D\u6BD2\u4E0E\u901F\u5EA6\u72B6\u6001\uFF0C\u6700\u540E\u5224\u65AD\u662F\u5426\u6B7B\u4EA1\u3002" : usesPoison ? "\u4E00\u6761\u6307\u4EE4\u4E2D\uFF0C\u4F9D\u6B21\u7ED3\u7B97\u79FB\u52A8\u4E0E\u76EE\u6807\u683C\u6548\u679C\u3001\u5230\u8FBE\u7EC8\u70B9\u8BB0\u5F55\u3001\u8BE5\u6307\u4EE4\u5F00\u59CB\u65F6\u5DF2\u6709\u7684\u4E2D\u6BD2\u72B6\u6001\uFF0C\u6700\u540E\u5224\u65AD\u662F\u5426\u6B7B\u4EA1\u3002" : "\u4E00\u6761\u6307\u4EE4\u4E2D\uFF0C\u4F9D\u6B21\u7ED3\u7B97\u79FB\u52A8\u4E0E\u76EE\u6807\u683C\u6548\u679C\u3001\u5230\u8FBE\u7EC8\u70B9\u8BB0\u5F55\uFF0C\u6700\u540E\u5224\u65AD\u662F\u5426\u6B7B\u4EA1\u3002";
|
|
1266
|
+
const orderRuleEn = m.complexity === "advanced" ? "Within one instruction, resolve time cost first, then movement and target-cell effects, then the reached-goal record, then poison and speed states that were active at the instruction's start, and finally death." : usesPoison ? "Within one instruction, resolve movement and target-cell effects, then the reached-goal record, then poison that was active at the instruction's start, and finally death." : "Within one instruction, resolve movement and target-cell effects, then the reached-goal record, and finally death.";
|
|
1267
|
+
const poisonRuleZh = "\u6BD2\u836F\u4ECE\u996E\u7528\u540E\u7684\u4E0B\u4E00\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u5F00\u59CB\u751F\u6548\u3002\u6BCF\u6761\u6307\u4EE4\uFF08\u5305\u62EC\u56E0\u5899\u3001\u5730\u56FE\u8FB9\u754C\u6216\u95E8\u800C\u672A\u79FB\u52A8\u7684\u6307\u4EE4\uFF09\u7ED3\u675F\u65F6\u6263\u9664\u6307\u5B9A\u5065\u5EB7\u503C\u5E76\u51CF\u5C11\u4E00\u6B21\u5269\u4F59\u6B21\u6570\u3002\u996E\u7528\u65B0\u7684\u6BD2\u836F\u4F1A\u8986\u76D6\u539F\u4E2D\u6BD2\u72B6\u6001\uFF1B\u996E\u7528\u6BD2\u836F\u6216\u89E3\u6BD2\u836F\u6C34\u7684\u5F53\u524D\u6307\u4EE4\u90FD\u4E0D\u4F1A\u89E6\u53D1\u539F\u6709\u6BD2\u7D20\u3002";
|
|
1268
|
+
const poisonRuleEn = "Poison starts with the instruction after it is drunk. At the end of each such instruction\u2014including one blocked by a wall, map boundary, or door\u2014it deals the stated damage and consumes one remaining instruction. New poison replaces any previous poison; neither a poison potion nor an antidote potion lets the previous poison tick on the instruction in which it is drunk.";
|
|
1269
|
+
const speedRuleZh = `\u6BCF\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u5728\u5F00\u59CB\u65F6\u6309\u5F53\u65F6\u901F\u5EA6\u8BA1\u5165\u65F6\u95F4\u5355\u4F4D\uFF1A\u6B63\u5E38${m.movementTime.normal}\u3001\u52A0\u901F${m.movementTime.fast}\u3001\u51CF\u901F${m.movementTime.slow}\u3002\u996E\u7528\u52A0\u901F\u836F\u6C34\u4F1A\u628A\u901F\u5EA6\u8BBE\u4E3A\u52A0\u901F\uFF0C\u996E\u7528\u51CF\u901F\u836F\u6C34\u4F1A\u628A\u901F\u5EA6\u8BBE\u4E3A\u51CF\u901F\uFF1B\u4E24\u79CD\u6548\u679C\u90FD\u4ECE\u996E\u7528\u540E\u7684\u4E0B\u4E00\u6761\u6307\u4EE4\u5F00\u59CB\uFF0C\u6301\u7EED\u6307\u5B9A\u6570\u91CF\u7684\u6307\u4EE4\u3002\u649E\u5899\u6216\u88AB\u95E8\u6321\u4F4F\u4E5F\u4F1A\u6D88\u8017\u4E00\u6B21\u6301\u7EED\u6B21\u6570\u3002\u996E\u7528\u901F\u5EA6\u836F\u6C34\u7684\u5F53\u524D\u6307\u4EE4\u6309\u996E\u7528\u524D\u7684\u901F\u5EA6\u8BA1\u65F6\uFF1B\u65B0\u901F\u5EA6\u836F\u6C34\u8986\u76D6\u65E7\u901F\u5EA6\u72B6\u6001\uFF0C\u5F53\u524D\u6307\u4EE4\u4E0D\u6D88\u8017\u65E7\u72B6\u6001\u6216\u65B0\u72B6\u6001\u7684\u6301\u7EED\u6B21\u6570\u3002\u6301\u7EED\u6B21\u6570\u8017\u5C3D\u540E\uFF0C\u901F\u5EA6\u6062\u590D\u4E3A\u6B63\u5E38\uFF0C\u5269\u4F59\u6307\u4EE4\u6570\u4E3A0\u3002`;
|
|
1270
|
+
const speedRuleEn = `At the start of each atomic movement instruction, add time units according to the current speed: normal ${m.movementTime.normal}, fast ${m.movementTime.fast}, slow ${m.movementTime.slow}. A haste potion sets speed to fast, and a slow potion sets speed to slow; either effect starts with the instruction after it is drunk and lasts for its stated number of instructions. Blocked instructions also consume one duration. The instruction that drinks a speed potion uses the pre-drink speed for time. A new speed potion replaces the previous speed state, and that instruction consumes neither the old nor the new duration. When the duration is exhausted, speed returns to normal with 0 speed-effect instructions remaining.`;
|
|
1271
|
+
const rules = language === "zh" ? [
|
|
1272
|
+
"\u6BCF\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u53EA\u5C1D\u8BD5\u5411\u4E0A\u3001\u4E0B\u3001\u5DE6\u6216\u53F3\u76F8\u90BB\u7684\u4E00\u683C\u79FB\u52A8\u3002\u884C\u52A8\u63CF\u8FF0\u4E2D\u7684\u201C\u79FB\u52A8N\u683C\u201D\u8868\u793A\u8FDE\u7EED\u6267\u884CN\u6761\u540C\u65B9\u5411\u7684\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u3002\u521D\u59CB\u4F4D\u4E8E\u5165\u53E3\u4E0D\u89C6\u4E3A\u8FDB\u5165\u8BE5\u683C\uFF1B\u53EA\u6709\u6210\u529F\u79FB\u52A8\u8FDB\u5165\u76EE\u6807\u683C\u65F6\uFF0C\u624D\u7ED3\u7B97\u8BE5\u683C\u7684\u6548\u679C\u3002",
|
|
1273
|
+
"\u82E5\u76EE\u6807\u683C\u662F\u5899\u683C\u6216\u5728\u5730\u56FE\u5916\uFF0C\u63A2\u9669\u8005\u505C\u5728\u539F\u5730\uFF0C\u4F46\u8BE5\u6307\u4EE4\u4ECD\u7B97\u4F5C\u4E00\u6761\u5DF2\u6267\u884C\u7684\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\uFF0C\u4E4B\u540E\u7684\u6307\u4EE4\u7EE7\u7EED\u6267\u884C\u3002",
|
|
1274
|
+
doorRuleZh,
|
|
1275
|
+
"\u7B2C\u4E00\u6B21\u8FDB\u5165\u94A5\u5319\u683C\u65F6\u53D6\u5F97\u8BE5\u94A5\u5319\uFF1B\u540C\u4E00\u628A\u94A5\u5319\u4E0D\u80FD\u91CD\u590D\u53D6\u5F97\u3002",
|
|
1276
|
+
chestRuleZh,
|
|
1277
|
+
"\u7B2C\u4E00\u6B21\u8FDB\u5165\u9677\u9631\u683C\u65F6\u53D7\u5230\u8BE5\u9677\u9631\u6807\u660E\u7684\u4F24\u5BB3\uFF1B\u540C\u4E00\u9677\u9631\u4EE5\u540E\u4E0D\u518D\u751F\u6548\u3002",
|
|
1278
|
+
usesPotionObjects ? "\u836F\u6C34\u683C\u662F\u6307\u542B\u6709\u6CBB\u7597\u836F\u6C34\u3001\u6BD2\u836F\u3001\u89E3\u6BD2\u836F\u6C34\u3001\u52A0\u901F\u836F\u6C34\u6216\u51CF\u901F\u836F\u6C34\u7684\u683C\u5B50\u3002\u7B2C\u4E00\u6B21\u8FDB\u5165\u836F\u6C34\u683C\u65F6\u7ACB\u5373\u996E\u7528\u5176\u4E2D\u7684\u836F\u6C34\u5E76\u5E94\u7528\u5176\u6548\u679C\uFF1B\u540C\u4E00\u74F6\u836F\u6C34\u4E0D\u80FD\u91CD\u590D\u996E\u7528\u3002" : "\u7B2C\u4E00\u6B21\u8FDB\u5165\u836F\u54C1\u623F\u65F6\u6062\u590D\u5176\u6807\u660E\u7684\u5065\u5EB7\u503C\uFF0C\u4F46\u4E0D\u8D85\u8FC7\u5065\u5EB7\u4E0A\u9650\uFF1B\u540C\u4E00\u836F\u54C1\u623F\u4EE5\u540E\u4E0D\u518D\u751F\u6548\u3002",
|
|
1279
|
+
orderRuleZh,
|
|
1280
|
+
...usesPoison ? [poisonRuleZh] : [],
|
|
1281
|
+
...m.complexity === "advanced" ? [speedRuleZh] : [],
|
|
1282
|
+
"\u5065\u5EB7\u503C\u964D\u81F30\u6216\u4EE5\u4E0B\u65F6\uFF0C\u5C06\u5065\u5EB7\u503C\u8BB0\u4E3A0\uFF0C\u63A2\u9669\u8005\u6B7B\u4EA1\u3002\u6B7B\u4EA1\u540E\u5C1A\u672A\u6267\u884C\u7684\u6307\u4EE4\u4ECD\u88AB\u8BFB\u53D6\uFF0C\u4F46\u4E0D\u79FB\u52A8\u3001\u4E0D\u8017\u65F6\uFF0C\u4E5F\u4E0D\u6539\u53D8\u4EFB\u4F55\u72B6\u6001\u3002",
|
|
1283
|
+
"\u4E00\u65E6\u8FDB\u5165\u7EC8\u70B9\u683C\uFF0C\u201C\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\u201D\u6C38\u4E45\u8BB0\u4E3A\u771F\uFF0C\u5373\u4F7F\u540E\u6765\u79BB\u5F00\u6216\u6B7B\u4EA1\u4E5F\u4E0D\u64A4\u9500\u3002"
|
|
1284
|
+
] : [
|
|
1285
|
+
"Each atomic movement instruction attempts to move exactly one cell up, down, left, or right. A direction followed by N cells means N consecutive atomic movement instructions in that direction. Starting at the entry does not count as entering its cell; target-cell effects are resolved only after a successful movement into that cell.",
|
|
1286
|
+
"If the target is a wall or outside the map, the explorer stays in place. The instruction still counts as executed, and later instructions continue.",
|
|
1287
|
+
doorRuleEn,
|
|
1288
|
+
"The first time the explorer enters a key cell, that key is collected. A key cannot be collected twice.",
|
|
1289
|
+
chestRuleEn,
|
|
1290
|
+
"The first time the explorer enters a trap cell, the trap reduces health by its stated amount. That trap has no effect afterward.",
|
|
1291
|
+
usesPotionObjects ? "A potion cell contains a healing, poison, antidote, haste, or slow potion. The first time the explorer enters a potion cell, its potion is drunk immediately and its effect is applied. A potion cannot be drunk twice." : "The first time the explorer enters a medicine room, it restores the stated health without exceeding maximum health. It has no effect afterward.",
|
|
1292
|
+
orderRuleEn,
|
|
1293
|
+
...usesPoison ? [poisonRuleEn] : [],
|
|
1294
|
+
...m.complexity === "advanced" ? [speedRuleEn] : [],
|
|
1295
|
+
"If health becomes 0 or less, set it to 0 and the explorer dies. Remaining instructions are still read but cause no movement, consume no time, and change no state.",
|
|
1296
|
+
"Once the explorer enters the goal, ever-reached-goal remains true even if the explorer later leaves or dies."
|
|
1297
|
+
];
|
|
1298
|
+
return rules.map((rule, index) => `${index + 1}. ${rule}`).join("\n");
|
|
1299
|
+
}
|
|
1300
|
+
function renderQuestions(maze, language) {
|
|
1301
|
+
const materialKeys = maze.mechanisms.keyMaterials.length > 0;
|
|
1302
|
+
const questions = language === "zh" ? ["\u5168\u90E8\u6307\u4EE4\u5904\u7406\u5B8C\u6BD5\u540E\uFF0C\u63A2\u9669\u8005\u4F4D\u4E8E\u54EA\u4E00\u683C\uFF1F", materialKeys ? "\u6700\u540E\u6301\u6709\u7684\u94A5\u5319\u603B\u6570\uFF08\u6240\u6709\u6750\u8D28\u5408\u8BA1\uFF09\u662F\u591A\u5C11\uFF1F" : "\u6700\u540E\u6301\u6709\u51E0\u628A\u666E\u901A\u94A5\u5319\uFF1F", "\u6700\u540E\u53D6\u5F97\u7684\u5B9D\u7269\u603B\u6570\uFF08\u6240\u6709\u7C7B\u578B\u5408\u8BA1\uFF09\u662F\u591A\u5C11\uFF1F", "\u6700\u540E\u5269\u4F59\u591A\u5C11\u5065\u5EB7\u503C\uFF1F", "\u6700\u540E\u662F\u5426\u5B58\u6D3B\uFF1F", "\u662F\u5426\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\uFF1F", "\u5171\u6253\u5F00\u51E0\u9053\u95E8\uFF1F", "\u5171\u6253\u5F00\u51E0\u53EA\u5B9D\u7BB1\uFF1F", "\u5171\u89E6\u53D1\u51E0\u4E2A\u9677\u9631\uFF1F"] : ["Which cell contains the explorer after all instructions have been processed?", materialKeys ? "How many keys remain in total across all key materials?" : "How many ordinary keys remain?", "How many treasures were collected in total across all treasure types?", "How much health remains?", "Is the explorer alive at the end?", "Did the explorer ever reach the goal?", "How many doors were opened?", "How many chests were opened?", "How many traps were triggered?"];
|
|
1303
|
+
if (maze.objects.some((item) => item.type === "medicine")) questions.push(language === "zh" ? "\u5171\u4F7F\u7528\u51E0\u4E2A\u836F\u54C1\u623F\uFF1F" : "How many medicine rooms were used?");
|
|
1304
|
+
if (maze.objects.some((item) => item.type === "potion")) questions.push(language === "zh" ? "\u5171\u996E\u7528\u51E0\u74F6\u836F\u6C34\uFF1F" : "How many potions were drunk?");
|
|
1305
|
+
questions.push(language === "zh" ? "\u6309\u5168\u90E8\u5217\u51FA\u7684\u539F\u5B50\u79FB\u52A8\u6307\u4EE4\u8BA1\u6570\uFF08\u5305\u62EC\u6B7B\u4EA1\u540E\u4ECD\u88AB\u8BFB\u53D6\u7684\u6307\u4EE4\uFF09\uFF0C\u5176\u4E2D\u591A\u5C11\u6761\u6CA1\u6709\u6539\u53D8\u63A2\u9669\u8005\u7684\u4F4D\u7F6E\uFF1F" : "Counting every listed atomic movement instruction, including instructions read after death, how many did not change the explorer's position?");
|
|
1306
|
+
for (const material of maze.mechanisms.keyMaterials) questions.push(language === "zh" ? `\u6700\u540E\u5269\u4F59\u51E0\u628A${MATERIAL_NAMES[material].zh}\u94A5\u5319\uFF1F` : `How many ${MATERIAL_NAMES[material].en} keys remain?`);
|
|
1307
|
+
if (maze.mechanisms.treasureTypes.some((type) => type !== "treasure")) for (const type of maze.mechanisms.treasureTypes) questions.push(language === "zh" ? `\u5171\u53D6\u5F97\u591A\u5C11${treasureUnit(type)}${TREASURE_NAMES[type].zh}\uFF1F` : `How many ${TREASURE_NAMES[type].en}s were collected?`);
|
|
1308
|
+
if (maze.mechanisms.complexity === "advanced") questions.push(language === "zh" ? "\u7D2F\u8BA1\u8017\u65F6\u662F\u591A\u5C11\u4E2A\u65F6\u95F4\u5355\u4F4D\uFF1F" : "What is the total elapsed time in time units?", language === "zh" ? "\u6700\u7EC8\u901F\u5EA6\u72B6\u6001\uFF08\u6B63\u5E38\u3001\u52A0\u901F\u6216\u51CF\u901F\uFF09\u53CA\u901F\u5EA6\u6548\u679C\u5269\u4F59\u6307\u4EE4\u6570\u5206\u522B\u662F\u4EC0\u4E48\uFF1F" : "What are the final speed state (normal, fast, or slow) and the number of speed-effect instructions remaining?", language === "zh" ? "\u6700\u7EC8\u4E2D\u6BD2\u72B6\u6001\u8FD8\u5269\u51E0\u6761\u6307\u4EE4\uFF1F" : "How many poison instructions remain at the end?");
|
|
1309
|
+
return questions.map((item, index) => `${index + 1}. ${item}`).join("\n");
|
|
1053
1310
|
}
|
|
1054
1311
|
function renderActionDescription(actions, language, style) {
|
|
1055
1312
|
const runs = compressActions(actions);
|
|
1056
1313
|
if (language === "zh") {
|
|
1057
1314
|
const connectors = ["\u968F\u540E", "\u63A5\u7740", "\u7136\u540E", "\u518D", "\u4E4B\u540E"];
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
});
|
|
1062
|
-
return paragraphize(clauses2, "\uFF0C", "\u3002");
|
|
1063
|
-
}
|
|
1064
|
-
const clauses = runs.map((run, index) => {
|
|
1065
|
-
const connector = index === 0 ? "First, the explorer moves" : index % 4 === 0 ? "The explorer then moves" : "then moves";
|
|
1066
|
-
const directionForms = {
|
|
1067
|
-
up: ["up", "upward"],
|
|
1068
|
-
down: ["down", "downward"],
|
|
1069
|
-
left: ["left", "to the left"],
|
|
1070
|
-
right: ["right", "to the right"]
|
|
1071
|
-
};
|
|
1072
|
-
const forms = directionForms[run.direction];
|
|
1073
|
-
const direction = forms[(style + index) % forms.length] ?? DIRECTIONS[run.direction].en;
|
|
1074
|
-
return `${connector} ${direction} ${run.count} cell${plural(run.count)}`;
|
|
1075
|
-
});
|
|
1076
|
-
return paragraphize(clauses, ", ", ".");
|
|
1315
|
+
return paragraphize(runs.map((run, index) => `${index === 0 ? "\u63A2\u9669\u8005\u5148" : connectors[(style + index) % connectors.length] ?? "\u7136\u540E"}\u5411${DIRECTIONS[run.direction].zh}\u79FB\u52A8${run.count}\u683C`), "\uFF0C", "\u3002");
|
|
1316
|
+
}
|
|
1317
|
+
return paragraphize(runs.map((run, index) => `${index === 0 ? "First, the explorer moves" : index % 4 === 0 ? "The explorer then moves" : "then moves"} ${DIRECTIONS[run.direction].en} ${run.count} cell${plural(run.count)}`), ", ", ".");
|
|
1077
1318
|
}
|
|
1078
1319
|
function paragraphize(clauses, separator, terminator) {
|
|
1079
1320
|
const sentences = [];
|
|
1080
|
-
for (let index = 0; index < clauses.length; index += 4) {
|
|
1081
|
-
sentences.push(`${clauses.slice(index, index + 4).join(separator)}${terminator}`);
|
|
1082
|
-
}
|
|
1321
|
+
for (let index = 0; index < clauses.length; index += 4) sentences.push(`${clauses.slice(index, index + 4).join(separator)}${terminator}`);
|
|
1083
1322
|
return sentences.join("\n");
|
|
1084
1323
|
}
|
|
1085
1324
|
function compressActions(actions) {
|
|
@@ -1091,6 +1330,18 @@ function compressActions(actions) {
|
|
|
1091
1330
|
}
|
|
1092
1331
|
return runs;
|
|
1093
1332
|
}
|
|
1333
|
+
function routeThrough(maze, points) {
|
|
1334
|
+
const actions = [];
|
|
1335
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
1336
|
+
const from = points[index - 1];
|
|
1337
|
+
const to = points[index];
|
|
1338
|
+
if (!from || !to) continue;
|
|
1339
|
+
const path = shortestPath(maze, from, to);
|
|
1340
|
+
if (path.length === 0) throw new Error("Could not connect scenario waypoints.");
|
|
1341
|
+
actions.push(...pathToActions(path));
|
|
1342
|
+
}
|
|
1343
|
+
return actions;
|
|
1344
|
+
}
|
|
1094
1345
|
function pathToActions(path) {
|
|
1095
1346
|
const actions = [];
|
|
1096
1347
|
for (let index = 1; index < path.length; index += 1) {
|
|
@@ -1104,40 +1355,28 @@ function pathToActions(path) {
|
|
|
1104
1355
|
return actions;
|
|
1105
1356
|
}
|
|
1106
1357
|
function wallDirection(maze, position) {
|
|
1107
|
-
const candidates = [
|
|
1108
|
-
["up", cell(position.row - 1, position.col)],
|
|
1109
|
-
["down", cell(position.row + 1, position.col)],
|
|
1110
|
-
["left", cell(position.row, position.col - 1)],
|
|
1111
|
-
["right", cell(position.row, position.col + 1)]
|
|
1112
|
-
];
|
|
1358
|
+
const candidates = [["up", cell(position.row - 1, position.col)], ["down", cell(position.row + 1, position.col)], ["left", cell(position.row, position.col - 1)], ["right", cell(position.row, position.col + 1)]];
|
|
1113
1359
|
return candidates.find(([, target]) => terrainAt(maze, target) !== ".")?.[0] ?? null;
|
|
1114
1360
|
}
|
|
1115
|
-
function coordinateList(values, language) {
|
|
1116
|
-
if (values.length <= 1) return values[0] ?? "";
|
|
1117
|
-
const last = values.at(-1);
|
|
1118
|
-
return language === "zh" ? `${values.slice(0, -1).join("\u3001")}\u548C${last}` : `${values.slice(0, -1).join(", ")} and ${last}`;
|
|
1119
|
-
}
|
|
1120
1361
|
function initialPossessions(state, language) {
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
const treasures = state.treasures > 0 ? `${state.treasures} treasure${plural(state.treasures)}` : "no treasures";
|
|
1128
|
-
return `${keys} and ${treasures}`;
|
|
1362
|
+
const materials = emptyMaterialCounts(state.keysByMaterial);
|
|
1363
|
+
const parts = Object.keys(materials).filter((key) => materials[key] > 0).map((key) => language === "zh" ? `${materials[key]}\u628A${MATERIAL_NAMES[key].zh}\u94A5\u5319` : `${materials[key]} ${MATERIAL_NAMES[key].en} key${plural(materials[key])}`);
|
|
1364
|
+
if (state.keys > 0) parts.unshift(language === "zh" ? `${state.keys}\u628A\u666E\u901A\u94A5\u5319` : `${state.keys} ordinary key${plural(state.keys)}`);
|
|
1365
|
+
const keys = parts.length > 0 ? parts.join(language === "zh" ? "\u3001" : ", ") : language === "zh" ? "\u6CA1\u6709\u94A5\u5319" : "no keys";
|
|
1366
|
+
const treasures = state.treasures > 0 ? language === "zh" ? `${state.treasures}\u4EF6\u5B9D\u7269` : `${state.treasures} treasure${plural(state.treasures)}` : language === "zh" ? "\u6CA1\u6709\u5B9D\u7269" : "no treasures";
|
|
1367
|
+
return language === "zh" ? `\u6301\u6709${keys}\uFF0C${treasures}` : `${keys} and ${treasures}`;
|
|
1129
1368
|
}
|
|
1130
|
-
function
|
|
1131
|
-
|
|
1132
|
-
return forms[value] ?? String(value);
|
|
1369
|
+
function treasureUnit(type) {
|
|
1370
|
+
return type === "coin" ? "\u679A" : type === "gem" ? "\u9897" : "\u4EF6";
|
|
1133
1371
|
}
|
|
1134
1372
|
function plural(value) {
|
|
1135
1373
|
return value === 1 ? "" : "s";
|
|
1136
1374
|
}
|
|
1375
|
+
function capitalize(value) {
|
|
1376
|
+
return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
|
|
1377
|
+
}
|
|
1137
1378
|
function integer(value, name, minimum) {
|
|
1138
|
-
if (!Number.isInteger(value) || value < minimum) {
|
|
1139
|
-
throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
|
|
1140
|
-
}
|
|
1379
|
+
if (!Number.isInteger(value) || value < minimum) throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
|
|
1141
1380
|
return value;
|
|
1142
1381
|
}
|
|
1143
1382
|
function oddInteger(value, name, minimum) {
|
|
@@ -1146,14 +1385,12 @@ function oddInteger(value, name, minimum) {
|
|
|
1146
1385
|
return value;
|
|
1147
1386
|
}
|
|
1148
1387
|
function finiteNumber(value, name, minimum, maximum) {
|
|
1149
|
-
if (!Number.isFinite(value) || value < minimum || value > maximum) {
|
|
1150
|
-
throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
|
|
1151
|
-
}
|
|
1388
|
+
if (!Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
|
|
1152
1389
|
return value;
|
|
1153
1390
|
}
|
|
1154
1391
|
|
|
1155
1392
|
// src/cli.ts
|
|
1156
|
-
var VERSION = "0.
|
|
1393
|
+
var VERSION = "0.3.0";
|
|
1157
1394
|
try {
|
|
1158
1395
|
const parsed = parseArguments(process.argv.slice(2));
|
|
1159
1396
|
if (parsed === "help") {
|
|
@@ -1267,6 +1504,33 @@ function parseArguments(args) {
|
|
|
1267
1504
|
case "--medicines":
|
|
1268
1505
|
trial.medicineCount = parseInteger(value, flag);
|
|
1269
1506
|
break;
|
|
1507
|
+
case "--potions":
|
|
1508
|
+
trial.potionCount = parseInteger(value, flag);
|
|
1509
|
+
break;
|
|
1510
|
+
case "--complexity":
|
|
1511
|
+
trial.complexity = value;
|
|
1512
|
+
break;
|
|
1513
|
+
case "--trap-damage":
|
|
1514
|
+
trial.trapDamages = parseIntegerList(value, flag);
|
|
1515
|
+
break;
|
|
1516
|
+
case "--potion-kinds":
|
|
1517
|
+
trial.potionKinds = parseList(value, flag);
|
|
1518
|
+
break;
|
|
1519
|
+
case "--key-materials":
|
|
1520
|
+
trial.keyMaterials = parseList(value, flag);
|
|
1521
|
+
break;
|
|
1522
|
+
case "--treasure-types":
|
|
1523
|
+
trial.treasureTypes = parseList(value, flag);
|
|
1524
|
+
break;
|
|
1525
|
+
case "--poison-damage":
|
|
1526
|
+
trial.poisonDamage = parseInteger(value, flag);
|
|
1527
|
+
break;
|
|
1528
|
+
case "--poison-duration":
|
|
1529
|
+
trial.poisonDuration = parseInteger(value, flag);
|
|
1530
|
+
break;
|
|
1531
|
+
case "--speed-duration":
|
|
1532
|
+
trial.speedDuration = parseInteger(value, flag);
|
|
1533
|
+
break;
|
|
1270
1534
|
case "--scenario":
|
|
1271
1535
|
trial.scenario = value;
|
|
1272
1536
|
break;
|
|
@@ -1302,6 +1566,14 @@ function parseNumber(value, flag) {
|
|
|
1302
1566
|
if (!Number.isFinite(parsed)) throw new Error(`${flag} requires a number.`);
|
|
1303
1567
|
return parsed;
|
|
1304
1568
|
}
|
|
1569
|
+
function parseList(value, flag) {
|
|
1570
|
+
const values = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1571
|
+
if (values.length === 0) throw new Error(`${flag} requires a non-empty comma-separated list.`);
|
|
1572
|
+
return values;
|
|
1573
|
+
}
|
|
1574
|
+
function parseIntegerList(value, flag) {
|
|
1575
|
+
return parseList(value, flag).map((item) => parseInteger(item, flag));
|
|
1576
|
+
}
|
|
1305
1577
|
function helpText() {
|
|
1306
1578
|
return `maze-test ${VERSION}
|
|
1307
1579
|
|
|
@@ -1320,11 +1592,20 @@ Options:
|
|
|
1320
1592
|
--rows <odd integer> Number of rows (default: 15)
|
|
1321
1593
|
--cols <odd integer> Number of columns (default: 15)
|
|
1322
1594
|
--braid <0..1> Chance to remove a dead end (default: 0)
|
|
1323
|
-
--
|
|
1324
|
-
--
|
|
1325
|
-
--
|
|
1326
|
-
--
|
|
1327
|
-
--
|
|
1595
|
+
--complexity <level> basic | intermediate | advanced (default: basic)
|
|
1596
|
+
--doors <integer> Number of doors and keys (preset-dependent)
|
|
1597
|
+
--chests <integer> Number of chests (preset-dependent)
|
|
1598
|
+
--traps <integer> Number of traps (preset-dependent)
|
|
1599
|
+
--potions <integer> Number of potions (preset-dependent)
|
|
1600
|
+
--medicines <integer> Legacy alias for --potions
|
|
1601
|
+
--trap-damage <list> Comma-separated positive damage values
|
|
1602
|
+
--potion-kinds <list> healing,poison,antidote,haste,slow
|
|
1603
|
+
--key-materials <list> copper,silver,gold
|
|
1604
|
+
--treasure-types <list> treasure,coin,gem,relic
|
|
1605
|
+
--poison-damage <int> Damage per poison tick
|
|
1606
|
+
--poison-duration <int> Poisoned instruction count
|
|
1607
|
+
--speed-duration <int> Fast/slow instruction count
|
|
1608
|
+
--scenario <name> success | treasure-and-leave | death-and-stop | mechanism-tour
|
|
1328
1609
|
(default: success)
|
|
1329
1610
|
--lang <language> en | zh (default: en)
|
|
1330
1611
|
--style <integer> Deterministic wording variation (default: 0)
|
|
@@ -1339,6 +1620,7 @@ Examples:
|
|
|
1339
1620
|
npx maze-test question --seed 42 --rows 15 --cols 15
|
|
1340
1621
|
npx maze-test answer --seed 42 --rows 15 --cols 15
|
|
1341
1622
|
npx maze-test question --seed 42 --lang zh
|
|
1623
|
+
npx maze-test question --seed 42 --complexity advanced --scenario mechanism-tour
|
|
1342
1624
|
npx maze-test answer --seed 42 --format json
|
|
1343
1625
|
`;
|
|
1344
1626
|
}
|