spriteforge 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spriteforge/__init__.py +1 -0
- spriteforge/__main__.py +3 -0
- spriteforge/agents/__init__.py +0 -0
- spriteforge/agents/assets.py +109 -0
- spriteforge/agents/cancellation.py +12 -0
- spriteforge/agents/director.py +50 -0
- spriteforge/agents/models.py +63 -0
- spriteforge/agents/roles/art-director.md +15 -0
- spriteforge/agents/roles/art-reviewer.md +14 -0
- spriteforge/agents/roles/director.md +14 -0
- spriteforge/agents/roles/game-designer.md +15 -0
- spriteforge/agents/roles/level-tile-designer.md +16 -0
- spriteforge/agents/roles/librarian.md +13 -0
- spriteforge/agents/roles/menu-designer.md +15 -0
- spriteforge/agents/roles/pixel-artist.md +14 -0
- spriteforge/agents/roles/programmer.md +14 -0
- spriteforge/agents/roles/qa-playtester.md +13 -0
- spriteforge/agents/roles/sound-director.md +14 -0
- spriteforge/agents/roles.py +39 -0
- spriteforge/agents/router.py +233 -0
- spriteforge/agents/router_eval.py +145 -0
- spriteforge/agents/routing_state.py +133 -0
- spriteforge/agents/runtime.py +430 -0
- spriteforge/agents/workflow.py +389 -0
- spriteforge/app/__init__.py +0 -0
- spriteforge/app/agents_view.py +248 -0
- spriteforge/app/chat.py +114 -0
- spriteforge/app/extensions.py +115 -0
- spriteforge/app/index.html +279 -0
- spriteforge/app/local_only.py +49 -0
- spriteforge/app/server.py +358 -0
- spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
- spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
- spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
- spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
- spriteforge/app/static/index.html +13 -0
- spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
- spriteforge/app/studio.py +278 -0
- spriteforge/app/workers.py +73 -0
- spriteforge/cli.py +241 -0
- spriteforge/config.py +193 -0
- spriteforge/config.yaml +157 -0
- spriteforge/core/__init__.py +0 -0
- spriteforge/core/acting.py +16 -0
- spriteforge/core/cost.py +236 -0
- spriteforge/core/credentials.py +24 -0
- spriteforge/core/demo.py +79 -0
- spriteforge/core/forms.py +20 -0
- spriteforge/core/gates.py +175 -0
- spriteforge/core/security.py +42 -0
- spriteforge/core/state.py +222 -0
- spriteforge/core/team.py +15 -0
- spriteforge/core/usage.py +68 -0
- spriteforge/game/__init__.py +0 -0
- spriteforge/game/actors.py +377 -0
- spriteforge/game/agent_tools.py +178 -0
- spriteforge/game/art.py +2097 -0
- spriteforge/game/art_checks.py +190 -0
- spriteforge/game/art_review.py +518 -0
- spriteforge/game/build.py +134 -0
- spriteforge/game/bundle.py +1178 -0
- spriteforge/game/cli.py +119 -0
- spriteforge/game/codegen.py +433 -0
- spriteforge/game/codeinfo.py +154 -0
- spriteforge/game/dbsource.py +137 -0
- spriteforge/game/design.py +342 -0
- spriteforge/game/export.py +62 -0
- spriteforge/game/gamedb.py +191 -0
- spriteforge/game/image_cache.py +61 -0
- spriteforge/game/lessons.py +259 -0
- spriteforge/game/level_gen.py +118 -0
- spriteforge/game/level_preview.py +97 -0
- spriteforge/game/levels.py +934 -0
- spriteforge/game/loops.py +38 -0
- spriteforge/game/menus.py +712 -0
- spriteforge/game/mock_game.js +109 -0
- spriteforge/game/mock_platformer.js +58 -0
- spriteforge/game/motion.py +881 -0
- spriteforge/game/pipeline.py +1768 -0
- spriteforge/game/plan.py +502 -0
- spriteforge/game/playtest.py +318 -0
- spriteforge/game/qa.py +664 -0
- spriteforge/game/qa_checks.py +223 -0
- spriteforge/game/qa_runtime.py +565 -0
- spriteforge/game/reach.py +807 -0
- spriteforge/game/record.py +138 -0
- spriteforge/game/research.py +262 -0
- spriteforge/game/revision.py +225 -0
- spriteforge/game/sdk/API.md +104 -0
- spriteforge/game/sdk/FLOW.md +19 -0
- spriteforge/game/sdk/LEVELS.md +65 -0
- spriteforge/game/sdk/sf.js +418 -0
- spriteforge/game/sdk/sfflow.js +442 -0
- spriteforge/game/sdk/sflevel.js +683 -0
- spriteforge/game/sdk/sfsound-editor.js +225 -0
- spriteforge/game/sdk/sfsound.js +260 -0
- spriteforge/game/sdk/sounds.html +67 -0
- spriteforge/game/sounds.py +793 -0
- spriteforge/game/story.py +188 -0
- spriteforge/game/structure.py +98 -0
- spriteforge/game/team_reports.py +394 -0
- spriteforge/game/tilesets.py +70 -0
- spriteforge/game/title_art.py +186 -0
- spriteforge/genres/__init__.py +0 -0
- spriteforge/library/__init__.py +15 -0
- spriteforge/library/cli.py +126 -0
- spriteforge/library/describe.py +242 -0
- spriteforge/library/looks.py +154 -0
- spriteforge/library/models.py +79 -0
- spriteforge/library/store.py +422 -0
- spriteforge/library/tilesets/README.md +32 -0
- spriteforge/library/tilesets/gandalf-seasons.json +111 -0
- spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
- spriteforge/mcp/__init__.py +0 -0
- spriteforge/mcp/cli.py +58 -0
- spriteforge/mcp/client.py +57 -0
- spriteforge/mcp/filesystem_read.py +32 -0
- spriteforge/mcp/gamedb.py +56 -0
- spriteforge/mcp/library.py +179 -0
- spriteforge/mcp/memory.py +37 -0
- spriteforge/mcp/pixel_tools.py +145 -0
- spriteforge/mcp/registry.py +145 -0
- spriteforge/mcp/service.py +224 -0
- spriteforge/mcp/templates/context7.json +6 -0
- spriteforge/mcp/templates/fetch.json +6 -0
- spriteforge/mcp/templates/filesystem-read.json +6 -0
- spriteforge/mcp/templates/pixel-tools.json +6 -0
- spriteforge/memory/README.md +10 -0
- spriteforge/memory/__init__.py +0 -0
- spriteforge/memory/cli.py +131 -0
- spriteforge/memory/okf/__init__.py +1 -0
- spriteforge/memory/okf/catalog.py +331 -0
- spriteforge/memory/okf/codec.py +63 -0
- spriteforge/memory/okf/git.py +104 -0
- spriteforge/memory/okf/indexes.py +50 -0
- spriteforge/memory/okf/models.py +114 -0
- spriteforge/memory/okf/search.py +53 -0
- spriteforge/memory/okf/seed.py +81 -0
- spriteforge/memory/okf/seeds/genres.yaml +121 -0
- spriteforge/memory/okf/seeds/other.yaml +150 -0
- spriteforge/memory/okf/store.py +402 -0
- spriteforge/memory/okf/validate.py +115 -0
- spriteforge/pixel/__init__.py +17 -0
- spriteforge/pixel/atlas.py +110 -0
- spriteforge/pixel/grid.py +174 -0
- spriteforge/pixel/palette.py +156 -0
- spriteforge/pixel/palettes/dungeon.json +9 -0
- spriteforge/pixel/postprocess.py +170 -0
- spriteforge/pixel/preview.py +40 -0
- spriteforge/pixel/schemas.py +139 -0
- spriteforge/pixel/source.py +237 -0
- spriteforge/pixel/terrain.py +48 -0
- spriteforge/pixel/validate.py +138 -0
- spriteforge/prompts/anim_frame.txt +1 -0
- spriteforge/prompts/character.txt +1 -0
- spriteforge/prompts/item.txt +1 -0
- spriteforge/prompts/tile.txt +1 -0
- spriteforge/prompts/ui.txt +1 -0
- spriteforge/providers/__init__.py +0 -0
- spriteforge/providers/base.py +97 -0
- spriteforge/providers/http.py +38 -0
- spriteforge/providers/mock.py +111 -0
- spriteforge/providers/muse_image.py +60 -0
- spriteforge/providers/muse_search.py +91 -0
- spriteforge/providers/muse_spark.py +66 -0
- spriteforge/skills/__init__.py +0 -0
- spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
- spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
- spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
- spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
- spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
- spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
- spriteforge/skills/builtin/level-design/SKILL.md +169 -0
- spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
- spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
- spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
- spriteforge/skills/builtin/platformer/SKILL.md +153 -0
- spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
- spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
- spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
- spriteforge/skills/catalog.py +200 -0
- spriteforge/skills/cli.py +41 -0
- spriteforge/stages/__init__.py +0 -0
- spriteforge/stages/asset_context.py +301 -0
- spriteforge/stages/asset_models.py +192 -0
- spriteforge/stages/asset_render.py +366 -0
- spriteforge/stages/asset_review.py +140 -0
- spriteforge/stages/asset_store.py +94 -0
- spriteforge/stages/chat_changes.py +236 -0
- spriteforge/stages/concept.py +42 -0
- spriteforge/stages/game.py +1699 -0
- spriteforge/stages/game_confirm.py +112 -0
- spriteforge/stages/game_final.py +220 -0
- spriteforge/stages/game_record.py +246 -0
- spriteforge/stages/gameplay.py +218 -0
- spriteforge/stages/intake.py +372 -0
- spriteforge/stages/preview_assets.py +212 -0
- spriteforge/stages/preview_models.py +167 -0
- spriteforge/stages/preview_render.py +169 -0
- spriteforge/stages/preview_repair.py +61 -0
- spriteforge/stages/preview_review.py +169 -0
- spriteforge/stages/production.py +170 -0
- spriteforge/stages/references.py +105 -0
- spriteforge/stages/rewind.py +128 -0
- spriteforge/stages/workflow.py +440 -0
- spriteforge/templates/phaser/audit.js +45 -0
- spriteforge/templates/phaser/font.png +0 -0
- spriteforge/templates/phaser/font.xml +1 -0
- spriteforge/templates/phaser/preview.html +9 -0
- spriteforge/templates/phaser/room.js +49 -0
- spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
- spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
- spriteforge-0.1.1.dist-info/METADATA +364 -0
- spriteforge-0.1.1.dist-info/RECORD +218 -0
- spriteforge-0.1.1.dist-info/WHEEL +5 -0
- spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
- spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
- spriteforge-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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")
|