#!/usr/bin/env python3 """One-shot reorganization: move audio assets from theme-specific dir to shared `public/resources/audio/`, and normalize the SFX layout into a consistent flat-categorical structure. Idempotent enough to re-run if interrupted, but designed for a single pass. Old layout: public/games/age-of-dwarves/assets/audio/sfx/turn_started.ogg (flat) public/games/age-of-dwarves/assets/audio/sfx/buildings/... (nested) public/games/age-of-dwarves/assets/audio/sfx/units/melee_spawn.ogg (flat-but-named) public/games/age-of-dwarves/assets/audio/sfx/units/melee/... (nested) public/games/age-of-dwarves/assets/audio/music/... New layout: public/resources/audio/sfx//.ogg public/resources/audio/music/.ogg public/resources/audio/sources.csv public/resources/audio/LICENSES.md Categories: ui, city, combat, era, buildings, units/, fauna, weather, generic. """ import json, csv, io, shutil, sys, re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent OLD_ROOT = REPO_ROOT / "public/games/age-of-dwarves/assets/audio" NEW_ROOT = REPO_ROOT / "public/resources/audio" MANIFEST = REPO_ROOT / "public/games/age-of-dwarves/data/audio.json" # Map of old asset-relative path -> new asset-relative path. The asset-relative # part is what shows up as `audio/...` in the manifest streams + sources.csv. RENAMES: dict[str, str] = { # ── flat sfx top-level → categorical subfolders ───────────────────── "audio/sfx/turn_started.ogg": "audio/sfx/ui/turn_started.ogg", "audio/sfx/turn_ended.ogg": "audio/sfx/ui/turn_ended.ogg", "audio/sfx/unit_moved.ogg": "audio/sfx/ui/unit_moved.ogg", "audio/sfx/unit_promoted.ogg": "audio/sfx/ui/unit_promoted.ogg", "audio/sfx/border_expanded.ogg": "audio/sfx/ui/border_expanded.ogg", "audio/sfx/research_start.ogg": "audio/sfx/ui/research_start.ogg", "audio/sfx/tech_researched.ogg": "audio/sfx/ui/tech_researched.ogg", "audio/sfx/culture_researched.ogg": "audio/sfx/ui/culture_researched.ogg", "audio/sfx/city_founded.ogg": "audio/sfx/city/city_founded.ogg", "audio/sfx/combat_hit.ogg": "audio/sfx/combat/combat_hit.ogg", "audio/sfx/unit_killed.ogg": "audio/sfx/combat/unit_killed.ogg", "audio/sfx/unit_defeated.ogg": "audio/sfx/combat/unit_defeated.ogg", "audio/sfx/unit_victorious.ogg": "audio/sfx/combat/unit_victorious.ogg", "audio/sfx/era_advanced.ogg": "audio/sfx/era/era_advanced.ogg", "audio/sfx/golden_age_swell.ogg": "audio/sfx/era/golden_age_swell.ogg", "audio/sfx/victory_fanfare.ogg": "audio/sfx/era/victory_fanfare.ogg", "audio/sfx/defeat_stinger.ogg": "audio/sfx/era/defeat_stinger.ogg", "audio/sfx/wonder_built.ogg": "audio/sfx/buildings/wonder_built.ogg", "audio/sfx/wonder_built_own.ogg": "audio/sfx/buildings/wonder_built_own.ogg", "audio/sfx/wonder_built_rival.ogg": "audio/sfx/buildings/wonder_built_rival.ogg", # ── normalize unit subcategory paths (flat-but-named → nested) ────── "audio/sfx/units/melee_spawn.ogg": "audio/sfx/units/melee/spawn.ogg", "audio/sfx/units/ranged_spawn.ogg": "audio/sfx/units/ranged/spawn.ogg", "audio/sfx/units/siege_hit.ogg": "audio/sfx/units/siege/hit.ogg", "audio/sfx/units/siege_death.ogg": "audio/sfx/units/siege/death.ogg", "audio/sfx/units/support_attack.ogg":"audio/sfx/units/support/attack.ogg", "audio/sfx/units/support_hit.ogg": "audio/sfx/units/support/hit.ogg", "audio/sfx/units/support_death.ogg": "audio/sfx/units/support/death.ogg", } def step_move_files() -> tuple[int, int]: """Move every audio file from OLD_ROOT to NEW_ROOT, applying RENAMES.""" moved = 0 skipped = 0 for src in OLD_ROOT.rglob("*"): if not src.is_file(): continue rel = src.relative_to(OLD_ROOT) # The manifest path is "audio/..." — equivalent to the relative path # under OLD_ROOT prefixed with "audio/". So a file at # OLD_ROOT/sfx/turn_started.ogg matches "audio/sfx/turn_started.ogg". manifest_key = f"audio/{rel.as_posix()}" new_manifest_key = RENAMES.get(manifest_key, manifest_key) # Special-case: LICENSES.md and sources.csv at OLD_ROOT root keep # their basename, no "audio/" prefix. if rel.parent == Path("."): new_path = NEW_ROOT / rel.name else: assert new_manifest_key.startswith("audio/") new_path = NEW_ROOT / new_manifest_key[len("audio/"):] new_path.parent.mkdir(parents=True, exist_ok=True) if new_path.exists(): skipped += 1 continue shutil.move(str(src), str(new_path)) moved += 1 return moved, skipped def step_update_manifest() -> int: """Rewrite audio.json stream paths.""" with open(MANIFEST) as f: data = json.load(f) rewrites = 0 for key, entry in data.get("sfx", {}).items(): if "stream" in entry and entry["stream"] in RENAMES: entry["stream"] = RENAMES[entry["stream"]] rewrites += 1 if "streams" in entry: new_list = [] for s in entry["streams"]: if s in RENAMES: new_list.append(RENAMES[s]) rewrites += 1 else: new_list.append(s) entry["streams"] = new_list # Music tracks didn't move (they were never in the flat-confusion zone), # but defensive. for track in data.get("music", {}).get("tracks", []): if "stream" in track and track["stream"] in RENAMES: track["stream"] = RENAMES[track["stream"]] rewrites += 1 with open(MANIFEST, "w") as f: json.dump(data, f, indent=2) f.write("\n") return rewrites def step_update_sources_csv() -> int: """Rewrite output_path column in sources.csv.""" csv_path = NEW_ROOT / "sources.csv" if not csv_path.exists(): return 0 head_lines: list[str] = [] body_lines: list[str] = [] with open(csv_path) as f: for ln in f: if ln.startswith("#"): head_lines.append(ln) else: body_lines.append(ln) reader = csv.reader(io.StringIO("".join(body_lines))) rows = list(reader) hdr, body = rows[0], rows[1:] op_idx = hdr.index("output_path") rewrites = 0 for r in body: if r[op_idx] in RENAMES: r[op_idx] = RENAMES[r[op_idx]] rewrites += 1 with open(csv_path, "w") as f: f.writelines(head_lines) w = csv.writer(f) w.writerow(hdr) w.writerows(body) return rewrites def step_remove_old_root() -> None: """Delete OLD_ROOT if empty after the move.""" if not OLD_ROOT.exists(): return leftover = list(OLD_ROOT.rglob("*")) leftover_files = [p for p in leftover if p.is_file()] if leftover_files: print(f" WARN: {len(leftover_files)} files remain at {OLD_ROOT}") for p in leftover_files[:5]: print(f" {p}") return shutil.rmtree(OLD_ROOT) # Walk up and prune empty parent dirs (assets/) but never above public/games/age-of-dwarves parent = OLD_ROOT.parent # .../assets if parent.is_dir() and not any(parent.iterdir()): parent.rmdir() def main() -> None: NEW_ROOT.mkdir(parents=True, exist_ok=True) moved, skipped = step_move_files() print(f"moved {moved} files, skipped {skipped} (already at destination)") rewrites = step_update_manifest() print(f"manifest rewrites: {rewrites}") csv_rewrites = step_update_sources_csv() print(f"sources.csv rewrites: {csv_rewrites}") step_remove_old_root() print("done.") if __name__ == "__main__": main()