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.cjs
CHANGED
|
@@ -25,15 +25,20 @@ __export(index_exports, {
|
|
|
25
25
|
cellKey: () => cellKey,
|
|
26
26
|
columnLabel: () => columnLabel,
|
|
27
27
|
decorateSolidCellMaze: () => decorateSolidCellMaze,
|
|
28
|
+
defaultObjectCounts: () => defaultObjectCounts,
|
|
29
|
+
emptyMaterialCounts: () => emptyMaterialCounts,
|
|
30
|
+
emptyTreasureCounts: () => emptyTreasureCounts,
|
|
28
31
|
generateAnswer: () => generateAnswer,
|
|
29
32
|
generateQuestion: () => generateQuestion,
|
|
30
33
|
generateSolidCellMaze: () => generateSolidCellMaze,
|
|
31
34
|
generateTrial: () => generateTrial,
|
|
32
35
|
isFloor: () => isFloor,
|
|
33
36
|
letterNumberCoordinate: () => letterNumberCoordinate,
|
|
37
|
+
mechanismPreset: () => mechanismPreset,
|
|
34
38
|
neighbors: () => neighbors,
|
|
35
39
|
packageName: () => packageName,
|
|
36
40
|
renderCharacterMaze: () => renderCharacterMaze,
|
|
41
|
+
resolveMechanisms: () => resolveMechanisms,
|
|
37
42
|
resolveTrialOptions: () => resolveTrialOptions,
|
|
38
43
|
rowColumnCoordinate: () => rowColumnCoordinate,
|
|
39
44
|
sameCell: () => sameCell,
|
|
@@ -41,6 +46,7 @@ __export(index_exports, {
|
|
|
41
46
|
simulateTrial: () => simulateTrial,
|
|
42
47
|
stateKey: () => stateKey,
|
|
43
48
|
terrainAt: () => terrainAt,
|
|
49
|
+
totalMaterialKeys: () => totalMaterialKeys,
|
|
44
50
|
validateSolidCellMaze: () => validateSolidCellMaze
|
|
45
51
|
});
|
|
46
52
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -81,6 +87,122 @@ function neighbors(value, rows, cols) {
|
|
|
81
87
|
].filter((item) => item.row >= 1 && item.row <= rows && item.col >= 1 && item.col <= cols);
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
// src/mechanisms.ts
|
|
91
|
+
var MATERIALS = ["copper", "silver", "gold"];
|
|
92
|
+
var TREASURE_TYPES = ["treasure", "coin", "gem", "relic"];
|
|
93
|
+
var POTION_KINDS = ["healing", "poison", "antidote", "haste", "slow"];
|
|
94
|
+
function mechanismPreset(complexity) {
|
|
95
|
+
if (complexity === "basic") {
|
|
96
|
+
return {
|
|
97
|
+
complexity,
|
|
98
|
+
trapDamages: [1],
|
|
99
|
+
potionKinds: ["healing"],
|
|
100
|
+
keyMaterials: [],
|
|
101
|
+
treasureTypes: ["treasure"],
|
|
102
|
+
poisonDamage: 1,
|
|
103
|
+
poisonDuration: 3,
|
|
104
|
+
speedDuration: 4,
|
|
105
|
+
movementTime: { normal: 1, fast: 1, slow: 1 }
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (complexity === "intermediate") {
|
|
109
|
+
return {
|
|
110
|
+
complexity,
|
|
111
|
+
trapDamages: [1, 2, 3],
|
|
112
|
+
potionKinds: ["healing", "poison", "antidote"],
|
|
113
|
+
keyMaterials: [],
|
|
114
|
+
treasureTypes: ["coin", "gem", "relic"],
|
|
115
|
+
poisonDamage: 1,
|
|
116
|
+
poisonDuration: 3,
|
|
117
|
+
speedDuration: 4,
|
|
118
|
+
movementTime: { normal: 1, fast: 1, slow: 1 }
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
complexity,
|
|
123
|
+
trapDamages: [1, 2, 3],
|
|
124
|
+
potionKinds: ["healing", "poison", "antidote", "haste", "slow"],
|
|
125
|
+
keyMaterials: ["copper", "silver", "gold"],
|
|
126
|
+
treasureTypes: ["coin", "gem", "relic"],
|
|
127
|
+
poisonDamage: 1,
|
|
128
|
+
poisonDuration: 3,
|
|
129
|
+
speedDuration: 4,
|
|
130
|
+
movementTime: { normal: 2, fast: 1, slow: 3 }
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function resolveMechanisms(options) {
|
|
134
|
+
const complexity = options.complexity ?? "basic";
|
|
135
|
+
if (!isComplexity(complexity)) throw new Error(`Unknown complexity: ${String(complexity)}`);
|
|
136
|
+
const preset = mechanismPreset(complexity);
|
|
137
|
+
const trapDamages = options.trapDamages ?? preset.trapDamages;
|
|
138
|
+
const potionKinds = options.potionKinds ?? preset.potionKinds;
|
|
139
|
+
const keyMaterials = options.keyMaterials ?? preset.keyMaterials;
|
|
140
|
+
const treasureTypes = options.treasureTypes ?? preset.treasureTypes;
|
|
141
|
+
validatePositiveList(trapDamages, "trapDamages");
|
|
142
|
+
validateEnumList(potionKinds, POTION_KINDS, "potionKinds");
|
|
143
|
+
validateEnumList(keyMaterials, MATERIALS, "keyMaterials", true);
|
|
144
|
+
validateEnumList(treasureTypes, TREASURE_TYPES, "treasureTypes");
|
|
145
|
+
if (complexity !== "advanced" && potionKinds.some((kind) => kind === "haste" || kind === "slow")) {
|
|
146
|
+
throw new Error("Haste and slow potions require advanced complexity so their movement-time costs are defined.");
|
|
147
|
+
}
|
|
148
|
+
if (complexity === "advanced" && keyMaterials.length === 0) {
|
|
149
|
+
throw new Error("Advanced complexity requires at least one key material.");
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
...preset,
|
|
153
|
+
trapDamages: [...trapDamages],
|
|
154
|
+
potionKinds: [...potionKinds],
|
|
155
|
+
keyMaterials: [...keyMaterials],
|
|
156
|
+
treasureTypes: [...treasureTypes],
|
|
157
|
+
poisonDamage: positiveInteger(options.poisonDamage ?? preset.poisonDamage, "poisonDamage"),
|
|
158
|
+
poisonDuration: positiveInteger(
|
|
159
|
+
options.poisonDuration ?? preset.poisonDuration,
|
|
160
|
+
"poisonDuration"
|
|
161
|
+
),
|
|
162
|
+
speedDuration: positiveInteger(options.speedDuration ?? preset.speedDuration, "speedDuration")
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function defaultObjectCounts(complexity) {
|
|
166
|
+
if (complexity === "basic") return { doors: 1, chests: 2, traps: 2, potions: 2 };
|
|
167
|
+
if (complexity === "intermediate") return { doors: 2, chests: 3, traps: 3, potions: 3 };
|
|
168
|
+
return { doors: 3, chests: 3, traps: 3, potions: 5 };
|
|
169
|
+
}
|
|
170
|
+
function emptyMaterialCounts(values = {}) {
|
|
171
|
+
return {
|
|
172
|
+
copper: values.copper ?? 0,
|
|
173
|
+
silver: values.silver ?? 0,
|
|
174
|
+
gold: values.gold ?? 0
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function emptyTreasureCounts(values = {}) {
|
|
178
|
+
return {
|
|
179
|
+
treasure: values.treasure ?? 0,
|
|
180
|
+
coin: values.coin ?? 0,
|
|
181
|
+
gem: values.gem ?? 0,
|
|
182
|
+
relic: values.relic ?? 0
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function totalMaterialKeys(counts) {
|
|
186
|
+
return counts.copper + counts.silver + counts.gold;
|
|
187
|
+
}
|
|
188
|
+
function isComplexity(value) {
|
|
189
|
+
return value === "basic" || value === "intermediate" || value === "advanced";
|
|
190
|
+
}
|
|
191
|
+
function positiveInteger(value, name) {
|
|
192
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`);
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
function validatePositiveList(values, name) {
|
|
196
|
+
if (values.length === 0) throw new Error(`${name} must not be empty.`);
|
|
197
|
+
for (const value of values) positiveInteger(value, name);
|
|
198
|
+
}
|
|
199
|
+
function validateEnumList(values, allowed, name, allowEmpty = false) {
|
|
200
|
+
if (!allowEmpty && values.length === 0) throw new Error(`${name} must not be empty.`);
|
|
201
|
+
for (const value of values) {
|
|
202
|
+
if (!allowed.includes(value)) throw new Error(`Unknown ${name} value: ${value}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
84
206
|
// src/maze.ts
|
|
85
207
|
function generateSolidCellMaze(options = {}) {
|
|
86
208
|
const rows = oddDimension(options.rows ?? 15, "rows");
|
|
@@ -88,6 +210,7 @@ function generateSolidCellMaze(options = {}) {
|
|
|
88
210
|
const seed = integerOption(options.seed ?? 1, "seed", 0);
|
|
89
211
|
const requestedSeed = integerOption(options.requestedSeed ?? seed, "requestedSeed", 0);
|
|
90
212
|
const braid = numberOption(options.braid ?? 0, "braid", 0, 1);
|
|
213
|
+
const mechanisms = options.mechanisms ?? mechanismPreset("basic");
|
|
91
214
|
const rng = mulberry32(seed);
|
|
92
215
|
const grid = Array.from({ length: rows }, () => Array(cols).fill("#"));
|
|
93
216
|
const logicalCells = [];
|
|
@@ -121,7 +244,7 @@ function generateSolidCellMaze(options = {}) {
|
|
|
121
244
|
const first = farthestFloor(cell(2, 2), terrain).cell;
|
|
122
245
|
const second = farthestFloor(first, terrain).cell;
|
|
123
246
|
const maze = {
|
|
124
|
-
schemaVersion: "solid-cell-maze-
|
|
247
|
+
schemaVersion: "solid-cell-maze-v4@1",
|
|
125
248
|
id: options.id ?? `solid-maze-${rows}x${cols}-${requestedSeed}`,
|
|
126
249
|
seed,
|
|
127
250
|
requestedSeed,
|
|
@@ -133,6 +256,7 @@ function generateSolidCellMaze(options = {}) {
|
|
|
133
256
|
columns: "letters-left-to-right",
|
|
134
257
|
rows: "numbers-top-to-bottom"
|
|
135
258
|
},
|
|
259
|
+
mechanisms: structuredClone(mechanisms),
|
|
136
260
|
terrain,
|
|
137
261
|
entry: first,
|
|
138
262
|
goal: second,
|
|
@@ -145,6 +269,8 @@ function generateSolidCellMaze(options = {}) {
|
|
|
145
269
|
}
|
|
146
270
|
function decorateSolidCellMaze(maze, options = {}) {
|
|
147
271
|
const result = structuredClone(maze);
|
|
272
|
+
const mechanisms = options.mechanisms ?? maze.mechanisms ?? mechanismPreset("basic");
|
|
273
|
+
result.mechanisms = structuredClone(mechanisms);
|
|
148
274
|
const decorationSeed = integerOption(options.seed ?? maze.seed + 71311, "decoration seed", 0);
|
|
149
275
|
const rng = mulberry32(decorationSeed);
|
|
150
276
|
const mainPath = shortestPath(result, result.entry, result.goal);
|
|
@@ -156,13 +282,23 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
156
282
|
);
|
|
157
283
|
const chestCount = integerOption(options.chestCount ?? 2, "chestCount", 0);
|
|
158
284
|
const trapCount = integerOption(options.trapCount ?? 2, "trapCount", 0);
|
|
159
|
-
const
|
|
285
|
+
const potionCount = integerOption(
|
|
286
|
+
options.potionCount ?? options.medicineCount ?? 2,
|
|
287
|
+
"potionCount",
|
|
288
|
+
0
|
|
289
|
+
);
|
|
160
290
|
const doorIndices = selectGatingDoorIndices(result, mainPath, doorCount);
|
|
161
291
|
result.doors = doorIndices.map((index, doorIndex) => {
|
|
162
292
|
const position = mainPath[index];
|
|
163
293
|
if (!position) throw new Error(`No path cell exists at door index ${index}.`);
|
|
164
294
|
occupied.add(cellKey(position));
|
|
165
|
-
|
|
295
|
+
const material = cyclicValue(mechanisms.keyMaterials, doorIndex + decorationSeed);
|
|
296
|
+
return {
|
|
297
|
+
id: `door-${doorIndex + 1}`,
|
|
298
|
+
position,
|
|
299
|
+
state: "closed",
|
|
300
|
+
...material ? { material } : {}
|
|
301
|
+
};
|
|
166
302
|
});
|
|
167
303
|
const objects = [];
|
|
168
304
|
for (let index = 0; index < result.doors.length; index += 1) {
|
|
@@ -171,7 +307,13 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
171
307
|
const preferredIndex = Math.max(1, Math.floor(doorIndex * 0.55));
|
|
172
308
|
const position = freePathCellBefore(mainPath, preferredIndex, occupied);
|
|
173
309
|
occupied.add(cellKey(position));
|
|
174
|
-
const
|
|
310
|
+
const door = result.doors[index];
|
|
311
|
+
const key = {
|
|
312
|
+
id: `key-${index + 1}`,
|
|
313
|
+
type: "key",
|
|
314
|
+
position,
|
|
315
|
+
...door?.material ? { material: door.material } : {}
|
|
316
|
+
};
|
|
175
317
|
objects.push(key);
|
|
176
318
|
}
|
|
177
319
|
const degrees = degreeMap(result);
|
|
@@ -184,11 +326,17 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
184
326
|
for (let index = 0; index < chestCount; index += 1) {
|
|
185
327
|
const position = takeFree(deadEnds, result, occupied, rng, "chest");
|
|
186
328
|
occupied.add(cellKey(position));
|
|
329
|
+
const treasureType = cyclicValue(mechanisms.treasureTypes, index + decorationSeed);
|
|
330
|
+
if (!treasureType) throw new Error("At least one treasure type is required.");
|
|
331
|
+
const count = 1 + index % 3;
|
|
332
|
+
const lockMaterial = cyclicValue(mechanisms.keyMaterials, index + decorationSeed + 1);
|
|
187
333
|
const chest = {
|
|
188
334
|
id: `chest-${index + 1}`,
|
|
189
335
|
type: "chest",
|
|
190
336
|
position,
|
|
191
|
-
treasures:
|
|
337
|
+
treasures: count,
|
|
338
|
+
contents: [{ type: treasureType, count }],
|
|
339
|
+
...lockMaterial ? { lockMaterial } : {}
|
|
192
340
|
};
|
|
193
341
|
objects.push(chest);
|
|
194
342
|
}
|
|
@@ -200,21 +348,36 @@ function decorateSolidCellMaze(maze, options = {}) {
|
|
|
200
348
|
id: `trap-${index + 1}`,
|
|
201
349
|
type: "trap",
|
|
202
350
|
position,
|
|
203
|
-
damage: 1
|
|
351
|
+
damage: mechanisms.trapDamages[index % mechanisms.trapDamages.length] ?? 1
|
|
204
352
|
};
|
|
205
353
|
objects.push(trap);
|
|
206
354
|
}
|
|
207
|
-
const
|
|
208
|
-
for (let index = 0; index <
|
|
209
|
-
const position = takeFree(
|
|
355
|
+
const potionCandidates = shuffle(rng, floorCells(result));
|
|
356
|
+
for (let index = 0; index < potionCount; index += 1) {
|
|
357
|
+
const position = takeFree(potionCandidates, result, occupied, rng, "potion");
|
|
210
358
|
occupied.add(cellKey(position));
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
359
|
+
if (mechanisms.complexity === "basic" && mechanisms.potionKinds.length === 1 && mechanisms.potionKinds[0] === "healing") {
|
|
360
|
+
const medicine = {
|
|
361
|
+
id: `medicine-${index + 1}`,
|
|
362
|
+
type: "medicine",
|
|
363
|
+
position,
|
|
364
|
+
recovery: 1
|
|
365
|
+
};
|
|
366
|
+
objects.push(medicine);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const kind = mechanisms.potionKinds[index % mechanisms.potionKinds.length];
|
|
370
|
+
if (!kind) throw new Error("At least one potion kind is required.");
|
|
371
|
+
const potion = {
|
|
372
|
+
id: `potion-${index + 1}`,
|
|
373
|
+
type: "potion",
|
|
214
374
|
position,
|
|
215
|
-
|
|
375
|
+
kind,
|
|
376
|
+
...kind === "healing" ? { potency: 1 + index % 2 } : {},
|
|
377
|
+
...kind === "poison" ? { potency: mechanisms.poisonDamage, duration: mechanisms.poisonDuration } : {},
|
|
378
|
+
...kind === "haste" || kind === "slow" ? { duration: mechanisms.speedDuration } : {}
|
|
216
379
|
};
|
|
217
|
-
objects.push(
|
|
380
|
+
objects.push(potion);
|
|
218
381
|
}
|
|
219
382
|
result.objects = objects;
|
|
220
383
|
result.metrics = analyzeSolidCellMaze(result);
|
|
@@ -227,7 +390,8 @@ function analyzeSolidCellMaze(maze) {
|
|
|
227
390
|
const distances = bfsDistances(maze.entry, graph);
|
|
228
391
|
const degreeValues = [...graph.values()].map((items) => items.length);
|
|
229
392
|
const edgeCount = degreeValues.reduce((sum, value) => sum + value, 0) / 2;
|
|
230
|
-
const mainPath =
|
|
393
|
+
const mainPath = shortestPathInGraph(graph, maze.entry, maze.goal);
|
|
394
|
+
const entryGoalSeparators = separatingCellKeys(graph, maze.entry, maze.goal);
|
|
231
395
|
const objectCounts = {};
|
|
232
396
|
for (const object of maze.objects ?? []) {
|
|
233
397
|
objectCounts[object.type] = (objectCounts[object.type] ?? 0) + 1;
|
|
@@ -245,7 +409,7 @@ function analyzeSolidCellMaze(maze) {
|
|
|
245
409
|
entryGoalDistance: mainPath.length > 0 ? mainPath.length - 1 : null,
|
|
246
410
|
doorCount: maze.doors.length,
|
|
247
411
|
gatingDoorCount: maze.doors.filter(
|
|
248
|
-
(door) =>
|
|
412
|
+
(door) => entryGoalSeparators.has(cellKey(door.position))
|
|
249
413
|
).length,
|
|
250
414
|
objectCounts
|
|
251
415
|
};
|
|
@@ -306,6 +470,8 @@ function validateSolidCellMaze(maze) {
|
|
|
306
470
|
if (!key) errors.push(`Missing a key for ${door.id}.`);
|
|
307
471
|
else if (keyIndex === void 0 || doorIndex === void 0 || keyIndex >= doorIndex) {
|
|
308
472
|
errors.push(`${key.id} is not before ${door.id} on the main path.`);
|
|
473
|
+
} else if (door.material !== (key.type === "key" ? key.material : void 0)) {
|
|
474
|
+
errors.push(`${key.id} does not match the material of ${door.id}.`);
|
|
309
475
|
}
|
|
310
476
|
}
|
|
311
477
|
return { valid: errors.length === 0, errors, metrics };
|
|
@@ -313,6 +479,9 @@ function validateSolidCellMaze(maze) {
|
|
|
313
479
|
function shortestPath(maze, start, goal, excludedCell = null) {
|
|
314
480
|
if (excludedCell && (sameCell(start, excludedCell) || sameCell(goal, excludedCell))) return [];
|
|
315
481
|
const graph = adjacency(maze, excludedCell);
|
|
482
|
+
return shortestPathInGraph(graph, start, goal);
|
|
483
|
+
}
|
|
484
|
+
function shortestPathInGraph(graph, start, goal) {
|
|
316
485
|
const queue = [start];
|
|
317
486
|
const parent = /* @__PURE__ */ new Map([[cellKey(start), null]]);
|
|
318
487
|
const values = /* @__PURE__ */ new Map([[cellKey(start), start]]);
|
|
@@ -444,12 +613,14 @@ function degreeMap(maze) {
|
|
|
444
613
|
}
|
|
445
614
|
function selectGatingDoorIndices(maze, mainPath, count) {
|
|
446
615
|
if (count === 0) return [];
|
|
447
|
-
const
|
|
616
|
+
const graph = adjacency(maze);
|
|
617
|
+
const separators = separatingCellKeys(graph, maze.entry, maze.goal);
|
|
448
618
|
const candidates = [];
|
|
449
619
|
for (let index = 3; index < mainPath.length - 3; index += 1) {
|
|
450
620
|
const pathCell = mainPath[index];
|
|
451
|
-
if (!pathCell
|
|
452
|
-
|
|
621
|
+
if (!pathCell) continue;
|
|
622
|
+
const key = cellKey(pathCell);
|
|
623
|
+
if ((graph.get(key)?.length ?? 0) === 2 && separators.has(key)) candidates.push(index);
|
|
453
624
|
}
|
|
454
625
|
if (candidates.length < count) {
|
|
455
626
|
throw new Error(`Only ${candidates.length} non-bypassable path cells are available for ${count} doors.`);
|
|
@@ -468,6 +639,59 @@ function selectGatingDoorIndices(maze, mainPath, count) {
|
|
|
468
639
|
if (selected.length !== count) throw new Error(`Could not space ${count} doors along the main path.`);
|
|
469
640
|
return selected.sort((left, right) => left - right);
|
|
470
641
|
}
|
|
642
|
+
function separatingCellKeys(graph, start, goal) {
|
|
643
|
+
const startKey = cellKey(start);
|
|
644
|
+
const goalKey = cellKey(goal);
|
|
645
|
+
if (!graph.has(startKey) || !graph.has(goalKey)) return /* @__PURE__ */ new Set();
|
|
646
|
+
const discovered = /* @__PURE__ */ new Map();
|
|
647
|
+
const low = /* @__PURE__ */ new Map();
|
|
648
|
+
const parent = /* @__PURE__ */ new Map([[startKey, null]]);
|
|
649
|
+
const stack = [
|
|
650
|
+
{ key: startKey, nextNeighbor: 0 }
|
|
651
|
+
];
|
|
652
|
+
let order = 1;
|
|
653
|
+
discovered.set(startKey, order);
|
|
654
|
+
low.set(startKey, order);
|
|
655
|
+
while (stack.length > 0) {
|
|
656
|
+
const frame = stack.at(-1);
|
|
657
|
+
if (!frame) break;
|
|
658
|
+
const adjacent = graph.get(frame.key) ?? [];
|
|
659
|
+
const next = adjacent[frame.nextNeighbor];
|
|
660
|
+
if (next) {
|
|
661
|
+
frame.nextNeighbor += 1;
|
|
662
|
+
const nextKey = cellKey(next);
|
|
663
|
+
if (!discovered.has(nextKey)) {
|
|
664
|
+
order += 1;
|
|
665
|
+
discovered.set(nextKey, order);
|
|
666
|
+
low.set(nextKey, order);
|
|
667
|
+
parent.set(nextKey, frame.key);
|
|
668
|
+
stack.push({ key: nextKey, nextNeighbor: 0 });
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (nextKey !== parent.get(frame.key)) {
|
|
672
|
+
low.set(frame.key, Math.min(low.get(frame.key) ?? order, discovered.get(nextKey) ?? order));
|
|
673
|
+
}
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
stack.pop();
|
|
677
|
+
const parentKey = parent.get(frame.key);
|
|
678
|
+
if (parentKey) {
|
|
679
|
+
low.set(parentKey, Math.min(low.get(parentKey) ?? order, low.get(frame.key) ?? order));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (!discovered.has(goalKey)) return /* @__PURE__ */ new Set();
|
|
683
|
+
const separators = /* @__PURE__ */ new Set();
|
|
684
|
+
let childKey = goalKey;
|
|
685
|
+
while (childKey !== startKey) {
|
|
686
|
+
const parentKey = parent.get(childKey);
|
|
687
|
+
if (!parentKey) break;
|
|
688
|
+
if (parentKey !== startKey && (low.get(childKey) ?? Number.POSITIVE_INFINITY) >= (discovered.get(parentKey) ?? Number.NEGATIVE_INFINITY)) {
|
|
689
|
+
separators.add(parentKey);
|
|
690
|
+
}
|
|
691
|
+
childKey = parentKey;
|
|
692
|
+
}
|
|
693
|
+
return separators;
|
|
694
|
+
}
|
|
471
695
|
function freePathCellBefore(mainPath, preferredIndex, occupied) {
|
|
472
696
|
for (let distance = 0; distance < mainPath.length; distance += 1) {
|
|
473
697
|
for (const index of [preferredIndex - distance, preferredIndex + distance]) {
|
|
@@ -543,6 +767,10 @@ function shuffle(rng, input) {
|
|
|
543
767
|
}
|
|
544
768
|
return values;
|
|
545
769
|
}
|
|
770
|
+
function cyclicValue(values, index) {
|
|
771
|
+
if (values.length === 0) return void 0;
|
|
772
|
+
return values[(index % values.length + values.length) % values.length];
|
|
773
|
+
}
|
|
546
774
|
function mulberry32(seed) {
|
|
547
775
|
let value = seed >>> 0;
|
|
548
776
|
return () => {
|
|
@@ -572,7 +800,7 @@ function emptyMetrics(rows, cols) {
|
|
|
572
800
|
}
|
|
573
801
|
|
|
574
802
|
// src/renderer.ts
|
|
575
|
-
var SYMBOLS = { key: "K", chest: "B", trap: "T", medicine: "H" };
|
|
803
|
+
var SYMBOLS = { key: "K", chest: "B", trap: "T", medicine: "H", potion: "P" };
|
|
576
804
|
function renderCharacterMaze(maze, language = "en") {
|
|
577
805
|
const rowWidth = String(maze.rows).length;
|
|
578
806
|
const prefix = " ".repeat(rowWidth + 1);
|
|
@@ -601,7 +829,7 @@ function renderCharacterMaze(maze, language = "en") {
|
|
|
601
829
|
}
|
|
602
830
|
lines.push("");
|
|
603
831
|
lines.push(
|
|
604
|
-
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."
|
|
832
|
+
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."
|
|
605
833
|
);
|
|
606
834
|
return `${lines.join("\n")}
|
|
607
835
|
`;
|
|
@@ -615,40 +843,105 @@ function center(value, width) {
|
|
|
615
843
|
|
|
616
844
|
// src/simulator.ts
|
|
617
845
|
var DELTAS = {
|
|
618
|
-
up:
|
|
619
|
-
down:
|
|
620
|
-
left:
|
|
621
|
-
right:
|
|
846
|
+
up: { row: -1, col: 0 },
|
|
847
|
+
down: { row: 1, col: 0 },
|
|
848
|
+
left: { row: 0, col: -1 },
|
|
849
|
+
right: { row: 0, col: 1 }
|
|
622
850
|
};
|
|
623
851
|
function simulateTrial(maze, actions, initial = {}) {
|
|
852
|
+
const mechanisms = maze.mechanisms ?? mechanismPreset("basic");
|
|
624
853
|
const health = initial.health ?? 5;
|
|
625
854
|
const state = {
|
|
626
855
|
position: structuredClone(maze.entry),
|
|
627
856
|
health,
|
|
628
857
|
healthMax: initial.healthMax ?? health,
|
|
629
858
|
keys: initial.keys ?? 0,
|
|
859
|
+
keysByMaterial: emptyMaterialCounts(initial.keysByMaterial),
|
|
630
860
|
treasures: initial.treasures ?? 0,
|
|
861
|
+
treasuresByType: emptyTreasureCounts(initial.treasuresByType),
|
|
862
|
+
elapsedTime: initial.elapsedTime ?? 0,
|
|
863
|
+
speed: initial.speed ?? "normal",
|
|
864
|
+
speedRemaining: initial.speedRemaining ?? 0,
|
|
865
|
+
poisonDamage: initial.poisonDamage ?? 0,
|
|
866
|
+
poisonRemaining: initial.poisonRemaining ?? 0,
|
|
631
867
|
alive: health > 0,
|
|
632
868
|
reachedGoal: false,
|
|
633
869
|
openedDoors: [],
|
|
634
870
|
collectedKeys: [],
|
|
635
871
|
openedChests: [],
|
|
636
872
|
triggeredTraps: [],
|
|
637
|
-
usedMedicines: []
|
|
873
|
+
usedMedicines: [],
|
|
874
|
+
usedPotions: []
|
|
638
875
|
};
|
|
639
876
|
const trace = [];
|
|
640
877
|
for (let index = 0; index < actions.length; index += 1) {
|
|
641
878
|
const direction = actions[index];
|
|
642
879
|
if (!direction) continue;
|
|
643
|
-
|
|
880
|
+
const before = cloneState(state);
|
|
881
|
+
const events = [];
|
|
882
|
+
if (!state.alive) {
|
|
883
|
+
trace.push({ step: index + 1, direction, before, moved: false, reason: "dead", events, after: cloneState(state) });
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
const context = {
|
|
887
|
+
speedAtStart: state.speed,
|
|
888
|
+
speedRemainingAtStart: state.speedRemaining,
|
|
889
|
+
poisonDamageAtStart: state.poisonDamage,
|
|
890
|
+
poisonRemainingAtStart: state.poisonRemaining,
|
|
891
|
+
speedReplaced: false,
|
|
892
|
+
poisonReplaced: false
|
|
893
|
+
};
|
|
894
|
+
const timeCost = mechanisms.movementTime[context.speedAtStart];
|
|
895
|
+
state.elapsedTime += timeCost;
|
|
896
|
+
const delta = DELTAS[direction];
|
|
897
|
+
const target = cell(state.position.row + delta.row, state.position.col + delta.col);
|
|
898
|
+
let moved = false;
|
|
899
|
+
let reason = null;
|
|
900
|
+
if (terrainAt(maze, target) !== ".") {
|
|
901
|
+
reason = "wall-or-outside";
|
|
902
|
+
} else {
|
|
903
|
+
const door = maze.doors.find((item) => cellKey(item.position) === cellKey(target));
|
|
904
|
+
if (door && !state.openedDoors.includes(door.id)) {
|
|
905
|
+
if (door.material) {
|
|
906
|
+
if (state.keysByMaterial[door.material] < 1) {
|
|
907
|
+
reason = "closed-door-without-matching-key";
|
|
908
|
+
} else {
|
|
909
|
+
state.keysByMaterial[door.material] -= 1;
|
|
910
|
+
state.openedDoors.push(door.id);
|
|
911
|
+
events.push({ type: "open-door", id: door.id, material: door.material, keyCost: 1, timeCost });
|
|
912
|
+
}
|
|
913
|
+
} else if (state.keys < 1) {
|
|
914
|
+
reason = "closed-door-without-key";
|
|
915
|
+
} else {
|
|
916
|
+
state.keys -= 1;
|
|
917
|
+
state.openedDoors.push(door.id);
|
|
918
|
+
events.push({ type: "open-door", id: door.id, keyCost: 1, timeCost });
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (!reason) {
|
|
922
|
+
state.position = target;
|
|
923
|
+
moved = true;
|
|
924
|
+
applyCellObjects(maze, state, events, context);
|
|
925
|
+
if (cellKey(state.position) === cellKey(maze.goal) && !state.reachedGoal) {
|
|
926
|
+
state.reachedGoal = true;
|
|
927
|
+
events.push({ type: "reach-goal" });
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
finishStatuses(state, events, context);
|
|
932
|
+
trace.push({ step: index + 1, direction, before, moved, reason, events, after: cloneState(state) });
|
|
644
933
|
}
|
|
934
|
+
const blockedMoves = trace.filter((item) => !item.moved).length;
|
|
935
|
+
const blockedAfterDeath = trace.filter((item) => item.reason === "dead").length;
|
|
645
936
|
return {
|
|
646
|
-
state
|
|
937
|
+
state,
|
|
647
938
|
trace,
|
|
648
939
|
answers: {
|
|
649
940
|
finalPosition: letterNumberCoordinate(state.position),
|
|
650
|
-
keys: state.keys,
|
|
941
|
+
keys: state.keys + totalMaterialKeys(state.keysByMaterial),
|
|
942
|
+
keysByMaterial: structuredClone(state.keysByMaterial),
|
|
651
943
|
treasures: state.treasures,
|
|
944
|
+
treasuresByType: structuredClone(state.treasuresByType),
|
|
652
945
|
health: state.health,
|
|
653
946
|
alive: state.alive,
|
|
654
947
|
reachedGoal: state.reachedGoal,
|
|
@@ -656,106 +949,158 @@ function simulateTrial(maze, actions, initial = {}) {
|
|
|
656
949
|
openedChests: state.openedChests.length,
|
|
657
950
|
triggeredTraps: state.triggeredTraps.length,
|
|
658
951
|
usedMedicines: state.usedMedicines.length,
|
|
659
|
-
|
|
660
|
-
|
|
952
|
+
usedPotions: state.usedPotions.length,
|
|
953
|
+
elapsedTime: state.elapsedTime,
|
|
954
|
+
finalSpeed: state.speed,
|
|
955
|
+
speedRemaining: state.speedRemaining,
|
|
956
|
+
poisonDamage: state.poisonDamage,
|
|
957
|
+
poisonRemaining: state.poisonRemaining,
|
|
958
|
+
blockedMoves,
|
|
959
|
+
blockedAfterDeath
|
|
661
960
|
}
|
|
662
961
|
};
|
|
663
962
|
}
|
|
664
|
-
function
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
if (
|
|
668
|
-
const [dr, dc] = DELTAS[direction];
|
|
669
|
-
const target = cell(state.position.row + dr, state.position.col + dc);
|
|
670
|
-
if (terrainAt(maze, target) !== ".") {
|
|
671
|
-
return record(step, direction, before, state, false, "wall-or-outside", events);
|
|
672
|
-
}
|
|
673
|
-
const door = maze.doors.find((item) => sameCell(item.position, target));
|
|
674
|
-
if (door && !state.openedDoors.includes(door.id)) {
|
|
675
|
-
if (state.keys <= 0) {
|
|
676
|
-
return record(
|
|
677
|
-
step,
|
|
678
|
-
direction,
|
|
679
|
-
before,
|
|
680
|
-
state,
|
|
681
|
-
false,
|
|
682
|
-
"closed-door-without-key",
|
|
683
|
-
events
|
|
684
|
-
);
|
|
685
|
-
}
|
|
686
|
-
state.keys -= 1;
|
|
687
|
-
state.openedDoors.push(door.id);
|
|
688
|
-
events.push({ type: "open-door", id: door.id, keyCost: 1 });
|
|
689
|
-
}
|
|
690
|
-
state.position = target;
|
|
691
|
-
const object = maze.objects.find((item) => sameCell(item.position, target));
|
|
692
|
-
if (object?.type === "key" && !state.collectedKeys.includes(object.id)) {
|
|
963
|
+
function applyCellObjects(maze, state, events, context) {
|
|
964
|
+
const object = maze.objects.find((item) => cellKey(item.position) === cellKey(state.position));
|
|
965
|
+
if (!object) return;
|
|
966
|
+
if (object.type === "key" && !state.collectedKeys.includes(object.id)) {
|
|
693
967
|
state.collectedKeys.push(object.id);
|
|
694
|
-
state.
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
968
|
+
if (object.material) state.keysByMaterial[object.material] += 1;
|
|
969
|
+
else state.keys += 1;
|
|
970
|
+
events.push({
|
|
971
|
+
type: "collect-key",
|
|
972
|
+
id: object.id,
|
|
973
|
+
...object.material ? { material: object.material } : {}
|
|
974
|
+
});
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (object.type === "chest" && !state.openedChests.includes(object.id)) {
|
|
978
|
+
let canOpen = false;
|
|
979
|
+
if (object.lockMaterial) {
|
|
980
|
+
canOpen = state.keysByMaterial[object.lockMaterial] > 0;
|
|
981
|
+
if (canOpen) state.keysByMaterial[object.lockMaterial] -= 1;
|
|
982
|
+
} else {
|
|
983
|
+
canOpen = state.keys > 0;
|
|
984
|
+
if (canOpen) state.keys -= 1;
|
|
985
|
+
}
|
|
986
|
+
if (!canOpen) {
|
|
702
987
|
events.push({
|
|
703
|
-
type: "
|
|
988
|
+
type: "pass-closed-chest",
|
|
704
989
|
id: object.id,
|
|
705
|
-
|
|
706
|
-
treasures: object.treasures
|
|
990
|
+
...object.lockMaterial ? { material: object.lockMaterial } : {}
|
|
707
991
|
});
|
|
708
|
-
|
|
709
|
-
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
state.openedChests.push(object.id);
|
|
995
|
+
const contents = object.contents?.length > 0 ? object.contents : [{ type: "treasure", count: object.treasures }];
|
|
996
|
+
for (const content of contents) {
|
|
997
|
+
state.treasures += content.count;
|
|
998
|
+
state.treasuresByType[content.type] += content.count;
|
|
710
999
|
}
|
|
1000
|
+
events.push({
|
|
1001
|
+
type: "open-chest",
|
|
1002
|
+
id: object.id,
|
|
1003
|
+
...object.lockMaterial ? { material: object.lockMaterial } : {},
|
|
1004
|
+
keyCost: 1,
|
|
1005
|
+
treasures: object.treasures,
|
|
1006
|
+
contents: structuredClone(contents)
|
|
1007
|
+
});
|
|
1008
|
+
return;
|
|
711
1009
|
}
|
|
712
|
-
if (object
|
|
1010
|
+
if (object.type === "trap" && !state.triggeredTraps.includes(object.id)) {
|
|
713
1011
|
state.triggeredTraps.push(object.id);
|
|
714
1012
|
state.health = Math.max(0, state.health - object.damage);
|
|
715
1013
|
events.push({ type: "trigger-trap", id: object.id, damage: object.damage });
|
|
1014
|
+
return;
|
|
716
1015
|
}
|
|
717
|
-
if (object
|
|
1016
|
+
if (object.type === "medicine" && !state.usedMedicines.includes(object.id)) {
|
|
718
1017
|
state.usedMedicines.push(object.id);
|
|
719
|
-
const
|
|
1018
|
+
const before = state.health;
|
|
720
1019
|
state.health = Math.min(state.healthMax, state.health + object.recovery);
|
|
721
|
-
events.push({ type: "use-medicine", id: object.id, recovery: state.health -
|
|
1020
|
+
events.push({ type: "use-medicine", id: object.id, recovery: state.health - before });
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (object.type === "potion" && !state.usedPotions.includes(object.id)) {
|
|
1024
|
+
state.usedPotions.push(object.id);
|
|
1025
|
+
applyPotion(state, object, context);
|
|
1026
|
+
const recovery = object.kind === "healing" ? object.potency ?? 1 : void 0;
|
|
1027
|
+
const damage = object.kind === "poison" ? object.potency ?? maze.mechanisms.poisonDamage : void 0;
|
|
1028
|
+
events.push({
|
|
1029
|
+
type: "drink-potion",
|
|
1030
|
+
id: object.id,
|
|
1031
|
+
potionKind: object.kind,
|
|
1032
|
+
...recovery === void 0 ? {} : { recovery },
|
|
1033
|
+
...damage === void 0 ? {} : { damage },
|
|
1034
|
+
...object.duration === void 0 ? {} : { duration: object.duration }
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
function applyPotion(state, potion, context) {
|
|
1039
|
+
if (potion.kind === "healing") {
|
|
1040
|
+
state.health = Math.min(state.healthMax, state.health + (potion.potency ?? 1));
|
|
1041
|
+
} else if (potion.kind === "poison") {
|
|
1042
|
+
state.poisonDamage = potion.potency ?? 1;
|
|
1043
|
+
state.poisonRemaining = potion.duration ?? 1;
|
|
1044
|
+
context.poisonReplaced = true;
|
|
1045
|
+
} else if (potion.kind === "antidote") {
|
|
1046
|
+
state.poisonDamage = 0;
|
|
1047
|
+
state.poisonRemaining = 0;
|
|
1048
|
+
context.poisonReplaced = true;
|
|
1049
|
+
} else if (potion.kind === "haste") {
|
|
1050
|
+
state.speed = "fast";
|
|
1051
|
+
state.speedRemaining = potion.duration ?? 1;
|
|
1052
|
+
context.speedReplaced = true;
|
|
1053
|
+
} else {
|
|
1054
|
+
state.speed = "slow";
|
|
1055
|
+
state.speedRemaining = potion.duration ?? 1;
|
|
1056
|
+
context.speedReplaced = true;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
function finishStatuses(state, events, context) {
|
|
1060
|
+
if (!context.poisonReplaced && context.poisonRemainingAtStart > 0) {
|
|
1061
|
+
state.health = Math.max(0, state.health - context.poisonDamageAtStart);
|
|
1062
|
+
state.poisonRemaining = context.poisonRemainingAtStart - 1;
|
|
1063
|
+
state.poisonDamage = state.poisonRemaining > 0 ? context.poisonDamageAtStart : 0;
|
|
1064
|
+
events.push({ type: "poison-tick", damage: context.poisonDamageAtStart });
|
|
1065
|
+
}
|
|
1066
|
+
if (!context.speedReplaced && context.speedRemainingAtStart > 0) {
|
|
1067
|
+
state.speedRemaining = context.speedRemainingAtStart - 1;
|
|
1068
|
+
if (state.speedRemaining === 0) {
|
|
1069
|
+
state.speed = "normal";
|
|
1070
|
+
events.push({ type: "speed-expired" });
|
|
1071
|
+
}
|
|
722
1072
|
}
|
|
723
1073
|
if (state.health <= 0 && state.alive) {
|
|
1074
|
+
state.health = 0;
|
|
724
1075
|
state.alive = false;
|
|
725
1076
|
events.push({ type: "die" });
|
|
726
1077
|
}
|
|
727
|
-
if (sameCell(state.position, maze.goal) && !state.reachedGoal) {
|
|
728
|
-
state.reachedGoal = true;
|
|
729
|
-
events.push({ type: "reach-goal" });
|
|
730
|
-
}
|
|
731
|
-
return record(step, direction, before, state, true, null, events);
|
|
732
1078
|
}
|
|
733
|
-
function
|
|
734
|
-
return
|
|
735
|
-
step,
|
|
736
|
-
direction,
|
|
737
|
-
before,
|
|
738
|
-
moved,
|
|
739
|
-
reason,
|
|
740
|
-
events,
|
|
741
|
-
after: structuredClone(state)
|
|
742
|
-
};
|
|
1079
|
+
function cloneState(state) {
|
|
1080
|
+
return structuredClone(state);
|
|
743
1081
|
}
|
|
744
1082
|
function stateKey(state) {
|
|
745
|
-
return
|
|
746
|
-
|
|
747
|
-
state.health,
|
|
748
|
-
state.
|
|
749
|
-
state.
|
|
750
|
-
state.treasures,
|
|
751
|
-
state.
|
|
752
|
-
state.
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1083
|
+
return JSON.stringify({
|
|
1084
|
+
position: state.position,
|
|
1085
|
+
health: state.health,
|
|
1086
|
+
keys: state.keys,
|
|
1087
|
+
keysByMaterial: state.keysByMaterial,
|
|
1088
|
+
treasures: state.treasures,
|
|
1089
|
+
treasuresByType: state.treasuresByType,
|
|
1090
|
+
elapsedTime: state.elapsedTime,
|
|
1091
|
+
speed: state.speed,
|
|
1092
|
+
speedRemaining: state.speedRemaining,
|
|
1093
|
+
poisonDamage: state.poisonDamage,
|
|
1094
|
+
poisonRemaining: state.poisonRemaining,
|
|
1095
|
+
alive: state.alive,
|
|
1096
|
+
reachedGoal: state.reachedGoal,
|
|
1097
|
+
openedDoors: state.openedDoors,
|
|
1098
|
+
collectedKeys: state.collectedKeys,
|
|
1099
|
+
openedChests: state.openedChests,
|
|
1100
|
+
triggeredTraps: state.triggeredTraps,
|
|
1101
|
+
usedMedicines: state.usedMedicines,
|
|
1102
|
+
usedPotions: state.usedPotions
|
|
1103
|
+
});
|
|
759
1104
|
}
|
|
760
1105
|
|
|
761
1106
|
// src/trial.ts
|
|
@@ -765,54 +1110,29 @@ var DIRECTIONS = {
|
|
|
765
1110
|
left: { en: "left", zh: "\u5DE6" },
|
|
766
1111
|
right: { en: "right", zh: "\u53F3" }
|
|
767
1112
|
};
|
|
768
|
-
var
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
var
|
|
780
|
-
"\
|
|
781
|
-
"\
|
|
782
|
-
"\
|
|
783
|
-
"\
|
|
784
|
-
"\
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
"\
|
|
788
|
-
"\
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
"Which cell is the explorer in after all actions are complete?",
|
|
792
|
-
"How many keys does the explorer have at the end?",
|
|
793
|
-
"How many treasures has the explorer collected at the end?",
|
|
794
|
-
"How many health points does the explorer have at the end?",
|
|
795
|
-
"Is the explorer alive at the end?",
|
|
796
|
-
"Did the explorer ever reach the goal?",
|
|
797
|
-
"How many doors were opened?",
|
|
798
|
-
"How many chests were opened?",
|
|
799
|
-
"How many traps were triggered?",
|
|
800
|
-
"How many medicine rooms were used?",
|
|
801
|
-
"How many individual moves did not change the explorer's position?"
|
|
802
|
-
];
|
|
803
|
-
var CHINESE_QUESTIONS = [
|
|
804
|
-
"\u5168\u90E8\u884C\u52A8\u7ED3\u675F\u540E\uFF0C\u63A2\u9669\u8005\u4F4D\u4E8E\u54EA\u4E00\u683C\uFF1F",
|
|
805
|
-
"\u63A2\u9669\u8005\u6700\u540E\u6301\u6709\u51E0\u628A\u94A5\u5319\uFF1F",
|
|
806
|
-
"\u63A2\u9669\u8005\u6700\u540E\u53D6\u5F97\u4E86\u51E0\u4EF6\u5B9D\u7269\uFF1F",
|
|
807
|
-
"\u63A2\u9669\u8005\u6700\u540E\u8FD8\u5269\u51E0\u70B9\u5065\u5EB7\u503C\uFF1F",
|
|
808
|
-
"\u63A2\u9669\u8005\u6700\u540E\u662F\u5426\u4ECD\u7136\u5B58\u6D3B\uFF1F",
|
|
809
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\uFF0C\u63A2\u9669\u8005\u662F\u5426\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\uFF1F",
|
|
810
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u6253\u5F00\u4E86\u51E0\u9053\u95E8\uFF1F",
|
|
811
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u6253\u5F00\u4E86\u51E0\u53EA\u5B9D\u7BB1\uFF1F",
|
|
812
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u89E6\u53D1\u4E86\u51E0\u4E2A\u9677\u9631\uFF1F",
|
|
813
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u4E00\u5171\u4F7F\u7528\u4E86\u51E0\u4E2A\u836F\u54C1\u623F\uFF1F",
|
|
814
|
-
"\u884C\u52A8\u8FC7\u7A0B\u4E2D\u5171\u6709\u591A\u5C11\u6B21\u79FB\u52A8\u6CA1\u6709\u6539\u53D8\u63A2\u9669\u8005\u7684\u4F4D\u7F6E\uFF1F"
|
|
815
|
-
];
|
|
1113
|
+
var MATERIAL_NAMES = {
|
|
1114
|
+
copper: { en: "copper", zh: "\u94DC" },
|
|
1115
|
+
silver: { en: "silver", zh: "\u94F6" },
|
|
1116
|
+
gold: { en: "gold", zh: "\u91D1" }
|
|
1117
|
+
};
|
|
1118
|
+
var TREASURE_NAMES = {
|
|
1119
|
+
treasure: { en: "treasure", zh: "\u5B9D\u7269" },
|
|
1120
|
+
coin: { en: "coin", zh: "\u94B1\u5E01" },
|
|
1121
|
+
gem: { en: "gem", zh: "\u5B9D\u77F3" },
|
|
1122
|
+
relic: { en: "relic", zh: "\u9057\u7269" }
|
|
1123
|
+
};
|
|
1124
|
+
var POTION_NAMES = {
|
|
1125
|
+
healing: { en: "healing potion", zh: "\u6CBB\u7597\u836F\u6C34" },
|
|
1126
|
+
poison: { en: "poison potion", zh: "\u6BD2\u836F" },
|
|
1127
|
+
antidote: { en: "antidote potion", zh: "\u89E3\u6BD2\u836F\u6C34" },
|
|
1128
|
+
haste: { en: "haste potion", zh: "\u52A0\u901F\u836F\u6C34" },
|
|
1129
|
+
slow: { en: "slow potion", zh: "\u51CF\u901F\u836F\u6C34" }
|
|
1130
|
+
};
|
|
1131
|
+
var SPEED_NAMES = {
|
|
1132
|
+
normal: { en: "normal", zh: "\u6B63\u5E38" },
|
|
1133
|
+
fast: { en: "fast", zh: "\u52A0\u901F" },
|
|
1134
|
+
slow: { en: "slow", zh: "\u51CF\u901F" }
|
|
1135
|
+
};
|
|
816
1136
|
function generateTrial(options = {}) {
|
|
817
1137
|
const resolved = resolveTrialOptions(options);
|
|
818
1138
|
const maze = selectMaze(resolved);
|
|
@@ -821,21 +1141,19 @@ function generateTrial(options = {}) {
|
|
|
821
1141
|
verifyScenario(resolved.scenario, result.answers, maze);
|
|
822
1142
|
const sections = {
|
|
823
1143
|
map: renderMapDescription(maze, plan.initialState, resolved.language, resolved.style),
|
|
824
|
-
rules: renderRules(resolved.language),
|
|
1144
|
+
rules: renderRules(maze, resolved.language),
|
|
825
1145
|
actions: renderActionDescription(plan.actions, resolved.language, resolved.style),
|
|
826
|
-
questions: renderQuestions(resolved.language)
|
|
1146
|
+
questions: renderQuestions(maze, resolved.language)
|
|
827
1147
|
};
|
|
828
|
-
const question = renderQuestion(resolved, maze, sections);
|
|
829
|
-
const answer = renderAnswer(resolved, maze, result.answers, plan.actions.length);
|
|
830
1148
|
return {
|
|
831
|
-
schemaVersion: "maze-test-trial@
|
|
1149
|
+
schemaVersion: "maze-test-trial@2",
|
|
832
1150
|
options: resolved,
|
|
833
1151
|
maze,
|
|
834
1152
|
initialState: plan.initialState,
|
|
835
1153
|
actions: plan.actions,
|
|
836
1154
|
sections,
|
|
837
|
-
question,
|
|
838
|
-
answer,
|
|
1155
|
+
question: renderQuestion(resolved, maze, sections),
|
|
1156
|
+
answer: renderAnswer(resolved, maze, result.answers, plan.actions.length),
|
|
839
1157
|
result
|
|
840
1158
|
};
|
|
841
1159
|
}
|
|
@@ -848,61 +1166,53 @@ function generateAnswer(options = {}) {
|
|
|
848
1166
|
function resolveTrialOptions(options = {}) {
|
|
849
1167
|
const scenario = options.scenario ?? "success";
|
|
850
1168
|
const language = options.language ?? "en";
|
|
851
|
-
if (!["success", "treasure-and-leave", "death-and-stop"].includes(scenario)) {
|
|
852
|
-
throw new Error(`Unknown scenario: ${scenario}`);
|
|
853
|
-
}
|
|
1169
|
+
if (!["success", "treasure-and-leave", "death-and-stop", "mechanism-tour"].includes(scenario)) throw new Error(`Unknown scenario: ${scenario}`);
|
|
854
1170
|
if (language !== "en" && language !== "zh") throw new Error(`Unknown language: ${language}`);
|
|
1171
|
+
const mechanisms = resolveMechanisms(options);
|
|
1172
|
+
const defaults = defaultObjectCounts(mechanisms.complexity);
|
|
855
1173
|
const resolved = {
|
|
856
1174
|
seed: integer(options.seed ?? 1, "seed", 0),
|
|
857
1175
|
rows: oddInteger(options.rows ?? 15, "rows", 7),
|
|
858
1176
|
cols: oddInteger(options.cols ?? 15, "cols", 7),
|
|
859
1177
|
braid: finiteNumber(options.braid ?? 0, "braid", 0, 1),
|
|
860
|
-
doorCount: integer(options.doorCount ??
|
|
861
|
-
chestCount: integer(options.chestCount ??
|
|
862
|
-
trapCount: integer(options.trapCount ??
|
|
863
|
-
|
|
1178
|
+
doorCount: integer(options.doorCount ?? defaults.doors, "doorCount", 0),
|
|
1179
|
+
chestCount: integer(options.chestCount ?? defaults.chests, "chestCount", 0),
|
|
1180
|
+
trapCount: integer(options.trapCount ?? defaults.traps, "trapCount", 0),
|
|
1181
|
+
potionCount: integer(options.potionCount ?? options.medicineCount ?? defaults.potions, "potionCount", 0),
|
|
1182
|
+
mechanisms,
|
|
864
1183
|
scenario,
|
|
865
1184
|
language,
|
|
866
1185
|
style: integer(options.style ?? 0, "style", 0),
|
|
867
1186
|
minDistance: integer(options.minDistance ?? 0, "minDistance", 0),
|
|
868
1187
|
maxAttempts: integer(options.maxAttempts ?? 500, "maxAttempts", 1)
|
|
869
1188
|
};
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1189
|
+
validateScenarioOptions(resolved);
|
|
1190
|
+
return resolved;
|
|
1191
|
+
}
|
|
1192
|
+
function validateScenarioOptions(options) {
|
|
1193
|
+
if (options.scenario === "treasure-and-leave") {
|
|
1194
|
+
if (options.chestCount < 1) throw new Error("The treasure-and-leave scenario requires at least one chest.");
|
|
1195
|
+
if (!options.mechanisms.potionKinds.includes("healing")) throw new Error("The treasure-and-leave scenario requires healing in potionKinds.");
|
|
1196
|
+
if (options.potionCount < 1) throw new Error("The treasure-and-leave scenario requires at least one potion.");
|
|
875
1197
|
}
|
|
876
|
-
if (scenario === "death-and-stop" &&
|
|
877
|
-
|
|
1198
|
+
if (options.scenario === "death-and-stop" && options.trapCount < 1) throw new Error("The death-and-stop scenario requires at least one trap.");
|
|
1199
|
+
if (options.scenario === "mechanism-tour") {
|
|
1200
|
+
if (options.trapCount < options.mechanisms.trapDamages.length) throw new Error("The mechanism-tour scenario needs at least one trap for every configured trap damage.");
|
|
1201
|
+
if (options.potionCount < options.mechanisms.potionKinds.length) throw new Error("The mechanism-tour scenario needs at least one potion for every configured potion kind.");
|
|
1202
|
+
if (options.chestCount < options.mechanisms.treasureTypes.length) throw new Error("The mechanism-tour scenario needs at least one chest for every configured treasure type.");
|
|
1203
|
+
if (options.doorCount < options.mechanisms.keyMaterials.length) throw new Error("The mechanism-tour scenario needs at least one door for every configured key material.");
|
|
878
1204
|
}
|
|
879
|
-
return resolved;
|
|
880
1205
|
}
|
|
881
1206
|
function selectMaze(options) {
|
|
882
1207
|
let lastError = null;
|
|
883
1208
|
for (let offset = 0; offset < options.maxAttempts; offset += 1) {
|
|
884
1209
|
const effectiveSeed = options.seed + offset;
|
|
885
1210
|
try {
|
|
886
|
-
const base = generateSolidCellMaze({
|
|
887
|
-
|
|
888
|
-
cols: options.cols,
|
|
889
|
-
braid: options.braid,
|
|
890
|
-
seed: effectiveSeed,
|
|
891
|
-
requestedSeed: options.seed,
|
|
892
|
-
id: `maze-test-${options.rows}x${options.cols}-${options.seed}`
|
|
893
|
-
});
|
|
894
|
-
const maze = decorateSolidCellMaze(base, {
|
|
895
|
-
seed: effectiveSeed + 8e4,
|
|
896
|
-
doorCount: options.doorCount,
|
|
897
|
-
chestCount: options.chestCount,
|
|
898
|
-
trapCount: options.trapCount,
|
|
899
|
-
medicineCount: options.medicineCount
|
|
900
|
-
});
|
|
1211
|
+
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}` });
|
|
1212
|
+
const maze = decorateSolidCellMaze(base, { seed: effectiveSeed + 8e4, doorCount: options.doorCount, chestCount: options.chestCount, trapCount: options.trapCount, potionCount: options.potionCount, mechanisms: options.mechanisms });
|
|
901
1213
|
const validation = validateSolidCellMaze(maze);
|
|
902
1214
|
if (!validation.valid) throw new Error(validation.errors.join(" "));
|
|
903
|
-
if ((validation.metrics?.entryGoalDistance ?? 0) < options.minDistance) {
|
|
904
|
-
throw new Error(`The entry-goal distance is below ${options.minDistance}.`);
|
|
905
|
-
}
|
|
1215
|
+
if ((validation.metrics?.entryGoalDistance ?? 0) < options.minDistance) throw new Error(`The entry-goal distance is below ${options.minDistance}.`);
|
|
906
1216
|
const plan = buildScenarioPlan(maze, options.scenario);
|
|
907
1217
|
const result = simulateTrial(maze, plan.actions, plan.initialState);
|
|
908
1218
|
verifyScenario(options.scenario, result.answers, maze);
|
|
@@ -912,172 +1222,82 @@ function selectMaze(options) {
|
|
|
912
1222
|
}
|
|
913
1223
|
}
|
|
914
1224
|
const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : "";
|
|
915
|
-
throw new Error(
|
|
916
|
-
`Could not generate a valid trial in ${options.maxAttempts} deterministic attempts.${detail}`
|
|
917
|
-
);
|
|
1225
|
+
throw new Error(`Could not generate a valid trial in ${options.maxAttempts} deterministic attempts.${detail}`);
|
|
918
1226
|
}
|
|
919
1227
|
function buildScenarioPlan(maze, scenario) {
|
|
1228
|
+
const ample = ampleInitialState(maze);
|
|
920
1229
|
if (scenario === "success") {
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
};
|
|
1230
|
+
const damage = maze.objects.filter((item) => item.type === "trap").reduce((sum, item) => sum + item.damage, 0);
|
|
1231
|
+
const poisonBudget = maze.mechanisms.potionKinds.includes("poison") ? maze.mechanisms.poisonDamage * maze.mechanisms.poisonDuration : 0;
|
|
1232
|
+
const health = Math.max(5, damage + poisonBudget + 1);
|
|
1233
|
+
return { initialState: { health, healthMax: health, keys: 0, treasures: 0 }, actions: pathToActions(shortestPath(maze, maze.entry, maze.goal)) };
|
|
925
1234
|
}
|
|
926
1235
|
if (scenario === "treasure-and-leave") {
|
|
927
|
-
const
|
|
1236
|
+
const healing = maze.objects.find((item) => item.type === "medicine" || item.type === "potion" && item.kind === "healing");
|
|
928
1237
|
const chest = maze.objects.find((item) => item.type === "chest");
|
|
929
|
-
if (!
|
|
930
|
-
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
...pathToActions(path2),
|
|
937
|
-
...pathToActions(path3),
|
|
938
|
-
...pathToActions(leavePath)
|
|
939
|
-
];
|
|
940
|
-
const initialState = {
|
|
941
|
-
health: 3,
|
|
942
|
-
healthMax: 5,
|
|
943
|
-
keys: maze.doors.length + 2,
|
|
944
|
-
treasures: 0
|
|
945
|
-
};
|
|
946
|
-
const beforeBump = simulateTrial(maze, actions, initialState);
|
|
947
|
-
const bump = wallDirection(maze, beforeBump.state.position);
|
|
948
|
-
if (bump) actions = [...actions, bump, bump];
|
|
1238
|
+
if (!healing || !chest) throw new Error("This scenario requires a healing object and a chest.");
|
|
1239
|
+
let actions = routeThrough(maze, [maze.entry, healing.position, chest.position, maze.goal]);
|
|
1240
|
+
const leavePath = shortestPath(maze, maze.goal, chest.position).slice(0, 10);
|
|
1241
|
+
actions.push(...pathToActions(leavePath));
|
|
1242
|
+
const initialState = { ...ample, health: 50, healthMax: 100 };
|
|
1243
|
+
const bump = wallDirection(maze, simulateTrial(maze, actions, initialState).state.position);
|
|
1244
|
+
if (bump) actions.push(bump, bump);
|
|
949
1245
|
return { initialState, actions };
|
|
950
1246
|
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
},
|
|
962
|
-
actions: [...pathToActions(toTrap), ...pathToActions(afterTrap)]
|
|
963
|
-
};
|
|
964
|
-
}
|
|
965
|
-
function verifyScenario(scenario, answers, maze) {
|
|
966
|
-
if (scenario === "success" && (!answers.reachedGoal || !answers.alive || answers.finalPosition !== letterNumberCoordinate(maze.goal))) {
|
|
967
|
-
throw new Error("The success scenario did not finish alive at the goal.");
|
|
968
|
-
}
|
|
969
|
-
if (scenario === "treasure-and-leave" && (!answers.reachedGoal || answers.finalPosition === letterNumberCoordinate(maze.goal) || answers.openedChests < 1 || answers.usedMedicines < 1 || answers.blockedMoves < 2)) {
|
|
970
|
-
throw new Error("The treasure-and-leave scenario did not meet its invariants.");
|
|
1247
|
+
if (scenario === "death-and-stop") {
|
|
1248
|
+
const trap = maze.objects.find((item) => item.type === "trap");
|
|
1249
|
+
if (!trap) throw new Error("This scenario requires a trap.");
|
|
1250
|
+
const actions = [...pathToActions(shortestPath(maze, maze.entry, trap.position)), ...pathToActions(shortestPath(maze, trap.position, maze.goal).slice(0, 14))];
|
|
1251
|
+
for (let health = 1; health <= 200; health += 1) {
|
|
1252
|
+
const initialState = { ...ample, health, healthMax: health };
|
|
1253
|
+
const result = simulateTrial(maze, actions, initialState);
|
|
1254
|
+
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 };
|
|
1255
|
+
}
|
|
1256
|
+
throw new Error("Could not choose health that makes the death-and-stop scenario die on its target trap.");
|
|
971
1257
|
}
|
|
972
|
-
|
|
973
|
-
|
|
1258
|
+
return { initialState: { ...ample, health: 1e3, healthMax: 1e3 }, actions: routeThrough(maze, [maze.entry, ...maze.objects.map((item) => item.position), maze.goal]) };
|
|
1259
|
+
}
|
|
1260
|
+
function ampleInitialState(maze) {
|
|
1261
|
+
const material = emptyMaterialCounts();
|
|
1262
|
+
for (const door of maze.doors) if (door.material) material[door.material] += 1;
|
|
1263
|
+
for (const object of maze.objects) if (object.type === "chest" && object.lockMaterial) material[object.lockMaterial] += 1;
|
|
1264
|
+
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 };
|
|
1265
|
+
}
|
|
1266
|
+
function verifyScenario(scenario, a, maze) {
|
|
1267
|
+
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.");
|
|
1268
|
+
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.");
|
|
1269
|
+
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.");
|
|
1270
|
+
if (scenario === "mechanism-tour") {
|
|
1271
|
+
const count = (type) => maze.objects.filter((item) => item.type === type).length;
|
|
1272
|
+
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.");
|
|
974
1273
|
}
|
|
975
1274
|
}
|
|
976
1275
|
function renderQuestion(options, maze, sections) {
|
|
977
|
-
const isZh = options.language === "zh";
|
|
978
|
-
const title = isZh ? "\u8FF7\u5BAB\u8BD5\u9898" : "Maze Trial";
|
|
979
|
-
const labels = isZh ? ["\u5730\u56FE\u63CF\u8FF0", "\u673A\u5236\u63CF\u8FF0", "\u884C\u52A8\u63CF\u8FF0", "\u95EE\u9898"] : ["Map", "Rules", "Actions", "Questions"];
|
|
980
|
-
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})`;
|
|
981
|
-
return [
|
|
982
|
-
`# ${title}`,
|
|
983
|
-
"",
|
|
984
|
-
seedLine,
|
|
985
|
-
"",
|
|
986
|
-
`## 1. ${labels[0]}`,
|
|
987
|
-
"",
|
|
988
|
-
sections.map,
|
|
989
|
-
"",
|
|
990
|
-
`## 2. ${labels[1]}`,
|
|
991
|
-
"",
|
|
992
|
-
sections.rules,
|
|
993
|
-
"",
|
|
994
|
-
`## 3. ${labels[2]}`,
|
|
995
|
-
"",
|
|
996
|
-
sections.actions,
|
|
997
|
-
"",
|
|
998
|
-
`## 4. ${labels[3]}`,
|
|
999
|
-
"",
|
|
1000
|
-
sections.questions,
|
|
1001
|
-
""
|
|
1002
|
-
].join("\n");
|
|
1003
|
-
}
|
|
1004
|
-
function renderAnswer(options, maze, answers, actionCount) {
|
|
1005
1276
|
const zh = options.language === "zh";
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
] : [
|
|
1019
|
-
|
|
1020
|
-
["Keys remaining", answers.keys],
|
|
1021
|
-
["Treasures collected", answers.treasures],
|
|
1022
|
-
["Health remaining", answers.health],
|
|
1023
|
-
["Alive", answers.alive ? "Yes" : "No"],
|
|
1024
|
-
["Ever reached the goal", answers.reachedGoal ? "Yes" : "No"],
|
|
1025
|
-
["Doors opened", answers.openedDoors],
|
|
1026
|
-
["Chests opened", answers.openedChests],
|
|
1027
|
-
["Traps triggered", answers.triggeredTraps],
|
|
1028
|
-
["Medicine rooms used", answers.usedMedicines],
|
|
1029
|
-
["Moves that did not change position", answers.blockedMoves]
|
|
1030
|
-
];
|
|
1031
|
-
return [
|
|
1032
|
-
`# ${zh ? "\u6807\u51C6\u7B54\u6848" : "Answer Key"}`,
|
|
1033
|
-
"",
|
|
1034
|
-
`${zh ? "\u79CD\u5B50" : "Seed"}: ${options.seed}`,
|
|
1035
|
-
...maze.seed === options.seed ? [] : [
|
|
1036
|
-
`${zh ? "\u6709\u6548\u8FF7\u5BAB\u79CD\u5B50" : "Effective maze seed"}: ${maze.seed}`
|
|
1037
|
-
],
|
|
1038
|
-
"",
|
|
1039
|
-
`| ${zh ? "\u95EE\u9898" : "Item"} | ${zh ? "\u7B54\u6848" : "Answer"} |`,
|
|
1040
|
-
"|---|---|",
|
|
1041
|
-
...rows.map(([label, value]) => `| ${label} | ${String(value)} |`),
|
|
1042
|
-
"",
|
|
1043
|
-
`${zh ? "\u539F\u5B50\u884C\u52A8\u6570" : "Atomic action count"}: ${actionCount}`,
|
|
1044
|
-
""
|
|
1045
|
-
].join("\n");
|
|
1277
|
+
const labels = zh ? ["\u5730\u56FE\u63CF\u8FF0", "\u673A\u5236\u63CF\u8FF0", "\u884C\u52A8\u63CF\u8FF0", "\u95EE\u9898"] : ["Map", "Rules", "Actions", "Questions"];
|
|
1278
|
+
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})`;
|
|
1279
|
+
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");
|
|
1280
|
+
}
|
|
1281
|
+
function renderAnswer(options, maze, a, actionCount) {
|
|
1282
|
+
const zh = options.language === "zh";
|
|
1283
|
+
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]];
|
|
1284
|
+
if (maze.objects.some((item) => item.type === "medicine")) rows.push([zh ? "\u4F7F\u7528\u7684\u836F\u54C1\u623F" : "Medicine rooms used", a.usedMedicines]);
|
|
1285
|
+
if (maze.objects.some((item) => item.type === "potion")) rows.push([zh ? "\u996E\u7528\u7684\u836F\u6C34" : "Potions drunk", a.usedPotions]);
|
|
1286
|
+
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]);
|
|
1287
|
+
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]]);
|
|
1288
|
+
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]]);
|
|
1289
|
+
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]);
|
|
1290
|
+
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");
|
|
1046
1291
|
}
|
|
1047
1292
|
function renderMapDescription(maze, initial, language, style) {
|
|
1048
1293
|
const terrain = renderTerrainDescription(maze, language, style);
|
|
1049
1294
|
const objects = renderObjectDescription(maze, language);
|
|
1050
|
-
if (language === "zh") {
|
|
1051
|
-
|
|
1052
|
-
`\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`,
|
|
1053
|
-
`\u5165\u53E3\u4F4D\u4E8E${letterNumberCoordinate(maze.entry)}\uFF0C\u7EC8\u70B9\u4F4D\u4E8E${letterNumberCoordinate(maze.goal)}\u3002`,
|
|
1054
|
-
"",
|
|
1055
|
-
terrain,
|
|
1056
|
-
"",
|
|
1057
|
-
"\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",
|
|
1058
|
-
objects,
|
|
1059
|
-
"",
|
|
1060
|
-
`\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`
|
|
1061
|
-
].join("\n");
|
|
1062
|
-
}
|
|
1063
|
-
return [
|
|
1064
|
-
`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}.`,
|
|
1065
|
-
`The entry is at ${letterNumberCoordinate(maze.entry)}, and the goal is at ${letterNumberCoordinate(maze.goal)}.`,
|
|
1066
|
-
"",
|
|
1067
|
-
terrain,
|
|
1068
|
-
"",
|
|
1069
|
-
"Doors and other objects are distributed as follows. Each door occupies a whole cell.",
|
|
1070
|
-
objects,
|
|
1071
|
-
"",
|
|
1072
|
-
`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.`
|
|
1073
|
-
].join("\n");
|
|
1295
|
+
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");
|
|
1296
|
+
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");
|
|
1074
1297
|
}
|
|
1075
1298
|
function renderTerrainDescription(maze, language, style) {
|
|
1076
1299
|
const clauses = Array.from({ length: maze.rows }, (_, index) => index + 1).map((row) => {
|
|
1077
|
-
const kinds = Array.from(
|
|
1078
|
-
{ length: maze.cols },
|
|
1079
|
-
(_, index) => terrainAt(maze, cell(row, index + 1)) === "." ? "floor" : "wall"
|
|
1080
|
-
);
|
|
1300
|
+
const kinds = Array.from({ length: maze.cols }, (_, index) => terrainAt(maze, cell(row, index + 1)) === "." ? "floor" : "wall");
|
|
1081
1301
|
const label = language === "zh" ? [`\u7B2C${row}\u884C`, `${row}\u3001`, `\uFF08${row}\uFF09`][style % 3] : `Row ${row}: `;
|
|
1082
1302
|
return `${label ?? `\u7B2C${row}\u884C`}${describeTerrainLine(kinds, language)}`;
|
|
1083
1303
|
});
|
|
@@ -1086,9 +1306,7 @@ function renderTerrainDescription(maze, language, style) {
|
|
|
1086
1306
|
function describeTerrainLine(kinds, language) {
|
|
1087
1307
|
const walls = [];
|
|
1088
1308
|
const floors = [];
|
|
1089
|
-
for (let index = 0; index < kinds.length; index += 1)
|
|
1090
|
-
(kinds[index] === "wall" ? walls : floors).push(index + 1);
|
|
1091
|
-
}
|
|
1309
|
+
for (let index = 0; index < kinds.length; index += 1) (kinds[index] === "wall" ? walls : floors).push(index + 1);
|
|
1092
1310
|
if (language === "zh") {
|
|
1093
1311
|
if (walls.length === 0) return "\u90FD\u662F\u901A\u8DEF";
|
|
1094
1312
|
if (floors.length === 0) return "\u90FD\u662F\u5899\u58C1";
|
|
@@ -1119,76 +1337,110 @@ function positionList(indices, language) {
|
|
|
1119
1337
|
end = value;
|
|
1120
1338
|
}
|
|
1121
1339
|
ranges.push([start, end]);
|
|
1122
|
-
const parts = ranges.map(
|
|
1123
|
-
([from, to]) => language === "zh" ? from === to ? `\u7B2C${from}\u683C` : `\u7B2C${from}\u683C\u81F3\u7B2C${to}\u683C` : from === to ? `cell ${from}` : `cells ${from}-${to}`
|
|
1124
|
-
);
|
|
1340
|
+
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}`);
|
|
1125
1341
|
if (parts.length === 1) return parts[0] ?? "";
|
|
1126
|
-
|
|
1127
|
-
return language === "zh" ? `${parts.slice(0, -1).join("\u3001")}\u548C${last}` : `${parts.slice(0, -1).join(", ")} and ${last}`;
|
|
1342
|
+
return language === "zh" ? `${parts.slice(0, -1).join("\u3001")}\u548C${parts.at(-1)}` : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
|
|
1128
1343
|
}
|
|
1129
1344
|
function renderObjectDescription(maze, language) {
|
|
1130
|
-
const
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
const traps = maze.objects.filter((item) => item.type === "trap");
|
|
1135
|
-
const medicines = maze.objects.filter((item) => item.type === "medicine");
|
|
1136
|
-
if (language === "zh") {
|
|
1137
|
-
if (doorPositions.length > 0) sentences.push(`${coordinateList(doorPositions, language)}${doorPositions.length > 1 ? "\u5404" : ""}\u6709\u4E00\u9053\u521D\u59CB\u5173\u95ED\u7684\u95E8\u3002`);
|
|
1138
|
-
const keyPositions = keys.map((item) => letterNumberCoordinate(item.position));
|
|
1139
|
-
if (keyPositions.length > 0) sentences.push(`${coordinateList(keyPositions, language)}${keyPositions.length > 1 ? "\u5404" : ""}\u653E\u7740\u4E00\u628A\u94A5\u5319\u3002`);
|
|
1140
|
-
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`);
|
|
1141
|
-
const trapPositions = traps.map((item) => letterNumberCoordinate(item.position));
|
|
1142
|
-
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`);
|
|
1143
|
-
const medicinePositions = medicines.map((item) => letterNumberCoordinate(item.position));
|
|
1144
|
-
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`);
|
|
1145
|
-
} else {
|
|
1146
|
-
if (doorPositions.length > 0) sentences.push(`${coordinateList(doorPositions, language)} ${doorPositions.length === 1 ? "contains a closed door" : "each contain a closed door"}.`);
|
|
1147
|
-
const keyPositions = keys.map((item) => letterNumberCoordinate(item.position));
|
|
1148
|
-
if (keyPositions.length > 0) sentences.push(`${coordinateList(keyPositions, language)} ${keyPositions.length === 1 ? "contains a key" : "each contain a key"}.`);
|
|
1149
|
-
for (const chest of chests) sentences.push(`${letterNumberCoordinate(chest.position)} contains a closed chest with ${chest.treasures} treasure${plural(chest.treasures)}.`);
|
|
1150
|
-
const trapPositions = traps.map((item) => letterNumberCoordinate(item.position));
|
|
1151
|
-
if (trapPositions.length > 0) sentences.push(`${coordinateList(trapPositions, language)} ${trapPositions.length === 1 ? "contains an untriggered trap" : "each contain an untriggered trap"}.`);
|
|
1152
|
-
const medicinePositions = medicines.map((item) => letterNumberCoordinate(item.position));
|
|
1153
|
-
if (medicinePositions.length > 0) sentences.push(`${coordinateList(medicinePositions, language)} ${medicinePositions.length === 1 ? "contains an unused medicine room" : "each contain an unused medicine room"}.`);
|
|
1345
|
+
const lines = [];
|
|
1346
|
+
for (const door of maze.doors) {
|
|
1347
|
+
const at = letterNumberCoordinate(door.position);
|
|
1348
|
+
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.`);
|
|
1154
1349
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1350
|
+
for (const object of maze.objects) {
|
|
1351
|
+
const at = letterNumberCoordinate(object.position);
|
|
1352
|
+
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.`);
|
|
1353
|
+
else if (object.type === "chest") {
|
|
1354
|
+
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 ");
|
|
1355
|
+
const lock = object.lockMaterial ? `a ${MATERIAL_NAMES[object.lockMaterial].en} lock` : "an ordinary lock";
|
|
1356
|
+
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}.`);
|
|
1357
|
+
} 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.`);
|
|
1358
|
+
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.`);
|
|
1359
|
+
else {
|
|
1360
|
+
const details = potionDetails(object.kind, object.potency, object.duration, language);
|
|
1361
|
+
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}.`);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
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.";
|
|
1159
1365
|
}
|
|
1160
|
-
function
|
|
1161
|
-
|
|
1366
|
+
function potionDetails(kind, potency, duration, language) {
|
|
1367
|
+
if (language === "zh") {
|
|
1368
|
+
if (kind === "healing") return `\uFF0C\u6062\u590D${potency ?? 1}\u70B9\u5065\u5EB7\u503C\uFF0C\u4F46\u4E0D\u8D85\u8FC7\u5065\u5EB7\u4E0A\u9650`;
|
|
1369
|
+
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`;
|
|
1370
|
+
if (kind === "haste" || kind === "slow") return `\uFF0C\u6548\u679C\u6301\u7EED\u4E4B\u540E${duration ?? 1}\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4`;
|
|
1371
|
+
return "\uFF0C\u53EF\u7ACB\u5373\u6E05\u9664\u4E2D\u6BD2\u72B6\u6001";
|
|
1372
|
+
}
|
|
1373
|
+
if (kind === "healing") return ` that restores ${potency ?? 1} health without exceeding maximum health`;
|
|
1374
|
+
if (kind === "poison") return ` that causes ${potency ?? 1} damage after each of the next ${duration ?? 1} atomic movement instructions`;
|
|
1375
|
+
if (kind === "haste" || kind === "slow") return ` whose effect lasts for the next ${duration ?? 1} atomic movement instructions`;
|
|
1376
|
+
return " that immediately clears poison";
|
|
1377
|
+
}
|
|
1378
|
+
function renderRules(maze, language) {
|
|
1379
|
+
const m = maze.mechanisms;
|
|
1380
|
+
const usesPotionObjects = maze.objects.some((item) => item.type === "potion");
|
|
1381
|
+
const usesPoison = usesPotionObjects && m.potionKinds.some((kind) => kind === "poison" || kind === "antidote");
|
|
1382
|
+
const usesMaterials = m.keyMaterials.length > 0;
|
|
1383
|
+
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";
|
|
1384
|
+
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.";
|
|
1385
|
+
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";
|
|
1386
|
+
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.";
|
|
1387
|
+
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";
|
|
1388
|
+
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.";
|
|
1389
|
+
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";
|
|
1390
|
+
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.";
|
|
1391
|
+
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`;
|
|
1392
|
+
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.`;
|
|
1393
|
+
const rules = language === "zh" ? [
|
|
1394
|
+
"\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",
|
|
1395
|
+
"\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",
|
|
1396
|
+
doorRuleZh,
|
|
1397
|
+
"\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",
|
|
1398
|
+
chestRuleZh,
|
|
1399
|
+
"\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",
|
|
1400
|
+
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",
|
|
1401
|
+
orderRuleZh,
|
|
1402
|
+
...usesPoison ? [poisonRuleZh] : [],
|
|
1403
|
+
...m.complexity === "advanced" ? [speedRuleZh] : [],
|
|
1404
|
+
"\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",
|
|
1405
|
+
"\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"
|
|
1406
|
+
] : [
|
|
1407
|
+
"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.",
|
|
1408
|
+
"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.",
|
|
1409
|
+
doorRuleEn,
|
|
1410
|
+
"The first time the explorer enters a key cell, that key is collected. A key cannot be collected twice.",
|
|
1411
|
+
chestRuleEn,
|
|
1412
|
+
"The first time the explorer enters a trap cell, the trap reduces health by its stated amount. That trap has no effect afterward.",
|
|
1413
|
+
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.",
|
|
1414
|
+
orderRuleEn,
|
|
1415
|
+
...usesPoison ? [poisonRuleEn] : [],
|
|
1416
|
+
...m.complexity === "advanced" ? [speedRuleEn] : [],
|
|
1417
|
+
"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.",
|
|
1418
|
+
"Once the explorer enters the goal, ever-reached-goal remains true even if the explorer later leaves or dies."
|
|
1419
|
+
];
|
|
1420
|
+
return rules.map((rule, index) => `${index + 1}. ${rule}`).join("\n");
|
|
1421
|
+
}
|
|
1422
|
+
function renderQuestions(maze, language) {
|
|
1423
|
+
const materialKeys = maze.mechanisms.keyMaterials.length > 0;
|
|
1424
|
+
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?"];
|
|
1425
|
+
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?");
|
|
1426
|
+
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?");
|
|
1427
|
+
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?");
|
|
1428
|
+
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?`);
|
|
1429
|
+
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?`);
|
|
1430
|
+
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?");
|
|
1431
|
+
return questions.map((item, index) => `${index + 1}. ${item}`).join("\n");
|
|
1162
1432
|
}
|
|
1163
1433
|
function renderActionDescription(actions, language, style) {
|
|
1164
1434
|
const runs = compressActions(actions);
|
|
1165
1435
|
if (language === "zh") {
|
|
1166
1436
|
const connectors = ["\u968F\u540E", "\u63A5\u7740", "\u7136\u540E", "\u518D", "\u4E4B\u540E"];
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
});
|
|
1171
|
-
return paragraphize(clauses2, "\uFF0C", "\u3002");
|
|
1172
|
-
}
|
|
1173
|
-
const clauses = runs.map((run, index) => {
|
|
1174
|
-
const connector = index === 0 ? "First, the explorer moves" : index % 4 === 0 ? "The explorer then moves" : "then moves";
|
|
1175
|
-
const directionForms = {
|
|
1176
|
-
up: ["up", "upward"],
|
|
1177
|
-
down: ["down", "downward"],
|
|
1178
|
-
left: ["left", "to the left"],
|
|
1179
|
-
right: ["right", "to the right"]
|
|
1180
|
-
};
|
|
1181
|
-
const forms = directionForms[run.direction];
|
|
1182
|
-
const direction = forms[(style + index) % forms.length] ?? DIRECTIONS[run.direction].en;
|
|
1183
|
-
return `${connector} ${direction} ${run.count} cell${plural(run.count)}`;
|
|
1184
|
-
});
|
|
1185
|
-
return paragraphize(clauses, ", ", ".");
|
|
1437
|
+
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");
|
|
1438
|
+
}
|
|
1439
|
+
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)}`), ", ", ".");
|
|
1186
1440
|
}
|
|
1187
1441
|
function paragraphize(clauses, separator, terminator) {
|
|
1188
1442
|
const sentences = [];
|
|
1189
|
-
for (let index = 0; index < clauses.length; index += 4) {
|
|
1190
|
-
sentences.push(`${clauses.slice(index, index + 4).join(separator)}${terminator}`);
|
|
1191
|
-
}
|
|
1443
|
+
for (let index = 0; index < clauses.length; index += 4) sentences.push(`${clauses.slice(index, index + 4).join(separator)}${terminator}`);
|
|
1192
1444
|
return sentences.join("\n");
|
|
1193
1445
|
}
|
|
1194
1446
|
function compressActions(actions) {
|
|
@@ -1200,6 +1452,18 @@ function compressActions(actions) {
|
|
|
1200
1452
|
}
|
|
1201
1453
|
return runs;
|
|
1202
1454
|
}
|
|
1455
|
+
function routeThrough(maze, points) {
|
|
1456
|
+
const actions = [];
|
|
1457
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
1458
|
+
const from = points[index - 1];
|
|
1459
|
+
const to = points[index];
|
|
1460
|
+
if (!from || !to) continue;
|
|
1461
|
+
const path = shortestPath(maze, from, to);
|
|
1462
|
+
if (path.length === 0) throw new Error("Could not connect scenario waypoints.");
|
|
1463
|
+
actions.push(...pathToActions(path));
|
|
1464
|
+
}
|
|
1465
|
+
return actions;
|
|
1466
|
+
}
|
|
1203
1467
|
function pathToActions(path) {
|
|
1204
1468
|
const actions = [];
|
|
1205
1469
|
for (let index = 1; index < path.length; index += 1) {
|
|
@@ -1213,40 +1477,28 @@ function pathToActions(path) {
|
|
|
1213
1477
|
return actions;
|
|
1214
1478
|
}
|
|
1215
1479
|
function wallDirection(maze, position) {
|
|
1216
|
-
const candidates = [
|
|
1217
|
-
["up", cell(position.row - 1, position.col)],
|
|
1218
|
-
["down", cell(position.row + 1, position.col)],
|
|
1219
|
-
["left", cell(position.row, position.col - 1)],
|
|
1220
|
-
["right", cell(position.row, position.col + 1)]
|
|
1221
|
-
];
|
|
1480
|
+
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)]];
|
|
1222
1481
|
return candidates.find(([, target]) => terrainAt(maze, target) !== ".")?.[0] ?? null;
|
|
1223
1482
|
}
|
|
1224
|
-
function coordinateList(values, language) {
|
|
1225
|
-
if (values.length <= 1) return values[0] ?? "";
|
|
1226
|
-
const last = values.at(-1);
|
|
1227
|
-
return language === "zh" ? `${values.slice(0, -1).join("\u3001")}\u548C${last}` : `${values.slice(0, -1).join(", ")} and ${last}`;
|
|
1228
|
-
}
|
|
1229
1483
|
function initialPossessions(state, language) {
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
const treasures = state.treasures > 0 ? `${state.treasures} treasure${plural(state.treasures)}` : "no treasures";
|
|
1237
|
-
return `${keys} and ${treasures}`;
|
|
1484
|
+
const materials = emptyMaterialCounts(state.keysByMaterial);
|
|
1485
|
+
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])}`);
|
|
1486
|
+
if (state.keys > 0) parts.unshift(language === "zh" ? `${state.keys}\u628A\u666E\u901A\u94A5\u5319` : `${state.keys} ordinary key${plural(state.keys)}`);
|
|
1487
|
+
const keys = parts.length > 0 ? parts.join(language === "zh" ? "\u3001" : ", ") : language === "zh" ? "\u6CA1\u6709\u94A5\u5319" : "no keys";
|
|
1488
|
+
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";
|
|
1489
|
+
return language === "zh" ? `\u6301\u6709${keys}\uFF0C${treasures}` : `${keys} and ${treasures}`;
|
|
1238
1490
|
}
|
|
1239
|
-
function
|
|
1240
|
-
|
|
1241
|
-
return forms[value] ?? String(value);
|
|
1491
|
+
function treasureUnit(type) {
|
|
1492
|
+
return type === "coin" ? "\u679A" : type === "gem" ? "\u9897" : "\u4EF6";
|
|
1242
1493
|
}
|
|
1243
1494
|
function plural(value) {
|
|
1244
1495
|
return value === 1 ? "" : "s";
|
|
1245
1496
|
}
|
|
1497
|
+
function capitalize(value) {
|
|
1498
|
+
return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
|
|
1499
|
+
}
|
|
1246
1500
|
function integer(value, name, minimum) {
|
|
1247
|
-
if (!Number.isInteger(value) || value < minimum) {
|
|
1248
|
-
throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
|
|
1249
|
-
}
|
|
1501
|
+
if (!Number.isInteger(value) || value < minimum) throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
|
|
1250
1502
|
return value;
|
|
1251
1503
|
}
|
|
1252
1504
|
function oddInteger(value, name, minimum) {
|
|
@@ -1255,9 +1507,7 @@ function oddInteger(value, name, minimum) {
|
|
|
1255
1507
|
return value;
|
|
1256
1508
|
}
|
|
1257
1509
|
function finiteNumber(value, name, minimum, maximum) {
|
|
1258
|
-
if (!Number.isFinite(value) || value < minimum || value > maximum) {
|
|
1259
|
-
throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
|
|
1260
|
-
}
|
|
1510
|
+
if (!Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
|
|
1261
1511
|
return value;
|
|
1262
1512
|
}
|
|
1263
1513
|
|
|
@@ -1270,15 +1520,20 @@ var packageName = "maze-test";
|
|
|
1270
1520
|
cellKey,
|
|
1271
1521
|
columnLabel,
|
|
1272
1522
|
decorateSolidCellMaze,
|
|
1523
|
+
defaultObjectCounts,
|
|
1524
|
+
emptyMaterialCounts,
|
|
1525
|
+
emptyTreasureCounts,
|
|
1273
1526
|
generateAnswer,
|
|
1274
1527
|
generateQuestion,
|
|
1275
1528
|
generateSolidCellMaze,
|
|
1276
1529
|
generateTrial,
|
|
1277
1530
|
isFloor,
|
|
1278
1531
|
letterNumberCoordinate,
|
|
1532
|
+
mechanismPreset,
|
|
1279
1533
|
neighbors,
|
|
1280
1534
|
packageName,
|
|
1281
1535
|
renderCharacterMaze,
|
|
1536
|
+
resolveMechanisms,
|
|
1282
1537
|
resolveTrialOptions,
|
|
1283
1538
|
rowColumnCoordinate,
|
|
1284
1539
|
sameCell,
|
|
@@ -1286,6 +1541,7 @@ var packageName = "maze-test";
|
|
|
1286
1541
|
simulateTrial,
|
|
1287
1542
|
stateKey,
|
|
1288
1543
|
terrainAt,
|
|
1544
|
+
totalMaterialKeys,
|
|
1289
1545
|
validateSolidCellMaze
|
|
1290
1546
|
});
|
|
1291
1547
|
//# sourceMappingURL=index.cjs.map
|