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_studio.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from spawnpoint import godot
|
|
8
|
+
from spawnpoint.analyze import analyze
|
|
9
|
+
from spawnpoint.studio import levelgen
|
|
10
|
+
from spawnpoint.studio.assemble import PLATFORMER_GRAVITY, TILE, assemble, jump_velocity
|
|
11
|
+
from spawnpoint.studio.brief import GameBrief, normalize, pascal_singular
|
|
12
|
+
from spawnpoint.studio.creative import apply_patch, creative_pass
|
|
13
|
+
from spawnpoint.studio.llm import ScriptedModel, parse_json
|
|
14
|
+
from spawnpoint.studio.orchestrator import create, design_brief, diff_briefs, load_session, tweak
|
|
15
|
+
from spawnpoint.studio.web import export_preset
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def brief(**kw) -> GameBrief:
|
|
19
|
+
data = {"title": "Test", "genre": "platformer",
|
|
20
|
+
"enemies": [{"name": "Slime", "archetype": "walker", "count": 3}]}
|
|
21
|
+
data.update(kw)
|
|
22
|
+
return GameBrief.model_validate(data)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --- normalization ----------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize("raw, expected", [
|
|
28
|
+
("Stars", "Star"), ("fire bats", "FireBat"), ("Enemies", "Enemy"), ("Berries", "Berry"),
|
|
29
|
+
("Boss", "Boss"), ("Cactus", "Cactus"), ("mice", "Mouse"), ("123", "Coin"), ("", "Coin"),
|
|
30
|
+
])
|
|
31
|
+
def test_pascal_singular(raw, expected):
|
|
32
|
+
assert pascal_singular(raw, "Coin") == expected
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_normalize_repairs_weak_model_output():
|
|
36
|
+
b, notes = normalize(brief(
|
|
37
|
+
genre="topdown",
|
|
38
|
+
enemies=[{"name": "Zombies", "archetype": "walker", "count": 4},
|
|
39
|
+
{"name": "Health", "archetype": "turret", "count": 1},
|
|
40
|
+
{"name": "zombie", "archetype": "chaser", "count": 2},
|
|
41
|
+
{"name": "Ghost", "archetype": "chaser", "count": 0}],
|
|
42
|
+
collectible={"name": "Player", "count": 5},
|
|
43
|
+
player={"abilities": ["double_jump", "shoot", "shoot"]},
|
|
44
|
+
hazards=["pits", "spikes"]))
|
|
45
|
+
assert [e.name for e in b.enemies] == ["Zombie", "HealthFoe"]
|
|
46
|
+
assert b.enemies[0].archetype == "chaser"
|
|
47
|
+
assert b.collectible.name == "PlayerItem"
|
|
48
|
+
assert b.player.abilities == ["shoot"]
|
|
49
|
+
assert b.hazards == ["spikes"]
|
|
50
|
+
assert b.intro
|
|
51
|
+
assert len(notes) >= 5
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_normalize_fixes_impossible_goals():
|
|
55
|
+
b, _ = normalize(brief(goal="collect_all", collectible={"name": "Gem", "count": 0}))
|
|
56
|
+
assert b.collectible.count > 0
|
|
57
|
+
b, _ = normalize(brief(goal="defeat_all", enemies=[]))
|
|
58
|
+
assert b.goal == "reach_exit"
|
|
59
|
+
b, _ = normalize(brief(goal="defeat_all", player={"abilities": []}))
|
|
60
|
+
assert "stomp" in b.player.abilities
|
|
61
|
+
b, _ = normalize(brief(genre="topdown", goal="defeat_all", enemies=[{"name": "Bot", "archetype": "chaser"}]))
|
|
62
|
+
assert "shoot" in b.player.abilities
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- assembler --------------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def test_jump_velocity_reaches_requested_height():
|
|
68
|
+
for tiles in (2.0, 3.5, 6.0):
|
|
69
|
+
v = jump_velocity(tiles)
|
|
70
|
+
h = v * v / (2 * PLATFORMER_GRAVITY)
|
|
71
|
+
assert h == pytest.approx(tiles * TILE * 0.92, rel=1e-3)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
COMBOS = list(itertools.product(
|
|
75
|
+
["platformer", "topdown"],
|
|
76
|
+
["reach_exit", "collect_all", "collect_all_then_exit", "defeat_all", "score_target", "survive"],
|
|
77
|
+
["walker", "hopper", "chaser", "shooter", "turret"],
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize("genre, goal, archetype", COMBOS)
|
|
82
|
+
def test_every_combination_compiles_cleanly(manifest, genre, goal, archetype):
|
|
83
|
+
b, _ = normalize(brief(genre=genre, goal=goal, hazards=["spikes", "pits"],
|
|
84
|
+
enemies=[{"name": "Foe", "archetype": archetype, "count": 4}],
|
|
85
|
+
player={"abilities": ["double_jump", "dash", "stomp", "shoot"]}))
|
|
86
|
+
for layer in (0, 1, 2):
|
|
87
|
+
a = analyze(assemble(b, layer, seed=11), manifest)
|
|
88
|
+
problems = [str(d) for d in a.diagnostics if not (layer == 0 and d.code == "W_NO_WIN")]
|
|
89
|
+
assert a.ok and not problems, problems
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize("jump_tiles", [1.5, 2.0, 3.0, 5.0, 8.0])
|
|
93
|
+
@pytest.mark.parametrize("length", ["short", "long"])
|
|
94
|
+
def test_platformer_levels_reachable_for_any_jump(manifest, jump_tiles, length):
|
|
95
|
+
b, _ = normalize(brief(length=length, difficulty=5, hazards=["pits", "spikes"], goal="collect_all",
|
|
96
|
+
player={"jump_tiles": jump_tiles}))
|
|
97
|
+
for seed in range(3):
|
|
98
|
+
a = analyze(assemble(b, 2, seed), manifest)
|
|
99
|
+
assert a.ok and not a.diagnostics.warnings, [str(d) for d in a.diagnostics]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_layers_add_content(manifest):
|
|
103
|
+
b, _ = normalize(brief(juice="wild", feel="heavy"))
|
|
104
|
+
l0, l1, l2 = (assemble(b, layer, 5) for layer in (0, 1, 2))
|
|
105
|
+
assert set(l0["entities"]) == {"Player"}
|
|
106
|
+
assert {"Slime", b.collectible.name} <= set(l1["entities"])
|
|
107
|
+
assert "juice" not in l1["entities"]["Player"]
|
|
108
|
+
assert l2["entities"]["Player"]["juice"]["shake_on_hurt"] > 0.35
|
|
109
|
+
assert l2["entities"]["Player"]["components"]["PlatformerMovement"]["fall_gravity_multiplier"] == 2.2
|
|
110
|
+
tiles = lambda g: [r.translate(str.maketrans("PC1234^+DEO", "." * 11)).rstrip(".") for r in g["level"]["map"]]
|
|
111
|
+
assert tiles(l0) == tiles(l2) # same geometry across layers
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_deterministic_and_cosmetic_tweaks_keep_the_map():
|
|
115
|
+
b, _ = normalize(brief())
|
|
116
|
+
assert assemble(b, 2, 7) == assemble(b, 2, 7)
|
|
117
|
+
recolored, _ = normalize(b.model_copy(update={"palette": "lava", "juice": "wild"}))
|
|
118
|
+
assert assemble(b, 2, 7)["level"]["map"] == assemble(recolored, 2, 7)["level"]["map"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_arena_floor_is_connected():
|
|
122
|
+
for seed in range(5):
|
|
123
|
+
lay = levelgen.arena(seed, "long", 5)
|
|
124
|
+
free = {(x, y) for x in range(lay.width) for y in range(lay.height) if lay.is_free((x, y))}
|
|
125
|
+
assert len(lay.free_cells) + 1 == len(free)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_defeat_all_counts_every_enemy_type():
|
|
129
|
+
b, _ = normalize(brief(goal="defeat_all", enemies=[{"name": "Slime", "archetype": "walker"},
|
|
130
|
+
{"name": "Frog", "archetype": "hopper"}]))
|
|
131
|
+
rules = assemble(b, 1, 1)["rules"]
|
|
132
|
+
wins = [r for r in rules if r["do"] == ["win()"]]
|
|
133
|
+
assert {r["on"] for r in wins} == {"Slime.died", "Frog.died"}
|
|
134
|
+
assert all(r["if"] == "count('Slime') + count('Frog') == 0" for r in wins)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- creative pass ----------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def test_apply_patch():
|
|
140
|
+
g = {"entities": {"Slime": {"components": {"Health": {}}}}, "rules": []}
|
|
141
|
+
out = apply_patch(g, {"set_params": [{"entity": "Slime", "component": "Health", "param": "max_health", "value": 3}],
|
|
142
|
+
"add_rules": [{"on": "Slime.died", "if": "", "do": ["shake(0.5)"]}]})
|
|
143
|
+
assert out["entities"]["Slime"]["components"]["Health"] == {"max_health": 3}
|
|
144
|
+
assert out["rules"] == [{"on": "Slime.died", "do": ["shake(0.5)"]}]
|
|
145
|
+
assert g["rules"] == [] # input untouched
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_creative_pass_repairs_then_applies(manifest):
|
|
149
|
+
b, _ = normalize(brief())
|
|
150
|
+
graph = assemble(b, 2, 3)
|
|
151
|
+
bad = {"set_params": [{"entity": "Slime", "component": "Health", "param": "max_hp", "value": 3}], "add_rules": []}
|
|
152
|
+
good = {"set_params": [{"entity": "Slime", "component": "Health", "param": "max_health", "value": 2}],
|
|
153
|
+
"add_rules": [{"on": "Slime.died", "if": "", "do": ["shake(0.4)"]}]}
|
|
154
|
+
llm = ScriptedModel([bad, good])
|
|
155
|
+
res = creative_pass(graph, "slimes", llm, manifest)
|
|
156
|
+
assert res.applied and res.rounds == 2
|
|
157
|
+
assert "max_hp" in llm.seen[1][-1]["content"] # diagnostics were fed back
|
|
158
|
+
assert res.graph["entities"]["Slime"]["components"]["Health"]["max_health"] == 2
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_creative_pass_drops_unfixable_patch(manifest):
|
|
162
|
+
graph = assemble(normalize(brief())[0], 2, 3)
|
|
163
|
+
bad = {"set_params": [], "add_rules": [{"on": "Nope.died", "if": "", "do": ["win()"]}]}
|
|
164
|
+
res = creative_pass(graph, "x", ScriptedModel([bad, bad, bad]), manifest)
|
|
165
|
+
assert not res.applied and res.graph is graph and res.errors
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- LLM plumbing -----------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def test_parse_json_tolerates_fences():
|
|
171
|
+
assert parse_json('```json\n{"a": 1}\n```') == {"a": 1}
|
|
172
|
+
assert parse_json('Sure! {"a": 2} hope that helps') == {"a": 2}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_design_brief_translates_non_english():
|
|
176
|
+
raw = brief().model_dump()
|
|
177
|
+
llm = ScriptedModel([{"english": "a ninja collects stars"}, raw])
|
|
178
|
+
b, notes, english = design_brief("ninja ăn sao", llm)
|
|
179
|
+
assert english == "a ninja collects stars"
|
|
180
|
+
assert llm.seen[1][-1]["content"] == "a ninja collects stars"
|
|
181
|
+
assert b.title == "Test"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_diff_briefs():
|
|
185
|
+
a = brief().model_dump()
|
|
186
|
+
b = json.loads(json.dumps(a))
|
|
187
|
+
b["player"]["jump_tiles"] = 4.2
|
|
188
|
+
b["palette"] = "lava"
|
|
189
|
+
assert diff_briefs(a, b) == ['palette: "night" -> "lava"', "player.jump_tiles: 3.5 -> 4.2"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_export_preset_is_single_threaded_with_live_reload():
|
|
193
|
+
cfg = export_preset()
|
|
194
|
+
assert "variant/thread_support=false" in cfg
|
|
195
|
+
assert "version.json" in cfg
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# --- end to end with real Godot, scripted LLM -------------------------------------------------
|
|
199
|
+
|
|
200
|
+
@pytest.mark.skipif(not godot.available(), reason="Godot not installed")
|
|
201
|
+
def test_create_and_tweak_with_scripted_llm(tmp_path, manifest):
|
|
202
|
+
raw = brief(goal="collect_all_then_exit").model_dump()
|
|
203
|
+
llm = ScriptedModel([raw])
|
|
204
|
+
out = tmp_path / "game"
|
|
205
|
+
seen_layers = []
|
|
206
|
+
res = create("a slime game", out, llm, web_export=False, manifest=manifest,
|
|
207
|
+
on_layer=lambda r: seen_layers.append(r.layer))
|
|
208
|
+
assert res.ok, [str(d) for r in res.layers for d in r.build.diagnostics]
|
|
209
|
+
assert seen_layers == [0, 1, 2]
|
|
210
|
+
session = load_session(out)
|
|
211
|
+
assert session["brief"]["title"] == "Test"
|
|
212
|
+
map_before = json.loads((out / "spawnpoint.json").read_text())["level"]["map"]
|
|
213
|
+
|
|
214
|
+
updated = json.loads(json.dumps(session["brief"]))
|
|
215
|
+
updated["player"]["jump_tiles"] = 4.2
|
|
216
|
+
llm2 = ScriptedModel([{"brief": updated, "new_level": False}])
|
|
217
|
+
res2 = tweak(out, "jump 20% higher", llm2, web_export=False, manifest=manifest)
|
|
218
|
+
assert res2.ok and res2.changes == ["player.jump_tiles: 3.5 -> 4.2"]
|
|
219
|
+
graph = json.loads((out / "spawnpoint.json").read_text())
|
|
220
|
+
assert graph["level"]["map"] == map_before
|
|
221
|
+
assert math.isclose(graph["entities"]["Player"]["components"]["PlatformerMovement"]["jump_velocity"],
|
|
222
|
+
jump_velocity(4.2))
|
|
223
|
+
assert load_session(out)["history"][0]["request"] == "jump 20% higher"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --- dataset recording ------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
@pytest.mark.skipif(not godot.available(), reason="Godot not installed")
|
|
229
|
+
def test_recorder_captures_corrected_targets(tmp_path, manifest):
|
|
230
|
+
from spawnpoint.studio.dataset import Recorder, iter_records, stats
|
|
231
|
+
|
|
232
|
+
raw = brief(enemies=[{"name": "Slimes", "archetype": "walker", "count": 3}]).model_dump()
|
|
233
|
+
bad_patch = {"set_params": [{"entity": "Slime", "component": "Health", "param": "max_hp", "value": 2}],
|
|
234
|
+
"add_rules": []}
|
|
235
|
+
good_patch = {"set_params": [{"entity": "Slime", "component": "Health", "param": "max_health", "value": 2}],
|
|
236
|
+
"add_rules": []}
|
|
237
|
+
llm = ScriptedModel([{"english": "slime game"}, raw, bad_patch, good_patch])
|
|
238
|
+
rec = Recorder(tmp_path / "ds")
|
|
239
|
+
res = create("game slime ăn xu", tmp_path / "game", llm, web_export=False, creative=True,
|
|
240
|
+
manifest=manifest, recorder=rec)
|
|
241
|
+
assert res.ok
|
|
242
|
+
records = list(iter_records(tmp_path / "ds"))
|
|
243
|
+
assert [r["task"] for r in records] == ["translate", "brief", "creative", "creative"]
|
|
244
|
+
b = records[1]
|
|
245
|
+
assert b["raw"]["enemies"][0]["name"] == "Slimes"
|
|
246
|
+
assert b["target"]["enemies"][0]["name"] == "Slime" # normalized answer is the training target
|
|
247
|
+
assert any("Slimes" in n for n in b["notes"])
|
|
248
|
+
assert b["accepted"] is True and b["corrected"] is True and b["meta"]["prompt"] == "game slime ăn xu"
|
|
249
|
+
assert records[0]["corrected"] is False
|
|
250
|
+
bad, good = records[2], records[3]
|
|
251
|
+
assert bad["accepted"] is False and bad["target"] is None and bad["errors"]
|
|
252
|
+
assert good["accepted"] is True and good["target"] == good_patch
|
|
253
|
+
st = stats(tmp_path / "ds")
|
|
254
|
+
assert st["records"] == 4 and st["tasks"]["brief"]["corrected"] == 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_recorder_flushes_even_when_the_llm_fails(tmp_path, manifest):
|
|
258
|
+
from spawnpoint.studio.dataset import Recorder, iter_records
|
|
259
|
+
from spawnpoint.studio.orchestrator import StudioError
|
|
260
|
+
|
|
261
|
+
llm = ScriptedModel([{"title": 5}]) # not a valid brief
|
|
262
|
+
rec = Recorder(tmp_path / "ds")
|
|
263
|
+
with pytest.raises(StudioError):
|
|
264
|
+
create("x", tmp_path / "game", llm, web_export=False, manifest=manifest, recorder=rec)
|
|
265
|
+
(r,) = list(iter_records(tmp_path / "ds"))
|
|
266
|
+
assert r["task"] == "brief" and r["accepted"] is False and r["target"] is None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@pytest.mark.skipif(not godot.available(), reason="Godot not installed")
|
|
270
|
+
def test_tweak_keeps_creative_patch_and_tuning(tmp_path, manifest):
|
|
271
|
+
from spawnpoint.studio.orchestrator import _save_session
|
|
272
|
+
|
|
273
|
+
raw = brief().model_dump()
|
|
274
|
+
patch = {"set_params": [{"entity": "Slime", "component": "Health", "param": "max_health", "value": 2}],
|
|
275
|
+
"add_rules": []}
|
|
276
|
+
out = tmp_path / "game"
|
|
277
|
+
assert create("slimes", out, ScriptedModel([raw, patch]), web_export=False, creative=True, manifest=manifest).ok
|
|
278
|
+
session = load_session(out)
|
|
279
|
+
session["tuning"] = [{"entity": "Player", "component": "PlatformerMovement", "param": "coyote_time", "value": 0.18},
|
|
280
|
+
{"entity": "Ghost", "component": "Health", "param": "max_health", "value": 9}] # stale entry
|
|
281
|
+
_save_session(out, session)
|
|
282
|
+
updated = json.loads(json.dumps(session["brief"]))
|
|
283
|
+
updated["palette"] = "lava"
|
|
284
|
+
assert tweak(out, "lava colors", ScriptedModel([{"brief": updated, "new_level": False}]), web_export=False,
|
|
285
|
+
manifest=manifest).ok
|
|
286
|
+
g = json.loads((out / "spawnpoint.json").read_text())
|
|
287
|
+
assert g["entities"]["Slime"]["components"]["Health"]["max_health"] == 2
|
|
288
|
+
assert g["entities"]["Player"]["components"]["PlatformerMovement"]["coyote_time"] == 0.18
|
|
289
|
+
assert "Ghost" not in g["entities"]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@pytest.mark.skipif(not godot.available(), reason="Godot not installed")
|
|
293
|
+
def test_studio_playtest_uses_brief_difficulty(tmp_path, manifest):
|
|
294
|
+
from spawnpoint.studio.orchestrator import studio_playtest
|
|
295
|
+
|
|
296
|
+
out = tmp_path / "game"
|
|
297
|
+
assert create("slimes", out, ScriptedModel([brief(difficulty=4, length="short").model_dump()]),
|
|
298
|
+
web_export=False, manifest=manifest).ok
|
|
299
|
+
res = studio_playtest(out, runs=1, tune=False, web_export=False, manifest=manifest)
|
|
300
|
+
assert res.difficulty == 4
|
|
301
|
+
assert set(res.final.profiles) == {"novice", "average", "expert"}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@pytest.mark.skipif(not godot.available(), reason="Godot not installed")
|
|
305
|
+
def test_cockpit_overrides_persist_and_beat_tuning(tmp_path, manifest):
|
|
306
|
+
from spawnpoint.studio.edits import apply_override, set_param_patch
|
|
307
|
+
from spawnpoint.studio.orchestrator import _save_session
|
|
308
|
+
|
|
309
|
+
out = tmp_path / "game"
|
|
310
|
+
assert create("slimes", out, ScriptedModel([brief().model_dump()]), web_export=False, manifest=manifest).ok
|
|
311
|
+
session = load_session(out)
|
|
312
|
+
session["tuning"] = [{"entity": "Player", "component": "Health", "param": "max_health", "value": 4}]
|
|
313
|
+
_save_session(out, session)
|
|
314
|
+
res = apply_override(out, set_param_patch("Player", "Health", "max_health", 7), manifest=manifest, web_export=False)
|
|
315
|
+
assert res.ok, res.errors
|
|
316
|
+
assert load_session(out)["overrides"][0]["set_params"][0]["value"] == 7
|
|
317
|
+
bad = apply_override(out, set_param_patch("Player", "Health", "max_hp", 7), manifest=manifest, web_export=False)
|
|
318
|
+
assert not bad.ok and "max_hp" in bad.errors[0] and len(load_session(out)["overrides"]) == 1
|
|
319
|
+
updated = json.loads(json.dumps(load_session(out)["brief"]))
|
|
320
|
+
updated["palette"] = "snow"
|
|
321
|
+
assert tweak(out, "snow", ScriptedModel([{"brief": updated, "new_level": False}]), web_export=False, manifest=manifest).ok
|
|
322
|
+
g = json.loads((out / "spawnpoint.json").read_text())
|
|
323
|
+
assert g["entities"]["Player"]["components"]["Health"]["max_health"] == 7 # the person's edit wins
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_resolve_model_prefers_fine_tuned_adapter(monkeypatch):
|
|
327
|
+
from spawnpoint.studio.llm import OpenAICompatible
|
|
328
|
+
|
|
329
|
+
llm = OpenAICompatible(model=None, base_url="http://x")
|
|
330
|
+
monkeypatch.setattr(llm, "_request", lambda path, payload=None: {"data": [
|
|
331
|
+
{"id": "base", "parent": None}, {"id": "Tuned-7B", "parent": "base"}]})
|
|
332
|
+
assert llm.resolve_model() == "Tuned-7B"
|
|
333
|
+
llm2 = OpenAICompatible(model=None, base_url="http://x")
|
|
334
|
+
monkeypatch.setattr(llm2, "_request", lambda path, payload=None: {"data": [{"id": "only"}]})
|
|
335
|
+
assert llm2.resolve_model() == "only"
|
tests/test_web.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Web export + real browser check (skipped without Godot web templates or Chrome)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from spawnpoint import godot
|
|
8
|
+
from spawnpoint.build import build
|
|
9
|
+
from spawnpoint.studio import web, webcheck
|
|
10
|
+
|
|
11
|
+
from conftest import load_example
|
|
12
|
+
|
|
13
|
+
TEMPLATES = Path.home() / ".local/share/godot/export_templates"
|
|
14
|
+
has_templates = any(TEMPLATES.glob("*/web_nothreads_release.zip"))
|
|
15
|
+
|
|
16
|
+
pytestmark = [
|
|
17
|
+
pytest.mark.skipif(not godot.available(), reason="Godot not installed"),
|
|
18
|
+
pytest.mark.skipif(not has_templates, reason="Godot web export templates not installed"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_export_serve_and_play_in_browser(tmp_path):
|
|
23
|
+
out = tmp_path / "game"
|
|
24
|
+
assert build(load_example("coin_hopper.json"), out).ok
|
|
25
|
+
web.export(out)
|
|
26
|
+
for f in ("index.html", "index.wasm", "index.pck", "index.js", "version.json"):
|
|
27
|
+
assert (out / "web" / f).exists(), f
|
|
28
|
+
assert "version.json" in (out / "web" / "index.html").read_text() # live reload injected
|
|
29
|
+
assert web.bump_version(out) == 2
|
|
30
|
+
|
|
31
|
+
if webcheck.find_chrome() is None:
|
|
32
|
+
pytest.skip("Chrome not installed")
|
|
33
|
+
server = web.Server(out, 8090).start()
|
|
34
|
+
try:
|
|
35
|
+
res = webcheck.check(server.url, seconds=12)
|
|
36
|
+
finally:
|
|
37
|
+
server.stop()
|
|
38
|
+
assert not res.errors, res.errors
|
|
39
|
+
assert any("Godot Engine" in line for line in res.console), res.console
|
|
40
|
+
assert res.canvas_size and res.canvas_size[0] > 0
|
|
41
|
+
assert res.screenshot and len(res.screenshot) > 5000
|