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,331 @@
1
+ """Mirror asset packs, tile sets and actors into the OKF corpus under `catalog/`.
2
+
3
+ Agents navigate knowledge through memory_index / memory_search / memory_show, so the art
4
+ inventory must live there too, not only in assets/library/index.json. Everything here is
5
+ derived from files on disk (pack.json, library/tilesets/*.json); re-running the sync rewrites
6
+ only entries whose text changed and deprecates entries whose source disappeared (e.g. the kit
7
+ and skin entries from before games were written from the prompt).
8
+ """
9
+
10
+ from spriteforge.game.actors import ANIM_SOURCES, load_actors
11
+ from spriteforge.game.art import actor_sheet, reference_assets, whole_objects
12
+ from spriteforge.game.tilesets import load_tilesets
13
+ from spriteforge.library.store import library_root, load_packs, search, tile_descriptions
14
+
15
+ from .models import Entry, Metadata
16
+ from .store import KnowledgeStore
17
+
18
+ SOURCE = {"resource": "spriteforge catalog sync", "author": "spriteforge/catalog"}
19
+ USAGE_TEXT = {
20
+ "ship": "may be used in builds as-is",
21
+ "ship-with-credit": "may be used in builds with a credits line",
22
+ "reference-only": "reference only: never put into a build",
23
+ }
24
+ CAMERA = {"side": "side-view", "topdown": "top-down"}
25
+
26
+
27
+ def entry(ident: str, type_: str, title: str, description: str, tags, body: str, source: str):
28
+ return Entry(
29
+ id=ident,
30
+ metadata=Metadata(
31
+ type=type_,
32
+ title=title,
33
+ description=description,
34
+ tags=sorted({t.lower() for t in tags if t}),
35
+ sources=[{**SOURCE, "id": source}],
36
+ ),
37
+ body=body.strip() + "\n",
38
+ )
39
+
40
+
41
+ def guide_entry() -> Entry:
42
+ body = """
43
+ # How a game is made
44
+ Muse Spark writes every game from the human's request; there are no pre-built game templates.
45
+ 1. The Game Designer writes a design doc: rules, every entity the player sees, progression,
46
+ difficulty over time, win/lose, and *checks* (events the code must emit) - G2 gate.
47
+ 2. The Art Director picks art per entity: the local library first (actors, whole objects,
48
+ tile-set tiles, parallax backdrops), Muse Image for anything the library lacks.
49
+ 3. The Programmer writes the Phaser 3 code against the SpriteForge SDK; the harness checks it,
50
+ playtests the whole session with the game's own bot and sends failures back - G4 gate.
51
+
52
+ # What the library offers
53
+ Start at `catalog/library` (everything at a glance), then `catalog/packs/<pack>` (every asset
54
+ by category), `catalog/actors/<actor>` (animations, camera) and `catalog/tilesets/<id>`.
55
+
56
+ # License rules (enforced in code, repeated here for planning)
57
+ * Only packs whose usage is `ship` or `ship-with-credit` may appear in a build.
58
+ * `ai_allowed: false` packs must never be sent to Muse as references or edit inputs; their art
59
+ may still be used in a build as-is.
60
+ * `redistributable: false` packs stay on this machine (git-ignored); builds may include them.
61
+ * Side-view art faces right in a game; packs marked `faces: left` are mirrored.
62
+ * Library first: if the library has the same kind of creature or object for the game's camera,
63
+ it is used; otherwise Muse Image draws it from a concrete prompt with an AI-allowed style
64
+ reference. Greybox appears only when the per-build image cap is reached or Muse fails.
65
+ """
66
+ return entry(
67
+ "catalog/guide",
68
+ "Catalog Guide",
69
+ "How games are made and how agents use the art library",
70
+ "Design -> art (library first, else Muse) -> code; license rules.",
71
+ ["catalog", "guide", "license", "asset", "actor", "tileset", "muse"],
72
+ body,
73
+ "guide",
74
+ )
75
+
76
+
77
+ def tileset_entry(ts: dict) -> Entry:
78
+ tiles = "".join(f"| `{ts['id']}/{n}` | {t['what']} |\n" for n, t in ts["tiles"].items())
79
+ variants = "".join(
80
+ f"* `{name}`: for worlds described with {', '.join(v['words']) or '(default)'}\n"
81
+ for name, v in ts["variants"].items()
82
+ )
83
+ body = f"""
84
+ # Summary
85
+ {ts["summary"]}
86
+
87
+ Camera: **{ts["camera"]}**, {ts["tile_size"]} px tiles. Pack: [{ts["pack"]}](../packs/{ts["pack"]}.md).
88
+ Fits worlds described with: {", ".join(ts["keywords"])}.
89
+ {"Has a parallax backdrop (ArtPlan source=backdrop, asset=" + ts["id"] + ")." if ts.get("backdrop") else ""}
90
+
91
+ # Variants
92
+ {variants}
93
+ # Tiles (ArtPlan source=tile)
94
+ | Asset | What |
95
+ |---|---|
96
+ {tiles}
97
+ """
98
+ return entry(
99
+ f"catalog/tilesets/{ts['id']}",
100
+ "Tile Set",
101
+ ts["id"].replace("-", " ").title(),
102
+ f"{ts['camera']} {ts['tile_size']} px tiles from {ts['pack']}: {ts['summary']}",
103
+ ["tileset", "tiles", ts["camera"], *ts["keywords"], *ts["variants"], *ts["tiles"]],
104
+ body,
105
+ f"src/spriteforge/library/tilesets/{ts['id']}.json",
106
+ )
107
+
108
+
109
+ def pack_entry(pack) -> Entry:
110
+ counts: dict[str, int] = {}
111
+ for a in pack.assets:
112
+ counts[a.category] = counts.get(a.category, 0) + 1
113
+ tags = sorted({t for a in pack.assets for t in a.tags})
114
+ grid = sorted({f"{a.cell[0]}x{a.cell[1]}" for a in pack.assets if a.frames > 1})
115
+ body = f"""
116
+ # Summary
117
+ {pack.title} by {pack.author}. {len(pack.assets)} assets:
118
+ {", ".join(f"{n} {c}" for c, n in sorted(counts.items()))}.
119
+
120
+ # License and handling
121
+ * License: **{pack.license}** ({"verified" if pack.license_verified else "NOT verified"}).
122
+ * Usage: `{pack.usage}`: {USAGE_TEXT[pack.usage]}.
123
+ * AI use: {"allowed" if pack.ai_allowed else "**forbidden**: never send to Muse"}.
124
+ * Redistributable: {"yes" if pack.redistributable else "no (git-ignored, local only)"}.
125
+ * Side-view art faces: {pack.faces}.
126
+ * Source: {pack.source_url or "not recorded"}.
127
+
128
+ # Notes
129
+ {pack.notes}
130
+
131
+ # Grids
132
+ Animation/tile cells: {", ".join(grid) or "single images"}. Files live under
133
+ `assets/library/{pack.id}/`.
134
+
135
+ # Contents
136
+ {pack_contents(pack)}
137
+ """
138
+ return entry(
139
+ f"catalog/packs/{pack.id}",
140
+ "Asset Pack",
141
+ pack.title,
142
+ f"{pack.license} ({pack.usage}{'' if pack.ai_allowed else ', no AI'}): "
143
+ + ", ".join(f"{n} {c}" for c, n in sorted(counts.items())),
144
+ ["asset-pack", pack.id, pack.license, pack.usage, *tags[:20]],
145
+ body,
146
+ f"assets/library/{pack.id}/pack.json",
147
+ )
148
+
149
+
150
+ def _asset_line(a, notes: dict | None = None) -> str:
151
+ grid = f"{a.cell[0]}x{a.cell[1]}" + (f" x{a.frames}" if a.frames > 1 else "")
152
+ note = (notes or {}).get(a.id, {})
153
+ shows = f": {note['what']}" if note.get("described_by", "measured") != "measured" else ""
154
+ return f"`{a.id}` {a.name} ({grid}){shows}"
155
+
156
+
157
+ def described(note: dict | None) -> str:
158
+ """' — <what the image shows>; looks <measured>' from `spriteforge library describe`."""
159
+ if not note or not note.get("described_by"):
160
+ return ""
161
+ parts = [note["description"].rstrip(".")] if note["described_by"] != "measured" else []
162
+ if note.get("look"):
163
+ parts.append(f"looks {note['look']}")
164
+ return f" — {'; '.join(parts)}" if parts else ""
165
+
166
+
167
+ def pack_contents(pack) -> str:
168
+ """Every asset by category, so an agent can see that a pack has, say, bonfires or icons."""
169
+ notes = {a["id"]: a for a in search()}
170
+ lines = []
171
+ for cat in sorted({a.category for a in pack.assets}):
172
+ items = [a for a in pack.assets if a.category == cat]
173
+ shown = items if len(items) <= 40 else items[:40]
174
+ more = f" ... and {len(items) - 40} more" if len(items) > 40 else ""
175
+ lines.append(
176
+ f"* **{cat}** ({len(items)}): " + "; ".join(_asset_line(a, notes) for a in shown) + more
177
+ )
178
+ return "\n".join(lines)
179
+
180
+
181
+ def library_entry() -> Entry:
182
+ """One page an agent reads to know the whole library: packs, actors, objects, tile sets."""
183
+ packs = {p.id: p for p in load_packs()} if library_root().exists() else {}
184
+ rows = []
185
+ for p in packs.values():
186
+ counts: dict[str, int] = {}
187
+ for a in p.assets:
188
+ counts[a.category] = counts.get(a.category, 0) + 1
189
+ views = sorted({t for a in p.assets for t in a.tags if t in {"top-down", "side-view"}})
190
+ rows.append(
191
+ f"| `{p.id}` | {', '.join(views) or 'any'} | "
192
+ + ", ".join(f"{n} {c}" for c, n in sorted(counts.items()))
193
+ + f" | {p.usage} | {'yes' if p.ai_allowed else '**no**'} |"
194
+ )
195
+ actors = load_actors() if packs else []
196
+ notes = {a["id"]: a for a in search()} if packs else {}
197
+ actor_lines = "\n".join(
198
+ f"* `{a.key}` ({CAMERA[a.side()]} {'hero' if a.category == 'character' else 'enemy'}): "
199
+ + ", ".join(sorted(a.anims))
200
+ + described(notes.get(actor_sheet(a)))
201
+ for a in actors
202
+ )
203
+ objects = whole_objects() if packs else {}
204
+ object_lines = "\n".join(
205
+ f"* `{i}` {o['category']}: {o['name']} ({o['cell'][0]}x{o['cell'][1]}"
206
+ + (f", {o['frames']} frames" if o["frames"] > 1 else "")
207
+ + ("" if o.get("ai_allowed", True) else ", no AI")
208
+ + ")"
209
+ + described(notes.get(i))
210
+ for i, o in sorted(objects.items())
211
+ )
212
+ tilesets = load_tilesets() if packs else []
213
+ tile_notes = tile_descriptions() if packs else {}
214
+ tile_lines = "\n".join(
215
+ f"* `{ts['id']}` ({ts['camera']}, {ts['tile_size']} px): "
216
+ + ", ".join(ts["tiles"])
217
+ + "".join(
218
+ f"\n * `{ts['id']}/{n}` {t['what']}" + described(tile_notes.get(f"{ts['id']}/{n}"))
219
+ for n, t in ts["tiles"].items()
220
+ )
221
+ for ts in tilesets
222
+ )
223
+ refs = sorted({a["pack"] for a in reference_assets().values()}) if packs else []
224
+ body = f"""
225
+ # Packs
226
+ | Pack | Camera | Contents | Usage | AI use |
227
+ | --- | --- | --- | --- | --- |
228
+ {chr(10).join(rows) or "| (no packs installed) | | | | |"}
229
+
230
+ Details per pack (every asset by category): `catalog/packs/<pack>`.
231
+
232
+ # Actors (animated characters and enemies; ArtPlan source=actor)
233
+ {actor_lines or "none installed"}
234
+
235
+ # Tile sets (ArtPlan source=tile / backdrop)
236
+ {tile_lines or "none installed"}
237
+
238
+ # Whole objects (pickups, props, projectiles; ArtPlan source=object)
239
+ {object_lines or "none installed"}
240
+
241
+ # Muse style references
242
+ Packs Muse may see as a style example: {", ".join(f"`{r}`" for r in refs) or "none"}.
243
+ Anything the library lacks is drawn by Muse Image (source=muse with a concrete prompt).
244
+ """
245
+ return entry(
246
+ "catalog/library",
247
+ "Asset Library",
248
+ "What the local pixel-art library contains",
249
+ f"{len(packs)} packs, {len(actors)} actors, {len(tilesets)} tile sets, "
250
+ f"{len(objects)} whole objects",
251
+ ["catalog", "library", "asset", "pack", "actor", "tileset", "muse", "reference"],
252
+ body,
253
+ "assets/library/index.json",
254
+ )
255
+
256
+
257
+ def actor_slug(key: str) -> str:
258
+ return key.replace("/", "-").replace(" ", "-")
259
+
260
+
261
+ def actor_entry(actor, packs: dict) -> Entry:
262
+ pack = packs.get(actor.pack)
263
+ camera = CAMERA[actor.side()]
264
+ anims = ", ".join(f"{a} x{len(f)}" for a, f in sorted(actor.anims.items()))
265
+ usable = sorted(k for k, sources in ANIM_SOURCES.items() if set(sources) & set(actor.anims))
266
+ flying = "fly" in actor.anims or "eye" in actor.tokens
267
+ role = "playable character" if actor.category == "character" else "enemy"
268
+ body = f"""
269
+ # Summary
270
+ {actor.name.title()} from [{actor.pack}](../packs/{actor.pack}.md); {camera} art,
271
+ {role}{", flies" if flying else ""}. Use it in an ArtPlan as source=actor, asset=`{actor.key}`
272
+ for a {camera} game.
273
+
274
+ # Animations
275
+ {anims}. Can stand in for animations: {", ".join(usable)}.
276
+
277
+ # Handling
278
+ Usage `{pack.usage if pack else "unknown"}`; AI use {"allowed" if pack and pack.ai_allowed else "forbidden"};
279
+ faces {actor.faces}. Matched from requests by the words: {", ".join(sorted(actor.tokens))}.
280
+ """
281
+ return entry(
282
+ f"catalog/actors/{actor_slug(actor.key)}",
283
+ "Actor",
284
+ f"{actor.name.title()} ({actor.pack})",
285
+ f"{camera} {'hero' if actor.category == 'character' else 'enemy'}"
286
+ f"{' (flying)' if flying else ''}: {anims}",
287
+ ["actor", camera, actor.category, actor.pack, *actor.tokens],
288
+ body,
289
+ f"assets/library/{actor.pack}",
290
+ )
291
+
292
+
293
+ def catalog_entries() -> list[Entry]:
294
+ packs = {p.id: p for p in load_packs()} if library_root().exists() else {}
295
+ entries = [guide_entry(), library_entry()]
296
+ entries += [tileset_entry(ts) for ts in load_tilesets()]
297
+ entries += [pack_entry(p) for p in packs.values()]
298
+ if packs:
299
+ entries += [actor_entry(a, packs) for a in load_actors()]
300
+ return entries
301
+
302
+
303
+ def sync_catalog(store: KnowledgeStore) -> dict[str, int]:
304
+ """Write changed catalog entries; deprecate ones whose source is gone. One git commit."""
305
+ present, _ = store.scan()
306
+ current = {e.id: e for e in present if e.id.startswith("catalog/")}
307
+ wanted = {e.id: e for e in catalog_entries()}
308
+ changed = [
309
+ e
310
+ for e in wanted.values()
311
+ if e.id not in current
312
+ or current[e.id].body != e.body
313
+ or current[e.id].metadata.description != e.metadata.description
314
+ or current[e.id].metadata.status != "stable"
315
+ ]
316
+ gone = []
317
+ for ident, old in current.items():
318
+ if ident not in wanted and old.metadata.status != "deprecated":
319
+ meta = old.metadata.model_copy(update={"status": "deprecated"})
320
+ gone.append(Entry(id=ident, metadata=meta, body=old.body))
321
+ if changed or gone:
322
+ store.write_batch(
323
+ changed + gone,
324
+ f"Catalog sync: {len(changed)} updated, {len(gone)} deprecated "
325
+ f"({len(wanted)} packs/tile sets/actors)",
326
+ actor="spriteforge/catalog",
327
+ )
328
+ return {"total": len(wanted), "updated": len(changed), "deprecated": len(gone)}
329
+
330
+
331
+ __all__ = ["catalog_entries", "sync_catalog"]
@@ -0,0 +1,63 @@
1
+ import re
2
+ from pathlib import PurePosixPath
3
+
4
+ import yaml
5
+
6
+ from spriteforge.core.security import redact
7
+
8
+ from .models import Entry, Metadata
9
+
10
+
11
+ class UnsafeKnowledge(ValueError):
12
+ pass
13
+
14
+
15
+ def ensure_no_secrets(text: str) -> None:
16
+ assignment = r"(?i)(?:api[_-]?key|access[_-]?token|password|secret)\s*[:=]\s*[\"']?[^\s\"'`]+"
17
+ if redact(text) != text or re.search(assignment, text) or "-----BEGIN PRIVATE KEY" in text:
18
+ raise UnsafeKnowledge("Credential-like content rejected; no knowledge was persisted")
19
+
20
+
21
+ def check_id(ident: str) -> str:
22
+ path = PurePosixPath(ident)
23
+ if (
24
+ not ident
25
+ or "\\" in ident
26
+ or ":" in ident
27
+ or path.is_absolute()
28
+ or any(p in {".", ".."} or p.startswith(".") for p in path.parts)
29
+ or str(path) != ident
30
+ or path.name in {"index", "log"}
31
+ or ident.endswith(".md")
32
+ ):
33
+ raise ValueError("Concept ID must be a safe relative path without .md or reserved names")
34
+ ensure_no_secrets(ident)
35
+ return ident
36
+
37
+
38
+ def parse(text: str, ident: str) -> Entry:
39
+ check_id(ident)
40
+ ensure_no_secrets(text)
41
+ text = text.lstrip("\ufeff")
42
+ match = re.match(r"\A---\r?\n(.*?)\r?\n---(?:\r?\n|\Z)", text, re.DOTALL)
43
+ if match is None:
44
+ raise ValueError("Missing YAML frontmatter")
45
+ data = yaml.safe_load(match[1])
46
+ if not isinstance(data, dict):
47
+ raise ValueError("Frontmatter must be a mapping") # noqa: TRY004 -- serialized YAML format error
48
+ return Entry(id=ident, metadata=Metadata.model_validate(data), body=text[match.end() :])
49
+
50
+
51
+ def serialize(entry: Entry) -> str:
52
+ check_id(entry.id)
53
+ metadata = entry.metadata.model_dump(mode="json", by_alias=True, exclude_unset=True)
54
+ if entry.private:
55
+ metadata["x-spriteforge-scope"] = "private"
56
+ result = (
57
+ "---\n"
58
+ + yaml.safe_dump(metadata, sort_keys=False, allow_unicode=True)
59
+ + "---\n"
60
+ + entry.body
61
+ )
62
+ ensure_no_secrets(result)
63
+ return result
@@ -0,0 +1,104 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import tempfile
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+
8
+ from spriteforge.core.security import redact
9
+
10
+
11
+ def git(root: Path, *args: str, index: Path | None = None) -> str:
12
+ command = [
13
+ "git",
14
+ "-c",
15
+ f"safe.directory={root.as_posix()}",
16
+ "-c",
17
+ "user.name=SpriteForge",
18
+ "-c",
19
+ "user.email=spriteforge@localhost",
20
+ "-c",
21
+ "core.hooksPath=/dev/null",
22
+ "-C",
23
+ str(root),
24
+ *args,
25
+ ]
26
+ result = subprocess.run(
27
+ command,
28
+ env={**os.environ, **({"GIT_INDEX_FILE": str(index)} if index else {})},
29
+ check=False,
30
+ capture_output=True,
31
+ text=True,
32
+ encoding="utf-8",
33
+ errors="replace",
34
+ )
35
+ if result.returncode:
36
+ raise RuntimeError(redact(result.stderr.strip()))
37
+ return result.stdout.strip()
38
+
39
+
40
+ def init(root: Path) -> None:
41
+ root.mkdir(parents=True, exist_ok=True)
42
+ if (root / ".git").exists():
43
+ return
44
+ # Two runs opening a new corpus at once must not both `git init` it (the second fails).
45
+ with write_lock(root.parent / f".{root.name}.init.lock"):
46
+ if not (root / ".git").exists():
47
+ git(root, "init", ".")
48
+
49
+
50
+ @contextmanager
51
+ def write_lock(path: Path):
52
+ with path.open("a+b") as handle:
53
+ if os.name == "nt":
54
+ import msvcrt
55
+
56
+ handle.seek(0)
57
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
58
+ else:
59
+ import fcntl
60
+
61
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
62
+ try:
63
+ yield
64
+ finally:
65
+ if os.name == "nt":
66
+ handle.seek(0)
67
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
68
+ else:
69
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
70
+
71
+
72
+ def commit(root: Path, paths: list[str], message: str) -> None:
73
+ real_index = root / git(root, "rev-parse", "--git-path", "index")
74
+ with tempfile.TemporaryDirectory(dir=real_index.parent) as directory:
75
+ index = Path(directory) / "index"
76
+ if real_index.exists():
77
+ shutil.copyfile(real_index, index)
78
+ git(root, "add", "--", *paths, index=index)
79
+ if git(root, "diff", "--cached", "--name-only", index=index):
80
+ git(root, "commit", "-m", message, "--only", "--", *paths, index=index)
81
+ os.replace(index, real_index)
82
+
83
+
84
+ @contextmanager
85
+ def rollback_git(root: Path):
86
+ """Restore the exact index and ref if any part of a write batch fails."""
87
+ index = root / git(root, "rev-parse", "--git-path", "index")
88
+ previous_index = index.read_bytes() if index.exists() else None
89
+ try:
90
+ head = git(root, "rev-parse", "--verify", "HEAD")
91
+ except RuntimeError:
92
+ head = None
93
+ try:
94
+ yield
95
+ except BaseException:
96
+ if head is None:
97
+ git(root, "update-ref", "-d", "HEAD")
98
+ else:
99
+ git(root, "update-ref", "HEAD", head)
100
+ if previous_index is None:
101
+ index.unlink(missing_ok=True)
102
+ else:
103
+ index.write_bytes(previous_index)
104
+ raise
@@ -0,0 +1,50 @@
1
+ from pathlib import PurePosixPath
2
+
3
+ from .models import Entry
4
+
5
+
6
+ def indexes(
7
+ entries: list[Entry],
8
+ directories: set[str] | None = None,
9
+ *,
10
+ absolute: bool = False,
11
+ title: str = "SpriteForge knowledge",
12
+ ) -> dict[str, str]:
13
+ """Every directory's index.md. `absolute`: links are bundle-relative from the root
14
+ (`/entities/hero.md`), as OKF recommends; otherwise relative to the index."""
15
+ dirs = {"", *(directories or set())}
16
+ for entry in entries:
17
+ parent = PurePosixPath(entry.id).parent
18
+ dirs.update(str(p) if str(p) != "." else "" for p in [parent, *parent.parents])
19
+ files = {}
20
+ for directory in sorted(dirs):
21
+ prefix = directory + "/" if directory else ""
22
+ lines = ['---\nokf_version: "0.2"\n---\n'] if not directory else []
23
+ lines.append(f"# {directory or title}\n")
24
+ for child in sorted(
25
+ d
26
+ for d in dirs
27
+ if d
28
+ and ("" if str(PurePosixPath(d).parent) == "." else str(PurePosixPath(d).parent))
29
+ == directory
30
+ ):
31
+ name = child.rsplit("/", 1)[-1]
32
+ target = f"/{child}/index.md" if absolute else f"{name}/index.md"
33
+ lines.append(f"* [{name}]({target}) - Browse {name} summaries.\n")
34
+ for entry in sorted(entries, key=lambda e: e.id):
35
+ if str(PurePosixPath(entry.id).parent) not in {
36
+ directory,
37
+ "." if not directory else directory,
38
+ }:
39
+ continue
40
+ # Public indexes never expose private concept titles or descriptions.
41
+ if entry.private and not directory.startswith("user-prefs"):
42
+ continue
43
+ title = entry.title.replace("\n", " ").replace("[", "").replace("]", "")
44
+ summary = (entry.metadata.description or "Open this concept for details.").replace(
45
+ "\n", " "
46
+ )
47
+ target = f"/{entry.id}.md" if absolute else f"{entry.id.rsplit('/', 1)[-1]}.md"
48
+ lines.append(f"* [{title}]({target}) - {summary}\n")
49
+ files[prefix + "index.md"] = "".join(lines)
50
+ return files
@@ -0,0 +1,114 @@
1
+ from datetime import UTC, datetime
2
+ from typing import Any, Literal
3
+
4
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator
5
+
6
+
7
+ class Extensible(BaseModel):
8
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
9
+
10
+
11
+ class Generated(Extensible):
12
+ by: str = Field(min_length=1)
13
+ at: AwareDatetime | None = None
14
+
15
+
16
+ class Verified(Extensible):
17
+ by: str = Field(min_length=1)
18
+ at: AwareDatetime
19
+
20
+
21
+ class Source(Extensible):
22
+ resource: str = Field(min_length=1)
23
+ id: str | None = None
24
+ title: str | None = None
25
+ author: str | None = None
26
+ usage_count: int | None = Field(default=None, ge=0)
27
+ last_modified: AwareDatetime | None = None
28
+
29
+
30
+ class Metadata(Extensible):
31
+ model_config = ConfigDict(
32
+ extra="allow", populate_by_name=False, validate_by_name=False, validate_by_alias=True
33
+ )
34
+ type: str = Field(min_length=1)
35
+ title: str | None = None
36
+ description: str | None = None
37
+ resource: str | None = None
38
+ tags: list[str] = Field(default_factory=list)
39
+ sources: list[Source] = Field(default_factory=list)
40
+ generated: Generated | None = None
41
+ verified: list[Verified] = Field(default_factory=list)
42
+ status: Literal["draft", "stable", "deprecated"] = "stable"
43
+ stale_after: AwareDatetime | None = None
44
+ scope: Literal["project", "private"] = Field(default="project", alias="x-spriteforge-scope")
45
+
46
+ @field_validator("type")
47
+ @classmethod
48
+ def nonblank(cls, value):
49
+ if not value.strip():
50
+ raise ValueError("type must not be blank")
51
+ return value
52
+
53
+ @field_validator("verified", mode="before")
54
+ @classmethod
55
+ def verifier_mapping(cls, value):
56
+ return [value] if isinstance(value, dict) else value
57
+
58
+ @property
59
+ def trust_tier(self) -> str:
60
+ if any(v.by.startswith("human:") for v in self.verified):
61
+ return "human-reviewed"
62
+ return "machine-confirmed" if self.verified else "unverified"
63
+
64
+ @property
65
+ def stale(self) -> bool:
66
+ return self.stale_after is not None and datetime.now(UTC) >= self.stale_after
67
+
68
+
69
+ class Entry(BaseModel):
70
+ id: str
71
+ metadata: Metadata
72
+ body: str
73
+
74
+ @property
75
+ def private(self) -> bool:
76
+ return self.id.split("/")[0] == "user-prefs" or self.metadata.scope == "private"
77
+
78
+ @property
79
+ def title(self) -> str:
80
+ return self.metadata.title or self.id.rsplit("/", 1)[-1].replace("-", " ")
81
+
82
+
83
+ class Snippet(BaseModel):
84
+ id: str
85
+ title: str
86
+ trust_tier: str
87
+ stale: bool
88
+ status: str
89
+ scope: str
90
+ score: float
91
+ bootstrap_unreviewed: bool = False
92
+ text: str
93
+ tokens: int = 0
94
+
95
+
96
+ class ValidationReport(BaseModel):
97
+ errors: list[str] = Field(default_factory=list)
98
+ warnings: list[str] = Field(default_factory=list)
99
+ external: str = "not installed"
100
+
101
+
102
+ class GenreSeed(BaseModel):
103
+ id: str
104
+ title: str
105
+ perspective: str
106
+ controls: str
107
+ mechanics: str
108
+ entities: list[str]
109
+ palette: str
110
+ assets: dict[str, int]
111
+ animations: str
112
+ pitfalls: list[str]
113
+ questions: list[str] = Field(min_length=5, max_length=8)
114
+ extra: dict[str, Any] = Field(default_factory=dict)