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