Merge remote-tracking branch 'origin/main' into acs2-recovery-candidate
This commit is contained in:
commit
8ab4239bf3
20 changed files with 2164 additions and 64 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,360 @@
|
|||
//! `mc-ai::tactical` — tactical (per-turn) AI decisions.
|
||||
//!
|
||||
//! This module hosts the port of the GDScript tactical AI stack
|
||||
//! (`simple_heuristic_ai.gd`, `ai_tactical.gd`, `ai_military.gd`) into Rust
|
||||
//! per objective `p0-26`. It is the sibling of the strategic MCTS layer in
|
||||
//! `crate::mcts_tree` — MCTS chooses the strategic direction, `tactical`
|
||||
//! executes the per-turn unit/city decisions.
|
||||
//!
|
||||
//! # Surface
|
||||
//!
|
||||
//! The single entry point is [`decide_tactical_actions`]. Submodules
|
||||
//! ([`movement`], [`settle`], [`production`], [`citizen`], [`combat_predict`])
|
||||
//! own individual decision domains and are assembled by the entry point.
|
||||
//! Each submodule returns `Vec<Action>` so the top level is a straight
|
||||
//! concatenation — no cross-talk between domains at the contract level.
|
||||
//!
|
||||
//! # State contract
|
||||
//!
|
||||
//! The tactical layer operates on [`TacticalState`] (from [`state`]) — a
|
||||
//! hex-level snapshot of the world. The GPU-compact
|
||||
//! [`crate::abstract_state::AbstractRolloutState`] remains the MCTS rollout
|
||||
//! POD; tactical decisions cannot fit inside 256 bytes per turn because
|
||||
//! they select specific units, cities, and tiles.
|
||||
//!
|
||||
//! # Action contract
|
||||
//!
|
||||
//! [`Action`] is the JSON-transport shape the GDExtension bridge
|
||||
//! (`api-gdext::ai::GdAiController`) relays to GDScript. Variants mirror
|
||||
//! the verbs the GDScript turn loop applied directly before the port.
|
||||
//! Serde round-trip is a hard requirement: the bridge serializes each
|
||||
//! action via `serde_json::to_string` and GDScript decodes it with
|
||||
//! `JSON.parse_string`.
|
||||
|
||||
pub(crate) mod citizen;
|
||||
pub mod combat_predict;
|
||||
pub(crate) mod movement;
|
||||
pub(crate) mod production;
|
||||
pub(crate) mod settle;
|
||||
pub mod state;
|
||||
pub mod thresholds;
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::evaluator::ScoringWeights;
|
||||
use crate::mcts::XorShift64;
|
||||
|
||||
pub use state::{
|
||||
TacticalCity, TacticalEphemerals, TacticalMap, TacticalPlayerState, TacticalState,
|
||||
TacticalTile, TacticalUnit,
|
||||
};
|
||||
|
||||
/// A single tactical decision emitted by the per-turn AI.
|
||||
///
|
||||
/// Variants are the union of verbs `simple_heuristic_ai.gd` and
|
||||
/// `ai_tactical.gd` dispatched in a turn. Hex coordinates use axial
|
||||
/// `(col, row)` pairs to match the GDScript engine's `(int, int)` hex
|
||||
/// addressing.
|
||||
///
|
||||
/// Serde round-trip is load-bearing: the bridge emits these as JSON
|
||||
/// strings across the GDExtension boundary.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Action {
|
||||
/// Move `unit_id` toward `to_hex` along the best path the movement
|
||||
/// layer found this turn.
|
||||
MoveUnit {
|
||||
/// Engine-assigned unit identifier.
|
||||
unit_id: u32,
|
||||
/// Target axial hex `(col, row)`.
|
||||
to_hex: (i32, i32),
|
||||
},
|
||||
/// Engage `target_id` with `attacker_id`. Resolution is the combat
|
||||
/// module's responsibility; this action is the decision only.
|
||||
AttackTarget {
|
||||
/// Engine-assigned attacker unit id.
|
||||
attacker_id: u32,
|
||||
/// Engine-assigned target unit id.
|
||||
target_id: u32,
|
||||
},
|
||||
/// Fortify `unit_id` in place for the defensive bonus.
|
||||
Fortify {
|
||||
/// Engine-assigned unit identifier.
|
||||
unit_id: u32,
|
||||
},
|
||||
/// Heal `unit_id` in place (skip turn to recover HP).
|
||||
Heal {
|
||||
/// Engine-assigned unit identifier.
|
||||
unit_id: u32,
|
||||
},
|
||||
/// Settle `settler_id` at `at_hex`, consuming the settler.
|
||||
FoundCity {
|
||||
/// Settler unit id.
|
||||
settler_id: u32,
|
||||
/// Axial hex `(col, row)` to found at.
|
||||
at_hex: (i32, i32),
|
||||
},
|
||||
/// Set `city_id`'s production queue head to `item_id`
|
||||
/// (building/unit/wonder data-pack id).
|
||||
SetProduction {
|
||||
/// City identifier.
|
||||
city_id: u32,
|
||||
/// Data-pack production item id (e.g. `"dwarf_warrior"`,
|
||||
/// `"building_forge"`).
|
||||
item_id: String,
|
||||
},
|
||||
/// Assign an unemployed citizen of `city_id` to work `tile_hex`.
|
||||
AssignCitizen {
|
||||
/// City identifier.
|
||||
city_id: u32,
|
||||
/// Worked tile axial hex `(col, row)`.
|
||||
tile_hex: (i32, i32),
|
||||
},
|
||||
/// Send `unit_id` to scout `to_hex` (exploration, not combat).
|
||||
Scout {
|
||||
/// Engine-assigned scout/unit id.
|
||||
unit_id: u32,
|
||||
/// Axial hex `(col, row)` to explore toward.
|
||||
to_hex: (i32, i32),
|
||||
},
|
||||
/// Issue a patrol order for `unit_id` with the given waypoint loop.
|
||||
IssuePatrol {
|
||||
unit_id: u32,
|
||||
waypoints: Vec<(i32, i32)>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Compute the full set of tactical actions for the player whose turn it
|
||||
/// is (`state.current_player`) given the hex-level [`TacticalState`] and
|
||||
/// that player's [`ScoringWeights`].
|
||||
///
|
||||
/// This is the single entry point the `api-gdext::ai::GdAiController`
|
||||
/// bridge calls once per AI-controlled player per turn. The order below
|
||||
/// is stable — port teammates may refine ordering, but the bridge and
|
||||
/// regression suite depend on the concatenation boundary.
|
||||
///
|
||||
/// `deadline` is an optional wall-clock deadline (computed once by the
|
||||
/// caller). When `Some(t)`, the loop checks `Instant::now() >= t` between
|
||||
/// submodule calls and also inside any per-unit / per-city iteration within
|
||||
/// the submodules via `budget_deadline`. Partial work is returned — the
|
||||
/// caller always receives whatever actions were completed before the
|
||||
/// deadline fired. `None` is the legacy unbounded path (default when the
|
||||
/// env var is unset). See p1-22.
|
||||
///
|
||||
/// Stable submodule order:
|
||||
/// 1. [`movement::decide_movement`]
|
||||
/// 2. [`combat_predict::decide_combat`]
|
||||
/// 3. [`settle::decide_settle`]
|
||||
/// 4. [`production::decide_production`]
|
||||
/// 5. [`citizen::decide_citizens`]
|
||||
pub fn decide_tactical_actions(
|
||||
state: &TacticalState,
|
||||
weights: &ScoringWeights,
|
||||
rng: &mut XorShift64,
|
||||
deadline: Option<Instant>,
|
||||
) -> Vec<Action> {
|
||||
let is_expired = |dl: &Option<Instant>| -> bool {
|
||||
dl.map_or(false, |d| Instant::now() >= d)
|
||||
};
|
||||
|
||||
let mut actions = Vec::new();
|
||||
actions.extend(movement::decide_movement(state, weights, rng, deadline));
|
||||
if is_expired(&deadline) {
|
||||
return actions;
|
||||
}
|
||||
actions.extend(combat_predict::decide_combat(state, weights, rng));
|
||||
if is_expired(&deadline) {
|
||||
return actions;
|
||||
}
|
||||
actions.extend(settle::decide_settle(state, weights, rng, deadline));
|
||||
if is_expired(&deadline) {
|
||||
return actions;
|
||||
}
|
||||
actions.extend(production::decide_production(state, weights, rng, deadline));
|
||||
if is_expired(&deadline) {
|
||||
return actions;
|
||||
}
|
||||
actions.extend(citizen::decide_citizens(state, weights, rng, deadline));
|
||||
actions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::evaluator::ScoringWeights;
|
||||
use crate::mcts::XorShift64;
|
||||
use crate::tactical::{
|
||||
decide_tactical_actions, Action, TacticalCity, TacticalMap, TacticalPlayerState,
|
||||
TacticalState, TacticalTile, TacticalUnit,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn tactical_module_compiles() {
|
||||
// Smoke test — the module's type surface must compile and load.
|
||||
// Real coverage comes from per-submodule ports (tasks #4-#7),
|
||||
// `state.rs` round-trip tests, and the regression suite
|
||||
// (task #9).
|
||||
assert!(true);
|
||||
}
|
||||
|
||||
/// Build a state with many units and cities so that the unbounded path
|
||||
/// would visit every unit-city pair in the tactical submodules.
|
||||
fn large_state(n_units: u32, n_cities: u32) -> TacticalState {
|
||||
let mut tiles = Vec::new();
|
||||
for row in 0i32..30 {
|
||||
for col in 0i32..30 {
|
||||
tiles.push(TacticalTile {
|
||||
hex: (col, row),
|
||||
biome: "plains".into(),
|
||||
yields: (2, 1, 0),
|
||||
resource: None,
|
||||
is_coast: false,
|
||||
owner: if col < 15 { Some(0) } else { Some(1) },
|
||||
});
|
||||
}
|
||||
}
|
||||
let map = TacticalMap { width: 30, height: 30, tiles };
|
||||
|
||||
let units_p0: Vec<TacticalUnit> = (0..n_units)
|
||||
.map(|i| TacticalUnit {
|
||||
id: i,
|
||||
kind: "warrior".into(),
|
||||
hex: ((i % 15) as i32, (i / 15) as i32),
|
||||
hp: 10,
|
||||
hp_max: 10,
|
||||
moves_left: 2,
|
||||
fortified: false,
|
||||
can_found_city: false,
|
||||
patrol_order: None,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let units_p1: Vec<TacticalUnit> = (0..n_units)
|
||||
.map(|i| TacticalUnit {
|
||||
id: n_units + i,
|
||||
kind: "warrior".into(),
|
||||
hex: (15 + (i % 15) as i32, (i / 15) as i32),
|
||||
hp: 10,
|
||||
hp_max: 10,
|
||||
moves_left: 2,
|
||||
fortified: false,
|
||||
can_found_city: false,
|
||||
patrol_order: None,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cities_p0: Vec<TacticalCity> = (0..n_cities)
|
||||
.map(|i| TacticalCity {
|
||||
id: i,
|
||||
hex: ((i * 2) as i32, 0),
|
||||
population: 3,
|
||||
tiles_worked: Vec::new(),
|
||||
production_queue: Vec::new(),
|
||||
buildings: Vec::new(),
|
||||
health: 25,
|
||||
is_capital: i == 0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cities_p1: Vec<TacticalCity> = (0..n_cities)
|
||||
.map(|i| TacticalCity {
|
||||
id: n_cities + i,
|
||||
hex: (15 + (i * 2) as i32, 0),
|
||||
population: 3,
|
||||
tiles_worked: Vec::new(),
|
||||
production_queue: Vec::new(),
|
||||
buildings: Vec::new(),
|
||||
health: 25,
|
||||
is_capital: i == 0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
TacticalState {
|
||||
current_player: 0,
|
||||
turn: 50,
|
||||
map,
|
||||
players: vec![
|
||||
TacticalPlayerState {
|
||||
index: 0,
|
||||
clan_id: "blackhammer".into(),
|
||||
gold: 100,
|
||||
happiness_pool: 0,
|
||||
units: units_p0,
|
||||
cities: cities_p0,
|
||||
researched_techs: Vec::new(),
|
||||
relations: vec![0, -1],
|
||||
strategic_axes: Default::default(),
|
||||
race_id: None,
|
||||
strategic_resources: Vec::new(),
|
||||
},
|
||||
TacticalPlayerState {
|
||||
index: 1,
|
||||
clan_id: "ironhold".into(),
|
||||
gold: 100,
|
||||
happiness_pool: 0,
|
||||
units: units_p1,
|
||||
cities: cities_p1,
|
||||
researched_techs: Vec::new(),
|
||||
relations: vec![-1, 0],
|
||||
strategic_axes: Default::default(),
|
||||
race_id: None,
|
||||
strategic_resources: Vec::new(),
|
||||
},
|
||||
],
|
||||
unit_catalog: Vec::new(),
|
||||
difficulty_threshold_mult: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// p1-22 regression: the tactical path must respect a wall-clock budget.
|
||||
///
|
||||
/// Construct a state large enough that the unbounded path would iterate
|
||||
/// many units and city–tile pairs, then set a 50ms deadline. The call
|
||||
/// must return within 500ms regardless of state size, and must produce
|
||||
/// at least one action (partial work counts). Mirrors the pattern from
|
||||
/// `mcts_tree.rs::simulate_parallel_respects_wall_clock_budget`.
|
||||
#[test]
|
||||
fn tactical_budget_respected() {
|
||||
// 100 units × 10 cities × 900-tile map = non-trivial scan cost unbounded.
|
||||
let state = large_state(100, 10);
|
||||
let weights = ScoringWeights::default();
|
||||
let mut rng = XorShift64::new(0xDEAD_BEEF);
|
||||
|
||||
let deadline = Some(Instant::now() + Duration::from_millis(50));
|
||||
|
||||
let wall_start = Instant::now();
|
||||
let actions = decide_tactical_actions(&state, &weights, &mut rng, deadline);
|
||||
let elapsed = wall_start.elapsed();
|
||||
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(500),
|
||||
"tactical path with 50ms budget should return in <500ms; elapsed={elapsed:?}"
|
||||
);
|
||||
// Partial work is fine — but the first submodule (movement) should
|
||||
// have produced at least one action before the budget fired.
|
||||
assert!(
|
||||
!actions.is_empty(),
|
||||
"expected at least one action before budget fired; got none"
|
||||
);
|
||||
}
|
||||
|
||||
/// Unbounded path (deadline=None) must still return all actions and not
|
||||
/// regress on a small state. This is the legacy default behavior guard.
|
||||
#[test]
|
||||
fn tactical_unbounded_produces_actions_on_small_state() {
|
||||
let state = large_state(3, 1);
|
||||
let weights = ScoringWeights::default();
|
||||
let mut rng = XorShift64::new(42);
|
||||
|
||||
let actions = decide_tactical_actions(&state, &weights, &mut rng, None);
|
||||
// Small state: 3 units, 1 city per player. Movement + production should fire.
|
||||
assert!(
|
||||
!actions.is_empty(),
|
||||
"unbounded path should produce actions on a small state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,480 @@
|
|||
//! Hex-level tactical state consumed by [`super::decide_tactical_actions`].
|
||||
//!
|
||||
//! This type tree is the tactical AI's view of the world. It is richer than
|
||||
//! the GPU-compact [`crate::abstract_state::AbstractRolloutState`] (which
|
||||
//! stays the MCTS rollout POD) and carries the per-unit / per-city / per-hex
|
||||
//! data the ported GDScript AI needs to express a turn's decisions.
|
||||
//!
|
||||
//! # Serde contract
|
||||
//!
|
||||
//! Every struct derives `Serialize + Deserialize + PartialEq` — the
|
||||
//! GDExtension bridge (`api-gdext::ai::GdAiController`) round-trips
|
||||
//! `TacticalState` across the FFI boundary as JSON. `PartialEq` exists so
|
||||
//! regression tests (task #9) can snapshot state before/after port.
|
||||
//!
|
||||
//! # Hex addressing
|
||||
//!
|
||||
//! All coordinates are axial `(col, row)` pairs matching the GDScript engine
|
||||
//! convention (`src/game/engine/src/hex_math.gd`). The `TacticalMap::tiles`
|
||||
//! vector is row-major and carries `width * height` entries — the bridge is
|
||||
//! responsible for populating in a stable order so `PartialEq` works.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Top-level tactical state passed to [`super::decide_tactical_actions`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TacticalState {
|
||||
/// Slot index of the player whose turn is being decided.
|
||||
pub current_player: u8,
|
||||
/// Game turn number at the point of decision.
|
||||
pub turn: u32,
|
||||
/// Full map state including terrain, yields, and ownership.
|
||||
pub map: TacticalMap,
|
||||
/// Per-player slots. Indexed by `TacticalPlayerState::index`.
|
||||
pub players: Vec<TacticalPlayerState>,
|
||||
/// Catalog of producible military units with tier + tech gate, populated
|
||||
/// from `units/*.json` by the GDExtension bridge. Consumed by
|
||||
/// `tactical::production::pick_best_melee` to select tier-N units as tech
|
||||
/// unlocks (p0-39). Empty vec falls back to tier-1 `warrior` only.
|
||||
#[serde(default)]
|
||||
pub unit_catalog: Vec<TacticalUnitSpec>,
|
||||
/// Multiplicative scalar applied on top of all personality-axis-derived
|
||||
/// thresholds (p0-24). Easy < 1.0 (overcommits), Hard > 1.0 (waits for
|
||||
/// real superiority). Defaults to 1.0 (normal / unset). Populated from
|
||||
/// `difficulty.json::ai_modifiers.difficulty_threshold_mult` by the bridge.
|
||||
#[serde(default = "default_threshold_mult")]
|
||||
pub difficulty_threshold_mult: f32,
|
||||
}
|
||||
|
||||
fn default_threshold_mult() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Hex map with row-major tile storage.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TacticalMap {
|
||||
/// Grid width in hexes (column count).
|
||||
pub width: u32,
|
||||
/// Grid height in hexes (row count).
|
||||
pub height: u32,
|
||||
/// Row-major tile data, length `width * height`.
|
||||
pub tiles: Vec<TacticalTile>,
|
||||
}
|
||||
|
||||
/// A single map tile.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TacticalTile {
|
||||
/// Axial `(col, row)` coordinates.
|
||||
pub hex: (i32, i32),
|
||||
/// Biome id as defined in `public/games/age-of-dwarves/data/biomes.json`.
|
||||
pub biome: String,
|
||||
/// `(food, production, gold)` yields before city / improvement bonuses.
|
||||
pub yields: (u32, u32, u32),
|
||||
/// Deposit / resource id present on the tile, if any
|
||||
/// (e.g. `"iron_ore"`, `"gold_vein"`).
|
||||
pub resource: Option<String>,
|
||||
/// Whether the tile is adjacent to ocean / lake water.
|
||||
pub is_coast: bool,
|
||||
/// Owning player slot, if any. Unowned tiles are `None`.
|
||||
pub owner: Option<u8>,
|
||||
}
|
||||
|
||||
/// Per-player state: economy, units, cities, diplomacy, research.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TacticalPlayerState {
|
||||
/// Slot index. Must equal the index of this struct inside
|
||||
/// [`TacticalState::players`].
|
||||
pub index: u8,
|
||||
/// Personality id from `ai_personalities.json` (e.g. `"blackhammer"`).
|
||||
pub clan_id: String,
|
||||
/// Treasury, signed to permit transient deficits while the turn is being
|
||||
/// decided.
|
||||
pub gold: i32,
|
||||
/// Happiness pool — preserved from the old `AbstractRolloutState` signal
|
||||
/// so the citizen / production submodules can keep the food-floor check.
|
||||
pub happiness_pool: i32,
|
||||
/// All units owned by this player.
|
||||
pub units: Vec<TacticalUnit>,
|
||||
/// All cities owned by this player.
|
||||
pub cities: Vec<TacticalCity>,
|
||||
/// Tech ids this player has researched (for gating `Action::SetProduction`).
|
||||
pub researched_techs: Vec<String>,
|
||||
/// Diplomatic relations per opponent slot. `<0` war, `0` peace, `>0`
|
||||
/// friend. Self-slot is 0.
|
||||
pub relations: Vec<i8>,
|
||||
/// Race id (e.g. `"dwarf"`, `"human"`). `None` for fixtures predating
|
||||
/// race-gated unit selection. Consumed by
|
||||
/// `tactical::production::pick_best_melee` to filter units whose
|
||||
/// `race_required` doesn't match.
|
||||
#[serde(default)]
|
||||
pub race_id: Option<String>,
|
||||
/// Strategic resource ids the player currently controls (tiles they own
|
||||
/// that provide `iron_ore`, `horses`, etc.). Consumed by
|
||||
/// `tactical::production::pick_best_melee` to filter units whose
|
||||
/// `requires_resource` isn't available.
|
||||
#[serde(default)]
|
||||
pub strategic_resources: Vec<String>,
|
||||
/// Clan personality axes on the `1..=10` scale (neutral = 5). Consumed by
|
||||
/// `tactical::thresholds` for personality-emergent posture / retreat /
|
||||
/// chase / siege thresholds (p0-37). Empty map = baseline (axis=5 for
|
||||
/// every axis) for back-compat with fixtures predating this field.
|
||||
///
|
||||
/// Flexible deserializer accepts GDScript's float-formatted integers
|
||||
/// (`JSON.stringify` emits `3.0` rather than `3`) without panic.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "mc_core::gd_compat::de_btreemap_string_i32_flexible"
|
||||
)]
|
||||
pub strategic_axes: BTreeMap<String, i32>,
|
||||
}
|
||||
|
||||
/// A unit on the map.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TacticalUnit {
|
||||
/// Engine-assigned unique id — matches `Action::MoveUnit::unit_id`.
|
||||
pub id: u32,
|
||||
/// Unit kind id (e.g. `"warrior"`, `"founder"`, `"scout"`, or race-prefixed
|
||||
/// like `"dwarf_warrior"`). Both generic and race-prefixed kinds coexist;
|
||||
/// see `data/units/<id>.json` (generic) and `resources/units/<race>_<id>.json`
|
||||
/// (race-specific). For founder detection prefer `is_founder()` over a
|
||||
/// kind-string match — clan-themed founders like `"dwarf_tribe"` carry the
|
||||
/// `can_found_city` flag and would be missed by string match.
|
||||
pub kind: String,
|
||||
/// Current axial `(col, row)` position.
|
||||
pub hex: (i32, i32),
|
||||
/// Current hit points.
|
||||
pub hp: u32,
|
||||
/// Max hit points (for healing / damage-prediction math).
|
||||
pub hp_max: u32,
|
||||
/// Remaining movement points this turn.
|
||||
pub moves_left: u32,
|
||||
/// Fortify-in-place flag.
|
||||
pub fortified: bool,
|
||||
/// True when this unit can found a city — data-driven from the engine's
|
||||
/// `unit.can_found_city` flag. NEVER match on `kind` string alone because
|
||||
/// clan-themed founder units (e.g. `"dwarf_tribe"`) DO NOT literally spell
|
||||
/// "settler" or "founder". Default `false` for serde back-compat with
|
||||
/// fixtures that predate this field.
|
||||
#[serde(default)]
|
||||
pub can_found_city: bool,
|
||||
/// Active patrol standing order waypoints, if any. `None` means idle or fortified.
|
||||
/// Stored as waypoint list only (no cursor/mode) so the AI can score IssuePatrol
|
||||
/// without depending on mc-turn. The full `PatrolOrder` lives on `MapUnit` in mc-turn.
|
||||
#[serde(default)]
|
||||
pub patrol_order: Option<Vec<(i32, i32)>>,
|
||||
}
|
||||
|
||||
impl TacticalUnit {
|
||||
/// True when this unit can found a city. Checks the data-driven flag first;
|
||||
/// falls back to kind-string matching for test fixtures that omit the flag.
|
||||
pub fn is_founder(&self) -> bool {
|
||||
self.can_found_city || matches!(self.kind.as_str(), "settler" | "founder")
|
||||
}
|
||||
}
|
||||
|
||||
/// Specification for a producible military unit — carries enough data for the
|
||||
/// production layer to select tier-appropriate units as tech unlocks (p0-39).
|
||||
///
|
||||
/// Populated from `public/games/age-of-dwarves/data/units/*.json` by the
|
||||
/// GDExtension bridge and handed through on every `TacticalState`. Empty vec =
|
||||
/// back-compat (tier-1 fallback only) for fixtures predating p0-39.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TacticalUnitSpec {
|
||||
/// Unit id (e.g. `"warrior"`, `"pikeman"`).
|
||||
pub id: String,
|
||||
/// Tier on the 1..N content ladder.
|
||||
pub tier: u32,
|
||||
/// Tech gate — unit is buildable when the player has researched this id.
|
||||
/// `None` means always available (tier-1 starting units).
|
||||
pub tech_required: Option<String>,
|
||||
/// Unit-type classification mirroring `units/*.json::unit_type`:
|
||||
/// `"military"` | `"worker"` | `"founder"` | `"scout"` | …
|
||||
pub unit_type: String,
|
||||
/// Strategic resource gate — unit buildable only when the player owns at
|
||||
/// least one tile providing this resource id (e.g. `"iron_ore"` for
|
||||
/// cavalry). `None` means no resource requirement. Filtered by
|
||||
/// `tactical::production::pick_best_melee` to avoid queueing units the
|
||||
/// engine's strategic-gate check will reject.
|
||||
#[serde(default)]
|
||||
pub requires_resource: Option<String>,
|
||||
/// Race gate — unit buildable only when the player's race matches this id
|
||||
/// (e.g. `"dwarf"` for berserker / ironwarden / forge_titan). `None`
|
||||
/// means no race restriction.
|
||||
#[serde(default)]
|
||||
pub race_required: Option<String>,
|
||||
/// Clan IDs that prefer this unit (e.g. `["ironhold", "deepforge"]` for
|
||||
/// `mountain_king`). Drives clan personality differentiation in the
|
||||
/// production picker (p1-37). Empty vec = neutral / shared by all clans.
|
||||
#[serde(default)]
|
||||
pub clan_affinity: Vec<String>,
|
||||
/// Archetype label mirroring `units/*.json::archetype`:
|
||||
/// `"light_melee"` | `"heavy_melee"` | `"anti_cavalry"` | `"ranged"` |
|
||||
/// `"siege"` | `"cavalry_walker"` | `"wild"` | `"civilian"`. `None` for
|
||||
/// fixtures predating p1-34.
|
||||
#[serde(default)]
|
||||
pub archetype: Option<String>,
|
||||
}
|
||||
|
||||
/// A city.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TacticalCity {
|
||||
/// Engine-assigned unique id — matches `Action::SetProduction::city_id`.
|
||||
pub id: u32,
|
||||
/// City-center axial `(col, row)`.
|
||||
pub hex: (i32, i32),
|
||||
/// Current population.
|
||||
pub population: u32,
|
||||
/// Axial hexes currently worked by this city's citizens. Used by the
|
||||
/// citizen-assignment submodule to avoid double-assigning tiles.
|
||||
pub tiles_worked: Vec<(i32, i32)>,
|
||||
/// Production queue of item ids, head at index 0.
|
||||
pub production_queue: Vec<String>,
|
||||
/// Building ids already constructed in this city. The production picker
|
||||
/// reads this to skip duplicates.
|
||||
pub buildings: Vec<String>,
|
||||
/// City HP for siege / damage-prediction math.
|
||||
pub health: u32,
|
||||
/// Whether this city is the player's capital.
|
||||
pub is_capital: bool,
|
||||
}
|
||||
|
||||
/// Per-turn ephemeral state passed to `GdAiController::decide_actions` after
|
||||
/// the tile catalog has been moved into the Rust-resident `TacticalMap`.
|
||||
///
|
||||
/// This struct carries everything that changes every turn but excludes the
|
||||
/// map tiles (which are held in `GdAiController::cached_map` and only pushed
|
||||
/// once at game-start via `set_map` + incrementally via `update_tile`).
|
||||
///
|
||||
/// `decide_actions` parses this from JSON and assembles a full `TacticalState`
|
||||
/// by combining it with the cached `TacticalMap`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TacticalEphemerals {
|
||||
/// Slot index of the player whose turn is being decided.
|
||||
pub current_player: u8,
|
||||
/// Game turn number at the point of decision.
|
||||
pub turn: u32,
|
||||
/// Per-player slots. Indexed by `TacticalPlayerState::index`.
|
||||
pub players: Vec<TacticalPlayerState>,
|
||||
/// Catalog of producible military units (see `TacticalState::unit_catalog`).
|
||||
#[serde(default)]
|
||||
pub unit_catalog: Vec<TacticalUnitSpec>,
|
||||
/// Difficulty threshold multiplier (see `TacticalState::difficulty_threshold_mult`).
|
||||
#[serde(default = "default_threshold_mult")]
|
||||
pub difficulty_threshold_mult: f32,
|
||||
}
|
||||
|
||||
impl TacticalEphemerals {
|
||||
/// Combine with a cached `TacticalMap` to produce a full `TacticalState`
|
||||
/// ready for `decide_tactical_actions`.
|
||||
pub fn into_tactical_state(self, map: TacticalMap) -> TacticalState {
|
||||
TacticalState {
|
||||
current_player: self.current_player,
|
||||
turn: self.turn,
|
||||
map,
|
||||
players: self.players,
|
||||
unit_catalog: self.unit_catalog,
|
||||
difficulty_threshold_mult: self.difficulty_threshold_mult,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn non_trivial_state() -> TacticalState {
|
||||
let tiles: Vec<TacticalTile> = (0..10)
|
||||
.flat_map(|row| {
|
||||
(0..10).map(move |col| TacticalTile {
|
||||
hex: (col, row),
|
||||
biome: if (col + row) % 3 == 0 { "hills" } else { "plains" }.into(),
|
||||
yields: (2, 1, 0),
|
||||
resource: if col == 3 && row == 3 { Some("iron_ore".into()) } else { None },
|
||||
is_coast: col == 0 || col == 9,
|
||||
owner: if col < 3 {
|
||||
Some(0)
|
||||
} else if col > 6 {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let units = vec![
|
||||
TacticalUnit {
|
||||
id: 1,
|
||||
kind: "warrior".into(),
|
||||
hex: (1, 1),
|
||||
hp: 10,
|
||||
hp_max: 10,
|
||||
moves_left: 2,
|
||||
fortified: false,
|
||||
can_found_city: false,
|
||||
patrol_order: None,
|
||||
..Default::default()
|
||||
},
|
||||
TacticalUnit {
|
||||
id: 2,
|
||||
kind: "settler".into(),
|
||||
hex: (2, 2),
|
||||
hp: 5,
|
||||
hp_max: 5,
|
||||
moves_left: 2,
|
||||
fortified: false,
|
||||
can_found_city: false,
|
||||
patrol_order: None,
|
||||
..Default::default()
|
||||
},
|
||||
TacticalUnit {
|
||||
id: 3,
|
||||
kind: "scout".into(),
|
||||
hex: (1, 2),
|
||||
hp: 6,
|
||||
hp_max: 6,
|
||||
moves_left: 3,
|
||||
fortified: false,
|
||||
can_found_city: false,
|
||||
patrol_order: None,
|
||||
..Default::default()
|
||||
},
|
||||
TacticalUnit {
|
||||
id: 4,
|
||||
kind: "warrior".into(),
|
||||
hex: (8, 8),
|
||||
hp: 8,
|
||||
hp_max: 10,
|
||||
moves_left: 0,
|
||||
fortified: true,
|
||||
can_found_city: false,
|
||||
patrol_order: None,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
let cities = vec![
|
||||
TacticalCity {
|
||||
id: 10,
|
||||
hex: (1, 1),
|
||||
population: 3,
|
||||
tiles_worked: vec![(0, 1), (1, 0), (2, 1)],
|
||||
production_queue: vec!["warrior".into()],
|
||||
buildings: vec!["granary".into()],
|
||||
health: 25,
|
||||
is_capital: true,
|
||||
},
|
||||
TacticalCity {
|
||||
id: 20,
|
||||
hex: (8, 8),
|
||||
population: 2,
|
||||
tiles_worked: vec![(7, 8), (8, 7)],
|
||||
production_queue: vec!["forge".into(), "warrior".into()],
|
||||
buildings: Vec::new(),
|
||||
health: 20,
|
||||
is_capital: true,
|
||||
},
|
||||
];
|
||||
|
||||
TacticalState {
|
||||
current_player: 0,
|
||||
turn: 42,
|
||||
map: TacticalMap {
|
||||
width: 10,
|
||||
height: 10,
|
||||
tiles,
|
||||
},
|
||||
players: vec![
|
||||
TacticalPlayerState {
|
||||
index: 0,
|
||||
clan_id: "blackhammer".into(),
|
||||
gold: 100,
|
||||
happiness_pool: 3,
|
||||
units: units.iter().take(3).cloned().collect(),
|
||||
cities: cities.iter().take(1).cloned().collect(),
|
||||
researched_techs: vec!["bronze_working".into()],
|
||||
relations: vec![0, -1],
|
||||
strategic_axes: ::std::collections::BTreeMap::new(),
|
||||
race_id: None,
|
||||
strategic_resources: Vec::new(),
|
||||
},
|
||||
TacticalPlayerState {
|
||||
index: 1,
|
||||
clan_id: "goldbeard".into(),
|
||||
gold: 60,
|
||||
happiness_pool: -1,
|
||||
units: units.iter().skip(3).cloned().collect(),
|
||||
cities: cities.iter().skip(1).cloned().collect(),
|
||||
researched_techs: Vec::new(),
|
||||
relations: vec![-1, 0],
|
||||
strategic_axes: ::std::collections::BTreeMap::new(),
|
||||
race_id: None,
|
||||
strategic_resources: Vec::new(),
|
||||
},
|
||||
],
|
||||
unit_catalog: Vec::new(),
|
||||
difficulty_threshold_mult: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tactical_state_roundtrips_through_json() {
|
||||
let state = non_trivial_state();
|
||||
let json = serde_json::to_string(&state).expect("serialize");
|
||||
let back: TacticalState = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(state, back);
|
||||
// Sanity: the serialized form actually contains the expected 100 tiles.
|
||||
assert_eq!(state.map.tiles.len(), 100);
|
||||
assert_eq!(state.players.len(), 2);
|
||||
assert_eq!(state.players[0].units.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tactical_state_roundtrips() {
|
||||
let empty = TacticalState {
|
||||
current_player: 0,
|
||||
turn: 0,
|
||||
map: TacticalMap {
|
||||
width: 0,
|
||||
height: 0,
|
||||
tiles: Vec::new(),
|
||||
},
|
||||
players: Vec::new(),
|
||||
unit_catalog: Vec::new(),
|
||||
difficulty_threshold_mult: 1.0,
|
||||
};
|
||||
let json = serde_json::to_string(&empty).expect("serialize");
|
||||
let back: TacticalState = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(empty, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_roundtrips_through_json() {
|
||||
use crate::tactical::Action;
|
||||
let variants = vec![
|
||||
Action::MoveUnit { unit_id: 1, to_hex: (3, 4) },
|
||||
Action::AttackTarget { attacker_id: 1, target_id: 2 },
|
||||
Action::Fortify { unit_id: 5 },
|
||||
Action::Heal { unit_id: 7 },
|
||||
Action::FoundCity { settler_id: 9, at_hex: (-2, 3) },
|
||||
Action::SetProduction {
|
||||
city_id: 10,
|
||||
item_id: "forge".into(),
|
||||
},
|
||||
Action::AssignCitizen {
|
||||
city_id: 10,
|
||||
tile_hex: (1, 0),
|
||||
},
|
||||
Action::Scout { unit_id: 3, to_hex: (0, 5) },
|
||||
];
|
||||
for a in &variants {
|
||||
let json = serde_json::to_string(a).expect("serialize");
|
||||
let back: Action = serde_json::from_str(&json).expect("deserialize");
|
||||
// Action doesn't derive PartialEq — re-serialize and compare strings.
|
||||
let back_json = serde_json::to_string(&back).expect("re-serialize");
|
||||
assert_eq!(json, back_json, "action variant lost fidelity: {a:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -202,6 +202,18 @@
|
|||
"volume_db": -7.0,
|
||||
"bus": "SFX"
|
||||
},
|
||||
"unit.siege.spawn": {
|
||||
"stream": "audio/sfx/units/siege/spawn.ogg",
|
||||
"volume_db": -8.0,
|
||||
"bus": "SFX",
|
||||
"description": "Heavy hit-jingle — 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."
|
||||
},
|
||||
"unit.siege.attack": {
|
||||
"streams": [
|
||||
"audio/sfx/units/siege/bombard_01.ogg",
|
||||
|
|
@ -217,12 +229,6 @@
|
|||
"volume_db": -8.0,
|
||||
"bus": "SFX"
|
||||
},
|
||||
"building.civic.complete": {
|
||||
"stream": "audio/sfx/buildings/build_complete_civic.ogg",
|
||||
"volume_db": -5.0,
|
||||
"bus": "SFX",
|
||||
"description": "Low ceremonial bell on civic-building completion."
|
||||
},
|
||||
"building.production.complete": {
|
||||
"stream": "audio/sfx/buildings/build_complete_prod.ogg",
|
||||
"volume_db": -5.0,
|
||||
|
|
@ -440,41 +446,29 @@
|
|||
"bus": "SFX",
|
||||
"description": "Light scholarly tap \u2014 research building completed."
|
||||
},
|
||||
"building.complete": {
|
||||
"stream": "audio/sfx/buildings/generic_complete.ogg",
|
||||
"building.economy.complete": {
|
||||
"stream": "audio/sfx/buildings/economy_complete.ogg",
|
||||
"volume_db": -6.0,
|
||||
"bus": "SFX",
|
||||
"description": "Stone-on-plate impact \u2014 kind-only fallback for any building category."
|
||||
"description": "Plucked-string flourish \u2014 economy building completed."
|
||||
},
|
||||
"complete": {
|
||||
"stream": "audio/sfx/generic/complete.ogg",
|
||||
"volume_db": -7.0,
|
||||
"bus": "SFX",
|
||||
"description": "Plate impact \u2014 bare-kind fallback for any 'complete' event."
|
||||
},
|
||||
"attack": {
|
||||
"stream": "audio/sfx/generic/attack.ogg",
|
||||
"volume_db": -7.0,
|
||||
"bus": "SFX",
|
||||
"description": "Generic attack swing \u2014 last-resort fallback for unclassified entities."
|
||||
},
|
||||
"hit": {
|
||||
"stream": "audio/sfx/generic/hit.ogg",
|
||||
"volume_db": -7.0,
|
||||
"bus": "SFX",
|
||||
"description": "Generic impact \u2014 last-resort fallback for unclassified entities."
|
||||
},
|
||||
"death": {
|
||||
"stream": "audio/sfx/generic/death.ogg",
|
||||
"building.food.complete": {
|
||||
"stream": "audio/sfx/buildings/food_complete.ogg",
|
||||
"volume_db": -6.0,
|
||||
"bus": "SFX",
|
||||
"description": "Generic fall thud \u2014 last-resort fallback for unclassified entities."
|
||||
"description": "Plucked-string flourish \u2014 food building completed."
|
||||
},
|
||||
"spawn": {
|
||||
"stream": "audio/sfx/fauna/spawn.ogg",
|
||||
"volume_db": -9.0,
|
||||
"building.naval.complete": {
|
||||
"stream": "audio/sfx/buildings/naval_complete.ogg",
|
||||
"volume_db": -6.0,
|
||||
"bus": "SFX",
|
||||
"description": "Brush rustle \u2014 bare-kind fallback (aliases wild_spawn texture for unclassified spawn events)."
|
||||
"description": "Plucked-string flourish \u2014 naval building completed."
|
||||
},
|
||||
"building.resource.complete": {
|
||||
"stream": "audio/sfx/buildings/resource_complete.ogg",
|
||||
"volume_db": -6.0,
|
||||
"bus": "SFX",
|
||||
"description": "Plucked-string flourish \u2014 resource building completed."
|
||||
}
|
||||
},
|
||||
"music": {
|
||||
|
|
|
|||
|
|
@ -32,15 +32,17 @@ Each row records one `.ogg` shipped under `public/games/age-of-dwarves/assets/au
|
|||
| `audio/music/victory_economic_b.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%231%20%5BExploration%5D%20by%20Juhani%20Junkala.zip#Exploration4 - Prairie Nights.ogg) | Juhani Junkala (SubspaceAudio | OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps |
|
||||
| `audio/music/victory_science_a.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%231%20%5BExploration%5D%20by%20Juhani%20Junkala.zip#Exploration3 - Tha'el Mines.ogg) | Juhani Junkala (SubspaceAudio | OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps |
|
||||
| `audio/music/victory_science_b.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%231%20%5BExploration%5D%20by%20Juhani%20Junkala.zip#Exploration6 - Tropical Island.ogg) | Juhani Junkala (SubspaceAudio | OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps |
|
||||
| `audio/sfx/buildings/build_complete_civic.ogg` | CC0-1.0 | [link](https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/confirmation_001.wav) | Kenney (Calinou repackage) | loudnorm I=-16/TP=-3+wav→ogg 128kbps | 2026-04-27 |
|
||||
| `audio/sfx/buildings/build_complete_def.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_heavy_002.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |
|
||||
| `audio/sfx/buildings/build_complete_mil.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactBell_heavy_002.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |
|
||||
| `audio/sfx/buildings/build_complete_prod.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactMetal_heavy_002.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/buildings/culture_complete.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactWood_heavy_004.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/buildings/diplomacy_complete.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_medium_000.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/buildings/generic_complete.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_medium_003.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/buildings/economy_complete.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Pizzicato jingles/jingles_PIZZI04.ogg) | Kenney (Music Jingles) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-30 |
|
||||
| `audio/sfx/buildings/food_complete.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Pizzicato jingles/jingles_PIZZI05.ogg) | Kenney (Music Jingles) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-30 |
|
||||
| `audio/sfx/buildings/infrastructure_complete.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactMining_001.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/buildings/naval_complete.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Pizzicato jingles/jingles_PIZZI06.ogg) | Kenney (Music Jingles) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-30 |
|
||||
| `audio/sfx/buildings/research_complete.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_light_004.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/buildings/resource_complete.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Pizzicato jingles/jingles_PIZZI08.ogg) | Kenney (Music Jingles) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-30 |
|
||||
| `audio/sfx/buildings/wonder_built.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactWood_heavy_000.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/buildings/wonder_built_own.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/fanfare_0.ogg) | Spring Spring (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-28 |
|
||||
| `audio/sfx/buildings/wonder_built_rival.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactBell_heavy_002.ogg) | Kenney (Impact Sounds) | loudnorm I=-22/TP=-6+ogg 128kbps (extra-quiet for distant feel) | 2026-04-28 |
|
||||
|
|
@ -75,10 +77,6 @@ Each row records one `.ogg` shipped under `public/games/age-of-dwarves/assets/au
|
|||
| `audio/sfx/fauna/predator_hurt_02.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#hurt_02.ogg) | rubberduck (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |
|
||||
| `audio/sfx/fauna/predator_spawn.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#howl.ogg) | rubberduck (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |
|
||||
| `audio/sfx/fauna/spawn.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#bug_01.ogg) | rubberduck (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |
|
||||
| `audio/sfx/generic/attack.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactGeneric_light_000.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/generic/complete.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_medium_004.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/generic/death.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactGeneric_light_004.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/generic/hit.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactGeneric_light_002.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/ui/border_expanded.ogg` | CC0-1.0 | [link](https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/pluck_001.wav) | Kenney (Calinou repackage) | loudnorm I=-16/TP=-3+wav→ogg 128kbps | 2026-04-27 |
|
||||
| `audio/sfx/ui/culture_researched.ogg` | CC0-1.0 | [link](https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/confirmation_003.wav) | Kenney (Calinou repackage) | loudnorm I=-16/TP=-3+wav→ogg 128kbps | 2026-04-27 |
|
||||
| `audio/sfx/ui/research_start.ogg` | CC0-1.0 | [link](https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/tick_002.wav) | Kenney (Calinou repackage) | loudnorm I=-16/TP=-3+wav→ogg 128kbps | 2026-04-27 |
|
||||
|
|
@ -107,9 +105,11 @@ Each row records one `.ogg` shipped under `public/games/age-of-dwarves/assets/au
|
|||
| `audio/sfx/units/siege/bombard_02.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_heavy_004.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |
|
||||
| `audio/sfx/units/siege/death.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_medium_002.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/units/siege/hit.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlank_medium_004.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/units/siege/spawn.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Hit jingles/jingles_HIT08.ogg) | Kenney (Music Jingles) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-30 |
|
||||
| `audio/sfx/units/support/attack.ogg` | CC0-1.0 | [link](https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/pluck_002.wav) | Kenney (Calinou repackage) | loudnorm I=-16/TP=-3+wav→ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/units/support/death.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactSoft_heavy_002.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/units/support/hit.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactSoft_heavy_001.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/units/support/spawn.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Pizzicato jingles/jingles_PIZZI09.ogg) | Kenney (Music Jingles) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-30 |
|
||||
| `audio/sfx/weather/blizzard.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/sfx_loops.zip#weird_01.ogg) | rubberduck (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/weather/drought.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/sfx_loops.zip#weird_03.ogg) | rubberduck (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
| `audio/sfx/weather/heat_wave.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/sfx_loops.zip#weird_02.ogg) | rubberduck (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-29 |
|
||||
|
|
|
|||
Binary file not shown.
BIN
public/resources/audio/sfx/buildings/economy_complete.ogg
Normal file
BIN
public/resources/audio/sfx/buildings/economy_complete.ogg
Normal file
Binary file not shown.
BIN
public/resources/audio/sfx/buildings/food_complete.ogg
Normal file
BIN
public/resources/audio/sfx/buildings/food_complete.ogg
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/resources/audio/sfx/buildings/naval_complete.ogg
Normal file
BIN
public/resources/audio/sfx/buildings/naval_complete.ogg
Normal file
Binary file not shown.
BIN
public/resources/audio/sfx/buildings/resource_complete.ogg
Normal file
BIN
public/resources/audio/sfx/buildings/resource_complete.ogg
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
public/resources/audio/sfx/units/siege/spawn.ogg
Normal file
BIN
public/resources/audio/sfx/units/siege/spawn.ogg
Normal file
Binary file not shown.
BIN
public/resources/audio/sfx/units/support/spawn.ogg
Normal file
BIN
public/resources/audio/sfx/units/support/spawn.ogg
Normal file
Binary file not shown.
|
|
@ -26,7 +26,6 @@ audio/sfx/ui/unit_promoted.ogg,https://github.com/Calinou/kenney-interface-sound
|
|||
audio/sfx/ui/unit_moved.ogg,https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/click_004.wav,CC0-1.0,Kenney (Calinou repackage),loudnorm I=-16/TP=-3+wav→ogg 128kbps,2026-04-27
|
||||
audio/sfx/city/city_founded.ogg,https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/bong_001.wav,CC0-1.0,Kenney (Calinou repackage),loudnorm I=-16/TP=-3+wav→ogg 128kbps,2026-04-27
|
||||
audio/sfx/city/city_starved.ogg,https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/error_004.wav,CC0-1.0,Kenney (Calinou repackage),loudnorm I=-16/TP=-3+wav→ogg 128kbps,2026-04-27
|
||||
audio/sfx/buildings/build_complete_civic.ogg,https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/confirmation_001.wav,CC0-1.0,Kenney (Calinou repackage),loudnorm I=-16/TP=-3+wav→ogg 128kbps,2026-04-27
|
||||
audio/sfx/units/melee/attack_01.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactMetal_light_000.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-27
|
||||
audio/sfx/units/melee/attack_02.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactMetal_light_002.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-27
|
||||
audio/sfx/units/melee/attack_03.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactMetal_light_003.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-27
|
||||
|
|
@ -118,12 +117,7 @@ audio/sfx/buildings/culture_complete.ogg,https://kenney.nl/media/pages/assets/im
|
|||
audio/sfx/buildings/diplomacy_complete.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_medium_000.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/buildings/infrastructure_complete.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactMining_001.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/buildings/research_complete.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_light_004.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/generic/attack.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactGeneric_light_000.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/generic/hit.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactGeneric_light_002.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/generic/death.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactGeneric_light_004.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/buildings/build_complete_prod.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactMetal_heavy_002.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/buildings/generic_complete.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_medium_003.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/generic/complete.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_medium_004.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/units/support/attack.ogg,https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/pluck_002.wav,CC0-1.0,Kenney (Calinou repackage),loudnorm I=-16/TP=-3+wav→ogg 128kbps,2026-04-29
|
||||
audio/sfx/combat/unit_killed.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_light_002.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/weather/blizzard.ogg,https://opengameart.org/sites/default/files/sfx_loops.zip#weird_01.ogg,CC0-1.0,rubberduck (OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
|
|
@ -134,4 +128,10 @@ audio/sfx/fauna/herbivore_attack.ogg,https://opengameart.org/sites/default/files
|
|||
audio/music/defeat_culture.ogg,https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%234%20%5BCalm%5D%20by%20Juhani%20Junkala_0.zip#Calm3 - Peaceful Days.ogg,CC0-1.0,Juhani Junkala (SubspaceAudio, OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/music/defeat_science.ogg,https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%231%20%5BExploration%5D%20by%20Juhani%20Junkala.zip#Exploration5 - Sneaking Around.ogg,CC0-1.0,Juhani Junkala (SubspaceAudio, OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/music/defeat_economic.ogg,https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%234%20%5BCalm%5D%20by%20Juhani%20Junkala_0.zip#Calm4 - Sand Castles.ogg,CC0-1.0,Juhani Junkala (SubspaceAudio, OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/music/defeat_domination.ogg,https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%231%20%5BExploration%5D%20by%20Juhani%20Junkala.zip#Exploration2 - Military Base.ogg,CC0-1.0,Juhani Junkala (SubspaceAudio, OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/music/defeat_domination.ogg,https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%231%20%5BExploration%5D%20by%20Juhani%20Junkala.zip#Exploration2 - Military Base.ogg,CC0-1.0,Juhani Junkala (SubspaceAudio, OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-29
|
||||
audio/sfx/buildings/economy_complete.ogg,https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Pizzicato jingles/jingles_PIZZI04.ogg,CC0-1.0,Kenney (Music Jingles),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-30
|
||||
audio/sfx/buildings/food_complete.ogg,https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Pizzicato jingles/jingles_PIZZI05.ogg,CC0-1.0,Kenney (Music Jingles),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-30
|
||||
audio/sfx/buildings/naval_complete.ogg,https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Pizzicato jingles/jingles_PIZZI06.ogg,CC0-1.0,Kenney (Music Jingles),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-30
|
||||
audio/sfx/buildings/resource_complete.ogg,https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Pizzicato jingles/jingles_PIZZI08.ogg,CC0-1.0,Kenney (Music Jingles),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-30
|
||||
audio/sfx/units/siege/spawn.ogg,https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Hit jingles/jingles_HIT08.ogg,CC0-1.0,Kenney (Music Jingles),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-30
|
||||
audio/sfx/units/support/spawn.ogg,https://kenney.nl/media/pages/assets/music-jingles/4f5dd770b7-1677590399/kenney_music-jingles.zip#Audio/Pizzicato jingles/jingles_PIZZI09.ogg,CC0-1.0,Kenney (Music Jingles),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-30
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -317,6 +317,12 @@ func _play_stream(stream: AudioStream, entry: Dictionary) -> void:
|
|||
## `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]:
|
||||
# Two-level chain: bespoke per-entity key, then categorical
|
||||
# `<kind>.<sub>.<event_kind>`. The kind-only and bare fallbacks
|
||||
# (`<kind>.<event_kind>`, `<event_kind>`) were removed: they were
|
||||
# unreachable once every concrete category had a manifest entry,
|
||||
# and keeping them invited silent-fallback drift instead of
|
||||
# fail-loud authoring discipline.
|
||||
var keys: Array[String] = []
|
||||
keys.append("%s.%s" % [entity_id, event_kind])
|
||||
|
||||
|
|
@ -326,9 +332,7 @@ func _resolve_keys(entity_id: String, event_kind: String) -> Array[String]:
|
|||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -168,20 +168,17 @@ func test_missing_key_emits_audio_asset_missing_signal() -> void:
|
|||
|
||||
|
||||
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.
|
||||
# Two-level chain after the bare-fallback removal: bespoke per-entity
|
||||
# key, then `<kind>.<sub>.<event_kind>` if DataLoader knows the entity.
|
||||
# Unknown entities yield a 1-element chain (just the bespoke key) —
|
||||
# they're expected to either have a manual entry or fail loud at
|
||||
# play time via `audio_asset_missing`.
|
||||
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")
|
||||
|
|
@ -258,11 +255,30 @@ func test_every_unit_resolution_chain_terminates_in_manifest() -> void:
|
|||
_assert_chain_resolves(unit_id, kind)
|
||||
|
||||
|
||||
func test_every_building_completion_chain_terminates_in_manifest() -> void:
|
||||
func test_every_building_category_has_complete_cue() -> void:
|
||||
# Closure check is category-keyed, not building-keyed: every distinct
|
||||
# `category` value used by any building must have a manifest entry at
|
||||
# `building.<category>.complete`. Per-building bespoke entries are
|
||||
# optional. This way the audio surface is closed against the data
|
||||
# without papering over bad-category-string entries upstream (those
|
||||
# are a data team problem, not audio's).
|
||||
var bldgs: Dictionary = DataLoader.get_data("buildings") as Dictionary
|
||||
assert_gt(bldgs.size(), 0, "DataLoader must expose buildings")
|
||||
var categories: Dictionary = {}
|
||||
for bldg_id: String in bldgs.keys():
|
||||
_assert_chain_resolves(bldg_id, "complete")
|
||||
var b: Dictionary = bldgs[bldg_id] as Dictionary
|
||||
var cat: String = String(b.get("category", "")).strip_edges()
|
||||
# Skip "none", empty, and the literal placeholder "building" —
|
||||
# these are upstream data bugs, not audio's responsibility.
|
||||
if cat.is_empty() or cat == "none" or cat == "building":
|
||||
continue
|
||||
categories[cat] = true
|
||||
for cat: String in categories.keys():
|
||||
var key: String = "building.%s.complete" % cat
|
||||
assert_true(
|
||||
AudioManager._sfx_events.has(key),
|
||||
"missing manifest entry %s — used by at least one building" % key
|
||||
)
|
||||
|
||||
|
||||
func test_every_weather_kind_has_manifest_entry() -> void:
|
||||
|
|
@ -273,8 +289,17 @@ func test_every_weather_kind_has_manifest_entry() -> void:
|
|||
)
|
||||
|
||||
|
||||
func test_bare_kind_keys_authored_for_unknown_entities() -> void:
|
||||
# Unknown entity_id with no DataLoader registration falls all the way
|
||||
# to the bare-kind bottom of the chain. These four keys must be authored.
|
||||
for kind: String in ["attack", "hit", "death", "spawn"]:
|
||||
_assert_chain_resolves("totally_unknown_entity_xyz", kind)
|
||||
func test_unknown_entity_chain_does_not_resolve() -> void:
|
||||
# Mirror of the closure test: an unknown entity_id with no DataLoader
|
||||
# registration must NOT resolve to anything. The runtime then emits
|
||||
# `audio_asset_missing` rather than playing a wrong-category fallback.
|
||||
# Catches accidental re-introduction of bare-kind catch-alls.
|
||||
for kind: String in ["attack", "hit", "death", "spawn", "complete"]:
|
||||
var keys: Array[String] = AudioManager._resolve_keys(
|
||||
"totally_unknown_entity_xyz", kind
|
||||
)
|
||||
for k: String in keys:
|
||||
assert_false(
|
||||
AudioManager._sfx_events.has(k),
|
||||
"resolver leaked a fallback for unknown entity: %s" % k
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue