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
spawnpoint/expr.py ADDED
@@ -0,0 +1,219 @@
1
+ """Rule expressions: a whitelisted subset of Python syntax translated to GDScript.
2
+
3
+ Supported: numbers, strings, true/false, `score`, `lives`, `time`, `vars.<name>`,
4
+ `event.<arg>`, `self`, `self.<Component>.<property>`, functions count("Entity"), randf(),
5
+ min, max, abs, str; operators + - * / %, comparisons (chainable), and/or/not.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field
14
+
15
+ from .diagnostics import Diagnostics, did_you_mean
16
+ from .manifest import Manifest
17
+
18
+ FUNCS = {"count": (1, 1), "randf": (0, 0), "min": (2, 2), "max": (2, 2), "abs": (1, 1), "str": (1, 1)}
19
+ GLOBALS = {"score": "GameState.score", "lives": "GameState.lives", "time": "GameState.time"}
20
+ CONSTS = {"true": "true", "false": "false", "True": "true", "False": "false", "null": "null", "None": "null"}
21
+
22
+ _BIN = {ast.Add: "+", ast.Sub: "-", ast.Mult: "*"}
23
+ _CMP = {ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">="}
24
+
25
+
26
+ class ExprError(Exception):
27
+ def __init__(self, message: str, hint: str | None = None):
28
+ super().__init__(message)
29
+ self.hint = hint
30
+
31
+
32
+ @dataclass
33
+ class Scope:
34
+ """What an expression may reference."""
35
+
36
+ manifest: Manifest
37
+ entities: dict[str, list[str]] # entity name -> component names
38
+ event_args: dict[str, str] = field(default_factory=dict) # arg name -> GDScript param name
39
+ self_entity: str | None = None # entity type of `self`, None if no source entity
40
+ used: set[str] = field(default_factory=set) # param names referenced (for unused-arg naming)
41
+
42
+
43
+ _STRING = re.compile(r"(\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')")
44
+
45
+
46
+ def normalize(src: str) -> str:
47
+ """Accept common C-style operators from LLM output (outside string literals)."""
48
+ parts = _STRING.split(src)
49
+ for i in range(0, len(parts), 2):
50
+ s = parts[i].replace("&&", " and ").replace("||", " or ")
51
+ parts[i] = re.sub(r"!(?!=)", " not ", s)
52
+ return "".join(parts)
53
+
54
+
55
+ def parse(src: str) -> ast.expr:
56
+ try:
57
+ return ast.parse(normalize(src).strip(), mode="eval").body
58
+ except SyntaxError as e:
59
+ raise ExprError(f"syntax error in '{src}': {e.msg}") from e
60
+
61
+
62
+ def translate(src: str, scope: Scope) -> str:
63
+ return _Translator(scope).visit(parse(src))
64
+
65
+
66
+ def check(src: str, scope: Scope, diags: Diagnostics, path: str) -> str | None:
67
+ try:
68
+ return translate(src, scope)
69
+ except ExprError as e:
70
+ diags.error("E_EXPR", path, str(e), e.hint)
71
+ return None
72
+
73
+
74
+ def gd_string(s: str) -> str:
75
+ return json.dumps(s, ensure_ascii=False)
76
+
77
+
78
+ class _Translator:
79
+ def __init__(self, scope: Scope):
80
+ self.s = scope
81
+
82
+ def visit(self, node: ast.AST) -> str:
83
+ method = getattr(self, "v_" + type(node).__name__, None)
84
+ if method is None:
85
+ raise ExprError(f"'{ast.unparse(node)}' is not allowed in rule expressions")
86
+ return method(node)
87
+
88
+ def v_Constant(self, n: ast.Constant) -> str:
89
+ v = n.value
90
+ if isinstance(v, bool):
91
+ return "true" if v else "false"
92
+ if v is None:
93
+ return "null"
94
+ if isinstance(v, (int, float)):
95
+ return repr(v)
96
+ if isinstance(v, str):
97
+ return gd_string(v)
98
+ raise ExprError(f"unsupported literal {v!r}")
99
+
100
+ def v_Name(self, n: ast.Name) -> str:
101
+ if n.id in CONSTS:
102
+ return CONSTS[n.id]
103
+ if n.id in GLOBALS:
104
+ return GLOBALS[n.id]
105
+ if n.id == "self":
106
+ if self.s.self_entity is None:
107
+ raise ExprError("'self' is not available for this event (no emitting entity)")
108
+ self.s.used.add("source")
109
+ return "source"
110
+ if n.id in self.s.entities:
111
+ raise ExprError(f"'{n.id}' is an entity type, not a value", f'Use count("{n.id}") for how many exist')
112
+ if n.id in ("event", "vars"):
113
+ raise ExprError(f"'{n.id}' needs a field, e.g. {n.id}.something")
114
+ raise ExprError(f"unknown name '{n.id}'", did_you_mean(n.id, [*GLOBALS, "self", "event", "vars"]))
115
+
116
+ def v_Attribute(self, n: ast.Attribute) -> str:
117
+ chain = _attr_chain(n)
118
+ if chain is None:
119
+ raise ExprError(f"'{ast.unparse(n)}' is not allowed")
120
+ head, *rest = chain
121
+ if head == "vars" and len(rest) == 1:
122
+ return f"GameState.get_var({gd_string(rest[0])})"
123
+ if head == "event" and len(rest) == 1:
124
+ arg = rest[0]
125
+ if arg not in self.s.event_args:
126
+ opts = list(self.s.event_args)
127
+ raise ExprError(f"event has no argument '{arg}'",
128
+ did_you_mean(arg, opts) if opts else "This event has no arguments")
129
+ gd = self.s.event_args[arg]
130
+ self.s.used.add(gd)
131
+ return gd
132
+ if head == "self" and len(rest) == 2:
133
+ if self.s.self_entity is None:
134
+ raise ExprError("'self' is not available for this event (no emitting entity)")
135
+ comp_name, prop = rest
136
+ comps = self.s.entities.get(self.s.self_entity, [])
137
+ if comp_name not in comps:
138
+ raise ExprError(f"{self.s.self_entity} has no component '{comp_name}'", did_you_mean(comp_name, comps))
139
+ comp = self.s.manifest.get(comp_name)
140
+ props = [*comp.params, *comp.state] if comp else []
141
+ if prop not in props:
142
+ raise ExprError(f"{comp_name} has no property '{prop}'", did_you_mean(prop, props))
143
+ self.s.used.add("source")
144
+ return f'SP.component(source, &"{comp_name}").{prop}'
145
+ raise ExprError(f"'{ast.unparse(n)}' is not allowed",
146
+ "Use vars.<name>, event.<arg> or self.<Component>.<property>")
147
+
148
+ def v_Call(self, n: ast.Call) -> str:
149
+ if not isinstance(n.func, ast.Name) or n.func.id not in FUNCS:
150
+ name = ast.unparse(n.func)
151
+ raise ExprError(f"function '{name}' is not allowed in expressions", did_you_mean(name, FUNCS))
152
+ if n.keywords:
153
+ raise ExprError("keyword arguments are not supported")
154
+ lo, hi = FUNCS[n.func.id]
155
+ if not lo <= len(n.args) <= hi:
156
+ raise ExprError(f"{n.func.id}() takes {lo if lo == hi else f'{lo}-{hi}'} argument(s), got {len(n.args)}")
157
+ if n.func.id == "count":
158
+ name = entity_literal(n.args[0], self.s.entities)
159
+ return f'World.count(&"{name}")'
160
+ args = ", ".join(self.visit(a) for a in n.args)
161
+ return f"{n.func.id}({args})"
162
+
163
+ def v_BinOp(self, n: ast.BinOp) -> str:
164
+ a, b = self.visit(n.left), self.visit(n.right)
165
+ if isinstance(n.op, ast.Div):
166
+ return f"(float({a}) / {b})"
167
+ if isinstance(n.op, ast.Mod):
168
+ return f"fposmod({a}, {b})"
169
+ op = _BIN.get(type(n.op))
170
+ if op is None:
171
+ raise ExprError(f"operator in '{ast.unparse(n)}' is not supported")
172
+ return f"({a} {op} {b})"
173
+
174
+ def v_UnaryOp(self, n: ast.UnaryOp) -> str:
175
+ v = self.visit(n.operand)
176
+ if isinstance(n.op, ast.Not):
177
+ return f"(not {v})"
178
+ if isinstance(n.op, ast.USub):
179
+ return f"(-{v})"
180
+ if isinstance(n.op, ast.UAdd):
181
+ return v
182
+ raise ExprError(f"operator in '{ast.unparse(n)}' is not supported")
183
+
184
+ def v_BoolOp(self, n: ast.BoolOp) -> str:
185
+ op = " and " if isinstance(n.op, ast.And) else " or "
186
+ return "(" + op.join(self.visit(v) for v in n.values) + ")"
187
+
188
+ def v_Compare(self, n: ast.Compare) -> str:
189
+ parts = []
190
+ left = n.left
191
+ for op, right in zip(n.ops, n.comparators):
192
+ sym = _CMP.get(type(op))
193
+ if sym is None:
194
+ raise ExprError(f"comparison in '{ast.unparse(n)}' is not supported")
195
+ parts.append(f"{self.visit(left)} {sym} {self.visit(right)}")
196
+ left = right
197
+ return "(" + " and ".join(parts) + ")"
198
+
199
+
200
+ def _attr_chain(n: ast.AST) -> list[str] | None:
201
+ out: list[str] = []
202
+ while isinstance(n, ast.Attribute):
203
+ out.append(n.attr)
204
+ n = n.value
205
+ if not isinstance(n, ast.Name):
206
+ return None
207
+ out.append(n.id)
208
+ return out[::-1]
209
+
210
+
211
+ def entity_literal(node: ast.AST, entities: dict[str, list[str]]) -> str:
212
+ """An argument that must be a string literal naming an entity type."""
213
+ if isinstance(node, ast.Name) and node.id in entities:
214
+ return node.id # be lenient: count(Slime) == count("Slime")
215
+ if not (isinstance(node, ast.Constant) and isinstance(node.value, str)):
216
+ raise ExprError(f"expected an entity name in quotes, got '{ast.unparse(node)}'")
217
+ if node.value not in entities:
218
+ raise ExprError(f"unknown entity '{node.value}'", did_you_mean(node.value, entities))
219
+ return node.value
spawnpoint/gif.py ADDED
@@ -0,0 +1,58 @@
1
+ """Record a gameplay GIF of a built project (autopilot + screenshots -> optimized GIF).
2
+
3
+ Needs a display (the game renders in a real window for a few seconds).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ from PIL import Image
14
+
15
+ from . import godot
16
+ from .manifest import ADDON_DIR
17
+
18
+ RECORD_SCENE = "res://addons/spawn_point/tools/record.tscn"
19
+
20
+
21
+ def record_frames(project: Path, out_dir: Path, seconds: float, fps: int) -> list[Path]:
22
+ # Projects built before the recorder existed get the current tool copied in.
23
+ tools = project / "addons" / "spawn_point" / "tools"
24
+ for name in ("record.gd", "record.tscn"):
25
+ shutil.copy2(ADDON_DIR / "tools" / name, tools / name)
26
+ step = max(1, round(60 / fps))
27
+ total = int(seconds * 60)
28
+ cmd = [godot.find_binary(), "--path", str(project), "--fixed-fps", "60", RECORD_SCENE,
29
+ "--", str(out_dir), str(total), str(step)]
30
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=seconds * 20 + 60)
31
+ frames = sorted(out_dir.glob("frame_*.png"))
32
+ if not frames:
33
+ raise godot.GodotError("recording produced no frames (is a display available?)", proc.stdout + proc.stderr)
34
+ return frames
35
+
36
+
37
+ def make_gif(frames: list[Path], out: Path, fps: int, width: int, colors: int = 128) -> Path:
38
+ images = [Image.open(f).convert("RGB") for f in frames]
39
+ w, h = images[0].size
40
+ size = (width, round(h * width / w))
41
+ resample = Image.NEAREST if w % width == 0 else Image.LANCZOS # exact halves keep pixel art crisp
42
+ images = [im.resize(size, resample) for im in images]
43
+ # One shared palette (sampled across the clip) so colors don't flicker between frames.
44
+ sample = Image.new("RGB", (size[0], size[1] * min(6, len(images))))
45
+ for i, im in enumerate(images[:: max(1, len(images) // 6)][:6]):
46
+ sample.paste(im, (0, i * size[1]))
47
+ palette = sample.quantize(colors=colors, method=Image.Quantize.MEDIANCUT)
48
+ quantized = [im.quantize(palette=palette, dither=Image.Dither.NONE) for im in images]
49
+ out.parent.mkdir(parents=True, exist_ok=True)
50
+ quantized[0].save(out, save_all=True, append_images=quantized[1:], duration=round(1000 / fps), loop=0,
51
+ optimize=True, disposal=1)
52
+ return out
53
+
54
+
55
+ def record_gif(project: Path, out: Path, seconds: float = 8.0, fps: int = 20, width: int = 640) -> Path:
56
+ with tempfile.TemporaryDirectory() as tmp:
57
+ frames = record_frames(project, Path(tmp), seconds, fps)
58
+ return make_gif(frames, out, fps, width)
spawnpoint/godot.py ADDED
@@ -0,0 +1,107 @@
1
+ """Locating and running the Godot binary headless."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ CANDIDATES = ("godot4", "godot", "Godot", "godot-4")
13
+
14
+ # Lines Godot prints at exit in headless mode that are not problems of the game.
15
+ _NOISE = (
16
+ "RID allocations of type",
17
+ "were leaked at exit",
18
+ "ObjectDB instances leaked at exit",
19
+ "resources still in use at exit",
20
+ "at: cleanup",
21
+ "at: clear",
22
+ "at: finalize",
23
+ )
24
+
25
+
26
+ class GodotError(RuntimeError):
27
+ def __init__(self, message: str, output: str = ""):
28
+ super().__init__(message)
29
+ self.output = output
30
+
31
+
32
+ @dataclass
33
+ class RunResult:
34
+ returncode: int
35
+ output: str
36
+
37
+ @property
38
+ def script_errors(self) -> list[str]:
39
+ return find_errors(self.output)
40
+
41
+
42
+ def find_binary() -> str:
43
+ env = os.environ.get("GODOT_BIN") or os.environ.get("GODOT")
44
+ if env:
45
+ return env
46
+ for c in CANDIDATES:
47
+ path = shutil.which(c)
48
+ if path:
49
+ return path
50
+ raise GodotError("Godot 4 not found. Install it or set GODOT_BIN=/path/to/godot.")
51
+
52
+
53
+ def available() -> bool:
54
+ try:
55
+ find_binary()
56
+ return True
57
+ except GodotError:
58
+ return False
59
+
60
+
61
+ def version() -> str:
62
+ out = subprocess.run([find_binary(), "--version"], capture_output=True, text=True, timeout=30)
63
+ return out.stdout.strip()
64
+
65
+
66
+ def run(project: Path, args: list[str], timeout: float = 180) -> RunResult:
67
+ cmd = [find_binary(), "--headless", "--path", str(project), *args]
68
+ try:
69
+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
70
+ except subprocess.TimeoutExpired as e:
71
+ out = (e.stdout or b"").decode() if isinstance(e.stdout, bytes) else (e.stdout or "")
72
+ raise GodotError(f"Godot timed out after {timeout}s: {' '.join(cmd)}", out) from e
73
+ return RunResult(p.returncode, (p.stdout or "") + (p.stderr or ""))
74
+
75
+
76
+ def import_project(project: Path) -> RunResult:
77
+ """Imports resources and refreshes the global class cache (required before running scripts)."""
78
+ return run(project, ["--import"], timeout=300)
79
+
80
+
81
+ def run_scene(project: Path, scene: str, user_args: list[str] | None = None, *, import_first: bool = False,
82
+ fixed_fps: int | None = None, timeout: float = 180, check: bool = True) -> RunResult:
83
+ if import_first:
84
+ import_project(project)
85
+ args: list[str] = []
86
+ if fixed_fps:
87
+ args += ["--fixed-fps", str(fixed_fps)]
88
+ args.append(scene)
89
+ if user_args:
90
+ args += ["--", *user_args]
91
+ result = run(project, args, timeout=timeout)
92
+ if check and (result.returncode != 0 or result.script_errors):
93
+ raise GodotError(f"Godot failed running {scene} (exit {result.returncode})", result.output)
94
+ return result
95
+
96
+
97
+ _ERR = re.compile(r"^(SCRIPT ERROR|ERROR|USER ERROR|Parse Error|Compile Error)\b.*", re.MULTILINE)
98
+
99
+
100
+ def find_errors(output: str) -> list[str]:
101
+ errors = []
102
+ for m in _ERR.finditer(output):
103
+ line = m.group(0)
104
+ if any(n in line for n in _NOISE):
105
+ continue
106
+ errors.append(line.strip())
107
+ return errors
spawnpoint/graph.py ADDED
@@ -0,0 +1,175 @@
1
+ """Game graph schema (v1). Structural validation only; semantics live in analyze.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Literal, Union
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
9
+
10
+ from .diagnostics import Diagnostics, did_you_mean, join_path
11
+
12
+ HEX_COLOR = re.compile(r"^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
13
+
14
+
15
+ class _Model(BaseModel):
16
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
17
+
18
+
19
+ def _check_color(v: str | None) -> str | None:
20
+ if v is not None and not HEX_COLOR.match(v):
21
+ raise ValueError(f"'{v}' is not a hex color like '#29adff'")
22
+ return v
23
+
24
+
25
+ class Shape(_Model):
26
+ type: Literal["rect", "circle", "capsule", "triangle", "spikes", "none"] = "rect"
27
+ size: tuple[float, float] = (28.0, 28.0)
28
+ color: str | None = None
29
+
30
+ _color = field_validator("color")(_check_color)
31
+
32
+ @field_validator("size")
33
+ @classmethod
34
+ def _positive(cls, v: tuple[float, float]) -> tuple[float, float]:
35
+ if v[0] <= 0 or v[1] <= 0 or v[0] > 1024 or v[1] > 1024:
36
+ raise ValueError("size must be within 1..1024 px")
37
+ return v
38
+
39
+
40
+ class SpriteGenerate(_Model):
41
+ kind: str
42
+ color: str | None = None
43
+ features: list[str] | None = None
44
+ seed: int = 0
45
+
46
+ _color = field_validator("color")(_check_color)
47
+
48
+
49
+ class SpriteAI(_Model):
50
+ prompt: str = Field(min_length=1, max_length=200)
51
+ kind: str # also the pixel size, animations and the fallback when no image provider is available
52
+ color: str | None = None
53
+ seed: int = 0
54
+
55
+ _color = field_validator("color")(_check_color)
56
+
57
+
58
+ class SpriteRef(_Model):
59
+ generate: SpriteGenerate | None = None
60
+ ai: SpriteAI | None = None
61
+
62
+ @model_validator(mode="after")
63
+ def _one(self) -> "SpriteRef":
64
+ if (self.generate is None) == (self.ai is None):
65
+ raise ValueError('use exactly one of "generate" or "ai"')
66
+ return self
67
+
68
+
69
+ class Entity(_Model):
70
+ body: Literal["character", "static", "area"] = "character"
71
+ team: Literal["player", "enemy", "neutral"] | None = None
72
+ shape: Shape = Field(default_factory=Shape)
73
+ # Path to a .sprite.json (relative to the graph file) or {"generate": {...}}.
74
+ sprite: Union[str, SpriteRef, None] = None
75
+ components: dict[str, dict[str, Any]] = Field(default_factory=dict)
76
+ juice: Union[Literal["none"], dict[str, Any], None] = None
77
+
78
+
79
+ class Level(_Model):
80
+ map: list[str]
81
+ legend: dict[str, str]
82
+
83
+
84
+ class Rule(_Model):
85
+ on: str
86
+ if_: str | None = Field(default=None, alias="if")
87
+ do: list[str] | str
88
+
89
+ @property
90
+ def actions(self) -> list[str]:
91
+ return [self.do] if isinstance(self.do, str) else list(self.do)
92
+
93
+
94
+ class TileStyle(_Model):
95
+ color: str | None = None
96
+ top: str | None = None
97
+ _color = field_validator("color", "top")(_check_color)
98
+
99
+
100
+ class Settings(_Model):
101
+ tile_size: int = Field(default=32, ge=8, le=128)
102
+ viewport: tuple[int, int] = (960, 540)
103
+ lives: int = Field(default=3, ge=1, le=99)
104
+ juice: Literal["default", "none"] = "default"
105
+ background: str | None = None
106
+ gravity: float | None = Field(default=None, ge=0, le=10000)
107
+ seed: int = 0
108
+ tiles: dict[Literal["solid", "oneway"], TileStyle] = Field(default_factory=dict)
109
+ tile_style: Literal["pixel", "flat"] = "pixel"
110
+ zoom: float = Field(default=1.0, ge=0.5, le=4.0)
111
+
112
+ _bg = field_validator("background")(_check_color)
113
+
114
+
115
+ class Graph(_Model):
116
+ spawnpoint: Literal[1] = 1
117
+ name: str = Field(min_length=1, max_length=80)
118
+ genre: Literal["platformer", "topdown"]
119
+ settings: Settings = Field(default_factory=Settings)
120
+ entities: dict[str, Entity]
121
+ level: Level
122
+ rules: list[Rule] = Field(default_factory=list)
123
+
124
+
125
+ def parse(data: Any) -> tuple[Graph | None, Diagnostics]:
126
+ """Parse raw JSON data into a Graph, converting pydantic errors into diagnostics."""
127
+ diags = Diagnostics()
128
+ try:
129
+ return Graph.model_validate(data), diags
130
+ except ValidationError as e:
131
+ found = []
132
+ for err in e.errors():
133
+ # Drop pydantic's union-branch tags ("str", "SpriteRef", "literal['none']", ...).
134
+ loc = [p for p in err["loc"] if not (isinstance(p, str) and ("[" in p or "-" in p or p in _TYPE_TAGS))]
135
+ path = join_path(*loc) if loc else "(root)"
136
+ msg = err["msg"]
137
+ hint = None
138
+ if err["type"] == "extra_forbidden":
139
+ field = str(loc[-1])
140
+ hint = did_you_mean(field, _allowed_fields(loc[:-1]))
141
+ msg = f"unknown field '{field}'"
142
+ elif err["type"] == "missing":
143
+ msg = f"missing required field '{loc[-1]}'"
144
+ found.append((path, msg, hint))
145
+ # A union reports one error per branch; keep only the most specific ones.
146
+ paths = {p for p, _, _ in found}
147
+ for path, msg, hint in dict.fromkeys(found):
148
+ if not any(o != path and o.startswith(path + ".") for o in paths):
149
+ diags.error("E_SCHEMA", path, msg, hint)
150
+ return None, diags
151
+
152
+
153
+ _TYPE_TAGS = {"str", "int", "float", "bool", "SpriteRef", "SpriteGenerate", "SpriteAI", "Shape", "Entity"}
154
+
155
+
156
+ # Which model lives under a field name (dict/list containers skip their key/index).
157
+ _CHILD = {"settings": (Settings, 1), "shape": (Shape, 1), "level": (Level, 1), "generate": (SpriteGenerate, 1),
158
+ "ai": (SpriteAI, 1),
159
+ "sprite": (SpriteRef, 1),
160
+ "entities": (Entity, 2), "rules": (Rule, 2)}
161
+
162
+
163
+ def _allowed_fields(parent_loc: list) -> list[str]:
164
+ """Valid field names at a location (for typo hints)."""
165
+ model: type[BaseModel] | None = Graph
166
+ i = 0
167
+ while model is not None and i < len(parent_loc):
168
+ child = _CHILD.get(str(parent_loc[i]))
169
+ if child is None:
170
+ return []
171
+ model, step = child
172
+ i += step
173
+ if model is None:
174
+ return []
175
+ return [f.alias or name for name, f in model.model_fields.items()]