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

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