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.
Files changed (118) hide show
  1. spawnpoint/__init__.py +3 -0
  2. spawnpoint/_data/examples/coin_hopper.json +228 -0
  3. spawnpoint/_data/examples/neon_arena.json +251 -0
  4. spawnpoint/_data/examples/pixel_arena.json +242 -0
  5. spawnpoint/_data/examples/pixel_hopper.json +221 -0
  6. spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
  7. spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
  8. spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
  9. spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
  10. spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
  11. spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
  12. spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
  13. spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
  14. spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
  15. spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
  16. spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
  17. spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
  18. spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
  19. spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
  20. spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
  21. spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
  22. spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
  23. spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
  24. spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
  25. spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
  26. spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
  27. spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
  28. spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
  29. spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
  30. spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
  31. spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
  32. spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
  33. spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
  34. spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
  35. spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
  36. spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
  37. spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
  38. spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
  39. spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
  40. spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
  41. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
  42. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
  43. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
  44. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
  45. spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
  46. spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
  47. spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
  48. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
  49. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
  50. spawnpoint/analyze.py +387 -0
  51. spawnpoint/build.py +146 -0
  52. spawnpoint/cli.py +290 -0
  53. spawnpoint/cockpit/__init__.py +0 -0
  54. spawnpoint/cockpit/server.py +473 -0
  55. spawnpoint/cockpit/static/app.css +111 -0
  56. spawnpoint/cockpit/static/app.js +327 -0
  57. spawnpoint/cockpit/static/index.html +79 -0
  58. spawnpoint/diagnostics.py +91 -0
  59. spawnpoint/expr.py +219 -0
  60. spawnpoint/gif.py +58 -0
  61. spawnpoint/godot.py +107 -0
  62. spawnpoint/graph.py +175 -0
  63. spawnpoint/level.py +157 -0
  64. spawnpoint/llm_server.py +77 -0
  65. spawnpoint/manifest.py +222 -0
  66. spawnpoint/playtest/__init__.py +104 -0
  67. spawnpoint/playtest/report.py +148 -0
  68. spawnpoint/playtest/runner.py +51 -0
  69. spawnpoint/playtest/tuner.py +151 -0
  70. spawnpoint/project.py +94 -0
  71. spawnpoint/py.typed +0 -0
  72. spawnpoint/rules.py +310 -0
  73. spawnpoint/sfx.py +352 -0
  74. spawnpoint/sprites/__init__.py +0 -0
  75. spawnpoint/sprites/ai.py +222 -0
  76. spawnpoint/sprites/contour.py +147 -0
  77. spawnpoint/sprites/generate.py +507 -0
  78. spawnpoint/sprites/importer.py +212 -0
  79. spawnpoint/sprites/resolve.py +139 -0
  80. spawnpoint/sprites/spriteset.py +159 -0
  81. spawnpoint/sprites/tiles.py +102 -0
  82. spawnpoint/studio/__init__.py +0 -0
  83. spawnpoint/studio/assemble.py +528 -0
  84. spawnpoint/studio/brief.py +197 -0
  85. spawnpoint/studio/creative.py +124 -0
  86. spawnpoint/studio/dataset.py +136 -0
  87. spawnpoint/studio/edits.py +206 -0
  88. spawnpoint/studio/levelgen.py +352 -0
  89. spawnpoint/studio/llm.py +145 -0
  90. spawnpoint/studio/orchestrator.py +341 -0
  91. spawnpoint/studio/prompts.py +122 -0
  92. spawnpoint/studio/web.py +123 -0
  93. spawnpoint/studio/webcheck.py +210 -0
  94. spawnpoint/studio_cli.py +232 -0
  95. spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
  96. spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
  97. spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
  98. spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
  99. spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  100. spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
  101. tests/conftest.py +49 -0
  102. tests/test_ai_sprites.py +122 -0
  103. tests/test_analyze.py +116 -0
  104. tests/test_cockpit.py +148 -0
  105. tests/test_e2e.py +33 -0
  106. tests/test_expr.py +59 -0
  107. tests/test_gif.py +19 -0
  108. tests/test_godot_library.py +17 -0
  109. tests/test_graph.py +44 -0
  110. tests/test_level.py +64 -0
  111. tests/test_live_llm.py +38 -0
  112. tests/test_manifest.py +54 -0
  113. tests/test_playtest.py +129 -0
  114. tests/test_rules.py +85 -0
  115. tests/test_sfx.py +30 -0
  116. tests/test_sprites.py +261 -0
  117. tests/test_studio.py +335 -0
  118. 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)
@@ -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)