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:
parent
1fe6d9e868
commit
01ef1dd56d
7 changed files with 1075 additions and 21 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
92
public/games/age-of-dwarves/data/schemas/audio.schema.json
Normal file
92
public/games/age-of-dwarves/data/schemas/audio.schema.json
Normal 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 ∈ [1−j, 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
291
tools/audio-validate.py
Executable 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())
|
||||
Loading…
Add table
Reference in a new issue