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,210 @@
|
|
|
1
|
+
"""Load a web build in headless Chrome via the DevTools protocol: console errors + screenshot.
|
|
2
|
+
|
|
3
|
+
Standard library only (a minimal WebSocket client), so it runs anywhere Chrome is installed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import socket
|
|
14
|
+
import struct
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
import urllib.request
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
CHROME_CANDIDATES = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_chrome() -> str | None:
|
|
26
|
+
env = os.environ.get("CHROME_BIN")
|
|
27
|
+
if env:
|
|
28
|
+
return env
|
|
29
|
+
for c in CHROME_CANDIDATES:
|
|
30
|
+
if shutil.which(c):
|
|
31
|
+
return shutil.which(c)
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class WebCheck:
|
|
37
|
+
console: list[str] = field(default_factory=list)
|
|
38
|
+
errors: list[str] = field(default_factory=list)
|
|
39
|
+
screenshot: bytes | None = None
|
|
40
|
+
canvas_size: tuple[int, int] | None = None
|
|
41
|
+
value: object = None # result of `expression`, if given
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _WebSocket:
|
|
45
|
+
def __init__(self, url: str):
|
|
46
|
+
m = re.match(r"ws://([^/:]+):(\d+)(/.*)", url)
|
|
47
|
+
host, port, path = m.group(1), int(m.group(2)), m.group(3)
|
|
48
|
+
self.sock = socket.create_connection((host, port), timeout=30)
|
|
49
|
+
key = base64.b64encode(os.urandom(16)).decode()
|
|
50
|
+
self.sock.sendall((f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
|
|
51
|
+
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
|
|
52
|
+
resp = b""
|
|
53
|
+
while b"\r\n\r\n" not in resp:
|
|
54
|
+
resp += self.sock.recv(4096)
|
|
55
|
+
if b" 101 " not in resp.split(b"\r\n")[0]:
|
|
56
|
+
raise ConnectionError(f"websocket handshake failed: {resp[:200]!r}")
|
|
57
|
+
self._buf = resp.split(b"\r\n\r\n", 1)[1]
|
|
58
|
+
|
|
59
|
+
def send(self, obj: dict) -> None:
|
|
60
|
+
data = json.dumps(obj).encode()
|
|
61
|
+
header = bytearray([0x81])
|
|
62
|
+
n = len(data)
|
|
63
|
+
if n < 126:
|
|
64
|
+
header.append(0x80 | n)
|
|
65
|
+
elif n < 65536:
|
|
66
|
+
header.append(0x80 | 126)
|
|
67
|
+
header += struct.pack(">H", n)
|
|
68
|
+
else:
|
|
69
|
+
header.append(0x80 | 127)
|
|
70
|
+
header += struct.pack(">Q", n)
|
|
71
|
+
mask = os.urandom(4)
|
|
72
|
+
header += mask
|
|
73
|
+
self.sock.sendall(bytes(header) + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
|
|
74
|
+
|
|
75
|
+
def _read(self, n: int) -> bytes:
|
|
76
|
+
while len(self._buf) < n:
|
|
77
|
+
chunk = self.sock.recv(65536)
|
|
78
|
+
if not chunk:
|
|
79
|
+
raise ConnectionError("websocket closed")
|
|
80
|
+
self._buf += chunk
|
|
81
|
+
out, self._buf = self._buf[:n], self._buf[n:]
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
def recv(self) -> dict:
|
|
85
|
+
payload = b""
|
|
86
|
+
while True:
|
|
87
|
+
b1, b2 = self._read(2)
|
|
88
|
+
n = b2 & 0x7F
|
|
89
|
+
if n == 126:
|
|
90
|
+
n = struct.unpack(">H", self._read(2))[0]
|
|
91
|
+
elif n == 127:
|
|
92
|
+
n = struct.unpack(">Q", self._read(8))[0]
|
|
93
|
+
payload += self._read(n)
|
|
94
|
+
if b1 & 0x80:
|
|
95
|
+
break
|
|
96
|
+
return json.loads(payload)
|
|
97
|
+
|
|
98
|
+
def close(self) -> None:
|
|
99
|
+
self.sock.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check(url: str, seconds: float = 12.0, size: tuple[int, int] = (960, 540), keys: list[str] | None = None,
|
|
103
|
+
expression: str | None = None, clicks: list[tuple[float, float, str]] | None = None) -> WebCheck:
|
|
104
|
+
"""Open `url`, wait `seconds` (optionally pressing `keys` midway), return console output and a PNG."""
|
|
105
|
+
chrome = find_chrome()
|
|
106
|
+
if chrome is None:
|
|
107
|
+
raise RuntimeError("Chrome not found (set CHROME_BIN)")
|
|
108
|
+
result = WebCheck()
|
|
109
|
+
with tempfile.TemporaryDirectory() as profile:
|
|
110
|
+
proc = subprocess.Popen(
|
|
111
|
+
[chrome, "--headless=new", "--no-sandbox", f"--user-data-dir={profile}", "--remote-debugging-port=0",
|
|
112
|
+
"--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--autoplay-policy=no-user-gesture-required",
|
|
113
|
+
f"--window-size={size[0]},{size[1]}", "about:blank"],
|
|
114
|
+
stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True)
|
|
115
|
+
try:
|
|
116
|
+
ws_url = _devtools_url(proc)
|
|
117
|
+
port = re.match(r"ws://[^:]+:(\d+)", ws_url).group(1)
|
|
118
|
+
targets = json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=10).read())
|
|
119
|
+
page = next(t for t in targets if t.get("type") == "page")
|
|
120
|
+
ws = _WebSocket(page["webSocketDebuggerUrl"])
|
|
121
|
+
msg_id = 0
|
|
122
|
+
|
|
123
|
+
def call(method: str, **params) -> dict:
|
|
124
|
+
nonlocal msg_id
|
|
125
|
+
msg_id += 1
|
|
126
|
+
ws.send({"id": msg_id, "method": method, "params": params})
|
|
127
|
+
while True:
|
|
128
|
+
m = ws.recv()
|
|
129
|
+
_collect(m, result)
|
|
130
|
+
if m.get("id") == msg_id:
|
|
131
|
+
return m.get("result", {})
|
|
132
|
+
|
|
133
|
+
call("Runtime.enable")
|
|
134
|
+
call("Log.enable")
|
|
135
|
+
call("Page.enable")
|
|
136
|
+
call("Page.navigate", url=url)
|
|
137
|
+
deadline = time.time() + seconds
|
|
138
|
+
pressed = False
|
|
139
|
+
ws.sock.settimeout(0.5)
|
|
140
|
+
while time.time() < deadline:
|
|
141
|
+
if (keys or clicks) and not pressed and time.time() > deadline - seconds / 2:
|
|
142
|
+
ws.sock.settimeout(30)
|
|
143
|
+
for k in keys or []:
|
|
144
|
+
_key(call, k)
|
|
145
|
+
for x, y, button in clicks or []:
|
|
146
|
+
for t in ("mousePressed", "mouseReleased"):
|
|
147
|
+
call("Input.dispatchMouseEvent", type=t, x=x, y=y, button=button, clickCount=1)
|
|
148
|
+
ws.sock.settimeout(0.5)
|
|
149
|
+
pressed = True
|
|
150
|
+
try:
|
|
151
|
+
_collect(ws.recv(), result)
|
|
152
|
+
except (socket.timeout, TimeoutError):
|
|
153
|
+
pass
|
|
154
|
+
ws.sock.settimeout(30)
|
|
155
|
+
shot = call("Page.captureScreenshot", format="png")
|
|
156
|
+
result.screenshot = base64.b64decode(shot["data"]) if shot.get("data") else None
|
|
157
|
+
dims = call("Runtime.evaluate", returnByValue=True, expression=(
|
|
158
|
+
"(() => { const c = document.querySelector('canvas'); return c ? [c.width, c.height] : null })()"))
|
|
159
|
+
value = dims.get("result", {}).get("value")
|
|
160
|
+
result.canvas_size = tuple(value) if value else None
|
|
161
|
+
if expression:
|
|
162
|
+
ev = call("Runtime.evaluate", returnByValue=True, expression=expression)
|
|
163
|
+
result.value = ev.get("result", {}).get("value")
|
|
164
|
+
ws.close()
|
|
165
|
+
finally:
|
|
166
|
+
proc.terminate()
|
|
167
|
+
try:
|
|
168
|
+
proc.wait(timeout=10)
|
|
169
|
+
except subprocess.TimeoutExpired:
|
|
170
|
+
proc.kill()
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _devtools_url(proc: subprocess.Popen) -> str:
|
|
175
|
+
deadline = time.time() + 20
|
|
176
|
+
while time.time() < deadline:
|
|
177
|
+
line = proc.stderr.readline()
|
|
178
|
+
m = re.search(r"DevTools listening on (ws://\S+)", line)
|
|
179
|
+
if m:
|
|
180
|
+
return m.group(1)
|
|
181
|
+
raise RuntimeError("Chrome did not start its DevTools endpoint")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _key(call, key: str) -> None:
|
|
185
|
+
code = {"Space": 32, "ArrowRight": 39, "ArrowLeft": 37, "KeyX": 88}.get(key, 0)
|
|
186
|
+
for t in ("keyDown", "keyUp"):
|
|
187
|
+
call("Input.dispatchKeyEvent", type=t, code=key, key=" " if key == "Space" else key,
|
|
188
|
+
windowsVirtualKeyCode=code)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _collect(m: dict, result: WebCheck) -> None:
|
|
192
|
+
method = m.get("method")
|
|
193
|
+
if method == "Runtime.consoleAPICalled":
|
|
194
|
+
p = m["params"]
|
|
195
|
+
text = " ".join(str(a.get("value", a.get("description", ""))) for a in p.get("args", []))
|
|
196
|
+
result.console.append(f"{p.get('type')}: {text}")
|
|
197
|
+
if p.get("type") == "error":
|
|
198
|
+
result.errors.append(text)
|
|
199
|
+
elif method == "Runtime.exceptionThrown":
|
|
200
|
+
d = m["params"]["exceptionDetails"]
|
|
201
|
+
result.errors.append(d.get("exception", {}).get("description") or d.get("text", "exception"))
|
|
202
|
+
elif method == "Log.entryAdded":
|
|
203
|
+
e = m["params"]["entry"]
|
|
204
|
+
if e.get("level") == "error" and "favicon" not in e.get("url", ""):
|
|
205
|
+
result.errors.append(f"{e.get('source')}: {e.get('text')}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def save_screenshot(res: WebCheck, path: Path) -> None:
|
|
209
|
+
if res.screenshot:
|
|
210
|
+
path.write_bytes(res.screenshot)
|
spawnpoint/studio_cli.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""CLI handlers for the studio commands: create, tweak, serve."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .project import slugify
|
|
12
|
+
from .studio import dataset as dataset_mod, web
|
|
13
|
+
from .studio.brief import GameBrief
|
|
14
|
+
from .studio.llm import LLMError, OpenAICompatible
|
|
15
|
+
from .studio.orchestrator import LayerReport, StudioError, create as do_create, tweak as do_tweak
|
|
16
|
+
|
|
17
|
+
LAYER_NAMES = {0: "L0 core loop", 1: "L1 goals & enemies", 2: "L2 feel & polish"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _say(msg: str) -> None:
|
|
21
|
+
print(msg, flush=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _report_layer(r: LayerReport) -> None:
|
|
25
|
+
b = r.build
|
|
26
|
+
status = "ok" if b.ok else "FAILED"
|
|
27
|
+
secs = b.timings.get("total", 0) + (r.web_seconds or 0)
|
|
28
|
+
_say(f" [{LAYER_NAMES[r.layer]}] {status} in {secs:.1f}s"
|
|
29
|
+
+ (f" (build {b.timings.get('total', 0):.1f}s, web {r.web_seconds:.1f}s)" if r.web_seconds else ""))
|
|
30
|
+
for d in b.diagnostics:
|
|
31
|
+
if d.severity == "error" or r.layer > 0:
|
|
32
|
+
_say(f" {d}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _brief_summary(brief: GameBrief, notes: list[str]) -> None:
|
|
36
|
+
b = brief
|
|
37
|
+
enemies = ", ".join(f"{e.count}x {e.name} ({e.archetype})" for e in b.enemies) or "none"
|
|
38
|
+
_say(f" “{b.title}” — {b.genre}, palette {b.palette}, goal {b.goal}, {b.length}, difficulty {b.difficulty}")
|
|
39
|
+
_say(f" player: {', '.join(b.player.abilities) or 'no abilities'}; jump {b.player.jump_tiles} tiles; "
|
|
40
|
+
f"speed {b.player.speed}; feel {b.feel}; juice {b.juice}")
|
|
41
|
+
_say(f" enemies: {enemies}; pickups: {b.collectible.count}x {b.collectible.name}; hazards: {', '.join(b.hazards) or 'none'}")
|
|
42
|
+
for n in notes:
|
|
43
|
+
_say(f" note: {n}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create(args) -> int:
|
|
47
|
+
if getattr(args, "ai_art", False):
|
|
48
|
+
import os
|
|
49
|
+
os.environ["SPAWNPOINT_AI_ART"] = "1" # read by create(); stored in studio.json for later tweaks
|
|
50
|
+
llm = OpenAICompatible()
|
|
51
|
+
brief = GameBrief.model_validate(json.loads(args.brief.read_text())) if args.brief else None
|
|
52
|
+
server: web.Server | None = None
|
|
53
|
+
out_holder: dict[str, Path] = {}
|
|
54
|
+
|
|
55
|
+
def out_dir_for(b: GameBrief) -> Path:
|
|
56
|
+
out_holder["out"] = (args.out or Path("out") / slugify(b.title)).resolve()
|
|
57
|
+
_say(f"Building into {_rel(out_holder['out'])}")
|
|
58
|
+
return out_holder["out"]
|
|
59
|
+
|
|
60
|
+
def on_brief(b: GameBrief, notes: list[str]) -> None:
|
|
61
|
+
_say("Design:")
|
|
62
|
+
_brief_summary(b, notes)
|
|
63
|
+
|
|
64
|
+
def on_layer(r: LayerReport) -> None:
|
|
65
|
+
nonlocal server
|
|
66
|
+
_report_layer(r)
|
|
67
|
+
if r.build.ok and not args.no_web and not args.no_serve and server is None:
|
|
68
|
+
server = web.Server(out_holder["out"], args.port).start()
|
|
69
|
+
_say(f" ▶ playing at {server.url} (the page reloads as layers land)")
|
|
70
|
+
if not args.no_open:
|
|
71
|
+
webbrowser.open(server.url)
|
|
72
|
+
|
|
73
|
+
recorder = _recorder(args)
|
|
74
|
+
_say(f"Designing with {llm.base_url} ..." if brief is None else "Using the given brief ...")
|
|
75
|
+
try:
|
|
76
|
+
result = do_create(args.prompt, out_dir_for, llm, seed=args.seed, creative=args.creative,
|
|
77
|
+
brief=brief, web_export=not args.no_web, on_layer=on_layer, on_brief=on_brief,
|
|
78
|
+
recorder=recorder, playtest=args.playtest,
|
|
79
|
+
on_progress=lambda m: _say(f" [playtest] {m}"))
|
|
80
|
+
except (LLMError, StudioError) as e:
|
|
81
|
+
_say(f"error: {e}")
|
|
82
|
+
return 2
|
|
83
|
+
if result.creative is not None:
|
|
84
|
+
c = result.creative
|
|
85
|
+
_say(f"Creative pass: {'applied' if c.applied else 'dropped'} after {c.rounds} round(s)"
|
|
86
|
+
+ (f": {json.dumps(c.patch)}" if c.applied else (f" — {c.errors[:2]}" if c.errors else "")))
|
|
87
|
+
if result.playtest is not None:
|
|
88
|
+
_print_playtest(result.playtest)
|
|
89
|
+
_say(f"Done in {result.timings.get('total', 0):.1f}s (design {result.timings.get('design', 0):.1f}s; LLM {result.usage}).")
|
|
90
|
+
out = out_holder["out"]
|
|
91
|
+
_say(f"Change it: spawnpoint tweak {_rel(out)} \"make the jump 20% higher\"")
|
|
92
|
+
_say(f"Native: spawnpoint run {_rel(out)}")
|
|
93
|
+
if not result.ok:
|
|
94
|
+
return 1
|
|
95
|
+
if server is not None and not args.no_serve:
|
|
96
|
+
_say("Serving — press Ctrl+C to stop.")
|
|
97
|
+
try:
|
|
98
|
+
while True:
|
|
99
|
+
time.sleep(3600)
|
|
100
|
+
except KeyboardInterrupt:
|
|
101
|
+
server.stop()
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def tweak(args) -> int:
|
|
106
|
+
llm = OpenAICompatible()
|
|
107
|
+
project = args.project.resolve()
|
|
108
|
+
_say(f"Tweaking {project} ...")
|
|
109
|
+
try:
|
|
110
|
+
result = do_tweak(project, args.request, llm, web_export=not args.no_web, on_layer=_report_layer,
|
|
111
|
+
recorder=_recorder(args))
|
|
112
|
+
except (LLMError, StudioError) as e:
|
|
113
|
+
_say(f"error: {e}")
|
|
114
|
+
return 2
|
|
115
|
+
if result.changes:
|
|
116
|
+
_say("Changes:")
|
|
117
|
+
for c in result.changes:
|
|
118
|
+
_say(f" {c}")
|
|
119
|
+
else:
|
|
120
|
+
_say("No changes (the request did not map to anything the brief controls).")
|
|
121
|
+
for n in result.notes:
|
|
122
|
+
_say(f" note: {n}")
|
|
123
|
+
_say(f"Done in {result.timings.get('total', 0):.1f}s (LLM {result.usage}). "
|
|
124
|
+
"An open browser tab reloads automatically; otherwise run `spawnpoint serve`.")
|
|
125
|
+
return 0 if result.ok else 1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _print_playtest(res) -> None:
|
|
129
|
+
from .playtest import tuner
|
|
130
|
+
|
|
131
|
+
def table(rep, label):
|
|
132
|
+
_say(f" {label}:")
|
|
133
|
+
for p in rep.profiles.values():
|
|
134
|
+
causes = ", ".join(f"{k} {v}" for k, v in list(p.causes.items())[:3]) or "no deaths"
|
|
135
|
+
_say(f" {p.profile:<8} won {p.wins}/{p.runs} deaths {p.deaths:<3} ({causes}) "
|
|
136
|
+
f"missed jumps {p.jumps_missed}/{p.jumps_intended} pickups {p.pickup_rate:.0%} stuck {p.stuck}")
|
|
137
|
+
|
|
138
|
+
table(res.baseline, "before" if res.changes else "results")
|
|
139
|
+
for r in res.rounds:
|
|
140
|
+
verdict = "kept" if r.get("accepted") else "reverted"
|
|
141
|
+
_say(f" round {r['round']} ({verdict}, score {r.get('score_before')} -> {r.get('score_after')}):")
|
|
142
|
+
for c in r["changes"]:
|
|
143
|
+
_say(f" {c}")
|
|
144
|
+
if res.changes:
|
|
145
|
+
table(res.final, "after")
|
|
146
|
+
for issue in res.final.level_issues:
|
|
147
|
+
_say(f" level issue: {issue}")
|
|
148
|
+
_say(f" targets (difficulty {res.difficulty}): {tuner.targets(res.difficulty)}")
|
|
149
|
+
_say(f" report: {_rel(res.files['md'])} heatmap: {_rel(res.files['heatmap'])} ({res.seconds:.1f}s)")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def playtest(args) -> int:
|
|
153
|
+
from . import playtest as pt
|
|
154
|
+
from .studio.orchestrator import studio_playtest
|
|
155
|
+
|
|
156
|
+
project = args.project.resolve()
|
|
157
|
+
profiles = tuple(p.strip() for p in args.profiles.split(",") if p.strip())
|
|
158
|
+
progress = lambda m: _say(f" {m}")
|
|
159
|
+
_say(f"Playtesting {_rel(project)} ...")
|
|
160
|
+
if (project / "studio.json").exists() and args.difficulty is None:
|
|
161
|
+
res = studio_playtest(project, runs=args.runs, tune=args.tune, rounds=args.rounds, on_progress=progress)
|
|
162
|
+
else:
|
|
163
|
+
res = pt.playtest(project, runs=args.runs, profiles=profiles, tune=args.tune, rounds=args.rounds,
|
|
164
|
+
seed=args.seed, difficulty=args.difficulty or 2, on_progress=progress)
|
|
165
|
+
if args.json:
|
|
166
|
+
print(res.files["json"].read_text())
|
|
167
|
+
else:
|
|
168
|
+
_print_playtest(res)
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def studio(args) -> int:
|
|
173
|
+
from .cockpit.server import Cockpit, CockpitServer
|
|
174
|
+
|
|
175
|
+
server = CockpitServer(Cockpit(args.workspace), args.port).start()
|
|
176
|
+
_say(f"Spawn Point Studio at {server.url} (games in {_rel(args.workspace.resolve())}) — Ctrl+C to stop.")
|
|
177
|
+
if not args.no_open:
|
|
178
|
+
webbrowser.open(server.url)
|
|
179
|
+
try:
|
|
180
|
+
while True:
|
|
181
|
+
time.sleep(3600)
|
|
182
|
+
except KeyboardInterrupt:
|
|
183
|
+
server.stop()
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _recorder(args) -> dataset_mod.Recorder | None:
|
|
188
|
+
if not (getattr(args, "record", False) or dataset_mod.enabled_by_env()):
|
|
189
|
+
return None
|
|
190
|
+
rec = dataset_mod.Recorder()
|
|
191
|
+
_say(f"Recording LLM calls as training data in {rec.directory} (local only)")
|
|
192
|
+
return rec
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def dataset(args) -> int:
|
|
196
|
+
if args.action == "path":
|
|
197
|
+
_say(str(dataset_mod.default_dir()))
|
|
198
|
+
return 0
|
|
199
|
+
st = dataset_mod.stats()
|
|
200
|
+
if args.json:
|
|
201
|
+
print(json.dumps(st, indent=2))
|
|
202
|
+
return 0
|
|
203
|
+
_say(f"{st['records']} record(s) in {dataset_mod.default_dir()}")
|
|
204
|
+
for task, t in st["tasks"].items():
|
|
205
|
+
_say(f" {task:<9} {t['count']:>5} accepted {t['accepted']:>5} corrected by the pipeline {t['corrected']:>5}")
|
|
206
|
+
if st["models"]:
|
|
207
|
+
_say(" models: " + ", ".join(f"{m} ({n})" for m, n in st["models"].items()))
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def serve(args) -> int:
|
|
212
|
+
project = args.project.resolve()
|
|
213
|
+
if not (project / web.WEB_DIR / "index.html").exists():
|
|
214
|
+
_say(f"error: {project} has no web build (run `spawnpoint create` or `tweak` first)")
|
|
215
|
+
return 2
|
|
216
|
+
server = web.Server(project, args.port).start()
|
|
217
|
+
_say(f"Playing at {server.url} — press Ctrl+C to stop.")
|
|
218
|
+
if not args.no_open:
|
|
219
|
+
webbrowser.open(server.url)
|
|
220
|
+
try:
|
|
221
|
+
while True:
|
|
222
|
+
time.sleep(3600)
|
|
223
|
+
except KeyboardInterrupt:
|
|
224
|
+
server.stop()
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _rel(p: Path) -> str:
|
|
229
|
+
try:
|
|
230
|
+
return str(p.relative_to(Path.cwd()))
|
|
231
|
+
except ValueError:
|
|
232
|
+
return str(p)
|