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