Twelve scripts retired, three domains registered. `reach` now covers nine. generate: `generate-brands` and `generate-corporations` were the second and third copies of the same 24-line build-if-missing-then-exec bash `tooling/atlas` carried, so they collapsed into `core.process.cargo_binary` rather than being ported. `import_economics` shelled out to the first of those, so it now calls that helper — `generated_brands.toml` comes back byte-identical, and the stamp registry swaps the retired wrapper for `core/process.py`. pr: `watchlist-diff` derives its watched set from `generator_sources.py` instead of restating it, so it cannot drift from the stamp check. dev: the environment scripts split decision from performing, per D-263's guarded-exec rule. `godot_plan()` and `worktree_plan()` decide what would happen; `install_godot()`, `install_rust()` and `setup_worktree()` do it. `tooling/test_environment.py` pins the version pin, both override precedences, the already-current skip, the platform refusal and both worktree refusals — none of them performed. `make setup` now installs reach first, since the targets that install rust and godot are reach verbs. Two live bugs found while porting: - The clerk read its decision index from `decisions/README.md`, a path that stopped existing when the DQR tree moved to `governance/`. Every clerk agent has been grepping blind; its prompt pointed at the same dead directory. - The conformance exec-check matched any `x.system()` regardless of receiver, so `platform.system()` read as `os.system()`. Narrowed and re-proved against a real mutant. `process.run` gains `input=`, `timeout=` and a `ProcessTimeout` subclass so a killed run stays distinguishable from a verdict. The pre-push hook no longer merges the clerk's stderr into its stdout — under streaming the last merged line is a JSONL event, which would read as an unrecognised verdict and block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
3.8 KiB
Python
Executable File
119 lines
3.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Generate client/assets/characters/manifest.json from the asset directories.
|
|
|
|
Run this whenever artists add new assets so the manifest stays in sync:
|
|
reach generate character-manifest
|
|
|
|
The manifest is the single source of truth for CharacterCreation asset IDs.
|
|
DirAccess.open() cannot enumerate res:// paths in exported PCK builds (#720).
|
|
|
|
Output: client/assets/characters/manifest.json
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
|
|
from tooling.core import config, console
|
|
|
|
# config.repo_root(), not __file__-relative: this file moved two directories
|
|
# deeper, and a relative root would resolve to nothing and report an empty
|
|
# manifest as success (the failure that bit validate-checklist in T-1282).
|
|
ASSETS_ROOT = str(config.path("client", "assets", "characters"))
|
|
MANIFEST_PATH = os.path.join(ASSETS_ROOT, "manifest.json")
|
|
|
|
|
|
def scan_glb_ids(dir_path: str) -> list[str]:
|
|
"""Return sorted list of .glb basenames (without extension) in dir_path."""
|
|
if not os.path.isdir(dir_path):
|
|
return []
|
|
ids = sorted(
|
|
os.path.splitext(f)[0]
|
|
for f in os.listdir(dir_path)
|
|
if f.endswith(".glb") and not f.startswith(".")
|
|
)
|
|
return ids
|
|
|
|
|
|
def scan_subdirs(dir_path: str) -> list[str]:
|
|
"""Return sorted list of subdirectory names in dir_path."""
|
|
if not os.path.isdir(dir_path):
|
|
return []
|
|
return sorted(
|
|
d for d in os.listdir(dir_path)
|
|
if os.path.isdir(os.path.join(dir_path, d)) and not d.startswith(".")
|
|
)
|
|
|
|
|
|
def infer_clothing_slot(item_id: str) -> str:
|
|
"""Infer clothing slot from item_id by convention."""
|
|
id_lower = item_id.lower()
|
|
if any(k in id_lower for k in ("boot", "shoe", "sandal", "slipper")):
|
|
return "feet"
|
|
if any(k in id_lower for k in ("pant", "trouser", "skirt", "short")):
|
|
return "legs"
|
|
if any(k in id_lower for k in ("glove", "gauntlet")):
|
|
return "hands"
|
|
# Default: torso (jacket, tunic, shirt, coveralls, vest, etc.)
|
|
return "torso"
|
|
|
|
|
|
def build_manifest() -> dict:
|
|
# Body types: subdirs under bodies/
|
|
body_types = scan_subdirs(os.path.join(ASSETS_ROOT, "bodies"))
|
|
|
|
# Heads: .glb files in heads/templates/
|
|
heads = scan_glb_ids(os.path.join(ASSETS_ROOT, "heads", "templates"))
|
|
|
|
# Hair: .glb files in hair/
|
|
hair = scan_glb_ids(os.path.join(ASSETS_ROOT, "hair"))
|
|
|
|
# Facial hair: .glb files in facial_hair/
|
|
facial_hair = scan_glb_ids(os.path.join(ASSETS_ROOT, "facial_hair"))
|
|
|
|
# Eyebrows: .glb files in eyebrows/
|
|
eyebrows = scan_glb_ids(os.path.join(ASSETS_ROOT, "eyebrows"))
|
|
|
|
# Clothing: subdirs under clothing/, each assigned a slot
|
|
clothing_items = scan_subdirs(os.path.join(ASSETS_ROOT, "clothing"))
|
|
clothing: dict = {}
|
|
for item_id in clothing_items:
|
|
clothing[item_id] = {"slot": infer_clothing_slot(item_id)}
|
|
|
|
# Accessories: no directory yet — leave empty
|
|
accessories: list = []
|
|
acc_dir = os.path.join(ASSETS_ROOT, "accessories")
|
|
if os.path.isdir(acc_dir):
|
|
accessories = scan_glb_ids(acc_dir)
|
|
|
|
return {
|
|
"body_types": body_types,
|
|
"heads": heads,
|
|
"hair": hair,
|
|
"facial_hair": facial_hair,
|
|
"eyebrows": eyebrows,
|
|
"clothing": clothing,
|
|
"accessories": accessories,
|
|
}
|
|
|
|
|
|
def run() -> None:
|
|
"""Rebuild the manifest from the asset directories and report the counts."""
|
|
manifest = build_manifest()
|
|
output = json.dumps(manifest, indent=2) + "\n"
|
|
with open(MANIFEST_PATH, "w", encoding="utf-8") as f:
|
|
f.write(output)
|
|
console.event(f"Written: {MANIFEST_PATH}")
|
|
for key in (
|
|
"body_types",
|
|
"heads",
|
|
"hair",
|
|
"facial_hair",
|
|
"eyebrows",
|
|
"clothing",
|
|
"accessories",
|
|
):
|
|
console.event(f" {key:<12}: {len(manifest[key])}")
|
|
console.verdict(
|
|
f"generate-character-manifest: OK — {sum(len(v) for v in manifest.values())} entries"
|
|
)
|