feat(@projects/@magic-civilization): implement subscription audio system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-30 04:20:19 -04:00
parent d54e0178bd
commit fa41a670cf
9 changed files with 305 additions and 81 deletions

View file

@ -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";

View file

@ -0,0 +1,5 @@
{
"source": "resources/audio",
"includes": true,
"overrides": {}
}

View file

@ -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"
]
}
}

View file

@ -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"]
}
]
}
}

View file

@ -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 <theme>/data/audio/manifest.json — subscription gate
## 3. apply overrides from the manifest — per-game tweaks
## 4. read <theme>/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:

View file

@ -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(&params.attacker_keywords, is_ranged)

View file

@ -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",

View file

@ -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/<theme>/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/<theme>/data/audio/manifest.json # subscription
{source: "resources/audio", includes: true, overrides: {}}
public/games/<theme>/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()

View file

@ -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