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.
- dungeoncoder/__init__.py +4 -0
- dungeoncoder/__main__.py +254 -0
- dungeoncoder/_api_config.py +6 -0
- dungeoncoder/_generated/__init__.py +8 -0
- dungeoncoder/_generated/api/__init__.py +1 -0
- dungeoncoder/_generated/api/game/__init__.py +1 -0
- dungeoncoder/_generated/api/game/get_statistics.py +158 -0
- dungeoncoder/_generated/api/game/get_version.py +166 -0
- dungeoncoder/_generated/api/hero/__init__.py +1 -0
- dungeoncoder/_generated/api/hero/ask_oracle.py +192 -0
- dungeoncoder/_generated/api/hero/configure.py +207 -0
- dungeoncoder/_generated/api/hero/drop.py +207 -0
- dungeoncoder/_generated/api/hero/get_inventory.py +188 -0
- dungeoncoder/_generated/api/hero/get_items_at_position.py +188 -0
- dungeoncoder/_generated/api/hero/interact.py +188 -0
- dungeoncoder/_generated/api/hero/is_abyss_in_front.py +188 -0
- dungeoncoder/_generated/api/hero/is_at_goal.py +192 -0
- dungeoncoder/_generated/api/hero/is_collision_in_front.py +188 -0
- dungeoncoder/_generated/api/hero/is_enemy_in_front.py +188 -0
- dungeoncoder/_generated/api/hero/is_facing_north.py +188 -0
- dungeoncoder/_generated/api/hero/is_switch_in_front.py +188 -0
- dungeoncoder/_generated/api/hero/is_torch_in_front.py +188 -0
- dungeoncoder/_generated/api/hero/move.py +188 -0
- dungeoncoder/_generated/api/hero/peek_item_value.py +211 -0
- dungeoncoder/_generated/api/hero/pickup.py +207 -0
- dungeoncoder/_generated/api/hero/read_item_value.py +188 -0
- dungeoncoder/_generated/api/hero/sense_goal.py +192 -0
- dungeoncoder/_generated/api/hero/set_pace.py +207 -0
- dungeoncoder/_generated/api/hero/turn_left.py +188 -0
- dungeoncoder/_generated/api/level/__init__.py +1 -0
- dungeoncoder/_generated/api/level/load_level.py +190 -0
- dungeoncoder/_generated/api/level/reset_level.py +158 -0
- dungeoncoder/_generated/client.py +271 -0
- dungeoncoder/_generated/errors.py +14 -0
- dungeoncoder/_generated/models/__init__.py +47 -0
- dungeoncoder/_generated/models/api_error_response.py +98 -0
- dungeoncoder/_generated/models/api_error_response_status.py +7 -0
- dungeoncoder/_generated/models/boolean_result.py +96 -0
- dungeoncoder/_generated/models/boolean_result_status.py +7 -0
- dungeoncoder/_generated/models/configure_params.py +84 -0
- dungeoncoder/_generated/models/distance_param.py +76 -0
- dungeoncoder/_generated/models/name_param.py +76 -0
- dungeoncoder/_generated/models/nullable_integer_result.py +104 -0
- dungeoncoder/_generated/models/nullable_integer_result_status.py +7 -0
- dungeoncoder/_generated/models/nullable_string_result.py +104 -0
- dungeoncoder/_generated/models/nullable_string_result_status.py +7 -0
- dungeoncoder/_generated/models/pace_params.py +76 -0
- dungeoncoder/_generated/models/statistics.py +217 -0
- dungeoncoder/_generated/models/statistics_result.py +104 -0
- dungeoncoder/_generated/models/statistics_result_status.py +7 -0
- dungeoncoder/_generated/models/string_array_result.py +100 -0
- dungeoncoder/_generated/models/string_array_result_status.py +7 -0
- dungeoncoder/_generated/models/tiled_level.py +67 -0
- dungeoncoder/_generated/models/version_result.py +104 -0
- dungeoncoder/_generated/models/version_result_result.py +84 -0
- dungeoncoder/_generated/models/version_result_status.py +7 -0
- dungeoncoder/_generated/types.py +53 -0
- dungeoncoder/asciimap.py +824 -0
- dungeoncoder/dungeoncoder.py +482 -0
- dungeoncoder/generator.py +213 -0
- dungeoncoder/packs/gelaende.json +1 -0
- dungeoncoder/packs/items.json +67 -0
- dungeoncoder/packs/station.json +1 -0
- dungeoncoder/packs/studio.json +1 -0
- dungeoncoder/packs/tilesets.json +1 -0
- dungeoncoder/packs/werkstatt.json +1 -0
- dungeoncoder/replay.py +80 -0
- dungeoncoder/requirements.txt +2 -0
- dungeoncoder/sim.py +792 -0
- dungeoncoder/testing.py +63 -0
- dungeoncoder/trace.py +92 -0
- dungeoncoder/variants.py +112 -0
- dungeoncoder-0.2.0.dist-info/METADATA +37 -0
- dungeoncoder-0.2.0.dist-info/RECORD +77 -0
- dungeoncoder-0.2.0.dist-info/WHEEL +5 -0
- dungeoncoder-0.2.0.dist-info/licenses/LICENSE.txt +25 -0
- 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()
|