diff --git a/docs/MULTIPLAYER_PLAN.md b/docs/MULTIPLAYER_PLAN.md index 5de90ec..092b204 100644 --- a/docs/MULTIPLAYER_PLAN.md +++ b/docs/MULTIPLAYER_PLAN.md @@ -6,9 +6,9 @@ concrete decisions for this codebase, as it stands after M1. ## 1. Goals - **Self-hosted**, 2-8 players (up to 16 possible). No central service, no accounts: a world lives on whoever hosts it. -- **Three ways to play, one code path:** - - **Single-player**: the game runs a server inside itself and connects to - it (loopback). It plays exactly as now. +- **Three ways to play, the same rules (Actions):** + - **Single-player**: no server; your game decides, exactly as now + (decided in M2.4, §7). - **Host & play**: "Host game" on the title screen, and friends join you. - **Dedicated server**: a headless "server only" process with no local player. It keeps the world running (or paused) for whoever connects. @@ -204,12 +204,32 @@ it needs no syncing. 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. +**Your character on the server** *(built in M2.4: `player_net.gd`, +`server_player.gd`, autoload PlayerNet)*. Online the server decides; +offline there's no server and your game decides, exactly as before. +- Each player has a character on the server with their inventory, hotbar + choice, cloak, inventory-screen cursor, bed and survival stats. The stats + run there: warmth from the server's fires and buildings, rain, hunger and + the diet. Sprinting and jumping cost stamina there too. +- Online, every `Actions.*` intent becomes a request (`PlayerNet.ask("eat", + [slot])`). The server runs the same Actions function on your character + and sends back your inventory (after every request, and whenever it + changes) and your stats (4 times a second). What your own game has in + its bag counts for nothing: a modified game that adds walls to its bag + can't build with them. +- Clicks in your own bag also happen on your screen at once, so dragging + items doesn't wait for the network. The server's copy replaces yours as + soon as it has handled every click you sent. +- Death happens on the server: a grave with your name on it (only you can + open it until the M2.6 rules), then you wake at your bed if it's still + standing, otherwise the island start. +- Characters are saved in `worlds//players/.json`, with + the world, so you come back where you left off. + +Until later phases, while online: animals, hunting and fishing are off +(M2.5), and so is sleeping (it needs the sleep rule, M2.5). The HUD says so. +The server doesn't yet check that you're out of stamina when you sprint +(it charges you, but doesn't stop you). **Interest.** On a 320 m worldlet with 8 players, the server sends every entity to everyone at first. Distance-based interest is a later @@ -243,10 +263,18 @@ net ids, no owners yet) are written next to `server.json` within 10 s of any change, when a player leaves, and every `autosave_minutes`. (Ctrl+C kills Godot without running any code, so there's no save on the way out.) An existing world keeps the seed it was made with, even if `server.json` -changes. Player files, owners and backups come in M2.4 and M2.7. -- Single-player saves (today's `world.json` with a `"player"` block) are - migrated on first load. That block becomes the local player's file. The - test suite keeps an old-format save fixture to prove it. +changes. *As of M2.4:* `players/.json` too (position, facing, +inventory, hotbar choice, stats, diet, buffs, cloak, bed), saved with the +world, when the player leaves and when they die. Graves carry their +owner. Backups come in M2.7. + +*Decision (M2.4):* single-player stays as it is, with no server inside it +("if it's single-player there is no server, so the game decides"). So +single-player saves need no migration: they keep working unchanged, and +`tests/test_save_load.gd` still proves it. The old idea of running +single-player as a loopback server is dropped. +- *(Originally: single-player saves migrated to a player file on first + load. Not needed since the M2.4 decision above.)* - Per-player graves already work, since graves are pieces. With `death_penalty: grave` they get an owner, and only the owner (and friends, depending on locks) can loot them. @@ -274,7 +302,7 @@ player" or "this player": | **M2.1** *(done)* | network skeleton: `--server`, ENet on 24242, Join screen (address, name), handshake with identity and challenge, name rules, player list | two clients and a headless server on one Mac connect, see the player list, and disconnect cleanly | | **M2.2** *(done)* | player bodies and name tags; predicted movement, interpolation for others | two players walk around together at 100 ms simulated latency without visible rubber-banding | | **M2.3** *(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** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads | | **M2.5** | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens | | **M2.6** | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 | | **M2.7** | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour | @@ -288,9 +316,8 @@ player" or "this player": - **Rules tests:** one per server rule (PvP off means no damage between players; raiding off means you can't dismantle a stranger's wall; locks keep strangers out of your chest). -- **Save tests:** world and player file round trips, migration of old - saves, backups. -- The existing 9 suites keep running in single-player (loopback) mode. +- **Save tests:** world and player file round trips, backups. +- The single-player suites keep running as they are (no server). - `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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1f88e85..f8cfdf1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -47,12 +47,13 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d). Detailed plan in `docs/MULTIPLAYER_PLAN.md` (hosting on UDP 24242 via Tailscale or port forwarding, player identity and names, server rules for co-op/PvP, and phases M2.0-M2.7). -- [ ] Refactor state to server-authoritative (single-player becomes loopback server) +- [x] Server-authoritative online (single-player stays as it is, with no server) *(M2.4)* - [x] Headless dedicated server, joining, identity handshake, names, player list *(M2.1)* - [x] ENet transport, player bodies and name tags, local movement checked by the server, interpolation for others *(M2.2)* - [x] Replicate resource nodes, pickups, building, chests, fires, doors, clock *(M2.3)* - [x] Persistence: world save/load on the server *(M2.3)* -- [ ] Per-player inventory, stats and saves on the server *(M2.4)* +- [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)* +- [ ] Animals, hunting, fishing and sleeping on the server *(M2.5)* - **Done when:** a friend joins your self-hosted server and you build together. ## M3 — Addon SDK + sandbox · ~2–3 months diff --git a/project.godot b/project.godot index 1060df7..32cf494 100644 --- a/project.godot +++ b/project.godot @@ -28,6 +28,7 @@ Actions="*res://scripts/core/actions.gd" Saves="*res://scripts/core/saves.gd" Net="*res://scripts/net/net.gd" WorldNet="*res://scripts/net/world_net.gd" +PlayerNet="*res://scripts/net/player_net.gd" [display] diff --git a/scripts/building/grave.gd b/scripts/building/grave.gd index 24c57eb..3054f83 100644 --- a/scripts/building/grave.gd +++ b/scripts/building/grave.gd @@ -1,20 +1,46 @@ extends "res://scripts/building/piece.gd" ## Grave: left where you died, holding everything you carried. Open it with E ## to take your things back; it crumbles once empty. +## +## On a server it has an owner (whoever died), and only they can open it +## (player_net.gd asks use_block_reason). Server rules for friends come in M2.6. var inventory := Inventory.new(40) +var owner_id := "" +var owner_name := "" func _build() -> void: inventory.changed.connect(_on_changed) +func _mine() -> bool: + return owner_id.is_empty() or Net.state != "connected" or owner_id == Net.get_identity().player_id + func display_name() -> String: - return "Your grave" + return "Your grave" if _mine() else "%s's grave" % owner_name func interact_prompt(_player: Node) -> String: + if not _mine(): + return "%s's grave" % owner_name return "Your grave [E] Recover belongings" func interact(player: Node) -> void: - player.open_inventory(self) + if _mine(): + player.open_inventory(self) + else: + Actions.notify.emit("That's %s's grave" % owner_name) + +## Server: why player `id` can't take from it ("" = they can). +func use_block_reason(id: String) -> String: + if owner_id.is_empty() or owner_id == id: + return "" + return "That's %s's grave" % owner_name + +func save_extra() -> Dictionary: + return {"owner_id": owner_id, "owner_name": owner_name} if not owner_id.is_empty() else {} + +func load_extra(d: Dictionary) -> void: + owner_id = String(d.get("owner_id", "")) + owner_name = String(d.get("owner_name", "")) func _on_changed() -> void: if WorldNet.world != null: diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index a875191..9a97a54 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -54,11 +54,23 @@ func _give(player: Node, id: String, n: int) -> int: 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. +## come back to everyone, us included. func _online() -> bool: return WorldNet.is_client() +## Online, the server decides (player_net.gd): every intent is sent there as +## a request, and the server runs this same function on our character there. +## Offline nothing changes: the game decides. +func _ask(what: String, args: Array = []) -> bool: + PlayerNet.ask(what, args) + return true + +const NOT_ONLINE := "Hunting and fishing aren't on servers yet (they come with the animals)" + +func _not_online() -> bool: + notify.emit(NOT_ONLINE) + return false + 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 @@ -83,6 +95,8 @@ func can_craft(player: Node, recipe_id: String) -> bool: return inv.has_all(r.inputs) and station_near(player, r.station) func craft(player: Node, recipe_id: String) -> bool: + if _online(): + return _ask("craft", [recipe_id]) var r: Dictionary = ItemDB.recipes.get(recipe_id, {}) if r.is_empty(): return false @@ -109,13 +123,9 @@ func craft(player: Node, recipe_id: String) -> bool: func pickup(player: Node3D, pickup_node: Node3D) -> bool: if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node): return false - var id: String = pickup_node.item_id if _online(): - if player.inventory.space_for(id) < 1: - notify.emit("Inventory full") - return false - WorldNet.take(pickup_node) # first come, first served: the server decides - return true + return _ask("pickup", [WorldNet.pickup_key(pickup_node)]) # first come, first served + var id: String = pickup_node.item_id if _give(player, id, pickup_node.amount) <= 0: return false pickup_node.collected() @@ -125,6 +135,8 @@ func pickup(player: Node3D, pickup_node: Node3D) -> bool: func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool: if not is_instance_valid(node) or node.depleted: return false + if _online(): + return _ask("hit", [String(node.uid), hit_point]) if player.global_position.distance_to(hit_point) > REACH + 1.0: return false var held: Dictionary = ItemDB.get_item(player.held_item_id()) @@ -135,9 +147,6 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool: if not player.survival.use_stamina(6.0): notify.emit("Too tired") return false - if _online(): - WorldNet.hit(node, float(tool.get("damage", 1.0)), hit_point) - return true var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position) for id in drops: _give(player, id, int(drops[id])) @@ -152,6 +161,8 @@ func can_place(player: Node3D, slot: int) -> bool: func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool: if not can_place(player, slot): return false + if _online(): + return _ask("place", [slot, xf]) if player.global_position.distance_to(xf.origin) > 8.0: return false var id: String = player.inventory.get_slot(slot).id @@ -160,9 +171,6 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool: notify.emit("Can't place that here") return false player.inventory.take_from(slot, 1) - if _online(): - WorldNet.place(id, xf) # (given back if the server says no) - return true world.spawn_piece(def, xf) return true @@ -171,6 +179,8 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool: func dismantle(player: Node3D, piece: Node3D) -> bool: if not is_instance_valid(piece) or not piece.is_in_group("piece") or piece.is_queued_for_deletion(): return false + if _online(): + return _ask("dismantle", [WorldNet.nid(piece)]) if player.global_position.distance_to(piece.global_position) > 8.0: return false if piece.item_id == "grave": @@ -183,9 +193,6 @@ func dismantle(player: Node3D, piece: Node3D) -> bool: if player.inventory.space_for(piece.item_id) < 1: notify.emit("Inventory full") return false - if _online(): - WorldNet.dismantle(piece) - return true _give(player, piece.item_id, 1) piece.get_parent().get_parent().remove_piece(piece, player) return true @@ -194,8 +201,7 @@ func toggle_door(player: Node3D, door: Node3D) -> bool: if not is_instance_valid(door) or not _in_reach(player, door, 1.0): return false if _online(): - WorldNet.door(door) - return true + return _ask("door", [WorldNet.nid(door)]) door.set_open(not door.open) return true @@ -203,9 +209,16 @@ func toggle_door(player: Node3D, door: Node3D) -> bool: func use_bed(player: Node3D, bed: Node3D) -> bool: if not is_instance_valid(bed) or not _in_reach(player, bed, 1.0): return false + if _online(): + return _ask("bed", [WorldNet.nid(bed)]) var world: Node = bed.get_parent().get_parent() - if not bed.is_spawn(): - world.set_bed_spawn(bed) + # On a server each player has their own bed (server_player.gd). + var mine: bool = player.claims_bed(bed) if player.has_method("claims_bed") else bed.is_spawn() + if not mine: + if player.has_method("claim_bed"): + player.claim_bed(bed) + else: + world.set_bed_spawn(bed) notify.emit("Spawn point set") return true var why: String = world.sleep_block_reason(bed) @@ -237,6 +250,8 @@ func melee_stats(player: Node) -> Dictionary: ## One melee blow at an animal. func melee(player: Node3D, target: Node3D) -> bool: + if _online(): + return _not_online() if not is_instance_valid(target) or not target.is_in_group("creature"): return false var m := melee_stats(player) @@ -264,6 +279,8 @@ func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3, ## Throws the spear in `slot` from `origin` along `dir`. func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool: + if _online(): + return _not_online() var s = player.inventory.get_slot(slot) if s == null: return false @@ -281,6 +298,8 @@ func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world ## Starts drawing the held bow: needs arrows and a little stamina. func can_draw(player: Node3D) -> bool: + if _online(): + return _not_online() var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {}) if bow.is_empty(): return false @@ -294,6 +313,8 @@ func can_draw(player: Node3D) -> bool: ## Looses an arrow. `power` 0..1 is how far the bow was drawn. func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: Node3D) -> bool: + if _online(): + return _not_online() var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {}) if bow.is_empty() or player.global_position.distance_to(origin) > 2.5: return false @@ -306,6 +327,8 @@ func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: N return true func butcher(player: Node3D, carcass: Node3D) -> bool: + if _online(): + return _not_online() if not is_instance_valid(carcass) or not carcass.is_in_group("carcass") or not _in_reach(player, carcass, 1.0): return false var drops: Dictionary = carcass.butcher_drops() @@ -322,6 +345,8 @@ func butcher(player: Node3D, carcass: Node3D) -> bool: ## Casts the held rod from `origin` along `dir`. func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> bool: + if _online(): + return _not_online() var rod: Dictionary = ItemDB.get_item(player.held_item_id()).get("fishing", {}) if rod.is_empty() or player.global_position.distance_to(origin) > 2.5: return false @@ -335,6 +360,8 @@ func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> ## Reels in. With a fish biting you land it; otherwise the line just comes in. func reel(player: Node3D, world: Node3D) -> bool: + if _online(): + return _not_online() var b: Node3D = world.bobber if b == null or not is_instance_valid(b): return false @@ -351,6 +378,8 @@ func reel(player: Node3D, world: Node3D) -> bool: ## Puts on the cloak in `slot` (swapping out any cloak you were wearing). func wear(player: Node3D, slot: int) -> bool: + if _online(): + return _ask("wear", [slot]) var s = player.inventory.get_slot(slot) if s == null or not ItemDB.get_item(s.id).has("wear"): return false @@ -366,6 +395,8 @@ func wear(player: Node3D, slot: int) -> bool: func take_off(player: Node3D) -> bool: if player.worn.is_empty(): return false + if _online(): + return _ask("take_off") if player.inventory.space_for(player.worn) < 1: notify.emit("Inventory full") return false @@ -378,8 +409,7 @@ func forage(player: Node3D, bush: Node3D) -> bool: if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe(): return false if _online(): - WorldNet.forage(bush) - return true + return _ask("forage", [String(bush.uid)]) var got: Dictionary = bush.harvest() for id in got: _give(player, id, int(got[id])) @@ -387,6 +417,8 @@ func forage(player: Node3D, bush: Node3D) -> bool: ## Eats one of the food in `slot`. func eat(player: Node3D, slot: int) -> bool: + if _online(): + return _ask("eat", [slot]) var s = player.inventory.get_slot(slot) if s == null: return false @@ -406,16 +438,15 @@ func eat(player: Node3D, slot: int) -> bool: func add_fuel(player: Node3D, fire: Node3D) -> bool: if not is_instance_valid(fire) or not _in_reach(player, fire): return false + if _online(): + return _ask("fuel", [WorldNet.nid(fire)]) if fire.fuel >= fire.MAX_FUEL - 1.0: notify.emit("The fire is fully stoked") return false for id in ["wood", "stick", "fiber"]: if player.inventory.count(id) > 0: 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()) return true notify.emit("Nothing to burn (wood or sticks)") @@ -426,14 +457,8 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool: func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void: if container != null and not _in_reach(player, container, 1.0): return - if _online() and container != null: - var s = from.get_slot(slot) - if s == null: - return - if from == player.inventory: - from.set_slot(slot, null) - WorldNet.chest_put(container, s) # what doesn't fit comes back - else: - WorldNet.chest_take(container, slot, to.space_for(s.id)) + if _online(): + if container != null: + _ask("transfer", [WorldNet.nid(container), slot, from == player.inventory]) return from.quick_move(slot, to) diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index d062bfc..87b4e43 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -224,7 +224,7 @@ func _think(dt: float) -> void: _state_t = 4.0 elif d < def.attack_range and _attack_cd <= 0.0: _attack_cd = def.attack_cd - player.survival.damage(def.damage, global_position) + player.survival.damage(def.damage, global_position, "a " + String(def.name).to_lower()) func _start_flee(from: Vector3) -> void: state = "flee" diff --git a/scripts/creatures/wolf.gd b/scripts/creatures/wolf.gd index 8c5d8fa..258de15 100644 --- a/scripts/creatures/wolf.gd +++ b/scripts/creatures/wolf.gd @@ -119,7 +119,7 @@ func _attack(pp: Vector3, d: float, as_state: String) -> void: steer(atan2(-to.x, -to.z)) if d < def.attack_range and _attack_cd <= 0.0 and _can_reach_player(): _attack_cd = def.attack_cd - player.survival.damage(def.damage, global_position) + player.survival.damage(def.damage, global_position, "a " + String(def.name).to_lower()) if as_state == "lunge": state = "retreat" _state_t = 1.6 diff --git a/scripts/net/net.gd b/scripts/net/net.gd index a21f9a9..2b6d55e 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day const Identity = preload("res://scripts/net/identity.gd") const ServerConfig = preload("res://scripts/net/server_config.gd") -const PROTOCOL := 3 ## 3: the shared world (M2.3) +const PROTOCOL := 4 ## 3: the shared world (M2.3); 4: the server decides (M2.4) const DEFAULT_PORT := 24242 const HANDSHAKE_TIMEOUT := 10.0 const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join @@ -238,6 +238,7 @@ func _on_peer_disconnected(id: int) -> void: players.erase(id) print("[server] %s left (%d online)" % [p.name, players.size()]) _s2c_player_left.rpc(id) + PlayerNet.server_leave(id) # saves their character WorldNet.peer_left(id) players_changed.emit() @@ -275,6 +276,15 @@ func _tick_clock(delta: float) -> void: func player_pos(peer: int) -> Vector3: return _states[peer].pos if _states.has(peer) else Vector3.INF +func island_spawn() -> Vector3: + return _spawn + +## The server moved a player (respawning): their next moves are checked from here. +func teleport(peer: int, pos: Vector3) -> void: + if _states.has(peer): + _states[peer].pos = pos + _states[peer].t = server_time + func _broadcast_snapshot() -> void: if _states.is_empty(): return @@ -318,11 +328,13 @@ func _c2s_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String, print("[server] moved %s back (%s)" % [players[peer].name, why]) _s2c_correct.rpc_id(peer, s.pos) return + var from: Vector3 = s.pos s.pos = pos s.yaw = wrapf(yaw, -PI, PI) s.pitch = clampf(pitch, -1.5, 1.5) s.anim = clampi(anim, 0, 4) - s.held = held if held.length() < 40 and (held.is_empty() or not ItemDB.get_item(held).is_empty()) else "" + s.held = PlayerNet.held(peer) # (what's really in their hand, not what they say) + PlayerNet.server_moved(peer, from, pos, s.yaw, s.anim, server_time - s.t) s.t = server_time s.stamp = stamp @@ -422,14 +434,19 @@ func _c2s_proof(sig: PackedByteArray) -> void: print("[server] %s is the first player, so they're now an admin" % name) var p := {"id": id, "name": name, "appearance": Identity.sanitize_appearance(hello.get("appearance", {})), "admin": config.is_admin(id)} - var start := _spawn_for(players.size()) + # Their character (player_net.gd): back where they left off, or new. + var start: Vector3 = PlayerNet.server_join(peer, id, name) + var yaw: float = PlayerNet.yaw_of(peer) + if start == Vector3.INF: + start = _spawn_for(players.size()) + PlayerNet.place_at(peer, start) players[peer] = p - _states[peer] = {"pos": start, "yaw": 0.0, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0, + _states[peer] = {"pos": start, "yaw": yaw, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0, "stamp": server_time, "off": INF} print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "", players.size(), int(config.v("max_players"))]) # (The world's own seed: an existing world keeps the seed it was made with.) - var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, + var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw, "island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time} _s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world}) for other in players: diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd new file mode 100644 index 0000000..d3858b3 --- /dev/null +++ b/scripts/net/player_net.gd @@ -0,0 +1,453 @@ +extends Node +## Autoload "PlayerNet": each player's character on a dedicated server (M2.4, +## docs/MULTIPLAYER_PLAN.md §5). Online, the server decides; offline (single +## player) there's no server and the game decides, exactly as before. +## +## - Your game sends what you want to do: `ask("eat", [slot])`, +## `ask("hit", [tree uid, point])`... (Actions does this for every intent +## while online, instead of acting itself). +## - The server runs the same Actions rule on your character there +## (server_player.gd), and sends back your inventory (after every request, +## and whenever it changes) and your stats (4 times a second). +## - Clicks in your own inventory are also done on your screen straight away, +## so dragging items doesn't wait for the network; the server's copy +## replaces it once it has caught up with every click you sent. +## - Characters are saved per player in the server's world: +## worlds//players/.json. + +signal cursor_changed(stack) ## client: the server says this is on your cursor + +const ServerPlayer = preload("res://scripts/net/server_player.gd") +const STATS_INTERVAL := 0.25 +const NOT_YET := "Hunting and fishing aren't on servers yet (they come with the animals)" + +# Server. +var proxies: Dictionary = {} ## peer -> server_player.gd +var _acting := 0 ## the peer whose request is being run (for its messages) +var _dirty: Dictionary = {} ## peers whose inventory changed +var _acks: Dictionary = {} ## peer -> last request id handled +var _stats_left := 0.0 +var _root: Node3D +var cheats := false ## --debug-cheats (tests only): give items, set stats, die + +# Client. +var _seq := 0 ## id of the last request we sent +var world: Node3D + +func _ready() -> void: + Actions.notify.connect(_route_notify) + cheats = Net.parse_args(OS.get_cmdline_user_args()).has("debug-cheats") + +# --- server -------------------------------------------------------------------- + +func _route_notify(text: String) -> void: + if Net.is_server() and _acting != 0 and proxies.has(_acting): + _s2c_notify.rpc_id(_acting, text) + +func _players_root() -> Node3D: + if _root == null or not is_instance_valid(_root): + _root = Node3D.new() + _root.name = "Players" + WorldNet.world.add_child(_root) + return _root + +func _file(id: String) -> String: + return Saves.root_dir.path_join(Saves.current_id).path_join("players").path_join(id + ".json") + +## A player came in: their character, from their save if they've been here +## before. Returns where they were (or INF for a new player). +func server_join(peer: int, id: String, display: String) -> Vector3: + var p: Node3D = ServerPlayer.new() + p.name = "P%d" % peer + p.peer = peer + p.player_id = id + p.display = display + _players_root().add_child(p) + p.setup(WorldNet.world) + proxies[peer] = p + _acks[peer] = 0 + p.inventory.changed.connect(func(): _dirty[peer] = true; WorldNet.mark_changed()) + p.worn_changed.connect(func(): _dirty[peer] = true) + p.survival.died.connect(_on_died.bind(peer)) + var at := Vector3.INF + var d := _read(_file(id)) + if not d.is_empty(): + p.load_data(d) + var t: Node3D = WorldNet.world.terrain + var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF + if pos.is_finite() and t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0: + at = pos + print("[server] %s's character loaded" % display) + if at != Vector3.INF: + p.global_position = at + return at + +func place_at(peer: int, pos: Vector3) -> void: + if proxies.has(peer): + proxies[peer].global_position = pos + +func yaw_of(peer: int) -> float: + return proxies[peer].rotation.y if proxies.has(peer) else 0.0 + +func server_leave(peer: int) -> void: + var p: Node3D = proxies.get(peer) + if p == null: + return + if p.survival.dead: + _respawn(peer, false) # left on the death screen: they come back alive + _save(p) + proxies.erase(peer) + _dirty.erase(peer) + _acks.erase(peer) + p.queue_free() + +func save_all() -> void: + for p in proxies.values(): + _save(p) + +func server_reset() -> void: + proxies.clear() + _dirty.clear() + _acks.clear() + _root = null + +func _save(p: Node3D) -> void: + var path := _file(p.player_id) + DirAccess.make_dir_recursive_absolute(path.get_base_dir()) + var tmp := path + ".tmp" + var f := FileAccess.open(tmp, FileAccess.WRITE) + if f == null: + printerr("[server] can't save %s's character" % p.display) + return + f.store_string(JSON.stringify(p.to_data(), "\t")) + f.close() + DirAccess.rename_absolute(tmp, path) + +static func _read(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) + return d if d is Dictionary else {} + +func held(peer: int) -> String: + return proxies[peer].held_item_id() if proxies.has(peer) else "" + +## A move the server accepted: the character follows, and pays stamina for +## sprinting and jumping (the same costs as in your game). +func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int, dt: float) -> void: + var p: Node3D = proxies.get(peer) + if p == null: + return + var A := preload("res://scripts/net/avatar.gd").Anim + if anim == A.RUN: + p.survival.use_stamina(14.0 * dt, true) + elif anim == A.AIR and p.anim != A.AIR and to.y > from.y + 0.05: + p.survival.use_stamina(8.0) + p.anim = anim + p.velocity = (to - from) / maxf(dt, 0.001) + p.global_position = to + p.rotation.y = yaw + if to.y < p.water_level - 0.6: + p.survival.soak() + +func _process(delta: float) -> void: + if not Net.is_server() or proxies.is_empty(): + return + for peer in _dirty.keys(): + _push_inv(peer) + _dirty.clear() + _stats_left -= delta + if _stats_left <= 0.0: + _stats_left = STATS_INTERVAL + for peer in proxies: + _push_stats(peer) + +func push_all(peer: int) -> void: + _push_inv(peer) + _push_stats(peer) + +func _push_inv(peer: int) -> void: + var p: Node3D = proxies.get(peer) + if p != null: + _s2c_inv.rpc_id(peer, p.inventory.to_data(), p.cursor, p.worn, int(_acks.get(peer, 0))) + +func _push_stats(peer: int) -> void: + var p: Node3D = proxies.get(peer) + if p == null: + return + var s: Node = p.survival + _s2c_stats.rpc_id(peer, s.to_data(), s.statuses, [s.roofed, s.sheltered, s.heat], + [p.has_bed_spawn, p.bed_spawn]) + +## Death on the server: everything goes into a grave where they fell (with +## their name on it), and they wake at their bed or the island start. +func _on_died(peer: int) -> void: + var p: Node3D = proxies.get(peer) + var w: Node3D = WorldNet.world + if p == null or w == null: + return + _acting = peer + var pos: Vector3 = p.global_position + pos.y = w.terrain.sample_height(pos.x, pos.z) + if pos.y < w.terrain.water_level: + pos = w.terrain.find_spawn(Vector2(pos.x, pos.z)) + if not p.worn.is_empty() and p.inventory.add(p.worn, 1) == 0: + p.worn = "" + if p.cursor != null: + p.inventory.add(p.cursor.id, p.cursor.count) + p.cursor = null + var has_items := false + for st in p.inventory.slots: + if st != null: + has_items = true + if has_items: + var g: Node3D = w.spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, p.rotation.y), pos), {"owner_id": p.player_id, "owner_name": p.display}) + g.inventory.load_data(p.inventory.to_data()) + p.inventory.load_data(Inventory.new(p.inventory.size()).to_data()) + # They stay down on the death screen for a few seconds, then ask to respawn. + p.died_at = Time.get_ticks_msec() + _dirty[peer] = true + _s2c_died.rpc_id(peer, p.survival.cause if not p.survival.cause.is_empty() else "You died", has_items, p.bed_piece(w) != null) + _acting = 0 + +const RESPAWN_DELAY := 3.0 + +## Respawn (the death screen's button), once they've been down long enough: +## at their bed if it's still standing, otherwise the island start. +func _respawn(peer: int, tell: bool = true) -> void: + var p: Node3D = proxies.get(peer) + var w: Node3D = WorldNet.world + if p == null or w == null or not p.survival.dead: + return + if tell and Time.get_ticks_msec() - p.died_at < RESPAWN_DELAY * 1000.0 - 100.0: + return # (too soon: the button only unlocks after the delay) + var bed: Node3D = p.bed_piece(w) + var at: Vector3 + var yaw := 0.0 + if bed != null: + at = bed.global_position + Vector3(0, 0.9, 0) + yaw = bed.global_basis.get_euler().y + PI + else: + if p.has_bed_spawn: + p.has_bed_spawn = false + Actions.notify.emit("Your bed is gone, so you woke at the island start") + at = Net.island_spawn() + Vector3(0, 1.5, 0) + p.survival.reset_after_death() + p.global_position = at + p.rotation.y = yaw + Net.teleport(peer, at) + _dirty[peer] = true + if tell: + _s2c_respawn.rpc_id(peer, at, yaw, "") + _save(p) + +# --- server: requests ------------------------------------------------------------ + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_ask(seq: int, what: String, args: Array) -> void: + var peer := multiplayer.get_remote_sender_id() + var p: Node3D = proxies.get(peer) + if not Net.is_server() or p == null or WorldNet.world == null: + return + _acting = peer + if what == "respawn": + _respawn(peer) + elif not p.survival.dead: + _run(p, what, args) + _acting = 0 + _acks[peer] = maxi(int(_acks.get(peer, 0)), seq) + _dirty[peer] = true # (always answer, so the game knows we've caught up) + +func _run(p: Node3D, what: String, a: Array) -> void: + var w: Node3D = WorldNet.world + match what: + "select": + p.select(_i(a, 0)) + "craft": + Actions.craft(p, _s(a, 0)) + "eat": + Actions.eat(p, _i(a, 0)) + "wear": + Actions.wear(p, _i(a, 0)) + "take_off": + Actions.take_off(p) + "hit": + var n: Node3D = w.props.resource_nodes.get(_s(a, 0)) + var at: Vector3 = _v(a, 1) + if n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at): + WorldNet.node_hit(n, at, p.global_position) + "pickup": + var key := _s(a, 0) + var pk: Node3D = WorldNet.find_pickup(key) + if pk != null and not pk.taken and Actions.pickup(p, pk): + WorldNet.taken(key) + "forage": + var b: Node3D = w.props.forage.get(_s(a, 0)) + if b != null and Actions.forage(p, b): + WorldNet.foraged(b) + "place": + var xf: Transform3D = a[1] if a.size() > 1 and a[1] is Transform3D else Transform3D() + if xf.origin.is_finite() and xf.origin != Vector3.ZERO: + Actions.place(p, _i(a, 0), xf.orthonormalized(), w) + "dismantle": + var piece := WorldNet.piece(_i(a, 0)) + if piece != null: + Actions.dismantle(p, piece) + "door": + var d := WorldNet.piece(_i(a, 0)) + if d != null and d.get("open") != null and Actions.toggle_door(p, d): + WorldNet.piece_state(d) + "fuel": + var f := WorldNet.piece(_i(a, 0)) + if f != null and f.get("fuel") != null and Actions.add_fuel(p, f): + WorldNet.piece_state(f) + "bed": + var b := WorldNet.piece(_i(a, 0)) + if b != null and b.item_id == "bed": + Actions.use_bed(p, b) + "inv_click": + # Slot `a[1]` of your own inventory (a[0] = 0) or a chest's. + var inv: Inventory = p.inventory + if _i(a, 0) != 0: + var c := _container(p, _i(a, 0)) + if c == null: + return + inv = c.inventory + var slot := _i(a, 1) + if slot >= 0 and slot < inv.size(): + p.cursor = inv.click(slot, p.cursor, _b(a, 2)) + "transfer": + # Shift-click: from your inventory into the chest, or the other way. + var c := _container(p, _i(a, 0)) + if c == null: + return + var into_chest := _b(a, 2) + var from: Inventory = p.inventory if into_chest else c.inventory + var to: Inventory = c.inventory if into_chest else p.inventory + var slot := _i(a, 1) + if slot >= 0 and slot < from.size(): + Actions.transfer(p, from, slot, to, c) + "close": + # The inventory screen closed: what's on the cursor goes back in + # your bag, or at your feet if it's full. + if p.cursor != null: + var left: int = p.inventory.add(p.cursor.id, p.cursor.count) + if left > 0: + w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0))) + p.cursor = null + "debug_give": + if cheats and not ItemDB.get_item(_s(a, 0)).is_empty(): + p.inventory.add(_s(a, 0), _i(a, 1)) + "debug_clear": + if cheats: + p.inventory.load_data(Inventory.new(p.inventory.size()).to_data()) + "debug_stats": + if cheats and a.size() > 0 and a[0] is Dictionary: + for k in a[0]: + if k in ["health", "stamina", "hunger", "warmth"]: + p.survival.set(k, float(a[0][k])) + elif k == "diet" and a[0][k] is Dictionary: + for g in a[0][k]: + if p.survival.diet.has(g): + p.survival.diet[g] = float(a[0][k][g]) + "debug_die": + if cheats: + p.survival.damage(10000.0, p.global_position) + "debug_drop": + if cheats and not ItemDB.get_item(_s(a, 0)).is_empty(): + w.drop_item(_s(a, 0), maxi(_i(a, 1), 1), Transform3D(Basis(), _v(a, 2))) + _: + Actions.notify.emit(NOT_YET if what in ["hunt", "fish"] else "Unknown request") + +## The chest or grave `nid`, if this player may use it right now. +func _container(p: Node3D, nid: int) -> Node3D: + var c := WorldNet.piece(nid) + if c == null or c.get("inventory") == null or not Actions._in_reach(p, c, 1.0): + return null + var why: String = c.use_block_reason(p.player_id) if c.has_method("use_block_reason") else "" + if not why.is_empty(): + Actions.notify.emit(why) + return null + return c + +static func _i(a: Array, k: int) -> int: + return int(a[k]) if a.size() > k and (a[k] is int or a[k] is float) else -1 + +static func _s(a: Array, k: int) -> String: + return String(a[k]) if a.size() > k and a[k] is String else "" + +static func _b(a: Array, k: int) -> bool: + return a.size() > k and a[k] is bool and a[k] + +static func _v(a: Array, k: int) -> Vector3: + return a[k] if a.size() > k and a[k] is Vector3 else Vector3.INF + +# --- client ---------------------------------------------------------------------- + +func is_client() -> bool: + return WorldNet.is_client() + +## Our world is online: follow the server's view of us. +func join_world(w: Node3D) -> void: + world = w + _seq = 0 + w.player.survival.set_process(false) # the server runs our stats + w.player.selected_changed.connect(_on_selected) + _on_selected() + +func leave_world() -> void: + if world != null and is_instance_valid(world) and world.player.selected_changed.is_connected(_on_selected): + world.player.selected_changed.disconnect(_on_selected) + world = null + +func _on_selected() -> void: + if world != null: + ask("select", [world.player.selected]) + +## Sends a request to the server (Actions calls this while online). +func ask(what: String, args: Array = []) -> void: + if Net.state != "connected": + return + _seq += 1 + _c2s_ask.rpc_id(1, _seq, what, args) + +@rpc("authority", "call_remote", "reliable") +func _s2c_inv(data: Array, cursor: Variant, worn: String, ack: int) -> void: + if world == null or not is_instance_valid(world) or ack < _seq: + return # we've asked for more since: the next copy will include it + var pl: CharacterBody3D = world.player + pl.inventory.load_data(data) + if pl.worn != worn: + pl.worn = worn + pl.worn_changed.emit() + cursor_changed.emit(cursor) + +@rpc("authority", "call_remote", "reliable") +func _s2c_stats(stats: Dictionary, statuses: Array, env: Array, bed: Array) -> void: + if world == null or not is_instance_valid(world): + return + var s: Node = world.player.survival + s.load_data(stats) + s.statuses.assign(statuses) + s.roofed = bool(env[0]) + s.sheltered = bool(env[1]) + s.heat = float(env[2]) + world.has_bed_spawn = bool(bed[0]) + world.bed_spawn = bed[1] + +@rpc("authority", "call_remote", "reliable") +func _s2c_died(cause: String, has_items: bool, to_bed: bool) -> void: + if world != null and is_instance_valid(world): + world.died_online(cause, has_items, to_bed) + +@rpc("authority", "call_remote", "reliable") +func _s2c_respawn(at: Vector3, yaw: float, text: String) -> void: + if world == null or not is_instance_valid(world): + return + world.respawn_from_server(at, yaw) + if not text.is_empty(): + Actions.notify.emit(text) + +@rpc("authority", "call_remote", "reliable") +func _s2c_notify(text: String) -> void: + Actions.notify.emit(text) diff --git a/scripts/net/server_player.gd b/scripts/net/server_player.gd new file mode 100644 index 0000000..c67398a --- /dev/null +++ b/scripts/net/server_player.gd @@ -0,0 +1,91 @@ +extends Node3D +## One player's character on a dedicated server (M2.4): what they carry, what +## they hold, what they wear, their bed, and their survival stats, which run +## here (warmth from the server's fires and buildings, hunger, diet...). +## +## It has the same fields and functions the Actions rules use on the local +## player in single-player (inventory, selected, worn, held_item_id, +## survival...), so the server runs exactly the same rules for everyone. +## It has no body: where it is comes from the player's moves (net.gd). +## Saved as players/.json in the server's world (player_net.gd). + +signal worn_changed +signal selected_changed + +const HOTBAR := 8 + +var peer := 0 +var player_id := "" +var display := "" +var inventory := Inventory.new(40) +var selected := 0 +var worn := "" +var cursor = null ## the stack held on the inventory screen's cursor +var survival: Node +var hands: Node ## (self: held_heat for a carried torch) +var velocity := Vector3.ZERO +var water_level := -INF +var has_bed_spawn := false +var bed_spawn := Vector3.ZERO +var anim := 0 ## last movement state reported (avatar.gd Anim) +var died_at := 0 ## ticks (ms) when they last died + +func setup(world: Node3D) -> void: + hands = self + water_level = world.terrain.water_level + survival = preload("res://scripts/player/survival.gd").new() + survival.name = "Survival" + survival.player = self + survival.world = world + add_child(survival) + +func held_item_id() -> String: + var s = inventory.get_slot(selected) + return s.id if s != null else "" + +func select(i: int) -> void: + selected = wrapi(i, 0, HOTBAR) + selected_changed.emit() + +func held_heat() -> float: + return float(ItemDB.get_item(held_item_id()).get("light", {}).get("heat", 0.0)) + +## Chests and graves call this on interact; on the server there's no screen. +func open_inventory(_container: Node) -> void: + pass + +func claims_bed(bed: Node3D) -> bool: + return has_bed_spawn and bed_spawn.distance_to(bed.global_position) < 0.3 + +func claim_bed(bed: Node3D) -> void: + has_bed_spawn = true + bed_spawn = bed.global_position + +## The bed this player respawns at, if it's still standing. +func bed_piece(world: Node3D) -> Node3D: + if not has_bed_spawn: + return null + for p in world.all_pieces(): + if p.item_id == "bed" and claims_bed(p): + return p + return null + +func to_data() -> Dictionary: + return {"name": display, "pos": [global_position.x, global_position.y, global_position.z], "yaw": rotation.y, + "inventory": inventory.to_data(), "selected": selected, "stats": survival.to_data(), "worn": worn, + "bed": {"has": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]}} + +func load_data(d: Dictionary) -> void: + if d.has("inventory"): + inventory.load_data(d.inventory) + selected = int(d.get("selected", 0)) + if d.has("stats"): + survival.load_data(d.stats) + worn = String(d.get("worn", "")) + if not worn.is_empty() and ItemDB.get_item(worn).is_empty(): + worn = "" + var b: Dictionary = d.get("bed", {}) + has_bed_spawn = bool(b.get("has", false)) + if b.has("pos"): + bed_spawn = Vector3(b.pos[0], b.pos[1], b.pos[2]) + rotation.y = float(d.get("yaw", 0.0)) diff --git a/scripts/net/world_net.gd b/scripts/net/world_net.gd index 94fb05e..8a38a09 100644 --- a/scripts/net/world_net.gd +++ b/scripts/net/world_net.gd @@ -7,26 +7,19 @@ extends Node ## 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. +## Players' requests arrive through PlayerNet (player_net.gd), which runs the +## Actions rules on that player's character on the server. The results show +## up here and go to everyone: +## - pieces placed, removed or collapsed, and items dropped: the server's +## world reports them (world.gd signals), wherever they came from; +## - hits, pickups, berries, doors and fires: PlayerNet tells us; +## - chest and grave contents: whenever they change. ## ## 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) @@ -36,8 +29,7 @@ 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 +var _chests_dirty: Dictionary = {} ## Counters the tests read. var received := 0 ## client: world events applied @@ -62,24 +54,23 @@ func open_server_world(config: RefCounted) -> Node3D: 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() + _chests_dirty.clear() _next_nid = 1 + world = w + get_tree().root.add_child(w) # (loads the save: pieces come in through _on_piece_added) for p in w.all_pieces(): - if p.has_meta("nid"): - _next_nid = maxi(_next_nid, int(p.get_meta("nid")) + 1) + _next_nid = maxi(_next_nid, int(p.get_meta("nid", 0)) + 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 + _register(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) + _register_dropped(d) + w.piece_added.connect(_on_piece_added) + w.pieces_removed.connect(_on_pieces_removed) + w.dropped_added.connect(_on_dropped_added) + w.saved.connect(PlayerNet.save_all) w.props.regrown.connect(_on_regrown) var args: Dictionary = Net.parse_args(OS.get_cmdline_user_args()) if args.has("debug-regrow"): @@ -99,23 +90,35 @@ func close_server_world() -> void: pieces.clear() dropped.clear() _ready_peers.clear() + PlayerNet.server_reset() func save() -> void: _save_left = INF if world != null and is_instance_valid(world) and world.server_mode: - world.save_now() + world.save_now() # (players too: world.saved) + +## Something changed: save within a few seconds. Ctrl+C kills Godot without +## a chance to save, so this keeps the loss small. +func mark_changed() -> void: + if is_server() and _save_left == INF: + _save_left = SAVE_AFTER 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 +func _register(p: Node3D) -> void: + if not p.has_meta("nid"): + p.set_meta("nid", _new_nid()) + pieces[int(p.get_meta("nid"))] = p + if p.get("inventory") != null: + var nid := int(p.get_meta("nid")) + p.inventory.changed.connect(func(): _chests_dirty[nid] = true; mark_changed()) + +func _register_dropped(d: Node3D) -> void: + if not d.has_meta("nid"): + d.set_meta("nid", _new_nid()) + dropped[int(d.get_meta("nid"))] = d ## A player left: they no longer get events. Save while it's fresh. func peer_left(peer: int) -> void: @@ -125,11 +128,17 @@ func peer_left(peer: int) -> void: func _process(delta: float) -> void: if not is_server(): return + for nid in _chests_dirty.keys(): + _flush_chest(nid) + _chests_dirty.clear() # 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 + _save_left -= delta + if _save_left <= 0.0: + save() _blocker_left -= delta if _blocker_left <= 0.0: _blocker_left = 1.0 @@ -137,10 +146,6 @@ func _process(delta: float) -> void: 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 @@ -152,54 +157,77 @@ func _process(delta: float) -> void: 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. +## Sends to every player who has their snapshot. func _all(method: String, args: Array) -> void: - if method != "_s2c_fires" and _save_left == INF: - _save_left = SAVE_AFTER + if method != "_s2c_fires": + mark_changed() 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: +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) +## A pickup by the key players use: its scenery id, or "#" if it was dropped. +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 -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))} +# --- server: telling everyone ---------------------------------------------------- + +func node_hit(n: Node3D, point: Vector3, from: Vector3) -> void: + _all("_s2c_node", [String(n.uid), n.hp, point, from]) + +func taken(key: String) -> void: + if key.begins_with("#"): + dropped.erase(int(key.substr(1))) + _all("_s2c_taken", [key]) + +func foraged(bush: Node3D) -> void: + _all("_s2c_forage", [String(bush.uid), bush.regrow_left]) + +func piece_state(p: Node3D) -> void: + if p.has_method("save_extra"): + _all("_s2c_piece_state", [int(p.get_meta("nid", 0)), p.save_extra()]) + +func _on_piece_added(p: Node3D) -> void: + _register(p) + _all("_s2c_piece_added", [world.piece_entry(p)]) + +func _on_pieces_removed(gone: Array, fell: Array) -> void: + var g: Array = [] + var f: Array = [] + for p in gone: + var nid := int(p.get_meta("nid", 0)) + pieces.erase(nid) + _chests_dirty.erase(nid) + g.append(nid) + if p in fell: + f.append(nid) + _all("_s2c_pieces_removed", [g, f]) + +func _on_dropped_added(d: Node3D) -> void: + _register_dropped(d) + _all("_s2c_dropped", [world.dropped_entry(d)]) + +func _on_regrown(uid: String) -> void: + _all("_s2c_regrown", [uid]) + +func _flush_chest(nid: int) -> void: + var p := piece(nid) + if p == null: + return + _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) static func _empty(inv: Inventory) -> bool: for s in inv.slots: @@ -207,8 +235,6 @@ static func _empty(inv: Inventory) -> bool: return false return true -# --- client -> server ------------------------------------------------------------- - @rpc("any_peer", "call_remote", "reliable") func _c2s_world() -> void: var peer := multiplayer.get_remote_sender_id() @@ -216,216 +242,15 @@ func _c2s_world() -> void: return _ready_peers[peer] = true _s2c_world.rpc_id(peer, world.collect_shared()) + PlayerNet.push_all(peer) -@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 ---------------------------------------------------------------- +# --- client ------------------------------------------------------------------------ ## 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) @@ -435,49 +260,13 @@ func leave_world() -> void: pieces.clear() dropped.clear() -func hit(node: Node3D, damage: float, point: Vector3) -> void: - _c2s_hit.rpc_id(1, String(node.uid), damage, point) - ## The key the server knows a pickup by: its scenery id, or "#". func pickup_key(pk: Node3D) -> String: return "#%d" % int(pk.get_meta("nid")) if pk.has_meta("nid") else String(pk.uid) -func take(pk: Node3D) -> void: - _c2s_take.rpc_id(1, pickup_key(pk)) - -func forage(bush: Node3D) -> void: - _c2s_forage.rpc_id(1, String(bush.uid)) - -func place(item: String, xf: Transform3D, contents: Array = []) -> void: - _c2s_place.rpc_id(1, item, xf, contents) - static func nid(p: Node3D) -> int: return int(p.get_meta("nid", 0)) -func dismantle(p: Node3D) -> void: - _c2s_dismantle.rpc_id(1, nid(p)) - -func door(p: Node3D) -> void: - _c2s_door.rpc_id(1, nid(p)) - -func fuel(p: Node3D, item: String) -> void: - _c2s_fuel.rpc_id(1, nid(p), item) - -## `done(cursor)` gets what you're holding afterwards. -func chest_click(p: Node3D, slot: int, cursor: Variant, right: bool, done: Callable) -> void: - chest_busy = true - _chest_cb = done - _c2s_chest_click.rpc_id(1, nid(p), slot, cursor, right) - -func chest_put(p: Node3D, stack: Dictionary) -> void: - _c2s_chest_put.rpc_id(1, nid(p), stack) - -func chest_take(p: Node3D, slot: int, room: int) -> void: - _c2s_chest_take.rpc_id(1, nid(p), slot, room) - -func drop(item: String, count: int, xf: Transform3D) -> void: - _c2s_drop.rpc_id(1, item, count, xf) - # --- server -> client ------------------------------------------------------------ @rpc("authority", "call_remote", "reliable") @@ -518,7 +307,7 @@ func _s2c_regrown(uid: String) -> void: func _s2c_taken(key: String) -> void: if not is_client(): return - var pk := _find_pickup(key) + var pk := find_pickup(key) if key.begins_with("#"): dropped.erase(int(key.substr(1))) if pk != null: @@ -598,29 +387,3 @@ func _s2c_dropped(e: Dictionary) -> void: if d != null: dropped[int(e.nid)] = d received += 1 - -## Items the server decided we earned. Anything that doesn't fit goes back -## on the ground at our feet (for anyone to pick up). -@rpc("authority", "call_remote", "reliable") -func _s2c_give(items: Dictionary) -> void: - if not is_client(): - return - for id in items: - var n := int(items[id]) - var got: int = Actions._give(world.player, String(id), n) - if got < n: - drop(String(id), n - got, Transform3D(Basis(), world.player.global_position + Vector3(0, 0.1, 0))) - -@rpc("authority", "call_remote", "reliable") -func _s2c_cursor(stack: Variant) -> void: - chest_busy = false - var cb := _chest_cb - _chest_cb = Callable() - if cb.is_valid(): - cb.call(stack) - elif stack != null and is_client(): - _s2c_give({stack.id: stack.count}) - -@rpc("authority", "call_remote", "reliable") -func _s2c_notify(text: String) -> void: - Actions.notify.emit(text) diff --git a/scripts/player/survival.gd b/scripts/player/survival.gd index 21ec852..181e2b1 100644 --- a/scripts/player/survival.gd +++ b/scripts/player/survival.gd @@ -38,6 +38,7 @@ var _rest_build := 0.0 ## Food buffs: id -> {name, left (s), stamina_regen, health_regen (multipliers)}. var buffs: Dictionary = {} var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit +var cause := "" ## what last hurt you, as the death screen says it ("You froze") signal hurt(amount: float, from: Vector3) const HURT_NO_REGEN := 6.0 @@ -71,19 +72,20 @@ func balanced_diet() -> bool: return false return true -var player: CharacterBody3D +var player: Node3D ## the local player, or a server_player.gd on a server var world: Node3D var _stamina_wait := 0.0 func _process(delta: float) -> void: - if world != null and not world.menu_mode: + # (Online, the server runs our stats and sends them: player_net.gd.) + if world != null and not world.menu_mode and not world.is_online(): step(delta) func step(dt: float) -> void: if dead: return var p := player.global_position - heat = world.heat_at(p) + heat = world.heat_at(p, player) _shelter_left -= dt if _shelter_left <= 0.0: _shelter_left = 0.25 @@ -137,6 +139,12 @@ func step(dt: float) -> void: dmg += MALNOURISHED_DRAIN if dmg > 0.0: health = maxf(health - dmg * dt, 0.0) + if hunger <= 0.0: + cause = "You starved" + elif warmth < 12.0: + cause = "You froze to death" + else: + cause = "Malnourished: you needed %s" % " and ".join(PackedStringArray(lacking.map(func(g): return DIET_NAMES[g]))) elif hunger > 40.0 and warmth > 35.0 and _hurt_cd <= 0.0 and lacking.is_empty(): var diet_mult := 1.5 if balanced_diet() else 1.0 health = minf(health + 0.5 * buff_mult("health_regen") * diet_mult * dt, MAX) @@ -219,15 +227,18 @@ func reset_after_death() -> void: warmth = 60.0 wet = 0.0 buffs.clear() + cause = "" # Don't respawn straight back into malnutrition. for g in DIET_GROUPS: diet[g] = maxf(float(diet[g]), 30.0) changed.emit() ## Takes a hit (a boar, later wolves). Pauses natural healing briefly. -func damage(amount: float, from: Vector3) -> void: +## `by` is what did it ("a wolf"), for the death screen. +func damage(amount: float, from: Vector3, by: String = "") -> void: if dead: return + cause = "Killed by %s" % by if not by.is_empty() else "You were killed" health = maxf(health - amount, 0.0) _hurt_cd = HURT_NO_REGEN hurt.emit(amount, from) diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index 258817a..d90f80f 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -209,6 +209,72 @@ func fade(mid: Callable) -> void: player.ui_open = false black.queue_free()) +var _death: Control + +## The death screen: the world fades to dark red, it says what killed you +## and where your things are, and after `delay` seconds Respawn (or Enter) +## calls `respawn`. +func show_death(cause: String, note: String, delay: float, respawn: Callable) -> void: + hide_death() + player.ui_open = true + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + var bg := ColorRect.new() + bg.color = Color(0.22, 0.02, 0.02, 0.0) + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + bg.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(bg) + _death = bg + var col := VBoxContainer.new() + col.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + col.grow_horizontal = Control.GROW_DIRECTION_BOTH + col.grow_vertical = Control.GROW_DIRECTION_BOTH + col.alignment = BoxContainer.ALIGNMENT_CENTER + col.add_theme_constant_override("separation", 14) + col.modulate.a = 0.0 + bg.add_child(col) + var title := UITheme.label("You died", 56) + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + col.add_child(title) + var why := UITheme.label(cause if not cause.is_empty() else "You died", 22) + why.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + col.add_child(why) + var where := UITheme.label(note, 16, UITheme.TEXT_DIM) + where.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + col.add_child(where) + var btn := Button.new() + btn.custom_minimum_size = Vector2(220, 46) + btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + btn.disabled = true + btn.text = "Respawn (%d)" % ceili(delay) + col.add_child(btn) + btn.pressed.connect(func(): + hide_death() + respawn.call()) + var tw := bg.create_tween() + tw.tween_property(bg, "color:a", 0.72, 0.9) + tw.parallel().tween_property(col, "modulate:a", 1.0, 0.9) + # Count down, then let them go. + for k in range(1, ceili(delay)): + var left := ceili(delay) - k + get_tree().create_timer(float(k)).timeout.connect(func(): + if is_instance_valid(btn) and btn.disabled: + btn.text = "Respawn (%d)" % left) + get_tree().create_timer(delay).timeout.connect(func(): + if is_instance_valid(btn): + btn.disabled = false + btn.text = "Respawn" + btn.grab_focus()) + +func is_dead_screen() -> bool: + return _death != null and is_instance_valid(_death) + +func hide_death() -> void: + if is_dead_screen(): + _death.queue_free() + _death = null + player.ui_open = false + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + ## Closes the inventory / menu (e.g. on death). func close_screens() -> void: if _screen.is_open(): @@ -221,6 +287,8 @@ func _on_inventory_requested(container: Node) -> void: ## ESC: close the inventory if it's open, otherwise toggle the pause menu. func _input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: + if is_dead_screen(): + return # (respawn first) if _screen.is_open(): _screen.close() elif _menu.is_open(): diff --git a/scripts/ui/inventory_screen.gd b/scripts/ui/inventory_screen.gd index 45eeaf0..fc3d114 100644 --- a/scripts/ui/inventory_screen.gd +++ b/scripts/ui/inventory_screen.gd @@ -88,6 +88,7 @@ func bind(p: CharacterBody3D) -> void: wr.add_child(_worn_btn) pv.add_child(wr) player.worn_changed.connect(_refresh) + PlayerNet.cursor_changed.connect(_on_server_cursor) pp.add_child(pv) row.add_child(pp) @@ -142,14 +143,13 @@ func open(chest: Node) -> void: _refresh() func close() -> void: - if _cursor != null: + if WorldNet.is_client(): + PlayerNet.ask("close") # the server puts the cursor back in our bag + _cursor = null + elif _cursor != null: var left: int = player.inventory.add(_cursor.id, _cursor.count) 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 if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh): container.inventory.changed.disconnect(_refresh) @@ -193,11 +193,16 @@ func _rebuild_chest() -> void: _chest_grid.add_child(s) _chest_slots.append(s) +## Online the server does every click (player_net.gd). Clicks in our own +## bag also happen here straight away, so dragging doesn't wait on the +## network; the server's copy replaces ours once it's caught up. func _on_player_slot(i: int, right: bool, shift: bool) -> void: if shift and container != null: Actions.transfer(player, player.inventory, i, container.inventory, container) else: _cursor = player.inventory.click(i, _cursor, right) + if WorldNet.is_client(): + PlayerNet.ask("inv_click", [0, i, right]) _refresh() func _on_chest_slot(i: int, right: bool, shift: bool) -> void: @@ -206,26 +211,17 @@ func _on_chest_slot(i: int, right: bool, shift: bool) -> void: if shift: Actions.transfer(player, container.inventory, i, player.inventory, container) elif WorldNet.is_client(): - # A shared chest: the server does the click (someone else may be in - # it too) and hands back what we're holding. - if WorldNet.chest_busy: - return - var held = _cursor - _cursor = null - WorldNet.chest_click(container, i, held, right, _on_chest_cursor) + # A shared chest (someone else may be in it too): only the server's + # answer counts. + PlayerNet.ask("inv_click", [WorldNet.nid(container), i, right]) else: _cursor = container.inventory.click(i, _cursor, right) _refresh() -func _on_chest_cursor(stack) -> void: - if visible: - _cursor = stack - _refresh() - elif stack != null: - # Closed in the meantime: into our bag (or the ground). - var left: int = player.inventory.add(stack.id, stack.count) - if left > 0: - player.get_parent().drop_item(stack.id, left, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0))) +## Online: what the server says we're holding. +func _on_server_cursor(stack) -> void: + _cursor = stack if visible else null + _refresh() func _refresh() -> void: if player == null: diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 4eb0d5c..ebb4cfb 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -65,6 +65,14 @@ var bed_spawn := Vector3.ZERO ## position of the bed you respawn at func is_online() -> bool: return not menu_mode and Net.state == "connected" var _loading := false ## applying a save: defer support recompute +var _applying := false ## applying a save or snapshot: not news for the server's players + +## Server only: changes to the island, wherever they came from (a player's +## request, a collapse, a death), for world_net.gd to send to everyone. +signal piece_added(piece: Node3D) +signal pieces_removed(gone: Array, fell: Array) +signal dropped_added(item: Node3D) +signal saved func _ready() -> void: _autosave_left = autosave_seconds @@ -151,12 +159,15 @@ func air_warmth(pos: Vector3, sheltered: bool = false) -> float: return w ## 0..1 heat from fires and a held torch. -func heat_at(pos: Vector3) -> float: +## `holder` is whose torch counts (default: the local player; on a server, +## each player's character). +func heat_at(pos: Vector3, holder: Node = null) -> float: var h := 0.0 for n in get_tree().get_nodes_in_group("heat"): h = maxf(h, n.heat_at(pos)) - if player.hands != null: - h = maxf(h, player.hands.held_heat()) + var who: Node = holder if holder != null else player + if who.get("hands") != null: + h = maxf(h, who.hands.held_heat()) return h ## Death: everything you carried goes into a grave where you fell, and you @@ -176,16 +187,34 @@ func _on_player_died() -> void: if st != null: has_items = true if has_items: - 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()) + var grave := spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, player.rotation.y), pos)) + grave.inventory.load_data(player.inventory.to_data()) player.inventory.load_data(Inventory.new(player.inventory.size()).to_data()) + # The death screen: you stay down for a few seconds, then Respawn. + # (Headless, with no screen, you wake straight away.) + dead_waiting = true + player.velocity = Vector3.ZERO + player.set_physics_process(false) if hud != null: hud.close_screens() - var bed := spawn_bed() if net_sync == null else null # (online: beds aren't shared until M2.4) + hud.show_death(player.survival.cause, _death_note(has_items, spawn_bed() != null), RESPAWN_DELAY, respawn_after_death) + else: + respawn_after_death() + +const RESPAWN_DELAY := 3.0 ## seconds on the death screen before you can respawn +var dead_waiting := false ## dead, on the death screen + +static func _death_note(has_items: bool, to_bed: bool) -> String: + return "%s\nYou'll wake %s." % ["Your belongings are in a grave where you fell." if has_items else "You had nothing on you.", + "at your bed" if to_bed else "at the island start"] + +## Respawn (the death screen's button): at your bed, or the island start. +func respawn_after_death() -> void: + if not dead_waiting: + return + dead_waiting = false + player.set_physics_process(true) + var bed := spawn_bed() if bed != null: _respawn_at_bed(bed) else: @@ -194,7 +223,17 @@ func _on_player_died() -> void: has_bed_spawn = false Actions.notify.emit("Your bed is gone, so you woke at the island start") player.survival.reset_after_death() - Actions.notify.emit("You died. Your belongings are in your grave." if has_items else "You died.") + +## Online: the server says we died. Same screen; Respawn asks the server. +func died_online(text: String, has_items: bool, to_bed: bool) -> void: + dead_waiting = true + player.velocity = Vector3.ZERO + player.set_physics_process(false) + if hud != null: + hud.close_screens() + hud.show_death(text, _death_note(has_items, to_bed), RESPAWN_DELAY, func(): PlayerNet.ask("respawn")) + else: + get_tree().create_timer(RESPAWN_DELAY + 0.1).timeout.connect(func(): PlayerNet.ask("respawn")) # --- beds and sleeping ------------------------------------------------------ @@ -217,9 +256,21 @@ func _respawn_at_bed(bed: Node3D) -> void: player.rotation = Vector3(0.0, bed.global_basis.get_euler().y + PI, 0.0) player.get_node("Camera3D").rotation.x = 0.0 +## Online: the server says we died (or were moved) and puts us here. +func respawn_from_server(at: Vector3, yaw: float) -> void: + dead_waiting = false + player.set_physics_process(true) + if hud != null: + hud.close_screens() + hud.hide_death() + player.global_position = at + player.velocity = Vector3.ZERO + player.rotation = Vector3(0.0, yaw, 0.0) + player.get_node("Camera3D").rotation.x = 0.0 + ## Why you can't sleep in `bed` right now ("" = you can). func sleep_block_reason(bed: Node3D) -> String: - if is_online(): + if is_online() or server_mode: return "Sleeping on a server isn't in yet (everyone would have to agree)" var h: float = day_night.hours() if h > 5.5 and h < 19.5: @@ -334,12 +385,17 @@ func save_now() -> bool: _autosave_left = autosave_seconds if not Saves.has_world(): return false + if dead_waiting and not server_mode: + respawn_after_death() # never save someone lying dead + if hud != null: + hud.hide_death() var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time) if ok: _session_time = 0.0 if server_mode: if not ok: printerr("[server] saving the world FAILED") + saved.emit() # (the players' characters save with it) else: Actions.notify.emit("Game saved" if ok else "Save FAILED") return ok @@ -502,6 +558,11 @@ func remove_pieces_net(gone: Array, collapsed_nids: Array) -> void: _update_grass_cuts() func apply_save(s: Dictionary, with_player: bool = true) -> void: + _applying = true + _apply_save(s, with_player) + _applying = false + +func _apply_save(s: Dictionary, with_player: bool) -> void: var p: Dictionary = s.get("player", {}) if with_player else {} if p.has("pos"): player.global_position = Vector3(p.pos[0], p.pos[1] + 0.05, p.pos[2]) @@ -596,16 +657,21 @@ func placement_valid(def: Dictionary, xf: Transform3D) -> bool: q.exclude = [terrain.get_node("TerrainBody").get_rid(), player.get_rid()] return get_world_3d().direct_space_state.intersect_shape(q, 1).is_empty() -func spawn_piece(def: Dictionary, xf: Transform3D) -> Node3D: +## `extra` is the piece's own state (a grave's owner, a door left open...). +func spawn_piece(def: Dictionary, xf: Transform3D, extra: Dictionary = {}) -> Node3D: var script: Script = PIECES.get(def.place.piece, preload("res://scripts/building/piece.gd")) var piece: StaticBody3D = script.new() piece.setup(def) _pieces.add_child(piece) piece.global_transform = xf + if not extra.is_empty() and piece.has_method("load_extra"): + piece.load_extra(extra) if piece.is_in_group("build") and not _loading: build.recompute() if piece.kind == "floor": _update_grass_cuts() + if server_mode and not _applying: + piece_added.emit(piece) return piece ## Grass doesn't grow up through floors. @@ -636,6 +702,8 @@ func _go_online() -> void: _spawn = Net.world_info.get("island_spawn", _spawn) var start: Vector3 = Net.world_info.get("spawn", _spawn) player.global_position = start + Vector3(0.0, 0.05, 0.0) + if Net.world_info.has("yaw"): + player.rotation.y = float(Net.world_info.yaw) # facing the way you were when you left net_sync = preload("res://scripts/net/net_sync.gd").new() net_sync.name = "NetSync" add_child(net_sync) @@ -645,8 +713,9 @@ func _go_online() -> void: # The island's changes come from the server from now on. props.regrow_enabled = false WorldNet.join_world(self) + PlayerNet.join_world(self) # and our inventory and stats if hud != null: - var bar := UITheme.label("Online: %s · the island is shared; your inventory isn't saved yet, and there are no animals yet" \ + var bar := UITheme.label("Online: %s · no animals, hunting or fishing on servers yet" \ % Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM) bar.position = Vector2(16, 40) $HUD.add_child(bar) @@ -654,6 +723,7 @@ func _go_online() -> void: func _on_net_left(_reason: String) -> void: WorldNet.leave_world() + PlayerNet.leave_world() if is_inside_tree(): get_tree().paused = false get_tree().auto_accept_quit = true @@ -661,10 +731,10 @@ func _on_net_left(_reason: String) -> void: ## Leaves `count` of an item lying in the world at `xf` (an arrow stuck in a ## tree, a thrown spear, meat that didn't fit). Saved with the world. -## Online, the server puts it in the shared world (and returns null here). +## Online only the server drops things (our game never does: returns null). func drop_item(id: String, count: int, xf: Transform3D) -> Node3D: if net_sync != null: - WorldNet.drop(id, count, xf) + push_warning("[world] drop_item(%s) on a client: only the server drops items" % id) return null return _make_dropped(id, count, xf) @@ -675,6 +745,8 @@ func _make_dropped(id: String, count: int, xf: Transform3D) -> Node3D: pk.dropped = true _dropped.add_child(pk) pk.global_transform = xf + if server_mode and not _applying: + dropped_added.emit(pk) return pk ## Casts a fishing float (Actions validated it). One at a time. @@ -708,8 +780,12 @@ func remove_piece(piece: Node3D, refund_to: Node3D = null) -> int: _pieces.remove_child(piece) piece.queue_free() if not was_build: + if server_mode: + pieces_removed.emit([piece], []) return 0 var weak: Array = build.recompute() + if server_mode: + pieces_removed.emit([piece] + weak, weak) for w in weak: if refund_to != null: Actions._give(refund_to, w.item_id, 1) diff --git a/tests/test_net_shared.gd b/tests/test_net_shared.gd index 23d7359..090f98f 100644 --- a/tests/test_net_shared.gd +++ b/tests/test_net_shared.gd @@ -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()