M2.3: the shared world (trees, pickups, pieces, chests, fires, doors, graves, clock on the server)

This commit is contained in:
Paul Bailey
2026-11-04 18:12:49 +00:00
parent 74cbf96458
commit 0c18b6d49e
17 changed files with 1672 additions and 94 deletions

View File

@@ -18,10 +18,10 @@ concrete decisions for this codebase, as it stands after M1.
## 2. Running a server ## 2. Running a server
*Built in M2.1* (`scripts/net/net.gd`, `server_config.gd`, `identity.gd`). *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 The server accepts players, checks who they are and keeps the player list.
player list. The world itself isn't served yet (M2.3). Flags: `--server`, Since M2.3 it also runs the shared island (§5). Flags: `--server`,
`--port`, `--config`, `--server-name`, `--max-players`, `--password`, `--port`, `--config`, `--server-name`, `--max-players`, `--password`,
`--world`, and `--exit-with <pid>` (quit when that process exits; the tests `--world`, `--seed`, and `--exit-with <pid>` (quit when that process exits; the tests
use it so a crashed test never leaves a server running). Flags apply to use it so a crashed test never leaves a server running). Flags apply to
that run only and are never written to `server.json`. that run only and are never written to `server.json`.
```bash ```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 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. 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. 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 Since M2.3 the server also refuses a move whose straight line crosses a
doesn't know about buildings yet. 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 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), 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 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. it ends within a centimetre of where the walker stopped.
Until later phases, while online: animals are off (M2.5), and building, **The shared world** *(built in M2.3: `world_net.gd`, autoload WorldNet)*.
gathering and other world changes are local only and aren't saved (M2.3, The dedicated server runs the real world scene headless with nobody of its
M2.4). The HUD says so. Respawning ignores beds. own in it (`world.gd` `server_mode`), and saves it like a single-player
world, in `worlds/<world>/` 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 **Interest.** On a 320 m worldlet with 8 players, the server sends every
entity to everyone at first. Distance-based interest is a later entity to everyone at first. Distance-based interest is a later
@@ -208,6 +238,12 @@ worlds/<world>/
friends, name, appearance friends, name, appearance
backups/<timestamp>/ rolling copies (backups_kept) backups/<timestamp>/ rolling copies (backups_kept)
``` ```
*As of M2.3:* `worlds/<world>/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 - 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 migrated on first load. That block becomes the local player's file. The
test suite keeps an old-format save fixture to prove it. 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.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.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.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.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.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 | | **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 - **Save tests:** world and player file round trips, migration of old
saves, backups. saves, backups.
- The existing 9 suites keep running in single-player (loopback) mode. - 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 <k>` server flag speeds regrowth up for it.
## 11. Risks ## 11. Risks
- **Prediction feel** is the hard part. It gets an early phase (M2.2) so - **Prediction feel** is the hard part. It gets an early phase (M2.2) so

View File

@@ -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 UDP 24242 with no window. Settings live in `server.json` (its path is printed
at start-up; on macOS it's under at start-up; on macOS it's under
`~/Library/Application Support/Godot/app_userdata/Emberwild/server/`). `~/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 Tailscale, use the server machine's 100.x address or its machine name. See
`docs/MULTIPLAYER_PLAN.md` for all the options. `docs/MULTIPLAYER_PLAN.md` for all the options.

View File

@@ -27,6 +27,7 @@ ItemDB="*res://scripts/core/item_db.gd"
Actions="*res://scripts/core/actions.gd" Actions="*res://scripts/core/actions.gd"
Saves="*res://scripts/core/saves.gd" Saves="*res://scripts/core/saves.gd"
Net="*res://scripts/net/net.gd" Net="*res://scripts/net/net.gd"
WorldNet="*res://scripts/net/world_net.gd"
[display] [display]

View File

@@ -17,6 +17,8 @@ func interact(player: Node) -> void:
player.open_inventory(self) player.open_inventory(self)
func _on_changed() -> void: func _on_changed() -> void:
if WorldNet.world != null:
return # online, the server crumbles it (world_net.gd)
for s in inventory.slots: for s in inventory.slots:
if s != null: if s != null:
return return

View File

@@ -53,6 +53,12 @@ func _give(player: Node, id: String, n: int) -> int:
notify.emit("Inventory full") notify.emit("Inventory full")
return got 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: 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 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): if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node):
return false return false
var id: String = pickup_node.item_id 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: if _give(player, id, pickup_node.amount) <= 0:
return false return false
pickup_node.collected() 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): if not player.survival.use_stamina(6.0):
notify.emit("Too tired") notify.emit("Too tired")
return false 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) var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position)
for id in drops: for id in drops:
_give(player, id, int(drops[id])) _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") notify.emit("Can't place that here")
return false return false
player.inventory.take_from(slot, 1) 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) world.spawn_piece(def, xf)
return true return true
@@ -165,6 +183,9 @@ func dismantle(player: Node3D, piece: Node3D) -> bool:
if player.inventory.space_for(piece.item_id) < 1: if player.inventory.space_for(piece.item_id) < 1:
notify.emit("Inventory full") notify.emit("Inventory full")
return false return false
if _online():
WorldNet.dismantle(piece)
return true
_give(player, piece.item_id, 1) _give(player, piece.item_id, 1)
piece.get_parent().get_parent().remove_piece(piece, player) piece.get_parent().get_parent().remove_piece(piece, player)
return true return true
@@ -172,6 +193,9 @@ func dismantle(player: Node3D, piece: Node3D) -> bool:
func toggle_door(player: Node3D, door: Node3D) -> bool: func toggle_door(player: Node3D, door: Node3D) -> bool:
if not is_instance_valid(door) or not _in_reach(player, door, 1.0): if not is_instance_valid(door) or not _in_reach(player, door, 1.0):
return false return false
if _online():
WorldNet.door(door)
return true
door.set_open(not door.open) door.set_open(not door.open)
return true return true
@@ -353,6 +377,9 @@ func take_off(player: Node3D) -> bool:
func forage(player: Node3D, bush: 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(): if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
return false return false
if _online():
WorldNet.forage(bush)
return true
var got: Dictionary = bush.harvest() var got: Dictionary = bush.harvest()
for id in got: for id in got:
_give(player, id, int(got[id])) _give(player, id, int(got[id]))
@@ -385,6 +412,9 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool:
for id in ["wood", "stick", "fiber"]: for id in ["wood", "stick", "fiber"]:
if player.inventory.count(id) > 0: if player.inventory.count(id) > 0:
player.inventory.remove(id, 1) player.inventory.remove(id, 1)
if _online():
WorldNet.fuel(fire, id)
else:
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10))) fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower()) notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower())
return true return true
@@ -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: 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): if container != null and not _in_reach(player, container, 1.0):
return 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) from.quick_move(slot, to)

View File

@@ -58,6 +58,22 @@ func create_world(world_name: String, seed: int) -> String:
}) })
return id 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: func delete_world(id: String) -> void:
var dir := _dir(id) var dir := _dir(id)
var d := DirAccess.open(dir) var d := DirAccess.open(dir)

View File

@@ -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 Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.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 DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0 const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join 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 var debug_jitter_ms := 0.0
# Server state. # 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 _spawn := Vector3.ZERO
var _states: Dictionary = {} ## peer -> {"pos", "yaw", "pitch", "anim", "held", "t", "corr_t"} var _states: Dictionary = {} ## peer -> {"pos", "yaw", "pitch", "anim", "held", "t", "corr_t"}
var server_time := 0.0 ## seconds since the server started 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 _send_left := 0.0
var _clock_left := 0.0 var _clock_left := 0.0
# Client delay queues (debug latency): [release_ms, callable]. # 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): if overrides.has(flag):
var v: Variant = overrides[flag] var v: Variant = overrides[flag]
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v) 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. # The shared island (world_net.gd): the same terrain and scenery every
_terrain = load("res://scripts/world/terrain.gd").new() # client generates from the seed, plus everything players changed.
_terrain.name = "ServerTerrain" _world = WorldNet.open_server_world(config)
_terrain.noise_seed = int(config.v("seed")) _terrain = _world.terrain
add_child(_terrain)
_spawn = _terrain.find_spawn(Vector2.ZERO) _spawn = _terrain.find_spawn(Vector2.ZERO)
server_time = 0.0 server_time = 0.0
_clock_day = 1
_clock_time = 8.0 / 24.0
_peer = ENetMultiplayerPeer.new() _peer = ENetMultiplayerPeer.new()
var port := int(config.v("port")) var port := int(config.v("port"))
# A few spare slots so a full server can still say "server is full". # 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) var err := _peer.create_server(port, int(config.v("max_players")) + 4)
if err != OK: 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] return "Couldn't open UDP port %d (error %d). Is another server already running on it?" % [port, err]
multiplayer.multiplayer_peer = _peer multiplayer.multiplayer_peer = _peer
multiplayer.server_relay = false # clients only ever talk to the server multiplayer.server_relay = false # clients only ever talk to the server
@@ -218,8 +217,9 @@ func stop() -> void:
_out_q.clear() _out_q.clear()
_in_q.clear() _in_q.clear()
local_peer = 0 local_peer = 0
if _terrain != null: if _world != null:
_terrain.queue_free() WorldNet.close_server_world() # saves it first
_world = null
_terrain = null _terrain = null
if state != "offline": if state != "offline":
_set_state("offline") _set_state("offline")
@@ -238,6 +238,7 @@ func _on_peer_disconnected(id: int) -> void:
players.erase(id) players.erase(id)
print("[server] %s left (%d online)" % [p.name, players.size()]) print("[server] %s left (%d online)" % [p.name, players.size()])
_s2c_player_left.rpc(id) _s2c_player_left.rpc(id)
WorldNet.peer_left(id)
players_changed.emit() players_changed.emit()
func _process(delta: float) -> void: func _process(delta: float) -> void:
@@ -260,20 +261,19 @@ func _process(delta: float) -> void:
_run_queue(_out_q) _run_queue(_out_q)
_run_queue(_in_q) _run_queue(_in_q)
## The world clock runs while anyone's on (or always, if the server's set ## The world's clock (it runs while anyone's on, or always if the server's
## not to pause when empty). ## set not to pause when empty: world_net.gd pauses the world). Players get
## it every few seconds.
func _tick_clock(delta: float) -> void: 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 _clock_left -= delta
if _clock_left <= 0.0: if _clock_left <= 0.0 and not players.is_empty():
_clock_left = CLOCK_INTERVAL _clock_left = CLOCK_INTERVAL
for peer in players: 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: func _broadcast_snapshot() -> void:
if _states.is_empty(): if _states.is_empty():
@@ -343,8 +343,36 @@ func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> St
return "outside the island" return "outside the island"
if to.y < _terrain.sample_height(to.x, to.z) - 1.5: if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
return "underground" return "underground"
if not teleported and _through_wall(from, to):
return "through a wall"
return "" 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: func _reject(peer: int, reason: String) -> void:
_pending.erase(peer) _pending.erase(peer)
print("[server] rejected peer %d: %s" % [peer, reason]) print("[server] rejected peer %d: %s" % [peer, reason])
@@ -400,8 +428,9 @@ func _c2s_proof(sig: PackedByteArray) -> void:
"stamp": server_time, "off": INF} "stamp": server_time, "off": INF}
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "", print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
players.size(), int(config.v("max_players"))]) players.size(), int(config.v("max_players"))])
var world := {"seed": int(config.v("seed")), "worldlet": String(config.v("worldlet")), "spawn": start, # (The world's own seed: an existing world keeps the seed it was made with.)
"island_spawn": _spawn, "day": _clock_day, "time": _clock_time, "server_time": server_time} 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}) _s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
for other in players: for other in players:
if other != peer: if other != peer:

626
scripts/net/world_net.gd Normal file
View File

@@ -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/<world>/.
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 "#<net id>".
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)

View File

@@ -144,7 +144,11 @@ func open(chest: Node) -> void:
func close() -> void: func close() -> void:
if _cursor != null: if _cursor != null:
var left: int = player.inventory.add(_cursor.id, _cursor.count) var left: int = player.inventory.add(_cursor.id, _cursor.count)
if left > 0 and container != null: 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) left = container.inventory.add(_cursor.id, left)
_cursor = null _cursor = null
if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh): if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh):
@@ -201,10 +205,28 @@ func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
return return
if shift: if shift:
Actions.transfer(player, container.inventory, i, player.inventory, container) 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: else:
_cursor = container.inventory.click(i, _cursor, right) _cursor = container.inventory.click(i, _cursor, right)
_refresh() _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: func _refresh() -> void:
if player == null: if player == null:
return return

View File

@@ -45,6 +45,12 @@ var forage: Dictionary = {} ## uid -> berry bush (forage.gd)
## Regrowth: uid -> seconds until a felled tree, mined boulder or picked-up ## Regrowth: uid -> seconds until a felled tree, mined boulder or picked-up
## item comes back. Saved with the world. Bushes keep their own timers. ## item comes back. Saved with the world. Bushes keep their own timers.
var regrow: Dictionary = {} 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 <k>
signal regrown(uid: String)
const REGROW_SECONDS := {"tree": 2400.0, "rock": 3600.0, "pickup": 900.0} 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 const REGROW_RETRY := 120.0 ## blocked (player or a building nearby): try again later
var _regrow_tick := 0.0 var _regrow_tick := 0.0
@@ -125,15 +131,15 @@ func _regrow_kind(uid: String) -> String:
return resource_nodes[uid].kind return resource_nodes[uid].kind
func _on_depleted(node: Node) -> void: 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: 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 ## Advances regrowth timers by `secs` (every second while playing, or in one
## go when you sleep). ## go when you sleep).
func skip_time(secs: float) -> void: func skip_time(secs: float) -> void:
for uid in regrow.keys(): for uid in regrow.keys() if regrow_enabled else []:
regrow[uid] -= secs regrow[uid] -= secs
if regrow[uid] <= 0.0: if regrow[uid] <= 0.0:
_try_regrow(uid) _try_regrow(uid)
@@ -148,12 +154,15 @@ func _try_regrow(uid: String) -> void:
regrow.erase(uid) regrow.erase(uid)
return return
var r := 0.5 if pickups.has(uid) else 1.4 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 \ var blocked := _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0
or _built_over(node.global_position, r): for b in blockers:
regrow[uid] = REGROW_RETRY 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 return
regrow.erase(uid) regrow.erase(uid)
node.regrow() node.regrow()
regrown.emit(uid)
## Something built (or being built) where a tree or rock would come back. ## Something built (or being built) where a tree or rock would come back.
func _built_over(pos: Vector3, r: float) -> bool: func _built_over(pos: Vector3, r: float) -> bool:
@@ -179,10 +188,10 @@ func restore_regrow(saved: Dictionary) -> void:
regrow[uid] = float(saved[uid]) regrow[uid] = float(saved[uid])
for uid in resource_nodes: for uid in resource_nodes:
if resource_nodes[uid].depleted and not regrow.has(uid): 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: for uid in pickups:
if pickups[uid].taken and not regrow.has(uid): if pickups[uid].taken and not regrow.has(uid):
regrow[uid] = REGROW_SECONDS["pickup"] regrow[uid] = REGROW_SECONDS["pickup"] * regrow_scale
# --- materials -------------------------------------------------------------- # --- materials --------------------------------------------------------------

View File

@@ -56,15 +56,26 @@ func interact_prompt(player: Node) -> String:
## Applies one hit. Returns the items earned by this hit. ## Applies one hit. Returns the items earned by this hit.
func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary: func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
var out := {} var out := {}
hp -= damage
for id in drops_per_hit: for id in drops_per_hit:
out[id] = drops_per_hit[id] out[id] = drops_per_hit[id]
_burst(point, chip_color, 14) if hp - damage <= 0.0:
if hp <= 0.0:
depleted = true
for id in drops_final: for id in drops_final:
var r: Array = drops_final[id] var r: Array = drops_final[id]
out[id] = out.get(id, 0) + rng.randi_range(int(r[0]), int(r[1])) 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) _deplete(from)
else: else:
_shake = 1.0 _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 _shake_axis = away.normalized().cross(Vector3.UP) if away.length() > 0.01 else Vector3.RIGHT
if kind == "rock": if kind == "rock":
_set_instance(_rock_xf()) _set_instance(_rock_xf())
return out
func _process(delta: float) -> void: func _process(delta: float) -> void:
if _shake <= 0.0 or depleted: if _shake <= 0.0 or depleted:

View File

@@ -33,6 +33,12 @@ const AUTOSAVE_SECONDS := 120.0
## Set by the title screen before adding the world: no player, no HUD, no ## 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. ## saving, just a slow camera flight over the island as a menu backdrop.
var menu_mode := false 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 := Vector3.ZERO
var _spawn_yaw := 0.0 var _spawn_yaw := 0.0
@@ -61,11 +67,15 @@ func is_online() -> bool:
var _loading := false ## applying a save: defer support recompute var _loading := false ## applying a save: defer support recompute
func _ready() -> void: func _ready() -> void:
_autosave_left = autosave_seconds
_pieces = Node3D.new() _pieces = Node3D.new()
_pieces.name = "Pieces" _pieces.name = "Pieces"
add_child(_pieces) add_child(_pieces)
day_night = preload("res://scripts/world/day_night.gd").new() day_night = preload("res://scripts/world/day_night.gd").new()
day_night.name = "DayNight" 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) add_child(day_night)
day_night.setup(sun, $WorldEnvironment.environment) day_night.setup(sun, $WorldEnvironment.environment)
weather = preload("res://scripts/world/weather.gd").new() weather = preload("res://scripts/world/weather.gd").new()
@@ -86,7 +96,15 @@ func _ready() -> void:
creatures.name = "Creatures" creatures.name = "Creatures"
creatures.enabled = false # until the scenery is placed creatures.enabled = false # until the scenery is placed
add_child(creatures) 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 = preload("res://scripts/world/weather_fx.gd").new()
weather_fx.name = "WeatherFX" weather_fx.name = "WeatherFX"
add_child(weather_fx) add_child(weather_fx)
@@ -101,13 +119,13 @@ func _ready() -> void:
add_child(_menu_cam) add_child(_menu_cam)
_menu_cam.current = true _menu_cam.current = true
$HUD.visible = false $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() hud = preload("res://scripts/ui/hud.gd").new()
add_child(hud) add_child(hud)
hud.bind(player) hud.bind(player)
if not menu_mode: if not menu_mode and not server_mode:
player.survival.died.connect(_on_player_died) 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. # Save on window close instead of quitting straight away.
get_tree().auto_accept_quit = false get_tree().auto_accept_quit = false
player.set_physics_process(false) player.set_physics_process(false)
@@ -158,7 +176,11 @@ func _on_player_died() -> void:
if st != null: if st != null:
has_items = true has_items = true
if has_items: if has_items:
var grave := spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, player.rotation.y), pos)) 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()) grave.inventory.load_data(player.inventory.to_data())
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data()) player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
if hud != null: if hud != null:
@@ -197,6 +219,8 @@ func _respawn_at_bed(bed: Node3D) -> void:
## Why you can't sleep in `bed` right now ("" = you can). ## Why you can't sleep in `bed` right now ("" = you can).
func sleep_block_reason(bed: Node3D) -> String: 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() var h: float = day_night.hours()
if h > 5.5 and h < 19.5: if h > 5.5 and h < 19.5:
return "You can only sleep at night (after 19:30)" 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)) print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
if menu_mode: if menu_mode:
return 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() _respawn()
if Saves.has_world(): if Saves.has_world():
var state: Dictionary = Saves.load_state(Saves.current_id) 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 ## 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). ## session isn't tied to a slot (e.g. running world.tscn directly).
func save_now() -> bool: func save_now() -> bool:
_autosave_left = AUTOSAVE_SECONDS _autosave_left = autosave_seconds
if not Saves.has_world(): if not Saves.has_world():
return false return false
var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time) var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time)
if ok: if ok:
_session_time = 0.0 _session_time = 0.0
if server_mode:
if not ok:
printerr("[server] saving the world FAILED")
else:
Actions.notify.emit("Game saved" if ok else "Save FAILED") Actions.notify.emit("Game saved" if ok else "Save FAILED")
return ok 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: func _notification(what: int) -> void:
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode: if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
return return
@@ -349,20 +389,13 @@ func collect_save() -> Dictionary:
if not is_instance_valid(pk) or pk.taken: if not is_instance_valid(pk) or pk.taken:
taken.append(uid) taken.append(uid)
var dropped: Array = [] var dropped: Array = []
for d in _dropped.get_children(): for d in all_dropped():
if d.is_queued_for_deletion() or d.taken: dropped.append(dropped_entry(d))
continue
dropped.append({"item": d.item_id, "count": d.amount, "xf": _xf_to_array(d.global_transform)})
var pieces: Array = [] var pieces: Array = []
for piece in _pieces.get_children(): for piece in all_pieces():
if piece.is_queued_for_deletion(): pieces.append(piece_entry(piece))
continue if server_mode:
var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)} return _shared_state(harvested, regrow, taken, dropped, pieces)
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)
return { return {
"player": { "player": {
"pos": [player.global_position.x, player.global_position.y, player.global_position.z], "pos": [player.global_position.x, player.global_position.y, player.global_position.z],
@@ -383,12 +416,98 @@ func collect_save() -> Dictionary:
"pieces": pieces, "pieces": pieces,
} }
func apply_save(s: Dictionary) -> void: func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, dropped: Array, pieces: Array) -> Dictionary:
var p: Dictionary = s.get("player", {}) 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"): if p.has("pos"):
player.global_position = Vector3(p.pos[0], p.pos[1] + 0.05, p.pos[2]) 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.rotation = Vector3(0.0, float(p.get("yaw", 0.0)), 0.0)
player.get_node("Camera3D").rotation.x = float(p.get("pitch", 0.0)) player.get_node("Camera3D").rotation.x = float(p.get("pitch", 0.0))
if with_player:
if p.has("inventory"): if p.has("inventory"):
player.inventory.load_data(p.inventory) player.inventory.load_data(p.inventory)
player.select(int(p.get("selected", 0))) player.select(int(p.get("selected", 0)))
@@ -397,8 +516,7 @@ func apply_save(s: Dictionary) -> void:
player.worn = String(p.get("worn", "")) player.worn = String(p.get("worn", ""))
player.worn_changed.emit() player.worn_changed.emit()
for d in s.get("dropped", []): for d in s.get("dropped", []):
if not ItemDB.get_item(d.get("item", "")).is_empty(): add_dropped_entry(d)
drop_item(d.item, int(d.get("count", 1)), _array_to_xf(d.xf))
var t: Dictionary = s.get("time", {}) var t: Dictionary = s.get("time", {})
if not t.is_empty(): if not t.is_empty():
day_night.day = int(t.get("day", 1)) day_night.day = int(t.get("day", 1))
@@ -424,14 +542,7 @@ func apply_save(s: Dictionary) -> void:
weather.snap() weather.snap()
_loading = true _loading = true
for e in s.get("pieces", []): for e in s.get("pieces", []):
var def: Dictionary = ItemDB.get_item(e.get("item", "")) add_piece_entry(e)
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)
_loading = false _loading = false
build.recompute() # same terrain, same pieces: nothing collapses on load build.recompute() # same terrain, same pieces: nothing collapses on load
_update_grass_cuts() _update_grass_cuts()
@@ -531,14 +642,18 @@ func _go_online() -> void:
net_sync.setup(self) net_sync.setup(self)
Net.left.connect(_on_net_left) Net.left.connect(_on_net_left)
get_tree().auto_accept_quit = false # leave the server cleanly on window close 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: 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) % Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM)
bar.position = Vector2(16, 40) bar.position = Vector2(16, 40)
$HUD.add_child(bar) $HUD.add_child(bar)
Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server")) Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server"))
func _on_net_left(_reason: String) -> void: func _on_net_left(_reason: String) -> void:
WorldNet.leave_world()
if is_inside_tree(): if is_inside_tree():
get_tree().paused = false get_tree().paused = false
get_tree().auto_accept_quit = true 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 ## 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. ## 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: 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() var pk: Area3D = preload("res://scripts/world/pickup.gd").new()
pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3) pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3)
pk.amount = count pk.amount = count

159
tests/net_world_bot.gd Normal file
View File

@@ -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):
## <dir>/cmd_<n>.json -> runs it -> <dir>/res_<n>.json
## Commands: walk, report, take, chest_take, bump, hop, leave.
## Args (after --): --identity <dir> --name <name> --address host:port --dir <cmd 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

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..." echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1 godot --headless --path . --import >/dev/null 2>&1
fi 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="" extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever, # A suite that waits on a world that failed to load would hang forever,

View File

@@ -207,10 +207,13 @@ func _result(path: String) -> Dictionary:
func _sleep(s: float) -> void: func _sleep(s: float) -> void:
await create_timer(s).timeout 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: func _rmdir(d: String) -> void:
var da := DirAccess.open(d) var da := DirAccess.open(d)
if da == null: if da == null:
return return
for sub in da.get_directories():
_rmdir(d.path_join(sub))
for f in da.get_files(): for f in da.get_files():
da.remove(f) da.remove(f)
DirAccess.remove_absolute(d) DirAccess.remove_absolute(d)

494
tests/test_net_shared.gd Normal file
View File

@@ -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)

View File

@@ -252,10 +252,13 @@ func _result(path: String) -> Dictionary:
func _sleep(s: float) -> void: func _sleep(s: float) -> void:
await create_timer(s).timeout 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: func _rmdir(d: String) -> void:
var da := DirAccess.open(d) var da := DirAccess.open(d)
if da == null: if da == null:
return return
for sub in da.get_directories():
_rmdir(d.path_join(sub))
for f in da.get_files(): for f in da.get_files():
da.remove(f) da.remove(f)
DirAccess.remove_absolute(d) DirAccess.remove_absolute(d)