W2: several lakes per worldlet, a beach block shelving into the sea with a current that turns swimmers back; testbay test worldlet through the water, edge and boundary suites; the two ways to play (journey, one land) recorded

This commit is contained in:
Paul Bailey
2026-11-06 16:48:20 +00:00
parent 68e6d814d9
commit 9b88a10609
9 changed files with 400 additions and 43 deletions

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1
fi
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_terrain_edges test_boundary; do
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_terrain_edges test_boundary; do
extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever,

View File

@@ -4,34 +4,63 @@ extends SceneTree
## the invisible wall, falls off the map, or ends up stuck below the waterline
## (in a pit near the edge).
##
## Then the same on a worldlet with a beach (W2, tests/worldlets/testbay.json):
## off the beach you swim out into the sea, and the current must turn you back
## before the wall (being afloat in the open sea there is fine).
##
## Run: godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd
const DIRECTIONS := 24
const BAY_DIRECTIONS := 8 # (spread over the beach and its headlands)
const SECONDS_PER_TRY := 25.0
var _failures := 0
var _tries := 0
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var S: Node = root.get_node("Saves")
S.root_dir = "user://test_boundary_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
print("the Valley")
await _escape_all(DIRECTIONS, -1.0)
print("a beach (testbay)")
S.current_id = S.create_world("Bay", 1337, {"worldlet": "testbay"})
await _escape_all(BAY_DIRECTIONS, 90.0)
for w in S.list_worlds():
S.delete_world(w.id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d/%d directions held)" % ["PASS" if _failures == 0 else "FAIL", _tries - _failures, _tries])
quit(1 if _failures > 0 else 0)
## Walks out in `count` directions: all round, or (beach_deg >= 0) across the
## beach's arc and a little past it either side.
func _escape_all(count: int, beach_deg: float) -> void:
var world: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
_run.call_deferred(world)
func _run(world: Node3D) -> void:
for _i in range(10):
await physics_frame
var terrain: Node3D = world.get_node("Terrain")
var player: CharacterBody3D = world.get_node("Player")
var failures := 0
var tick := 1.0 / Engine.physics_ticks_per_second
for i in range(DIRECTIONS):
var ang := TAU * i / DIRECTIONS
var pulled := 0
for i in range(count):
var ang := TAU * i / count
if beach_deg >= 0.0:
ang = deg_to_rad(beach_deg + lerpf(-65.0, 65.0, float(i) / (count - 1)))
var out := Vector3(cos(ang), 0.0, sin(ang))
var start: Vector3 = out * (terrain.play_radius - 12.0)
start.y = terrain.sample_height(start.x, start.z) + 1.0
start.y = maxf(terrain.sample_height(start.x, start.z), terrain.water_level) + 1.0
player.global_position = start
player.velocity = Vector3.ZERO
player.rotation = Vector3(0.0, atan2(-out.x, -out.z), 0.0)
Input.action_press("move_forward")
var escaped := false
var max_d := 0.0
var in_current := false
var frames := int(SECONDS_PER_TRY / tick)
for f in range(frames):
if f % 12 == 0:
@@ -41,17 +70,28 @@ func _run(world: Node3D) -> void:
await physics_frame
var p := player.global_position
max_d = maxf(max_d, Vector2(p.x, p.z).length())
in_current = in_current or terrain.current_at(p.x, p.z) > 0.2
if not terrain.inside_boundary(p.x, p.z, -1.5) or p.y < terrain.min_height - 5.0:
escaped = true
break
Input.action_release("move_forward")
Input.action_release("jump")
var p := player.global_position
if not escaped and p.y < terrain.water_level - 0.5:
var at_sea: bool = world.water_at(p.x, p.z).get("type", "") == "sea"
if not escaped and p.y < terrain.water_level - 0.5 and not at_sea:
escaped = true # stuck in a pit below the waterline
print("dir %2d (%3d deg): %s max radius %.1f final (%.1f, %.1f, %.1f)" % [
i, int(rad_to_deg(ang)), "FAILED" if escaped else "held", max_d, p.x, p.y, p.z])
if in_current:
pulled += 1
print("dir %2d (%3d deg): %s max radius %.1f final (%.1f, %.1f, %.1f)%s" % [
i, int(rad_to_deg(ang)), "FAILED" if escaped else "held", max_d, p.x, p.y, p.z,
" (the current turned them back)" if in_current else ""])
_tries += 1
if escaped:
failures += 1
print("RESULT: %s (%d/%d directions held)" % ["PASS" if failures == 0 else "FAIL", DIRECTIONS - failures, DIRECTIONS])
quit(1 if failures > 0 else 0)
_failures += 1
if beach_deg >= 0.0 and pulled == 0:
print("FAILED: nobody swam far enough out to meet the current")
_failures += 1
_tries += 1
world.queue_free()
for _i in range(3):
await process_frame

View File

@@ -19,18 +19,25 @@ const SEEDS := [1337, 1, 42, 777, 4242, 9001, 31337, 123456]
func _initialize() -> void:
_all.call_deferred()
## Each worldlet's edges (W2): the Valley, and a test worldlet with two
## ponds and a beach (tests/worldlets/testbay.json).
const WORLDLETS := ["valley", "testbay"]
func _all() -> void:
var failed := 0
for s in SEEDS:
var terrain: Node3D = load("res://scripts/world/terrain.gd").new()
terrain.noise_seed = s
root.add_child(terrain) # builds in _ready
var bad := _check(terrain)
print("seed %6d: %s" % [s, "ok" if bad == 0 else "%d bad samples" % bad])
if bad > 0:
failed += 1
terrain.free()
print("RESULT: %s (%d/%d seeds clean)" % ["PASS" if failed == 0 else "FAIL", SEEDS.size() - failed, SEEDS.size()])
for w in WORLDLETS:
for s in SEEDS:
var terrain: Node3D = load("res://scripts/world/terrain.gd").new()
terrain.noise_seed = s
terrain.worldlet_id = w
root.add_child(terrain) # builds in _ready
var bad := _check(terrain)
print("%-8s seed %6d: %s" % [w, s, "ok" if bad == 0 and terrain.worldlet_id == w else "%d bad samples" % bad])
if bad > 0 or terrain.worldlet_id != w:
failed += 1
terrain.free()
var n := WORLDLETS.size() * SEEDS.size()
print("RESULT: %s (%d/%d seeds clean)" % ["PASS" if failed == 0 else "FAIL", n - failed, n])
quit(1 if failed > 0 else 0)
func _check(terrain: Node3D) -> int:
@@ -42,7 +49,10 @@ func _check(terrain: Node3D) -> int:
while x < half:
var z := -half
while z < half:
if terrain.inside_boundary(x, z, 0.0) and not terrain.inside_boundary(x, z, BAND):
# (Off a beach the edge is open sea, and the current keeps you
# from the wall: tests/test_boundary.gd swims out there.)
if terrain.inside_boundary(x, z, 0.0) and not terrain.inside_boundary(x, z, BAND) \
and terrain.beach_weight(x, z) <= 0.5:
var h: float = terrain.sample_height(x, z)
# Hazard 1: a pit too deep to wade out of near the edge.
var too_deep: bool = h < terrain.water_level - MAX_EDGE_DEPTH

123
tests/test_water_bodies.gd Normal file
View File

@@ -0,0 +1,123 @@
extends SceneTree
## W2 (docs/WORLDLETS_DESIGN.md): several bodies of water in one world, and a
## beach. On a test worldlet with two ponds and a beach
## (tests/worldlets/testbay.json): the world lists both ponds and the sea,
## water_at() knows which is which, the beach shelves gently into the sea,
## the sea there is one surface (no second water sheet), and a swimmer out
## past the shallows is carried back by the current. The Valley has no
## current anywhere.
## Run: godot --headless --path . --script res://tests/test_water_bodies.gd
var _fails := 0
var _finished := false
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var S: Node = root.get_node("Saves")
S.root_dir = "user://test_water_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
await _body(S)
check(_finished, "test ran to the end (no script errors)")
for w in S.list_worlds():
S.delete_world(w.id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _body(S: Node) -> void:
var T: Script = load("res://scripts/world/terrain.gd")
check(not T.worldlets().any(func(w): return w.id == "testbay"), "the test worldlet is never listed (nor offered)")
print("the Valley: no current")
var v: Node3D = T.new()
root.add_child(v)
await process_frame
var any := false
for i in range(72):
var a := TAU * i / 72.0
for r in [60.0, 100.0, 110.0, 120.0]:
any = any or v.current_at(cos(a) * r, sin(a) * r) > 0.0
check(not any, "nowhere in the Valley has a current")
check(v.lakes.size() == 1 and v.lakes[0].center == Vector2(14, -46), "and its one lake is the old one")
v.free()
print("two ponds and a beach")
S.current_id = S.create_world("Bay", 1337, {"worldlet": "testbay"})
var world: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(8):
await physics_frame
var t: Node3D = world.terrain
check(world.worldlet_id() == "testbay", "the world is the test bay")
var bodies: Array = world.water_bodies()
var ponds: Array = bodies.filter(func(b): return b.type == "lake")
var seas: Array = bodies.filter(func(b): return b.type == "sea")
check(ponds.size() == 2 and seas.size() == 1, "its water: two ponds and the sea (%s)" % [bodies.map(func(b): return b.type)])
for p in ponds:
var c: Vector2 = p.center
var w: Dictionary = world.water_at(c.x, c.y)
check(w.get("type", "") == "lake" and float(w.get("depth", 0.0)) > 2.0,
"the pond at (%.0f, %.0f) is water, %.1f m deep" % [c.x, c.y, float(w.get("depth", 0.0))])
var bay: Vector2 = seas[0].center
var sw: Dictionary = world.water_at(bay.x, bay.y)
check(sw.get("type", "") == "sea", "out in the bay is the sea (%s)" % [sw])
check(absf(t.sea_level - t.water_level) < 0.01, "the sea off a beach is at the ponds' level (%.1f)" % t.sea_level)
check(t.get_node_or_null("Water") == null and t.get_node_or_null("Ocean") != null, "one water surface: the ocean, no second sheet")
print("the beach")
# Walking straight out from the middle of the beach: dry land, the
# waterline, then gently deeper (no cliff), then the current, then the wall.
var dir := Vector2(cos(deg_to_rad(t.beach_angle_deg)), sin(deg_to_rad(t.beach_angle_deg)))
var shore := -1.0
var current := -1.0
var wall := -1.0
var steepest := 0.0
var prev: float = t.sample_height(dir.x * 40.0, dir.y * 40.0)
var r := 41.0
while r < 150.0:
var q := dir * r
var h: float = t.sample_height(q.x, q.y)
if r > 60.0:
steepest = maxf(steepest, absf(h - prev))
prev = h
if shore < 0.0 and h < t.water_level:
shore = r
if current < 0.0 and t.current_at(q.x, q.y) > 0.0:
current = r
if wall < 0.0 and not t.inside_boundary(q.x, q.y):
wall = r
r += 1.0
check(shore > 0.0 and current > shore and wall > current, "dry land, the waterline at %.0f m, the current from %.0f m, the wall at %.0f m" % [shore, current, wall])
check(steepest < 0.5, "a gentle shelf, never a drop (steepest %.2f m per metre)" % steepest)
var depth_at_current: float = t.water_level - t.sample_height(dir.x * current, dir.y * current)
check(depth_at_current > 1.3, "the current starts where you're swimming, not wading (%.1f m deep)" % depth_at_current)
print("the current")
var player: CharacterBody3D = world.player
player.set_physics_process(false) # (no swimming: just drift)
var start := dir * (wall - 4.0)
player.global_position = Vector3(start.x, t.water_level - 1.3, start.y)
var d0 := start.length()
for _i in range(60):
await physics_frame
var p := player.global_position
var d1 := Vector2(p.x, p.z).length()
check(d1 < d0 - 1.5, "a swimmer by the wall is carried back towards the shore (%.1f m in a second)" % (d0 - d1))
check(world._in_current, "(and told so)")
var near := dir * (shore + 2.0)
player.global_position = Vector3(near.x, t.water_level - 0.5, near.y)
for _i in range(30):
await physics_frame
p = player.global_position
check(absf(Vector2(p.x, p.z).length() - (shore + 2.0)) < 0.01, "but nothing pulls at you in the shallows")
check(t.find_spawn(Vector2.ZERO).y > t.water_level + 1.0, "the start is on dry land")
world.queue_free()
await process_frame
_finished = true

View File

@@ -0,0 +1 @@
uid://da1vwsptqh68b

View File

@@ -0,0 +1,17 @@
{
"_doc": "Test-only worldlet (W2): the Valley's hills and rim, but a beach instead of the sea cliff and two ponds instead of the lake. Found by terrain.gd's load_worldlet() after data/worldlets/, never listed or offered. Used by tests/test_water_bodies.gd, test_terrain_edges.gd and test_boundary.gd.",
"id": "testbay",
"name": "Test Bay",
"description": "Hills, two ponds and a beach (tests only).",
"size": 320,
"resolution": 256,
"terrain": [
{"type": "hills", "height_scale": 7.0, "detail": 1.4},
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 58.0, "rim_warp": 16.0, "wall_frac": 0.45},
{"type": "beach", "angle_deg": 90.0, "half_width_deg": 40.0, "fade_deg": 20.0, "shore_offset": -10.0, "slope": 0.08, "current_out": 20.0, "wall_out": 34.0},
{"type": "lake", "center": [-40.0, -35.0], "radius": 14.0, "water_level": -2.6},
{"type": "lake", "center": [35.0, -45.0], "radius": 11.0},
{"type": "ocean_edge"}
],
"climate": {"snowline": 40.0}
}