diff --git a/docs/MULTIPLAYER_PLAN.md b/docs/MULTIPLAYER_PLAN.md index 092b204..e8ae7d4 100644 --- a/docs/MULTIPLAYER_PLAN.md +++ b/docs/MULTIPLAYER_PLAN.md @@ -226,10 +226,39 @@ offline there's no server and your game decides, exactly as before. - 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). +**Wildlife, hunting, fishing and sleep** *(built in M2.5: `creature_net.gd`, +autoload CreatureNet)*. +- The server's world runs the animals and the wolf pack. Animals live + around every player (the budget per player, up to twice it however many + are on), and go once they're far from all of them. A player joining gets + some wildlife about them straight away. +- **The pack splits between the players it hunts**: wolf 1 takes the + nearest to the den, wolf 2 the next, preferring players away from a fire. + Each hunted player has their own circle and their own one-wolf-at-a-time + darts. Fire, torches and walls work per player as in single-player. +- Ten times a second everyone gets every animal, wolf and carcass (23 bytes + each, split so no packet goes over the MTU: an oversized unreliable packet + is simply lost) and every fishing float. Games show them as **puppets** + (`creature.gd`/`bobber.gd` `puppet`) that animate and can be hit or + butchered, and decide nothing. Something missing for a second goes. +- **Hitting** (club, spear thrust) sends the creature's id; the server runs + Actions.melee on your character there. **Shooting and throwing** send only + the aim: the arrow or spear starts from where the server knows your eyes + are. The server's projectile hits; everyone else's screens fly a copy for + show (sent once, when it's loosed). An arrow that misses lands as a + dropped item for anyone. +- **Fishing**: one float per player, on the server. Bites, misses and a + snapped line are told to the player whose float it is (`Actions.tell`). +- **Sleeping** follows `sleep` in `server.json`: `all_online` (default), + `half` or `anyone`. Getting into a bed you've claimed puts you "in bed" + (moving more than 1.5 m gets you out); once enough players are in bed + the night passes for everyone (fires burn down, things regrow, the clock + jumps to 06:00) and sleepers wake rested. +- The pack's howls and "the wolves are hunting tonight" go to everyone; + being bitten or gored flashes your screen red as in single-player. + +Still to come: the server doesn't yet stop you sprinting when you're out of +stamina (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 @@ -303,7 +332,7 @@ player" or "this player": | **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** *(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.5** *(done)* | 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 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c2cf682..3c3df02 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -57,7 +57,7 @@ phases M2.0-M2.7). - [x] Replicate resource nodes, pickups, building, chests, fires, doors, clock *(M2.3)* - [x] Persistence: world save/load on the server *(M2.3)* - [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)* -- [ ] Animals, hunting, fishing and sleeping on the server *(M2.5)* +- [x] Animals, wolves, 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 32cf494..b649f44 100644 --- a/project.godot +++ b/project.godot @@ -29,6 +29,7 @@ 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" +CreatureNet="*res://scripts/net/creature_net.gd" [display] diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index 9a97a54..d4c9d1c 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -65,11 +65,25 @@ 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)" +## A message for one player: on screen in single-player; on a server, to +## that player's game (player_net.gd). For things that happen later than the +## request that caused them (a fish biting, an arrow landing a kill). +func tell(player: Node, text: String) -> void: + if Net.is_server() and player != null and player.get("peer") != null: + PlayerNet.tell(int(player.peer), text) + else: + notify.emit(text) -func _not_online() -> bool: - notify.emit(NOT_ONLINE) - return false +## A message for everyone (the wolves howling). +func tell_all(text: String) -> void: + if Net.is_server(): + PlayerNet.tell_all(text) + else: + notify.emit(text) + +## Online, a creature is known by its net id (creature_net.gd). +static func _cid(c: Node) -> int: + return int(c.get_meta("cid", 0)) 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 @@ -251,7 +265,12 @@ 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 + if not melee_stats(player).armed: + notify.emit(NEED_WEAPON) + return false + return _ask("melee", [_cid(target)]) if not is_instance_valid(target) or not target.is_in_group("creature"): return false var m := melee_stats(player) @@ -275,12 +294,12 @@ func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3, var was_alive: bool = not target.is_dead() target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id) if was_alive and target.is_dead(): - notify.emit("Killed a %s" % target.display_name().to_lower()) + tell(shooter, "Killed a %s" % target.display_name().to_lower()) ## 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() + return _ask("throw", [slot, dir]) var s = player.inventory.get_slot(slot) if s == null: return false @@ -298,9 +317,15 @@ 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 _online(): + # (The server checks arrows and spends the stamina when you shoot.) + if bow.is_empty(): + return false + if player.inventory.count(bow.ammo) <= 0: + notify.emit("No arrows (craft them at a workbench: sticks, flint, feathers)") + return false + return true if bow.is_empty(): return false if player.inventory.count(bow.ammo) <= 0: @@ -314,7 +339,7 @@ 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() + return _ask("shoot", [dir, power]) 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 @@ -328,7 +353,9 @@ func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: N 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"): + return false + return _ask("butcher", [_cid(carcass)]) 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() @@ -346,7 +373,9 @@ 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() + if ItemDB.get_item(player.held_item_id()).get("fishing", {}).is_empty(): + return false + return _ask("cast", [dir]) 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 @@ -361,14 +390,14 @@ 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 + return _ask("reel") + var b: Node3D = world.bobber_of(player) if b == null or not is_instance_valid(b): return false var caught := "" if b.state == "biting": caught = b.fish - world.end_fishing() + world.end_fishing(player) if caught.is_empty(): return false if _give(player, caught, 1) == 0: diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index 87b4e43..1d4278e 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -168,6 +168,9 @@ func step(dt: float) -> void: _flash -= dt if _flash <= 0.0: _set_material(_mat) + if puppet: + _puppet_step(dt) + return if is_dead(): carcass_left -= dt if carcass_left <= 0.0: @@ -175,10 +178,58 @@ func step(dt: float) -> void: return _attack_cd = maxf(_attack_cd - dt, 0.0) _aggro = maxf(_aggro - dt, 0.0) - _think(dt) + # Who it's reacting to: the player (or on a server, the nearest one). + var t := _pick_target() + if t != null: + player = t + _think(dt) + elif state in ["flee", "charge"]: + state = "idle" # nobody about _move(dt) _animate(dt) +func _pick_target() -> Node3D: + return world.nearest_player(global_position) + +# --- on a client playing online: the server's animal, shown here ------------ + +## A puppet only shows what the server's copy is doing (creature_net.gd): +## no thinking, no damage dealt. It can still be hit (that's sent to the +## server) and butchered. +var puppet := false +var _net_pos := Vector3.ZERO +var _net_yaw := 0.0 + +func net_update(pos: Vector3, y: float, st: String, spd: float, new_hp: float) -> void: + if not puppet: + return + _net_pos = pos + _net_yaw = y + speed = spd + if new_hp < hp - 0.01 and st != "dead": + _flash = 0.15 + _set_material(_hit_mat) + _burst() + hp = new_hp + if st == "dead" and state != "dead": + _burst() + _die(global_position + global_basis.z) + return + if state != "dead": + state = st + +func _puppet_step(dt: float) -> void: + if is_dead(): + return + var k := 1.0 - exp(-12.0 * dt) + var p := global_position.lerp(_net_pos, k) + if p.distance_to(_net_pos) > 6.0: + p = _net_pos # (a jump: a wolf slipping home to the den) + global_position = p + yaw = lerp_angle(yaw, _net_yaw, k) + rotation.y = yaw + _animate(dt) + ## Whether the player is close enough to be noticed. Sprinting, or moving ## fast while swimming or jumping, is loud. func _notices_player(d: float) -> bool: @@ -313,6 +364,8 @@ func _animate(dt: float) -> void: ## Applies a hit (Actions validates who hit what). `from` = where it came ## from, for the reaction. func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void: + if puppet: + return # (online the server's copy takes the hit) if is_dead(): if not embed_id.is_empty(): embedded[embed_id] = embedded.get(embed_id, 0) + 1 @@ -349,6 +402,9 @@ func _die(from: Vector3) -> void: collision_layer = 2 for l in _legs: l.rotation.x = 0.4 + if puppet: + _visual.rotation.z = -PI * 0.5 + return # Topple onto its side, away from the blow. var side := 1.0 if (global_position - from).dot(global_basis.x) > 0.0 else -1.0 if is_inside_tree() and DisplayServer.get_name() != "headless": diff --git a/scripts/creatures/projectile.gd b/scripts/creatures/projectile.gd index 3b4d0b5..c3423c6 100644 --- a/scripts/creatures/projectile.gd +++ b/scripts/creatures/projectile.gd @@ -51,7 +51,7 @@ func step(dt: float) -> void: velocity.y -= gravity * dt var to := from + velocity * dt var ex: Array[RID] = [] - if shooter != null: + if shooter != null and is_instance_valid(shooter) and shooter.has_method("get_rid"): ex.append(shooter.get_rid()) var q := PhysicsRayQueryParameters3D.create(from, to, 1 | 8, ex) var hit := get_world_3d().direct_space_state.intersect_ray(q) @@ -66,6 +66,9 @@ func step(dt: float) -> void: func _on_hit(hit: Dictionary) -> void: var c: Object = hit.collider var dir := velocity.normalized() + if visual: + queue_free() + return if c != null and (c as Node).is_in_group("creature"): var k := clampf(velocity.length() / _start_speed, 0.4, 1.0) Actions.projectile_hit(shooter, c, damage * k, global_position, item_id if item_id == "arrow" else "") @@ -83,5 +86,11 @@ func _on_hit(hit: Dictionary) -> void: _drop(Transform3D(b, at)) func _drop(xf: Transform3D) -> void: - world.drop_item(item_id, 1, xf) + if not visual: + world.drop_item(item_id, 1, xf) queue_free() + +## Online, our game shows the server's arrows and spears in flight +## (creature_net.gd). They hit nothing: the server's copy does that, and +## anything that lands comes from the server as a dropped item. +var visual := false diff --git a/scripts/creatures/spawner.gd b/scripts/creatures/spawner.gd index 03b9711..e7d8a5c 100644 --- a/scripts/creatures/spawner.gd +++ b/scripts/creatures/spawner.gd @@ -40,10 +40,21 @@ func alive(kind: String = "") -> Array: ## Fills the budget straight away (world start), closer in than usual so ## there's something to see, but not on top of the player. -func populate() -> void: +func populate(around: Vector3 = Vector3.INF) -> void: for kind in BUDGET: for i in range(BUDGET[kind]): - _spawn_near(kind, 22.0, RING_MAX) + _spawn_near(kind, 22.0, RING_MAX, around) + +## A player joined a server: some wildlife around them straight away, up to +## the island's budget. +func populate_for(pos: Vector3) -> void: + if not enabled: + return + var n: int = world.players().size() + for kind in BUDGET: + var near := alive(kind).filter(func(c): return c.global_position.distance_to(pos) < RING_MAX).size() + for i in range(mini(BUDGET[kind] - near, budget(kind, n) - alive(kind).size())): + _spawn_near(kind, 22.0, RING_MAX, pos) func _process(delta: float) -> void: if not enabled: @@ -54,19 +65,32 @@ func _process(delta: float) -> void: if _check > 0.0: return _check = CHECK_EVERY - var pp: Vector3 = world.player.global_position + # Around every player (on a server there can be several, wherever they + # are): an animal goes once it's far from all of them. + var everyone: Array = world.players() + if everyone.is_empty(): + return for c in get_children(): - if c.global_position.distance_to(pp) > DESPAWN: + var near := false + for p in everyone: + near = near or c.global_position.distance_to(p.global_position) <= DESPAWN + if not near: if c.is_in_group("creature"): _cooldown[c.kind] = RESPAWN_DELAY * 0.3 c.queue_free() for kind in BUDGET: if _cooldown[kind] > 0.0: continue - if alive(kind).size() < BUDGET[kind]: - if _spawn_near(kind, RING_MIN, RING_MAX) != null: + if alive(kind).size() < budget(kind, everyone.size()): + var who: Node3D = everyone[0] if everyone.size() == 1 else everyone[_rng.randi() % everyone.size()] + if _spawn_near(kind, RING_MIN, RING_MAX, who.global_position) != null: _cooldown[kind] = RESPAWN_DELAY * 0.25 +## How many of a kind the island holds: the budget per player, up to twice +## it however many are on (MULTIPLAYER_PLAN.md §11). +static func budget(kind: String, players: int) -> int: + return BUDGET[kind] * clampi(players, 1, 2) + func _on_died(c: Node) -> void: _cooldown[c.kind] = RESPAWN_DELAY @@ -78,8 +102,8 @@ func _habitat(kind: String, x: float, z: float) -> float: "deer": return 1.0 - absf(f - 0.45) * 1.6 _: return smoothstep(0.35, 0.75, f) -func _spawn_near(kind: String, rmin: float, rmax: float) -> Node3D: - var pp: Vector3 = world.player.global_position +func _spawn_near(kind: String, rmin: float, rmax: float, around: Vector3 = Vector3.INF) -> Node3D: + var pp: Vector3 = around if around != Vector3.INF else world.player.global_position for _try in range(40): var a := _rng.randf() * TAU var r := _rng.randf_range(rmin, rmax) diff --git a/scripts/creatures/wolf.gd b/scripts/creatures/wolf.gd index 258de15..a479752 100644 --- a/scripts/creatures/wolf.gd +++ b/scripts/creatures/wolf.gd @@ -33,6 +33,9 @@ func interact_prompt(p: Node) -> String: s += " (you need a weapon)" return s +func _pick_target() -> Node3D: + return pack.target_for(self) + func _think(dt: float) -> void: var pp: Vector3 = player.global_position var d := global_position.distance_to(pp) @@ -85,7 +88,7 @@ func _think_hunt(pp: Vector3, d: float) -> void: match state: "lunge": # Break off first if you've reached a fire or it's taking too long. - if _state_t <= 0.0 or not pack.may_close_in(): + if _state_t <= 0.0 or not pack.may_close_in(player): state = "stalk" pack.lunge_done(self) else: @@ -98,7 +101,7 @@ func _think_hunt(pp: Vector3, d: float) -> void: _: state = "stalk" _go_to(pack.circle_point(self)) - if pack.request_lunge(self): + if pack.request_lunge(self, player): state = "lunge" _state_t = 4.0 diff --git a/scripts/creatures/wolf_pack.gd b/scripts/creatures/wolf_pack.gd index 33a122c..e0b9f81 100644 --- a/scripts/creatures/wolf_pack.gd +++ b/scripts/creatures/wolf_pack.gd @@ -107,16 +107,19 @@ func step(dt: float) -> void: if not night and alive_count() < pack_size(day): fill_den(false) _gap = maxf(_gap - dt, 0.0) + for t in _gaps.keys(): + _gaps[t] = maxf(float(_gaps[t]) - dt, 0.0) + if not is_instance_valid(t): + _gaps.erase(t) _circle_a += dt * 0.25 if _lunger != null and (not is_instance_valid(_lunger) or _lunger.is_dead() or _lunger.state != "lunge"): _lunger = null # At dawn wolves head home; once one is well away from you (out of sight), # it slips straight back rather than picking its way across the island. if mode == "den": - var pp: Vector3 = world.player.global_position for m in alive(): - if m.global_position.distance_to(den_pos) > 6.0 and m.global_position.distance_to(pp) > 45.0 \ - and pp.distance_to(den_pos) > 35.0: + if m.global_position.distance_to(den_pos) > 6.0 and _far_from_everyone(m.global_position, 45.0) \ + and _far_from_everyone(den_pos, 35.0): m.global_position = den.slot_pos(m.slot) m.wounded = false m.state = "rest" @@ -146,9 +149,9 @@ func _warn(day: int) -> void: return _warned[day] = true if day == DEN_DAY: - Actions.notify.emit("Wolves are howling up in the hills. They'll come hunting soon.") + Actions.tell_all("Wolves are howling up in the hills. They'll come hunting soon.") elif day == HUNT_DAY: - Actions.notify.emit("The wolves are hunting tonight. Stay near a fire or behind closed walls.") + Actions.tell_all("The wolves are hunting tonight. Stay near a fire or behind closed walls.") _howl_left = _rng.randf_range(2.0, 6.0) ## Brings the pack up to size, wolves lying in the den. `force` ignores @@ -157,7 +160,7 @@ func fill_den(force: bool) -> void: var need: int = pack_size(effective_day()) - alive_count() if need <= 0: return - if not force and world.player.global_position.distance_to(den_pos) < 35.0: + if not force and not _far_from_everyone(den_pos, 35.0): return members = alive() var used := {} @@ -181,44 +184,119 @@ func fill_den(force: bool) -> void: func home_slot(w: Node) -> Vector3: return den.slot_pos(w.slot) -## The lit campfire you're sheltering by, if any. -func player_fire() -> Node3D: - var pp: Vector3 = world.player.global_position +func _far_from_everyone(pos: Vector3, r: float) -> bool: + for p in world.players(): + if p.global_position.distance_to(pos) < r: + return false + return true + +# --- who the pack goes after ---------------------------------------------------- +# In single-player that's you. On a server the pack splits between the +# players it's hunting: wolf 1 takes the first, wolf 2 the second, and so on, +# preferring players away from a fire (you can't be got at by a fire, so +# they'd rather go for someone who isn't). Each target gets their own circle +# and their own one-wolf-at-a-time darts. + +var _targets: Array = [] +var _target_t := 0.0 + +## Who wolf `w` is after right now (null: nobody about). +func target_for(w: Node) -> Node3D: + if not world.server_mode: + return world.player + if mode != "hunt": + return world.nearest_player((w as Node3D).global_position) + _refresh_targets() + if _targets.is_empty(): + return null + var i := alive().find(w) + return _targets[maxi(i, 0) % _targets.size()] + +## Players worth hunting, nearest the den first: those away from a fire if +## there are any, otherwise everyone (they'll circle the fire). +func _refresh_targets() -> void: + var now := Time.get_ticks_msec() / 1000.0 + if now - _target_t < 1.0 and _targets.all(func(p): return is_instance_valid(p) and not p.survival.dead): + return + _target_t = now + var everyone: Array = world.players() + var open := everyone.filter(func(p): return fire_near(p) == null) + _targets = open if not open.is_empty() else everyone + _targets.sort_custom(func(a, b): return a.global_position.distance_to(den_pos) < b.global_position.distance_to(den_pos)) + +func _p(p: Node3D) -> Node3D: + return p if p != null else world.player + +## The lit campfire `p` is sheltering by, if any. +func fire_near(p: Node3D) -> Node3D: + var pp: Vector3 = _p(p).global_position for f in get_tree().get_nodes_in_group("station:campfire"): if f.station_active() and (f as Node3D).global_position.distance_to(pp) < FIRE_SAFE: return f return null -func _torch() -> bool: - return world.player.hands != null and world.player.hands.held_heat() > 0.0 +## (Single-player name, used by tests.) +func player_fire() -> Node3D: + return fire_near(world.player) + +func _torch(p: Node3D = null) -> bool: + var who := _p(p) + return who.get("hands") != null and who.hands.held_heat() > 0.0 ## Wolves only close in when you're away from a fire. -func may_close_in() -> bool: - return player_fire() == null and not world.player.survival.dead +func may_close_in(p: Node3D = null) -> bool: + return fire_near(p) == null and not _p(p).survival.dead + +## The wolves going after the same player as `w`. +func _hunting_with(w: Node) -> Array: + var t := target_for(w) + return alive().filter(func(m): return target_for(m) == t) ## Where wolf `w` should be while circling. func circle_point(w: Node) -> Vector3: - var fire := player_fire() - var center: Vector3 = fire.global_position if fire != null else world.player.global_position - var r := FIRE_RING if fire != null else RING * (1.3 if _torch() else 1.0) - var live := alive() - var n := maxi(live.size(), 1) - var a := _circle_a + TAU * float(live.find(w)) / n + var t := _p(target_for(w)) + var fire := fire_near(t) + var center: Vector3 = fire.global_position if fire != null else t.global_position + var r := FIRE_RING if fire != null else RING * (1.3 if _torch(t) else 1.0) + var group := _hunting_with(w) + var n := maxi(group.size(), 1) + var a := _circle_a + TAU * float(maxi(group.find(w), 0)) / n return center + Vector3(cos(a), 0, sin(a)) * r -## A wolf asks for its turn to dart in. -func request_lunge(w: Node) -> bool: - if _lunger != null or _gap > 0.0 or not may_close_in() or alive_count() < 2: - return false - if w.global_position.distance_to(world.player.global_position) > RING * 1.8: +## Darting in: one wolf at a time per player being hunted. +var _lungers: Dictionary = {} ## target -> wolf +var _gaps: Dictionary = {} ## target -> seconds until the next dart + +## A wolf asks for its turn to dart in at `p`. +func request_lunge(w: Node, p: Node3D = null) -> bool: + var t := _p(p) + if not world.server_mode: + if _lunger != null or _gap > 0.0 or not may_close_in(t) or alive_count() < 2: + return false + else: + var cur: Node = _lungers.get(t) + if cur != null and is_instance_valid(cur) and not cur.is_dead() and cur.state == "lunge": + return false + if float(_gaps.get(t, 0.0)) > 0.0 or not may_close_in(t) or alive_count() < 2: + return false + if w.global_position.distance_to(t.global_position) > RING * 1.8: return false _lunger = w + _lungers[t] = w return true func lunge_done(w: Node) -> void: if _lunger == w: _lunger = null - _gap = ATTACK_GAP * (2.5 if _torch() else 1.0) * _rng.randf_range(0.8, 1.3) + if not world.server_mode: + _gap = ATTACK_GAP * (2.5 if _torch() else 1.0) * _rng.randf_range(0.8, 1.3) + return + var t: Node3D = target_for(w) if w != null and is_instance_valid(w) else null + for k in _lungers.keys(): + if _lungers[k] == w: + _lungers.erase(k) + if t != null: + _gaps[t] = ATTACK_GAP * (2.5 if _torch(t) else 1.0) * _rng.randf_range(0.8, 1.3) ## A wolf was hit: the whole pack turns on you. func alert(_from: Vector3) -> void: @@ -227,7 +305,12 @@ func alert(_from: Vector3) -> void: func howl() -> void: var live := alive() - if _audio == null or live.is_empty(): + if live.is_empty(): + return + if world.server_mode: + CreatureNet.howl((live[0] as Node3D).global_position + Vector3(0, 1, 0)) # everyone hears it + return + if _audio == null: return _audio.global_position = (live[0] as Node3D).global_position + Vector3(0, 1, 0) _audio.pitch_scale = _rng.randf_range(0.9, 1.1) diff --git a/scripts/fishing/bobber.gd b/scripts/fishing/bobber.gd index 40dce38..4a989e9 100644 --- a/scripts/fishing/bobber.gd +++ b/scripts/fishing/bobber.gd @@ -55,7 +55,15 @@ func setup(w: Node3D, p: Node3D, from: Vector3, vel: Vector3, seed: int) -> void _line.material_override = lm add_child(_line) +## Online, our game shows the server's float (creature_net.gd): it doesn't +## run any fishing itself, it's just moved and shown. +var puppet := false + func _physics_process(delta: float) -> void: + if puppet: + _t += delta + _mesh.position.y = (-0.06 + sin(_t * 18.0) * 0.03) if state == "biting" else (sin(_t * 2.2) * 0.012 if state == "floating" else 0.0) + return step(delta) func _process(_delta: float) -> void: @@ -94,8 +102,8 @@ func step(dt: float) -> void: _t += dt var terrain: Node3D = world.terrain if global_position.distance_to(player.global_position) > LINE_LENGTH + 4.0: - Actions.notify.emit("The line snapped") - world.end_fishing() + Actions.tell(player, "The line snapped") + world.end_fishing(player) return match state: "flying": @@ -109,17 +117,17 @@ func step(dt: float) -> void: table = ItemDB.water_fishing(water_type) if table.is_empty() or table.fish.is_empty(): state = "landed" - Actions.notify.emit("Nothing to catch here") + Actions.tell(player, "Nothing to catch here") elif float(water.depth) < float(table.min_depth): state = "landed" - Actions.notify.emit("Too shallow here: cast further out") + Actions.tell(player, "Too shallow here: cast further out") else: state = "floating" wait_left = _next_wait() elif p.y <= ground + 0.03: p.y = ground + 0.03 state = "landed" - Actions.notify.emit("Cast into the water") + Actions.tell(player, "Cast into the water") global_position = p "floating": wait_left -= dt @@ -128,7 +136,7 @@ func step(dt: float) -> void: state = "biting" fish = _pick_fish() bite_left = BITE_WINDOW - Actions.notify.emit("Bite! Click to reel in") + Actions.tell(player, "Bite! Click to reel in") "biting": bite_left -= dt # The float bobs hard and goes under. @@ -138,7 +146,7 @@ func step(dt: float) -> void: fish = "" wait_left = _next_wait() * float(ItemDB.fishing.modifiers.get("missed_wait", 1.3)) _mesh.position.y = 0.0 - Actions.notify.emit("It got away") + Actions.tell(player, "It got away") func _draw_line() -> void: _imesh.clear_surfaces() diff --git a/scripts/net/creature_net.gd b/scripts/net/creature_net.gd new file mode 100644 index 0000000..9c060b4 --- /dev/null +++ b/scripts/net/creature_net.gd @@ -0,0 +1,257 @@ +extends Node +## Autoload "CreatureNet": the wildlife on a server (M2.5, +## docs/MULTIPLAYER_PLAN.md §5). +## +## The server's world runs the animals and the wolf pack (world.gd in server +## mode), so everyone sees the same rabbit in the same place, and a pack +## hunts whoever it likes. Ten times a second it sends everyone: +## - every animal and wolf, alive or a carcass: id, kind, where, facing, +## what it's doing, its speed and health; +## - every fishing float: whose it is, where, and whether a fish is biting. +## Players' games show these as puppets (creature.gd, bobber.gd `puppet`): +## they animate and can be hit or butchered (Actions sends that to the +## server), but decide nothing. Arrows and spears in flight are sent once +## when they're loosed and flown on each screen for show; only the server's +## copy hits anything. Howls are sent too. +## +## Snapshots are unreliable (a lost one is replaced 0.1 s later). Something +## missing from the snapshots for a second (despawned, butchered) goes. + +const Creature = preload("res://scripts/creatures/creature.gd") +const Bobber = preload("res://scripts/fishing/bobber.gd") +const Projectile = preload("res://scripts/creatures/projectile.gd") +const WolfPack = preload("res://scripts/creatures/wolf_pack.gd") +const SEND_INTERVAL := 0.1 +const FORGET_AFTER := 1.0 + +# Server. +var _next_cid := 1 +var _by_cid: Dictionary = {} ## cid -> creature (server) +var _send_left := 0.0 + +# Client. +var world: Node3D +var puppets: Dictionary = {} ## cid -> creature.gd puppet +var floats: Dictionary = {} ## owner peer -> bobber.gd puppet +var _seen: Dictionary = {} ## cid or "f" -> last time seen (s) +var _root: Node3D +var _howl_player: AudioStreamPlayer3D +var projectiles_seen := 0 ## tests read these +var howls_heard := 0 + +# --- server ---------------------------------------------------------------------- + +func _server_world() -> Node3D: + return WorldNet.world if Net.is_server() and WorldNet.world != null and is_instance_valid(WorldNet.world) else null + +## The server's creature with id `cid`, alive or a carcass. +func creature(cid: int) -> Node3D: + var c: Node3D = _by_cid.get(cid) + return c if c != null and is_instance_valid(c) and not c.is_queued_for_deletion() else null + +func _process(delta: float) -> void: + var w := _server_world() + if w != null: + _server_tick(w, delta) + elif world != null: + _client_tick() + +func _server_tick(w: Node3D, delta: float) -> void: + if not w.launched.is_connected(_on_launched): + w.launched.connect(_on_launched) + _send_left -= delta + if _send_left > 0.0: + return + _send_left = SEND_INTERVAL + var all: Array = [] + _by_cid.clear() + for group in ["creature", "carcass"]: + for c in w.get_tree().get_nodes_in_group(group): + if not c.has_meta("cid"): + c.set_meta("cid", _next_cid) + _next_cid += 1 + _by_cid[int(c.get_meta("cid"))] = c + all.append(c) + var fl: Array = [] + for p in w.bobbers: + var b: Node3D = w.bobbers[p] + if is_instance_valid(b) and is_instance_valid(p): + fl.append([int(p.peer), b.global_position, b.state]) + if all.is_empty() and fl.is_empty(): + return + # Packed tight and split, so no packet goes over the MTU (a lost + # fragment loses the whole unreliable packet). + var i := 0 + while i < maxi(all.size(), 1): + var chunk := PackedByteArray() + chunk.resize(mini(PER_PACKET, all.size() - i) * ENTRY) + var o := 0 + for c in all.slice(i, i + PER_PACKET): + _encode(chunk, o, c) + o += ENTRY + for peer in WorldNet._ready_peers: + _s2c_life.rpc_id(peer, chunk, fl if i == 0 else []) + i += PER_PACKET + +const PER_PACKET := 40 +const ENTRY := 23 +const KIND_IDS := ["rabbit", "deer", "boar", "wolf"] +const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend"] + +## One creature in 23 bytes: id, kind, state, position, facing, speed, health. +static func _encode(buf: PackedByteArray, o: int, c: Node3D) -> void: + buf.encode_u32(o, int(c.get_meta("cid"))) + buf.encode_u8(o + 4, maxi(KIND_IDS.find(c.kind), 0)) + buf.encode_u8(o + 5, maxi(STATE_IDS.find(c.state), 0)) + var p: Vector3 = c.global_position + buf.encode_float(o + 6, p.x) + buf.encode_float(o + 10, p.y) + buf.encode_float(o + 14, p.z) + buf.encode_u16(o + 18, int(fposmod(c.yaw, TAU) / TAU * 65535.0)) + buf.encode_u8(o + 20, clampi(int(c.speed * 20.0), 0, 255)) + buf.encode_u16(o + 21, clampi(int(c.hp * 100.0), 0, 65535)) + +static func _decode(buf: PackedByteArray, o: int) -> Array: + return [buf.decode_u32(o), KIND_IDS[mini(buf.decode_u8(o + 4), KIND_IDS.size() - 1)], + Vector3(buf.decode_float(o + 6), buf.decode_float(o + 10), buf.decode_float(o + 14)), + buf.decode_u16(o + 18) / 65535.0 * TAU, STATE_IDS[mini(buf.decode_u8(o + 5), STATE_IDS.size() - 1)], + buf.decode_u8(o + 20) / 20.0, buf.decode_u16(o + 21) / 100.0] + +func _on_launched(id: String, origin: Vector3, vel: Vector3, shooter: Node3D) -> void: + var by := int(shooter.peer) if shooter != null and is_instance_valid(shooter) and shooter.get("peer") != null else 0 + for peer in WorldNet._ready_peers: + _s2c_launched.rpc_id(peer, id, origin, vel, by) + +## The pack howls (wolf_pack.gd): everyone hears it, from where it is. +func howl(at: Vector3) -> void: + for peer in WorldNet._ready_peers: + _s2c_howl.rpc_id(peer, at) + +# --- client ---------------------------------------------------------------------- + +func join_world(w: Node3D) -> void: + world = w + puppets.clear() + floats.clear() + _seen.clear() + projectiles_seen = 0 + howls_heard = 0 + _root = Node3D.new() + _root.name = "NetCreatures" + w.add_child(_root) + +func leave_world() -> void: + if not Net.is_server(): + world = null + puppets.clear() + floats.clear() + _root = null + +static func _now() -> float: + return Time.get_ticks_msec() / 1000.0 + +func _alive_world() -> bool: + return world != null and is_instance_valid(world) and _root != null and is_instance_valid(_root) + +## Anything the server has stopped sending goes. +func _client_tick() -> void: + if not _alive_world(): + return + var now := _now() + for cid in puppets.keys(): + if now - float(_seen.get(cid, 0.0)) > FORGET_AFTER: + var c: Variant = puppets[cid] + puppets.erase(cid) + if is_instance_valid(c): + (c as Node).queue_free() + for peer in floats.keys(): + if now - float(_seen.get("f%d" % peer, 0.0)) > FORGET_AFTER * 0.6: + _drop_float(peer) + +func _drop_float(peer: int) -> void: + var b: Variant = floats.get(peer) # (may already be freed: untyped) + floats.erase(peer) + if is_instance_valid(b): + (b as Node).queue_free() + if peer == Net.local_peer and world != null and is_instance_valid(world): + world.bobber = null + +@rpc("authority", "call_remote", "unreliable_ordered") +func _s2c_life(packed: PackedByteArray, fl: Array) -> void: + if not _alive_world(): + return + var now := _now() + var list: Array = [] + for o in range(0, packed.size() - ENTRY + 1, ENTRY): + list.append(_decode(packed, o)) + for e in list: + var cid := int(e[0]) + var kind := String(e[1]) + if not Creature.KINDS.has(kind): + continue + var got: Variant = puppets.get(cid) + var c: Node3D = got if is_instance_valid(got) else null + if c == null: + c = Creature.new() + c.puppet = true + c.setup(kind, world, e[2], cid) + c.set_meta("cid", cid) + c.yaw = float(e[3]) + _root.add_child(c) + c.global_position = e[2] + c.rotation.y = c.yaw + puppets[cid] = c + c.net_update(e[2], float(e[3]), String(e[4]), float(e[5]), float(e[6])) + _seen[cid] = now + for f in fl: + _float_update(int(f[0]), f[1], String(f[2])) + _seen["f%d" % int(f[0])] = now + +func _float_update(peer: int, pos: Vector3, state: String) -> void: + var got: Variant = floats.get(peer) + var b: Node3D = got if is_instance_valid(got) else null + if b == null: + b = Bobber.new() + b.puppet = true + b.setup(world, world.player, pos, Vector3.ZERO, 0) + _root.add_child(b) + floats[peer] = b + if peer == Net.local_peer: + world.bobber = b # hands.gd: the prompt, the line from our rod + b.global_position = pos + b.state = state + if peer != Net.local_peer: + # Someone else's line: from about where their rod would be. + var av: Node3D = world.net_sync.avatars.get(peer) if world.net_sync != null else null + if av != null and is_instance_valid(av): + b.tip = av.global_position + Vector3(0, 1.5, 0) + (-av.global_basis.z) * 0.8 + else: + b.tip = pos + Vector3(0, 0.1, 0) + +@rpc("authority", "call_remote", "reliable") +func _s2c_launched(id: String, origin: Vector3, vel: Vector3, by: int) -> void: + if not _alive_world(): + return + projectiles_seen += 1 + var pr: Node3D = Projectile.new() + pr.visual = true + pr.setup(id, world, origin, vel, 0.0, world.player if by == Net.local_peer else null) + _root.add_child(pr) + +@rpc("authority", "call_remote", "reliable") +func _s2c_howl(at: Vector3) -> void: + if not _alive_world(): + return + howls_heard += 1 + if DisplayServer.get_name() == "headless": + return + if _howl_player == null or not is_instance_valid(_howl_player): + _howl_player = AudioStreamPlayer3D.new() + _howl_player.stream = WolfPack.howl_stream() + _howl_player.unit_size = 30.0 + _howl_player.max_distance = 400.0 + _howl_player.volume_db = 2.0 + _root.add_child(_howl_player) + _howl_player.global_position = at + _howl_player.pitch_scale = randf_range(0.9, 1.1) + _howl_player.play() diff --git a/scripts/net/net.gd b/scripts/net/net.gd index 2b6d55e..03966f0 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 := 4 ## 3: the shared world (M2.3); 4: the server decides (M2.4) +const PROTOCOL := 5 ## 3: shared world (M2.3); 4: the server decides (M2.4); 5: wildlife (M2.5) const DEFAULT_PORT := 24242 const HANDSHAKE_TIMEOUT := 10.0 const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join @@ -272,6 +272,12 @@ func _tick_clock(delta: float) -> void: for peer in players: _s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time) +## Everyone gets the clock now (after a night's sleep, an admin's /time). +func push_clock() -> void: + _clock_left = CLOCK_INTERVAL + for peer in players: + _s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time) + ## Where a player is, as far as the server knows. func player_pos(peer: int) -> Vector3: return _states[peer].pos if _states.has(peer) else Vector3.INF @@ -440,6 +446,7 @@ func _c2s_proof(sig: PackedByteArray) -> void: if start == Vector3.INF: start = _spawn_for(players.size()) PlayerNet.place_at(peer, start) + _world.creatures.populate_for(start) # some wildlife about them straight away players[peer] = p _states[peer] = {"pos": start, "yaw": yaw, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0, "stamp": server_time, "off": INF} diff --git a/scripts/net/net_sync.gd b/scripts/net/net_sync.gd index 2aabf20..6304831 100644 --- a/scripts/net/net_sync.gd +++ b/scripts/net/net_sync.gd @@ -130,3 +130,4 @@ func _on_clock(day: int, time: float) -> void: if absf(diff) > 0.01: world.day_night.time = time world.day_night.apply(true) + world.weather.snap() # (a night slept through: the weather jumps too) diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd index d3858b3..5343707 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -19,7 +19,6 @@ signal cursor_changed(stack) ## client: the server says this is on your cur 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 @@ -69,6 +68,10 @@ func server_join(peer: int, id: String, display: String) -> Vector3: 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)) + # A wolf bite or a boar: their screen flashes red, from the right side. + p.survival.hurt.connect(func(amount, from): + if proxies.has(peer): + _s2c_hurt.rpc_id(peer, amount, from)) var at := Vector3.INF var d := _read(_file(id)) if not d.is_empty(): @@ -96,10 +99,14 @@ func server_leave(peer: int) -> void: if p.survival.dead: _respawn(peer, false) # left on the death screen: they come back alive _save(p) + var w: Node3D = WorldNet.world + if w != null and w.bobber_of(p) != null: + w.end_fishing(p) proxies.erase(peer) _dirty.erase(peer) _acks.erase(peer) p.queue_free() + _maybe_pass_night() # (the last one awake leaving lets the others sleep) func save_all() -> void: for p in proxies.values(): @@ -149,6 +156,9 @@ func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int, p.rotation.y = yaw if to.y < p.water_level - 0.6: p.survival.soak() + if p.in_bed and to.distance_to(p.bed_at) > 1.5: + p.in_bed = false + tell(p.peer, "You got out of bed") func _process(delta: float) -> void: if not Net.is_server() or proxies.is_empty(): @@ -356,8 +366,129 @@ func _run(p: Node3D, what: String, a: Array) -> void: "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))) + # --- hunting and fishing (M2.5) --- + # Arrows, spears and casts start from where the server knows you are + # (your eyes), whatever your game says: only the aim comes from you. + "melee": + var c: Node3D = CreatureNet.creature(_i(a, 0)) + if c != null: + Actions.melee(p, c) + "butcher": + var c: Node3D = CreatureNet.creature(_i(a, 0)) + if c != null: + Actions.butcher(p, c) + "shoot": + var dir := _dir(a, 0) + var power := clampf(float(a[1]) if a.size() > 1 and (a[1] is float or a[1] is int) else 0.0, 0.0, 1.0) + if dir != Vector3.ZERO and Actions.can_draw(p): + Actions.shoot(p, _eye(p) + dir * 0.5, dir, power, w) + "throw": + var dir := _dir(a, 1) + if dir != Vector3.ZERO: + Actions.throw_spear(p, _i(a, 0), _eye(p) + dir * 0.5 + Vector3(0, -0.05, 0), dir + Vector3(0, 0.06, 0), w) + "cast": + var dir := _dir(a, 0) + if dir != Vector3.ZERO: + Actions.cast_line(p, _eye(p) + dir * 0.6, dir, w) + "reel": + Actions.reel(p, w) + "debug_spawn": + if cheats and Creature.KINDS.has(_s(a, 0)) and _s(a, 0) != "wolf" and _v(a, 1).is_finite(): + w.creatures.spawn(_s(a, 0), _v(a, 1)) + "debug_time": + if cheats: + w.day_night.day = maxi(_i(a, 0), 1) + w.day_night.time = clampf(float(a[1]) / 24.0, 0.0, 0.999) if a.size() > 1 else w.day_night.time + w.day_night.apply(true) + w.weather.snap() + Net.push_clock() + "debug_tp": + var to := _v(a, 0) + if cheats and to.is_finite(): + p.global_position = to + Net.teleport(p.peer, to) + _s2c_respawn.rpc_id(p.peer, to, p.rotation.y, "") + "debug_hut": + if cheats and _v(a, 0).is_finite(): + _debug_hut(_v(a, 0)) _: - Actions.notify.emit(NOT_YET if what in ["hunt", "fish"] else "Unknown request") + Actions.notify.emit("Unknown request") + +const Creature = preload("res://scripts/creatures/creature.gd") + +static func _eye(p: Node3D) -> Vector3: + return p.global_position + Vector3(0, 1.6, 0) + +## A unit aim direction from the request, or ZERO. +static func _dir(a: Array, k: int) -> Vector3: + var d := _v(a, k) + return d.normalized() if d.is_finite() and d.length() > 0.01 else Vector3.ZERO + +## Test hook: a closed hut (floors, walls, door, roof) with two beds in it. +func _debug_hut(center: Vector3) -> void: + var w: Node3D = WorldNet.world + var out: Dictionary = await w.build.debug_build_hut(center, 0.0) + if out.pieces.is_empty(): + return + var top: float = out.pieces[0].global_position.y + for x in [-0.6, 0.6]: + w.spawn_piece(ItemDB.get_item("bed"), Transform3D(Basis(), Vector3(center.x + x, top, center.z - 0.3))) + +# --- server: messages, getting hurt, sleeping ---------------------------------------- + +func tell(peer: int, text: String) -> void: + if proxies.has(peer): + _s2c_notify.rpc_id(peer, text) + +func tell_all(text: String) -> void: + for peer in proxies: + _s2c_notify.rpc_id(peer, text) + +## The server's sleep rule (server.json "sleep"): how many of the players +## online must be in bed for the night to pass. +func _beds_needed(n: int) -> int: + match String(Net.config.v("sleep")): + "anyone": return 1 + "half": return ceili(n / 2.0) + return n # "all_online" (the default) + +## Actions.use_bed decided `p` may sleep: they get into bed, and the night +## passes once enough players are in bed. +func go_to_bed(p: Node3D) -> void: + p.in_bed = true + p.bed_at = p.global_position + if not _maybe_pass_night(): + var n: int = _alive().size() + var asleep: int = _alive().filter(func(q): return q.in_bed).size() + tell(p.peer, "You're in bed (%d of %d asleep). The night passes when %s." % [asleep, n, + "everyone's in bed" if _beds_needed(n) >= n else "%d are" % _beds_needed(n)]) + for q in _alive(): + if q != p: + tell(q.peer, "%s is in bed (%d of %d asleep)" % [p.display, asleep, n]) + +func _alive() -> Array: + return proxies.values().filter(func(q): return not q.survival.dead) + +## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires, +## regrowth and weather move on; sleepers wake rested. +func _maybe_pass_night() -> bool: + var w: Node3D = WorldNet.world + var live := _alive() + if w == null or live.is_empty(): + return false + var asleep := live.filter(func(q): return q.in_bed) + if asleep.size() < _beds_needed(live.size()): + return false + var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length + w.skip_time(secs) + for q in live: + if q.in_bed: + q.survival.after_sleep(secs) + q.in_bed = false + _s2c_slept.rpc_id(q.peer, asleep.has(q)) + Net.push_clock() + WorldNet.save() + return true ## The chest or grave `nid`, if this player may use it right now. func _container(p: Node3D, nid: int) -> Node3D: @@ -451,3 +582,22 @@ func _s2c_respawn(at: Vector3, yaw: float, text: String) -> void: @rpc("authority", "call_remote", "reliable") func _s2c_notify(text: String) -> void: Actions.notify.emit(text) + +var hurts := 0 ## client: how often the server said we were hurt (tests read it) + +@rpc("authority", "call_remote", "reliable") +func _s2c_hurt(amount: float, from: Vector3) -> void: + hurts += 1 + if world != null and is_instance_valid(world): + world.player.survival.hurt.emit(amount, from) + +## The night passed (we were asleep, or up while the others slept). +@rpc("authority", "call_remote", "reliable") +func _s2c_slept(was_asleep: bool) -> void: + if world == null or not is_instance_valid(world): + return + var text := "You slept until morning" if was_asleep else "The night passed while the others slept" + if world.hud != null and was_asleep: + world.hud.fade(func(): Actions.notify.emit(text)) + else: + Actions.notify.emit(text) diff --git a/scripts/net/server_player.gd b/scripts/net/server_player.gd index c67398a..619a71a 100644 --- a/scripts/net/server_player.gd +++ b/scripts/net/server_player.gd @@ -1,4 +1,4 @@ -extends Node3D +extends CharacterBody3D ## 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...). @@ -23,8 +23,12 @@ 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 +## (A body with no collision, so it has a physics id: wolves look for a clear +## line to it, and arrows skip whoever shot them. `velocity` comes from the +## player's moves.) var water_level := -INF +var in_bed := false ## got into bed, waiting for the night to pass +var bed_at := Vector3.ZERO var has_bed_spawn := false var bed_spawn := Vector3.ZERO var anim := 0 ## last movement state reported (avatar.gd Anim) @@ -32,6 +36,9 @@ var died_at := 0 ## ticks (ms) when they last died func setup(world: Node3D) -> void: hands = self + collision_layer = 0 + collision_mask = 0 + set_physics_process(false) water_level = world.terrain.water_level survival = preload("res://scripts/player/survival.gd").new() survival.name = "Survival" diff --git a/scripts/world/world.gd b/scripts/world/world.gd index ebb4cfb..b7a42d1 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -270,8 +270,6 @@ func respawn_from_server(at: Vector3, yaw: float) -> void: ## Why you can't sleep in `bed` right now ("" = you can). func sleep_block_reason(bed: Node3D) -> String: - 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: return "You can only sleep at night (after 19:30)" @@ -284,7 +282,12 @@ func sleep_block_reason(bed: Node3D) -> String: return "" ## Sleeps until 06:00: the clock, fires, regrowth and weather all move on. +## On a server you get into bed, and the night passes once enough players +## are in bed (the server's sleep rule, player_net.gd). func sleep(p: Node3D) -> void: + if server_mode: + PlayerNet.go_to_bed(p) + return var secs: float = fposmod(6.0 - day_night.hours(), 24.0) / 24.0 * day_night.day_length if hud != null: hud.fade(func(): _finish_sleep(p, secs)) @@ -335,7 +338,18 @@ func _on_terrain_ready() -> void: var saved: Dictionary = Saves.load_state(Saves.current_id) if not saved.is_empty(): apply_save(saved) - return # no player, no animals (the server runs those in M2.5) + # The server runs the wildlife and the wolves for everyone (M2.5): + # animals live around the players, and players see them via + # creature_net.gd. + set_physics_process(false) # (no player of our own to keep on the map) + creatures.setup(self) + creatures.enabled = not "--no-creatures" in OS.get_cmdline_user_args() + if den.found: + wolves = preload("res://scripts/creatures/wolf_pack.gd").new() + wolves.name = "Wolves" + add_child(wolves) + wolves.setup(self, den) + return _respawn() if Saves.has_world(): var state: Dictionary = Saves.load_state(Saves.current_id) @@ -714,9 +728,9 @@ func _go_online() -> void: props.regrow_enabled = false WorldNet.join_world(self) PlayerNet.join_world(self) # and our inventory and stats + CreatureNet.join_world(self) # the server's animals, wolves, arrows and floats if hud != null: - 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) + var bar := UITheme.label("Online: %s" % Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM) bar.position = Vector2(16, 40) $HUD.add_child(bar) Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server")) @@ -724,6 +738,7 @@ func _go_online() -> void: func _on_net_left(_reason: String) -> void: WorldNet.leave_world() PlayerNet.leave_world() + CreatureNet.leave_world() if is_inside_tree(): get_tree().paused = false get_tree().auto_accept_quit = true @@ -750,25 +765,69 @@ func _make_dropped(id: String, count: int, xf: Transform3D) -> Node3D: return pk ## Casts a fishing float (Actions validated it). One at a time. +## One float per player (on a server, everyone can fish at once). `bobber` +## is the local player's. func start_fishing(p: Node3D, origin: Vector3, vel: Vector3) -> Node3D: - end_fishing() - bobber = preload("res://scripts/fishing/bobber.gd").new() - bobber.setup(self, p, origin, vel, randi()) - add_child(bobber) - return bobber + end_fishing(p) + var b: Node3D = preload("res://scripts/fishing/bobber.gd").new() + b.setup(self, p, origin, vel, randi()) + add_child(b) + bobbers[p] = b + if p == player: + bobber = b + return b -func end_fishing() -> void: - if bobber != null and is_instance_valid(bobber): - bobber.queue_free() - bobber = null +func end_fishing(p: Node3D = null) -> void: + if p == null: + p = player + if is_online() and p == player and bobber != null: + PlayerNet.ask("reel") # (switched away from the rod: the server reels in) + var b: Node3D = bobbers.get(p) + bobbers.erase(p) + if b != null and is_instance_valid(b): + b.queue_free() + if p == player: + if bobber != null and is_instance_valid(bobber) and bobber != b: + bobber.queue_free() # (online: the server's float, shown here) + bobber = null + +func bobber_of(p: Node3D) -> Node3D: + var b: Node3D = bobbers.get(p) + return b if b != null and is_instance_valid(b) else null + +var bobbers: Dictionary = {} ## player -> their float + +# --- who's here ------------------------------------------------------------ + +## Everyone animals react to: the local player in single-player; on a +## server, every player's character that's alive (player_net.gd). +func players() -> Array: + if server_mode: + return PlayerNet.proxies.values().filter(func(p): return is_instance_valid(p) and not p.survival.dead) + return [player] + +## The nearest of them to `pos`, or null if nobody's around (a server with +## everyone dead or gone). +func nearest_player(pos: Vector3) -> Node3D: + if not server_mode: + return player + var best: Node3D = null + for p in players(): + if best == null or p.global_position.distance_to(pos) < best.global_position.distance_to(pos): + best = p + return best ## Fires an arrow or thrown spear (Actions validated it). func launch(id: String, origin: Vector3, vel: Vector3, damage: float, shooter: Node3D) -> Node3D: var pr: Node3D = preload("res://scripts/creatures/projectile.gd").new() pr.setup(id, self, origin, vel, damage, shooter) add_child(pr) + if server_mode: + launched.emit(id, origin, vel, shooter) # everyone sees it fly (creature_net.gd) return pr +signal launched(id: String, origin: Vector3, vel: Vector3, shooter: Node3D) + ## Takes a piece out of the world (dismantled). Anything that loses its ## support collapses, and its item goes back to `refund_to` if there's room. ## Returns how many pieces collapsed. diff --git a/tests/net_world_bot.gd b/tests/net_world_bot.gd index bedc7a2..16fa210 100644 --- a/tests/net_world_bot.gd +++ b/tests/net_world_bot.gd @@ -109,8 +109,43 @@ func _do(cmd: Dictionary) -> Dictionary: net.leave() await create_timer(0.3).timeout return {"left": true} + # M2.5 (tests/test_net_life.gd). + "tp": + root.get_node("PlayerNet").ask("debug_tp", [_p(cmd.to)]) + await create_timer(0.6).timeout + player.set_physics_process(false) + return {"pos": _v(player.global_position)} + "ask": + var args: Array = [] + for x in cmd.get("args", []): + args.append(_p(x.v) if x is Dictionary and x.has("v") else x) + root.get_node("PlayerNet").ask(String(cmd.what), args) + await create_timer(0.4).timeout + return {"inv": player.inventory.to_data()} + "bed": + var b: Node3D = WN.pieces.get(int(cmd.nid)) + if b == null: + return {"error": "no bed %d" % int(cmd.nid)} + root.get_node("Actions").use_bed(player, b) + await create_timer(0.5).timeout + return {"asked": true} + "life": + return life(world, root.get_node("CreatureNet"), root.get_node("PlayerNet")) return {"error": "unknown command"} +## What this player's game shows of the server's wildlife. +static func life(w: Node3D, CN: Node, PN: Node) -> Dictionary: + var out := {"puppets": {}, "floats": [], "projectiles": CN.projectiles_seen, "howls": CN.howls_heard, + "hurts": PN.hurts, "health": w.player.survival.health, "day": w.day_night.day, + "hours": w.day_night.hours(), "statuses": w.player.survival.statuses.duplicate()} + for cid in CN.puppets: + var c: Node3D = CN.puppets[cid] + if is_instance_valid(c): + out.puppets[str(cid)] = {"kind": c.kind, "pos": _v(c.global_position), "state": c.state, "hp": c.hp} + for peer in CN.floats: + out.floats.append(peer) + return out + # Shared with test_net_shared.gd (it loads this script for these). ## Walks (by setting positions, like the game sending its moves) at 5 m/s. diff --git a/tests/run_all.sh b/tests/run_all.sh index 397c600..d830f94 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 fi -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_net_lobby test_net_world test_net_shared test_terrain_edges test_boundary; do +for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_net_lobby test_net_world test_net_shared test_net_life test_terrain_edges test_boundary; do extra="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_net_life.gd b/tests/test_net_life.gd new file mode 100644 index 0000000..0d85630 --- /dev/null +++ b/tests/test_net_life.gd @@ -0,0 +1,412 @@ +extends SceneTree +## M2.5: wildlife on a server. The server runs the animals and the wolves; +## both players see the same ones. Hunting (club and bow), butchering, +## fishing and sleeping all happen on the server, and a wolf pack hunts two +## players at once. +## +## A real headless server (with --debug-cheats, so the test can set the +## clock, give items, spawn an animal and move players), Alice (this test, +## with the real world scene) and Bob (tests/net_world_bot.gd, another +## process with his own world, following this test's commands). +## Run: godot --headless --path . --script res://tests/test_net_life.gd + +const PORT := 24348 +const DIR := "user://test_net" +const SRV := DIR + "/server6" +const BOB := DIR + "/bob_life" +const SEED := 777 +const Bot = preload("res://tests/net_world_bot.gd") + +var _fails := 0 +var _finished := false +var _pids: Array[int] = [] +var Net: Node +var WN: Node +var PN: Node +var CN: Node +var A: Node +var world: Node3D +var player: CharacterBody3D +var _cmd := 0 +var _toasts: Array[String] = [] + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + Engine.max_fps = 60 + Net = root.get_node("Net") + WN = root.get_node("WorldNet") + PN = root.get_node("PlayerNet") + CN = root.get_node("CreatureNet") + A = root.get_node("Actions") + A.notify.connect(func(t): _toasts.append(t)) + for d in [SRV, BOB]: + _rm_tree(d) + DirAccess.make_dir_recursive_absolute(BOB) + _spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json", + "--server-name", "Life Test", "--seed", str(SEED), "--debug-cheats"]) + await _sleep(4.0) + await _body() + Net.stop() + for pid in _pids: + if OS.is_process_running(pid): + OS.kill(pid) + check(_finished, "test ran to the end (no script errors)") + _rm_tree(SRV) + _rm_tree(BOB) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _body() -> void: + var me = load("res://scripts/net/identity.gd").open(DIR + "/a") + me.name = "Alice" + Net.identity = me + var r := await _join() + check(r == "", "Alice joins (%s)" % r) + if r != "": + return + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(8): + await physics_frame + player = world.player + player.set_physics_process(false) + await _until(func(): return WN.received >= 1, 10.0) + _spawn(["--identity", DIR + "/bob", "--name", "Bob", "--address", "127.0.0.1:%d" % PORT, "--dir", BOB], + "res://tests/net_world_bot.gd") + var b0 := await _bob_result(0, 30.0) + check(bool(b0.get("ok", false)), "Bob joins (%s)" % b0.get("why", "ok")) + if not bool(b0.get("ok", false)): + return + PN.ask("debug_stats", [{"health": 100.0, "hunger": 90.0}]) + await _wildlife() + await _club() + await _bow() + await _fishing() + await _sleeping() + await _wolves() + world.queue_free() + await process_frame + Net.leave() + _finished = true + +# --- the same animals for everyone ------------------------------------------------ + +func _wildlife() -> void: + print("the server's wildlife") + await _until(func(): return CN.puppets.size() >= 5, 8.0) + check(CN.puppets.size() >= 5, "animals appear around Alice: %d, from the server" % CN.puppets.size()) + check(not world.creatures.enabled and world.creatures.alive().is_empty(), "none of them are her game's own") + # (Bob may only just have joined: give his first snapshots a moment.) + var life := await _bob({"do": "life"}) + for _i in range(10): + if (life.puppets as Dictionary).size() >= 5: + break + await _sleep(0.3) + life = await _bob({"do": "life"}) + var same := 0 + var close := 0 + for cid in CN.puppets: + var mine: Node3D = CN.puppets[cid] + var his: Dictionary = life.puppets.get(str(cid), {}) + if his.is_empty(): + continue + same += 1 + if _p(his.pos).distance_to(mine.global_position) < 3.0: + close += 1 + check(same >= mini(CN.puppets.size(), life.puppets.size()) - 1 and same >= 3, + "Bob sees the same ones (%d in common of %d / %d)" % [same, CN.puppets.size(), life.puppets.size()]) + check(close >= same - 1, "in the same places (%d of %d within 3 m)" % [close, same]) + +## A boar on the server, `dist` metres in front of Alice. Returns her view of it. +func _boar(dist: float) -> Node3D: + var fwd := -player.global_basis.z + fwd.y = 0.0 + fwd = fwd.normalized() + var at := player.global_position + fwd * dist + at.y = world.terrain.sample_height(at.x, at.z) + var before: Array = CN.puppets.keys() + PN.ask("debug_spawn", ["boar", at]) + var found := {"c": null} # (lambdas capture locals by value: use a dictionary) + await _until(func(): + for cid in CN.puppets: + if not cid in before and CN.puppets[cid].kind == "boar": + found.c = CN.puppets[cid] + return true + return false, 3.0) + return found.c + +func _club() -> void: + print("a club and a boar") + var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(10, 0, 0), 3.0) + await _tp(site) + await _give("club", 1) + player.select(_slot_of("club")) + await _sleep(0.3) + var boar := await _boar(2.2) + check(boar != null, "a boar on the server shows up in front of Alice") + if boar == null: + return + var cid := int(boar.get_meta("cid")) + var hurts0: int = PN.hurts + var swings := 0 + var t := 0.0 + while not boar.is_dead() and t < 15.0: + var to: Vector3 = boar.global_position - player.global_position + player.rotation.y = atan2(-to.x, -to.z) + if to.length() > 2.2: + await Bot.walk(player, boar.global_position - to.normalized() * 1.6, world) + A.melee(player, boar) + swings += 1 + await _sleep(0.6) + t += 0.6 + check(boar.is_dead(), "Alice clubs it to death on the server (%d swings)" % swings) + check(PN.hurts > hurts0, "it fought back: the server's boar gored her (%d hits)" % (PN.hurts - hurts0)) + var life := await _bob({"do": "life"}) + var his: Dictionary = life.puppets.get(str(cid), {}) + check(String(his.get("state", "")) == "dead", "Bob sees the carcass too") + var meat0: int = player.inventory.count("raw_meat") + A.butcher(player, boar) + await _until(func(): return player.inventory.count("raw_meat") > meat0, 2.0) + check(player.inventory.count("raw_meat") >= meat0 + 3 and player.inventory.count("hide") >= 1, + "she butchers it: %d meat and %d hide from the server" % [player.inventory.count("raw_meat") - meat0, player.inventory.count("hide")]) + await _until(func(): return not CN.puppets.has(cid), 3.0) + check(not CN.puppets.has(cid), "and the carcass is gone") + +func _bow() -> void: + print("a bow") + PN.ask("debug_stats", [{"health": 100.0, "stamina": 100.0}]) + await _give("bow", 1) + await _give("arrow", 10) + player.select(_slot_of("bow")) + await _sleep(0.3) + var boar := await _boar(9.0) + check(boar != null, "another boar, 9 m off") + if boar == null: + return + var cid := int(boar.get_meta("cid")) + var seen0: int = CN.projectiles_seen + var bob0: Dictionary = await _bob({"do": "life"}) + var shots := 0 + while boar.hp >= 60.0 - 0.01 and shots < 4: + var aim: Vector3 = (boar.global_position + Vector3(0, 0.55, 0)) - (player.global_position + Vector3(0, 1.6, 0)) + player.rotation.y = atan2(-aim.x, -aim.z) + if A.can_draw(player): + A.shoot(player, Vector3.ZERO, aim.normalized(), 1.0, world) + shots += 1 + await _sleep(1.0) + check(boar.hp < 60.0, "an arrow hits it (%d shot%s, it's at %.0f HP on Alice's screen)" % [shots, "" if shots == 1 else "s", boar.hp]) + check(player.inventory.count("arrow") == 10 - shots, "the server took %d arrow%s from her quiver" % [shots, "" if shots == 1 else "s"]) + check(CN.projectiles_seen > seen0, "she saw her arrow fly") + var life := await _bob({"do": "life"}) + check(int(life.projectiles) > int(bob0.projectiles), "so did Bob") + var his: Dictionary = life.puppets.get(str(cid), {}) + check(float(his.get("hp", 60.0)) < 60.0, "and on his screen it's hurt too (%.0f HP)" % float(his.get("hp", 60.0))) + +func _fishing() -> void: + print("fishing") + var lake: Vector2 = world.terrain.lake_center + var c3 := Vector3(lake.x, 0, lake.y) + var dir := (player.global_position - c3) + dir.y = 0.0 + dir = dir.normalized() + var shore := c3 + for i in range(200): + shore = c3 + dir * float(i) + if world.terrain.sample_height(shore.x, shore.z) > world.terrain.water_level + 0.4: + break + shore += dir * 1.0 + shore.y = world.terrain.sample_height(shore.x, shore.z) + await _tp(shore) + player.rotation.y = atan2(dir.x, dir.z) # facing the water + await _give("fishing_rod", 1) + player.select(_slot_of("fishing_rod")) + await _sleep(0.3) + var caught := false + var tries := 0 + while not caught and tries < 3: + tries += 1 + A.cast_line(player, Vector3.ZERO, -dir + Vector3(0, -0.1, 0), world) + await _until(func(): return world.bobber != null and world.bobber.state == "floating", 4.0) + if tries == 1: + check(world.bobber != null and world.bobber.state == "floating", "Alice casts: the server's float lands on the lake, and she sees it") + var life := await _bob({"do": "life"}) + check((life.floats as Array).any(func(f): return int(f) == Net.local_peer), "Bob sees her float too") + await _until(func(): return world.bobber != null and world.bobber.state == "biting", 25.0) + if world.bobber != null and world.bobber.state == "biting": + var fish0: int = player.inventory.count("perch") + player.inventory.count("trout") + A.reel(player, world) + await _until(func(): return player.inventory.count("perch") + player.inventory.count("trout") > fish0, 2.0) + caught = player.inventory.count("perch") + player.inventory.count("trout") > fish0 + check(caught, "a bite, she reels in, and the server gives her the fish (%d cast%s)" % [tries, "" if tries == 1 else "s"]) + player.select(0) + await _sleep(0.5) + check(world.bobber == null, "the line is in") + +func _sleeping() -> void: + print("sleeping (the server's rule: everyone online in bed)") + PN.ask("debug_time", [1, 21.0]) + var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(0, 0, 14), 3.0) + var n0: int = WN.pieces.size() + PN.ask("debug_hut", [site]) + await _until(func(): return _beds().size() >= 2, 8.0) + var beds := _beds() + check(beds.size() >= 2 and WN.pieces.size() > n0 + 10, "a hut with two beds (%d pieces)" % (WN.pieces.size() - n0)) + if beds.size() < 2: + return + await _tp(beds[0].global_position + Vector3(0, 0.1, 0.9)) + await _bob({"do": "tp", "to": _v(beds[1].global_position + Vector3(0, 0.1, 0.9))}) + check(world.day_night.hours() > 20.5, "it's night (%s)" % world.day_night.clock_text()) + _toasts.clear() + A.use_bed(player, beds[0]) + await _sleep(0.5) + A.use_bed(player, beds[0]) + await _sleep(1.0) + check(_toasts.any(func(t): return t.begins_with("You're in bed (1 of 2")), "Alice gets into bed: 1 of 2 asleep (%s)" % [_toasts]) + check(world.day_night.hours() > 20.5, "and the night goes on, because Bob's still up") + await _bob({"do": "bed", "nid": WN.nid(beds[1])}) + await _bob({"do": "bed", "nid": WN.nid(beds[1])}) + await _until(func(): return world.day_night.hours() < 7.0, 3.0) + check(world.day_night.hours() >= 5.9 and world.day_night.hours() < 7.0 and world.day_night.day == 2, + "Bob gets into bed too, and the night passes for both (%s, day %d)" % [world.day_night.clock_text(), world.day_night.day]) + await _until(func(): return "Rested" in player.survival.statuses, 2.0) + check(_toasts.has("You slept until morning") and "Rested" in player.survival.statuses, "Alice wakes rested") + var life := await _bob({"do": "life"}) + check(float(life.hours) >= 5.9 and float(life.hours) < 7.0, "Bob's clock says morning too (%.1f h)" % float(life.hours)) + +func _beds() -> Array: + return WN.pieces.values().filter(func(p): return is_instance_valid(p) and p.item_id == "bed") + +func _wolves() -> void: + print("a wolf pack hunts both players") + PN.ask("debug_time", [5, 12.0]) # by day the pack moves into the den... + await _sleep(1.5) + PN.ask("debug_time", [5, 22.0]) # ...and at night it hunts + PN.ask("debug_stats", [{"health": 100.0, "warmth": 80.0}]) + await _bob({"do": "ask", "what": "debug_stats", "args": [{"health": 100.0, "warmth": 80.0}]}) + var den: Node3D = world.den + check(den.found, "this island has a den (seed %d)" % SEED) + if not den.found: + return + var out: Vector3 = Basis(Vector3.UP, den.yaw) * Vector3(0, 0, 1) + var side := out.cross(Vector3.UP) + var a: Vector3 = den.mouth_pos() + out * 16.0 + var b: Vector3 = den.mouth_pos() + out * 16.0 + side * 10.0 + a.y = world.terrain.sample_height(a.x, a.z) + b.y = world.terrain.sample_height(b.x, b.z) + await _tp(a) + await _bob({"do": "tp", "to": _v(b)}) + var a0: int = PN.hurts + var life0 := await _bob({"do": "life"}) + var b0 := int(life0.hurts) + var t := 0.0 + var bob_hurts := b0 + while t < 45.0 and (PN.hurts == a0 or bob_hurts == b0): + await _sleep(1.0) + t += 1.0 + if int(t) % 3 == 0: + var l := await _bob({"do": "life"}) + bob_hurts = int(l.hurts) + var wolves: Array = CN.puppets.values().filter(func(c): return c.kind == "wolf") + check(wolves.size() >= 2, "Alice sees the pack: %d wolves, from the server" % wolves.size()) + check(PN.hurts > a0, "the wolves bite Alice (%d)" % (PN.hurts - a0)) + check(bob_hurts > b0, "and Bob, 10 m away, at the same time (%d), after %.0f s" % [bob_hurts - b0, t]) + check(player.survival.health < 100.0, "Alice's health is down on her screen (%.0f)" % player.survival.health) + +# --- helpers -------------------------------------------------------------------- + +func _tp(at: Vector3) -> void: + PN.ask("debug_tp", [at + Vector3(0, 0.05, 0)]) + await _until(func(): return player.global_position.distance_to(at) < 1.0, 2.0) + player.set_physics_process(false) + await _sleep(0.3) + +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 _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() + +static func _v(p: Vector3) -> Array: + return [p.x, p.y, p.z] + +static func _p(a: Array) -> Vector3: + return Vector3(a[0], a[1], a[2]) + +func _bob(cmd: Dictionary) -> Dictionary: + _cmd += 1 + var f := FileAccess.open("%s/cmd_%d.json" % [BOB, _cmd], FileAccess.WRITE) + f.store_string(JSON.stringify(cmd)) + f.close() + return await _bob_result(_cmd, 20.0) + +func _bob_result(n: int, limit: float) -> Dictionary: + var path := "%s/res_%d.json" % [BOB, n] + var t := 0.0 + while not FileAccess.file_exists(path) and t < limit: + await _sleep(0.05) + t += 0.05 + await _sleep(0.05) + var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null + return d if d is Dictionary else {"error": "Bob gave no answer to command %d" % n, "puppets": {}, "floats": [], + "projectiles": 0, "hurts": 0, "hours": -1.0} + +func _join() -> String: + var state := {"done": false, "reason": ""} + var ok := func(): state.done = true + var bad := func(why): state.done = true; state.reason = why + Net.joined.connect(ok) + Net.join_failed.connect(bad) + Net.join("127.0.0.1:%d" % PORT) + var t := 0.0 + while not state.done and t < 25.0: + await _sleep(0.05) + t += 0.05 + Net.joined.disconnect(ok) + Net.join_failed.disconnect(bad) + return state.reason if state.done else "test timed out" + +func _spawn(user_args: Array, script: String = "") -> int: + var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://")]) + if not script.is_empty(): + args.append_array(["--script", script]) + args.append("--") + for x in user_args: + args.append(str(x)) + args.append_array(["--exit-with", str(OS.get_process_id())]) + var pid := OS.create_process(OS.get_executable_path(), args) + _pids.append(pid) + return pid + +func _sleep(s: float) -> void: + await create_timer(s).timeout + +static func _rm_tree(d: String) -> void: + var da := DirAccess.open(d) + if da == null: + return + for sub in da.get_directories(): + _rm_tree(d.path_join(sub)) + for f in da.get_files(): + da.remove(f) + DirAccess.remove_absolute(d) diff --git a/tests/test_net_world.gd b/tests/test_net_world.gd index 87ba45e..e7e029c 100644 --- a/tests/test_net_world.gd +++ b/tests/test_net_world.gd @@ -68,7 +68,7 @@ func _body() -> void: var sp: Vector3 = Net.world_info.spawn check(Vector2(player.global_position.x - sp.x, player.global_position.z - sp.z).length() < 1.0, "Alice starts where the server put her") check(not world.creatures.enabled and world.creatures.alive().is_empty() and world.wolves == null, - "no local-only animals while online (the server will run them in M2.5)") + "no animals of her game's own while online (the server runs them: test_net_life.gd)") check(world.day_night.day == int(Net.world_info.day) and absf(world.day_night.time - float(Net.world_info.time)) < 0.01, "the clock follows the server (%s)" % world.day_night.clock_text())