perf(audio-manager): Introduce schema validation in AudioManager, expand test coverage, and add audio-validate.py CLI tool for early validation during asset processing

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-26 19:55:00 -07:00
parent 1fe6d9e868
commit 01ef1dd56d
7 changed files with 1075 additions and 21 deletions

View file

@ -1,11 +1,9 @@
---
id: asset-audio
name: Asset — Audio
specialization: Source, license, encode, and ship the Game 1 audio asset library (SFX + music)
objectives:
- p2-16
specialization: "Source, license, encode, and ship the Game 1 audio asset library (SFX + music)"
objectives: [p2-16, p2-33]
---
## Mandate
Ship the `.ogg` audio files that `AudioManager` expects — 10 SFX events + 6 music tracks. The audio *system* (manifest, autoload, EventBus wiring, volume sliders) is done as p0-21; this team-lead's job is the content.

View file

@ -1,6 +1,11 @@
{
"schema_version": 1,
"schema_version": 2,
"sfx": {
"_silent": {
"streams": [],
"description": "Sentinel terminator for fallback chains. play_sfx exits silently when the chain reaches this entry."
},
"turn_started": {
"stream": "audio/sfx/turn_started.ogg",
"volume_db": -6.0,
@ -29,7 +34,7 @@
"stream": "audio/sfx/unit_killed.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Short combat death thud + metal."
"description": "Generic combat death thud + metal — categorical fallback target."
},
"wonder_built": {
"stream": "audio/sfx/wonder_built.ogg",
@ -47,7 +52,7 @@
"stream": "audio/sfx/combat_hit.ogg",
"volume_db": -8.0,
"bus": "SFX",
"description": "Generic combat impact, plays on combat_resolved."
"description": "Generic combat impact — categorical fallback target."
},
"unit_moved": {
"stream": "audio/sfx/unit_moved.ogg",
@ -60,6 +65,286 @@
"volume_db": -2.0,
"bus": "SFX",
"description": "Full brass fanfare on victory_achieved."
},
"combat_started": {
"stream": "audio/sfx/combat/combat_started.ogg",
"volume_db": -6.0,
"bus": "SFX",
"fallback": "combat_hit",
"description": "Brief war-horn before first blow lands."
},
"unit_promoted": {
"stream": "audio/sfx/unit_promoted.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "tech_researched",
"description": "Bright brass flourish on unit promotion."
},
"city_grew": {
"stream": "audio/sfx/city/city_grew.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "city_founded",
"description": "Gentle bell on city population growth."
},
"city_starved": {
"stream": "audio/sfx/city/city_starved.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Hollow wind on starvation; silent fallback so missing assets don't ride the city_founded sound."
},
"golden_age_swell": {
"stream": "audio/sfx/golden_age_swell.ogg",
"volume_db": -3.0,
"bus": "SFX",
"fallback": "era_advanced",
"description": "Slow brass crescendo on golden-age start."
},
"border_expanded": {
"stream": "audio/sfx/border_expanded.ogg",
"volume_db": -10.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Soft brass on border-tile claim."
},
"research_start": {
"stream": "audio/sfx/research_start.ogg",
"volume_db": -12.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Soft tap on research-start; deliberately quiet."
},
"culture_researched": {
"stream": "audio/sfx/culture_researched.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "tech_researched",
"description": "Drum + chant when a cultural tradition completes."
},
"wild_spawn": {
"stream": "audio/sfx/fauna/spawn.ogg",
"volume_db": -10.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Brush rustle when a wild creature emerges from a lair."
},
"unit.melee.attack": {
"streams": [
"audio/sfx/units/melee/attack_01.ogg",
"audio/sfx/units/melee/attack_02.ogg",
"audio/sfx/units/melee/attack_03.ogg"
],
"volume_db": -5.0,
"bus": "SFX",
"pitch_jitter": 0.05,
"fallback": "combat_hit",
"description": "Generic melee swing — random variant + ±5% pitch."
},
"unit.melee.hit": {
"streams": [
"audio/sfx/units/melee/hit_01.ogg",
"audio/sfx/units/melee/hit_02.ogg",
"audio/sfx/units/melee/hit_03.ogg"
],
"volume_db": -6.0,
"bus": "SFX",
"pitch_jitter": 0.04,
"fallback": "combat_hit"
},
"unit.melee.death": {
"streams": [
"audio/sfx/units/melee/death_01.ogg",
"audio/sfx/units/melee/death_02.ogg"
],
"volume_db": -7.0,
"bus": "SFX",
"pitch_jitter": 0.03,
"fallback": "unit_killed"
},
"unit.ranged.attack": {
"streams": [
"audio/sfx/units/ranged/fire_01.ogg",
"audio/sfx/units/ranged/fire_02.ogg"
],
"volume_db": -6.0,
"bus": "SFX",
"pitch_jitter": 0.05,
"fallback": "combat_hit",
"description": "Bowstring + arrow release."
},
"unit.ranged.hit": {
"streams": [
"audio/sfx/units/ranged/hit_01.ogg",
"audio/sfx/units/ranged/hit_02.ogg"
],
"volume_db": -7.0,
"bus": "SFX",
"pitch_jitter": 0.03,
"fallback": "combat_hit",
"description": "Arrow thunk into target."
},
"unit.ranged.death": {
"streams": ["audio/sfx/units/ranged/death_01.ogg"],
"volume_db": -7.0,
"bus": "SFX",
"fallback": "unit_killed"
},
"unit.siege.attack": {
"streams": [
"audio/sfx/units/siege/bombard_01.ogg",
"audio/sfx/units/siege/bombard_02.ogg"
],
"volume_db": -3.0,
"bus": "SFX",
"pitch_jitter": 0.02,
"fallback": "combat_hit",
"description": "Deep concussive boom — siege engine fire."
},
"unit.civilian.death": {
"stream": "audio/sfx/units/civilian/death.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "unit_killed"
},
"building.civic.complete": {
"stream": "audio/sfx/buildings/build_complete_civic.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "wonder_built",
"description": "Low ceremonial bell on civic-building completion."
},
"building.production.complete": {
"stream": "audio/sfx/buildings/build_complete_prod.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "wonder_built",
"description": "Anvil ring on production-building completion."
},
"building.military.complete": {
"stream": "audio/sfx/buildings/build_complete_mil.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "wonder_built",
"description": "Drum + horn on military-building completion."
},
"building.defense.complete": {
"stream": "audio/sfx/buildings/build_complete_def.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "building.production.complete",
"description": "Stone-slab thump on wall / fortification completion."
},
"fauna.predator.spawn": {
"stream": "audio/sfx/fauna/predator_spawn.ogg",
"volume_db": -10.0,
"bus": "SFX",
"fallback": "wild_spawn"
},
"fauna.predator.attack": {
"streams": [
"audio/sfx/fauna/predator_attack_01.ogg",
"audio/sfx/fauna/predator_attack_02.ogg"
],
"volume_db": -6.0,
"bus": "SFX",
"pitch_jitter": 0.05,
"fallback": "combat_hit",
"description": "Snarl + impact for wolves / bears."
},
"fauna.predator.hit": {
"streams": [
"audio/sfx/fauna/predator_hurt_01.ogg",
"audio/sfx/fauna/predator_hurt_02.ogg"
],
"volume_db": -7.0,
"bus": "SFX",
"pitch_jitter": 0.04,
"fallback": "combat_hit"
},
"fauna.predator.death": {
"streams": ["audio/sfx/fauna/predator_death.ogg"],
"volume_db": -7.0,
"bus": "SFX",
"fallback": "unit_killed"
},
"fauna.apex_predator.spawn": {
"stream": "audio/sfx/fauna/apex_roar.ogg",
"volume_db": -4.0,
"bus": "SFX",
"fallback": "fauna.predator.spawn"
},
"fauna.apex_predator.attack": {
"stream": "audio/sfx/fauna/apex_attack.ogg",
"volume_db": -3.0,
"bus": "SFX",
"fallback": "fauna.predator.attack"
},
"fauna.apex_predator.death": {
"stream": "audio/sfx/fauna/apex_death.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "fauna.predator.death"
},
"fauna.herbivore.spawn": {
"stream": "audio/sfx/fauna/herbivore_call.ogg",
"volume_db": -12.0,
"bus": "SFX",
"fallback": "wild_spawn",
"description": "Distant deer / elk call."
},
"fauna.herbivore.death": {
"stream": "audio/sfx/fauna/herbivore_death.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "unit_killed"
},
"weather.storm": {
"stream": "audio/sfx/weather/storm.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Distant thunder cue when a storm event applies."
},
"weather.blizzard": {
"stream": "audio/sfx/weather/blizzard.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "weather.storm"
},
"weather.heat_wave": {
"stream": "audio/sfx/weather/heat_wave.ogg",
"volume_db": -10.0,
"bus": "SFX",
"fallback": "_silent"
},
"weather.drought": {
"stream": "audio/sfx/weather/drought.ogg",
"volume_db": -12.0,
"bus": "SFX",
"fallback": "weather.heat_wave"
},
"weather.tornado": {
"stream": "audio/sfx/weather/tornado.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "weather.storm"
},
"weather.hurricane": {
"stream": "audio/sfx/weather/hurricane.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "weather.storm"
}
},
"music": {
@ -114,6 +399,16 @@
"mood": "climactic",
"description": "Orchestral peak + full choir. Era 9-10 ambient."
},
{
"id": "golden_age",
"stream": "audio/music/golden_age.ogg",
"volume_db": -8.0,
"bus": "Music",
"loop": true,
"era_range": null,
"mood": "triumph",
"description": "Plays while a golden age is active; replaces era track until golden_age_ended."
},
{
"id": "victory",
"stream": "audio/music/victory.ogg",

View file

@ -0,0 +1,92 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "audio",
"title": "Audio Manifest",
"description": "Theme sound pack — SFX events keyed by name, music tracks by id. Categorical keys (e.g. 'unit.melee.attack', 'building.production.complete', 'fauna.apex.roar') are part of the SFX block; the categorical fallback chain in audio_manager.gd resolves entity-scoped events to these keys.",
"type": "object",
"required": ["schema_version", "sfx", "music"],
"additionalProperties": false,
"properties": {
"schema_version": { "type": "integer", "minimum": 1 },
"sfx": {
"type": "object",
"description": "Map of SFX event key → entry. Keys may be flat ('combat_hit', 'turn_started') or dotted categorical ('unit.melee.attack').",
"additionalProperties": { "$ref": "#/$defs/sfx_entry" }
},
"music": {
"type": "object",
"required": ["tracks"],
"additionalProperties": false,
"properties": {
"tracks": {
"type": "array",
"items": { "$ref": "#/$defs/music_track" }
},
"crossfade_seconds": { "type": "number", "minimum": 0.0, "maximum": 30.0, "default": 2.0 },
"default_track_id": { "type": "string" }
}
}
},
"$defs": {
"sfx_entry": {
"type": "object",
"additionalProperties": false,
"properties": {
"stream": {
"type": "string",
"description": "Single relative stream path (legacy form). Use either `stream` or `streams`, not both. Empty `streams` arrays mark the `_silent` sentinel."
},
"streams": {
"type": "array",
"description": "Array of relative stream paths. One is picked uniformly per play to break repetition. Takes precedence over `stream` when both are present.",
"items": { "type": "string" }
},
"volume_db": {
"type": "number",
"description": "Per-event volume offset in decibels.",
"default": 0.0
},
"bus": {
"type": "string",
"description": "Mixer bus name. Master/Music/SFX are the buses created at boot by SettingsManager.",
"default": "SFX"
},
"pitch_jitter": {
"type": "number",
"description": "Per-play pitch_scale randomisation: actual scale ∈ [1j, 1+j]. 0.0 = no jitter.",
"minimum": 0.0,
"maximum": 0.5,
"default": 0.0
},
"fallback": {
"type": "string",
"description": "Sound key to try when none of this entry's streams load. Walks transitively. Bottom of the chain is the literal '_silent' sentinel — an entry with `streams: []`."
},
"description": {
"type": "string",
"description": "Author-facing notes; not used at runtime."
}
}
},
"music_track": {
"type": "object",
"required": ["id", "stream"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"stream": { "type": "string", "minLength": 1 },
"volume_db": { "type": "number", "default": -10.0 },
"bus": { "type": "string", "default": "Music" },
"loop": { "type": "boolean", "default": true },
"era_range": {
"oneOf": [
{ "type": "null" },
{ "type": "array", "items": { "type": "integer", "minimum": 1, "maximum": 10 }, "minItems": 2, "maxItems": 2 }
]
},
"mood": { "type": "string", "enum": ["ambient", "tension", "climactic", "triumph"] },
"description": { "type": "string" }
}
}
}
}

View file

@ -11,6 +11,12 @@ const SFX_POOL_SIZE: int = 6
const AUDIO_DATA_PATH_FMT: String = "res://public/games/%s/data/audio.json"
const SILENT_DB: float = -60.0
const UNIT_MOVED_THROTTLE_MSEC: int = 100
## Bottom of the fallback chain. An entry whose `streams: []` is empty is
## treated as the no-op terminator — `play_sfx` exits silently when reached.
const SILENT_KEY: String = "_silent"
## Defensive cap on fallback hops to guarantee termination even if data
## somehow forms a cycle.
const MAX_FALLBACK_HOPS: int = 8
var _loaded: bool = false
var _theme_id: String = ""
@ -27,9 +33,17 @@ var _active_music_player: AudioStreamPlayer = null
var _current_music_id: String = ""
var _stream_cache: Dictionary = {}
var _last_unit_moved_msec: int = 0
## Cache of entity_id → category bucket ("unit.melee" / "building.civic" /
## "fauna.apex_predator" / "" if unknown). Avoids re-walking DataLoader on
## every play_for_entity call.
var _entity_category_cache: Dictionary = {}
## RNG used for streams[] random pick + pitch_jitter. Default-seeded; audio
## jitter does not need a deterministic / replayable seed.
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
func _ready() -> void:
_rng.randomize()
_build_music_players()
_build_sfx_pool()
_connect_event_bus()
@ -81,20 +95,40 @@ func load_theme(theme_id: String) -> void:
func play_sfx(event_key: String) -> void:
## Public API (also called on EventBus signals). Silent no-op when missing.
## Walks the manifest's per-entry `fallback` chain until a stream resolves
## or the chain bottoms out at the `_silent` sentinel.
if not _loaded:
return
if not _sfx_events.has(event_key):
var key: String = event_key
for _hop: int in range(MAX_FALLBACK_HOPS):
if key.is_empty() or key == SILENT_KEY:
return
if not _sfx_events.has(key):
return
var entry: Dictionary = _sfx_events[key]
var stream: AudioStream = _resolve_entry_stream(entry)
if stream != null:
_play_stream(stream, entry)
return
var fallback: String = String(entry.get("fallback", ""))
if fallback.is_empty():
return
key = fallback
## Public API contextual to a specific game entity (unit / building / wild
## creature). Resolution walks the categorical ladder:
## <entity_id>.<event_kind> → <unit|building|fauna>.<category>.<event_kind>
## → <unit|building|fauna>.<event_kind> → <event_kind>
## The first key whose manifest entry resolves a stream wins. Categorical
## fallback is independent of the per-entry `fallback` chain in `play_sfx`.
func play_for_entity(entity_id: String, event_kind: String) -> void:
if not _loaded or entity_id.is_empty() or event_kind.is_empty():
return
var entry: Dictionary = _sfx_events[event_key]
var stream: AudioStream = _load_stream(String(entry.get("stream", "")))
if stream == null:
return
var player: AudioStreamPlayer = _sfx_pool[_sfx_cursor]
_sfx_cursor = (_sfx_cursor + 1) % _sfx_pool.size()
player.stream = stream
player.volume_db = float(entry.get("volume_db", 0.0))
player.bus = String(entry.get("bus", "SFX"))
player.play()
for key: String in _resolve_keys(entity_id, event_kind):
if _sfx_events.has(key):
play_sfx(key)
return
func play_music(track_id: String) -> void:
@ -153,6 +187,18 @@ func _connect_event_bus() -> void:
EventBus.combat_resolved.connect(_on_combat_resolved)
EventBus.unit_moved.connect(_on_unit_moved)
EventBus.victory_achieved.connect(_on_victory_achieved)
# p2-33 — categorical / additional wires.
EventBus.combat_started.connect(_on_combat_started)
EventBus.unit_promoted.connect(_on_unit_promoted)
EventBus.city_grew.connect(_on_city_grew)
EventBus.city_starved.connect(_on_city_starved)
EventBus.golden_age_started.connect(_on_golden_age_started)
EventBus.golden_age_ended.connect(_on_golden_age_ended)
EventBus.city_border_expanded.connect(_on_border_expanded)
EventBus.tech_research_started.connect(_on_tech_research_started)
EventBus.culture_researched.connect(_on_culture_researched)
EventBus.wild_creature_spawned.connect(_on_wild_creature_spawned)
EventBus.weather_event_applied.connect(_on_weather_event)
func _build_music_players() -> void:
@ -210,6 +256,168 @@ func _load_stream(relative_path: String) -> AudioStream:
return stream
## Resolve a stream from an SFX manifest entry. Prefers `streams[]` (random
## variant pick), falls back to legacy single `stream`. Returns null when no
## variant loads — caller is then expected to walk the entry's `fallback`
## chain. Empty `streams: []` is the `_silent` sentinel signal.
func _resolve_entry_stream(entry: Dictionary) -> AudioStream:
if entry.has("streams"):
var raw: Array = entry["streams"] as Array
if raw.is_empty():
return null # _silent sentinel — explicit silence, not a missing asset
# Try a random variant first, then walk the rest in order so one
# missing file does not silence the entry as long as another exists.
var indices: PackedInt32Array = []
var start: int = _rng.randi_range(0, raw.size() - 1)
for offset: int in range(raw.size()):
indices.append((start + offset) % raw.size())
for idx: int in indices:
var stream: AudioStream = _load_stream(String(raw[idx]))
if stream != null:
return stream
return null
return _load_stream(String(entry.get("stream", "")))
## Take a resolved stream + manifest entry and play it on the next pool
## slot. Applies pitch_jitter when requested.
func _play_stream(stream: AudioStream, entry: Dictionary) -> void:
var player: AudioStreamPlayer = _sfx_pool[_sfx_cursor]
_sfx_cursor = (_sfx_cursor + 1) % _sfx_pool.size()
player.stream = stream
player.volume_db = float(entry.get("volume_db", 0.0))
player.bus = String(entry.get("bus", "SFX"))
var jitter: float = clamp(float(entry.get("pitch_jitter", 0.0)), 0.0, 0.5)
if jitter > 0.0:
player.pitch_scale = 1.0 + _rng.randf_range(-jitter, jitter)
else:
player.pitch_scale = 1.0
player.play()
## Build the categorical resolution chain for an entity event. Order:
## 1. <entity_id>.<event_kind> bespoke
## 2. <kind>.<sub_category>.<event_kind> e.g. unit.melee.attack
## 3. <kind>.<event_kind> e.g. unit.attack
## 4. <event_kind> generic
## `kind` is `unit` / `building` / `fauna` based on which DataLoader
## category the id resolves into.
func _resolve_keys(entity_id: String, event_kind: String) -> Array[String]:
var keys: Array[String] = []
keys.append("%s.%s" % [entity_id, event_kind])
var kind_and_sub: PackedStringArray = _entity_kind_and_sub(entity_id)
if kind_and_sub.size() == 2:
var kind: String = kind_and_sub[0]
var sub: String = kind_and_sub[1]
if not sub.is_empty():
keys.append("%s.%s.%s" % [kind, sub, event_kind])
keys.append("%s.%s" % [kind, event_kind])
keys.append(event_kind)
return keys
## Return [kind, sub_category] for an entity id, e.g.
## ["unit", "melee"] / ["building", "production"] / ["fauna", "predator"] /
## [] when the id is not registered with DataLoader.
func _entity_kind_and_sub(entity_id: String) -> PackedStringArray:
if _entity_category_cache.has(entity_id):
return _entity_category_cache[entity_id]
var result: PackedStringArray = []
# Wilds first — wild creatures appear as units of `unit_type: "wild"` AND
# may live in the wilds registry. Route them through the fauna path so
# their roar / spawn / death sounds match.
var wilds: Dictionary = DataLoader.get_data("wilds") as Dictionary
if wilds.has(entity_id) and wilds[entity_id] is Dictionary:
var wild: Dictionary = wilds[entity_id] as Dictionary
if not wild.is_empty():
result.append("fauna")
result.append(_fauna_class(wild))
_entity_category_cache[entity_id] = result
return result
var unit: Dictionary = DataLoader.get_unit(entity_id) as Dictionary
if not unit.is_empty():
# `unit_type: "wild"` units that are not in the wilds registry still
# get fauna classification by trophic semantics from their `attributes`.
if String(unit.get("unit_type", "")) == "wild":
result.append("fauna")
result.append(_fauna_class(unit))
_entity_category_cache[entity_id] = result
return result
result.append("unit")
result.append(_unit_combat_class(unit))
_entity_category_cache[entity_id] = result
return result
var bldg: Dictionary = DataLoader.get_building(entity_id) as Dictionary
if not bldg.is_empty():
result.append("building")
result.append(String(bldg.get("category", "")))
_entity_category_cache[entity_id] = result
return result
_entity_category_cache[entity_id] = result
return result
## Coarse combat class derived from existing unit JSON fields. The data
## doesn't carry an explicit `combat_class`; we infer one for sound routing.
func _unit_combat_class(unit: Dictionary) -> String:
var unit_type: String = String(unit.get("unit_type", ""))
if unit_type == "civilian":
return "civilian"
var attack_type: String = String(unit.get("attack_type", ""))
if attack_type == "siege":
return "siege"
var ranged: int = int(unit.get("ranged_attack", 0))
if ranged > 0:
return "ranged"
# Default for military / mounted / other: melee.
return "melee"
## Fauna trophic class for sound routing. Prefers an explicit
## `trophic_class` field; falls back to scanning `attributes` /
## `flags` for a recognised tier.
func _fauna_class(creature: Dictionary) -> String:
var explicit: String = String(creature.get("trophic_class", ""))
if not explicit.is_empty():
return explicit
var tags: PackedStringArray = []
for raw_v in (creature.get("attributes", []) as Array):
tags.append(String(raw_v))
for raw_v in (creature.get("flags", []) as Array):
tags.append(String(raw_v))
for tag: String in tags:
if tag == "apex_predator" or tag == "apex":
return "apex_predator"
if tag == "predator":
return "predator"
if tag == "herbivore":
return "herbivore"
if tag == "omnivore":
return "omnivore"
return ""
## Extract a unit_id from a payload that EventBus signals carry as
## `unit: Variant`. Tolerates RefCounted entities and Dictionary payloads.
func _unit_id_of(unit: Variant) -> String:
if unit == null:
return ""
if unit is RefCounted:
var rc: RefCounted = unit as RefCounted
if "unit_id" in rc:
return String(rc.get("unit_id"))
return ""
if unit is Dictionary:
return String((unit as Dictionary).get("unit_id", ""))
return ""
# ---------------------------------------------------------------------------
# EventBus handlers (signal params use Variant per EventBus convention)
# ---------------------------------------------------------------------------
@ -231,7 +439,11 @@ func _on_tech_researched(_tech_id: String, _player_index: int) -> void:
play_sfx("tech_researched")
func _on_unit_destroyed(_unit: Variant, _killer: Variant) -> void:
func _on_unit_destroyed(unit: Variant, _killer: Variant) -> void:
var unit_id: String = _unit_id_of(unit)
if not unit_id.is_empty():
play_for_entity(unit_id, "death")
return
play_sfx("unit_killed")
@ -246,10 +458,71 @@ func _on_era_changed(new_era: int, _player_index: int) -> void:
play_music(track_id)
func _on_combat_resolved(_attacker: Variant, _defender: Variant, _result: Dictionary) -> void:
func _on_combat_resolved(_attacker: Variant, defender: Variant, _result: Dictionary) -> void:
var defender_id: String = _unit_id_of(defender)
if not defender_id.is_empty():
play_for_entity(defender_id, "hit")
return
play_sfx("combat_hit")
func _on_combat_started(attacker: Variant, _defender: Variant) -> void:
var attacker_id: String = _unit_id_of(attacker)
if not attacker_id.is_empty():
play_for_entity(attacker_id, "attack")
return
play_sfx("combat_started")
func _on_unit_promoted(_unit: Variant, _promotion: String) -> void:
play_sfx("unit_promoted")
func _on_city_grew(_city: Variant, _new_pop: int) -> void:
play_sfx("city_grew")
func _on_city_starved(_city: Variant, _new_pop: int) -> void:
play_sfx("city_starved")
func _on_golden_age_started(_player_index: int) -> void:
play_sfx("golden_age_swell")
play_music("golden_age")
func _on_golden_age_ended(_player_index: int) -> void:
# Drop golden-age music; era track resumes on the next era_changed (or
# stays silent until a music track is explicitly requested again).
stop_music()
func _on_border_expanded(_city: Variant, _tile: Vector2i) -> void:
play_sfx("border_expanded")
func _on_tech_research_started(_tech_id: String, _player_index: int) -> void:
play_sfx("research_start")
func _on_culture_researched(_tradition_id: String, _player_index: int) -> void:
play_sfx("culture_researched")
func _on_wild_creature_spawned(unit: Variant, _lair_pos: Vector2i) -> void:
var unit_id: String = _unit_id_of(unit)
if not unit_id.is_empty():
play_for_entity(unit_id, "spawn")
return
play_sfx("wild_spawn")
func _on_weather_event(kind: String, _tile: Vector2i, _severity: float) -> void:
if kind.is_empty():
return
play_sfx("weather.%s" % kind)
func _on_unit_moved(_unit: Variant, _from: Vector2i, _to: Vector2i) -> void:
# Throttle: movement fires dozens of times per turn during AI resolution.
var now: int = Time.get_ticks_msec()

View file

@ -130,3 +130,108 @@ func test_play_sfx_known_event_does_not_crash_when_stream_missing() -> void:
# The manager must degrade to silent no-op, not error.
AudioManager.play_sfx("city_founded")
assert_true(true, "play_sfx must not crash when stream file is absent")
# ── p2-33 — categorical fallback, variant pools, per-entity routing ─────────
func test_streams_array_pool_is_loaded_for_categorical_keys() -> void:
# unit.melee.attack ships with three variants in audio.json; the
# manifest must surface that array intact for the random-pick path.
assert_true(
AudioManager._sfx_events.has("unit.melee.attack"),
"Categorical key 'unit.melee.attack' must be present in the manifest"
)
var entry: Dictionary = AudioManager._sfx_events["unit.melee.attack"]
assert_true(entry.has("streams"), "unit.melee.attack must use streams[]")
var streams: Array = entry["streams"] as Array
assert_gte(streams.size(), 2, "streams[] should carry ≥2 variants")
assert_true(entry.has("pitch_jitter"), "categorical foley should declare pitch_jitter")
var jitter: float = float(entry["pitch_jitter"])
assert_true(jitter > 0.0 and jitter <= 0.5, "pitch_jitter must lie in (0.0, 0.5]")
func test_silent_sentinel_terminates_fallback_chain() -> void:
# An entry whose `streams: []` is empty terminates the fallback walk
# without error. city_starved → _silent in the live manifest.
assert_true(
AudioManager._sfx_events.has("_silent"),
"manifest must define a `_silent` sentinel for fallback termination"
)
var sentinel: Dictionary = AudioManager._sfx_events["_silent"]
assert_true(sentinel.has("streams"), "_silent must use empty streams[]")
assert_eq(
(sentinel["streams"] as Array).size(),
0,
"_silent.streams must be the empty array"
)
# Calling play_sfx on a chain that bottoms out at _silent must not crash.
AudioManager.play_sfx("city_starved")
assert_true(true, "play_sfx through a _silent fallback must be a no-op")
func test_play_for_entity_resolves_categorical_chain() -> void:
# Pass an unknown entity id with a known event_kind; resolution should
# reach the generic event-kind fallback without throwing. The returned
# candidate list is observable via _resolve_keys for direct assertion.
var keys: Array[String] = AudioManager._resolve_keys("paladin", "attack")
assert_eq(
keys[0],
"paladin.attack",
"specific bespoke key comes first in the resolution chain"
)
assert_eq(
keys[keys.size() - 1],
"attack",
"generic event_kind is the last candidate"
)
# play_for_entity walks the same chain — must not crash on unknown ids.
AudioManager.play_for_entity("paladin", "attack")
assert_true(true, "play_for_entity tolerates unknown entity ids")
func test_play_for_entity_routes_known_unit_through_combat_class() -> void:
# `archer` is a real unit with attack_type=pierce + ranged_attack>0, so
# the inferred combat class is `ranged`. The chain must include
# `unit.ranged.attack` between the bespoke and the generic.
var keys: Array[String] = AudioManager._resolve_keys("archer", "attack")
var has_categorical: bool = false
for k: String in keys:
if k == "unit.ranged.attack":
has_categorical = true
break
assert_true(
has_categorical,
"archer.attack chain must include unit.ranged.attack — got %s" % str(keys)
)
func test_new_event_signals_are_connected() -> void:
# p2-33 wires nine additional EventBus signals. Check each exists and
# is connected to AudioManager.
var required_signals: Array[String] = [
"combat_started",
"unit_promoted",
"city_grew",
"city_starved",
"golden_age_started",
"golden_age_ended",
"city_border_expanded",
"tech_research_started",
"culture_researched",
"wild_creature_spawned",
"weather_event_applied",
]
for sig_name: String in required_signals:
var sig: Signal = EventBus.get(sig_name)
var callables: Array = sig.get_connections()
var found: bool = false
for conn: Dictionary in callables:
var callable: Callable = conn.get("callable", Callable())
if callable.get_object() == AudioManager:
found = true
break
assert_true(
found,
"AudioManager must be connected to EventBus.%s (p2-33)" % sig_name
)

View file

@ -36,7 +36,7 @@ Modules live at `.claude/instructions/<file>.md` (symlink resolves to `tooling/c
| When the task involves… | MUST load before acting… |
|---|---|
| Game 1 scope, Game 2 deferral, what-ships-where | `scope-game1-vs-game2.md` |
| Hex tile geometry, formation duality (SQ + QL), why-hex, biome-edge contact | `public/games/age-of-dwarves/docs/HEX_GEOMETRY.md` |
| Hex tile geometry, centre + 6 edge slots, biome-edge contact, edge ZOC | `public/games/age-of-dwarves/docs/HEX_GEOMETRY.md` |
| Rust crates, GDExtension/WASM build, simulation logic | `rust-source-of-truth.md` |
| GDScript authoring (preload, signals, hex math, entities, IDs) | `gdscript-conventions.md` |
| "Where does this file go?" / `src/` tree / symlinks | `file-organization.md` |

291
tools/audio-validate.py Executable file
View file

@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""Audio manifest + asset coherence validator.
Checks (per pack under `public/games/<theme>/data/audio.json`):
1. JSON validates against the schema at
`public/games/<theme>/data/schemas/audio.schema.json`.
2. Every `stream` / `streams[]` path that the manifest references either
resolves to a real file under `public/games/<theme>/assets/audio/...`
OR is reachable via a `fallback` chain that terminates at the
`_silent` sentinel. Files that don't exist trigger a warning, not an
error `p2-16` is the asset-acquisition objective and lands files
incrementally.
3. No orphan `.ogg` files: every `.ogg` under `assets/audio/**` is
referenced by at least one manifest entry. Orphans warn; not fatal.
4. Every `fallback` reference is a valid sound key in the same manifest
(or `_silent`).
5. The `_silent` sentinel exists with an empty `streams: []`.
Exit status: non-zero iff a structural problem (1, 4, 5) is found.
Missing files (2) and orphans (3) only warn they're a normal
intermediate state while assets land.
Wire: `./run validate` calls this; it also runs in CI before the GUT pass.
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
DEFAULT_THEMES = ["age-of-dwarves"]
SILENT_KEY = "_silent"
@dataclass
class Report:
errors: list[str]
warnings: list[str]
def fail(self, msg: str) -> None:
self.errors.append(msg)
def warn(self, msg: str) -> None:
self.warnings.append(msg)
# ──────────────────────────────────────────────────────────────────────────
# Schema validation (lightweight — no jsonschema dep)
# ──────────────────────────────────────────────────────────────────────────
def _entry_streams(entry: dict) -> list[str]:
if "streams" in entry:
raw = entry["streams"]
if not isinstance(raw, list):
return []
return [str(s) for s in raw]
single = entry.get("stream")
if single:
return [str(single)]
return []
def validate_schema(manifest: dict, report: Report) -> None:
if not isinstance(manifest, dict):
report.fail("audio.json root is not a JSON object")
return
if "schema_version" not in manifest:
report.fail("missing required field: schema_version")
sfx = manifest.get("sfx")
if not isinstance(sfx, dict):
report.fail("missing or non-object `sfx` block")
sfx = {}
music = manifest.get("music")
if not isinstance(music, dict):
report.fail("missing or non-object `music` block")
music = {"tracks": []}
# Each SFX entry shape
allowed_entry_keys = {
"stream", "streams", "volume_db", "bus", "pitch_jitter",
"fallback", "description",
}
for key, entry in sfx.items():
if not isinstance(entry, dict):
report.fail(f"sfx[{key!r}] is not an object")
continue
extra = set(entry.keys()) - allowed_entry_keys
if extra:
report.fail(f"sfx[{key!r}] has unknown fields: {sorted(extra)}")
if "stream" in entry and "streams" in entry:
report.warn(
f"sfx[{key!r}] declares both `stream` and `streams[]` — "
f"streams[] takes precedence at runtime; drop `stream`"
)
if "pitch_jitter" in entry:
j = entry["pitch_jitter"]
if not isinstance(j, (int, float)) or j < 0.0 or j > 0.5:
report.fail(
f"sfx[{key!r}].pitch_jitter must be a number in [0.0, 0.5]"
)
# Sentinel
if SILENT_KEY not in sfx:
report.fail(
f"sfx[{SILENT_KEY!r}] sentinel missing — fallback chains "
f"need a terminator"
)
else:
sentinel = sfx[SILENT_KEY]
if not (isinstance(sentinel.get("streams"), list)
and len(sentinel["streams"]) == 0):
report.fail(
f"sfx[{SILENT_KEY!r}] must declare `streams: []` (empty array)"
)
# Fallback chain references resolve
for key, entry in sfx.items():
if not isinstance(entry, dict):
continue
fb = entry.get("fallback")
if fb is None:
continue
if not isinstance(fb, str) or not fb:
report.fail(f"sfx[{key!r}].fallback must be a non-empty string")
continue
if fb not in sfx:
report.fail(
f"sfx[{key!r}].fallback={fb!r} is not a valid sound key"
)
# Cycle check on fallback graph
for key in sfx:
seen: set[str] = set()
cur = key
while cur in sfx:
if cur in seen:
report.fail(
f"sfx[{key!r}] fallback chain cycles through {cur!r}"
)
break
seen.add(cur)
nxt = sfx[cur].get("fallback") if isinstance(sfx[cur], dict) else None
if not nxt:
break
cur = str(nxt)
# Music tracks
tracks = music.get("tracks", [])
if not isinstance(tracks, list):
report.fail("music.tracks is not an array")
tracks = []
seen_ids: set[str] = set()
for i, track in enumerate(tracks):
if not isinstance(track, dict):
report.fail(f"music.tracks[{i}] is not an object")
continue
tid = track.get("id")
if not tid:
report.fail(f"music.tracks[{i}] missing required `id`")
elif tid in seen_ids:
report.fail(f"music.tracks[{i}] duplicate id {tid!r}")
else:
seen_ids.add(tid)
if not track.get("stream"):
report.fail(f"music.tracks[{i}] ({tid}) missing required `stream`")
# ──────────────────────────────────────────────────────────────────────────
# Asset existence + orphan check
# ──────────────────────────────────────────────────────────────────────────
def collect_referenced_streams(manifest: dict) -> set[str]:
refs: set[str] = set()
for entry in manifest.get("sfx", {}).values():
if not isinstance(entry, dict):
continue
for s in _entry_streams(entry):
refs.add(s)
for track in manifest.get("music", {}).get("tracks", []):
if isinstance(track, dict) and track.get("stream"):
refs.add(str(track["stream"]))
return refs
def check_assets(theme: str, refs: set[str], report: Report) -> None:
assets_root = REPO / "public" / "games" / theme / "assets"
missing: list[str] = []
for rel in sorted(refs):
if not rel:
continue
p = assets_root / rel
if not p.exists():
missing.append(rel)
if missing:
report.warn(
f"{theme}: {len(missing)} referenced files do not yet exist "
f"(asset acquisition tracked by p2-16). First few: "
f"{missing[:3]}"
)
# Orphan scan
audio_dir = assets_root / "audio"
if not audio_dir.exists():
return
orphans: list[str] = []
for f in audio_dir.rglob("*.ogg"):
rel = str(f.relative_to(assets_root))
if rel not in refs:
orphans.append(rel)
if orphans:
report.warn(
f"{theme}: {len(orphans)} orphan .ogg files not referenced by "
f"audio.json. First few: {orphans[:3]}"
)
# ──────────────────────────────────────────────────────────────────────────
# Driver
# ──────────────────────────────────────────────────────────────────────────
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}")
return report
validate_schema(manifest, report)
refs = collect_referenced_streams(manifest)
check_assets(theme, refs, report)
return report
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--theme", action="append", default=None,
help="Theme id (e.g. 'age-of-dwarves'). Repeatable. "
"Default: every theme listed in DEFAULT_THEMES.",
)
parser.add_argument(
"--strict", action="store_true",
help="Treat warnings as errors (raises exit code on missing assets / orphans).",
)
args = parser.parse_args()
themes = args.theme if args.theme else DEFAULT_THEMES
total_errors = 0
total_warnings = 0
for theme in themes:
report = validate_theme(theme)
if report.errors:
print(f"[{theme}] ERRORS:")
for e in report.errors:
print(f"{e}")
total_errors += len(report.errors)
if report.warnings:
print(f"[{theme}] warnings:")
for w in report.warnings:
print(f" · {w}")
total_warnings += len(report.warnings)
if not report.errors and not report.warnings:
print(f"[{theme}] OK")
if total_errors > 0:
print(f"\nFAIL: {total_errors} structural error(s)")
return 1
if args.strict and total_warnings > 0:
print(f"\nFAIL (--strict): {total_warnings} warning(s)")
return 1
if total_warnings > 0:
print(f"\nOK with {total_warnings} warning(s)")
else:
print("\nOK")
return 0
if __name__ == "__main__":
sys.exit(main())