diff --git a/modules/world-worldgen/src/biome_registry.zig b/modules/world-worldgen/src/biome_registry.zig index 77fe1c9b..47597e6c 100644 --- a/modules/world-worldgen/src/biome_registry.zig +++ b/modules/world-worldgen/src/biome_registry.zig @@ -11,6 +11,9 @@ pub const TreeType = tree_registry.TreeType; /// Minimum sum threshold for biome blend calculation to avoid division by near-zero values pub const BLEND_EPSILON: f32 = 0.0001; +/// Normalized elevation produced by computeClimateParams at sea level. +pub const NORMALIZED_SEA_LEVEL: f32 = 0.30; + /// Represents a range of values for biome parameter matching pub const Range = struct { min: f32, @@ -387,6 +390,7 @@ pub const BIOME_REGISTRY: []const BiomeDefinition = &.{ .humidity = Range.any(), .elevation = .{ .min = 0.28, .max = 0.38 }, .continentalness = .{ .min = 0.35, .max = 0.42 }, // NARROW beach band + .max_height = 70, .max_slope = 2, .priority = 10, .surface = .{ .top = .sand, .filler = .sand, .depth_range = 2 }, @@ -399,6 +403,7 @@ pub const BIOME_REGISTRY: []const BiomeDefinition = &.{ .humidity = Range.any(), .elevation = .{ .min = 0.28, .max = 0.45 }, .continentalness = .{ .min = 0.35, .max = 0.45 }, + .max_height = 82, .max_slope = 8, .priority = 11, .surface = .{ .top = .stone, .filler = .gravel, .depth_range = 2 }, @@ -413,6 +418,7 @@ pub const BIOME_REGISTRY: []const BiomeDefinition = &.{ .humidity = Range.any(), .elevation = .{ .min = 0.28, .max = 0.38 }, .continentalness = .{ .min = 0.35, .max = 0.42 }, + .max_height = 70, .max_slope = 2, .priority = 12, .surface = .{ .top = .snow_block, .filler = .sand, .depth_range = 2 }, diff --git a/modules/world-worldgen/src/biome_selector.zig b/modules/world-worldgen/src/biome_selector.zig index c9b20d51..06df0693 100644 --- a/modules/world-worldgen/src/biome_selector.zig +++ b/modules/world-worldgen/src/biome_selector.zig @@ -10,6 +10,9 @@ const StructuralParams = registry.StructuralParams; const BIOME_REGISTRY = registry.BIOME_REGISTRY; const BIOME_POINTS = registry.BIOME_POINTS; const BLEND_EPSILON = registry.BLEND_EPSILON; +const NORMALIZED_SEA_LEVEL = registry.NORMALIZED_SEA_LEVEL; + +const OCEAN_CONTINENTALNESS_MAX: f32 = 0.35; // ============================================================================ // Voronoi Biome Selection (Issue #106) @@ -107,6 +110,7 @@ pub fn selectBiome(params: ClimateParams) BiomeId { var best_biome: BiomeId = .plains; // Default fallback for (BIOME_REGISTRY) |biome| { + if (isOceanBiome(biome.id) and !isOceanClimate(params)) continue; const s = biome.scoreClimate(params); if (s > best_score) { best_score = s; @@ -129,6 +133,7 @@ pub fn selectBiomeMultiParam(climate: ClimateParams, structural: StructuralParam var best_biome: BiomeId = .plains; for (BIOME_REGISTRY) |biome| { + if (isOceanBiome(biome.id) and !isOceanStructure(params, structural)) continue; if (!biome.meetsStructuralConstraints(structural.height, structural.slope, structural.continentalness, structural.ridge_mask)) continue; const s = biome.scoreClimate(params); @@ -141,6 +146,21 @@ pub fn selectBiomeMultiParam(climate: ClimateParams, structural: StructuralParam return best_biome; } +fn isOceanStructure(climate: ClimateParams, structural: StructuralParams) bool { + return structural.continentalness < OCEAN_CONTINENTALNESS_MAX and climate.elevation <= NORMALIZED_SEA_LEVEL; +} + +fn isOceanClimate(climate: ClimateParams) bool { + return climate.continentalness < OCEAN_CONTINENTALNESS_MAX and climate.elevation <= NORMALIZED_SEA_LEVEL; +} + +fn isOceanBiome(biome: BiomeId) bool { + return switch (biome) { + .deep_ocean, .ocean, .frozen_ocean, .cold_ocean, .warm_ocean => true, + else => false, + }; +} + /// Select biome with river override pub fn selectBiomeWithRiver(params: ClimateParams, river_mask: f32) BiomeId { // River biome takes priority when river mask is active @@ -161,18 +181,18 @@ pub fn computeClimateParams( sea_level: i32, max_height: i32, ) ClimateParams { - // Normalize elevation: 0 = below sea, 0.3 = sea level, 1.0 = max height + // Normalize elevation: 0 = below sea, NORMALIZED_SEA_LEVEL = sea level, 1.0 = max height // Use conditional to avoid integer overflow when height < sea_level const height_above_sea: i32 = if (height > sea_level) height - sea_level else 0; const elevation_range = max_height - sea_level; const elevation = if (elevation_range > 0) - 0.3 + 0.7 * @as(f32, @floatFromInt(height_above_sea)) / @as(f32, @floatFromInt(elevation_range)) + NORMALIZED_SEA_LEVEL + 0.7 * @as(f32, @floatFromInt(height_above_sea)) / @as(f32, @floatFromInt(elevation_range)) else - 0.3; + NORMALIZED_SEA_LEVEL; - // For underwater: scale 0-0.3 + // For underwater: scale 0-NORMALIZED_SEA_LEVEL const final_elevation = if (height < sea_level) - 0.3 * @as(f32, @floatFromInt(@max(0, height))) / @as(f32, @floatFromInt(sea_level)) + NORMALIZED_SEA_LEVEL * @as(f32, @floatFromInt(@max(0, height))) / @as(f32, @floatFromInt(sea_level)) else elevation; @@ -206,6 +226,7 @@ pub fn selectBiomeBlended(params: ClimateParams) BiomeSelection { var second_biome: ?BiomeId = null; for (BIOME_REGISTRY) |biome| { + if (isOceanBiome(biome.id) and !isOceanClimate(params)) continue; const s = biome.scoreClimate(params); if (s > best_score) { second_score = best_score; diff --git a/modules/world-worldgen/src/biome_selector_tests.zig b/modules/world-worldgen/src/biome_selector_tests.zig index bdd76891..b99699d1 100644 --- a/modules/world-worldgen/src/biome_selector_tests.zig +++ b/modules/world-worldgen/src/biome_selector_tests.zig @@ -393,6 +393,114 @@ test "selectBiomeWithConstraints locks structural edge cases" { })); } +test "selectBiomeWithConstraints preserves ocean sea-level boundary" { + const underwater_ocean = ClimateParams{ + .temperature = 0.5, + .humidity = 0.5, + .elevation = 0.30, + .continentalness = 0.25, + .ruggedness = 0.1, + }; + const inland_ocean_candidate = ClimateParams{ + .temperature = 0.5, + .humidity = 0.5, + .elevation = 0.31, + .continentalness = 0.25, + .ruggedness = 0.1, + }; + const structural = StructuralParams{ + .height = 64, + .slope = 1, + .continentalness = 0.25, + .ridge_mask = 0.1, + }; + + try testing.expectEqual(BiomeId.ocean, selectBiomeWithConstraints(underwater_ocean, structural)); + try testing.expect(selectBiomeWithConstraints(inland_ocean_candidate, structural) != .ocean); + try testing.expect(selectBiomeWithConstraints(inland_ocean_candidate, structural) != .deep_ocean); + try testing.expect(selectBiomeWithConstraints(inland_ocean_candidate, structural) != .frozen_ocean); + try testing.expect(selectBiomeWithConstraints(inland_ocean_candidate, structural) != .cold_ocean); + try testing.expect(selectBiomeWithConstraints(inland_ocean_candidate, structural) != .warm_ocean); +} + +test "selectBiomeWithConstraints preserves coast and inland water boundaries" { + const coast_climate = ClimateParams{ + .temperature = 0.6, + .humidity = 0.5, + .elevation = 0.3, + .continentalness = 0.35, + .ruggedness = 0.1, + }; + const coast_structural = StructuralParams{ + .height = 64, + .slope = 1, + .continentalness = 0.35, + .ridge_mask = 0.1, + }; + const steep_coast = StructuralParams{ + .height = 64, + .slope = 3, + .continentalness = 0.35, + .ridge_mask = 0.1, + }; + const high_coast = StructuralParams{ + .height = 71, + .slope = 1, + .continentalness = 0.35, + .ridge_mask = 0.1, + }; + const inland_water = ClimateParams{ + .temperature = 0.6, + .humidity = 0.5, + .elevation = 0.2, + .continentalness = 0.6, + .ruggedness = 0.1, + }; + + try testing.expectEqual(BiomeId.beach, selectBiomeWithConstraints(coast_climate, coast_structural)); + try testing.expect(selectBiomeWithConstraints(coast_climate, steep_coast) != .beach); + try testing.expect(selectBiomeWithConstraints(coast_climate, high_coast) != .beach); + try testing.expect(selectBiomeWithConstraints(inland_water, .{ + .height = 50, + .slope = 1, + .continentalness = 0.6, + .ridge_mask = 0.1, + }) != .beach); +} + +test "selectBiomeWithConstraintsAndRiver preserves river thresholds" { + const temperate = ClimateParams{ + .temperature = 0.21, + .humidity = 0.7, + .elevation = 0.3, + .continentalness = 0.6, + .ruggedness = 0.2, + }; + const frozen = ClimateParams{ + .temperature = 0.20, + .humidity = 0.7, + .elevation = 0.3, + .continentalness = 0.6, + .ruggedness = 0.2, + }; + const structural = StructuralParams{ + .height = 119, + .slope = 2, + .continentalness = 0.6, + .ridge_mask = 0.1, + }; + + try testing.expect(selectBiomeWithConstraintsAndRiver(temperate, structural, 0.5) != .river); + try testing.expectEqual(BiomeId.river, selectBiomeWithConstraintsAndRiver(temperate, structural, 0.5001)); + try testing.expectEqual(BiomeId.frozen_river, selectBiomeWithConstraintsAndRiver(frozen, structural, 0.5001)); + try testing.expect(selectBiomeWithConstraintsAndRiver(temperate, .{ + .height = 120, + .slope = 2, + .continentalness = 0.6, + .ridge_mask = 0.1, + }, 0.5001) != .river); +} + test "selectBiomeWithConstraints documents ridge mask baseline behavior" { const climate = ClimateParams{ .temperature = 0.4, @@ -547,6 +655,22 @@ test "selectBiomeSimple returns ocean for low continentalness" { try testing.expect(biome == .deep_ocean or biome == .ocean); } +test "selectBiomeSimple does not return ocean above sea level" { + const params = ClimateParams{ + .temperature = 0.5, + .humidity = 0.5, + .elevation = 0.31, + .continentalness = 0.15, + .ruggedness = 0.3, + }; + const biome = selectBiomeSimple(params); + try testing.expect(biome != .deep_ocean); + try testing.expect(biome != .ocean); + try testing.expect(biome != .frozen_ocean); + try testing.expect(biome != .cold_ocean); + try testing.expect(biome != .warm_ocean); +} + test "selectBiomeSimple returns deep_ocean for very low continentalness" { const params = ClimateParams{ .temperature = 0.5,