From db4a6592360c65e0d1fb12f6465b11cc8cdf1f27 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Fri, 6 Nov 2026 15:51:43 +0000 Subject: [PATCH] W0: terrain from worldlet building blocks (Valley in data/worldlets, identical heights), WorldInfo API, worldlet in saves and servers; the cliff tunnel plan --- data/worldlets/valley.json | 17 +++++ docs/WORLDLETS_DESIGN.md | 55 ++++++++++++++- export_presets.cfg | 4 +- scripts/core/saves.gd | 6 +- scripts/net/net.gd | 2 +- scripts/net/world_net.gd | 2 +- scripts/world/terrain.gd | 133 ++++++++++++++++++++++++++++++++++--- scripts/world/world.gd | 38 +++++++++++ tests/run_all.sh | 2 +- tests/test_worldlets.gd | 89 +++++++++++++++++++++++++ 10 files changed, 329 insertions(+), 19 deletions(-) create mode 100644 data/worldlets/valley.json create mode 100644 tests/test_worldlets.gd diff --git a/data/worldlets/valley.json b/data/worldlets/valley.json new file mode 100644 index 0000000..53c01ff --- /dev/null +++ b/data/worldlets/valley.json @@ -0,0 +1,17 @@ +{ + "_doc": "A worldlet (docs/WORLDLETS_DESIGN.md): its size, and its terrain as building blocks applied in order (scripts/world/terrain.gd: hills, rim, sea_cliff, lake, ocean_edge). The numbers here are the Valley exactly as it was hand-coded: tests/test_worldlets.gd checks it still makes the same height grid.", + "id": "valley", + "name": "Valley", + "description": "A sheltered valley ringed by mountains, with a lake in the middle and sea cliffs on one side.", + "size": 320, + "resolution": 256, + "terrain": [ + {"type": "hills", "height_scale": 9.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": "sea_cliff", "angle_deg": -75.0, "half_width_deg": 38.0, "fade_deg": 22.0, "sea_level": -24.0, "cliff_top": 0.5, "cliff_offset": 4.0, "drop_width": 6.0}, + {"type": "lake", "center": [14.0, -46.0], "radius": 24.0, "water_level": -2.6}, + {"type": "ocean_edge"} + ], + "climate": {"snowline": 40.0}, + "next": [{"to": "cove", "by": "tunnel", "where": "sea_cliff"}] +} diff --git a/docs/WORLDLETS_DESIGN.md b/docs/WORLDLETS_DESIGN.md index 372a7d6..fcce9af 100644 --- a/docs/WORLDLETS_DESIGN.md +++ b/docs/WORLDLETS_DESIGN.md @@ -1,7 +1,23 @@ # Worldlets design -Status: **proposal**. Nothing here is built yet, apart from the first piece -of groundwork (`world.water_at()` and `data/fish.json`, see §6). +Status: **W0 built**; the rest is planned. The journey between worldlets is +§10. + +*As built in W0:* +- `data/worldlets/valley.json`: the Valley as data, its terrain as five + building blocks in order (`hills`, `rim`, `sea_cliff`, `lake`, + `ocean_edge`) with every number that used to be hard-coded. +- `terrain.gd` runs a worldlet's blocks (`use_worldlet()`); each block is + the old code's arithmetic, unchanged. `tests/test_worldlets.gd` checks + four seeds give exactly the same height grid as before (SHA-256 of the + grid, taken from the old code). +- The worldlet is chosen like the seed: a server's (it sends its real one), + else the save's `meta.json` "worldlet", else the Valley (worlds from + before W0). New worlds and servers record it. +- The WorldInfo API on the world: `worldlet_id()`, `water_bodies()`, + `climate()`, `crossings()` (the planned ways on), beside `water_at()`. + Systems still read the terrain's numbers directly; they move over to + these as each new worldlet needs them to differ (W1-W3). ## 1. What a worldlet is A **worldlet** is a small, complete world type: a ~320 m playable area with @@ -224,6 +240,41 @@ you've learned and gathered there: - **Through:** the far end opens onto the next worldlet. Walking out loads it (a short fade, "The Cove"), with your bag, cloak and stats as they are. +### The first tunnel: down the sea cliff to the Cove +The Valley's sea side ends at a sheer cliff, its top about level with the +valley and the sea some 25 m below. The Cove is down there, so the first +tunnel goes **down**, inside the cliff, from the clifftop to the shore. +- **The seam:** a cracked outcrop on the clifftop strip near the edge (the + sea_cliff block marks where), with old pick marks and loose stones at its + foot. It can't be dug until you have the antler pickaxe (tier 3); a lesser + pick just chips it ("Too hard: you need a better pick"). +- **Digging down:** the tunnel is about six stretches, each a few metres on + and a few down: a sloping passage that turns back on itself like a + stair, so the 25 m drop fits inside the cliff. A stretch takes many pick + blows (stamina, and wear on the pick) to open. +- **Props:** each new stretch needs a **timber prop** (2 wood) set at its + mouth before you can dig the next. Leave one out and the face won't take + another blow ("It's loose: prop it first"); nothing caves in on you, so + it's a cost, not a trap. That's about 12 wood all the way down. +- **Light:** the tunnel has no daylight at all. Every stretch is pitch dark + until you bring light: a torch in your hand works, but your hands are full + with the pick, so the way to do it is to set **candles and torch stands** + as you go (tallow candles from bear fat, torch stands from wood). Placed + lights stay, so the way is lit for the trip back and for friends. +- **The bottom:** the last stretch breaks out into a sea cave, its mouth + opening on the Cove's shingle beach. Walking out of the mouth is the + crossing: a fade, then the Cove, arriving at the cave's Cove end. The way + back starts there and climbs the same tunnel. +- **What's saved:** the tunnel belongs to the Valley's save: how far it's + dug, the props, the lights. On a server it's shared: anyone can dig on + from where someone stopped, and the lights are everyone's. +- **How it's built:** the tunnel is its own small space (a "between" scene + next to the worldlet), made from a kit of rock pieces (straight, turn, + slope down, the cave mouth) laid out from the seed, with box-shaped + colliders. The heightmap can't have holes or overhangs, so the passage is + never cut into the terrain itself: you walk into the seam and you're in + the tunnel scene. + ### Why go back - **Each worldlet has things the others lack.** Clay and salt only in the Valley and Cove; little wood in the Highlands; no bears outside the diff --git a/export_presets.cfg b/export_presets.cfg index 1554356..620f6b9 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -6,7 +6,7 @@ runnable=true dedicated_server=false custom_features="" export_filter="all_resources" -include_filter="data/*.json" +include_filter="data/*.json, data/worldlets/*.json" exclude_filter="tests/*, docs/*, tools/*, server/*" export_path="build/macos/Emberwild.zip" patches=PackedStringArray() @@ -64,7 +64,7 @@ runnable=true dedicated_server=false custom_features="" export_filter="all_resources" -include_filter="data/*.json" +include_filter="data/*.json, data/worldlets/*.json" exclude_filter="tests/*, docs/*, tools/*, server/*" export_path="build/windows/Emberwild/Emberwild.exe" patches=PackedStringArray() diff --git a/scripts/core/saves.gd b/scripts/core/saves.gd index ba65e9f..55b5ff1 100644 --- a/scripts/core/saves.gd +++ b/scripts/core/saves.gd @@ -56,7 +56,7 @@ func create_world(world_name: String, seed: int, options: Dictionary = {}) -> St var now := Time.get_unix_time_from_system() var meta := { "name": world_name, "seed": seed, "created": now, "last_played": now, - "play_time": 0.0, "version": VERSION, + "play_time": 0.0, "version": VERSION, "worldlet": "valley", } for k in options: meta[k] = options[k] @@ -65,7 +65,7 @@ func create_world(world_name: String, seed: int, options: Dictionary = {}) -> St ## The world slot called exactly `id` (a server's world), made with `seed` if ## it doesn't exist yet. An existing world keeps its own seed. -func open_or_create(id: String, seed: int) -> String: +func open_or_create(id: String, seed: int, worldlet: String = "valley") -> String: if not read_meta(id).is_empty(): var had := int(read_meta(id).get("seed", seed)) if had != seed: @@ -75,7 +75,7 @@ func open_or_create(id: String, seed: int) -> String: var now := Time.get_unix_time_from_system() _write_json(_dir(id).path_join("meta.json"), { "name": id, "seed": seed, "created": now, "last_played": now, - "play_time": 0.0, "version": VERSION, + "play_time": 0.0, "version": VERSION, "worldlet": worldlet, }) return id diff --git a/scripts/net/net.gd b/scripts/net/net.gd index de92211..66cef13 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -638,7 +638,7 @@ func _c2s_proof(sig: PackedByteArray) -> void: print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "", players.size(), int(config.v("max_players"))]) # (The world's own seed: an existing world keeps the seed it was made with.) - var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw, + var world := {"seed": int(_terrain.noise_seed), "worldlet": String(_terrain.worldlet_id), "spawn": start, "yaw": yaw, "island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time, "weather": _world.weather.override} _s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world}) diff --git a/scripts/net/world_net.gd b/scripts/net/world_net.gd index d2afeb5..402f905 100644 --- a/scripts/net/world_net.gd +++ b/scripts/net/world_net.gd @@ -49,7 +49,7 @@ func open_server_world(config: RefCounted) -> Node3D: var id := String(config.v("world")).validate_filename() if id.is_empty(): id = "server-world" - Saves.current_id = Saves.open_or_create(id, int(config.v("seed"))) + Saves.current_id = Saves.open_or_create(id, int(config.v("seed")), String(config.v("worldlet"))) backup("start-up") # (the state it's about to load, before anyone changes it) _backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0 var w: Node3D = load("res://scenes/world.tscn").instantiate() diff --git a/scripts/world/terrain.gd b/scripts/world/terrain.gd index 65d40d0..348a849 100644 --- a/scripts/world/terrain.gd +++ b/scripts/world/terrain.gd @@ -75,8 +75,15 @@ func _ready() -> void: var saves := get_node_or_null("/root/Saves") if net != null and net.state == "connected" and net.world_info.has("seed"): noise_seed = int(net.world_info.seed) + worldlet_id = String(net.world_info.get("worldlet", worldlet_id)) elif saves != null and saves.has_world(): noise_seed = saves.world_seed() + # (worlds from before worldlets have none: they're the Valley) + worldlet_id = String(saves.read_meta(saves.current_id).get("worldlet", "valley")) + var why := use_worldlet(worldlet_id) + if not why.is_empty(): + push_error("[terrain] %s: using the Valley" % why) + use_worldlet("valley") _build() # --- height function -------------------------------------------------------- @@ -135,14 +142,114 @@ func _sea_w(x: float, z: float) -> float: func _wall_threshold(x: float, z: float) -> float: return lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z)) +# --- worldlets (docs/WORLDLETS_DESIGN.md, W0) --------------------------------------- +# A worldlet's terrain is a list of building blocks applied in order +# (data/worldlets/.json "terrain"). Each block takes the ground so far and +# returns it changed; "hills" starts it. A worldlet can only combine these. + +const BLOCKS := ["hills", "rim", "sea_cliff", "lake", "ocean_edge"] +var worldlet_id := "valley" +var worldlet: Dictionary = {} ## the definition, as loaded +var _blocks: Array = [] ## its terrain blocks, in order +var _valley := 0.0 ## the hills before anything else (the sea cliff eases from it) +var _detail_k := 1.4 + +## The definition of worldlet `id` (data/worldlets/.json), or {} if none. +static func load_worldlet(id: String) -> Dictionary: + var p := "res://data/worldlets/%s.json" % id + if not FileAccess.file_exists(p): + return {} + var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(p)) + return d if d is Dictionary else {} + +## Every worldlet there is: [{id, name, description}]. +static func worldlets() -> Array: + var out: Array = [] + for f in DirAccess.get_files_at("res://data/worldlets"): + if f.ends_with(".json"): + var d := load_worldlet(f.get_basename()) + if not d.is_empty(): + out.append({"id": String(d.id), "name": String(d.get("name", d.id)), "description": String(d.get("description", ""))}) + return out + +## Takes on worldlet `id`: its size and every block's numbers. Returns "" +## on success, else what's wrong (an unknown worldlet or block keeps the old +## settings, so the game still has an island). +func use_worldlet(id: String) -> String: + var d := load_worldlet(id) + if d.is_empty(): + return "no worldlet '%s'" % id + for b in d.get("terrain", []): + if not (b is Dictionary) or not String(b.get("type", "")) in BLOCKS: + return "worldlet '%s': unknown terrain block %s" % [id, str(b)] + worldlet_id = id + worldlet = d + size = int(d.get("size", size)) + resolution = int(d.get("resolution", resolution)) + _blocks = d.get("terrain", []) + for b in _blocks: + match String(b.type): + "hills": + height_scale = float(b.get("height_scale", height_scale)) + _detail_k = float(b.get("detail", _detail_k)) + "rim": + play_radius = float(b.get("play_radius", play_radius)) + rim_width = float(b.get("rim_width", rim_width)) + mountain_height = float(b.get("mountain_height", mountain_height)) + rim_warp = float(b.get("rim_warp", rim_warp)) + wall_frac = float(b.get("wall_frac", wall_frac)) + "sea_cliff": + sea_angle_deg = float(b.get("angle_deg", sea_angle_deg)) + sea_half_width_deg = float(b.get("half_width_deg", sea_half_width_deg)) + sea_fade_deg = float(b.get("fade_deg", sea_fade_deg)) + sea_level = float(b.get("sea_level", sea_level)) + cliff_top = float(b.get("cliff_top", cliff_top)) + cliff_offset = float(b.get("cliff_offset", cliff_offset)) + cliff_drop_width = float(b.get("drop_width", cliff_drop_width)) + "lake": + var c: Array = b.get("center", [lake_center.x, lake_center.y]) + lake_center = Vector2(float(c[0]), float(c[1])) + lake_radius = float(b.get("radius", lake_radius)) + water_level = float(b.get("water_level", water_level)) + snow_height = float(d.get("climate", {}).get("snowline", snow_height)) + return "" + +func _has_block(t: String) -> bool: + for b in _blocks: + if String(b.type) == t: + return true + return false + func _height_fn(x: float, z: float) -> float: _ensure_noise() - var valley := _noise.get_noise_2d(x, z) * height_scale - valley += _detail.get_noise_2d(x, z) * 1.4 - var d := _rim_d(x, z) + if _blocks.is_empty(): + use_worldlet(worldlet_id) + var land := 0.0 + for b in _blocks: + match String(b.type): + "hills": + land = _blk_hills(x, z) + "rim": + land = _blk_rim(x, z, land) + "sea_cliff": + land = _blk_sea_cliff(x, z, land) + "lake": + land = _blk_lake(x, z, land) + "ocean_edge": + land = _blk_ocean_edge(x, z, land) + return land - # Mountain rim. - var land := valley +## Rolling hills: the ground everything else is built on. +func _blk_hills(x: float, z: float) -> float: + var valley := _noise.get_noise_2d(x, z) * height_scale + valley += _detail.get_noise_2d(x, z) * _detail_k + _valley = valley + return valley + +## The mountain rim: rises from play_radius, stepped into ledges. +func _blk_rim(x: float, z: float, land: float) -> float: + var valley := _valley + var d := _rim_d(x, z) var t := smoothstep(play_radius, play_radius + rim_width, d) if t > 0.0: var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z)) @@ -153,7 +260,12 @@ func _height_fn(x: float, z: float) -> float: var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band m = lerpf(m, stepped, 0.45 * t) land = valley * (1.0 - t * 0.6) + m + return land +## The sea side of the rim: the valley eases to a cliff top, then drops sheer. +func _blk_sea_cliff(x: float, z: float, land: float) -> float: + var valley := _valley + var d := _rim_d(x, z) # Sea side: the valley eases to a cliff top, then drops sheer to the seabed. # The edge sits 1 m past the invisible wall at every angle, including the # headlands where the wall moves outward. (With the edge fixed at _cliff_d(), @@ -167,14 +279,17 @@ func _height_fn(x: float, z: float) -> float: var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0 coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d)) land = lerpf(land, coast, sw) + return land - # Lake basin: blend the ground down to a bed below the waterline. +## A lake basin: the ground blends down to a bed below the waterline. +func _blk_lake(x: float, z: float, land: float) -> float: var ld := Vector2(x - lake_center.x, z - lake_center.y).length() ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0 - land = lerpf(land, water_level - 3.2, smoothstep(lake_radius, lake_radius * 0.35, ld)) + return lerpf(land, water_level - 3.2, smoothstep(lake_radius, lake_radius * 0.35, ld)) - # Ocean all round the chunk edge, so the world is an island and the edge of - # the mesh is always hidden under deep water. +## Ocean all round the chunk edge, so the world is an island and the edge of +## the mesh is always hidden under deep water. +func _blk_ocean_edge(x: float, z: float, land: float) -> float: var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z)) return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e) diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 5704b59..979814a 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -895,6 +895,44 @@ func water_at(x: float, z: float) -> Dictionary: return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground} return {} +# --- the worldlet (docs/WORLDLETS_DESIGN.md: the WorldInfo API) -------------------- +# What a system outside the terrain should ask about this place, rather than +# reading the terrain's numbers: which worldlet it is, its water, its climate, +# and its ways out. (W0 adds these; systems move over to them as each worldlet +# needs them to be different.) + +## This worldlet's id ("valley") and its whole definition. +func worldlet_id() -> String: + return terrain.worldlet_id + +func worldlet() -> Dictionary: + return terrain.worldlet + +## Its bodies of water: [{"type", "surface", "center" (x, z; the sea's is a +## point on its coast), "radius" (a lake's; the sea's is 0)}]. +func water_bodies() -> Array: + var out: Array = [] + for b in terrain._blocks: + match String(b.type): + "lake": + out.append({"type": "lake", "surface": terrain.water_level, + "center": terrain.lake_center, "radius": terrain.lake_radius}) + "sea_cliff": + out.append({"type": "sea", "surface": terrain.sea_level, + "center": terrain.coast_point(terrain.sea_angle_deg, 20.0), "radius": 0.0}) + return out + +## Its climate: {"snowline": m, ...} (more as worldlets differ). +func climate() -> Dictionary: + var c: Dictionary = terrain.worldlet.get("climate", {}).duplicate() + c["snowline"] = terrain.snow_height + return c + +## Its ways on to other worldlets (the journey): [{"to", "by", "where"}]. +## Planned in the definition; travel comes with J1. +func crossings() -> Array: + return terrain.worldlet.get("next", []) + # --- multiplayer ----------------------------------------------------------------- ## Joined a server: start where it says, follow its clock, show the others. diff --git a/tests/run_all.sh b/tests/run_all.sh index f51a2ce..78efe4a 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -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_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_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, diff --git a/tests/test_worldlets.gd b/tests/test_worldlets.gd new file mode 100644 index 0000000..43d262e --- /dev/null +++ b/tests/test_worldlets.gd @@ -0,0 +1,89 @@ +extends SceneTree +## W0 (docs/WORLDLETS_DESIGN.md): the terrain is built from a worldlet's +## blocks (data/worldlets/.json), and the Valley made that way is the same +## island, height for height, as the hand-coded one it replaced (the hashes +## below were taken from the old code). Also: the world answers the WorldInfo +## questions, a new save remembers its worldlet, an old save without one is +## the Valley, and a bad worldlet falls back to the Valley. +## Run: godot --headless --path . --script res://tests/test_worldlets.gd + +## SHA-256 of the height grid, from the terrain before W0 (commit f4df2bc). +const GRID_HASHES := { + 1337: "c87679a4ed30bff0c4be1caacda735454ad23f690ada28e7179667a9a7d62aa7", + 777: "2edef348df09bf1f84ed3f5b0921f28fe54b3c827a698fac0a7d50fcdb95a24e", + 42: "801430858909739fc8cf46694eee35c0cffa86ff1a9ad1ed379f1dfdea4b133c", + 9001: "7d509d2508395a0e27e93a3384ac1e63323d588ef3cd1262f1e3f0c1c3e3254b", +} + +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_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 _grid_hash(t: Node) -> String: + var h := HashingContext.new() + h.start(HashingContext.HASH_SHA256) + h.update(t._heights.to_byte_array()) + return h.finish().hex_encode() + +func _body(S: Node) -> void: + var T: Script = load("res://scripts/world/terrain.gd") + print("the Valley, from data") + var list: Array = T.worldlets() + check(list.any(func(w): return w.id == "valley"), "the Valley is a worldlet (%s)" % [list.map(func(w): return w.id)]) + var def: Dictionary = T.load_worldlet("valley") + check(def.get("terrain", []).size() == 5, "built from 5 blocks: hills, rim, sea cliff, lake, ocean edge") + for s in GRID_HASHES: + var t: Node3D = T.new() + t.noise_seed = s + root.add_child(t) + await process_frame + check(_grid_hash(t) == GRID_HASHES[s], "seed %d: the same island, height for height" % s) + t.queue_free() + await process_frame + var bad: Node3D = T.new() + check(not bad.use_worldlet("nowhere").is_empty() and bad.worldlet_id == "valley", "an unknown worldlet is refused (the Valley stays)") + bad.free() + + print("in a world") + var id: String = S.create_world("W0", 1337, {}) + check(String(S.read_meta(id).get("worldlet", "")) == "valley", "a new world remembers its worldlet") + var meta: Dictionary = S.read_meta(id) + meta.erase("worldlet") + S._write_json(S._dir(id).path_join("meta.json"), meta) + S.current_id = id + var world: Node3D = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(5): + await physics_frame + check(world.worldlet_id() == "valley", "an old world with no worldlet is the Valley") + var water: Array = world.water_bodies() + check(water.size() == 2 and water.any(func(w): return w.type == "lake") and water.any(func(w): return w.type == "sea"), + "its water: a lake and the sea") + var lake: Dictionary = water.filter(func(w): return w.type == "lake")[0] + var lp: Vector2 = lake.center + check(world.water_at(lp.x, lp.y).get("type", "") == "lake", "(and the lake is where it says)") + check(absf(float(world.climate().snowline) - 40.0) < 0.01, "its snowline: 40 m") + check(world.crossings().any(func(c): return c.to == "cove" and c.by == "tunnel"), "its way on: a tunnel to the Cove (planned)") + world.queue_free() + await process_frame + _finished = true