From 9c4d635df3e9cf301febb84c4bc0fa0e54eb3628 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 29 Apr 2026 13:13:23 -0700 Subject: [PATCH] =?UTF-8?q?feat(grid):=20=E2=9C=A8=20Introduce=20terrain?= =?UTF-8?q?=20blending=20system=20with=20new=20structs/enums=20and=20blend?= =?UTF-8?q?ing=20functions=20for=20smooth=20transitions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-core/src/grid/mod.rs | 3 + .../crates/mc-core/src/grid/terrain_blend.rs | 258 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 src/simulator/crates/mc-core/src/grid/terrain_blend.rs diff --git a/src/simulator/crates/mc-core/src/grid/mod.rs b/src/simulator/crates/mc-core/src/grid/mod.rs index b0a739a7..61c12628 100644 --- a/src/simulator/crates/mc-core/src/grid/mod.rs +++ b/src/simulator/crates/mc-core/src/grid/mod.rs @@ -12,6 +12,9 @@ pub use edge::{ canonical_edge, edges_of_hex, hexes_of_edge, reverse_dir, EdgeFeatures, EdgeId, EdgeOccupant, }; +pub mod terrain_blend; +pub use terrain_blend::{TerrainBlendEntry, TerrainBlendTable}; + /// Reasons a centre-to-centre move can be rejected by /// [`GridState::validate_centre_to_centre_move`]. Distinct variants so the /// caller (UI, AI, combat) can route on the cause. diff --git a/src/simulator/crates/mc-core/src/grid/terrain_blend.rs b/src/simulator/crates/mc-core/src/grid/terrain_blend.rs new file mode 100644 index 00000000..83cf7c64 --- /dev/null +++ b/src/simulator/crates/mc-core/src/grid/terrain_blend.rs @@ -0,0 +1,258 @@ +//! Edge-terrain blend lookup (HEX_GEOMETRY.md §8 ecotones). +//! +//! Each hex edge carries a derived terrain — the blend of the two adjacent +//! tiles' centre terrains. A `plains+mountains` edge is *foothills*; a +//! `forest+ocean` edge is *riverside_forest*. This module owns the +//! pure-data lookup; consumers (combat, movement, renderer) call +//! [`TerrainBlendTable::lookup`] with the two parent terrain ids and get +//! `Some(edge_terrain_id)` or `None` (for same-terrain pairs and +//! undefined pairings — the caller defaults to the centre terrain +//! unchanged). +//! +//! ## Symmetry guarantee +//! +//! `lookup("plains", "mountains")` returns the same result as +//! `lookup("mountains", "plains")`. The table internally canonicalizes +//! pairs by alphabetic sort. +//! +//! ## Loading +//! +//! [`TerrainBlendTable::from_json_str`] parses the canonical +//! `public/games/age-of-dwarves/data/terrain/terrain_blends.json` +//! format. mc-core does not load files directly; the GDExtension / +//! WASM bridge reads the JSON and hands the string to this loader. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Schema for one entry in `terrain_blends.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerrainBlendEntry { + /// Unordered pair of terrain ids; the table canonicalizes via sort. + pub pair: [String; 2], + /// Edge terrain id assigned to that pair. + pub edge_terrain: String, +} + +/// Schema for the JSON file root. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TerrainBlendFile { + #[serde(rename = "_doc", default)] + _doc: String, + blends: Vec, +} + +/// In-memory blend table. Build via [`Self::from_json_str`] from the +/// canonical JSON, then call [`Self::lookup`] for each edge that needs +/// its blend resolved. +#[derive(Debug, Clone, Default)] +pub struct TerrainBlendTable { + /// Map from (smaller, larger) terrain id pair to edge terrain id. + /// Keys are canonical-sorted so `(plains, mountains)` and + /// `(mountains, plains)` resolve to the same entry. + entries: HashMap<(String, String), String>, +} + +impl TerrainBlendTable { + /// Parse a JSON string in the `terrain_blends.json` format. + /// + /// Duplicate pairs are *replaced* — later entries win. Returns a + /// `serde_json::Error` if the JSON is malformed. + pub fn from_json_str(s: &str) -> Result { + let file: TerrainBlendFile = serde_json::from_str(s)?; + let mut entries = HashMap::with_capacity(file.blends.len()); + for entry in file.blends { + let key = canonical_pair(&entry.pair[0], &entry.pair[1]); + entries.insert(key, entry.edge_terrain); + } + Ok(Self { entries }) + } + + /// Number of distinct pair entries in the table. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// True if the table has no entries. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Look up the blend terrain for an edge between `host` and `neighbour`. + /// + /// - Returns `Some(edge_terrain_id)` if the pair is in the table. + /// - Returns `None` if `host == neighbour` (same-terrain edge has no + /// transition; caller uses centre terrain unchanged). + /// - Returns `None` if the pair is not in the table (caller defaults + /// to centre terrain). + /// + /// Symmetric: `lookup(a, b) == lookup(b, a)` for any inputs. + pub fn lookup(&self, host: &str, neighbour: &str) -> Option<&str> { + if host == neighbour { + return None; + } + let key = canonical_pair(host, neighbour); + self.entries.get(&key).map(|s| s.as_str()) + } + + /// Iterate over all entries as `(canonical_pair, edge_terrain_id)`. + /// Useful for validators that want to confirm every blend has a + /// corresponding terrain JSON entry. + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } +} + +/// Canonical-sort two terrain ids alphabetically. The smaller string +/// goes first regardless of input order. +fn canonical_pair(a: &str, b: &str) -> (String, String) { + if a <= b { + (a.to_string(), b.to_string()) + } else { + (b.to_string(), a.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal blend table covering the 10 canonical Game 1 ecotones. + /// Mirrors the structure of `data/terrain/terrain_blends.json` so the + /// tests verify the same shape without filesystem access. + fn sample_table() -> TerrainBlendTable { + TerrainBlendTable::from_json_str(SAMPLE_JSON) + .expect("sample JSON should parse cleanly") + } + + const SAMPLE_JSON: &str = r#" + { + "_doc": "test sample", + "blends": [ + { "pair": ["forest", "plains"], "edge_terrain": "grass_fringe" }, + { "pair": ["mountains", "plains"], "edge_terrain": "foothills" }, + { "pair": ["ocean", "plains"], "edge_terrain": "shore" }, + { "pair": ["forest", "mountains"], "edge_terrain": "wooded_foothills" }, + { "pair": ["forest", "ocean"], "edge_terrain": "riverside_forest" }, + { "pair": ["mountains", "ocean"], "edge_terrain": "cliff" } + ] + } + "#; + + #[test] + fn parses_sample_json() { + let table = sample_table(); + assert_eq!(table.len(), 6); + } + + #[test] + fn lookup_returns_blend_for_known_pair() { + let table = sample_table(); + assert_eq!(table.lookup("plains", "mountains"), Some("foothills")); + assert_eq!(table.lookup("forest", "ocean"), Some("riverside_forest")); + } + + #[test] + fn lookup_is_symmetric_regardless_of_argument_order() { + let table = sample_table(); + for (a, b) in [ + ("plains", "mountains"), + ("forest", "ocean"), + ("mountains", "ocean"), + ] { + assert_eq!( + table.lookup(a, b), + table.lookup(b, a), + "lookup({a}, {b}) and lookup({b}, {a}) differed" + ); + } + } + + #[test] + fn lookup_returns_none_for_same_terrain_pair() { + let table = sample_table(); + // Same-terrain edges have no transition — the caller defaults + // to the centre terrain unchanged. + for terrain in ["plains", "forest", "mountains", "ocean"] { + assert_eq!( + table.lookup(terrain, terrain), + None, + "{terrain}+{terrain} should return None" + ); + } + } + + #[test] + fn lookup_returns_none_for_undefined_pair() { + let table = sample_table(); + // The sample table has no `desert+swamp` entry; the caller will + // default to the centre terrain unchanged. + assert_eq!(table.lookup("desert", "swamp"), None); + assert_eq!(table.lookup("snow", "tundra"), None); + } + + #[test] + fn duplicate_pairs_resolve_to_last_entry() { + let json = r#" + { + "_doc": "duplicate test", + "blends": [ + { "pair": ["a", "b"], "edge_terrain": "first" }, + { "pair": ["b", "a"], "edge_terrain": "second" } + ] + } + "#; + let table = TerrainBlendTable::from_json_str(json).unwrap(); + assert_eq!(table.len(), 1); + assert_eq!(table.lookup("a", "b"), Some("second")); + } + + #[test] + fn from_json_str_rejects_malformed_input() { + assert!(TerrainBlendTable::from_json_str("not json").is_err()); + assert!( + TerrainBlendTable::from_json_str(r#"{"blends": "not an array"}"#).is_err() + ); + } + + #[test] + fn iter_yields_every_entry() { + let table = sample_table(); + let collected: Vec<_> = table.iter().collect(); + assert_eq!(collected.len(), 6); + } + + /// End-to-end: parse the actual production `terrain_blends.json` and + /// verify the canonical Game 1 blend entries resolve. This protects + /// against the data file drifting from the schema. + #[test] + fn production_terrain_blends_json_parses_and_has_canonical_entries() { + const PROD_JSON: &str = include_str!( + "../../../../../../public/games/age-of-dwarves/data/terrain/terrain_blends.json" + ); + let table = TerrainBlendTable::from_json_str(PROD_JSON) + .expect("production terrain_blends.json must parse"); + + // Every Game 1 canonical blend must be present. + let canonical = [ + ("plains", "mountains", "foothills"), + ("plains", "forest", "grass_fringe"), + ("plains", "ocean", "shore"), + ("forest", "mountains", "wooded_foothills"), + ("forest", "ocean", "riverside_forest"), + ("mountains", "ocean", "cliff"), + ("forest", "desert", "scrub_edge"), + ("plains", "desert", "arid_plains"), + ("mountains", "desert", "badlands"), + ("forest", "swamp", "bog_edge"), + ]; + for (a, b, expected) in canonical { + assert_eq!( + table.lookup(a, b), + Some(expected), + "production blend table missing or wrong: {a}+{b} → expected {expected}" + ); + } + } +}