feat(grid): Introduce terrain blending system with new structs/enums and blending functions for smooth transitions.

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-29 13:13:23 -07:00
parent 666907f986
commit 9c4d635df3
2 changed files with 261 additions and 0 deletions

View file

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

View file

@ -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<TerrainBlendEntry>,
}
/// 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<Self, serde_json::Error> {
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<Item = (&(String, String), &String)> {
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}"
);
}
}
}