spawnpoint-studio 0.1.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.
- spawnpoint/__init__.py +3 -0
- spawnpoint/_data/examples/coin_hopper.json +228 -0
- spawnpoint/_data/examples/neon_arena.json +251 -0
- spawnpoint/_data/examples/pixel_arena.json +242 -0
- spawnpoint/_data/examples/pixel_hopper.json +221 -0
- spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
- spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
- spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
- spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
- spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
- spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
- spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
- spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
- spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
- spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
- spawnpoint/analyze.py +387 -0
- spawnpoint/build.py +146 -0
- spawnpoint/cli.py +290 -0
- spawnpoint/cockpit/__init__.py +0 -0
- spawnpoint/cockpit/server.py +473 -0
- spawnpoint/cockpit/static/app.css +111 -0
- spawnpoint/cockpit/static/app.js +327 -0
- spawnpoint/cockpit/static/index.html +79 -0
- spawnpoint/diagnostics.py +91 -0
- spawnpoint/expr.py +219 -0
- spawnpoint/gif.py +58 -0
- spawnpoint/godot.py +107 -0
- spawnpoint/graph.py +175 -0
- spawnpoint/level.py +157 -0
- spawnpoint/llm_server.py +77 -0
- spawnpoint/manifest.py +222 -0
- spawnpoint/playtest/__init__.py +104 -0
- spawnpoint/playtest/report.py +148 -0
- spawnpoint/playtest/runner.py +51 -0
- spawnpoint/playtest/tuner.py +151 -0
- spawnpoint/project.py +94 -0
- spawnpoint/py.typed +0 -0
- spawnpoint/rules.py +310 -0
- spawnpoint/sfx.py +352 -0
- spawnpoint/sprites/__init__.py +0 -0
- spawnpoint/sprites/ai.py +222 -0
- spawnpoint/sprites/contour.py +147 -0
- spawnpoint/sprites/generate.py +507 -0
- spawnpoint/sprites/importer.py +212 -0
- spawnpoint/sprites/resolve.py +139 -0
- spawnpoint/sprites/spriteset.py +159 -0
- spawnpoint/sprites/tiles.py +102 -0
- spawnpoint/studio/__init__.py +0 -0
- spawnpoint/studio/assemble.py +528 -0
- spawnpoint/studio/brief.py +197 -0
- spawnpoint/studio/creative.py +124 -0
- spawnpoint/studio/dataset.py +136 -0
- spawnpoint/studio/edits.py +206 -0
- spawnpoint/studio/levelgen.py +352 -0
- spawnpoint/studio/llm.py +145 -0
- spawnpoint/studio/orchestrator.py +341 -0
- spawnpoint/studio/prompts.py +122 -0
- spawnpoint/studio/web.py +123 -0
- spawnpoint/studio/webcheck.py +210 -0
- spawnpoint/studio_cli.py +232 -0
- spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
- spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
- spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
- spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
- spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +49 -0
- tests/test_ai_sprites.py +122 -0
- tests/test_analyze.py +116 -0
- tests/test_cockpit.py +148 -0
- tests/test_e2e.py +33 -0
- tests/test_expr.py +59 -0
- tests/test_gif.py +19 -0
- tests/test_godot_library.py +17 -0
- tests/test_graph.py +44 -0
- tests/test_level.py +64 -0
- tests/test_live_llm.py +38 -0
- tests/test_manifest.py +54 -0
- tests/test_playtest.py +129 -0
- tests/test_rules.py +85 -0
- tests/test_sfx.py +30 -0
- tests/test_sprites.py +261 -0
- tests/test_studio.py +335 -0
- tests/test_web.py +41 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Deterministic level generators.
|
|
2
|
+
|
|
3
|
+
Platformer levels are stitched from hand-designed chunks; top-down levels are bordered arenas whose
|
|
4
|
+
obstacles never disconnect the floor. Generators only produce geometry and *slots*; the assembler
|
|
5
|
+
decides what goes into each slot.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
from collections import deque
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
Cell = tuple[int, int]
|
|
15
|
+
|
|
16
|
+
PLATFORMER_HEIGHT = 14
|
|
17
|
+
STAND_ROW = 11 # row the player / walkers stand on (ground top is row 12)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Layout:
|
|
22
|
+
width: int
|
|
23
|
+
height: int
|
|
24
|
+
tiles: dict[Cell, str] = field(default_factory=dict) # solid | oneway
|
|
25
|
+
player: Cell = (0, 0)
|
|
26
|
+
exit: Cell | None = None
|
|
27
|
+
door: Cell | None = None
|
|
28
|
+
ground_slots: list[Cell] = field(default_factory=list) # walkers, turrets...
|
|
29
|
+
air_slots: list[Cell] = field(default_factory=list) # pickups
|
|
30
|
+
hazard_slots: list[Cell] = field(default_factory=list) # spikes
|
|
31
|
+
portal_slots: list[Cell] = field(default_factory=list) # spawners (top-down)
|
|
32
|
+
free_cells: list[Cell] = field(default_factory=list) # walkable, unoccupied (top-down)
|
|
33
|
+
chunks: list[str] = field(default_factory=list) # platformer chunk names, in order
|
|
34
|
+
|
|
35
|
+
def is_free(self, c: Cell) -> bool:
|
|
36
|
+
return c not in self.tiles
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --- platformer chunks ------------------------------------------------------------------------
|
|
40
|
+
# Rows are absolute rows 4..13 (the top 4 rows are always empty). Markers:
|
|
41
|
+
# '#' solid '=' one-way 'g' ground enemy slot 'c' pickup slot 'h' hazard slot '.' empty
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Chunk:
|
|
46
|
+
name: str
|
|
47
|
+
rows: tuple[str, ...]
|
|
48
|
+
needs_up: int # tiles of jump height required
|
|
49
|
+
pit: bool = False
|
|
50
|
+
min_difficulty: int = 1
|
|
51
|
+
needs_across: int = 0 # tiles of horizontal jump distance required
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
CHUNKS: tuple[Chunk, ...] = (
|
|
55
|
+
Chunk("flat", (
|
|
56
|
+
"..........",
|
|
57
|
+
"..........",
|
|
58
|
+
"..........",
|
|
59
|
+
"..........",
|
|
60
|
+
"..........",
|
|
61
|
+
"...c..c...",
|
|
62
|
+
"..........",
|
|
63
|
+
"..g....g..",
|
|
64
|
+
"##########",
|
|
65
|
+
"##########"), 0),
|
|
66
|
+
Chunk("low_platforms", (
|
|
67
|
+
"............",
|
|
68
|
+
"............",
|
|
69
|
+
"............",
|
|
70
|
+
"............",
|
|
71
|
+
"....cc......",
|
|
72
|
+
"............",
|
|
73
|
+
"...====.....",
|
|
74
|
+
".g.......g..",
|
|
75
|
+
"############",
|
|
76
|
+
"############"), 2),
|
|
77
|
+
Chunk("stairs", (
|
|
78
|
+
"..............",
|
|
79
|
+
"..............",
|
|
80
|
+
"..........cc..",
|
|
81
|
+
"..............",
|
|
82
|
+
".........====.",
|
|
83
|
+
"..............",
|
|
84
|
+
"..====........",
|
|
85
|
+
"g......g......",
|
|
86
|
+
"##############",
|
|
87
|
+
"##############"), 2),
|
|
88
|
+
Chunk("block_step", (
|
|
89
|
+
"............",
|
|
90
|
+
"............",
|
|
91
|
+
"............",
|
|
92
|
+
"............",
|
|
93
|
+
"....c.......",
|
|
94
|
+
"............",
|
|
95
|
+
"....###.....",
|
|
96
|
+
".g..###...g.",
|
|
97
|
+
"############",
|
|
98
|
+
"############"), 2),
|
|
99
|
+
Chunk("tower", (
|
|
100
|
+
"............",
|
|
101
|
+
"............",
|
|
102
|
+
"............",
|
|
103
|
+
".....ccc....",
|
|
104
|
+
"............",
|
|
105
|
+
".....###....",
|
|
106
|
+
".....###....",
|
|
107
|
+
".g...###..g.",
|
|
108
|
+
"############",
|
|
109
|
+
"############"), 3, min_difficulty=2),
|
|
110
|
+
Chunk("high_platform", (
|
|
111
|
+
"............",
|
|
112
|
+
"............",
|
|
113
|
+
"....cccc....",
|
|
114
|
+
"............",
|
|
115
|
+
"...======...",
|
|
116
|
+
"............",
|
|
117
|
+
"............",
|
|
118
|
+
"..g......g..",
|
|
119
|
+
"############",
|
|
120
|
+
"############"), 4, min_difficulty=2),
|
|
121
|
+
Chunk("spike_run", (
|
|
122
|
+
"............",
|
|
123
|
+
"............",
|
|
124
|
+
"............",
|
|
125
|
+
"............",
|
|
126
|
+
"............",
|
|
127
|
+
"....c..c....",
|
|
128
|
+
"............",
|
|
129
|
+
"..hh....hh..",
|
|
130
|
+
"############",
|
|
131
|
+
"############"), 2),
|
|
132
|
+
Chunk("low_ceiling", (
|
|
133
|
+
"............",
|
|
134
|
+
"............",
|
|
135
|
+
"............",
|
|
136
|
+
"............",
|
|
137
|
+
"..########..",
|
|
138
|
+
"............",
|
|
139
|
+
"...c.c.c....",
|
|
140
|
+
"..g.....g...",
|
|
141
|
+
"############",
|
|
142
|
+
"############"), 0),
|
|
143
|
+
Chunk("gap", (
|
|
144
|
+
"..........",
|
|
145
|
+
"..........",
|
|
146
|
+
"..........",
|
|
147
|
+
"..........",
|
|
148
|
+
"..........",
|
|
149
|
+
"....cc....",
|
|
150
|
+
"..........",
|
|
151
|
+
"..g.......",
|
|
152
|
+
"####..####",
|
|
153
|
+
"####..####"), 0, pit=True, needs_across=3),
|
|
154
|
+
Chunk("bridge_gap", (
|
|
155
|
+
"..............",
|
|
156
|
+
"..............",
|
|
157
|
+
"..............",
|
|
158
|
+
"..............",
|
|
159
|
+
"......cc......",
|
|
160
|
+
"..............",
|
|
161
|
+
".....====.....",
|
|
162
|
+
"..g........g..",
|
|
163
|
+
"####......####",
|
|
164
|
+
"####......####"), 2, pit=True, min_difficulty=2, needs_across=3),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
START = (
|
|
168
|
+
"......",
|
|
169
|
+
"......",
|
|
170
|
+
"......",
|
|
171
|
+
"......",
|
|
172
|
+
"......",
|
|
173
|
+
"......",
|
|
174
|
+
"......",
|
|
175
|
+
"..P...",
|
|
176
|
+
"######",
|
|
177
|
+
"######",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
LENGTH_CHUNKS = {"short": 4, "medium": 7, "long": 11}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _fits(c: Chunk, jump_up: int, jump_across: int, pits: bool) -> bool:
|
|
184
|
+
return c.needs_up <= jump_up and c.needs_across <= jump_across and (pits or not c.pit)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def usable_chunks(jump_up: int, jump_across: int, pits: bool, difficulty: int) -> list[Chunk]:
|
|
188
|
+
return [c for c in CHUNKS if _fits(c, jump_up, jump_across, pits) and c.min_difficulty <= difficulty]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
CHUNK_BY_NAME = {c.name: c for c in CHUNKS}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def plan_chunks(seed: int, length: str, jump_up: int, jump_across: int, pits: bool, difficulty: int) -> list[str]:
|
|
195
|
+
rng = random.Random(f"chunks:{seed}")
|
|
196
|
+
pool = usable_chunks(jump_up, jump_across, pits, difficulty)
|
|
197
|
+
picks: list[Chunk] = []
|
|
198
|
+
for _ in range(LENGTH_CHUNKS[length]):
|
|
199
|
+
options = [c for c in pool if not picks or c.name != picks[-1].name] or pool
|
|
200
|
+
# Harder levels lean toward chunks that need more skill.
|
|
201
|
+
weights = [1.0 + (c.needs_up + (2 if c.pit else 0)) * (difficulty - 1) * 0.25 for c in options]
|
|
202
|
+
picks.append(rng.choices(options, weights)[0])
|
|
203
|
+
return [c.name for c in picks]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def chunks_fit(names: list[str], length: str, jump_up: int, jump_across: int, pits: bool) -> bool:
|
|
207
|
+
"""Whether a previously planned chunk sequence still works for changed player abilities."""
|
|
208
|
+
if len(names) != LENGTH_CHUNKS[length] or any(n not in CHUNK_BY_NAME for n in names):
|
|
209
|
+
return False
|
|
210
|
+
return all(_fits(CHUNK_BY_NAME[n], jump_up, jump_across, pits) for n in names)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def platformer(seed: int, length: str, jump_up: int, jump_across: int, pits: bool, difficulty: int, door: bool,
|
|
214
|
+
chunks: list[str] | None = None) -> Layout:
|
|
215
|
+
names = chunks if chunks is not None else plan_chunks(seed, length, jump_up, jump_across, pits, difficulty)
|
|
216
|
+
picks = [CHUNK_BY_NAME[n] for n in names]
|
|
217
|
+
|
|
218
|
+
lay = Layout(0, PLATFORMER_HEIGHT)
|
|
219
|
+
x0 = _stamp(lay, START, 0)
|
|
220
|
+
for chunk in picks:
|
|
221
|
+
x0 = _stamp(lay, chunk.rows, x0)
|
|
222
|
+
x0 = _end_chunk(lay, x0, door)
|
|
223
|
+
lay.width = x0
|
|
224
|
+
lay.chunks = list(names)
|
|
225
|
+
return lay
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def floor_below(lay: Layout, c: Cell) -> int | None:
|
|
229
|
+
"""Height in tiles of cell `c` above the floor the player would stand on beneath it."""
|
|
230
|
+
x, y = c
|
|
231
|
+
for yy in range(y + 1, lay.height):
|
|
232
|
+
if (x, yy) in lay.tiles:
|
|
233
|
+
return yy - 1 - y
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _stamp(lay: Layout, rows: tuple[str, ...], x0: int) -> int:
|
|
238
|
+
top = PLATFORMER_HEIGHT - len(rows)
|
|
239
|
+
for dy, row in enumerate(rows):
|
|
240
|
+
y = top + dy
|
|
241
|
+
for dx, ch in enumerate(row):
|
|
242
|
+
c = (x0 + dx, y)
|
|
243
|
+
if ch == "#":
|
|
244
|
+
lay.tiles[c] = "solid"
|
|
245
|
+
elif ch == "=":
|
|
246
|
+
lay.tiles[c] = "oneway"
|
|
247
|
+
elif ch == "g":
|
|
248
|
+
lay.ground_slots.append(c)
|
|
249
|
+
elif ch == "c":
|
|
250
|
+
lay.air_slots.append(c)
|
|
251
|
+
elif ch == "h":
|
|
252
|
+
lay.hazard_slots.append(c)
|
|
253
|
+
elif ch == "P":
|
|
254
|
+
lay.player = c
|
|
255
|
+
return x0 + len(rows[0])
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _end_chunk(lay: Layout, x0: int, door: bool) -> int:
|
|
259
|
+
width = 9
|
|
260
|
+
for x in range(x0, x0 + width):
|
|
261
|
+
lay.tiles[(x, 12)] = lay.tiles[(x, 13)] = "solid"
|
|
262
|
+
if door:
|
|
263
|
+
# A wall from the ceiling down to the door, so the exit can only be reached through it.
|
|
264
|
+
for y in range(0, 8):
|
|
265
|
+
lay.tiles[(x0 + 2, y)] = "solid"
|
|
266
|
+
lay.door = (x0 + 2, STAND_ROW)
|
|
267
|
+
lay.exit = (x0 + 6, STAND_ROW)
|
|
268
|
+
return x0 + width
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def extra_ground_cells(lay: Layout, min_x: int) -> list[Cell]:
|
|
272
|
+
"""Standing cells on solid ground that no slot uses (for overflow enemies / pickups)."""
|
|
273
|
+
used = set(lay.ground_slots) | set(lay.air_slots) | set(lay.hazard_slots)
|
|
274
|
+
blocked = {lay.player, lay.exit, lay.door}
|
|
275
|
+
out = []
|
|
276
|
+
for x in range(min_x, lay.width):
|
|
277
|
+
c = (x, STAND_ROW)
|
|
278
|
+
if lay.tiles.get((x, STAND_ROW + 1)) == "solid" and lay.is_free(c) and c not in used and c not in blocked \
|
|
279
|
+
and lay.is_free((x, STAND_ROW - 1)):
|
|
280
|
+
out.append(c)
|
|
281
|
+
if lay.door:
|
|
282
|
+
out = [c for c in out if c[0] < lay.door[0] - 1]
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# --- top-down arena ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
ARENA_SIZE = {"short": (28, 18), "medium": (36, 24), "long": (46, 30)}
|
|
289
|
+
OBSTACLES = ((2, 2), (4, 1), (1, 4), (3, 3), (2, 1), (1, 2))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def arena(seed: int, length: str, difficulty: int) -> Layout:
|
|
293
|
+
w, h = ARENA_SIZE[length]
|
|
294
|
+
lay = Layout(w, h)
|
|
295
|
+
for x in range(w):
|
|
296
|
+
lay.tiles[(x, 0)] = lay.tiles[(x, h - 1)] = "solid"
|
|
297
|
+
for y in range(h):
|
|
298
|
+
lay.tiles[(0, y)] = lay.tiles[(w - 1, y)] = "solid"
|
|
299
|
+
lay.player = (w // 2, h // 2)
|
|
300
|
+
|
|
301
|
+
rng = random.Random(f"arena:{seed}")
|
|
302
|
+
target = (w * h) // (34 - difficulty * 2)
|
|
303
|
+
placed = attempts = 0
|
|
304
|
+
while placed * 4 < target and attempts < 400:
|
|
305
|
+
attempts += 1
|
|
306
|
+
ow, oh = rng.choice(OBSTACLES)
|
|
307
|
+
x, y = rng.randint(2, w - 3 - ow), rng.randint(2, h - 3 - oh)
|
|
308
|
+
cells = [(x + i, y + j) for i in range(ow) for j in range(oh)]
|
|
309
|
+
if any(abs(cx - lay.player[0]) <= 3 and abs(cy - lay.player[1]) <= 3 for cx, cy in cells):
|
|
310
|
+
continue
|
|
311
|
+
if any(c in lay.tiles for c in cells):
|
|
312
|
+
continue
|
|
313
|
+
for c in cells:
|
|
314
|
+
lay.tiles[c] = "solid"
|
|
315
|
+
if not _connected(lay):
|
|
316
|
+
for c in cells:
|
|
317
|
+
del lay.tiles[c]
|
|
318
|
+
continue
|
|
319
|
+
placed += 1
|
|
320
|
+
|
|
321
|
+
dist = _distances(lay, lay.player)
|
|
322
|
+
lay.free_cells = sorted(c for c in dist if c != lay.player)
|
|
323
|
+
lay.exit = max(dist, key=lambda c: (dist[c], c))
|
|
324
|
+
corners = [(2, 2), (w - 3, h - 3), (w - 3, 2), (2, h - 3)]
|
|
325
|
+
lay.portal_slots = [_nearest_free(lay, dist, c) for c in corners]
|
|
326
|
+
return lay
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _neighbors(c: Cell):
|
|
330
|
+
x, y = c
|
|
331
|
+
return ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _distances(lay: Layout, start: Cell) -> dict[Cell, int]:
|
|
335
|
+
dist = {start: 0}
|
|
336
|
+
q = deque([start])
|
|
337
|
+
while q:
|
|
338
|
+
c = q.popleft()
|
|
339
|
+
for n in _neighbors(c):
|
|
340
|
+
if n not in dist and lay.is_free(n) and 0 <= n[0] < lay.width and 0 <= n[1] < lay.height:
|
|
341
|
+
dist[n] = dist[c] + 1
|
|
342
|
+
q.append(n)
|
|
343
|
+
return dist
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _connected(lay: Layout) -> bool:
|
|
347
|
+
free = sum(1 for x in range(lay.width) for y in range(lay.height) if lay.is_free((x, y)))
|
|
348
|
+
return len(_distances(lay, lay.player)) == free
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _nearest_free(lay: Layout, dist: dict[Cell, int], c: Cell) -> Cell:
|
|
352
|
+
return min(dist, key=lambda d: (abs(d[0] - c[0]) + abs(d[1] - c[1]), d))
|
spawnpoint/studio/llm.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Minimal OpenAI-compatible chat client with structured (JSON-schema) output.
|
|
2
|
+
|
|
3
|
+
Works with vLLM, OpenAI, and any server implementing /v1/chat/completions with
|
|
4
|
+
`response_format: {"type": "json_schema"}`. No third-party dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "http://localhost:8000/v1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LLMError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Usage:
|
|
26
|
+
calls: int = 0
|
|
27
|
+
prompt_tokens: int = 0
|
|
28
|
+
completion_tokens: int = 0
|
|
29
|
+
seconds: float = 0.0
|
|
30
|
+
|
|
31
|
+
def add(self, other: "Usage") -> None:
|
|
32
|
+
self.calls += other.calls
|
|
33
|
+
self.prompt_tokens += other.prompt_tokens
|
|
34
|
+
self.completion_tokens += other.completion_tokens
|
|
35
|
+
self.seconds += other.seconds
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
return (f"{self.calls} call(s), {self.prompt_tokens} prompt + {self.completion_tokens} completion tokens, "
|
|
39
|
+
f"{self.seconds:.1f}s")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ChatModel(Protocol):
|
|
43
|
+
name: str
|
|
44
|
+
usage: Usage
|
|
45
|
+
|
|
46
|
+
def chat_json(self, messages: list[dict], schema: dict, schema_name: str, *,
|
|
47
|
+
temperature: float = 0.3, max_tokens: int = 1500) -> dict: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class OpenAICompatible:
|
|
52
|
+
base_url: str = field(default_factory=lambda: os.environ.get("SPAWNPOINT_LLM_BASE_URL", DEFAULT_BASE_URL))
|
|
53
|
+
model: str | None = field(default_factory=lambda: os.environ.get("SPAWNPOINT_LLM_MODEL"))
|
|
54
|
+
api_key: str | None = field(default_factory=lambda: os.environ.get("SPAWNPOINT_LLM_API_KEY"))
|
|
55
|
+
timeout: float = 180.0
|
|
56
|
+
usage: Usage = field(default_factory=Usage)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def name(self) -> str:
|
|
60
|
+
return self.model or "?"
|
|
61
|
+
|
|
62
|
+
def _request(self, path: str, payload: dict | None = None) -> dict:
|
|
63
|
+
url = self.base_url.rstrip("/") + path
|
|
64
|
+
data = json.dumps(payload).encode() if payload is not None else None
|
|
65
|
+
req = urllib.request.Request(url, data=data, method="POST" if data else "GET")
|
|
66
|
+
req.add_header("Content-Type", "application/json")
|
|
67
|
+
if self.api_key:
|
|
68
|
+
req.add_header("Authorization", f"Bearer {self.api_key}")
|
|
69
|
+
try:
|
|
70
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
71
|
+
return json.loads(resp.read())
|
|
72
|
+
except urllib.error.HTTPError as e:
|
|
73
|
+
body = e.read().decode(errors="replace")[:500]
|
|
74
|
+
raise LLMError(f"LLM server returned {e.code} for {path}: {body}") from e
|
|
75
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
76
|
+
raise LLMError(
|
|
77
|
+
f"cannot reach the LLM at {self.base_url} ({e}). Start it (e.g. `docker start spawnpoint-llm`) "
|
|
78
|
+
"or set SPAWNPOINT_LLM_BASE_URL.") from e
|
|
79
|
+
|
|
80
|
+
def resolve_model(self) -> str:
|
|
81
|
+
if not self.model:
|
|
82
|
+
models = self._request("/models").get("data", [])
|
|
83
|
+
if not models:
|
|
84
|
+
raise LLMError(f"the LLM server at {self.base_url} lists no models")
|
|
85
|
+
# A fine-tuned adapter (vLLM lists it with a `parent`) beats the base model it sits on.
|
|
86
|
+
adapters = [m for m in models if m.get("parent")]
|
|
87
|
+
self.model = (adapters or models)[0]["id"]
|
|
88
|
+
return self.model
|
|
89
|
+
|
|
90
|
+
def chat_json(self, messages: list[dict], schema: dict, schema_name: str, *,
|
|
91
|
+
temperature: float = 0.3, max_tokens: int = 1500) -> dict:
|
|
92
|
+
payload: dict[str, Any] = {
|
|
93
|
+
"model": self.resolve_model(),
|
|
94
|
+
"messages": messages,
|
|
95
|
+
"temperature": temperature,
|
|
96
|
+
"max_tokens": max_tokens,
|
|
97
|
+
"response_format": {"type": "json_schema",
|
|
98
|
+
"json_schema": {"name": schema_name, "schema": schema, "strict": True}},
|
|
99
|
+
}
|
|
100
|
+
t0 = time.perf_counter()
|
|
101
|
+
resp = self._request("/chat/completions", payload)
|
|
102
|
+
elapsed = time.perf_counter() - t0
|
|
103
|
+
u = resp.get("usage") or {}
|
|
104
|
+
self.usage.add(Usage(1, u.get("prompt_tokens", 0), u.get("completion_tokens", 0), elapsed))
|
|
105
|
+
try:
|
|
106
|
+
content = resp["choices"][0]["message"]["content"]
|
|
107
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
108
|
+
raise LLMError(f"unexpected LLM response: {str(resp)[:300]}") from e
|
|
109
|
+
return parse_json(content)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_json(content: str) -> dict:
|
|
113
|
+
"""Parse model output; tolerate code fences or text around a JSON object."""
|
|
114
|
+
text = (content or "").strip()
|
|
115
|
+
if text.startswith("```"):
|
|
116
|
+
text = text.strip("`")
|
|
117
|
+
text = text[text.find("{"):]
|
|
118
|
+
try:
|
|
119
|
+
return json.loads(text)
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
start, end = text.find("{"), text.rfind("}")
|
|
122
|
+
if start >= 0 and end > start:
|
|
123
|
+
try:
|
|
124
|
+
return json.loads(text[start:end + 1])
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
pass
|
|
127
|
+
raise LLMError(f"LLM did not return valid JSON: {text[:300]}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ScriptedModel:
|
|
132
|
+
"""Test double: returns canned responses in order and records the prompts it saw."""
|
|
133
|
+
|
|
134
|
+
responses: list[dict]
|
|
135
|
+
name: str = "scripted"
|
|
136
|
+
usage: Usage = field(default_factory=Usage)
|
|
137
|
+
seen: list[list[dict]] = field(default_factory=list)
|
|
138
|
+
|
|
139
|
+
def chat_json(self, messages: list[dict], schema: dict, schema_name: str, *,
|
|
140
|
+
temperature: float = 0.3, max_tokens: int = 1500) -> dict:
|
|
141
|
+
self.seen.append(messages)
|
|
142
|
+
self.usage.add(Usage(1, 0, 0, 0.0))
|
|
143
|
+
if not self.responses:
|
|
144
|
+
raise LLMError("scripted model ran out of responses")
|
|
145
|
+
return self.responses.pop(0)
|