dungeoncoder 0.2.0__py3-none-any.whl

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.
Files changed (77) hide show
  1. dungeoncoder/__init__.py +4 -0
  2. dungeoncoder/__main__.py +254 -0
  3. dungeoncoder/_api_config.py +6 -0
  4. dungeoncoder/_generated/__init__.py +8 -0
  5. dungeoncoder/_generated/api/__init__.py +1 -0
  6. dungeoncoder/_generated/api/game/__init__.py +1 -0
  7. dungeoncoder/_generated/api/game/get_statistics.py +158 -0
  8. dungeoncoder/_generated/api/game/get_version.py +166 -0
  9. dungeoncoder/_generated/api/hero/__init__.py +1 -0
  10. dungeoncoder/_generated/api/hero/ask_oracle.py +192 -0
  11. dungeoncoder/_generated/api/hero/configure.py +207 -0
  12. dungeoncoder/_generated/api/hero/drop.py +207 -0
  13. dungeoncoder/_generated/api/hero/get_inventory.py +188 -0
  14. dungeoncoder/_generated/api/hero/get_items_at_position.py +188 -0
  15. dungeoncoder/_generated/api/hero/interact.py +188 -0
  16. dungeoncoder/_generated/api/hero/is_abyss_in_front.py +188 -0
  17. dungeoncoder/_generated/api/hero/is_at_goal.py +192 -0
  18. dungeoncoder/_generated/api/hero/is_collision_in_front.py +188 -0
  19. dungeoncoder/_generated/api/hero/is_enemy_in_front.py +188 -0
  20. dungeoncoder/_generated/api/hero/is_facing_north.py +188 -0
  21. dungeoncoder/_generated/api/hero/is_switch_in_front.py +188 -0
  22. dungeoncoder/_generated/api/hero/is_torch_in_front.py +188 -0
  23. dungeoncoder/_generated/api/hero/move.py +188 -0
  24. dungeoncoder/_generated/api/hero/peek_item_value.py +211 -0
  25. dungeoncoder/_generated/api/hero/pickup.py +207 -0
  26. dungeoncoder/_generated/api/hero/read_item_value.py +188 -0
  27. dungeoncoder/_generated/api/hero/sense_goal.py +192 -0
  28. dungeoncoder/_generated/api/hero/set_pace.py +207 -0
  29. dungeoncoder/_generated/api/hero/turn_left.py +188 -0
  30. dungeoncoder/_generated/api/level/__init__.py +1 -0
  31. dungeoncoder/_generated/api/level/load_level.py +190 -0
  32. dungeoncoder/_generated/api/level/reset_level.py +158 -0
  33. dungeoncoder/_generated/client.py +271 -0
  34. dungeoncoder/_generated/errors.py +14 -0
  35. dungeoncoder/_generated/models/__init__.py +47 -0
  36. dungeoncoder/_generated/models/api_error_response.py +98 -0
  37. dungeoncoder/_generated/models/api_error_response_status.py +7 -0
  38. dungeoncoder/_generated/models/boolean_result.py +96 -0
  39. dungeoncoder/_generated/models/boolean_result_status.py +7 -0
  40. dungeoncoder/_generated/models/configure_params.py +84 -0
  41. dungeoncoder/_generated/models/distance_param.py +76 -0
  42. dungeoncoder/_generated/models/name_param.py +76 -0
  43. dungeoncoder/_generated/models/nullable_integer_result.py +104 -0
  44. dungeoncoder/_generated/models/nullable_integer_result_status.py +7 -0
  45. dungeoncoder/_generated/models/nullable_string_result.py +104 -0
  46. dungeoncoder/_generated/models/nullable_string_result_status.py +7 -0
  47. dungeoncoder/_generated/models/pace_params.py +76 -0
  48. dungeoncoder/_generated/models/statistics.py +217 -0
  49. dungeoncoder/_generated/models/statistics_result.py +104 -0
  50. dungeoncoder/_generated/models/statistics_result_status.py +7 -0
  51. dungeoncoder/_generated/models/string_array_result.py +100 -0
  52. dungeoncoder/_generated/models/string_array_result_status.py +7 -0
  53. dungeoncoder/_generated/models/tiled_level.py +67 -0
  54. dungeoncoder/_generated/models/version_result.py +104 -0
  55. dungeoncoder/_generated/models/version_result_result.py +84 -0
  56. dungeoncoder/_generated/models/version_result_status.py +7 -0
  57. dungeoncoder/_generated/types.py +53 -0
  58. dungeoncoder/asciimap.py +824 -0
  59. dungeoncoder/dungeoncoder.py +482 -0
  60. dungeoncoder/generator.py +213 -0
  61. dungeoncoder/packs/gelaende.json +1 -0
  62. dungeoncoder/packs/items.json +67 -0
  63. dungeoncoder/packs/station.json +1 -0
  64. dungeoncoder/packs/studio.json +1 -0
  65. dungeoncoder/packs/tilesets.json +1 -0
  66. dungeoncoder/packs/werkstatt.json +1 -0
  67. dungeoncoder/replay.py +80 -0
  68. dungeoncoder/requirements.txt +2 -0
  69. dungeoncoder/sim.py +792 -0
  70. dungeoncoder/testing.py +63 -0
  71. dungeoncoder/trace.py +92 -0
  72. dungeoncoder/variants.py +112 -0
  73. dungeoncoder-0.2.0.dist-info/METADATA +37 -0
  74. dungeoncoder-0.2.0.dist-info/RECORD +77 -0
  75. dungeoncoder-0.2.0.dist-info/WHEEL +5 -0
  76. dungeoncoder-0.2.0.dist-info/licenses/LICENSE.txt +25 -0
  77. dungeoncoder-0.2.0.dist-info/top_level.txt +1 -0
dungeoncoder/sim.py ADDED
@@ -0,0 +1,792 @@
1
+ """A pure-Python simulator of the Dungeon Coder game, for running scripts without
2
+ VS Code or a browser: grading, tests (pytest), experiments on many levels.
3
+
4
+ DUNGEONCODER_SIM=1 python mein_programm.py # any script, unchanged
5
+
6
+ import dungeoncoder
7
+ dungeoncoder.use_simulator() # or switch in code
8
+
9
+ It replaces only the connection to the game: the Hero and Game classes stay the
10
+ same, so every message and return value goes through the same code as with the
11
+ real game. The simulator is a port of the game's rules (game/src: level.js,
12
+ character.js, game-objects.js, script.js); tests/conformance.py checks that it
13
+ behaves like the real engine. Steps take no time, and there is no picture.
14
+
15
+ Tile rules (classes, collision, states) come from packs/tilesets.json, which is
16
+ exported from the game's tilesets (tools/ascii2level.py --export-packs), and from the asset
17
+ packs in DC_ASSET_PACKS. The tiles of the start inventory's items come from the packs'
18
+ manifests (pack.json "items"), then packs/items.json (the bundled assets' items).
19
+ Differences on purpose: none known. Fog only changes the picture, so it has no
20
+ effect here.
21
+ """
22
+ import json
23
+ import os
24
+
25
+ TILE = 16
26
+ PACK_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "packs")
27
+ DIRECTIONS_LEFT = {"north": "west", "west": "south", "south": "east", "east": "north"}
28
+
29
+ _rules_cache = None
30
+ _items_cache = None
31
+
32
+
33
+ def _rules_from_folder(folder):
34
+ """Tile rules read directly from a pack's tilesets/*.json (like the game reads them)."""
35
+ rules = {}
36
+ tilesets = os.path.join(folder, "tilesets")
37
+ for name in sorted(os.listdir(tilesets)) if os.path.isdir(tilesets) else []:
38
+ if name.endswith(".json"):
39
+ with open(os.path.join(tilesets, name), encoding="utf-8") as f:
40
+ data = json.load(f)
41
+ tiles = {}
42
+ for tile in data.get("tiles", []):
43
+ entry = {"type": tile["type"]} if tile.get("type") else {}
44
+ props = {p["name"]: p["value"] for p in tile.get("properties", [])}
45
+ if props:
46
+ entry["props"] = props
47
+ if entry:
48
+ tiles[str(tile["id"])] = entry
49
+ rules["../tilesets/" + name] = {"count": data["tilecount"], "tiles": tiles}
50
+ return rules
51
+
52
+
53
+ def _rules():
54
+ """packs/tilesets.json, overridden by the asset packs in DC_ASSET_PACKS (first pack wins,
55
+ as in the game): so a private course pack works without its rules in this package."""
56
+ global _rules_cache
57
+ if _rules_cache is None:
58
+ with open(os.path.join(PACK_DIR, "tilesets.json"), encoding="utf-8") as f:
59
+ _rules_cache = json.load(f)
60
+ for folder in reversed([p for p in os.environ.get("DC_ASSET_PACKS", "").split(os.pathsep) if p]):
61
+ _rules_cache.update(_rules_from_folder(folder))
62
+ return _rules_cache
63
+
64
+
65
+ def _items_from_manifest(manifest):
66
+ out = {}
67
+ for type_, spec in (manifest or {}).get("items", {}).items():
68
+ if isinstance(spec, dict) and isinstance(spec.get("tileset"), str) and isinstance(spec.get("tile"), int):
69
+ out[type_] = ("../tilesets/" + spec["tileset"], spec["tile"])
70
+ return out
71
+
72
+
73
+ def item_tiles():
74
+ """{item type: (tileset source, local id)} for the items the engine creates itself (the
75
+ start inventory): from the manifests (pack.json "items") of the packs in DC_ASSET_PACKS,
76
+ first pack wins, then packs/items.json, the items of the engine's bundled assets
77
+ (engine: game/src/assets.js, loadItemTiles)."""
78
+ global _items_cache
79
+ if _items_cache is None:
80
+ manifests = [os.path.join(p, "pack.json") for p in os.environ.get("DC_ASSET_PACKS", "").split(os.pathsep) if p]
81
+ manifests.append(os.path.join(PACK_DIR, "items.json"))
82
+ _items_cache = {}
83
+ for path in manifests:
84
+ try:
85
+ with open(path, encoding="utf-8") as f:
86
+ found = _items_from_manifest(json.load(f))
87
+ except (OSError, ValueError):
88
+ continue
89
+ for type_, tile in found.items():
90
+ _items_cache.setdefault(type_, tile)
91
+ return _items_cache
92
+
93
+
94
+ def missing_tileset_message(sources, pack=""):
95
+ """Same wording as the game (game/src/tiles.js, missingTilesetMessage); pack: the asset pack
96
+ the level names (map property `pack`)."""
97
+ needs = (f'This level needs the asset pack "{pack}": get it (the exercise sheet says how) and add it '
98
+ if pack else "Add the asset pack ")
99
+ return (f"Missing tileset {', '.join(sources)}: no asset pack has it. "
100
+ f"{needs}to the setting dungeonCoder.assetPacks (outside VS Code: DC_ASSET_PACKS).")
101
+
102
+
103
+ class MissingTileset(Exception):
104
+ """A level names tilesets that no asset pack has."""
105
+
106
+ def __init__(self, sources, pack=""):
107
+ super().__init__(missing_tileset_message(sources, pack))
108
+ self.sources = sources
109
+ self.pack = pack
110
+
111
+
112
+ class Tile:
113
+ """A tile's class ("type") and properties; the picture doesn't matter here."""
114
+
115
+ def __init__(self, local, entry):
116
+ self.local = local
117
+ self.type = entry.get("type") or ""
118
+ self.props = entry.get("props", {})
119
+
120
+ def get(self, name):
121
+ """Like Tile.getProperty in the engine: the value, or None."""
122
+ value = self.props.get(name)
123
+ return value if value else None
124
+
125
+
126
+ class Tilesets:
127
+ """The level's tilesets, in the level's order, like the engine's TileFactory."""
128
+
129
+ def __init__(self, descriptions, pack=""):
130
+ self.sets = []
131
+ missing = [d["source"] for d in descriptions if d["source"] not in _rules()]
132
+ if missing:
133
+ # without its rules a wall would be floor: refuse the level like the game (bug B24)
134
+ raise MissingTileset(missing, pack)
135
+ for d in descriptions:
136
+ rules = _rules()[d["source"]]
137
+ tiles = {int(k): Tile(int(k), v) for k, v in rules["tiles"].items()}
138
+ self.sets.append((d["firstgid"], rules["count"], tiles, d["source"]))
139
+
140
+ def tile(self, gid):
141
+ """The tile of a global id, or None (gid 0, or no tileset has it). Tiled's flip flags
142
+ (the top three bits) only change the picture."""
143
+ gid &= 0x1FFFFFFF
144
+ if not gid:
145
+ return None
146
+ for firstgid, count, tiles, _ in self.sets:
147
+ local = gid - firstgid
148
+ if 0 <= local < count:
149
+ return tiles.get(local) or Tile(local, {})
150
+ return None
151
+
152
+ def tile_by_type_and_state(self, type_, state):
153
+ for _, _, tiles, _ in self.sets:
154
+ for local in sorted(tiles):
155
+ t = tiles[local]
156
+ if t.type == type_ and t.props.get("state") == state:
157
+ return t
158
+ return None
159
+
160
+ def firstgid(self, source):
161
+ return next((f for f, _, _, s in self.sets if s == source), None)
162
+
163
+
164
+ # --- objects -------------------------------------------------------------------------
165
+ STATES = {
166
+ "Torch": ["burning", "off"], "Switch": ["left", "right"],
167
+ "Door": ["open", "closed"], "VerticalDoor": ["open", "closed"], "Grille": ["open", "closed"],
168
+ "VerticalGrille": ["open", "closed"], "Chest": ["open", "closed"], "Jug": ["unbroken", "broken"],
169
+ }
170
+ PROPAGATES = {"Torch", "Switch"} # interact() also triggers what they `controls`
171
+
172
+
173
+ class Obj:
174
+ """A game object (engine: GameObject and its subclasses)."""
175
+
176
+ def __init__(self, d, tilesets):
177
+ self.id = d.get("id")
178
+ self.name = d.get("name", "")
179
+ self.x, self.y = d.get("x", 0), d.get("y", 0)
180
+ self.width, self.height = d.get("width", TILE), d.get("height", TILE)
181
+ self.visible = d.get("visible", True)
182
+ self.props = {p["name"]: p for p in d.get("properties", []) or []}
183
+ self.tile = tilesets.tile(d.get("gid", 0)) or Tile(0, {})
184
+ type_ = d.get("type") or self.tile.type
185
+ self.type = type_
186
+ self.kind = type_
187
+ if type_ == "PatternDoor":
188
+ self.type = self.tile.type or "Door" # it looks like the door tile it was placed with
189
+ elif type_ == "Character":
190
+ self.type = "Character"
191
+ self.state = self.tile.get("state")
192
+ self.tiles = {"default": self.tile}
193
+ for state in STATES.get(self.type, []) if self.kind != "PatternDoor" else ["open", "closed"]:
194
+ t = tilesets.tile_by_type_and_state(self.type, state)
195
+ if t:
196
+ self.tiles[state] = t
197
+ self.initial_state = self.state # switches: for "all_switches"
198
+ # items: Crystal, Pebble, and a pack's own item classes (tile property `item`)
199
+ self.is_item = self.kind in ("Crystal", "Pebble") or bool(self.tile.get("item"))
200
+ value = self.props.get("value", {}).get("value") if self.is_item else None
201
+ self.value = value if isinstance(value, int) and not isinstance(value, bool) else None
202
+ if self.kind == "Guard":
203
+ self.behaviour = str(self.props.get("behaviour", {}).get("value", "patrol") or "patrol")
204
+ self.direction = str(self.props.get("direction", {}).get("value", "east") or "east")
205
+ if self.kind == "PatternDoor":
206
+ self.pattern = str(self.props.get("pattern", {}).get("value", "") or "")
207
+ self.torch_ids = [int(s) for s in str(self.props.get("torches", {}).get("value", "") or "").split(",")
208
+ if s.strip().lstrip("-").isdigit()]
209
+
210
+ def set_state(self, state):
211
+ if state in self.tiles:
212
+ self.tile = self.tiles[state]
213
+ self.state = state
214
+
215
+ def is_collision(self):
216
+ if self.is_item or self.kind == "Guard":
217
+ return False
218
+ return bool(self.tile.get("collision"))
219
+
220
+ def at(self, px, py):
221
+ return self.x <= px < self.x + self.width and self.y - self.height <= py < self.y
222
+
223
+ def interact(self, level):
224
+ if self.kind in ("PatternDoor", "Goal"):
225
+ return False
226
+ if self.type in STATES and self.kind != "Character":
227
+ before = self.state
228
+ a, b = STATES[self.type]
229
+ if self.type == "Jug":
230
+ if self.state != "broken":
231
+ self.set_state("broken")
232
+ else:
233
+ self.set_state(b if self.state == a else a)
234
+ changed = self.state != before
235
+ propagated = self._controls(level) if self.type in PROPAGATES else False
236
+ return changed or propagated
237
+ return self._controls(level)
238
+
239
+ def _controls(self, level):
240
+ prop = self.props.get("controls") # only object properties carry an id
241
+ if prop:
242
+ target = level.object_by_id(prop.get("value"))
243
+ if target:
244
+ return target.interact(level)
245
+ return False
246
+
247
+
248
+ # --- the level -------------------------------------------------------------------------
249
+ class Level:
250
+ def __init__(self, data):
251
+ props = data.get("properties", []) or []
252
+ self.properties = props
253
+ tilesets = list(data.get("tilesets", []))
254
+ inventory = self.prop("hero_inventory")
255
+ next_gid = max([1] + [t["firstgid"] for t in tilesets]) + 10000
256
+ for type_, _ in parse_start_inventory(inventory):
257
+ spec = item_tiles().get(type_)
258
+ if spec and not any(t["source"] == spec[0] for t in tilesets):
259
+ tilesets.append({"firstgid": next_gid, "source": spec[0]})
260
+ next_gid += 10000
261
+ self.tilesets = Tilesets(tilesets, self.prop("pack") or "")
262
+ self.width, self.height = data.get("width", 30), data.get("height", 20)
263
+ self.tw, self.th = data.get("tilewidth", TILE), data.get("tileheight", TILE)
264
+ self.layers = [] # (data, collision) of the tile layers
265
+ self.objects = []
266
+ for layer in data.get("layers", []):
267
+ if layer.get("type") == "tilelayer":
268
+ collision = any(p.get("name") == "collision" and p.get("type") == "bool" and p.get("value")
269
+ for p in layer.get("properties", []) or [])
270
+ self.layers.append((layer.get("data", []), collision))
271
+ elif layer.get("type") == "objectgroup":
272
+ for d in layer.get("objects", []):
273
+ if d.get("type") != "Region": # describes level variants only (variants.py)
274
+ self.objects.append(Obj(d, self.tilesets))
275
+ self.character = next((o for o in self.objects if o.name == "MainCharacter"), None)
276
+ # co-op levels (DC-T3c): more heroes, Character objects named Hero2, Hero3, ... (in that order)
277
+ extra = sorted((o for o in self.objects if o.type == "Character" and o.name[:4] == "Hero" and o.name[4:].isdigit()),
278
+ key=lambda o: int(o.name[4:]))
279
+ self.heroes = ([self.character] if self.character else []) + extra
280
+ self.goal = next((o for o in self.objects if o.type == "Goal"), None)
281
+ self.slots = self._find_slots()
282
+ self.inventory = []
283
+ self.caught = False
284
+ self.out_of_moves = False
285
+ self._give_start_inventory(inventory)
286
+ self.update()
287
+
288
+ def prop(self, name):
289
+ return next((p.get("value") for p in self.properties if p.get("name") == name), None)
290
+
291
+ def bool_prop(self, name, default=False):
292
+ p = next((p for p in self.properties if p.get("name") == name), None)
293
+ return p["value"] if p and p.get("type") == "bool" else default
294
+
295
+ def _find_slots(self):
296
+ def cell(o):
297
+ return (o.x // self.tw, (o.y - 1) // self.th)
298
+ items = sorted((o for o in self.objects if o.value is not None), key=lambda o: (cell(o)[1], cell(o)[0]))
299
+ for rank, o in enumerate(items):
300
+ o.start_rank = rank
301
+ return [cell(o) for o in items]
302
+
303
+ def _give_start_inventory(self, text):
304
+ if not self.character:
305
+ return
306
+ next_id = 1 + max([0] + [o.id or 0 for o in self.objects])
307
+ for type_, count in parse_start_inventory(text):
308
+ spec = item_tiles().get(type_)
309
+ firstgid = spec and self.tilesets.firstgid(spec[0])
310
+ if firstgid is None:
311
+ continue
312
+ for _ in range(count):
313
+ o = Obj({"gid": firstgid + spec[1], "id": next_id, "name": "", "type": type_, "visible": False,
314
+ "x": -100, "y": -100, "width": self.tw, "height": self.th}, self.tilesets)
315
+ next_id += 1
316
+ self.objects.append(o)
317
+ self.inventory.append(o)
318
+
319
+ # -- queries like the engine's Level
320
+ def object_by_id(self, id_):
321
+ return next((o for o in self.objects if o.id == id_), None)
322
+
323
+ def objects_at(self, px, py):
324
+ return [o for o in self.objects if o.at(px, py)]
325
+
326
+ def tiles_at(self, px, py):
327
+ col, row = int(px // self.tw), int(py // self.th)
328
+ if not (0 <= col < self.width and 0 <= row < self.height):
329
+ return []
330
+ out = []
331
+ for data, _ in self.layers:
332
+ t = self.tilesets.tile(data[row * self.width + col]) if row * self.width + col < len(data) else None
333
+ if t:
334
+ out.append(t)
335
+ return out
336
+
337
+ def wall_tile_at(self, px, py):
338
+ """A wall by the tiles alone: outside the map, or a tile with a local id other than 0 on a
339
+ tile layer with collision=true (engine: Level.isCollision without the objects)."""
340
+ col, row = int(px // self.tw), int(py // self.th)
341
+ if not (0 <= col < self.width and 0 <= row < self.height):
342
+ return True
343
+ for data, collision in self.layers:
344
+ if collision:
345
+ t = self.tilesets.tile(data[row * self.width + col])
346
+ if t and t.local != 0:
347
+ return True
348
+ return False
349
+
350
+ def is_collision(self, px, py):
351
+ return self.wall_tile_at(px, py) or any(o.is_collision() for o in self.objects_at(px, py))
352
+
353
+ def update(self):
354
+ """What the engine does every frame: pattern doors follow their torches."""
355
+ for o in self.objects:
356
+ if o.kind == "PatternDoor":
357
+ bits = "".join("1" if (t := self.object_by_id(i)) and t.type == "Torch" and t.state == "burning"
358
+ else "0" for i in o.torch_ids)
359
+ should_open = o.pattern != "" and bits == o.pattern
360
+ if should_open != (o.state == "open"):
361
+ o.set_state("open" if should_open else "closed")
362
+
363
+ # -- guards and the oracle (engine: Level.stepGuards, Level.oracleDirection)
364
+ def cell_of(self, o):
365
+ return (int(o.x // self.tw), int((o.y - 1) // self.th))
366
+
367
+ def walkable(self, col, row):
368
+ if not (0 <= col < self.width and 0 <= row < self.height):
369
+ return False
370
+ px, py = col * self.tw + self.tw / 2, row * self.th + self.th / 2
371
+ if self.is_collision(px, py):
372
+ return False
373
+ return not self.abyss_at(px, py)
374
+
375
+ def abyss_tile_at(self, px, py):
376
+ """An Abyss tile as the topmost tile of the field (B21: a floor over an abyss is floor)."""
377
+ tiles = self.tiles_at(px, py)
378
+ return bool(tiles) and tiles[-1].type == "Abyss"
379
+
380
+ def abyss_at(self, px, py):
381
+ """An Abyss object, or an Abyss tile as the topmost tile of the field (engine: Level.isAbyssAt)."""
382
+ return self.abyss_tile_at(px, py) or any(o.type == "Abyss" for o in self.objects_at(px, py))
383
+
384
+ def step_guards(self, hero_from, hero_to):
385
+ guards = [o for o in self.objects if o.kind == "Guard"]
386
+ caught = any(self.cell_of(g) == hero_to for g in guards)
387
+ for guard in guards:
388
+ col, row = self.cell_of(guard)
389
+ def free(c, r, guard=guard):
390
+ return self.walkable(c, r) and not any(o is not guard and self.cell_of(o) == (c, r) for o in guards)
391
+ if guard.behaviour == "chase":
392
+ dx, dy = hero_to[0] - col, hero_to[1] - row
393
+ h = "east" if dx > 0 else "west" if dx < 0 else None
394
+ v = "south" if dy > 0 else "north" if dy < 0 else None
395
+ options = [d for d in ((h, v) if abs(dx) >= abs(dy) else (v, h)) if d]
396
+ else:
397
+ options = [guard.direction, OPPOSITE[guard.direction]]
398
+ for d in options:
399
+ nc, nr = col + OFFSETS[d][0], row + OFFSETS[d][1]
400
+ if free(nc, nr):
401
+ guard.x += OFFSETS[d][0] * self.tw
402
+ guard.y += OFFSETS[d][1] * self.th
403
+ if guard.behaviour != "chase":
404
+ guard.direction = d
405
+ swapped = (col, row) == hero_to and (nc, nr) == hero_from
406
+ caught = caught or (nc, nr) == hero_to or swapped
407
+ break
408
+ return caught
409
+
410
+ def oracle_direction(self, col, row):
411
+ if not self.goal:
412
+ return None
413
+ goal = self.cell_of(self.goal)
414
+ if (col, row) == goal:
415
+ return None
416
+ first = {(col, row): None}
417
+ queue = [(col, row)]
418
+ while queue:
419
+ c, r = queue.pop(0)
420
+ for d, dx, dy in (("north", 0, -1), ("east", 1, 0), ("south", 0, 1), ("west", -1, 0)):
421
+ n = (c + dx, r + dy)
422
+ if n in first or not self.walkable(*n):
423
+ continue
424
+ step = first[(c, r)] or d
425
+ if n == goal:
426
+ return step
427
+ first[n] = step
428
+ queue.append(n)
429
+ return None
430
+
431
+ # -- win conditions
432
+ def hero_on_goal(self):
433
+ return bool(self.character and self.goal and self.character.x == self.goal.x
434
+ and self.character.y == self.goal.y)
435
+
436
+ def on_goal(self, hero):
437
+ """With one hero only the first Goal counts; in a co-op level every Goal (engine: Level.isOnGoal)."""
438
+ if len(self.heroes) <= 1:
439
+ return hero is self.character and self.hero_on_goal()
440
+ return any(o.type == "Goal" and o.x == hero.x and o.y == hero.y for o in self.objects)
441
+
442
+ def goal_distance(self, col, row):
443
+ """The amulet: Manhattan distance to the (first) goal (engine: Level.goalDistance)."""
444
+ if not self.goal:
445
+ return None
446
+ gc, gr = self.cell_of(self.goal)
447
+ return abs(gc - col) + abs(gr - row)
448
+
449
+ def items_on_slots(self):
450
+ return [[o for o in self.objects_at(c * self.tw + self.tw / 2, r * self.th + self.th / 2)
451
+ if o.value is not None and o.visible is not False] for c, r in self.slots]
452
+
453
+ def row_sorted(self):
454
+ slots = self.items_on_slots()
455
+ if any(len(items) != 1 for items in slots):
456
+ return False
457
+ values = [items[0].value for items in slots]
458
+ return all(values[i - 1] <= values[i] for i in range(1, len(values)))
459
+
460
+ def row_stable(self):
461
+ if not self.row_sorted():
462
+ return False
463
+ items = [s[0] for s in self.items_on_slots()]
464
+ return all(items[i - 1].value < items[i].value or items[i - 1].start_rank < items[i].start_rank
465
+ for i in range(1, len(items)))
466
+
467
+ def unmet(self):
468
+ wanted = [s.strip() for s in str(self.prop("win") or "").split(",") if s.strip()]
469
+ missing = []
470
+ if "all_sweets" in wanted:
471
+ left = sum(1 for o in self.objects if o.type == "Sweets" and o.visible is not False)
472
+ if left:
473
+ missing.append(f"{left} sweets")
474
+ if "all_switches" in wanted:
475
+ unflipped = sum(1 for o in self.objects if o.type == "Switch" and o.state == o.initial_state)
476
+ if unflipped:
477
+ missing.append(f"{unflipped} switches")
478
+ if "sorted" in wanted and not self.row_sorted():
479
+ missing.append("sorted row")
480
+ if "stable" in wanted and not self.row_stable():
481
+ missing.append("equal values in start order")
482
+ if "all_heroes" in wanted:
483
+ away = sum(1 for h in self.heroes if not self.on_goal(h))
484
+ if away:
485
+ missing.append(f"{away} heroes not at an exit")
486
+ return missing
487
+
488
+ def complete(self):
489
+ if self.caught or self.out_of_moves:
490
+ return False # caught by a guard on the goal field, or collapsed
491
+ return bool(self.character) and self.on_goal(self.character) and not self.unmet()
492
+
493
+
494
+ def parse_start_inventory(text):
495
+ out = []
496
+ for entry in str(text or "").split(","):
497
+ entry = entry.strip()
498
+ if entry:
499
+ type_, _, count = entry.partition("*")
500
+ try:
501
+ n = max(0, int(count.strip() or "1"))
502
+ except ValueError:
503
+ n = 0
504
+ out.append((type_.strip(), n))
505
+ return out
506
+
507
+
508
+ # --- the hero and the commands ------------------------------------------------------------
509
+ OFFSETS = {"north": (0, -1), "south": (0, 1), "west": (-1, 0), "east": (1, 0)}
510
+ OPPOSITE = {"north": "south", "south": "north", "east": "west", "west": "east"}
511
+
512
+
513
+ class Simulator:
514
+ """Answers the same commands as the game in the browser (game/src/script.js)."""
515
+
516
+ def __init__(self, level_override=None):
517
+ self.level = None
518
+ self.current = None # the hero the current command is for (co-op levels)
519
+ self.last_level_data = None
520
+ self.running = False
521
+ self.falling = False
522
+ # grading: every level the script loads is replaced by this file (DUNGEONCODER_LEVEL)
523
+ self.level_override = level_override if level_override is not None else os.environ.get("DUNGEONCODER_LEVEL") or None
524
+
525
+ # -- the hero's state lives in the character object: x, y (bottom-left) and its tile state
526
+ @property
527
+ def hero(self):
528
+ return self.current or self.level.character
529
+
530
+ def direction(self):
531
+ parts = str(self.hero.state or "").split("_")
532
+ return parts[1] if len(parts) >= 3 else "south"
533
+
534
+ def _set_direction(self, direction):
535
+ parts = str(self.hero.state or "standing_south_7").split("_")
536
+ number = parts[2] if len(parts) >= 3 else "7"
537
+ self.hero.state = f"standing_{direction}_{number}"
538
+
539
+ def _centre(self):
540
+ return self.hero.x + self.level.tw / 2, self.hero.y - self.level.th / 2
541
+
542
+ def _front(self, distance=1):
543
+ cx, cy = self._centre()
544
+ dx, dy = OFFSETS[self.direction()]
545
+ return cx + dx * self.level.tw * distance, cy + dy * self.level.th * distance
546
+
547
+ def _in_front(self, name):
548
+ px, py = self._front()
549
+ return (any(o.type == name for o in self.level.objects_at(px, py))
550
+ or any(t.type == name for t in self.level.tiles_at(px, py)))
551
+
552
+ def _tick(self):
553
+ """After every command: pattern doors, then falling, being caught, collapsing or completion end the level."""
554
+ self.level.update()
555
+ if self.falling or self.level.caught or self.level.out_of_moves or self.level.complete():
556
+ self.running = False
557
+
558
+ def _count(self, name):
559
+ self.stats[name] += 1
560
+
561
+ # -- commands
562
+ def load_level(self, data):
563
+ if self.level_override:
564
+ data = self._read_level(self.level_override)
565
+ try:
566
+ self.level = Level(data)
567
+ except MissingTileset:
568
+ raise # the game keeps the old level and refuses with a message
569
+ except Exception as err: # noqa: BLE001 - the game answers a broken level with an error
570
+ self.level = None
571
+ raise RuntimeError(f"Parsing level failed: {err}") from err
572
+ self.last_level_data = data
573
+ self.stats = dict.fromkeys(["moves", "turns", "bumps", "keyboard_moves", "interactions", "pickups",
574
+ "drops", "sensor_calls", "reads", "questions"], 0)
575
+ self.falling = False
576
+ self.running = True
577
+ self._tick()
578
+ return True
579
+
580
+ @staticmethod
581
+ def _read_level(path):
582
+ """The level that replaces every loaded one (grading); a level with variants becomes the
583
+ variant DUNGEONCODER_SEED names (else the level as drawn)."""
584
+ from .variants import prepare
585
+ if path.endswith(".txt"):
586
+ from .asciimap import level_from_file
587
+ data = level_from_file(path)
588
+ else:
589
+ with open(path, encoding="utf-8") as f:
590
+ data = json.load(f)
591
+ seed = os.environ.get("DUNGEONCODER_SEED", "").strip()
592
+ return prepare(data, int(seed) if seed.lstrip("-").isdigit() else None)[0]
593
+
594
+ def handle(self, method, params):
595
+ """(success, message, result) like the webview's handlers."""
596
+ if method == "load_level":
597
+ try:
598
+ return (True, "Parsing level successful.", self.load_level(params))
599
+ except MissingTileset as err:
600
+ return (False, str(err), False)
601
+ if method == "reset_level":
602
+ if self.last_level_data is None:
603
+ return (False, "No level loaded.", False)
604
+ return (True, "Level reset.", self.load_level(self.last_level_data))
605
+ if self.level is None or self.level.character is None:
606
+ raise RuntimeError("No level loaded.")
607
+ index = (params or {}).get("hero", 0) or 0
608
+ if index >= len(self.level.heroes):
609
+ count = len(self.level.heroes)
610
+ return (False, f"There is no hero {index} in this level (it has {count}: 0 to {count - 1}).", None)
611
+ self.current = self.level.heroes[index]
612
+ if method in ("move", "turn_left", "interact", "pickup", "drop") and not self.running:
613
+ return (False, "The level is over (goal reached or game over). Load a level to continue.", False)
614
+ result = getattr(self, "cmd_" + method)(params or {})
615
+ if method in ("move", "turn_left", "interact", "pickup", "drop"):
616
+ self._tick()
617
+ return result
618
+
619
+ def moves_left(self):
620
+ budget = self.level.prop("max_moves")
621
+ if not isinstance(budget, int) or isinstance(budget, bool):
622
+ return None
623
+ return max(0, budget - self.stats["moves"] - self.stats["bumps"])
624
+
625
+ def _hero_at(self, col, row):
626
+ """Another hero stands on the field (heroes block each other like walls)."""
627
+ return any(h is not self.hero and self.level.cell_of(h) == (col, row) for h in self.level.heroes)
628
+
629
+ def cmd_move(self, _):
630
+ if self.moves_left() == 0:
631
+ self.level.out_of_moves = True
632
+ return (False, f"No moves left: this level allows {self.level.prop('max_moves')} move() calls "
633
+ "(map property max_moves). The hero collapses.", False)
634
+ px, py = self._front()
635
+ start = self.level.cell_of(self.hero)
636
+ if self.level.is_collision(px, py) or self._hero_at(int(px // self.level.tw), int(py // self.level.th)):
637
+ if self.level.step_guards(start, start):
638
+ self.level.caught = True
639
+ self._count("bumps")
640
+ return (False, "Moving failed. Way is blocked.", False)
641
+ target = (int(px // self.level.tw), int(py // self.level.th))
642
+ if self.level.step_guards(start, target):
643
+ self.level.caught = True
644
+ abyss = self.level.abyss_at(px, py)
645
+ dx, dy = OFFSETS[self.direction()]
646
+ self.hero.x += dx * self.level.tw
647
+ self.hero.y += dy * self.level.th
648
+ if abyss:
649
+ self.falling = True
650
+ self._count("moves")
651
+ return (True, "Hero moved successfully.", True)
652
+
653
+ def cmd_turn_left(self, _):
654
+ self._set_direction(DIRECTIONS_LEFT[self.direction()])
655
+ self._count("turns")
656
+ return (True, "Hero turned left successfully.", True)
657
+
658
+ def cmd_interact(self, _):
659
+ self._count("interactions")
660
+ reacted = False
661
+ for o in self.level.objects_at(*self._front()):
662
+ reacted = o.interact(self.level) or reacted
663
+ return (reacted, "Hero interacted successfully." if reacted else "Hero could not interact.", reacted)
664
+
665
+ def cmd_configure(self, params):
666
+ number = params.get("typeNumber")
667
+ ok = isinstance(number, int) and 0 <= number <= 15
668
+ if ok:
669
+ self._set_direction(self.direction())
670
+ self.hero.state = "_".join(self.hero.state.split("_")[:2] + [str(number)])
671
+ return (ok, "Hero configured successfully." if ok else "Unable to configure hero.", ok)
672
+
673
+ def cmd_set_pace(self, params):
674
+ factor = params.get("factor")
675
+ ok = isinstance(factor, (int, float)) and not isinstance(factor, bool) and factor > 0
676
+ return (ok, "Pace changed." if ok else "Could not change pace.", ok)
677
+
678
+ def _sensor(self, value):
679
+ self._count("sensor_calls")
680
+ return (True, "", value)
681
+
682
+ def cmd_is_facing_north(self, _):
683
+ return self._sensor(self.direction() == "north")
684
+
685
+ def cmd_is_at_goal(self, _):
686
+ return self._sensor(self.level.on_goal(self.hero))
687
+
688
+ def cmd_is_collision_in_front(self, _):
689
+ px, py = self._front()
690
+ return self._sensor(self.level.is_collision(px, py) or self._hero_at(int(px // self.level.tw), int(py // self.level.th)))
691
+
692
+ def cmd_sense_goal(self, _):
693
+ if not self.level.bool_prop("amulett"):
694
+ return (False, "There is no amulet in this level (map property amulett).", None)
695
+ return self._sensor(self.level.goal_distance(*self.level.cell_of(self.hero)))
696
+
697
+ def cmd_is_abyss_in_front(self, _):
698
+ return self._sensor(self.level.abyss_at(*self._front()))
699
+
700
+ def cmd_is_torch_in_front(self, _):
701
+ return self._sensor(self._in_front("Torch"))
702
+
703
+ def cmd_is_switch_in_front(self, _):
704
+ return self._sensor(self._in_front("Switch"))
705
+
706
+ def cmd_is_enemy_in_front(self, _):
707
+ return self._sensor(self._in_front("Guard"))
708
+
709
+ def cmd_ask_oracle(self, _):
710
+ if not self.level.bool_prop("orakel"):
711
+ return (False, "There is no oracle in this level (map property orakel).", None)
712
+ self._count("questions")
713
+ return (True, "", self.level.oracle_direction(*self.level.cell_of(self.hero)))
714
+
715
+ def cmd_get_items_at_position(self, _):
716
+ self._count("sensor_calls")
717
+ return (True, "", [o.type for o in self.level.objects_at(*self._centre()) if o.type != "Character"])
718
+
719
+ def cmd_get_inventory(self, _):
720
+ return (True, "", [o.type for o in self.level.inventory])
721
+
722
+ def cmd_get_statistics(self, _):
723
+ return (True, "Statistics of the current level.",
724
+ {**self.stats, "at_goal": self.level.on_goal(self.hero),
725
+ "game_over": self.falling or self.level.caught or self.level.out_of_moves,
726
+ "level_complete": self.level.complete(),
727
+ "missing": self.level.unmet(), "moves_left": self.moves_left(), "heroes": len(self.level.heroes)})
728
+
729
+ def _inventory_full(self):
730
+ size = self.level.prop("inventory_size")
731
+ return isinstance(size, int) and not isinstance(size, bool) and len(self.level.inventory) >= size
732
+
733
+ def cmd_pickup(self, params):
734
+ name = params.get("name")
735
+ if self._inventory_full():
736
+ return (False, f"The inventory is full (the level allows {self.level.prop('inventory_size')} item(s)). "
737
+ "Drop something first.", False)
738
+ for o in self.level.objects_at(*self._centre()):
739
+ if o.type == name:
740
+ self.level.inventory.append(o)
741
+ o.x, o.y, o.visible = -100, -100, False
742
+ self._count("pickups")
743
+ return (True, f'Picked up item "{name}".', True)
744
+ return (False, f'Item "{name}" not found at current location.', False)
745
+
746
+ def cmd_drop(self, params):
747
+ name = params.get("name")
748
+ for i, o in enumerate(self.level.inventory):
749
+ if o.type == name:
750
+ o.x, o.y, o.visible = self.hero.x, self.hero.y, True
751
+ del self.level.inventory[i]
752
+ self._count("drops")
753
+ return (True, f'Successfully dropped item "{name}".', True)
754
+ return (False, f'Item "{name}" not in inventory.', False)
755
+
756
+ def _value_at(self, px, py):
757
+ for o in self.level.objects_at(px, py):
758
+ if o.type != "Character" and o.visible is not False and o.value is not None:
759
+ return o.value
760
+ return None
761
+
762
+ def cmd_read_item_value(self, _):
763
+ self._count("reads")
764
+ return (True, "", self._value_at(*self._centre()))
765
+
766
+ def cmd_peek_item_value(self, params):
767
+ if not self.level.bool_prop("fernrohr"):
768
+ return (False, "This level has no Fernrohr (map property fernrohr), so values can only be read on "
769
+ "the hero's own field.", None)
770
+ distance = params.get("distance")
771
+ if not isinstance(distance, int) or isinstance(distance, bool) or distance < 1:
772
+ return (False, f"distance must be a whole number of at least 1, not {distance!r}.", None)
773
+ self._count("reads")
774
+ px, py = self._front(distance)
775
+ col, row = int(px // self.level.tw), int(py // self.level.th)
776
+ if not (0 <= col < self.level.width and 0 <= row < self.level.height):
777
+ return (True, "", None)
778
+ return (True, "", self._value_at(px, py))
779
+
780
+ # -- the HTTP answer the extension host would send (src/extension.ts)
781
+ def request(self, method, params):
782
+ """(HTTP status, JSON body) for a command, like the extension host."""
783
+ if method == "configure" and not (isinstance(params.get("typeNumber"), int)
784
+ and 0 <= params["typeNumber"] <= 15):
785
+ return 400, {"message": "request/body/typeNumber must be between 0 and 15"}
786
+ try:
787
+ success, message, result = self.handle(method, params)
788
+ except Exception as err: # noqa: BLE001 - the game reports any failure as an error
789
+ return 500, {"status": "error", "message": str(err)}
790
+ if success:
791
+ return 200, {"status": "success", "message": message, "result": result}
792
+ return 500, {"status": "error", "message": message}