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