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.
Files changed (218) hide show
  1. spriteforge/__init__.py +1 -0
  2. spriteforge/__main__.py +3 -0
  3. spriteforge/agents/__init__.py +0 -0
  4. spriteforge/agents/assets.py +109 -0
  5. spriteforge/agents/cancellation.py +12 -0
  6. spriteforge/agents/director.py +50 -0
  7. spriteforge/agents/models.py +63 -0
  8. spriteforge/agents/roles/art-director.md +15 -0
  9. spriteforge/agents/roles/art-reviewer.md +14 -0
  10. spriteforge/agents/roles/director.md +14 -0
  11. spriteforge/agents/roles/game-designer.md +15 -0
  12. spriteforge/agents/roles/level-tile-designer.md +16 -0
  13. spriteforge/agents/roles/librarian.md +13 -0
  14. spriteforge/agents/roles/menu-designer.md +15 -0
  15. spriteforge/agents/roles/pixel-artist.md +14 -0
  16. spriteforge/agents/roles/programmer.md +14 -0
  17. spriteforge/agents/roles/qa-playtester.md +13 -0
  18. spriteforge/agents/roles/sound-director.md +14 -0
  19. spriteforge/agents/roles.py +39 -0
  20. spriteforge/agents/router.py +233 -0
  21. spriteforge/agents/router_eval.py +145 -0
  22. spriteforge/agents/routing_state.py +133 -0
  23. spriteforge/agents/runtime.py +430 -0
  24. spriteforge/agents/workflow.py +389 -0
  25. spriteforge/app/__init__.py +0 -0
  26. spriteforge/app/agents_view.py +248 -0
  27. spriteforge/app/chat.py +114 -0
  28. spriteforge/app/extensions.py +115 -0
  29. spriteforge/app/index.html +279 -0
  30. spriteforge/app/local_only.py +49 -0
  31. spriteforge/app/server.py +358 -0
  32. spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
  33. spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
  34. spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
  35. spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
  36. spriteforge/app/static/index.html +13 -0
  37. spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
  38. spriteforge/app/studio.py +278 -0
  39. spriteforge/app/workers.py +73 -0
  40. spriteforge/cli.py +241 -0
  41. spriteforge/config.py +193 -0
  42. spriteforge/config.yaml +157 -0
  43. spriteforge/core/__init__.py +0 -0
  44. spriteforge/core/acting.py +16 -0
  45. spriteforge/core/cost.py +236 -0
  46. spriteforge/core/credentials.py +24 -0
  47. spriteforge/core/demo.py +79 -0
  48. spriteforge/core/forms.py +20 -0
  49. spriteforge/core/gates.py +175 -0
  50. spriteforge/core/security.py +42 -0
  51. spriteforge/core/state.py +222 -0
  52. spriteforge/core/team.py +15 -0
  53. spriteforge/core/usage.py +68 -0
  54. spriteforge/game/__init__.py +0 -0
  55. spriteforge/game/actors.py +377 -0
  56. spriteforge/game/agent_tools.py +178 -0
  57. spriteforge/game/art.py +2097 -0
  58. spriteforge/game/art_checks.py +190 -0
  59. spriteforge/game/art_review.py +518 -0
  60. spriteforge/game/build.py +134 -0
  61. spriteforge/game/bundle.py +1178 -0
  62. spriteforge/game/cli.py +119 -0
  63. spriteforge/game/codegen.py +433 -0
  64. spriteforge/game/codeinfo.py +154 -0
  65. spriteforge/game/dbsource.py +137 -0
  66. spriteforge/game/design.py +342 -0
  67. spriteforge/game/export.py +62 -0
  68. spriteforge/game/gamedb.py +191 -0
  69. spriteforge/game/image_cache.py +61 -0
  70. spriteforge/game/lessons.py +259 -0
  71. spriteforge/game/level_gen.py +118 -0
  72. spriteforge/game/level_preview.py +97 -0
  73. spriteforge/game/levels.py +934 -0
  74. spriteforge/game/loops.py +38 -0
  75. spriteforge/game/menus.py +712 -0
  76. spriteforge/game/mock_game.js +109 -0
  77. spriteforge/game/mock_platformer.js +58 -0
  78. spriteforge/game/motion.py +881 -0
  79. spriteforge/game/pipeline.py +1768 -0
  80. spriteforge/game/plan.py +502 -0
  81. spriteforge/game/playtest.py +318 -0
  82. spriteforge/game/qa.py +664 -0
  83. spriteforge/game/qa_checks.py +223 -0
  84. spriteforge/game/qa_runtime.py +565 -0
  85. spriteforge/game/reach.py +807 -0
  86. spriteforge/game/record.py +138 -0
  87. spriteforge/game/research.py +262 -0
  88. spriteforge/game/revision.py +225 -0
  89. spriteforge/game/sdk/API.md +104 -0
  90. spriteforge/game/sdk/FLOW.md +19 -0
  91. spriteforge/game/sdk/LEVELS.md +65 -0
  92. spriteforge/game/sdk/sf.js +418 -0
  93. spriteforge/game/sdk/sfflow.js +442 -0
  94. spriteforge/game/sdk/sflevel.js +683 -0
  95. spriteforge/game/sdk/sfsound-editor.js +225 -0
  96. spriteforge/game/sdk/sfsound.js +260 -0
  97. spriteforge/game/sdk/sounds.html +67 -0
  98. spriteforge/game/sounds.py +793 -0
  99. spriteforge/game/story.py +188 -0
  100. spriteforge/game/structure.py +98 -0
  101. spriteforge/game/team_reports.py +394 -0
  102. spriteforge/game/tilesets.py +70 -0
  103. spriteforge/game/title_art.py +186 -0
  104. spriteforge/genres/__init__.py +0 -0
  105. spriteforge/library/__init__.py +15 -0
  106. spriteforge/library/cli.py +126 -0
  107. spriteforge/library/describe.py +242 -0
  108. spriteforge/library/looks.py +154 -0
  109. spriteforge/library/models.py +79 -0
  110. spriteforge/library/store.py +422 -0
  111. spriteforge/library/tilesets/README.md +32 -0
  112. spriteforge/library/tilesets/gandalf-seasons.json +111 -0
  113. spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
  114. spriteforge/mcp/__init__.py +0 -0
  115. spriteforge/mcp/cli.py +58 -0
  116. spriteforge/mcp/client.py +57 -0
  117. spriteforge/mcp/filesystem_read.py +32 -0
  118. spriteforge/mcp/gamedb.py +56 -0
  119. spriteforge/mcp/library.py +179 -0
  120. spriteforge/mcp/memory.py +37 -0
  121. spriteforge/mcp/pixel_tools.py +145 -0
  122. spriteforge/mcp/registry.py +145 -0
  123. spriteforge/mcp/service.py +224 -0
  124. spriteforge/mcp/templates/context7.json +6 -0
  125. spriteforge/mcp/templates/fetch.json +6 -0
  126. spriteforge/mcp/templates/filesystem-read.json +6 -0
  127. spriteforge/mcp/templates/pixel-tools.json +6 -0
  128. spriteforge/memory/README.md +10 -0
  129. spriteforge/memory/__init__.py +0 -0
  130. spriteforge/memory/cli.py +131 -0
  131. spriteforge/memory/okf/__init__.py +1 -0
  132. spriteforge/memory/okf/catalog.py +331 -0
  133. spriteforge/memory/okf/codec.py +63 -0
  134. spriteforge/memory/okf/git.py +104 -0
  135. spriteforge/memory/okf/indexes.py +50 -0
  136. spriteforge/memory/okf/models.py +114 -0
  137. spriteforge/memory/okf/search.py +53 -0
  138. spriteforge/memory/okf/seed.py +81 -0
  139. spriteforge/memory/okf/seeds/genres.yaml +121 -0
  140. spriteforge/memory/okf/seeds/other.yaml +150 -0
  141. spriteforge/memory/okf/store.py +402 -0
  142. spriteforge/memory/okf/validate.py +115 -0
  143. spriteforge/pixel/__init__.py +17 -0
  144. spriteforge/pixel/atlas.py +110 -0
  145. spriteforge/pixel/grid.py +174 -0
  146. spriteforge/pixel/palette.py +156 -0
  147. spriteforge/pixel/palettes/dungeon.json +9 -0
  148. spriteforge/pixel/postprocess.py +170 -0
  149. spriteforge/pixel/preview.py +40 -0
  150. spriteforge/pixel/schemas.py +139 -0
  151. spriteforge/pixel/source.py +237 -0
  152. spriteforge/pixel/terrain.py +48 -0
  153. spriteforge/pixel/validate.py +138 -0
  154. spriteforge/prompts/anim_frame.txt +1 -0
  155. spriteforge/prompts/character.txt +1 -0
  156. spriteforge/prompts/item.txt +1 -0
  157. spriteforge/prompts/tile.txt +1 -0
  158. spriteforge/prompts/ui.txt +1 -0
  159. spriteforge/providers/__init__.py +0 -0
  160. spriteforge/providers/base.py +97 -0
  161. spriteforge/providers/http.py +38 -0
  162. spriteforge/providers/mock.py +111 -0
  163. spriteforge/providers/muse_image.py +60 -0
  164. spriteforge/providers/muse_search.py +91 -0
  165. spriteforge/providers/muse_spark.py +66 -0
  166. spriteforge/skills/__init__.py +0 -0
  167. spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
  168. spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
  169. spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
  170. spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
  171. spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
  172. spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
  173. spriteforge/skills/builtin/level-design/SKILL.md +169 -0
  174. spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
  175. spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
  176. spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
  177. spriteforge/skills/builtin/platformer/SKILL.md +153 -0
  178. spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
  179. spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
  180. spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
  181. spriteforge/skills/catalog.py +200 -0
  182. spriteforge/skills/cli.py +41 -0
  183. spriteforge/stages/__init__.py +0 -0
  184. spriteforge/stages/asset_context.py +301 -0
  185. spriteforge/stages/asset_models.py +192 -0
  186. spriteforge/stages/asset_render.py +366 -0
  187. spriteforge/stages/asset_review.py +140 -0
  188. spriteforge/stages/asset_store.py +94 -0
  189. spriteforge/stages/chat_changes.py +236 -0
  190. spriteforge/stages/concept.py +42 -0
  191. spriteforge/stages/game.py +1699 -0
  192. spriteforge/stages/game_confirm.py +112 -0
  193. spriteforge/stages/game_final.py +220 -0
  194. spriteforge/stages/game_record.py +246 -0
  195. spriteforge/stages/gameplay.py +218 -0
  196. spriteforge/stages/intake.py +372 -0
  197. spriteforge/stages/preview_assets.py +212 -0
  198. spriteforge/stages/preview_models.py +167 -0
  199. spriteforge/stages/preview_render.py +169 -0
  200. spriteforge/stages/preview_repair.py +61 -0
  201. spriteforge/stages/preview_review.py +169 -0
  202. spriteforge/stages/production.py +170 -0
  203. spriteforge/stages/references.py +105 -0
  204. spriteforge/stages/rewind.py +128 -0
  205. spriteforge/stages/workflow.py +440 -0
  206. spriteforge/templates/phaser/audit.js +45 -0
  207. spriteforge/templates/phaser/font.png +0 -0
  208. spriteforge/templates/phaser/font.xml +1 -0
  209. spriteforge/templates/phaser/preview.html +9 -0
  210. spriteforge/templates/phaser/room.js +49 -0
  211. spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
  212. spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
  213. spriteforge-0.1.1.dist-info/METADATA +364 -0
  214. spriteforge-0.1.1.dist-info/RECORD +218 -0
  215. spriteforge-0.1.1.dist-info/WHEEL +5 -0
  216. spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
  217. spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
  218. 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)