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.cjs
CHANGED
|
@@ -59,6 +59,122 @@ function neighbors(value, rows, cols) {
|
|
|
59
59
|
].filter((item) => item.row >= 1 && item.row <= rows && item.col >= 1 && item.col <= cols);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// src/mechanisms.ts
|
|
63
|
+
var MATERIALS = ["copper", "silver", "gold"];
|
|
64
|
+
var TREASURE_TYPES = ["treasure", "coin", "gem", "relic"];
|
|
65
|
+
var POTION_KINDS = ["healing", "poison", "antidote", "haste", "slow"];
|
|
66
|
+
function mechanismPreset(complexity) {
|
|
67
|
+
if (complexity === "basic") {
|
|
68
|
+
return {
|
|
69
|
+
complexity,
|
|
70
|
+
trapDamages: [1],
|
|
71
|
+
potionKinds: ["healing"],
|
|
72
|
+
keyMaterials: [],
|
|
73
|
+
treasureTypes: ["treasure"],
|
|
74
|
+
poisonDamage: 1,
|
|
75
|
+
poisonDuration: 3,
|
|
76
|
+
speedDuration: 4,
|
|
77
|
+
movementTime: { normal: 1, fast: 1, slow: 1 }
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (complexity === "intermediate") {
|
|
81
|
+
return {
|
|
82
|
+
complexity,
|
|
83
|
+
trapDamages: [1, 2, 3],
|
|
84
|
+
potionKinds: ["healing", "poison", "antidote"],
|
|
85
|
+
keyMaterials: [],
|
|
86
|
+
treasureTypes: ["coin", "gem", "relic"],
|
|
87
|
+
poisonDamage: 1,
|
|
88
|
+
poisonDuration: 3,
|
|
89
|
+
speedDuration: 4,
|
|
90
|
+
movementTime: { normal: 1, fast: 1, slow: 1 }
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
complexity,
|
|
95
|
+
trapDamages: [1, 2, 3],
|
|
96
|
+
potionKinds: ["healing", "poison", "antidote", "haste", "slow"],
|
|
97
|
+
keyMaterials: ["copper", "silver", "gold"],
|
|
98
|
+
treasureTypes: ["coin", "gem", "relic"],
|
|
99
|
+
poisonDamage: 1,
|
|
100
|
+
poisonDuration: 3,
|
|
101
|
+
speedDuration: 4,
|
|
102
|
+
movementTime: { normal: 2, fast: 1, slow: 3 }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function resolveMechanisms(options) {
|
|
106
|
+
const complexity = options.complexity ?? "basic";
|
|
107
|
+
if (!isComplexity(complexity)) throw new Error(`Unknown complexity: ${String(complexity)}`);
|
|
108
|
+
const preset = mechanismPreset(complexity);
|
|
109
|
+
const trapDamages = options.trapDamages ?? preset.trapDamages;
|
|
110
|
+
const potionKinds = options.potionKinds ?? preset.potionKinds;
|
|
111
|
+
const keyMaterials = options.keyMaterials ?? preset.keyMaterials;
|
|
112
|
+
const treasureTypes = options.treasureTypes ?? preset.treasureTypes;
|
|
113
|
+
validatePositiveList(trapDamages, "trapDamages");
|
|
114
|
+
validateEnumList(potionKinds, POTION_KINDS, "potionKinds");
|
|
115
|
+
validateEnumList(keyMaterials, MATERIALS, "keyMaterials", true);
|
|
116
|
+
validateEnumList(treasureTypes, TREASURE_TYPES, "treasureTypes");
|
|
117
|
+
if (complexity !== "advanced" && potionKinds.some((kind) => kind === "haste" || kind === "slow")) {
|
|
118
|
+
throw new Error("Haste and slow potions require advanced complexity so their movement-time costs are defined.");
|
|
119
|
+
}
|
|
120
|
+
if (complexity === "advanced" && keyMaterials.length === 0) {
|
|
121
|
+
throw new Error("Advanced complexity requires at least one key material.");
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
...preset,
|
|
125
|
+
trapDamages: [...trapDamages],
|
|
126
|
+
potionKinds: [...potionKinds],
|
|
127
|
+
keyMaterials: [...keyMaterials],
|
|
128
|
+
treasureTypes: [...treasureTypes],
|
|
129
|
+
poisonDamage: positiveInteger(options.poisonDamage ?? preset.poisonDamage, "poisonDamage"),
|
|
130
|
+
poisonDuration: positiveInteger(
|
|
131
|
+
options.poisonDuration ?? preset.poisonDuration,
|
|
132
|
+
"poisonDuration"
|
|
133
|
+
),
|
|
134
|
+
speedDuration: positiveInteger(options.speedDuration ?? preset.speedDuration, "speedDuration")
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function defaultObjectCounts(complexity) {
|
|
138
|
+
if (complexity === "basic") return { doors: 1, chests: 2, traps: 2, potions: 2 };
|
|
139
|
+
if (complexity === "intermediate") return { doors: 2, chests: 3, traps: 3, potions: 3 };
|
|
140
|
+
return { doors: 3, chests: 3, traps: 3, potions: 5 };
|
|
141
|
+
}
|
|
142
|
+
function emptyMaterialCounts(values = {}) {
|
|
143
|
+
return {
|
|
144
|
+
copper: values.copper ?? 0,
|
|
145
|
+
silver: values.silver ?? 0,
|
|
146
|
+
gold: values.gold ?? 0
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function emptyTreasureCounts(values = {}) {
|
|
150
|
+
return {
|
|
151
|
+
treasure: values.treasure ?? 0,
|
|
152
|
+
coin: values.coin ?? 0,
|
|
153
|
+
gem: values.gem ?? 0,
|
|
154
|
+
relic: values.relic ?? 0
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function totalMaterialKeys(counts) {
|
|
158
|
+
return counts.copper + counts.silver + counts.gold;
|
|
159
|
+
}
|
|
160
|
+
function isComplexity(value) {
|
|
161
|
+
return value === "basic" || value === "intermediate" || value === "advanced";
|
|
162
|
+
}
|
|
163
|
+
function positiveInteger(value, name) {
|
|
164
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`);
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
function validatePositiveList(values, name) {
|
|
168
|
+
if (values.length === 0) throw new Error(`${name} must not be empty.`);
|
|
169
|
+
for (const value of values) positiveInteger(value, name);
|
|
170
|
+
}
|
|
171
|
+
function validateEnumList(values, allowed, name, allowEmpty = false) {
|
|
172
|
+
if (!allowEmpty && values.length === 0) throw new Error(`${name} must not be empty.`);
|
|
173
|
+
for (const value of values) {
|
|
174
|
+
if (!allowed.includes(value)) throw new Error(`Unknown ${name} value: ${value}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
62
178
|
// src/maze.ts
|
|
63
179
|
function generateSolidCellMaze(options = {}) {
|
|
64
180
|
const rows = oddDimension(options.rows ?? 15, "rows");
|
|
@@ -66,6 +182,7 @@ function generateSolidCellMaze(options = {}) {
|
|
|
66
182
|
const seed = integerOption(options.seed ?? 1, "seed", 0);
|
|
67
183
|
const requestedSeed = integerOption(options.requestedSeed ?? seed, "requestedSeed", 0);
|
|
68
184
|
const braid = numberOption(options.braid ?? 0, "braid", 0, 1);
|
|
185
|
+
const mechanisms = options.mechanisms ?? mechanismPreset("basic");
|
|
69
186
|
const rng = mulberry32(seed);
|
|
70
187
|
const grid = Array.from({ length: rows }, () => Array(cols).fill("#"));
|
|
71
188
|
const logicalCells = [];
|
|
@@ -99,7 +216,7 @@ function generateSolidCellMaze(options = {}) {
|
|
|
99
216
|
const first = farthestFloor(cell(2, 2), terrain).cell;
|
|
100
217
|
const second = farthestFloor(first, terrain).cell;
|
|
101
218
|
const maze = {
|
|
102
|
-
schemaVersion: "solid-cell-maze-
|
|
219
|
+
schemaVersion: "solid-cell-maze-v4@1",
|
|
103
220
|
id: options.id ?? `solid-maze-${rows}x${cols}-${requestedSeed}`,
|
|
104
221
|
seed,
|
|
105
222
|
requestedSeed,
|
|
@@ -111,6 +228,7 @@ function generateSolidCellMaze(options = {}) {
|
|
|
111
228
|
columns: "letters-left-to-right",
|
|
112
229
|
rows: "numbers-top-to-bottom"
|
|
113
230
|
},
|
|
231
|
+
mechanisms: structuredClone(mechanisms),
|
|
114
232
|
terrain,
|
|
115
233
|
entry: first,
|
|
116
234
|
goal: second,
|
|
@@ -123,6 +241,8 @@ function generateSolidCellMaze(options = {}) {
|
|
|
123
241
|
}
|
|
124
242
|
function decorateSolidCellMaze(maze, options = {}) {
|
|
125
243
|
const result = structuredClone(maze);
|
|
244
|
+
const mechanisms = options.mechanisms ?? maze.mechanisms ?? mechanismPreset("basic");
|
|
245
|
+
result.mechanisms = structuredClone(mechanisms);
|
|
126
246
|
const decorationSeed = integerOption(options.seed ?? maze.seed + 71311, "decoration seed", 0);
|
|
127
247
|
const rng = mulberry32(decorationSeed);
|
|
128
248
|
const mainPath = shortestPath(result, result.entry, result.goal);
|
|
@@ -134,13 +254,23 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
134
254
|
);
|
|
135
255
|
const chestCount = integerOption(options.chestCount ?? 2, "chestCount", 0);
|
|
136
256
|
const trapCount = integerOption(options.trapCount ?? 2, "trapCount", 0);
|
|
137
|
-
const
|
|
257
|
+
const potionCount = integerOption(
|
|
258
|
+
options.potionCount ?? options.medicineCount ?? 2,
|
|
259
|
+
"potionCount",
|
|
260
|
+
0
|
|
261
|
+
);
|
|
138
262
|
const doorIndices = selectGatingDoorIndices(result, mainPath, doorCount);
|
|
139
263
|
result.doors = doorIndices.map((index, doorIndex) => {
|
|
140
264
|
const position = mainPath[index];
|
|
141
265
|
if (!position) throw new Error(`No path cell exists at door index ${index}.`);
|
|
142
266
|
occupied.add(cellKey(position));
|
|
143
|
-
|
|
267
|
+
const material = cyclicValue(mechanisms.keyMaterials, doorIndex + decorationSeed);
|
|
268
|
+
return {
|
|
269
|
+
id: `door-${doorIndex + 1}`,
|
|
270
|
+
position,
|
|
271
|
+
state: "closed",
|
|
272
|
+
...material ? { material } : {}
|
|
273
|
+
};
|
|
144
274
|
});
|
|
145
275
|
const objects = [];
|
|
146
276
|
for (let index = 0; index < result.doors.length; index += 1) {
|
|
@@ -149,7 +279,13 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
149
279
|
const preferredIndex = Math.max(1, Math.floor(doorIndex * 0.55));
|
|
150
280
|
const position = freePathCellBefore(mainPath, preferredIndex, occupied);
|
|
151
281
|
occupied.add(cellKey(position));
|
|
152
|
-
const
|
|
282
|
+
const door = result.doors[index];
|
|
283
|
+
const key = {
|
|
284
|
+
id: `key-${index + 1}`,
|
|
285
|
+
type: "key",
|
|
286
|
+
position,
|
|
287
|
+
...door?.material ? { material: door.material } : {}
|
|
288
|
+
};
|
|
153
289
|
objects.push(key);
|
|
154
290
|
}
|
|
155
291
|
const degrees = degreeMap(result);
|
|
@@ -162,11 +298,17 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
162
298
|
for (let index = 0; index < chestCount; index += 1) {
|
|
163
299
|
const position = takeFree(deadEnds, result, occupied, rng, "chest");
|
|
164
300
|
occupied.add(cellKey(position));
|
|
301
|
+
const treasureType = cyclicValue(mechanisms.treasureTypes, index + decorationSeed);
|
|
302
|
+
if (!treasureType) throw new Error("At least one treasure type is required.");
|
|
303
|
+
const count = 1 + index % 3;
|
|
304
|
+
const lockMaterial = cyclicValue(mechanisms.keyMaterials, index + decorationSeed + 1);
|
|
165
305
|
const chest = {
|
|
166
306
|
id: `chest-${index + 1}`,
|
|
167
307
|
type: "chest",
|
|
168
308
|
position,
|
|
169
|
-
treasures:
|
|
309
|
+
treasures: count,
|
|
310
|
+
contents: [{ type: treasureType, count }],
|
|
311
|
+
...lockMaterial ? { lockMaterial } : {}
|
|
170
312
|
};
|
|
171
313
|
objects.push(chest);
|
|
172
314
|
}
|
|
@@ -178,21 +320,36 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
178
320
|
id: `trap-${index + 1}`,
|
|
179
321
|
type: "trap",
|
|
180
322
|
position,
|
|
181
|
-
damage: 1
|
|
323
|
+
damage: mechanisms.trapDamages[index % mechanisms.trapDamages.length] ?? 1
|
|
182
324
|
};
|
|
183
325
|
objects.push(trap);
|
|
184
326
|
}
|
|
185
|
-
const
|
|
186
|
-
for (let index = 0; index <
|
|
187
|
-
const position = takeFree(
|
|
327
|
+
const potionCandidates = shuffle(rng, floorCells(result));
|
|
328
|
+
for (let index = 0; index < potionCount; index += 1) {
|
|
329
|
+
const position = takeFree(potionCandidates, result, occupied, rng, "potion");
|
|
188
330
|
occupied.add(cellKey(position));
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
331
|
+
if (mechanisms.complexity === "basic" && mechanisms.potionKinds.length === 1 && mechanisms.potionKinds[0] === "healing") {
|
|
332
|
+
const medicine = {
|
|
333
|
+
id: `medicine-${index + 1}`,
|
|
334
|
+
type: "medicine",
|
|
335
|
+
position,
|
|
336
|
+
recovery: 1
|
|
337
|
+
};
|
|
338
|
+
objects.push(medicine);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const kind = mechanisms.potionKinds[index % mechanisms.potionKinds.length];
|
|
342
|
+
if (!kind) throw new Error("At least one potion kind is required.");
|
|
343
|
+
const potion = {
|
|
344
|
+
id: `potion-${index + 1}`,
|
|
345
|
+
type: "potion",
|
|
192
346
|
position,
|
|
193
|
-
|
|
347
|
+
kind,
|
|
348
|
+
...kind === "healing" ? { potency: 1 + index % 2 } : {},
|
|
349
|
+
...kind === "poison" ? { potency: mechanisms.poisonDamage, duration: mechanisms.poisonDuration } : {},
|
|
350
|
+
...kind === "haste" || kind === "slow" ? { duration: mechanisms.speedDuration } : {}
|
|
194
351
|
};
|
|
195
|
-
objects.push(
|
|
352
|
+
objects.push(potion);
|
|
196
353
|
}
|
|
197
354
|
result.objects = objects;
|
|
198
355
|
result.metrics = analyzeSolidCellMaze(result);
|
|
@@ -205,7 +362,8 @@ function analyzeSolidCellMaze(maze) {
|
|
|
205
362
|
const distances = bfsDistances(maze.entry, graph);
|
|
206
363
|
const degreeValues = [...graph.values()].map((items) => items.length);
|
|
207
364
|
const edgeCount = degreeValues.reduce((sum, value) => sum + value, 0) / 2;
|
|
208
|
-
const mainPath =
|
|
365
|
+
const mainPath = shortestPathInGraph(graph, maze.entry, maze.goal);
|
|
366
|
+
const entryGoalSeparators = separatingCellKeys(graph, maze.entry, maze.goal);
|
|
209
367
|
const objectCounts = {};
|
|
210
368
|
for (const object of maze.objects ?? []) {
|
|
211
369
|
objectCounts[object.type] = (objectCounts[object.type] ?? 0) + 1;
|
|
@@ -223,7 +381,7 @@ function analyzeSolidCellMaze(maze) {
|
|
|
223
381
|
entryGoalDistance: mainPath.length > 0 ? mainPath.length - 1 : null,
|
|
224
382
|
doorCount: maze.doors.length,
|
|
225
383
|
gatingDoorCount: maze.doors.filter(
|
|
226
|
-
(door) =>
|
|
384
|
+
(door) => entryGoalSeparators.has(cellKey(door.position))
|
|
227
385
|
).length,
|
|
228
386
|
objectCounts
|
|
229
387
|
};
|
|
@@ -284,6 +442,8 @@ function validateSolidCellMaze(maze) {
|
|
|
284
442
|
if (!key) errors.push(`Missing a key for ${door.id}.`);
|
|
285
443
|
else if (keyIndex === void 0 || doorIndex === void 0 || keyIndex >= doorIndex) {
|
|
286
444
|
errors.push(`${key.id} is not before ${door.id} on the main path.`);
|
|
445
|
+
} else if (door.material !== (key.type === "key" ? key.material : void 0)) {
|
|
446
|
+
errors.push(`${key.id} does not match the material of ${door.id}.`);
|
|
287
447
|
}
|
|
288
448
|
}
|
|
289
449
|
return { valid: errors.length === 0, errors, metrics };
|
|
@@ -291,6 +451,9 @@ function validateSolidCellMaze(maze) {
|
|
|
291
451
|
function shortestPath(maze, start, goal, excludedCell = null) {
|
|
292
452
|
if (excludedCell && (sameCell(start, excludedCell) || sameCell(goal, excludedCell))) return [];
|
|
293
453
|
const graph = adjacency(maze, excludedCell);
|
|
454
|
+
return shortestPathInGraph(graph, start, goal);
|
|
455
|
+
}
|
|
456
|
+
function shortestPathInGraph(graph, start, goal) {
|
|
294
457
|
const queue = [start];
|
|
295
458
|
const parent = /* @__PURE__ */ new Map([[cellKey(start), null]]);
|
|
296
459
|
const values = /* @__PURE__ */ new Map([[cellKey(start), start]]);
|
|
@@ -422,12 +585,14 @@ function degreeMap(maze) {
|
|
|
422
585
|
}
|
|
423
586
|
function selectGatingDoorIndices(maze, mainPath, count) {
|
|
424
587
|
if (count === 0) return [];
|
|
425
|
-
const
|
|
588
|
+
const graph = adjacency(maze);
|
|
589
|
+
const separators = separatingCellKeys(graph, maze.entry, maze.goal);
|
|
426
590
|
const candidates = [];
|
|
427
591
|
for (let index = 3; index < mainPath.length - 3; index += 1) {
|
|
428
592
|
const pathCell = mainPath[index];
|
|
429
|
-
if (!pathCell
|
|
430
|
-
|
|
593
|
+
if (!pathCell) continue;
|
|
594
|
+
const key = cellKey(pathCell);
|
|
595
|
+
if ((graph.get(key)?.length ?? 0) === 2 && separators.has(key)) candidates.push(index);
|
|
431
596
|
}
|
|
432
597
|
if (candidates.length < count) {
|
|
433
598
|
throw new Error(`Only ${candidates.length} non-bypassable path cells are available for ${count} doors.`);
|
|
@@ -446,6 +611,59 @@ function selectGatingDoorIndices(maze, mainPath, count) {
|
|
|
446
611
|
if (selected.length !== count) throw new Error(`Could not space ${count} doors along the main path.`);
|
|
447
612
|
return selected.sort((left, right) => left - right);
|
|
448
613
|
}
|
|
614
|
+
function separatingCellKeys(graph, start, goal) {
|
|
615
|
+
const startKey = cellKey(start);
|
|
616
|
+
const goalKey = cellKey(goal);
|
|
617
|
+
if (!graph.has(startKey) || !graph.has(goalKey)) return /* @__PURE__ */ new Set();
|
|
618
|
+
const discovered = /* @__PURE__ */ new Map();
|
|
619
|
+
const low = /* @__PURE__ */ new Map();
|
|
620
|
+
const parent = /* @__PURE__ */ new Map([[startKey, null]]);
|
|
621
|
+
const stack = [
|
|
622
|
+
{ key: startKey, nextNeighbor: 0 }
|
|
623
|
+
];
|
|
624
|
+
let order = 1;
|
|
625
|
+
discovered.set(startKey, order);
|
|
626
|
+
low.set(startKey, order);
|
|
627
|
+
while (stack.length > 0) {
|
|
628
|
+
const frame = stack.at(-1);
|
|
629
|
+
if (!frame) break;
|
|
630
|
+
const adjacent = graph.get(frame.key) ?? [];
|
|
631
|
+
const next = adjacent[frame.nextNeighbor];
|
|
632
|
+
if (next) {
|
|
633
|
+
frame.nextNeighbor += 1;
|
|
634
|
+
const nextKey = cellKey(next);
|
|
635
|
+
if (!discovered.has(nextKey)) {
|
|
636
|
+
order += 1;
|
|
637
|
+
discovered.set(nextKey, order);
|
|
638
|
+
low.set(nextKey, order);
|
|
639
|
+
parent.set(nextKey, frame.key);
|
|
640
|
+
stack.push({ key: nextKey, nextNeighbor: 0 });
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (nextKey !== parent.get(frame.key)) {
|
|
644
|
+
low.set(frame.key, Math.min(low.get(frame.key) ?? order, discovered.get(nextKey) ?? order));
|
|
645
|
+
}
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
stack.pop();
|
|
649
|
+
const parentKey = parent.get(frame.key);
|
|
650
|
+
if (parentKey) {
|
|
651
|
+
low.set(parentKey, Math.min(low.get(parentKey) ?? order, low.get(frame.key) ?? order));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (!discovered.has(goalKey)) return /* @__PURE__ */ new Set();
|
|
655
|
+
const separators = /* @__PURE__ */ new Set();
|
|
656
|
+
let childKey = goalKey;
|
|
657
|
+
while (childKey !== startKey) {
|
|
658
|
+
const parentKey = parent.get(childKey);
|
|
659
|
+
if (!parentKey) break;
|
|
660
|
+
if (parentKey !== startKey && (low.get(childKey) ?? Number.POSITIVE_INFINITY) >= (discovered.get(parentKey) ?? Number.NEGATIVE_INFINITY)) {
|
|
661
|
+
separators.add(parentKey);
|
|
662
|
+
}
|
|
663
|
+
childKey = parentKey;
|
|
664
|
+
}
|
|
665
|
+
return separators;
|
|
666
|
+
}
|
|
449
667
|
function freePathCellBefore(mainPath, preferredIndex, occupied) {
|
|
450
668
|
for (let distance = 0; distance < mainPath.length; distance += 1) {
|
|
451
669
|
for (const index of [preferredIndex - distance, preferredIndex + distance]) {
|
|
@@ -521,6 +739,10 @@ function shuffle(rng, input) {
|
|
|
521
739
|
}
|
|
522
740
|
return values;
|
|
523
741
|
}
|
|
742
|
+
function cyclicValue(values, index) {
|
|
743
|
+
if (values.length === 0) return void 0;
|
|
744
|
+
return values[(index % values.length + values.length) % values.length];
|
|
745
|
+
}
|
|
524
746
|
function mulberry32(seed) {
|
|
525
747
|
let value = seed >>> 0;
|
|
526
748
|
return () => {
|
|
@@ -551,40 +773,105 @@ function emptyMetrics(rows, cols) {
|
|
|
551
773
|
|
|
552
774
|
// src/simulator.ts
|
|
553
775
|
var DELTAS = {
|
|
554
|
-
up:
|
|
555
|
-
down:
|
|
556
|
-
left:
|
|
557
|
-
right:
|
|
776
|
+
up: { row: -1, col: 0 },
|
|
777
|
+
down: { row: 1, col: 0 },
|
|
778
|
+
left: { row: 0, col: -1 },
|
|
779
|
+
right: { row: 0, col: 1 }
|
|
558
780
|
};
|
|
559
781
|
function simulateTrial(maze, actions, initial = {}) {
|
|
782
|
+
const mechanisms = maze.mechanisms ?? mechanismPreset("basic");
|
|
560
783
|
const health = initial.health ?? 5;
|
|
561
784
|
const state = {
|
|
562
785
|
position: structuredClone(maze.entry),
|
|
563
786
|
health,
|
|
564
787
|
healthMax: initial.healthMax ?? health,
|
|
565
788
|
keys: initial.keys ?? 0,
|
|
789
|
+
keysByMaterial: emptyMaterialCounts(initial.keysByMaterial),
|
|
566
790
|
treasures: initial.treasures ?? 0,
|
|
791
|
+
treasuresByType: emptyTreasureCounts(initial.treasuresByType),
|
|
792
|
+
elapsedTime: initial.elapsedTime ?? 0,
|
|
793
|
+
speed: initial.speed ?? "normal",
|
|
794
|
+
speedRemaining: initial.speedRemaining ?? 0,
|
|
795
|
+
poisonDamage: initial.poisonDamage ?? 0,
|
|
796
|
+
poisonRemaining: initial.poisonRemaining ?? 0,
|
|
567
797
|
alive: health > 0,
|
|
568
798
|
reachedGoal: false,
|
|
569
799
|
openedDoors: [],
|
|
570
800
|
collectedKeys: [],
|
|
571
801
|
openedChests: [],
|
|
572
802
|
triggeredTraps: [],
|
|
573
|
-
usedMedicines: []
|
|
803
|
+
usedMedicines: [],
|
|
804
|
+
usedPotions: []
|
|
574
805
|
};
|
|
575
806
|
const trace = [];
|
|
576
807
|
for (let index = 0; index < actions.length; index += 1) {
|
|
577
808
|
const direction = actions[index];
|
|
578
809
|
if (!direction) continue;
|
|
579
|
-
|
|
810
|
+
const before = cloneState(state);
|
|
811
|
+
const events = [];
|
|
812
|
+
if (!state.alive) {
|
|
813
|
+
trace.push({ step: index + 1, direction, before, moved: false, reason: "dead", events, after: cloneState(state) });
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const context = {
|
|
817
|
+
speedAtStart: state.speed,
|
|
818
|
+
speedRemainingAtStart: state.speedRemaining,
|
|
819
|
+
poisonDamageAtStart: state.poisonDamage,
|
|
820
|
+
poisonRemainingAtStart: state.poisonRemaining,
|
|
821
|
+
speedReplaced: false,
|
|
822
|
+
poisonReplaced: false
|
|
823
|
+
};
|
|
824
|
+
const timeCost = mechanisms.movementTime[context.speedAtStart];
|
|
825
|
+
state.elapsedTime += timeCost;
|
|
826
|
+
const delta = DELTAS[direction];
|
|
827
|
+
const target = cell(state.position.row + delta.row, state.position.col + delta.col);
|
|
828
|
+
let moved = false;
|
|
829
|
+
let reason = null;
|
|
830
|
+
if (terrainAt(maze, target) !== ".") {
|
|
831
|
+
reason = "wall-or-outside";
|
|
832
|
+
} else {
|
|
833
|
+
const door = maze.doors.find((item) => cellKey(item.position) === cellKey(target));
|
|
834
|
+
if (door && !state.openedDoors.includes(door.id)) {
|
|
835
|
+
if (door.material) {
|
|
836
|
+
if (state.keysByMaterial[door.material] < 1) {
|
|
837
|
+
reason = "closed-door-without-matching-key";
|
|
838
|
+
} else {
|
|
839
|
+
state.keysByMaterial[door.material] -= 1;
|
|
840
|
+
state.openedDoors.push(door.id);
|
|
841
|
+
events.push({ type: "open-door", id: door.id, material: door.material, keyCost: 1, timeCost });
|
|
842
|
+
}
|
|
843
|
+
} else if (state.keys < 1) {
|
|
844
|
+
reason = "closed-door-without-key";
|
|
845
|
+
} else {
|
|
846
|
+
state.keys -= 1;
|
|
847
|
+
state.openedDoors.push(door.id);
|
|
848
|
+
events.push({ type: "open-door", id: door.id, keyCost: 1, timeCost });
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (!reason) {
|
|
852
|
+
state.position = target;
|
|
853
|
+
moved = true;
|
|
854
|
+
applyCellObjects(maze, state, events, context);
|
|
855
|
+
if (cellKey(state.position) === cellKey(maze.goal) && !state.reachedGoal) {
|
|
856
|
+
state.reachedGoal = true;
|
|
857
|
+
events.push({ type: "reach-goal" });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
finishStatuses(state, events, context);
|
|
862
|
+
trace.push({ step: index + 1, direction, before, moved, reason, events, after: cloneState(state) });
|
|
580
863
|
}
|
|
864
|
+
const blockedMoves = trace.filter((item) => !item.moved).length;
|
|
865
|
+
const blockedAfterDeath = trace.filter((item) => item.reason === "dead").length;
|
|
581
866
|
return {
|
|
582
|
-
state
|
|
867
|
+
state,
|
|
583
868
|
trace,
|
|
584
869
|
answers: {
|
|
585
870
|
finalPosition: letterNumberCoordinate(state.position),
|
|
586
|
-
keys: state.keys,
|
|
871
|
+
keys: state.keys + totalMaterialKeys(state.keysByMaterial),
|
|
872
|
+
keysByMaterial: structuredClone(state.keysByMaterial),
|
|
587
873
|
treasures: state.treasures,
|
|
874
|
+
treasuresByType: structuredClone(state.treasuresByType),
|
|
588
875
|
health: state.health,
|
|
589
876
|
alive: state.alive,
|
|
590
877
|
reachedGoal: state.reachedGoal,
|
|
@@ -592,90 +879,135 @@ function simulateTrial(maze, actions, initial = {}) {
|
|
|
592
879
|
openedChests: state.openedChests.length,
|
|
593
880
|
triggeredTraps: state.triggeredTraps.length,
|
|
594
881
|
usedMedicines: state.usedMedicines.length,
|
|
595
|
-
|
|
596
|
-
|
|
882
|
+
usedPotions: state.usedPotions.length,
|
|
883
|
+
elapsedTime: state.elapsedTime,
|
|
884
|
+
finalSpeed: state.speed,
|
|
885
|
+
speedRemaining: state.speedRemaining,
|
|
886
|
+
poisonDamage: state.poisonDamage,
|
|
887
|
+
poisonRemaining: state.poisonRemaining,
|
|
888
|
+
blockedMoves,
|
|
889
|
+
blockedAfterDeath
|
|
597
890
|
}
|
|
598
891
|
};
|
|
599
892
|
}
|
|
600
|
-
function
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
if (
|
|
604
|
-
const [dr, dc] = DELTAS[direction];
|
|
605
|
-
const target = cell(state.position.row + dr, state.position.col + dc);
|
|
606
|
-
if (terrainAt(maze, target) !== ".") {
|
|
607
|
-
return record(step, direction, before, state, false, "wall-or-outside", events);
|
|
608
|
-
}
|
|
609
|
-
const door = maze.doors.find((item) => sameCell(item.position, target));
|
|
610
|
-
if (door && !state.openedDoors.includes(door.id)) {
|
|
611
|
-
if (state.keys <= 0) {
|
|
612
|
-
return record(
|
|
613
|
-
step,
|
|
614
|
-
direction,
|
|
615
|
-
before,
|
|
616
|
-
state,
|
|
617
|
-
false,
|
|
618
|
-
"closed-door-without-key",
|
|
619
|
-
events
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
state.keys -= 1;
|
|
623
|
-
state.openedDoors.push(door.id);
|
|
624
|
-
events.push({ type: "open-door", id: door.id, keyCost: 1 });
|
|
625
|
-
}
|
|
626
|
-
state.position = target;
|
|
627
|
-
const object = maze.objects.find((item) => sameCell(item.position, target));
|
|
628
|
-
if (object?.type === "key" && !state.collectedKeys.includes(object.id)) {
|
|
893
|
+
function applyCellObjects(maze, state, events, context) {
|
|
894
|
+
const object = maze.objects.find((item) => cellKey(item.position) === cellKey(state.position));
|
|
895
|
+
if (!object) return;
|
|
896
|
+
if (object.type === "key" && !state.collectedKeys.includes(object.id)) {
|
|
629
897
|
state.collectedKeys.push(object.id);
|
|
630
|
-
state.
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
898
|
+
if (object.material) state.keysByMaterial[object.material] += 1;
|
|
899
|
+
else state.keys += 1;
|
|
900
|
+
events.push({
|
|
901
|
+
type: "collect-key",
|
|
902
|
+
id: object.id,
|
|
903
|
+
...object.material ? { material: object.material } : {}
|
|
904
|
+
});
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (object.type === "chest" && !state.openedChests.includes(object.id)) {
|
|
908
|
+
let canOpen = false;
|
|
909
|
+
if (object.lockMaterial) {
|
|
910
|
+
canOpen = state.keysByMaterial[object.lockMaterial] > 0;
|
|
911
|
+
if (canOpen) state.keysByMaterial[object.lockMaterial] -= 1;
|
|
912
|
+
} else {
|
|
913
|
+
canOpen = state.keys > 0;
|
|
914
|
+
if (canOpen) state.keys -= 1;
|
|
915
|
+
}
|
|
916
|
+
if (!canOpen) {
|
|
638
917
|
events.push({
|
|
639
|
-
type: "
|
|
918
|
+
type: "pass-closed-chest",
|
|
640
919
|
id: object.id,
|
|
641
|
-
|
|
642
|
-
treasures: object.treasures
|
|
920
|
+
...object.lockMaterial ? { material: object.lockMaterial } : {}
|
|
643
921
|
});
|
|
644
|
-
|
|
645
|
-
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
state.openedChests.push(object.id);
|
|
925
|
+
const contents = object.contents?.length > 0 ? object.contents : [{ type: "treasure", count: object.treasures }];
|
|
926
|
+
for (const content of contents) {
|
|
927
|
+
state.treasures += content.count;
|
|
928
|
+
state.treasuresByType[content.type] += content.count;
|
|
646
929
|
}
|
|
930
|
+
events.push({
|
|
931
|
+
type: "open-chest",
|
|
932
|
+
id: object.id,
|
|
933
|
+
...object.lockMaterial ? { material: object.lockMaterial } : {},
|
|
934
|
+
keyCost: 1,
|
|
935
|
+
treasures: object.treasures,
|
|
936
|
+
contents: structuredClone(contents)
|
|
937
|
+
});
|
|
938
|
+
return;
|
|
647
939
|
}
|
|
648
|
-
if (object
|
|
940
|
+
if (object.type === "trap" && !state.triggeredTraps.includes(object.id)) {
|
|
649
941
|
state.triggeredTraps.push(object.id);
|
|
650
942
|
state.health = Math.max(0, state.health - object.damage);
|
|
651
943
|
events.push({ type: "trigger-trap", id: object.id, damage: object.damage });
|
|
944
|
+
return;
|
|
652
945
|
}
|
|
653
|
-
if (object
|
|
946
|
+
if (object.type === "medicine" && !state.usedMedicines.includes(object.id)) {
|
|
654
947
|
state.usedMedicines.push(object.id);
|
|
655
|
-
const
|
|
948
|
+
const before = state.health;
|
|
656
949
|
state.health = Math.min(state.healthMax, state.health + object.recovery);
|
|
657
|
-
events.push({ type: "use-medicine", id: object.id, recovery: state.health -
|
|
950
|
+
events.push({ type: "use-medicine", id: object.id, recovery: state.health - before });
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (object.type === "potion" && !state.usedPotions.includes(object.id)) {
|
|
954
|
+
state.usedPotions.push(object.id);
|
|
955
|
+
applyPotion(state, object, context);
|
|
956
|
+
const recovery = object.kind === "healing" ? object.potency ?? 1 : void 0;
|
|
957
|
+
const damage = object.kind === "poison" ? object.potency ?? maze.mechanisms.poisonDamage : void 0;
|
|
958
|
+
events.push({
|
|
959
|
+
type: "drink-potion",
|
|
960
|
+
id: object.id,
|
|
961
|
+
potionKind: object.kind,
|
|
962
|
+
...recovery === void 0 ? {} : { recovery },
|
|
963
|
+
...damage === void 0 ? {} : { damage },
|
|
964
|
+
...object.duration === void 0 ? {} : { duration: object.duration }
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function applyPotion(state, potion, context) {
|
|
969
|
+
if (potion.kind === "healing") {
|
|
970
|
+
state.health = Math.min(state.healthMax, state.health + (potion.potency ?? 1));
|
|
971
|
+
} else if (potion.kind === "poison") {
|
|
972
|
+
state.poisonDamage = potion.potency ?? 1;
|
|
973
|
+
state.poisonRemaining = potion.duration ?? 1;
|
|
974
|
+
context.poisonReplaced = true;
|
|
975
|
+
} else if (potion.kind === "antidote") {
|
|
976
|
+
state.poisonDamage = 0;
|
|
977
|
+
state.poisonRemaining = 0;
|
|
978
|
+
context.poisonReplaced = true;
|
|
979
|
+
} else if (potion.kind === "haste") {
|
|
980
|
+
state.speed = "fast";
|
|
981
|
+
state.speedRemaining = potion.duration ?? 1;
|
|
982
|
+
context.speedReplaced = true;
|
|
983
|
+
} else {
|
|
984
|
+
state.speed = "slow";
|
|
985
|
+
state.speedRemaining = potion.duration ?? 1;
|
|
986
|
+
context.speedReplaced = true;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
function finishStatuses(state, events, context) {
|
|
990
|
+
if (!context.poisonReplaced && context.poisonRemainingAtStart > 0) {
|
|
991
|
+
state.health = Math.max(0, state.health - context.poisonDamageAtStart);
|
|
992
|
+
state.poisonRemaining = context.poisonRemainingAtStart - 1;
|
|
993
|
+
state.poisonDamage = state.poisonRemaining > 0 ? context.poisonDamageAtStart : 0;
|
|
994
|
+
events.push({ type: "poison-tick", damage: context.poisonDamageAtStart });
|
|
995
|
+
}
|
|
996
|
+
if (!context.speedReplaced && context.speedRemainingAtStart > 0) {
|
|
997
|
+
state.speedRemaining = context.speedRemainingAtStart - 1;
|
|
998
|
+
if (state.speedRemaining === 0) {
|
|
999
|
+
state.speed = "normal";
|
|
1000
|
+
events.push({ type: "speed-expired" });
|
|
1001
|
+
}
|
|
658
1002
|
}
|
|
659
1003
|
if (state.health <= 0 && state.alive) {
|
|
1004
|
+
state.health = 0;
|
|
660
1005
|
state.alive = false;
|
|
661
1006
|
events.push({ type: "die" });
|
|
662
1007
|
}
|
|
663
|
-
if (sameCell(state.position, maze.goal) && !state.reachedGoal) {
|
|
664
|
-
state.reachedGoal = true;
|
|
665
|
-
events.push({ type: "reach-goal" });
|
|
666
|
-
}
|
|
667
|
-
return record(step, direction, before, state, true, null, events);
|
|
668
1008
|
}
|
|
669
|
-
function
|
|
670
|
-
return
|
|
671
|
-
step,
|
|
672
|
-
direction,
|
|
673
|
-
before,
|
|
674
|
-
moved,
|
|
675
|
-
reason,
|
|
676
|
-
events,
|
|
677
|
-
after: structuredClone(state)
|
|
678
|
-
};
|
|
1009
|
+
function cloneState(state) {
|
|
1010
|
+
return structuredClone(state);
|
|
679
1011
|
}
|
|
680
1012
|
|
|
681
1013
|
// src/trial.ts
|
|
@@ -685,54 +1017,29 @@ var DIRECTIONS = {
|
|
|
685
1017
|
left: { en: "left", zh: "\u5DE6" },
|
|
686
1018
|
right: { en: "right", zh: "\u53F3" }
|
|
687
1019
|
};
|
|
688
|
-
var
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
var
|
|
700
|
-
"\
|
|
701
|
-
"\
|
|
702
|
-
"\
|
|
703
|
-
"\
|
|
704
|
-
"\
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
"\
|
|
708
|
-
"\
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
"Which cell is the explorer in after all actions are complete?",
|
|
712
|
-
"How many keys does the explorer have at the end?",
|
|
713
|
-
"How many treasures has the explorer collected at the end?",
|
|
714
|
-
"How many health points does the explorer have at the end?",
|
|
715
|
-
"Is the explorer alive at the end?",
|
|
716
|
-
"Did the explorer ever reach the goal?",
|
|
717
|
-
"How many doors were opened?",
|
|
718
|
-
"How many chests were opened?",
|
|
719
|
-
"How many traps were triggered?",
|
|
720
|
-
"How many medicine rooms were used?",
|
|
721
|
-
"How many individual moves did not change the explorer's position?"
|
|
722
|
-
];
|
|
723
|
-
var CHINESE_QUESTIONS = [
|
|
724
|
-
"\u5168\u90E8\u884C\u52A8\u7ED3\u675F\u540E\uFF0C\u63A2\u9669\u8005\u4F4D\u4E8E\u54EA\u4E00\u683C\uFF1F",
|
|
725
|
-
"\u63A2\u9669\u8005\u6700\u540E\u6301\u6709\u51E0\u628A\u94A5\u5319\uFF1F",
|
|
726
|
-
"\u63A2\u9669\u8005\u6700\u540E\u53D6\u5F97\u4E86\u51E0\u4EF6\u5B9D\u7269\uFF1F",
|
|
727
|
-
"\u63A2\u9669\u8005\u6700\u540E\u8FD8\u5269\u51E0\u70B9\u5065\u5EB7\u503C\uFF1F",
|
|
728
|
-
"\u63A2\u9669\u8005\u6700\u540E\u662F\u5426\u4ECD\u7136\u5B58\u6D3B\uFF1F",
|
|
729
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\uFF0C\u63A2\u9669\u8005\u662F\u5426\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\uFF1F",
|
|
730
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u6253\u5F00\u4E86\u51E0\u9053\u95E8\uFF1F",
|
|
731
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u6253\u5F00\u4E86\u51E0\u53EA\u5B9D\u7BB1\uFF1F",
|
|
732
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u89E6\u53D1\u4E86\u51E0\u4E2A\u9677\u9631\uFF1F",
|
|
733
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u4F7F\u7528\u4E86\u51E0\u4E2A\u836F\u54C1\u623F\uFF1F",
|
|
734
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u5171\u6709\u591A\u5C11\u6B21\u79FB\u52A8\u6CA1\u6709\u6539\u53D8\u63A2\u9669\u8005\u7684\u4F4D\u7F6E\uFF1F"
|
|
735
|
-
];
|
|
1020
|
+
var MATERIAL_NAMES = {
|
|
1021
|
+
copper: { en: "copper", zh: "\u94DC" },
|
|
1022
|
+
silver: { en: "silver", zh: "\u94F6" },
|
|
1023
|
+
gold: { en: "gold", zh: "\u91D1" }
|
|
1024
|
+
};
|
|
1025
|
+
var TREASURE_NAMES = {
|
|
1026
|
+
treasure: { en: "treasure", zh: "\u5B9D\u7269" },
|
|
1027
|
+
coin: { en: "coin", zh: "\u94B1\u5E01" },
|
|
1028
|
+
gem: { en: "gem", zh: "\u5B9D\u77F3" },
|
|
1029
|
+
relic: { en: "relic", zh: "\u9057\u7269" }
|
|
1030
|
+
};
|
|
1031
|
+
var POTION_NAMES = {
|
|
1032
|
+
healing: { en: "healing potion", zh: "\u6CBB\u7597\u836F\u6C34" },
|
|
1033
|
+
poison: { en: "poison potion", zh: "\u6BD2\u836F" },
|
|
1034
|
+
antidote: { en: "antidote potion", zh: "\u89E3\u6BD2\u836F\u6C34" },
|
|
1035
|
+
haste: { en: "haste potion", zh: "\u52A0\u901F\u836F\u6C34" },
|
|
1036
|
+
slow: { en: "slow potion", zh: "\u51CF\u901F\u836F\u6C34" }
|
|
1037
|
+
};
|
|
1038
|
+
var SPEED_NAMES = {
|
|
1039
|
+
normal: { en: "normal", zh: "\u6B63\u5E38" },
|
|
1040
|
+
fast: { en: "fast", zh: "\u52A0\u901F" },
|
|
1041
|
+
slow: { en: "slow", zh: "\u51CF\u901F" }
|
|
1042
|
+
};
|
|
736
1043
|
function generateTrial(options = {}) {
|
|
737
1044
|
const resolved = resolveTrialOptions(options);
|
|
738
1045
|
const maze = selectMaze(resolved);
|
|
@@ -741,82 +1048,72 @@ function generateTrial(options = {}) {
|
|
|
741
1048
|
verifyScenario(resolved.scenario, result.answers, maze);
|
|
742
1049
|
const sections = {
|
|
743
1050
|
map: renderMapDescription(maze, plan.initialState, resolved.language, resolved.style),
|
|
744
|
-
rules: renderRules(resolved.language),
|
|
1051
|
+
rules: renderRules(maze, resolved.language),
|
|
745
1052
|
actions: renderActionDescription(plan.actions, resolved.language, resolved.style),
|
|
746
|
-
questions: renderQuestions(resolved.language)
|
|
1053
|
+
questions: renderQuestions(maze, resolved.language)
|
|
747
1054
|
};
|
|
748
|
-
const question = renderQuestion(resolved, maze, sections);
|
|
749
|
-
const answer = renderAnswer(resolved, maze, result.answers, plan.actions.length);
|
|
750
1055
|
return {
|
|
751
|
-
schemaVersion: "maze-test-trial@
|
|
1056
|
+
schemaVersion: "maze-test-trial@2",
|
|
752
1057
|
options: resolved,
|
|
753
1058
|
maze,
|
|
754
1059
|
initialState: plan.initialState,
|
|
755
1060
|
actions: plan.actions,
|
|
756
1061
|
sections,
|
|
757
|
-
question,
|
|
758
|
-
answer,
|
|
1062
|
+
question: renderQuestion(resolved, maze, sections),
|
|
1063
|
+
answer: renderAnswer(resolved, maze, result.answers, plan.actions.length),
|
|
759
1064
|
result
|
|
760
1065
|
};
|
|
761
1066
|
}
|
|
762
1067
|
function resolveTrialOptions(options = {}) {
|
|
763
1068
|
const scenario = options.scenario ?? "success";
|
|
764
1069
|
const language = options.language ?? "en";
|
|
765
|
-
if (!["success", "treasure-and-leave", "death-and-stop"].includes(scenario)) {
|
|
766
|
-
throw new Error(`Unknown scenario: ${scenario}`);
|
|
767
|
-
}
|
|
1070
|
+
if (!["success", "treasure-and-leave", "death-and-stop", "mechanism-tour"].includes(scenario)) throw new Error(`Unknown scenario: ${scenario}`);
|
|
768
1071
|
if (language !== "en" && language !== "zh") throw new Error(`Unknown language: ${language}`);
|
|
1072
|
+
const mechanisms = resolveMechanisms(options);
|
|
1073
|
+
const defaults = defaultObjectCounts(mechanisms.complexity);
|
|
769
1074
|
const resolved = {
|
|
770
1075
|
seed: integer(options.seed ?? 1, "seed", 0),
|
|
771
1076
|
rows: oddInteger(options.rows ?? 15, "rows", 7),
|
|
772
1077
|
cols: oddInteger(options.cols ?? 15, "cols", 7),
|
|
773
1078
|
braid: finiteNumber(options.braid ?? 0, "braid", 0, 1),
|
|
774
|
-
doorCount: integer(options.doorCount ??
|
|
775
|
-
chestCount: integer(options.chestCount ??
|
|
776
|
-
trapCount: integer(options.trapCount ??
|
|
777
|
-
|
|
1079
|
+
doorCount: integer(options.doorCount ?? defaults.doors, "doorCount", 0),
|
|
1080
|
+
chestCount: integer(options.chestCount ?? defaults.chests, "chestCount", 0),
|
|
1081
|
+
trapCount: integer(options.trapCount ?? defaults.traps, "trapCount", 0),
|
|
1082
|
+
potionCount: integer(options.potionCount ?? options.medicineCount ?? defaults.potions, "potionCount", 0),
|
|
1083
|
+
mechanisms,
|
|
778
1084
|
scenario,
|
|
779
1085
|
language,
|
|
780
1086
|
style: integer(options.style ?? 0, "style", 0),
|
|
781
1087
|
minDistance: integer(options.minDistance ?? 0, "minDistance", 0),
|
|
782
1088
|
maxAttempts: integer(options.maxAttempts ?? 500, "maxAttempts", 1)
|
|
783
1089
|
};
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1090
|
+
validateScenarioOptions(resolved);
|
|
1091
|
+
return resolved;
|
|
1092
|
+
}
|
|
1093
|
+
function validateScenarioOptions(options) {
|
|
1094
|
+
if (options.scenario === "treasure-and-leave") {
|
|
1095
|
+
if (options.chestCount < 1) throw new Error("The treasure-and-leave scenario requires at least one chest.");
|
|
1096
|
+
if (!options.mechanisms.potionKinds.includes("healing")) throw new Error("The treasure-and-leave scenario requires healing in potionKinds.");
|
|
1097
|
+
if (options.potionCount < 1) throw new Error("The treasure-and-leave scenario requires at least one potion.");
|
|
789
1098
|
}
|
|
790
|
-
if (scenario === "death-and-stop" &&
|
|
791
|
-
|
|
1099
|
+
if (options.scenario === "death-and-stop" && options.trapCount < 1) throw new Error("The death-and-stop scenario requires at least one trap.");
|
|
1100
|
+
if (options.scenario === "mechanism-tour") {
|
|
1101
|
+
if (options.trapCount < options.mechanisms.trapDamages.length) throw new Error("The mechanism-tour scenario needs at least one trap for every configured trap damage.");
|
|
1102
|
+
if (options.potionCount < options.mechanisms.potionKinds.length) throw new Error("The mechanism-tour scenario needs at least one potion for every configured potion kind.");
|
|
1103
|
+
if (options.chestCount < options.mechanisms.treasureTypes.length) throw new Error("The mechanism-tour scenario needs at least one chest for every configured treasure type.");
|
|
1104
|
+
if (options.doorCount < options.mechanisms.keyMaterials.length) throw new Error("The mechanism-tour scenario needs at least one door for every configured key material.");
|
|
792
1105
|
}
|
|
793
|
-
return resolved;
|
|
794
1106
|
}
|
|
795
1107
|
function selectMaze(options) {
|
|
796
1108
|
let lastError = null;
|
|
797
1109
|
for (let offset = 0; offset < options.maxAttempts; offset += 1) {
|
|
798
1110
|
const effectiveSeed = options.seed + offset;
|
|
799
1111
|
try {
|
|
800
|
-
const base = generateSolidCellMaze({
|
|
801
|
-
|
|
802
|
-
cols: options.cols,
|
|
803
|
-
braid: options.braid,
|
|
804
|
-
seed: effectiveSeed,
|
|
805
|
-
requestedSeed: options.seed,
|
|
806
|
-
id: `maze-test-${options.rows}x${options.cols}-${options.seed}`
|
|
807
|
-
});
|
|
808
|
-
const maze = decorateSolidCellMaze(base, {
|
|
809
|
-
seed: effectiveSeed + 8e4,
|
|
810
|
-
doorCount: options.doorCount,
|
|
811
|
-
chestCount: options.chestCount,
|
|
812
|
-
trapCount: options.trapCount,
|
|
813
|
-
medicineCount: options.medicineCount
|
|
814
|
-
});
|
|
1112
|
+
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}` });
|
|
1113
|
+
const maze = decorateSolidCellMaze(base, { seed: effectiveSeed + 8e4, doorCount: options.doorCount, chestCount: options.chestCount, trapCount: options.trapCount, potionCount: options.potionCount, mechanisms: options.mechanisms });
|
|
815
1114
|
const validation = validateSolidCellMaze(maze);
|
|
816
1115
|
if (!validation.valid) throw new Error(validation.errors.join(" "));
|
|
817
|
-
if ((validation.metrics?.entryGoalDistance ?? 0) < options.minDistance) {
|
|
818
|
-
throw new Error(`The entry-goal distance is below ${options.minDistance}.`);
|
|
819
|
-
}
|
|
1116
|
+
if ((validation.metrics?.entryGoalDistance ?? 0) < options.minDistance) throw new Error(`The entry-goal distance is below ${options.minDistance}.`);
|
|
820
1117
|
const plan = buildScenarioPlan(maze, options.scenario);
|
|
821
1118
|
const result = simulateTrial(maze, plan.actions, plan.initialState);
|
|
822
1119
|
verifyScenario(options.scenario, result.answers, maze);
|
|
@@ -826,172 +1123,82 @@ function selectMaze(options) {
|
|
|
826
1123
|
}
|
|
827
1124
|
}
|
|
828
1125
|
const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : "";
|
|
829
|
-
throw new Error(
|
|
830
|
-
`Could not generate a valid trial in ${options.maxAttempts} deterministic attempts.${detail}`
|
|
831
|
-
);
|
|
1126
|
+
throw new Error(`Could not generate a valid trial in ${options.maxAttempts} deterministic attempts.${detail}`);
|
|
832
1127
|
}
|
|
833
1128
|
function buildScenarioPlan(maze, scenario) {
|
|
1129
|
+
const ample = ampleInitialState(maze);
|
|
834
1130
|
if (scenario === "success") {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
};
|
|
1131
|
+
const damage = maze.objects.filter((item) => item.type === "trap").reduce((sum, item) => sum + item.damage, 0);
|
|
1132
|
+
const poisonBudget = maze.mechanisms.potionKinds.includes("poison") ? maze.mechanisms.poisonDamage * maze.mechanisms.poisonDuration : 0;
|
|
1133
|
+
const health = Math.max(5, damage + poisonBudget + 1);
|
|
1134
|
+
return { initialState: { health, healthMax: health, keys: 0, treasures: 0 }, actions: pathToActions(shortestPath(maze, maze.entry, maze.goal)) };
|
|
839
1135
|
}
|
|
840
1136
|
if (scenario === "treasure-and-leave") {
|
|
841
|
-
const
|
|
1137
|
+
const healing = maze.objects.find((item) => item.type === "medicine" || item.type === "potion" && item.kind === "healing");
|
|
842
1138
|
const chest = maze.objects.find((item) => item.type === "chest");
|
|
843
|
-
if (!
|
|
844
|
-
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
...pathToActions(path2),
|
|
851
|
-
...pathToActions(path3),
|
|
852
|
-
...pathToActions(leavePath)
|
|
853
|
-
];
|
|
854
|
-
const initialState = {
|
|
855
|
-
health: 3,
|
|
856
|
-
healthMax: 5,
|
|
857
|
-
keys: maze.doors.length + 2,
|
|
858
|
-
treasures: 0
|
|
859
|
-
};
|
|
860
|
-
const beforeBump = simulateTrial(maze, actions, initialState);
|
|
861
|
-
const bump = wallDirection(maze, beforeBump.state.position);
|
|
862
|
-
if (bump) actions = [...actions, bump, bump];
|
|
1139
|
+
if (!healing || !chest) throw new Error("This scenario requires a healing object and a chest.");
|
|
1140
|
+
let actions = routeThrough(maze, [maze.entry, healing.position, chest.position, maze.goal]);
|
|
1141
|
+
const leavePath = shortestPath(maze, maze.goal, chest.position).slice(0, 10);
|
|
1142
|
+
actions.push(...pathToActions(leavePath));
|
|
1143
|
+
const initialState = { ...ample, health: 50, healthMax: 100 };
|
|
1144
|
+
const bump = wallDirection(maze, simulateTrial(maze, actions, initialState).state.position);
|
|
1145
|
+
if (bump) actions.push(bump, bump);
|
|
863
1146
|
return { initialState, actions };
|
|
864
1147
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
},
|
|
876
|
-
actions: [...pathToActions(toTrap), ...pathToActions(afterTrap)]
|
|
877
|
-
};
|
|
878
|
-
}
|
|
879
|
-
function verifyScenario(scenario, answers, maze) {
|
|
880
|
-
if (scenario === "success" && (!answers.reachedGoal || !answers.alive || answers.finalPosition !== letterNumberCoordinate(maze.goal))) {
|
|
881
|
-
throw new Error("The success scenario did not finish alive at the goal.");
|
|
882
|
-
}
|
|
883
|
-
if (scenario === "treasure-and-leave" && (!answers.reachedGoal || answers.finalPosition === letterNumberCoordinate(maze.goal) || answers.openedChests < 1 || answers.usedMedicines < 1 || answers.blockedMoves < 2)) {
|
|
884
|
-
throw new Error("The treasure-and-leave scenario did not meet its invariants.");
|
|
1148
|
+
if (scenario === "death-and-stop") {
|
|
1149
|
+
const trap = maze.objects.find((item) => item.type === "trap");
|
|
1150
|
+
if (!trap) throw new Error("This scenario requires a trap.");
|
|
1151
|
+
const actions = [...pathToActions(shortestPath(maze, maze.entry, trap.position)), ...pathToActions(shortestPath(maze, trap.position, maze.goal).slice(0, 14))];
|
|
1152
|
+
for (let health = 1; health <= 200; health += 1) {
|
|
1153
|
+
const initialState = { ...ample, health, healthMax: health };
|
|
1154
|
+
const result = simulateTrial(maze, actions, initialState);
|
|
1155
|
+
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 };
|
|
1156
|
+
}
|
|
1157
|
+
throw new Error("Could not choose health that makes the death-and-stop scenario die on its target trap.");
|
|
885
1158
|
}
|
|
886
|
-
|
|
887
|
-
|
|
1159
|
+
return { initialState: { ...ample, health: 1e3, healthMax: 1e3 }, actions: routeThrough(maze, [maze.entry, ...maze.objects.map((item) => item.position), maze.goal]) };
|
|
1160
|
+
}
|
|
1161
|
+
function ampleInitialState(maze) {
|
|
1162
|
+
const material = emptyMaterialCounts();
|
|
1163
|
+
for (const door of maze.doors) if (door.material) material[door.material] += 1;
|
|
1164
|
+
for (const object of maze.objects) if (object.type === "chest" && object.lockMaterial) material[object.lockMaterial] += 1;
|
|
1165
|
+
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 };
|
|
1166
|
+
}
|
|
1167
|
+
function verifyScenario(scenario, a, maze) {
|
|
1168
|
+
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.");
|
|
1169
|
+
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.");
|
|
1170
|
+
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.");
|
|
1171
|
+
if (scenario === "mechanism-tour") {
|
|
1172
|
+
const count = (type) => maze.objects.filter((item) => item.type === type).length;
|
|
1173
|
+
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.");
|
|
888
1174
|
}
|
|
889
1175
|
}
|
|
890
1176
|
function renderQuestion(options, maze, sections) {
|
|
891
|
-
const isZh = options.language === "zh";
|
|
892
|
-
const title = isZh ? "\u8FF7\u5BAB\u8BD5\u9898" : "Maze Trial";
|
|
893
|
-
const labels = isZh ? ["\u5730\u56FE\u63CF\u8FF0", "\u673A\u5236\u63CF\u8FF0", "\u884C\u52A8\u63CF\u8FF0", "\u95EE\u9898"] : ["Map", "Rules", "Actions", "Questions"];
|
|
894
|
-
const seedLine = maze.seed === options.seed ? `${isZh ? "\u79CD\u5B50" : "Seed"}: ${options.seed}` : `${isZh ? "\u79CD\u5B50" : "Seed"}: ${options.seed} (${isZh ? "\u6709\u6548\u8FF7\u5BAB\u79CD\u5B50" : "effective maze seed"}: ${maze.seed})`;
|
|
895
|
-
return [
|
|
896
|
-
`# ${title}`,
|
|
897
|
-
"",
|
|
898
|
-
seedLine,
|
|
899
|
-
"",
|
|
900
|
-
`## 1. ${labels[0]}`,
|
|
901
|
-
"",
|
|
902
|
-
sections.map,
|
|
903
|
-
"",
|
|
904
|
-
`## 2. ${labels[1]}`,
|
|
905
|
-
"",
|
|
906
|
-
sections.rules,
|
|
907
|
-
"",
|
|
908
|
-
`## 3. ${labels[2]}`,
|
|
909
|
-
"",
|
|
910
|
-
sections.actions,
|
|
911
|
-
"",
|
|
912
|
-
`## 4. ${labels[3]}`,
|
|
913
|
-
"",
|
|
914
|
-
sections.questions,
|
|
915
|
-
""
|
|
916
|
-
].join("\n");
|
|
917
|
-
}
|
|
918
|
-
function renderAnswer(options, maze, answers, actionCount) {
|
|
919
1177
|
const zh = options.language === "zh";
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
] : [
|
|
933
|
-
|
|
934
|
-
["Keys remaining", answers.keys],
|
|
935
|
-
["Treasures collected", answers.treasures],
|
|
936
|
-
["Health remaining", answers.health],
|
|
937
|
-
["Alive", answers.alive ? "Yes" : "No"],
|
|
938
|
-
["Ever reached the goal", answers.reachedGoal ? "Yes" : "No"],
|
|
939
|
-
["Doors opened", answers.openedDoors],
|
|
940
|
-
["Chests opened", answers.openedChests],
|
|
941
|
-
["Traps triggered", answers.triggeredTraps],
|
|
942
|
-
["Medicine rooms used", answers.usedMedicines],
|
|
943
|
-
["Moves that did not change position", answers.blockedMoves]
|
|
944
|
-
];
|
|
945
|
-
return [
|
|
946
|
-
`# ${zh ? "\u6807\u51C6\u7B54\u6848" : "Answer Key"}`,
|
|
947
|
-
"",
|
|
948
|
-
`${zh ? "\u79CD\u5B50" : "Seed"}: ${options.seed}`,
|
|
949
|
-
...maze.seed === options.seed ? [] : [
|
|
950
|
-
`${zh ? "\u6709\u6548\u8FF7\u5BAB\u79CD\u5B50" : "Effective maze seed"}: ${maze.seed}`
|
|
951
|
-
],
|
|
952
|
-
"",
|
|
953
|
-
`| ${zh ? "\u95EE\u9898" : "Item"} | ${zh ? "\u7B54\u6848" : "Answer"} |`,
|
|
954
|
-
"|---|---|",
|
|
955
|
-
...rows.map(([label, value]) => `| ${label} | ${String(value)} |`),
|
|
956
|
-
"",
|
|
957
|
-
`${zh ? "\u539F\u5B50\u884C\u52A8\u6570" : "Atomic action count"}: ${actionCount}`,
|
|
958
|
-
""
|
|
959
|
-
].join("\n");
|
|
1178
|
+
const labels = zh ? ["\u5730\u56FE\u63CF\u8FF0", "\u673A\u5236\u63CF\u8FF0", "\u884C\u52A8\u63CF\u8FF0", "\u95EE\u9898"] : ["Map", "Rules", "Actions", "Questions"];
|
|
1179
|
+
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})`;
|
|
1180
|
+
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");
|
|
1181
|
+
}
|
|
1182
|
+
function renderAnswer(options, maze, a, actionCount) {
|
|
1183
|
+
const zh = options.language === "zh";
|
|
1184
|
+
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]];
|
|
1185
|
+
if (maze.objects.some((item) => item.type === "medicine")) rows.push([zh ? "\u4F7F\u7528\u7684\u836F\u54C1\u623F" : "Medicine rooms used", a.usedMedicines]);
|
|
1186
|
+
if (maze.objects.some((item) => item.type === "potion")) rows.push([zh ? "\u996E\u7528\u7684\u836F\u6C34" : "Potions drunk", a.usedPotions]);
|
|
1187
|
+
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]);
|
|
1188
|
+
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]]);
|
|
1189
|
+
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]]);
|
|
1190
|
+
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]);
|
|
1191
|
+
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");
|
|
960
1192
|
}
|
|
961
1193
|
function renderMapDescription(maze, initial, language, style) {
|
|
962
1194
|
const terrain = renderTerrainDescription(maze, language, style);
|
|
963
1195
|
const objects = renderObjectDescription(maze, language);
|
|
964
|
-
if (language === "zh") {
|
|
965
|
-
|
|
966
|
-
`\u8FD9\u662F\u4E00\u5EA7${maze.rows}\u884C${maze.cols}\u5217\u7684\u8FF7\u5BAB\u3002\u5DE6\u4E0A\u89D2\u4E3AA1\uFF0C\u5217\u4ECE\u5DE6\u5411\u53F3\u4F9D\u6B21\u6807\u4E3AA\u81F3${columnLabel(maze.cols)}\uFF0C\u884C\u4ECE\u4E0A\u5411\u4E0B\u7F16\u53F7\u4E3A1\u81F3${maze.rows}\u3002`,
|
|
967
|
-
`\u5165\u53E3\u4F4D\u4E8E${letterNumberCoordinate(maze.entry)}\uFF0C\u7EC8\u70B9\u4F4D\u4E8E${letterNumberCoordinate(maze.goal)}\u3002`,
|
|
968
|
-
"",
|
|
969
|
-
terrain,
|
|
970
|
-
"",
|
|
971
|
-
"\u8FF7\u5BAB\u4E2D\u7684\u95E8\u548C\u5176\u4ED6\u5BF9\u8C61\u5206\u5E03\u5982\u4E0B\u3002\u95E8\u672C\u8EAB\u5360\u636E\u4E00\u4E2A\u5B8C\u6574\u683C\u5B50\u3002",
|
|
972
|
-
objects,
|
|
973
|
-
"",
|
|
974
|
-
`\u8FF7\u5BAB\u4E2D\u6709\u4E00\u540D\u63A2\u9669\u8005\u3002\u4ED6\u4ECE\u5165\u53E3\u51FA\u53D1\uFF0C\u521D\u59CB\u5065\u5EB7\u503C\u4E3A${numberZh(initial.health)}\u70B9\uFF0C\u5065\u5EB7\u4E0A\u9650\u4E3A${numberZh(initial.healthMax)}\u70B9\uFF1B${initialPossessions(initial, language)}\uFF0C\u5C1A\u672A\u5230\u8FBE\u7EC8\u70B9\u3002`
|
|
975
|
-
].join("\n");
|
|
976
|
-
}
|
|
977
|
-
return [
|
|
978
|
-
`This maze has ${maze.rows} rows and ${maze.cols} columns. The top-left cell is A1. Columns run left to right from A to ${columnLabel(maze.cols)}, and rows run top to bottom from 1 to ${maze.rows}.`,
|
|
979
|
-
`The entry is at ${letterNumberCoordinate(maze.entry)}, and the goal is at ${letterNumberCoordinate(maze.goal)}.`,
|
|
980
|
-
"",
|
|
981
|
-
terrain,
|
|
982
|
-
"",
|
|
983
|
-
"Doors and other objects are distributed as follows. Each door occupies a whole cell.",
|
|
984
|
-
objects,
|
|
985
|
-
"",
|
|
986
|
-
`An explorer starts at the entry with ${initial.health} health point${plural(initial.health)}, a maximum health of ${initial.healthMax}, ${initialPossessions(initial, language)}, and reached-goal set to false.`
|
|
987
|
-
].join("\n");
|
|
1196
|
+
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");
|
|
1197
|
+
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");
|
|
988
1198
|
}
|
|
989
1199
|
function renderTerrainDescription(maze, language, style) {
|
|
990
1200
|
const clauses = Array.from({ length: maze.rows }, (_, index) => index + 1).map((row) => {
|
|
991
|
-
const kinds = Array.from(
|
|
992
|
-
{ length: maze.cols },
|
|
993
|
-
(_, index) => terrainAt(maze, cell(row, index + 1)) === "." ? "floor" : "wall"
|
|
994
|
-
);
|
|
1201
|
+
const kinds = Array.from({ length: maze.cols }, (_, index) => terrainAt(maze, cell(row, index + 1)) === "." ? "floor" : "wall");
|
|
995
1202
|
const label = language === "zh" ? [`\u7B2C${row}\u884C`, `${row}\u3001`, `\uFF08${row}\uFF09`][style % 3] : `Row ${row}: `;
|
|
996
1203
|
return `${label ?? `\u7B2C${row}\u884C`}${describeTerrainLine(kinds, language)}`;
|
|
997
1204
|
});
|
|
@@ -1000,9 +1207,7 @@ function renderTerrainDescription(maze, language, style) {
|
|
|
1000
1207
|
function describeTerrainLine(kinds, language) {
|
|
1001
1208
|
const walls = [];
|
|
1002
1209
|
const floors = [];
|
|
1003
|
-
for (let index = 0; index < kinds.length; index += 1)
|
|
1004
|
-
(kinds[index] === "wall" ? walls : floors).push(index + 1);
|
|
1005
|
-
}
|
|
1210
|
+
for (let index = 0; index < kinds.length; index += 1) (kinds[index] === "wall" ? walls : floors).push(index + 1);
|
|
1006
1211
|
if (language === "zh") {
|
|
1007
1212
|
if (walls.length === 0) return "\u90FD\u662F\u901A\u8DEF";
|
|
1008
1213
|
if (floors.length === 0) return "\u90FD\u662F\u5899\u58C1";
|
|
@@ -1033,76 +1238,110 @@ function positionList(indices, language) {
|
|
|
1033
1238
|
end = value;
|
|
1034
1239
|
}
|
|
1035
1240
|
ranges.push([start, end]);
|
|
1036
|
-
const parts = ranges.map(
|
|
1037
|
-
([from, to]) => language === "zh" ? from === to ? `\u7B2C${from}\u683C` : `\u7B2C${from}\u683C\u81F3\u7B2C${to}\u683C` : from === to ? `cell ${from}` : `cells ${from}-${to}`
|
|
1038
|
-
);
|
|
1241
|
+
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}`);
|
|
1039
1242
|
if (parts.length === 1) return parts[0] ?? "";
|
|
1040
|
-
|
|
1041
|
-
return language === "zh" ? `${parts.slice(0, -1).join("\u3001")}\u548C${last}` : `${parts.slice(0, -1).join(", ")} and ${last}`;
|
|
1243
|
+
return language === "zh" ? `${parts.slice(0, -1).join("\u3001")}\u548C${parts.at(-1)}` : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
|
|
1042
1244
|
}
|
|
1043
1245
|
function renderObjectDescription(maze, language) {
|
|
1044
|
-
const
|
|
1045
|
-
const
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
const traps = maze.objects.filter((item) => item.type === "trap");
|
|
1049
|
-
const medicines = maze.objects.filter((item) => item.type === "medicine");
|
|
1050
|
-
if (language === "zh") {
|
|
1051
|
-
if (doorPositions.length > 0) sentences.push(`${coordinateList(doorPositions, language)}${doorPositions.length > 1 ? "\u5404" : ""}\u6709\u4E00\u9053\u521D\u59CB\u5173\u95ED\u7684\u95E8\u3002`);
|
|
1052
|
-
const keyPositions = keys.map((item) => letterNumberCoordinate(item.position));
|
|
1053
|
-
if (keyPositions.length > 0) sentences.push(`${coordinateList(keyPositions, language)}${keyPositions.length > 1 ? "\u5404" : ""}\u653E\u7740\u4E00\u628A\u94A5\u5319\u3002`);
|
|
1054
|
-
for (const chest of chests) sentences.push(`${letterNumberCoordinate(chest.position)}\u653E\u7740\u4E00\u53EA\u521D\u59CB\u5173\u95ED\u7684\u5B9D\u7BB1\uFF0C\u7BB1\u5185\u6709${numberZh(chest.treasures)}\u4EF6\u5B9D\u7269\u3002`);
|
|
1055
|
-
const trapPositions = traps.map((item) => letterNumberCoordinate(item.position));
|
|
1056
|
-
if (trapPositions.length > 0) sentences.push(`${coordinateList(trapPositions, language)}${trapPositions.length > 1 ? "\u5404" : ""}\u8BBE\u6709\u4E00\u4E2A\u5C1A\u672A\u89E6\u53D1\u7684\u9677\u9631\u3002`);
|
|
1057
|
-
const medicinePositions = medicines.map((item) => letterNumberCoordinate(item.position));
|
|
1058
|
-
if (medicinePositions.length > 0) sentences.push(`${coordinateList(medicinePositions, language)}${medicinePositions.length > 1 ? "\u5404" : ""}\u6709\u4E00\u4E2A\u5C1A\u672A\u4F7F\u7528\u7684\u836F\u54C1\u623F\u3002`);
|
|
1059
|
-
} else {
|
|
1060
|
-
if (doorPositions.length > 0) sentences.push(`${coordinateList(doorPositions, language)} ${doorPositions.length === 1 ? "contains a closed door" : "each contain a closed door"}.`);
|
|
1061
|
-
const keyPositions = keys.map((item) => letterNumberCoordinate(item.position));
|
|
1062
|
-
if (keyPositions.length > 0) sentences.push(`${coordinateList(keyPositions, language)} ${keyPositions.length === 1 ? "contains a key" : "each contain a key"}.`);
|
|
1063
|
-
for (const chest of chests) sentences.push(`${letterNumberCoordinate(chest.position)} contains a closed chest with ${chest.treasures} treasure${plural(chest.treasures)}.`);
|
|
1064
|
-
const trapPositions = traps.map((item) => letterNumberCoordinate(item.position));
|
|
1065
|
-
if (trapPositions.length > 0) sentences.push(`${coordinateList(trapPositions, language)} ${trapPositions.length === 1 ? "contains an untriggered trap" : "each contain an untriggered trap"}.`);
|
|
1066
|
-
const medicinePositions = medicines.map((item) => letterNumberCoordinate(item.position));
|
|
1067
|
-
if (medicinePositions.length > 0) sentences.push(`${coordinateList(medicinePositions, language)} ${medicinePositions.length === 1 ? "contains an unused medicine room" : "each contain an unused medicine room"}.`);
|
|
1246
|
+
const lines = [];
|
|
1247
|
+
for (const door of maze.doors) {
|
|
1248
|
+
const at = letterNumberCoordinate(door.position);
|
|
1249
|
+
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.`);
|
|
1068
1250
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1251
|
+
for (const object of maze.objects) {
|
|
1252
|
+
const at = letterNumberCoordinate(object.position);
|
|
1253
|
+
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.`);
|
|
1254
|
+
else if (object.type === "chest") {
|
|
1255
|
+
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 ");
|
|
1256
|
+
const lock = object.lockMaterial ? `a ${MATERIAL_NAMES[object.lockMaterial].en} lock` : "an ordinary lock";
|
|
1257
|
+
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}.`);
|
|
1258
|
+
} 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.`);
|
|
1259
|
+
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.`);
|
|
1260
|
+
else {
|
|
1261
|
+
const details = potionDetails(object.kind, object.potency, object.duration, language);
|
|
1262
|
+
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}.`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
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.";
|
|
1073
1266
|
}
|
|
1074
|
-
function
|
|
1075
|
-
|
|
1267
|
+
function potionDetails(kind, potency, duration, language) {
|
|
1268
|
+
if (language === "zh") {
|
|
1269
|
+
if (kind === "healing") return `\uFF0C\u6062\u590D${potency ?? 1}\u70B9\u5065\u5EB7\u503C\uFF0C\u4F46\u4E0D\u8D85\u8FC7\u5065\u5EB7\u4E0A\u9650`;
|
|
1270
|
+
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`;
|
|
1271
|
+
if (kind === "haste" || kind === "slow") return `\uFF0C\u6548\u679C\u6301\u7EED\u4E4B\u540E${duration ?? 1}\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4`;
|
|
1272
|
+
return "\uFF0C\u53EF\u7ACB\u5373\u6E05\u9664\u4E2D\u6BD2\u72B6\u6001";
|
|
1273
|
+
}
|
|
1274
|
+
if (kind === "healing") return ` that restores ${potency ?? 1} health without exceeding maximum health`;
|
|
1275
|
+
if (kind === "poison") return ` that causes ${potency ?? 1} damage after each of the next ${duration ?? 1} atomic movement instructions`;
|
|
1276
|
+
if (kind === "haste" || kind === "slow") return ` whose effect lasts for the next ${duration ?? 1} atomic movement instructions`;
|
|
1277
|
+
return " that immediately clears poison";
|
|
1278
|
+
}
|
|
1279
|
+
function renderRules(maze, language) {
|
|
1280
|
+
const m = maze.mechanisms;
|
|
1281
|
+
const usesPotionObjects = maze.objects.some((item) => item.type === "potion");
|
|
1282
|
+
const usesPoison = usesPotionObjects && m.potionKinds.some((kind) => kind === "poison" || kind === "antidote");
|
|
1283
|
+
const usesMaterials = m.keyMaterials.length > 0;
|
|
1284
|
+
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";
|
|
1285
|
+
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.";
|
|
1286
|
+
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";
|
|
1287
|
+
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.";
|
|
1288
|
+
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";
|
|
1289
|
+
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.";
|
|
1290
|
+
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";
|
|
1291
|
+
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.";
|
|
1292
|
+
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`;
|
|
1293
|
+
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.`;
|
|
1294
|
+
const rules = language === "zh" ? [
|
|
1295
|
+
"\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",
|
|
1296
|
+
"\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",
|
|
1297
|
+
doorRuleZh,
|
|
1298
|
+
"\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",
|
|
1299
|
+
chestRuleZh,
|
|
1300
|
+
"\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",
|
|
1301
|
+
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",
|
|
1302
|
+
orderRuleZh,
|
|
1303
|
+
...usesPoison ? [poisonRuleZh] : [],
|
|
1304
|
+
...m.complexity === "advanced" ? [speedRuleZh] : [],
|
|
1305
|
+
"\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",
|
|
1306
|
+
"\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"
|
|
1307
|
+
] : [
|
|
1308
|
+
"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.",
|
|
1309
|
+
"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.",
|
|
1310
|
+
doorRuleEn,
|
|
1311
|
+
"The first time the explorer enters a key cell, that key is collected. A key cannot be collected twice.",
|
|
1312
|
+
chestRuleEn,
|
|
1313
|
+
"The first time the explorer enters a trap cell, the trap reduces health by its stated amount. That trap has no effect afterward.",
|
|
1314
|
+
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.",
|
|
1315
|
+
orderRuleEn,
|
|
1316
|
+
...usesPoison ? [poisonRuleEn] : [],
|
|
1317
|
+
...m.complexity === "advanced" ? [speedRuleEn] : [],
|
|
1318
|
+
"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.",
|
|
1319
|
+
"Once the explorer enters the goal, ever-reached-goal remains true even if the explorer later leaves or dies."
|
|
1320
|
+
];
|
|
1321
|
+
return rules.map((rule, index) => `${index + 1}. ${rule}`).join("\n");
|
|
1322
|
+
}
|
|
1323
|
+
function renderQuestions(maze, language) {
|
|
1324
|
+
const materialKeys = maze.mechanisms.keyMaterials.length > 0;
|
|
1325
|
+
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?"];
|
|
1326
|
+
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?");
|
|
1327
|
+
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?");
|
|
1328
|
+
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?");
|
|
1329
|
+
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?`);
|
|
1330
|
+
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?`);
|
|
1331
|
+
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?");
|
|
1332
|
+
return questions.map((item, index) => `${index + 1}. ${item}`).join("\n");
|
|
1076
1333
|
}
|
|
1077
1334
|
function renderActionDescription(actions, language, style) {
|
|
1078
1335
|
const runs = compressActions(actions);
|
|
1079
1336
|
if (language === "zh") {
|
|
1080
1337
|
const connectors = ["\u968F\u540E", "\u63A5\u7740", "\u7136\u540E", "\u518D", "\u4E4B\u540E"];
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
});
|
|
1085
|
-
return paragraphize(clauses2, "\uFF0C", "\u3002");
|
|
1086
|
-
}
|
|
1087
|
-
const clauses = runs.map((run, index) => {
|
|
1088
|
-
const connector = index === 0 ? "First, the explorer moves" : index % 4 === 0 ? "The explorer then moves" : "then moves";
|
|
1089
|
-
const directionForms = {
|
|
1090
|
-
up: ["up", "upward"],
|
|
1091
|
-
down: ["down", "downward"],
|
|
1092
|
-
left: ["left", "to the left"],
|
|
1093
|
-
right: ["right", "to the right"]
|
|
1094
|
-
};
|
|
1095
|
-
const forms = directionForms[run.direction];
|
|
1096
|
-
const direction = forms[(style + index) % forms.length] ?? DIRECTIONS[run.direction].en;
|
|
1097
|
-
return `${connector} ${direction} ${run.count} cell${plural(run.count)}`;
|
|
1098
|
-
});
|
|
1099
|
-
return paragraphize(clauses, ", ", ".");
|
|
1338
|
+
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");
|
|
1339
|
+
}
|
|
1340
|
+
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)}`), ", ", ".");
|
|
1100
1341
|
}
|
|
1101
1342
|
function paragraphize(clauses, separator, terminator) {
|
|
1102
1343
|
const sentences = [];
|
|
1103
|
-
for (let index = 0; index < clauses.length; index += 4) {
|
|
1104
|
-
sentences.push(`${clauses.slice(index, index + 4).join(separator)}${terminator}`);
|
|
1105
|
-
}
|
|
1344
|
+
for (let index = 0; index < clauses.length; index += 4) sentences.push(`${clauses.slice(index, index + 4).join(separator)}${terminator}`);
|
|
1106
1345
|
return sentences.join("\n");
|
|
1107
1346
|
}
|
|
1108
1347
|
function compressActions(actions) {
|
|
@@ -1114,6 +1353,18 @@ function compressActions(actions) {
|
|
|
1114
1353
|
}
|
|
1115
1354
|
return runs;
|
|
1116
1355
|
}
|
|
1356
|
+
function routeThrough(maze, points) {
|
|
1357
|
+
const actions = [];
|
|
1358
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
1359
|
+
const from = points[index - 1];
|
|
1360
|
+
const to = points[index];
|
|
1361
|
+
if (!from || !to) continue;
|
|
1362
|
+
const path = shortestPath(maze, from, to);
|
|
1363
|
+
if (path.length === 0) throw new Error("Could not connect scenario waypoints.");
|
|
1364
|
+
actions.push(...pathToActions(path));
|
|
1365
|
+
}
|
|
1366
|
+
return actions;
|
|
1367
|
+
}
|
|
1117
1368
|
function pathToActions(path) {
|
|
1118
1369
|
const actions = [];
|
|
1119
1370
|
for (let index = 1; index < path.length; index += 1) {
|
|
@@ -1127,40 +1378,28 @@ function pathToActions(path) {
|
|
|
1127
1378
|
return actions;
|
|
1128
1379
|
}
|
|
1129
1380
|
function wallDirection(maze, position) {
|
|
1130
|
-
const candidates = [
|
|
1131
|
-
["up", cell(position.row - 1, position.col)],
|
|
1132
|
-
["down", cell(position.row + 1, position.col)],
|
|
1133
|
-
["left", cell(position.row, position.col - 1)],
|
|
1134
|
-
["right", cell(position.row, position.col + 1)]
|
|
1135
|
-
];
|
|
1381
|
+
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)]];
|
|
1136
1382
|
return candidates.find(([, target]) => terrainAt(maze, target) !== ".")?.[0] ?? null;
|
|
1137
1383
|
}
|
|
1138
|
-
function coordinateList(values, language) {
|
|
1139
|
-
if (values.length <= 1) return values[0] ?? "";
|
|
1140
|
-
const last = values.at(-1);
|
|
1141
|
-
return language === "zh" ? `${values.slice(0, -1).join("\u3001")}\u548C${last}` : `${values.slice(0, -1).join(", ")} and ${last}`;
|
|
1142
|
-
}
|
|
1143
1384
|
function initialPossessions(state, language) {
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
const treasures = state.treasures > 0 ? `${state.treasures} treasure${plural(state.treasures)}` : "no treasures";
|
|
1151
|
-
return `${keys} and ${treasures}`;
|
|
1385
|
+
const materials = emptyMaterialCounts(state.keysByMaterial);
|
|
1386
|
+
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])}`);
|
|
1387
|
+
if (state.keys > 0) parts.unshift(language === "zh" ? `${state.keys}\u628A\u666E\u901A\u94A5\u5319` : `${state.keys} ordinary key${plural(state.keys)}`);
|
|
1388
|
+
const keys = parts.length > 0 ? parts.join(language === "zh" ? "\u3001" : ", ") : language === "zh" ? "\u6CA1\u6709\u94A5\u5319" : "no keys";
|
|
1389
|
+
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";
|
|
1390
|
+
return language === "zh" ? `\u6301\u6709${keys}\uFF0C${treasures}` : `${keys} and ${treasures}`;
|
|
1152
1391
|
}
|
|
1153
|
-
function
|
|
1154
|
-
|
|
1155
|
-
return forms[value] ?? String(value);
|
|
1392
|
+
function treasureUnit(type) {
|
|
1393
|
+
return type === "coin" ? "\u679A" : type === "gem" ? "\u9897" : "\u4EF6";
|
|
1156
1394
|
}
|
|
1157
1395
|
function plural(value) {
|
|
1158
1396
|
return value === 1 ? "" : "s";
|
|
1159
1397
|
}
|
|
1398
|
+
function capitalize(value) {
|
|
1399
|
+
return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
|
|
1400
|
+
}
|
|
1160
1401
|
function integer(value, name, minimum) {
|
|
1161
|
-
if (!Number.isInteger(value) || value < minimum) {
|
|
1162
|
-
throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
|
|
1163
|
-
}
|
|
1402
|
+
if (!Number.isInteger(value) || value < minimum) throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
|
|
1164
1403
|
return value;
|
|
1165
1404
|
}
|
|
1166
1405
|
function oddInteger(value, name, minimum) {
|
|
@@ -1169,14 +1408,12 @@ function oddInteger(value, name, minimum) {
|
|
|
1169
1408
|
return value;
|
|
1170
1409
|
}
|
|
1171
1410
|
function finiteNumber(value, name, minimum, maximum) {
|
|
1172
|
-
if (!Number.isFinite(value) || value < minimum || value > maximum) {
|
|
1173
|
-
throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
|
|
1174
|
-
}
|
|
1411
|
+
if (!Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
|
|
1175
1412
|
return value;
|
|
1176
1413
|
}
|
|
1177
1414
|
|
|
1178
1415
|
// src/cli.ts
|
|
1179
|
-
var VERSION = "0.
|
|
1416
|
+
var VERSION = "0.3.0";
|
|
1180
1417
|
try {
|
|
1181
1418
|
const parsed = parseArguments(import_node_process.default.argv.slice(2));
|
|
1182
1419
|
if (parsed === "help") {
|
|
@@ -1290,6 +1527,33 @@ function parseArguments(args) {
|
|
|
1290
1527
|
case "--medicines":
|
|
1291
1528
|
trial.medicineCount = parseInteger(value, flag);
|
|
1292
1529
|
break;
|
|
1530
|
+
case "--potions":
|
|
1531
|
+
trial.potionCount = parseInteger(value, flag);
|
|
1532
|
+
break;
|
|
1533
|
+
case "--complexity":
|
|
1534
|
+
trial.complexity = value;
|
|
1535
|
+
break;
|
|
1536
|
+
case "--trap-damage":
|
|
1537
|
+
trial.trapDamages = parseIntegerList(value, flag);
|
|
1538
|
+
break;
|
|
1539
|
+
case "--potion-kinds":
|
|
1540
|
+
trial.potionKinds = parseList(value, flag);
|
|
1541
|
+
break;
|
|
1542
|
+
case "--key-materials":
|
|
1543
|
+
trial.keyMaterials = parseList(value, flag);
|
|
1544
|
+
break;
|
|
1545
|
+
case "--treasure-types":
|
|
1546
|
+
trial.treasureTypes = parseList(value, flag);
|
|
1547
|
+
break;
|
|
1548
|
+
case "--poison-damage":
|
|
1549
|
+
trial.poisonDamage = parseInteger(value, flag);
|
|
1550
|
+
break;
|
|
1551
|
+
case "--poison-duration":
|
|
1552
|
+
trial.poisonDuration = parseInteger(value, flag);
|
|
1553
|
+
break;
|
|
1554
|
+
case "--speed-duration":
|
|
1555
|
+
trial.speedDuration = parseInteger(value, flag);
|
|
1556
|
+
break;
|
|
1293
1557
|
case "--scenario":
|
|
1294
1558
|
trial.scenario = value;
|
|
1295
1559
|
break;
|
|
@@ -1325,6 +1589,14 @@ function parseNumber(value, flag) {
|
|
|
1325
1589
|
if (!Number.isFinite(parsed)) throw new Error(`${flag} requires a number.`);
|
|
1326
1590
|
return parsed;
|
|
1327
1591
|
}
|
|
1592
|
+
function parseList(value, flag) {
|
|
1593
|
+
const values = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1594
|
+
if (values.length === 0) throw new Error(`${flag} requires a non-empty comma-separated list.`);
|
|
1595
|
+
return values;
|
|
1596
|
+
}
|
|
1597
|
+
function parseIntegerList(value, flag) {
|
|
1598
|
+
return parseList(value, flag).map((item) => parseInteger(item, flag));
|
|
1599
|
+
}
|
|
1328
1600
|
function helpText() {
|
|
1329
1601
|
return `maze-test ${VERSION}
|
|
1330
1602
|
|
|
@@ -1343,11 +1615,20 @@ Options:
|
|
|
1343
1615
|
--rows <odd integer> Number of rows (default: 15)
|
|
1344
1616
|
--cols <odd integer> Number of columns (default: 15)
|
|
1345
1617
|
--braid <0..1> Chance to remove a dead end (default: 0)
|
|
1346
|
-
--
|
|
1347
|
-
--
|
|
1348
|
-
--
|
|
1349
|
-
--
|
|
1350
|
-
--
|
|
1618
|
+
--complexity <level> basic | intermediate | advanced (default: basic)
|
|
1619
|
+
--doors <integer> Number of doors and keys (preset-dependent)
|
|
1620
|
+
--chests <integer> Number of chests (preset-dependent)
|
|
1621
|
+
--traps <integer> Number of traps (preset-dependent)
|
|
1622
|
+
--potions <integer> Number of potions (preset-dependent)
|
|
1623
|
+
--medicines <integer> Legacy alias for --potions
|
|
1624
|
+
--trap-damage <list> Comma-separated positive damage values
|
|
1625
|
+
--potion-kinds <list> healing,poison,antidote,haste,slow
|
|
1626
|
+
--key-materials <list> copper,silver,gold
|
|
1627
|
+
--treasure-types <list> treasure,coin,gem,relic
|
|
1628
|
+
--poison-damage <int> Damage per poison tick
|
|
1629
|
+
--poison-duration <int> Poisoned instruction count
|
|
1630
|
+
--speed-duration <int> Fast/slow instruction count
|
|
1631
|
+
--scenario <name> success | treasure-and-leave | death-and-stop | mechanism-tour
|
|
1351
1632
|
(default: success)
|
|
1352
1633
|
--lang <language> en | zh (default: en)
|
|
1353
1634
|
--style <integer> Deterministic wording variation (default: 0)
|
|
@@ -1362,6 +1643,7 @@ Examples:
|
|
|
1362
1643
|
npx maze-test question --seed 42 --rows 15 --cols 15
|
|
1363
1644
|
npx maze-test answer --seed 42 --rows 15 --cols 15
|
|
1364
1645
|
npx maze-test question --seed 42 --lang zh
|
|
1646
|
+
npx maze-test question --seed 42 --complexity advanced --scenario mechanism-tour
|
|
1365
1647
|
npx maze-test answer --seed 42 --format json
|
|
1366
1648
|
`;
|
|
1367
1649
|
}
|