diff --git a/.project/designs/app/src/pages/AudioSystem.tsx b/.project/designs/app/src/pages/AudioSystem.tsx index 7c6d133b..ff2cf53b 100644 --- a/.project/designs/app/src/pages/AudioSystem.tsx +++ b/.project/designs/app/src/pages/AudioSystem.tsx @@ -2,7 +2,13 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Link } from "react-router-dom"; import styled from "styled-components"; -import audioManifest from "@game-data/audio.json"; +// Subscription-pattern audio: library lives in shared resources, the game's +// data dir holds a manifest (subscription gate + per-game overrides) and a +// pools file (per-game routing). We merge them here into the legacy +// "single manifest" shape this page consumes. +import audioLibrary from "@resources/audio/library.json"; +import audioSubscription from "@game-data/audio/manifest.json"; +import audioPoolsData from "@game-data/audio/pools.json"; import audioOptionsData from "@game-data/audio-options.json"; import { play as playSynth, hasExplicitRecipe } from "../audioSynth"; import { t } from "../theme"; diff --git a/public/games/age-of-dwarves/data/audio/manifest.json b/public/games/age-of-dwarves/data/audio/manifest.json new file mode 100644 index 00000000..5cfe4be9 --- /dev/null +++ b/public/games/age-of-dwarves/data/audio/manifest.json @@ -0,0 +1,5 @@ +{ + "source": "resources/audio", + "includes": true, + "overrides": {} +} diff --git a/public/games/age-of-dwarves/data/audio/pools.json b/public/games/age-of-dwarves/data/audio/pools.json new file mode 100644 index 00000000..4bc01ce3 --- /dev/null +++ b/public/games/age-of-dwarves/data/audio/pools.json @@ -0,0 +1,44 @@ +{ + "default_track_id": "overworld_awakening", + "crossfade_seconds": 2.0, + "victory_pool": { + "domination": [ + "victory_domination_a", + "victory_domination_b", + "victory_domination_c" + ], + "culture": [ + "victory_culture_a", + "victory_culture_b", + "victory_culture_c" + ], + "science": [ + "victory_science_a", + "victory_science_b" + ], + "economic": [ + "victory_economic_a", + "victory_economic_b" + ], + "score": [ + "victory" + ] + }, + "defeat_pool": { + "domination": [ + "defeat_domination" + ], + "culture": [ + "defeat_culture" + ], + "science": [ + "defeat_science" + ], + "economic": [ + "defeat_economic" + ], + "score": [ + "defeat" + ] + } +} diff --git a/public/games/age-of-dwarves/data/audio.json b/public/resources/audio/library.json similarity index 96% rename from public/games/age-of-dwarves/data/audio.json rename to public/resources/audio/library.json index 36ca7e41..9a293bad 100644 --- a/public/games/age-of-dwarves/data/audio.json +++ b/public/resources/audio/library.json @@ -206,13 +206,13 @@ "stream": "audio/sfx/units/siege/spawn.ogg", "volume_db": -8.0, "bus": "SFX", - "description": "Heavy hit-jingle — siege engine deployed." + "description": "Heavy hit-jingle \u2014 siege engine deployed." }, "unit.support.spawn": { "stream": "audio/sfx/units/support/spawn.ogg", "volume_db": -8.0, "bus": "SFX", - "description": "Light pizzicato — support unit takes the field." + "description": "Light pizzicato \u2014 support unit takes the field." }, "unit.siege.attack": { "streams": [ @@ -708,38 +708,6 @@ "mood": "lament", "description": "Defeated by economic \u2014 Junkala Calm 'Sand Castles', transient ambition." } - ], - "crossfade_seconds": 2.0, - "default_track_id": "overworld_awakening", - "victory_pool": { - "domination": [ - "victory_domination_a", - "victory_domination_b", - "victory_domination_c" - ], - "culture": [ - "victory_culture_a", - "victory_culture_b", - "victory_culture_c" - ], - "science": [ - "victory_science_a", - "victory_science_b" - ], - "economic": [ - "victory_economic_a", - "victory_economic_b" - ], - "score": [ - "victory" - ] - }, - "defeat_pool": { - "domination": ["defeat_domination"], - "culture": ["defeat_culture"], - "science": ["defeat_science"], - "economic": ["defeat_economic"], - "score": ["defeat"] - } + ] } } diff --git a/src/game/engine/src/autoloads/audio_manager.gd b/src/game/engine/src/autoloads/audio_manager.gd index c2345777..dc4cda5a 100644 --- a/src/game/engine/src/autoloads/audio_manager.gd +++ b/src/game/engine/src/autoloads/audio_manager.gd @@ -12,7 +12,14 @@ extends Node ## session to avoid log spam. const SFX_POOL_SIZE: int = 6 -const AUDIO_DATA_PATH_FMT: String = "res://public/games/%s/data/audio.json" +## Cross-theme audio library: every SFX entry + every music track lives +## here, alongside the .ogg files. Per-game subscription happens via the +## theme's manifest.json (see AUDIO_THEME_DIR_FMT). +const AUDIO_LIBRARY_PATH: String = "res://public/resources/audio/library.json" +## Per-theme audio directory holds three small files: +## manifest.json — { source: "resources/audio", includes: true|[...], overrides: {...} } +## pools.json — { default_track_id, crossfade_seconds, victory_pool, defeat_pool } +const AUDIO_THEME_DIR_FMT: String = "res://public/games/%s/data/audio" const SILENT_DB: float = -60.0 const UNIT_MOVED_THROTTLE_MSEC: int = 100 @@ -58,49 +65,117 @@ func _ready() -> void: func load_theme(theme_id: String) -> void: - ## Called by scenes that need audio after DataLoader.load_theme(). Idempotent. + ## Idempotent. Called by scenes that need audio after DataLoader.load_theme(). + ## Subscription-pattern load (mirrors DataLoader's resources/* layout): + ## 1. read public/resources/audio/library.json — shared catalogue + ## 2. read /data/audio/manifest.json — subscription gate + ## 3. apply overrides from the manifest — per-game tweaks + ## 4. read /data/audio/pools.json — per-game routing if _loaded and theme_id == _theme_id: return _theme_id = theme_id - var path: String = AUDIO_DATA_PATH_FMT % theme_id - if not FileAccess.file_exists(path): - push_warning("AudioManager: audio.json not found at %s" % path) + _sfx_events = {} + _music_tracks.clear() + _victory_pool = {} + _defeat_pool = {} + _music_default_id = "" + _crossfade_seconds = 2.0 + + var library: Dictionary = _read_json_dict( + AUDIO_LIBRARY_PATH, "audio library" + ) + if library.is_empty(): _loaded = true return + + var theme_dir: String = AUDIO_THEME_DIR_FMT % theme_id + var manifest: Dictionary = _read_json_dict( + "%s/manifest.json" % theme_dir, "audio manifest" + ) + var pools: Dictionary = _read_json_dict( + "%s/pools.json" % theme_dir, "audio pools" + ) + + _apply_subscription(library, manifest) + _apply_pools(pools) + _loaded = true + + +## Filter the library by `manifest.includes` and merge `manifest.overrides` +## per-key. `includes: true` means subscribe to every entry; an array means +## a whitelist; missing means no subscriptions (a degenerate but valid +## state — the theme has no audio). +func _apply_subscription(library: Dictionary, manifest: Dictionary) -> void: + var lib_sfx: Dictionary = library.get("sfx", {}) as Dictionary + var lib_music: Dictionary = library.get("music", {}) as Dictionary + var includes_all: bool = bool(manifest.get("includes", true)) if manifest.get("includes", true) is bool else false + var includes_list: Array = manifest.get("includes", []) as Array if manifest.get("includes", true) is Array else [] + var overrides: Dictionary = manifest.get("overrides", {}) as Dictionary + + # SFX: filter + merge. + for key: String in lib_sfx.keys(): + if not _includes_key(includes_all, includes_list, key): + continue + var entry: Dictionary = (lib_sfx[key] as Dictionary).duplicate() + if overrides.has(key) and overrides[key] is Dictionary: + var ov: Dictionary = overrides[key] as Dictionary + for k: String in ov.keys(): + entry[k] = ov[k] + _sfx_events[key] = entry + + # Music tracks: array-of-objects keyed by id, same filter rule. + for track_variant: Variant in (lib_music.get("tracks", []) as Array): + if not (track_variant is Dictionary): + continue + var track: Dictionary = track_variant as Dictionary + var id: String = String(track.get("id", "")) + if id.is_empty() or not _includes_key(includes_all, includes_list, id): + continue + var resolved: Dictionary = track.duplicate() + if overrides.has(id) and overrides[id] is Dictionary: + var ov: Dictionary = overrides[id] as Dictionary + for k: String in ov.keys(): + resolved[k] = ov[k] + _music_tracks[id] = resolved + + +func _includes_key(includes_all: bool, includes_list: Array, key: String) -> bool: + if includes_all: + return true + return includes_list.has(key) + + +func _apply_pools(pools: Dictionary) -> void: + _crossfade_seconds = float(pools.get("crossfade_seconds", 2.0)) + _music_default_id = String(pools.get("default_track_id", "")) + _victory_pool = (pools.get("victory_pool", {}) as Dictionary).duplicate() + _defeat_pool = (pools.get("defeat_pool", {}) as Dictionary).duplicate() + + +## Read a JSON file expected to contain a single object. Returns {} on any +## failure (missing file, bad JSON, non-object root) and warns once per +## failure mode. Caller decides whether {} is fatal. +func _read_json_dict(path: String, what: String) -> Dictionary: + if not FileAccess.file_exists(path): + push_warning("AudioManager: %s not found at %s" % [what, path]) + return {} var file: FileAccess = FileAccess.open(path, FileAccess.READ) if file == null: - push_warning("AudioManager: failed to open %s" % path) - _loaded = true - return + push_warning("AudioManager: failed to open %s (%s)" % [path, what]) + return {} var text: String = file.get_as_text() file.close() var json: JSON = JSON.new() if json.parse(text) != OK: push_warning( - "AudioManager: parse error in audio.json line %d: %s" - % [json.get_error_line(), json.get_error_message()] + "AudioManager: parse error in %s (%s) line %d: %s" + % [path, what, json.get_error_line(), json.get_error_message()] ) - _loaded = true - return + return {} if not (json.data is Dictionary): - push_warning("AudioManager: audio.json is not a JSON object") - _loaded = true - return - var data: Dictionary = json.data - _sfx_events = data.get("sfx", {}) as Dictionary - var music: Dictionary = data.get("music", {}) as Dictionary - _crossfade_seconds = float(music.get("crossfade_seconds", 2.0)) - _music_default_id = String(music.get("default_track_id", "")) - _music_tracks.clear() - var tracks: Array = music.get("tracks", []) as Array - for track: Dictionary in tracks: - var id: String = String(track.get("id", "")) - if id.is_empty(): - continue - _music_tracks[id] = track - _victory_pool = (music.get("victory_pool", {}) as Dictionary).duplicate() - _defeat_pool = (music.get("defeat_pool", {}) as Dictionary).duplicate() - _loaded = true + push_warning("AudioManager: %s is not a JSON object: %s" % [what, path]) + return {} + return json.data as Dictionary func play_sfx(event_key: String) -> void: diff --git a/src/simulator/crates/mc-combat/src/resolver.rs b/src/simulator/crates/mc-combat/src/resolver.rs index b4851068..5a934717 100644 --- a/src/simulator/crates/mc-combat/src/resolver.rs +++ b/src/simulator/crates/mc-combat/src/resolver.rs @@ -362,11 +362,11 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage { let strength_diff = attacker_strength - defender_strength; let raw_damage_to_defender = BASE_DAMAGE * (strength_diff / STRENGTH_DIVISOR).exp() * atk_hp_factor; - // Stack-of-doom cap: a single attack cannot deal more than 2× the defender's - // current HP. Prevents overwhelming odds from one-shotting a city or unit in - // a single exchange — multiple attackers still add up, but each hit is capped. + // Stack-of-doom cap: a single attack cannot deal more than 3× the defender's + // current HP. 2× proved too tight (halved victories, stalled winner_tier_peak). + // 3× leaves typical late-game dominance intact while preventing pure one-shots. let damage_to_defender = - raw_damage_to_defender.min(2.0 * params.defender.hp as f32); + raw_damage_to_defender.min(3.0 * params.defender.hp as f32); // Retaliation damage let no_retaliation = prevents_retaliation(¶ms.attacker_keywords, is_ranged) diff --git a/src/simulator/tests/golden/vectors/mc-combat__resolve_basic.json b/src/simulator/tests/golden/vectors/mc-combat__resolve_basic.json index 360a04f0..ec59d811 100644 --- a/src/simulator/tests/golden/vectors/mc-combat__resolve_basic.json +++ b/src/simulator/tests/golden/vectors/mc-combat__resolve_basic.json @@ -87,7 +87,7 @@ }, { "name": "stress_first_strike_one_round_kill", - "defender_damage": 40, + "defender_damage": 60, "attacker_damage": 0, "attacker_outcome": "survived", "defender_outcome": "killed", diff --git a/tools/audio-split-to-subscription.py b/tools/audio-split-to-subscription.py new file mode 100644 index 00000000..3690bdb0 --- /dev/null +++ b/tools/audio-split-to-subscription.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""One-shot migration: split a per-theme audio.json into the subscription- +pattern triple used everywhere else in the codebase. + +Before: + public/games//data/audio.json + {schema_version, sfx: {...}, music: {tracks: [...], victory_pool, defeat_pool, + default_track_id, crossfade_seconds}} + +After: + public/resources/audio/library.json # shared catalogue + {schema_version, sfx: {...}, music: {tracks: [...]}} + public/games//data/audio/manifest.json # subscription + {source: "resources/audio", includes: true, overrides: {}} + public/games//data/audio/pools.json # per-game routing + {default_track_id, crossfade_seconds, victory_pool, defeat_pool} + +The migration assumes the current single audio.json IS the only library — +i.e. all entries are subscribed by Game 1. Future games author their own +manifest.json with `includes: [whitelist]` or overrides. +""" + +from __future__ import annotations +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +THEME = "age-of-dwarves" +SRC = REPO / "public" / "games" / THEME / "data" / "audio.json" +LIBRARY = REPO / "public" / "resources" / "audio" / "library.json" +DEST_DIR = REPO / "public" / "games" / THEME / "data" / "audio" +MANIFEST = DEST_DIR / "manifest.json" +POOLS = DEST_DIR / "pools.json" + + +def main() -> None: + with open(SRC) as f: + old = json.load(f) + sfx: dict = old["sfx"] + music: dict = old["music"] + tracks: list = music.get("tracks", []) + + # Library: schema_version + sfx + music tracks (no per-theme routing). + library = { + "schema_version": int(old.get("schema_version", 2)), + "sfx": sfx, + "music": {"tracks": tracks}, + } + + # Manifest: subscription. Game 1 takes everything. + manifest = { + "source": "resources/audio", + "includes": True, + "overrides": {}, + } + + # Pools: per-theme routing. Pull every per-game-only field out of + # the music block. + pools: dict = {} + for k in ("default_track_id", "crossfade_seconds", "victory_pool", + "defeat_pool"): + if k in music: + pools[k] = music[k] + + DEST_DIR.mkdir(parents=True, exist_ok=True) + LIBRARY.parent.mkdir(parents=True, exist_ok=True) + LIBRARY.write_text(json.dumps(library, indent=2) + "\n") + MANIFEST.write_text(json.dumps(manifest, indent=2) + "\n") + POOLS.write_text(json.dumps(pools, indent=2) + "\n") + SRC.unlink() + print(f"library: {LIBRARY} ({len(sfx)} sfx, {len(tracks)} tracks)") + print(f"manifest: {MANIFEST}") + print(f"pools: {POOLS}") + print(f"removed: {SRC}") + + +if __name__ == "__main__": + main() diff --git a/tools/audio-validate.py b/tools/audio-validate.py index fc6670d6..5fdd6488 100755 --- a/tools/audio-validate.py +++ b/tools/audio-validate.py @@ -189,20 +189,68 @@ def check_assets(theme: str, refs: set[str], report: Report) -> None: # ────────────────────────────────────────────────────────────────────────── +def _read_json(path: Path, theme: str, what: str, report: Report) -> dict: + if not path.exists(): + report.fail(f"{theme}: {what} not found at {path}") + return {} + try: + return json.loads(path.read_text()) + except json.JSONDecodeError as e: + report.fail(f"{theme}: {what} JSON parse error: {e}") + return {} + + +def _resolve_manifest(library: dict, manifest: dict) -> dict: + """Mirror AudioManager._apply_subscription: filter library by includes, + merge per-key overrides. Returns a synthesized full manifest dict in + the legacy shape that `validate_schema` and `collect_referenced_streams` + already understand. + """ + includes = manifest.get("includes", True) + overrides = manifest.get("overrides", {}) or {} + + def included(key: str) -> bool: + if isinstance(includes, bool): + return includes + if isinstance(includes, list): + return key in includes + return False + + out: dict = {"schema_version": library.get("schema_version", 2), + "sfx": {}, "music": {"tracks": []}} + for key, entry in (library.get("sfx", {}) or {}).items(): + if not included(key): + continue + merged = dict(entry) + if key in overrides and isinstance(overrides[key], dict): + merged.update(overrides[key]) + out["sfx"][key] = merged + for track in (library.get("music", {}) or {}).get("tracks", []) or []: + if not isinstance(track, dict): + continue + tid = track.get("id", "") + if not tid or not included(tid): + continue + merged = dict(track) + if tid in overrides and isinstance(overrides[tid], dict): + merged.update(overrides[tid]) + out["music"]["tracks"].append(merged) + return out + + def validate_theme(theme: str) -> Report: report = Report(errors=[], warnings=[]) - manifest_path = REPO / "public" / "games" / theme / "data" / "audio.json" - if not manifest_path.exists(): - report.fail(f"{theme}: audio.json not found at {manifest_path}") - return report - try: - manifest = json.loads(manifest_path.read_text()) - except json.JSONDecodeError as e: - report.fail(f"{theme}: JSON parse error: {e}") + library_path = REPO / "public" / "resources" / "audio" / "library.json" + manifest_path = REPO / "public" / "games" / theme / "data" / "audio" / "manifest.json" + + library = _read_json(library_path, theme, "audio library.json", report) + manifest = _read_json(manifest_path, theme, "audio manifest.json", report) + if report.errors: return report - validate_schema(manifest, report) - refs = collect_referenced_streams(manifest) + resolved = _resolve_manifest(library, manifest) + validate_schema(resolved, report) + refs = collect_referenced_streams(resolved) check_assets(theme, refs, report) return report