Rename parameters for clearer semantics: - dig_hill: depth -> target_height - dig_bezier: start_depth/end_depth -> start_height/end_height The libtcod "dig" functions set terrain TO a target height, not relative to current values. "target_height" makes this clearer. Also add warnings for likely user errors: - add_hill/dig_hill/dig_bezier with radius <= 0 (no-op) - smooth with iterations <= 0 already raises ValueError Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
425 lines
14 KiB
Python
425 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit tests for mcrfpy.HeightMap terrain generation methods (#195)
|
|
|
|
Tests the HeightMap terrain methods: add_hill, dig_hill, add_voronoi,
|
|
mid_point_displacement, rain_erosion, dig_bezier, smooth
|
|
"""
|
|
|
|
import sys
|
|
import mcrfpy
|
|
|
|
|
|
def test_add_hill_basic():
|
|
"""add_hill() creates elevation at center"""
|
|
hmap = mcrfpy.HeightMap((50, 50), fill=0.0)
|
|
hmap.add_hill((25, 25), radius=10.0, height=1.0)
|
|
|
|
# Center should have highest value
|
|
center_val = hmap[25, 25]
|
|
edge_val = hmap[0, 0]
|
|
|
|
assert center_val > edge_val, f"Center ({center_val}) should be higher than edge ({edge_val})"
|
|
assert center_val > 0.5, f"Center should be significantly elevated, got {center_val}"
|
|
print("PASS: test_add_hill_basic")
|
|
|
|
|
|
def test_add_hill_returns_self():
|
|
"""add_hill() returns self for chaining"""
|
|
hmap = mcrfpy.HeightMap((20, 20))
|
|
result = hmap.add_hill((10, 10), 5.0, 1.0)
|
|
assert result is hmap
|
|
print("PASS: test_add_hill_returns_self")
|
|
|
|
|
|
def test_add_hill_flexible_center():
|
|
"""add_hill() accepts tuple, list, and Vector for center"""
|
|
hmap1 = mcrfpy.HeightMap((20, 20), fill=0.0)
|
|
hmap2 = mcrfpy.HeightMap((20, 20), fill=0.0)
|
|
hmap3 = mcrfpy.HeightMap((20, 20), fill=0.0)
|
|
|
|
hmap1.add_hill((10, 10), 5.0, 1.0)
|
|
hmap2.add_hill([10, 10], 5.0, 1.0)
|
|
hmap3.add_hill(mcrfpy.Vector(10, 10), 5.0, 1.0)
|
|
|
|
# All should produce same result
|
|
assert abs(hmap1[10, 10] - hmap2[10, 10]) < 0.001
|
|
assert abs(hmap1[10, 10] - hmap3[10, 10]) < 0.001
|
|
print("PASS: test_add_hill_flexible_center")
|
|
|
|
|
|
def test_dig_hill_basic():
|
|
"""dig_hill() creates depression at center using target_height"""
|
|
hmap = mcrfpy.HeightMap((50, 50), fill=0.5)
|
|
# dig_hill constructs a pit with target_height at center
|
|
# Only lowers cells - cells below target_height are unchanged
|
|
hmap.dig_hill((25, 25), radius=15.0, target_height=-0.3)
|
|
|
|
# Center should have lowest value (set to the target_height)
|
|
center_val = hmap[25, 25]
|
|
edge_val = hmap[0, 0]
|
|
|
|
assert center_val < edge_val, f"Center ({center_val}) should be lower than edge ({edge_val})"
|
|
assert center_val < 0, f"Center should be negative, got {center_val}"
|
|
print("PASS: test_dig_hill_basic")
|
|
|
|
|
|
def test_dig_hill_returns_self():
|
|
"""dig_hill() returns self for chaining"""
|
|
hmap = mcrfpy.HeightMap((20, 20))
|
|
result = hmap.dig_hill((10, 10), 5.0, 0.5)
|
|
assert result is hmap
|
|
print("PASS: test_dig_hill_returns_self")
|
|
|
|
|
|
def test_add_voronoi_basic():
|
|
"""add_voronoi() modifies heightmap"""
|
|
hmap = mcrfpy.HeightMap((50, 50), fill=0.0)
|
|
min_before, max_before = hmap.min_max()
|
|
|
|
hmap.add_voronoi(10, seed=12345)
|
|
|
|
min_after, max_after = hmap.min_max()
|
|
|
|
# Values should have changed
|
|
assert max_after > max_before or min_after < min_before, "Voronoi should modify values"
|
|
print("PASS: test_add_voronoi_basic")
|
|
|
|
|
|
def test_add_voronoi_with_coefficients():
|
|
"""add_voronoi() accepts custom coefficients"""
|
|
hmap = mcrfpy.HeightMap((30, 30), fill=0.0)
|
|
hmap.add_voronoi(5, coefficients=(1.0, -0.5, 0.3), seed=42)
|
|
|
|
# Should complete without error
|
|
min_val, max_val = hmap.min_max()
|
|
assert min_val != max_val, "Voronoi should create variation"
|
|
print("PASS: test_add_voronoi_with_coefficients")
|
|
|
|
|
|
def test_add_voronoi_returns_self():
|
|
"""add_voronoi() returns self for chaining"""
|
|
hmap = mcrfpy.HeightMap((20, 20))
|
|
result = hmap.add_voronoi(5)
|
|
assert result is hmap
|
|
print("PASS: test_add_voronoi_returns_self")
|
|
|
|
|
|
def test_add_voronoi_invalid_num_points():
|
|
"""add_voronoi() raises ValueError for invalid num_points"""
|
|
hmap = mcrfpy.HeightMap((20, 20))
|
|
|
|
try:
|
|
hmap.add_voronoi(0)
|
|
print("FAIL: should have raised ValueError for num_points=0")
|
|
sys.exit(1)
|
|
except ValueError:
|
|
pass
|
|
|
|
print("PASS: test_add_voronoi_invalid_num_points")
|
|
|
|
|
|
def test_mid_point_displacement_basic():
|
|
"""mid_point_displacement() generates terrain"""
|
|
# Use power-of-2+1 size for best results
|
|
hmap = mcrfpy.HeightMap((65, 65), fill=0.0)
|
|
hmap.mid_point_displacement(roughness=0.5, seed=12345)
|
|
|
|
min_val, max_val = hmap.min_max()
|
|
assert min_val != max_val, "MPD should create variation"
|
|
print("PASS: test_mid_point_displacement_basic")
|
|
|
|
|
|
def test_mid_point_displacement_returns_self():
|
|
"""mid_point_displacement() returns self for chaining"""
|
|
hmap = mcrfpy.HeightMap((33, 33))
|
|
result = hmap.mid_point_displacement()
|
|
assert result is hmap
|
|
print("PASS: test_mid_point_displacement_returns_self")
|
|
|
|
|
|
def test_mid_point_displacement_reproducible():
|
|
"""mid_point_displacement() is reproducible with same seed"""
|
|
hmap1 = mcrfpy.HeightMap((33, 33), fill=0.0)
|
|
hmap2 = mcrfpy.HeightMap((33, 33), fill=0.0)
|
|
|
|
hmap1.mid_point_displacement(roughness=0.6, seed=99999)
|
|
hmap2.mid_point_displacement(roughness=0.6, seed=99999)
|
|
|
|
# Should produce identical results
|
|
assert abs(hmap1[16, 16] - hmap2[16, 16]) < 0.001
|
|
assert abs(hmap1[5, 5] - hmap2[5, 5]) < 0.001
|
|
print("PASS: test_mid_point_displacement_reproducible")
|
|
|
|
|
|
def test_rain_erosion_basic():
|
|
"""rain_erosion() modifies terrain"""
|
|
hmap = mcrfpy.HeightMap((50, 50), fill=0.5)
|
|
# Add some hills first to have something to erode
|
|
hmap.add_hill((25, 25), 15.0, 0.5)
|
|
|
|
val_before = hmap[25, 25]
|
|
hmap.rain_erosion(1000, seed=12345)
|
|
val_after = hmap[25, 25]
|
|
|
|
# Erosion should change values
|
|
# Note: might not change if terrain is completely flat
|
|
# so we check min/max spread
|
|
min_val, max_val = hmap.min_max()
|
|
assert max_val > min_val, "Rain erosion should leave some variation"
|
|
print("PASS: test_rain_erosion_basic")
|
|
|
|
|
|
def test_rain_erosion_returns_self():
|
|
"""rain_erosion() returns self for chaining"""
|
|
hmap = mcrfpy.HeightMap((20, 20))
|
|
result = hmap.rain_erosion(100)
|
|
assert result is hmap
|
|
print("PASS: test_rain_erosion_returns_self")
|
|
|
|
|
|
def test_rain_erosion_invalid_drops():
|
|
"""rain_erosion() raises ValueError for invalid drops"""
|
|
hmap = mcrfpy.HeightMap((20, 20))
|
|
|
|
try:
|
|
hmap.rain_erosion(0)
|
|
print("FAIL: should have raised ValueError for drops=0")
|
|
sys.exit(1)
|
|
except ValueError:
|
|
pass
|
|
|
|
print("PASS: test_rain_erosion_invalid_drops")
|
|
|
|
|
|
def test_dig_bezier_basic():
|
|
"""dig_bezier() constructs a canal with target heights"""
|
|
hmap = mcrfpy.HeightMap((50, 50), fill=0.5)
|
|
|
|
# Construct a canal from corner to corner
|
|
# target_height sets the center height of the canal
|
|
points = ((0, 0), (10, 25), (40, 25), (49, 49))
|
|
hmap.dig_bezier(points, start_radius=5.0, end_radius=5.0,
|
|
start_height=-0.3, end_height=-0.3)
|
|
|
|
# Start and end should be at the target height (lowered)
|
|
assert hmap[0, 0] < 0.5, f"Start should be lowered, got {hmap[0, 0]}"
|
|
assert hmap[0, 0] < 0, f"Start should be at target height, got {hmap[0, 0]}"
|
|
print("PASS: test_dig_bezier_basic")
|
|
|
|
|
|
def test_dig_bezier_returns_self():
|
|
"""dig_bezier() returns self for chaining"""
|
|
hmap = mcrfpy.HeightMap((20, 20))
|
|
result = hmap.dig_bezier(((0, 0), (5, 10), (15, 10), (19, 19)),
|
|
start_radius=2.0, end_radius=2.0,
|
|
start_height=0.3, end_height=0.3)
|
|
assert result is hmap
|
|
print("PASS: test_dig_bezier_returns_self")
|
|
|
|
|
|
def test_dig_bezier_wrong_point_count():
|
|
"""dig_bezier() raises ValueError for wrong number of points"""
|
|
hmap = mcrfpy.HeightMap((20, 20))
|
|
|
|
try:
|
|
hmap.dig_bezier(((0, 0), (5, 5), (10, 10)),
|
|
start_radius=2.0, end_radius=2.0,
|
|
start_height=0.3, end_height=0.3) # Only 3 points
|
|
print("FAIL: should have raised ValueError for 3 points")
|
|
sys.exit(1)
|
|
except ValueError as e:
|
|
assert "4" in str(e)
|
|
|
|
print("PASS: test_dig_bezier_wrong_point_count")
|
|
|
|
|
|
def test_dig_bezier_accepts_list():
|
|
"""dig_bezier() accepts list of points"""
|
|
hmap = mcrfpy.HeightMap((20, 20), fill=0.5)
|
|
points = [[0, 0], [5, 10], [15, 10], [19, 19]] # List instead of tuple
|
|
hmap.dig_bezier(points, start_radius=2.0, end_radius=2.0,
|
|
start_height=0.3, end_height=0.3)
|
|
# Should complete without error
|
|
print("PASS: test_dig_bezier_accepts_list")
|
|
|
|
|
|
def test_smooth_basic():
|
|
"""smooth() reduces terrain variation"""
|
|
hmap = mcrfpy.HeightMap((30, 30), fill=0.0)
|
|
# Create sharp height differences
|
|
hmap.add_hill((15, 15), 5.0, 1.0)
|
|
|
|
# Get slope before smoothing (measure of sharpness)
|
|
slope_before = hmap.get_slope((15, 15))
|
|
|
|
hmap.smooth(iterations=3)
|
|
|
|
# Smoothing should reduce the slope
|
|
slope_after = hmap.get_slope((15, 15))
|
|
# Note: slope might not always decrease depending on terrain, so we just verify it runs
|
|
min_val, max_val = hmap.min_max()
|
|
assert max_val >= min_val
|
|
print("PASS: test_smooth_basic")
|
|
|
|
|
|
def test_smooth_returns_self():
|
|
"""smooth() returns self for chaining"""
|
|
hmap = mcrfpy.HeightMap((20, 20))
|
|
result = hmap.smooth()
|
|
assert result is hmap
|
|
print("PASS: test_smooth_returns_self")
|
|
|
|
|
|
def test_smooth_invalid_iterations():
|
|
"""smooth() raises ValueError for invalid iterations"""
|
|
hmap = mcrfpy.HeightMap((20, 20))
|
|
|
|
try:
|
|
hmap.smooth(iterations=0)
|
|
print("FAIL: should have raised ValueError for iterations=0")
|
|
sys.exit(1)
|
|
except ValueError:
|
|
pass
|
|
|
|
try:
|
|
hmap.smooth(iterations=-5)
|
|
print("FAIL: should have raised ValueError for iterations=-5")
|
|
sys.exit(1)
|
|
except ValueError:
|
|
pass
|
|
|
|
print("PASS: test_smooth_invalid_iterations")
|
|
|
|
|
|
def test_add_hill_zero_radius_warning():
|
|
"""add_hill() warns on zero/negative radius"""
|
|
import warnings
|
|
hmap = mcrfpy.HeightMap((20, 20), fill=0.5)
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter("always")
|
|
hmap.add_hill((10, 10), radius=0.0, height=1.0)
|
|
assert len(w) == 1
|
|
assert "radius <= 0" in str(w[0].message)
|
|
|
|
# Map should be unchanged
|
|
assert abs(hmap[10, 10] - 0.5) < 0.001
|
|
print("PASS: test_add_hill_zero_radius_warning")
|
|
|
|
|
|
def test_dig_hill_zero_radius_warning():
|
|
"""dig_hill() warns on zero/negative radius"""
|
|
import warnings
|
|
hmap = mcrfpy.HeightMap((20, 20), fill=0.5)
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter("always")
|
|
hmap.dig_hill((10, 10), radius=-1.0, target_height=0.0)
|
|
assert len(w) == 1
|
|
assert "radius <= 0" in str(w[0].message)
|
|
|
|
print("PASS: test_dig_hill_zero_radius_warning")
|
|
|
|
|
|
def test_dig_bezier_zero_radius_warning():
|
|
"""dig_bezier() warns on zero/negative radius"""
|
|
import warnings
|
|
hmap = mcrfpy.HeightMap((20, 20), fill=0.5)
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter("always")
|
|
hmap.dig_bezier(((0, 0), (5, 10), (15, 10), (19, 19)),
|
|
start_radius=0.0, end_radius=2.0,
|
|
start_height=0.3, end_height=0.3)
|
|
assert len(w) == 1
|
|
assert "radius <= 0" in str(w[0].message)
|
|
|
|
print("PASS: test_dig_bezier_zero_radius_warning")
|
|
|
|
|
|
def test_chaining_terrain_methods():
|
|
"""Terrain methods can be chained together"""
|
|
hmap = mcrfpy.HeightMap((50, 50), fill=0.0)
|
|
|
|
result = (hmap
|
|
.add_hill((25, 25), 10.0, 0.5)
|
|
.add_hill((10, 10), 8.0, 0.3)
|
|
.dig_hill((40, 40), 5.0, 0.2)
|
|
.smooth(iterations=2)
|
|
.normalize(0.0, 1.0))
|
|
|
|
assert result is hmap
|
|
min_val, max_val = hmap.min_max()
|
|
assert abs(min_val - 0.0) < 0.001
|
|
assert abs(max_val - 1.0) < 0.001
|
|
print("PASS: test_chaining_terrain_methods")
|
|
|
|
|
|
def test_terrain_pipeline():
|
|
"""Complete terrain generation pipeline"""
|
|
hmap = mcrfpy.HeightMap((65, 65), fill=0.0)
|
|
|
|
# Generate base terrain
|
|
hmap.mid_point_displacement(roughness=0.6, seed=42)
|
|
hmap.normalize(0.0, 1.0)
|
|
|
|
# Add features
|
|
hmap.add_hill((32, 32), 20.0, 0.3) # Mountain
|
|
# dig_bezier constructs a river valley at target heights
|
|
hmap.dig_bezier(((0, 32), (20, 20), (45, 45), (64, 32)),
|
|
start_radius=3.0, end_radius=2.0,
|
|
start_height=-0.3, end_height=-0.2)
|
|
|
|
# Apply erosion and smoothing
|
|
hmap.rain_erosion(500, seed=123)
|
|
hmap.smooth()
|
|
|
|
# Normalize to 0-1 range
|
|
hmap.normalize(0.0, 1.0)
|
|
|
|
min_val, max_val = hmap.min_max()
|
|
assert abs(min_val - 0.0) < 0.001
|
|
assert abs(max_val - 1.0) < 0.001
|
|
print("PASS: test_terrain_pipeline")
|
|
|
|
|
|
def run_all_tests():
|
|
"""Run all tests"""
|
|
print("Running HeightMap terrain generation tests (#195)...")
|
|
print()
|
|
|
|
test_add_hill_basic()
|
|
test_add_hill_returns_self()
|
|
test_add_hill_flexible_center()
|
|
test_dig_hill_basic()
|
|
test_dig_hill_returns_self()
|
|
test_add_voronoi_basic()
|
|
test_add_voronoi_with_coefficients()
|
|
test_add_voronoi_returns_self()
|
|
test_add_voronoi_invalid_num_points()
|
|
test_mid_point_displacement_basic()
|
|
test_mid_point_displacement_returns_self()
|
|
test_mid_point_displacement_reproducible()
|
|
test_rain_erosion_basic()
|
|
test_rain_erosion_returns_self()
|
|
test_rain_erosion_invalid_drops()
|
|
test_dig_bezier_basic()
|
|
test_dig_bezier_returns_self()
|
|
test_dig_bezier_wrong_point_count()
|
|
test_dig_bezier_accepts_list()
|
|
test_smooth_basic()
|
|
test_smooth_returns_self()
|
|
test_smooth_invalid_iterations()
|
|
test_add_hill_zero_radius_warning()
|
|
test_dig_hill_zero_radius_warning()
|
|
test_dig_bezier_zero_radius_warning()
|
|
test_chaining_terrain_methods()
|
|
test_terrain_pipeline()
|
|
|
|
print()
|
|
print("All HeightMap terrain generation tests PASSED!")
|
|
|
|
|
|
# Run tests directly
|
|
run_all_tests()
|
|
sys.exit(0)
|