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.
Files changed (218) hide show
  1. spriteforge/__init__.py +1 -0
  2. spriteforge/__main__.py +3 -0
  3. spriteforge/agents/__init__.py +0 -0
  4. spriteforge/agents/assets.py +109 -0
  5. spriteforge/agents/cancellation.py +12 -0
  6. spriteforge/agents/director.py +50 -0
  7. spriteforge/agents/models.py +63 -0
  8. spriteforge/agents/roles/art-director.md +15 -0
  9. spriteforge/agents/roles/art-reviewer.md +14 -0
  10. spriteforge/agents/roles/director.md +14 -0
  11. spriteforge/agents/roles/game-designer.md +15 -0
  12. spriteforge/agents/roles/level-tile-designer.md +16 -0
  13. spriteforge/agents/roles/librarian.md +13 -0
  14. spriteforge/agents/roles/menu-designer.md +15 -0
  15. spriteforge/agents/roles/pixel-artist.md +14 -0
  16. spriteforge/agents/roles/programmer.md +14 -0
  17. spriteforge/agents/roles/qa-playtester.md +13 -0
  18. spriteforge/agents/roles/sound-director.md +14 -0
  19. spriteforge/agents/roles.py +39 -0
  20. spriteforge/agents/router.py +233 -0
  21. spriteforge/agents/router_eval.py +145 -0
  22. spriteforge/agents/routing_state.py +133 -0
  23. spriteforge/agents/runtime.py +430 -0
  24. spriteforge/agents/workflow.py +389 -0
  25. spriteforge/app/__init__.py +0 -0
  26. spriteforge/app/agents_view.py +248 -0
  27. spriteforge/app/chat.py +114 -0
  28. spriteforge/app/extensions.py +115 -0
  29. spriteforge/app/index.html +279 -0
  30. spriteforge/app/local_only.py +49 -0
  31. spriteforge/app/server.py +358 -0
  32. spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
  33. spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
  34. spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
  35. spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
  36. spriteforge/app/static/index.html +13 -0
  37. spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
  38. spriteforge/app/studio.py +278 -0
  39. spriteforge/app/workers.py +73 -0
  40. spriteforge/cli.py +241 -0
  41. spriteforge/config.py +193 -0
  42. spriteforge/config.yaml +157 -0
  43. spriteforge/core/__init__.py +0 -0
  44. spriteforge/core/acting.py +16 -0
  45. spriteforge/core/cost.py +236 -0
  46. spriteforge/core/credentials.py +24 -0
  47. spriteforge/core/demo.py +79 -0
  48. spriteforge/core/forms.py +20 -0
  49. spriteforge/core/gates.py +175 -0
  50. spriteforge/core/security.py +42 -0
  51. spriteforge/core/state.py +222 -0
  52. spriteforge/core/team.py +15 -0
  53. spriteforge/core/usage.py +68 -0
  54. spriteforge/game/__init__.py +0 -0
  55. spriteforge/game/actors.py +377 -0
  56. spriteforge/game/agent_tools.py +178 -0
  57. spriteforge/game/art.py +2097 -0
  58. spriteforge/game/art_checks.py +190 -0
  59. spriteforge/game/art_review.py +518 -0
  60. spriteforge/game/build.py +134 -0
  61. spriteforge/game/bundle.py +1178 -0
  62. spriteforge/game/cli.py +119 -0
  63. spriteforge/game/codegen.py +433 -0
  64. spriteforge/game/codeinfo.py +154 -0
  65. spriteforge/game/dbsource.py +137 -0
  66. spriteforge/game/design.py +342 -0
  67. spriteforge/game/export.py +62 -0
  68. spriteforge/game/gamedb.py +191 -0
  69. spriteforge/game/image_cache.py +61 -0
  70. spriteforge/game/lessons.py +259 -0
  71. spriteforge/game/level_gen.py +118 -0
  72. spriteforge/game/level_preview.py +97 -0
  73. spriteforge/game/levels.py +934 -0
  74. spriteforge/game/loops.py +38 -0
  75. spriteforge/game/menus.py +712 -0
  76. spriteforge/game/mock_game.js +109 -0
  77. spriteforge/game/mock_platformer.js +58 -0
  78. spriteforge/game/motion.py +881 -0
  79. spriteforge/game/pipeline.py +1768 -0
  80. spriteforge/game/plan.py +502 -0
  81. spriteforge/game/playtest.py +318 -0
  82. spriteforge/game/qa.py +664 -0
  83. spriteforge/game/qa_checks.py +223 -0
  84. spriteforge/game/qa_runtime.py +565 -0
  85. spriteforge/game/reach.py +807 -0
  86. spriteforge/game/record.py +138 -0
  87. spriteforge/game/research.py +262 -0
  88. spriteforge/game/revision.py +225 -0
  89. spriteforge/game/sdk/API.md +104 -0
  90. spriteforge/game/sdk/FLOW.md +19 -0
  91. spriteforge/game/sdk/LEVELS.md +65 -0
  92. spriteforge/game/sdk/sf.js +418 -0
  93. spriteforge/game/sdk/sfflow.js +442 -0
  94. spriteforge/game/sdk/sflevel.js +683 -0
  95. spriteforge/game/sdk/sfsound-editor.js +225 -0
  96. spriteforge/game/sdk/sfsound.js +260 -0
  97. spriteforge/game/sdk/sounds.html +67 -0
  98. spriteforge/game/sounds.py +793 -0
  99. spriteforge/game/story.py +188 -0
  100. spriteforge/game/structure.py +98 -0
  101. spriteforge/game/team_reports.py +394 -0
  102. spriteforge/game/tilesets.py +70 -0
  103. spriteforge/game/title_art.py +186 -0
  104. spriteforge/genres/__init__.py +0 -0
  105. spriteforge/library/__init__.py +15 -0
  106. spriteforge/library/cli.py +126 -0
  107. spriteforge/library/describe.py +242 -0
  108. spriteforge/library/looks.py +154 -0
  109. spriteforge/library/models.py +79 -0
  110. spriteforge/library/store.py +422 -0
  111. spriteforge/library/tilesets/README.md +32 -0
  112. spriteforge/library/tilesets/gandalf-seasons.json +111 -0
  113. spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
  114. spriteforge/mcp/__init__.py +0 -0
  115. spriteforge/mcp/cli.py +58 -0
  116. spriteforge/mcp/client.py +57 -0
  117. spriteforge/mcp/filesystem_read.py +32 -0
  118. spriteforge/mcp/gamedb.py +56 -0
  119. spriteforge/mcp/library.py +179 -0
  120. spriteforge/mcp/memory.py +37 -0
  121. spriteforge/mcp/pixel_tools.py +145 -0
  122. spriteforge/mcp/registry.py +145 -0
  123. spriteforge/mcp/service.py +224 -0
  124. spriteforge/mcp/templates/context7.json +6 -0
  125. spriteforge/mcp/templates/fetch.json +6 -0
  126. spriteforge/mcp/templates/filesystem-read.json +6 -0
  127. spriteforge/mcp/templates/pixel-tools.json +6 -0
  128. spriteforge/memory/README.md +10 -0
  129. spriteforge/memory/__init__.py +0 -0
  130. spriteforge/memory/cli.py +131 -0
  131. spriteforge/memory/okf/__init__.py +1 -0
  132. spriteforge/memory/okf/catalog.py +331 -0
  133. spriteforge/memory/okf/codec.py +63 -0
  134. spriteforge/memory/okf/git.py +104 -0
  135. spriteforge/memory/okf/indexes.py +50 -0
  136. spriteforge/memory/okf/models.py +114 -0
  137. spriteforge/memory/okf/search.py +53 -0
  138. spriteforge/memory/okf/seed.py +81 -0
  139. spriteforge/memory/okf/seeds/genres.yaml +121 -0
  140. spriteforge/memory/okf/seeds/other.yaml +150 -0
  141. spriteforge/memory/okf/store.py +402 -0
  142. spriteforge/memory/okf/validate.py +115 -0
  143. spriteforge/pixel/__init__.py +17 -0
  144. spriteforge/pixel/atlas.py +110 -0
  145. spriteforge/pixel/grid.py +174 -0
  146. spriteforge/pixel/palette.py +156 -0
  147. spriteforge/pixel/palettes/dungeon.json +9 -0
  148. spriteforge/pixel/postprocess.py +170 -0
  149. spriteforge/pixel/preview.py +40 -0
  150. spriteforge/pixel/schemas.py +139 -0
  151. spriteforge/pixel/source.py +237 -0
  152. spriteforge/pixel/terrain.py +48 -0
  153. spriteforge/pixel/validate.py +138 -0
  154. spriteforge/prompts/anim_frame.txt +1 -0
  155. spriteforge/prompts/character.txt +1 -0
  156. spriteforge/prompts/item.txt +1 -0
  157. spriteforge/prompts/tile.txt +1 -0
  158. spriteforge/prompts/ui.txt +1 -0
  159. spriteforge/providers/__init__.py +0 -0
  160. spriteforge/providers/base.py +97 -0
  161. spriteforge/providers/http.py +38 -0
  162. spriteforge/providers/mock.py +111 -0
  163. spriteforge/providers/muse_image.py +60 -0
  164. spriteforge/providers/muse_search.py +91 -0
  165. spriteforge/providers/muse_spark.py +66 -0
  166. spriteforge/skills/__init__.py +0 -0
  167. spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
  168. spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
  169. spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
  170. spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
  171. spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
  172. spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
  173. spriteforge/skills/builtin/level-design/SKILL.md +169 -0
  174. spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
  175. spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
  176. spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
  177. spriteforge/skills/builtin/platformer/SKILL.md +153 -0
  178. spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
  179. spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
  180. spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
  181. spriteforge/skills/catalog.py +200 -0
  182. spriteforge/skills/cli.py +41 -0
  183. spriteforge/stages/__init__.py +0 -0
  184. spriteforge/stages/asset_context.py +301 -0
  185. spriteforge/stages/asset_models.py +192 -0
  186. spriteforge/stages/asset_render.py +366 -0
  187. spriteforge/stages/asset_review.py +140 -0
  188. spriteforge/stages/asset_store.py +94 -0
  189. spriteforge/stages/chat_changes.py +236 -0
  190. spriteforge/stages/concept.py +42 -0
  191. spriteforge/stages/game.py +1699 -0
  192. spriteforge/stages/game_confirm.py +112 -0
  193. spriteforge/stages/game_final.py +220 -0
  194. spriteforge/stages/game_record.py +246 -0
  195. spriteforge/stages/gameplay.py +218 -0
  196. spriteforge/stages/intake.py +372 -0
  197. spriteforge/stages/preview_assets.py +212 -0
  198. spriteforge/stages/preview_models.py +167 -0
  199. spriteforge/stages/preview_render.py +169 -0
  200. spriteforge/stages/preview_repair.py +61 -0
  201. spriteforge/stages/preview_review.py +169 -0
  202. spriteforge/stages/production.py +170 -0
  203. spriteforge/stages/references.py +105 -0
  204. spriteforge/stages/rewind.py +128 -0
  205. spriteforge/stages/workflow.py +440 -0
  206. spriteforge/templates/phaser/audit.js +45 -0
  207. spriteforge/templates/phaser/font.png +0 -0
  208. spriteforge/templates/phaser/font.xml +1 -0
  209. spriteforge/templates/phaser/preview.html +9 -0
  210. spriteforge/templates/phaser/room.js +49 -0
  211. spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
  212. spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
  213. spriteforge-0.1.1.dist-info/METADATA +364 -0
  214. spriteforge-0.1.1.dist-info/RECORD +218 -0
  215. spriteforge-0.1.1.dist-info/WHEEL +5 -0
  216. spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
  217. spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
  218. spriteforge-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,138 @@
1
+ """Serve a build locally and record its autoplay: fixed-step frames, encoded with ffmpeg."""
2
+
3
+ import base64
4
+ import json
5
+ import shutil
6
+ import subprocess
7
+ import time
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass
10
+ from functools import partial
11
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
12
+ from pathlib import Path
13
+ from tempfile import TemporaryDirectory
14
+ from threading import Thread
15
+
16
+ from playwright.sync_api import Error as PlaywrightError
17
+ from playwright.sync_api import sync_playwright
18
+
19
+ READY = "window.sf && window.sf.ready === true"
20
+
21
+
22
+ class QuietHandler(SimpleHTTPRequestHandler):
23
+ def log_message(self, *args):
24
+ pass
25
+
26
+
27
+ @contextmanager
28
+ def serve(directory: Path):
29
+ """Serve one build folder on 127.0.0.1 (random port) for the headless browser."""
30
+ server = ThreadingHTTPServer(("127.0.0.1", 0), partial(QuietHandler, directory=str(directory)))
31
+ thread = Thread(target=server.serve_forever, daemon=True)
32
+ thread.start()
33
+ try:
34
+ yield f"http://127.0.0.1:{server.server_port}"
35
+ finally:
36
+ server.shutdown()
37
+ server.server_close()
38
+ thread.join(timeout=5)
39
+
40
+
41
+ def wait_until(page, expression: str, timeout: float = 30) -> bool:
42
+ """Poll `expression` with `page.evaluate` until it is true; False after `timeout` seconds.
43
+
44
+ Not `page.wait_for_function`: when the expression is not true at its first check, it
45
+ polls by compiling the string in the page, which a build's CSP (no 'unsafe-eval') blocks
46
+ with an EvalError. Slow-loading builds then looked like they never booted (L2.2 live run:
47
+ a build with 24 entities was reported "no-boot" while it ran fine in a browser)."""
48
+ deadline = time.monotonic() + timeout
49
+ while True:
50
+ try:
51
+ if page.evaluate(expression):
52
+ return True
53
+ except PlaywrightError: # the page is still navigating: not ready yet
54
+ pass
55
+ if time.monotonic() >= deadline:
56
+ return False
57
+ page.wait_for_timeout(50)
58
+
59
+
60
+ def encode(frames_dir: Path, fps: int, scale: int, out: Path) -> Path | None:
61
+ """PNG frames -> H.264 mp4 scaled with nearest neighbour; None when ffmpeg is missing."""
62
+ ffmpeg = shutil.which("ffmpeg")
63
+ if not ffmpeg:
64
+ return None
65
+ cmd = [
66
+ ffmpeg,
67
+ "-y",
68
+ "-loglevel",
69
+ "error",
70
+ "-framerate",
71
+ str(fps),
72
+ "-i",
73
+ str(frames_dir / "%05d.png"),
74
+ "-vf",
75
+ f"scale=iw*{scale}:ih*{scale}:flags=neighbor,format=yuv420p",
76
+ "-c:v",
77
+ "libx264",
78
+ "-preset",
79
+ "veryfast",
80
+ "-crf",
81
+ "16",
82
+ "-movflags",
83
+ "+faststart",
84
+ str(out),
85
+ ]
86
+ subprocess.run(cmd, check=True)
87
+ return out
88
+
89
+
90
+ @dataclass
91
+ class Recording:
92
+ video: Path | None
93
+ poster: Path
94
+ summary: dict
95
+
96
+
97
+ def record_game(
98
+ build_dir: Path, out_dir: Path, *, seconds: float = 20, fps: int = 30, scale: int = 2
99
+ ) -> Recording:
100
+ """Play the build's own autoplay for `seconds` (?demo=1&capture=1) and save demo.mp4 and
101
+ poster.png. Frames are stepped by the SDK, so the same build always gives the same video."""
102
+ out_dir.mkdir(parents=True, exist_ok=True)
103
+ total = int(seconds * fps)
104
+ with TemporaryDirectory() as tmp, serve(Path(build_dir)) as url:
105
+ frames_dir = Path(tmp)
106
+ with sync_playwright() as pw:
107
+ browser = pw.chromium.launch(headless=True)
108
+ try:
109
+ page = browser.new_page(viewport={"width": 320, "height": 180})
110
+ errors: list[str] = []
111
+ page.on("pageerror", lambda e: errors.append(str(e)))
112
+ page.goto(f"{url}/index.html?demo=1&capture=1")
113
+ if not wait_until(page, READY):
114
+ raise RuntimeError("the game never finished booting (sf.ready stayed false)")
115
+ n = 0
116
+ while n < total:
117
+ batch = page.evaluate(f"window.sfCapture({min(fps, total - n)}, {fps})")
118
+ for data_url in batch:
119
+ png = base64.b64decode(data_url.split(",", 1)[1])
120
+ (frames_dir / f"{n:05d}.png").write_bytes(png)
121
+ n += 1
122
+ counts = page.evaluate("window.sf.counts")
123
+ errors += page.evaluate("window.sf.errors")
124
+ finally:
125
+ browser.close()
126
+ poster = out_dir / "poster.png"
127
+ shutil.copy(frames_dir / f"{min(total - 1, int(fps * 2.5)):05d}.png", poster)
128
+ video = encode(frames_dir, fps, scale, out_dir / "demo.mp4")
129
+ summary = {
130
+ "seconds": seconds,
131
+ "fps": fps,
132
+ "frames": total,
133
+ "video": video.name if video else None,
134
+ "events": {name: c["count"] for name, c in counts.items()},
135
+ "errors": errors[:20],
136
+ }
137
+ (out_dir / "recording.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
138
+ return Recording(video, poster, summary)
@@ -0,0 +1,262 @@
1
+ """Research: how good games of this kind work, studied with live web search before designing.
2
+
3
+ The Game Designer (Muse Spark with `web_search`, Responses API) names 3-6 reference games
4
+ with a page about each, the patterns that make the genre's levels interesting, the feel
5
+ numbers players expect (in tiles and seconds), and common pitfalls. References teach
6
+ patterns: the design is original and never copies a game's levels, characters, story or art.
7
+
8
+ Code checks what the model cannot be trusted with: a reference's URL counts as verified only
9
+ when one of the searches returned that page (the JSON answer may name a URL no search
10
+ found); URLs that are not http(s) are dropped; lists are bounded. Offline (mock runs, or the
11
+ human said no research at G1) a stand-in is read from the genre notes, with no references.
12
+ """
13
+
14
+ import re
15
+ from urllib.parse import urlsplit
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from spriteforge.agents.models import Contract
20
+ from spriteforge.providers.base import SearchResult, WebSource
21
+
22
+ MAX_REFERENCES, MAX_PATTERNS, MAX_FEEL, MAX_PITFALLS = 6, 8, 10, 8
23
+ PATTERN_KINDS = ("teaching a mechanic safely", "gating", "combining abilities",
24
+ "risk and reward", "pacing") # fmt: skip
25
+
26
+
27
+ class Reference(Contract):
28
+ title: str = Field(description="a published game of this genre")
29
+ url: str = Field(description="a page about it that one of your searches returned")
30
+ learn: str = Field(
31
+ description="the pattern to learn from it, concretely; never its levels, characters, "
32
+ "story or art to copy"
33
+ )
34
+
35
+
36
+ class Pattern(Contract):
37
+ name: str = Field(description="e.g. " + ", ".join(PATTERN_KINDS))
38
+ how: str = Field(description="how good games of the genre do it, concretely")
39
+ use: str = Field(description="how this game could use it")
40
+
41
+
42
+ class FeelNumber(Contract):
43
+ name: str = Field(description="e.g. jump height, air time, move speed, dash length, coyote time")
44
+ value: str = Field(description="the number players expect, in tiles and seconds")
45
+ why: str
46
+
47
+
48
+ class Research(Contract):
49
+ references: list[Reference] = Field(description="3 to 6 reference games")
50
+ patterns: list[Pattern]
51
+ feel: list[FeelNumber]
52
+ pitfalls: list[str] = Field(description="common mistakes in games of this kind")
53
+
54
+
55
+ class ResearchRecord(BaseModel):
56
+ """What the run keeps (`game/research.json`): the answer and how it was checked."""
57
+
58
+ research: Research
59
+ verified: list[bool] # per reference: a search returned its URL
60
+ sources: list[WebSource] = Field(default_factory=list) # every page the searches returned
61
+ queries: list[str] = Field(default_factory=list)
62
+ searches: int = 0
63
+ by: str = ""
64
+ notes: list[str] = Field(default_factory=list)
65
+
66
+ @property
67
+ def references(self) -> list[tuple[Reference, bool]]:
68
+ return list(zip(self.research.references, self.verified, strict=True))
69
+
70
+
71
+ def research_prompt(request: str, genre: str) -> str:
72
+ return f"""Before we design this game, study how good published games of its kind work.
73
+ Use web search. Find 3 to 6 well-regarded games of this genre and, for each, a page about it
74
+ that a search returned (url) and what to learn from it. Then:
75
+ * patterns: what makes this genre's levels interesting, concretely: {", ".join(PATTERN_KINDS)};
76
+ for each, how good games do it and how this game could use it;
77
+ * feel: the numbers players expect, in tiles (16 px) and seconds: move speed, jump height and
78
+ air time, dash length and cooldown, coyote time, or what fits this genre;
79
+ * pitfalls: common mistakes in games of this kind.
80
+ References teach patterns only. Our game is original: never copy a specific game's levels,
81
+ characters, story or art. Short, concrete sentences.
82
+
83
+ Genre: {genre}
84
+
85
+ The human's request:
86
+ {request.strip()}"""
87
+
88
+
89
+ def level_research_prompt(design_text: str, levels: str) -> str:
90
+ return f"""Before laying out this platformer's levels, study the level design of well-regarded
91
+ published platformers like it. Use web search. Find 3 to 6 games and, for each, a page about
92
+ it that a search returned (url) and the level-design lesson to learn from it. Then:
93
+ * patterns: how good levels use height and depth (upper routes, drops, more than one way),
94
+ puzzles (levers or switches and the doors they open, keys, coming back the way you came),
95
+ teaching a new idea safely, pacing and checkpoints, secrets and optional paths, and how
96
+ long a level runs; for each, how good games do it and how these levels could use it;
97
+ * feel: the level numbers players expect: screens per level, seconds per level, checkpoints
98
+ per level, how much of a level is climbing or falling;
99
+ * pitfalls: common level-design mistakes.
100
+ References teach patterns only. Our levels are original: never copy a game's levels.
101
+ Short, concrete sentences.
102
+
103
+ The game:
104
+ {design_text.strip()}
105
+
106
+ The planned levels:
107
+ {levels.strip()}"""
108
+
109
+
110
+ def fallback_level_research() -> Research:
111
+ """Offline stand-in: level-design patterns without references (nothing was searched)."""
112
+ p = lambda name, how, use: Pattern(name=name, how=how, use=use)
113
+ return Research(
114
+ references=[],
115
+ patterns=[
116
+ p("height and depth", "levels climb to upper ledges and drop into lower rooms; the "
117
+ "way on is seldom one flat line", "give each level an upper and a lower stretch"),
118
+ p("lever puzzles", "a lever off the main path opens a gate on it; the player goes "
119
+ "up or down to pull it and comes back", "where a level teaches looking around"),
120
+ p("pacing", "a hard stretch, then a calm one with a checkpoint", "a checkpoint "
121
+ "after every challenge"),
122
+ ],
123
+ feel=[FeelNumber(name="level length", value="3 to 6 screens (60 to 120 tiles)",
124
+ why="long enough to build an idea, short enough to retry")],
125
+ pitfalls=["one long flat corridor", "a gate whose lever cannot be found"],
126
+ )
127
+
128
+
129
+ def structure_prompt(text: str, sources: list[WebSource]) -> str:
130
+ """The second call when the searched answer did not fit the schema: structure it."""
131
+ pages = "\n".join(f"- {s.url}" + (f" ({s.title})" if s.title else "") for s in sources[:40])
132
+ return f"""Put these research notes into the schema. Use only these pages as reference
133
+ URLs (leave url '' when none fits); keep every number and pattern the notes give.
134
+
135
+ Pages the searches returned:
136
+ {pages or "- (none)"}
137
+
138
+ Notes:
139
+ {text.strip()[:12000]}"""
140
+
141
+
142
+ def section(body: str, heading: str) -> str:
143
+ part = body.split("# " + heading + "\n", 1)
144
+ return part[1].split("\n# ", 1)[0].strip() if len(part) == 2 else ""
145
+
146
+
147
+ def fallback_research(genre_body: str, side_view: bool) -> Research:
148
+ """Offline stand-in from the genre notes: no references (nothing was searched)."""
149
+ loop = section(genre_body, "Core mechanics template") or "the genre's core loop"
150
+ pitfalls = [re.sub(r"^[*-]\s*", "", line).strip()
151
+ for line in section(genre_body, "Common pitfalls").splitlines() if line.strip()] # fmt: skip
152
+ feel = [
153
+ FeelNumber(name="move speed", value="6 tiles per second", why="crosses a screen in 3 s"),
154
+ ]
155
+ if side_view:
156
+ feel += [
157
+ FeelNumber(name="jump height", value="3 tiles", why="clears enemies and low walls"),
158
+ FeelNumber(name="air time", value="0.7 s", why="time to steer without floating"),
159
+ FeelNumber(name="coyote time", value="0.1 s", why="forgives a late jump at a ledge"),
160
+ ]
161
+ patterns = [
162
+ Pattern(name="teaching a mechanic safely", how="show a new idea where failing costs "
163
+ "nothing, then test it with a hazard", use=f"each level introduces one idea of {loop}"),
164
+ Pattern(name="pacing", how="alternate tense stretches with calm ones and checkpoints",
165
+ use="a checkpoint after every hard challenge"),
166
+ ] # fmt: skip
167
+ return Research(references=[], patterns=patterns, feel=feel,
168
+ pitfalls=pitfalls[:MAX_PITFALLS] or ["difficulty spikes with no warning"]) # fmt: skip
169
+
170
+
171
+ def norm_url(url: str) -> str:
172
+ """Comparable form of a URL: no scheme, no www., no fragment, no trailing slash."""
173
+ try:
174
+ parts = urlsplit(url.strip())
175
+ except ValueError:
176
+ return ""
177
+ host = (parts.hostname or "").lower().removeprefix("www.")
178
+ path = parts.path.rstrip("/")
179
+ return host + path + (f"?{parts.query}" if parts.query else "")
180
+
181
+
182
+ def web_url(url: str) -> bool:
183
+ try:
184
+ parts = urlsplit(url.strip())
185
+ except ValueError:
186
+ return False
187
+ return parts.scheme in {"http", "https"} and bool(parts.hostname)
188
+
189
+
190
+ def fit_research(research: Research, found: SearchResult | None, by: str) -> ResearchRecord:
191
+ """The research as code allows it: bounded lists, unique references, http(s) URLs only,
192
+ and each reference marked verified only when a search returned its page."""
193
+ notes = []
194
+ seen, refs = set(), []
195
+ for r in research.references:
196
+ key = r.title.strip().casefold()
197
+ if not key or key in seen:
198
+ continue
199
+ seen.add(key)
200
+ if r.url and not web_url(r.url):
201
+ notes.append(f"{r.title}: {r.url!r} is not a web page; dropped")
202
+ r = r.model_copy(update={"url": ""})
203
+ refs.append(r)
204
+ if len(refs) > MAX_REFERENCES:
205
+ notes.append(f"{len(refs)} references; kept the first {MAX_REFERENCES}")
206
+ refs = refs[:MAX_REFERENCES]
207
+ pages = {norm_url(s.url) for s in (found.sources if found else [])} - {""}
208
+ verified = [bool(r.url) and norm_url(r.url) in pages for r in refs]
209
+ for r, ok in zip(refs, verified, strict=True):
210
+ if r.url and not ok:
211
+ notes.append(f"{r.title}: {r.url} was not among the pages the searches returned")
212
+ clean = lambda items, n: [re.sub(r"\s+", " ", i).strip() for i in items if i.strip()][:n]
213
+ fitted = research.model_copy(update={
214
+ "references": refs,
215
+ "patterns": research.patterns[:MAX_PATTERNS],
216
+ "feel": research.feel[:MAX_FEEL],
217
+ "pitfalls": clean(research.pitfalls, MAX_PITFALLS),
218
+ }) # fmt: skip
219
+ return ResearchRecord(
220
+ research=fitted,
221
+ verified=verified,
222
+ sources=[s for s in (found.sources if found else []) if web_url(s.url)],
223
+ queries=found.queries if found else [],
224
+ searches=found.searches if found else 0,
225
+ by=by,
226
+ notes=notes,
227
+ )
228
+
229
+
230
+ def research_brief(record: ResearchRecord) -> str:
231
+ """The research as the plan prompt reads it (no source lists)."""
232
+ r = record.research
233
+ refs = "\n".join(f"- {ref.title}: {ref.learn}" for ref in r.references)
234
+ pats = "\n".join(f"- {p.name}: {p.how} For this game: {p.use}" for p in r.patterns)
235
+ feel = "\n".join(f"- {f.name}: {f.value} ({f.why})" for f in r.feel)
236
+ return (
237
+ f"Reference games (patterns only, never copy):\n{refs or '- (none)'}\n"
238
+ f"Patterns:\n{pats or '- (none)'}\nFeel numbers players expect:\n{feel or '- (none)'}\n"
239
+ f"Pitfalls:\n" + ("\n".join(f"- {p}" for p in r.pitfalls) or "- (none)")
240
+ )
241
+
242
+
243
+ def research_markdown(record: ResearchRecord) -> str:
244
+ r = record.research
245
+ refs = "\n".join(
246
+ f"* **{ref.title}**: {ref.learn}"
247
+ + (f" [{ref.url}]({ref.url})" if ref.url else "")
248
+ + ("" if ok or not ref.url else " _(not among the pages the searches returned)_")
249
+ for ref, ok in record.references
250
+ )
251
+ pats = "\n".join(f"* **{p.name}**: {p.how} _For this game:_ {p.use}" for p in r.patterns)
252
+ feel = "\n".join(f"| {f.name} | {f.value} | {f.why} |" for f in r.feel)
253
+ sources = "\n".join(f"* {s.url}" for s in record.sources[:30])
254
+ return (
255
+ f"# Research\n\n_{record.by}; {record.searches} search call(s)._\n\n"
256
+ f"## Reference games (patterns only)\n{refs or '- (none)'}\n\n## Patterns\n{pats}\n\n"
257
+ f"## Feel numbers\n| what | value | why |\n|---|---|---|\n{feel}\n\n"
258
+ "## Pitfalls\n" + ("\n".join(f"* {p}" for p in r.pitfalls) or "- (none)")
259
+ + ("\n\n## Searched for\n" + "\n".join(f"* {q}" for q in record.queries) if record.queries else "")
260
+ + (f"\n\n## Sources the searches returned\n{sources}" if sources else "")
261
+ + "".join(f"\n> {n}" for n in record.notes) + "\n"
262
+ ) # fmt: skip
@@ -0,0 +1,225 @@
1
+ """Reviewer feedback on a playable build -> what to redo (Director, Muse Spark).
2
+
3
+ Only the parts the feedback is about are redone: the design (a new design version), the art
4
+ of named entities (Muse edits of the previous image; everything else stays locked), and the
5
+ code (the Programmer edits the working game). Untouched parts are reused as they are.
6
+ """
7
+
8
+ import re
9
+
10
+ from pydantic import Field, model_validator
11
+
12
+ from spriteforge.agents.models import Contract
13
+
14
+ from .design import GameDesign
15
+
16
+
17
+ class ArtChange(Contract):
18
+ entity: str = Field(description="entity id whose look should change")
19
+ prompt: str = Field(description="the new look for Muse Image: subject, colours, materials")
20
+
21
+
22
+ class LevelChange(Contract):
23
+ level: int = Field(description="the level number the feedback is about")
24
+ change: str = Field(description="what to change in that level, in the human's words")
25
+
26
+
27
+ class Revision(Contract):
28
+ design_changes: str = Field(
29
+ description="rule, entity, progression or difficulty changes for the design doc; ''"
30
+ )
31
+ art_changes: list[ArtChange] = Field(description="entities to redraw; [] if none")
32
+ code_instructions: str = Field(
33
+ description="behaviour, controls, feel or UI changes for the programmer; ''"
34
+ )
35
+ drop_qa_tests: list[str] = Field(
36
+ description="names of QA tests the human wants dropped ('ignore the restart test'); []"
37
+ )
38
+ story_changes: str = Field(
39
+ description="only when the human asks for a story or changes to it (named characters, "
40
+ "plot, lore): what they want; ''"
41
+ )
42
+ level_changes: list[LevelChange] = Field(
43
+ description="changes to one level's layout (gaps, platforms, enemies, crates, length, "
44
+ "difficulty of that level); [] if none"
45
+ )
46
+ menu_changes: str = Field(
47
+ description="changes to the menus or the title screen (labels, colours, the title "
48
+ "picture, screens): what they want, in their words; ''"
49
+ )
50
+ sound_changes: str = Field(
51
+ description="changes to the sound effects (a sound too loud, wrong, missing or unwanted; "
52
+ "a new sound for something): what they want, in their words; ''"
53
+ )
54
+ add_qa_checks: list[str] = Field(
55
+ description="checks the human wants the QA Playtester to add to every build, each in "
56
+ "their words ('check that the bat cannot fly through walls'); [] if none"
57
+ )
58
+
59
+ @model_validator(mode="before")
60
+ @classmethod
61
+ def older(cls, data):
62
+ """Revisions saved before QA tests, stories, levels, menus, sounds or suggested checks
63
+ existed lack those fields."""
64
+ if isinstance(data, dict):
65
+ data = {"drop_qa_tests": [], "story_changes": "", "level_changes": [],
66
+ "menu_changes": "", "sound_changes": "", "add_qa_checks": [],
67
+ **data} # fmt: skip
68
+ return data
69
+
70
+ @property
71
+ def story_only(self) -> bool:
72
+ """Only the story changes: nothing to rebuild."""
73
+ return bool(self.story_changes.strip()) and not (
74
+ self.design_changes.strip()
75
+ or self.art_changes
76
+ or self.code_instructions.strip()
77
+ or self.drop_qa_tests
78
+ or self.add_qa_checks
79
+ or self.level_changes
80
+ or self.menu_changes.strip()
81
+ or self.sound_changes.strip()
82
+ )
83
+
84
+ @property
85
+ def sound_only(self) -> bool:
86
+ """Only the sounds change: they are data, swapped in the same build (no rebuild)."""
87
+ return bool(self.sound_changes.strip()) and not (
88
+ self.design_changes.strip()
89
+ or self.art_changes
90
+ or self.code_instructions.strip()
91
+ or self.drop_qa_tests
92
+ or self.add_qa_checks
93
+ or self.level_changes
94
+ or self.menu_changes.strip()
95
+ or self.story_changes.strip()
96
+ )
97
+
98
+
99
+ def merge(a: Revision, b: Revision) -> Revision:
100
+ """Both revisions' changes: texts joined, lists concatenated (an entity or level named in
101
+ both keeps `b`'s change)."""
102
+
103
+ def text(x: str, y: str) -> str:
104
+ return "\n".join(p.strip() for p in (x, y) if p.strip())
105
+
106
+ art = {c.entity: c for c in [*a.art_changes, *b.art_changes]}
107
+ levels = {c.level: c for c in [*a.level_changes, *b.level_changes]}
108
+ return Revision(
109
+ design_changes=text(a.design_changes, b.design_changes),
110
+ art_changes=list(art.values()),
111
+ code_instructions=text(a.code_instructions, b.code_instructions),
112
+ drop_qa_tests=list(dict.fromkeys([*a.drop_qa_tests, *b.drop_qa_tests])),
113
+ story_changes=text(a.story_changes, b.story_changes),
114
+ level_changes=list(levels.values()),
115
+ menu_changes=text(a.menu_changes, b.menu_changes),
116
+ sound_changes=text(a.sound_changes, b.sound_changes),
117
+ add_qa_checks=list(dict.fromkeys([*a.add_qa_checks, *b.add_qa_checks])),
118
+ )
119
+
120
+
121
+ def revision_prompt(
122
+ design: GameDesign, art: dict[str, str], feedback: str, qa: dict[str, bool] | None = None
123
+ ) -> str:
124
+ """`qa`: the build's QA tests by name -> passed."""
125
+ sources = "\n".join(f"* {eid}: {src}" for eid, src in art.items())
126
+ tests = "\n".join(
127
+ f"* {name}: {'passed' if ok else 'FAILED'}" for name, ok in (qa or {}).items()
128
+ )
129
+ return (
130
+ "A human played this build and wrote the feedback below. Split it into what must be "
131
+ "redone: design_changes (new or changed rules, entities, upgrades, difficulty), "
132
+ "art_changes (only entities whose look they want different, with the new look), "
133
+ "code_instructions (how the game should behave or feel), drop_qa_tests (exact names "
134
+ "of QA tests they want ignored or dropped, e.g. a test they judge wrong), "
135
+ "story_changes (only when they ask for a story, named characters, a plot or lore, or "
136
+ "for changes to the story: their words), and level_changes (feedback about one level's "
137
+ "layout: its gaps, platforms, enemies, crates, length or difficulty, with the level "
138
+ "number), and menu_changes (the title screen, menus, pause or game-over screens: "
139
+ "labels, colours, the title picture; the SDK draws them from data, so they are not "
140
+ "code), and sound_changes (sound effects: a sound too loud, quiet, wrong, unwanted or "
141
+ "missing; the SDK synthesises them from data, so they are not code), and add_qa_checks "
142
+ "(a 'check that ...' the human wants tested in every build: their words). Leave a part "
143
+ "empty when the feedback does not touch it; do not invent changes.\n\n"
144
+ f"Feedback:\n{feedback.strip()}\n\nDesign:\n{design.model_dump_json()}\n\n"
145
+ f"Current art sources:\n{sources}"
146
+ + (f"\n\nQA tests of this build:\n{tests}" if tests else "")
147
+ )
148
+
149
+
150
+ DROP_VERBS = {"ignore", "drop", "remove", "skip", "delete", "disable"}
151
+ FILLER = {"the", "a", "an", "this", "that", "those", "these", "qa", "failing", "broken", "wrong",
152
+ "bad", "please", "also", "too", "it", "one", "you", "can", "we", "just", "flaky"} # fmt: skip
153
+
154
+
155
+ def dropped_tests(feedback: str, names: list[str]) -> list[str]:
156
+ """QA tests the feedback asks to drop: "ignore the restart test" drops the tests whose name
157
+ has every word said before "test" ("restarts after losing"). Only a part with a drop verb
158
+ counts, or one that carries it on with "and" ("remove the idle test and the restart test");
159
+ "the restart test is fine, drop the menu test" drops only the menu test."""
160
+ out, carried = [], False
161
+ for clause in re.split(r"[,;.!?]", feedback.lower()):
162
+ parts = re.split(r"\band\b", clause)
163
+ if not re.match(r"\s*(and|also)\b", clause):
164
+ carried = False
165
+ for part in parts:
166
+ carried = carried or any(re.search(rf"\b{v}\b", part) for v in DROP_VERBS)
167
+ match = re.search(r"([a-z0-9 -]*?)\btests?\b", part) if carried else None
168
+ if not match:
169
+ continue
170
+ said = [w for w in re.findall(r"[a-z0-9]+", match.group(1))
171
+ if w not in FILLER | DROP_VERBS] # fmt: skip
172
+ for name in names:
173
+ words = set(re.findall(r"[a-z0-9]+", name.lower()))
174
+ stems = {w.rstrip("s") for w in words} | {w[:-2] for w in words if w.endswith("ed")}
175
+ if said and all(w in words or w.rstrip("s") in stems for w in said):
176
+ out.append(name)
177
+ return list(dict.fromkeys(out))
178
+
179
+
180
+ def fallback_revision(design: GameDesign, feedback: str, qa_names: list[str] = ()) -> Revision:
181
+ """Offline Director: entities named in the feedback are redrawn with it appended to their
182
+ look; QA tests it asks to ignore are dropped; a request for a story goes to the story;
183
+ everything else goes to the programmer."""
184
+ text = feedback.lower()
185
+ if STORY_ASK.search(text):
186
+ return Revision(design_changes="", art_changes=[], code_instructions="",
187
+ drop_qa_tests=[], story_changes=feedback.strip(), level_changes=[],
188
+ menu_changes="") # fmt: skip
189
+ if SOUND_ASK.search(text):
190
+ return Revision(design_changes="", art_changes=[], code_instructions="", drop_qa_tests=[],
191
+ story_changes="", level_changes=[], menu_changes="",
192
+ sound_changes=feedback.strip()) # fmt: skip
193
+ if MENU_ASK.search(text):
194
+ return Revision(design_changes="", art_changes=[], code_instructions="", drop_qa_tests=[],
195
+ story_changes="", level_changes=[], menu_changes=feedback.strip()) # fmt: skip
196
+ if levels := sorted({int(n) for n in LEVEL_ASK.findall(text)}):
197
+ return Revision(design_changes="", art_changes=[], code_instructions="", drop_qa_tests=[],
198
+ story_changes="", level_changes=[LevelChange(level=n, change=feedback.strip())
199
+ for n in levels], menu_changes="") # fmt: skip
200
+ changes = [
201
+ ArtChange(entity=e.id, prompt=f"{e.look}; {feedback.strip()}")
202
+ for e in design.entities
203
+ if re.search(rf"\b{re.escape(e.id.replace('_', ' '))}s?\b", text.replace("_", " "))
204
+ ]
205
+ drops = dropped_tests(feedback, list(qa_names))
206
+ return Revision(
207
+ design_changes="",
208
+ art_changes=changes,
209
+ code_instructions="" if changes or drops else feedback.strip(),
210
+ drop_qa_tests=drops,
211
+ story_changes="",
212
+ level_changes=[],
213
+ menu_changes="",
214
+ )
215
+
216
+
217
+ # "add a story", "give it a plot", "the story should ...": the offline Director's story cue.
218
+ STORY_ASK = re.compile(r"\b(stor(y|ies|yline)|plot|lore|backstor(y|ies)|narrative)\b")
219
+ # "the menus should be red", "a darker title screen": the offline Director's menu cue.
220
+ MENU_ASK = re.compile(r"\b(menus?|title screen|title art|pause screen|game over screen|emblem)\b")
221
+ # "the jump sound is too loud", "no menu beeps": the offline Director's sound cue.
222
+ SOUND_ASK = re.compile(r"\b(sounds?|sfx|audio|noises?|beeps?|volume|loud(er)?|quiet(er)?|mute|"
223
+ r"splash(es)?|bleeps?)\b")
224
+ # "level 2 is too long", "make level 3 harder": the offline Director's level cue.
225
+ LEVEL_ASK = re.compile(r"\blevel\s+(\d)\b")