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
tests/test_ai_sprites.py
ADDED
|
@@ -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)
|