spriteforge 0.1.1__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.
- spriteforge/__init__.py +1 -0
- spriteforge/__main__.py +3 -0
- spriteforge/agents/__init__.py +0 -0
- spriteforge/agents/assets.py +109 -0
- spriteforge/agents/cancellation.py +12 -0
- spriteforge/agents/director.py +50 -0
- spriteforge/agents/models.py +63 -0
- spriteforge/agents/roles/art-director.md +15 -0
- spriteforge/agents/roles/art-reviewer.md +14 -0
- spriteforge/agents/roles/director.md +14 -0
- spriteforge/agents/roles/game-designer.md +15 -0
- spriteforge/agents/roles/level-tile-designer.md +16 -0
- spriteforge/agents/roles/librarian.md +13 -0
- spriteforge/agents/roles/menu-designer.md +15 -0
- spriteforge/agents/roles/pixel-artist.md +14 -0
- spriteforge/agents/roles/programmer.md +14 -0
- spriteforge/agents/roles/qa-playtester.md +13 -0
- spriteforge/agents/roles/sound-director.md +14 -0
- spriteforge/agents/roles.py +39 -0
- spriteforge/agents/router.py +233 -0
- spriteforge/agents/router_eval.py +145 -0
- spriteforge/agents/routing_state.py +133 -0
- spriteforge/agents/runtime.py +430 -0
- spriteforge/agents/workflow.py +389 -0
- spriteforge/app/__init__.py +0 -0
- spriteforge/app/agents_view.py +248 -0
- spriteforge/app/chat.py +114 -0
- spriteforge/app/extensions.py +115 -0
- spriteforge/app/index.html +279 -0
- spriteforge/app/local_only.py +49 -0
- spriteforge/app/server.py +358 -0
- spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
- spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
- spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
- spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
- spriteforge/app/static/index.html +13 -0
- spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
- spriteforge/app/studio.py +278 -0
- spriteforge/app/workers.py +73 -0
- spriteforge/cli.py +241 -0
- spriteforge/config.py +193 -0
- spriteforge/config.yaml +157 -0
- spriteforge/core/__init__.py +0 -0
- spriteforge/core/acting.py +16 -0
- spriteforge/core/cost.py +236 -0
- spriteforge/core/credentials.py +24 -0
- spriteforge/core/demo.py +79 -0
- spriteforge/core/forms.py +20 -0
- spriteforge/core/gates.py +175 -0
- spriteforge/core/security.py +42 -0
- spriteforge/core/state.py +222 -0
- spriteforge/core/team.py +15 -0
- spriteforge/core/usage.py +68 -0
- spriteforge/game/__init__.py +0 -0
- spriteforge/game/actors.py +377 -0
- spriteforge/game/agent_tools.py +178 -0
- spriteforge/game/art.py +2097 -0
- spriteforge/game/art_checks.py +190 -0
- spriteforge/game/art_review.py +518 -0
- spriteforge/game/build.py +134 -0
- spriteforge/game/bundle.py +1178 -0
- spriteforge/game/cli.py +119 -0
- spriteforge/game/codegen.py +433 -0
- spriteforge/game/codeinfo.py +154 -0
- spriteforge/game/dbsource.py +137 -0
- spriteforge/game/design.py +342 -0
- spriteforge/game/export.py +62 -0
- spriteforge/game/gamedb.py +191 -0
- spriteforge/game/image_cache.py +61 -0
- spriteforge/game/lessons.py +259 -0
- spriteforge/game/level_gen.py +118 -0
- spriteforge/game/level_preview.py +97 -0
- spriteforge/game/levels.py +934 -0
- spriteforge/game/loops.py +38 -0
- spriteforge/game/menus.py +712 -0
- spriteforge/game/mock_game.js +109 -0
- spriteforge/game/mock_platformer.js +58 -0
- spriteforge/game/motion.py +881 -0
- spriteforge/game/pipeline.py +1768 -0
- spriteforge/game/plan.py +502 -0
- spriteforge/game/playtest.py +318 -0
- spriteforge/game/qa.py +664 -0
- spriteforge/game/qa_checks.py +223 -0
- spriteforge/game/qa_runtime.py +565 -0
- spriteforge/game/reach.py +807 -0
- spriteforge/game/record.py +138 -0
- spriteforge/game/research.py +262 -0
- spriteforge/game/revision.py +225 -0
- spriteforge/game/sdk/API.md +104 -0
- spriteforge/game/sdk/FLOW.md +19 -0
- spriteforge/game/sdk/LEVELS.md +65 -0
- spriteforge/game/sdk/sf.js +418 -0
- spriteforge/game/sdk/sfflow.js +442 -0
- spriteforge/game/sdk/sflevel.js +683 -0
- spriteforge/game/sdk/sfsound-editor.js +225 -0
- spriteforge/game/sdk/sfsound.js +260 -0
- spriteforge/game/sdk/sounds.html +67 -0
- spriteforge/game/sounds.py +793 -0
- spriteforge/game/story.py +188 -0
- spriteforge/game/structure.py +98 -0
- spriteforge/game/team_reports.py +394 -0
- spriteforge/game/tilesets.py +70 -0
- spriteforge/game/title_art.py +186 -0
- spriteforge/genres/__init__.py +0 -0
- spriteforge/library/__init__.py +15 -0
- spriteforge/library/cli.py +126 -0
- spriteforge/library/describe.py +242 -0
- spriteforge/library/looks.py +154 -0
- spriteforge/library/models.py +79 -0
- spriteforge/library/store.py +422 -0
- spriteforge/library/tilesets/README.md +32 -0
- spriteforge/library/tilesets/gandalf-seasons.json +111 -0
- spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
- spriteforge/mcp/__init__.py +0 -0
- spriteforge/mcp/cli.py +58 -0
- spriteforge/mcp/client.py +57 -0
- spriteforge/mcp/filesystem_read.py +32 -0
- spriteforge/mcp/gamedb.py +56 -0
- spriteforge/mcp/library.py +179 -0
- spriteforge/mcp/memory.py +37 -0
- spriteforge/mcp/pixel_tools.py +145 -0
- spriteforge/mcp/registry.py +145 -0
- spriteforge/mcp/service.py +224 -0
- spriteforge/mcp/templates/context7.json +6 -0
- spriteforge/mcp/templates/fetch.json +6 -0
- spriteforge/mcp/templates/filesystem-read.json +6 -0
- spriteforge/mcp/templates/pixel-tools.json +6 -0
- spriteforge/memory/README.md +10 -0
- spriteforge/memory/__init__.py +0 -0
- spriteforge/memory/cli.py +131 -0
- spriteforge/memory/okf/__init__.py +1 -0
- spriteforge/memory/okf/catalog.py +331 -0
- spriteforge/memory/okf/codec.py +63 -0
- spriteforge/memory/okf/git.py +104 -0
- spriteforge/memory/okf/indexes.py +50 -0
- spriteforge/memory/okf/models.py +114 -0
- spriteforge/memory/okf/search.py +53 -0
- spriteforge/memory/okf/seed.py +81 -0
- spriteforge/memory/okf/seeds/genres.yaml +121 -0
- spriteforge/memory/okf/seeds/other.yaml +150 -0
- spriteforge/memory/okf/store.py +402 -0
- spriteforge/memory/okf/validate.py +115 -0
- spriteforge/pixel/__init__.py +17 -0
- spriteforge/pixel/atlas.py +110 -0
- spriteforge/pixel/grid.py +174 -0
- spriteforge/pixel/palette.py +156 -0
- spriteforge/pixel/palettes/dungeon.json +9 -0
- spriteforge/pixel/postprocess.py +170 -0
- spriteforge/pixel/preview.py +40 -0
- spriteforge/pixel/schemas.py +139 -0
- spriteforge/pixel/source.py +237 -0
- spriteforge/pixel/terrain.py +48 -0
- spriteforge/pixel/validate.py +138 -0
- spriteforge/prompts/anim_frame.txt +1 -0
- spriteforge/prompts/character.txt +1 -0
- spriteforge/prompts/item.txt +1 -0
- spriteforge/prompts/tile.txt +1 -0
- spriteforge/prompts/ui.txt +1 -0
- spriteforge/providers/__init__.py +0 -0
- spriteforge/providers/base.py +97 -0
- spriteforge/providers/http.py +38 -0
- spriteforge/providers/mock.py +111 -0
- spriteforge/providers/muse_image.py +60 -0
- spriteforge/providers/muse_search.py +91 -0
- spriteforge/providers/muse_spark.py +66 -0
- spriteforge/skills/__init__.py +0 -0
- spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
- spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
- spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
- spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
- spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
- spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
- spriteforge/skills/builtin/level-design/SKILL.md +169 -0
- spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
- spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
- spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
- spriteforge/skills/builtin/platformer/SKILL.md +153 -0
- spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
- spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
- spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
- spriteforge/skills/catalog.py +200 -0
- spriteforge/skills/cli.py +41 -0
- spriteforge/stages/__init__.py +0 -0
- spriteforge/stages/asset_context.py +301 -0
- spriteforge/stages/asset_models.py +192 -0
- spriteforge/stages/asset_render.py +366 -0
- spriteforge/stages/asset_review.py +140 -0
- spriteforge/stages/asset_store.py +94 -0
- spriteforge/stages/chat_changes.py +236 -0
- spriteforge/stages/concept.py +42 -0
- spriteforge/stages/game.py +1699 -0
- spriteforge/stages/game_confirm.py +112 -0
- spriteforge/stages/game_final.py +220 -0
- spriteforge/stages/game_record.py +246 -0
- spriteforge/stages/gameplay.py +218 -0
- spriteforge/stages/intake.py +372 -0
- spriteforge/stages/preview_assets.py +212 -0
- spriteforge/stages/preview_models.py +167 -0
- spriteforge/stages/preview_render.py +169 -0
- spriteforge/stages/preview_repair.py +61 -0
- spriteforge/stages/preview_review.py +169 -0
- spriteforge/stages/production.py +170 -0
- spriteforge/stages/references.py +105 -0
- spriteforge/stages/rewind.py +128 -0
- spriteforge/stages/workflow.py +440 -0
- spriteforge/templates/phaser/audit.js +45 -0
- spriteforge/templates/phaser/font.png +0 -0
- spriteforge/templates/phaser/font.xml +1 -0
- spriteforge/templates/phaser/preview.html +9 -0
- spriteforge/templates/phaser/room.js +49 -0
- spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
- spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
- spriteforge-0.1.1.dist-info/METADATA +364 -0
- spriteforge-0.1.1.dist-info/RECORD +218 -0
- spriteforge-0.1.1.dist-info/WHEEL +5 -0
- spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
- spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
- spriteforge-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
"""Can a platformer level be played with the player's real movement? (L2.3)
|
|
2
|
+
|
|
3
|
+
Code proves it by searching the level the way the SDK's player moves: the same gravity, take-off
|
|
4
|
+
speed, run speed and dash as `sflevel.js`, stepped at Phaser's fixed 60 Hz physics with the
|
|
5
|
+
bot's inputs changing once per playtest frame. Units are tiles and seconds; rows count down.
|
|
6
|
+
|
|
7
|
+
* `fly` follows one planned move (a jump, a walk off a ledge, a dash) through the grid and says
|
|
8
|
+
where the player lands, or None when the move hits a wall, a ceiling, a hazard, an enemy's
|
|
9
|
+
patrol or falls out of the level. The model jumps a little lower than the SDK (CLEARANCE vs
|
|
10
|
+
the SDK's 6 px), and a landing needs a quarter tile of the body over the platform.
|
|
11
|
+
* `World`: the grid with some things opened (crates dissolved, walls broken, enemies beaten);
|
|
12
|
+
its `runs` are the floors the player can stand and walk on, and `link` finds a move between
|
|
13
|
+
two runs.
|
|
14
|
+
* `solve`: a search over what the player holds (abilities from pickups; some replace each
|
|
15
|
+
other) and what is opened, greedily opening whatever it can reach and come back from. It
|
|
16
|
+
returns the route the bot follows in the playtest, or why the exit cannot be reached.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from collections import deque
|
|
23
|
+
from dataclasses import dataclass, field, replace
|
|
24
|
+
from functools import lru_cache
|
|
25
|
+
|
|
26
|
+
DT = 1 / 60 # Phaser Arcade's fixed physics step
|
|
27
|
+
CLEARANCE_SDK = 6 / 16 # the SDK's jump peaks this far (tiles) above the plan's jump height
|
|
28
|
+
CLEARANCE = 4 / 16 # the model's jump: 2 px under the SDK's, a safety margin
|
|
29
|
+
LAND = 0.25 # tiles of the body that must be over a platform for a landing
|
|
30
|
+
EDGE = 0.15 # tiles of the body left on the ledge at an edge take-off
|
|
31
|
+
MAX_FALL = 40.0 # tiles/s; the SDK caps falls at this too (no tunnelling through tiles)
|
|
32
|
+
DASH_STEPS = 9 # physics steps a dash lasts (0.15 s)
|
|
33
|
+
MAX_AIR = 3.0 # s; longer flights are not considered
|
|
34
|
+
LOCKS = ("crate", "breakable", "enemy", "gate") # closed: in the way; a gate opens by its lever
|
|
35
|
+
EPS = 1e-9
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def take_off_speed(gravity: float, height: float) -> float:
|
|
39
|
+
"""Take-off speed whose jump peaks exactly at `height` under semi-implicit Euler at DT
|
|
40
|
+
(Arcade adds gravity to the velocity, then moves): v = g dt/2 + sqrt((g dt/2)^2 + 2 g h)."""
|
|
41
|
+
half = gravity * DT / 2
|
|
42
|
+
return half + math.sqrt(half * half + 2 * gravity * height)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def key(text: str) -> str:
|
|
46
|
+
"""Ability names compared loosely (case, spaces, dashes and underscores do not matter)."""
|
|
47
|
+
return " ".join("".join(ch if ch.isalnum() else " " for ch in str(text).casefold()).split())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Motion:
|
|
52
|
+
"""The player's movement in tiles and seconds, and its body."""
|
|
53
|
+
|
|
54
|
+
speed: float # run speed
|
|
55
|
+
gravity: float # 8 h / T^2 from the plan
|
|
56
|
+
jump: float # the plan's jump height (0 = no jump)
|
|
57
|
+
dash: float = 0.0 # dash distance (0 = no dash)
|
|
58
|
+
cooldown: float = 0.0
|
|
59
|
+
coyote: float = 0.0
|
|
60
|
+
body_w: float = 0.75
|
|
61
|
+
body_h: float = 0.75
|
|
62
|
+
fps: int = 30 # the bot decides once per frame; physics steps twice per frame at 30 fps
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def v0(self) -> float:
|
|
66
|
+
return take_off_speed(self.gravity, self.jump + CLEARANCE) if self.jump > 0 else 0.0
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def tall(self) -> int:
|
|
70
|
+
return max(1, math.ceil(self.body_h - 1e-6))
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def sub(self) -> int:
|
|
74
|
+
return max(1, round(1 / (self.fps * DT)))
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def dash_speed(self) -> float:
|
|
78
|
+
return self.dash / (DASH_STEPS * DT)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def max_rise(self) -> int:
|
|
82
|
+
return int(self.jump + CLEARANCE + EPS) if self.jump > 0 else 0
|
|
83
|
+
|
|
84
|
+
def rise_time(self, rise: float, up: bool) -> float | None:
|
|
85
|
+
"""Seconds after take-off when the feet are `rise` tiles up, rising (up) or
|
|
86
|
+
falling; None when the jump never gets that high."""
|
|
87
|
+
if self.v0 <= 0:
|
|
88
|
+
return None
|
|
89
|
+
v = self.v0 - self.gravity * DT / 2 # the stepped jump follows this parabola exactly
|
|
90
|
+
disc = v * v - 2 * self.gravity * rise
|
|
91
|
+
if disc < 0:
|
|
92
|
+
return None
|
|
93
|
+
return (v - math.sqrt(disc) if up else v + math.sqrt(disc)) / self.gravity
|
|
94
|
+
|
|
95
|
+
def sdk(self, tile_px: int = 16) -> dict:
|
|
96
|
+
"""The numbers the SDK's controller uses (tiles; the SDK multiplies by its tile size)."""
|
|
97
|
+
return {
|
|
98
|
+
"speed": round(self.speed, 4),
|
|
99
|
+
"gravity": round(self.gravity, 4),
|
|
100
|
+
"jump": round(self.jump + CLEARANCE_SDK, 4) if self.jump > 0 else 0,
|
|
101
|
+
"dash": round(self.dash, 4),
|
|
102
|
+
"dash_steps": DASH_STEPS,
|
|
103
|
+
"cooldown": round(self.cooldown, 4),
|
|
104
|
+
"coyote": round(self.coyote, 4),
|
|
105
|
+
"max_fall": MAX_FALL,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --- the grid ----------------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
Cell = tuple[int, int]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Thing:
|
|
117
|
+
"""Something placed in a level (not a tile): crates, breakable walls, pickups, enemies,
|
|
118
|
+
checkpoints, the exit and the player's start."""
|
|
119
|
+
|
|
120
|
+
id: str
|
|
121
|
+
kind: str
|
|
122
|
+
entity: str
|
|
123
|
+
cells: tuple[Cell, ...]
|
|
124
|
+
needs: str = ""
|
|
125
|
+
opened_by: str = "never"
|
|
126
|
+
holds: str = ""
|
|
127
|
+
gives: str = ""
|
|
128
|
+
patrol: tuple[int, int] | None = None # enemy: the columns it walks between
|
|
129
|
+
tall: int = 1 # enemy: rows it spans
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def col(self) -> int:
|
|
133
|
+
return min(c for c, _ in self.cells)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def row(self) -> int:
|
|
137
|
+
return max(r for _, r in self.cells)
|
|
138
|
+
|
|
139
|
+
def zone(self) -> set[Cell]:
|
|
140
|
+
"""Cells an enemy may be in while it patrols (hazard while it lives)."""
|
|
141
|
+
if self.kind != "enemy":
|
|
142
|
+
return set(self.cells)
|
|
143
|
+
a, b = self.patrol or (self.col, self.col)
|
|
144
|
+
return {(c, self.row - k) for c in range(a, b + 1) for k in range(self.tall)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class Grid:
|
|
149
|
+
cols: int
|
|
150
|
+
rows: int
|
|
151
|
+
tiles: dict[Cell, str] # "solid" | "one_way" | "hazard"
|
|
152
|
+
things: list[Thing]
|
|
153
|
+
start: Cell
|
|
154
|
+
|
|
155
|
+
def thing(self, ident: str) -> Thing:
|
|
156
|
+
return next(t for t in self.things if t.id == ident)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class Run:
|
|
161
|
+
"""A floor: cells (c0..c1, row) the player can stand on and walk between."""
|
|
162
|
+
|
|
163
|
+
i: int
|
|
164
|
+
row: int
|
|
165
|
+
c0: int
|
|
166
|
+
c1: int
|
|
167
|
+
|
|
168
|
+
def has(self, cell: Cell) -> bool:
|
|
169
|
+
return cell[1] == self.row and self.c0 <= cell[0] <= self.c1
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class Move:
|
|
174
|
+
"""One planned move in the air, as the bot plays it. `x0` is where the body's centre is
|
|
175
|
+
when it starts (a jump from standing; for a fall, just past the ledge)."""
|
|
176
|
+
|
|
177
|
+
kind: str # "jump" | "fall"
|
|
178
|
+
x0: float
|
|
179
|
+
vx: float # horizontal speed in the air (after `wait`)
|
|
180
|
+
side: int # -1 left, 1 right, 0 straight up
|
|
181
|
+
wait: float = 0.0 # seconds with no horizontal speed first (rise beside a wall)
|
|
182
|
+
dash: float = -1.0 # seconds after the start the dash is pressed; -1: none
|
|
183
|
+
after: float = 0.0 # horizontal speed after the dash
|
|
184
|
+
|
|
185
|
+
def data(self) -> dict:
|
|
186
|
+
return {"do": self.kind, "x0": round(self.x0, 4), "vx": round(self.vx, 4),
|
|
187
|
+
"side": self.side, "wait": round(self.wait, 4), "dash": round(self.dash, 4),
|
|
188
|
+
"after": round(self.after, 4)} # fmt: skip
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass
|
|
192
|
+
class Flight:
|
|
193
|
+
cell: Cell # where it lands (the standing cell)
|
|
194
|
+
x: float # the body's centre at landing
|
|
195
|
+
time: float
|
|
196
|
+
swept: frozenset[Cell]
|
|
197
|
+
box: tuple[int, int, int, int] # c0, r0, c1, r1 of everything the flight came near
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class World:
|
|
201
|
+
"""The grid with the things in `opened` gone (opened crates and walls, beaten enemies)."""
|
|
202
|
+
|
|
203
|
+
def __init__(self, grid: Grid, opened: frozenset[str], m: Motion):
|
|
204
|
+
self.grid, self.opened, self.m = grid, opened, m
|
|
205
|
+
self.cols, self.rows = grid.cols, grid.rows
|
|
206
|
+
self.solid = {c for c, k in grid.tiles.items() if k == "solid"}
|
|
207
|
+
self.oneway = {c for c, k in grid.tiles.items() if k == "one_way"}
|
|
208
|
+
self.hazard = {c for c, k in grid.tiles.items() if k == "hazard"}
|
|
209
|
+
self.support = self.solid | self.oneway
|
|
210
|
+
self.owner: dict[Cell, str] = {} # cells of things still closed -> thing id
|
|
211
|
+
for t in grid.things:
|
|
212
|
+
if t.kind not in LOCKS or t.id in opened:
|
|
213
|
+
continue
|
|
214
|
+
cells = t.zone()
|
|
215
|
+
if t.kind == "enemy":
|
|
216
|
+
self.hazard |= cells
|
|
217
|
+
else:
|
|
218
|
+
self.solid |= cells
|
|
219
|
+
if t.kind in ("breakable", "gate"): # stood on; a crate dissolves on touch
|
|
220
|
+
self.support |= cells
|
|
221
|
+
self.owner.update(dict.fromkeys(cells, t.id))
|
|
222
|
+
self.runs = self.find_runs()
|
|
223
|
+
self.run_at = {(c, r.row): r for r in self.runs for c in range(r.c0, r.c1 + 1)}
|
|
224
|
+
self.links: dict[int, list[Link]] | None = None
|
|
225
|
+
|
|
226
|
+
def blocked(self, c: int, r: int) -> bool:
|
|
227
|
+
return c < 0 or c >= self.cols or (c, r) in self.solid or (c, r) in self.hazard
|
|
228
|
+
|
|
229
|
+
def standable(self, c: int, r: int) -> bool:
|
|
230
|
+
if not (0 <= c < self.cols and 0 <= r < self.rows - 1) or (c, r + 1) not in self.support:
|
|
231
|
+
return False
|
|
232
|
+
return not any(self.blocked(c, r - k) for k in range(self.m.tall))
|
|
233
|
+
|
|
234
|
+
def find_runs(self) -> list[Run]:
|
|
235
|
+
out = []
|
|
236
|
+
for r in range(self.rows):
|
|
237
|
+
c = 0
|
|
238
|
+
while c < self.cols:
|
|
239
|
+
if self.standable(c, r):
|
|
240
|
+
c0 = c
|
|
241
|
+
while c + 1 < self.cols and self.standable(c + 1, r):
|
|
242
|
+
c += 1
|
|
243
|
+
out.append(Run(len(out), r, c0, c))
|
|
244
|
+
c += 1
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
def state(self, box: tuple[int, int, int, int]) -> tuple:
|
|
248
|
+
"""Which closed things lie in a box: a flight through it depends on them."""
|
|
249
|
+
c0, r0, c1, r1 = box
|
|
250
|
+
return tuple(sorted({o for (c, r), o in self.owner.items()
|
|
251
|
+
if c0 <= c <= c1 and r0 <= r <= r1})) # fmt: skip
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# --- flights -----------------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def body_cells(x: float, y: float, w: float, h: float):
|
|
258
|
+
for c in range(math.floor(x - w / 2 + EPS), math.floor(x + w / 2 - EPS) + 1):
|
|
259
|
+
for r in range(math.floor(y - h + EPS), math.floor(y - EPS) + 1):
|
|
260
|
+
yield c, r
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def fly(world: World, row: int, move: Move) -> Flight | None:
|
|
264
|
+
"""Follow `move` from standing on row `row` (feet at y = row + 1)."""
|
|
265
|
+
m = world.m
|
|
266
|
+
w, h, g = m.body_w, m.body_h, m.gravity
|
|
267
|
+
x, y = move.x0, row + 1.0
|
|
268
|
+
vy = -m.v0 if move.kind == "jump" else 0.0
|
|
269
|
+
dashing, dashed = 0, False
|
|
270
|
+
swept: set[Cell] = set()
|
|
271
|
+
lo_x, hi_x, lo_y, hi_y = x, x, y, y
|
|
272
|
+
|
|
273
|
+
def box() -> tuple[int, int, int, int]:
|
|
274
|
+
return (math.floor(lo_x - w / 2) - 1, math.floor(lo_y - h) - 1,
|
|
275
|
+
math.floor(hi_x + w / 2) + 1, math.floor(hi_y) + 1) # fmt: skip
|
|
276
|
+
|
|
277
|
+
for frame in range(int(MAX_AIR * m.fps)):
|
|
278
|
+
t = frame / m.fps
|
|
279
|
+
vx = 0.0 if t < move.wait - EPS else move.vx
|
|
280
|
+
if move.dash >= 0 and not dashed and t >= move.dash - EPS:
|
|
281
|
+
dashed, dashing, vy = True, DASH_STEPS, 0.0
|
|
282
|
+
if dashed and not dashing:
|
|
283
|
+
vx = move.after
|
|
284
|
+
for _ in range(m.sub):
|
|
285
|
+
if dashing:
|
|
286
|
+
sx, vy = move.side * m.dash_speed, 0.0
|
|
287
|
+
else:
|
|
288
|
+
sx, vy = vx, min(vy + g * DT, MAX_FALL)
|
|
289
|
+
py = y
|
|
290
|
+
x += sx * DT
|
|
291
|
+
y += vy * DT
|
|
292
|
+
if dashing:
|
|
293
|
+
dashing -= 1
|
|
294
|
+
lo_x, hi_x, lo_y, hi_y = min(lo_x, x), max(hi_x, x), min(lo_y, y), max(hi_y, y)
|
|
295
|
+
if x - w / 2 < 0 or x + w / 2 > world.cols or y - h > world.rows:
|
|
296
|
+
return None # the side of the level, or a pit
|
|
297
|
+
if vy > 0:
|
|
298
|
+
for k in range(math.ceil(py - EPS), math.floor(y + EPS) + 1):
|
|
299
|
+
if k > py - EPS: # the feet crossed the top of row k, coming down
|
|
300
|
+
landed = land(world, x, k, w)
|
|
301
|
+
if landed is not None:
|
|
302
|
+
cell = (landed, k - 1)
|
|
303
|
+
if any(world.blocked(c, r) for c, r in body_cells(x, k, w, h)):
|
|
304
|
+
return None
|
|
305
|
+
swept.update(body_cells(x, k, w, h))
|
|
306
|
+
return Flight(cell, x, frame / m.fps, frozenset(swept), box())
|
|
307
|
+
if landed is None and touches(world, x, k, w):
|
|
308
|
+
return None # only the edge of a platform: too close to call
|
|
309
|
+
for c, r in body_cells(x, y, w, h):
|
|
310
|
+
if world.blocked(c, r):
|
|
311
|
+
return None
|
|
312
|
+
swept.add((c, r))
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def touches(world: World, x: float, k: int, w: float) -> bool:
|
|
317
|
+
return any((c, k) in world.support
|
|
318
|
+
for c in range(math.floor(x - w / 2 + EPS), math.floor(x + w / 2 - EPS) + 1)) # fmt: skip
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def land(world: World, x: float, k: int, w: float) -> int | None:
|
|
322
|
+
"""The column the body lands on when its feet reach the top of row k: the support with
|
|
323
|
+
the most overlap, if it is at least LAND; None otherwise."""
|
|
324
|
+
best, col = 0.0, None
|
|
325
|
+
for c in range(math.floor(x - w / 2 + EPS), math.floor(x + w / 2 - EPS) + 1):
|
|
326
|
+
if (c, k) in world.support:
|
|
327
|
+
overlap = min(x + w / 2, c + 1) - max(x - w / 2, c)
|
|
328
|
+
if overlap > best:
|
|
329
|
+
best, col = overlap, c
|
|
330
|
+
if col is None or best < min(LAND, w) - EPS:
|
|
331
|
+
return None
|
|
332
|
+
return col if world.standable(col, k - 1) else None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class Flights:
|
|
336
|
+
"""`fly` with a memory: a flight is reused in another world when the closed things near
|
|
337
|
+
it are the same."""
|
|
338
|
+
|
|
339
|
+
def __init__(self):
|
|
340
|
+
self.seen: dict[tuple, list[tuple[tuple, Flight | None]]] = {}
|
|
341
|
+
|
|
342
|
+
def __call__(self, world: World, row: int, move: Move) -> Flight | None:
|
|
343
|
+
for state, result in self.seen.get((row, move), []):
|
|
344
|
+
if world.state(state[0]) == state[1]:
|
|
345
|
+
return result
|
|
346
|
+
result = fly(world, row, move)
|
|
347
|
+
span = result.box if result else wide_box(world, row, move)
|
|
348
|
+
self.seen.setdefault((row, move), []).append(((span, world.state(span)), result))
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def wide_box(world: World, row: int, move: Move) -> tuple[int, int, int, int]:
|
|
353
|
+
"""Everything a failed flight could have come near."""
|
|
354
|
+
m = world.m
|
|
355
|
+
reach = m.speed * MAX_AIR + m.dash + 2
|
|
356
|
+
lo = move.x0 - reach if move.side <= 0 else move.x0 - 2
|
|
357
|
+
hi = move.x0 + reach if move.side >= 0 else move.x0 + 2
|
|
358
|
+
return (math.floor(lo), row - m.max_rise - m.tall - 2, math.ceil(hi), world.rows)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# --- links between floors ----------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass
|
|
365
|
+
class Link:
|
|
366
|
+
src: int
|
|
367
|
+
dst: int
|
|
368
|
+
move: Move
|
|
369
|
+
flight: Flight
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def moves(m: Motion, s: Run, t: Run, a: int, b: int):
|
|
373
|
+
"""Moves worth trying from cell a of run s to cell b of run t, simplest first."""
|
|
374
|
+
side = (b > a) - (b < a)
|
|
375
|
+
rise = s.row - t.row
|
|
376
|
+
end = (side > 0 and a == s.c1) or (side < 0 and a == s.c0)
|
|
377
|
+
xa, xb = a + 0.5, b + 0.5
|
|
378
|
+
starts = [xa] + ([a + 0.5 + side * (0.5 + m.body_w / 2 - EDGE)] if end else [])
|
|
379
|
+
ends = [xb, b + 0.5 - side * 0.3] if side else [xb]
|
|
380
|
+
if m.v0:
|
|
381
|
+
down = m.rise_time(rise, up=False)
|
|
382
|
+
if down:
|
|
383
|
+
for x0 in starts:
|
|
384
|
+
for x1 in ends:
|
|
385
|
+
vx = (x1 - x0) / down
|
|
386
|
+
if abs(vx) <= m.speed + EPS:
|
|
387
|
+
yield Move("jump", x0, vx, side)
|
|
388
|
+
# Rise first, then move: over the top of a wall beside the take-off, or over
|
|
389
|
+
# spikes or an enemy right next to it.
|
|
390
|
+
for height in ((rise + 0.3, rise + 0.05) if rise > 0 else (1.05,)):
|
|
391
|
+
up = m.rise_time(height, up=True) if side else None
|
|
392
|
+
if up is None:
|
|
393
|
+
continue
|
|
394
|
+
wait = math.ceil(up * m.fps - EPS) / m.fps
|
|
395
|
+
if wait < down:
|
|
396
|
+
vx = (xb - xa) / (down - wait)
|
|
397
|
+
if abs(vx) > m.speed:
|
|
398
|
+
vx = side * m.speed
|
|
399
|
+
yield Move("jump", xa, vx, side, wait=wait)
|
|
400
|
+
if rise < 0 and side and end:
|
|
401
|
+
x0 = (s.c1 + 1 if side > 0 else s.c0) + side * (m.body_w / 2 + 0.02)
|
|
402
|
+
fall = math.sqrt(2 * -rise / m.gravity)
|
|
403
|
+
vx = (xb - x0) / fall
|
|
404
|
+
if abs(vx) < 1:
|
|
405
|
+
vx = side * 1.0
|
|
406
|
+
if abs(vx) <= m.speed + EPS:
|
|
407
|
+
yield Move("fall", x0, vx, side)
|
|
408
|
+
if m.dash > 0 and side:
|
|
409
|
+
x0 = starts[-1]
|
|
410
|
+
if m.v0:
|
|
411
|
+
apex = math.ceil(m.v0 / m.gravity * m.fps) / m.fps
|
|
412
|
+
for at in (apex, 0.0):
|
|
413
|
+
for after in (1.0, 0.5, 0.0):
|
|
414
|
+
yield Move("jump", x0, side * m.speed, side, dash=at, after=side * m.speed * after)
|
|
415
|
+
else:
|
|
416
|
+
yield Move("fall", x0, side * m.speed, side, dash=0.0, after=side * m.speed)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def reachable_pair(m: Motion, s: Run, t: Run) -> bool:
|
|
420
|
+
"""A cheap test before trying moves: the height and the distance can be covered."""
|
|
421
|
+
rise = s.row - t.row
|
|
422
|
+
if rise > m.max_rise:
|
|
423
|
+
return False
|
|
424
|
+
if t.c0 > s.c1:
|
|
425
|
+
gap = t.c0 - s.c1 - 1
|
|
426
|
+
elif t.c1 < s.c0:
|
|
427
|
+
gap = s.c0 - t.c1 - 1
|
|
428
|
+
else:
|
|
429
|
+
return True
|
|
430
|
+
air = m.rise_time(rise, up=False) if m.v0 else None
|
|
431
|
+
if air is None:
|
|
432
|
+
air = math.sqrt(2 * max(0, -rise) / m.gravity) if rise < 0 else 0.0
|
|
433
|
+
return gap <= m.speed * air + m.dash + 1
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def candidates(s: Run, t: Run) -> list[tuple[int, int]]:
|
|
437
|
+
if t.c0 > s.c1:
|
|
438
|
+
froms, tos = range(s.c1, max(s.c0, s.c1 - 3) - 1, -1), range(t.c0, min(t.c1, t.c0 + 2) + 1)
|
|
439
|
+
elif t.c1 < s.c0:
|
|
440
|
+
froms, tos = range(s.c0, min(s.c1, s.c0 + 3) + 1), range(t.c1, max(t.c0, t.c1 - 2) - 1, -1)
|
|
441
|
+
else:
|
|
442
|
+
froms = [c for c in (t.c0 - 1, t.c1 + 1, t.c0 - 2, t.c1 + 2, t.c0 - 3, t.c1 + 3, t.c0, t.c1)
|
|
443
|
+
if s.c0 <= c <= s.c1] # fmt: skip
|
|
444
|
+
tos = [c for c in (t.c0, t.c1, t.c0 + 1, t.c1 - 1) if t.c0 <= c <= t.c1]
|
|
445
|
+
# A ledge over a wider floor: off its own ends, down beside it (the way back from a
|
|
446
|
+
# lever on an upper floor; before, a floating platform was a one-way trip up).
|
|
447
|
+
froms += [s.c1, s.c0]
|
|
448
|
+
tos += [c for c in (s.c1 + 1, s.c0 - 1, s.c1 + 2, s.c0 - 2) if t.c0 <= c <= t.c1]
|
|
449
|
+
pairs = []
|
|
450
|
+
for a in dict.fromkeys(froms):
|
|
451
|
+
for b in dict.fromkeys(tos):
|
|
452
|
+
pairs.append((a, b))
|
|
453
|
+
return pairs
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def link(world: World, flights: Flights, s: Run, t: Run) -> Link | None:
|
|
457
|
+
if not reachable_pair(world.m, s, t):
|
|
458
|
+
return None
|
|
459
|
+
for a, b in candidates(s, t):
|
|
460
|
+
for move in moves(world.m, s, t, a, b):
|
|
461
|
+
f = flights(world, s.row, move)
|
|
462
|
+
if f is not None and t.has(f.cell):
|
|
463
|
+
return Link(s.i, t.i, move, f)
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def links(world: World, flights: Flights) -> dict[int, list[Link]]:
|
|
468
|
+
if world.links is None:
|
|
469
|
+
world.links = {r.i: [] for r in world.runs}
|
|
470
|
+
for s in world.runs:
|
|
471
|
+
for t in world.runs:
|
|
472
|
+
if s.i != t.i and (found := link(world, flights, s, t)):
|
|
473
|
+
world.links[s.i].append(found)
|
|
474
|
+
return world.links
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# --- the search --------------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@dataclass
|
|
481
|
+
class Action:
|
|
482
|
+
do: str # "touch" | "dash" | "take" | "exit"
|
|
483
|
+
thing: str
|
|
484
|
+
at: Cell # where the player stands to do it
|
|
485
|
+
hop: Move | None = None # take/exit by jumping in place (a pickup in the air)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@dataclass
|
|
489
|
+
class Step:
|
|
490
|
+
"""One step of the route the bot plays."""
|
|
491
|
+
|
|
492
|
+
do: str # walk | jump | fall | touch | dash | collect | exit
|
|
493
|
+
x: float = 0.0 # walk: where to; touch/dash: where to stand
|
|
494
|
+
move: Move | None = None
|
|
495
|
+
cell: Cell | None = None # where the player stands after it
|
|
496
|
+
thing: str = ""
|
|
497
|
+
drop: bool = False # collect: the thing a crate dropped
|
|
498
|
+
|
|
499
|
+
def data(self) -> dict:
|
|
500
|
+
out = {"do": self.do}
|
|
501
|
+
if self.move:
|
|
502
|
+
out |= self.move.data() | {"do": self.do}
|
|
503
|
+
else:
|
|
504
|
+
out["x"] = round(self.x, 4)
|
|
505
|
+
if self.cell:
|
|
506
|
+
out["cell"] = list(self.cell)
|
|
507
|
+
if self.thing:
|
|
508
|
+
out["thing"] = self.thing
|
|
509
|
+
if self.drop:
|
|
510
|
+
out["drop"] = True
|
|
511
|
+
return out
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@dataclass
|
|
515
|
+
class Solution:
|
|
516
|
+
solved: bool
|
|
517
|
+
route: list[Step]
|
|
518
|
+
reached: set[Cell] # every standing cell any explored state could reach
|
|
519
|
+
actions: list[Action]
|
|
520
|
+
problems: list[str] = field(default_factory=list) # why the exit could not be reached
|
|
521
|
+
states: int = 0
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@dataclass(frozen=True)
|
|
525
|
+
class State:
|
|
526
|
+
cell: Cell
|
|
527
|
+
held: frozenset[str]
|
|
528
|
+
opened: frozenset[str]
|
|
529
|
+
taken: frozenset[str]
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class Search:
|
|
533
|
+
def __init__(self, grid: Grid, m: Motion, held: set[str], exclusive: set[str]):
|
|
534
|
+
self.grid, self.m = grid, m
|
|
535
|
+
self.start_held = frozenset(key(a) for a in held)
|
|
536
|
+
self.exclusive = {key(a) for a in exclusive}
|
|
537
|
+
self.flights = Flights()
|
|
538
|
+
self.worlds: dict[frozenset[str], World] = {}
|
|
539
|
+
|
|
540
|
+
def world(self, opened: frozenset[str]) -> World:
|
|
541
|
+
if opened not in self.worlds:
|
|
542
|
+
self.worlds[opened] = World(self.grid, opened, self.m)
|
|
543
|
+
return self.worlds[opened]
|
|
544
|
+
|
|
545
|
+
def reach(self, world: World, cell: Cell) -> dict[int, tuple[int, Link] | None]:
|
|
546
|
+
"""Runs reachable from the run at `cell`: run -> (previous run, link) (BFS tree)."""
|
|
547
|
+
first = world.run_at.get(cell)
|
|
548
|
+
if first is None:
|
|
549
|
+
return {}
|
|
550
|
+
graph = links(world, self.flights)
|
|
551
|
+
tree: dict[int, tuple[int, Link] | None] = {first.i: None}
|
|
552
|
+
queue = deque([first.i])
|
|
553
|
+
while queue:
|
|
554
|
+
i = queue.popleft()
|
|
555
|
+
for ln in graph[i]:
|
|
556
|
+
if ln.dst not in tree:
|
|
557
|
+
tree[ln.dst] = (i, ln)
|
|
558
|
+
queue.append(ln.dst)
|
|
559
|
+
return tree
|
|
560
|
+
|
|
561
|
+
def stands(self, world: World, tree, cell: Cell) -> bool:
|
|
562
|
+
run = world.run_at.get(cell)
|
|
563
|
+
return run is not None and run.i in tree
|
|
564
|
+
|
|
565
|
+
# where the player stands to deal with a thing
|
|
566
|
+
|
|
567
|
+
def touch_cells(self, world: World, t: Thing) -> list[Cell]:
|
|
568
|
+
tall = self.m.tall
|
|
569
|
+
if t.kind == "enemy":
|
|
570
|
+
a, b = t.patrol or (t.col, t.col)
|
|
571
|
+
spots = [(a - 1, t.row), (b + 1, t.row)]
|
|
572
|
+
else:
|
|
573
|
+
own = set(t.cells)
|
|
574
|
+
spots = [(c + d, r2) for c, r in t.cells for d in (-1, 1)
|
|
575
|
+
for r2 in range(r, r + tall)
|
|
576
|
+
if (c + d, r2) not in own and (c + d, r2 + 1) not in own] # fmt: skip
|
|
577
|
+
return [s for s in dict.fromkeys(spots) if world.standable(*s)]
|
|
578
|
+
|
|
579
|
+
def visit(self, world: World, tree, t: Thing) -> tuple[Cell, Move | None] | None:
|
|
580
|
+
"""Where the player gets a pickup or reaches the exit: standing in its cell, or a jump
|
|
581
|
+
in place under it."""
|
|
582
|
+
tall = self.m.tall
|
|
583
|
+
for c, r in t.cells:
|
|
584
|
+
for r2 in range(r, r + tall):
|
|
585
|
+
if self.stands(world, tree, (c, r2)):
|
|
586
|
+
return (c, r2), None
|
|
587
|
+
for c, r in t.cells:
|
|
588
|
+
for dc in (0, -1, 1):
|
|
589
|
+
for r2 in range(r + tall, min(world.rows - 1, r + tall + self.m.max_rise + 1)):
|
|
590
|
+
spot = (c + dc, r2)
|
|
591
|
+
if not self.stands(world, tree, spot):
|
|
592
|
+
continue
|
|
593
|
+
hop = Move("jump", spot[0] + 0.5 - dc * 0.25, 0.0, 0)
|
|
594
|
+
f = self.flights(world, r2, hop)
|
|
595
|
+
if f is not None and (c, r) in f.swept and f.cell == spot:
|
|
596
|
+
return spot, hop
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
def available(self, state: State) -> list[Thing]:
|
|
600
|
+
"""Pickups that give an ability and can be taken now (placed ones, and what opened
|
|
601
|
+
crates dropped)."""
|
|
602
|
+
out = []
|
|
603
|
+
for t in self.grid.things:
|
|
604
|
+
if t.kind in ("pickup", "lever") and t.gives and t.id not in state.taken:
|
|
605
|
+
out.append(t)
|
|
606
|
+
if t.kind == "crate" and t.gives and t.id in state.opened and t.id + ">" not in state.taken:
|
|
607
|
+
out.append(Thing(t.id + ">", "pickup", t.holds, t.cells, gives=t.gives))
|
|
608
|
+
return out
|
|
609
|
+
|
|
610
|
+
def gates(self, t: Thing) -> frozenset[str]:
|
|
611
|
+
"""The gates a lever opens when it is pulled (anywhere in the level)."""
|
|
612
|
+
if t.kind != "lever":
|
|
613
|
+
return frozenset()
|
|
614
|
+
return frozenset(g.id for g in self.grid.things
|
|
615
|
+
if g.kind == "gate" and key(g.needs) == key(t.gives)) # fmt: skip
|
|
616
|
+
|
|
617
|
+
def can_open(self, t: Thing, held: frozenset[str]) -> bool:
|
|
618
|
+
return (t.kind in LOCKS and t.opened_by in {"touch", "dash"}
|
|
619
|
+
and (not t.needs or key(t.needs) in held)) # fmt: skip
|
|
620
|
+
|
|
621
|
+
def closure(self, state: State) -> tuple[State, list[Action]]:
|
|
622
|
+
"""Open everything reachable that the player can open and come back from."""
|
|
623
|
+
acts = []
|
|
624
|
+
while True:
|
|
625
|
+
world = self.world(state.opened)
|
|
626
|
+
tree = self.reach(world, state.cell)
|
|
627
|
+
options = []
|
|
628
|
+
for t in self.grid.things:
|
|
629
|
+
if t.id in state.opened or not self.can_open(t, state.held):
|
|
630
|
+
continue
|
|
631
|
+
spots = [s for s in self.touch_cells(world, t) if self.stands(world, tree, s)]
|
|
632
|
+
if spots:
|
|
633
|
+
spot = min(spots, key=lambda s: abs(s[0] - state.cell[0]) + abs(s[1] - state.cell[1]))
|
|
634
|
+
options.append((abs(spot[0] - state.cell[0]), t, spot))
|
|
635
|
+
for _, t, spot in sorted(options, key=lambda o: (o[0], o[1].id)):
|
|
636
|
+
opened = state.opened | {t.id}
|
|
637
|
+
after = self.world(opened)
|
|
638
|
+
back = self.reach(after, spot)
|
|
639
|
+
home = after.run_at.get(state.cell)
|
|
640
|
+
if home is not None and home.i in back: # the player can come back
|
|
641
|
+
acts.append(Action("dash" if t.opened_by == "dash" else "touch", t.id, spot))
|
|
642
|
+
state = State(spot, state.held, opened, state.taken)
|
|
643
|
+
break
|
|
644
|
+
else:
|
|
645
|
+
return state, acts
|
|
646
|
+
|
|
647
|
+
def branches(self, state: State):
|
|
648
|
+
world = self.world(state.opened)
|
|
649
|
+
tree = self.reach(world, state.cell)
|
|
650
|
+
for p in self.available(state):
|
|
651
|
+
spot = self.visit(world, tree, p)
|
|
652
|
+
if spot:
|
|
653
|
+
gives = key(p.gives)
|
|
654
|
+
held = (state.held - self.exclusive if gives in self.exclusive else state.held) | {gives}
|
|
655
|
+
yield (State(spot[0], frozenset(held), state.opened | self.gates(p),
|
|
656
|
+
state.taken | {p.id}), Action("take", p.id, spot[0], spot[1])) # fmt: skip
|
|
657
|
+
for t in self.grid.things:
|
|
658
|
+
if t.id in state.opened or not self.can_open(t, state.held):
|
|
659
|
+
continue
|
|
660
|
+
for spot in self.touch_cells(world, t):
|
|
661
|
+
if self.stands(world, tree, spot):
|
|
662
|
+
yield (State(spot, state.held, state.opened | {t.id}, state.taken),
|
|
663
|
+
Action("dash" if t.opened_by == "dash" else "touch", t.id, spot))
|
|
664
|
+
break
|
|
665
|
+
|
|
666
|
+
def run(self, limit: int = 400) -> Solution:
|
|
667
|
+
exit_ = next((t for t in self.grid.things if t.kind == "exit"), None)
|
|
668
|
+
start = State(self.grid.start, self.start_held, frozenset(), frozenset())
|
|
669
|
+
if exit_ is None or self.world(frozenset()).run_at.get(self.grid.start) is None:
|
|
670
|
+
why = "the level has no exit" if exit_ is None else "the start is not on a floor"
|
|
671
|
+
return Solution(False, [], set(), [], [why])
|
|
672
|
+
nodes: list[tuple[State, int, list[Action]]] = [(start, -1, [])]
|
|
673
|
+
seen = {start}
|
|
674
|
+
queue = deque([0])
|
|
675
|
+
reached: set[Cell] = set()
|
|
676
|
+
frontier: dict[str, Thing] = {}
|
|
677
|
+
while queue and len(nodes) <= limit:
|
|
678
|
+
i = queue.popleft()
|
|
679
|
+
state, acts = self.closure(nodes[i][0])
|
|
680
|
+
world = self.world(state.opened)
|
|
681
|
+
tree = self.reach(world, state.cell)
|
|
682
|
+
reached |= {(c, world.runs[r].row) for r in tree
|
|
683
|
+
for c in range(world.runs[r].c0, world.runs[r].c1 + 1)} # fmt: skip
|
|
684
|
+
for t in self.grid.things:
|
|
685
|
+
if t.kind in LOCKS and t.id not in state.opened and any(
|
|
686
|
+
self.stands(world, tree, s) for s in self.touch_cells(world, t)): # fmt: skip
|
|
687
|
+
frontier[t.id] = t
|
|
688
|
+
goal = self.visit(world, tree, exit_)
|
|
689
|
+
if goal:
|
|
690
|
+
actions = self.path(i, nodes) + acts + [Action("exit", exit_.id, goal[0], goal[1])]
|
|
691
|
+
route = self.route(actions)
|
|
692
|
+
return Solution(True, route, reached, actions, states=len(nodes))
|
|
693
|
+
for nxt, act in self.branches(state):
|
|
694
|
+
if nxt not in seen:
|
|
695
|
+
seen.add(nxt)
|
|
696
|
+
nodes.append((nxt, i, acts + [act]))
|
|
697
|
+
queue.append(len(nodes) - 1)
|
|
698
|
+
held = set().union(*(s.held for s, _, _ in nodes)) | self.start_held
|
|
699
|
+
return Solution(False, [], reached, [], self.why(reached, frontier, held, exit_),
|
|
700
|
+
states=len(nodes)) # fmt: skip
|
|
701
|
+
|
|
702
|
+
def path(self, i: int, nodes) -> list[Action]:
|
|
703
|
+
out = []
|
|
704
|
+
while i > 0:
|
|
705
|
+
_, parent, acts = nodes[i]
|
|
706
|
+
out = acts + out
|
|
707
|
+
i = parent
|
|
708
|
+
return out
|
|
709
|
+
|
|
710
|
+
def why(self, reached: set[Cell], frontier: dict[str, Thing], held: set[str],
|
|
711
|
+
exit_: Thing) -> list[str]: # fmt: skip
|
|
712
|
+
out = []
|
|
713
|
+
for t in frontier.values():
|
|
714
|
+
if t.kind == "gate":
|
|
715
|
+
levers = [x for x in self.grid.things if x.kind == "lever" and key(x.gives) == key(t.needs)]
|
|
716
|
+
out.append(f"a gate ({t.entity or 'gate'}) at column {t.col} stays shut: "
|
|
717
|
+
+ (f"its lever at column {levers[0].col}, row {levers[0].row} cannot be "
|
|
718
|
+
"reached before it" if levers else "no lever opens it")) # fmt: skip
|
|
719
|
+
continue
|
|
720
|
+
if t.opened_by == "never": # live 2026-11-03: this was not said, and the retry failed
|
|
721
|
+
if t.kind in ("enemy", "breakable"):
|
|
722
|
+
what = "an enemy" if t.kind == "enemy" else "a breakable wall"
|
|
723
|
+
out.append(f"{what} ({t.entity}) at column {t.col} blocks the way and nothing "
|
|
724
|
+
"beats it (opened_by 'never'): give it something that beats it, or "
|
|
725
|
+
"leave room to jump over it") # fmt: skip
|
|
726
|
+
continue
|
|
727
|
+
if t.needs and key(t.needs) not in held:
|
|
728
|
+
what = {"enemy": "an enemy", "breakable": "a breakable wall", "crate": "a crate"}[t.kind]
|
|
729
|
+
out.append(f"{what} ({t.entity}) at column {t.col} blocks the way and needs "
|
|
730
|
+
f"{t.needs}, but no {t.needs} can be picked up before it") # fmt: skip
|
|
731
|
+
far = max(reached, key=lambda c: c[0], default=self.grid.start)
|
|
732
|
+
out.append(f"the exit at column {exit_.col}, row {exit_.row} cannot be reached: the player "
|
|
733
|
+
f"gets no further than column {far[0]}, row {far[1]}") # fmt: skip
|
|
734
|
+
return out
|
|
735
|
+
|
|
736
|
+
# the route the bot plays
|
|
737
|
+
|
|
738
|
+
def route(self, actions: list[Action]) -> list[Step]:
|
|
739
|
+
steps: list[Step] = []
|
|
740
|
+
opened: frozenset[str] = frozenset()
|
|
741
|
+
cell = self.grid.start
|
|
742
|
+
for act in actions:
|
|
743
|
+
world = self.world(opened)
|
|
744
|
+
tree = self.reach(world, cell)
|
|
745
|
+
steps += self.walk(world, tree, cell, act.at)
|
|
746
|
+
cell = act.at
|
|
747
|
+
if act.hop:
|
|
748
|
+
steps.append(Step("jump", move=act.hop, cell=act.at))
|
|
749
|
+
if act.do in {"touch", "dash"}:
|
|
750
|
+
t = self.grid.thing(act.thing)
|
|
751
|
+
steps.append(Step(act.do, x=act.at[0] + 0.5, cell=act.at, thing=act.thing))
|
|
752
|
+
opened = opened | {act.thing}
|
|
753
|
+
if t.kind == "crate" and t.holds and not t.gives:
|
|
754
|
+
spot = t.cells[0]
|
|
755
|
+
after = self.world(opened)
|
|
756
|
+
if after.standable(*spot) and after.run_at.get(spot) is after.run_at.get(cell):
|
|
757
|
+
steps.append(Step("collect", x=spot[0] + 0.5, cell=spot, thing=t.id, drop=True))
|
|
758
|
+
cell = spot
|
|
759
|
+
elif act.do == "take":
|
|
760
|
+
ident, drop = act.thing.rstrip(">"), act.thing.endswith(">")
|
|
761
|
+
steps.append(Step("collect", x=act.at[0] + 0.5, cell=act.at, thing=ident, drop=drop))
|
|
762
|
+
if not drop: # a lever opens its gates
|
|
763
|
+
opened = opened | self.gates(self.grid.thing(ident))
|
|
764
|
+
elif act.do == "exit":
|
|
765
|
+
steps.append(Step("exit", x=act.at[0] + 0.5, cell=act.at, thing=act.thing))
|
|
766
|
+
return steps
|
|
767
|
+
|
|
768
|
+
def walk(self, world: World, tree, cell: Cell, to: Cell) -> list[Step]:
|
|
769
|
+
here, there = world.run_at[cell], world.run_at[to]
|
|
770
|
+
chain = []
|
|
771
|
+
i = there.i
|
|
772
|
+
while i != here.i:
|
|
773
|
+
parent, ln = tree[i]
|
|
774
|
+
chain.append(ln)
|
|
775
|
+
i = parent
|
|
776
|
+
steps = []
|
|
777
|
+
for ln in reversed(chain):
|
|
778
|
+
steps.append(Step(ln.move.kind, move=ln.move, cell=ln.flight.cell))
|
|
779
|
+
if not steps or steps[-1].cell != to:
|
|
780
|
+
steps.append(Step("walk", x=to[0] + 0.5, cell=to))
|
|
781
|
+
return steps
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
@lru_cache(maxsize=64)
|
|
785
|
+
def gap_limits(m: Motion) -> tuple[int, int]:
|
|
786
|
+
"""The widest gap the model crosses with a jump, and with a jump and a dash (0 without a
|
|
787
|
+
dash). Edge take-offs make these one wider than the plan's whole-tile estimate at times
|
|
788
|
+
(live 2026-11-02: the plan said 3, a jump from the very edge crossed 4)."""
|
|
789
|
+
|
|
790
|
+
def widest(motion: Motion) -> int:
|
|
791
|
+
best = 0
|
|
792
|
+
for gap in range(1, 24):
|
|
793
|
+
cols = gap + 8
|
|
794
|
+
tiles = {(c, 2): "solid" for c in range(cols) if not 3 <= c < 3 + gap}
|
|
795
|
+
grid = Grid(cols, 3, tiles, [Thing("x", "exit", "", ((cols - 2, 1),))], (1, 1))
|
|
796
|
+
if not solve(grid, motion).solved:
|
|
797
|
+
break
|
|
798
|
+
best = gap
|
|
799
|
+
return best
|
|
800
|
+
|
|
801
|
+
jump = widest(replace(m, dash=0.0))
|
|
802
|
+
return jump, (widest(m) if m.dash > 0 else 0)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def solve(grid: Grid, m: Motion, held: set[str] = (), exclusive: set[str] = (),
|
|
806
|
+
limit: int = 400) -> Solution: # fmt: skip
|
|
807
|
+
return Search(grid, m, set(held), set(exclusive)).run(limit)
|