From b938e045cbcfd0b77f7f979208d6fbf6605cd7fc Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Fri, 6 Nov 2026 21:57:37 +0000 Subject: [PATCH] One clock for every land: going back to a land catches it up on the time away (like a night's sleep) instead of rewinding to when you left; J3 decided: a server per land with the Valley's as hub, sleep needs everyone in every land --- docs/WORLDLETS_DESIGN.md | 58 +++++++++++++++++++++++++++--------- scripts/world/world.gd | 21 +++++++++++++ tests/test_tunnel.gd | 6 ++++ tools/site/guide_sections.md | 2 +- 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/docs/WORLDLETS_DESIGN.md b/docs/WORLDLETS_DESIGN.md index c84ae79..670e42e 100644 --- a/docs/WORLDLETS_DESIGN.md +++ b/docs/WORLDLETS_DESIGN.md @@ -363,8 +363,10 @@ tunnel goes **down**, inside the cliff, from the clifftop to the shore. meat, clay jars) becomes what you carry between them. - **Your bases stay** where you built them: each worldlet keeps its own save file, so your Valley hut, chests and jars are waiting. -- **Time:** a worldlet you're not in stands still (as a server does with - nobody on), so a base you left doesn't starve or get raided. +- **Time:** there's one clock for every land (see the decisions below). A + worldlet you're not in doesn't run, but catches up on the time when you + come back, as after a night's sleep; nothing roams it meanwhile, so a base + you left isn't raided. ### Building it (phases) | Phase | Delivers | Done when | @@ -395,17 +397,45 @@ A server today runs one island. Options: bites can make you sick, and bog, mist and the like. Others in the same spirit per biome (to design with each worldlet). - **Multiplayer: spread out.** Each player goes where they like; the server - runs every worldlet someone is in, and the rest stand still. Built in J3, - after single-player travel works. What that means for the code: - - one server process holds several worldlet scenes, each with its own - clock, weather, creatures and save file; - - every player belongs to one worldlet at a time, and the server only - sends them that worldlet's world, creatures, sounds and chat-free events - (chat and the player list stay server-wide); - - a crossing moves a player from one worldlet to another (save their - place, load or wake the other worldlet, send them its world); - - sleep: "everyone in bed" counts the players in that worldlet only; - - the Old Stag and other bosses scale with the players in their worldlet. + runs every worldlet someone is in. Built in J3, after single-player travel + works. +- **A server per land (Paul, Nov 2026)**, rather than one process holding + several worldlets (which would mean making every network module, the + saves and the actions handle several worlds at once): + - the Valley's server is the **hub**: the address everyone joins. It + starts a server for another land (the next UDP port: 24243 the Cove, + and so on) when someone first goes there, and stops it a couple of + minutes after its last player leaves; + - each land server is today's server, with its own worldlet, save file, + weather and creatures; it keeps a private link to the hub on the host + machine; + - characters live in one shared folder. A crossing saves your character + first, marks you as on your way, and hands your game the land's port + and a one-time pass; your game reconnects there ("On to the Cove..."). + Nothing can be lost or copied on the way: crash mid-way and you come + back where you were going. Joining the hub when your character's in + another land sends you straight on there; + - chat, the player list (with where each player is) and admin commands + go through the hub to every land; + - the tunnel belongs to the Valley's server; the Cove's asks the hub + whether it's dug through; + - from the internet, friends need those extra ports forwarded too (on a + home network, nothing to do). The Host page will list them; + - the Old Stag and other bosses scale with the players in their land. + - Phases: **J3a** the tunnel online, land servers on demand, the + crossing and the shared characters; **J3b** chat, the player list and + admin across lands; **J3c** the awkward cases (a land server crashing, + rejoining mid-crossing, the Host page's ports). +- **One clock for every land (Paul, Nov 2026).** The day and hour are the + same everywhere, so the lands never drift apart: + - sleeping until morning needs **everyone, in every land**, in bed (on a + server the hub counts them all, and every land skips to morning + together); + - a land nobody is in doesn't run, but when someone arrives it catches up + on the time since anyone was last there, as after a night's sleep: + kilns and smelters work, fires burn down, food goes off, trees and + berries grow back. (Built for single-player already: `world.catch_up()`.) + No creatures roam it meanwhile, so a base you left isn't raided. - **Boats: one each, no passengers.** A boat belongs to whoever built it (like a bed): only the owner, and their friends under the server's locks rule, can sail it, and nobody else can dismantle it. It stays moored where @@ -439,7 +469,7 @@ One worldlet, a dedicated server on the M5 Pro, never pausing: about creatures, weather and pickups around them. So with *spread out*, the worst case of 8 players in all 5 worldlets is about five of today's servers in one process: roughly 1.4 GB and a little over half a core before players, -and worldlets with nobody in them stand still and cost next to nothing. +and worldlets with nobody in them aren't running and cost nothing (each starts its own server when someone goes there). Comfortable on the home Mac; a small cloud server (2 GB) would be tight. ### (Answered) questions diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 963e6ea..a2155a1 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -446,6 +446,7 @@ func _on_terrain_ready() -> void: if not state.is_empty(): apply_save(state) print("[world] loaded '%s'" % Saves.current_id) + catch_up(state.get("catch_up_to", {})) _arrive(Saves.take_arrival(Saves.current_id)) player.set_physics_process(true) set_physics_process(true) @@ -995,6 +996,10 @@ func travel(to: String, arrive: String) -> void: var there: Dictionary = Saves.load_state(id, to) if there.is_empty(): there = {"time": here.time} # (a new place: the same day and hour) + else: + # One clock for every land: the one you're going to catches up on the + # time you were away when it loads (catch_up()). + there["catch_up_to"] = here.time there["player"] = here.player Saves.save_state(id, there, 0.0, to) Saves.set_worldlet(id, to, arrive) @@ -1007,6 +1012,22 @@ func travel(to: String, arrive: String) -> void: signal travelled(to: String) ## saved and on the way to another worldlet +## Back in a land you've been to before: its clock comes forward to `to` +## ({day, time}, the clock where you've come from), and everything in it +## catches up on the time between, as after a night's sleep (kilns and +## smelters work, fires burn, food goes off, trees and berries grow back). +## Returns the seconds caught up. +func catch_up(to: Dictionary) -> float: + if to.is_empty(): + return 0.0 + var secs: float = ((int(to.get("day", day_night.day)) - day_night.day) \ + + (float(to.get("time", day_night.time)) - day_night.time)) * day_night.day_length + if secs <= 0.0: + return 0.0 + skip_time(secs) + print("[world] %s caught up on %.0f s while you were away" % [worldlet_id(), secs]) + return secs + ## Just arrived from another worldlet: put you where you came in. func _arrive(at: String) -> void: if at.is_empty(): diff --git a/tests/test_tunnel.gd b/tests/test_tunnel.gd index 3212178..c44da64 100644 --- a/tests/test_tunnel.gd +++ b/tests/test_tunnel.gd @@ -238,6 +238,10 @@ func _body() -> void: print("back up home") check(w.sea_cave.open(), "the sea cave leads back up (the tunnel's dug)") + # A day and a half in the Cove before going home. + w.day_night.advance(w.day_night.day_length * 1.5) + var cove_day: int = w.day_night.day + var cove_time: float = w.day_night.time _went = "" w.sea_cave.interact(player) check(_went == "valley", "into the cave: back to the Valley") @@ -247,6 +251,8 @@ func _body() -> void: player = w.player check(w.worldlet_id() == "valley" and w.underground_at(player.global_position) and player.global_position.distance_to(t.bottom) < 2.0, "arriving at the bottom of the tunnel") + check(w.day_night.day == cove_day and absf(w.day_night.time - cove_time) < 0.01, + "one clock for every land: home is on the Cove's day and hour (day %d), not the one you left it" % w.day_night.day) t.go_out(player) await physics_frame check(not w.underground_at(player.global_position) and player.global_position.distance_to(t.seam_out) < 1.0, "climb out at the top, on the clifftop") diff --git a/tools/site/guide_sections.md b/tools/site/guide_sections.md index 27de2cc..b9566a6 100644 --- a/tools/site/guide_sections.md +++ b/tools/site/guide_sections.md @@ -350,7 +350,7 @@ Low hills run down to a sheltered sandy bay, with mountains behind, a small pond - **The pier** runs out over the bay. Fish off its end for **mackerel**, the usual catch. **Cod** are bigger but only bite where the water's at least 2.2 m deep, so cast well out. Both bite best at dawn and dusk, and grill at a campfire, salt or smoke like lake fish. - **The sea cave** at the end of the beach is the way back: `E` goes into it and up the same tunnel, arriving at the bottom. -Each land keeps its own state. What you build in the Valley waits for you while you're away, and time stands still in a land you're not in. +Each land keeps its own state, and there's one clock for them all. What you build in the Valley waits for you while you're away, and when you come back the time you were gone catches up, as after a night's sleep: kilns and smelters will have done their work, fires burned down and food gone off. Nothing roams a land while you're away, so your base won't have been raided. ## Multiplayer {#multiplayer}