M2.4: the server decides online (characters, inventory, stats, death, owned graves, per-player saves); death screen with Respawn after 3 s

This commit is contained in:
Paul Bailey
2026-11-04 19:18:31 +00:00
parent 8e8f10eb96
commit 35a656e996
16 changed files with 1124 additions and 477 deletions

View File

@@ -5,6 +5,10 @@ extends SceneTree
## into them, for real and by cheating), doors, chests, fires, dismantling,
## and the whole lot surviving a server restart.
##
## M2.4: the server decides. Alice's inventory, crafting, eating and stats
## live on the server; a game that edits its own bag gets nothing for it;
## graves belong to whoever died; characters are saved per player.
##
## 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.
@@ -22,6 +26,7 @@ var _finished := false
var _pids: Array[int] = []
var Net: Node
var WN: Node
var PN: Node
var A: Node
var world: Node3D
var player: CharacterBody3D
@@ -41,6 +46,7 @@ func _run() -> void:
Engine.max_fps = 60
Net = root.get_node("Net")
WN = root.get_node("WorldNet")
PN = root.get_node("PlayerNet")
A = root.get_node("Actions")
A.notify.connect(func(t): _toasts.append(t))
for d in [SRV, BOB]:
@@ -62,7 +68,7 @@ func _run() -> void:
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"])
"--server-name", "Shared Test", "--seed", str(SEED), "--debug-regrow", "0.002", "--debug-cheats"])
func _body() -> void:
var me = load("res://scripts/net/identity.gd").open(DIR + "/a")
@@ -83,6 +89,7 @@ func _body() -> void:
if not bool(b0.get("ok", false)):
return
await _server_decides()
await _trees()
await _building()
await _containers_and_fires()
@@ -90,6 +97,66 @@ func _body() -> void:
await _restart()
_finished = true
# --- the server decides -----------------------------------------------------------
func _server_decides() -> void:
print("the server decides")
await _sleep(1.0)
check(not player.survival.is_processing() and player.survival.hunger < 100.0 and player.survival.hunger > 90.0,
"Alice's stats run on the server and come to her (hunger %.2f)" % player.survival.hunger)
await _give("stick", 2)
await _give("flint", 2)
A.craft(player, "stone_axe")
await _until(func(): return player.inventory.count("stone_axe") == 1, 2.0)
check(player.inventory.count("stone_axe") == 1 and player.inventory.count("stick") == 0 and player.inventory.count("flint") == 0,
"she crafts a stone axe: the server takes the sticks and flint and gives her the axe")
# A modified game puts walls in its own bag and tries to build with them.
var fake := 7
player.inventory.set_slot(fake, {"id": "wood_wall", "count": 5})
var n0: int = WN.pieces.size()
var front := player.global_position - player.global_basis.z * 3.0
A.place(player, fake, Transform3D(Basis(), Vector3(front.x, world.terrain.sample_height(front.x, front.z), front.z)), world)
await _sleep(0.8)
check(WN.pieces.size() == n0 and player.inventory.get_slot(fake) == null,
"walls a game invents in its own bag build nothing, and the server's copy of her bag replaces it")
var bs: int = await _give("berries", 3)
PN.ask("debug_stats", [{"hunger": 50.0, "diet": {"plants": 10.0}}])
await _until(func(): return player.survival.hunger < 51.0, 2.0)
A.eat(player, bs)
await _until(func(): return player.survival.hunger > 55.0, 2.0)
check(player.survival.hunger > 55.0 and float(player.survival.diet.plants) > 20.0 and player.inventory.count("berries") == 2,
"she eats a berry on the server: hunger %.0f, greens %.0f" % [player.survival.hunger, float(player.survival.diet.plants)])
## Test-only (the server runs with --debug-cheats): items into Alice's bag on
## the server. Returns the slot they're in.
func _give(id: String, n: int) -> int:
var before: int = player.inventory.count(id)
PN.ask("debug_give", [id, n])
await _until(func(): return player.inventory.count(id) >= before + n, 3.0)
return _slot_of(id)
func _clear() -> void:
PN.ask("debug_clear")
await _until(func():
for s in player.inventory.slots:
if s != null:
return false
return true, 3.0)
func _slot_of(id: String) -> int:
for i in range(player.inventory.size()):
var s = player.inventory.get_slot(i)
if s != null and s.id == id:
return i
return -1
func _until(cond: Callable, limit: float) -> bool:
var t := 0.0
while not cond.call() and t < limit:
await _sleep(0.05)
t += 0.05
return cond.call()
# --- trees --------------------------------------------------------------------
func _trees() -> void:
@@ -99,13 +166,12 @@ func _trees() -> void:
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)
player.select(_slot_of("stone_axe"))
await _sleep(0.2)
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)
@@ -185,12 +251,14 @@ func _place(id: String, at: Vector3, yaw: float) -> Node3D:
print(" (can't place %s: %s)" % [id, res.reason])
return null
xf = res.xf
player.inventory.set_slot(5, {"id": id, "count": 1})
var slot: int = await _give(id, 1)
var had: int = player.inventory.count(id)
var before: Array = WN.pieces.keys()
if not A.place(player, 5, xf, world):
if not A.place(player, slot, xf, world):
print(" (Actions.place refused %s)" % id)
return null
check(player.inventory.get_slot(5) == null, "(%s taken from Alice's bag)" % id)
await _until(func(): return player.inventory.count(id) == had - 1, 2.0)
check(player.inventory.count(id) == had - 1, "(%s taken from Alice's bag by the server)" % id)
var t := 0.0
while t < 3.0:
for k in WN.pieces:
@@ -213,18 +281,22 @@ func _containers_and_fires() -> void:
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)")
await _clear()
var ws: int = await _give("wood", 10)
A.transfer(player, player.inventory, ws, _chest.inventory, _chest)
await _until(func(): return _chest.inventory.count("wood") == 10, 2.0)
check(player.inventory.count("wood") == 0 and _chest.inventory.count("wood") == 10, "she puts 10 wood in (shift-click)")
var ss: int = await _give("stone", 3)
var cur := {"c": "waiting"}
var on_cur := func(c): cur.c = c
PN.cursor_changed.connect(on_cur)
PN.ask("inv_click", [0, ss, false]) # pick the stone up...
PN.ask("inv_click", [WN.nid(_chest), 3, false]) # ...and put it in slot 4
await _until(func(): return _chest.inventory.get_slot(3) != null, 2.0)
await _sleep(0.2)
PN.cursor_changed.disconnect(on_cur)
check(_chest.inventory.get_slot(3) != null and _chest.inventory.get_slot(3).id == "stone" and cur.c == null,
"and drags 3 stone into slot 4 by hand (the server does both clicks)")
var nid: int = WN.nid(_chest)
await _bob({"do": "walk", "to": _v(site + Vector3(1.8, 0, 0))})
var rep := await _bob({"do": "report"})
@@ -248,20 +320,23 @@ func _containers_and_fires() -> void:
if _fire == null:
return
var f0: float = _fire.fuel
player.inventory.set_slot(7, {"id": "wood", "count": 2})
await _give("wood", 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)))
await _until(func(): return "Warm" in player.survival.statuses, 2.0)
check("Warm" in player.survival.statuses and player.survival.heat > 0.25,
"standing by it, the server says she's Warm (heat %.2f)" % player.survival.heat)
# --- 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))
PN.ask("debug_drop", ["flint", 2, at]) # (only the server drops things now)
var t := 0.0
var key := ""
while key.is_empty() and t < 3.0:
@@ -270,7 +345,7 @@ func _items_on_the_ground() -> void:
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")
check(not key.is_empty(), "2 flint dropped on the ground: 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))})
@@ -295,11 +370,12 @@ func _items_on_the_ground() -> void:
"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})
await _clear()
await _give("stone", 7)
await _give("berries", 4)
var died_at := player.global_position
world._on_player_died()
PN.ask("debug_die")
var died_t := Time.get_ticks_msec()
var grave: Node3D = null
var t2 := 0.0
while grave == null and t2 < 3.0:
@@ -308,14 +384,24 @@ func _items_on_the_ground() -> void:
grave = p
await _sleep(0.05)
t2 += 0.05
await _sleep(0.3)
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")
"Alice dies (on the server): her things go into a grave in the shared world")
check(world.dead_waiting and player.global_position.distance_to(died_at) < 1.0,
"she stays down on the death screen for a moment")
await _until(func(): return not world.dead_waiting, 6.0)
var down := (Time.get_ticks_msec() - died_t) / 1000.0
check(not world.dead_waiting and player.global_position.distance_to(died_at) > 3.0 and down >= world.RESPAWN_DELAY - 0.2,
"then respawns (after %.1f s), and the server wakes her at the island start" % down)
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 _bob({"do": "walk", "to": _v(grave.global_position + Vector3(-1.5, 0, 0))})
var bt := await _bob({"do": "chest_take", "nid": gnid, "slot": 0})
check(_count(bt.get("inv", []), "stone") == 0 and grave.inventory.count("stone") == 7, "but it's hers: Bob can't take from it")
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:
@@ -355,6 +441,9 @@ func _restart() -> void:
for k in WN.pieces:
expect[k] = WN.pieces[k].item_id
var chest_nid: int = WN.nid(_chest) if _chest != null else -1
await _give("flint", 3) # (a change, so the save comes after it)
var bag: Array = player.inventory.to_data()
var where := player.global_position
# 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.
@@ -385,6 +474,9 @@ func _restart() -> void:
if p.item_id == "wood_door":
d = p
check(d != null and d.open, "and the door is still open")
await _sleep(0.5)
check(player.inventory.to_data() == bag, "Alice's bag is just as she left it (her character is saved on the server)")
check(player.global_position.distance_to(where) < 2.0, "and she's back where she was (%.1f m off)" % player.global_position.distance_to(where))
world.queue_free()
await process_frame
Net.leave()