diff --git a/docs/MULTIPLAYER_PLAN.md b/docs/MULTIPLAYER_PLAN.md index 15fb649..5de90ec 100644 --- a/docs/MULTIPLAYER_PLAN.md +++ b/docs/MULTIPLAYER_PLAN.md @@ -18,10 +18,10 @@ concrete decisions for this codebase, as it stands after M1. ## 2. Running a server *Built in M2.1* (`scripts/net/net.gd`, `server_config.gd`, `identity.gd`). -As of M2.1 the server accepts players, checks who they are and keeps the -player list. The world itself isn't served yet (M2.3). Flags: `--server`, +The server accepts players, checks who they are and keeps the player list. +Since M2.3 it also runs the shared island (§5). Flags: `--server`, `--port`, `--config`, `--server-name`, `--max-players`, `--password`, -`--world`, and `--exit-with ` (quit when that process exits; the tests +`--world`, `--seed`, and `--exit-with ` (quit when that process exits; the tests use it so a crashed test never leaves a server running). Flags apply to that run only and are never written to `server.json`. ```bash @@ -161,8 +161,9 @@ A refused move sends the player back to their last good position. Respawning is a flagged teleport, allowed if it lands within 25 m of the island spawn. Normal play is never corrected, so there's no rubber-banding. This is the Valheim model, and simpler than replaying inputs on the server. -Through-wall checks come with the shared world (M2.3), since the server -doesn't know about buildings yet. +Since M2.3 the server also refuses a move whose straight line crosses a +wall, door frame (or closed door) or gable at waist height, checked from +both ends so someone a wall was built on top of can still step out. The server sends everyone's positions 20 times a second. Each one is stamped with when the player moved (their clock plus a smoothed per-player offset), @@ -177,9 +178,38 @@ Measured in `tests/test_net_world.gd` at 100 ms ± 30 ms simulated latency: a 4 m/s walk is seen at a median of 4.2 m/s, top 5.0, with no freezes, and it ends within a centimetre of where the walker stopped. -Until later phases, while online: animals are off (M2.5), and building, -gathering and other world changes are local only and aren't saved (M2.3, -M2.4). The HUD says so. Respawning ignores beds. +**The shared world** *(built in M2.3: `world_net.gd`, autoload WorldNet)*. +The dedicated server runs the real world scene headless with nobody of its +own in it (`world.gd` `server_mode`), and saves it like a single-player +world, in `worlds//` next to `server.json`. It owns trees and rocks +(damage, felling, regrowth), pickups, berry bushes, dropped items, every +placed piece (walls, doors, chests, graves, fires, beds), fire fuel, door +state and the clock. Weather is worked out from the seed and the clock, so +it needs no syncing. +- A client's `Actions.*` intents that change the island become requests + (`_c2s_hit`, `_c2s_place`, `_c2s_take`, `_c2s_chest_click`...). The server + checks reach (from where it last saw that player), applies the change, + and tells everyone (`_s2c_node`, `_s2c_piece_added`...). Nothing changes + on your screen until the server says so, so two people grabbing the same + stick get one stick between them. +- Whatever a change earns you (wood, a pickup, a dismantled wall and + anything that collapsed with it, items out of a chest) the server sends + to you (`_s2c_give`). What doesn't fit lands at your feet. +- Pieces have a net id that's saved with them, so they keep it across + server restarts. +- Chest clicks happen on the server (someone else may be using the chest), + which hands back what you're holding. +- On joining, your world asks for a snapshot of everything once its + scenery is up, then gets each change in order. Regrowth only happens on + the server, never on top of a player. +- The world (fires, regrowth, the clock) pauses while nobody's on. + +Until later phases, while online: your inventory, stats and crafting stay on +your own game and aren't saved by the server (M2.4), so the server trusts +what you say you're putting into the world (a wall, a chest deposit, a +grave's contents) but decides everything taken out. Animals are off (M2.5). +Sleeping is off (it needs the sleep rule, M2.5). Respawning ignores beds. +The HUD says so. **Interest.** On a 320 m worldlet with 8 players, the server sends every entity to everyone at first. Distance-based interest is a later @@ -208,6 +238,12 @@ worlds// friends, name, appearance backups// rolling copies (backups_kept) ``` +*As of M2.3:* `worlds//meta.json` and `world.json` (with pieces' +net ids, no owners yet) are written next to `server.json` within 10 s of +any change, when a player leaves, and every `autosave_minutes`. (Ctrl+C +kills Godot without running any code, so there's no save on the way out.) An +existing world keeps the seed it was made with, even if `server.json` +changes. Player files, owners and backups come in M2.4 and M2.7. - Single-player saves (today's `world.json` with a `"player"` block) are migrated on first load. That block becomes the local player's file. The test suite keeps an old-format save fixture to prove it. @@ -237,7 +273,7 @@ player" or "this player": | **M2.0** | worldlet W0 (terrain from data, no visible change), recommended first | seed 1337 generates the same height grid as today; all suites pass | | **M2.1** *(done)* | network skeleton: `--server`, ENet on 24242, Join screen (address, name), handshake with identity and challenge, name rules, player list | two clients and a headless server on one Mac connect, see the player list, and disconnect cleanly | | **M2.2** *(done)* | player bodies and name tags; predicted movement, interpolation for others | two players walk around together at 100 ms simulated latency without visible rubber-banding | -| **M2.3** | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it | +| **M2.3** *(done)* | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it | | **M2.4** | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads | | **M2.5** | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens | | **M2.6** | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 | @@ -255,6 +291,10 @@ player" or "this player": - **Save tests:** world and player file round trips, migration of old saves, backups. - The existing 9 suites keep running in single-player (loopback) mode. +- `tests/test_net_shared.gd` (M2.3) is the two-world version: this test is + one player and `tests/net_world_bot.gd` (a second process with its own + world, following commands the test writes as files) is the other. A + `--debug-regrow ` server flag speeds regrowth up for it. ## 11. Risks - **Prediction feel** is the hard part. It gets an early phase (M2.2) so diff --git a/docs/SETUP_ON_NEW_MACHINE.md b/docs/SETUP_ON_NEW_MACHINE.md index ce59b33..0c7f5f7 100644 --- a/docs/SETUP_ON_NEW_MACHINE.md +++ b/docs/SETUP_ON_NEW_MACHINE.md @@ -34,7 +34,9 @@ It checks the Godot version, imports a fresh copy, then starts the server on UDP 24242 with no window. Settings live in `server.json` (its path is printed at start-up; on macOS it's under `~/Library/Application Support/Godot/app_userdata/Emberwild/server/`). -Stop it with Ctrl+C. Players join with **Join Game** on the title screen. Over +The shared island is saved in `worlds/server-world/` in that same folder, +within 10 seconds of any change (and when someone leaves). Stop it with +Ctrl+C. Players join with **Join Game** on the title screen. Over Tailscale, use the server machine's 100.x address or its machine name. See `docs/MULTIPLAYER_PLAN.md` for all the options. diff --git a/project.godot b/project.godot index 5c4b341..1060df7 100644 --- a/project.godot +++ b/project.godot @@ -27,6 +27,7 @@ ItemDB="*res://scripts/core/item_db.gd" Actions="*res://scripts/core/actions.gd" Saves="*res://scripts/core/saves.gd" Net="*res://scripts/net/net.gd" +WorldNet="*res://scripts/net/world_net.gd" [display] diff --git a/scripts/building/grave.gd b/scripts/building/grave.gd index 6789528..24c57eb 100644 --- a/scripts/building/grave.gd +++ b/scripts/building/grave.gd @@ -17,6 +17,8 @@ func interact(player: Node) -> void: player.open_inventory(self) func _on_changed() -> void: + if WorldNet.world != null: + return # online, the server crumbles it (world_net.gd) for s in inventory.slots: if s != null: return diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index 44bcf99..a875191 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -53,6 +53,12 @@ func _give(player: Node, id: String, n: int) -> int: notify.emit("Inventory full") return got +## Playing on a server: changes to the island go to it (world_net.gd), and +## come back to everyone, us included. Our own inventory and stats stay here +## until M2.4. +func _online() -> bool: + return WorldNet.is_client() + func _in_reach(player: Node3D, target: Node3D, extra: float = 0.0) -> bool: return player.global_position.distance_to(target.global_position) <= REACH + 2.5 + extra @@ -104,6 +110,12 @@ func pickup(player: Node3D, pickup_node: Node3D) -> bool: if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node): return false var id: String = pickup_node.item_id + if _online(): + if player.inventory.space_for(id) < 1: + notify.emit("Inventory full") + return false + WorldNet.take(pickup_node) # first come, first served: the server decides + return true if _give(player, id, pickup_node.amount) <= 0: return false pickup_node.collected() @@ -123,6 +135,9 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool: if not player.survival.use_stamina(6.0): notify.emit("Too tired") return false + if _online(): + WorldNet.hit(node, float(tool.get("damage", 1.0)), hit_point) + return true var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position) for id in drops: _give(player, id, int(drops[id])) @@ -145,6 +160,9 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool: notify.emit("Can't place that here") return false player.inventory.take_from(slot, 1) + if _online(): + WorldNet.place(id, xf) # (given back if the server says no) + return true world.spawn_piece(def, xf) return true @@ -165,6 +183,9 @@ func dismantle(player: Node3D, piece: Node3D) -> bool: if player.inventory.space_for(piece.item_id) < 1: notify.emit("Inventory full") return false + if _online(): + WorldNet.dismantle(piece) + return true _give(player, piece.item_id, 1) piece.get_parent().get_parent().remove_piece(piece, player) return true @@ -172,6 +193,9 @@ func dismantle(player: Node3D, piece: Node3D) -> bool: func toggle_door(player: Node3D, door: Node3D) -> bool: if not is_instance_valid(door) or not _in_reach(player, door, 1.0): return false + if _online(): + WorldNet.door(door) + return true door.set_open(not door.open) return true @@ -353,6 +377,9 @@ func take_off(player: Node3D) -> bool: func forage(player: Node3D, bush: Node3D) -> bool: if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe(): return false + if _online(): + WorldNet.forage(bush) + return true var got: Dictionary = bush.harvest() for id in got: _give(player, id, int(got[id])) @@ -385,7 +412,10 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool: for id in ["wood", "stick", "fiber"]: if player.inventory.count(id) > 0: player.inventory.remove(id, 1) - fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10))) + if _online(): + WorldNet.fuel(fire, id) + else: + fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10))) notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower()) return true notify.emit("Nothing to burn (wood or sticks)") @@ -396,4 +426,14 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool: func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void: if container != null and not _in_reach(player, container, 1.0): return + if _online() and container != null: + var s = from.get_slot(slot) + if s == null: + return + if from == player.inventory: + from.set_slot(slot, null) + WorldNet.chest_put(container, s) # what doesn't fit comes back + else: + WorldNet.chest_take(container, slot, to.space_for(s.id)) + return from.quick_move(slot, to) diff --git a/scripts/core/saves.gd b/scripts/core/saves.gd index 1c91423..daf3002 100644 --- a/scripts/core/saves.gd +++ b/scripts/core/saves.gd @@ -58,6 +58,22 @@ func create_world(world_name: String, seed: int) -> String: }) return id +## 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: + if not read_meta(id).is_empty(): + var had := int(read_meta(id).get("seed", seed)) + if had != seed: + print("Saves: world '%s' was made with seed %d, so it keeps it (the config says %d)" % [id, had, seed]) + return id + DirAccess.make_dir_recursive_absolute(_dir(id)) + 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, + }) + return id + func delete_world(id: String) -> void: var dir := _dir(id) var d := DirAccess.open(dir) diff --git a/scripts/net/net.gd b/scripts/net/net.gd index baaaf1a..a21f9a9 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day const Identity = preload("res://scripts/net/identity.gd") const ServerConfig = preload("res://scripts/net/server_config.gd") -const PROTOCOL := 2 +const PROTOCOL := 3 ## 3: the shared world (M2.3) const DEFAULT_PORT := 24242 const HANDSHAKE_TIMEOUT := 10.0 const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join @@ -62,12 +62,11 @@ var debug_latency_ms := 0.0 var debug_jitter_ms := 0.0 # Server state. -var _terrain: Node3D ## the island, regenerated from the seed, for movement checks +var _world: Node3D ## the shared island (world.gd in server mode) +var _terrain: Node3D ## its terrain, for movement checks var _spawn := Vector3.ZERO var _states: Dictionary = {} ## peer -> {"pos", "yaw", "pitch", "anim", "held", "t", "corr_t"} var server_time := 0.0 ## seconds since the server started -var _clock_day := 1 -var _clock_time := 8.0 / 24.0 var _send_left := 0.0 var _clock_left := 0.0 # Client delay queues (debug latency): [release_ms, callable]. @@ -180,20 +179,20 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String: if overrides.has(flag): var v: Variant = overrides[flag] config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v) - # The island, from the seed: the same terrain every client generates. - _terrain = load("res://scripts/world/terrain.gd").new() - _terrain.name = "ServerTerrain" - _terrain.noise_seed = int(config.v("seed")) - add_child(_terrain) + # The shared island (world_net.gd): the same terrain and scenery every + # client generates from the seed, plus everything players changed. + _world = WorldNet.open_server_world(config) + _terrain = _world.terrain _spawn = _terrain.find_spawn(Vector2.ZERO) server_time = 0.0 - _clock_day = 1 - _clock_time = 8.0 / 24.0 _peer = ENetMultiplayerPeer.new() var port := int(config.v("port")) # A few spare slots so a full server can still say "server is full". var err := _peer.create_server(port, int(config.v("max_players")) + 4) if err != OK: + WorldNet.close_server_world() + _world = null + _terrain = null return "Couldn't open UDP port %d (error %d). Is another server already running on it?" % [port, err] multiplayer.multiplayer_peer = _peer multiplayer.server_relay = false # clients only ever talk to the server @@ -218,9 +217,10 @@ func stop() -> void: _out_q.clear() _in_q.clear() local_peer = 0 - if _terrain != null: - _terrain.queue_free() - _terrain = null + if _world != null: + WorldNet.close_server_world() # saves it first + _world = null + _terrain = null if state != "offline": _set_state("offline") @@ -238,6 +238,7 @@ func _on_peer_disconnected(id: int) -> void: players.erase(id) print("[server] %s left (%d online)" % [p.name, players.size()]) _s2c_player_left.rpc(id) + WorldNet.peer_left(id) players_changed.emit() func _process(delta: float) -> void: @@ -260,20 +261,19 @@ func _process(delta: float) -> void: _run_queue(_out_q) _run_queue(_in_q) -## The world clock runs while anyone's on (or always, if the server's set -## not to pause when empty). +## The world's clock (it runs while anyone's on, or always if the server's +## set not to pause when empty: world_net.gd pauses the world). Players get +## it every few seconds. func _tick_clock(delta: float) -> void: - if players.is_empty() and bool(config.v("pause_when_empty")): - return - _clock_time += delta / (float(config.v("day_length_minutes")) * 60.0) - while _clock_time >= 1.0: - _clock_time -= 1.0 - _clock_day += 1 _clock_left -= delta - if _clock_left <= 0.0: + if _clock_left <= 0.0 and not players.is_empty(): _clock_left = CLOCK_INTERVAL for peer in players: - _s2c_clock.rpc_id(peer, _clock_day, _clock_time) + _s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time) + +## Where a player is, as far as the server knows. +func player_pos(peer: int) -> Vector3: + return _states[peer].pos if _states.has(peer) else Vector3.INF func _broadcast_snapshot() -> void: if _states.is_empty(): @@ -343,8 +343,36 @@ func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> St return "outside the island" if to.y < _terrain.sample_height(to.x, to.z) - 1.5: return "underground" + if not teleported and _through_wall(from, to): + return "through a wall" return "" +## Whether the straight line between two positions crosses a wall, a closed +## door or a gable (at waist height). A genuine crossing hits the wall from +## both sides; someone a wall was built on top of can still step out of it. +func _through_wall(from: Vector3, to: Vector3) -> bool: + if _world == null or from.distance_to(to) < 0.05: + return false + var space: PhysicsDirectSpaceState3D = _world.get_world_3d().direct_space_state + var up := Vector3(0, 0.9, 0) + return _wall_hit(space, from + up, to + up) and _wall_hit(space, to + up, from + up) + +func _wall_hit(space: PhysicsDirectSpaceState3D, a: Vector3, b: Vector3) -> bool: + var ex: Array[RID] = [] + for _i in range(4): + var rq := PhysicsRayQueryParameters3D.create(a, b, 4, ex) # 4: building pieces + var hit := space.intersect_ray(rq) + if hit.is_empty(): + return false + var c: Object = hit.collider + var piece: Node = c as Node + while piece != null and piece.get("kind") == null: + piece = piece.get_parent() + if piece != null and String(piece.kind) in ["wall", "door", "gable"]: + return true + ex.append(hit.rid) # a floor or roof edge: look past it + return false + func _reject(peer: int, reason: String) -> void: _pending.erase(peer) print("[server] rejected peer %d: %s" % [peer, reason]) @@ -400,8 +428,9 @@ func _c2s_proof(sig: PackedByteArray) -> void: "stamp": server_time, "off": INF} print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "", players.size(), int(config.v("max_players"))]) - var world := {"seed": int(config.v("seed")), "worldlet": String(config.v("worldlet")), "spawn": start, - "island_spawn": _spawn, "day": _clock_day, "time": _clock_time, "server_time": server_time} + # (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, + "island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time} _s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world}) for other in players: if other != peer: diff --git a/scripts/net/world_net.gd b/scripts/net/world_net.gd new file mode 100644 index 0000000..94fb05e --- /dev/null +++ b/scripts/net/world_net.gd @@ -0,0 +1,626 @@ +extends Node +## Autoload "WorldNet": the shared world (M2.3, docs/MULTIPLAYER_PLAN.md §5). +## +## A dedicated server runs the real world scene headless, with no player of its +## own (world.gd `server_mode`). It owns everything in the island that changes: +## trees and rocks (damage, felling, regrowth), pickups, berry bushes, dropped +## items, building pieces, chests and graves, fires, doors and the clock. The +## weather follows from the seed and the clock, so it needs no syncing. +## +## Clients ask; the server checks, applies and tells everyone: +## client -> server _c2s_hit uid server -> all _s2c_node uid hp +## client -> server _c2s_place item server -> all _s2c_piece_added {...} +## ...and so on for each kind of change (the _c2s_* / _s2c_* pairs below). +## When a change earns the asker something (wood from a tree, a pickup, a +## dismantled wall back), the server sends it to them with _s2c_give. +## +## Inventories, stats and crafting stay on each player's own game until M2.4, +## so the server trusts what a client says it puts into the world (a placed +## wall, a chest deposit) but decides everything it takes out. +## +## On joining, a client's world asks for a snapshot (_c2s_world) once its +## scenery is up; from then on it gets every change, in order. + +const REACH := 3.2 + 2.5 ## Actions._in_reach, plus slack for latency +const SLACK := 2.0 +const MAX_HIT := 12.0 ## no tool hits harder than this +const FIRE_SYNC := 10.0 ## seconds between fire fuel updates +const SAVE_AFTER := 10.0 ## seconds after a change before saving +const FUELS := ["wood", "stick", "fiber"] + +var world: Node3D ## server: its world; client: ours while online +var pieces: Dictionary = {} ## net id -> piece (both sides) +var dropped: Dictionary = {} ## net id -> dropped pickup (both sides) +var _next_nid := 1 +var _ready_peers: Dictionary = {} ## server: peers that have their snapshot +var _fire_left := FIRE_SYNC +var _blocker_left := 0.0 +var _save_left := INF ## counting down to a save after a change +var _chest_cb := Callable() ## client: waiting for a chest click's result +var chest_busy := false + +## Counters the tests read. +var received := 0 ## client: world events applied + +func is_server() -> bool: + return Net.is_server() and world != null + +func is_client() -> bool: + return Net.state == "connected" and world != null and is_instance_valid(world) + +# --- server: the world ----------------------------------------------------------- + +## Loads (or creates) the server's world from its config and adds it to the +## tree. Saves live next to server.json, in worlds//. +func open_server_world(config: RefCounted) -> Node3D: + Saves.root_dir = config.path.get_base_dir().path_join("worlds") + 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"))) + var w: Node3D = load("res://scenes/world.tscn").instantiate() + w.server_mode = true + w.day_length = float(config.v("day_length_minutes")) * 60.0 + w.autosave_seconds = maxf(float(config.v("autosave_minutes")), 0.5) * 60.0 + get_tree().root.add_child(w) + world = w + pieces.clear() + dropped.clear() + _ready_peers.clear() + _next_nid = 1 + for p in w.all_pieces(): + if p.has_meta("nid"): + _next_nid = maxi(_next_nid, int(p.get_meta("nid")) + 1) + for p in w.all_pieces(): + if not p.has_meta("nid"): + p.set_meta("nid", _new_nid()) + pieces[int(p.get_meta("nid"))] = p + for d in w.all_dropped(): + if not d.has_meta("nid"): + d.set_meta("nid", _new_nid()) + dropped[int(d.get_meta("nid"))] = d + _next_nid = maxi(_next_nid, _max_nid() + 1) + w.props.regrown.connect(_on_regrown) + var args: Dictionary = Net.parse_args(OS.get_cmdline_user_args()) + if args.has("debug-regrow"): + w.props.regrow_scale = float(args["debug-regrow"]) # (tests: things grow back in seconds) + print("[server] world '%s' (seed %d): %d pieces, %d dropped items. Saves: %s" % [Saves.current_id, + w.terrain.noise_seed, pieces.size(), dropped.size(), ProjectSettings.globalize_path(Saves.root_dir)]) + return w + +func close_server_world() -> void: + if world == null: + return + if Net.is_server() and is_instance_valid(world): + save() + if is_instance_valid(world): + world.queue_free() + world = null + pieces.clear() + dropped.clear() + _ready_peers.clear() + +func save() -> void: + _save_left = INF + if world != null and is_instance_valid(world) and world.server_mode: + world.save_now() + +func _new_nid() -> int: + _next_nid += 1 + return _next_nid - 1 + +func _max_nid() -> int: + var m := 0 + for k in pieces: + m = maxi(m, k) + for k in dropped: + m = maxi(m, k) + return m + +## A player left: they no longer get events. Save while it's fresh. +func peer_left(peer: int) -> void: + _ready_peers.erase(peer) + save() + +func _process(delta: float) -> void: + if not is_server(): + return + # Pause the island while nobody's on (fires, regrowth, the clock). + var paused: bool = Net.players.is_empty() and bool(Net.config.v("pause_when_empty")) + world.process_mode = Node.PROCESS_MODE_DISABLED if paused else Node.PROCESS_MODE_INHERIT + if paused: + return + _blocker_left -= delta + if _blocker_left <= 0.0: + _blocker_left = 1.0 + var at: Array = [] + for peer in Net.players: + at.append(Net.player_pos(peer)) + world.props.blockers = at # nothing regrows on top of a player + _save_left -= delta + if _save_left <= 0.0: + _save_left = INF + save() + _fire_left -= delta + if _fire_left <= 0.0: + _fire_left = FIRE_SYNC + var fires: Array = [] + for nid in pieces: + var p: Node3D = pieces[nid] + if is_instance_valid(p) and p.get("fuel") != null: + fires.append([nid, p.fuel]) + if not fires.is_empty(): + _all("_s2c_fires", [fires]) + +## Sends to every player who has their snapshot. Any change to the island +## also means saving soon: Ctrl+C kills Godot without a chance to save, so +## this keeps the loss to a few seconds. +func _all(method: String, args: Array) -> void: + if method != "_s2c_fires" and _save_left == INF: + _save_left = SAVE_AFTER + for peer in _ready_peers: + callv("rpc_id", [peer, method] + args) + +func _give_to(peer: int, items: Dictionary) -> void: + var clean := {} + for id in items: + if int(items[id]) > 0: + clean[id] = int(items[id]) + if not clean.is_empty(): + _s2c_give.rpc_id(peer, clean) + +func _tell(peer: int, text: String) -> void: + _s2c_notify.rpc_id(peer, text) + +## The asker, if they're a player in the world (else 0). +func _asker() -> int: + var peer := multiplayer.get_remote_sender_id() + if not is_server() or not _ready_peers.has(peer): + return 0 + return peer + +func _near(peer: int, at: Vector3, r: float) -> bool: + return Net.player_pos(peer).distance_to(at) <= r + SLACK + +func _piece(nid: int) -> Node3D: + var p: Node3D = pieces.get(nid) + if p == null or not is_instance_valid(p) or p.is_queued_for_deletion(): + pieces.erase(nid) + return null + return p + +func _entry(p: Node3D) -> Dictionary: + return world.piece_entry(p) + +static func _clean_stack(s: Variant) -> Variant: + if not s is Dictionary: + return null + var id := String(s.get("id", "")) + var n := int(s.get("count", 0)) + if ItemDB.get_item(id).is_empty() or n <= 0: + return null + return {"id": id, "count": mini(n, ItemDB.stack_size(id))} + +static func _empty(inv: Inventory) -> bool: + for s in inv.slots: + if s != null: + return false + return true + +# --- client -> server ------------------------------------------------------------- + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_world() -> void: + var peer := multiplayer.get_remote_sender_id() + if not is_server() or not Net.players.has(peer): + return + _ready_peers[peer] = true + _s2c_world.rpc_id(peer, world.collect_shared()) + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_hit(uid: String, damage: float, point: Vector3) -> void: + var peer := _asker() + var n: Node3D = world.props.resource_nodes.get(uid) if peer != 0 else null + if n == null or not is_instance_valid(n) or n.depleted: + return + if not _near(peer, point, 3.2 + 1.0) or point.distance_to(n.global_position) > 6.0: + return + var from: Vector3 = Net.player_pos(peer) + var drops: Dictionary = n.hit(clampf(damage, 0.0, MAX_HIT), point, from) + _give_to(peer, drops) + _all("_s2c_node", [uid, n.hp, point, from]) + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_take(key: String) -> void: + var peer := _asker() + if peer == 0: + return + var pk: Node3D = _find_pickup(key) + if pk == null or pk.taken or not _near(peer, pk.global_position, REACH): + return + var got := {pk.item_id: pk.amount} + if key.begins_with("#"): + dropped.erase(int(key.substr(1))) + pk.collected() + _give_to(peer, got) + _all("_s2c_taken", [key]) + +func _find_pickup(key: String) -> Node3D: + var pk: Node3D + if key.begins_with("#"): + pk = dropped.get(int(key.substr(1))) + else: + pk = world.props.pickups.get(key) if world != null else null + return pk if pk != null and is_instance_valid(pk) else null + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_forage(uid: String) -> void: + var peer := _asker() + var bush: Node3D = world.props.forage.get(uid) if peer != 0 else null + if bush == null or not bush.ripe() or not _near(peer, bush.global_position, REACH): + return + _give_to(peer, bush.harvest()) + _all("_s2c_forage", [uid, bush.regrow_left]) + +## Places `item` at `xf`. The client has already taken it out of its +## inventory: if it can't go here, it's given back. A grave carries what the +## player was holding when they died. +@rpc("any_peer", "call_remote", "reliable") +func _c2s_place(item: String, xf: Transform3D, contents: Array) -> void: + var peer := _asker() + if peer == 0: + return + var def: Dictionary = ItemDB.get_item(item) + if def.is_empty() or not def.has("place"): + return + # (A grave can be further off: drowning puts it on the nearest shore.) + var ok := xf.origin.is_finite() and _near(peer, xf.origin, 40.0 if item == "grave" else 8.0) + if item == "grave": + ok = ok and not contents.is_empty() + else: + ok = ok and world.placement_valid(def, xf) + if not ok: + if item != "grave": + _give_to(peer, {item: 1}) + _tell(peer, "Can't place that here") + return + var p: Node3D = world.spawn_piece(def, xf.orthonormalized()) + if item == "grave" and p.get("inventory") != null: + var clean: Array = [] + for s in contents.slice(0, p.inventory.size()): + clean.append(_clean_stack(s)) + p.inventory.load_data(clean) + p.set_meta("nid", _new_nid()) + pieces[int(p.get_meta("nid"))] = p + _all("_s2c_piece_added", [_entry(p)]) + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_dismantle(nid: int) -> void: + var peer := _asker() + var p: Node3D = _piece(nid) if peer != 0 else null + if p == null or not _near(peer, p.global_position, 8.0) or p.item_id == "grave": + return + var why: String = p.dismantle_block_reason() + if not why.is_empty(): + _tell(peer, why) + return + # Whatever loses its support collapses: those come back to you too. + var items := {} + for k in pieces: + if _piece(k) != null: + items[k] = pieces[k].item_id + world.remove_piece(p, null) + var gone: Array = [] + var fell: Array = [] + var refund := {} + for k in items: + if _piece(k) == null: + gone.append(k) + refund[items[k]] = refund.get(items[k], 0) + 1 + if k != nid: + fell.append(k) + _all("_s2c_pieces_removed", [gone, fell]) + _give_to(peer, refund) + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_door(nid: int) -> void: + var peer := _asker() + var p: Node3D = _piece(nid) if peer != 0 else null + if p == null or p.get("open") == null or not _near(peer, p.global_position, REACH + 1.0): + return + p.set_open(not p.open) + _all("_s2c_piece_state", [nid, p.save_extra()]) + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_fuel(nid: int, item: String) -> void: + var peer := _asker() + if peer == 0 or not item in FUELS: + return + var p := _piece(nid) + if p == null or p.get("fuel") == null or not _near(peer, p.global_position, REACH) \ + or p.fuel >= p.MAX_FUEL - 1.0: + _give_to(peer, {item: 1}) + return + p.add_fuel(float(ItemDB.get_item(item).get("fuel", 10))) + _all("_s2c_piece_state", [nid, p.save_extra()]) + +## A click on a chest slot while holding `cursor`: the same as clicking in +## your own inventory (pick up, put down, swap). The new cursor comes back. +@rpc("any_peer", "call_remote", "reliable") +func _c2s_chest_click(nid: int, slot: int, cursor: Variant, right: bool) -> void: + var peer := _asker() + if peer == 0: + return + var c: Variant = _clean_stack(cursor) + var p := _piece(nid) + if p == null or p.get("inventory") == null or slot < 0 or slot >= p.inventory.size() \ + or not _near(peer, p.global_position, REACH + 1.0): + _s2c_cursor.rpc_id(peer, c) # nothing happened: keep what you were holding + return + var res: Variant = p.inventory.click(slot, c, right) + _s2c_cursor.rpc_id(peer, res) + _chest_changed(nid, p) + +## Shift-click from your inventory: as much of `stack` as fits goes in, the +## rest comes back. +@rpc("any_peer", "call_remote", "reliable") +func _c2s_chest_put(nid: int, stack: Variant) -> void: + var peer := _asker() + var s: Variant = _clean_stack(stack) + if peer == 0 or s == null: + return + var p := _piece(nid) + if p == null or p.get("inventory") == null or not _near(peer, p.global_position, REACH + 1.0): + _give_to(peer, {s.id: s.count}) + return + var left: int = p.inventory.add(s.id, s.count) + _give_to(peer, {s.id: left}) + _chest_changed(nid, p) + +## Shift-click on a chest slot: up to `room` of it comes to you. +@rpc("any_peer", "call_remote", "reliable") +func _c2s_chest_take(nid: int, slot: int, room: int) -> void: + var peer := _asker() + var p: Node3D = _piece(nid) if peer != 0 else null + if p == null or p.get("inventory") == null or slot < 0 or slot >= p.inventory.size() or room <= 0 \ + or not _near(peer, p.global_position, REACH + 1.0): + return + var s: Variant = p.inventory.get_slot(slot) + if s == null: + return + var id: String = s.id + var n: int = p.inventory.take_from(slot, room) + _give_to(peer, {id: n}) + _chest_changed(nid, p) + +func _chest_changed(nid: int, p: Node3D) -> void: + _all("_s2c_chest", [nid, p.inventory.to_data()]) + # An emptied grave crumbles. + if p.item_id == "grave" and _empty(p.inventory): + world.remove_piece(p, null) + pieces.erase(nid) + _all("_s2c_pieces_removed", [[nid], []]) + +## Something the player put down in the world: an arrow that landed, a fish +## that didn't fit. Anyone can pick it up. +@rpc("any_peer", "call_remote", "reliable") +func _c2s_drop(item: String, count: int, xf: Transform3D) -> void: + var peer := _asker() + if peer == 0 or ItemDB.get_item(item).is_empty() or count <= 0 or count > 999 \ + or not xf.origin.is_finite() or not _near(peer, xf.origin, 60.0): + return # (60 m: an arrow can land a long way off) + var d: Node3D = world.drop_item(item, count, xf.orthonormalized()) + d.set_meta("nid", _new_nid()) + dropped[int(d.get_meta("nid"))] = d + _all("_s2c_dropped", [world.dropped_entry(d)]) + +func _on_regrown(uid: String) -> void: + _all("_s2c_regrown", [uid]) + +# --- client: asking ---------------------------------------------------------------- + +## Our world is up: ask for the shared state. +func join_world(w: Node3D) -> void: + world = w + pieces.clear() + dropped.clear() + chest_busy = false + _chest_cb = Callable() + received = 0 + _c2s_world.rpc_id(1) + +func leave_world() -> void: + if not Net.is_server(): + world = null + pieces.clear() + dropped.clear() + +func hit(node: Node3D, damage: float, point: Vector3) -> void: + _c2s_hit.rpc_id(1, String(node.uid), damage, point) + +## The key the server knows a pickup by: its scenery id, or "#". +func pickup_key(pk: Node3D) -> String: + return "#%d" % int(pk.get_meta("nid")) if pk.has_meta("nid") else String(pk.uid) + +func take(pk: Node3D) -> void: + _c2s_take.rpc_id(1, pickup_key(pk)) + +func forage(bush: Node3D) -> void: + _c2s_forage.rpc_id(1, String(bush.uid)) + +func place(item: String, xf: Transform3D, contents: Array = []) -> void: + _c2s_place.rpc_id(1, item, xf, contents) + +static func nid(p: Node3D) -> int: + return int(p.get_meta("nid", 0)) + +func dismantle(p: Node3D) -> void: + _c2s_dismantle.rpc_id(1, nid(p)) + +func door(p: Node3D) -> void: + _c2s_door.rpc_id(1, nid(p)) + +func fuel(p: Node3D, item: String) -> void: + _c2s_fuel.rpc_id(1, nid(p), item) + +## `done(cursor)` gets what you're holding afterwards. +func chest_click(p: Node3D, slot: int, cursor: Variant, right: bool, done: Callable) -> void: + chest_busy = true + _chest_cb = done + _c2s_chest_click.rpc_id(1, nid(p), slot, cursor, right) + +func chest_put(p: Node3D, stack: Dictionary) -> void: + _c2s_chest_put.rpc_id(1, nid(p), stack) + +func chest_take(p: Node3D, slot: int, room: int) -> void: + _c2s_chest_take.rpc_id(1, nid(p), slot, room) + +func drop(item: String, count: int, xf: Transform3D) -> void: + _c2s_drop.rpc_id(1, item, count, xf) + +# --- server -> client ------------------------------------------------------------ + +@rpc("authority", "call_remote", "reliable") +func _s2c_world(state: Dictionary) -> void: + if not is_client(): + return + world.apply_shared(state) + pieces.clear() + dropped.clear() + for p in world.all_pieces(): + if p.has_meta("nid"): + pieces[int(p.get_meta("nid"))] = p + for d in world.all_dropped(): + if d.has_meta("nid"): + dropped[int(d.get_meta("nid"))] = d + received += 1 + +@rpc("authority", "call_remote", "reliable") +func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void: + if not is_client(): + return + var n: Node3D = world.props.resource_nodes.get(uid) + if n != null and is_instance_valid(n): + n.apply_remote(hp, point, from) + received += 1 + +@rpc("authority", "call_remote", "reliable") +func _s2c_regrown(uid: String) -> void: + if not is_client(): + return + var n: Node3D = world.props.pickups.get(uid, world.props.resource_nodes.get(uid)) + if n != null and is_instance_valid(n): + world.props.regrow.erase(uid) + n.regrow() + received += 1 + +@rpc("authority", "call_remote", "reliable") +func _s2c_taken(key: String) -> void: + if not is_client(): + return + var pk := _find_pickup(key) + if key.begins_with("#"): + dropped.erase(int(key.substr(1))) + if pk != null: + pk.collected() + received += 1 + +@rpc("authority", "call_remote", "reliable") +func _s2c_forage(uid: String, left: float) -> void: + if not is_client(): + return + var bush: Node3D = world.props.forage.get(uid) + if bush != null: + bush.restore(left) + received += 1 + +@rpc("authority", "call_remote", "reliable") +func _s2c_piece_added(e: Dictionary) -> void: + if not is_client(): + return + var p: Node3D = world.add_piece_entry(e) + if p != null: + pieces[int(e.nid)] = p + received += 1 + +@rpc("authority", "call_remote", "reliable") +func _s2c_pieces_removed(nids: Array, fell: Array) -> void: + if not is_client(): + return + var gone: Array = [] + for k in nids: + var p: Node3D = pieces.get(int(k)) + pieces.erase(int(k)) + if p != null and is_instance_valid(p): + gone.append(p) + var collapsed: Array = [] + for k in fell: + collapsed.append(int(k)) + world.remove_pieces_net(gone, collapsed) + received += 1 + +@rpc("authority", "call_remote", "reliable") +func _s2c_piece_state(nid_: int, extra: Dictionary) -> void: + if not is_client(): + return + var p: Node3D = pieces.get(nid_) + if p == null or not is_instance_valid(p): + return + if extra.has("open") and p.has_method("set_open"): + p.set_open(bool(extra.open)) + elif p.has_method("load_extra"): + p.load_extra(extra) + received += 1 + +@rpc("authority", "call_remote", "reliable") +func _s2c_fires(fires: Array) -> void: + if not is_client(): + return + for f in fires: + var p: Node3D = pieces.get(int(f[0])) + if p != null and is_instance_valid(p) and p.has_method("load_extra"): + p.load_extra({"fuel": float(f[1])}) + +@rpc("authority", "call_remote", "reliable") +func _s2c_chest(nid_: int, data: Array) -> void: + if not is_client(): + return + var p: Node3D = pieces.get(nid_) + if p != null and is_instance_valid(p) and p.get("inventory") != null: + p.inventory.load_data(data) + received += 1 + +@rpc("authority", "call_remote", "reliable") +func _s2c_dropped(e: Dictionary) -> void: + if not is_client(): + return + var d: Node3D = world.add_dropped_entry(e) + if d != null: + dropped[int(e.nid)] = d + received += 1 + +## Items the server decided we earned. Anything that doesn't fit goes back +## on the ground at our feet (for anyone to pick up). +@rpc("authority", "call_remote", "reliable") +func _s2c_give(items: Dictionary) -> void: + if not is_client(): + return + for id in items: + var n := int(items[id]) + var got: int = Actions._give(world.player, String(id), n) + if got < n: + drop(String(id), n - got, Transform3D(Basis(), world.player.global_position + Vector3(0, 0.1, 0))) + +@rpc("authority", "call_remote", "reliable") +func _s2c_cursor(stack: Variant) -> void: + chest_busy = false + var cb := _chest_cb + _chest_cb = Callable() + if cb.is_valid(): + cb.call(stack) + elif stack != null and is_client(): + _s2c_give({stack.id: stack.count}) + +@rpc("authority", "call_remote", "reliable") +func _s2c_notify(text: String) -> void: + Actions.notify.emit(text) diff --git a/scripts/ui/inventory_screen.gd b/scripts/ui/inventory_screen.gd index 01d2151..45eeaf0 100644 --- a/scripts/ui/inventory_screen.gd +++ b/scripts/ui/inventory_screen.gd @@ -144,8 +144,12 @@ func open(chest: Node) -> void: func close() -> void: if _cursor != null: var left: int = player.inventory.add(_cursor.id, _cursor.count) - if left > 0 and container != null: - left = container.inventory.add(_cursor.id, left) + if left > 0 and container != null and is_instance_valid(container): + if WorldNet.is_client(): + WorldNet.chest_put(container, {"id": _cursor.id, "count": left}) + left = 0 + else: + left = container.inventory.add(_cursor.id, left) _cursor = null if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh): container.inventory.changed.disconnect(_refresh) @@ -201,10 +205,28 @@ func _on_chest_slot(i: int, right: bool, shift: bool) -> void: return if shift: Actions.transfer(player, container.inventory, i, player.inventory, container) + elif WorldNet.is_client(): + # A shared chest: the server does the click (someone else may be in + # it too) and hands back what we're holding. + if WorldNet.chest_busy: + return + var held = _cursor + _cursor = null + WorldNet.chest_click(container, i, held, right, _on_chest_cursor) else: _cursor = container.inventory.click(i, _cursor, right) _refresh() +func _on_chest_cursor(stack) -> void: + if visible: + _cursor = stack + _refresh() + elif stack != null: + # Closed in the meantime: into our bag (or the ground). + var left: int = player.inventory.add(stack.id, stack.count) + if left > 0: + player.get_parent().drop_item(stack.id, left, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0))) + func _refresh() -> void: if player == null: return diff --git a/scripts/world/props.gd b/scripts/world/props.gd index 7dcab69..d20a9c9 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -45,6 +45,12 @@ var forage: Dictionary = {} ## uid -> berry bush (forage.gd) ## Regrowth: uid -> seconds until a felled tree, mined boulder or picked-up ## item comes back. Saved with the world. Bushes keep their own timers. var regrow: Dictionary = {} +## Off on a client playing online: the server says when things grow back. +var regrow_enabled := true +## Other places nothing may regrow on top of (a server's players). +var blockers: Array = [] +var regrow_scale := 1.0 ## test hook: a server started with --debug-regrow +signal regrown(uid: String) const REGROW_SECONDS := {"tree": 2400.0, "rock": 3600.0, "pickup": 900.0} const REGROW_RETRY := 120.0 ## blocked (player or a building nearby): try again later var _regrow_tick := 0.0 @@ -125,15 +131,15 @@ func _regrow_kind(uid: String) -> String: return resource_nodes[uid].kind func _on_depleted(node: Node) -> void: - regrow[node.uid] = REGROW_SECONDS[node.kind] + regrow[node.uid] = REGROW_SECONDS[node.kind] * regrow_scale func _on_taken(pk: Node) -> void: - regrow[pk.uid] = REGROW_SECONDS["pickup"] + regrow[pk.uid] = REGROW_SECONDS["pickup"] * regrow_scale ## Advances regrowth timers by `secs` (every second while playing, or in one ## go when you sleep). func skip_time(secs: float) -> void: - for uid in regrow.keys(): + for uid in regrow.keys() if regrow_enabled else []: regrow[uid] -= secs if regrow[uid] <= 0.0: _try_regrow(uid) @@ -148,12 +154,15 @@ func _try_regrow(uid: String) -> void: regrow.erase(uid) return var r := 0.5 if pickups.has(uid) else 1.4 - if _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0 \ - or _built_over(node.global_position, r): - regrow[uid] = REGROW_RETRY + var blocked := _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0 + for b in blockers: + blocked = blocked or (b as Vector3).distance_to(node.global_position) < r + 3.0 + if blocked or _built_over(node.global_position, r): + regrow[uid] = REGROW_RETRY * regrow_scale return regrow.erase(uid) node.regrow() + regrown.emit(uid) ## Something built (or being built) where a tree or rock would come back. func _built_over(pos: Vector3, r: float) -> bool: @@ -179,10 +188,10 @@ func restore_regrow(saved: Dictionary) -> void: regrow[uid] = float(saved[uid]) for uid in resource_nodes: if resource_nodes[uid].depleted and not regrow.has(uid): - regrow[uid] = REGROW_SECONDS[resource_nodes[uid].kind] + regrow[uid] = REGROW_SECONDS[resource_nodes[uid].kind] * regrow_scale for uid in pickups: if pickups[uid].taken and not regrow.has(uid): - regrow[uid] = REGROW_SECONDS["pickup"] + regrow[uid] = REGROW_SECONDS["pickup"] * regrow_scale # --- materials -------------------------------------------------------------- diff --git a/scripts/world/resource_node.gd b/scripts/world/resource_node.gd index 8538d06..a5463ac 100644 --- a/scripts/world/resource_node.gd +++ b/scripts/world/resource_node.gd @@ -56,15 +56,26 @@ func interact_prompt(player: Node) -> String: ## Applies one hit. Returns the items earned by this hit. func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary: var out := {} - hp -= damage for id in drops_per_hit: out[id] = drops_per_hit[id] - _burst(point, chip_color, 14) - if hp <= 0.0: - depleted = true + if hp - damage <= 0.0: for id in drops_final: var r: Array = drops_final[id] out[id] = out.get(id, 0) + rng.randi_range(int(r[0]), int(r[1])) + _show_hit(hp - damage, point, from) + return out + +## A hit someone else's game reported, via the server (world_net.gd): the +## same chips, shake and fall, with no drops. +func apply_remote(new_hp: float, point: Vector3, from: Vector3) -> void: + if not depleted: + _show_hit(new_hp, point, from) + +func _show_hit(new_hp: float, point: Vector3, from: Vector3) -> void: + hp = new_hp + _burst(point, chip_color, 14) + if hp <= 0.0: + depleted = true _deplete(from) else: _shake = 1.0 @@ -73,7 +84,6 @@ func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary: _shake_axis = away.normalized().cross(Vector3.UP) if away.length() > 0.01 else Vector3.RIGHT if kind == "rock": _set_instance(_rock_xf()) - return out func _process(delta: float) -> void: if _shake <= 0.0 or depleted: diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 5396163..4eb0d5c 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -33,6 +33,12 @@ const AUTOSAVE_SECONDS := 120.0 ## Set by the title screen before adding the world: no player, no HUD, no ## saving, just a slow camera flight over the island as a menu backdrop. var menu_mode := false +## Set by WorldNet on a dedicated server: the shared island with nobody of its +## own in it (no player, HUD, animals or effects). It saves to the server's +## world slot. See world_net.gd. +var server_mode := false +var day_length := 1200.0 ## seconds per day (a server sets its own) +var autosave_seconds := AUTOSAVE_SECONDS var _spawn := Vector3.ZERO var _spawn_yaw := 0.0 @@ -61,11 +67,15 @@ func is_online() -> bool: var _loading := false ## applying a save: defer support recompute func _ready() -> void: + _autosave_left = autosave_seconds _pieces = Node3D.new() _pieces.name = "Pieces" add_child(_pieces) day_night = preload("res://scripts/world/day_night.gd").new() day_night.name = "DayNight" + if is_online() and Net.server_info.has("day_length_minutes"): + day_length = float(Net.server_info.day_length_minutes) * 60.0 # same days, same weather + day_night.day_length = day_length add_child(day_night) day_night.setup(sun, $WorldEnvironment.environment) weather = preload("res://scripts/world/weather.gd").new() @@ -86,7 +96,15 @@ func _ready() -> void: creatures.name = "Creatures" creatures.enabled = false # until the scenery is placed add_child(creatures) - if not menu_mode and DisplayServer.get_name() != "headless": + if server_mode: + # Nobody of our own: the player node stays, parked out of the way and + # switched off, because a few shared functions expect one. + player.process_mode = Node.PROCESS_MODE_DISABLED + player.collision_layer = 0 + player.position = Vector3(0, -500, 0) + player.get_node("Camera3D").current = false + $HUD.visible = false + elif not menu_mode and DisplayServer.get_name() != "headless": weather_fx = preload("res://scripts/world/weather_fx.gd").new() weather_fx.name = "WeatherFX" add_child(weather_fx) @@ -101,13 +119,13 @@ func _ready() -> void: add_child(_menu_cam) _menu_cam.current = true $HUD.visible = false - elif DisplayServer.get_name() != "headless": + elif DisplayServer.get_name() != "headless" and not server_mode: hud = preload("res://scripts/ui/hud.gd").new() add_child(hud) hud.bind(player) - if not menu_mode: + if not menu_mode and not server_mode: player.survival.died.connect(_on_player_died) - if Saves.has_world() and not menu_mode: + if Saves.has_world() and not menu_mode and not server_mode: # Save on window close instead of quitting straight away. get_tree().auto_accept_quit = false player.set_physics_process(false) @@ -158,8 +176,12 @@ func _on_player_died() -> void: if st != null: has_items = true if has_items: - var grave := spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, player.rotation.y), pos)) - grave.inventory.load_data(player.inventory.to_data()) + var gxf := Transform3D(Basis(Vector3.UP, player.rotation.y), pos) + if net_sync != null: + WorldNet.place("grave", gxf, player.inventory.to_data()) # the server puts it in the shared world + else: + var grave := spawn_piece(ItemDB.get_item("grave"), gxf) + grave.inventory.load_data(player.inventory.to_data()) player.inventory.load_data(Inventory.new(player.inventory.size()).to_data()) if hud != null: hud.close_screens() @@ -197,6 +219,8 @@ func _respawn_at_bed(bed: Node3D) -> void: ## Why you can't sleep in `bed` right now ("" = you can). func sleep_block_reason(bed: Node3D) -> String: + if is_online(): + return "Sleeping on a server isn't in yet (everyone would have to agree)" var h: float = day_night.hours() if h > 5.5 and h < 19.5: return "You can only sleep at night (after 19:30)" @@ -255,6 +279,12 @@ func _on_terrain_ready() -> void: print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0)) if menu_mode: return + if server_mode: + if Saves.has_world(): + var saved: Dictionary = Saves.load_state(Saves.current_id) + if not saved.is_empty(): + apply_save(saved) + return # no player, no animals (the server runs those in M2.5) _respawn() if Saves.has_world(): var state: Dictionary = Saves.load_state(Saves.current_id) @@ -301,15 +331,25 @@ func _process(delta: float) -> void: ## Writes the current game to the active world slot. Returns false if this ## session isn't tied to a slot (e.g. running world.tscn directly). func save_now() -> bool: - _autosave_left = AUTOSAVE_SECONDS + _autosave_left = autosave_seconds if not Saves.has_world(): return false var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time) if ok: _session_time = 0.0 - Actions.notify.emit("Game saved" if ok else "Save FAILED") + if server_mode: + if not ok: + printerr("[server] saving the world FAILED") + else: + Actions.notify.emit("Game saved" if ok else "Save FAILED") return ok +## A server's world saves as it closes, however the server stops (Ctrl+C +## included), unless it has just saved. +func _exit_tree() -> void: + if server_mode and Saves.has_world() and _session_time > 0.0: + save_now() + func _notification(what: int) -> void: if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode: return @@ -349,20 +389,13 @@ func collect_save() -> Dictionary: if not is_instance_valid(pk) or pk.taken: taken.append(uid) var dropped: Array = [] - for d in _dropped.get_children(): - if d.is_queued_for_deletion() or d.taken: - continue - dropped.append({"item": d.item_id, "count": d.amount, "xf": _xf_to_array(d.global_transform)}) + for d in all_dropped(): + dropped.append(dropped_entry(d)) var pieces: Array = [] - for piece in _pieces.get_children(): - if piece.is_queued_for_deletion(): - continue - var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)} - if piece.get("inventory") != null: - entry["inventory"] = piece.inventory.to_data() - if piece.has_method("save_extra"): - entry["extra"] = piece.save_extra() - pieces.append(entry) + for piece in all_pieces(): + pieces.append(piece_entry(piece)) + if server_mode: + return _shared_state(harvested, regrow, taken, dropped, pieces) return { "player": { "pos": [player.global_position.x, player.global_position.y, player.global_position.z], @@ -383,22 +416,107 @@ func collect_save() -> Dictionary: "pieces": pieces, } -func apply_save(s: Dictionary) -> void: - var p: Dictionary = s.get("player", {}) +func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, dropped: Array, pieces: Array) -> Dictionary: + return { + "dropped": dropped, + "time": {"day": day_night.day, "time": day_night.time}, + "forage_regrow": regrow, + "regrow": props.regrow.duplicate(), + "harvested": harvested, + "pickups_taken": taken, + "pieces": pieces, + } + +## The live pieces (placed and not being removed). +func all_pieces() -> Array: + return _pieces.get_children().filter(func(p): return not p.is_queued_for_deletion()) + +func all_dropped() -> Array: + return _dropped.get_children().filter(func(d): return not d.is_queued_for_deletion() and not d.taken) + +## One piece as saved (and as sent to players): its item, where it is, what's +## in it, its state (fuel, door open), and its net id if it has one. +func piece_entry(piece: Node3D) -> Dictionary: + var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)} + if piece.get("inventory") != null: + entry["inventory"] = piece.inventory.to_data() + if piece.has_method("save_extra"): + entry["extra"] = piece.save_extra() + if piece.has_meta("nid"): + entry["nid"] = int(piece.get_meta("nid")) + return entry + +func dropped_entry(d: Node3D) -> Dictionary: + var e := {"item": d.item_id, "count": d.amount, "xf": _xf_to_array(d.global_transform)} + if d.has_meta("nid"): + e["nid"] = int(d.get_meta("nid")) + return e + +## The shared state a server sends a player who joins (everything but the +## server's own bookkeeping). +func collect_shared() -> Dictionary: + return collect_save() + +## A joining player's copy of the server's island. +func apply_shared(s: Dictionary) -> void: + apply_save(s, false) + props.regrow.clear() # the server says when things grow back + +func add_piece_entry(e: Dictionary) -> Node3D: + var def: Dictionary = ItemDB.get_item(e.get("item", "")) + if def.is_empty() or not def.has("place"): + return null + var piece := spawn_piece(def, _array_to_xf(e.xf)) + if e.has("inventory") and piece.get("inventory") != null: + piece.inventory.load_data(e.inventory) + if e.has("extra") and piece.has_method("load_extra"): + piece.load_extra(e.extra) + if e.has("nid"): + piece.set_meta("nid", int(e.nid)) + return piece + +func add_dropped_entry(e: Dictionary) -> Node3D: + if ItemDB.get_item(e.get("item", "")).is_empty(): + return null + var d := _make_dropped(e.item, int(e.get("count", 1)), _array_to_xf(e.xf)) + if e.has("nid"): + d.set_meta("nid", int(e.nid)) + return d + +## Pieces the server took out (dismantled, collapsed, a grave emptied). No +## refunds here: the server sends those to whoever did it. +func remove_pieces_net(gone: Array, collapsed_nids: Array) -> void: + var any_build := false + for p in gone: + if p.item_id == "bed" and spawn_bed() == p: + has_bed_spawn = false + if p.is_in_group("build"): + any_build = true + if int(p.get_meta("nid", 0)) in collapsed_nids: + _collapse_fx(p) + _pieces.remove_child(p) + p.queue_free() + if any_build: + build.recompute() + _settle_loose_pieces() + _update_grass_cuts() + +func apply_save(s: Dictionary, with_player: bool = true) -> void: + var p: Dictionary = s.get("player", {}) if with_player else {} if p.has("pos"): player.global_position = Vector3(p.pos[0], p.pos[1] + 0.05, p.pos[2]) player.rotation = Vector3(0.0, float(p.get("yaw", 0.0)), 0.0) player.get_node("Camera3D").rotation.x = float(p.get("pitch", 0.0)) - if p.has("inventory"): - player.inventory.load_data(p.inventory) - player.select(int(p.get("selected", 0))) - if p.has("stats"): - player.survival.load_data(p.stats) - player.worn = String(p.get("worn", "")) - player.worn_changed.emit() + if with_player: + if p.has("inventory"): + player.inventory.load_data(p.inventory) + player.select(int(p.get("selected", 0))) + if p.has("stats"): + player.survival.load_data(p.stats) + player.worn = String(p.get("worn", "")) + player.worn_changed.emit() for d in s.get("dropped", []): - if not ItemDB.get_item(d.get("item", "")).is_empty(): - drop_item(d.item, int(d.get("count", 1)), _array_to_xf(d.xf)) + add_dropped_entry(d) var t: Dictionary = s.get("time", {}) if not t.is_empty(): day_night.day = int(t.get("day", 1)) @@ -424,14 +542,7 @@ func apply_save(s: Dictionary) -> void: weather.snap() _loading = true for e in s.get("pieces", []): - var def: Dictionary = ItemDB.get_item(e.get("item", "")) - if def.is_empty() or not def.has("place"): - continue - var piece := spawn_piece(def, _array_to_xf(e.xf)) - if e.has("inventory") and piece.get("inventory") != null: - piece.inventory.load_data(e.inventory) - if e.has("extra") and piece.has_method("load_extra"): - piece.load_extra(e.extra) + add_piece_entry(e) _loading = false build.recompute() # same terrain, same pieces: nothing collapses on load _update_grass_cuts() @@ -531,14 +642,18 @@ func _go_online() -> void: net_sync.setup(self) Net.left.connect(_on_net_left) get_tree().auto_accept_quit = false # leave the server cleanly on window close + # The island's changes come from the server from now on. + props.regrow_enabled = false + WorldNet.join_world(self) if hud != null: - var bar := UITheme.label("Online: %s · you can see each other; building, gathering and animals aren't shared yet" \ + var bar := UITheme.label("Online: %s · the island is shared; your inventory isn't saved yet, and there are no animals yet" \ % Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM) bar.position = Vector2(16, 40) $HUD.add_child(bar) Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server")) func _on_net_left(_reason: String) -> void: + WorldNet.leave_world() if is_inside_tree(): get_tree().paused = false get_tree().auto_accept_quit = true @@ -546,7 +661,14 @@ func _on_net_left(_reason: String) -> void: ## Leaves `count` of an item lying in the world at `xf` (an arrow stuck in a ## tree, a thrown spear, meat that didn't fit). Saved with the world. +## Online, the server puts it in the shared world (and returns null here). func drop_item(id: String, count: int, xf: Transform3D) -> Node3D: + if net_sync != null: + WorldNet.drop(id, count, xf) + return null + return _make_dropped(id, count, xf) + +func _make_dropped(id: String, count: int, xf: Transform3D) -> Node3D: var pk: Area3D = preload("res://scripts/world/pickup.gd").new() pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3) pk.amount = count diff --git a/tests/net_world_bot.gd b/tests/net_world_bot.gd new file mode 100644 index 0000000..bedc7a2 --- /dev/null +++ b/tests/net_world_bot.gd @@ -0,0 +1,159 @@ +extends SceneTree +## A second player with a real world, for tests/test_net_shared.gd. Joins, +## loads the island like the game does, then follows commands the test +## writes as files (so the test can line up both players' actions): +## /cmd_.json -> runs it -> /res_.json +## Commands: walk, report, take, chest_take, bump, hop, leave. +## Args (after --): --identity --name --address host:port --dir + +var net: Node +var world: Node3D +var player: CharacterBody3D +var dir := "" +var corrections := 0 + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + net = root.get_node("Net") + var args: Dictionary = net.parse_args(OS.get_cmdline_user_args()) + dir = String(args.dir) + var me = load("res://scripts/net/identity.gd").open(String(args.identity)) + me.name = String(args.get("name", "Bob")) + net.identity = me + var outcome := {"v": ""} + net.joined.connect(func(): outcome.v = "joined") + net.join_failed.connect(func(r): outcome.v = "failed: " + r) + net.join(String(args.address)) + var t0 := Time.get_ticks_msec() + while outcome.v.is_empty() and Time.get_ticks_msec() - t0 < 20000: + await process_frame + if outcome.v != "joined": + _write(0, {"ok": false, "why": outcome.v}) + quit() + return + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(8): + await physics_frame + player = world.player + player.set_physics_process(false) # the test moves us + net.corrected.connect(func(_p): corrections += 1) + t0 = Time.get_ticks_msec() + while world.get_node("/root/WorldNet").received < 1 and Time.get_ticks_msec() - t0 < 10000: + await process_frame + _write(0, {"ok": true, "pos": _v(player.global_position)}) + var n := 1 + while net.state == "connected": + var path := "%s/cmd_%d.json" % [dir, n] + if not FileAccess.file_exists(path): + await create_timer(0.05).timeout + continue + await create_timer(0.05).timeout + var cmd: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(path)) + var res: Dictionary = await _do(cmd) + _write(n, res) + n += 1 + if cmd.get("do") == "leave": + break + quit() + +func _write(n: int, d: Dictionary) -> void: + var f := FileAccess.open("%s/res_%d.json" % [dir, n], FileAccess.WRITE) + f.store_string(JSON.stringify(d)) + f.close() + +static func _v(p: Vector3) -> Array: + return [p.x, p.y, p.z] + +static func _p(a: Array) -> Vector3: + return Vector3(a[0], a[1], a[2]) + +func _do(cmd: Dictionary) -> Dictionary: + var WN: Node = root.get_node("WorldNet") + match String(cmd.get("do", "")): + "walk": + await walk(player, _p(cmd.to), world) + return {"pos": _v(player.global_position), "corrections": corrections} + "report": + return report(world, WN, cmd, corrections) + "take": + var pk: Node3D = _pickup(world, WN, String(cmd.key)) + var ok: bool = pk != null and root.get_node("Actions").pickup(player, pk) + await create_timer(0.6).timeout + return {"asked": ok, "inv": player.inventory.to_data()} + "chest_take": + var c: Node3D = WN.pieces.get(int(cmd.nid)) + if c == null: + return {"error": "no chest %d" % int(cmd.nid)} + root.get_node("Actions").transfer(player, c.inventory, int(cmd.slot), player.inventory, c) + await create_timer(0.6).timeout + return {"inv": player.inventory.to_data()} + "bump": + # Walk into whatever's in front, with real collisions. + var start := player.global_position + var d: Vector3 = _p(cmd.dir).normalized() + for _i in range(int(float(cmd.dist) / 0.1)): + player.move_and_collide(d * 0.1) + await physics_frame + await create_timer(0.3).timeout + return {"moved": Vector2(player.global_position.x - start.x, player.global_position.z - start.z).length()} + "hop": + # Cheat: step straight through to the other side. + var c0 := corrections + player.global_position = _p(cmd.to) + await create_timer(1.0).timeout + return {"corrected": corrections - c0, "pos": _v(player.global_position)} + "leave": + net.leave() + await create_timer(0.3).timeout + return {"left": true} + return {"error": "unknown command"} + +# Shared with test_net_shared.gd (it loads this script for these). + +## Walks (by setting positions, like the game sending its moves) at 5 m/s. +static func walk(p: CharacterBody3D, to: Vector3, w: Node3D) -> void: + var tree := p.get_tree() + var from := p.global_position + var dist := Vector2(to.x - from.x, to.z - from.z).length() + var steps := maxi(1, int(dist / (5.0 / 60.0))) + for i in range(1, steps + 1): + var q := from.lerp(to, float(i) / steps) + q.y = w.terrain.sample_height(q.x, q.z) + 0.05 + p.global_position = q + await tree.physics_frame + await tree.create_timer(0.4).timeout # let the server hear about it + +static func _pickup(w: Node3D, WN: Node, key: String) -> Node3D: + if key.begins_with("#"): + return WN.dropped.get(int(key.substr(1))) + return w.props.pickups.get(key) + +static func report(w: Node3D, WN: Node, cmd: Dictionary, corr: int) -> Dictionary: + var out := {"nodes": {}, "pieces": {}, "taken": {}, "inv": w.player.inventory.to_data(), + "received": WN.received, "corrections": corr, "dropped": []} + for uid in cmd.get("uids", []): + var n: Node3D = w.props.resource_nodes.get(uid) + out.nodes[uid] = {"hp": n.hp, "depleted": n.depleted} if n != null else null + for key in cmd.get("keys", []): + var pk: Node3D = _pickup(w, WN, key) + out.taken[key] = pk == null or not is_instance_valid(pk) or pk.taken + for nid in WN.pieces: + var p: Node3D = WN.pieces[nid] + if p == null or not is_instance_valid(p) or p.is_queued_for_deletion(): + continue + var e := {"item": p.item_id, "pos": _v(p.global_position)} + if p.get("inventory") != null: + e.inv = p.inventory.to_data() + if p.get("open") != null: + e.open = p.open + if p.get("fuel") != null: + e.fuel = p.fuel + out.pieces[str(nid)] = e + for nid in WN.dropped: + var d: Node3D = WN.dropped[nid] + if d != null and is_instance_valid(d) and not d.taken: + out.dropped.append({"nid": nid, "item": d.item_id, "count": d.amount}) + return out diff --git a/tests/run_all.sh b/tests/run_all.sh index 229ac14..397c600 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_den_sites test_fishing_diet test_net_lobby test_net_world 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_den_sites test_fishing_diet test_net_lobby test_net_world test_net_shared 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_net_lobby.gd b/tests/test_net_lobby.gd index 96c2458..eea3bd7 100644 --- a/tests/test_net_lobby.gd +++ b/tests/test_net_lobby.gd @@ -207,10 +207,13 @@ func _result(path: String) -> Dictionary: func _sleep(s: float) -> void: await create_timer(s).timeout +## Deletes a folder and everything in it (a server's folder has its world in it). func _rmdir(d: String) -> void: var da := DirAccess.open(d) if da == null: return + for sub in da.get_directories(): + _rmdir(d.path_join(sub)) for f in da.get_files(): da.remove(f) DirAccess.remove_absolute(d) diff --git a/tests/test_net_shared.gd b/tests/test_net_shared.gd new file mode 100644 index 0000000..23d7359 --- /dev/null +++ b/tests/test_net_shared.gd @@ -0,0 +1,494 @@ +extends SceneTree +## M2.3: the shared world. Two players on a dedicated server see the same +## island change: a tree one of them fells (and its regrowth), pickups and +## dropped items (first come, first served), building pieces (and walking +## into them, for real and by cheating), doors, chests, fires, dismantling, +## and the whole lot surviving a server restart. +## +## A real headless server, plus "Bob" (tests/net_world_bot.gd) as another +## process with his own world, following this test's commands. This test is +## "Alice" with the real world scene. +## Run: godot --headless --path . --script res://tests/test_net_shared.gd + +const PORT := 24346 +const DIR := "user://test_net" +const SRV := DIR + "/server5" +const BOB := DIR + "/bob_cmds" +const SEED := 777 +const Bot = preload("res://tests/net_world_bot.gd") + +var _fails := 0 +var _finished := false +var _pids: Array[int] = [] +var Net: Node +var WN: Node +var A: Node +var world: Node3D +var player: CharacterBody3D +var _cmd := 0 +var _server_pid := 0 +var _toasts: Array[String] = [] + +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: + Engine.max_fps = 60 + Net = root.get_node("Net") + WN = root.get_node("WorldNet") + A = root.get_node("Actions") + A.notify.connect(func(t): _toasts.append(t)) + for d in [SRV, BOB]: + _rm_tree(d) + DirAccess.make_dir_recursive_absolute(BOB) + _start_server() + await _sleep(4.0) + await _body() + Net.stop() + for pid in _pids: + if OS.is_process_running(pid): + OS.kill(pid) + check(_finished, "test ran to the end (no script errors)") + _rm_tree(SRV) + _rm_tree(BOB) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _start_server() -> void: + # --debug-regrow: things grow back in seconds instead of half an hour. + _server_pid = _spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json", + "--server-name", "Shared Test", "--seed", str(SEED), "--debug-regrow", "0.002"]) + +func _body() -> void: + var me = load("res://scripts/net/identity.gd").open(DIR + "/a") + me.name = "Alice" + Net.identity = me + var r := await _join() + check(r == "", "Alice joins (%s)" % r) + if r != "": + return + await _load_world() + check(WN.received >= 1 and WN.world == world, "and gets the server's island") + + print("Bob, with his own world") + _spawn(["--identity", DIR + "/bob", "--name", "Bob", "--address", "127.0.0.1:%d" % PORT, "--dir", BOB], + "res://tests/net_world_bot.gd") + var b0 := await _bob_result(0, 30.0) + check(bool(b0.get("ok", false)), "Bob joins and loads it too (%s)" % b0.get("why", "ok")) + if not bool(b0.get("ok", false)): + return + + await _trees() + await _building() + await _containers_and_fires() + await _items_on_the_ground() + await _restart() + _finished = true + +# --- trees -------------------------------------------------------------------- + +func _trees() -> void: + print("felling a tree") + var tree := _nearest(world.props.resource_nodes.values().filter(func(n): return n.kind == "tree" and not n.depleted)) + var uid: String = tree.uid + var to_tree: Vector3 = (tree.global_position - player.global_position) + to_tree.y = 0.0 + await Bot.walk(player, tree.global_position - to_tree.normalized() * 1.6, world) + player.inventory.set_slot(0, {"id": "stone_axe", "count": 1}) + player.select(0) + var wood0: int = player.inventory.count("wood") + var hits := 0 + var t := 0.0 + while not tree.depleted and t < 20.0: + player.survival.stamina = 100.0 + if A.harvest_hit(player, tree, tree.global_position + Vector3(0, 1.0, 0)): + hits += 1 + await _sleep(0.25) + t += 0.25 + check(tree.depleted, "Alice fells it in %d hits (her world shows it when the server says so)" % hits) + await _sleep(0.3) + check(player.inventory.count("wood") > wood0, "and the server gives her the wood (%d)" % (player.inventory.count("wood") - wood0)) + var rep := await _bob({"do": "report", "uids": [uid]}) + var bn: Variant = rep.nodes.get(uid) + check(bn != null and bool(bn.depleted), "Bob sees it fall too") + + print("regrowth (sped up for the test)") + await _sleep(6.0) # (trees take 4.8 s at this speed-up) + check(tree.depleted, "it doesn't grow back with Alice standing next to it") + var walk_from := Time.get_ticks_msec() + await Bot.walk(player, player.global_position + Vector3(14, 0, 0), world) + t = 0.0 + while tree.depleted and t < 15.0: + await _sleep(0.2) + t += 0.2 + check(not tree.depleted, "once she walks off, it grows back (%.1f s after she set off)" % ((Time.get_ticks_msec() - walk_from) / 1000.0)) + rep = await _bob({"do": "report", "uids": [uid]}) + bn = rep.nodes.get(uid) + check(bn != null and not bool(bn.depleted), "for Bob as well") + +# --- building ----------------------------------------------------------------- + +var _wall: Node3D +var _door: Node3D + +func _building() -> void: + print("building a wall") + var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(0, 0, 10), 3.0) + await Bot.walk(player, site + Vector3(-3.5, 0, 0), world) + _wall = await _place("wood_wall", site, 0.0) + check(_wall != null, "Alice puts up a wall (the server agrees and tells everyone)") + if _wall == null: + return + var rep := await _bob({"do": "report"}) + var seen := _find(rep, "wood_wall") + check(not seen.is_empty() and _p(seen.pos).distance_to(_wall.global_position) < 0.01, "Bob has the same wall, in the same place") + + print("Bob walks into it") + var n: Vector3 = _wall.global_basis.z.normalized() + var base := _wall.global_position + await _bob({"do": "walk", "to": _v(base + n * 2.5)}) + var bump := await _bob({"do": "bump", "dir": _v(-n), "dist": 4.0}) + check(float(bump.get("moved", 9.0)) < 2.3, "and it stops him (moved %.2f of 4 m)" % float(bump.get("moved", 9.0))) + var hop := await _bob({"do": "hop", "to": _v(base - n * 0.6 + Vector3(0, 0.05, 0))}) + var hp: Vector3 = _p(hop.get("pos", [0, 0, 0])) + check(int(hop.get("corrected", 0)) >= 1 and (hp - base).dot(n) > 0.0, + "a game that steps him through anyway is refused by the server, and he's put back on his side") + await _dismantle() + + print("a door") + var dsite: Vector3 = world.build.debug_find_site(site + Vector3(0, 0, 8), 2.0) + await Bot.walk(player, dsite + Vector3(-2.5, 0, 0), world) + _door = await _place("wood_door", dsite, 0.0) + check(_door != null, "Alice puts up a door") + if _door == null: + return + A.toggle_door(player, _door) + await _sleep(0.6) + check(_door.open, "she opens it") + rep = await _bob({"do": "report"}) + seen = _find(rep, "wood_door") + check(not seen.is_empty() and bool(seen.get("open", false)), "and it's open for Bob") + +## Places `id` on the ground at `at` through Actions, the way the game does. +func _place(id: String, at: Vector3, yaw: float) -> Node3D: + var def: Dictionary = root.get_node("ItemDB").get_item(id) + var xf := Transform3D(Basis(Vector3.UP, yaw), Vector3(at.x, world.terrain.sample_height(at.x, at.z), at.z)) + if def.place.has("kind"): + var hit := {"position": xf.origin, "normal": Vector3.UP, "collider": world.terrain.get_node("TerrainBody")} + var res: Dictionary = world.build.placement(def, hit, yaw) + if not res.ok: + print(" (can't place %s: %s)" % [id, res.reason]) + return null + xf = res.xf + player.inventory.set_slot(5, {"id": id, "count": 1}) + var before: Array = WN.pieces.keys() + if not A.place(player, 5, xf, world): + print(" (Actions.place refused %s)" % id) + return null + check(player.inventory.get_slot(5) == null, "(%s taken from Alice's bag)" % id) + var t := 0.0 + while t < 3.0: + for k in WN.pieces: + if not k in before and WN.pieces[k].item_id == id: + return WN.pieces[k] + await _sleep(0.05) + t += 0.05 + return null + +# --- chests and fires ------------------------------------------------------------ + +var _chest: Node3D +var _fire: Node3D + +func _containers_and_fires() -> void: + print("a shared chest") + var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(6, 0, 0), 1.5) + await Bot.walk(player, site + Vector3(-2.0, 0, 0), world) + _chest = await _place("chest", site, 0.0) + check(_chest != null, "Alice places a chest") + if _chest == null: + return + player.inventory.set_slot(6, {"id": "wood", "count": 10}) + A.transfer(player, player.inventory, 6, _chest.inventory, _chest) + await _sleep(0.5) + check(player.inventory.get_slot(6) == null and _chest.inventory.count("wood") == 10, "she puts 10 wood in") + var got := {"c": "waiting"} + WN.chest_click(_chest, 3, {"id": "stone", "count": 3}, false, func(c): got.c = c) + var t := 0.0 + while got.c is String and t < 3.0: + await _sleep(0.05) + t += 0.05 + check(got.c == null and _chest.inventory.get_slot(3) != null and _chest.inventory.get_slot(3).id == "stone", + "and drops 3 stone in slot 4 by hand (the server does the click and hands back an empty cursor)") + var nid: int = WN.nid(_chest) + await _bob({"do": "walk", "to": _v(site + Vector3(1.8, 0, 0))}) + var rep := await _bob({"do": "report"}) + var seen: Dictionary = rep.pieces.get(str(nid), {}) + check(_count(seen.get("inv", []), "wood") == 10 and _count(seen.get("inv", []), "stone") == 3, "Bob sees what's in it") + var slot := -1 + for i in range(_chest.inventory.size()): + var s = _chest.inventory.get_slot(i) + if s != null and s.id == "wood": + slot = i + var took := await _bob({"do": "chest_take", "nid": nid, "slot": slot}) + check(_count(took.get("inv", []), "wood") == 10, "Bob takes the wood out (shift-click)") + await _sleep(0.3) + check(_chest.inventory.count("wood") == 0 and _chest.inventory.count("stone") == 3, "and it's gone from Alice's view of the chest") + + print("a fire") + var fsite: Vector3 = world.build.debug_find_site(site + Vector3(0, 0, -5), 1.5) + await Bot.walk(player, fsite + Vector3(-2.0, 0, 0), world) + _fire = await _place("campfire", fsite, 0.0) + check(_fire != null, "Alice lights a campfire") + if _fire == null: + return + var f0: float = _fire.fuel + player.inventory.set_slot(7, {"id": "wood", "count": 2}) + A.add_fuel(player, _fire) + await _sleep(0.5) + check(_fire.fuel > f0 + 30.0, "and feeds it a log (%.0f s -> %.0f s of fuel)" % [f0, _fire.fuel]) + rep = await _bob({"do": "report"}) + seen = rep.pieces.get(str(WN.nid(_fire)), {}) + check(absf(float(seen.get("fuel", -100.0)) - _fire.fuel) < 5.0, "Bob's fire has the same fuel (%.0f s)" % float(seen.get("fuel", -1.0))) + +# --- things on the ground -------------------------------------------------------- + +func _items_on_the_ground() -> void: + print("dropped items") + var at := player.global_position + Vector3(1.0, 0.1, 0) + world.drop_item("flint", 2, Transform3D(Basis(), at)) + var t := 0.0 + var key := "" + while key.is_empty() and t < 3.0: + for k in WN.dropped: + if WN.dropped[k].item_id == "flint": + key = "#%d" % k + await _sleep(0.05) + t += 0.05 + check(not key.is_empty(), "Alice drops 2 flint: it's in the shared world") + var rep := await _bob({"do": "report"}) + check(rep.dropped.any(func(d): return d.item == "flint" and int(d.count) == 2), "Bob sees it") + await _bob({"do": "walk", "to": _v(at + Vector3(1.0, 0, 0))}) + var took := await _bob({"do": "take", "key": key}) + check(_count(took.get("inv", []), "flint") == 2, "Bob picks it up") + await _sleep(0.3) + check(WN.dropped.is_empty() or not WN.dropped.has(int(key.substr(1))), "and it's gone for Alice") + + print("first come, first served") + var pk := _nearest(world.props.pickups.values().filter(func(p): return not p.taken)) + await Bot.walk(player, pk.global_position + Vector3(-1.0, 0, 0), world) + await _bob({"do": "walk", "to": _v(pk.global_position + Vector3(1.0, 0, 0))}) + var id: String = pk.item_id + var a0: int = player.inventory.count(id) + _bob_send({"do": "take", "key": pk.uid}) # both reach for it at once + A.pickup(player, pk) + var br := await _bob_wait(_cmd) + await _sleep(0.4) + var a_got: int = player.inventory.count(id) - a0 + var b_got: int = _count(br.get("inv", []), id) - (2 if id == "flint" else 0) + check(a_got + b_got == pk.amount and (a_got == 0 or b_got == 0), + "only one of them gets the %s (Alice %d, Bob %d)" % [id, a_got, b_got]) + + print("dying online") + player.inventory.load_data(Inventory.new(player.inventory.size()).to_data()) + player.inventory.set_slot(0, {"id": "stone", "count": 7}) + player.inventory.set_slot(1, {"id": "berries", "count": 4}) + var died_at := player.global_position + world._on_player_died() + var grave: Node3D = null + var t2 := 0.0 + while grave == null and t2 < 3.0: + for p in WN.pieces.values(): + if p.item_id == "grave": + grave = p + await _sleep(0.05) + t2 += 0.05 + check(grave != null and grave.global_position.distance_to(died_at) < 3.0 and player.inventory.count("stone") == 0, + "Alice dies: her things go into a grave in the shared world") + if grave == null: + return + var gnid: int = WN.nid(grave) + var rep2 := await _bob({"do": "report"}) + var g: Dictionary = rep2.pieces.get(str(gnid), {}) + check(_count(g.get("inv", []), "stone") == 7 and _count(g.get("inv", []), "berries") == 4, "Bob sees the grave, with what she had") + await Bot.walk(player, grave.global_position + Vector3(1.5, 0, 0), world) + for i in range(grave.inventory.size()): + if is_instance_valid(grave) and grave.inventory.get_slot(i) != null: + A.transfer(player, grave.inventory, i, player.inventory, grave) + await _sleep(0.15) + await _sleep(0.5) + check(player.inventory.count("stone") == 7 and player.inventory.count("berries") == 4, "she takes it all back") + check(not WN.pieces.has(gnid), "and the empty grave crumbles") + rep2 = await _bob({"do": "report"}) + check(not rep2.pieces.has(str(gnid)), "for Bob too") + +# --- dismantling --------------------------------------------------------------- + +func _dismantle() -> void: + if _wall == null: + return + print("taking the wall down") + # (Straight after building it, while she's still beside it: a test walk + # back from somewhere else can cut through the door frame, and the + # server rightly sends her back.) + var nid: int = WN.nid(_wall) + var w0: int = player.inventory.count("wood_wall") + _toasts.clear() + var ok: bool = A.dismantle(player, _wall) + check(ok, "Alice dismantles the wall (%.1f m away%s)" % [player.global_position.distance_to(_wall.global_position), + "" if ok else ", refused: %s" % [_toasts]]) + await _sleep(0.5) + check(not WN.pieces.has(nid) and player.inventory.count("wood_wall") == w0 + 1, "it's gone, and back in her bag") + var rep := await _bob({"do": "report"}) + check(not rep.pieces.has(str(nid)), "gone for Bob too") + +# --- persistence ------------------------------------------------------------------- + +func _restart() -> void: + print("the server restarts") + var expect := {} + for k in WN.pieces: + expect[k] = WN.pieces[k].item_id + var chest_nid: int = WN.nid(_chest) if _chest != null else -1 + # Killed outright while everyone's still on (like Ctrl+C, which gives + # Godot no chance to save): the server saved within a few seconds of the + # last change anyway. + await _sleep(WN.SAVE_AFTER + 1.5) + check(FileAccess.file_exists(SRV + "/worlds/server-world/world.json"), "the server saved the island soon after the last change") + OS.kill(_server_pid) + _pids.erase(_server_pid) + await _bob({"do": "leave"}) + world.queue_free() + await process_frame + Net.leave() + await _sleep(1.0) + _start_server() + await _sleep(4.0) + var r := await _join() + check(r == "", "Alice rejoins (%s)" % r) + if r != "": + return + await _load_world() + var same: bool = expect.size() == WN.pieces.size() + for k in expect: + same = same and WN.pieces.has(k) and WN.pieces[k].item_id == expect[k] + check(same, "everything built is still there, with the same ids (%d pieces)" % WN.pieces.size()) + var c: Node3D = WN.pieces.get(chest_nid) + check(c != null and c.inventory.count("stone") == 3 and c.inventory.count("wood") == 0, "the chest still has the 3 stone") + var d: Node3D = null + for p in WN.pieces.values(): + if p.item_id == "wood_door": + d = p + check(d != null and d.open, "and the door is still open") + world.queue_free() + await process_frame + Net.leave() + +# --- helpers -------------------------------------------------------------------- + +func _load_world() -> void: + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(8): + await physics_frame + player = world.player + player.set_physics_process(false) # the test moves Alice + var t := 0.0 + while WN.received < 1 and t < 10.0: + await _sleep(0.05) + t += 0.05 + +func _nearest(nodes: Array) -> Node3D: + var best: Node3D = null + for n in nodes: + if best == null or n.global_position.distance_to(player.global_position) < best.global_position.distance_to(player.global_position): + best = n + return best + +func _find(rep: Dictionary, item: String) -> Dictionary: + for k in rep.get("pieces", {}): + if rep.pieces[k].item == item: + return rep.pieces[k] + return {} + +static func _count(inv: Array, id: String) -> int: + var n := 0 + for s in inv: + if s != null and s.id == id: + n += int(s.count) + return n + +static func _v(p: Vector3) -> Array: + return [p.x, p.y, p.z] + +static func _p(a: Array) -> Vector3: + return Vector3(a[0], a[1], a[2]) + +func _bob_send(cmd: Dictionary) -> void: + _cmd += 1 + var f := FileAccess.open("%s/cmd_%d.json" % [BOB, _cmd], FileAccess.WRITE) + f.store_string(JSON.stringify(cmd)) + f.close() + +func _bob(cmd: Dictionary) -> Dictionary: + _bob_send(cmd) + return await _bob_wait(_cmd) + +func _bob_wait(n: int) -> Dictionary: + return await _bob_result(n, 20.0) + +func _bob_result(n: int, limit: float) -> Dictionary: + var path := "%s/res_%d.json" % [BOB, n] + var t := 0.0 + while not FileAccess.file_exists(path) and t < limit: + await _sleep(0.05) + t += 0.05 + await _sleep(0.05) + var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null + return d if d is Dictionary else {"error": "Bob gave no answer to command %d" % n, "nodes": {}, "pieces": {}, "dropped": []} + +func _join() -> String: + var state := {"done": false, "reason": ""} + var ok := func(): state.done = true + var bad := func(why): state.done = true; state.reason = why + Net.joined.connect(ok) + Net.join_failed.connect(bad) + Net.join("127.0.0.1:%d" % PORT) + var t := 0.0 + while not state.done and t < 25.0: + await _sleep(0.05) + t += 0.05 + Net.joined.disconnect(ok) + Net.join_failed.disconnect(bad) + return state.reason if state.done else "test timed out" + +func _spawn(user_args: Array, script: String = "") -> int: + var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://")]) + if not script.is_empty(): + args.append_array(["--script", script]) + args.append("--") + for x in user_args: + args.append(str(x)) + args.append_array(["--exit-with", str(OS.get_process_id())]) + var pid := OS.create_process(OS.get_executable_path(), args) + _pids.append(pid) + return pid + +func _sleep(s: float) -> void: + await create_timer(s).timeout + +## Deletes a folder and everything in it. +static func _rm_tree(d: String) -> void: + var da := DirAccess.open(d) + if da == null: + return + for sub in da.get_directories(): + _rm_tree(d.path_join(sub)) + for f in da.get_files(): + da.remove(f) + DirAccess.remove_absolute(d) diff --git a/tests/test_net_world.gd b/tests/test_net_world.gd index 1208f9f..87ba45e 100644 --- a/tests/test_net_world.gd +++ b/tests/test_net_world.gd @@ -252,10 +252,13 @@ func _result(path: String) -> Dictionary: func _sleep(s: float) -> void: await create_timer(s).timeout +## Deletes a folder and everything in it (a server's folder has its world in it). func _rmdir(d: String) -> void: var da := DirAccess.open(d) if da == null: return + for sub in da.get_directories(): + _rmdir(d.path_join(sub)) for f in da.get_files(): da.remove(f) DirAccess.remove_absolute(d)