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,122 @@
1
+ """AI sprite providers against fake A1111 / OpenAI-images servers."""
2
+
3
+ import base64
4
+ import io
5
+ import json
6
+ import threading
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+
9
+ import numpy as np
10
+ import pytest
11
+ from PIL import Image, ImageDraw
12
+
13
+ from spawnpoint.analyze import analyze
14
+ from spawnpoint.sprites import ai
15
+ from spawnpoint.sprites.generate import KINDS
16
+ from spawnpoint.studio.assemble import assemble
17
+ from spawnpoint.studio.brief import GameBrief, normalize
18
+
19
+ from conftest import codes, minimal
20
+
21
+
22
+ def fake_art() -> bytes:
23
+ img = Image.new("RGB", (512, 512), (255, 0, 255))
24
+ d = ImageDraw.Draw(img)
25
+ d.rectangle((180, 220, 330, 470), fill=(41, 120, 220)) # body
26
+ d.ellipse((170, 60, 340, 230), fill=(240, 190, 140)) # head
27
+ d.ellipse((280, 120, 310, 150), fill=(255, 255, 255)) # eye (white, inside the head)
28
+ buf = io.BytesIO()
29
+ img.save(buf, "PNG")
30
+ return buf.getvalue()
31
+
32
+
33
+ @pytest.fixture
34
+ def server():
35
+ calls = {"n": 0}
36
+ png = base64.b64encode(fake_art()).decode()
37
+
38
+ class H(BaseHTTPRequestHandler):
39
+ def log_message(self, *a):
40
+ pass
41
+
42
+ def do_POST(self):
43
+ calls["n"] += 1
44
+ self.rfile.read(int(self.headers["Content-Length"]))
45
+ body = {"images": [png]} if self.path.startswith("/sdapi") else {"data": [{"b64_json": png}]}
46
+ data = json.dumps(body).encode()
47
+ self.send_response(200)
48
+ self.send_header("Content-Type", "application/json")
49
+ self.send_header("Content-Length", str(len(data)))
50
+ self.end_headers()
51
+ self.wfile.write(data)
52
+
53
+ httpd = ThreadingHTTPServer(("127.0.0.1", 0), H)
54
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
55
+ yield f"http://127.0.0.1:{httpd.server_port}", calls
56
+ httpd.shutdown()
57
+
58
+
59
+ @pytest.fixture(autouse=True)
60
+ def cache(tmp_path, monkeypatch):
61
+ monkeypatch.setattr(ai, "CACHE", tmp_path / "cache")
62
+ for k in ("SPAWNPOINT_IMAGE_PROVIDER", "SPAWNPOINT_IMAGE_URL"):
63
+ monkeypatch.delenv(k, raising=False)
64
+
65
+
66
+ def test_background_removal_keeps_inner_white():
67
+ rgba = ai.remove_background(Image.open(io.BytesIO(fake_art())))
68
+ assert rgba[0, 0, 3] == 0 and rgba[300, 250, 3] == 255
69
+ assert rgba[135, 295, 3] == 255 # the white eye is enclosed by the head, so it stays opaque
70
+
71
+
72
+ @pytest.mark.parametrize("provider_cls", ["a1111", "openai"])
73
+ def test_ai_sprite_from_provider_and_cache(server, provider_cls):
74
+ url, calls = server
75
+ provider = ai.A1111Provider(url) if provider_cls == "a1111" else ai.OpenAIImagesProvider(url + "/v1", "k", "m")
76
+ s = ai.ai_sprite("Hero", "hero", "a tiny knight", provider, seed=1)
77
+ assert s.frame_size == KINDS["hero"].size
78
+ assert set(s.animations) == {"idle", "run", "jump", "fall", "hurt"}
79
+ assert len(s.polygon) >= 3
80
+ idle = s.frame(0, 0)
81
+ assert idle[0, 0, 3] == 0 and (idle[..., 3] > 0).sum() > 60
82
+ assert len({tuple(p) for p in idle[idle[..., 3] > 0][:, :3].tolist()}) <= 16 # small palette
83
+ ai.ai_sprite("Hero", "hero", "a tiny knight", provider, seed=1)
84
+ assert calls["n"] == 1 # second call came from the cache
85
+
86
+
87
+ def test_graph_ai_sprite_uses_provider(server, monkeypatch, manifest):
88
+ url, calls = server
89
+ monkeypatch.setenv("SPAWNPOINT_IMAGE_PROVIDER", "a1111")
90
+ monkeypatch.setenv("SPAWNPOINT_IMAGE_URL", url)
91
+ g = minimal()
92
+ g["entities"]["Player"]["sprite"] = {"ai": {"prompt": "a tiny knight", "kind": "hero"}}
93
+ a = analyze(g, manifest)
94
+ assert a.ok and "W_AI_SPRITE" not in codes(a.diagnostics), [str(d) for d in a.diagnostics]
95
+ assert calls["n"] == 1 and "Player" in a.sprites
96
+
97
+
98
+ @pytest.mark.parametrize("env", [{}, {"SPAWNPOINT_IMAGE_PROVIDER": "a1111", "SPAWNPOINT_IMAGE_URL": "http://127.0.0.1:9"}])
99
+ def test_falls_back_to_generated_sprite(env, monkeypatch, manifest):
100
+ for k, v in env.items():
101
+ monkeypatch.setenv(k, v)
102
+ g = minimal()
103
+ g["entities"]["Player"]["sprite"] = {"ai": {"prompt": "a tiny knight", "kind": "hero"}}
104
+ a = analyze(g, manifest)
105
+ assert a.ok and "W_AI_SPRITE" in codes(a.diagnostics, "warning")
106
+ assert a.sprites["Player"].frame_size == KINDS["hero"].size
107
+
108
+
109
+ def test_sprite_ref_needs_exactly_one_form(manifest):
110
+ g = minimal()
111
+ g["entities"]["Player"]["sprite"] = {"ai": {"prompt": "x", "kind": "hero"}, "generate": {"kind": "hero"}}
112
+ assert "E_SCHEMA" in codes(analyze(g, manifest).diagnostics)
113
+
114
+
115
+ def test_studio_ai_art_flag():
116
+ b, _ = normalize(GameBrief.model_validate({"title": "Knight Run", "genre": "platformer",
117
+ "player": {"character": "Knight"},
118
+ "enemies": [{"name": "Slime", "archetype": "walker"}]}))
119
+ g = assemble(b, 2, 1, ai_art=True)
120
+ assert g["entities"]["Player"]["sprite"]["ai"]["prompt"].startswith("knight")
121
+ assert g["entities"]["Slime"]["sprite"]["ai"]["kind"] == "slime"
122
+ assert "generate" in assemble(b, 2, 1)["entities"]["Player"]["sprite"]
tests/test_analyze.py ADDED
@@ -0,0 +1,116 @@
1
+ import pytest
2
+
3
+ from spawnpoint.analyze import analyze
4
+
5
+ from conftest import codes, minimal
6
+
7
+
8
+ def test_examples_are_clean(manifest, platformer, topdown):
9
+ for g in (platformer, topdown):
10
+ a = analyze(g, manifest)
11
+ assert a.ok, [str(d) for d in a.diagnostics]
12
+ assert not a.diagnostics.warnings, [str(d) for d in a.diagnostics]
13
+
14
+
15
+ def test_plan_shape(manifest):
16
+ a = analyze(minimal(), manifest)
17
+ assert a.ok, [str(d) for d in a.diagnostics]
18
+ plan = a.plan
19
+ player = next(e for e in plan["entities"] if e["name"] == "Player")
20
+ assert player["is_player"] and player["team"] == "player"
21
+ assert player["layer"] == 2 and player["mask"] == 1
22
+ assert player["pivot"] == "bottom"
23
+ names = [c["name"] for c in player["components"]]
24
+ assert names[-1] == "Juice"
25
+ hurtbox = next(c for c in player["components"] if c["name"] == "Hurtbox")
26
+ assert hurtbox["area_scale"] == pytest.approx(0.85)
27
+ # Player feet rest on the floor row (row 2 -> y = 64).
28
+ p = next(p for p in plan["placements"] if p["entity"] == "Player")
29
+ assert p["y"] + 14 == pytest.approx(64) # default shape is 28 px tall
30
+ # Collectibles float at the cell center.
31
+ c = next(p for p in plan["placements"] if p["entity"] == "Coin")
32
+ assert c["y"] == pytest.approx(48)
33
+ assert plan["map"]["cells"][0] == [0, 2, "solid"]
34
+
35
+
36
+ def test_juice_none_and_overrides(manifest):
37
+ g = minimal()
38
+ g["entities"]["Coin"]["juice"] = "none"
39
+ g["entities"]["Player"]["juice"] = {"shake_on_hurt": 0.9}
40
+ plan = analyze(g, manifest).plan
41
+ coin = next(e for e in plan["entities"] if e["name"] == "Coin")
42
+ assert "Juice" not in [c["name"] for c in coin["components"]]
43
+ player = next(e for e in plan["entities"] if e["name"] == "Player")
44
+ assert player["components"][-1]["params"] == {"shake_on_hurt": 0.9}
45
+ g["settings"] = {"juice": "none"}
46
+ plan = analyze(g, manifest).plan
47
+ assert all(c["name"] != "Juice" for e in plan["entities"] for c in e["components"])
48
+
49
+
50
+ def _with(mutate):
51
+ g = minimal()
52
+ mutate(g)
53
+ return g
54
+
55
+
56
+ def _player(g):
57
+ return g["entities"]["Player"]["components"]
58
+
59
+
60
+ @pytest.mark.parametrize("mutate, code, hint", [
61
+ (lambda g: _player(g).update({"Helth": {}}), "E_UNKNOWN_COMPONENT", "Did you mean 'Health'?"),
62
+ (lambda g: _player(g)["Health"].update({"max_hp": 3}), "E_UNKNOWN_PARAM", "Did you mean 'max_health'?"),
63
+ (lambda g: _player(g)["Health"].update({"max_health": 0}), "E_PARAM_RANGE", None),
64
+ (lambda g: _player(g)["Health"].update({"max_health": 2.5}), "E_PARAM_TYPE", None),
65
+ (lambda g: _player(g)["PlatformerMovement"].update({"max_speed": "fast"}), "E_PARAM_TYPE", None),
66
+ (lambda g: g["entities"]["Coin"]["components"].update({"Gate": {}}), "E_HOST", None),
67
+ (lambda g: _player(g).pop("Health"), "E_REQUIRES", None),
68
+ (lambda g: _player(g).update({"Weapon": {"projectile": "Coin"}}), "E_NOT_PROJECTILE", None),
69
+ (lambda g: _player(g).update({"Weapon": {"projectile": "Bulet"}}), "E_ENTITY_REF", None),
70
+ (lambda g: _player(g).update({"Weapon": {}}), "E_ENTITY_REF", None),
71
+ (lambda g: _player(g).update({"TopDownMovement": {}}), "E_MOVEMENT", None),
72
+ (lambda g: _player(g).pop("PlayerInput"), "E_NO_PLAYER", None),
73
+ (lambda g: g["level"].update(map=["PP........", "##########"]), "E_PLAYER_PLACEMENT", None),
74
+ (lambda g: g["entities"].update({"slime": {}}), "E_ENTITY_NAME", None),
75
+ (lambda g: g["entities"].update({"Health": {}}), "E_ENTITY_NAME", None),
76
+ (lambda g: g["entities"]["Player"].update({"juice": {"shake": 1}}), "E_UNKNOWN_PARAM", None),
77
+ (lambda g: g["entities"]["Coin"].update({"shape": {"type": "none"}}) or g["entities"]["Coin"]["components"].update({"Hitbox": {}}), "E_SHAPE", None),
78
+ ])
79
+ def test_entity_errors(manifest, mutate, code, hint):
80
+ a = analyze(_with(mutate), manifest)
81
+ assert not a.ok
82
+ assert code in codes(a.diagnostics, "error"), [str(d) for d in a.diagnostics]
83
+ if hint:
84
+ assert any(d.hint == hint for d in a.diagnostics.errors)
85
+
86
+
87
+ def test_aim_mode_choice(manifest):
88
+ g = minimal()
89
+ _player(g).update({"Aim": {"mode": "mose"}})
90
+ _player(g).update({"Weapon": {"projectile": "Coin"}})
91
+ a = analyze(g, manifest)
92
+ err = next(d for d in a.diagnostics.errors if d.code == "E_PARAM_CHOICE")
93
+ assert err.hint == "Did you mean 'mouse'?"
94
+
95
+
96
+ def test_warnings(manifest):
97
+ g = minimal(rules=[])
98
+ g["level"]["map"] = [".P........", ".........."]
99
+ a = analyze(g, manifest)
100
+ assert a.ok
101
+ assert set(codes(a.diagnostics, "warning")) >= {"W_NO_WIN", "W_NO_GROUND"}
102
+
103
+
104
+ def test_genre_mismatch_warning(manifest):
105
+ g = minimal(genre="topdown")
106
+ a = analyze(g, manifest)
107
+ assert "W_GENRE" in codes(a.diagnostics, "warning")
108
+
109
+
110
+ def test_topdown_plan(manifest, topdown):
111
+ plan = analyze(topdown, manifest).plan
112
+ assert plan["gravity"] == 0
113
+ assert plan["walls"] == ["left", "right", "top", "bottom"]
114
+ bullet = next(e for e in plan["entities"] if e["name"] == "Bullet")
115
+ assert bullet["layer"] == 0 and bullet["mask"] == 1
116
+ assert all(e["pivot"] == "center" for e in plan["entities"])
tests/test_cockpit.py ADDED
@@ -0,0 +1,148 @@
1
+ """Cockpit API against a live server (stdlib HTTP + SSE), with a scripted LLM."""
2
+
3
+ import json
4
+ import threading
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+ import pytest
9
+
10
+ from spawnpoint import godot
11
+ from spawnpoint.build import build
12
+ from spawnpoint.cockpit.server import Cockpit, CockpitServer
13
+ from spawnpoint.studio.llm import ScriptedModel
14
+ from spawnpoint.studio import webcheck
15
+
16
+ from conftest import load_example
17
+
18
+ pytestmark = pytest.mark.skipif(not godot.available(), reason="Godot not installed")
19
+
20
+
21
+ @pytest.fixture(scope="module")
22
+ def workspace(tmp_path_factory):
23
+ ws = tmp_path_factory.mktemp("ws")
24
+ assert build(load_example("pixel_hopper.json"), ws / "pixel-hopper").ok
25
+ return ws
26
+
27
+
28
+ @pytest.fixture
29
+ def cockpit(workspace):
30
+ llm = {"model": None}
31
+ app = Cockpit(workspace, llm_factory=lambda: llm["model"], web_export=False)
32
+ srv = CockpitServer(app, 8095).start()
33
+ yield srv, app, llm
34
+ srv.stop()
35
+
36
+
37
+ def get(srv, path):
38
+ with urllib.request.urlopen(srv.url.rstrip("/") + path, timeout=10) as r:
39
+ return r.status, r.read()
40
+
41
+
42
+ def post(srv, path, body):
43
+ req = urllib.request.Request(srv.url.rstrip("/") + path, json.dumps(body).encode(),
44
+ {"Content-Type": "application/json"})
45
+ try:
46
+ with urllib.request.urlopen(req, timeout=10) as r:
47
+ return r.status, json.loads(r.read())
48
+ except urllib.error.HTTPError as e:
49
+ return e.code, json.loads(e.read())
50
+
51
+
52
+ def test_static_and_projects(cockpit):
53
+ srv, _, _ = cockpit
54
+ code, html = get(srv, "/")
55
+ assert code == 200 and b"Spawn Point" in html
56
+ _, data = get(srv, "/api/projects")
57
+ (p,) = json.loads(data)
58
+ assert p["name"] == "pixel-hopper" and p["genre"] == "platformer" and not p["studio"]
59
+ _, data = get(srv, "/api/projects/pixel-hopper")
60
+ d = json.loads(data)
61
+ health = d["entities"]["Player"]["components"]["Health"]
62
+ mh = next(x for x in health["params"] if x["name"] == "max_health")
63
+ assert mh["value"] == 3 and mh["set"] and mh["min"] == 1
64
+ assert d["entities"]["Slime"]["sprite"] is True
65
+
66
+
67
+ @pytest.mark.parametrize("path", ["/game/../../etc/passwd", "/api/projects/..", "/files/pixel-hopper/../spawnpoint.json",
68
+ "/files/pixel-hopper/rules/main_rules.gd", "/static/../server.py"])
69
+ def test_paths_stay_inside(cockpit, path):
70
+ srv, _, _ = cockpit
71
+ with pytest.raises(urllib.error.HTTPError) as e:
72
+ get(srv, path)
73
+ assert e.value.code in (403, 404)
74
+
75
+
76
+ def test_bad_requests(cockpit):
77
+ srv, _, _ = cockpit
78
+ assert post(srv, "/api/create", {"prompt": " "})[0] == 400
79
+ assert post(srv, "/api/projects/nope/tweak", {"request": "x"})[0] == 404
80
+ assert post(srv, "/api/projects/pixel-hopper/params", {"entity": "Player"})[0] == 400
81
+
82
+
83
+ def test_param_edit_job_and_events(cockpit, workspace):
84
+ srv, app, _ = cockpit
85
+ events = []
86
+ ready = threading.Event()
87
+
88
+ def listen():
89
+ with urllib.request.urlopen(srv.url + "api/events", timeout=60) as r:
90
+ ready.set()
91
+ for raw in r:
92
+ line = raw.decode().strip()
93
+ if line.startswith("data: "):
94
+ ev = json.loads(line[6:])
95
+ events.append(ev)
96
+ if ev["type"] in ("done", "error"):
97
+ return
98
+
99
+ t = threading.Thread(target=listen, daemon=True)
100
+ t.start()
101
+ ready.wait(5)
102
+ code, job = post(srv, "/api/projects/pixel-hopper/params",
103
+ {"entity": "Player", "component": "Health", "param": "max_health", "value": 5})
104
+ assert code == 202
105
+ done = app.jobs.wait(job["id"])
106
+ assert done.status == "done", done.error
107
+ t.join(10)
108
+ assert [e["type"] for e in events][-1] == "done"
109
+ graph = json.loads((workspace / "pixel-hopper" / "spawnpoint.json").read_text())
110
+ assert graph["entities"]["Player"]["components"]["Health"]["max_health"] == 5
111
+
112
+
113
+ def test_invalid_param_is_rejected_before_building(cockpit):
114
+ srv, app, _ = cockpit
115
+ _, job = post(srv, "/api/projects/pixel-hopper/params",
116
+ {"entity": "Player", "component": "Health", "param": "max_health", "value": 0})
117
+ done = app.jobs.wait(job["id"])
118
+ assert done.status == "error" and "outside" in done.error
119
+
120
+
121
+ def test_entity_edit_repairs_and_creates_projectile(cockpit, workspace):
122
+ srv, app, llm = cockpit
123
+ bad = {"set_params": [{"component": "Health", "param": "max_hp", "value": 2}], "add_components": [], "add_rules": []}
124
+ good = {"set_params": [{"component": "Health", "param": "max_health", "value": 2}],
125
+ "add_components": [{"component": "Weapon", "params": [{"param": "projectile", "value": "Fireball"},
126
+ {"param": "auto_fire", "value": True}]}],
127
+ "add_rules": []}
128
+ llm["model"] = ScriptedModel([bad, good])
129
+ _, job = post(srv, "/api/projects/pixel-hopper/entity", {"entity": "Slime", "request": "tougher, shoots fire"})
130
+ done = app.jobs.wait(job["id"])
131
+ assert done.status == "done", done.error
132
+ assert done.result["rounds"] == 2
133
+ graph = json.loads((workspace / "pixel-hopper" / "spawnpoint.json").read_text())
134
+ slime = graph["entities"]["Slime"]["components"]
135
+ assert slime["Health"]["max_health"] == 2 and slime["Weapon"]["projectile"] == "Fireball"
136
+ assert "Bullet" not in graph["entities"] # only entities that have a Weapon get projectiles
137
+ fireball = graph["entities"]["Fireball"]
138
+ assert "Projectile" in fireball["components"] and fireball["team"] == "enemy"
139
+
140
+
141
+ def test_cockpit_page_in_browser(cockpit):
142
+ if webcheck.find_chrome() is None:
143
+ pytest.skip("Chrome not installed")
144
+ srv, _, _ = cockpit
145
+ res = webcheck.check(srv.url, seconds=4, size=(1400, 900),
146
+ expression="[...document.querySelectorAll('#project-list li')].map(li => li.textContent)")
147
+ assert not res.errors, res.errors
148
+ assert res.value and "Pixel Hopper" in res.value[0]
tests/test_e2e.py ADDED
@@ -0,0 +1,33 @@
1
+ """End-to-end: build the examples with real Godot and smoke-run them."""
2
+
3
+ import pytest
4
+
5
+ from spawnpoint import godot
6
+ from spawnpoint.build import build
7
+
8
+ from conftest import load_example, minimal
9
+
10
+ pytestmark = pytest.mark.skipif(not godot.available(), reason="Godot not installed")
11
+
12
+
13
+ @pytest.mark.parametrize("example", ["coin_hopper.json", "neon_arena.json", "pixel_hopper.json"])
14
+ def test_examples_build_and_run(tmp_path, example):
15
+ result = build(load_example(example), tmp_path / "game")
16
+ assert result.ok, "\n".join(str(d) for d in result.diagnostics) + "\n" + result.log[-2000:]
17
+ assert not result.diagnostics.warnings, [str(d) for d in result.diagnostics]
18
+ out = tmp_path / "game"
19
+ for f in ("project.godot", "levels/main.tscn", "levels/tiles.tres", "rules/main_rules.gd", "sfx/jump.wav"):
20
+ assert (out / f).exists(), f
21
+ smoke = result.smoke
22
+ assert smoke["player_found"] and smoke["player_alive"]
23
+ assert smoke["moved_px"] > 50
24
+ assert result.timings["total"] < 30
25
+
26
+
27
+ def test_invalid_graph_never_reaches_godot(tmp_path):
28
+ g = minimal()
29
+ g["entities"]["Player"]["components"]["Helth"] = {}
30
+ result = build(g, tmp_path / "game")
31
+ assert not result.ok
32
+ assert not (tmp_path / "game").exists()
33
+ assert "godot_import" not in result.timings
tests/test_expr.py ADDED
@@ -0,0 +1,59 @@
1
+ import pytest
2
+
3
+ from spawnpoint.expr import ExprError, Scope, translate
4
+
5
+
6
+ @pytest.fixture
7
+ def scope(manifest):
8
+ return Scope(manifest, {"Player": ["Health", "PlatformerMovement"], "Coin": ["Collectible"]},
9
+ {"by": "a_by"}, self_entity="Player")
10
+
11
+
12
+ @pytest.mark.parametrize("src, expected", [
13
+ ("score >= 10", "(GameState.score >= 10)"),
14
+ ("0 < lives <= 3", "(0 < GameState.lives and GameState.lives <= 3)"),
15
+ ("vars.keys == 2 and not false", '((GameState.get_var("keys") == 2) and (not false))'),
16
+ ("count('Coin') == 0", '(World.count(&"Coin") == 0)'),
17
+ ("count(Coin) == 0", '(World.count(&"Coin") == 0)'),
18
+ ("score / 2 > 1.5", "((float(GameState.score) / 2) > 1.5)"),
19
+ ("score % 3 == 0", "(fposmod(GameState.score, 3) == 0)"),
20
+ ("self.Health.current < 2", '(SP.component(source, &"Health").current < 2)'),
21
+ ("score > 1 && lives > 0 || !true", "(((GameState.score > 1) and (GameState.lives > 0)) or (not true))"),
22
+ ("event.by != null", "(a_by != null)"),
23
+ ])
24
+ def test_translations(scope, src, expected):
25
+ assert translate(src, scope) == expected
26
+
27
+
28
+ def test_exclamation_inside_strings_is_kept(scope):
29
+ assert translate("str('Win!') == 'Win!'", scope) == '(str("Win!") == "Win!")'
30
+
31
+
32
+ @pytest.mark.parametrize("src, fragment", [
33
+ ("__import__('os')", "not allowed"),
34
+ ("score.real", "not allowed"),
35
+ ("Player > 1", "entity type"),
36
+ ("scroe > 1", "unknown name"),
37
+ ("event.nope", "no argument"),
38
+ ("self.Health.hp", "no property"),
39
+ ("self.Gate.is_open", "no component"),
40
+ ("count('Nope')", "unknown entity"),
41
+ ("[1, 2]", "not allowed"),
42
+ ("score ** 2", "not supported"),
43
+ ])
44
+ def test_rejections(scope, src, fragment):
45
+ with pytest.raises(ExprError) as e:
46
+ translate(src, scope)
47
+ assert fragment in str(e.value)
48
+
49
+
50
+ def test_typo_hint(scope):
51
+ with pytest.raises(ExprError) as e:
52
+ translate("scroe > 1", scope)
53
+ assert e.value.hint == "Did you mean 'score'?"
54
+
55
+
56
+ def test_self_unavailable(manifest):
57
+ s = Scope(manifest, {}, {}, None)
58
+ with pytest.raises(ExprError, match="not available"):
59
+ translate("self.Health.current", s)
tests/test_gif.py ADDED
@@ -0,0 +1,19 @@
1
+ from PIL import Image
2
+
3
+ from spawnpoint.gif import make_gif
4
+
5
+
6
+ def test_make_gif_shared_palette_and_size(tmp_path):
7
+ frames = []
8
+ for i in range(6):
9
+ im = Image.new("RGB", (960, 540), (29, 43, 83))
10
+ im.paste((255, 0, 77), (100 + i * 40, 200, 160 + i * 40, 260))
11
+ path = tmp_path / f"frame_{i:04d}.png"
12
+ im.save(path)
13
+ frames.append(path)
14
+ out = make_gif(frames, tmp_path / "demo.gif", fps=20, width=480)
15
+ gif = Image.open(out)
16
+ assert gif.size == (480, 270)
17
+ assert gif.n_frames == 6
18
+ assert gif.info["duration"] == 50
19
+ assert gif.info["loop"] == 0
@@ -0,0 +1,17 @@
1
+ """Runs the GDScript component test-suite (godot/tests) so `pytest` covers everything."""
2
+
3
+ import pytest
4
+
5
+ from spawnpoint import godot
6
+ from spawnpoint.manifest import LIBRARY_PROJECT
7
+
8
+ pytestmark = pytest.mark.skipif(not godot.available(), reason="Godot not installed")
9
+
10
+
11
+ def test_component_library_suite():
12
+ godot.import_project(LIBRARY_PROJECT)
13
+ result = godot.run_scene(LIBRARY_PROJECT, "res://tests/run_tests.tscn", fixed_fps=60, check=False, timeout=300)
14
+ summary = [ln for ln in result.output.splitlines() if "tests," in ln]
15
+ assert result.returncode == 0, result.output[-4000:]
16
+ assert summary and summary[-1].endswith(", 0 failures"), result.output[-4000:]
17
+ assert not result.script_errors, result.script_errors
tests/test_graph.py ADDED
@@ -0,0 +1,44 @@
1
+ from spawnpoint.graph import parse
2
+
3
+ from conftest import minimal
4
+
5
+
6
+ def test_minimal_graph_parses():
7
+ g, d = parse(minimal())
8
+ assert g is not None and d.ok
9
+ assert g.settings.tile_size == 32
10
+ assert g.entities["Player"].body == "character"
11
+
12
+
13
+ def test_unknown_field_gets_typo_hint():
14
+ data = minimal()
15
+ data["entities"]["Player"]["compnents"] = {}
16
+ g, d = parse(data)
17
+ assert g is None
18
+ (err,) = d.errors
19
+ assert err.path == "entities.Player.compnents"
20
+ assert err.hint == "Did you mean 'components'?"
21
+
22
+
23
+ def test_missing_required_field():
24
+ data = minimal()
25
+ del data["genre"]
26
+ _, d = parse(data)
27
+ assert any(e.message == "missing required field 'genre'" for e in d.errors)
28
+
29
+
30
+ def test_bad_color_and_genre():
31
+ data = minimal(genre="rpg")
32
+ data["entities"]["Player"]["shape"] = {"color": "blue"}
33
+ _, d = parse(data)
34
+ paths = {e.path for e in d.errors}
35
+ assert "genre" in paths
36
+ assert any(p.startswith("entities.Player.shape.color") for p in paths)
37
+
38
+
39
+ def test_rule_if_alias():
40
+ data = minimal(rules=[{"on": "Exit.triggered", "if": "score > 1", "do": "win()"}])
41
+ g, d = parse(data)
42
+ assert d.ok
43
+ assert g.rules[0].if_ == "score > 1"
44
+ assert g.rules[0].actions == ["win()"]
tests/test_level.py ADDED
@@ -0,0 +1,64 @@
1
+ from spawnpoint.diagnostics import Diagnostics
2
+ from spawnpoint.graph import Level
3
+ from spawnpoint.level import Placement, jump_model, parse_level, unreachable
4
+
5
+
6
+ def parse(rows, legend, entities=("Player", "Coin")):
7
+ d = Diagnostics()
8
+ data = parse_level(Level(map=rows, legend=legend), set(entities), d)
9
+ return data, d
10
+
11
+
12
+ def test_tiles_and_placements():
13
+ data, d = parse(["P.C", "#=#"], {"#": "solid", "=": "oneway", "P": "Player", "C": "Coin"})
14
+ assert d.ok
15
+ assert data.tiles == {(0, 1): "solid", (1, 1): "oneway", (2, 1): "solid"}
16
+ assert [(p.entity, p.x, p.y) for p in data.placements] == [("Player", 0, 0), ("Coin", 2, 0)]
17
+
18
+
19
+ def test_ragged_rows_are_padded():
20
+ data, d = parse(["P", "###"], {"#": "solid", "P": "Player"})
21
+ assert d.ok and data.width == 3
22
+
23
+
24
+ def test_unknown_char_reported_once_with_positions():
25
+ _, d = parse(["P.X.X", "#####"], {"#": "solid", "P": "Player"})
26
+ (err,) = d.errors
27
+ assert err.code == "E_MAP_CHAR" and "(2,0), (4,0)" in err.message
28
+
29
+
30
+ def test_bad_legend_value_hint():
31
+ _, d = parse(["P", "#"], {"#": "solidd", "P": "Player"})
32
+ assert d.errors[0].hint == "Did you mean 'solid'?"
33
+
34
+
35
+ def test_reserved_legend_key():
36
+ _, d = parse(["P", "#"], {"#": "solid", ".": "Coin", "P": "Player"})
37
+ assert d.errors[0].code == "E_LEGEND_KEY"
38
+
39
+
40
+ def test_reachability():
41
+ rows = [
42
+ "..........C", # far above anything the player can stand on
43
+ "...........",
44
+ "...........",
45
+ "...........",
46
+ "...........",
47
+ "......C....",
48
+ "...........",
49
+ "....===....",
50
+ "...........",
51
+ ".P.........",
52
+ "###########",
53
+ ]
54
+ data, d = parse(rows, {"#": "solid", "=": "oneway", "P": "Player", "C": "Coin"})
55
+ jm = jump_model(620, 1600, 1.7, 230, 32, 34)
56
+ coins = [p for p in data.placements if p.entity == "Coin"]
57
+ missing = unreachable(data, (1, 9), coins, jm)
58
+ assert missing == [Placement("Coin", 10, 0)]
59
+
60
+
61
+ def test_double_jump_extends_reach():
62
+ single = jump_model(620, 1600, 1.7, 230, 32, 34)
63
+ double = jump_model(620, 1600, 1.7, 230, 32, 34, air_jumps=1)
64
+ assert double.up > single.up
tests/test_live_llm.py ADDED
@@ -0,0 +1,38 @@
1
+ """End-to-end with the real LLM server (skipped when it is not running). Slow: ~1 min."""
2
+
3
+ import json
4
+ import urllib.request
5
+
6
+ import pytest
7
+
8
+ from spawnpoint.studio.llm import OpenAICompatible
9
+ from spawnpoint.studio.orchestrator import create, load_session, tweak
10
+
11
+
12
+ def _llm_up() -> bool:
13
+ try:
14
+ urllib.request.urlopen(OpenAICompatible().base_url.rstrip("/") + "/models", timeout=2)
15
+ return True
16
+ except Exception:
17
+ return False
18
+
19
+
20
+ pytestmark = [pytest.mark.llm, pytest.mark.skipif(not _llm_up(), reason="LLM server not running")]
21
+
22
+
23
+ def test_create_then_tweak_vietnamese(tmp_path, manifest):
24
+ out = tmp_path / "game"
25
+ llm = OpenAICompatible()
26
+ res = create("game nhảy bục, nhân vật là ninja, ăn sao để qua màn", out, llm, web_export=False,
27
+ manifest=manifest)
28
+ assert res.ok
29
+ assert res.brief.genre == "platformer"
30
+ assert res.brief.collectible.count > 0
31
+ before = load_session(out)["brief"]
32
+
33
+ res2 = tweak(out, "nhảy cao hơn 20%", llm, web_export=False, manifest=manifest)
34
+ assert res2.ok
35
+ after = load_session(out)["brief"]
36
+ assert after["player"]["jump_tiles"] > before["player"]["jump_tiles"]
37
+ unchanged = {k for k in before if before[k] == after[k]}
38
+ assert {"genre", "enemies", "goal", "length"} <= unchanged, json.dumps(res2.changes)