From aae0eccc8bd1572901a8da3503b89c664ad9038a Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 20:39:47 +0200 Subject: [PATCH] =?UTF-8?q?Generator=20f=C3=BCr=20die=20Testkarte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baut aus einer Startzahl eine 64 × 64 Felder große Karte mit Rand aus Grundgestein, Lichtung am Startpunkt, Felsblock daneben, weichem Fels innen und hartem Fels außen. Die Regeln sind für 300 Startzahlen geprüft. Co-Authored-By: Claude Haiku 4.5 --- core/src/lib.rs | 1 + core/src/mapgen.rs | 104 ++++++++++++++++++++++++++++++++++ core/tests/mapgen.rs | 132 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 core/src/mapgen.rs create mode 100644 core/tests/mapgen.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index 6482889..2396db1 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -9,4 +9,5 @@ pub mod codec; pub mod hash; pub mod map; +pub mod mapgen; pub mod rng; diff --git a/core/src/mapgen.rs b/core/src/mapgen.rs new file mode 100644 index 0000000..686fd00 --- /dev/null +++ b/core/src/mapgen.rs @@ -0,0 +1,104 @@ +//! Generator for the milestone 1 test map. +//! +//! The broad layout is fixed and the details are random: a bedrock border, a +//! clearing around the spawn point, a rock outcrop right next to the +//! clearing, cave-like soft rock in the inner area and hard rock further out. + +use crate::map::{Map, Pos, Terrain, Tile}; +use crate::rng::Rng; + +/// Edge length of the test map, in tiles. +pub const TEST_MAP_SIZE: u32 = 64; +/// Tiles within this distance of the spawn point are always open ground. +pub const SPAWN_CLEARING_RADIUS: i32 = 5; +/// Edge length of the soft rock outcrop that every test map has. +pub const OUTCROP_SIZE: i32 = 4; +/// Rock at least this many tiles from the spawn point, counted along the +/// longer axis, is hard rock. +pub const HARD_ROCK_DISTANCE: i32 = 22; + +const INITIAL_ROCK_PERCENT: u32 = 46; +const SMOOTHING_PASSES: u32 = 4; +/// Keeps the map sequence apart from other uses of the same seed. +const SEED_SALT: u64 = 0x6D61_7067_656E_0001; + +/// Spawn point of a map: its centre tile. +pub fn spawn_pos(map: &Map) -> Pos { + Pos::new((map.width() / 2) as i32, (map.height() / 2) as i32) +} + +/// Top left tile of the soft rock outcrop next to the spawn clearing. +pub fn outcrop_origin(map: &Map) -> Pos { + let spawn = spawn_pos(map); + Pos::new(spawn.x + SPAWN_CLEARING_RADIUS + 2, spawn.y - 2) +} + +/// Builds the test map for `seed`. The same seed always gives the same map. +pub fn generate_test_map(seed: u64) -> Map { + let size = TEST_MAP_SIZE; + let mut rng = Rng::new(seed ^ SEED_SALT); + + let mut rock = vec![false; (size as usize) * (size as usize)]; + for cell in rock.iter_mut() { + *cell = rng.below(100) < INITIAL_ROCK_PERCENT; + } + for _ in 0..SMOOTHING_PASSES { + rock = smooth(&rock, size); + } + + let mut map = Map::filled(size, size, Terrain::Ground); + let spawn = spawn_pos(&map); + let outcrop = outcrop_origin(&map); + let last = size as i32 - 1; + + for y in 0..=last { + for x in 0..=last { + let dx = x - spawn.x; + let dy = y - spawn.y; + let on_border = x == 0 || y == 0 || x == last || y == last; + let in_clearing = dx * dx + dy * dy <= SPAWN_CLEARING_RADIUS * SPAWN_CLEARING_RADIUS; + let in_outcrop = (outcrop.x..outcrop.x + OUTCROP_SIZE).contains(&x) + && (outcrop.y..outcrop.y + OUTCROP_SIZE).contains(&y); + let is_rock = rock[(y as usize) * (size as usize) + (x as usize)]; + + let terrain = if on_border { + Terrain::Bedrock + } else if in_clearing { + Terrain::Ground + } else if in_outcrop { + Terrain::SoftRock + } else if !is_rock { + Terrain::Ground + } else if dx.abs().max(dy.abs()) >= HARD_ROCK_DISTANCE { + Terrain::HardRock + } else { + Terrain::SoftRock + }; + map.set(Pos::new(x, y), Tile::new(terrain)); + } + } + map +} + +/// One pass of cave smoothing: a cell becomes rock when at least five of the +/// nine cells around it, itself included, are rock. Cells outside the grid +/// count as rock. +fn smooth(rock: &[bool], size: u32) -> Vec { + let size = size as i32; + let mut out = vec![false; rock.len()]; + for y in 0..size { + for x in 0..size { + let mut count = 0; + for ny in y - 1..=y + 1 { + for nx in x - 1..=x + 1 { + let outside = nx < 0 || ny < 0 || nx >= size || ny >= size; + if outside || rock[(ny * size + nx) as usize] { + count += 1; + } + } + } + out[(y * size + x) as usize] = count >= 5; + } + } + out +} diff --git a/core/tests/mapgen.rs b/core/tests/mapgen.rs new file mode 100644 index 0000000..5c97e5d --- /dev/null +++ b/core/tests/mapgen.rs @@ -0,0 +1,132 @@ +//! Rules the map generator has to keep for every seed. + +use grovewright_core::map::{Map, Pos, Terrain}; +use grovewright_core::mapgen::{ + generate_test_map, outcrop_origin, spawn_pos, HARD_ROCK_DISTANCE, OUTCROP_SIZE, + SPAWN_CLEARING_RADIUS, TEST_MAP_SIZE, +}; + +/// Number of seeds every rule is checked against. +const SEEDS: u64 = 300; + +fn terrain_at(map: &Map, x: i32, y: i32) -> Terrain { + map.get(Pos::new(x, y)) + .expect("position is on the map") + .terrain +} + +#[test] +fn the_same_seed_gives_the_same_map() { + assert_eq!(generate_test_map(42), generate_test_map(42)); +} + +#[test] +fn different_seeds_give_different_maps() { + assert_ne!(generate_test_map(1), generate_test_map(2)); +} + +#[test] +fn the_map_has_the_test_size() { + let map = generate_test_map(0); + assert_eq!((map.width(), map.height()), (TEST_MAP_SIZE, TEST_MAP_SIZE)); +} + +#[test] +fn bedrock_forms_the_border_and_nothing_else() { + let last = TEST_MAP_SIZE as i32 - 1; + for seed in 0..SEEDS { + let map = generate_test_map(seed); + for y in 0..=last { + for x in 0..=last { + let on_border = x == 0 || y == 0 || x == last || y == last; + let is_bedrock = terrain_at(&map, x, y) == Terrain::Bedrock; + assert_eq!(is_bedrock, on_border, "seed {seed}, tile {x}/{y}"); + } + } + } +} + +#[test] +fn the_spawn_clearing_is_open_ground() { + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let spawn = spawn_pos(&map); + for dy in -SPAWN_CLEARING_RADIUS..=SPAWN_CLEARING_RADIUS { + for dx in -SPAWN_CLEARING_RADIUS..=SPAWN_CLEARING_RADIUS { + if dx * dx + dy * dy > SPAWN_CLEARING_RADIUS * SPAWN_CLEARING_RADIUS { + continue; + } + let terrain = terrain_at(&map, spawn.x + dx, spawn.y + dy); + assert_eq!(terrain, Terrain::Ground, "seed {seed}, offset {dx}/{dy}"); + } + } + } +} + +#[test] +fn a_soft_rock_outcrop_lies_next_to_the_clearing() { + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let spawn = spawn_pos(&map); + let origin = outcrop_origin(&map); + assert_eq!(origin.x - spawn.x, SPAWN_CLEARING_RADIUS + 2, "seed {seed}"); + for y in origin.y..origin.y + OUTCROP_SIZE { + for x in origin.x..origin.x + OUTCROP_SIZE { + assert_eq!( + terrain_at(&map, x, y), + Terrain::SoftRock, + "seed {seed}, tile {x}/{y}" + ); + } + } + } +} + +#[test] +fn hard_rock_appears_only_far_from_the_spawn_point() { + let last = TEST_MAP_SIZE as i32 - 1; + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let spawn = spawn_pos(&map); + for y in 1..last { + for x in 1..last { + let far = (x - spawn.x).abs().max((y - spawn.y).abs()) >= HARD_ROCK_DISTANCE; + match terrain_at(&map, x, y) { + Terrain::HardRock => assert!(far, "seed {seed}: hard rock near spawn"), + Terrain::SoftRock => assert!(!far, "seed {seed}: soft rock far out"), + Terrain::Ground | Terrain::Bedrock => {} + } + } + } + } +} + +#[test] +fn every_map_mixes_rock_and_open_ground() { + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let total = map.tiles().len(); + let rock = map + .tiles() + .iter() + .filter(|tile| !tile.terrain.is_walkable()) + .count(); + let percent = rock * 100 / total; + assert!( + (20..=80).contains(&percent), + "seed {seed}: {percent} percent rock" + ); + let hard = map + .tiles() + .iter() + .filter(|tile| tile.terrain == Terrain::HardRock) + .count(); + assert!(hard > 0, "seed {seed}: no hard rock"); + } +} + +#[test] +fn no_generated_tile_starts_damaged() { + let map = generate_test_map(5); + assert!(map.tiles().iter().all(|tile| tile.damage == 0)); +}