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
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}
|