spawnpoint-studio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spawnpoint/__init__.py +3 -0
- spawnpoint/_data/examples/coin_hopper.json +228 -0
- spawnpoint/_data/examples/neon_arena.json +251 -0
- spawnpoint/_data/examples/pixel_arena.json +242 -0
- spawnpoint/_data/examples/pixel_hopper.json +221 -0
- spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
- spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
- spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
- spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
- spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
- spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
- spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
- spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
- spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
- spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
- spawnpoint/analyze.py +387 -0
- spawnpoint/build.py +146 -0
- spawnpoint/cli.py +290 -0
- spawnpoint/cockpit/__init__.py +0 -0
- spawnpoint/cockpit/server.py +473 -0
- spawnpoint/cockpit/static/app.css +111 -0
- spawnpoint/cockpit/static/app.js +327 -0
- spawnpoint/cockpit/static/index.html +79 -0
- spawnpoint/diagnostics.py +91 -0
- spawnpoint/expr.py +219 -0
- spawnpoint/gif.py +58 -0
- spawnpoint/godot.py +107 -0
- spawnpoint/graph.py +175 -0
- spawnpoint/level.py +157 -0
- spawnpoint/llm_server.py +77 -0
- spawnpoint/manifest.py +222 -0
- spawnpoint/playtest/__init__.py +104 -0
- spawnpoint/playtest/report.py +148 -0
- spawnpoint/playtest/runner.py +51 -0
- spawnpoint/playtest/tuner.py +151 -0
- spawnpoint/project.py +94 -0
- spawnpoint/py.typed +0 -0
- spawnpoint/rules.py +310 -0
- spawnpoint/sfx.py +352 -0
- spawnpoint/sprites/__init__.py +0 -0
- spawnpoint/sprites/ai.py +222 -0
- spawnpoint/sprites/contour.py +147 -0
- spawnpoint/sprites/generate.py +507 -0
- spawnpoint/sprites/importer.py +212 -0
- spawnpoint/sprites/resolve.py +139 -0
- spawnpoint/sprites/spriteset.py +159 -0
- spawnpoint/sprites/tiles.py +102 -0
- spawnpoint/studio/__init__.py +0 -0
- spawnpoint/studio/assemble.py +528 -0
- spawnpoint/studio/brief.py +197 -0
- spawnpoint/studio/creative.py +124 -0
- spawnpoint/studio/dataset.py +136 -0
- spawnpoint/studio/edits.py +206 -0
- spawnpoint/studio/levelgen.py +352 -0
- spawnpoint/studio/llm.py +145 -0
- spawnpoint/studio/orchestrator.py +341 -0
- spawnpoint/studio/prompts.py +122 -0
- spawnpoint/studio/web.py +123 -0
- spawnpoint/studio/webcheck.py +210 -0
- spawnpoint/studio_cli.py +232 -0
- spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
- spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
- spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
- spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
- spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +49 -0
- tests/test_ai_sprites.py +122 -0
- tests/test_analyze.py +116 -0
- tests/test_cockpit.py +148 -0
- tests/test_e2e.py +33 -0
- tests/test_expr.py +59 -0
- tests/test_gif.py +19 -0
- tests/test_godot_library.py +17 -0
- tests/test_graph.py +44 -0
- tests/test_level.py +64 -0
- tests/test_live_llm.py +38 -0
- tests/test_manifest.py +54 -0
- tests/test_playtest.py +129 -0
- tests/test_rules.py +85 -0
- tests/test_sfx.py +30 -0
- tests/test_sprites.py +261 -0
- tests/test_studio.py +335 -0
- tests/test_web.py +41 -0
|
@@ -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
|