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,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); }
|