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
@@ -0,0 +1,482 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import tempfile
5
+
6
+ try:
7
+ import httpx
8
+ except ImportError: # the most common first problem: the script runs with a Python without the packages
9
+ raise ImportError(
10
+ f"The package 'httpx' is missing in the Python that runs this program ({sys.executable}). "
11
+ "Usually VS Code uses another Python than your environment: choose it with "
12
+ "'Python: Select Interpreter' (the entry with .venv), or run in the terminal: "
13
+ "pip install -r requirements.txt (or: pip install -r dungeoncoder/requirements.txt), "
14
+ "then check the setup with: python -m dungeoncoder") from None
15
+
16
+ from . import asciimap, generator, trace, variants
17
+ from ._api_config import BASE_URL as _CONFIGURED_BASE_URL, HOST
18
+ from ._generated import Client
19
+ from ._generated.errors import UnexpectedStatus
20
+ from ._generated.models.api_error_response import ApiErrorResponse
21
+ from ._generated.models.configure_params import ConfigureParams
22
+ from ._generated.models.distance_param import DistanceParam
23
+ from ._generated.models.name_param import NameParam
24
+ from ._generated.models.pace_params import PaceParams
25
+ from ._generated.models.tiled_level import TiledLevel
26
+ from ._generated.api.hero import (
27
+ configure as _configure_api,
28
+ drop as _drop_api,
29
+ get_inventory as _get_inventory_api,
30
+ get_items_at_position as _get_items_at_position_api,
31
+ interact as _interact_api,
32
+ is_abyss_in_front as _is_abyss_in_front_api,
33
+ ask_oracle as _ask_oracle_api,
34
+ is_at_goal as _is_at_goal_api,
35
+ is_enemy_in_front as _is_enemy_in_front_api,
36
+ is_collision_in_front as _is_collision_in_front_api,
37
+ is_facing_north as _is_facing_north_api,
38
+ sense_goal as _sense_goal_api,
39
+ is_switch_in_front as _is_switch_in_front_api,
40
+ is_torch_in_front as _is_torch_in_front_api,
41
+ move as _move_api,
42
+ peek_item_value as _peek_item_value_api,
43
+ pickup as _pickup_api,
44
+ read_item_value as _read_item_value_api,
45
+ set_pace as _set_pace_api,
46
+ turn_left as _turn_left_api,
47
+ )
48
+ from ._generated.api.level import load_level as _load_level_api, reset_level as _reset_level_api
49
+ from ._generated.api.game import get_statistics as _get_statistics_api, get_version as _get_version_api
50
+
51
+ os.environ["NO_PROXY"] = HOST
52
+
53
+
54
+ def _discover_base_url() -> str:
55
+ """Where the game listens: DUNGEONCODER_PORT, else the port the extension wrote to
56
+ .dungeoncoder-port in the working folder or a folder above it (setting dungeonCoder.port),
57
+ else the default from api/openapi.yaml."""
58
+ port = os.environ.get("DUNGEONCODER_PORT", "").strip()
59
+ if not port.isdigit():
60
+ port = ""
61
+ folder = os.getcwd()
62
+ while True:
63
+ candidate = os.path.join(folder, ".dungeoncoder-port")
64
+ if os.path.isfile(candidate):
65
+ with open(candidate, encoding="utf-8") as f:
66
+ port = f.read().strip()
67
+ break
68
+ parent = os.path.dirname(folder)
69
+ if parent == folder:
70
+ break
71
+ folder = parent
72
+ return f"http://{HOST}:{port}" if port.isdigit() else _CONFIGURED_BASE_URL
73
+
74
+
75
+ DEFAULT_BASE_URL = _discover_base_url()
76
+
77
+ # move() and turn_left() return only after the animation: a step takes
78
+ # _STEP_SECONDS / pace (game/src/character.js). The server gives up on a step
79
+ # after 5 s + step time (src/extension.ts), so the client waits a bit longer.
80
+ _STEP_SECONDS = 0.5
81
+ _ACTION_TIMEOUT_SLACK = 6.0
82
+
83
+
84
+ def _extract_message(response) -> str:
85
+ """Best-effort extraction of the server's error message from a raw HTTP response."""
86
+ try:
87
+ return json.loads(response.content).get("message", "")
88
+ except (ValueError, AttributeError):
89
+ return ""
90
+
91
+
92
+ _simulator = None # a sim.Simulator while scripts run without the game (use_simulator)
93
+
94
+
95
+ def use_simulator(on: bool = True) -> None:
96
+ """Runs everything in the pure-Python simulator (dungeoncoder/sim.py) instead of the
97
+ game in VS Code: same classes, same answers, no picture, steps take no time.
98
+ Also switched on by the environment variable DUNGEONCODER_SIM=1."""
99
+ global _simulator
100
+ from .sim import Simulator
101
+ _simulator = Simulator() if on else None
102
+
103
+
104
+ def _simulated(client, sync_detailed_fn, body=None, hero=None):
105
+ """What the generated call would return if the extension host had answered."""
106
+ module = sys.modules[sync_detailed_fn.__module__]
107
+ method = sync_detailed_fn.__module__.rsplit(".", 1)[1]
108
+ params = body.to_dict() if body is not None else {}
109
+ if hero:
110
+ params["hero"] = hero
111
+ status, payload = _simulator.request(method, params)
112
+ return module._build_response(client=client, response=httpx.Response(status, json=payload))
113
+
114
+
115
+ _version_checked = False
116
+
117
+
118
+ def _check_version(base_url):
119
+ """Once per program: does this copy of the package fit the running extension? A copied
120
+ `dungeoncoder` folder ages with its exercise folder, and after an update of the extension
121
+ it fails in odd ways (a method that doesn't exist yet). Only warns, never stops."""
122
+ global _version_checked
123
+ if _version_checked or _simulator is not None:
124
+ return
125
+ _version_checked = True
126
+ from . import __version__
127
+ try:
128
+ response = _get_version_api.sync_detailed(client=Client(base_url=base_url, timeout=httpx.Timeout(1)))
129
+ except Exception:
130
+ return # no game running: loading the level says so
131
+ if response.status_code == 404:
132
+ print(f"Warning: the Dungeon Coder extension in VS Code is older than this Python package "
133
+ f"(dungeoncoder {__version__}): some commands may be missing. Update the extension, "
134
+ f"or check that the right Dungeon Coder runs (Extensions view).")
135
+ return
136
+ extension = getattr(getattr(response.parsed, "result", None), "extension", None)
137
+ if extension and extension.split(".")[:2] != __version__.split(".")[:2]:
138
+ print(f"Warning: this Python package (dungeoncoder {__version__}, {os.path.dirname(os.path.abspath(__file__))}) "
139
+ f"doesn't fit the Dungeon Coder extension ({extension}). Run \"Dungeon Coder: Copy Python API "
140
+ f"to workspace\" again, or: pip install --upgrade dungeoncoder")
141
+
142
+
143
+ def _call(client: Client, sync_detailed_fn, *, default, explain_refusal=False, **kwargs):
144
+ """
145
+ Runs a generated API call, translating transport/validation failures and
146
+ ordinary negative business outcomes (e.g. "move blocked by a wall") into
147
+ `default`, the same way the hand-rolled HTTP client used to. On an
148
+ unexpected response, the server's own error message is printed so a
149
+ student can see *why* a call failed (e.g. a bad argument type).
150
+ """
151
+ result = _call_unrecorded(client, sync_detailed_fn, default=default, explain_refusal=explain_refusal, **kwargs)
152
+ body = kwargs.get("body")
153
+ method = sync_detailed_fn.__module__.rsplit(".", 1)[1]
154
+ params = body.to_dict() if body is not None else None
155
+ if kwargs.get("hero"):
156
+ params = {**(params or {}), "hero": kwargs["hero"]}
157
+ if method == "load_level" and _simulator is not None and _simulator.level_override:
158
+ params = _simulator.last_level_data # grading: the level that was really played
159
+ trace.loading(_simulator.level_override)
160
+ trace.record(method, params, result)
161
+ return result
162
+
163
+
164
+ def _call_unrecorded(client: Client, sync_detailed_fn, *, default, explain_refusal=False, **kwargs):
165
+ try:
166
+ if _simulator is not None:
167
+ response = _simulated(client, sync_detailed_fn, **kwargs)
168
+ else:
169
+ response = sync_detailed_fn(client=client, **kwargs)
170
+ except UnexpectedStatus as err:
171
+ print(f"API error: {_extract_message(err) or err}")
172
+ return default
173
+ except httpx.ConnectError:
174
+ print("Connection error occurred. Did you start the Dungeon Coder Plugin? "
175
+ f"(looked for the game at {getattr(client, '_base_url', DEFAULT_BASE_URL)})")
176
+ return default
177
+ except httpx.TimeoutException:
178
+ print("Timeout error occurred. Server took too long to respond.")
179
+ return default
180
+ except Exception as err:
181
+ print(f"An unknown error occurred: {err}")
182
+ return default
183
+
184
+ parsed = response.parsed
185
+ if isinstance(parsed, ApiErrorResponse):
186
+ # explain_refusal: True prints every refusal, a string only those starting with it
187
+ if explain_refusal is True or (explain_refusal and str(parsed.message).startswith(explain_refusal)):
188
+ print(parsed.message)
189
+ return default
190
+ if parsed is None:
191
+ message = _extract_message(response) or f"Unexpected response (HTTP {response.status_code})."
192
+ print(f"API error: {message}")
193
+ return default
194
+ return parsed.result
195
+
196
+
197
+ class Hero:
198
+ """
199
+ A class to control the hero's actions and get its state.
200
+ """
201
+ BASE_URL = DEFAULT_BASE_URL
202
+
203
+ def __init__(self, base_url: str = BASE_URL, index: int = 0):
204
+ self._base_url = base_url
205
+ self._client = Client(base_url=base_url, timeout=httpx.Timeout(1))
206
+ self._pace = 1.0
207
+ self._action_client = self._make_action_client()
208
+ self._index = index # which hero in a co-op level (0 = MainCharacter)
209
+
210
+ def _call(self, client, fn, **kwargs):
211
+ """_call for this hero: heroes after the first one send their index (query parameter hero)."""
212
+ return _call(client, fn, **kwargs, **({"hero": self._index} if self._index else {}))
213
+
214
+ def _make_action_client(self) -> Client:
215
+ """Client for requests that wait for an animation; its timeout follows the pace."""
216
+ read_timeout = _ACTION_TIMEOUT_SLACK + _STEP_SECONDS / self._pace
217
+ return Client(base_url=self._base_url, timeout=httpx.Timeout(1, read=read_timeout))
218
+
219
+ def configure(self, name: str, typeNumber: int) -> bool:
220
+ return self._call(self._client, _configure_api.sync_detailed, default=False,
221
+ body=ConfigureParams(name=name, type_number=typeNumber))
222
+
223
+ def move(self) -> bool:
224
+ """Moves the hero one field forward. Returns False if the way is blocked (a wall, or
225
+ another hero), or if a level with a move budget (max_moves) has no moves left."""
226
+ return self._call(self._action_client, _move_api.sync_detailed, default=False, explain_refusal="No moves left")
227
+
228
+ def turn_left(self) -> bool:
229
+ """Turns the hero 90 degrees to the left."""
230
+ return self._call(self._action_client, _turn_left_api.sync_detailed, default=False)
231
+
232
+ def interact(self) -> bool:
233
+ """Interacts with the object in front of the hero (torch, switch, chest, ...).
234
+ Returns True if something reacted, False if there is nothing to interact with."""
235
+ return self._call(self._client, _interact_api.sync_detailed, default=False)
236
+
237
+ def is_collision_in_front(self) -> bool:
238
+ """Checks if there is a collision in front of the hero."""
239
+ return self._call(self._client, _is_collision_in_front_api.sync_detailed, default=False)
240
+
241
+ def is_switch_in_front(self) -> bool:
242
+ """Checks if there is a switch in front of the hero."""
243
+ return self._call(self._client, _is_switch_in_front_api.sync_detailed, default=False)
244
+
245
+ def is_facing_north(self) -> bool:
246
+ """Checks if the hero is facing north."""
247
+ return self._call(self._client, _is_facing_north_api.sync_detailed, default=False)
248
+
249
+ def is_abyss_in_front(self) -> bool:
250
+ """Checks if there is an abyss in front of the hero."""
251
+ return self._call(self._client, _is_abyss_in_front_api.sync_detailed, default=False)
252
+
253
+ def is_torch_in_front(self) -> bool:
254
+ """Checks if there is a torch in front of the hero."""
255
+ return self._call(self._client, _is_torch_in_front_api.sync_detailed, default=False)
256
+
257
+ def sense_goal(self) -> int | None:
258
+ """The amulet: how many fields the exit is away as the crow flies (columns plus rows,
259
+ walls don't count), or None. Only levels with an amulet answer."""
260
+ return self._call(self._client, _sense_goal_api.sync_detailed, default=None, explain_refusal=True)
261
+
262
+ def is_at_goal(self) -> bool:
263
+ """Checks if the hero stands on the goal field. Some levels also need
264
+ win conditions (e.g. all sweets collected): see Game.get_statistics()."""
265
+ return self._call(self._client, _is_at_goal_api.sync_detailed, default=False)
266
+
267
+ def get_items_at_position(self) -> list[str]:
268
+ """Returns the items at the hero's current position."""
269
+ return self._call(self._client, _get_items_at_position_api.sync_detailed, default=[])
270
+
271
+ def get_inventory(self) -> list[str]:
272
+ """Returns the list of items in the hero's inventory."""
273
+ return self._call(self._client, _get_inventory_api.sync_detailed, default=[])
274
+
275
+ def is_enemy_in_front(self) -> bool:
276
+ """Checks if a guard stands on the field in front of the hero."""
277
+ return self._call(self._client, _is_enemy_in_front_api.sync_detailed, default=False)
278
+
279
+ def ask_oracle(self) -> str | None:
280
+ """Asks the oracle the way to the exit: the direction of the first step of a
281
+ shortest way ("north", "east", "south" or "west"), or None if the hero stands
282
+ on the exit or there is no way. Only levels with an oracle answer."""
283
+ return self._call(self._client, _ask_oracle_api.sync_detailed, default=None, explain_refusal=True)
284
+
285
+ def read_item_value(self) -> int | None:
286
+ """Returns the value of the item on the hero's field, e.g. a crystal's weight,
287
+ or None if no item with a value lies there. Values are never shown on screen."""
288
+ return self._call(self._client, _read_item_value_api.sync_detailed, default=None)
289
+
290
+ def peek_item_value(self, distance: int) -> int | None:
291
+ """Returns the value of the item `distance` fields ahead in the direction the
292
+ hero faces (1 = the field in front), or None if there is none. Only works in
293
+ levels that have a Fernrohr (telescope)."""
294
+ if type(distance) != int or distance < 1:
295
+ print("Error: distance must be a whole number of at least 1.")
296
+ return None
297
+ return self._call(self._client, _peek_item_value_api.sync_detailed, default=None,
298
+ explain_refusal=True, body=DistanceParam(distance=distance))
299
+
300
+ def pickup(self, name: str) -> bool:
301
+ """Picks up the item with provided name. Use get_items_at_position()
302
+ to check what can be picked up at the current location."""
303
+ if type(name) != str:
304
+ print("Error: You have to pass a single string with the item to pickup.")
305
+ return False
306
+ return self._call(self._client, _pickup_api.sync_detailed, default=False, body=NameParam(name=name),
307
+ explain_refusal="The inventory is full")
308
+
309
+ def drop(self, name: str) -> bool:
310
+ """Drops the item with provided name. Use get_inventory()
311
+ to check what can be dropped."""
312
+ if type(name) != str:
313
+ print("Error: You have to pass a single string with the item to drop.")
314
+ return False
315
+ return self._call(self._client, _drop_api.sync_detailed, default=False, body=NameParam(name=name))
316
+
317
+ def set_pace(self, factor: float) -> bool:
318
+ """Sets the hero's speed: 1 is normal, 2 twice as fast, 0.5 half as fast."""
319
+ if type(factor) != float and type(factor) != int:
320
+ print("Error: Factor has to be of type 'int' or 'float'.")
321
+ return False
322
+ if factor <= 0:
323
+ print("Error: Factor has to be greater than 0.")
324
+ return False
325
+ ok = self._call(self._client, _set_pace_api.sync_detailed, default=False, body=PaceParams(factor=factor))
326
+ if ok:
327
+ self._pace = factor
328
+ self._action_client = self._make_action_client()
329
+ return ok
330
+
331
+
332
+ class Game:
333
+ """
334
+ A class to interact with the hero game API.
335
+
336
+ Attributes:
337
+ hero: An instance of Hero, which handles hero-related commands.
338
+ level: An instance of Level, which handles level-related commands.
339
+ """
340
+
341
+ BASE_URL = DEFAULT_BASE_URL
342
+
343
+ def __init__(self, level_file, seed: int | None = None):
344
+ """Loads the level. `seed` picks a variant of a level that has some (objects that may
345
+ lie anywhere in a region, see variants.py): the same seed, the same variant."""
346
+ _check_version(self.BASE_URL)
347
+ if seed is not None and (type(seed) != int):
348
+ print("Error: seed must be a whole number.")
349
+ sys.exit(1)
350
+ self.__level = self.Level(self.BASE_URL)
351
+ try:
352
+ trace.loading(level_file)
353
+ loaded = self.__level.load(level_file, seed)
354
+ except FileNotFoundError:
355
+ print(f"Error: Level file '{level_file}' not found. "
356
+ f"Please check the path and that you opened the right folder in VS Code "
357
+ f"(current folder: {os.getcwd()}).")
358
+ sys.exit(1)
359
+ except json.JSONDecodeError as err:
360
+ print(f"Error: Level file '{level_file}' is not valid JSON: {err}")
361
+ sys.exit(1)
362
+ except asciimap.MapError as err:
363
+ print(f"Error: Map file '{level_file}': {err}")
364
+ sys.exit(1)
365
+ except ValueError as err: # a variant that doesn't fit its region
366
+ print(f"Error: Level '{level_file}', seed {seed}: {err}")
367
+ sys.exit(1)
368
+ if not loaded:
369
+ print(f"Error: Level '{level_file}' could not be loaded (see the message above). "
370
+ f"Did you start Dungeon Coder (\"Dungeon Coder: Enter the dungeon\")?")
371
+ sys.exit(1)
372
+
373
+ self.__hero = Hero(self.BASE_URL)
374
+ self.__heroes = None
375
+ self.seed = self.__level.seed
376
+ """The seed of this level (a generated level or a variant), or None."""
377
+
378
+ @classmethod
379
+ def generate(cls, seed: int | None = None, kind: str = "maze", **options) -> "Game":
380
+ """Loads a generated level; the same seed always gives the same level.
381
+ Without a seed, a new level each time: its seed is printed and kept in game.seed.
382
+
383
+ Example: Game.generate(seed=7, width=21, height=15, loops=3, fog="dark")
384
+ kind "maze": width and height odd (default 15x11), loops = extra openings
385
+ (cycles and free-standing walls); style = the text-map style (default "maze",
386
+ from the course's asset pack); other keywords become map properties.
387
+ The map text is available as generator.maze_text(seed, ...)."""
388
+ if seed is None:
389
+ seed, _ = variants.random_seed()
390
+ print(f"Level {kind} no. {seed} (the same again: Game.generate(seed={seed}))")
391
+ try:
392
+ text = generator.generate_text(seed, kind, **options)
393
+ except asciimap.MapError as err:
394
+ print(f"Error: Game.generate: {err}")
395
+ sys.exit(1)
396
+ path = os.path.join(tempfile.mkdtemp(prefix="dungeoncoder-"), f"{kind}_{seed}.txt")
397
+ with open(path, "w", encoding="utf-8") as f:
398
+ f.write(text)
399
+ return cls(path)
400
+
401
+ def get_hero(self):
402
+ return self.__hero
403
+
404
+ def get_heroes(self) -> list:
405
+ """All heroes of the level, the first one is get_hero(). A co-op level has several;
406
+ each moves on its own, and heroes block each other like walls."""
407
+ if self.__heroes is None:
408
+ count = self.get_statistics().get("heroes", 1) or 1
409
+ self.__heroes = [self.__hero] + [Hero(self.BASE_URL, i) for i in range(1, count)]
410
+ return self.__heroes
411
+
412
+ def save_trace(self, path: str) -> bool:
413
+ """Writes every call made on this level so far, with what came back, to a JSON file
414
+ (a trace: `python -m dungeoncoder replay FILE` plays it again in the game)."""
415
+ if not trace.runs:
416
+ print("Error: no level loaded, nothing to save.")
417
+ return False
418
+ self.get_statistics()
419
+ trace.write(path, [trace.runs[-1]])
420
+ return True
421
+
422
+ def get_statistics(self) -> dict:
423
+ """Counters of the current level since it was loaded, e.g.
424
+ {"moves": 12, "turns": 5, "bumps": 1, "keyboard_moves": 0, "interactions": 2,
425
+ "pickups": 0, "drops": 0, "sensor_calls": 30, "at_goal": False,
426
+ "level_complete": False, "missing": ["3 sweets"], "moves_left": None, "heroes": 1}.
427
+ "missing" lists the level's win conditions that are not met yet; "moves_left" the
428
+ move() calls left in a level with a budget (max_moves)."""
429
+ result = _call(self.__level._client, _get_statistics_api.sync_detailed, default=None)
430
+ return result.to_dict() if result is not None else {}
431
+
432
+ class Level:
433
+ """
434
+ A class to manage game levels.
435
+ """
436
+ def __init__(self, base_url):
437
+ self._client = Client(base_url=base_url, timeout=httpx.Timeout(1))
438
+ self.seed = None
439
+
440
+ def load(self, filename, seed=None):
441
+ """
442
+ Loads a level from a Tiled JSON file, or builds it from an ASCII map
443
+ (a file ending in .txt, see asciimap.py for the format).
444
+
445
+ Args:
446
+ filename (str): The path to the level file.
447
+ """
448
+ if not os.path.exists(filename):
449
+ raise FileNotFoundError(filename)
450
+
451
+ if filename.endswith(".txt"):
452
+ with open(filename, encoding="utf-8") as f:
453
+ text = f.read()
454
+ header, _ = asciimap.parse_text(text)
455
+ level_data = asciimap.level_from_text(text)
456
+ random_recipe = "generate" in header and header.get("seed", ["random"])[0].lower() == "random"
457
+ else:
458
+ with open(filename, 'r') as f:
459
+ level_data = json.load(f)
460
+ random_recipe = False
461
+ level_data, self.seed, chosen = variants.prepare(level_data, seed)
462
+ if self.seed is None: # a generated level carries its seed as a map property
463
+ self.seed = next((p.get("value") for p in level_data.get("properties", []) or []
464
+ if p.get("name") == "seed" and isinstance(p.get("value"), int)), None)
465
+ if random_recipe:
466
+ self.seed = next((p.get("value") for p in level_data.get("properties", []) if p.get("name") == "seed"), None)
467
+ print(f"Level no. {self.seed} (the same again: 'seed: {self.seed}' in {os.path.basename(filename)}, "
468
+ f"or DUNGEONCODER_SEED={self.seed})")
469
+ elif chosen:
470
+ print(f"Level variant {self.seed} (the same again: Game(\"{filename}\", seed={self.seed}))")
471
+
472
+ # a refused level (e.g. a missing asset pack) prints the game's explanation
473
+ return _call(self._client, _load_level_api.sync_detailed, default=False,
474
+ explain_refusal=True, body=TiledLevel.from_dict(level_data))
475
+
476
+ def reset(self):
477
+ """Resets the current level."""
478
+ return _call(self._client, _reset_level_api.sync_detailed, default=False)
479
+
480
+
481
+ if os.environ.get("DUNGEONCODER_SIM", "").strip() not in ("", "0", "false", "False"):
482
+ use_simulator()