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