maze-test 0.2.0 → 0.3.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/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-v3@1",
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 medicineCount = integerOption(options.medicineCount ?? 2, "medicineCount", 0);
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
- return { id: `door-${doorIndex + 1}`, position, state: "closed" };
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 key = { id: `key-${index + 1}`, type: "key", position };
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: 1 + index % 3
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 medicineCandidates = shuffle(rng, floorCells(result));
208
- for (let index = 0; index < medicineCount; index += 1) {
209
- const position = takeFree(medicineCandidates, result, occupied, rng, "medicine");
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
- const medicine = {
212
- id: `medicine-${index + 1}`,
213
- type: "medicine",
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
- recovery: 1
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(medicine);
380
+ objects.push(potion);
218
381
  }
219
382
  result.objects = objects;
220
383
  result.metrics = analyzeSolidCellMaze(result);
@@ -306,6 +469,8 @@ function validateSolidCellMaze(maze) {
306
469
  if (!key) errors.push(`Missing a key for ${door.id}.`);
307
470
  else if (keyIndex === void 0 || doorIndex === void 0 || keyIndex >= doorIndex) {
308
471
  errors.push(`${key.id} is not before ${door.id} on the main path.`);
472
+ } else if (door.material !== (key.type === "key" ? key.material : void 0)) {
473
+ errors.push(`${key.id} does not match the material of ${door.id}.`);
309
474
  }
310
475
  }
311
476
  return { valid: errors.length === 0, errors, metrics };
@@ -543,6 +708,10 @@ function shuffle(rng, input) {
543
708
  }
544
709
  return values;
545
710
  }
711
+ function cyclicValue(values, index) {
712
+ if (values.length === 0) return void 0;
713
+ return values[(index % values.length + values.length) % values.length];
714
+ }
546
715
  function mulberry32(seed) {
547
716
  let value = seed >>> 0;
548
717
  return () => {
@@ -572,7 +741,7 @@ function emptyMetrics(rows, cols) {
572
741
  }
573
742
 
574
743
  // src/renderer.ts
575
- var SYMBOLS = { key: "K", chest: "B", trap: "T", medicine: "H" };
744
+ var SYMBOLS = { key: "K", chest: "B", trap: "T", medicine: "H", potion: "P" };
576
745
  function renderCharacterMaze(maze, language = "en") {
577
746
  const rowWidth = String(maze.rows).length;
578
747
  const prefix = " ".repeat(rowWidth + 1);
@@ -601,7 +770,7 @@ function renderCharacterMaze(maze, language = "en") {
601
770
  }
602
771
  lines.push("");
603
772
  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."
773
+ 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
774
  );
606
775
  return `${lines.join("\n")}
607
776
  `;
@@ -615,40 +784,105 @@ function center(value, width) {
615
784
 
616
785
  // src/simulator.ts
617
786
  var DELTAS = {
618
- up: [-1, 0],
619
- down: [1, 0],
620
- left: [0, -1],
621
- right: [0, 1]
787
+ up: { row: -1, col: 0 },
788
+ down: { row: 1, col: 0 },
789
+ left: { row: 0, col: -1 },
790
+ right: { row: 0, col: 1 }
622
791
  };
623
792
  function simulateTrial(maze, actions, initial = {}) {
793
+ const mechanisms = maze.mechanisms ?? mechanismPreset("basic");
624
794
  const health = initial.health ?? 5;
625
795
  const state = {
626
796
  position: structuredClone(maze.entry),
627
797
  health,
628
798
  healthMax: initial.healthMax ?? health,
629
799
  keys: initial.keys ?? 0,
800
+ keysByMaterial: emptyMaterialCounts(initial.keysByMaterial),
630
801
  treasures: initial.treasures ?? 0,
802
+ treasuresByType: emptyTreasureCounts(initial.treasuresByType),
803
+ elapsedTime: initial.elapsedTime ?? 0,
804
+ speed: initial.speed ?? "normal",
805
+ speedRemaining: initial.speedRemaining ?? 0,
806
+ poisonDamage: initial.poisonDamage ?? 0,
807
+ poisonRemaining: initial.poisonRemaining ?? 0,
631
808
  alive: health > 0,
632
809
  reachedGoal: false,
633
810
  openedDoors: [],
634
811
  collectedKeys: [],
635
812
  openedChests: [],
636
813
  triggeredTraps: [],
637
- usedMedicines: []
814
+ usedMedicines: [],
815
+ usedPotions: []
638
816
  };
639
817
  const trace = [];
640
818
  for (let index = 0; index < actions.length; index += 1) {
641
819
  const direction = actions[index];
642
820
  if (!direction) continue;
643
- trace.push(executeStep(maze, state, direction, index + 1));
821
+ const before = cloneState(state);
822
+ const events = [];
823
+ if (!state.alive) {
824
+ trace.push({ step: index + 1, direction, before, moved: false, reason: "dead", events, after: cloneState(state) });
825
+ continue;
826
+ }
827
+ const context = {
828
+ speedAtStart: state.speed,
829
+ speedRemainingAtStart: state.speedRemaining,
830
+ poisonDamageAtStart: state.poisonDamage,
831
+ poisonRemainingAtStart: state.poisonRemaining,
832
+ speedReplaced: false,
833
+ poisonReplaced: false
834
+ };
835
+ const timeCost = mechanisms.movementTime[context.speedAtStart];
836
+ state.elapsedTime += timeCost;
837
+ const delta = DELTAS[direction];
838
+ const target = cell(state.position.row + delta.row, state.position.col + delta.col);
839
+ let moved = false;
840
+ let reason = null;
841
+ if (terrainAt(maze, target) !== ".") {
842
+ reason = "wall-or-outside";
843
+ } else {
844
+ const door = maze.doors.find((item) => cellKey(item.position) === cellKey(target));
845
+ if (door && !state.openedDoors.includes(door.id)) {
846
+ if (door.material) {
847
+ if (state.keysByMaterial[door.material] < 1) {
848
+ reason = "closed-door-without-matching-key";
849
+ } else {
850
+ state.keysByMaterial[door.material] -= 1;
851
+ state.openedDoors.push(door.id);
852
+ events.push({ type: "open-door", id: door.id, material: door.material, keyCost: 1, timeCost });
853
+ }
854
+ } else if (state.keys < 1) {
855
+ reason = "closed-door-without-key";
856
+ } else {
857
+ state.keys -= 1;
858
+ state.openedDoors.push(door.id);
859
+ events.push({ type: "open-door", id: door.id, keyCost: 1, timeCost });
860
+ }
861
+ }
862
+ if (!reason) {
863
+ state.position = target;
864
+ moved = true;
865
+ applyCellObjects(maze, state, events, context);
866
+ if (cellKey(state.position) === cellKey(maze.goal) && !state.reachedGoal) {
867
+ state.reachedGoal = true;
868
+ events.push({ type: "reach-goal" });
869
+ }
870
+ }
871
+ }
872
+ finishStatuses(state, events, context);
873
+ trace.push({ step: index + 1, direction, before, moved, reason, events, after: cloneState(state) });
644
874
  }
875
+ const blockedMoves = trace.filter((item) => !item.moved).length;
876
+ const blockedAfterDeath = trace.filter((item) => item.reason === "dead").length;
645
877
  return {
646
- state: structuredClone(state),
878
+ state,
647
879
  trace,
648
880
  answers: {
649
881
  finalPosition: letterNumberCoordinate(state.position),
650
- keys: state.keys,
882
+ keys: state.keys + totalMaterialKeys(state.keysByMaterial),
883
+ keysByMaterial: structuredClone(state.keysByMaterial),
651
884
  treasures: state.treasures,
885
+ treasuresByType: structuredClone(state.treasuresByType),
652
886
  health: state.health,
653
887
  alive: state.alive,
654
888
  reachedGoal: state.reachedGoal,
@@ -656,106 +890,158 @@ function simulateTrial(maze, actions, initial = {}) {
656
890
  openedChests: state.openedChests.length,
657
891
  triggeredTraps: state.triggeredTraps.length,
658
892
  usedMedicines: state.usedMedicines.length,
659
- blockedMoves: trace.filter((item) => !item.moved).length,
660
- blockedAfterDeath: trace.filter((item) => item.reason === "dead").length
893
+ usedPotions: state.usedPotions.length,
894
+ elapsedTime: state.elapsedTime,
895
+ finalSpeed: state.speed,
896
+ speedRemaining: state.speedRemaining,
897
+ poisonDamage: state.poisonDamage,
898
+ poisonRemaining: state.poisonRemaining,
899
+ blockedMoves,
900
+ blockedAfterDeath
661
901
  }
662
902
  };
663
903
  }
664
- function executeStep(maze, state, direction, step) {
665
- const before = structuredClone(state);
666
- const events = [];
667
- if (!state.alive) return record(step, direction, before, state, false, "dead", events);
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)) {
904
+ function applyCellObjects(maze, state, events, context) {
905
+ const object = maze.objects.find((item) => cellKey(item.position) === cellKey(state.position));
906
+ if (!object) return;
907
+ if (object.type === "key" && !state.collectedKeys.includes(object.id)) {
693
908
  state.collectedKeys.push(object.id);
694
- state.keys += 1;
695
- events.push({ type: "collect-key", id: object.id });
696
- }
697
- if (object?.type === "chest" && !state.openedChests.includes(object.id)) {
698
- if (state.keys > 0) {
699
- state.keys -= 1;
700
- state.openedChests.push(object.id);
701
- state.treasures += object.treasures;
909
+ if (object.material) state.keysByMaterial[object.material] += 1;
910
+ else state.keys += 1;
911
+ events.push({
912
+ type: "collect-key",
913
+ id: object.id,
914
+ ...object.material ? { material: object.material } : {}
915
+ });
916
+ return;
917
+ }
918
+ if (object.type === "chest" && !state.openedChests.includes(object.id)) {
919
+ let canOpen = false;
920
+ if (object.lockMaterial) {
921
+ canOpen = state.keysByMaterial[object.lockMaterial] > 0;
922
+ if (canOpen) state.keysByMaterial[object.lockMaterial] -= 1;
923
+ } else {
924
+ canOpen = state.keys > 0;
925
+ if (canOpen) state.keys -= 1;
926
+ }
927
+ if (!canOpen) {
702
928
  events.push({
703
- type: "open-chest",
929
+ type: "pass-closed-chest",
704
930
  id: object.id,
705
- keyCost: 1,
706
- treasures: object.treasures
931
+ ...object.lockMaterial ? { material: object.lockMaterial } : {}
707
932
  });
708
- } else {
709
- events.push({ type: "pass-closed-chest", id: object.id });
933
+ return;
710
934
  }
935
+ state.openedChests.push(object.id);
936
+ const contents = object.contents?.length > 0 ? object.contents : [{ type: "treasure", count: object.treasures }];
937
+ for (const content of contents) {
938
+ state.treasures += content.count;
939
+ state.treasuresByType[content.type] += content.count;
940
+ }
941
+ events.push({
942
+ type: "open-chest",
943
+ id: object.id,
944
+ ...object.lockMaterial ? { material: object.lockMaterial } : {},
945
+ keyCost: 1,
946
+ treasures: object.treasures,
947
+ contents: structuredClone(contents)
948
+ });
949
+ return;
711
950
  }
712
- if (object?.type === "trap" && !state.triggeredTraps.includes(object.id)) {
951
+ if (object.type === "trap" && !state.triggeredTraps.includes(object.id)) {
713
952
  state.triggeredTraps.push(object.id);
714
953
  state.health = Math.max(0, state.health - object.damage);
715
954
  events.push({ type: "trigger-trap", id: object.id, damage: object.damage });
955
+ return;
716
956
  }
717
- if (object?.type === "medicine" && !state.usedMedicines.includes(object.id)) {
957
+ if (object.type === "medicine" && !state.usedMedicines.includes(object.id)) {
718
958
  state.usedMedicines.push(object.id);
719
- const prior = state.health;
959
+ const before = state.health;
720
960
  state.health = Math.min(state.healthMax, state.health + object.recovery);
721
- events.push({ type: "use-medicine", id: object.id, recovery: state.health - prior });
961
+ events.push({ type: "use-medicine", id: object.id, recovery: state.health - before });
962
+ return;
963
+ }
964
+ if (object.type === "potion" && !state.usedPotions.includes(object.id)) {
965
+ state.usedPotions.push(object.id);
966
+ applyPotion(state, object, context);
967
+ const recovery = object.kind === "healing" ? object.potency ?? 1 : void 0;
968
+ const damage = object.kind === "poison" ? object.potency ?? maze.mechanisms.poisonDamage : void 0;
969
+ events.push({
970
+ type: "drink-potion",
971
+ id: object.id,
972
+ potionKind: object.kind,
973
+ ...recovery === void 0 ? {} : { recovery },
974
+ ...damage === void 0 ? {} : { damage },
975
+ ...object.duration === void 0 ? {} : { duration: object.duration }
976
+ });
977
+ }
978
+ }
979
+ function applyPotion(state, potion, context) {
980
+ if (potion.kind === "healing") {
981
+ state.health = Math.min(state.healthMax, state.health + (potion.potency ?? 1));
982
+ } else if (potion.kind === "poison") {
983
+ state.poisonDamage = potion.potency ?? 1;
984
+ state.poisonRemaining = potion.duration ?? 1;
985
+ context.poisonReplaced = true;
986
+ } else if (potion.kind === "antidote") {
987
+ state.poisonDamage = 0;
988
+ state.poisonRemaining = 0;
989
+ context.poisonReplaced = true;
990
+ } else if (potion.kind === "haste") {
991
+ state.speed = "fast";
992
+ state.speedRemaining = potion.duration ?? 1;
993
+ context.speedReplaced = true;
994
+ } else {
995
+ state.speed = "slow";
996
+ state.speedRemaining = potion.duration ?? 1;
997
+ context.speedReplaced = true;
998
+ }
999
+ }
1000
+ function finishStatuses(state, events, context) {
1001
+ if (!context.poisonReplaced && context.poisonRemainingAtStart > 0) {
1002
+ state.health = Math.max(0, state.health - context.poisonDamageAtStart);
1003
+ state.poisonRemaining = context.poisonRemainingAtStart - 1;
1004
+ state.poisonDamage = state.poisonRemaining > 0 ? context.poisonDamageAtStart : 0;
1005
+ events.push({ type: "poison-tick", damage: context.poisonDamageAtStart });
1006
+ }
1007
+ if (!context.speedReplaced && context.speedRemainingAtStart > 0) {
1008
+ state.speedRemaining = context.speedRemainingAtStart - 1;
1009
+ if (state.speedRemaining === 0) {
1010
+ state.speed = "normal";
1011
+ events.push({ type: "speed-expired" });
1012
+ }
722
1013
  }
723
1014
  if (state.health <= 0 && state.alive) {
1015
+ state.health = 0;
724
1016
  state.alive = false;
725
1017
  events.push({ type: "die" });
726
1018
  }
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
1019
  }
733
- function record(step, direction, before, state, moved, reason, events) {
734
- return {
735
- step,
736
- direction,
737
- before,
738
- moved,
739
- reason,
740
- events,
741
- after: structuredClone(state)
742
- };
1020
+ function cloneState(state) {
1021
+ return structuredClone(state);
743
1022
  }
744
1023
  function stateKey(state) {
745
- return [
746
- cellKey(state.position),
747
- state.health,
748
- state.healthMax,
749
- state.keys,
750
- state.treasures,
751
- state.alive,
752
- state.reachedGoal,
753
- [...state.openedDoors].sort().join(","),
754
- [...state.collectedKeys].sort().join(","),
755
- [...state.openedChests].sort().join(","),
756
- [...state.triggeredTraps].sort().join(","),
757
- [...state.usedMedicines].sort().join(",")
758
- ].join("|");
1024
+ return JSON.stringify({
1025
+ position: state.position,
1026
+ health: state.health,
1027
+ keys: state.keys,
1028
+ keysByMaterial: state.keysByMaterial,
1029
+ treasures: state.treasures,
1030
+ treasuresByType: state.treasuresByType,
1031
+ elapsedTime: state.elapsedTime,
1032
+ speed: state.speed,
1033
+ speedRemaining: state.speedRemaining,
1034
+ poisonDamage: state.poisonDamage,
1035
+ poisonRemaining: state.poisonRemaining,
1036
+ alive: state.alive,
1037
+ reachedGoal: state.reachedGoal,
1038
+ openedDoors: state.openedDoors,
1039
+ collectedKeys: state.collectedKeys,
1040
+ openedChests: state.openedChests,
1041
+ triggeredTraps: state.triggeredTraps,
1042
+ usedMedicines: state.usedMedicines,
1043
+ usedPotions: state.usedPotions
1044
+ });
759
1045
  }
760
1046
 
761
1047
  // src/trial.ts
@@ -765,54 +1051,29 @@ var DIRECTIONS = {
765
1051
  left: { en: "left", zh: "\u5DE6" },
766
1052
  right: { en: "right", zh: "\u53F3" }
767
1053
  };
768
- var ENGLISH_RULES = [
769
- "The explorer can move only one cell at a time to an orthogonally adjacent cell. An instruction to move several cells is executed as that many consecutive one-cell moves.",
770
- "If the next cell is a wall or outside the map, the explorer stays in place, and the remaining actions continue.",
771
- "A closed door occupies a whole cell. To enter it, the explorer must spend one key; the door then stays open. Without a key, the explorer stays in place.",
772
- "The first time the explorer enters a cell containing a key, they collect it. A key cannot be collected twice.",
773
- "The first time the explorer enters a cell containing an unopened chest, one key is spent to open it if a key is available. Without a key, the explorer may pass through but does not open the chest.",
774
- "A trap removes one health point the first time it is entered and has no effect afterward.",
775
- "A medicine room restores one health point the first time it is entered, without exceeding maximum health, and has no effect afterward.",
776
- "When health reaches zero, the explorer dies. All remaining moves are still read but cannot change any state.",
777
- "Once the explorer enters the goal cell, reached-goal remains true even if the explorer later leaves it."
778
- ];
779
- var CHINESE_RULES = [
780
- "\u63A2\u9669\u8005\u6BCF\u6B21\u53EA\u80FD\u5411\u4E0A\u3001\u4E0B\u3001\u5DE6\u3001\u53F3\u76F8\u90BB\u7684\u4E00\u683C\u79FB\u52A8\uFF1B\u201C\u5411\u67D0\u65B9\u5411\u79FB\u52A8\u82E5\u5E72\u683C\u201D\u4F9D\u6B21\u6267\u884C\u76F8\u5E94\u6B21\u6570\u7684\u5355\u683C\u79FB\u52A8\u3002",
781
- "\u5982\u679C\u4E00\u6B21\u79FB\u52A8\u7684\u76EE\u6807\u662F\u5899\u683C\u6216\u8D85\u51FA\u5730\u56FE\uFF0C\u63A2\u9669\u8005\u505C\u5728\u539F\u5730\uFF0C\u4F46\u540E\u7EED\u884C\u52A8\u4ECD\u7136\u7EE7\u7EED\u3002",
782
- "\u95E8\u5360\u636E\u4E00\u4E2A\u5B8C\u6574\u683C\u5B50\u3002\u76EE\u6807\u683C\u662F\u5173\u95ED\u7684\u95E8\u65F6\uFF0C\u63A2\u9669\u8005\u82E5\u6301\u6709\u94A5\u5319\uFF0C\u5C31\u6D88\u8017\u4E00\u628A\u94A5\u5319\u3001\u6253\u5F00\u8BE5\u95E8\u5E76\u8FDB\u5165\u95E8\u683C\uFF1B\u6CA1\u6709\u94A5\u5319\u5219\u505C\u5728\u539F\u5730\u3002\u5DF2\u7ECF\u6253\u5F00\u7684\u95E8\u53EF\u4EE5\u76F4\u63A5\u8FDB\u5165\u3002",
783
- "\u63A2\u9669\u8005\u7B2C\u4E00\u6B21\u8FDB\u5165\u67D0\u628A\u94A5\u5319\u6240\u5728\u7684\u683C\u5B50\u65F6\u53D6\u5F97\u8BE5\u94A5\u5319\uFF0C\u94A5\u5319\u6570\u589E\u52A0\u4E00\uFF1B\u540C\u4E00\u628A\u94A5\u5319\u4E0D\u80FD\u91CD\u590D\u53D6\u5F97\u3002",
784
- "\u63A2\u9669\u8005\u7B2C\u4E00\u6B21\u8FDB\u5165\u5C1A\u672A\u6253\u5F00\u7684\u5B9D\u7BB1\u683C\u4E14\u6301\u6709\u94A5\u5319\u65F6\uFF0C\u6D88\u8017\u4E00\u628A\u94A5\u5319\u3001\u6253\u5F00\u5B9D\u7BB1\u5E76\u53D6\u5F97\u5176\u4E2D\u5168\u90E8\u5B9D\u7269\uFF1B\u6CA1\u6709\u94A5\u5319\u65F6\u53EF\u4EE5\u7ECF\u8FC7\u8BE5\u683C\uFF0C\u4F46\u4E0D\u80FD\u6253\u5F00\u5B9D\u7BB1\u3002",
785
- "\u63A2\u9669\u8005\u7B2C\u4E00\u6B21\u8FDB\u5165\u67D0\u4E2A\u9677\u9631\u683C\u65F6\u51CF\u5C11\u4E00\u70B9\u5065\u5EB7\u503C\uFF1B\u540C\u4E00\u9677\u9631\u4EE5\u540E\u4E0D\u518D\u751F\u6548\u3002",
786
- "\u4ECD\u7136\u5B58\u6D3B\u7684\u63A2\u9669\u8005\u7B2C\u4E00\u6B21\u8FDB\u5165\u67D0\u4E2A\u836F\u54C1\u623F\u65F6\u6062\u590D\u4E00\u70B9\u5065\u5EB7\u503C\uFF0C\u4F46\u4E0D\u5F97\u8D85\u8FC7\u5065\u5EB7\u4E0A\u9650\uFF1B\u540C\u4E00\u836F\u54C1\u623F\u4EE5\u540E\u4E0D\u518D\u751F\u6548\u3002",
787
- "\u5065\u5EB7\u503C\u964D\u4E3A\u96F6\u65F6\u63A2\u9669\u8005\u6B7B\u4EA1\u3002\u6B7B\u4EA1\u4EE5\u540E\uFF0C\u6240\u6709\u5C1A\u672A\u6267\u884C\u7684\u79FB\u52A8\u90FD\u4E0D\u518D\u6539\u53D8\u4EFB\u4F55\u72B6\u6001\u3002",
788
- "\u63A2\u9669\u8005\u4E00\u65E6\u8FDB\u5165\u7EC8\u70B9\u683C\uFF0C\u5C31\u628A\u201C\u66FE\u7ECF\u5230\u8FBE\u7EC8\u70B9\u201D\u8BB0\u5F55\u4E3A\u771F\uFF1B\u4EE5\u540E\u5373\u4F7F\u79BB\u5F00\u7EC8\u70B9\uFF0C\u8BE5\u8BB0\u5F55\u4ECD\u7136\u4FDD\u6301\u4E3A\u771F\u3002"
789
- ];
790
- var ENGLISH_QUESTIONS = [
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
- ];
1054
+ var MATERIAL_NAMES = {
1055
+ copper: { en: "copper", zh: "\u94DC" },
1056
+ silver: { en: "silver", zh: "\u94F6" },
1057
+ gold: { en: "gold", zh: "\u91D1" }
1058
+ };
1059
+ var TREASURE_NAMES = {
1060
+ treasure: { en: "treasure", zh: "\u5B9D\u7269" },
1061
+ coin: { en: "coin", zh: "\u94B1\u5E01" },
1062
+ gem: { en: "gem", zh: "\u5B9D\u77F3" },
1063
+ relic: { en: "relic", zh: "\u9057\u7269" }
1064
+ };
1065
+ var POTION_NAMES = {
1066
+ healing: { en: "healing potion", zh: "\u6CBB\u7597\u836F\u6C34" },
1067
+ poison: { en: "poison potion", zh: "\u6BD2\u836F" },
1068
+ antidote: { en: "antidote potion", zh: "\u89E3\u6BD2\u836F\u6C34" },
1069
+ haste: { en: "haste potion", zh: "\u52A0\u901F\u836F\u6C34" },
1070
+ slow: { en: "slow potion", zh: "\u51CF\u901F\u836F\u6C34" }
1071
+ };
1072
+ var SPEED_NAMES = {
1073
+ normal: { en: "normal", zh: "\u6B63\u5E38" },
1074
+ fast: { en: "fast", zh: "\u52A0\u901F" },
1075
+ slow: { en: "slow", zh: "\u51CF\u901F" }
1076
+ };
816
1077
  function generateTrial(options = {}) {
817
1078
  const resolved = resolveTrialOptions(options);
818
1079
  const maze = selectMaze(resolved);
@@ -821,21 +1082,19 @@ function generateTrial(options = {}) {
821
1082
  verifyScenario(resolved.scenario, result.answers, maze);
822
1083
  const sections = {
823
1084
  map: renderMapDescription(maze, plan.initialState, resolved.language, resolved.style),
824
- rules: renderRules(resolved.language),
1085
+ rules: renderRules(maze, resolved.language),
825
1086
  actions: renderActionDescription(plan.actions, resolved.language, resolved.style),
826
- questions: renderQuestions(resolved.language)
1087
+ questions: renderQuestions(maze, resolved.language)
827
1088
  };
828
- const question = renderQuestion(resolved, maze, sections);
829
- const answer = renderAnswer(resolved, maze, result.answers, plan.actions.length);
830
1089
  return {
831
- schemaVersion: "maze-test-trial@1",
1090
+ schemaVersion: "maze-test-trial@2",
832
1091
  options: resolved,
833
1092
  maze,
834
1093
  initialState: plan.initialState,
835
1094
  actions: plan.actions,
836
1095
  sections,
837
- question,
838
- answer,
1096
+ question: renderQuestion(resolved, maze, sections),
1097
+ answer: renderAnswer(resolved, maze, result.answers, plan.actions.length),
839
1098
  result
840
1099
  };
841
1100
  }
@@ -848,61 +1107,53 @@ function generateAnswer(options = {}) {
848
1107
  function resolveTrialOptions(options = {}) {
849
1108
  const scenario = options.scenario ?? "success";
850
1109
  const language = options.language ?? "en";
851
- if (!["success", "treasure-and-leave", "death-and-stop"].includes(scenario)) {
852
- throw new Error(`Unknown scenario: ${scenario}`);
853
- }
1110
+ if (!["success", "treasure-and-leave", "death-and-stop", "mechanism-tour"].includes(scenario)) throw new Error(`Unknown scenario: ${scenario}`);
854
1111
  if (language !== "en" && language !== "zh") throw new Error(`Unknown language: ${language}`);
1112
+ const mechanisms = resolveMechanisms(options);
1113
+ const defaults = defaultObjectCounts(mechanisms.complexity);
855
1114
  const resolved = {
856
1115
  seed: integer(options.seed ?? 1, "seed", 0),
857
1116
  rows: oddInteger(options.rows ?? 15, "rows", 7),
858
1117
  cols: oddInteger(options.cols ?? 15, "cols", 7),
859
1118
  braid: finiteNumber(options.braid ?? 0, "braid", 0, 1),
860
- doorCount: integer(options.doorCount ?? 1, "doorCount", 0),
861
- chestCount: integer(options.chestCount ?? 2, "chestCount", 0),
862
- trapCount: integer(options.trapCount ?? 2, "trapCount", 0),
863
- medicineCount: integer(options.medicineCount ?? 2, "medicineCount", 0),
1119
+ doorCount: integer(options.doorCount ?? defaults.doors, "doorCount", 0),
1120
+ chestCount: integer(options.chestCount ?? defaults.chests, "chestCount", 0),
1121
+ trapCount: integer(options.trapCount ?? defaults.traps, "trapCount", 0),
1122
+ potionCount: integer(options.potionCount ?? options.medicineCount ?? defaults.potions, "potionCount", 0),
1123
+ mechanisms,
864
1124
  scenario,
865
1125
  language,
866
1126
  style: integer(options.style ?? 0, "style", 0),
867
1127
  minDistance: integer(options.minDistance ?? 0, "minDistance", 0),
868
1128
  maxAttempts: integer(options.maxAttempts ?? 500, "maxAttempts", 1)
869
1129
  };
870
- if (scenario === "treasure-and-leave" && resolved.chestCount < 1) {
871
- throw new Error("The treasure-and-leave scenario requires at least one chest.");
872
- }
873
- if (scenario === "treasure-and-leave" && resolved.medicineCount < 1) {
874
- throw new Error("The treasure-and-leave scenario requires at least one medicine room.");
1130
+ validateScenarioOptions(resolved);
1131
+ return resolved;
1132
+ }
1133
+ function validateScenarioOptions(options) {
1134
+ if (options.scenario === "treasure-and-leave") {
1135
+ if (options.chestCount < 1) throw new Error("The treasure-and-leave scenario requires at least one chest.");
1136
+ if (!options.mechanisms.potionKinds.includes("healing")) throw new Error("The treasure-and-leave scenario requires healing in potionKinds.");
1137
+ if (options.potionCount < 1) throw new Error("The treasure-and-leave scenario requires at least one potion.");
875
1138
  }
876
- if (scenario === "death-and-stop" && resolved.trapCount < 1) {
877
- throw new Error("The death-and-stop scenario requires at least one trap.");
1139
+ if (options.scenario === "death-and-stop" && options.trapCount < 1) throw new Error("The death-and-stop scenario requires at least one trap.");
1140
+ if (options.scenario === "mechanism-tour") {
1141
+ if (options.trapCount < options.mechanisms.trapDamages.length) throw new Error("The mechanism-tour scenario needs at least one trap for every configured trap damage.");
1142
+ if (options.potionCount < options.mechanisms.potionKinds.length) throw new Error("The mechanism-tour scenario needs at least one potion for every configured potion kind.");
1143
+ if (options.chestCount < options.mechanisms.treasureTypes.length) throw new Error("The mechanism-tour scenario needs at least one chest for every configured treasure type.");
1144
+ 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
1145
  }
879
- return resolved;
880
1146
  }
881
1147
  function selectMaze(options) {
882
1148
  let lastError = null;
883
1149
  for (let offset = 0; offset < options.maxAttempts; offset += 1) {
884
1150
  const effectiveSeed = options.seed + offset;
885
1151
  try {
886
- const base = generateSolidCellMaze({
887
- rows: options.rows,
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
- });
1152
+ 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}` });
1153
+ const maze = decorateSolidCellMaze(base, { seed: effectiveSeed + 8e4, doorCount: options.doorCount, chestCount: options.chestCount, trapCount: options.trapCount, potionCount: options.potionCount, mechanisms: options.mechanisms });
901
1154
  const validation = validateSolidCellMaze(maze);
902
1155
  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
- }
1156
+ if ((validation.metrics?.entryGoalDistance ?? 0) < options.minDistance) throw new Error(`The entry-goal distance is below ${options.minDistance}.`);
906
1157
  const plan = buildScenarioPlan(maze, options.scenario);
907
1158
  const result = simulateTrial(maze, plan.actions, plan.initialState);
908
1159
  verifyScenario(options.scenario, result.answers, maze);
@@ -912,172 +1163,82 @@ function selectMaze(options) {
912
1163
  }
913
1164
  }
914
1165
  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
- );
1166
+ throw new Error(`Could not generate a valid trial in ${options.maxAttempts} deterministic attempts.${detail}`);
918
1167
  }
919
1168
  function buildScenarioPlan(maze, scenario) {
1169
+ const ample = ampleInitialState(maze);
920
1170
  if (scenario === "success") {
921
- return {
922
- initialState: { health: 5, healthMax: 5, keys: 0, treasures: 0 },
923
- actions: pathToActions(shortestPath(maze, maze.entry, maze.goal))
924
- };
1171
+ const damage = maze.objects.filter((item) => item.type === "trap").reduce((sum, item) => sum + item.damage, 0);
1172
+ const poisonBudget = maze.mechanisms.potionKinds.includes("poison") ? maze.mechanisms.poisonDamage * maze.mechanisms.poisonDuration : 0;
1173
+ const health = Math.max(5, damage + poisonBudget + 1);
1174
+ return { initialState: { health, healthMax: health, keys: 0, treasures: 0 }, actions: pathToActions(shortestPath(maze, maze.entry, maze.goal)) };
925
1175
  }
926
1176
  if (scenario === "treasure-and-leave") {
927
- const medicine = maze.objects.find((item) => item.type === "medicine");
1177
+ const healing = maze.objects.find((item) => item.type === "medicine" || item.type === "potion" && item.kind === "healing");
928
1178
  const chest = maze.objects.find((item) => item.type === "chest");
929
- if (!medicine || !chest) throw new Error("This scenario requires a medicine room and a chest.");
930
- const path1 = shortestPath(maze, maze.entry, medicine.position);
931
- const path2 = shortestPath(maze, medicine.position, chest.position);
932
- const path3 = shortestPath(maze, chest.position, maze.goal);
933
- const leavePath = [...path3].reverse().slice(0, Math.min(10, path3.length));
934
- let actions = [
935
- ...pathToActions(path1),
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];
1179
+ if (!healing || !chest) throw new Error("This scenario requires a healing object and a chest.");
1180
+ let actions = routeThrough(maze, [maze.entry, healing.position, chest.position, maze.goal]);
1181
+ const leavePath = shortestPath(maze, maze.goal, chest.position).slice(0, 10);
1182
+ actions.push(...pathToActions(leavePath));
1183
+ const initialState = { ...ample, health: 50, healthMax: 100 };
1184
+ const bump = wallDirection(maze, simulateTrial(maze, actions, initialState).state.position);
1185
+ if (bump) actions.push(bump, bump);
949
1186
  return { initialState, actions };
950
1187
  }
951
- const trap = maze.objects.find((item) => item.type === "trap");
952
- if (!trap) throw new Error("This scenario requires a trap.");
953
- const toTrap = shortestPath(maze, maze.entry, trap.position);
954
- const afterTrap = shortestPath(maze, trap.position, maze.goal).slice(0, 14);
955
- return {
956
- initialState: {
957
- health: 1,
958
- healthMax: 1,
959
- keys: maze.doors.length + 1,
960
- treasures: 0
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.");
1188
+ if (scenario === "death-and-stop") {
1189
+ const trap = maze.objects.find((item) => item.type === "trap");
1190
+ if (!trap) throw new Error("This scenario requires a trap.");
1191
+ const actions = [...pathToActions(shortestPath(maze, maze.entry, trap.position)), ...pathToActions(shortestPath(maze, trap.position, maze.goal).slice(0, 14))];
1192
+ for (let health = 1; health <= 200; health += 1) {
1193
+ const initialState = { ...ample, health, healthMax: health };
1194
+ const result = simulateTrial(maze, actions, initialState);
1195
+ 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 };
1196
+ }
1197
+ throw new Error("Could not choose health that makes the death-and-stop scenario die on its target trap.");
971
1198
  }
972
- if (scenario === "death-and-stop" && (answers.alive || answers.blockedAfterDeath < 5)) {
973
- throw new Error("The death-and-stop scenario did not meet its invariants.");
1199
+ return { initialState: { ...ample, health: 1e3, healthMax: 1e3 }, actions: routeThrough(maze, [maze.entry, ...maze.objects.map((item) => item.position), maze.goal]) };
1200
+ }
1201
+ function ampleInitialState(maze) {
1202
+ const material = emptyMaterialCounts();
1203
+ for (const door of maze.doors) if (door.material) material[door.material] += 1;
1204
+ for (const object of maze.objects) if (object.type === "chest" && object.lockMaterial) material[object.lockMaterial] += 1;
1205
+ 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 };
1206
+ }
1207
+ function verifyScenario(scenario, a, maze) {
1208
+ 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.");
1209
+ 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.");
1210
+ 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.");
1211
+ if (scenario === "mechanism-tour") {
1212
+ const count = (type) => maze.objects.filter((item) => item.type === type).length;
1213
+ 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
1214
  }
975
1215
  }
976
1216
  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
1217
  const zh = options.language === "zh";
1006
- const rows = zh ? [
1007
- ["\u6700\u7EC8\u4F4D\u7F6E", answers.finalPosition],
1008
- ["\u6700\u7EC8\u94A5\u5319\u6570", answers.keys],
1009
- ["\u6700\u7EC8\u5B9D\u7269\u6570", answers.treasures],
1010
- ["\u6700\u7EC8\u5065\u5EB7\u503C", answers.health],
1011
- ["\u662F\u5426\u5B58\u6D3B", answers.alive ? "\u662F" : "\u5426"],
1012
- ["\u662F\u5426\u66FE\u5230\u8FBE\u7EC8\u70B9", answers.reachedGoal ? "\u662F" : "\u5426"],
1013
- ["\u6253\u5F00\u7684\u95E8", answers.openedDoors],
1014
- ["\u6253\u5F00\u7684\u5B9D\u7BB1", answers.openedChests],
1015
- ["\u89E6\u53D1\u7684\u9677\u9631", answers.triggeredTraps],
1016
- ["\u4F7F\u7528\u7684\u836F\u54C1\u623F", answers.usedMedicines],
1017
- ["\u672A\u6539\u53D8\u4F4D\u7F6E\u7684\u79FB\u52A8", answers.blockedMoves]
1018
- ] : [
1019
- ["Final position", answers.finalPosition],
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");
1218
+ const labels = zh ? ["\u5730\u56FE\u63CF\u8FF0", "\u673A\u5236\u63CF\u8FF0", "\u884C\u52A8\u63CF\u8FF0", "\u95EE\u9898"] : ["Map", "Rules", "Actions", "Questions"];
1219
+ 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})`;
1220
+ 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");
1221
+ }
1222
+ function renderAnswer(options, maze, a, actionCount) {
1223
+ const zh = options.language === "zh";
1224
+ 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]];
1225
+ if (maze.objects.some((item) => item.type === "medicine")) rows.push([zh ? "\u4F7F\u7528\u7684\u836F\u54C1\u623F" : "Medicine rooms used", a.usedMedicines]);
1226
+ if (maze.objects.some((item) => item.type === "potion")) rows.push([zh ? "\u996E\u7528\u7684\u836F\u6C34" : "Potions drunk", a.usedPotions]);
1227
+ 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]);
1228
+ 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]]);
1229
+ 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]]);
1230
+ 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]);
1231
+ 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
1232
  }
1047
1233
  function renderMapDescription(maze, initial, language, style) {
1048
1234
  const terrain = renderTerrainDescription(maze, language, style);
1049
1235
  const objects = renderObjectDescription(maze, language);
1050
- if (language === "zh") {
1051
- return [
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");
1236
+ 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");
1237
+ 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
1238
  }
1075
1239
  function renderTerrainDescription(maze, language, style) {
1076
1240
  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
- );
1241
+ const kinds = Array.from({ length: maze.cols }, (_, index) => terrainAt(maze, cell(row, index + 1)) === "." ? "floor" : "wall");
1081
1242
  const label = language === "zh" ? [`\u7B2C${row}\u884C`, `${row}\u3001`, `\uFF08${row}\uFF09`][style % 3] : `Row ${row}: `;
1082
1243
  return `${label ?? `\u7B2C${row}\u884C`}${describeTerrainLine(kinds, language)}`;
1083
1244
  });
@@ -1086,9 +1247,7 @@ function renderTerrainDescription(maze, language, style) {
1086
1247
  function describeTerrainLine(kinds, language) {
1087
1248
  const walls = [];
1088
1249
  const floors = [];
1089
- for (let index = 0; index < kinds.length; index += 1) {
1090
- (kinds[index] === "wall" ? walls : floors).push(index + 1);
1091
- }
1250
+ for (let index = 0; index < kinds.length; index += 1) (kinds[index] === "wall" ? walls : floors).push(index + 1);
1092
1251
  if (language === "zh") {
1093
1252
  if (walls.length === 0) return "\u90FD\u662F\u901A\u8DEF";
1094
1253
  if (floors.length === 0) return "\u90FD\u662F\u5899\u58C1";
@@ -1119,76 +1278,110 @@ function positionList(indices, language) {
1119
1278
  end = value;
1120
1279
  }
1121
1280
  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
- );
1281
+ 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
1282
  if (parts.length === 1) return parts[0] ?? "";
1126
- const last = parts.at(-1);
1127
- return language === "zh" ? `${parts.slice(0, -1).join("\u3001")}\u548C${last}` : `${parts.slice(0, -1).join(", ")} and ${last}`;
1283
+ return language === "zh" ? `${parts.slice(0, -1).join("\u3001")}\u548C${parts.at(-1)}` : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
1128
1284
  }
1129
1285
  function renderObjectDescription(maze, language) {
1130
- const sentences = [];
1131
- const doorPositions = maze.doors.map((item) => letterNumberCoordinate(item.position));
1132
- const keys = maze.objects.filter((item) => item.type === "key");
1133
- const chests = maze.objects.filter((item) => item.type === "chest");
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"}.`);
1286
+ const lines = [];
1287
+ for (const door of maze.doors) {
1288
+ const at = letterNumberCoordinate(door.position);
1289
+ 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
1290
  }
1155
- return sentences.length > 0 ? sentences.join("\n") : language === "zh" ? "\u6CA1\u6709\u95E8\u6216\u5176\u4ED6\u5BF9\u8C61\u3002" : "There are no doors or other objects.";
1156
- }
1157
- function renderRules(language) {
1158
- return (language === "zh" ? CHINESE_RULES : ENGLISH_RULES).join("\n");
1291
+ for (const object of maze.objects) {
1292
+ const at = letterNumberCoordinate(object.position);
1293
+ 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.`);
1294
+ else if (object.type === "chest") {
1295
+ 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 ");
1296
+ const lock = object.lockMaterial ? `a ${MATERIAL_NAMES[object.lockMaterial].en} lock` : "an ordinary lock";
1297
+ 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}.`);
1298
+ } 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.`);
1299
+ 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.`);
1300
+ else {
1301
+ const details = potionDetails(object.kind, object.potency, object.duration, language);
1302
+ 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}.`);
1303
+ }
1304
+ }
1305
+ 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
1306
  }
1160
- function renderQuestions(language) {
1161
- return (language === "zh" ? CHINESE_QUESTIONS : ENGLISH_QUESTIONS).join("\n");
1307
+ function potionDetails(kind, potency, duration, language) {
1308
+ if (language === "zh") {
1309
+ if (kind === "healing") return `\uFF0C\u6062\u590D${potency ?? 1}\u70B9\u5065\u5EB7\u503C\uFF0C\u4F46\u4E0D\u8D85\u8FC7\u5065\u5EB7\u4E0A\u9650`;
1310
+ 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`;
1311
+ if (kind === "haste" || kind === "slow") return `\uFF0C\u6548\u679C\u6301\u7EED\u4E4B\u540E${duration ?? 1}\u6761\u539F\u5B50\u79FB\u52A8\u6307\u4EE4`;
1312
+ return "\uFF0C\u53EF\u7ACB\u5373\u6E05\u9664\u4E2D\u6BD2\u72B6\u6001";
1313
+ }
1314
+ if (kind === "healing") return ` that restores ${potency ?? 1} health without exceeding maximum health`;
1315
+ if (kind === "poison") return ` that causes ${potency ?? 1} damage after each of the next ${duration ?? 1} atomic movement instructions`;
1316
+ if (kind === "haste" || kind === "slow") return ` whose effect lasts for the next ${duration ?? 1} atomic movement instructions`;
1317
+ return " that immediately clears poison";
1318
+ }
1319
+ function renderRules(maze, language) {
1320
+ const m = maze.mechanisms;
1321
+ const usesPotionObjects = maze.objects.some((item) => item.type === "potion");
1322
+ const usesPoison = usesPotionObjects && m.potionKinds.some((kind) => kind === "poison" || kind === "antidote");
1323
+ const usesMaterials = m.keyMaterials.length > 0;
1324
+ 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";
1325
+ 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.";
1326
+ 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";
1327
+ 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.";
1328
+ 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";
1329
+ 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.";
1330
+ 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";
1331
+ 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.";
1332
+ 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`;
1333
+ 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.`;
1334
+ const rules = language === "zh" ? [
1335
+ "\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",
1336
+ "\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",
1337
+ doorRuleZh,
1338
+ "\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",
1339
+ chestRuleZh,
1340
+ "\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",
1341
+ 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",
1342
+ orderRuleZh,
1343
+ ...usesPoison ? [poisonRuleZh] : [],
1344
+ ...m.complexity === "advanced" ? [speedRuleZh] : [],
1345
+ "\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",
1346
+ "\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"
1347
+ ] : [
1348
+ "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.",
1349
+ "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.",
1350
+ doorRuleEn,
1351
+ "The first time the explorer enters a key cell, that key is collected. A key cannot be collected twice.",
1352
+ chestRuleEn,
1353
+ "The first time the explorer enters a trap cell, the trap reduces health by its stated amount. That trap has no effect afterward.",
1354
+ 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.",
1355
+ orderRuleEn,
1356
+ ...usesPoison ? [poisonRuleEn] : [],
1357
+ ...m.complexity === "advanced" ? [speedRuleEn] : [],
1358
+ "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.",
1359
+ "Once the explorer enters the goal, ever-reached-goal remains true even if the explorer later leaves or dies."
1360
+ ];
1361
+ return rules.map((rule, index) => `${index + 1}. ${rule}`).join("\n");
1362
+ }
1363
+ function renderQuestions(maze, language) {
1364
+ const materialKeys = maze.mechanisms.keyMaterials.length > 0;
1365
+ 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?"];
1366
+ 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?");
1367
+ 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?");
1368
+ 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?");
1369
+ 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?`);
1370
+ 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?`);
1371
+ 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?");
1372
+ return questions.map((item, index) => `${index + 1}. ${item}`).join("\n");
1162
1373
  }
1163
1374
  function renderActionDescription(actions, language, style) {
1164
1375
  const runs = compressActions(actions);
1165
1376
  if (language === "zh") {
1166
1377
  const connectors = ["\u968F\u540E", "\u63A5\u7740", "\u7136\u540E", "\u518D", "\u4E4B\u540E"];
1167
- const clauses2 = runs.map((run, index) => {
1168
- const connector = index === 0 ? "\u63A2\u9669\u8005\u5148" : connectors[(style + index) % connectors.length];
1169
- return `${connector ?? "\u7136\u540E"}\u5411${DIRECTIONS[run.direction].zh}\u79FB\u52A8${numberZh(run.count)}\u683C`;
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, ", ", ".");
1378
+ 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");
1379
+ }
1380
+ 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
1381
  }
1187
1382
  function paragraphize(clauses, separator, terminator) {
1188
1383
  const sentences = [];
1189
- for (let index = 0; index < clauses.length; index += 4) {
1190
- sentences.push(`${clauses.slice(index, index + 4).join(separator)}${terminator}`);
1191
- }
1384
+ for (let index = 0; index < clauses.length; index += 4) sentences.push(`${clauses.slice(index, index + 4).join(separator)}${terminator}`);
1192
1385
  return sentences.join("\n");
1193
1386
  }
1194
1387
  function compressActions(actions) {
@@ -1200,6 +1393,18 @@ function compressActions(actions) {
1200
1393
  }
1201
1394
  return runs;
1202
1395
  }
1396
+ function routeThrough(maze, points) {
1397
+ const actions = [];
1398
+ for (let index = 1; index < points.length; index += 1) {
1399
+ const from = points[index - 1];
1400
+ const to = points[index];
1401
+ if (!from || !to) continue;
1402
+ const path = shortestPath(maze, from, to);
1403
+ if (path.length === 0) throw new Error("Could not connect scenario waypoints.");
1404
+ actions.push(...pathToActions(path));
1405
+ }
1406
+ return actions;
1407
+ }
1203
1408
  function pathToActions(path) {
1204
1409
  const actions = [];
1205
1410
  for (let index = 1; index < path.length; index += 1) {
@@ -1213,40 +1418,28 @@ function pathToActions(path) {
1213
1418
  return actions;
1214
1419
  }
1215
1420
  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
- ];
1421
+ 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
1422
  return candidates.find(([, target]) => terrainAt(maze, target) !== ".")?.[0] ?? null;
1223
1423
  }
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
1424
  function initialPossessions(state, language) {
1230
- if (language === "zh") {
1231
- const keys2 = state.keys > 0 ? `\u8D77\u521D\u6301\u6709${numberZh(state.keys)}\u628A\u94A5\u5319` : "\u8D77\u521D\u6CA1\u6709\u94A5\u5319";
1232
- const treasures2 = state.treasures > 0 ? `\u6301\u6709${numberZh(state.treasures)}\u4EF6\u5B9D\u7269` : "\u6CA1\u6709\u5B9D\u7269";
1233
- return `${keys2}\uFF0C${treasures2}`;
1234
- }
1235
- const keys = state.keys > 0 ? `${state.keys} key${plural(state.keys)}` : "no keys";
1236
- const treasures = state.treasures > 0 ? `${state.treasures} treasure${plural(state.treasures)}` : "no treasures";
1237
- return `${keys} and ${treasures}`;
1425
+ const materials = emptyMaterialCounts(state.keysByMaterial);
1426
+ 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])}`);
1427
+ if (state.keys > 0) parts.unshift(language === "zh" ? `${state.keys}\u628A\u666E\u901A\u94A5\u5319` : `${state.keys} ordinary key${plural(state.keys)}`);
1428
+ const keys = parts.length > 0 ? parts.join(language === "zh" ? "\u3001" : ", ") : language === "zh" ? "\u6CA1\u6709\u94A5\u5319" : "no keys";
1429
+ 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";
1430
+ return language === "zh" ? `\u6301\u6709${keys}\uFF0C${treasures}` : `${keys} and ${treasures}`;
1238
1431
  }
1239
- function numberZh(value) {
1240
- const forms = ["\u96F6", "\u4E00", "\u4E24", "\u4E09", "\u56DB", "\u4E94", "\u516D", "\u4E03", "\u516B", "\u4E5D", "\u5341"];
1241
- return forms[value] ?? String(value);
1432
+ function treasureUnit(type) {
1433
+ return type === "coin" ? "\u679A" : type === "gem" ? "\u9897" : "\u4EF6";
1242
1434
  }
1243
1435
  function plural(value) {
1244
1436
  return value === 1 ? "" : "s";
1245
1437
  }
1438
+ function capitalize(value) {
1439
+ return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
1440
+ }
1246
1441
  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
- }
1442
+ if (!Number.isInteger(value) || value < minimum) throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
1250
1443
  return value;
1251
1444
  }
1252
1445
  function oddInteger(value, name, minimum) {
@@ -1255,9 +1448,7 @@ function oddInteger(value, name, minimum) {
1255
1448
  return value;
1256
1449
  }
1257
1450
  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
- }
1451
+ if (!Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`${name} must be between ${minimum} and ${maximum}.`);
1261
1452
  return value;
1262
1453
  }
1263
1454
 
@@ -1270,15 +1461,20 @@ var packageName = "maze-test";
1270
1461
  cellKey,
1271
1462
  columnLabel,
1272
1463
  decorateSolidCellMaze,
1464
+ defaultObjectCounts,
1465
+ emptyMaterialCounts,
1466
+ emptyTreasureCounts,
1273
1467
  generateAnswer,
1274
1468
  generateQuestion,
1275
1469
  generateSolidCellMaze,
1276
1470
  generateTrial,
1277
1471
  isFloor,
1278
1472
  letterNumberCoordinate,
1473
+ mechanismPreset,
1279
1474
  neighbors,
1280
1475
  packageName,
1281
1476
  renderCharacterMaze,
1477
+ resolveMechanisms,
1282
1478
  resolveTrialOptions,
1283
1479
  rowColumnCoordinate,
1284
1480
  sameCell,
@@ -1286,6 +1482,7 @@ var packageName = "maze-test";
1286
1482
  simulateTrial,
1287
1483
  stateKey,
1288
1484
  terrainAt,
1485
+ totalMaterialKeys,
1289
1486
  validateSolidCellMaze
1290
1487
  });
1291
1488
  //# sourceMappingURL=index.cjs.map