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
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