spriteforge 0.1.1__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.
- spriteforge/__init__.py +1 -0
- spriteforge/__main__.py +3 -0
- spriteforge/agents/__init__.py +0 -0
- spriteforge/agents/assets.py +109 -0
- spriteforge/agents/cancellation.py +12 -0
- spriteforge/agents/director.py +50 -0
- spriteforge/agents/models.py +63 -0
- spriteforge/agents/roles/art-director.md +15 -0
- spriteforge/agents/roles/art-reviewer.md +14 -0
- spriteforge/agents/roles/director.md +14 -0
- spriteforge/agents/roles/game-designer.md +15 -0
- spriteforge/agents/roles/level-tile-designer.md +16 -0
- spriteforge/agents/roles/librarian.md +13 -0
- spriteforge/agents/roles/menu-designer.md +15 -0
- spriteforge/agents/roles/pixel-artist.md +14 -0
- spriteforge/agents/roles/programmer.md +14 -0
- spriteforge/agents/roles/qa-playtester.md +13 -0
- spriteforge/agents/roles/sound-director.md +14 -0
- spriteforge/agents/roles.py +39 -0
- spriteforge/agents/router.py +233 -0
- spriteforge/agents/router_eval.py +145 -0
- spriteforge/agents/routing_state.py +133 -0
- spriteforge/agents/runtime.py +430 -0
- spriteforge/agents/workflow.py +389 -0
- spriteforge/app/__init__.py +0 -0
- spriteforge/app/agents_view.py +248 -0
- spriteforge/app/chat.py +114 -0
- spriteforge/app/extensions.py +115 -0
- spriteforge/app/index.html +279 -0
- spriteforge/app/local_only.py +49 -0
- spriteforge/app/server.py +358 -0
- spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
- spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
- spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
- spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
- spriteforge/app/static/index.html +13 -0
- spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
- spriteforge/app/studio.py +278 -0
- spriteforge/app/workers.py +73 -0
- spriteforge/cli.py +241 -0
- spriteforge/config.py +193 -0
- spriteforge/config.yaml +157 -0
- spriteforge/core/__init__.py +0 -0
- spriteforge/core/acting.py +16 -0
- spriteforge/core/cost.py +236 -0
- spriteforge/core/credentials.py +24 -0
- spriteforge/core/demo.py +79 -0
- spriteforge/core/forms.py +20 -0
- spriteforge/core/gates.py +175 -0
- spriteforge/core/security.py +42 -0
- spriteforge/core/state.py +222 -0
- spriteforge/core/team.py +15 -0
- spriteforge/core/usage.py +68 -0
- spriteforge/game/__init__.py +0 -0
- spriteforge/game/actors.py +377 -0
- spriteforge/game/agent_tools.py +178 -0
- spriteforge/game/art.py +2097 -0
- spriteforge/game/art_checks.py +190 -0
- spriteforge/game/art_review.py +518 -0
- spriteforge/game/build.py +134 -0
- spriteforge/game/bundle.py +1178 -0
- spriteforge/game/cli.py +119 -0
- spriteforge/game/codegen.py +433 -0
- spriteforge/game/codeinfo.py +154 -0
- spriteforge/game/dbsource.py +137 -0
- spriteforge/game/design.py +342 -0
- spriteforge/game/export.py +62 -0
- spriteforge/game/gamedb.py +191 -0
- spriteforge/game/image_cache.py +61 -0
- spriteforge/game/lessons.py +259 -0
- spriteforge/game/level_gen.py +118 -0
- spriteforge/game/level_preview.py +97 -0
- spriteforge/game/levels.py +934 -0
- spriteforge/game/loops.py +38 -0
- spriteforge/game/menus.py +712 -0
- spriteforge/game/mock_game.js +109 -0
- spriteforge/game/mock_platformer.js +58 -0
- spriteforge/game/motion.py +881 -0
- spriteforge/game/pipeline.py +1768 -0
- spriteforge/game/plan.py +502 -0
- spriteforge/game/playtest.py +318 -0
- spriteforge/game/qa.py +664 -0
- spriteforge/game/qa_checks.py +223 -0
- spriteforge/game/qa_runtime.py +565 -0
- spriteforge/game/reach.py +807 -0
- spriteforge/game/record.py +138 -0
- spriteforge/game/research.py +262 -0
- spriteforge/game/revision.py +225 -0
- spriteforge/game/sdk/API.md +104 -0
- spriteforge/game/sdk/FLOW.md +19 -0
- spriteforge/game/sdk/LEVELS.md +65 -0
- spriteforge/game/sdk/sf.js +418 -0
- spriteforge/game/sdk/sfflow.js +442 -0
- spriteforge/game/sdk/sflevel.js +683 -0
- spriteforge/game/sdk/sfsound-editor.js +225 -0
- spriteforge/game/sdk/sfsound.js +260 -0
- spriteforge/game/sdk/sounds.html +67 -0
- spriteforge/game/sounds.py +793 -0
- spriteforge/game/story.py +188 -0
- spriteforge/game/structure.py +98 -0
- spriteforge/game/team_reports.py +394 -0
- spriteforge/game/tilesets.py +70 -0
- spriteforge/game/title_art.py +186 -0
- spriteforge/genres/__init__.py +0 -0
- spriteforge/library/__init__.py +15 -0
- spriteforge/library/cli.py +126 -0
- spriteforge/library/describe.py +242 -0
- spriteforge/library/looks.py +154 -0
- spriteforge/library/models.py +79 -0
- spriteforge/library/store.py +422 -0
- spriteforge/library/tilesets/README.md +32 -0
- spriteforge/library/tilesets/gandalf-seasons.json +111 -0
- spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
- spriteforge/mcp/__init__.py +0 -0
- spriteforge/mcp/cli.py +58 -0
- spriteforge/mcp/client.py +57 -0
- spriteforge/mcp/filesystem_read.py +32 -0
- spriteforge/mcp/gamedb.py +56 -0
- spriteforge/mcp/library.py +179 -0
- spriteforge/mcp/memory.py +37 -0
- spriteforge/mcp/pixel_tools.py +145 -0
- spriteforge/mcp/registry.py +145 -0
- spriteforge/mcp/service.py +224 -0
- spriteforge/mcp/templates/context7.json +6 -0
- spriteforge/mcp/templates/fetch.json +6 -0
- spriteforge/mcp/templates/filesystem-read.json +6 -0
- spriteforge/mcp/templates/pixel-tools.json +6 -0
- spriteforge/memory/README.md +10 -0
- spriteforge/memory/__init__.py +0 -0
- spriteforge/memory/cli.py +131 -0
- spriteforge/memory/okf/__init__.py +1 -0
- spriteforge/memory/okf/catalog.py +331 -0
- spriteforge/memory/okf/codec.py +63 -0
- spriteforge/memory/okf/git.py +104 -0
- spriteforge/memory/okf/indexes.py +50 -0
- spriteforge/memory/okf/models.py +114 -0
- spriteforge/memory/okf/search.py +53 -0
- spriteforge/memory/okf/seed.py +81 -0
- spriteforge/memory/okf/seeds/genres.yaml +121 -0
- spriteforge/memory/okf/seeds/other.yaml +150 -0
- spriteforge/memory/okf/store.py +402 -0
- spriteforge/memory/okf/validate.py +115 -0
- spriteforge/pixel/__init__.py +17 -0
- spriteforge/pixel/atlas.py +110 -0
- spriteforge/pixel/grid.py +174 -0
- spriteforge/pixel/palette.py +156 -0
- spriteforge/pixel/palettes/dungeon.json +9 -0
- spriteforge/pixel/postprocess.py +170 -0
- spriteforge/pixel/preview.py +40 -0
- spriteforge/pixel/schemas.py +139 -0
- spriteforge/pixel/source.py +237 -0
- spriteforge/pixel/terrain.py +48 -0
- spriteforge/pixel/validate.py +138 -0
- spriteforge/prompts/anim_frame.txt +1 -0
- spriteforge/prompts/character.txt +1 -0
- spriteforge/prompts/item.txt +1 -0
- spriteforge/prompts/tile.txt +1 -0
- spriteforge/prompts/ui.txt +1 -0
- spriteforge/providers/__init__.py +0 -0
- spriteforge/providers/base.py +97 -0
- spriteforge/providers/http.py +38 -0
- spriteforge/providers/mock.py +111 -0
- spriteforge/providers/muse_image.py +60 -0
- spriteforge/providers/muse_search.py +91 -0
- spriteforge/providers/muse_spark.py +66 -0
- spriteforge/skills/__init__.py +0 -0
- spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
- spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
- spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
- spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
- spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
- spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
- spriteforge/skills/builtin/level-design/SKILL.md +169 -0
- spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
- spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
- spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
- spriteforge/skills/builtin/platformer/SKILL.md +153 -0
- spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
- spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
- spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
- spriteforge/skills/catalog.py +200 -0
- spriteforge/skills/cli.py +41 -0
- spriteforge/stages/__init__.py +0 -0
- spriteforge/stages/asset_context.py +301 -0
- spriteforge/stages/asset_models.py +192 -0
- spriteforge/stages/asset_render.py +366 -0
- spriteforge/stages/asset_review.py +140 -0
- spriteforge/stages/asset_store.py +94 -0
- spriteforge/stages/chat_changes.py +236 -0
- spriteforge/stages/concept.py +42 -0
- spriteforge/stages/game.py +1699 -0
- spriteforge/stages/game_confirm.py +112 -0
- spriteforge/stages/game_final.py +220 -0
- spriteforge/stages/game_record.py +246 -0
- spriteforge/stages/gameplay.py +218 -0
- spriteforge/stages/intake.py +372 -0
- spriteforge/stages/preview_assets.py +212 -0
- spriteforge/stages/preview_models.py +167 -0
- spriteforge/stages/preview_render.py +169 -0
- spriteforge/stages/preview_repair.py +61 -0
- spriteforge/stages/preview_review.py +169 -0
- spriteforge/stages/production.py +170 -0
- spriteforge/stages/references.py +105 -0
- spriteforge/stages/rewind.py +128 -0
- spriteforge/stages/workflow.py +440 -0
- spriteforge/templates/phaser/audit.js +45 -0
- spriteforge/templates/phaser/font.png +0 -0
- spriteforge/templates/phaser/font.xml +1 -0
- spriteforge/templates/phaser/preview.html +9 -0
- spriteforge/templates/phaser/room.js +49 -0
- spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
- spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
- spriteforge-0.1.1.dist-info/METADATA +364 -0
- spriteforge-0.1.1.dist-info/RECORD +218 -0
- spriteforge-0.1.1.dist-info/WHEEL +5 -0
- spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
- spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
- spriteforge-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Header, HTTPException, Request
|
|
8
|
+
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
|
9
|
+
from fastapi.staticfiles import StaticFiles
|
|
10
|
+
|
|
11
|
+
from spriteforge.agents.workflow import WORKFLOW, advance_until_gate
|
|
12
|
+
from spriteforge.config import Config, load_config
|
|
13
|
+
from spriteforge.core.gates import Feedback, Gates
|
|
14
|
+
from spriteforge.core.security import redact
|
|
15
|
+
from spriteforge.core.state import Store
|
|
16
|
+
from spriteforge.core.usage import usage_report
|
|
17
|
+
from spriteforge.stages.workflow import WORKFLOW as INTAKE
|
|
18
|
+
from spriteforge.stages.workflow import advance_until_gate as advance_intake
|
|
19
|
+
|
|
20
|
+
from .local_only import LocalOnly
|
|
21
|
+
from .studio import studio_router
|
|
22
|
+
from .workers import Workers
|
|
23
|
+
|
|
24
|
+
STATIC = Path(__file__).with_name("static") # the React studio build (ui/, `npm run build`)
|
|
25
|
+
CLASSIC = Path(__file__).with_name("index.html")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def latest_play(store, run_id) -> dict | None:
|
|
29
|
+
"""The newest game build that can be played (its report has a play link): shown even while
|
|
30
|
+
the next build runs and no gate is open."""
|
|
31
|
+
reports = {}
|
|
32
|
+
with store.connect() as db: # only the reports: this runs on every studio update
|
|
33
|
+
rows = db.execute(
|
|
34
|
+
"SELECT name,data FROM artifacts WHERE run_id=? AND name GLOB 'game/v*/report.json'",
|
|
35
|
+
(run_id,),
|
|
36
|
+
).fetchall()
|
|
37
|
+
for row in rows:
|
|
38
|
+
if m := re.fullmatch(r"game/v(\d+)/report\.json", row["name"]):
|
|
39
|
+
reports[int(m[1])] = row["data"]
|
|
40
|
+
for version in sorted(reports, reverse=True):
|
|
41
|
+
report = json.loads(reports[version])
|
|
42
|
+
if report.get("play"):
|
|
43
|
+
return {"version": version, "url": report["play"], "sounds": bool(report.get("sounds"))}
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_app(
|
|
48
|
+
store: Store,
|
|
49
|
+
run_id: str | None = None,
|
|
50
|
+
interval: float = 1.0,
|
|
51
|
+
mcp_service=None,
|
|
52
|
+
cfg: Config | None = None,
|
|
53
|
+
) -> FastAPI:
|
|
54
|
+
"""`run_id`: the run the CLI started or resumed (stepped at once, selected by /classic).
|
|
55
|
+
`cfg`: the settings new projects start with (default: the packaged config)."""
|
|
56
|
+
workers = Workers(store, interval)
|
|
57
|
+
|
|
58
|
+
@asynccontextmanager
|
|
59
|
+
async def lifespan(app):
|
|
60
|
+
if run_id:
|
|
61
|
+
workers.start(run_id)
|
|
62
|
+
yield
|
|
63
|
+
await workers.close()
|
|
64
|
+
|
|
65
|
+
app = FastAPI(lifespan=lifespan)
|
|
66
|
+
app.add_middleware(LocalOnly)
|
|
67
|
+
from .extensions import router
|
|
68
|
+
|
|
69
|
+
app.include_router(router(store, mcp_service))
|
|
70
|
+
app.include_router(studio_router(store, workers, cfg or load_config(None)))
|
|
71
|
+
app.state.workers = workers
|
|
72
|
+
|
|
73
|
+
def require_run(id):
|
|
74
|
+
try:
|
|
75
|
+
return store.get_run(id)
|
|
76
|
+
except KeyError:
|
|
77
|
+
raise HTTPException(404, "Run not found") from None
|
|
78
|
+
|
|
79
|
+
@app.get("/classic", response_class=HTMLResponse)
|
|
80
|
+
def classic():
|
|
81
|
+
return CLASSIC.read_text(encoding="utf-8")
|
|
82
|
+
|
|
83
|
+
@app.get("/", response_class=HTMLResponse)
|
|
84
|
+
def index():
|
|
85
|
+
# The studio build is committed; the classic page covers a checkout without it.
|
|
86
|
+
page = STATIC / "index.html"
|
|
87
|
+
return (page if page.is_file() else CLASSIC).read_text(encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
@app.get("/api/runs")
|
|
90
|
+
def runs():
|
|
91
|
+
return {
|
|
92
|
+
"runs": [r.model_dump() for r in store.runs()],
|
|
93
|
+
"active": run_id,
|
|
94
|
+
"error": workers.errors.get(run_id) if run_id else None,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@app.get("/api/runs/{id}")
|
|
98
|
+
def detail(id: str):
|
|
99
|
+
run = require_run(id)
|
|
100
|
+
# Gates only reference already-persisted artifacts. Read the gate first so
|
|
101
|
+
# a concurrent worker cannot return a new gate with an older artifact list.
|
|
102
|
+
gate = Gates(store).pending(id)
|
|
103
|
+
production = None
|
|
104
|
+
preview = None
|
|
105
|
+
if run.config.get("gameplay_preview"):
|
|
106
|
+
from spriteforge.stages.asset_store import ProductionStore
|
|
107
|
+
|
|
108
|
+
saved = ProductionStore(store, id)
|
|
109
|
+
reports = [
|
|
110
|
+
n
|
|
111
|
+
for n in saved.artifacts
|
|
112
|
+
if n.startswith("preview/v") and n.endswith("/readability.json")
|
|
113
|
+
]
|
|
114
|
+
versions = sorted(reports, key=lambda n: int(n.split("/")[1][1:]))
|
|
115
|
+
preview = {
|
|
116
|
+
"report": saved.read(versions[-1]) if versions else None,
|
|
117
|
+
"versions": [
|
|
118
|
+
{
|
|
119
|
+
"version": int(n.split("/")[1][1:]),
|
|
120
|
+
"artifact": saved.artifacts[
|
|
121
|
+
n.replace("readability.json", "gameplay-4x.png")
|
|
122
|
+
].id,
|
|
123
|
+
}
|
|
124
|
+
for n in versions
|
|
125
|
+
],
|
|
126
|
+
"cap": min(
|
|
127
|
+
run.config["settings"].get("preview", {}).get("max_image_calls", 16),
|
|
128
|
+
run.config["budget"]["max_image_calls"],
|
|
129
|
+
),
|
|
130
|
+
}
|
|
131
|
+
if run.config.get("asset_production"):
|
|
132
|
+
from spriteforge.stages.asset_store import ProductionStore
|
|
133
|
+
|
|
134
|
+
assets = ProductionStore(store, id)
|
|
135
|
+
reports = sorted(n for n in assets.artifacts if n.endswith("/consistency.json"))
|
|
136
|
+
production = {
|
|
137
|
+
"assets": [r.model_dump() for r in assets.records().values()],
|
|
138
|
+
"consistency": assets.read(max(reports, key=lambda n: int(n.split("/")[2])))
|
|
139
|
+
if reports
|
|
140
|
+
else None,
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
"run": run.model_dump(),
|
|
144
|
+
"play": latest_play(store, id),
|
|
145
|
+
"production": production,
|
|
146
|
+
"preview": preview,
|
|
147
|
+
"error": next(
|
|
148
|
+
(
|
|
149
|
+
e.payload.get("message")
|
|
150
|
+
for e in reversed(store.events(id))
|
|
151
|
+
if e.type
|
|
152
|
+
in {"run.failed", "production.budget_exhausted", "preview.budget_exhausted"}
|
|
153
|
+
),
|
|
154
|
+
None,
|
|
155
|
+
)
|
|
156
|
+
if run.status in {"failed", "budget_exhausted"}
|
|
157
|
+
else None,
|
|
158
|
+
"usage": usage_report(store, id).model_dump(),
|
|
159
|
+
"artifacts": [a.model_dump(exclude={"data"}) for a in store.artifacts(id)],
|
|
160
|
+
"gate": gate.model_dump(mode="json") if gate else None,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@app.post("/api/runs/{id}/gates/{gate_id}")
|
|
164
|
+
async def respond(id: str, gate_id: str, feedback: Feedback, wait: bool = True):
|
|
165
|
+
"""`wait=false` (the studio) answers at once and the run's worker carries on in the
|
|
166
|
+
background; the default advances to the next gate before answering (classic)."""
|
|
167
|
+
run = require_run(id)
|
|
168
|
+
if run.config.get("workflow") not in {WORKFLOW, INTAKE}:
|
|
169
|
+
raise HTTPException(409, "Run does not use agent gates")
|
|
170
|
+
try:
|
|
171
|
+
if run.config.get("workflow") == INTAKE:
|
|
172
|
+
pending = Gates(store).pending(id)
|
|
173
|
+
if pending and pending.id == gate_id:
|
|
174
|
+
feedback = feedback.resolved(pending)
|
|
175
|
+
if feedback.action == "request_changes" and pending.stage not in {
|
|
176
|
+
"concept",
|
|
177
|
+
"assets",
|
|
178
|
+
"gameplay",
|
|
179
|
+
"design",
|
|
180
|
+
"menus",
|
|
181
|
+
"playtest",
|
|
182
|
+
"final",
|
|
183
|
+
"confirm",
|
|
184
|
+
}:
|
|
185
|
+
raise ValueError("Changes can be requested at the concept review")
|
|
186
|
+
if feedback.action == "request_changes" and pending.stage == "assets":
|
|
187
|
+
from spriteforge.stages.production import selected_targets
|
|
188
|
+
|
|
189
|
+
selected_targets(store, id, feedback)
|
|
190
|
+
if feedback.action == "approve" and pending.stage == "concept":
|
|
191
|
+
from spriteforge.pixel.schemas import hex_color
|
|
192
|
+
|
|
193
|
+
for value in feedback.form.values():
|
|
194
|
+
hex_color(value)
|
|
195
|
+
if pending.stage == "gameplay":
|
|
196
|
+
from spriteforge.stages.asset_models import Manifest
|
|
197
|
+
from spriteforge.stages.asset_store import ProductionStore
|
|
198
|
+
from spriteforge.stages.preview_models import PreviewChange
|
|
199
|
+
from spriteforge.stages.preview_review import validate_change
|
|
200
|
+
|
|
201
|
+
saved = ProductionStore(store, id)
|
|
202
|
+
if feedback.action == "approve":
|
|
203
|
+
if any(
|
|
204
|
+
feedback.form.get(k, "").strip() for k in ("targets", "palette")
|
|
205
|
+
):
|
|
206
|
+
raise ValueError(
|
|
207
|
+
"Use Request changes to apply asset, layout or palette edits"
|
|
208
|
+
)
|
|
209
|
+
reports = [
|
|
210
|
+
n for n in saved.artifacts if n.endswith("/readability.json")
|
|
211
|
+
]
|
|
212
|
+
report = saved.read(
|
|
213
|
+
max(reports, key=lambda n: int(n.split("/")[1][1:]))
|
|
214
|
+
)
|
|
215
|
+
if any(r["failures"] for r in report["assets"].values()):
|
|
216
|
+
raise ValueError("Fix failing asset validators before approval")
|
|
217
|
+
elif (
|
|
218
|
+
feedback.action == "request_changes"
|
|
219
|
+
and feedback.form.get("targets", "").strip()
|
|
220
|
+
):
|
|
221
|
+
validate_change(
|
|
222
|
+
PreviewChange(
|
|
223
|
+
targets=[
|
|
224
|
+
s.strip()
|
|
225
|
+
for s in feedback.form["targets"].split(",")
|
|
226
|
+
if s.strip()
|
|
227
|
+
],
|
|
228
|
+
instruction=feedback.text,
|
|
229
|
+
palette=[
|
|
230
|
+
s.strip()
|
|
231
|
+
for s in feedback.form.get("palette", "").split(",")
|
|
232
|
+
if s.strip()
|
|
233
|
+
],
|
|
234
|
+
),
|
|
235
|
+
Manifest.model_validate(saved.read("preview/manifest.json")),
|
|
236
|
+
)
|
|
237
|
+
from spriteforge.core.cost import stop_requested
|
|
238
|
+
from spriteforge.stages.rewind import clear_stop
|
|
239
|
+
|
|
240
|
+
with store.connect() as db:
|
|
241
|
+
stale = stop_requested(db, id)
|
|
242
|
+
if stale: # a Go back that came too late: the round had already reached its gate
|
|
243
|
+
clear_stop(store, id, "the gate opened first")
|
|
244
|
+
if feedback.action == "request_changes" and not feedback.text.strip():
|
|
245
|
+
from spriteforge.stages.chat_changes import for_gate
|
|
246
|
+
|
|
247
|
+
if queued := for_gate(store, id): # the changes asked in agent chats
|
|
248
|
+
form = {**feedback.form, "chat_changes": [c["id"] for c in queued]}
|
|
249
|
+
feedback = feedback.model_copy(update={"form": form})
|
|
250
|
+
gate = await asyncio.to_thread(Gates(store).respond, id, gate_id, feedback)
|
|
251
|
+
except KeyError:
|
|
252
|
+
raise HTTPException(404, "Gate not found") from None
|
|
253
|
+
except ValueError as exc:
|
|
254
|
+
raise HTTPException(409, str(exc)) from None
|
|
255
|
+
# Advance only after the persisted human answer; the next gate pauses again.
|
|
256
|
+
if not wait:
|
|
257
|
+
workers.start(id)
|
|
258
|
+
return {"gate": gate.model_dump(mode="json"), "run": store.get_run(id).model_dump()}
|
|
259
|
+
try:
|
|
260
|
+
await asyncio.to_thread(
|
|
261
|
+
advance_intake if run.config.get("workflow") == INTAKE else advance_until_gate,
|
|
262
|
+
store,
|
|
263
|
+
id,
|
|
264
|
+
)
|
|
265
|
+
except Exception as exc: # noqa: BLE001 -- persist scrubbed worker errors
|
|
266
|
+
await asyncio.to_thread(store.fail, id, str(exc))
|
|
267
|
+
raise HTTPException(500, redact(str(exc))) from None
|
|
268
|
+
return {"gate": gate.model_dump(mode="json"), "run": store.get_run(id).model_dump()}
|
|
269
|
+
|
|
270
|
+
@app.get("/api/runs/{id}/play/{version}/{path:path}")
|
|
271
|
+
def play(id: str, version: str, path: str):
|
|
272
|
+
"""Serve a generated game build so the reviewer can play it, not just watch it."""
|
|
273
|
+
import mimetypes
|
|
274
|
+
import re
|
|
275
|
+
|
|
276
|
+
require_run(id)
|
|
277
|
+
if not re.fullmatch(r"v\d+", version):
|
|
278
|
+
raise HTTPException(404, "Unknown build version")
|
|
279
|
+
root = (store.path.parent / "runs" / id / "game" / version / "build").resolve()
|
|
280
|
+
target = (root / (path or "index.html")).resolve()
|
|
281
|
+
if not target.is_relative_to(root) or not target.is_file():
|
|
282
|
+
raise HTTPException(404, "Not part of this build")
|
|
283
|
+
media = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
|
284
|
+
return Response(target.read_bytes(), media_type=media,
|
|
285
|
+
headers={"X-Content-Type-Options": "nosniff"}) # fmt: skip
|
|
286
|
+
|
|
287
|
+
@app.post("/api/runs/{id}/sounds/{version}")
|
|
288
|
+
async def save_sounds(id: str, version: str, request: Request):
|
|
289
|
+
"""The build's sound editor saves here: a new sounds version (by the human, clamped by
|
|
290
|
+
code) that build vN plays at once and every later build plays."""
|
|
291
|
+
from pydantic import ValidationError
|
|
292
|
+
|
|
293
|
+
from spriteforge.game.sounds import SoundSet
|
|
294
|
+
from spriteforge.stages.game import save_edited_sounds
|
|
295
|
+
|
|
296
|
+
require_run(id)
|
|
297
|
+
if not re.fullmatch(r"v\d+", version):
|
|
298
|
+
raise HTTPException(404, "Unknown build version")
|
|
299
|
+
try:
|
|
300
|
+
body = await request.json()
|
|
301
|
+
sounds = SoundSet.model_validate((body or {}).get("sounds"))
|
|
302
|
+
except (ValueError, ValidationError) as exc:
|
|
303
|
+
raise HTTPException(422, redact(str(exc))[:500]) from None
|
|
304
|
+
try:
|
|
305
|
+
return await asyncio.to_thread(save_edited_sounds, store, id, int(version[1:]), sounds)
|
|
306
|
+
except KeyError as exc:
|
|
307
|
+
raise HTTPException(404, redact(str(exc))) from None
|
|
308
|
+
|
|
309
|
+
@app.get("/api/artifacts/{id}")
|
|
310
|
+
def artifact(id: str):
|
|
311
|
+
with store.connect() as db:
|
|
312
|
+
row = db.execute("SELECT data,mime FROM artifacts WHERE id=?", (id,)).fetchone()
|
|
313
|
+
if row is None:
|
|
314
|
+
raise HTTPException(404, "Artifact not found")
|
|
315
|
+
return Response(row[0], media_type=row[1], headers={"X-Content-Type-Options": "nosniff"})
|
|
316
|
+
|
|
317
|
+
@app.get("/api/runs/{id}/events")
|
|
318
|
+
async def events(
|
|
319
|
+
id: str, request: Request, after: int = 0, last_event_id: str | None = Header(default=None)
|
|
320
|
+
):
|
|
321
|
+
require_run(id)
|
|
322
|
+
try:
|
|
323
|
+
cursor = max(0, after, int(last_event_id or 0))
|
|
324
|
+
except ValueError:
|
|
325
|
+
raise HTTPException(400, "Invalid Last-Event-ID") from None
|
|
326
|
+
|
|
327
|
+
async def stream():
|
|
328
|
+
nonlocal cursor
|
|
329
|
+
while not await request.is_disconnected():
|
|
330
|
+
completed = store.get_run(id).status in {
|
|
331
|
+
"completed",
|
|
332
|
+
"aborted",
|
|
333
|
+
"failed",
|
|
334
|
+
"budget_exhausted",
|
|
335
|
+
}
|
|
336
|
+
batch = await asyncio.to_thread(store.events, id, cursor)
|
|
337
|
+
for event in batch:
|
|
338
|
+
cursor = event.id
|
|
339
|
+
yield f"id: {event.id}\ndata: {event.model_dump_json()}\n\n"
|
|
340
|
+
if completed:
|
|
341
|
+
yield "event: done\ndata: {}\n\n"
|
|
342
|
+
break
|
|
343
|
+
if error := workers.errors.get(id):
|
|
344
|
+
yield "event: worker-error\ndata: " + json.dumps(error) + "\n\n"
|
|
345
|
+
break
|
|
346
|
+
yield ": heartbeat\n\n"
|
|
347
|
+
await asyncio.sleep(0.25)
|
|
348
|
+
|
|
349
|
+
return StreamingResponse(
|
|
350
|
+
stream(),
|
|
351
|
+
media_type="text/event-stream",
|
|
352
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Last, so every API route above wins over a same-named static file.
|
|
356
|
+
if STATIC.is_dir():
|
|
357
|
+
app.mount("/", StaticFiles(directory=STATIC), name="studio")
|
|
358
|
+
return app
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@font-face{font-family:"Press Start 2P";font-style:normal;font-display:swap;font-weight:400;src:url(/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2) format("woff2"),url(/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff) format("woff")}:root{--bg: #151822;--panel: #1d2130;--line: #2c3246;--text: #e2e8f0;--muted: #8b93a7;--accent: #f6c453;--ok: #5fd38d;--warn: #f6c453;--bad: #f07178;--pixel: "Press Start 2P", ui-monospace, monospace;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 system-ui,sans-serif}a{color:var(--accent)}h1,h2,h3{font-family:var(--pixel);font-weight:400;line-height:1.4}h1{font-size:22px}h2{font-size:13px;color:var(--muted);text-transform:uppercase}.muted{color:var(--muted)}.error{color:var(--bad)}.home{max-width:880px;margin:0 auto;padding:48px 20px}.projects{list-style:none;margin:0;padding:0;display:grid;gap:8px}.projects a{display:flex;justify-content:space-between;gap:16px;padding:12px 16px;background:var(--panel);border:1px solid var(--line);border-radius:6px;color:var(--text);text-decoration:none}.projects a:hover{border-color:var(--accent)}.status{color:var(--muted);white-space:nowrap}.status-completed{color:var(--ok)}.status-paused{color:var(--warn)}.status-failed,.status-aborted{color:var(--bad)}button{font:inherit;padding:8px 16px;border:1px solid var(--accent);border-radius:6px;background:var(--accent);color:#151822;cursor:pointer}button.secondary{background:transparent;color:var(--accent)}button:disabled{opacity:.5;cursor:default}.notice{border:1px solid var(--warn);border-radius:6px;padding:4px 16px;margin-bottom:16px}.new-project{display:grid;gap:8px;margin-bottom:32px}.new-project textarea{font:inherit;width:100%;padding:12px;resize:vertical;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}.new-project textarea:focus{outline:none;border-color:var(--accent)}.actions{display:flex;gap:8px;justify-content:flex-end}.projects .thumb{flex:none;width:64px;height:48px;background:var(--line);border-radius:4px;overflow:hidden}.projects .thumb img{width:100%;height:100%;object-fit:cover;image-rendering:pixelated}.project-text{flex:1;display:grid;min-width:0}.project-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.projects a{align-items:center}.status-gate{color:var(--warn)}.cost{color:var(--muted);white-space:nowrap}.studio{display:grid;grid-template-columns:230px minmax(0,1fr) 240px;grid-template-rows:auto minmax(0,1fr);grid-template-areas:"top top top" "agents center side";height:100vh}.topbar{grid-area:top;display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}.topbar h1{font-size:13px;margin:0;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.home-link{color:var(--muted);text-decoration:none;white-space:nowrap}.tag{border:1px solid var(--line);border-radius:4px;padding:0 6px;color:var(--muted);font-size:13px}a.button{display:inline-block;padding:8px 16px;border-radius:6px;background:var(--accent);color:#151822;text-decoration:none;white-space:nowrap}.agents{grid-area:agents;overflow-y:auto;border-right:1px solid var(--line);padding:8px}.agents h2,.side h2{font-size:10px;margin:8px}.agents ul{list-style:none;margin:0;padding:0}.agents .agent{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:6px;color:var(--text);text-decoration:none}.agents .agent:hover{background:var(--panel)}.agents .agent.selected{background:var(--line)}.agents .agent .icon{width:14px;text-align:center;color:var(--muted)}.agents .agent .name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.agent-done .icon,.state.agent-done{color:var(--ok)}.agent-working .icon,.state.agent-working{color:var(--accent);animation:pulse 1.2s ease-in-out infinite}.agent-needs_you .icon,.state.agent-needs_you{color:var(--warn);font-weight:700}.agent-waiting .name{color:var(--muted)}.badge{background:var(--warn);color:#151822;border-radius:9px;min-width:18px;padding:0 5px;font-size:12px;text-align:center}.agent-cost{color:var(--muted);font-size:12px}@keyframes pulse{50%{opacity:.35}}.center{grid-area:center;display:flex;flex-direction:column;min-height:0}.chat-head{display:flex;align-items:baseline;gap:12px;padding:10px 20px;border-bottom:1px solid var(--line)}.chat-head h2{margin:0;font-size:12px;color:var(--text);text-transform:none}.state{color:var(--muted);font-size:13px}.chat{display:flex;flex-direction:column;flex:1;min-height:0}.thread{flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:10px}.bubble{max-width:80%;padding:8px 12px;border-radius:10px;background:var(--panel);border:1px solid var(--line)}.bubble p{margin:0;white-space:pre-wrap}.bubble.user{align-self:flex-end;background:#2a3550;border-color:#34426a}.bubble.failed{border-color:var(--bad)}.intent{display:block;margin-top:4px;font-size:12px;color:var(--muted)}.progress,.milestone{margin:0;color:var(--muted);font-size:13px}.progress:before{content:"- "}.milestone{text-align:center;font-family:var(--pixel);font-size:9px;padding:6px 0}.typing{animation:pulse 1.2s ease-in-out infinite}.composer{display:flex;gap:8px;padding:12px 20px;border-top:1px solid var(--line)}.composer textarea,.gate-card textarea,.field input[type=text],.field input[type=number],.field select{flex:1;width:100%;font:inherit;padding:8px 10px;resize:vertical;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px}.composer textarea:focus,.gate-card textarea:focus,.field input:focus,.field select:focus{outline:none;border-color:var(--accent)}.composer button{align-self:flex-end}.gate-card{border:1px solid var(--line);border-radius:8px;padding:12px 14px;background:var(--panel)}.report-card{border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;padding:12px 14px;background:var(--panel)}.report-card.report-exit{border-left-color:var(--ok)}.report-card header{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:4px 12px}.report-card h3{margin:0;font-size:11px}.report-kind{font-size:13px;color:var(--muted)}.report-section h4{margin:12px 0 4px;font-size:13px;color:var(--accent)}.report-exit .report-section h4{color:var(--ok)}.report-section ol,.report-section ul{margin:0;padding-left:22px;font-size:14px;line-height:1.5}.gate-card.open{border-color:var(--warn)}.gate-card header{display:flex;justify-content:space-between;align-items:baseline;gap:12px}.gate-card h3{margin:0;font-size:11px}.gate-status{font-size:13px;color:var(--muted);white-space:nowrap}.gate-pending{color:var(--warn)}.gate-approved{color:var(--ok)}.gate-text{color:var(--muted);font-size:14px}.gate-card form{display:grid;gap:10px;margin-top:10px}.field label{display:grid;gap:4px}.field label>span{font-weight:600}.hint{margin:2px 0 0;color:var(--muted);font-size:13px}.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}button.chip{padding:3px 10px;font-size:13px;background:transparent;color:var(--text);border-color:var(--line);border-radius:14px;text-align:left}button.chip.on{border-color:var(--accent);color:var(--accent)}.artifacts{display:grid;gap:8px}.image-grid{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end}.image-grid a{display:grid;gap:2px;justify-items:center;max-width:260px;color:var(--muted);font-size:11px;text-decoration:none}.image-grid img{height:96px;width:auto;max-width:256px;object-fit:contain;image-rendering:pixelated;background:#0d0f16;border-radius:4px}.image-grid span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:256px}.doc{border:1px solid var(--line);border-radius:6px;padding:6px 10px;background:var(--panel)}.doc summary{cursor:pointer;color:var(--accent)}.files{display:flex;flex-wrap:wrap;gap:4px 12px;margin:0;font-size:13px}.build-report{border:1px solid var(--line);border-radius:8px;padding:10px 14px;background:var(--panel)}.build-head{display:flex;justify-content:space-between;gap:12px}.ok{color:var(--ok)}.warn{color:var(--warn)}.play-links{display:flex;align-items:center;gap:16px}.levels{margin:4px 0 0;padding-left:18px;font-size:14px}.markdown{overflow-x:auto}.markdown>:first-child{margin-top:0}.markdown>:last-child{margin-bottom:0}.markdown h1,.markdown h2,.markdown h3{font-size:11px;color:var(--text);text-transform:none}.markdown table{border-collapse:collapse}.markdown th,.markdown td{border:1px solid var(--line);padding:2px 8px}.markdown code{background:var(--bg);padding:0 4px;border-radius:3px}.side{grid-area:side;overflow-y:auto;border-left:1px solid var(--line);padding:8px 12px}.panel h3{font-size:9px;color:var(--muted);margin:16px 0 6px}.panel dl{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin:0}.panel dt{color:var(--muted)}.panel dd{margin:0;text-align:right}@media (max-width: 900px){.studio{grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto minmax(0,1fr) auto;grid-template-areas:"top" "agents" "center" "side";height:auto;min-height:100vh}.agents ul{display:flex;overflow-x:auto}.agents,.side{border:none}.thread{max-height:70vh}}.intent.queued{color:var(--warn)}.intent.applied{color:var(--ok)}.gate-card .queued{margin:10px 0;padding:8px 10px;border:1px dashed var(--warn);border-radius:6px}.gate-card .queued p{margin:0 0 4px}.gate-card .queued ul{margin:0;padding-left:18px}.composer-tools{display:flex;align-items:center;gap:10px;padding:8px 20px 0;border-top:1px solid var(--line)}.composer-tools+.composer{border-top:none}.go-back{display:flex;gap:8px;margin-top:8px}.go-back-state{margin:6px 0 0;font-size:12px;color:var(--muted)}.go-back-state.went_back,.go-back-state.going_back{color:var(--warn)}.chat-head .note{color:var(--warn);font-size:13px}.go-back button{white-space:nowrap}.allowance p{margin:8px 0 4px;font-size:13px}.allowance meter{width:100%}.attach{align-self:flex-end;cursor:pointer;padding:8px 4px;font-size:18px}.attach input,.drop input{position:absolute;width:1px;height:1px;opacity:0}.attach:focus-within,.drop:focus-within{outline:2px solid var(--accent);border-radius:6px}.reference-upload{display:flex;gap:12px;align-items:flex-start;width:100%;padding:10px;border:1px dashed var(--accent);border-radius:8px;background:var(--panel)}.reference-upload img{width:72px;height:72px;object-fit:contain;image-rendering:pixelated;background:var(--bg);border-radius:4px}.reference-upload p{margin:0 0 4px}.reference-upload .rights{display:flex;gap:6px;align-items:center;margin:6px 0;font-size:13px}.reference-upload .actions{display:flex;gap:8px}.panel .reference-upload{flex-direction:column}.references{list-style:none;margin:0 0 10px;padding:0;display:flex;flex-direction:column;gap:8px}.references li{display:flex;gap:8px;align-items:center;font-size:13px;min-width:0}.references img{width:48px;height:48px;object-fit:contain;image-rendering:pixelated;background:var(--bg);border-radius:4px}.references span{display:flex;flex-direction:column;min-width:0;overflow-wrap:anywhere}.references small{color:var(--muted)}.drop{position:relative;display:block;padding:14px 10px;border:1px dashed var(--line);border-radius:8px;color:var(--muted);font-size:13px;text-align:center;cursor:pointer}.drop.over{border-color:var(--accent);color:var(--text)}.bubble.reference img{display:block;max-width:220px;max-height:160px;margin-bottom:6px;image-rendering:pixelated;border-radius:4px}.side .panel+.panel{margin-top:18px}
|