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,188 @@
1
+ """The story bible: who the characters are and what the story is, written from the design.
2
+
3
+ The design says what the game does; the story bible says who is in it and why, so every later
4
+ level, menu and cutscene stays consistent. The Game Designer writes it right after each design
5
+ version; it is shown at G2 with the design and revised with it. Each character is one design
6
+ entity (its art and behaviour), exactly one is the player; the arc's first beat is the current
7
+ game, the rest are levels that may follow; canon lists what no later level may break.
8
+
9
+ `fit_story` repairs an answer against the design instead of rejecting it (unknown entities
10
+ dropped, a missing player added, names and ids made unique, lists bounded).
11
+ """
12
+
13
+ import re
14
+
15
+ from pydantic import Field
16
+
17
+ from spriteforge.agents.models import Contract
18
+
19
+ from .design import GameDesign, snake
20
+
21
+ MAX_ARC, MAX_CANON, MAX_THEMES = 7, 12, 5
22
+
23
+
24
+ class Character(Contract):
25
+ entity: str = Field(description="the design entity id this character is (its art and play)")
26
+ name: str
27
+ role: str = Field(description="hero, villain, boss, minion, ally, neutral ...")
28
+ look: str = Field(description="what they look like, consistent with the entity's look")
29
+ personality: str
30
+ backstory: str = Field(description="their history in two sentences at most")
31
+ goal: str = Field(description="what they want")
32
+ abilities: str = Field(description="what they can do in play, as the design says")
33
+ relationships: str = Field(description="how they relate to the other characters; '' if none")
34
+
35
+
36
+ class Location(Contract):
37
+ id: str = Field(description="snake_case id")
38
+ name: str
39
+ description: str
40
+ mood: str
41
+
42
+
43
+ class StoryBible(Contract):
44
+ title: str
45
+ logline: str = Field(description="the story in one sentence")
46
+ premise: str = Field(description="the situation when the game starts, 2-4 sentences")
47
+ setting: str = Field(description="the world: place, era, what is normal there")
48
+ tone: str
49
+ themes: list[str]
50
+ characters: list[Character]
51
+ locations: list[Location]
52
+ arc: list[str] = Field(
53
+ description="3-7 story beats in order; the first is this game's session, the rest are "
54
+ "levels that may follow"
55
+ )
56
+ canon: list[str] = Field(description="4-10 facts no later level may break")
57
+
58
+
59
+ def story_prompt(design: GameDesign, request: str) -> str:
60
+ return f"""Write the story bible for this game: the canon every later level, menu and
61
+ cutscene will be written from, so they stay consistent with each other.
62
+ * characters: one per design entity that is a creature or person (the player, each enemy
63
+ type, bosses, allies; a prop only when it is a character). `entity` is its id in the design;
64
+ keep its look and behaviour, give it a name, personality, goal and a short backstory.
65
+ Exactly one character is the player.
66
+ * locations: the places of the story, starting with the world of this game.
67
+ * arc: 3 to 7 story beats in order. The first is this game's session as designed; the others
68
+ are levels that may follow and raise the stakes.
69
+ * canon: 4 to 10 short facts no later level may break (who is who, the rules of the world,
70
+ what never happens).
71
+ Keep the human's words; invent nothing that contradicts the design. Short, concrete sentences.
72
+
73
+ The human's request:
74
+ {request.strip()}
75
+
76
+ Design:
77
+ {design.model_dump_json(indent=1)}"""
78
+
79
+
80
+ def story_revision_prompt(story: StoryBible, design: GameDesign, feedback: str) -> str:
81
+ return f"""Revise this story bible so it fits the revised design and the feedback. Keep the
82
+ names, the canon and everything that still fits; change only what must change.
83
+
84
+ Feedback:
85
+ {feedback.strip()}
86
+
87
+ Revised design:
88
+ {design.model_dump_json(indent=1)}
89
+
90
+ Current story bible:
91
+ {story.model_dump_json(indent=1)}"""
92
+
93
+
94
+ def fallback_story(design: GameDesign) -> StoryBible:
95
+ """Offline Game Designer: a plain story read off the design."""
96
+ cast = [e for e in design.entities if e.role in {"player", "enemy"}]
97
+ title = lambda e: e.id.replace("_", " ").title()
98
+ player = next(e for e in design.entities if e.role == "player")
99
+ characters = [
100
+ Character(
101
+ entity=e.id,
102
+ name=title(e),
103
+ role="hero" if e.role == "player" else "enemy",
104
+ look=e.look,
105
+ personality="determined" if e.role == "player" else "relentless",
106
+ backstory=design.player if e.role == "player" else f"{title(e)}s roam {design.world}",
107
+ goal=design.win if e.role == "player" else f"stop {title(player)}",
108
+ abilities=e.behaviour,
109
+ relationships="" if e.role == "player" else f"hunts {title(player)}",
110
+ )
111
+ for e in cast
112
+ ]
113
+ return StoryBible(
114
+ title=design.title,
115
+ logline=design.pitch.split(".")[0].strip() + ".",
116
+ premise=design.pitch,
117
+ setting=design.world,
118
+ tone=design.look,
119
+ themes=["survival"],
120
+ characters=characters,
121
+ locations=[
122
+ Location(id="arena", name="The arena", description=design.world, mood=design.look)
123
+ ],
124
+ arc=[f"{design.title}: {design.win}", "A harder place, a stronger foe", "The final stand"],
125
+ canon=[*design.rules[:4], f"Lose: {design.lose}"],
126
+ )
127
+
128
+
129
+ def fit_story(story: StoryBible, design: GameDesign) -> tuple[StoryBible, list[str]]:
130
+ """The story repaired against the design: characters only for real entities (one per
131
+ entity), the player always present, unique names and location ids, bounded lists.
132
+ Returns (story, notes on what was repaired)."""
133
+ notes, entities = [], {e.id: e for e in design.entities}
134
+ player = next(e for e in design.entities if e.role == "player")
135
+ chars, names = [], set()
136
+ for c in story.characters:
137
+ c = c.model_copy(update={"entity": snake(c.entity), "name": c.name.strip()})
138
+ if c.entity not in entities:
139
+ notes.append(f"character {c.name!r}: no design entity {c.entity!r}; left out")
140
+ continue
141
+ if any(o.entity == c.entity for o in chars):
142
+ notes.append(f"character {c.name!r}: entity {c.entity!r} already has a character")
143
+ continue
144
+ base, n = c.name or c.entity.replace("_", " ").title(), 2
145
+ name = base
146
+ while name.casefold() in names:
147
+ name, n = f"{base} {n}", n + 1
148
+ names.add(name.casefold())
149
+ chars.append(c.model_copy(update={"name": name}))
150
+ if not any(c.entity == player.id for c in chars):
151
+ notes.append(f"no character for the player entity {player.id!r}; added one")
152
+ hero = fallback_story(design).characters[0]
153
+ chars.insert(0, hero)
154
+ locations, ids = [], set()
155
+ for loc in story.locations:
156
+ ident = snake(loc.id or loc.name)
157
+ if ident not in ids:
158
+ ids.add(ident)
159
+ locations.append(loc.model_copy(update={"id": ident}))
160
+ clean = lambda items, n: [re.sub(r"\s+", " ", i).strip() for i in items if i.strip()][:n]
161
+ fitted = story.model_copy(
162
+ update={
163
+ "characters": chars,
164
+ "locations": locations,
165
+ "arc": clean(story.arc, MAX_ARC),
166
+ "canon": clean(story.canon, MAX_CANON),
167
+ "themes": clean(story.themes, MAX_THEMES),
168
+ }
169
+ )
170
+ return fitted, notes
171
+
172
+
173
+ def story_markdown(story: StoryBible) -> str:
174
+ chars = "\n".join(
175
+ f"* **{c.name}** (`{c.entity}`, {c.role}): {c.look}. {c.personality}. Goal: {c.goal}."
176
+ for c in story.characters
177
+ )
178
+ places = "\n".join(
179
+ f"* **{loc.name}** (`{loc.id}`): {loc.description}" for loc in story.locations
180
+ )
181
+ arc = "\n".join(f"{n}. {beat}" for n, beat in enumerate(story.arc, 1))
182
+ canon = "\n".join(f"* {fact}" for fact in story.canon)
183
+ return (
184
+ f"# {story.title}: story bible\n\n_{story.logline}_\n\n{story.premise}\n\n"
185
+ f"**Setting:** {story.setting} \n**Tone:** {story.tone} \n"
186
+ f"**Themes:** {', '.join(story.themes) or '-'}\n\n## Characters\n{chars}\n\n"
187
+ f"## Locations\n{places or '- (none)'}\n\n## Arc\n{arc}\n\n## Canon\n{canon or '- (none)'}\n"
188
+ )
@@ -0,0 +1,98 @@
1
+ """The game's structure (L2.5): its levels in order, what carries over between them, what is
2
+ saved and how the game is won, derived by code from the design and the game plan (no model
3
+ call). G2 shows it beside the design; feedback that changes the plan's levels changes it.
4
+
5
+ A game whose levels are data (a side-view game with a plan, L2.3) has the plan's levels; any
6
+ other game is one level: its whole session. The SDK's flow (`sfflow.js`) plays the structure:
7
+ title, level intro, results between levels, game over, victory, progress saved in the browser.
8
+ """
9
+
10
+ import re
11
+
12
+ from pydantic import Field, model_validator
13
+
14
+ from spriteforge.agents.models import Contract
15
+
16
+ from .design import GameDesign
17
+ from .plan import PlanRecord
18
+
19
+ MAX_LEVELS = 8
20
+ SCORE_WORDS = re.compile(r"\b(score|points?|gems?|coins?|kills?|stars?)\b", re.IGNORECASE)
21
+
22
+
23
+ class PlannedLevel(Contract):
24
+ n: int
25
+ name: str = Field(min_length=1, max_length=40)
26
+ goal: str = Field(description="what the level is about, in one line")
27
+
28
+
29
+ class GameStructure(Contract):
30
+ levels: list[PlannedLevel]
31
+ carries: list[str] = Field(description="what carries over from one level to the next")
32
+ saved: list[str] = Field(description="what is saved between sessions (in the browser)")
33
+ win: str = Field(description="how the game is won")
34
+ lose: str = Field(description="how a level is lost ('' when it cannot be)")
35
+
36
+ @model_validator(mode="after")
37
+ def check(self):
38
+ if not 1 <= len(self.levels) <= MAX_LEVELS:
39
+ raise ValueError(f"a game has 1 to {MAX_LEVELS} levels, not {len(self.levels)}")
40
+ names = [lv.name.casefold() for lv in self.levels]
41
+ if len(set(names)) != len(names):
42
+ raise ValueError("level names must be unique")
43
+ if [lv.n for lv in self.levels] != list(range(1, len(self.levels) + 1)):
44
+ raise ValueError("levels are numbered 1, 2, 3 ... in order")
45
+ return self
46
+
47
+
48
+ def has_score(design: GameDesign) -> bool:
49
+ text = " ".join([*design.hud, *(c.event for c in design.checks)])
50
+ return bool(SCORE_WORDS.search(text.replace("_", " ")))
51
+
52
+
53
+ def unique_names(names: list[str]) -> list[str]:
54
+ out, seen = [], set()
55
+ for name in names:
56
+ base, n = (name.strip() or "Level")[:36], 2
57
+ name = base
58
+ while name.casefold() in seen:
59
+ name, n = f"{base} {n}", n + 1
60
+ seen.add(name.casefold())
61
+ out.append(name)
62
+ return out
63
+
64
+
65
+ def make_structure(design: GameDesign, plan: PlanRecord | None, levels: bool) -> GameStructure:
66
+ """`levels`: the game plays its plan's levels as data (`pipeline.uses_levels`)."""
67
+ planned = plan.plan.levels[:MAX_LEVELS] if (levels and plan) else []
68
+ if planned:
69
+ names = unique_names([lv.name for lv in planned])
70
+ rows = [PlannedLevel(n=i, name=name, goal=(lv.teaching_goal or lv.new_idea)[:200])
71
+ for i, (lv, name) in enumerate(zip(planned, names, strict=True), 1)] # fmt: skip
72
+ else:
73
+ rows = [PlannedLevel(n=1, name=design.title[:40] or "Level 1", goal=design.win[:200])]
74
+ carries = []
75
+ if len(rows) > 1:
76
+ held = [a.name for a in plan.plan.abilities if a.unlocked_by.strip().lower() == "start"] if plan else []
77
+ carries.append("the abilities held from the start" + (f" ({', '.join(held)})" if held else ""))
78
+ if has_score(design):
79
+ carries.append("the score")
80
+ saved = ["the settings (difficulty, fewer flashes)", "the best result of each level"]
81
+ if len(rows) > 1:
82
+ saved.insert(0, "the levels unlocked so far (Continue and Level select)")
83
+ win = (f"Finish level {len(rows)} ({rows[-1].name})" if len(rows) > 1 else design.win) or "Win"
84
+ return GameStructure(levels=rows, carries=carries, saved=saved, win=win, lose=design.lose)
85
+
86
+
87
+ def structure_markdown(s: GameStructure) -> str:
88
+ rows = "\n".join(f"| {lv.n} | {lv.name} | {lv.goal} |" for lv in s.levels)
89
+ bullets = lambda items: "\n".join(f"- {i}" for i in items) or "- (nothing)"
90
+ return (
91
+ "# Game structure\n\nThe SDK plays it: a title, the levels in order (an intro before each, "
92
+ "results after each), pause on Escape, game over and victory screens. Progress is saved "
93
+ "in the browser.\n\n"
94
+ f"## Levels\n| n | name | goal |\n|---|---|---|\n{rows}\n\n"
95
+ f"## Carries over between levels\n{bullets(s.carries)}\n\n"
96
+ f"## Saved\n{bullets(s.saved)}\n\n"
97
+ f"**Win:** {s.win} \n**Lose:** {s.lose or '(cannot be lost: the player is sent back to a checkpoint)'}\n"
98
+ )