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,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)
|