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
spriteforge/app/chat.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Chatting with an agent: a message is a question (answered now, one Spark call with that
|
|
2
|
+
agent's own work as context) or a change (filed for the gated pipeline, never run freely)."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from spriteforge.agents.models import Contract
|
|
10
|
+
from spriteforge.config import BudgetConfig, Config
|
|
11
|
+
from spriteforge.core.cost import Budget, for_the_user
|
|
12
|
+
from spriteforge.core.state import Store
|
|
13
|
+
from spriteforge.stages.references import images
|
|
14
|
+
|
|
15
|
+
Intent = Literal["question", "change"]
|
|
16
|
+
|
|
17
|
+
# "can you make the bat purple?" asks for a change, though it ends with a question mark.
|
|
18
|
+
CHANGE_START = re.compile(
|
|
19
|
+
r"^(please|can you|could you|would you|will you|i want|i'd like|i would like|let's|"
|
|
20
|
+
r"make|change|add|remove|delete|use|set|turn|give|move|swap|replace|fix|try|keep|drop|"
|
|
21
|
+
r"increase|decrease|raise|lower|speed|slow|shorten|lengthen|rename|recolou?r)\b"
|
|
22
|
+
)
|
|
23
|
+
QUESTION_START = re.compile(
|
|
24
|
+
r"^(why|what|how|when|where|which|who|whose|is|are|was|were|does|do|did|has|have|"
|
|
25
|
+
r"should|explain|tell me)\b"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
CONTEXT_CHARS = 20000 # the agent's own artifacts sent with a question, newest first
|
|
29
|
+
TEXT_MIMES = {"application/json", "text/markdown", "text/plain"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def classify(text: str) -> Intent:
|
|
33
|
+
"""Deterministic and free; the studio lets the user flip it when it guesses wrong."""
|
|
34
|
+
t = " ".join(text.lower().split())
|
|
35
|
+
if CHANGE_START.match(t):
|
|
36
|
+
return "change"
|
|
37
|
+
if t.endswith("?") or QUESTION_START.match(t):
|
|
38
|
+
return "question"
|
|
39
|
+
return "change"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ChatAnswer(Contract): # Contract: Meta's strict json_schema needs additionalProperties false
|
|
43
|
+
answer: str = Field(description="Your reply to the human, in plain words", max_length=4000)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def own_work(store: Store, run_id: str, agent: str, limit: int = CONTEXT_CHARS) -> str:
|
|
47
|
+
"""The agent's latest text artifacts (one version of each, Markdown over JSON when it wrote
|
|
48
|
+
both), newest first, up to `limit` characters."""
|
|
49
|
+
mine = {
|
|
50
|
+
e.payload["id"]
|
|
51
|
+
for e in store.events(run_id)
|
|
52
|
+
if e.type == "artifact.created" and e.agent == agent
|
|
53
|
+
}
|
|
54
|
+
latest: dict[str, tuple[int, str, str]] = {}
|
|
55
|
+
for a in store.artifacts(run_id):
|
|
56
|
+
if a.id not in mine or a.mime not in TEXT_MIMES:
|
|
57
|
+
continue
|
|
58
|
+
family = re.sub(r"v\d+", "vN", a.name.rsplit(".", 1)[0])
|
|
59
|
+
version = max(map(int, re.findall(r"v(\d+)", a.name)), default=0)
|
|
60
|
+
rank = (version, a.mime == "text/markdown")
|
|
61
|
+
if family not in latest or rank > latest[family][:2]:
|
|
62
|
+
latest[family] = (*rank, a.name, a.data.decode("utf-8", "replace"))
|
|
63
|
+
parts, used = [], 0
|
|
64
|
+
for *_, name, text in sorted(latest.values(), key=lambda v: v[:2], reverse=True):
|
|
65
|
+
block = f"### {name}\n{text}\n"
|
|
66
|
+
if used + len(block) > limit:
|
|
67
|
+
block = block[: max(0, limit - used)]
|
|
68
|
+
if block:
|
|
69
|
+
parts.append(block)
|
|
70
|
+
used += len(block)
|
|
71
|
+
return "\n".join(parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def history(store: Store, run_id: str, agent: str, turns: int = 6) -> str:
|
|
75
|
+
lines = []
|
|
76
|
+
for e in store.events(run_id):
|
|
77
|
+
if e.type == "chat.user" and e.payload.get("to") == agent:
|
|
78
|
+
lines.append("Human: " + e.payload.get("text", ""))
|
|
79
|
+
elif e.type == "chat.agent" and e.agent == agent:
|
|
80
|
+
lines.append(f"{agent}: " + e.payload.get("text", ""))
|
|
81
|
+
return "\n".join(lines[-turns:])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def question_prompt(store: Store, run_id: str, agent: str, text: str) -> str:
|
|
85
|
+
work = own_work(store, run_id, agent) or "(nothing yet: you have not run in this project)"
|
|
86
|
+
talk = history(store, run_id, agent)
|
|
87
|
+
return (
|
|
88
|
+
"The human making this game asks you about your work. Answer from your work below, "
|
|
89
|
+
"briefly and concretely; say so when the answer is not in it. Do not promise changes: "
|
|
90
|
+
"the human asks for those separately and they go through review.\n\n"
|
|
91
|
+
f"## Your work so far\n{work}\n\n"
|
|
92
|
+
+ (f"## The conversation so far\n{talk}\n\n" if talk else "")
|
|
93
|
+
+ f"## The question\n{text}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def answer_question(store: Store, run_id: str, agent: str, text: str, models=None) -> str:
|
|
98
|
+
"""One Spark call (offline: a stand-in answer) booked to this agent in the cost ledger.
|
|
99
|
+
`models` (tests) replaces the run's own model access."""
|
|
100
|
+
from spriteforge.game.pipeline import Models
|
|
101
|
+
|
|
102
|
+
run = store.get_run(run_id)
|
|
103
|
+
if models is None:
|
|
104
|
+
cfg = Config.model_validate(run.config.get("settings") or {})
|
|
105
|
+
budget = Budget(store, run_id, BudgetConfig.model_validate(run.config.get("budget", {})))
|
|
106
|
+
models = Models(cfg, budget, mock=bool(run.config.get("mock")))
|
|
107
|
+
models.references = images(store, run_id, [agent])
|
|
108
|
+
fallback = ChatAnswer(
|
|
109
|
+
answer=f"(Offline mock: no model is called.) I'm the {agent}. Ask again with a live "
|
|
110
|
+
"Muse key for a real answer about my work."
|
|
111
|
+
)
|
|
112
|
+
with for_the_user(): # the human asked: no cap or stop request refuses it
|
|
113
|
+
return models.ask(agent, question_prompt(store, run_id, agent, text), ChatAnswer,
|
|
114
|
+
fallback, "chat").answer # fmt: skip
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Human-only extension administration and per-call confirmation routes."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from spriteforge.agents.roles import load_roles
|
|
7
|
+
from spriteforge.core.security import redact
|
|
8
|
+
from spriteforge.mcp.service import ConfirmationRequired, MCPService
|
|
9
|
+
from spriteforge.skills.catalog import SkillCatalog
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EnableRequest(BaseModel):
|
|
13
|
+
role: str
|
|
14
|
+
enabled: bool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PolicyRequest(BaseModel):
|
|
18
|
+
tool: str
|
|
19
|
+
confirm: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvokeRequest(BaseModel):
|
|
23
|
+
role: str
|
|
24
|
+
tool: str
|
|
25
|
+
arguments: dict = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Decision(BaseModel):
|
|
29
|
+
approve: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def router(store, service=None):
|
|
33
|
+
def local_request(request: Request):
|
|
34
|
+
# Explicit UI approvals must not be forgeable by an unrelated web page.
|
|
35
|
+
if request.url.hostname not in {"127.0.0.1", "localhost", "::1", "testserver"}:
|
|
36
|
+
raise HTTPException(403, "Local host required")
|
|
37
|
+
origin = request.headers.get("origin")
|
|
38
|
+
if origin and origin != str(request.base_url).rstrip("/"):
|
|
39
|
+
raise HTTPException(403, "Same-origin request required")
|
|
40
|
+
|
|
41
|
+
api = APIRouter(dependencies=[Depends(local_request)])
|
|
42
|
+
service = service or MCPService(store)
|
|
43
|
+
roles = load_roles()
|
|
44
|
+
|
|
45
|
+
@api.get("/api/extensions")
|
|
46
|
+
def extensions():
|
|
47
|
+
return {
|
|
48
|
+
"roles": list(roles),
|
|
49
|
+
"skills": {n: SkillCatalog(store.path.parent).summaries(r) for n, r in roles.items()},
|
|
50
|
+
"servers": {
|
|
51
|
+
n: redact(s.model_dump()) for n, s in service.registry.read().mcpServers.items()
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@api.post("/api/mcp/{name}/inspect")
|
|
56
|
+
async def inspect(name: str):
|
|
57
|
+
try:
|
|
58
|
+
return {"tools": await service.discover(name)}
|
|
59
|
+
except Exception as exc: # noqa: BLE001 -- scrub all SDK errors at the HTTP boundary
|
|
60
|
+
raise HTTPException(400, redact(str(exc))) from None
|
|
61
|
+
|
|
62
|
+
@api.post("/api/mcp/{name}/enable")
|
|
63
|
+
def enable(name: str, body: EnableRequest):
|
|
64
|
+
try:
|
|
65
|
+
if body.enabled:
|
|
66
|
+
service.registry.enable(name, body.role)
|
|
67
|
+
else:
|
|
68
|
+
service.registry.disable(name, body.role)
|
|
69
|
+
except (KeyError, ValueError) as exc:
|
|
70
|
+
raise HTTPException(400, str(exc)) from None
|
|
71
|
+
return {"ok": True}
|
|
72
|
+
|
|
73
|
+
@api.post("/api/mcp/{name}/policy")
|
|
74
|
+
def policy(name: str, body: PolicyRequest):
|
|
75
|
+
try:
|
|
76
|
+
service.registry.policy(name, body.tool, body.confirm)
|
|
77
|
+
except (KeyError, ValueError) as exc:
|
|
78
|
+
raise HTTPException(400, str(exc)) from None
|
|
79
|
+
return {"ok": True}
|
|
80
|
+
|
|
81
|
+
@api.get("/api/runs/{run_id}/mcp/confirmations")
|
|
82
|
+
def confirmations(run_id: str):
|
|
83
|
+
return service.pending(run_id)
|
|
84
|
+
|
|
85
|
+
@api.post("/api/runs/{run_id}/mcp/call")
|
|
86
|
+
async def call(run_id: str, body: InvokeRequest):
|
|
87
|
+
try:
|
|
88
|
+
return await service.call(run_id, roles[body.role], body.tool, body.arguments)
|
|
89
|
+
except ConfirmationRequired as exc:
|
|
90
|
+
return {"confirmation": exc.ident}
|
|
91
|
+
except Exception as exc: # noqa: BLE001 -- scrub all SDK errors at the HTTP boundary
|
|
92
|
+
raise HTTPException(400, redact(str(exc))) from None
|
|
93
|
+
|
|
94
|
+
@api.post("/api/runs/{run_id}/mcp/confirmations/{ident}")
|
|
95
|
+
async def confirm(run_id: str, ident: str, body: Decision):
|
|
96
|
+
try:
|
|
97
|
+
row = service.row(run_id, ident)
|
|
98
|
+
if row is None:
|
|
99
|
+
raise ValueError("Unknown confirmation")
|
|
100
|
+
import json
|
|
101
|
+
|
|
102
|
+
role = roles[json.loads(row["intent"])["role"]]
|
|
103
|
+
result = await service.respond(run_id, ident, body.approve, role)
|
|
104
|
+
# Runtime checkpoints consume the saved result without repeating the tool call.
|
|
105
|
+
if store.get_run(run_id).config.get("workflow") == "agent-runtime-mock-v1":
|
|
106
|
+
import asyncio
|
|
107
|
+
|
|
108
|
+
from spriteforge.agents.workflow import advance_until_gate
|
|
109
|
+
|
|
110
|
+
await asyncio.to_thread(advance_until_gate, store, run_id)
|
|
111
|
+
return result
|
|
112
|
+
except Exception as exc: # noqa: BLE001 -- scrub all SDK errors at the HTTP boundary
|
|
113
|
+
raise HTTPException(409, redact(str(exc))) from None
|
|
114
|
+
|
|
115
|
+
return api
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
|
3
|
+
<title>SpriteForge</title><style>
|
|
4
|
+
.concept-preview{width:100%!important;height:auto!important;max-width:960px!important}
|
|
5
|
+
.concept-figure{width:100%;max-width:960px}.swatch-field{display:inline-block;margin:8px;padding:8px}
|
|
6
|
+
.gameplay-figure{flex:1 1 560px;max-width:100%;order:0}.gameplay-image{width:100%!important;height:auto!important}.mood-figure{flex:0 1 240px;order:1}.mood-image{width:240px!important;height:auto!important;max-width:100%}.preview-checks{flex-basis:100%;order:2}#preview-history a{display:inline-block;margin-right:16px}#preview-summary{white-space:pre-wrap}
|
|
7
|
+
.asset-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.asset-card{border:1px solid #556;padding:12px;overflow-wrap:anywhere}.asset-card img{width:auto;height:auto;max-width:192px;max-height:192px}.asset-pass{color:#9ae6b4}.asset-fail{color:#ffb4b4}.production-sheet{width:100%!important;height:auto!important}
|
|
8
|
+
body{background:#151822;color:#e2e8f0;font:16px system-ui;margin:40px auto;max-width:960px;padding:0 20px}
|
|
9
|
+
h1{color:#9ae6b4}select{padding:8px;max-width:100%}li{padding:8px;border-bottom:1px solid #333}
|
|
10
|
+
#artifacts,#gate-artifacts{display:flex;gap:24px;flex-wrap:wrap}img{width:128px;height:128px;image-rendering:pixelated;background:#303447}
|
|
11
|
+
.muted{color:#a8b3c7}#error{color:#ffb4b4}figure{margin:0}pre{white-space:pre-wrap;max-width:800px;overflow-wrap:anywhere}textarea{display:block;width:95%;min-height:80px}fieldset{margin:16px 0}button{padding:10px;margin:6px}#gate-panel{border:2px solid #9ae6b4;padding:16px}#gate-artifacts img{width:auto;max-width:100%;height:160px}figcaption{padding:8px}a{color:#9ae6b4}
|
|
12
|
+
</style></head><body><h1>SpriteForge</h1><p class="muted">Local game builder · human approval gates</p>
|
|
13
|
+
<details id="extensions"><summary>Skills and MCP servers</summary>
|
|
14
|
+
<label>Agent role <select id="extension-role"></select></label><h3>Available skills</h3><div id="skill-list"></div>
|
|
15
|
+
<h3>MCP servers</h3><p>Inspect tools, then explicitly enable a server for the selected role. Every tool requires confirmation by default.</p><div id="mcp-list"></div><p id="extension-error" role="alert"></p>
|
|
16
|
+
<label>Tool name (server.tool) <input id="mcp-tool"></label><label>Tool arguments (JSON)<textarea id="mcp-arguments">{}</textarea></label><button id="mcp-call" type="button">Request tool call</button>
|
|
17
|
+
</details><section id="mcp-confirmations" aria-label="MCP confirmations"></section>
|
|
18
|
+
<label>Run <select id="runs" aria-label="Select run"></select></label>
|
|
19
|
+
<p id="status" role="status">Connecting…</p><p id="usage"></p><p id="play"></p><p id="error" role="alert"></p>
|
|
20
|
+
<section id="preview-panel" hidden aria-label="Gameplay preview history"><p id="preview-summary"></p><details id="preview-warnings"><summary>Processing warnings</summary><ul id="preview-warning-list"></ul></details><nav id="preview-history" aria-label="Preview versions"></nav></section>
|
|
21
|
+
<section id="production-panel" hidden aria-label="Production assets"><h2>Asset production</h2><p id="production-mode"></p><p id="set-review"></p><div id="production-assets"></div></section>
|
|
22
|
+
<section id="gate-panel" hidden aria-labelledby="gate-title">
|
|
23
|
+
<h2 id="gate-title"></h2><div id="gate-artifacts"></div><div id="change-requests"></div>
|
|
24
|
+
<form id="gate-form"><div id="custom-fields"></div>
|
|
25
|
+
<fieldset id="tags"><legend>Feedback tags</legend>
|
|
26
|
+
<label><input type="checkbox" value="art">Art</label>
|
|
27
|
+
<label><input type="checkbox" value="gameplay">Gameplay</label>
|
|
28
|
+
<label><input type="checkbox" value="controls">Controls</label>
|
|
29
|
+
<label><input type="checkbox" value="difficulty">Difficulty</label>
|
|
30
|
+
<label><input type="checkbox" value="bug">Bug</label></fieldset>
|
|
31
|
+
<label for="feedback-text">Feedback</label><textarea id="feedback-text"></textarea>
|
|
32
|
+
<p id="feedback-hint" hidden>Describe what to change and submit to get a new version, or leave the box empty and submit to approve this build.</p>
|
|
33
|
+
<button id="approve" type="submit" value="approve">Approve</button>
|
|
34
|
+
<button id="request-changes" type="submit" value="request_changes">Request changes</button>
|
|
35
|
+
<button id="submit-feedback" type="submit" value="submit" hidden>Approve (no feedback)</button>
|
|
36
|
+
<button type="submit" value="abort">Abort</button><p id="gate-error" role="alert"></p>
|
|
37
|
+
</form></section><h2>Artifacts</h2><div id="artifacts"></div><h2>Timeline</h2><ol id="timeline"></ol>
|
|
38
|
+
<script>
|
|
39
|
+
const $=id=>document.getElementById(id);let source, selected;
|
|
40
|
+
let renderedGate=null,artifactIds={};
|
|
41
|
+
async function extensionPost(path,body){const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});const data=await response.json();if(!response.ok)throw new Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail));return data;}
|
|
42
|
+
async function extensionAction(fn){try{$('extension-error').textContent='';await fn();await loadExtensions();await loadConfirmations();}catch(e){$('extension-error').textContent=e.message;}}
|
|
43
|
+
async function loadExtensions(){const data=await(await fetch('/api/extensions')).json();const pick=$('extension-role');if(!pick.options.length)for(const role of data.roles){const option=new Option(role,role);pick.add(option);}
|
|
44
|
+
const role=pick.value;$('skill-list').replaceChildren();for(const skill of data.skills[role]||[]){const p=document.createElement('p');p.textContent=skill.name+' — '+skill.description;$('skill-list').append(p);}
|
|
45
|
+
$('mcp-list').replaceChildren();for(const [name,server] of Object.entries(data.servers)){const box=document.createElement('fieldset'),legend=document.createElement('legend');legend.textContent=name+' — '+(Object.hasOwn(server.enabled_roles,role)?'enabled for '+role:'disabled for '+role);box.append(legend);
|
|
46
|
+
const inspect=document.createElement('button');inspect.textContent='Inspect tools: '+name;inspect.type='button';inspect.onclick=()=>extensionAction(()=>extensionPost('/api/mcp/'+name+'/inspect'));box.append(inspect);
|
|
47
|
+
const enable=document.createElement('button');const enabled=Object.hasOwn(server.enabled_roles,role);enable.textContent=(enabled?'Disable ':'Enable ')+name+' for '+role;enable.type='button';enable.disabled=!server.discovered_fingerprint;enable.onclick=()=>extensionAction(()=>extensionPost('/api/mcp/'+name+'/enable',{role,enabled:!enabled}));box.append(enable);
|
|
48
|
+
for(const tool of server.tools){const p=document.createElement('p');p.textContent=name+'.'+tool.name+' — '+(tool.description||'');box.append(p);const label=document.createElement('label'),toggle=document.createElement('input');toggle.type='checkbox';toggle.checked=server.confirmation[tool.name]!==false;toggle.onchange=()=>extensionAction(()=>extensionPost('/api/mcp/'+name+'/policy',{tool:tool.name,confirm:toggle.checked}));label.append(toggle,document.createTextNode(' Require human confirmation for '+name+'.'+tool.name));box.append(label);const schema=document.createElement('details'),summary=document.createElement('summary'),pre=document.createElement('pre');summary.textContent='Arguments';pre.textContent=JSON.stringify(tool.inputSchema,null,2);schema.append(summary,pre);box.append(schema);}$('mcp-list').append(box);}}
|
|
49
|
+
async function loadConfirmations(){if(!selected)return;const current=selected;const calls=await(await fetch('/api/runs/'+current+'/mcp/confirmations')).json();if(selected!==current)return;$('mcp-confirmations').replaceChildren();for(const call of calls){const box=document.createElement('fieldset'),title=document.createElement('legend'),pre=document.createElement('pre');title.textContent='Confirm MCP call: '+call.tool+' ('+call.role+')';pre.textContent=JSON.stringify(call.arguments,null,2);box.append(title,pre);for(const approve of [true,false]){const b=document.createElement('button');b.textContent=approve?'Approve tool call':'Deny tool call';b.onclick=()=>extensionAction(async()=>{b.disabled=true;await extensionPost('/api/runs/'+current+'/mcp/confirmations/'+encodeURIComponent(call.id),{approve});await refresh();});box.append(b);}$('mcp-confirmations').append(box);}}
|
|
50
|
+
$('extension-role').onchange=()=>extensionAction(async()=>{});
|
|
51
|
+
$('mcp-call').onclick=()=>extensionAction(async()=>{if(!selected)throw new Error('Select an active run first');await extensionPost('/api/runs/'+selected+'/mcp/call',{role:$('extension-role').value,tool:$('mcp-tool').value,arguments:JSON.parse($('mcp-arguments').value)});});
|
|
52
|
+
loadExtensions().catch(e=>{$('extension-error').textContent=e.message;});setInterval(()=>loadConfirmations().catch(()=>{}),1500);
|
|
53
|
+
async function renderArtifact(a,container){
|
|
54
|
+
const fig=document.createElement('figure'),cap=document.createElement('figcaption');cap.textContent=a.name;
|
|
55
|
+
if(a.mime.startsWith('image/')){const img=document.createElement('img');img.src='/api/artifacts/'+a.id;img.alt=a.name;
|
|
56
|
+
if(a.name.startsWith('concept/')){img.className='concept-preview';fig.className='concept-figure';cap.textContent=a.name+' · CONCEPT — not final art'+(a.name.includes('-pixel')?' · Pixel-true preview (320×180, nearest-neighbor 3×)':'');}
|
|
57
|
+
if(a.name.endsWith('/contact-sheet.png'))img.className='production-sheet';
|
|
58
|
+
if(/^game\/v\d+\/art\.png$/.test(a.name)){img.style.imageRendering='pixelated';cap.textContent='Art for every entity: library first, Muse Image for the rest · '+a.name;}
|
|
59
|
+
if(/^game\/v\d+\/levels\/level-\d+\.png$/.test(a.name)){img.style.imageRendering='pixelated';img.style.width='auto';img.style.height='auto';img.style.maxWidth='100%';fig.style.flexBasis='100%';cap.textContent='Level '+a.name.match(/level-(\d+)/)[1]+' map, drawn by code from the level data (the white line is the route the bot plays; gold: a dash) · '+a.name;}
|
|
60
|
+
if(/^game\/v\d+\/poster\.png$/.test(a.name)){img.style.imageRendering='pixelated';img.style.width='960px';img.style.maxWidth='100%';cap.textContent='Frame from the autoplay (install ffmpeg for the video) · '+a.name;}
|
|
61
|
+
if(a.name.endsWith('/gameplay-4x.png')||a.name.endsWith('/gameplay.png')){img.className='gameplay-image';fig.className='gameplay-figure';img.alt='Gameplay screen rendered from actual tiles, sprites and HUD';cap.textContent='This is what your game will look like · '+a.name;}
|
|
62
|
+
if(a.name.startsWith('mood/')){img.className='mood-image';fig.className='mood-figure';cap.textContent='Mood board, not final art';}
|
|
63
|
+
fig.append(img);}
|
|
64
|
+
else if(a.mime.startsWith('video/')){const v=document.createElement('video');v.src='/api/artifacts/'+a.id;v.controls=true;v.autoplay=true;v.muted=true;v.loop=true;v.playsInline=true;v.className='kit-video';v.style.maxWidth='100%';v.style.width='960px';v.style.imageRendering='pixelated';cap.textContent='20 s autoplay of the generated game · '+a.name;fig.append(v);}
|
|
65
|
+
else if(a.mime==='application/json'&&/^game\/design\/v\d+\.json$/.test(a.name)){const d=await(await fetch('/api/artifacts/'+a.id)).json();const box=document.createElement('div');
|
|
66
|
+
const h=document.createElement('h3');h.textContent=d.title;const p=document.createElement('p');p.textContent=d.pitch;box.append(h,p);
|
|
67
|
+
const list=(title,items)=>{const t=document.createElement('h4');t.textContent=title;const ul=document.createElement('ul');for(const i of items){const li=document.createElement('li');li.textContent=i;ul.append(li);}box.append(t,ul);};
|
|
68
|
+
list('Basics',['Camera: '+d.camera,'Controls: '+d.controls,'Session: '+d.session_seconds+' s','Player: '+d.player,'World: '+d.world,'Look: '+d.look,'Win: '+d.win,'Lose: '+d.lose]);
|
|
69
|
+
list('Rules',d.rules);if(d.progression.length)list('Progression',d.progression);if(d.difficulty.length)list('Difficulty',d.difficulty);
|
|
70
|
+
list('Entities',d.entities.map(e=>e.id+' ('+e.role+', '+e.width+'x'+e.height+'): '+e.look+' — '+e.behaviour));
|
|
71
|
+
list('Checks the playtest will verify',d.checks.map(c=>c.event+' ≥ '+c.min_count+(c.by_second?' by '+c.by_second+' s':'')+' — '+c.description));
|
|
72
|
+
fig.append(box);cap.textContent='Game design doc · '+a.name;}
|
|
73
|
+
else if(a.mime==='application/json'&&a.name==='game/research.json'){const rec=await(await fetch('/api/artifacts/'+a.id)).json(),r=rec.research;const box=document.createElement('div');
|
|
74
|
+
const list=(title,items)=>{const t=document.createElement('h4');t.textContent=title;const ul=document.createElement('ul');for(const i of items){const li=document.createElement('li');if(i instanceof Node)li.append(i);else li.textContent=i;ul.append(li);}box.append(t,ul);};
|
|
75
|
+
box.append(Object.assign(document.createElement('h3'),{textContent:'Research: how similar games do it'}),Object.assign(document.createElement('p'),{textContent:rec.by+' · '+rec.searches+' search call(s) · '+rec.sources.length+' pages found'}));
|
|
76
|
+
list('Reference games (patterns only; the game stays original)',r.references.map((ref,i)=>{const span=document.createElement('span');span.append(ref.title+': '+ref.learn+' ');if(ref.url&&/^https?:\/\//.test(ref.url)){const link=document.createElement('a');link.href=ref.url;link.target='_blank';link.rel='noopener noreferrer';link.textContent=ref.url;span.append(link);}if(ref.url&&!rec.verified[i])span.append(' ⚠ not among the pages the searches returned');return span;}));
|
|
77
|
+
list('Patterns',r.patterns.map(x=>x.name+': '+x.how+' For this game: '+x.use));
|
|
78
|
+
list('Feel numbers players expect',r.feel.map(f=>f.name+': '+f.value+' ('+f.why+')'));
|
|
79
|
+
list('Pitfalls',r.pitfalls);if(rec.queries.length)list('Searched for',rec.queries);if(rec.notes.length)list('Notes',rec.notes);
|
|
80
|
+
fig.append(box);cap.textContent='Research (Game Designer with web search) · '+a.name;}
|
|
81
|
+
else if(a.mime==='application/json'&&/^game\/plan\/v\d+\.json$/.test(a.name)){const rec=await(await fetch('/api/artifacts/'+a.id)).json(),p=rec.plan,k=rec.kinematics,m=p.movement;const box=document.createElement('div');
|
|
82
|
+
const list=(title,items)=>{const t=document.createElement('h4');t.textContent=title;const ul=document.createElement('ul');for(const i of items){const li=document.createElement('li');li.textContent=i;ul.append(li);}box.append(t,ul);};
|
|
83
|
+
box.append(Object.assign(document.createElement('h3'),{textContent:'Game plan'}),Object.assign(document.createElement('p'),{textContent:'Style: '+p.style}));
|
|
84
|
+
if(rec.problems.length)list('⚠ Problems code found (the plan was '+(rec.retried?'asked for again once':'not retried')+')',rec.problems);
|
|
85
|
+
list('Your requirements (your words) and what covers each',p.requirements.map(r=>r.id+'. "'+r.text+'" → '+(p.coverage.filter(c=>c.requirement===r.id).map(c=>c.parts.join(', ')+': '+c.how).join('; ')||'NOT COVERED')));
|
|
86
|
+
list('Movement',[m.move_speed+' tiles/s','jump '+m.jump_height+' tiles, '+m.air_time+' s in the air'+(k?' → gravity '+k.gravity+' px/s², clears gaps up to '+k.max_gap+' tiles ('+k.max_gap_with_dash+' with a dash), platforms up to '+k.max_rise+' tiles high':''),'dash '+m.dash_distance+' tiles, cooldown '+m.dash_cooldown+' s','coyote time '+m.coyote_time+' s']);
|
|
87
|
+
list('Abilities',p.abilities.map(x=>x.name+': '+x.does+(x.numbers?' ('+x.numbers+')':'')+' · from '+x.unlocked_by));
|
|
88
|
+
list('Mechanics',p.mechanics.map(x=>x.name+': '+x.what+' — for '+x.purpose));
|
|
89
|
+
list('Challenges',p.challenges.map(c=>c.name+': '+c.tests));
|
|
90
|
+
list('Levels',p.levels.map(l=>l.n+'. '+l.name+' (difficulty '+l.difficulty+'): teaches '+l.teaching_goal+' · new idea: '+l.new_idea+' · challenges: '+(l.challenges.join(', ')||'-')+' · needs: '+(l.abilities.join(', ')||'-')));
|
|
91
|
+
list('Feel',p.feel.map(f=>f.event+': '+f.see+(f.hear?' · '+f.hear:'')));
|
|
92
|
+
list('Animations',p.animations.map(x=>x.entity+': '+x.rows.map(r=>r.name+' '+r.frames+'f '+(r.loop?'loop':'once')+' ('+r.shows+')').join('; ')));
|
|
93
|
+
list('Risks',p.risks);
|
|
94
|
+
fig.append(box);cap.textContent='Game plan: the design is written from it · '+a.name;}
|
|
95
|
+
else if(a.mime==='application/json'&&/^game\/(design\/v\d+-story|story\/v\d+-[a-z0-9-]+)\.json$/.test(a.name)){const s=await(await fetch('/api/artifacts/'+a.id)).json();const box=document.createElement('div');
|
|
96
|
+
const list=(title,items)=>{const t=document.createElement('h4');t.textContent=title;const ul=document.createElement('ul');for(const i of items){const li=document.createElement('li');li.textContent=i;ul.append(li);}box.append(t,ul);};
|
|
97
|
+
const h=document.createElement('h3');h.textContent=s.title+' — story bible';box.append(h);
|
|
98
|
+
box.append(Object.assign(document.createElement('p'),{textContent:s.logline}),Object.assign(document.createElement('p'),{textContent:s.premise}));
|
|
99
|
+
list('Basics',['Setting: '+s.setting,'Tone: '+s.tone,'Themes: '+(s.themes.join(', ')||'-')]);
|
|
100
|
+
list('Characters',s.characters.map(c=>c.name+' ('+c.role+', '+c.entity+'): '+c.look+' — '+c.personality+'. Goal: '+c.goal+(c.relationships?' · '+c.relationships:'')));
|
|
101
|
+
list('Locations',s.locations.map(l=>l.name+': '+l.description));
|
|
102
|
+
list('Arc (beat 1 is this game)',s.arc.map((b,i)=>(i+1)+'. '+b));
|
|
103
|
+
list('Canon — later levels must keep these',s.canon);
|
|
104
|
+
fig.append(box);cap.textContent='Story bible: the canon every later level is written from · '+a.name;}
|
|
105
|
+
else if(a.mime==='application/json'&&/^game\/v\d+\/report\.json$/.test(a.name)){const r=await(await fetch('/api/artifacts/'+a.id)).json();const box=document.createElement('div');
|
|
106
|
+
if(r.play){const play=document.createElement('a');play.href=r.play;play.target='_blank';play.rel='noopener';play.textContent='▶ Play this build in a new tab';box.append(play);if(r.sounds){const ed=document.createElement('a');ed.href=r.play.replace(/index\.html$/,'sounds.html');ed.target='_blank';ed.rel='noopener';ed.textContent=' · 🔊 Edit the sounds';box.append(ed);}}
|
|
107
|
+
if(r.levels){const h=document.createElement('h4');h.textContent='Levels (each played on its own by the bot)';const lu=document.createElement('ul');for(const lv of r.levels){const li=document.createElement('li');li.append((lv.cleared?'✔ ':'✖ ')+'Level '+lv.n+': '+lv.name+' — '+(lv.cleared?'cleared in '+lv.game_seconds+' game s':'not cleared ('+(lv.ended||'?')+(lv.bot&&lv.bot.detail?': '+lv.bot.detail:'')+')')+(lv.missing&&lv.missing.length?' · events missing: '+lv.missing.join(', '):'')+' · difficulty '+lv.difficulty+' ');if(lv.play){const a=document.createElement('a');a.href=lv.play;a.target='_blank';a.rel='noopener';a.textContent='▶ play level '+lv.n;li.append(a);}for(const p of lv.problems||[]){const x=document.createElement('div');x.textContent='⚠ code found: '+p;li.append(x);}lu.append(li);}box.append(h,lu);}
|
|
108
|
+
const ul=document.createElement('ul');const li=t=>{const x=document.createElement('li');x.textContent=t;ul.append(x);};
|
|
109
|
+
li((r.passed?'✔ All playtest checks passed':'✖ Some playtest checks failed')+' · written by '+r.chosen_by+' · design v'+r.design_version+' · '+r.attempts.length+' attempt(s)');
|
|
110
|
+
for(const c of r.checks)li((c.ok?'✔ ':'✖ ')+c.event+': '+c.count+'/'+c.min_count+(c.by_second?' by '+c.by_second+' s':'')+' — '+c.description);
|
|
111
|
+
for(const p of r.problems||[])li('✖ '+p);
|
|
112
|
+
for(const x of r.art)li('art '+x.entity+': '+x.source);
|
|
113
|
+
if(r.sounds)li('Sounds v'+r.sounds.version+' ('+r.sounds.by+'): '+r.sounds.cues.map(c=>c.name+(c.heard?' ✔':'')).join(', ')+(r.sounds.never_heard.length?' · not heard in the bot playtest: '+r.sounds.never_heard.join(', '):''));
|
|
114
|
+
for(const w of r.warnings||[])li('⚠ '+w);
|
|
115
|
+
if(r.revision)li('Feedback applied: design «'+(r.revision.design_changes||'-')+'» · art '+(r.revision.art_changes.map(c=>c.entity).join(', ')||'-')+' · code «'+(r.revision.code_instructions||'-')+'»');
|
|
116
|
+
if(r.game_db)li('Game record (OKF bundle: design, entities, art, code, QA, builds, decisions, and the story when asked for): '+(r.game_db.concepts??r.game_db.entries)+' concepts in '+r.game_db.path);
|
|
117
|
+
li('Model calls: '+r.model_calls.chat+' Spark, '+r.model_calls.image+' Muse Image, about $'+r.model_calls.estimated_cost_usd);
|
|
118
|
+
box.append(ul);if(r.art_review)box.append(artReview(r.art_review,container.id==='gate-artifacts'));if(r.qa)box.append(qaSection(r.qa));const details=document.createElement('details'),summary=document.createElement('summary'),pre=document.createElement('pre');summary.textContent='Full report.json';pre.textContent=JSON.stringify(r,null,2);details.append(summary,pre);box.append(details);fig.append(box);cap.textContent='Build and playtest report · '+a.name;}
|
|
119
|
+
else if(a.mime==='application/json'&&/^game\/final\/v\d+-review\.json$/.test(a.name)){const f=await(await fetch('/api/artifacts/'+a.id)).json();const box=document.createElement('div');
|
|
120
|
+
const list=(title,items)=>{const t=document.createElement('h4');t.textContent=title;const ul=document.createElement('ul');for(const i of items){const li=document.createElement('li');li.textContent=i;ul.append(li);}box.append(t,ul);};
|
|
121
|
+
const tests=q=>q.passed+'/'+(q.passed+q.failed)+(q.failing&&q.failing.length?' (failing: '+q.failing.join(', ')+')':'');
|
|
122
|
+
if(f.play){const p=document.createElement('a');p.href=f.play;p.target='_blank';p.rel='noopener';p.textContent='▶ Play the whole game in a new tab';box.append(p);}
|
|
123
|
+
if(f.levels.length)list('Levels: the bot’s run and the QA tests that start in each',f.levels.map(l=>(l.cleared?'✔ ':'✖ ')+'Level '+l.n+': '+l.name+' — '+(l.cleared?'cleared in '+l.game_seconds+' game s':'not cleared ('+(l.ended||'?')+')')+' · QA '+tests(l.qa)));
|
|
124
|
+
list('QA tests',[tests(f.qa)+' pass in this build',...Object.entries(f.qa.by_kind).map(([k,v])=>k+': '+tests(v))]);
|
|
125
|
+
list('Art',[f.art.entities+' entities: '+Object.entries(f.art.sources).map(([k,v])=>v+' '+k).join(', '),...Object.entries(f.art.review_scores).map(([k,v])=>'art review '+k+': '+v+'/5')]);
|
|
126
|
+
list('Cost of the whole run',[f.cost.chat+' Spark and '+f.cost.image+' Muse Image calls, about $'+f.cost.estimated_cost_usd+' over '+f.builds+' build(s)']);
|
|
127
|
+
list('The game’s record (OKF bundle)',[f.record.concepts+' concepts in '+f.record.path,'sections: '+Object.entries(f.record.sections).map(([k,v])=>k+' '+v).join(', '),'trust now: '+Object.entries(f.record.trust).map(([k,v])=>v+' '+k).join(', ')+' (all human-reviewed when you approve)']);
|
|
128
|
+
fig.append(box);cap.textContent='Final review of the whole game · '+a.name;}
|
|
129
|
+
else if(a.mime==='application/json'&&/^game\/final\/v\d+-lessons\.json$/.test(a.name)){const r=await(await fetch('/api/artifacts/'+a.id)).json();const box=document.createElement('div');const ol=document.createElement('ol');
|
|
130
|
+
for(const l of r.lessons){const li=document.createElement('li'),b=document.createElement('strong'),e=document.createElement('small');b.textContent=l.title+' ('+l.topic+')';e.textContent='Evidence: '+l.evidence;li.append(b,document.createElement('br'),l.lesson,document.createElement('br'),e);ol.append(li);}
|
|
131
|
+
const details=document.createElement('details'),summary=document.createElement('summary'),ul=document.createElement('ul');summary.textContent='The facts code recorded ('+r.facts.length+')';for(const x of r.facts){const li=document.createElement('li');li.textContent=x;ul.append(li);}details.append(summary,ul);box.append(ol,details);
|
|
132
|
+
fig.append(box);cap.textContent='Lessons by the Librarian ('+r.by+'): they go to the shared memory (lessons/) as generated knowledge when you approve; promote them with spriteforge memory promote · '+a.name;}
|
|
133
|
+
else if(a.mime==='text/x-python'){const link=document.createElement('a');link.href='/api/artifacts/'+a.id;link.download=a.name.split('/').pop();link.textContent='⬇ Download '+link.download+' (QA tests: pytest + Playwright; put it in the build’s tests/ folder)';fig.append(link);cap.textContent='Exported QA tests · '+a.name;}
|
|
134
|
+
else if(a.mime==='application/json'){const pre=document.createElement('pre');
|
|
135
|
+
pre.textContent=JSON.stringify(await(await fetch('/api/artifacts/'+a.id)).json(),null,2);
|
|
136
|
+
if(a.name.endsWith('/readability.json')){fig.className='preview-checks';const details=document.createElement('details'),summary=document.createElement('summary');summary.textContent='Asset validation and readability report';details.append(summary,pre);fig.append(details);}else fig.append(pre);}
|
|
137
|
+
fig.append(cap);container.append(fig);
|
|
138
|
+
}
|
|
139
|
+
// Art review on the G4 report: scores, fixed art (before/after), every issue; at the playtest
|
|
140
|
+
// gate each suggestion has Apply, which submits it as feedback (a targeted art rebuild).
|
|
141
|
+
function artReview(rv,canApply){const box=document.createElement('section');box.className='art-review';
|
|
142
|
+
const h=document.createElement('h4');h.textContent='Art review · '+rv.reviewed_by;box.append(h);
|
|
143
|
+
const scores=document.createElement('p');scores.textContent=Object.entries(rv.scores).map(([k,v])=>k+' '+v+'/5').join(' · ')+(rv.cost?' · cost $'+(rv.cost.review+rv.cost.fixes).toFixed(3)+' of $'+rv.cost.cap.toFixed(2)+' (review $'+rv.cost.review.toFixed(3)+', fixes $'+rv.cost.fixes.toFixed(3)+')':'');box.append(scores);
|
|
144
|
+
const pic=name=>{const img=document.createElement('img');img.src='/api/artifacts/'+artifactIds[name];img.alt=name;img.style.cssText='width:auto;height:64px;vertical-align:middle';return img;};
|
|
145
|
+
for(const f of rv.fixes){const p=document.createElement('p');p.textContent=(f.fixed?'✔ Fixed ':'✖ Not fixed ')+f.entity+' ('+f.mode+')'+(f.notes.length?': '+f.notes.join('; '):'');
|
|
146
|
+
if(f.before&&artifactIds[f.before]&&artifactIds[f.after])p.append(' ',pic(f.before),' → ',pic(f.after));box.append(p);}
|
|
147
|
+
if(rv.replay)box.append(Object.assign(document.createElement('p'),{textContent:'Replayed with the fixed art: '+(rv.replay.passed?'checks passed':'checks failed')+(rv.replay.qa?' · QA '+rv.replay.qa.passed+'/'+(rv.replay.qa.passed+rv.replay.qa.failed):'')+(rv.replay.reverted?' — fixes reverted':'')}));
|
|
148
|
+
const ul=document.createElement('ul');
|
|
149
|
+
for(const i of rv.issues){const li=document.createElement('li');li.textContent='#'+i.n+' ['+i.severity+'] '+i.entity+': '+i.problem+' — fix: '+i.fix_prompt+' · '+i.status+' ('+i.from+')';
|
|
150
|
+
if(canApply&&i.status!=='fixed'){const b=document.createElement('button');b.type='button';b.textContent='Apply #'+i.n;b.onclick=()=>applySuggestion(i,b);li.append(' ',b);}ul.append(li);}
|
|
151
|
+
if(!rv.issues.length)ul.append(Object.assign(document.createElement('li'),{textContent:'No issues'}));box.append(ul);
|
|
152
|
+
const missed=rv.named.filter(n=>!n.ok);if(missed.length)box.append(Object.assign(document.createElement('p'),{textContent:'Naming test misses: '+missed.map(n=>n.entity+' seen as “'+n.seen_as+'”').join('; ')}));
|
|
153
|
+
const seen=rv.images.filter(n=>/\/(sheet-4x|shot-\d+)\.png$/.test(n)&&artifactIds[n]);
|
|
154
|
+
if(seen.length){const details=document.createElement('details'),summary=document.createElement('summary');summary.textContent='What the reviewer saw';details.append(summary);for(const n of seen){const img=pic(n);img.style.height='180px';details.append(img);}box.append(details);}
|
|
155
|
+
return box;}
|
|
156
|
+
// QA tests on the G4 report: each test with pass/fail, its steps, the failing step and its
|
|
157
|
+
// screenshots, and the exported Playwright file.
|
|
158
|
+
function qaSection(qa){const box=document.createElement('section');box.className='qa-tests';
|
|
159
|
+
const total=qa.passed+qa.failed,h=document.createElement('h4');
|
|
160
|
+
h.textContent='QA tests and code checks · '+qa.passed+'/'+total+' pass'+(qa.broken?' · '+qa.broken+' check(s) broken (an error in the check itself, not in the game)':'');box.append(h);
|
|
161
|
+
if(qa.failed)box.append(Object.assign(document.createElement('p'),{textContent:'A failing test is a game bug or a wrong test (they are written from the design, not the code). If the test is wrong, drop it with feedback such as “ignore the restart test”.'}));
|
|
162
|
+
if(qa.note)box.append(Object.assign(document.createElement('p'),{textContent:'⚠ '+qa.note}));
|
|
163
|
+
if(qa.export_url){const a=document.createElement('a');a.href=qa.export_url;a.download='test_game_qa.py';a.textContent='⬇ test_game_qa.py: run these tests yourself (pytest + Playwright)';box.append(a);}
|
|
164
|
+
const ul=document.createElement('ul');
|
|
165
|
+
for(const t of qa.tests){const li=document.createElement('li');
|
|
166
|
+
const chk=t.kind==='check';
|
|
167
|
+
li.textContent=(t.passed?'✔ ':t.broken?'⚠ ':'✖ ')+(chk?'[code check] ':'')+t.name+(t.god?'':' (the player can die)')+' — '+t.purpose+(t.since?' · since v'+t.since:'')+(t.passed?'':chk?' · '+t.failure:' · failed at step '+t.failed_step+': '+t.failure);
|
|
168
|
+
const d=document.createElement('details'),sm=document.createElement('summary');sm.textContent=chk?'the check (JavaScript the QA Playtester wrote for this game)':'steps';d.append(sm);
|
|
169
|
+
if(chk){const pre=document.createElement('pre');pre.textContent=t.code+((t.logs||[]).length?'\n\n// notes: '+t.logs.join(' | '):'');d.append(pre);}
|
|
170
|
+
const ol=document.createElement('ol');for(const st of t.steps){ol.append(Object.assign(document.createElement('li'),{textContent:(st.ok?'✔ ':'✖ ')+st.step+(st.detail?' — '+st.detail:'')}));}d.append(ol);
|
|
171
|
+
for(const n of t.screenshots||[])if(artifactIds[n]){const img=document.createElement('img');img.src='/api/artifacts/'+artifactIds[n];img.alt=n;img.style.cssText='width:auto;height:120px;image-rendering:pixelated;margin:2px';d.append(img);}
|
|
172
|
+
li.append(d);ul.append(li);}
|
|
173
|
+
box.append(ul);
|
|
174
|
+
if(qa.dropped&&qa.dropped.length)box.append(Object.assign(document.createElement('p'),{textContent:'Dropped by feedback: '+qa.dropped.join(', ')}));
|
|
175
|
+
return box;}
|
|
176
|
+
async function applySuggestion(i,button){const gate=renderedGate;if(!gate)return;button.disabled=true;
|
|
177
|
+
const body={action:'submit',tags:['art'],text:'Apply art review suggestion #'+i.n+' ('+i.entity+'): '+i.fix_prompt,form:{apply_suggestion:i.n}};
|
|
178
|
+
try{const response=await fetch('/api/runs/'+selected+'/gates/'+gate,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
179
|
+
const result=await response.json();if(!response.ok)throw new Error(typeof result.detail==='string'?result.detail:JSON.stringify(result.detail));await refresh();}
|
|
180
|
+
catch(err){$('gate-error').textContent=err.message;button.disabled=false;}}
|
|
181
|
+
let refreshing=false,refreshAgain=false;
|
|
182
|
+
async function refresh(){if(refreshing){refreshAgain=true;return;}refreshing=true;try{await refreshRun();}catch(error){$('error').textContent='Could not refresh the run. Check the local server connection: '+error.message;}finally{refreshing=false;if(refreshAgain){refreshAgain=false;refresh();}}}
|
|
183
|
+
async function refreshRun(){const current=selected;const d=await (await fetch('/api/runs/'+current)).json();
|
|
184
|
+
if(current!==selected)return;
|
|
185
|
+
artifactIds=Object.fromEntries(d.artifacts.map(a=>[a.name,a.id]));
|
|
186
|
+
$('status').textContent=d.run.status+' · checkpoint '+d.run.cursor;
|
|
187
|
+
$('error').textContent=d.error||'';
|
|
188
|
+
$('play').replaceChildren();if(d.play){const a=document.createElement('a');a.href=d.play.url;a.target='_blank';a.rel='noopener';a.textContent='▶ Play the latest build (v'+d.play.version+')';$('play').append(a);if(d.play.sounds){const s=document.createElement('a');s.href=d.play.url.replace(/index\.html$/,'sounds.html');s.target='_blank';s.rel='noopener';s.textContent=' · 🔊 Edit the sounds';$('play').append(s);}}
|
|
189
|
+
const zip=d.artifacts.filter(a=>/^game\/v\d+\/export\.zip$/.test(a.name)).pop();if(zip){const a=document.createElement('a');a.href='/api/artifacts/'+zip.id;a.download=zip.name.replaceAll('/','-');a.textContent=' · ⬇ The finished game (build, record, tests)';$('play').append(a);}
|
|
190
|
+
const u=d.usage, dollars=v=>v==null?'unknown':'$'+v.toFixed(6);
|
|
191
|
+
$('usage').textContent=u?`${u.image_calls} / ${u.cap} image calls (${dollars(u.estimated_image_cost)}) · Spark: ${u.spark.calls} calls, ${u.spark.input_tokens ?? 'unknown'} input / ${u.spark.output_tokens ?? 'unknown'} output tokens, ${u.spark.cached_input_tokens ?? 'unknown'} cached input (${dollars(u.spark.estimated_cost)}) · total estimate ${dollars(u.estimated_total_cost)} · failed attempts: ${u.failed_attempts} (excluded)${u.spark.missing_usage_calls ? ' · token usage unavailable for '+u.spark.missing_usage_calls+' calls' : ''}`:'';
|
|
192
|
+
renderProduction(d);
|
|
193
|
+
renderPreview(d);
|
|
194
|
+
$('artifacts').replaceChildren();for(const a of d.artifacts.filter(a=>a.mime.startsWith('image/')&&(!a.name.startsWith('production/')||a.name.endsWith('/sprite.png'))&&(!a.name.startsWith('preview/')||a.name.endsWith('/sprite.png')||a.name.endsWith('/gameplay-4x.png'))&&(!a.name.startsWith('mood/')||a.name.endsWith('/landscape.png')))){await renderArtifact(a,$('artifacts'));}
|
|
195
|
+
if(current!==selected)return;
|
|
196
|
+
const g=d.gate;
|
|
197
|
+
if(!g){$('gate-panel').hidden=true;renderedGate=null;return;}if(renderedGate===g.id){$('gate-panel').hidden=false;return;}
|
|
198
|
+
$('gate-panel').hidden=true;renderedGate=null;
|
|
199
|
+
$('gate-title').textContent=g.title;$('gate-form').reset();$('gate-error').textContent='';
|
|
200
|
+
$('gate-artifacts').replaceChildren();for(const id of g.artifacts_to_show){const a=d.artifacts.find(a=>a.id===id);if(a)await renderArtifact(a,$('gate-artifacts'));}
|
|
201
|
+
$('change-requests').replaceChildren();
|
|
202
|
+
if(g.changes){for(const c of g.changes.requests){const p=document.createElement('p');
|
|
203
|
+
p.textContent='Target: '+JSON.stringify(c.target)+' | '+c.instruction+' | '+c.severity+' | '+c.scope+' | Regenerate: '+c.needs_regen;
|
|
204
|
+
$('change-requests').append(p);}}
|
|
205
|
+
$('request-changes').hidden=g.kind==='confirm_changes'||(d.run.config.workflow==='intake-concept-v1'&&!['concept','assets','gameplay','design','menus','playtest','final'].includes(g.stage));$('tags').hidden=g.kind==='confirm_changes';
|
|
206
|
+
const single=!!g.empty_feedback_approves;$('feedback-hint').hidden=!single;$('submit-feedback').hidden=!single;$('approve').hidden=single;if(single)$('request-changes').hidden=true;
|
|
207
|
+
const label=()=>{const story=$('custom-fields').querySelector('[name="story"]');$('submit-feedback').textContent=$('feedback-text').value.trim()?'Send feedback (make a new version)':(story&&story.value==='Add a story'?'Add a story (then review it here)':'Approve (no feedback)');};$('feedback-text').oninput=label;label();
|
|
208
|
+
$('custom-fields').replaceChildren();
|
|
209
|
+
if(g.form_schema.title!=='Feedback')for(const [name,rule] of Object.entries(g.form_schema.properties||{})){
|
|
210
|
+
const label=document.createElement('label');label.textContent=rule.title||name;
|
|
211
|
+
let input;
|
|
212
|
+
if(rule.enum){input=document.createElement('select');for(const v of rule.enum){const o=document.createElement('option');o.value=v;o.textContent=v;input.append(o);}}
|
|
213
|
+
else{input=document.createElement('input');input.type=rule.format==='color'?'color':rule.type==='boolean'?'checkbox':(['integer','number'].includes(rule.type)?'number':'text');}
|
|
214
|
+
input.name=name;input.dataset.kind=rule.type||'string';input.required=(g.form_schema.required||[]).includes(name);if(rule.default!==undefined)input.value=rule.default;
|
|
215
|
+
label.append(input);const box=document.createElement('fieldset');box.append(label);
|
|
216
|
+
if(rule.format==='color')box.className='swatch-field';
|
|
217
|
+
if(rule.suggestions&&rule.format!=='color'){const choices=document.createElement('div');
|
|
218
|
+
for(const value of rule.suggestions){const chip=document.createElement('button');chip.type='button';chip.textContent=value;chip.onclick=()=>{input.value=value;};choices.append(chip);}box.append(choices);
|
|
219
|
+
const suggest=document.createElement('button');suggest.type='button';suggest.textContent="I'm not sure, suggest options";suggest.onclick=()=>{choices.hidden=false;choices.style.border='1px solid #9ae6b4';choices.querySelector('button')?.focus();};box.append(suggest);
|
|
220
|
+
const skip=document.createElement('button');skip.type='button';skip.textContent='Skip / use default';skip.onclick=()=>{input.value=rule.default||'';};box.append(skip);}
|
|
221
|
+
$('custom-fields').append(box);}
|
|
222
|
+
$('custom-fields').onchange=label;label();
|
|
223
|
+
const optionsArtifact=d.artifacts.find(a=>a.name==='options.json');
|
|
224
|
+
if(g.stage==='styles'&&optionsArtifact){const options=await(await fetch('/api/artifacts/'+optionsArtifact.id)).json();
|
|
225
|
+
for(const option of options.options){const box=document.createElement('fieldset');const title=document.createElement('legend');title.textContent=option.name;box.append(title);const desc=document.createElement('p');desc.textContent=option.description;box.append(desc);
|
|
226
|
+
for(const color of option.colors){const swatch=document.createElement('span');swatch.style.cssText='display:inline-block;width:42px;height:42px;margin:4px';swatch.style.backgroundColor=color;swatch.title=color;box.append(swatch);}$('custom-fields').append(box);}}
|
|
227
|
+
if(current!==selected)return;renderedGate=g.id;$('gate-panel').hidden=false;
|
|
228
|
+
}
|
|
229
|
+
function renderPreview(d){
|
|
230
|
+
$('preview-panel').hidden=!d.preview;
|
|
231
|
+
if(!d.preview)return;
|
|
232
|
+
$('usage').textContent+=` · Gate 2 cap: ${d.preview.cap} image calls`;
|
|
233
|
+
const report=d.preview.report;
|
|
234
|
+
$('preview-summary').textContent=report?`Hero: ${report.hero_logical_height} logical pixels tall${report.hero_logical_height<24?' — WARNING: under 24':''} · Palette: ${report.palette_size} colors`:'';
|
|
235
|
+
const warnings=report?.warnings||[];$('preview-warnings').hidden=!warnings.length;$('preview-warning-list').replaceChildren();
|
|
236
|
+
for(const warning of warnings){const item=document.createElement('li');item.textContent=warning;$('preview-warning-list').append(item);}
|
|
237
|
+
$('preview-history').replaceChildren();
|
|
238
|
+
for(const v of d.preview.versions){const link=document.createElement('a');link.href='/api/artifacts/'+v.artifact;link.textContent='Gameplay v'+v.version;link.target='_blank';link.rel='noopener';$('preview-history').append(link);}
|
|
239
|
+
}
|
|
240
|
+
function renderProduction(d){
|
|
241
|
+
const panel=$('production-panel');panel.hidden=!d.production;if(!d.production)return;
|
|
242
|
+
$('production-mode').textContent=d.run.config.mock?'Mock preview: synthetic images and scripted critic scores.':'Muse asset production with pixel checks and visual review.';
|
|
243
|
+
const issues=d.production.consistency?.issues||[];$('set-review').textContent=d.production.consistency?`Set style score: ${d.production.consistency.style_match}/5. `+(issues.length?'Review issues: '+issues.map(i=>i.asset_ids.join(', ')+': '+i.issue+' '+i.fix).join(' | '):'No set consistency issues reported.'):'Production in progress; accepted assets are saved.';
|
|
244
|
+
$('production-assets').replaceChildren();
|
|
245
|
+
for(const type of ['character','tile','item','ui','fx']){
|
|
246
|
+
const records=d.production.assets.filter(r=>r.type===type);if(!records.length)continue;
|
|
247
|
+
const heading=document.createElement('h3');heading.textContent=type;const grid=document.createElement('div');grid.className='asset-grid';
|
|
248
|
+
for(const r of records){const card=document.createElement('article');card.className='asset-card';card.dataset.assetId=r.id;const title=document.createElement('h4');title.textContent=r.id;card.append(title);
|
|
249
|
+
const a=d.artifacts.find(a=>a.name===r.artifact);if(a){const img=document.createElement('img');img.src='/api/artifacts/'+a.id;img.alt=r.id;img.onload=()=>{img.style.width=(img.naturalWidth*4)+'px';};card.append(img);}
|
|
250
|
+
const failed=r.status!=='accepted'||issues.some(i=>i.asset_ids.includes(r.id));const badge=document.createElement('p');badge.className=failed?'asset-fail':'asset-pass';badge.textContent=(failed?'FAIL / '+(r.status==='placeholder'?'PLACEHOLDER':'SET REVIEW'):'PASS')+` · attempts ${r.attempts}/3 · v${r.version}`;card.append(badge);
|
|
251
|
+
if(r.scores){const scores=document.createElement('p');scores.textContent=`Style ${r.scores.style_match}/5 · Readability ${r.scores.readability}/5`;card.append(scores);}
|
|
252
|
+
if(r.issues.length){const detail=document.createElement('p');detail.textContent=r.issues.join('; ');card.append(detail);}
|
|
253
|
+
if(d.gate?.stage==='assets'){const button=document.createElement('button');button.type='button';button.textContent='Change '+r.id;button.onclick=()=>{const input=$('custom-fields').querySelector('[name="targets"]');if(input){input.value=r.id;input.focus();}$('feedback-text').focus();};card.append(button);}grid.append(card);}
|
|
254
|
+
$('production-assets').append(heading,grid);}
|
|
255
|
+
}
|
|
256
|
+
$('gate-form').onsubmit=async e=>{e.preventDefault();const gate=renderedGate;if(!gate)return;
|
|
257
|
+
const form={};for(const input of $('custom-fields').querySelectorAll('input,select')){
|
|
258
|
+
form[input.name]=input.dataset.kind==='boolean'?input.checked:(['integer','number'].includes(input.dataset.kind)?Number(input.value):input.value);}
|
|
259
|
+
const body={action:e.submitter.value,tags:[...$('tags').querySelectorAll('input:checked')].map(i=>i.value),text:$('feedback-text').value,form};
|
|
260
|
+
for(const b of $('gate-form').querySelectorAll('button'))b.disabled=true;
|
|
261
|
+
try{const response=await fetch('/api/runs/'+selected+'/gates/'+gate,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
262
|
+
const result=await response.json();if(!response.ok)throw new Error(typeof result.detail==='string'?result.detail:JSON.stringify(result.detail));
|
|
263
|
+
await refresh();}catch(err){$('gate-error').textContent=err.message;}finally{for(const b of $('gate-form').querySelectorAll('button'))b.disabled=false;}
|
|
264
|
+
};
|
|
265
|
+
function choose(){if(source)source.close();selected=$('runs').value;renderedGate=null;$('timeline').replaceChildren();refresh();
|
|
266
|
+
source=new EventSource('/api/runs/'+selected+'/events');source.onmessage=e=>{const d=JSON.parse(e.data);
|
|
267
|
+
const li=document.createElement('li');li.textContent=d.timestamp+' · '+d.agent+' · '+d.type+' · '+(d.payload.message||'');
|
|
268
|
+
if(d.type==='router.decision'||d.type==='router.outcome'){
|
|
269
|
+
const details=document.createElement('details'),summary=document.createElement('summary'),pre=document.createElement('pre');
|
|
270
|
+
summary.textContent=d.type==='router.decision'?'Capability selection':'Capability usage';
|
|
271
|
+
pre.textContent=JSON.stringify(d.payload,null,2);details.append(summary,pre);li.append(details);
|
|
272
|
+
}
|
|
273
|
+
$('timeline').append(li);refresh();};source.addEventListener('done',()=>{source.close();refresh();});
|
|
274
|
+
source.addEventListener('worker-error',e=>{$('error').textContent=JSON.parse(e.data);source.close();});}
|
|
275
|
+
fetch('/api/runs').then(r=>r.json()).then(d=>{for(const r of d.runs){const o=document.createElement('option');
|
|
276
|
+
o.value=r.id;o.textContent=r.id+' · '+r.status;$('runs').append(o);}const wanted=new URLSearchParams(location.search).get('run');if(wanted&&d.runs.some(r=>r.id===wanted))$('runs').value=wanted;else if(d.active)$('runs').value=d.active;
|
|
277
|
+
if(d.error)$('error').textContent=d.error;if(d.runs.length)choose();else $('status').textContent='No runs yet';})
|
|
278
|
+
.catch(e=>{$('error').textContent=e.message;});$('runs').onchange=choose;
|
|
279
|
+
</script></body></html>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Localhost-only guard: the studio has no accounts, so any website the user visits could try to
|
|
2
|
+
drive it through the browser. Requests must name a loopback host (DNS rebinding), and requests
|
|
3
|
+
that change state must come from the studio's own origin (cross-site forms and fetches)."""
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1", "testserver"}
|
|
10
|
+
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def host_name(host: str) -> str:
|
|
14
|
+
return (urlsplit("//" + host).hostname or "") if host else ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def refusal(method: str, headers) -> str | None:
|
|
18
|
+
"""Why this request is refused, or None. `headers` is a case-insensitive mapping."""
|
|
19
|
+
host = headers.get("host", "")
|
|
20
|
+
if host_name(host) not in LOCAL_HOSTS:
|
|
21
|
+
return "Local host required"
|
|
22
|
+
if method in SAFE_METHODS:
|
|
23
|
+
return None
|
|
24
|
+
origin = headers.get("origin")
|
|
25
|
+
if origin is not None:
|
|
26
|
+
parts = urlsplit(origin)
|
|
27
|
+
# Compared with the Host header (not the bind address) so the Vite dev proxy works.
|
|
28
|
+
if parts.scheme not in {"http", "https"} or parts.netloc != host:
|
|
29
|
+
return "Same-origin request required"
|
|
30
|
+
elif headers.get("sec-fetch-site") == "cross-site":
|
|
31
|
+
return "Same-origin request required"
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LocalOnly:
|
|
36
|
+
"""Pure ASGI middleware, so streaming responses (SSE) pass through untouched."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, app):
|
|
39
|
+
self.app = app
|
|
40
|
+
|
|
41
|
+
async def __call__(self, scope, receive, send):
|
|
42
|
+
if scope["type"] == "http":
|
|
43
|
+
from starlette.datastructures import Headers
|
|
44
|
+
|
|
45
|
+
reason = refusal(scope["method"], Headers(scope=scope))
|
|
46
|
+
if reason:
|
|
47
|
+
await JSONResponse({"detail": reason}, status_code=403)(scope, receive, send)
|
|
48
|
+
return
|
|
49
|
+
await self.app(scope, receive, send)
|