spawnpoint-studio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. spawnpoint/__init__.py +3 -0
  2. spawnpoint/_data/examples/coin_hopper.json +228 -0
  3. spawnpoint/_data/examples/neon_arena.json +251 -0
  4. spawnpoint/_data/examples/pixel_arena.json +242 -0
  5. spawnpoint/_data/examples/pixel_hopper.json +221 -0
  6. spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
  7. spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
  8. spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
  9. spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
  10. spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
  11. spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
  12. spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
  13. spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
  14. spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
  15. spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
  16. spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
  17. spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
  18. spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
  19. spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
  20. spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
  21. spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
  22. spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
  23. spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
  24. spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
  25. spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
  26. spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
  27. spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
  28. spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
  29. spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
  30. spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
  31. spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
  32. spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
  33. spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
  34. spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
  35. spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
  36. spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
  37. spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
  38. spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
  39. spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
  40. spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
  41. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
  42. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
  43. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
  44. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
  45. spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
  46. spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
  47. spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
  48. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
  49. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
  50. spawnpoint/analyze.py +387 -0
  51. spawnpoint/build.py +146 -0
  52. spawnpoint/cli.py +290 -0
  53. spawnpoint/cockpit/__init__.py +0 -0
  54. spawnpoint/cockpit/server.py +473 -0
  55. spawnpoint/cockpit/static/app.css +111 -0
  56. spawnpoint/cockpit/static/app.js +327 -0
  57. spawnpoint/cockpit/static/index.html +79 -0
  58. spawnpoint/diagnostics.py +91 -0
  59. spawnpoint/expr.py +219 -0
  60. spawnpoint/gif.py +58 -0
  61. spawnpoint/godot.py +107 -0
  62. spawnpoint/graph.py +175 -0
  63. spawnpoint/level.py +157 -0
  64. spawnpoint/llm_server.py +77 -0
  65. spawnpoint/manifest.py +222 -0
  66. spawnpoint/playtest/__init__.py +104 -0
  67. spawnpoint/playtest/report.py +148 -0
  68. spawnpoint/playtest/runner.py +51 -0
  69. spawnpoint/playtest/tuner.py +151 -0
  70. spawnpoint/project.py +94 -0
  71. spawnpoint/py.typed +0 -0
  72. spawnpoint/rules.py +310 -0
  73. spawnpoint/sfx.py +352 -0
  74. spawnpoint/sprites/__init__.py +0 -0
  75. spawnpoint/sprites/ai.py +222 -0
  76. spawnpoint/sprites/contour.py +147 -0
  77. spawnpoint/sprites/generate.py +507 -0
  78. spawnpoint/sprites/importer.py +212 -0
  79. spawnpoint/sprites/resolve.py +139 -0
  80. spawnpoint/sprites/spriteset.py +159 -0
  81. spawnpoint/sprites/tiles.py +102 -0
  82. spawnpoint/studio/__init__.py +0 -0
  83. spawnpoint/studio/assemble.py +528 -0
  84. spawnpoint/studio/brief.py +197 -0
  85. spawnpoint/studio/creative.py +124 -0
  86. spawnpoint/studio/dataset.py +136 -0
  87. spawnpoint/studio/edits.py +206 -0
  88. spawnpoint/studio/levelgen.py +352 -0
  89. spawnpoint/studio/llm.py +145 -0
  90. spawnpoint/studio/orchestrator.py +341 -0
  91. spawnpoint/studio/prompts.py +122 -0
  92. spawnpoint/studio/web.py +123 -0
  93. spawnpoint/studio/webcheck.py +210 -0
  94. spawnpoint/studio_cli.py +232 -0
  95. spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
  96. spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
  97. spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
  98. spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
  99. spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  100. spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
  101. tests/conftest.py +49 -0
  102. tests/test_ai_sprites.py +122 -0
  103. tests/test_analyze.py +116 -0
  104. tests/test_cockpit.py +148 -0
  105. tests/test_e2e.py +33 -0
  106. tests/test_expr.py +59 -0
  107. tests/test_gif.py +19 -0
  108. tests/test_godot_library.py +17 -0
  109. tests/test_graph.py +44 -0
  110. tests/test_level.py +64 -0
  111. tests/test_live_llm.py +38 -0
  112. tests/test_manifest.py +54 -0
  113. tests/test_playtest.py +129 -0
  114. tests/test_rules.py +85 -0
  115. tests/test_sfx.py +30 -0
  116. tests/test_sprites.py +261 -0
  117. tests/test_studio.py +335 -0
  118. tests/test_web.py +41 -0
@@ -0,0 +1,197 @@
1
+ """GameBrief: the small, enumerated design an LLM fills in, plus normalization of weak-model output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ Genre = Literal["platformer", "topdown"]
11
+ Palette = Literal["night", "forest", "desert", "neon", "candy", "ocean", "lava", "snow"]
12
+ Ability = Literal["double_jump", "dash", "stomp", "shoot"]
13
+ Archetype = Literal["walker", "hopper", "chaser", "shooter", "turret"]
14
+ Speed = Literal["slow", "normal", "fast"]
15
+ Goal = Literal["reach_exit", "collect_all", "collect_all_then_exit", "defeat_all", "score_target", "survive"]
16
+ Hazard = Literal["spikes", "pits"]
17
+
18
+ ARCHETYPES_BY_GENRE: dict[str, tuple[str, ...]] = {
19
+ "platformer": ("walker", "hopper", "chaser", "shooter", "turret"),
20
+ "topdown": ("chaser", "shooter", "turret"),
21
+ }
22
+ # What an archetype becomes when it does not exist in the genre.
23
+ ARCHETYPE_FALLBACK = {"topdown": {"walker": "chaser", "hopper": "chaser"}}
24
+
25
+
26
+ class _Model(BaseModel):
27
+ model_config = ConfigDict(extra="forbid")
28
+
29
+
30
+ class PlayerBrief(_Model):
31
+ character: str = Field(default="Hero", max_length=24, description="what the player is, e.g. Ninja, Fox, Robot")
32
+ shape: Literal["capsule", "circle", "rect"] = "capsule"
33
+ abilities: list[Ability] = Field(default_factory=list, max_length=4)
34
+ jump_tiles: float = Field(default=3.5, ge=1.5, le=8.0, description="jump height in tiles (platformer)")
35
+ speed: Speed = "normal"
36
+ health: int = Field(default=3, ge=1, le=10)
37
+
38
+
39
+ class EnemyBrief(_Model):
40
+ name: str = Field(max_length=24)
41
+ archetype: Archetype
42
+ count: int = Field(default=3, ge=0, le=20)
43
+ health: int = Field(default=1, ge=1, le=20)
44
+ speed: Speed = "normal"
45
+
46
+
47
+ class CollectibleBrief(_Model):
48
+ name: str = Field(default="Coin", max_length=24)
49
+ count: int = Field(default=10, ge=0, le=60)
50
+
51
+
52
+ class GameBrief(_Model):
53
+ title: str = Field(max_length=40)
54
+ genre: Genre
55
+ palette: Palette = "night"
56
+ player: PlayerBrief = Field(default_factory=PlayerBrief)
57
+ enemies: list[EnemyBrief] = Field(default_factory=list, max_length=4)
58
+ collectible: CollectibleBrief = Field(default_factory=CollectibleBrief)
59
+ health_pickups: int = Field(default=1, ge=0, le=10)
60
+ goal: Goal = "collect_all_then_exit"
61
+ score_target: int = Field(default=100, ge=10, le=10000)
62
+ survive_seconds: int = Field(default=60, ge=10, le=600)
63
+ hazards: list[Hazard] = Field(default_factory=list, max_length=2)
64
+ length: Literal["short", "medium", "long"] = "medium"
65
+ difficulty: int = Field(default=2, ge=1, le=5)
66
+ feel: Literal["snappy", "floaty", "heavy"] = "snappy"
67
+ juice: Literal["subtle", "normal", "wild"] = "normal"
68
+ intro: str = Field(default="", max_length=80)
69
+
70
+
71
+ def json_schema() -> dict:
72
+ """JSON schema for guided decoding (vLLM / OpenAI structured outputs)."""
73
+ return GameBrief.model_json_schema()
74
+
75
+
76
+ # --- normalization ----------------------------------------------------------------------------
77
+
78
+ RESERVED_NAMES = {
79
+ "Player", "Bullet", "EnemyBullet", "Exit", "Door", "HealthPack", "Spikes", "Portal",
80
+ "GameState", "Game", "World", "FX", "Sfx", "SP", "Entity", "Visual", "Shape", "Main", "Tiles",
81
+ "Entities", "Rules", "HUD", "Camera",
82
+ }
83
+ COMPONENT_NAMES = {
84
+ "Health", "Hitbox", "Hurtbox", "Weapon", "Projectile", "Collectible", "TriggerZone", "Spawner",
85
+ "Gate", "PlayerInput", "PatrolAI", "ChaseAI", "PlatformerMovement", "TopDownMovement", "Aim",
86
+ "Juice", "Stomp",
87
+ }
88
+ _IRREGULAR = {"enemies": "enemy", "bodies": "body", "ponies": "pony", "berries": "berry", "cherries": "cherry",
89
+ "flies": "fly", "mice": "mouse", "geese": "goose", "children": "child", "leaves": "leaf",
90
+ "knives": "knife", "wolves": "wolf", "elves": "elf", "zombies": "zombie", "lives": "life"}
91
+ _KEEP_S = ("ss", "us", "is", "os")
92
+
93
+
94
+ def pascal_singular(raw: str, fallback: str) -> str:
95
+ words = re.findall(r"[A-Za-z][A-Za-z0-9]*", raw or "")
96
+ if not words:
97
+ return fallback
98
+ last = words[-1].lower()
99
+ if last in _IRREGULAR:
100
+ words[-1] = _IRREGULAR[last]
101
+ elif last.endswith("ies") and len(last) > 4:
102
+ words[-1] = words[-1][:-3] + "y"
103
+ elif last.endswith("s") and not last.endswith(_KEEP_S) and len(last) > 3:
104
+ words[-1] = words[-1][:-1]
105
+ name = "".join(w[:1].upper() + w[1:] for w in words)[:24]
106
+ return name if name[:1].isalpha() else fallback
107
+
108
+
109
+ def normalize(brief: GameBrief) -> tuple[GameBrief, list[str]]:
110
+ """Repair typical weak-model mistakes. Returns a new brief and human-readable notes."""
111
+ b = brief.model_copy(deep=True)
112
+ notes: list[str] = []
113
+ taken = set(RESERVED_NAMES) | set(COMPONENT_NAMES)
114
+
115
+ def unique(name: str, fallback: str, what: str) -> str:
116
+ fixed = pascal_singular(name, fallback)
117
+ if fixed in taken:
118
+ fixed = fixed + ("Foe" if what == "enemy" else "Item")
119
+ base, i = fixed, 2
120
+ while fixed in taken:
121
+ fixed, i = f"{base}{i}", i + 1
122
+ if fixed != name:
123
+ notes.append(f"renamed {what} '{name}' -> '{fixed}'")
124
+ taken.add(fixed)
125
+ return fixed
126
+
127
+ b.collectible.name = unique(b.collectible.name, "Coin", "collectible")
128
+ b.player.character = pascal_singular(b.player.character, "Hero")
129
+
130
+ allowed = ARCHETYPES_BY_GENRE[b.genre]
131
+ enemies = []
132
+ seen_names: set[str] = set()
133
+ for e in b.enemies:
134
+ if e.count == 0:
135
+ notes.append(f"dropped enemy '{e.name}' with count 0")
136
+ continue
137
+ if e.archetype not in allowed:
138
+ new = ARCHETYPE_FALLBACK.get(b.genre, {}).get(e.archetype, allowed[0])
139
+ notes.append(f"{e.name}: archetype '{e.archetype}' does not exist in {b.genre}; using '{new}'")
140
+ e.archetype = new
141
+ if pascal_singular(e.name, "Enemy").lower() in seen_names:
142
+ notes.append(f"merged duplicate enemy '{e.name}'")
143
+ continue
144
+ seen_names.add(pascal_singular(e.name, "Enemy").lower())
145
+ e.name = unique(e.name, "Enemy", "enemy")
146
+ enemies.append(e)
147
+ b.enemies = enemies
148
+
149
+ b.player.abilities = list(dict.fromkeys(b.player.abilities))
150
+ if b.genre == "topdown":
151
+ dropped = [a for a in b.player.abilities if a in ("double_jump", "stomp")]
152
+ if dropped:
153
+ notes.append(f"top-down has no jumping; ignored {', '.join(dropped)}")
154
+ b.player.abilities = [a for a in b.player.abilities if a not in dropped]
155
+ if "pits" in b.hazards:
156
+ b.hazards = [h for h in b.hazards if h != "pits"]
157
+ notes.append("top-down has no pits; ignored")
158
+ if not b.enemies and "shoot" not in b.player.abilities and b.goal in ("defeat_all",):
159
+ pass
160
+ b.hazards = list(dict.fromkeys(b.hazards))
161
+
162
+ total_enemies = sum(e.count for e in b.enemies)
163
+ if b.goal in ("collect_all", "collect_all_then_exit") and b.collectible.count == 0:
164
+ b.collectible.count = 8
165
+ notes.append(f"goal '{b.goal}' needs pickups; added 8 {b.collectible.name}s")
166
+ if b.goal == "defeat_all":
167
+ if total_enemies == 0:
168
+ b.goal = "reach_exit" if b.genre == "platformer" else "collect_all"
169
+ notes.append(f"goal 'defeat_all' without enemies; using '{b.goal}'")
170
+ elif b.genre == "topdown" and "shoot" not in b.player.abilities:
171
+ b.player.abilities.append("shoot")
172
+ notes.append("defeating enemies in top-down needs shooting; gave the player 'shoot'")
173
+ elif b.genre == "platformer" and not {"stomp", "shoot"} & set(b.player.abilities):
174
+ b.player.abilities.append("stomp")
175
+ notes.append("defeating enemies needs an attack; gave the player 'stomp'")
176
+ if b.goal == "collect_all" and b.genre == "topdown" and b.collectible.count == 0:
177
+ b.collectible.count = 10
178
+ if b.goal == "score_target":
179
+ reachable = b.collectible.count + total_enemies * 10
180
+ if reachable < b.score_target and b.genre == "platformer":
181
+ b.score_target = max(10, reachable - reachable % 5)
182
+ notes.append(f"score_target lowered to {b.score_target} (the level holds only that many points)")
183
+ if not b.intro:
184
+ b.intro = _default_intro(b)
185
+ return b, notes
186
+
187
+
188
+ def _default_intro(b: GameBrief) -> str:
189
+ c = b.collectible.name
190
+ return {
191
+ "reach_exit": "Reach the exit!",
192
+ "collect_all": f"Collect every {c}!",
193
+ "collect_all_then_exit": f"Collect every {c} to open the door!",
194
+ "defeat_all": "Defeat every enemy!",
195
+ "score_target": f"Score {b.score_target} points!",
196
+ "survive": f"Survive {b.survive_seconds} seconds!",
197
+ }[b.goal]
@@ -0,0 +1,124 @@
1
+ """Creative pass: the LLM proposes a small graph patch; the compiler is the judge.
2
+
3
+ The patch is applied to a copy of the graph and analyzed. Diagnostics go back to the LLM for up to
4
+ `max_repairs` rounds. If it still does not compile, the patch is dropped — the working build is kept.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import json
11
+ from dataclasses import dataclass, field
12
+
13
+ from ..analyze import analyze
14
+ from ..manifest import Manifest
15
+ from .llm import ChatModel, LLMError
16
+ from .prompts import creative_messages, repair_message
17
+
18
+ PATCH_SCHEMA = {
19
+ "type": "object",
20
+ "properties": {
21
+ "set_params": {
22
+ "type": "array", "maxItems": 4,
23
+ "items": {
24
+ "type": "object",
25
+ "properties": {
26
+ "entity": {"type": "string"}, "component": {"type": "string"}, "param": {"type": "string"},
27
+ "value": {"anyOf": [{"type": "number"}, {"type": "string"}, {"type": "boolean"}]},
28
+ },
29
+ "required": ["entity", "component", "param", "value"], "additionalProperties": False,
30
+ },
31
+ },
32
+ "add_rules": {
33
+ "type": "array", "maxItems": 3,
34
+ "items": {
35
+ "type": "object",
36
+ "properties": {"on": {"type": "string"}, "if": {"type": "string"},
37
+ "do": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 4}},
38
+ "required": ["on", "if", "do"], "additionalProperties": False,
39
+ },
40
+ },
41
+ },
42
+ "required": ["set_params", "add_rules"], "additionalProperties": False,
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class CreativeResult:
48
+ graph: dict
49
+ applied: bool
50
+ patch: dict | None = None
51
+ rounds: int = 0
52
+ errors: list[str] = field(default_factory=list)
53
+ history: list[dict] = field(default_factory=list) # per round: {"patch", "errors"}
54
+
55
+
56
+ def apply_patch(graph: dict, patch: dict) -> dict:
57
+ """Applies a graph patch: add_entities, add_components, set_params, add_rules (in that order)."""
58
+ g = copy.deepcopy(graph)
59
+ for name, ent in (patch.get("add_entities") or {}).items():
60
+ g["entities"].setdefault(name, copy.deepcopy(ent))
61
+ for ac in patch.get("add_components", []):
62
+ ent = g["entities"].setdefault(ac["entity"], {"components": {}})
63
+ ent.setdefault("components", {}).setdefault(ac["component"], {}).update(ac.get("params") or {})
64
+ for sp in patch.get("set_params", []):
65
+ ent = g["entities"].get(sp["entity"])
66
+ if ent is None:
67
+ # Unknown entity: create the reference so the compiler reports it with a hint.
68
+ g["entities"].setdefault(sp["entity"], {"components": {}})
69
+ ent = g["entities"][sp["entity"]]
70
+ ent.setdefault("components", {}).setdefault(sp["component"], {})[sp["param"]] = sp["value"]
71
+ for r in patch.get("add_rules", []):
72
+ rule = {"on": r["on"], "do": r["do"]}
73
+ if r.get("if", "").strip():
74
+ rule["if"] = r["if"]
75
+ g.setdefault("rules", []).append(rule)
76
+ return g
77
+
78
+
79
+ def summarize_graph(graph: dict) -> str:
80
+ lines = [f"genre: {graph['genre']}", "entities:"]
81
+ for name, ent in graph["entities"].items():
82
+ comps = ", ".join(f"{c}{params if params else ''}" for c, params in ent.get("components", {}).items())
83
+ lines.append(f" {name}: {comps}")
84
+ lines.append("rules:")
85
+ for r in graph.get("rules", []):
86
+ cond = f" if {r['if']}" if r.get("if") else ""
87
+ lines.append(f" on {r['on']}{cond} do {r['do']}")
88
+ return "\n".join(lines)
89
+
90
+
91
+ def reference(graph: dict, manifest: Manifest) -> str:
92
+ """Only the components the game uses, to keep the prompt small."""
93
+ used = {c for ent in graph["entities"].values() for c in ent.get("components", {})}
94
+ lines = []
95
+ for name in sorted(used):
96
+ comp = manifest.get(name)
97
+ if comp is None:
98
+ continue
99
+ params = "; ".join(p.describe() for p in comp.params.values())
100
+ sigs = ", ".join(f"{s.name}({', '.join(a.name for a in s.args)})" for s in comp.signals.values())
101
+ methods = ", ".join(f"{m.name}({', '.join(a.name for a in m.args)})" for m in comp.methods.values())
102
+ lines.append(f"{name}: params {params}" + (f" | signals {sigs}" if sigs else "")
103
+ + (f" | methods {methods}" if methods else ""))
104
+ return "\n".join(lines)
105
+
106
+
107
+ def creative_pass(graph: dict, prompt: str, llm: ChatModel, manifest: Manifest, max_repairs: int = 2) -> CreativeResult:
108
+ messages = creative_messages(prompt, summarize_graph(graph), reference(graph, manifest))
109
+ errors: list[str] = []
110
+ history: list[dict] = []
111
+ for round_no in range(1 + max_repairs):
112
+ try:
113
+ patch = llm.chat_json(messages, PATCH_SCHEMA, "GraphPatch", temperature=0.5, max_tokens=800)
114
+ except LLMError as e:
115
+ return CreativeResult(graph, False, None, round_no, [str(e)], history)
116
+ candidate = apply_patch(graph, patch)
117
+ analysis = analyze(candidate, manifest)
118
+ errors = [str(d) for d in analysis.diagnostics.errors]
119
+ errors += [str(d) for d in analysis.diagnostics.warnings if d.code == "W_UNREACHABLE"]
120
+ history.append({"patch": patch, "errors": errors})
121
+ if not errors:
122
+ return CreativeResult(candidate, True, patch, round_no + 1, [], history)
123
+ messages = messages + [{"role": "assistant", "content": json.dumps(patch)}, repair_message(errors)]
124
+ return CreativeResult(graph, False, None, 1 + max_repairs, errors, history)
@@ -0,0 +1,136 @@
1
+ """Opt-in, local-only recording of LLM calls as fine-tuning data (sub-project 7).
2
+
3
+ Each LLM call becomes one JSONL record: the exact messages, the model's raw output, and a
4
+ `target` — the corrected answer the pipeline actually used (the normalized brief, or the patch the
5
+ compiler accepted). Training on `messages -> target` teaches a model to stop making the mistakes the
6
+ normalizer and the compiler had to catch. Nothing leaves the machine.
7
+
8
+ Enable with `--record` or SPAWNPOINT_RECORD=1; location: SPAWNPOINT_DATASET_DIR
9
+ (default ~/.local/share/spawnpoint/dataset).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ import uuid
18
+ from collections import Counter
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .llm import ChatModel, Usage
24
+
25
+ TASKS = {"Translation": "translate", "GameBrief": "brief", "BriefUpdate": "tweak", "GraphPatch": "creative"}
26
+
27
+
28
+ def enabled_by_env() -> bool:
29
+ return os.environ.get("SPAWNPOINT_RECORD", "").lower() in ("1", "true", "yes", "on")
30
+
31
+
32
+ def default_dir() -> Path:
33
+ env = os.environ.get("SPAWNPOINT_DATASET_DIR")
34
+ return Path(env) if env else Path.home() / ".local" / "share" / "spawnpoint" / "dataset"
35
+
36
+
37
+ @dataclass
38
+ class Record:
39
+ task: str
40
+ messages: list[dict]
41
+ schema_name: str
42
+ raw: Any
43
+ model: str
44
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
45
+ time: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%S"))
46
+ target: Any = None
47
+ notes: list[str] = field(default_factory=list)
48
+ errors: list[str] = field(default_factory=list)
49
+ accepted: bool | None = None
50
+ corrected: bool | None = None # the pipeline had to fix the model's answer
51
+ meta: dict = field(default_factory=dict)
52
+
53
+
54
+ class Recorder:
55
+ """Collects records for one studio operation, then appends them to the day's JSONL file."""
56
+
57
+ def __init__(self, directory: Path | None = None):
58
+ self.directory = directory or default_dir()
59
+ self.pending: list[Record] = []
60
+
61
+ def wrap(self, llm: ChatModel) -> "RecordingModel":
62
+ return RecordingModel(llm, self)
63
+
64
+ def last(self, task: str) -> Record | None:
65
+ for r in reversed(self.pending):
66
+ if r.task == task:
67
+ return r
68
+ return None
69
+
70
+ def all(self, task: str) -> list[Record]:
71
+ return [r for r in self.pending if r.task == task]
72
+
73
+ def flush(self, accepted: bool, meta: dict | None = None) -> Path | None:
74
+ if not self.pending:
75
+ return None
76
+ self.directory.mkdir(parents=True, exist_ok=True)
77
+ path = self.directory / f"{time.strftime('%Y-%m-%d')}.jsonl"
78
+ with path.open("a", encoding="utf-8") as f:
79
+ for r in self.pending:
80
+ if r.accepted is None:
81
+ r.accepted = accepted
82
+ r.meta = {**(meta or {}), **r.meta}
83
+ f.write(json.dumps(r.__dict__, ensure_ascii=False) + "\n")
84
+ self.pending = []
85
+ return path
86
+
87
+
88
+ @dataclass
89
+ class RecordingModel:
90
+ """ChatModel proxy that records every call into a Recorder."""
91
+
92
+ inner: ChatModel
93
+ recorder: Recorder
94
+
95
+ @property
96
+ def name(self) -> str:
97
+ return self.inner.name
98
+
99
+ @property
100
+ def usage(self) -> Usage:
101
+ return self.inner.usage
102
+
103
+ def chat_json(self, messages: list[dict], schema: dict, schema_name: str, **kw) -> dict:
104
+ raw = self.inner.chat_json(messages, schema, schema_name, **kw)
105
+ self.recorder.pending.append(Record(
106
+ task=TASKS.get(schema_name, schema_name), messages=messages, schema_name=schema_name,
107
+ raw=raw, model=self.inner.name))
108
+ return raw
109
+
110
+
111
+ def iter_records(directory: Path | None = None):
112
+ d = directory or default_dir()
113
+ for path in sorted(d.glob("*.jsonl")):
114
+ for line in path.read_text(encoding="utf-8").splitlines():
115
+ if line.strip():
116
+ yield json.loads(line)
117
+
118
+
119
+ def stats(directory: Path | None = None) -> dict:
120
+ by_task: Counter = Counter()
121
+ accepted: Counter = Counter()
122
+ corrected: Counter = Counter()
123
+ models: Counter = Counter()
124
+ for r in iter_records(directory):
125
+ t = r["task"]
126
+ by_task[t] += 1
127
+ models[r.get("model", "?")] += 1
128
+ if r.get("accepted"):
129
+ accepted[t] += 1
130
+ if r.get("corrected"):
131
+ corrected[t] += 1
132
+ return {
133
+ "records": sum(by_task.values()),
134
+ "models": dict(models),
135
+ "tasks": {t: {"count": n, "accepted": accepted[t], "corrected": corrected[t]} for t, n in sorted(by_task.items())},
136
+ }
@@ -0,0 +1,206 @@
1
+ """Edits of a built game from the cockpit: direct parameter changes and LLM edits scoped to one entity.
2
+
3
+ Every edit is a graph patch (add_entities / add_components / set_params / add_rules). Studio games keep
4
+ it as an override in studio.json (re-applied after every assemble); hand-written graphs are patched in
5
+ place. The compiler judges every patch before anything is built.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from .. import manifest as manifest_mod
16
+ from ..analyze import analyze
17
+ from ..build import BuildResult, build
18
+ from ..manifest import Manifest
19
+ from . import web
20
+ from .assemble import LAYERS, assemble
21
+ from .brief import GameBrief
22
+ from .creative import apply_patch
23
+ from .llm import ChatModel, LLMError
24
+ from .orchestrator import SESSION_FILE, _save_session, finalize_graph, load_session
25
+ from .prompts import needs_translation, repair_message, translate_messages, TRANSLATE_SCHEMA
26
+
27
+ VALUE = {"anyOf": [{"type": "number"}, {"type": "string"}, {"type": "boolean"}]}
28
+ ENTITY_PATCH_SCHEMA = {
29
+ "type": "object",
30
+ "properties": {
31
+ "set_params": {"type": "array", "maxItems": 6, "items": {
32
+ "type": "object", "additionalProperties": False, "required": ["component", "param", "value"],
33
+ "properties": {"component": {"type": "string"}, "param": {"type": "string"}, "value": VALUE}}},
34
+ "add_components": {"type": "array", "maxItems": 3, "items": {
35
+ "type": "object", "additionalProperties": False, "required": ["component", "params"],
36
+ "properties": {"component": {"type": "string"}, "params": {"type": "array", "maxItems": 6, "items": {
37
+ "type": "object", "additionalProperties": False, "required": ["param", "value"],
38
+ "properties": {"param": {"type": "string"}, "value": VALUE}}}}}},
39
+ "add_rules": {"type": "array", "maxItems": 2, "items": {
40
+ "type": "object", "additionalProperties": False, "required": ["on", "if", "do"],
41
+ "properties": {"on": {"type": "string"}, "if": {"type": "string"},
42
+ "do": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 3}}}},
43
+ },
44
+ "required": ["set_params", "add_components", "add_rules"],
45
+ "additionalProperties": False,
46
+ }
47
+
48
+ ADDABLE = ("Health", "Hurtbox", "Hitbox", "Weapon", "ChaseAI", "PatrolAI", "Spawner", "Stomp",
49
+ "PlatformerMovement", "TopDownMovement", "Collectible", "Gate")
50
+
51
+ ENTITY_SYSTEM = """You edit ONE entity of a 2D game built from components. Return a small patch for it.
52
+ - set_params: change parameters of components the entity already has (use the exact names below, stay in range).
53
+ - add_components: give it a new component (only from the list of addable components).
54
+ - add_rules: optional extra rules, e.g. {"on": "Slime.died", "if": "", "do": ["shake(0.6)"]}.
55
+ Common requests:
56
+ - "more health" / "x2 health" -> Health.max_health. "faster" -> the movement component's max_speed.
57
+ - "make it shoot" -> add Weapon with projectile "EnemyBullet" (it will be created) and auto_fire true,
58
+ or with ChaseAI so it aims at the player. "fire bullets" -> projectile "Fireball".
59
+ - "jump higher" -> PlatformerMovement.jump_velocity. "hits harder" -> Hitbox.damage.
60
+ Keep it minimal: change only what is asked."""
61
+
62
+
63
+ @dataclass
64
+ class EditResult:
65
+ ok: bool
66
+ patch: dict | None = None
67
+ build: BuildResult | None = None
68
+ errors: list[str] = field(default_factory=list)
69
+ rounds: int = 0
70
+ english: str = ""
71
+
72
+
73
+ def current_graph(project: Path) -> dict:
74
+ return json.loads((project / "spawnpoint.json").read_text())
75
+
76
+
77
+ def _entity_view(graph: dict, entity: str, manifest: Manifest) -> str:
78
+ ent = graph["entities"][entity]
79
+ lines = [f"Entity {entity}: body {ent.get('body', 'character')}, team {ent.get('team', 'auto')}"]
80
+ for cname, params in ent.get("components", {}).items():
81
+ comp = manifest.get(cname)
82
+ if comp is None:
83
+ continue
84
+ values = {p.name: params.get(p.name, p.default) for p in comp.params.values()}
85
+ ranges = "; ".join(p.describe() for p in comp.params.values())
86
+ lines.append(f"- {cname} current {json.dumps(values)} | params: {ranges}")
87
+ addable = [c for c in ADDABLE if c not in ent.get("components", {}) and manifest.get(c)]
88
+ lines.append("Addable components:")
89
+ for cname in addable:
90
+ comp = manifest.get(cname)
91
+ lines.append(f"- {cname}: {comp.doc.splitlines()[0] if comp.doc else ''} params: "
92
+ + "; ".join(p.describe() for p in comp.params.values()))
93
+ lines.append("Other entities: " + ", ".join(n for n in graph["entities"] if n != entity))
94
+ return "\n".join(lines)
95
+
96
+
97
+ def scope_patch(entity: str, raw: dict) -> dict:
98
+ """LLM output (entity-relative) -> generic graph patch."""
99
+ return {
100
+ "set_params": [dict(sp, entity=entity) for sp in raw.get("set_params", [])],
101
+ "add_components": [{"entity": entity, "component": ac["component"],
102
+ "params": {p["param"]: p["value"] for p in ac.get("params", [])}}
103
+ for ac in raw.get("add_components", [])],
104
+ "add_rules": [{k: v for k, v in r.items() if not (k == "if" and not str(v).strip())}
105
+ for r in raw.get("add_rules", [])],
106
+ }
107
+
108
+
109
+ def add_missing_projectiles(graph: dict, patch: dict) -> dict:
110
+ """A new Weapon may name a projectile that does not exist yet: create a sensible one."""
111
+ patched = apply_patch(graph, patch)
112
+ extra = dict(patch.get("add_entities") or {})
113
+ for name, ent in patched["entities"].items():
114
+ weapon = ent.get("components", {}).get("Weapon")
115
+ if weapon is None:
116
+ continue
117
+ proj = weapon.get("projectile", "Bullet")
118
+ if proj in patched["entities"] or proj in extra:
119
+ continue
120
+ fiery = any(w in proj.lower() for w in ("fire", "flame", "lava", "magma"))
121
+ team = ent.get("team") or ("player" if "PlayerInput" in ent.get("components", {}) else "enemy")
122
+ extra[proj] = {
123
+ "body": "area", "team": team,
124
+ "sprite": {"generate": {"kind": "bullet", "color": "#ff6c11" if fiery else "#ff77a8"}},
125
+ "components": {"Projectile": {"speed": 320.0 if team == "enemy" else 700.0, "lifetime": 2.5},
126
+ "Hitbox": {"damage": 1, "knockback": 220.0}},
127
+ }
128
+ if extra:
129
+ patch = dict(patch, add_entities=extra)
130
+ return patch
131
+
132
+
133
+ def ask_entity(project: Path, entity: str, request: str, llm: ChatModel, manifest: Manifest | None = None,
134
+ max_repairs: int = 2) -> tuple[dict | None, list[str], int, str]:
135
+ """LLM -> validated patch for one entity. Returns (patch or None, last errors, rounds, english)."""
136
+ manifest = manifest or manifest_mod.load()
137
+ graph = current_graph(project)
138
+ if entity not in graph["entities"]:
139
+ return None, [f"unknown entity '{entity}'"], 0, request
140
+ english = request
141
+ if needs_translation(request):
142
+ english = llm.chat_json(translate_messages(request), TRANSLATE_SCHEMA, "Translation",
143
+ temperature=0.1, max_tokens=300).get("english") or request
144
+ messages = [{"role": "system", "content": ENTITY_SYSTEM},
145
+ {"role": "user", "content": f"{_entity_view(graph, entity, manifest)}\n\nRequest: {english}"}]
146
+ errors: list[str] = []
147
+ for round_no in range(1 + max_repairs):
148
+ raw = llm.chat_json(messages, ENTITY_PATCH_SCHEMA, "EntityPatch", temperature=0.2, max_tokens=700)
149
+ patch = add_missing_projectiles(graph, scope_patch(entity, raw))
150
+ if not any(patch.get(k) for k in ("set_params", "add_components", "add_rules")):
151
+ errors = ["the patch is empty"]
152
+ else:
153
+ analysis = analyze(apply_patch(graph, patch), manifest, base_dir=project)
154
+ errors = [str(d) for d in analysis.diagnostics.errors]
155
+ if not errors:
156
+ return patch, [], round_no + 1, english
157
+ messages = messages + [{"role": "assistant", "content": json.dumps(raw)}, repair_message(errors)]
158
+ return None, errors, 1 + max_repairs, english
159
+
160
+
161
+ def apply_override(project: Path, patch: dict, *, manifest: Manifest | None = None, web_export: bool = True,
162
+ smoke: bool = True) -> EditResult:
163
+ """Validates, persists and builds a patch. Studio games keep it in studio.json overrides."""
164
+ manifest = manifest or manifest_mod.load()
165
+ studio = (project / SESSION_FILE).exists()
166
+ if studio:
167
+ session = load_session(project)
168
+ brief = GameBrief.model_validate(session["brief"])
169
+ trial = dict(session, overrides=(session.get("overrides") or []) + [patch])
170
+ base = assemble(brief, LAYERS[-1], session["seed"], session.get("chunks"), ai_art=bool(session.get("ai_art")))
171
+ graph = finalize_graph(base, trial, manifest)
172
+ # finalize_graph silently skips patches that no longer fit; this one must fit now.
173
+ check = analyze(apply_patch(finalize_graph(base, session, manifest), patch), manifest)
174
+ else:
175
+ graph = apply_patch(current_graph(project), patch)
176
+ check = analyze(graph, manifest, base_dir=project)
177
+ errors = [str(d) for d in check.diagnostics.errors]
178
+ if errors:
179
+ return EditResult(False, patch, errors=errors)
180
+ res = build(graph, project, manifest=manifest, smoke=smoke)
181
+ if not res.ok:
182
+ return EditResult(False, patch, res, [str(d) for d in res.diagnostics.errors])
183
+ if studio:
184
+ session["overrides"] = trial["overrides"]
185
+ _save_session(project, session)
186
+ if web_export and (project / web.WEB_DIR / "index.html").exists():
187
+ web.export(project)
188
+ return EditResult(True, patch, res)
189
+
190
+
191
+ def set_param_patch(entity: str, component: str, param: str, value) -> dict:
192
+ return {"set_params": [{"entity": entity, "component": component, "param": param, "value": value}]}
193
+
194
+
195
+ def edit_entity(project: Path, entity: str, request: str, llm: ChatModel, *, manifest: Manifest | None = None,
196
+ web_export: bool = True) -> EditResult:
197
+ manifest = manifest or manifest_mod.load()
198
+ try:
199
+ patch, errors, rounds, english = ask_entity(project, entity, request, llm, manifest)
200
+ except LLMError as e:
201
+ return EditResult(False, None, errors=[str(e)])
202
+ if patch is None:
203
+ return EditResult(False, None, errors=errors, rounds=rounds, english=english)
204
+ res = apply_override(project, copy.deepcopy(patch), manifest=manifest, web_export=web_export)
205
+ res.rounds, res.english = rounds, english
206
+ return res