Merge remote-tracking branch 'origin/main' into acs2-recovery-candidate

This commit is contained in:
ACS-Recovery 2026-04-29 21:39:53 -07:00
commit 8ab4239bf3
20 changed files with 2164 additions and 64 deletions

View file

@ -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 citytile 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"
);
}
}

View file

@ -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:?}");
}
}
}

View file

@ -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": {

View file

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

Binary file not shown.

Binary file not shown.

View file

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

View file

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

View file

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