From 5fd9fa325a588a17610206347bdd211fbd64bd94 Mon Sep 17 00:00:00 2001 From: carlid Date: Thu, 6 Aug 2026 23:11:18 +0200 Subject: [PATCH 1/4] Add composable layered world generator V2 --- Cargo.lock | 2 + crates/worldgen/src/lib.rs | 2 + crates/worldgen/src/v2/mod.rs | 196 ++++++ crates/worldgen/src/v2/model.rs | 504 ++++++++++++++ crates/worldgen/src/v2/noise.rs | 72 ++ crates/worldgen/src/v2/passes.rs | 994 +++++++++++++++++++++++++++ crates/worldgen/src/v2/pipeline.rs | 253 +++++++ crates/worldgen/src/v2/validation.rs | 295 ++++++++ docs/README.md | 3 + docs/implementation.md | 7 + docs/worldgen-v2.md | 136 ++++ maps/README.md | 17 + tools/mapgen/Cargo.toml | 2 + tools/mapgen/src/main.rs | 259 ++++++- 14 files changed, 2726 insertions(+), 16 deletions(-) create mode 100644 crates/worldgen/src/v2/mod.rs create mode 100644 crates/worldgen/src/v2/model.rs create mode 100644 crates/worldgen/src/v2/noise.rs create mode 100644 crates/worldgen/src/v2/passes.rs create mode 100644 crates/worldgen/src/v2/pipeline.rs create mode 100644 crates/worldgen/src/v2/validation.rs create mode 100644 docs/worldgen-v2.md diff --git a/Cargo.lock b/Cargo.lock index bd2a7e6..7fb8ecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3950,6 +3950,8 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "hex-core", + "serde", "serde_json", "worldgen", ] diff --git a/crates/worldgen/src/lib.rs b/crates/worldgen/src/lib.rs index c27beae..56e2600 100644 --- a/crates/worldgen/src/lib.rs +++ b/crates/worldgen/src/lib.rs @@ -10,6 +10,8 @@ use hex_core::{ }; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +pub mod v2; + pub const NEUTRAL_PLAYER: u32 = 0; pub const PLAYER_ONE: u32 = 1; pub const PLAYER_TWO: u32 = 2; diff --git a/crates/worldgen/src/v2/mod.rs b/crates/worldgen/src/v2/mod.rs new file mode 100644 index 0000000..d8a3124 --- /dev/null +++ b/crates/worldgen/src/v2/mod.rs @@ -0,0 +1,196 @@ +//! Layered, chunk-addressable deterministic world generation. +//! +//! Version one remains the compatibility generator used by existing matches. +//! This module provides the version-two contract: independent physical, +//! hydrology, biome, edge, and gameplay layers composed by typed passes. + +// WorldSpec validates dimensions and total cell count before any generator +// pass runs. Keeping cell IDs as u32 and coordinates as i32 is the serialized +// contract, so conversions inside that validated boundary are intentional. +#![allow( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_precision_loss, + clippy::cast_sign_loss +)] + +mod model; +mod noise; +mod passes; +mod pipeline; +mod validation; + +pub use model::{ + Biome, CellLayers, ChunkCell, Crossing, EdgeLayers, GameplayCell, Landform, LayeredWorld, + PassProvenance, RiverCell, Surface, TerrainChunk, TerrainTags, V2Manifest, WorldLayer, + WorldParameters, WorldSpec, +}; +pub use passes::{ + BiomePass, CoastConnectivityPass, ContinentPass, GameplayPass, HydrologyPass, LakeBasinPass, + LandformPass, MountainPass, SpawnPass, +}; +pub use pipeline::{ElevationWrite, PassReport, WorldPass, WorldPatch, WorldPipeline, generate}; +pub use validation::{V2ValidationReport, validate}; + +/// Layered map format and default pipeline version. +pub const GENERATOR_VERSION: u16 = 2; + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use hex_core::{HexDirection, HexEdge}; + + use super::*; + + fn spec(size: u32, seed: u64) -> WorldSpec { + let mut spec = WorldSpec::new(format!("layered-{size}"), size, size, seed); + spec.player_count = 8; + spec.chunk_size = 32; + spec + } + + #[test] + fn layered_generation_is_deterministic_composed_and_valid() { + let spec = spec(96, 42); + let first = generate(&spec).expect("layered map should generate"); + let second = generate(&spec).expect("layered map should repeat"); + assert_eq!(first, second); + let report = validate(&first).expect("layered map should validate"); + assert_eq!(report.total_cells, 96 * 96); + assert!(report.land_cells > 0); + assert_eq!(first.manifest.generator_version, GENERATOR_VERSION); + assert_eq!(first.manifest.pipeline.len(), 9); + assert_eq!(first.manifest.pipeline[0].name, "continent"); + assert!( + first.manifest.pipeline[0] + .writes + .contains(&WorldLayer::Elevation) + ); + assert!( + first + .cells() + .iter() + .any(|cell| cell.surface == Surface::Land && cell.river.is_some()) + ); + assert!(first.cells().iter().any(|cell| { + cell.surface == Surface::Land + && cell.river.is_some() + && matches!( + cell.landform, + Landform::Plain | Landform::Valley | Landform::Hill + ) + })); + } + + #[test] + fn lakes_are_connected_and_have_stable_water_body_ids() { + let mut spec = spec(128, 7); + spec.parameters.lake_depth_threshold = 6; + let world = generate(&spec).expect("lake-rich map should generate"); + let ids = world + .cells() + .iter() + .filter(|cell| cell.surface == Surface::Lake) + .map(|cell| cell.water_body_id.expect("lake has water-body id")) + .collect::>(); + assert!(!ids.is_empty()); + assert_eq!( + validate(&world) + .expect("lakes should validate") + .water_bodies, + ids.len() + ); + } + + #[test] + fn river_connections_remain_consistent_across_chunk_boundaries() { + let world = generate(&spec(192, 99)).expect("layered map should generate"); + let mut crossed_boundary = false; + for (cell_id, cell) in world.cells().iter().enumerate() { + let Some(river) = cell.river else { + continue; + }; + let next = world + .neighbor_id(cell_id as u32, river.outflow) + .expect("validated outflow"); + let first = world.coordinate(cell_id as u32).expect("cell coordinate"); + let second = world.coordinate(next).expect("next coordinate"); + let size = u32::from(world.manifest.chunk_size); + let chunk_for = |coordinate: hex_core::Axial| hex_core::ChunkCoord { + q: (coordinate.q - world.manifest.q_min).div_euclid(size as i32), + r: (coordinate.r - world.manifest.r_min).div_euclid(size as i32), + }; + if chunk_for(first) != chunk_for(second) { + crossed_boundary = true; + let first_chunk = chunk_for(first); + let extracted = world.chunk(first_chunk).expect("source chunk"); + assert!( + extracted + .cells + .iter() + .any(|cell| cell.cell_id == cell_id as u32) + ); + } + } + assert!( + crossed_boundary, + "fixture should exercise a cross-chunk river" + ); + } + + struct RoadPass; + + impl WorldPass for RoadPass { + fn name(&self) -> &'static str { + "test-roads" + } + + fn run(&self, world: &LayeredWorld, _seed: u64) -> Result { + let first = world.manifest.spawn_cells[0]; + let second = HexDirection::ALL + .into_iter() + .map(|direction| first.neighbor(direction)) + .find(|neighbor| { + world + .cell(*neighbor) + .is_some_and(|cell| cell.gameplay.passable) + }) + .ok_or_else(|| "spawn has no road neighbor".to_owned())?; + Ok(WorldPatch { + edges: vec![( + HexEdge::new(first, second).expect("adjacent road"), + EdgeLayers { + road_level: 1, + ..EdgeLayers::default() + }, + )], + ..WorldPatch::default() + }) + } + } + + #[test] + fn independent_edge_pass_composes_without_replacing_cell_layers() { + let spec = spec(96, 123); + let base = generate(&spec).expect("base map"); + let (composed, _) = WorldPipeline::default_v2() + .with_pass(RoadPass) + .run(&spec) + .expect("road pass"); + assert_eq!(base.cells(), composed.cells()); + assert_eq!(composed.edges.len(), 1); + let json = serde_json::to_vec(&composed).expect("edge map should serialize as an array"); + let decoded: LayeredWorld = + serde_json::from_slice(&json).expect("edge map should deserialize"); + assert_eq!(decoded, composed); + } + + #[test] + fn scale_fixture_256_square_is_chunk_complete() { + let world = generate(&spec(256, 2026)).expect("scale fixture should generate"); + let report = validate(&world).expect("scale fixture should validate"); + assert_eq!(report.total_cells, 256 * 256); + assert_eq!(report.chunks, 64); + } +} diff --git a/crates/worldgen/src/v2/model.rs b/crates/worldgen/src/v2/model.rs new file mode 100644 index 0000000..e4b969c --- /dev/null +++ b/crates/worldgen/src/v2/model.rs @@ -0,0 +1,504 @@ +use std::collections::BTreeMap; + +use hex_core::{Axial, ChunkCoord, HexDirection, HexEdge}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; + +use super::GENERATOR_VERSION; + +pub const DEFAULT_CHUNK_SIZE: u16 = 64; +pub const DEFAULT_MACRO_CELL_SIZE: u16 = 32; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct WorldParameters { + pub mountain_density_bps: u16, + pub lake_depth_threshold: i16, + pub river_threshold: u32, +} + +impl Default for WorldParameters { + fn default() -> Self { + Self { + mountain_density_bps: 3_000, + lake_depth_threshold: 18, + river_threshold: 0, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct WorldSpec { + pub name: String, + pub width: u32, + pub height: u32, + pub seed: u64, + pub player_count: u16, + pub chunk_size: u16, + pub macro_cell_size: u16, + pub parameters: WorldParameters, +} + +impl WorldSpec { + #[must_use] + pub fn new(name: impl Into, width: u32, height: u32, seed: u64) -> Self { + Self { + name: name.into(), + width, + height, + seed, + player_count: 2, + chunk_size: DEFAULT_CHUNK_SIZE, + macro_cell_size: DEFAULT_MACRO_CELL_SIZE, + parameters: WorldParameters::default(), + } + } + + /// Checks dimensions, IDs, player count, and pass configuration. + /// + /// # Errors + /// + /// Returns a descriptive error when the specification cannot be represented + /// by the layered map contract. + pub fn validate(&self) -> Result<(), String> { + if self.width < 24 || self.height < 24 { + return Err("layered maps must be at least 24 by 24".to_owned()); + } + if !(2..=500).contains(&self.player_count) { + return Err("player count must be between 2 and 500".to_owned()); + } + if self.chunk_size == 0 || self.macro_cell_size == 0 { + return Err("chunk and macro-cell sizes must be nonzero".to_owned()); + } + if self.parameters.mountain_density_bps > 10_000 { + return Err("mountain density must not exceed 10,000 basis points".to_owned()); + } + if self.parameters.lake_depth_threshold <= 0 { + return Err("lake depth threshold must be positive".to_owned()); + } + if self.width > i32::MAX as u32 || self.height > i32::MAX as u32 { + return Err("map dimensions exceed axial coordinate storage".to_owned()); + } + let count = u64::from(self.width) * u64::from(self.height); + if count >= u64::from(u32::MAX) { + return Err("layered maps must contain fewer than u32::MAX cells".to_owned()); + } + Ok(()) + } + + #[must_use] + pub const fn q_min(&self) -> i32 { + -(self.width as i32 / 2) + } + + #[must_use] + pub const fn r_min(&self) -> i32 { + -(self.height as i32 / 2) + } + + #[must_use] + pub const fn cell_count(&self) -> usize { + self.width as usize * self.height as usize + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[repr(u8)] +pub enum Surface { + #[default] + Land, + Ocean, + Lake, +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[repr(u8)] +pub enum Landform { + #[default] + Plain, + Hill, + Mountain, + Valley, + Plateau, +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[repr(u8)] +pub enum Biome { + #[default] + TemperateGrassland, + Forest, + Wetland, + Dryland, + Alpine, + Tundra, +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TerrainTags(pub u16); + +impl TerrainTags { + pub const COAST: u16 = 1 << 0; + pub const RIVERBANK: u16 = 1 << 1; + pub const SOURCE: u16 = 1 << 2; + pub const OUTLET: u16 = 1 << 3; + + pub fn insert(&mut self, flag: u16) { + self.0 |= flag; + } + + #[must_use] + pub const fn contains(self, flag: u16) -> bool { + self.0 & flag != 0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct RiverCell { + /// Direction toward the next river, lake, or ocean cell. + pub outflow: HexDirection, + /// Bit `HexDirection::index()` is set for every upstream river neighbor. + pub inflow_mask: u8, + pub order: u8, + pub discharge: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct GameplayCell { + pub passable: bool, + pub capturable: bool, + pub habitable: bool, + pub movement_cost: u16, + pub military_capacity: u16, + pub civilian_capacity: u16, +} + +impl Default for GameplayCell { + fn default() -> Self { + Self { + passable: true, + capturable: true, + habitable: true, + movement_cost: 10, + military_capacity: 100, + civilian_capacity: 100, + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct CellLayers { + pub elevation: i16, + pub surface: Surface, + pub landform: Landform, + pub biome: Biome, + pub moisture: u8, + pub fertility: u8, + pub water_body_id: Option, + pub river: Option, + pub tags: TerrainTags, + pub gameplay: GameplayCell, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[repr(u8)] +pub enum Crossing { + #[default] + None, + Ford, + Bridge, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct EdgeLayers { + pub road_level: u8, + pub crossing: Crossing, + pub movement_modifier_bps: i16, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub enum WorldLayer { + Elevation, + Surface, + Landform, + Biome, + Moisture, + Fertility, + Hydrology, + Tags, + Gameplay, + Edges, + Spawns, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PassProvenance { + pub name: String, + pub seed: u64, + pub reads: Vec, + pub writes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct V2Manifest { + pub name: String, + pub generator_version: u16, + pub width: u32, + pub height: u32, + pub q_min: i32, + pub r_min: i32, + pub seed: u64, + pub player_count: u16, + pub chunk_size: u16, + pub macro_cell_size: u16, + pub parameters: WorldParameters, + pub pipeline: Vec, + pub content_hash: u64, + pub land_cells: u32, + pub lake_cells: u32, + pub river_cells: u32, + pub spawn_cells: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LayeredWorld { + pub manifest: V2Manifest, + cells: Vec, + pub edges: BTreeMap, +} + +impl Serialize for LayeredWorld { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + struct WireWorld<'a> { + manifest: &'a V2Manifest, + cells: &'a [CellLayers], + edges: Vec<(&'a HexEdge, &'a EdgeLayers)>, + } + + WireWorld { + manifest: &self.manifest, + cells: &self.cells, + edges: self.edges.iter().collect(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for LayeredWorld { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireWorld { + manifest: V2Manifest, + cells: Vec, + edges: Vec<(HexEdge, EdgeLayers)>, + } + + let wire = WireWorld::deserialize(deserializer)?; + let mut edges = BTreeMap::new(); + for (edge, layers) in wire.edges { + if edges.insert(edge, layers).is_some() { + return Err(D::Error::custom(format!( + "duplicate layered edge {:?}->{:?}", + edge.a, edge.b + ))); + } + } + Ok(Self { + manifest: wire.manifest, + cells: wire.cells, + edges, + }) + } +} + +impl LayeredWorld { + pub(crate) fn empty(spec: &WorldSpec) -> Self { + Self { + manifest: V2Manifest { + name: spec.name.clone(), + generator_version: GENERATOR_VERSION, + width: spec.width, + height: spec.height, + q_min: spec.q_min(), + r_min: spec.r_min(), + seed: spec.seed, + player_count: spec.player_count, + chunk_size: spec.chunk_size, + macro_cell_size: spec.macro_cell_size, + parameters: spec.parameters.clone(), + pipeline: Vec::new(), + content_hash: 0, + land_cells: 0, + lake_cells: 0, + river_cells: 0, + spawn_cells: Vec::new(), + }, + cells: vec![CellLayers::default(); spec.cell_count()], + edges: BTreeMap::new(), + } + } + + #[must_use] + pub const fn width(&self) -> u32 { + self.manifest.width + } + + #[must_use] + pub const fn height(&self) -> u32 { + self.manifest.height + } + + #[must_use] + pub fn cells(&self) -> &[CellLayers] { + &self.cells + } + + #[must_use] + pub fn cells_mut(&mut self) -> &mut [CellLayers] { + &mut self.cells + } + + #[must_use] + pub fn cell_id(&self, coordinate: Axial) -> Option { + let column = coordinate.q.checked_sub(self.manifest.q_min)?; + let row = coordinate.r.checked_sub(self.manifest.r_min)?; + if column < 0 + || row < 0 + || column >= self.manifest.width as i32 + || row >= self.manifest.height as i32 + { + return None; + } + Some(row as u32 * self.manifest.width + column as u32) + } + + #[must_use] + pub fn coordinate(&self, cell_id: u32) -> Option { + if cell_id as usize >= self.cells.len() { + return None; + } + let column = cell_id % self.manifest.width; + let row = cell_id / self.manifest.width; + Some(Axial::new( + self.manifest.q_min + column as i32, + self.manifest.r_min + row as i32, + )) + } + + #[must_use] + pub fn cell(&self, coordinate: Axial) -> Option<&CellLayers> { + self.cell_id(coordinate) + .and_then(|cell_id| self.cells.get(cell_id as usize)) + } + + pub fn cell_mut(&mut self, coordinate: Axial) -> Option<&mut CellLayers> { + let cell_id = self.cell_id(coordinate)?; + self.cells.get_mut(cell_id as usize) + } + + #[must_use] + pub fn neighbor_id(&self, cell_id: u32, direction: HexDirection) -> Option { + let coordinate = self.coordinate(cell_id)?; + self.cell_id(coordinate.neighbor(direction)) + } + + #[must_use] + pub fn chunks_wide(&self) -> u32 { + self.width().div_ceil(u32::from(self.manifest.chunk_size)) + } + + #[must_use] + pub fn chunks_high(&self) -> u32 { + self.height().div_ceil(u32::from(self.manifest.chunk_size)) + } + + /// Extracts one zero-based storage chunk, including sparse edges touching it. + #[must_use] + pub fn chunk(&self, coordinate: ChunkCoord) -> Option { + if coordinate.q < 0 || coordinate.r < 0 { + return None; + } + let size = u32::from(self.manifest.chunk_size); + let column_start = u32::try_from(coordinate.q).ok()?.checked_mul(size)?; + let row_start = u32::try_from(coordinate.r).ok()?.checked_mul(size)?; + if column_start >= self.width() || row_start >= self.height() { + return None; + } + let width = size.min(self.width() - column_start); + let height = size.min(self.height() - row_start); + let mut cells = Vec::with_capacity(width as usize * height as usize); + for local_row in 0..height { + for local_column in 0..width { + let cell_id = (row_start + local_row) * self.width() + column_start + local_column; + let coordinate = self.coordinate(cell_id)?; + cells.push(ChunkCell { + cell_id, + coordinate, + layers: self.cells[cell_id as usize].clone(), + }); + } + } + let q_min = self.manifest.q_min + column_start as i32; + let r_min = self.manifest.r_min + row_start as i32; + let q_max = q_min + width as i32; + let r_max = r_min + height as i32; + let edges = self + .edges + .iter() + .filter(|(edge, _)| { + let contains = |cell: Axial| { + cell.q >= q_min && cell.q < q_max && cell.r >= r_min && cell.r < r_max + }; + contains(edge.a) || contains(edge.b) + }) + .map(|(edge, layers)| (*edge, layers.clone())) + .collect(); + Some(TerrainChunk { + coordinate, + width, + height, + cells, + edges, + }) + } + + pub(crate) fn refresh_manifest_counts(&mut self) { + self.manifest.land_cells = self + .cells + .iter() + .filter(|cell| cell.surface == Surface::Land) + .count() as u32; + self.manifest.lake_cells = self + .cells + .iter() + .filter(|cell| cell.surface == Surface::Lake) + .count() as u32; + self.manifest.river_cells = self + .cells + .iter() + .filter(|cell| cell.river.is_some()) + .count() as u32; + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ChunkCell { + pub cell_id: u32, + pub coordinate: Axial, + pub layers: CellLayers, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TerrainChunk { + pub coordinate: ChunkCoord, + pub width: u32, + pub height: u32, + pub cells: Vec, + pub edges: Vec<(HexEdge, EdgeLayers)>, +} diff --git a/crates/worldgen/src/v2/noise.rs b/crates/worldgen/src/v2/noise.rs new file mode 100644 index 0000000..b13fc40 --- /dev/null +++ b/crates/worldgen/src/v2/noise.rs @@ -0,0 +1,72 @@ +pub fn mix(seed: u64, x: i32, y: i32) -> u64 { + let mut value = seed + ^ u64::from(u32::from_ne_bytes(x.to_ne_bytes())).wrapping_mul(0x9E37_79B1_85EB_CA87) + ^ u64::from(u32::from_ne_bytes(y.to_ne_bytes())).wrapping_mul(0xC2B2_AE3D_27D4_EB4F); + value ^= value >> 30; + value = value.wrapping_mul(0xBF58_476D_1CE4_E5B9); + value ^= value >> 27; + value = value.wrapping_mul(0x94D0_49BB_1331_11EB); + value ^ (value >> 31) +} + +pub fn pass_seed(root: u64, name: &str) -> u64 { + name.bytes() + .fold(root ^ 0xA076_1D64_78BD_642F, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0xE703_7ED1_A0B4_28DB) + }) +} + +fn smooth(value: i64) -> i64 { + // Q16 smoothstep: t²(3 - 2t). + let square = (value * value) >> 16; + (square * (3 * 65_536 - 2 * value)) >> 16 +} + +fn lerp(first: i64, second: i64, amount: i64) -> i64 { + first + (((second - first) * amount) >> 16) +} + +/// Interpolated deterministic value noise in approximately `-1024..=1024`. +pub fn value_noise(seed: u64, x: u32, y: u32, scale: u32) -> i32 { + let scale = scale.max(1); + let grid_x = x / scale; + let grid_y = y / scale; + let local_x = i64::from(x % scale) * 65_536 / i64::from(scale); + let local_y = i64::from(y % scale) * 65_536 / i64::from(scale); + let sample = |dx: u32, dy: u32| { + let hash = mix( + seed, + i32::try_from(grid_x + dx).unwrap_or(i32::MAX), + i32::try_from(grid_y + dy).unwrap_or(i32::MAX), + ); + i64::try_from(hash % 2_049).unwrap_or_default() - 1_024 + }; + let x_amount = smooth(local_x); + let y_amount = smooth(local_y); + let top = lerp(sample(0, 0), sample(1, 0), x_amount); + let bottom = lerp(sample(0, 1), sample(1, 1), x_amount); + i32::try_from(lerp(top, bottom, y_amount)).unwrap_or_default() +} + +pub fn fractal_noise(seed: u64, x: u32, y: u32, base_scale: u32) -> i32 { + let scales = [ + base_scale.max(1), + (base_scale / 2).max(1), + (base_scale / 4).max(1), + ]; + let weights = [5_i32, 3, 1]; + scales + .into_iter() + .zip(weights) + .enumerate() + .map(|(octave, (scale, weight))| { + value_noise( + seed ^ (octave as u64).wrapping_mul(0x9E37_79B9), + x, + y, + scale, + ) * weight + }) + .sum::() + / weights.into_iter().sum::() +} diff --git a/crates/worldgen/src/v2/passes.rs b/crates/worldgen/src/v2/passes.rs new file mode 100644 index 0000000..1817b95 --- /dev/null +++ b/crates/worldgen/src/v2/passes.rs @@ -0,0 +1,994 @@ +use std::{ + cmp::Reverse, + collections::{BTreeSet, BinaryHeap, VecDeque}, +}; + +use hex_core::{Axial, HexDirection}; + +use super::{ + Biome, GameplayCell, Landform, LayeredWorld, RiverCell, Surface, TerrainTags, WorldLayer, + noise::{fractal_noise, mix}, + pipeline::{ElevationWrite, PassReport, WorldPass, WorldPatch}, +}; + +pub struct ContinentPass; +pub struct CoastConnectivityPass; +pub struct MountainPass; +pub struct LakeBasinPass; +pub struct HydrologyPass; +pub struct LandformPass; +pub struct BiomePass; +pub struct GameplayPass; +pub struct SpawnPass; + +fn report(changed_cells: usize, notes: impl IntoIterator) -> PassReport { + PassReport { + changed_cells, + notes: notes.into_iter().collect(), + ..PassReport::default() + } +} + +impl WorldPass for ContinentPass { + fn name(&self) -> &'static str { + "continent" + } + + fn writes(&self) -> &'static [WorldLayer] { + &[WorldLayer::Elevation, WorldLayer::Surface, WorldLayer::Tags] + } + + fn run(&self, world: &LayeredWorld, seed: u64) -> Result { + let width = world.width(); + let height = world.height(); + let base_scale = u32::from(world.manifest.macro_cell_size).saturating_mul(4); + let mut elevations = Vec::with_capacity(world.cells().len()); + let mut surfaces = Vec::with_capacity(world.cells().len()); + let mut land_mask = vec![false; world.cells().len()]; + for row in 0..height { + for column in 0..width { + let id = row * width + column; + let x = (i64::from(column) * 2 + 1) * 1_024 / i64::from(width) - 1_024; + let y = (i64::from(row) * 2 + 1) * 1_024 / i64::from(height) - 1_024; + let radial = (x * x + y * y + x * y / 3) / 1_024; + let noise = i64::from(fractal_noise(seed, column, row, base_scale)); + let elevation = 440_i64 - radial / 2 + noise * 9 / 32; + let elevation = i16::try_from(elevation.clamp(-1_024, 1_024)) + .map_err(|_| "continent elevation overflow".to_owned())?; + let surface = if elevation > 0 { + Surface::Land + } else { + Surface::Ocean + }; + land_mask[id as usize] = surface == Surface::Land; + elevations.push((id, ElevationWrite::Set(elevation))); + surfaces.push((id, surface)); + } + } + + let mut tags = Vec::new(); + for id in 0..world.cells().len() as u32 { + if !land_mask[id as usize] { + continue; + } + if HexDirection::ALL.into_iter().any(|direction| { + world + .neighbor_id(id, direction) + .is_some_and(|neighbor| !land_mask[neighbor as usize]) + }) { + tags.push((id, TerrainTags(TerrainTags::COAST))); + } + } + let land = land_mask.iter().filter(|value| **value).count(); + Ok(WorldPatch { + elevation: elevations, + surfaces, + tags, + report: report( + world.cells().len(), + [format!( + "created {land} land cells with interpolated multi-scale coast noise" + )], + ), + ..WorldPatch::default() + }) + } +} + +impl WorldPass for CoastConnectivityPass { + fn name(&self) -> &'static str { + "coast-connectivity" + } + + fn reads(&self) -> &'static [WorldLayer] { + &[WorldLayer::Elevation, WorldLayer::Surface] + } + + fn writes(&self) -> &'static [WorldLayer] { + &[WorldLayer::Elevation, WorldLayer::Surface] + } + + #[allow(clippy::too_many_lines)] + fn run(&self, world: &LayeredWorld, _seed: u64) -> Result { + let count = world.cells().len(); + let mut labels = vec![usize::MAX; count]; + let mut components = Vec::>::new(); + for seed in 0..count as u32 { + if labels[seed as usize] != usize::MAX + || world.cells()[seed as usize].surface != Surface::Land + { + continue; + } + let label = components.len(); + labels[seed as usize] = label; + let mut cells = Vec::new(); + let mut pending = VecDeque::from([seed]); + while let Some(current) = pending.pop_front() { + cells.push(current); + for direction in HexDirection::ALL { + let Some(neighbor) = world.neighbor_id(current, direction) else { + continue; + }; + if labels[neighbor as usize] == usize::MAX + && world.cells()[neighbor as usize].surface == Surface::Land + { + labels[neighbor as usize] = label; + pending.push_back(neighbor); + } + } + } + components.push(cells); + } + if components.len() <= 1 { + return Ok(WorldPatch { + report: report(0, ["coast already forms one connected landmass".to_owned()]), + ..WorldPatch::default() + }); + } + let main = components + .iter() + .enumerate() + .max_by_key(|(_, cells)| cells.len()) + .map(|(index, _)| index) + .ok_or_else(|| "coast connectivity has no land component".to_owned())?; + + // One multi-source search finds the shortest ocean route from every + // island to the main continent. Short gaps become land bridges; remote + // specks are returned to ocean instead of producing implausible causeways. + let mut previous = vec![None; count]; + let mut distance = vec![u32::MAX; count]; + let mut pending = VecDeque::new(); + for &cell_id in &components[main] { + distance[cell_id as usize] = 0; + pending.push_back(cell_id); + } + let mut endpoints = vec![None; components.len()]; + while let Some(current) = pending.pop_front() { + let current_distance = distance[current as usize]; + let label = labels[current as usize]; + if label != usize::MAX && label != main && endpoints[label].is_none() { + endpoints[label] = Some(current); + } + for direction in HexDirection::ALL { + let Some(neighbor) = world.neighbor_id(current, direction) else { + continue; + }; + if distance[neighbor as usize] != u32::MAX { + continue; + } + distance[neighbor as usize] = current_distance.saturating_add(1); + previous[neighbor as usize] = Some(current); + pending.push_back(neighbor); + } + } + + let maximum_bridge = u32::from(world.manifest.macro_cell_size).max(4); + let mut bridge_cells = BTreeSet::new(); + let mut discarded = BTreeSet::new(); + let mut connected_components = 0; + for (label, component) in components.iter().enumerate() { + if label == main { + continue; + } + let Some(mut current) = endpoints[label] else { + discarded.extend(component.iter().copied()); + continue; + }; + if distance[current as usize] > maximum_bridge { + discarded.extend(component.iter().copied()); + continue; + } + connected_components += 1; + while labels[current as usize] != main { + if world.cells()[current as usize].surface != Surface::Land { + bridge_cells.insert(current); + } + let Some(next) = previous[current as usize] else { + break; + }; + current = next; + } + } + let mut surfaces = bridge_cells + .iter() + .copied() + .map(|id| (id, Surface::Land)) + .collect::>(); + surfaces.extend(discarded.iter().copied().map(|id| (id, Surface::Ocean))); + let elevation = bridge_cells + .iter() + .copied() + .map(|id| (id, ElevationWrite::Set(1))) + .collect::>(); + Ok(WorldPatch { + report: report( + surfaces.len(), + [format!( + "connected {connected_components} coastal components with {} bridge cells; discarded {} remote island cells", + bridge_cells.len(), + discarded.len() + )], + ), + elevation, + surfaces, + ..WorldPatch::default() + }) + } +} + +#[derive(Clone, Copy)] +struct Ridge { + first: (f64, f64), + second: (f64, f64), + radius: f64, + height: i16, +} + +impl WorldPass for MountainPass { + fn name(&self) -> &'static str { + "mountains" + } + + fn reads(&self) -> &'static [WorldLayer] { + &[WorldLayer::Elevation, WorldLayer::Surface] + } + + fn writes(&self) -> &'static [WorldLayer] { + &[WorldLayer::Elevation] + } + + fn run(&self, world: &LayeredWorld, seed: u64) -> Result { + let area = u64::from(world.width()) * u64::from(world.height()); + let density = u64::from(world.manifest.parameters.mountain_density_bps); + let range_count = + usize::try_from((area * density / 10_000 / (256 * 256)).clamp(2, 12)).unwrap_or(2); + let short = f64::from(world.width().min(world.height())); + let mut ridges = Vec::with_capacity(range_count); + for index in 0..range_count { + let hash = mix(seed, index as i32, 0); + let center_x = + f64::from(world.width()) * (0.20 + (hash & 0xffff) as f64 / 65_535.0 * 0.60); + let center_y = f64::from(world.height()) + * (0.20 + ((hash >> 16) & 0xffff) as f64 / 65_535.0 * 0.60); + let angle = ((hash >> 32) % 6) as f64 * std::f64::consts::PI / 3.0; + let half_length = short * (0.08 + ((hash >> 40) & 0xff) as f64 / 255.0 * 0.11); + let dx = angle.cos() * half_length; + let dy = angle.sin() * half_length; + ridges.push(Ridge { + first: (center_x - dx, center_y - dy), + second: (center_x + dx, center_y + dy), + radius: (short / 34.0).max(3.0), + height: 310 + i16::try_from((hash >> 48) % 180).unwrap_or_default(), + }); + } + + let mut elevation = Vec::new(); + for id in 0..world.cells().len() as u32 { + let cell = &world.cells()[id as usize]; + if cell.surface != Surface::Land { + continue; + } + let column = f64::from(id % world.width()); + let row = f64::from(id / world.width()); + let uplift = ridges.iter().fold(0_i16, |largest, ridge| { + let distance = point_segment_distance((column, row), ridge.first, ridge.second); + if distance >= ridge.radius { + return largest; + } + let profile = 1.0 - distance / ridge.radius; + largest.max((f64::from(ridge.height) * profile * profile).round() as i16) + }); + if uplift > 0 { + elevation.push((id, ElevationWrite::Add(uplift))); + } + } + Ok(WorldPatch { + report: report( + elevation.len(), + [format!( + "composed {range_count} deterministic mountain ranges" + )], + ), + elevation, + ..WorldPatch::default() + }) + } +} + +fn point_segment_distance(point: (f64, f64), first: (f64, f64), second: (f64, f64)) -> f64 { + let segment = (second.0 - first.0, second.1 - first.1); + let length_squared = segment.0 * segment.0 + segment.1 * segment.1; + let amount = if length_squared == 0.0 { + 0.0 + } else { + (((point.0 - first.0) * segment.0 + (point.1 - first.1) * segment.1) / length_squared) + .clamp(0.0, 1.0) + }; + let nearest = (first.0 + segment.0 * amount, first.1 + segment.1 * amount); + (point.0 - nearest.0).hypot(point.1 - nearest.1) +} + +impl WorldPass for LakeBasinPass { + fn name(&self) -> &'static str { + "lake-basins" + } + + fn reads(&self) -> &'static [WorldLayer] { + &[WorldLayer::Elevation, WorldLayer::Surface] + } + + fn writes(&self) -> &'static [WorldLayer] { + &[WorldLayer::Elevation] + } + + fn run(&self, world: &LayeredWorld, seed: u64) -> Result { + let area = u64::from(world.width()) * u64::from(world.height()); + let basin_count = usize::try_from((area / (384 * 384)).clamp(1, 6)).unwrap_or(1); + let short = f64::from(world.width().min(world.height())); + let radius = (short / 38.0).max(4.0); + let land = (0..world.cells().len() as u32) + .filter(|id| world.cells()[*id as usize].surface == Surface::Land) + .collect::>(); + if land.is_empty() { + return Err("lake-basin pass has no land".to_owned()); + } + let mut centers = Vec::with_capacity(basin_count); + for index in 0..basin_count { + let hash = mix(seed, index as i32, 71); + let desired_column = world.width() / 4 + + u32::try_from(hash % u64::from((world.width() / 2).max(1))).unwrap_or_default(); + let desired_row = world.height() / 4 + + u32::try_from((hash >> 32) % u64::from((world.height() / 2).max(1))) + .unwrap_or_default(); + let desired = Axial::new( + world.manifest.q_min + desired_column as i32, + world.manifest.r_min + desired_row as i32, + ); + let center = land + .iter() + .copied() + .min_by_key(|cell_id| { + let coordinate = world.coordinate(*cell_id).expect("land cell in bounds"); + (coordinate.distance(desired), coordinate) + }) + .expect("land is nonempty"); + centers.push(center); + } + + let mut elevation = Vec::new(); + for cell_id in land { + let coordinate = world.coordinate(cell_id).expect("land cell in bounds"); + let deepest = centers.iter().fold(0_i16, |current, center| { + let center = world.coordinate(*center).expect("basin center in bounds"); + let distance = coordinate.distance(center) as f64; + if distance >= radius { + current + } else { + let profile = 1.0 - distance / radius; + current.max((150.0 * profile * profile).round() as i16) + } + }); + if deepest > 0 { + elevation.push((cell_id, ElevationWrite::Add(-deepest))); + } + } + Ok(WorldPatch { + report: report( + elevation.len(), + [format!( + "carved {basin_count} deterministic hydrology basins" + )], + ), + elevation, + ..WorldPatch::default() + }) + } +} + +impl WorldPass for HydrologyPass { + fn name(&self) -> &'static str { + "hydrology" + } + + fn reads(&self) -> &'static [WorldLayer] { + &[WorldLayer::Elevation, WorldLayer::Surface] + } + + fn writes(&self) -> &'static [WorldLayer] { + &[ + WorldLayer::Surface, + WorldLayer::Moisture, + WorldLayer::Hydrology, + WorldLayer::Tags, + ] + } + + #[allow(clippy::too_many_lines)] + fn run(&self, world: &LayeredWorld, seed: u64) -> Result { + let count = world.cells().len(); + let mut filled = world + .cells() + .iter() + .map(|cell| cell.elevation) + .collect::>(); + let mut visited = vec![false; count]; + let mut frontier = BinaryHeap::new(); + for (id, cell) in world.cells().iter().enumerate() { + if cell.surface == Surface::Ocean { + visited[id] = true; + frontier.push(Reverse((cell.elevation, id as u32))); + } + } + while let Some(Reverse((height, cell_id))) = frontier.pop() { + for direction in HexDirection::ALL { + let Some(neighbor) = world.neighbor_id(cell_id, direction) else { + continue; + }; + if visited[neighbor as usize] { + continue; + } + visited[neighbor as usize] = true; + filled[neighbor as usize] = filled[neighbor as usize].max(height); + frontier.push(Reverse((filled[neighbor as usize], neighbor))); + } + } + + let lake_depth = world.manifest.parameters.lake_depth_threshold.max(1); + let mut lake_mask = vec![false; count]; + for id in 0..count { + let cell = &world.cells()[id]; + lake_mask[id] = cell.surface == Surface::Land + && filled[id].saturating_sub(cell.elevation) >= lake_depth; + } + let (water_bodies, lake_count) = label_lakes(world, &lake_mask); + + let mut downstream = vec![None; count]; + for cell_id in 0..count as u32 { + if world.cells()[cell_id as usize].surface != Surface::Land + || lake_mask[cell_id as usize] + { + continue; + } + let current_key = (filled[cell_id as usize], cell_id); + downstream[cell_id as usize] = HexDirection::ALL + .into_iter() + .filter_map(|direction| { + let neighbor = world.neighbor_id(cell_id, direction)?; + let neighbor_cell = &world.cells()[neighbor as usize]; + let terminal = + neighbor_cell.surface == Surface::Ocean || lake_mask[neighbor as usize]; + let key = (filled[neighbor as usize], neighbor); + (terminal || key < current_key).then_some((terminal, key, direction, neighbor)) + }) + .min_by_key(|(terminal, key, _, _)| (!*terminal, *key)) + .map(|(_, _, direction, neighbor)| (direction, neighbor)); + } + + let mut discharge = (0..count) + .map(|id| 1 + u32::try_from(mix(seed, id as i32, 17) % 4).unwrap_or_default()) + .collect::>(); + let mut drainage_order = (0..count as u32).collect::>(); + drainage_order.sort_unstable_by_key(|id| Reverse((filled[*id as usize], *id))); + for &cell_id in &drainage_order { + if let Some((_, next)) = downstream[cell_id as usize] { + discharge[next as usize] = + discharge[next as usize].saturating_add(discharge[cell_id as usize]); + } + } + + // Shallow unresolved sinks are allowed to remain ordinary damp land, + // but they must not create rivers that disappear before reaching a + // lake or ocean. The flow graph is acyclic by `(filled_height, id)`. + let mut drains_to_water = vec![false; count]; + for &cell_id in drainage_order.iter().rev() { + let Some((_, next)) = downstream[cell_id as usize] else { + continue; + }; + drains_to_water[cell_id as usize] = world.cells()[next as usize].surface + == Surface::Ocean + || lake_mask[next as usize] + || drains_to_water[next as usize]; + } + + // Keep river density approximately proportional as worlds grow. A + // threshold based only on a tiny fixed accumulation turns half of a + // million-cell continent into channels; one source-area per ~512 map + // cells retains a sparse, readable global network. + let automatic_threshold = (count as u32 / 512) + .max(world.width().min(world.height()).saturating_mul(2)) + .max(64); + let threshold = if world.manifest.parameters.river_threshold == 0 { + automatic_threshold + } else { + world.manifest.parameters.river_threshold + }; + let mut rivers = vec![None; count]; + for cell_id in 0..count as u32 { + let Some((outflow, _)) = downstream[cell_id as usize] else { + continue; + }; + if discharge[cell_id as usize] < threshold || !drains_to_water[cell_id as usize] { + continue; + } + let relative = discharge[cell_id as usize] / threshold.max(1); + rivers[cell_id as usize] = Some(RiverCell { + outflow, + inflow_mask: 0, + order: u8::try_from(relative.ilog2() + 1).unwrap_or(u8::MAX), + discharge: discharge[cell_id as usize], + }); + } + for cell_id in 0..count as u32 { + let Some(river) = rivers[cell_id as usize] else { + continue; + }; + let Some((_, next)) = downstream[cell_id as usize] else { + continue; + }; + if let Some(next_river) = &mut rivers[next as usize] { + next_river.inflow_mask |= 1 << river.outflow.opposite().index(); + } + } + + let mut surfaces = Vec::new(); + let mut water_body_writes = Vec::new(); + let mut river_writes = Vec::new(); + let mut moisture = Vec::with_capacity(count); + let mut tags = Vec::new(); + for cell_id in 0..count as u32 { + if lake_mask[cell_id as usize] { + surfaces.push((cell_id, Surface::Lake)); + water_body_writes.push((cell_id, water_bodies[cell_id as usize])); + } + if let Some(river) = rivers[cell_id as usize] { + river_writes.push((cell_id, Some(river))); + let mut flags = TerrainTags::RIVERBANK; + if river.inflow_mask == 0 { + flags |= TerrainTags::SOURCE; + } + if let Some((_, next)) = downstream[cell_id as usize] + && (world.cells()[next as usize].surface == Surface::Ocean + || lake_mask[next as usize]) + { + flags |= TerrainTags::OUTLET; + } + tags.push((cell_id, TerrainTags(flags))); + } + let column = cell_id % world.width(); + let row = cell_id / world.width(); + let base = 92 + fractal_noise(seed ^ 0x4D4F_4953_5455_5245, column, row, 96) / 16; + let adjacent_water = HexDirection::ALL.into_iter().any(|direction| { + world + .neighbor_id(cell_id, direction) + .is_some_and(|neighbor| { + lake_mask[neighbor as usize] + || world.cells()[neighbor as usize].surface == Surface::Ocean + || rivers[neighbor as usize].is_some() + }) + }); + let value = if lake_mask[cell_id as usize] || rivers[cell_id as usize].is_some() { + 255 + } else if adjacent_water { + base.max(190) + } else { + base + }; + moisture.push(( + cell_id, + u8::try_from(value.clamp(0, 255)).unwrap_or_default(), + )); + } + let river_count = river_writes.len(); + Ok(WorldPatch { + surfaces, + water_bodies: water_body_writes, + rivers: river_writes, + moisture, + tags, + report: report( + lake_mask.iter().filter(|value| **value).count() + river_count, + [ + format!("identified {lake_count} depression-fed lakes"), + format!( + "traced {river_count} river cells at accumulation threshold {threshold}" + ), + ], + ), + ..WorldPatch::default() + }) + } +} + +fn label_lakes(world: &LayeredWorld, lake_mask: &[bool]) -> (Vec>, u32) { + let mut ids = vec![None; lake_mask.len()]; + let mut next_id = 1_u32; + for seed in 0..lake_mask.len() as u32 { + if !lake_mask[seed as usize] || ids[seed as usize].is_some() { + continue; + } + ids[seed as usize] = Some(next_id); + let mut pending = VecDeque::from([seed]); + while let Some(current) = pending.pop_front() { + for direction in HexDirection::ALL { + let Some(neighbor) = world.neighbor_id(current, direction) else { + continue; + }; + if lake_mask[neighbor as usize] && ids[neighbor as usize].is_none() { + ids[neighbor as usize] = Some(next_id); + pending.push_back(neighbor); + } + } + } + next_id += 1; + } + (ids, next_id - 1) +} + +impl WorldPass for LandformPass { + fn name(&self) -> &'static str { + "landforms" + } + + fn reads(&self) -> &'static [WorldLayer] { + &[ + WorldLayer::Elevation, + WorldLayer::Surface, + WorldLayer::Hydrology, + ] + } + + fn writes(&self) -> &'static [WorldLayer] { + &[WorldLayer::Landform] + } + + fn run(&self, world: &LayeredWorld, _seed: u64) -> Result { + let mut landforms = Vec::with_capacity(world.cells().len()); + for cell_id in 0..world.cells().len() as u32 { + let cell = &world.cells()[cell_id as usize]; + if cell.surface != Surface::Land { + continue; + } + let max_relief = HexDirection::ALL + .into_iter() + .filter_map(|direction| world.neighbor_id(cell_id, direction)) + .map(|neighbor| { + cell.elevation + .abs_diff(world.cells()[neighbor as usize].elevation) + }) + .max() + .unwrap_or_default(); + let landform = if cell.river.is_some() && max_relief > 28 { + Landform::Valley + } else if cell.elevation >= 430 || max_relief >= 170 { + Landform::Mountain + } else if cell.elevation >= 300 && max_relief < 48 { + Landform::Plateau + } else if cell.elevation >= 155 || max_relief >= 58 { + Landform::Hill + } else { + Landform::Plain + }; + landforms.push((cell_id, landform)); + } + Ok(WorldPatch { + report: report( + landforms.len(), + ["derived landforms from final relief and hydrology".to_owned()], + ), + landforms, + ..WorldPatch::default() + }) + } +} + +impl WorldPass for BiomePass { + fn name(&self) -> &'static str { + "biomes" + } + + fn reads(&self) -> &'static [WorldLayer] { + &[ + WorldLayer::Elevation, + WorldLayer::Surface, + WorldLayer::Landform, + WorldLayer::Moisture, + ] + } + + fn writes(&self) -> &'static [WorldLayer] { + &[WorldLayer::Biome, WorldLayer::Fertility] + } + + fn run(&self, world: &LayeredWorld, seed: u64) -> Result { + let mut biomes = Vec::new(); + let mut fertility = Vec::new(); + for cell_id in 0..world.cells().len() as u32 { + let cell = &world.cells()[cell_id as usize]; + if cell.surface != Surface::Land { + continue; + } + let row = cell_id / world.width(); + let latitude = (i64::from(row) * 2 - i64::from(world.height())).unsigned_abs() * 255 + / u64::from(world.height()); + let biome = if cell.landform == Landform::Mountain || cell.elevation >= 480 { + Biome::Alpine + } else if latitude > 215 { + Biome::Tundra + } else if cell.moisture >= 205 { + Biome::Wetland + } else if cell.moisture >= 135 { + Biome::Forest + } else if cell.moisture < 68 { + Biome::Dryland + } else { + Biome::TemperateGrassland + }; + let variation = + i16::try_from(mix(seed, cell_id as i32, 9) % 31).unwrap_or_default() - 15; + let base_fertility = match biome { + Biome::Wetland => 205, + Biome::Forest => 165, + Biome::TemperateGrassland => 180, + Biome::Dryland => 70, + Biome::Alpine | Biome::Tundra => 45, + }; + biomes.push((cell_id, biome)); + fertility.push(( + cell_id, + u8::try_from((base_fertility + variation).clamp(0, 255)).unwrap_or_default(), + )); + } + Ok(WorldPatch { + report: report( + biomes.len(), + ["classified biome and fertility without replacing landforms".to_owned()], + ), + biomes, + fertility, + ..WorldPatch::default() + }) + } +} + +impl WorldPass for GameplayPass { + fn name(&self) -> &'static str { + "gameplay" + } + + fn reads(&self) -> &'static [WorldLayer] { + &[ + WorldLayer::Surface, + WorldLayer::Landform, + WorldLayer::Biome, + WorldLayer::Hydrology, + WorldLayer::Fertility, + ] + } + + fn writes(&self) -> &'static [WorldLayer] { + &[WorldLayer::Gameplay] + } + + fn run(&self, world: &LayeredWorld, _seed: u64) -> Result { + let mut gameplay = world + .cells() + .iter() + .enumerate() + .map(|(id, cell)| { + let land = cell.surface == Surface::Land; + let movement_cost = match cell.landform { + Landform::Plain | Landform::Valley => 10, + Landform::Hill | Landform::Plateau => 14, + Landform::Mountain => 22, + } + u16::from(cell.river.is_some()) * 2; + let military_capacity = match cell.landform { + Landform::Plain | Landform::Valley => 100, + Landform::Hill | Landform::Plateau => 82, + Landform::Mountain => 58, + }; + let habitable = land && !matches!(cell.biome, Biome::Alpine | Biome::Tundra); + ( + id as u32, + GameplayCell { + passable: land, + capturable: land, + habitable, + movement_cost, + military_capacity: if land { military_capacity } else { 0 }, + civilian_capacity: if habitable { + u16::from(cell.fertility).saturating_mul(2) + } else { + 0 + }, + }, + ) + }) + .collect::>(); + let mut labels = vec![usize::MAX; world.cells().len()]; + let mut component_sizes = Vec::new(); + for seed in 0..world.cells().len() as u32 { + if labels[seed as usize] != usize::MAX + || world.cells()[seed as usize].surface != Surface::Land + { + continue; + } + let label = component_sizes.len(); + let mut size = 0_usize; + labels[seed as usize] = label; + let mut pending = VecDeque::from([seed]); + while let Some(current) = pending.pop_front() { + size += 1; + for direction in HexDirection::ALL { + let Some(neighbor) = world.neighbor_id(current, direction) else { + continue; + }; + if labels[neighbor as usize] == usize::MAX + && world.cells()[neighbor as usize].surface == Surface::Land + { + labels[neighbor as usize] = label; + pending.push_back(neighbor); + } + } + } + component_sizes.push(size); + } + let main_component = component_sizes + .iter() + .enumerate() + .max_by_key(|(_, size)| **size) + .map(|(label, _)| label) + .ok_or_else(|| "gameplay pass has no land component".to_owned())?; + let mut excluded = 0_usize; + for (cell_id, cell) in &mut gameplay { + if world.cells()[*cell_id as usize].surface == Surface::Land + && labels[*cell_id as usize] != main_component + { + excluded += 1; + cell.passable = false; + cell.capturable = false; + cell.habitable = false; + cell.military_capacity = 0; + cell.civilian_capacity = 0; + } + } + Ok(WorldPatch { + report: report( + gameplay.len(), + [format!( + "derived gameplay properties and excluded {excluded} disconnected decorative land cells" + )], + ), + gameplay, + ..WorldPatch::default() + }) + } +} + +impl WorldPass for SpawnPass { + fn name(&self) -> &'static str { + "spawns" + } + + fn reads(&self) -> &'static [WorldLayer] { + &[WorldLayer::Gameplay] + } + + fn writes(&self) -> &'static [WorldLayer] { + &[WorldLayer::Spawns] + } + + fn run(&self, world: &LayeredWorld, _seed: u64) -> Result { + let needed = usize::from(world.manifest.player_count); + let all_candidates = (0..world.cells().len() as u32) + .filter(|cell_id| { + let cell = &world.cells()[*cell_id as usize]; + cell.gameplay.capturable + && cell.gameplay.habitable + && HexDirection::ALL.into_iter().any(|direction| { + world + .neighbor_id(*cell_id, direction) + .is_some_and(|neighbor| { + world.cells()[neighbor as usize].gameplay.capturable + }) + }) + }) + .collect::>(); + if all_candidates.len() < needed { + return Err(format!( + "spawn pass needs {needed} habitable candidates; found {}", + all_candidates.len() + )); + } + let target_candidates = needed.saturating_mul(64).clamp(4_096, 65_536); + let stride = all_candidates.len().div_ceil(target_candidates).max(1); + let mut candidates = all_candidates + .into_iter() + .step_by(stride) + .collect::>(); + if candidates.len() < needed { + candidates = (0..world.cells().len() as u32) + .filter(|id| world.cells()[*id as usize].gameplay.habitable) + .collect(); + } + let desired = Axial::new( + world.manifest.q_min + world.width() as i32 / 4, + world.manifest.r_min + world.height() as i32 / 2, + ); + let first = candidates + .iter() + .enumerate() + .min_by_key(|(_, id)| { + let coordinate = world.coordinate(**id).expect("candidate in bounds"); + (coordinate.distance(desired), coordinate) + }) + .map(|(index, _)| index) + .ok_or_else(|| "spawn pass has no candidates".to_owned())?; + let mut chosen = vec![false; candidates.len()]; + let mut nearest = vec![u64::MAX; candidates.len()]; + let mut spawns = Vec::with_capacity(needed); + let choose = + |index: usize, spawns: &mut Vec, chosen: &mut [bool], nearest: &mut [u64]| { + let coordinate = world + .coordinate(candidates[index]) + .expect("candidate in bounds"); + chosen[index] = true; + nearest[index] = 0; + spawns.push(coordinate); + for (candidate_index, cell_id) in candidates.iter().enumerate() { + if chosen[candidate_index] { + continue; + } + let candidate = world.coordinate(*cell_id).expect("candidate in bounds"); + nearest[candidate_index] = + nearest[candidate_index].min(candidate.distance(coordinate)); + } + }; + choose(first, &mut spawns, &mut chosen, &mut nearest); + while spawns.len() < needed { + let next = candidates + .iter() + .enumerate() + .filter(|(index, _)| !chosen[*index]) + .max_by_key(|(index, id)| { + ( + nearest[*index], + world.coordinate(**id).expect("candidate in bounds"), + ) + }) + .map(|(index, _)| index) + .ok_or_else(|| "spawn candidate sampling exhausted".to_owned())?; + choose(next, &mut spawns, &mut chosen, &mut nearest); + } + Ok(WorldPatch { + spawns: Some(spawns), + report: report( + needed, + [format!( + "sampled {} spawn candidates from a bounded pool", + candidates.len() + )], + ), + ..WorldPatch::default() + }) + } +} diff --git a/crates/worldgen/src/v2/pipeline.rs b/crates/worldgen/src/v2/pipeline.rs new file mode 100644 index 0000000..585939e --- /dev/null +++ b/crates/worldgen/src/v2/pipeline.rs @@ -0,0 +1,253 @@ +use hex_core::{Axial, HexEdge}; +use serde::{Deserialize, Serialize}; + +use super::{ + Biome, CellLayers, EdgeLayers, GameplayCell, Landform, LayeredWorld, PassProvenance, RiverCell, + Surface, TerrainTags, WorldLayer, WorldSpec, + passes::{ + BiomePass, CoastConnectivityPass, ContinentPass, GameplayPass, HydrologyPass, + LakeBasinPass, LandformPass, MountainPass, SpawnPass, + }, +}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PassReport { + pub name: String, + pub changed_cells: usize, + pub changed_edges: usize, + pub notes: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ElevationWrite { + Set(i16), + Add(i16), +} + +#[derive(Clone, Debug, Default)] +pub struct WorldPatch { + pub elevation: Vec<(u32, ElevationWrite)>, + pub surfaces: Vec<(u32, Surface)>, + pub landforms: Vec<(u32, Landform)>, + pub biomes: Vec<(u32, Biome)>, + pub moisture: Vec<(u32, u8)>, + pub fertility: Vec<(u32, u8)>, + pub water_bodies: Vec<(u32, Option)>, + pub rivers: Vec<(u32, Option)>, + pub tags: Vec<(u32, TerrainTags)>, + pub gameplay: Vec<(u32, GameplayCell)>, + pub edges: Vec<(HexEdge, EdgeLayers)>, + pub spawns: Option>, + pub report: PassReport, +} + +pub trait WorldPass { + fn name(&self) -> &'static str; + + fn reads(&self) -> &'static [WorldLayer] { + &[] + } + + fn writes(&self) -> &'static [WorldLayer] { + &[] + } + + /// Computes typed writes without mutating the input world. + /// + /// # Errors + /// + /// Returns an error when the pass cannot satisfy its layer contract. + fn run(&self, world: &LayeredWorld, pass_seed: u64) -> Result; +} + +pub struct WorldPipeline { + passes: Vec>, +} + +impl Default for WorldPipeline { + fn default() -> Self { + Self::new() + } +} + +impl WorldPipeline { + #[must_use] + pub const fn new() -> Self { + Self { passes: Vec::new() } + } + + #[must_use] + pub fn default_v2() -> Self { + Self { + passes: vec![ + Box::new(ContinentPass), + Box::new(CoastConnectivityPass), + Box::new(MountainPass), + Box::new(LakeBasinPass), + Box::new(HydrologyPass), + Box::new(LandformPass), + Box::new(BiomePass), + Box::new(GameplayPass), + Box::new(SpawnPass), + ], + } + } + + #[must_use] + pub fn with_pass(mut self, pass: impl WorldPass + 'static) -> Self { + self.passes.push(Box::new(pass)); + self + } + + pub fn push_pass(&mut self, pass: impl WorldPass + 'static) { + self.passes.push(Box::new(pass)); + } + + /// Executes every pass in order and records pass provenance in the manifest. + /// + /// # Errors + /// + /// Returns an error for an invalid specification, a failed pass, or an + /// out-of-bounds patch write. + pub fn run(&self, spec: &WorldSpec) -> Result<(LayeredWorld, Vec), String> { + spec.validate()?; + let mut world = LayeredWorld::empty(spec); + let mut reports = Vec::with_capacity(self.passes.len()); + for pass in &self.passes { + let seed = super::noise::pass_seed(spec.seed, pass.name()); + let mut patch = pass.run(&world, seed)?; + pass.name().clone_into(&mut patch.report.name); + let report = patch.report.clone(); + apply_patch(&mut world, patch)?; + world.manifest.pipeline.push(PassProvenance { + name: pass.name().to_owned(), + seed, + reads: pass.reads().to_vec(), + writes: pass.writes().to_vec(), + }); + reports.push(report); + } + world.refresh_manifest_counts(); + world.manifest.content_hash = content_hash(&world); + Ok((world, reports)) + } +} + +/// Generates a layered map with the standard version-two pass sequence. +/// +/// # Errors +/// +/// Returns a pass or validation error when a valid layered map cannot be built. +pub fn generate(spec: &WorldSpec) -> Result { + let (world, _) = WorldPipeline::default_v2().run(spec)?; + super::validation::validate(&world)?; + Ok(world) +} + +fn apply_patch(world: &mut LayeredWorld, patch: WorldPatch) -> Result<(), String> { + { + let mut write_cell = |cell_id: u32, update: &mut dyn FnMut(&mut CellLayers)| { + let cell = world + .cells_mut() + .get_mut(cell_id as usize) + .ok_or_else(|| format!("pass wrote out-of-bounds cell {cell_id}"))?; + update(cell); + Ok::<_, String>(()) + }; + for (cell_id, write) in patch.elevation { + write_cell(cell_id, &mut |cell| match write { + ElevationWrite::Set(value) => cell.elevation = value, + ElevationWrite::Add(value) => cell.elevation = cell.elevation.saturating_add(value), + })?; + } + for (cell_id, value) in patch.surfaces { + write_cell(cell_id, &mut |cell| cell.surface = value)?; + } + for (cell_id, value) in patch.landforms { + write_cell(cell_id, &mut |cell| cell.landform = value)?; + } + for (cell_id, value) in patch.biomes { + write_cell(cell_id, &mut |cell| cell.biome = value)?; + } + for (cell_id, value) in patch.moisture { + write_cell(cell_id, &mut |cell| cell.moisture = value)?; + } + for (cell_id, value) in patch.fertility { + write_cell(cell_id, &mut |cell| cell.fertility = value)?; + } + for (cell_id, value) in patch.water_bodies { + write_cell(cell_id, &mut |cell| cell.water_body_id = value)?; + } + for (cell_id, value) in patch.rivers { + write_cell(cell_id, &mut |cell| cell.river = value)?; + } + for (cell_id, value) in patch.tags { + write_cell(cell_id, &mut |cell| cell.tags.0 |= value.0)?; + } + for (cell_id, value) in patch.gameplay { + write_cell(cell_id, &mut |cell| cell.gameplay = value)?; + } + } + for (edge, value) in patch.edges { + world.edges.insert(edge, value); + } + if let Some(spawns) = patch.spawns { + world.manifest.spawn_cells = spawns; + } + Ok(()) +} + +pub(crate) fn content_hash(world: &LayeredWorld) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + let mut value = |number: u64| { + for byte in number.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01B3); + } + }; + value(u64::from(world.width())); + value(u64::from(world.height())); + value(world.manifest.seed); + value(u64::from(world.manifest.player_count)); + for cell in world.cells() { + value(u64::from(u16::from_ne_bytes(cell.elevation.to_ne_bytes()))); + value(cell.surface as u64); + value(cell.landform as u64); + value(cell.biome as u64); + value(u64::from(cell.moisture)); + value(u64::from(cell.fertility)); + value(u64::from(cell.water_body_id.unwrap_or_default())); + value(u64::from(cell.tags.0)); + if let Some(river) = cell.river { + value(1); + value(river.outflow as u64); + value(u64::from(river.inflow_mask)); + value(u64::from(river.order)); + value(u64::from(river.discharge)); + } else { + value(0); + } + value(u64::from(cell.gameplay.passable)); + value(u64::from(cell.gameplay.capturable)); + value(u64::from(cell.gameplay.habitable)); + value(u64::from(cell.gameplay.movement_cost)); + value(u64::from(cell.gameplay.military_capacity)); + value(u64::from(cell.gameplay.civilian_capacity)); + } + for (edge, layers) in &world.edges { + value(u64::from(u32::from_ne_bytes(edge.a.q.to_ne_bytes()))); + value(u64::from(u32::from_ne_bytes(edge.a.r.to_ne_bytes()))); + value(u64::from(u32::from_ne_bytes(edge.b.q.to_ne_bytes()))); + value(u64::from(u32::from_ne_bytes(edge.b.r.to_ne_bytes()))); + value(u64::from(layers.road_level)); + value(layers.crossing as u64); + value(u64::from(u16::from_ne_bytes( + layers.movement_modifier_bps.to_ne_bytes(), + ))); + } + for spawn in &world.manifest.spawn_cells { + value(u64::from(u32::from_ne_bytes(spawn.q.to_ne_bytes()))); + value(u64::from(u32::from_ne_bytes(spawn.r.to_ne_bytes()))); + } + hash +} diff --git a/crates/worldgen/src/v2/validation.rs b/crates/worldgen/src/v2/validation.rs new file mode 100644 index 0000000..c72e7fe --- /dev/null +++ b/crates/worldgen/src/v2/validation.rs @@ -0,0 +1,295 @@ +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use hex_core::HexDirection; + +use super::{GENERATOR_VERSION, LayeredWorld, Surface}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct V2ValidationReport { + pub total_cells: usize, + pub land_cells: usize, + pub lake_cells: usize, + pub river_cells: usize, + pub water_bodies: usize, + pub chunks: usize, +} + +/// Validates layer compatibility, hydrology, spawns, connectivity, and chunks. +/// +/// # Errors +/// +/// Returns the first deterministic structural error found in the generated map. +pub fn validate(world: &LayeredWorld) -> Result { + validate_manifest(world)?; + validate_layers(world)?; + validate_lakes(world)?; + validate_rivers(world)?; + validate_spawns(world)?; + validate_land_connectivity(world)?; + validate_chunks(world)?; + + let water_bodies = world + .cells() + .iter() + .filter_map(|cell| cell.water_body_id) + .collect::>() + .len(); + Ok(V2ValidationReport { + total_cells: world.cells().len(), + land_cells: world + .cells() + .iter() + .filter(|cell| cell.surface == Surface::Land) + .count(), + lake_cells: world + .cells() + .iter() + .filter(|cell| cell.surface == Surface::Lake) + .count(), + river_cells: world + .cells() + .iter() + .filter(|cell| cell.river.is_some()) + .count(), + water_bodies, + chunks: world.chunks_wide() as usize * world.chunks_high() as usize, + }) +} + +fn validate_manifest(world: &LayeredWorld) -> Result<(), String> { + if world.manifest.generator_version != GENERATOR_VERSION { + return Err(format!( + "unsupported layered generator version {}", + world.manifest.generator_version + )); + } + let expected = world.width() as usize * world.height() as usize; + if world.cells().len() != expected { + return Err("layered manifest dimensions do not match cell count".to_owned()); + } + if world.manifest.pipeline.is_empty() { + return Err("layered manifest has no pass provenance".to_owned()); + } + let hash = super::pipeline::content_hash(world); + if hash != world.manifest.content_hash { + return Err(format!( + "layered content hash mismatch: manifest {:016x}, computed {hash:016x}", + world.manifest.content_hash + )); + } + Ok(()) +} + +fn validate_layers(world: &LayeredWorld) -> Result<(), String> { + for (cell_id, cell) in world.cells().iter().enumerate() { + match cell.surface { + Surface::Land => { + if cell.water_body_id.is_some() { + return Err(format!("land cell {cell_id} belongs to a lake")); + } + if cell.gameplay.capturable && !cell.gameplay.passable { + return Err(format!("capturable land cell {cell_id} is impassable")); + } + } + Surface::Ocean => { + if cell.water_body_id.is_some() || cell.river.is_some() { + return Err(format!( + "ocean cell {cell_id} has inland hydrology metadata" + )); + } + if cell.gameplay.passable || cell.gameplay.capturable { + return Err(format!("ocean cell {cell_id} is playable land")); + } + } + Surface::Lake => { + if cell.water_body_id.is_none() || cell.river.is_some() { + return Err(format!( + "lake cell {cell_id} has inconsistent hydrology metadata" + )); + } + if cell.gameplay.passable || cell.gameplay.capturable { + return Err(format!("lake cell {cell_id} is playable land")); + } + } + } + } + Ok(()) +} + +fn validate_lakes(world: &LayeredWorld) -> Result<(), String> { + let mut cells_by_lake = BTreeMap::>::new(); + for (cell_id, cell) in world.cells().iter().enumerate() { + if let Some(water_body) = cell.water_body_id { + cells_by_lake + .entry(water_body) + .or_default() + .insert(cell_id as u32); + } + } + for (water_body, cells) in cells_by_lake { + let Some(&seed) = cells.first() else { + continue; + }; + let mut reached = BTreeSet::from([seed]); + let mut pending = VecDeque::from([seed]); + while let Some(current) = pending.pop_front() { + for direction in HexDirection::ALL { + let Some(neighbor) = world.neighbor_id(current, direction) else { + continue; + }; + if cells.contains(&neighbor) && reached.insert(neighbor) { + pending.push_back(neighbor); + } + } + } + if reached != cells { + return Err(format!("lake {water_body} is disconnected")); + } + } + Ok(()) +} + +fn validate_rivers(world: &LayeredWorld) -> Result<(), String> { + for (cell_id, cell) in world.cells().iter().enumerate() { + let Some(river) = cell.river else { + continue; + }; + if cell.surface != Surface::Land { + return Err(format!("river cell {cell_id} is not land")); + } + let next = world + .neighbor_id(cell_id as u32, river.outflow) + .ok_or_else(|| format!("river cell {cell_id} flows outside the map"))?; + let target = &world.cells()[next as usize]; + if target.surface == Surface::Land && target.river.is_none() { + return Err(format!( + "river cell {cell_id} terminates on ordinary land {next}" + )); + } + if let Some(target_river) = target.river { + let expected_bit = 1 << river.outflow.opposite().index(); + if target_river.inflow_mask & expected_bit == 0 { + return Err(format!( + "river connection {cell_id}->{next} lacks reciprocal inflow" + )); + } + if target_river.discharge < river.discharge { + return Err(format!( + "river discharge decreases from {cell_id} to {next}" + )); + } + } + + let mut visited = BTreeSet::new(); + let mut current = cell_id as u32; + loop { + if !visited.insert(current) { + return Err(format!("river from {cell_id} contains a cycle")); + } + let current_cell = &world.cells()[current as usize]; + let Some(segment) = current_cell.river else { + break; + }; + let Some(next) = world.neighbor_id(current, segment.outflow) else { + return Err(format!("river from {cell_id} leaves map bounds")); + }; + if world.cells()[next as usize].surface != Surface::Land { + break; + } + current = next; + } + } + Ok(()) +} + +fn validate_spawns(world: &LayeredWorld) -> Result<(), String> { + if world.manifest.spawn_cells.len() != usize::from(world.manifest.player_count) { + return Err("layered spawn count does not match player count".to_owned()); + } + let unique = world + .manifest + .spawn_cells + .iter() + .copied() + .collect::>(); + if unique.len() != world.manifest.spawn_cells.len() { + return Err("layered spawns are not distinct".to_owned()); + } + for spawn in &world.manifest.spawn_cells { + let cell = world + .cell(*spawn) + .ok_or_else(|| format!("spawn {spawn:?} is outside the map"))?; + if !cell.gameplay.capturable || !cell.gameplay.habitable { + return Err(format!("spawn {spawn:?} is not habitable capturable land")); + } + } + Ok(()) +} + +fn validate_land_connectivity(world: &LayeredWorld) -> Result<(), String> { + let Some(seed) = world + .cells() + .iter() + .position(|cell| cell.gameplay.passable) + .map(|id| id as u32) + else { + return Err("layered map has no passable land".to_owned()); + }; + let expected = world + .cells() + .iter() + .filter(|cell| cell.gameplay.passable) + .count(); + let mut reached = BTreeSet::from([seed]); + let mut pending = VecDeque::from([seed]); + while let Some(current) = pending.pop_front() { + for direction in HexDirection::ALL { + let Some(neighbor) = world.neighbor_id(current, direction) else { + continue; + }; + if world.cells()[neighbor as usize].gameplay.passable && reached.insert(neighbor) { + pending.push_back(neighbor); + } + } + } + if reached.len() != expected { + return Err(format!( + "layered passable land has disconnected cells: reached {} of {expected}", + reached.len() + )); + } + Ok(()) +} + +fn validate_chunks(world: &LayeredWorld) -> Result<(), String> { + let mut seen = vec![false; world.cells().len()]; + for chunk_r in 0..world.chunks_high() { + for chunk_q in 0..world.chunks_wide() { + let chunk = world + .chunk(hex_core::ChunkCoord { + q: chunk_q as i32, + r: chunk_r as i32, + }) + .ok_or_else(|| format!("missing terrain chunk {chunk_q},{chunk_r}"))?; + for cell in chunk.cells { + let slot = seen + .get_mut(cell.cell_id as usize) + .ok_or_else(|| format!("chunk contains invalid cell {}", cell.cell_id))?; + if *slot { + return Err(format!("cell {} appears in multiple chunks", cell.cell_id)); + } + *slot = true; + if world.cell(cell.coordinate) != Some(&cell.layers) { + return Err(format!( + "chunk cell {} differs from its source layers", + cell.cell_id + )); + } + } + } + } + if seen.iter().any(|value| !value) { + return Err("chunk extraction does not cover every map cell".to_owned()); + } + Ok(()) +} diff --git a/docs/README.md b/docs/README.md index 388a391..8d662fd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,9 @@ current interaction or simulation contract. - [V1 game design](./v1-game-design.md) — player experience, rules, interaction experiments, provisional tuning, and acceptance criteria. - [Technical architecture](./technical-architecture.md) — Bevy and SpacetimeDB boundaries, simulation and data flow, scalability, testing, and the implementation sequence. - [V1 implementation guide](./implementation.md) — executable components, runtime topology, operational flow, evidence, and known limits. +- [Layered world generator V2](./worldgen-v2.md) — composable terrain layers, + deterministic passes, hydrology, chunk export, validation, and the runtime + integration boundary for much larger maps. - [Graybox UI direction](./v1-ui-direction.md) — implemented interaction states, controls, HUD hierarchy, overlays, and playtest risks. - [Graybox UI brief](./v1-ui-brief.md) — concise requirements for visual and interaction exploration within the canonical control contract. diff --git a/docs/implementation.md b/docs/implementation.md index 15332c8..cf5f001 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -174,6 +174,13 @@ round-trips without non-string map keys. V1 has no per-edge map overrides; roads, rivers, bridges, and crossings require an explicit versioned edge array later. +The offline [`worldgen` V2 pipeline](./worldgen-v2.md) now provides that +separate layered contract, including hydrology overlays, sparse edge features, +custom dimensions, chunk extraction, and large-map validation. It is not yet an +authoritative match preset: V1 hashes and bindings stay pinned until the +runtime adopts immutable terrain chunks and the remaining simulation/client +scale work described in that document. + ## Client structure The native client renders combined chunk meshes rather than one entity per hex. diff --git a/docs/worldgen-v2.md b/docs/worldgen-v2.md new file mode 100644 index 0000000..1e59411 --- /dev/null +++ b/docs/worldgen-v2.md @@ -0,0 +1,136 @@ +# Layered world generator V2 + +Status: implemented offline generator contract; authoritative match integration +is deliberately separate from the pinned V1 map format. + +## Purpose + +V2 separates geography into composable layers so one cell can be a fertile +temperate plain, carry a river, and participate in an independently defined +road/crossing edge. It also replaces the generator's ordered per-cell tree with +dense row-major storage and chunk extraction suitable for maps far larger than +the 192 x 192 V1 validation preset. + +V1 remains unchanged and continues to produce its pinned hashes. V2 uses +generator version `2` and has its own manifest, content hash, pass provenance, +and validation report. + +## Layer contract + +Each cell carries independent fields for: + +- elevation; +- surface (`Land`, `Ocean`, or `Lake`); +- landform (`Plain`, `Hill`, `Mountain`, `Valley`, or `Plateau`); +- biome, moisture, and fertility; +- optional water-body and river metadata; +- composable tags; +- derived gameplay properties. + +Sparse canonical edges independently carry roads, crossings, and movement +modifiers. Rivers are centerline hydrology overlays with upstream masks and an +outflow direction; they do not replace the underlying landform or biome. + +Every built-in pass declares the layers it reads and writes. The generated +manifest records the pass name, independently derived seed, read set, and write +set. Elevation writes explicitly choose set or additive semantics, surface and +classification writes replace only their own field, and tags merge by union. +Custom passes implement `WorldPass` and return a `WorldPatch`. + +## Default pipeline + +1. `continent` creates a global elevation field and coherent interpolated coast. +2. `coast-connectivity` bridges nearby fragments and removes remote specks. +3. `mountains` adds area-scaled regional ridges. +4. `lake-basins` carves deterministic inland basins. +5. `hydrology` priority-fills depressions, labels lakes, calculates flow and + upstream discharge, and retains river networks that terminate in water. +6. `landforms` classifies the final relief without touching hydrology. +7. `biomes` derives climate, biome, and fertility. +8. `gameplay` derives passability, capacity, and the main connected play area. +9. `spawns` uses bounded-candidate farthest-point sampling rather than scanning + every cell once per player. + +Each pass receives `hash(world_seed, pass_name)`. Adding a later independent +pass therefore does not perturb the random choices of existing passes. + +## CLI + +The default remains V1: + +```bash +cargo run -p mapgen -- --preset validation +``` + +Generate and validate a custom layered map: + +```bash +cargo run -p mapgen --release -- \ + --generator v2 --width 1024 --height 1024 --players 500 --seed 42 +``` + +Tune geographic components: + +```bash +cargo run -p mapgen --release -- \ + --generator v2 --width 512 --height 512 \ + --mountain-density-bps 3500 \ + --lake-depth-threshold 20 \ + --river-threshold 0 +``` + +`--river-threshold 0` selects the area-scaled default. + +Export a layer as a binary portable graymap: + +```bash +cargo run -p mapgen -- --generator v2 --width 512 --height 512 \ + --inspect-layer rivers --inspect-output /tmp/rivers.pgm +``` + +Supported inspection layers are elevation, surface, landform, biome, moisture, +fertility, rivers, and gameplay. + +Export a manifest and deterministic JSON chunks: + +```bash +cargo run -p mapgen --release -- --generator v2 \ + --width 1024 --height 1024 --chunk-size 64 \ + --chunks-dir /tmp/layered-world +``` + +Chunk coordinates are zero-based storage coordinates. Each chunk includes its +cells and any sparse edge records touching it. JSON is intended for inspection; +a production client/server handoff should add a packed compressed encoding +without changing the layered model or content identity. + +## Validation and scale + +Validation checks manifest/hash consistency, layer compatibility, connected +water-body IDs, river reciprocity/discharge/termination/cycle freedom, spawn +uniqueness and suitability, connected playable land, and exact chunk coverage. +Tests cover V1 compatibility, deterministic layer composition, plains carrying +rivers, independent edge-pass composition, lakes, rivers crossing chunk +boundaries, and a 256 x 256 scale fixture. + +On the development machine, the release generator produced and validated a +1024 x 1024 / 500-player fixture in approximately 0.75 seconds with about 92 MB +peak resident memory. This measures the offline generator only; the current +SpacetimeDB schema and Bevy client still materialize full per-cell/per-edge +state and must not switch to V2 at that size without runtime terrain streaming, +sparse wave topology, and map-size-based subscription/simulation policies. + +## Runtime integration boundary + +V2 is intentionally not selected by the match reducer yet. Safe integration +requires a versioned terrain adapter and generated bindings, followed by: + +- packed immutable chunk storage or deterministic client-side regeneration; +- spatial terrain interest and a resident-set renderer; +- dynamic state only for playable land; +- compact static topology instead of one database row per adjacent edge; +- scale decisions based on map cells/chunks as well as player count; +- sparse expansion-wave topology and symbolic large-component commands. + +Keeping this boundary explicit prevents an offline million-cell success from +being mistaken for a playable million-cell authoritative match. diff --git a/maps/README.md b/maps/README.md index f8e2fb8..77d5ef3 100644 --- a/maps/README.md +++ b/maps/README.md @@ -10,3 +10,20 @@ cargo run -p mapgen -- --preset dev --players 2 \ ``` The larger playtest and validation maps are generated on demand so tens of thousands of derived cell rows do not need to be committed. `--players` accepts 2 through 500 (default 2); manifests record `player_count` (`u16`, legacy JSON defaults to 2) and a deterministic ordered spawn-cell vector. Counts `2..=8` keep multi-cell ring footprints; counts above eight use deterministic farthest-point one-cell starts. Custom two-player generation requires dimensions of at least 24 by 24, while custom maps with more than two players require at least 32 by 32; the Rust API panics when these dimension or player-count preconditions are violated. Generator-version-1 JSON written before `player_count` existed remains readable as a two-player manifest, and the existing two-player terrain/spawn output is unchanged. + +## Layered V2 maps + +Generator V2 is available for offline generation and inspection while V1 stays +the authoritative match format. It composes independent elevation, surface, +landform, hydrology, biome, edge, and gameplay layers and supports custom large +dimensions: + +```bash +cargo run -p mapgen --release -- \ + --generator v2 --width 1024 --height 1024 --players 500 --seed 42 +``` + +Use `--inspect-layer rivers --inspect-output rivers.pgm` for layer diagnostics, +or `--chunks-dir ` to write a manifest and zero-based terrain chunks. +See [the V2 generator design](../docs/worldgen-v2.md) for the layer merge rules, +pass sequence, validation, tuning flags, and runtime integration boundary. diff --git a/tools/mapgen/Cargo.toml b/tools/mapgen/Cargo.toml index f16e282..42e771b 100644 --- a/tools/mapgen/Cargo.toml +++ b/tools/mapgen/Cargo.toml @@ -9,6 +9,8 @@ description = "Offline generator and validator for curated RTS maps" anyhow.workspace = true clap.workspace = true serde_json.workspace = true +serde.workspace = true +hex-core.workspace = true worldgen = { path = "../../crates/worldgen" } [lints] diff --git a/tools/mapgen/src/main.rs b/tools/mapgen/src/main.rs index 9a1c95f..e7be150 100644 --- a/tools/mapgen/src/main.rs +++ b/tools/mapgen/src/main.rs @@ -1,9 +1,19 @@ use std::{fs, path::PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use clap::{Parser, ValueEnum}; +use worldgen::v2::{ + Biome, Landform, LayeredWorld, Surface, WorldPipeline, WorldSpec, validate as validate_v2, +}; use worldgen::{MapPreset, generate_for_players, generate_preset_for_players, validate}; +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +enum GeneratorArg { + #[default] + V1, + V2, +} + #[derive(Clone, Copy, Debug, ValueEnum)] enum PresetArg { Dev, @@ -21,9 +31,25 @@ impl From for MapPreset { } } +#[derive(Clone, Copy, Debug, ValueEnum)] +enum LayerArg { + Elevation, + Surface, + Landform, + Biome, + Moisture, + Fertility, + Rivers, + Gameplay, +} + #[derive(Debug, Parser)] -#[command(about = "Generate and validate a deterministic stepped-island map")] +#[command(about = "Generate, inspect, validate, and chunk deterministic maps")] struct Arguments { + /// Preserve V1 output or use the composable layered V2 pipeline. + #[arg(long, value_enum, default_value_t)] + generator: GeneratorArg, + #[arg(long, value_enum, default_value_t = PresetArg::Dev)] preset: PresetArg, @@ -34,12 +60,64 @@ struct Arguments { #[arg(long, default_value_t = 2, value_parser = clap::value_parser!(u16).range(2..=500))] players: u16, + /// Custom V2 width. Must be paired with `--height`. + #[arg(long)] + width: Option, + + /// Custom V2 height. Must be paired with `--width`. + #[arg(long)] + height: Option, + + #[arg(long, default_value_t = 64)] + chunk_size: u16, + + #[arg(long, default_value_t = 32)] + macro_cell_size: u16, + + /// Approximate mountain-range coverage, in basis points. + #[arg(long, default_value_t = 3_000, value_parser = clap::value_parser!(u16).range(0..=10_000))] + mountain_density_bps: u16, + + /// Minimum filled-depression depth that becomes a lake. + #[arg(long, default_value_t = 18)] + lake_depth_threshold: i16, + + /// Upstream accumulation required for a river; zero selects an area-scaled default. + #[arg(long, default_value_t = 0)] + river_threshold: u32, + + /// Complete generated map as inspectable JSON. #[arg(long)] output: Option, + + /// Export every V2 terrain chunk and its manifest into this directory. + #[arg(long)] + chunks_dir: Option, + + /// Render one V2 layer as a portable graymap image. + #[arg(long, value_enum)] + inspect_layer: Option, + + #[arg(long, requires = "inspect_layer")] + inspect_output: Option, } fn main() -> Result<()> { let arguments = Arguments::parse(); + match arguments.generator { + GeneratorArg::V1 => run_v1(&arguments), + GeneratorArg::V2 => run_v2(&arguments), + } +} + +fn run_v1(arguments: &Arguments) -> Result<()> { + if arguments.width.is_some() + || arguments.height.is_some() + || arguments.chunks_dir.is_some() + || arguments.inspect_layer.is_some() + { + bail!("custom dimensions, chunks, and layer inspection require --generator v2"); + } let preset = MapPreset::from(arguments.preset); let generated = if let Some(seed) = arguments.seed { let (width, height) = preset.dimensions(); @@ -54,21 +132,9 @@ fn main() -> Result<()> { generate_preset_for_players(preset, arguments.players) }; let report = validate(&generated).map_err(anyhow::Error::msg)?; - - if let Some(output) = arguments.output { - if let Some(parent) = output - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - let json = serde_json::to_vec_pretty(&generated).context("failed to serialize map")?; - fs::write(&output, json) - .with_context(|| format!("failed to write {}", output.display()))?; - println!("Wrote {}", output.display()); + if let Some(output) = &arguments.output { + write_pretty_json(output, &generated)?; } - println!( "{}: {}x{}, {} players, {} capturable / {} ground, {} slopes, {} cliffs, hash {:016x}", generated.manifest.name, @@ -83,3 +149,164 @@ fn main() -> Result<()> { ); Ok(()) } + +fn run_v2(arguments: &Arguments) -> Result<()> { + let preset = MapPreset::from(arguments.preset); + let preset_dimensions = preset.dimensions(); + let (width, height) = match (arguments.width, arguments.height) { + (Some(width), Some(height)) => (width, height), + (None, None) => ( + u32::from(preset_dimensions.0), + u32::from(preset_dimensions.1), + ), + _ => bail!("--width and --height must be provided together"), + }; + let seed = arguments.seed.unwrap_or_else(|| preset.seed()); + let mut spec = WorldSpec::new(format!("{}-layered-v2", preset.name()), width, height, seed); + spec.player_count = arguments.players; + spec.chunk_size = arguments.chunk_size; + spec.macro_cell_size = arguments.macro_cell_size; + spec.parameters.mountain_density_bps = arguments.mountain_density_bps; + spec.parameters.lake_depth_threshold = arguments.lake_depth_threshold; + spec.parameters.river_threshold = arguments.river_threshold; + + let (world, pass_reports) = WorldPipeline::default_v2() + .run(&spec) + .map_err(anyhow::Error::msg)?; + let report = validate_v2(&world).map_err(anyhow::Error::msg)?; + for pass in pass_reports { + println!( + "pass {:<12} cells {:>8} edges {:>6}{}", + pass.name, + pass.changed_cells, + pass.changed_edges, + pass.notes + .first() + .map_or_else(String::new, |note| format!(" {note}")), + ); + } + if let Some(output) = &arguments.output { + write_pretty_json(output, &world)?; + } + if let Some(directory) = &arguments.chunks_dir { + write_chunks(directory, &world)?; + } + if let Some(layer) = arguments.inspect_layer { + let output = arguments + .inspect_output + .clone() + .unwrap_or_else(|| PathBuf::from(format!("{}-{layer:?}.pgm", world.manifest.name))); + write_layer_image(&output, &world, layer)?; + } + println!( + "{}: {}x{}, {} players, {} land, {} lakes / {} cells, {} river cells, {} chunks, hash {:016x}", + world.manifest.name, + world.width(), + world.height(), + world.manifest.player_count, + report.land_cells, + report.water_bodies, + report.lake_cells, + report.river_cells, + report.chunks, + world.manifest.content_hash, + ); + Ok(()) +} + +fn ensure_parent(path: &std::path::Path) -> Result<()> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + Ok(()) +} + +fn write_pretty_json(path: &std::path::Path, value: &impl serde::Serialize) -> Result<()> { + ensure_parent(path)?; + let json = serde_json::to_vec_pretty(value).context("failed to serialize generated map")?; + fs::write(path, json).with_context(|| format!("failed to write {}", path.display()))?; + println!("Wrote {}", path.display()); + Ok(()) +} + +fn write_chunks(directory: &std::path::Path, world: &LayeredWorld) -> Result<()> { + fs::create_dir_all(directory) + .with_context(|| format!("failed to create {}", directory.display()))?; + write_pretty_json(&directory.join("manifest.json"), &world.manifest)?; + for chunk_r in 0..world.chunks_high() { + for chunk_q in 0..world.chunks_wide() { + let coordinate = hex_core::ChunkCoord { + q: i32::try_from(chunk_q).context("chunk q exceeds i32")?, + r: i32::try_from(chunk_r).context("chunk r exceeds i32")?, + }; + let chunk = world + .chunk(coordinate) + .with_context(|| format!("missing generated chunk {chunk_q},{chunk_r}"))?; + write_pretty_json( + &directory.join(format!("chunk-{chunk_q}-{chunk_r}.json")), + &chunk, + )?; + } + } + Ok(()) +} + +fn write_layer_image(path: &std::path::Path, world: &LayeredWorld, layer: LayerArg) -> Result<()> { + ensure_parent(path)?; + let elevations = world.cells().iter().map(|cell| cell.elevation); + let minimum = elevations.clone().min().unwrap_or_default(); + let maximum = elevations.max().unwrap_or(minimum); + let range = i32::from(maximum).saturating_sub(i32::from(minimum)).max(1); + let mut image = format!("P5\n{} {}\n255\n", world.width(), world.height()).into_bytes(); + image.extend(world.cells().iter().map(|cell| match layer { + LayerArg::Elevation => { + let normalized = (i32::from(cell.elevation) - i32::from(minimum)) * 255 / range; + u8::try_from(normalized).unwrap_or_default() + } + LayerArg::Surface => match cell.surface { + Surface::Ocean => 0, + Surface::Lake => 75, + Surface::Land => 205, + }, + LayerArg::Landform => match cell.landform { + Landform::Plain => 50, + Landform::Valley => 90, + Landform::Hill => 145, + Landform::Plateau => 190, + Landform::Mountain => 245, + }, + LayerArg::Biome => match cell.biome { + Biome::Dryland => 35, + Biome::Tundra => 70, + Biome::TemperateGrassland => 115, + Biome::Forest => 160, + Biome::Alpine => 205, + Biome::Wetland => 245, + }, + LayerArg::Moisture => cell.moisture, + LayerArg::Fertility => cell.fertility, + LayerArg::Rivers => { + if cell.river.is_some() { + 255 + } else { + 0 + } + } + LayerArg::Gameplay => { + if cell.gameplay.habitable { + 255 + } else if cell.gameplay.passable { + 130 + } else { + 0 + } + } + })); + fs::write(path, image).with_context(|| format!("failed to write {}", path.display()))?; + println!("Wrote {}", path.display()); + Ok(()) +} From 36d2dc8f99ab75cd8a6685837a68debd876ea88b Mon Sep 17 00:00:00 2001 From: carlid Date: Thu, 6 Aug 2026 23:34:23 +0200 Subject: [PATCH 2/4] Add layered worldgen client viewer --- Cargo.lock | 1 + crates/game-client/Cargo.toml | 1 + crates/game-client/src/config.rs | 87 ++++++++- crates/game-client/src/interaction.rs | 2 + crates/game-client/src/main.rs | 34 +++- crates/game-client/src/map_view.rs | 2 + crates/game-client/src/model.rs | 195 +++++++++++++++++++ crates/game-client/src/network.rs | 2 + crates/game-client/src/online.rs | 2 + crates/game-client/src/overlays.rs | 2 + crates/game-client/src/population_outline.rs | 2 + crates/game-client/src/terrain.rs | 68 ++++++- docs/worldgen-v2.md | 34 +++- 13 files changed, 415 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7fb8ecb..791f933 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2991,6 +2991,7 @@ dependencies = [ "spacetimedb-sdk", "wasm-bindgen-futures", "web-sys", + "worldgen", ] [[package]] diff --git a/crates/game-client/Cargo.toml b/crates/game-client/Cargo.toml index 7ced75c..6ab1d1c 100644 --- a/crates/game-client/Cargo.toml +++ b/crates/game-client/Cargo.toml @@ -14,6 +14,7 @@ bevy = { workspace = true, features = ["3d", "ui"] } hex-core = { workspace = true } match-bindings = { path = "../match-bindings" } spacetimedb-sdk.workspace = true +worldgen = { path = "../worldgen" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] bevy = { workspace = true, features = ["x11"] } diff --git a/crates/game-client/src/config.rs b/crates/game-client/src/config.rs index d2b9a56..767e04b 100644 --- a/crates/game-client/src/config.rs +++ b/crates/game-client/src/config.rs @@ -25,7 +25,7 @@ const DEFAULT_DATABASE: &str = match option_env!("OF_WEB_DATABASE") { #[derive(Parser, Debug)] #[command( name = "game-client", - about = "Native V1 hex RTS client", + about = "Native hex RTS client", disable_version_flag = true )] struct ClientArgs { @@ -33,6 +33,38 @@ struct ClientArgs { #[arg(long)] offline: bool, + /// Generate a composable layered V2 map for the offline viewer. + #[arg(long, requires = "offline")] + worldgen_v2: bool, + + /// Width of the generated V2 viewer map (default: 256). + #[arg( + long, + requires = "worldgen_v2", + value_parser = clap::value_parser!(u32).range(24..) + )] + map_width: Option, + + /// Height of the generated V2 viewer map (default: 256). + #[arg( + long, + requires = "worldgen_v2", + value_parser = clap::value_parser!(u32).range(24..) + )] + map_height: Option, + + /// Seed for the generated V2 viewer map (default: 42). + #[arg(long, requires = "worldgen_v2")] + map_seed: Option, + + /// Player spawn regions generated on the V2 viewer map (default: 2). + #[arg( + long, + requires = "worldgen_v2", + value_parser = clap::value_parser!(u16).range(2..=500) + )] + map_players: Option, + /// `SpacetimeDB` host URI (env: `OF_HOST`). #[arg(long)] host: Option, @@ -58,9 +90,18 @@ struct ClientArgs { auto_join: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LayeredWorldOptions { + pub width: u32, + pub height: u32, + pub seed: u64, + pub players: u16, +} + #[derive(Resource, Clone, Debug)] pub struct ClientConfig { pub offline: bool, + pub layered_world: Option, pub host: String, pub database: String, pub preferred_player: u16, @@ -74,6 +115,11 @@ impl ClientConfig { #[cfg(not(target_arch = "wasm32"))] pub fn from_process() -> Self { let args = ClientArgs::parse(); + Self::from_args(args) + } + + #[cfg(not(target_arch = "wasm32"))] + fn from_args(args: ClientArgs) -> Self { let explicit_player = args.player.is_some() || env_nonempty("OF_PLAYER").is_some(); let auto_join = args.auto_join || env_flag("OF_AUTO_JOIN") || explicit_player; let preferred_player = args @@ -101,6 +147,12 @@ impl ClientConfig { }); Self { offline: args.offline || env_flag("OF_OFFLINE"), + layered_world: args.worldgen_v2.then(|| LayeredWorldOptions { + width: args.map_width.unwrap_or(256), + height: args.map_height.unwrap_or(256), + seed: args.map_seed.unwrap_or(42), + players: args.map_players.unwrap_or(2), + }), host: args .host .or_else(|| env_nonempty("OF_HOST")) @@ -133,6 +185,7 @@ impl ClientConfig { Self { offline: browser_flag("offline"), + layered_world: None, host: browser_param("host").unwrap_or_else(|| DEFAULT_HOST.to_owned()), database: browser_param("database") .or_else(|| browser_param("db")) @@ -164,7 +217,13 @@ impl ClientConfig { } pub const fn mode_label(&self) -> &'static str { - if self.offline { "Offline" } else { "Online" } + if self.layered_world.is_some() { + "Offline V2" + } else if self.offline { + "Offline" + } else { + "Online" + } } } @@ -230,4 +289,28 @@ mod tests { assert_eq!(safe_profile("../../token"), None); assert_eq!(safe_profile(""), None); } + + #[test] + fn layered_viewer_arguments_have_safe_defaults() { + let args = ClientArgs::try_parse_from(["game-client", "--offline", "--worldgen-v2"]) + .expect("layered viewer arguments"); + let config = ClientConfig::from_args(args); + assert_eq!( + config.layered_world, + Some(LayeredWorldOptions { + width: 256, + height: 256, + seed: 42, + players: 2, + }) + ); + assert_eq!(config.mode_label(), "Offline V2"); + } + + #[test] + fn layered_viewer_requires_offline_mode() { + let error = ClientArgs::try_parse_from(["game-client", "--worldgen-v2"]) + .expect_err("online layered generation must be rejected"); + assert!(error.to_string().contains("--offline")); + } } diff --git a/crates/game-client/src/interaction.rs b/crates/game-client/src/interaction.rs index eecba6d..bd7c39e 100644 --- a/crates/game-client/src/interaction.rs +++ b/crates/game-client/src/interaction.rs @@ -2729,6 +2729,8 @@ mod tests { CellView { coordinate, terrain: TerrainKind::Plains, + river: false, + lake: false, elevation, owner, civilians: 0, diff --git a/crates/game-client/src/main.rs b/crates/game-client/src/main.rs index 200fc23..36db9a2 100644 --- a/crates/game-client/src/main.rs +++ b/crates/game-client/src/main.rs @@ -40,20 +40,50 @@ use overlays::OverlayPlugin; use performance::PerformanceOverlayPlugin; use population_outline::PopulationOutlinePlugin; use terrain::{spawn_terrain, sync_terrain_chunks}; +use worldgen::v2::{WorldSpec, generate as generate_v2}; fn main() { let config = ClientConfig::from_process(); - let match_view = if config.offline { + let match_view = if let Some(options) = &config.layered_world { + eprintln!( + "Generating layered V2 viewer map {}x{} · {} players · seed {}…", + options.width, options.height, options.players, options.seed + ); + let mut spec = WorldSpec::new( + format!("viewer-v2-{}x{}", options.width, options.height), + options.width, + options.height, + options.seed, + ); + spec.player_count = options.players; + let world = generate_v2(&spec).unwrap_or_else(|error| { + eprintln!("failed to generate layered V2 viewer map: {error}"); + std::process::exit(2); + }); + eprintln!( + "Generated {:016x} · {} land · {} lake · {} river cells", + world.manifest.content_hash, + world.manifest.land_cells, + world.manifest.lake_cells, + world.manifest.river_cells, + ); + MatchView::offline_layered_world(&world, config.preferred_player) + } else if config.offline { MatchView::offline_fixture() } else { MatchView::connecting(config.preferred_player) }; + let window_title = if config.layered_world.is_some() { + "Hex RTS · Layered V2 Viewer".to_owned() + } else { + format!("Hex RTS · V1 {}", config.mode_label()) + }; let mut app = App::new(); app.insert_resource(config.clone()) .insert_resource(match_view) .add_plugins(DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { - title: format!("Hex RTS · V1 {}", config.mode_label()), + title: window_title, resolution: WindowResolution::new(1440, 900), resizable: true, canvas: Some("#game-canvas".to_owned()), diff --git a/crates/game-client/src/map_view.rs b/crates/game-client/src/map_view.rs index 02c4e84..1670e6b 100644 --- a/crates/game-client/src/map_view.rs +++ b/crates/game-client/src/map_view.rs @@ -731,6 +731,8 @@ mod tests { CellView { coordinate: Axial::ZERO, terrain: TerrainKind::Plains, + river: false, + lake: false, elevation: 0, owner: Some(1), civilians, diff --git a/crates/game-client/src/model.rs b/crates/game-client/src/model.rs index 6434a48..70797bd 100644 --- a/crates/game-client/src/model.rs +++ b/crates/game-client/src/model.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use bevy::prelude::*; use hex_core::{Axial, ChunkCoord, TerrainKind}; +use worldgen::v2::{Landform, LayeredWorld, Surface}; use crate::geometry::chunk_of; @@ -103,6 +104,10 @@ impl MatchPhase { pub struct CellView { pub coordinate: Axial, pub terrain: TerrainKind, + /// V2-only visual metadata. Authoritative V1 cells leave this false. + pub river: bool, + /// Distinguishes generated lakes from ocean water in the viewer. + pub lake: bool, pub elevation: i16, pub owner: Option, pub civilians: u64, @@ -389,6 +394,8 @@ impl MatchView { CellView { coordinate, terrain: TerrainKind::Water, + river: false, + lake: false, elevation: 0, owner: None, civilians: 0, @@ -439,6 +446,8 @@ impl MatchView { CellView { coordinate, terrain, + river: false, + lake: false, elevation, owner, civilians, @@ -512,6 +521,124 @@ impl MatchView { } } + /// Projects a generated layered world into the existing offline client + /// model. Static V2 layers remain authoritative in `worldgen`; this view + /// keeps only the fields that the current renderer and interaction model + /// can display. + pub fn offline_layered_world(world: &LayeredWorld, preferred_player: u16) -> Self { + let player_count = world.manifest.player_count.clamp(2, 500); + let local_player = u32::from(preferred_player.min(player_count).max(1)); + let spawn_owners = layered_spawn_owners(world); + let mut cells = BTreeMap::new(); + let mut non_capturable_cells = BTreeSet::new(); + let mut capturable_cells = 0_u64; + + for (cell_id, layered) in world.cells().iter().enumerate() { + let coordinate = world + .coordinate(u32::try_from(cell_id).expect("V2 cell IDs fit in u32")) + .expect("V2 dense cell has a coordinate"); + let water = layered.surface != Surface::Land; + let terrain = if water { + TerrainKind::Water + } else { + match layered.landform { + Landform::Plain | Landform::Valley => TerrainKind::Plains, + Landform::Hill | Landform::Plateau => TerrainKind::Hills, + Landform::Mountain => TerrainKind::Mountain, + } + }; + // V2 stores broad signed relief while the V1 viewer models six + // discrete elevation steps. Quantize without flattening ridges. + let elevation = if water { + 0 + } else { + i16::try_from(((i32::from(layered.elevation).max(0) + 99) / 100).clamp(1, 6)) + .expect("viewer elevation is clamped") + }; + let owner = spawn_owners.get(&coordinate).copied(); + let military_capacity = u64::from(layered.gameplay.military_capacity); + let civilians = owner.map_or(0, |_| u64::from(layered.gameplay.civilian_capacity) / 2); + let infantry = owner.map_or(0, |_| military_capacity / 2); + if layered.gameplay.capturable { + capturable_cells += 1; + } else if !water { + non_capturable_cells.insert(coordinate); + } + cells.insert( + coordinate, + CellView { + coordinate, + terrain, + river: layered.river.is_some(), + lake: layered.surface == Surface::Lake, + elevation, + owner, + civilians, + infantry, + military_capacity, + blocked: !layered.gameplay.passable, + }, + ); + } + + let cells_by_chunk = index_cells_by_chunk(&cells); + let dirty_chunks = cells_by_chunk.keys().copied().collect(); + let required_control = + capturable_cells.saturating_mul(8_000).saturating_add(9_999) / 10_000; + let mut order_log = VecDeque::new(); + order_log.push_front(format!( + "Layered V2 map {:016x} · {}x{} · seed {}", + world.manifest.content_hash, + world.width(), + world.height(), + world.manifest.seed, + )); + + Self { + cells, + cells_by_chunk, + chunk_index_revision: 1, + cell_state_revision: 1, + planning_revision: 1, + ownership_revision: 1, + contest_revision: 0, + local_player, + player_count, + claimed_players: player_count, + authority: AuthorityState::Offline, + connection: vec![ConnectionState::Offline; usize::from(player_count)], + phase: MatchPhase::Running, + conquest_threshold_bps: 8_000, + max_elevation_step: 2, + non_capturable_cells, + authoritative_control: None, + capturable_cells, + required_control, + logical_step: 0, + mobilization_target: 0.55, + active_orders: 0, + queued_infantry: 0, + active_flows: Vec::new(), + authoritative_flows: BTreeMap::new(), + authoritative_flows_by_chunk: BTreeMap::new(), + active_fronts: Vec::new(), + contested_cells: BTreeMap::new(), + retask_projection: RetaskProjection::default(), + retask_revision: 0, + latest_result: format!( + "Layered V2 viewer · {} land · {} lakes · {} rivers", + world.manifest.land_cells, world.manifest.lake_cells, world.manifest.river_cells, + ), + order_log, + toast: Some(Toast { + text: format!("Layered V2 · Player {local_player}"), + kind: ToastKind::Info, + remaining: 4.0, + }), + dirty_chunks, + } + } + pub fn cell(&self, coordinate: Axial) -> Option<&CellView> { self.cells.get(&coordinate) } @@ -848,6 +975,42 @@ fn offline_owner(coordinate: Axial, player_count: u16) -> Option { }) } +fn layered_spawn_owners(world: &LayeredWorld) -> BTreeMap { + const SPAWN_RADIUS: i32 = 2; + // Reserve each exact spawn before expanding neighborhoods so even dense + // high-player-count maps visibly retain one cell for every player. + let mut owners = world + .manifest + .spawn_cells + .iter() + .copied() + .enumerate() + .map(|(index, spawn)| { + ( + spawn, + u32::try_from(index + 1).expect("V2 supports at most 500 players"), + ) + }) + .collect::>(); + for (index, spawn) in world.manifest.spawn_cells.iter().copied().enumerate() { + let player = u32::try_from(index + 1).expect("V2 supports at most 500 players"); + for q_offset in -SPAWN_RADIUS..=SPAWN_RADIUS { + let r_min = (-SPAWN_RADIUS).max(-q_offset - SPAWN_RADIUS); + let r_max = SPAWN_RADIUS.min(-q_offset + SPAWN_RADIUS); + for r_offset in r_min..=r_max { + let coordinate = Axial::new(spawn.q + q_offset, spawn.r + r_offset); + if world + .cell(coordinate) + .is_some_and(|cell| cell.gameplay.passable) + { + owners.entry(coordinate).or_insert(player); + } + } + } + } + owners +} + fn offline_player_anchors(player_count: u16) -> Vec { if player_count <= 8 { return OFFLINE_PLAYER_ANCHORS @@ -1014,6 +1177,8 @@ mod tests { CellView { coordinate, terrain: TerrainKind::Plains, + river: false, + lake: false, elevation: 0, owner: Some(PLAYER_ONE), civilians: 0, @@ -1082,6 +1247,36 @@ mod tests { assert!(owners.iter().any(|owner| *owner > 8)); } + #[test] + fn layered_world_projects_terrain_hydrology_and_spawns_into_viewer() { + let mut spec = worldgen::v2::WorldSpec::new("viewer-test", 96, 96, 42); + spec.player_count = 4; + let world = worldgen::v2::generate(&spec).expect("layered fixture"); + let view = MatchView::offline_layered_world(&world, 99); + + assert_eq!(view.cells.len(), 96 * 96); + assert_eq!(view.player_count, 4); + assert_eq!(view.local_player, 4); + assert!(view.cells.values().any(CellView::is_water)); + assert!(view.cells.values().any(|cell| cell.river)); + assert!( + view.cells.values().any(|cell| { + matches!(cell.terrain, TerrainKind::Mountain) && cell.elevation > 0 + }) + ); + assert_eq!( + view.cells.values().filter(|cell| cell.lake).count(), + world.manifest.lake_cells as usize + ); + assert_eq!( + view.cells + .values() + .filter_map(|cell| cell.owner) + .collect::>(), + BTreeSet::from([1, 2, 3, 4]) + ); + } + #[test] fn authoritative_control_projection_is_keyed_for_every_player() { let mut view = MatchView::connecting(4); diff --git a/crates/game-client/src/network.rs b/crates/game-client/src/network.rs index 62cfa4e..2f9af21 100644 --- a/crates/game-client/src/network.rs +++ b/crates/game-client/src/network.rs @@ -2141,6 +2141,8 @@ mod tests { CellView { coordinate, terrain: TerrainKind::Plains, + river: false, + lake: false, elevation: 0, owner: Some(1), civilians: 0, diff --git a/crates/game-client/src/online.rs b/crates/game-client/src/online.rs index ba6357a..952c0ce 100644 --- a/crates/game-client/src/online.rs +++ b/crates/game-client/src/online.rs @@ -2191,6 +2191,8 @@ fn cell_view_from_rows( match_bindings::TerrainClass::Hills => TerrainKind::Hills, match_bindings::TerrainClass::Mountain => TerrainKind::Mountain, }, + river: false, + lake: false, elevation: terrain.elevation, owner: state.and_then(|state| owner(state.owner_player_id)), civilians: state.map_or(0, |state| state.civilians), diff --git a/crates/game-client/src/overlays.rs b/crates/game-client/src/overlays.rs index a7fbd83..0483009 100644 --- a/crates/game-client/src/overlays.rs +++ b/crates/game-client/src/overlays.rs @@ -803,6 +803,8 @@ mod tests { CellView { coordinate, terrain: TerrainKind::Plains, + river: false, + lake: false, elevation: 0, owner, civilians: 0, diff --git a/crates/game-client/src/population_outline.rs b/crates/game-client/src/population_outline.rs index 575b65e..f537630 100644 --- a/crates/game-client/src/population_outline.rs +++ b/crates/game-client/src/population_outline.rs @@ -341,6 +341,8 @@ mod tests { CellView { coordinate, terrain: TerrainKind::Plains, + river: false, + lake: false, elevation: 1, owner, civilians: 20, diff --git a/crates/game-client/src/terrain.rs b/crates/game-client/src/terrain.rs index 118a8c6..d2cedef 100644 --- a/crates/game-client/src/terrain.rs +++ b/crates/game-client/src/terrain.rs @@ -558,7 +558,11 @@ fn recolor_chunk_mesh( fn cell_color(cell: &CellView, contest: Option<&ContestedCellView>, mode: MapViewMode) -> [f32; 4] { let base = if cell.is_water() { - Color::srgb(0.055, 0.16, 0.21) + if cell.lake { + Color::srgb(0.075, 0.27, 0.31) + } else { + Color::srgb(0.055, 0.16, 0.21) + } } else { match cell.owner { Some(player) => player_color(player).unwrap_or_else(|| terrain_color(cell.terrain)), @@ -577,6 +581,13 @@ fn cell_color(cell: &CellView, contest: Option<&ContestedCellView>, mode: MapVie ) }, ); + if cell.river && cell.is_land() { + linear = mix_linear_rgba( + linear, + LinearRgba::from(Color::srgb(0.055, 0.34, 0.43)), + 0.46, + ); + } let intensity = match mode { MapViewMode::Overview => 0.42, MapViewMode::Soldiers => normalized_soldier_strength( @@ -660,14 +671,20 @@ fn normalized_share(share: f32) -> f32 { } fn mix_linear_colors(controller: Color, attacker: Color, attacker_share: f32) -> LinearRgba { - let controller = LinearRgba::from(controller); - let attacker = LinearRgba::from(attacker); - let controller_share = 1.0 - attacker_share; + mix_linear_rgba( + LinearRgba::from(controller), + LinearRgba::from(attacker), + attacker_share, + ) +} + +fn mix_linear_rgba(first: LinearRgba, second: LinearRgba, second_share: f32) -> LinearRgba { + let first_share = 1.0 - second_share; LinearRgba::new( - controller.red * controller_share + attacker.red * attacker_share, - controller.green * controller_share + attacker.green * attacker_share, - controller.blue * controller_share + attacker.blue * attacker_share, - controller.alpha * controller_share + attacker.alpha * attacker_share, + first.red * first_share + second.red * second_share, + first.green * first_share + second.green * second_share, + first.blue * first_share + second.blue * second_share, + first.alpha * first_share + second.alpha * second_share, ) } @@ -723,6 +740,8 @@ mod tests { CellView { coordinate: Axial::ZERO, terrain: TerrainKind::Plains, + river: false, + lake: false, elevation: 1, owner: Some(PLAYER_ONE), civilians, @@ -749,6 +768,39 @@ mod tests { assert_eq!(player_color(2), Some(Color::srgb(0.76, 0.24, 0.16))); } + #[test] + fn layered_hydrology_has_distinct_viewer_colors() { + let ordinary = test_cell(0, 0, 100); + let river = CellView { + river: true, + ..ordinary.clone() + }; + let lake = CellView { + terrain: TerrainKind::Water, + lake: true, + ..ordinary.clone() + }; + let ocean = CellView { + terrain: TerrainKind::Water, + ..ordinary.clone() + }; + + let visibly_distinct = |first: [f32; 4], second: [f32; 4]| { + first + .into_iter() + .zip(second) + .any(|(first, second)| (first - second).abs() > 1.0e-6) + }; + assert!(visibly_distinct( + cell_color(&river, None, MapViewMode::Overview), + cell_color(&ordinary, None, MapViewMode::Overview) + )); + assert!(visibly_distinct( + cell_color(&lake, None, MapViewMode::Overview), + cell_color(&ocean, None, MapViewMode::Overview) + )); + } + #[test] fn player_colors_cover_one_through_five_hundred_deterministically() { let mut seen = std::collections::BTreeSet::new(); diff --git a/docs/worldgen-v2.md b/docs/worldgen-v2.md index 1e59411..13e5d0e 100644 --- a/docs/worldgen-v2.md +++ b/docs/worldgen-v2.md @@ -1,7 +1,8 @@ # Layered world generator V2 -Status: implemented offline generator contract; authoritative match integration -is deliberately separate from the pinned V1 map format. +Status: implemented offline generator contract and native client viewer; +authoritative match integration is deliberately separate from the pinned V1 +map format. ## Purpose @@ -62,6 +63,28 @@ The default remains V1: cargo run -p mapgen -- --preset validation ``` +### Native client viewer + +Generate V2 in-process and open it in the existing offline Bevy viewer: + +```bash +./scripts/run-client.sh --offline --worldgen-v2 \ + --map-width 512 --map-height 512 \ + --map-players 32 --map-seed 42 +``` + +With no map options, `--worldgen-v2` defaults to a 256 x 256 map, two players, +and seed 42. The ordinary `--offline` command still loads the small hand-built +fixture. V2 oceans and lakes use separate water tones, and river-bearing land +cells receive a blue-green overlay while retaining their plain, hill, plateau, +valley, or mountain geometry. Generated spawn neighborhoods are assigned to +their players so the normal ownership and interaction overlays remain usable. + +The current renderer builds 8 x 8 render chunks with bounded per-frame work, +but eventually retains every chunk. A 256 or 512 square map is the practical +viewer starting point; million-cell runtime play still needs resident-set +terrain streaming as described below. + Generate and validate a custom layered map: ```bash @@ -120,10 +143,11 @@ SpacetimeDB schema and Bevy client still materialize full per-cell/per-edge state and must not switch to V2 at that size without runtime terrain streaming, sparse wave topology, and map-size-based subscription/simulation policies. -## Runtime integration boundary +## Authoritative runtime integration boundary -V2 is intentionally not selected by the match reducer yet. Safe integration -requires a versioned terrain adapter and generated bindings, followed by: +The offline client can project V2 into its viewer model, but V2 is intentionally +not selected by the match reducer yet. Safe authoritative integration requires +versioned generated bindings, followed by: - packed immutable chunk storage or deterministic client-side regeneration; - spatial terrain interest and a resident-set renderer; From 2186f9b539bc0c59cf0e3bf09919162e6093dc72 Mon Sep 17 00:00:00 2001 From: Diego Carlino Date: Fri, 7 Aug 2026 18:03:00 +0200 Subject: [PATCH 3/4] Fill MatchView lobby when projecting layered worlds Main added LobbyView to MatchView; keep the offline V2 viewer compiling after the rebase. --- crates/game-client/src/model.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/game-client/src/model.rs b/crates/game-client/src/model.rs index 70797bd..66217a7 100644 --- a/crates/game-client/src/model.rs +++ b/crates/game-client/src/model.rs @@ -605,6 +605,16 @@ impl MatchView { local_player, player_count, claimed_players: player_count, + lobby: LobbyView { + map_size: u16::try_from(world.width().max(world.height())).unwrap_or(u16::MAX), + configuration_locked: true, + available: false, + action_pending: false, + local_player: Some(u16::try_from(local_player).expect("local player fits u16")), + player_names: (1..=player_count) + .map(|player| format!("Player {player}")) + .collect(), + }, authority: AuthorityState::Offline, connection: vec![ConnectionState::Offline; usize::from(player_count)], phase: MatchPhase::Running, From b689b96e9526568984473bfbc43decc715e51c5f Mon Sep 17 00:00:00 2001 From: Diego Carlino Date: Fri, 7 Aug 2026 18:13:26 +0200 Subject: [PATCH 4/4] Fix order-input tests after lobby phase gate MatchView::connecting stays in Lobby, but process_order_input only runs while Running; put the harness in Running so the suite matches in-match input. --- crates/game-client/src/interaction.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/game-client/src/interaction.rs b/crates/game-client/src/interaction.rs index bd7c39e..83a0bdb 100644 --- a/crates/game-client/src/interaction.rs +++ b/crates/game-client/src/interaction.rs @@ -2799,7 +2799,10 @@ mod tests { input } - fn order_input_app(view: MatchView, interaction: InteractionState) -> App { + fn order_input_app(mut view: MatchView, interaction: InteractionState) -> App { + // Order-input tests build from MatchView::connecting (Lobby); the + // production gate requires Running before accepting map input. + view.phase = crate::model::MatchPhase::Running; let mut app = App::new(); app.add_message::() .add_message::()