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_manifest.py ADDED
@@ -0,0 +1,54 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from spawnpoint import godot, manifest as manifest_mod
6
+ from spawnpoint.manifest import parse_docs
7
+
8
+ SOURCE = '''class_name FooComponent
9
+ extends Node
10
+ ## Does foo.
11
+ ## Second line.
12
+
13
+ const SP_NAME := "Foo"
14
+
15
+ ## Fired on foo.
16
+ signal fooed(amount: int)
17
+
18
+ ## How much foo.
19
+ @export_range(0, 10) var amount: int = 3
20
+ var _private := 1
21
+
22
+
23
+ ## Foos now.
24
+ func foo() -> void:
25
+ pass
26
+ '''
27
+
28
+
29
+ def test_parse_docs():
30
+ docs = parse_docs(SOURCE)
31
+ assert docs["@class"] == "Does foo. Second line."
32
+ assert docs["fooed"] == "Fired on foo."
33
+ assert docs["amount"] == "How much foo."
34
+ assert docs["foo"] == "Foos now."
35
+ assert "_private" not in docs
36
+
37
+
38
+ def test_manifest_contents(manifest):
39
+ pm = manifest.get("PlatformerMovement")
40
+ assert pm.hosts == ["CharacterBody2D"]
41
+ assert pm.params["coyote_time"].max == 0.5
42
+ assert "step_velocity" not in pm.methods # internal
43
+ assert pm.signals["landed"].args[0].name == "impact_speed"
44
+ assert manifest.get("Aim").params["mode"].choices == ("mouse", "nearest_enemy", "movement")
45
+ assert manifest.get("Health").state["current"] == "int"
46
+ assert all(c.doc for c in manifest.components.values())
47
+ assert all(p.doc for c in manifest.components.values() for p in c.params.values())
48
+
49
+
50
+ @pytest.mark.skipif(not godot.available(), reason="Godot not installed")
51
+ def test_manifest_is_up_to_date(tmp_path):
52
+ fresh = manifest_mod.generate(out=tmp_path / "manifest.json")
53
+ committed = json.loads(manifest_mod.MANIFEST_PATH.read_text())
54
+ assert fresh == committed, "manifest.json is stale: run `spawnpoint manifest`"
tests/test_playtest.py ADDED
@@ -0,0 +1,129 @@
1
+ import json
2
+
3
+ import pytest
4
+ from PIL import Image
5
+
6
+ from spawnpoint import godot
7
+ from spawnpoint.build import build
8
+ from spawnpoint.playtest import goal_entities, merge_tuning, playtest
9
+ from spawnpoint.playtest.report import aggregate, heatmap, markdown
10
+ from spawnpoint.playtest.runner import time_limit
11
+ from spawnpoint.playtest.tuner import Change, propose, score, targets
12
+
13
+ from conftest import load_example
14
+
15
+
16
+ def run(profile, outcome="won", deaths=(), missed=0, intended=10, collected=5, total=5, unreachable=()):
17
+ return {"profile": profile, "run": 0, "outcome": outcome, "time": 20.0, "score": 0, "lives_left": 1,
18
+ "deaths": [{"x": x, "y": 5, "cause": c, "t": 1.0, "after_jump": aj} for c, x, aj in deaths],
19
+ "damage": [], "stuck": [], "unreachable": list(unreachable),
20
+ "jumps": {"intended": intended, "performed": intended - missed, "missed": [{"x": 0, "y": 0}] * missed},
21
+ "pickups": {"total": total, "collected": collected}}
22
+
23
+
24
+ def graph():
25
+ g = load_example("pixel_hopper.json")
26
+ return json.loads(json.dumps(g))
27
+
28
+
29
+ def test_aggregate_and_markdown():
30
+ rep = aggregate([run("novice", "lost", [("Spikes", 3, False), ("Spikes", 4, False), ("fall", 9, True)], missed=4),
31
+ run("novice"), run("expert")])
32
+ n = rep.get("novice")
33
+ assert n.win_rate == 0.5 and n.deaths == 3 and n.causes == {"Spikes": 2, "fall": 1}
34
+ assert n.falls_after_jump == 1 and n.missed_rate == 0.2
35
+ md = markdown(rep, "Test", ["Player.Health.max_health = 4 — reason"])
36
+ assert "| novice | 1/2 |" in md and "Tuning applied" in md
37
+
38
+
39
+ def test_level_issues_only_for_goal_entities():
40
+ u = [{"entity": "Coin", "x": 3, "y": 2, "reason": "no_path"}, {"entity": "Slime", "x": 9, "y": 2, "reason": "no_path"}]
41
+ rep = aggregate([run("expert", "timeout", unreachable=u)], {"Coin"})
42
+ assert rep.level_issues == ["Coin at (3,2) has no path from the player",
43
+ "even the expert bot never won — the goal may be impossible or the time too short"]
44
+
45
+
46
+ def test_targets_and_score():
47
+ assert targets(1)["novice"] == 0.8 and targets(5)["novice"] == 0.3
48
+ good = aggregate([run("novice"), run("average"), run("expert")])
49
+ bad = aggregate([run("novice", "lost"), run("average", "lost"), run("expert")])
50
+ assert score(good, 2) == 0 and score(bad, 2) > 1
51
+
52
+
53
+ def test_rule_missed_jumps_raise_coyote_and_buffer(manifest):
54
+ g = graph()
55
+ rep = aggregate([run("novice", missed=5, intended=10), run("average")])
56
+ changes = {(c.entity, c.param): c.value for c in propose(rep, g, g, manifest)}
57
+ assert changes[("Player", "coyote_time")] == pytest.approx(0.14)
58
+ assert changes[("Player", "jump_buffer")] == pytest.approx(0.16)
59
+
60
+
61
+ def test_rule_dominant_enemy_slows_down(manifest):
62
+ g = graph()
63
+ rep = aggregate([run("novice", "lost", [("Slime", 3, False)] * 3), run("average", "lost", [("Slime", 4, False)])])
64
+ changes = {(c.entity, c.component, c.param): c.value for c in propose(rep, g, g, manifest)}
65
+ assert changes[("Slime", "PlatformerMovement", "max_speed")] == pytest.approx(51.0)
66
+ assert changes[("Player", "Health", "max_health")] == 4
67
+
68
+
69
+ def test_rule_spikes_shrink_hurtbox_and_bounds(manifest):
70
+ g = graph()
71
+ g["entities"]["Player"]["components"]["Hurtbox"] = {"size_scale": 0.6}
72
+ rep = aggregate([run("novice", "lost", [("Spikes", 3, False)] * 3), run("average", "lost", [("Spikes", 3, False)])])
73
+ keys = {(c.entity, c.component, c.param) for c in propose(rep, g, g, manifest)}
74
+ assert ("Player", "Hurtbox", "size_scale") not in keys # already at the floor
75
+
76
+
77
+ def test_rule_bullets_lower_fire_rate(manifest):
78
+ g = load_example("pixel_arena.json")
79
+ rep = aggregate([run("novice", "lost", [("EnemyBullet", 3, False)] * 3), run("average", "lost", [("EnemyBullet", 3, False)])])
80
+ changes = {(c.entity, c.param): c.value for c in propose(rep, g, g, manifest)}
81
+ assert changes[("Shooter", "fire_rate")] == pytest.approx(1.3 * 0.85, abs=0.01)
82
+
83
+
84
+ def test_rule_too_easy_only_on_hard_games(manifest):
85
+ g = graph()
86
+ rep = aggregate([run("novice"), run("novice"), run("average"), run("expert")])
87
+ assert propose(rep, g, g, manifest, difficulty=2) == []
88
+ hard = {(c.entity, c.param) for c in propose(rep, g, g, manifest, difficulty=4)}
89
+ assert ("Slime", "max_speed") in hard and ("Frog", "max_speed") in hard
90
+
91
+
92
+ def test_bounds_are_relative_to_the_original(manifest):
93
+ original = graph()
94
+ tuned = graph()
95
+ tuned["entities"]["Slime"]["components"]["PlatformerMovement"]["max_speed"] = 31
96
+ rep = aggregate([run("novice", "lost", [("Slime", 3, False)] * 3), run("average", "lost", [("Slime", 3, False)])])
97
+ c = next(c for c in propose(rep, tuned, original, manifest) if c.entity == "Slime")
98
+ assert c.value == 30 # floor: 50% of the original 60
99
+
100
+
101
+ def test_merge_tuning_latest_wins():
102
+ old = [{"entity": "Player", "component": "Health", "param": "max_health", "value": 4}]
103
+ new = merge_tuning(old, [Change("Player", "Health", "max_health", 5, "r"), Change("Slime", "PatrolAI", "speed_scale", 0.8, "r")])
104
+ assert {(t["entity"], t["value"]) for t in new} == {("Player", 5), ("Slime", 0.8)}
105
+
106
+
107
+ def test_time_limit_and_goal_entities():
108
+ g = graph()
109
+ assert 45 <= time_limit(g) <= 240
110
+ assert time_limit(load_example("pixel_arena.json"), {"goal": "survive", "survive_seconds": 120}) == 135
111
+ assert goal_entities(g) == {"Coin", "Heart", "Exit"}
112
+
113
+
114
+ def test_heatmap_renders(tmp_path, manifest):
115
+ from spawnpoint.analyze import analyze
116
+ plan = analyze(graph(), manifest).plan
117
+ out = heatmap(plan, [run("novice", "lost", [("Spikes", 3, False), ("fall", 20, True)])], tmp_path / "h.png")
118
+ img = Image.open(out)
119
+ assert img.width == plan["map"]["width"] * 12
120
+
121
+
122
+ @pytest.mark.skipif(not godot.available(), reason="Godot not installed")
123
+ def test_playtest_end_to_end(tmp_path):
124
+ out = tmp_path / "game"
125
+ assert build(load_example("pixel_hopper.json"), out).ok
126
+ res = playtest(out, runs=1, profiles=("expert",))
127
+ exp = res.final.get("expert")
128
+ assert exp.runs == 1 and exp.wins == 1, res.final.to_dict()["profiles"]
129
+ assert (out / "playtest" / "heatmap.png").exists() and (out / "playtest" / "report.md").exists()
tests/test_rules.py ADDED
@@ -0,0 +1,85 @@
1
+ import pytest
2
+
3
+ from spawnpoint.diagnostics import Diagnostics
4
+ from spawnpoint.expr import ExprError
5
+ from spawnpoint.graph import Rule
6
+ from spawnpoint.rules import compile_rules, resolve_event
7
+
8
+ ENTITIES = {
9
+ "Player": ["PlatformerMovement", "PlayerInput", "Health", "Hurtbox", "Stomp"],
10
+ "Slime": ["PlatformerMovement", "PatrolAI", "Health", "Hurtbox", "Hitbox"],
11
+ "Coin": ["Collectible"],
12
+ "Door": ["Gate"],
13
+ "Exit": ["TriggerZone"],
14
+ }
15
+
16
+
17
+ def run(manifest, *rules):
18
+ d = Diagnostics()
19
+ res = compile_rules([Rule.model_validate(r) for r in rules], ENTITIES, manifest, d)
20
+ return res, d
21
+
22
+
23
+ def test_short_event_resolves_unique_component(manifest):
24
+ ev = resolve_event("Coin.collected", ENTITIES, manifest)
25
+ assert (ev.component, ev.signal, ev.args) == ("Collectible", "collected", ["by"])
26
+
27
+
28
+ def test_ambiguous_event(manifest):
29
+ with pytest.raises(ExprError) as e:
30
+ resolve_event("Slime.dashed", {"Slime": ["PlatformerMovement", "TopDownMovement"]}, manifest)
31
+ assert "ambiguous" in str(e.value)
32
+ assert "Slime.PlatformerMovement.dashed" in e.value.hint
33
+
34
+
35
+ def test_unknown_signal_lists_available(manifest):
36
+ with pytest.raises(ExprError) as e:
37
+ resolve_event("Door.open", ENTITIES, manifest)
38
+ assert "Door.opened" in e.value.hint
39
+
40
+
41
+ def test_codegen(manifest):
42
+ res, d = run(manifest,
43
+ {"on": "Coin.collected", "if": "count('Coin') == 0", "do": ["Door.open()", "message('Open!')"]},
44
+ {"on": "Slime.died", "do": ["add_score(5)", "spawn('Coin')"]},
45
+ {"on": "Game.ready", "do": "message('Go')"},
46
+ {"on": "GameState.score_changed", "if": "event.score >= 3", "do": ["win()"]},
47
+ {"on": "Coin.collected", "do": ["event.by.Health.heal(1)"]})
48
+ assert d.ok, [str(x) for x in d]
49
+ src = res.source
50
+ assert res.calls_win
51
+ assert 'World.bind_signal(&"Coin", &"Collectible", &"collected", _rule_0)' in src
52
+ assert "func _rule_0(_a_by, _source) -> void:" in src
53
+ assert 'World.call_all(&"Door", &"Gate", &"open", [])' in src
54
+ assert 'World.spawn.call_deferred("Coin", source.global_position)' in src
55
+ assert "func _rule_1(source) -> void:" in src
56
+ assert "GameState.score_changed.connect(_rule_3)" in src
57
+ assert "func _rule_3(a_score) -> void:" in src
58
+ assert "_on_game_ready.call_deferred()" in src
59
+ assert 'World.call_on(a_by, &"Health", &"heal", [1])' in src
60
+
61
+
62
+ @pytest.mark.parametrize("rule, code, fragment", [
63
+ ({"on": "Coin.colected", "do": ["win()"]}, "E_RULE_EVENT", "Did you mean"),
64
+ ({"on": "Ghost.died", "do": ["win()"]}, "E_RULE_EVENT", "unknown entity"),
65
+ ({"on": "Exit.triggered", "do": ["wn()"]}, "E_RULE_DO", "unknown action"),
66
+ ({"on": "Exit.triggered", "do": ["Door.opne()"]}, "E_RULE_DO", "no method"),
67
+ ({"on": "Exit.triggered", "do": ["Door.Gate.open(1)"]}, "E_RULE_DO", "argument"),
68
+ ({"on": "Exit.triggered", "do": ["Coin.Gate.open()"]}, "E_RULE_DO", "no component"),
69
+ ({"on": "Game.ready", "do": ["spawn('Coin')"]}, "E_RULE_DO", "has none"),
70
+ ({"on": "Game.ready", "do": ["destroy(self)"]}, "E_RULE_DO", "not available"),
71
+ ({"on": "Exit.triggered", "if": "scor > 1", "do": ["win()"]}, "E_RULE_IF", "unknown name"),
72
+ ({"on": "Exit.triggered", "do": ["set_var(x, 1)"]}, "E_RULE_DO", "quotes"),
73
+ ({"on": "Exit.triggered", "do": ["win"]}, "E_RULE_DO", "must be a call"),
74
+ ])
75
+ def test_rule_errors(manifest, rule, code, fragment):
76
+ _, d = run(manifest, rule)
77
+ assert not d.ok
78
+ err = d.errors[0]
79
+ assert err.code == code
80
+ assert fragment in (err.message + " " + (err.hint or ""))
81
+
82
+
83
+ def test_error_paths_point_at_action(manifest):
84
+ _, d = run(manifest, {"on": "Exit.triggered", "do": ["win()", "nope()"]})
85
+ assert d.errors[0].path == "rules[0].do[1]"
tests/test_sfx.py ADDED
@@ -0,0 +1,30 @@
1
+ import wave
2
+
3
+ from spawnpoint import sfx
4
+
5
+
6
+ def test_presets_are_deterministic():
7
+ a = sfx.synthesize(sfx.preset("coin", 3), seed=3)
8
+ b = sfx.synthesize(sfx.preset("coin", 3), seed=3)
9
+ assert a == b
10
+ assert sfx.preset("coin", 3) != sfx.preset("coin", 4)
11
+
12
+
13
+ def test_all_presets_render_audible_short_sounds(tmp_path):
14
+ files = sfx.write_all(tmp_path, seed=1)
15
+ assert {f.stem for f in files} == set(sfx.PRESETS)
16
+ for f in files:
17
+ with wave.open(str(f)) as w:
18
+ assert w.getframerate() == sfx.SAMPLE_RATE and w.getsampwidth() == 2
19
+ seconds = w.getnframes() / w.getframerate()
20
+ assert 0.02 < seconds <= 2.0, f.name
21
+ frames = w.readframes(w.getnframes())
22
+ peak = max(abs(int.from_bytes(frames[i:i + 2], "little", signed=True)) for i in range(0, len(frames), 2))
23
+ assert peak > 8000, f"{f.name} is too quiet"
24
+
25
+
26
+ def test_write_all_skips_unchanged(tmp_path):
27
+ sfx.write_all(tmp_path, seed=1, names=["jump"])
28
+ mtime = (tmp_path / "jump.wav").stat().st_mtime_ns
29
+ sfx.write_all(tmp_path, seed=1, names=["jump"])
30
+ assert (tmp_path / "jump.wav").stat().st_mtime_ns == mtime
tests/test_sprites.py ADDED
@@ -0,0 +1,261 @@
1
+ import json
2
+
3
+ import numpy as np
4
+ import pytest
5
+ from PIL import Image
6
+
7
+ from spawnpoint.analyze import analyze
8
+ from spawnpoint.sprites import contour, importer
9
+ from spawnpoint.sprites.generate import KINDS, features_for, generate
10
+ from spawnpoint.sprites.resolve import convex_hull, plan_entry, safe_polygon
11
+ from spawnpoint.sprites.spriteset import SpriteError, load
12
+ from spawnpoint.studio.assemble import sprite_kind
13
+ from spawnpoint.studio.brief import GameBrief
14
+
15
+ from conftest import codes, load_example, minimal
16
+
17
+
18
+ def rgba(mask: np.ndarray) -> np.ndarray:
19
+ img = np.zeros((*mask.shape, 4), np.uint8)
20
+ img[mask] = (255, 0, 0, 255)
21
+ return img
22
+
23
+
24
+ # --- contour ----------------------------------------------------------------------------------
25
+
26
+ def test_contour_of_rectangle_is_exact():
27
+ m = np.zeros((8, 8), bool)
28
+ m[2:6, 1:7] = True
29
+ assert contour.outline(rgba(m)) == [(1.0, 2.0), (7.0, 2.0), (7.0, 6.0), (1.0, 6.0)]
30
+ assert contour.bounds(m) == (1, 2, 6, 4)
31
+
32
+
33
+ def test_contour_circle_area_and_detached_detail():
34
+ yy, xx = np.mgrid[:20, :20]
35
+ m = ((yy - 10) ** 2 + (xx - 10) ** 2) <= 49
36
+ m[2, 10] = True # antenna tip one pixel above the head
37
+ poly = contour.outline(rgba(m))
38
+ assert abs(abs(contour.polygon_area(poly)) - np.pi * 49) < 15
39
+ assert min(y for _, y in poly) == 2.0 # merged into the outline
40
+
41
+
42
+ def test_contour_empty():
43
+ assert contour.outline(np.zeros((4, 4, 4), np.uint8)) == []
44
+
45
+
46
+ def test_safe_polygon_falls_back_to_hull_for_self_intersections():
47
+ bowtie = [(0, 0), (4, 4), (4, 0), (0, 4)]
48
+ assert sorted(safe_polygon(bowtie)) == sorted(convex_hull(bowtie)) == [(0, 0), (0, 4), (4, 0), (4, 4)]
49
+ square = [(0, 0), (4, 0), (4, 4), (0, 4)]
50
+ assert safe_polygon(square) == square
51
+
52
+
53
+ # --- generator --------------------------------------------------------------------------------
54
+
55
+ @pytest.mark.parametrize("kind", sorted(KINDS))
56
+ def test_every_kind_renders(kind):
57
+ s = generate("Thing", kind, "#ff004d")
58
+ assert "idle" in s.animations
59
+ w, h = s.frame_size
60
+ for name, a in s.animations.items():
61
+ for col, row in a.frames:
62
+ assert (s.frame(col, row)[..., 3] > 0).any(), f"{kind}.{name} frame empty"
63
+ bx, by, bw, bh = s.bounds
64
+ assert 0 <= bx and 0 <= by and bx + bw <= w and by + bh <= h
65
+ assert len(s.polygon) >= 3
66
+ assert generate("Thing", kind, "#ff004d").sheet.tobytes() == s.sheet.tobytes() # deterministic
67
+
68
+
69
+ def test_features_from_names():
70
+ assert features_for("NinjaFox") == ["mask", "ears", "tail"]
71
+ assert features_for("fire bat") == ["wings"]
72
+ assert features_for("Player") == []
73
+
74
+
75
+ def test_names_change_the_look():
76
+ a = generate("Goblin", "hero", "#ff004d").sheet.tobytes()
77
+ b = generate("Ninja", "hero", "#ff004d").sheet.tobytes()
78
+ assert a != b
79
+
80
+
81
+ # --- import -----------------------------------------------------------------------------------
82
+
83
+ @pytest.fixture
84
+ def art(tmp_path):
85
+ """An artist-style sheet (4 columns, 2px spacing) and a folder of frames, from a generated knight."""
86
+ s = generate("Knight", "hero", "#c2c3c7", ["helmet"])
87
+ cells = [s.frame(*f) for a in ("idle", "run") for f in s.animations[a].frames] # 2 idle + 4 run
88
+ sheet = Image.new("RGBA", (4 * 26, 2 * 26))
89
+ for i, f in enumerate(cells):
90
+ sheet.paste(Image.fromarray(f), ((i % 4) * 26 + 6, (i // 4) * 26 + 4))
91
+ sheet.save(tmp_path / "knight.png")
92
+ frames = tmp_path / "frames"
93
+ frames.mkdir()
94
+ for a, fname in (("idle", "idle"), ("run", "walk")):
95
+ for i, f in enumerate(s.animations[a].frames):
96
+ Image.fromarray(s.frame(*f)).save(frames / f"knight_{fname}_{i}.png")
97
+ return tmp_path
98
+
99
+
100
+ def test_parse_ranges():
101
+ assert importer.parse_ranges("0-3, 7,9-10") == [0, 1, 2, 3, 7, 9, 10]
102
+
103
+
104
+ def test_import_grid_with_indices(art):
105
+ s = importer.import_sheet(art / "knight.png", grid=(24, 24), spacing=2, anims={"idle": [0, 1], "run": [2, 3, 4, 5]})
106
+ assert s.frame_size == (24, 24)
107
+ assert {k: len(a.frames) for k, a in s.animations.items()} == {"idle": 2, "run": 4}
108
+ assert s.name == "Knight"
109
+
110
+
111
+ def test_import_grid_rejects_empty_frames(art):
112
+ with pytest.raises(SpriteError, match="empty frame"):
113
+ importer.import_sheet(art / "knight.png", grid=(24, 24), spacing=2, anims={"run": [4, 5, 6, 7]})
114
+
115
+
116
+ def test_import_grid_rejects_out_of_range(art):
117
+ with pytest.raises(SpriteError, match="has 8 frames"):
118
+ importer.import_sheet(art / "knight.png", grid=(24, 24), spacing=2, anims={"idle": [0, 99]})
119
+
120
+
121
+ def test_import_auto_slices_by_gutters(art):
122
+ s = importer.import_sheet(art / "knight.png")
123
+ # Rows become animations in standard order; frames are trimmed and bottom-aligned.
124
+ assert [len(a.frames) for a in s.animations.values()] == [4, 2]
125
+ assert list(s.animations) == ["idle", "run"]
126
+ w, h = s.frame_size
127
+ assert w < 24 and h <= 24
128
+
129
+
130
+ def test_import_folder_groups_and_maps_synonyms(art):
131
+ s = importer.import_folder(art / "frames")
132
+ assert {k: len(a.frames) for k, a in s.animations.items()} == {"idle": 2, "run": 4}
133
+ assert s.animations["run"].fps == 10.0
134
+
135
+
136
+ def test_save_load_roundtrip_and_preview(art, tmp_path):
137
+ s = importer.import_folder(art / "frames", "Knight")
138
+ path = s.save(tmp_path / "lib")
139
+ back = load(path)
140
+ assert back.frame_size == s.frame_size and back.polygon == s.polygon and back.bounds == s.bounds
141
+ img = importer.preview(back)
142
+ assert img.width > 100 and img.height > 50
143
+
144
+
145
+ def test_load_errors(tmp_path):
146
+ with pytest.raises(SpriteError, match="cannot read"):
147
+ load(tmp_path / "nope.sprite.json")
148
+ Image.new("RGBA", (8, 8), (255, 0, 0, 255)).save(tmp_path / "a.png")
149
+ (tmp_path / "a.sprite.json").write_text(json.dumps({
150
+ "image": "a.png", "frame_size": [8, 8], "animations": {"idle": {"frames": [[1, 0]]}}}))
151
+ with pytest.raises(SpriteError, match="outside"):
152
+ load(tmp_path / "a.sprite.json")
153
+
154
+
155
+ # --- compiler integration ---------------------------------------------------------------------
156
+
157
+ def test_generated_sprite_derives_collision_and_plan(manifest):
158
+ g = minimal()
159
+ g["entities"]["Player"]["sprite"] = {"generate": {"kind": "hero"}}
160
+ g["entities"]["Coin"]["sprite"] = {"generate": {"kind": "coin"}}
161
+ a = analyze(g, manifest)
162
+ assert a.ok, [str(d) for d in a.diagnostics]
163
+ player = next(e for e in a.plan["entities"] if e["name"] == "Player")
164
+ s = a.sprites["Player"]
165
+ assert player["shape"]["type"] == "capsule"
166
+ assert player["shape"]["size"][1] == s.bounds[3] * s.scale
167
+ assert player["sprite"]["texture"] == "res://sprites/Player.png"
168
+ assert "Animator" in [c["name"] for c in player["components"]]
169
+ assert player["eye"] is False
170
+ # Feet still rest on the floor row.
171
+ p = next(p for p in a.plan["placements"] if p["entity"] == "Player")
172
+ assert p["y"] + player["shape"]["size"][1] / 2 == pytest.approx(64)
173
+
174
+
175
+ def test_plan_entry_centers_the_opaque_box():
176
+ s = generate("Coin", "coin", "#ffec27")
177
+ e = plan_entry("Coin", s)
178
+ bx, by, bw, bh = s.bounds
179
+ xs = [p[0] for p in e["polygon"]]
180
+ ys = [p[1] for p in e["polygon"]]
181
+ assert min(xs) == pytest.approx(-bw, abs=bw) and max(xs) <= bw * s.scale / 2 + 0.01
182
+ assert min(ys) >= -bh * s.scale / 2 - 0.01 and max(ys) <= bh * s.scale / 2 + 0.01
183
+
184
+
185
+ def test_explicit_shape_wins(manifest):
186
+ g = minimal()
187
+ g["entities"]["Player"]["sprite"] = {"generate": {"kind": "hero"}}
188
+ g["entities"]["Player"]["shape"] = {"type": "rect", "size": [20, 20]}
189
+ plan = analyze(g, manifest).plan
190
+ player = next(e for e in plan["entities"] if e["name"] == "Player")
191
+ assert player["shape"] == {"type": "rect", "size": [20.0, 20.0], "color": "#29adff"}
192
+
193
+
194
+ @pytest.mark.parametrize("sprite, fragment", [
195
+ ({"generate": {"kind": "dragon"}}, "unknown sprite kind"),
196
+ ("art/hero.sprite.json", "needs the graph's folder"),
197
+ ])
198
+ def test_sprite_errors(manifest, sprite, fragment):
199
+ g = minimal()
200
+ g["entities"]["Player"]["sprite"] = sprite
201
+ a = analyze(g, manifest)
202
+ err = next(d for d in a.diagnostics.errors if d.code == "E_SPRITE")
203
+ assert fragment in err.message and err.path == "entities.Player.sprite"
204
+
205
+
206
+ def test_sprite_path_relative_to_graph(manifest, tmp_path):
207
+ generate("Hero", "hero", "#29adff").save(tmp_path / "art", "hero")
208
+ g = minimal()
209
+ g["entities"]["Player"]["sprite"] = "art/hero.sprite.json"
210
+ assert analyze(g, manifest, base_dir=tmp_path).ok
211
+ g["entities"]["Player"]["sprite"] = "art/missing.sprite.json"
212
+ assert "E_SPRITE" in codes(analyze(g, manifest, base_dir=tmp_path).diagnostics)
213
+
214
+
215
+ def test_pixel_example_is_clean(manifest):
216
+ a = analyze(load_example("pixel_hopper.json"), manifest)
217
+ assert a.ok and not a.diagnostics.warnings, [str(d) for d in a.diagnostics]
218
+ assert len(a.sprites) == 8
219
+
220
+
221
+ # --- studio kind mapping ----------------------------------------------------------------------
222
+
223
+ @pytest.mark.parametrize("name, comps, archetype, platformer, kind", [
224
+ ("Player", {}, None, True, "hero"),
225
+ ("Goblin", {}, "walker", True, "hero"),
226
+ ("Slime", {}, "walker", True, "slime"),
227
+ ("FireBat", {}, "chaser", True, "flyer"),
228
+ ("LaserBot", {}, "shooter", False, "robot"),
229
+ ("Beetle", {}, "chaser", False, "bug"),
230
+ ("Thing", {}, "turret", False, "turret"),
231
+ ("Star", {"Collectible": {}}, None, True, "star"),
232
+ ("Acorn", {"Collectible": {}}, None, True, "orb"),
233
+ ("Crystal", {"Collectible": {}}, None, True, "gem"),
234
+ ("Exit", {}, None, False, "portal"),
235
+ ])
236
+ def test_sprite_kind(name, comps, archetype, platformer, kind):
237
+ b = GameBrief(title="t", genre="platformer" if platformer else "topdown")
238
+ assert sprite_kind(name, {"components": comps}, archetype, b, platformer) == kind
239
+
240
+
241
+ # --- tiles & zoom -----------------------------------------------------------------------------
242
+
243
+ @pytest.mark.parametrize("genre", ["platformer", "topdown"])
244
+ def test_pixel_tileset(genre):
245
+ from spawnpoint.sprites.tiles import tileset
246
+ img = tileset(genre, "#5f574f", "#00e436", "#ab5236", 32, seed=3)
247
+ assert img.size == (96, 32)
248
+ px = np.asarray(img)
249
+ assert (px[:, :64, 3] == 255).all() # solid tiles are fully opaque
250
+ assert (px[20:, 64:, 3] == 0).all() # the one-way plank leaves the lower part open
251
+ assert tileset(genre, "#5f574f", "#00e436", "#ab5236", 32, seed=3).tobytes() == img.tobytes()
252
+
253
+
254
+ def test_plan_carries_tiles_and_zoom(manifest):
255
+ g = minimal(settings={"zoom": 1.5})
256
+ a = analyze(g, manifest)
257
+ assert a.plan["zoom"] == 1.5 and a.plan["tiles"]["texture"] == "res://sprites/tiles.png"
258
+ assert a.tiles_image is not None
259
+ flat = analyze(minimal(settings={"tile_style": "flat"}), manifest)
260
+ assert "texture" not in flat.plan["tiles"] and flat.tiles_image is None
261
+ assert "E_SCHEMA" in codes(analyze(minimal(settings={"zoom": 9}), manifest).diagnostics)