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,473 @@
1
+ """Cockpit backend: standard-library HTTP server, JSON API, Server-Sent Events, one build worker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ import json
7
+ import mimetypes
8
+ import queue
9
+ import threading
10
+ import time
11
+ import traceback
12
+ import urllib.request
13
+ from dataclasses import dataclass
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+ from urllib.parse import unquote, urlparse
18
+
19
+ from .. import godot, manifest as manifest_mod
20
+ from ..project import slugify
21
+ from ..studio import web
22
+ from ..studio.llm import ChatModel, LLMError, OpenAICompatible
23
+
24
+ STATIC = Path(__file__).parent / "static"
25
+ FILE_DIRS = ("sprites", "playtest") # project sub-folders the page may read
26
+
27
+
28
+ # --- events -----------------------------------------------------------------------------------
29
+
30
+ class Hub:
31
+ """Fan-out of events to every connected SSE client."""
32
+
33
+ def __init__(self) -> None:
34
+ self._clients: list[queue.Queue] = []
35
+ self._lock = threading.Lock()
36
+
37
+ def subscribe(self) -> queue.Queue:
38
+ q: queue.Queue = queue.Queue(maxsize=1000)
39
+ with self._lock:
40
+ self._clients.append(q)
41
+ return q
42
+
43
+ def unsubscribe(self, q: queue.Queue) -> None:
44
+ with self._lock:
45
+ if q in self._clients:
46
+ self._clients.remove(q)
47
+
48
+ def publish(self, event: dict) -> None:
49
+ event = dict(event, time=round(time.time(), 3))
50
+ with self._lock:
51
+ for q in list(self._clients):
52
+ try:
53
+ q.put_nowait(event)
54
+ except queue.Full:
55
+ pass
56
+
57
+
58
+ @dataclass
59
+ class Job:
60
+ id: int
61
+ kind: str
62
+ project: str | None
63
+ status: str = "queued" # queued | running | done | error
64
+ result: Any = None
65
+ error: str | None = None
66
+
67
+ def to_dict(self) -> dict:
68
+ return {"id": self.id, "kind": self.kind, "project": self.project, "status": self.status,
69
+ "result": self.result, "error": self.error}
70
+
71
+
72
+ class Jobs:
73
+ """Runs jobs one at a time (Godot builds must not overlap) and reports through the hub."""
74
+
75
+ def __init__(self, hub: Hub) -> None:
76
+ self.hub = hub
77
+ self._q: queue.Queue = queue.Queue()
78
+ self._ids = itertools.count(1)
79
+ self.jobs: dict[int, Job] = {}
80
+ threading.Thread(target=self._worker, daemon=True).start()
81
+
82
+ def submit(self, kind: str, project: str | None, fn: Callable[[Callable[[str], None]], Any]) -> Job:
83
+ job = Job(next(self._ids), kind, project)
84
+ self.jobs[job.id] = job
85
+ self._q.put((job, fn))
86
+ self.hub.publish({"type": "queued", "job": job.to_dict()})
87
+ return job
88
+
89
+ def _worker(self) -> None:
90
+ while True:
91
+ job, fn = self._q.get()
92
+ job.status = "running"
93
+ self.hub.publish({"type": "started", "job": job.to_dict()})
94
+
95
+ def log(msg: str, job=job) -> None:
96
+ self.hub.publish({"type": "log", "job": job.id, "message": msg})
97
+
98
+ try:
99
+ job.result = fn(log)
100
+ job.status = "done"
101
+ self.hub.publish({"type": "done", "job": job.to_dict()})
102
+ except Exception as e: # report every failure to the page instead of dying
103
+ job.status, job.error = "error", f"{type(e).__name__}: {e}"
104
+ log(traceback.format_exc(limit=3))
105
+ self.hub.publish({"type": "error", "job": job.to_dict()})
106
+
107
+ def wait(self, job_id: int, timeout: float = 600) -> Job:
108
+ end = time.time() + timeout
109
+ while time.time() < end:
110
+ job = self.jobs[job_id]
111
+ if job.status in ("done", "error"):
112
+ return job
113
+ time.sleep(0.05)
114
+ raise TimeoutError(f"job {job_id} still {self.jobs[job_id].status}")
115
+
116
+
117
+ # --- application ------------------------------------------------------------------------------
118
+
119
+ class Cockpit:
120
+ def __init__(self, workspace: Path, llm_factory: Callable[[], ChatModel] = OpenAICompatible,
121
+ web_export: bool = True) -> None:
122
+ self.workspace = workspace.resolve()
123
+ self.workspace.mkdir(parents=True, exist_ok=True)
124
+ self.llm_factory = llm_factory
125
+ self.web_export = web_export
126
+ self.hub = Hub()
127
+ self.jobs = Jobs(self.hub)
128
+ self.manifest = manifest_mod.load()
129
+
130
+ # -- helpers -----------------------------------------------------------------------------
131
+ def project_dir(self, name: str) -> Path:
132
+ p = (self.workspace / name).resolve()
133
+ if p.parent != self.workspace or not (p / "spawnpoint.json").exists():
134
+ raise KeyError(name)
135
+ return p
136
+
137
+ def projects(self) -> list[dict]:
138
+ out = []
139
+ for d in sorted(self.workspace.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
140
+ g = d / "spawnpoint.json"
141
+ if not g.exists():
142
+ continue
143
+ try:
144
+ graph = json.loads(g.read_text())
145
+ except ValueError:
146
+ continue
147
+ out.append({"name": d.name, "title": graph.get("name", d.name), "genre": graph.get("genre"),
148
+ "studio": (d / "studio.json").exists(), "web": (d / "web" / "index.html").exists(),
149
+ "updated": round(g.stat().st_mtime)})
150
+ return out
151
+
152
+ def detail(self, name: str) -> dict:
153
+ p = self.project_dir(name)
154
+ graph = json.loads((p / "spawnpoint.json").read_text())
155
+ session = json.loads((p / "studio.json").read_text()) if (p / "studio.json").exists() else None
156
+ entities = {}
157
+ for ename, ent in graph["entities"].items():
158
+ comps = {}
159
+ for cname, params in (ent.get("components") or {}).items():
160
+ spec = self.manifest.get(cname)
161
+ comps[cname] = {
162
+ "doc": spec.doc.split("\n")[0] if spec else "",
163
+ "params": [{"name": prm.name, "type": prm.type, "value": params.get(prm.name, prm.default),
164
+ "default": prm.default, "min": prm.min, "max": prm.max,
165
+ "choices": list(prm.choices) if prm.choices else None, "doc": prm.doc,
166
+ "set": prm.name in params}
167
+ for prm in (spec.params.values() if spec else [])],
168
+ }
169
+ entities[ename] = {"body": ent.get("body", "character"), "team": ent.get("team"),
170
+ "components": comps, "sprite": (p / "sprites" / f"{ename}.png").exists()}
171
+ report = p / "playtest" / "report.json"
172
+ return {
173
+ "name": name, "title": graph.get("name"), "genre": graph.get("genre"), "graph": graph,
174
+ "entities": entities, "rules": graph.get("rules", []),
175
+ "brief": session.get("brief") if session else None,
176
+ "history": session.get("history", []) if session else [],
177
+ "overrides": session.get("overrides", []) if session else [],
178
+ "tuning": session.get("tuning", []) if session else [],
179
+ "playtest": json.loads(report.read_text()) if report.exists() else None,
180
+ "web": (p / "web" / "index.html").exists(),
181
+ }
182
+
183
+ def status(self) -> dict:
184
+ llm = self.llm_factory()
185
+ up = True
186
+ model = None
187
+ try:
188
+ if isinstance(llm, OpenAICompatible):
189
+ urllib.request.urlopen(llm.base_url.rstrip("/") + "/models", timeout=2)
190
+ model = llm.resolve_model()
191
+ else:
192
+ model = llm.name
193
+ except Exception:
194
+ up = False
195
+ return {"llm": up, "model": model, "base_url": getattr(llm, "base_url", None), "godot": godot.available(),
196
+ "workspace": str(self.workspace)}
197
+
198
+ # -- jobs --------------------------------------------------------------------------------
199
+ def create(self, prompt: str, creative: bool = False, playtest: bool = False) -> Job:
200
+ from ..studio.orchestrator import create
201
+
202
+ def run(log):
203
+ holder: dict[str, Path] = {}
204
+
205
+ def out_for(brief):
206
+ holder["out"] = self.workspace / slugify(brief.title)
207
+ log(f"designing done: “{brief.title}” ({brief.genre}, goal {brief.goal})")
208
+ return holder["out"]
209
+
210
+ def on_layer(r):
211
+ log(f"layer {r.layer}: {'ok' if r.build.ok else 'FAILED'} "
212
+ f"({r.build.timings.get('total', 0):.1f}s build, {r.web_seconds or 0:.1f}s web)")
213
+ if r.build.ok:
214
+ self.hub.publish({"type": "reload", "project": holder["out"].name})
215
+
216
+ res = create(prompt, out_for, self.llm_factory(), creative=creative, web_export=self.web_export,
217
+ on_layer=on_layer, playtest=playtest, on_progress=lambda m: log(f"playtest: {m}"))
218
+ if not res.ok:
219
+ raise RuntimeError("build failed: " + "; ".join(
220
+ str(d) for r in res.layers for d in r.build.diagnostics.errors))
221
+ return {"project": holder["out"].name, "notes": res.notes}
222
+
223
+ return self.jobs.submit("create", None, run)
224
+
225
+ def tweak(self, name: str, request: str) -> Job:
226
+ from ..studio.orchestrator import tweak
227
+
228
+ p = self.project_dir(name)
229
+
230
+ def run(log):
231
+ res = tweak(p, request, self.llm_factory(), web_export=self.web_export)
232
+ for c in res.changes:
233
+ log(f"changed {c}")
234
+ if not res.ok:
235
+ raise RuntimeError("build failed")
236
+ self.hub.publish({"type": "reload", "project": name})
237
+ return {"changes": res.changes, "notes": res.notes}
238
+
239
+ return self.jobs.submit("tweak", name, run)
240
+
241
+ def edit_entity(self, name: str, entity: str, request: str) -> Job:
242
+ from ..studio.edits import edit_entity
243
+
244
+ p = self.project_dir(name)
245
+
246
+ def run(log):
247
+ res = edit_entity(p, entity, request, self.llm_factory(), manifest=self.manifest,
248
+ web_export=self.web_export)
249
+ if not res.ok:
250
+ raise RuntimeError("; ".join(res.errors[:3]) or "edit failed")
251
+ log(f"applied after {res.rounds} round(s): {json.dumps(res.patch)}")
252
+ self.hub.publish({"type": "reload", "project": name})
253
+ return {"patch": res.patch, "rounds": res.rounds}
254
+
255
+ return self.jobs.submit("entity", name, run)
256
+
257
+ def set_param(self, name: str, entity: str, component: str, param: str, value: Any) -> Job:
258
+ from ..studio.edits import apply_override, set_param_patch
259
+
260
+ p = self.project_dir(name)
261
+
262
+ def run(log):
263
+ res = apply_override(p, set_param_patch(entity, component, param, value), manifest=self.manifest,
264
+ web_export=self.web_export)
265
+ if not res.ok:
266
+ raise RuntimeError("; ".join(res.errors[:3]))
267
+ log(f"{entity}.{component}.{param} = {value}")
268
+ self.hub.publish({"type": "reload", "project": name})
269
+ return {"patch": res.patch}
270
+
271
+ return self.jobs.submit("params", name, run)
272
+
273
+ def playtest(self, name: str, tune: bool, runs: int = 3) -> Job:
274
+ from .. import playtest as pt
275
+ from ..studio.orchestrator import studio_playtest
276
+
277
+ p = self.project_dir(name)
278
+
279
+ def run(log):
280
+ if (p / "studio.json").exists():
281
+ res = studio_playtest(p, runs=runs, tune=tune, web_export=self.web_export, manifest=self.manifest,
282
+ on_progress=log)
283
+ else:
284
+ res = pt.playtest(p, runs=runs, tune=tune, manifest=self.manifest, on_progress=log)
285
+ if self.web_export and tune and res.changes and (p / "web" / "index.html").exists():
286
+ web.export(p)
287
+ if res.changes:
288
+ self.hub.publish({"type": "reload", "project": name})
289
+ return {"changes": [str(c) for c in res.changes], "issues": res.final.level_issues}
290
+
291
+ return self.jobs.submit("playtest", name, run)
292
+
293
+ def export(self, name: str) -> Job:
294
+ p = self.project_dir(name)
295
+
296
+ def run(log):
297
+ secs = web.export(p)
298
+ log(f"web build ready in {secs:.1f}s")
299
+ self.hub.publish({"type": "reload", "project": name})
300
+ return {}
301
+
302
+ return self.jobs.submit("export", name, run)
303
+
304
+
305
+ # --- HTTP -------------------------------------------------------------------------------------
306
+
307
+ def make_handler(app: Cockpit):
308
+ class Handler(BaseHTTPRequestHandler):
309
+ protocol_version = "HTTP/1.1"
310
+
311
+ def log_message(self, format: str, *args) -> None: # quiet
312
+ pass
313
+
314
+ # -- responses ------------------------------------------------------------------------
315
+ def _send(self, code: int, body: bytes, ctype: str, extra: dict | None = None) -> None:
316
+ self.send_response(code)
317
+ self.send_header("Content-Type", ctype)
318
+ self.send_header("Content-Length", str(len(body)))
319
+ self.send_header("Cache-Control", "no-store")
320
+ for k, v in (extra or {}).items():
321
+ self.send_header(k, v)
322
+ self.end_headers()
323
+ self.wfile.write(body)
324
+
325
+ def _json(self, data: Any, code: int = 200) -> None:
326
+ self._send(code, json.dumps(data, ensure_ascii=False).encode(), "application/json; charset=utf-8")
327
+
328
+ def _error(self, code: int, msg: str) -> None:
329
+ self._json({"error": msg}, code)
330
+
331
+ def _file(self, path: Path, root: Path) -> None:
332
+ try:
333
+ path = path.resolve()
334
+ path.relative_to(root.resolve())
335
+ except (ValueError, OSError):
336
+ return self._error(403, "forbidden")
337
+ if path.is_dir():
338
+ path = path / "index.html"
339
+ if not path.is_file():
340
+ return self._error(404, "not found")
341
+ ctype = {".wasm": "application/wasm", ".pck": "application/octet-stream", ".js": "text/javascript",
342
+ ".css": "text/css"}.get(path.suffix) or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
343
+ self._send(200, path.read_bytes(), ctype)
344
+
345
+ def _body(self) -> dict:
346
+ n = int(self.headers.get("Content-Length") or 0)
347
+ if n == 0:
348
+ return {}
349
+ try:
350
+ data = json.loads(self.rfile.read(n))
351
+ except ValueError:
352
+ raise ValueError("invalid JSON body")
353
+ if not isinstance(data, dict):
354
+ raise ValueError("JSON body must be an object")
355
+ return data
356
+
357
+ # -- routing --------------------------------------------------------------------------
358
+ def do_GET(self) -> None:
359
+ path = unquote(urlparse(self.path).path)
360
+ parts = [p for p in path.split("/") if p]
361
+ try:
362
+ if not parts:
363
+ return self._file(STATIC / "index.html", STATIC)
364
+ if parts[0] == "static":
365
+ return self._file(STATIC.joinpath(*parts[1:]), STATIC)
366
+ if parts[0] == "game" and len(parts) >= 2:
367
+ root = app.project_dir(parts[1]) / "web"
368
+ return self._file(root.joinpath(*parts[2:]) if len(parts) > 2 else root, root)
369
+ if parts[0] == "files" and len(parts) >= 3 and parts[2] in FILE_DIRS:
370
+ proj = app.project_dir(parts[1])
371
+ return self._file(proj.joinpath(*parts[2:]), proj / parts[2])
372
+ if parts[:2] == ["api", "events"]:
373
+ return self._events()
374
+ if parts[:2] == ["api", "status"]:
375
+ return self._json(app.status())
376
+ if parts[:2] == ["api", "projects"] and len(parts) == 2:
377
+ return self._json(app.projects())
378
+ if parts[:2] == ["api", "projects"] and len(parts) == 3:
379
+ return self._json(app.detail(parts[2]))
380
+ if parts[:2] == ["api", "jobs"] and len(parts) == 3:
381
+ return self._json(app.jobs.jobs[int(parts[2])].to_dict())
382
+ except KeyError:
383
+ return self._error(404, "no such project or job")
384
+ return self._error(404, "not found")
385
+
386
+ def do_POST(self) -> None:
387
+ parts = [p for p in unquote(urlparse(self.path).path).split("/") if p]
388
+ try:
389
+ body = self._body()
390
+ if parts == ["api", "create"]:
391
+ prompt = str(body.get("prompt", "")).strip()
392
+ if not prompt:
393
+ return self._error(400, "prompt is required")
394
+ job = app.create(prompt, bool(body.get("creative")), bool(body.get("playtest")))
395
+ elif len(parts) == 4 and parts[:2] == ["api", "projects"]:
396
+ name, action = parts[2], parts[3]
397
+ if action == "tweak":
398
+ job = app.tweak(name, _required(body, "request"))
399
+ elif action == "entity":
400
+ job = app.edit_entity(name, _required(body, "entity"), _required(body, "request"))
401
+ elif action == "params":
402
+ if "value" not in body:
403
+ return self._error(400, "value is required")
404
+ job = app.set_param(name, _required(body, "entity"), _required(body, "component"),
405
+ _required(body, "param"), body["value"])
406
+ elif action == "playtest":
407
+ job = app.playtest(name, bool(body.get("tune")), int(body.get("runs", 3)))
408
+ elif action == "export":
409
+ job = app.export(name)
410
+ else:
411
+ return self._error(404, "unknown action")
412
+ else:
413
+ return self._error(404, "not found")
414
+ except KeyError:
415
+ return self._error(404, "no such project")
416
+ except ValueError as e:
417
+ return self._error(400, str(e))
418
+ self._json(job.to_dict(), 202)
419
+
420
+ def _events(self) -> None:
421
+ q = app.hub.subscribe()
422
+ try:
423
+ self.send_response(200)
424
+ self.send_header("Content-Type", "text/event-stream")
425
+ self.send_header("Cache-Control", "no-store")
426
+ self.send_header("Connection", "keep-alive")
427
+ self.end_headers()
428
+ self.wfile.write(b": connected\n\n")
429
+ self.wfile.flush()
430
+ while True:
431
+ try:
432
+ event = q.get(timeout=15)
433
+ self.wfile.write(f"data: {json.dumps(event, ensure_ascii=False)}\n\n".encode())
434
+ except queue.Empty:
435
+ self.wfile.write(b": ping\n\n")
436
+ self.wfile.flush()
437
+ except (BrokenPipeError, ConnectionResetError, OSError):
438
+ pass
439
+ finally:
440
+ app.hub.unsubscribe(q)
441
+
442
+ return Handler
443
+
444
+
445
+ def _required(body: dict, key: str) -> str:
446
+ value = str(body.get(key, "")).strip()
447
+ if not value:
448
+ raise ValueError(f"{key} is required")
449
+ return value
450
+
451
+
452
+ class CockpitServer:
453
+ def __init__(self, app: Cockpit, port: int = 8070) -> None:
454
+ self.app = app
455
+ self.port = web.free_port(port)
456
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", self.port), make_handler(app))
457
+ self.httpd.daemon_threads = True
458
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
459
+
460
+ @property
461
+ def url(self) -> str:
462
+ return f"http://localhost:{self.port}/"
463
+
464
+ def start(self) -> "CockpitServer":
465
+ self.thread.start()
466
+ return self
467
+
468
+ def stop(self) -> None:
469
+ self.httpd.shutdown()
470
+ self.httpd.server_close()
471
+
472
+
473
+ __all__ = ["Cockpit", "CockpitServer", "Hub", "Jobs", "LLMError"]
@@ -0,0 +1,111 @@
1
+ :root {
2
+ --bg: #11131c;
3
+ --panel: #181b27;
4
+ --card: #1f2333;
5
+ --line: #2c3146;
6
+ --text: #e8e6f0;
7
+ --muted: #8d90a8;
8
+ --accent: #29adff;
9
+ --good: #00e436;
10
+ --warn: #ffa300;
11
+ --bad: #ff004d;
12
+ --gold: #ffec27;
13
+ --radius: 8px;
14
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
15
+ color-scheme: dark;
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body { margin: 0; background: var(--bg); color: var(--text); font-size: 14px; height: 100vh; display: flex; flex-direction: column; }
19
+ button, input, textarea, select { font: inherit; color: inherit; }
20
+ h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 16px 0 8px; }
21
+ h3 { font-size: 14px; margin: 0; }
22
+ .muted { color: var(--muted); }
23
+ [hidden] { display: none !important; }
24
+
25
+ .topbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
26
+ .brand { font-size: 16px; letter-spacing: .02em; }
27
+ .logo { color: var(--gold); margin-right: 4px; }
28
+ .pills { display: flex; gap: 8px; }
29
+ .pill { padding: 3px 10px; border-radius: 99px; background: var(--card); border: 1px solid var(--line); font-size: 12px; color: var(--muted); }
30
+ .pill.ok { color: var(--good); border-color: #1d4d2b; }
31
+ .pill.down { color: var(--bad); border-color: #5a1a2c; }
32
+ .pill.busy { color: var(--gold); border-color: #5a5420; }
33
+
34
+ .floor { flex: 1; display: grid; grid-template-columns: 260px minmax(0, 1fr) 400px; gap: 1px; background: var(--line); min-height: 0; }
35
+ .panel { background: var(--panel); padding: 12px; overflow: auto; min-height: 0; }
36
+ @media (max-width: 1100px) {
37
+ .floor { grid-template-columns: 1fr; overflow: auto; }
38
+ .panel { overflow: visible; }
39
+ }
40
+
41
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px; }
42
+ .card label { display: block; font-weight: 600; margin-bottom: 6px; }
43
+ textarea, input[type=text], input:not([type]), input[type=number], select {
44
+ width: 100%; background: #0d0f16; border: 1px solid var(--line); border-radius: 6px; padding: 7px 9px; outline: none;
45
+ }
46
+ textarea:focus, input:focus, select:focus { border-color: var(--accent); }
47
+ textarea { resize: vertical; }
48
+ .row { display: flex; gap: 10px; align-items: center; margin: 8px 0; flex-wrap: wrap; }
49
+ .row.between { justify-content: space-between; margin: 0 0 6px; }
50
+ .check { display: flex !important; gap: 6px; align-items: center; font-weight: 400 !important; margin: 0 !important; color: var(--muted); }
51
+
52
+ button { cursor: pointer; border-radius: 6px; border: 1px solid var(--line); background: var(--card); padding: 7px 12px; }
53
+ button:hover { border-color: var(--accent); }
54
+ button.primary { background: var(--accent); border-color: var(--accent); color: #07121c; font-weight: 600; }
55
+ button.primary:hover { filter: brightness(1.1); }
56
+ button.ghost { background: none; border: none; color: var(--muted); padding: 2px 6px; }
57
+ button:disabled { opacity: .45; cursor: not-allowed; }
58
+
59
+ .list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
60
+ .list li { padding: 8px 10px; border-radius: 6px; cursor: pointer; border: 1px solid transparent; display: flex; justify-content: space-between; gap: 8px; }
61
+ .list li:hover { background: var(--card); }
62
+ .list li.active { background: var(--card); border-color: var(--accent); }
63
+ .badge { font-size: 11px; padding: 1px 7px; border-radius: 99px; background: #0d0f16; color: var(--muted); white-space: nowrap; }
64
+
65
+ #arcade { display: flex; flex-direction: column; gap: 8px; }
66
+ .stage { position: relative; background: #000; border-radius: var(--radius); overflow: hidden; aspect-ratio: 16 / 9; border: 1px solid var(--line); }
67
+ .stage iframe { width: 100%; height: 100%; border: 0; display: block; }
68
+ .stage-empty { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; color: var(--muted); background: var(--bg); gap: 10px; }
69
+ .hint { margin: 0; font-size: 12px; color: var(--muted); }
70
+ .bar { display: flex; gap: 8px; }
71
+ .bar input { flex: 1; }
72
+ .log { flex: 1; min-height: 120px; background: #0b0c12; border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 10px; overflow: auto; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
73
+ .log div { white-space: pre-wrap; word-break: break-word; }
74
+ .log .error { color: var(--bad); }
75
+ .log .done { color: var(--good); }
76
+ .log .start { color: var(--gold); }
77
+
78
+ .selection { border-color: var(--gold); margin-bottom: 10px; }
79
+ .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 10px; }
80
+ .tab { border: none; background: none; padding: 8px 12px; color: var(--muted); border-bottom: 2px solid transparent; border-radius: 0; }
81
+ .tab.active { color: var(--text); border-bottom-color: var(--accent); }
82
+
83
+ .kv { display: grid; grid-template-columns: 120px 1fr; gap: 4px 10px; margin: 0; }
84
+ .kv dt { color: var(--muted); }
85
+ .kv dd { margin: 0; }
86
+ .chips { display: flex; gap: 4px; flex-wrap: wrap; }
87
+ .chip { font-size: 12px; padding: 1px 8px; border-radius: 99px; background: #0d0f16; border: 1px solid var(--line); }
88
+
89
+ .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
90
+ .sprite-card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px; text-align: center; cursor: pointer; }
91
+ .sprite-card:hover { border-color: var(--accent); }
92
+ .sprite-card .thumb { height: 72px; display: grid; place-items: center; overflow: hidden; background: repeating-conic-gradient(#191c28 0 25%, #1e2130 0 50%) 0 0 / 12px 12px; border-radius: 4px; margin-bottom: 6px; }
93
+ .sprite-card img { image-rendering: pixelated; max-width: 100%; max-height: 70px; }
94
+
95
+ details.entity { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 6px; }
96
+ details.entity[open] { border-color: #3a4160; }
97
+ details.entity summary { padding: 8px 10px; cursor: pointer; display: flex; justify-content: space-between; }
98
+ details.entity.selected { border-color: var(--gold); }
99
+ .component { padding: 6px 10px 10px; border-top: 1px solid var(--line); }
100
+ .component h4 { margin: 4px 0 6px; font-size: 13px; color: var(--accent); }
101
+ .param { display: grid; grid-template-columns: 1fr 120px; gap: 8px; align-items: center; margin: 3px 0; }
102
+ .param label { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
103
+ .param.set label { color: var(--text); }
104
+ .param input, .param select { padding: 4px 6px; }
105
+
106
+ table.stats { width: 100%; border-collapse: collapse; font-size: 12px; }
107
+ table.stats th, table.stats td { text-align: left; padding: 5px 6px; border-bottom: 1px solid var(--line); }
108
+ table.stats th { color: var(--muted); font-weight: 500; }
109
+ .heatmap { width: 100%; image-rendering: pixelated; border-radius: 6px; border: 1px solid var(--line); margin-top: 8px; }
110
+ .changes li { margin: 4px 0; }
111
+ .issues li { color: var(--warn); }