diff --git a/docs/MULTIPLAYER_PLAN.md b/docs/MULTIPLAYER_PLAN.md index e8ae7d4..c59d571 100644 --- a/docs/MULTIPLAYER_PLAN.md +++ b/docs/MULTIPLAYER_PLAN.md @@ -113,7 +113,7 @@ What each rule means: | `raiding` | false / true | Whether players can damage or dismantle **other players'** pieces. | | `locks` | `off` / `owner` / `owner_and_friends` | Who may open or take from a chest, use a door or bed, or dismantle a piece. Pieces remember who placed them. | | `death_penalty` | `grave` / `keep` / `drop_all` | Today's grave bag; keep everything; or drop it all on the ground (hardcore-ish). | -| `sleep` | `all_online` / `half` / `off` | When a night can be slept through. | +| `sleep` | `all_online` / `half` / `anyone` / `off` | When a night can be slept through (how many of those online must be in bed). | | `pause_when_empty` | true / false | Freeze the world when nobody's on (fires don't burn out, no time passes). | | `wolf_grace_days` | 0-10 | Days before wolves arrive; hunts start the night after. | | `difficulty` | `easy` / `normal` / `hard` | Multipliers on hunger and diet drain, animal damage and pack size. | @@ -265,17 +265,46 @@ entity to everyone at first. Distance-based interest is a later optimisation. ## 6. Admin and console -In-game chat starts with `/`; the server console takes the same commands -without it. -- `/players`, `/kick `, `/ban `, `/unban`, `/admin ` -- `/save`, `/backup`, `/time 08:00`, `/weather clear|rain|storm|snow` -- `/rules` (show) and `/set pvp on` (change at runtime, saved to - `server.json`) -- `/rename `, `/release-name ` -- `/say ` (a server message) +*(Built in M2.6: `chat.gd`, autoload Chat; `rules.gd`.)* In-game chat +starts with `/`; the server console takes the same commands without it +(the server reads its terminal when started with `--console`, which +`server/run_server.sh` does). +- Anyone: `/help`, `/players`, `/rules`, `/friend `, + `/unfriend `, `/friends`. +- Admins: `/kick `, `/ban `, `/unban `, `/admin `, + `/save`, `/time 08:00`, `/weather clear|cloudy|rain|storm|snow|blizzard|auto`, + `/set ` (changes it for everyone at once and saves it to + `server.json`: pvp, raiding, locks, death_penalty, sleep, + pause_when_empty, difficulty, wolf_grace_days, motd, server_name; bad + values are refused with what's allowed), `/say `, + `/release-name `. +- Not built: `/backup` (comes with backups in M2.7) and `/rename` (the name + is the player's own, set in their profile; `/release-name` frees one). -Chat: `T` to talk. It's global, so everyone on the server sees it (no -proximity chat in v1). +Chat: `T` to talk, Enter to send, Esc to cancel. It's global, so everyone +on the server sees it (no proximity chat in v1). Lines fade after 14 s and +all come back while you type. Plain chat is limited to a line every 0.4 s +per player (commands aren't). + +**How the rules work** *(M2.6)*: +- Pieces remember who placed them (and graves who died). Everyone's game + knows, so the prompt says "Bob's · Chest". Pieces from before M2.6 are + anyone's. +- `locks`: who may open or take from chests and graves, open doors and use + beds. `owner_and_friends` (the default) lets the people you've + `/friend`ed in; `owner` keeps everyone else out; `off` opens everything. +- `raiding`: taking down a piece you aren't allowed to use. Off by default. +- `pvp`: with it off, weapons and arrows go straight through other players. + On, a swing at someone's avatar is checked on the server (reach, + weapon, stamina, cooldown) and arrows and thrown spears hit people. + Deaths say who did it ("Killed by Bob"). +- `death_penalty`: `grave` (default), `keep` (wake with everything) or + `drop_all` (it's all scattered where you fell, for anyone). +- `difficulty`: easy / normal / hard scales hunger and diet drain (x0.75 / + 1 / 1.3), animal and wolf damage (x0.7 / 1 / 1.4) and the pack (-1 / 0 / + +1 wolf). +- `wolf_grace_days`: the day wolves move in; they hunt from the next night. +- `sleep`: `all_online` (default), `half`, `anyone` or `off`. ## 7. Saves on the server ``` @@ -333,7 +362,7 @@ player" or "this player": | **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** *(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.6** *(done)* | 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 | ## 10. Testing diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3c3df02..2556687 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -58,6 +58,7 @@ phases M2.0-M2.7). - [x] Persistence: world save/load on the server *(M2.3)* - [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)* - [x] Animals, wolves, hunting, fishing and sleeping on the server *(M2.5)* +- [x] Server rules (PvP, raiding, locks and friends, death penalty, difficulty, wolf grace), chat and admin commands, server console *(M2.6)* - **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 b649f44..e8fa06d 100644 --- a/project.godot +++ b/project.godot @@ -30,6 +30,7 @@ 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" +Chat="*res://scripts/net/chat.gd" [display] diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index d4c9d1c..65026ac 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -287,6 +287,38 @@ func melee(player: Node3D, target: Node3D) -> bool: notify.emit("Killed a %s" % target.display_name().to_lower()) return true +## A melee blow at another player (online, with PvP on). Online only. +func hit_player(player: Node3D, peer: int) -> bool: + if _online(): + if not melee_stats(player).armed: + notify.emit(NEED_WEAPON) + return false + return _ask("hit_player", [peer]) + if not Net.is_server() or not PlayerNet.proxies.has(peer) or player.get("peer") == peer: + return false + var target: Node3D = PlayerNet.proxies[peer] + if not preload("res://scripts/net/rules.gd").pvp(): + notify.emit("PvP is off on this server") + return false + if target.survival.dead: + return false + var m := melee_stats(player) + if not m.armed: + notify.emit(NEED_WEAPON) + return false + # (Positions are where the server last heard you both were: a little slack.) + if player.global_position.distance_to(target.global_position) > m.reach + 2.5: + return false + if Time.get_ticks_msec() < int(player.melee_ready_at): + return false + if not player.survival.use_stamina(m.stamina): + notify.emit("Too tired") + return false + player.melee_ready_at = Time.get_ticks_msec() + int(m.cooldown * 1000.0 * 0.8) + target.survival.damage(m.damage, player.global_position, String(player.display)) + notify.emit("Hit %s" % String(target.display)) + return true + ## An arrow or thrown spear reaching an animal (projectile.gd). func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3, embed_id: String) -> void: if not is_instance_valid(target) or not target.is_in_group("creature"): diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index 1d4278e..b30b54f 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -275,7 +275,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, "a " + String(def.name).to_lower()) + player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a " + String(def.name).to_lower()) func _start_flee(from: Vector3) -> void: state = "flee" diff --git a/scripts/creatures/projectile.gd b/scripts/creatures/projectile.gd index c3423c6..81ab1fa 100644 --- a/scripts/creatures/projectile.gd +++ b/scripts/creatures/projectile.gd @@ -53,7 +53,10 @@ func step(dt: float) -> void: var ex: Array[RID] = [] 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 mask := 1 | 8 + if world.server_mode and preload("res://scripts/net/rules.gd").pvp(): + mask |= 16 # other players (server_player.gd); with PvP off arrows pass through them + var q := PhysicsRayQueryParameters3D.create(from, to, mask, ex) var hit := get_world_3d().direct_space_state.intersect_ray(q) if not hit.is_empty(): _on_hit(hit) @@ -69,6 +72,15 @@ func _on_hit(hit: Dictionary) -> void: if visual: queue_free() return + if c != null and c.get("player_id") != null and c.get("survival") != null: + # Another player, on a server with PvP on. + var k := clampf(velocity.length() / _start_speed, 0.4, 1.0) + var by: String = String(shooter.display) if shooter != null and is_instance_valid(shooter) and shooter.get("display") != null else "an arrow" + (c as Node).survival.damage(damage * k, global_position - dir, by) + Actions.tell(shooter, "Hit %s" % String(c.display)) + var at: Vector3 = (c as Node3D).global_position + Vector3(0, 0.05, 0) + _drop(Transform3D(Basis(Vector3.UP, randf() * TAU) * Basis(Vector3.FORWARD, PI * 0.5), at)) + 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 "") diff --git a/scripts/creatures/wolf.gd b/scripts/creatures/wolf.gd index a479752..ff95302 100644 --- a/scripts/creatures/wolf.gd +++ b/scripts/creatures/wolf.gd @@ -122,7 +122,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, "a " + String(def.name).to_lower()) + player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a " + String(def.name).to_lower()) if as_state == "lunge": state = "retreat" _state_t = 1.6 diff --git a/scripts/creatures/wolf_pack.gd b/scripts/creatures/wolf_pack.gd index e0b9f81..1b5c493 100644 --- a/scripts/creatures/wolf_pack.gd +++ b/scripts/creatures/wolf_pack.gd @@ -67,14 +67,22 @@ func is_night() -> bool: var h: float = world.day_night.hours() return h >= 19.5 or h < 5.5 -static func pack_size(day: int) -> int: - if day < DEN_DAY: +## The pack grows 3 and 6 days after it arrives (2, 3, then 4 wolves), plus +## or minus one for a server's difficulty. +func pack_size(day: int) -> int: + if day < den_day: return 0 - if day < 6: - return 2 - if day < 9: - return 3 - return 4 + var n := 4 + if day < den_day + 3: + n = 2 + elif day < den_day + 6: + n = 3 + return clampi(n + pack_bonus, 1, 5) + +## The schedule (a server sets it from its rules; single-player keeps 3/4). +var den_day := DEN_DAY +var hunt_day := HUNT_DAY +var pack_bonus := 0 func alive() -> Array: var out := [] @@ -95,8 +103,8 @@ func step(dt: float) -> void: var day := effective_day() var night := is_night() var want := "none" - if day >= DEN_DAY: - want = "hunt" if night and day >= HUNT_DAY else "den" + if day >= den_day: + want = "hunt" if night and day >= hunt_day else "den" if want != mode: _enter(want, day) if mode == "none": @@ -123,7 +131,7 @@ func step(dt: float) -> void: m.global_position = den.slot_pos(m.slot) m.wounded = false m.state = "rest" - if night and day >= DEN_DAY: + if night and day >= den_day: _howl_left -= dt if _howl_left <= 0.0: _howl_left = _rng.randf_range(50.0, 110.0) @@ -148,9 +156,9 @@ func _warn(day: int) -> void: if _warned.has(day): return _warned[day] = true - if day == DEN_DAY: + if day == den_day: Actions.tell_all("Wolves are howling up in the hills. They'll come hunting soon.") - elif day == HUNT_DAY: + elif day == hunt_day: 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) diff --git a/scripts/net/avatar.gd b/scripts/net/avatar.gd index f0b3501..978e4dc 100644 --- a/scripts/net/avatar.gd +++ b/scripts/net/avatar.gd @@ -19,6 +19,7 @@ var info: Dictionary = {} var anim := Anim.IDLE var held := "" var tag: Label3D +var hitbox: AnimatableBody3D var _buf: Array = [] ## [t, pos, yaw, pitch, anim, held], oldest first var _body: Node3D @@ -57,6 +58,22 @@ func setup(p: int, d: Dictionary) -> void: _held_mi.rotation = Vector3(-PI * 0.5, 0, 0) # item handles run along +Y; point them forward _held_mi.scale = Vector3.ONE * 0.85 _arms[1].add_child(_held_mi) + # A hitbox for swinging at them when the server has PvP on (hands.gd + # looks for it; the server decides what the hit does). Layer 5 (value + # 16): nothing walks into it. + hitbox = AnimatableBody3D.new() + hitbox.sync_to_physics = false + hitbox.collision_layer = 16 + hitbox.collision_mask = 0 + hitbox.set_meta("avatar_peer", p) + var cs := CollisionShape3D.new() + var cap := CapsuleShape3D.new() + cap.radius = 0.4 + cap.height = 1.8 + cs.shape = cap + cs.position = Vector3(0, 0.9, 0) + hitbox.add_child(cs) + add_child(hitbox) tag = Label3D.new() tag.text = String(d.get("name", "?")) tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED diff --git a/scripts/net/chat.gd b/scripts/net/chat.gd new file mode 100644 index 0000000..36468db --- /dev/null +++ b/scripts/net/chat.gd @@ -0,0 +1,228 @@ +extends Node +## Autoload "Chat": talking on a server, and its commands +## (docs/MULTIPLAYER_PLAN.md §6). +## +## T opens the chat line. Plain text goes to everyone (global chat). A line +## starting with / is a command; the server console takes the same commands +## without the /, when started with --console (server/run_server.sh does). +## +## Anyone: /help /players /rules /friend /unfriend /friends +## Admins: /kick /ban /unban /admin /save +## /time 08:00 /weather clear|cloudy|rain|storm|snow|blizzard|auto +## /set /say /release-name + +signal message(text: String, kind: String) ## client: show this (kind: chat | server | reply) + +const MAX_LEN := 200 +const GAP := 0.4 ## seconds between one player's lines +const ADMIN_ONLY := ["kick", "ban", "unban", "admin", "save", "time", "weather", "set", "say", "release-name"] + +var _last: Dictionary = {} ## peer -> ticks of their last line +var _console: Thread +var heard: Array = [] ## client: everything shown (tests read it) + +func _ready() -> void: + if Net.parse_args(OS.get_cmdline_user_args()).has("console"): + Net.state_changed.connect(func(s): + if s == "server" and _console == null: + _console = Thread.new() + _console.start(_read_console)) + +# --- client ------------------------------------------------------------------------ + +func say(text: String) -> void: + text = text.strip_edges() + if text.is_empty() or Net.state != "connected": + return + _c2s_say.rpc_id(1, text.substr(0, MAX_LEN)) + +func _show(text: String, kind: String) -> void: + heard.append(text) + message.emit(text, kind) + +@rpc("authority", "call_remote", "reliable") +func _s2c_line(text: String, kind: String) -> void: + _show(text, kind) + +# --- server ------------------------------------------------------------------------ + +@rpc("any_peer", "call_remote", "reliable") +func _c2s_say(text: String) -> void: + var peer := multiplayer.get_remote_sender_id() + if not Net.is_server() or not Net.players.has(peer): + return + text = text.strip_edges().substr(0, MAX_LEN).replace("\n", " ") + if text.is_empty(): + return + var who: String = Net.players[peer].name + if text.begins_with("/"): + print("[server] %s: %s" % [who, text]) + run(text.substr(1), peer) + return + # Plain chat only: a line every GAP seconds, so nobody can flood it. + var now := Time.get_ticks_msec() + if now - int(_last.get(peer, -100000)) < GAP * 1000.0: + _reply(peer, "(Too fast: that line wasn't sent)") + return + _last[peer] = now + print("[chat] %s: %s" % [who, text]) + _all("%s: %s" % [who, text], "chat") + +func _all(text: String, kind: String) -> void: + if kind == "server": + print("[server] ", text) + for peer in Net.players: + _s2c_line.rpc_id(peer, text, kind) + +## An answer to whoever gave the command (peer 0 = the console). +func _reply(peer: int, text: String) -> void: + if peer == 0: + print(text) + else: + _s2c_line.rpc_id(peer, text, "reply") + +func _is_admin(peer: int) -> bool: + return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id))) + +## The player id a name belongs to (online or reserved), or "". +func _id_of(n: String) -> String: + var peer := Net.peer_by_name(n) + if peer != 0: + return String(Net.players[peer].id) + return Net.config.name_owner(n) + +## Runs a command line (without the /) from player `peer`, or the console (0). +func run(line: String, peer: int) -> void: + var parts := line.strip_edges().split(" ", false) + if parts.is_empty(): + return + var cmd := parts[0].to_lower() + var rest := " ".join(parts.slice(1)) + var arg: String = parts[1] if parts.size() > 1 else "" + if cmd in ADMIN_ONLY and not _is_admin(peer): + _reply(peer, "Only admins can use /%s" % cmd) + return + var me: String = String(Net.players[peer].id) if Net.players.has(peer) else "" + var by: String = String(Net.players[peer].name) if Net.players.has(peer) else "The server" + match cmd: + "help": + _reply(peer, "Commands: /players /rules /friend /unfriend /friends" + + (" · admin: /kick /ban /unban /admin /save /time 08:00 /weather /set /say /release-name" if _is_admin(peer) else "")) + "players": + var names: Array[String] = [] + for p in Net.players.values(): + names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else "")) + _reply(peer, "%d online: %s" % [names.size(), ", ".join(names)]) + "rules": + var c: RefCounted = Net.config + _reply(peer, "PvP %s · raiding %s · locks %s · death: %s · sleep: %s · difficulty %s · wolves after day %d" % [ + "on" if bool(c.v("pvp")) else "off", "on" if bool(c.v("raiding")) else "off", c.v("locks"), + c.v("death_penalty"), c.v("sleep"), c.v("difficulty"), int(c.v("wolf_grace_days"))]) + "friend", "unfriend": + if me.is_empty(): + _reply(peer, "Only players have friends") + return + var fid := _id_of(arg) + if fid.is_empty() or fid == me: + _reply(peer, "No player called '%s' here" % arg) + elif cmd == "friend": + Net.config.add_friend(me, fid) + _reply(peer, "%s can now use your doors, chests and beds" % arg) + var fp := Net.peer_by_name(arg) + if fp != 0: + _reply(fp, "%s added you as a friend: you can use their doors, chests and beds" % by) + else: + _reply(peer, "%s is no longer a friend" % arg if Net.config.remove_friend(me, fid) else "%s wasn't a friend" % arg) + "friends": + if me.is_empty(): + return + var names: Array[String] = [] + for fid in Net.config.friends.get(me, []): + names.append(Net.config.name_of(String(fid))) + _reply(peer, "Your friends: %s" % (", ".join(names) if not names.is_empty() else "nobody yet (/friend )")) + "kick": + var kp := Net.peer_by_name(arg) + if kp == 0: + _reply(peer, "Nobody called '%s' is online" % arg) + return + Net.kick(kp, "You were kicked by %s" % by) + _all("%s was kicked" % arg, "server") + "ban": + var bid := _id_of(arg) + if bid.is_empty(): + _reply(peer, "No player called '%s'" % arg) + return + Net.config.ban(bid) + var bp := Net.peer_by_name(arg) + if bp != 0: + Net.kick(bp, "You were banned by %s" % by) + _all("%s was banned" % arg, "server") + "unban": + var uid: String = Net.config.name_owner(arg) + _reply(peer, "%s can join again" % arg if not uid.is_empty() and Net.config.unban(uid) else "'%s' isn't banned" % arg) + "admin": + var aid := _id_of(arg) + if aid.is_empty(): + _reply(peer, "No player called '%s'" % arg) + return + Net.config.add_admin(aid) + _reply(peer, "%s is now an admin" % arg) + var ap := Net.peer_by_name(arg) + if ap != 0: + Net.players[ap].admin = true + _reply(ap, "%s made you an admin (/help for the commands)" % by) + "save": + WorldNet.save() + _reply(peer, "Saved") + "time": + var hm := arg.split(":") + if hm.size() < 1 or not hm[0].is_valid_int() or int(hm[0]) > 23 or (hm.size() > 1 and not hm[1].is_valid_int()): + _reply(peer, "Use /time HH:MM, e.g. /time 08:00") + return + var w: Node3D = WorldNet.world + w.day_night.time = (int(hm[0]) + (int(hm[1]) if hm.size() > 1 else 0) / 60.0) / 24.0 + w.day_night.apply(true) + w.weather.snap() + Net.push_clock() + _all("%s set the time to %02d:%02d" % [by, int(hm[0]), int(hm[1]) if hm.size() > 1 else 0], "server") + "weather": + var states: Array = preload("res://scripts/world/weather.gd").STATES + if arg != "auto" and not arg in states: + _reply(peer, "Use /weather %s or auto" % "|".join(PackedStringArray(states))) + return + Net.set_weather("" if arg == "auto" else arg) + _all("%s set the weather to %s" % [by, arg], "server") + "set": + if parts.size() < 3: + _reply(peer, "Use /set , e.g. /set pvp true (/rules shows them)") + return + var why: String = Net.config.set_rule(parts[1], " ".join(parts.slice(2))) + if not why.is_empty(): + _reply(peer, why) + return + Net.broadcast_rules() + _all("%s set %s to %s" % [by, parts[1], " ".join(parts.slice(2))], "server") + "say": + _all("[Server] %s" % rest, "server") + "release-name": + _reply(peer, "'%s' is free for anyone now" % arg if Net.config.release_name(arg) else "Nobody has the name '%s'" % arg) + _: + _reply(peer, "Unknown command /%s (/help lists them)" % cmd) + +# --- server console ------------------------------------------------------------------ + +## Reads commands typed into the server's terminal (a thread: reading blocks). +func _read_console() -> void: + var empty := 0 + while empty < 20: + var line := OS.read_string_from_stdin(1024).strip_edges() + if line.is_empty(): + empty += 1 + OS.delay_msec(250) + continue + empty = 0 + _console_line.call_deferred(line.trim_prefix("/")) + +func _console_line(line: String) -> void: + if Net.is_server(): + run(line, 0) diff --git a/scripts/net/net.gd b/scripts/net/net.gd index 03966f0..694d985 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 := 5 ## 3: shared world (M2.3); 4: the server decides (M2.4); 5: wildlife (M2.5) +const PROTOCOL := 6 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6) 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,45 @@ func _tick_clock(delta: float) -> void: for peer in players: _s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time) +# --- admin (chat.gd) --------------------------------------------------------------- + +signal rules_changed ## clients: the server's rules changed (/set) + +## The peer of the player online called `n` (any case), or 0. +func peer_by_name(n: String) -> int: + for peer in players: + if String(players[peer].name).to_lower() == n.strip_edges().to_lower(): + return peer + return 0 + +## Disconnects a player with a reason (their title screen shows it). +func kick(peer: int, reason: String) -> void: + if players.has(peer): + _reject(peer, reason) + +## Everyone gets the rules again (after /set) and the weather override. +func broadcast_rules() -> void: + _world.apply_rules() + for peer in players: + _s2c_rules.rpc_id(peer, config.public_info()) + +func set_weather(state: String) -> void: + _world.weather.force(state) + for peer in players: + _s2c_weather.rpc_id(peer, state) + +@rpc("authority", "call_remote", "reliable") +func _s2c_rules(info: Dictionary) -> void: + server_info = info + rules_changed.emit() + +@rpc("authority", "call_remote", "reliable") +func _s2c_weather(state: String) -> void: + world_info.weather = state + weather_changed.emit(state) + +signal weather_changed(state: String) + ## Everyone gets the clock now (after a night's sleep, an admin's /time). func push_clock() -> void: _clock_left = CLOCK_INTERVAL @@ -454,7 +493,8 @@ func _c2s_proof(sig: PackedByteArray) -> void: 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, "yaw": yaw, - "island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time} + "island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time, + "weather": _world.weather.override} _s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world}) for other in players: if other != peer: diff --git a/scripts/net/net_sync.gd b/scripts/net/net_sync.gd index 6304831..0a09dd1 100644 --- a/scripts/net/net_sync.gd +++ b/scripts/net/net_sync.gd @@ -36,10 +36,17 @@ func setup(w: Node3D) -> void: Net.player_left.connect(func(n): Actions.notify.emit("%s left" % n)) if Net.world_info.has("day"): _on_clock(int(Net.world_info.day), float(Net.world_info.time)) + # An admin's /weather (otherwise the weather follows from seed and clock). + Net.weather_changed.connect(_on_weather) + if not String(Net.world_info.get("weather", "")).is_empty(): + _on_weather(String(Net.world_info.weather)) + +func _on_weather(state: String) -> void: + world.weather.force(state) func _exit_tree() -> void: for sig in [[Net.snapshot, _on_snapshot], [Net.corrected, _on_corrected], [Net.clock, _on_clock], - [Net.players_changed, _sync_avatars]]: + [Net.players_changed, _sync_avatars], [Net.weather_changed, _on_weather]]: if (sig[0] as Signal).is_connected(sig[1]): (sig[0] as Signal).disconnect(sig[1]) diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd index 5343707..9a08046 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -68,6 +68,7 @@ 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)) + p.survival.drain_mult = Rules.difficulty("drain") # 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): @@ -210,14 +211,28 @@ func _on_died(peer: int) -> void: for st in p.inventory.slots: if st != null: has_items = true - if has_items: + # What dying costs (the server's death_penalty rule). + var penalty := Rules.death_penalty() + if has_items and penalty == "grave": 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()) + elif has_items and penalty == "drop_all": + # Everything scattered on the ground where you fell, for anyone. + var rng := RandomNumberGenerator.new() + for st in p.inventory.slots: + if st != null: + var off := Vector3(rng.randf_range(-1.2, 1.2), 0.0, rng.randf_range(-1.2, 1.2)) + var at: Vector3 = pos + off + at.y = w.terrain.sample_height(at.x, at.z) + 0.05 + w.drop_item(st.id, int(st.count), Transform3D(Basis(Vector3.UP, rng.randf() * TAU), at)) + p.inventory.load_data(Inventory.new(p.inventory.size()).to_data()) + # ("keep": you keep everything.) # 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) + _s2c_died.rpc_id(peer, p.survival.cause if not p.survival.cause.is_empty() else "You died", + has_items and penalty != "keep", p.bed_piece(w) != null, penalty) _acting = 0 const RESPAWN_DELAY := 3.0 @@ -301,11 +316,12 @@ func _run(p: Node3D, what: String, a: Array) -> void: Actions.place(p, _i(a, 0), xf.orthonormalized(), w) "dismantle": var piece := WorldNet.piece(_i(a, 0)) - if piece != null: + if piece != null and _allowed(Rules.dismantle_problem(p.player_id, piece)): 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): + if d != null and d.get("open") != null and _allowed(Rules.use_problem(p.player_id, d)) \ + and Actions.toggle_door(p, d): WorldNet.piece_state(d) "fuel": var f := WorldNet.piece(_i(a, 0)) @@ -313,7 +329,7 @@ func _run(p: Node3D, what: String, a: Array) -> void: WorldNet.piece_state(f) "bed": var b := WorldNet.piece(_i(a, 0)) - if b != null and b.item_id == "bed": + if b != null and b.item_id == "bed" and _allowed(Rules.use_problem(p.player_id, b)): Actions.use_bed(p, b) "inv_click": # Slot `a[1]` of your own inventory (a[0] = 0) or a chest's. @@ -373,6 +389,8 @@ func _run(p: Node3D, what: String, a: Array) -> void: var c: Node3D = CreatureNet.creature(_i(a, 0)) if c != null: Actions.melee(p, c) + "hit_player": + Actions.hit_player(p, _i(a, 0)) "butcher": var c: Node3D = CreatureNet.creature(_i(a, 0)) if c != null: @@ -455,6 +473,9 @@ func _beds_needed(n: int) -> int: ## 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: + if String(Net.config.v("sleep")) == "off": + tell(p.peer, "Sleeping is off on this server: the nights have to be lived through") + return p.in_bed = true p.bed_at = p.global_position if not _maybe_pass_night(): @@ -495,11 +516,16 @@ 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 + return c if _allowed(Rules.use_problem(p.player_id, c)) else null + +const Rules = preload("res://scripts/net/rules.gd") + +## Tells the player why not, if there's a reason. +func _allowed(why: String) -> bool: + if why.is_empty(): + return true + Actions.notify.emit(why) + return false 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 @@ -567,9 +593,9 @@ func _s2c_stats(stats: Dictionary, statuses: Array, env: Array, bed: Array) -> v world.bed_spawn = bed[1] @rpc("authority", "call_remote", "reliable") -func _s2c_died(cause: String, has_items: bool, to_bed: bool) -> void: +func _s2c_died(cause: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void: if world != null and is_instance_valid(world): - world.died_online(cause, has_items, to_bed) + world.died_online(cause, has_items, to_bed, penalty) @rpc("authority", "call_remote", "reliable") func _s2c_respawn(at: Vector3, yaw: float, text: String) -> void: diff --git a/scripts/net/rules.gd b/scripts/net/rules.gd new file mode 100644 index 0000000..a4ae29f --- /dev/null +++ b/scripts/net/rules.gd @@ -0,0 +1,74 @@ +extends RefCounted +## A dedicated server's rules (docs/MULTIPLAYER_PLAN.md §4): who may use and +## take down whose things, whether players can hurt each other, what death +## costs, and how hard the island is. All checked on the server; single +## player has no rules (everything is yours). +## +## Pieces remember who placed them (meta "owner" = player id, "owner_name"). +## Pieces with no owner (placed before M2.6, or by the world) are anyone's. + +const DIFFICULTY := { + # hunger and diet drain, animal and wolf damage, extra wolves in the pack + "easy": {"drain": 0.75, "damage": 0.7, "pack": -1}, + "normal": {"drain": 1.0, "damage": 1.0, "pack": 0}, + "hard": {"drain": 1.3, "damage": 1.4, "pack": 1}, +} + +static func _cfg() -> RefCounted: + var net: Node = (Engine.get_main_loop() as SceneTree).root.get_node_or_null("Net") + return net.config if net != null and net.is_server() else null + +static func v(k: String) -> Variant: + var c := _cfg() + return c.v(k) if c != null else null + +static func owner_of(piece: Node) -> String: + return String(piece.get_meta("owner", "")) if piece != null else "" + +static func owner_name(piece: Node) -> String: + return String(piece.get_meta("owner_name", "someone")) + +## Whether player `id` may open, take from or use `piece` (chests, doors, +## beds, graves), going by `locks`. +static func may_use(id: String, piece: Node) -> bool: + var c := _cfg() + var owner := owner_of(piece) + if c == null or owner.is_empty() or owner == id: + return true + match String(c.v("locks")): + "off": + return true + "owner_and_friends": + return c.is_friend(owner, id) + return false # "owner" + +## Why `id` can't use `piece` ("" = they can). +static func use_problem(id: String, piece: Node) -> String: + if may_use(id, piece): + return "" + var what: String = piece.display_name().to_lower() if piece.has_method("display_name") else "that" + if piece.item_id == "grave": + return "That's %s's grave" % owner_name(piece) + return "That %s is %s's (locked)" % [what, owner_name(piece)] + +## Taking a piece down: your own, anything you're allowed to use (friends, +## or locks off), or anyone's if raiding is on. +static func dismantle_problem(id: String, piece: Node) -> String: + if may_use(id, piece) or bool(v("raiding")): + return "" + return "That's %s's, and raiding is off on this server" % owner_name(piece) + +static func pvp() -> bool: + return bool(v("pvp")) + +static func death_penalty() -> String: + var d: Variant = v("death_penalty") + return String(d) if d != null else "grave" + +static func difficulty(k: String) -> float: + var d: Variant = v("difficulty") + return float(DIFFICULTY.get(String(d) if d != null else "normal", DIFFICULTY.normal)[k]) + +static func grace_days() -> int: + var g: Variant = v("wolf_grace_days") + return clampi(int(g), 0, 10) if g != null else 3 diff --git a/scripts/net/server_config.gd b/scripts/net/server_config.gd index 3d33fa1..c415958 100644 --- a/scripts/net/server_config.gd +++ b/scripts/net/server_config.gd @@ -54,6 +54,7 @@ static func open(p: String = DEFAULT_PATH) -> RefCounted: if FileAccess.file_exists(c.names_path()) else null if n is Dictionary: c.names = n + c.load_friends() return c func names_path() -> String: @@ -104,3 +105,104 @@ func add_admin(id: String) -> void: if not id in data.admins: data.admins.append(id) save() + +func ban(id: String) -> void: + if not id in data.banned: + data.banned.append(id) + save() + +func unban(id: String) -> bool: + if not id in data.banned: + return false + data.banned.erase(id) + save() + return true + +## A reserved name back up for grabs (the player who had it keeps their +## character, and picks another name next time they join). +func release_name(n: String) -> bool: + if not names.has(n.to_lower()): + return false + names.erase(n.to_lower()) + save_names() + return true + +## The name a player id goes by here ("" if none). +func name_of(id: String) -> String: + for k in names: + if names[k].id == id: + return String(names[k].name) + return "" + +# --- rules that can change while the server runs (/set) -------------------------- + +## What each can be set to. The others in DEFAULTS are fixed at start-up. +const SETTABLE := { + "pvp": ["false", "true"], + "raiding": ["false", "true"], + "locks": ["off", "owner", "owner_and_friends"], + "death_penalty": ["grave", "keep", "drop_all"], + "sleep": ["all_online", "half", "anyone", "off"], + "pause_when_empty": ["false", "true"], + "difficulty": ["easy", "normal", "hard"], + "wolf_grace_days": "0-10", + "motd": "text", + "server_name": "text", +} + +## Changes a rule and saves server.json. Returns "" or what's wrong. +func set_rule(k: String, value: String) -> String: + if not SETTABLE.has(k): + return "Can't set '%s'. These can be set: %s" % [k, ", ".join(PackedStringArray(SETTABLE.keys()))] + var allowed: Variant = SETTABLE[k] + var val: Variant + if allowed is Array: + if not value in allowed: + return "%s can be: %s" % [k, " / ".join(PackedStringArray(allowed))] + val = (value == "true") if value in ["true", "false"] else value + elif allowed == "0-10": + if not value.is_valid_int() or int(value) < 0 or int(value) > 10: + return "%s is a number from 0 to 10" % k + val = int(value) + else: + if value.strip_edges().is_empty() or value.length() > 80: + return "%s needs some text (up to 80 characters)" % k + val = value.strip_edges() + data[k] = val + overrides.erase(k) # (a flag no longer wins over what the admin just set) + save() + return "" + +# --- friends (docs/MULTIPLAYER_PLAN.md §4) ---------------------------------------- +# Per player: whose doors and chests they share. Stored as owner id -> [ids], +# in friends.json next to server.json. + +var friends: Dictionary = {} + +func friends_path() -> String: + return path.get_base_dir().path_join("friends.json") + +func load_friends() -> void: + var f: Variant = JSON.parse_string(FileAccess.get_file_as_string(friends_path())) \ + if FileAccess.file_exists(friends_path()) else null + friends = f if f is Dictionary else {} + +## `owner` lets `friend` use their things. +func add_friend(owner: String, friend: String) -> void: + var l: Array = friends.get(owner, []) + if not friend in l: + l.append(friend) + friends[owner] = l + _write(friends_path(), friends) + +func remove_friend(owner: String, friend: String) -> bool: + var l: Array = friends.get(owner, []) + if not friend in l: + return false + l.erase(friend) + friends[owner] = l + _write(friends_path(), friends) + return true + +func is_friend(owner: String, friend: String) -> bool: + return friend in friends.get(owner, []) diff --git a/scripts/net/server_player.gd b/scripts/net/server_player.gd index 619a71a..06d7c9a 100644 --- a/scripts/net/server_player.gd +++ b/scripts/net/server_player.gd @@ -33,12 +33,24 @@ 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 +var melee_ready_at := 0 ## ticks (ms): PvP swings wait for the weapon's cooldown + +## Physics layer 5 (value 16): only arrows and thrown spears look for it, +## and only when PvP is on (projectile.gd). Nothing else collides with it. +const PLAYER_LAYER := 16 func setup(world: Node3D) -> void: hands = self - collision_layer = 0 + collision_layer = PLAYER_LAYER collision_mask = 0 set_physics_process(false) + var cs := CollisionShape3D.new() + var cap := CapsuleShape3D.new() + cap.radius = 0.4 + cap.height = 1.8 + cs.shape = cap + cs.position = Vector3(0, 0.9, 0) + add_child(cs) water_level = world.terrain.water_level survival = preload("res://scripts/player/survival.gd").new() survival.name = "Survival" diff --git a/scripts/net/world_net.gd b/scripts/net/world_net.gd index 8a38a09..ba6a646 100644 --- a/scripts/net/world_net.gd +++ b/scripts/net/world_net.gd @@ -198,6 +198,11 @@ func piece_state(p: Node3D) -> void: _all("_s2c_piece_state", [int(p.get_meta("nid", 0)), p.save_extra()]) func _on_piece_added(p: Node3D) -> void: + # Whoever's request placed it owns it (graves: whoever died). + var by: Node = PlayerNet.proxies.get(PlayerNet._acting) + if by != null and not p.has_meta("owner"): + p.set_meta("owner", String(by.player_id)) + p.set_meta("owner_name", String(by.display)) _register(p) _all("_s2c_piece_added", [world.piece_entry(p)]) diff --git a/scripts/player/hands.gd b/scripts/player/hands.gd index 22b2b3b..f2e72ae 100644 --- a/scripts/player/hands.gd +++ b/scripts/player/hands.gd @@ -164,6 +164,11 @@ func _physics_process(delta: float) -> void: var p: String = t.interact_prompt(player) if t.is_in_group("piece") and t.item_id != "grave": p += " [X] Dismantle" + # Online: someone else's piece says whose it is (the server decides + # whether you may use it, by its rules). + var owner := String(t.get_meta("owner", "")) + if not owner.is_empty() and Net.state == "connected" and owner != Net.get_identity().player_id: + p = "%s's · %s" % [String(t.get_meta("owner_name", "someone")), p] _set_prompt(p) elif def.has("food"): _set_prompt("[RMB] Eat %s" % def.name) @@ -346,6 +351,9 @@ func _apply_hit() -> void: # Animals first: a short box sweep in front of the camera, so you don't # need pixel-perfect aim at a running rabbit. var animal := melee_target() + if animal != null and animal.has_meta("avatar_peer"): + Actions.hit_player(player, int(animal.get_meta("avatar_peer"))) + return if animal != null: Actions.melee(player, animal) return @@ -366,12 +374,14 @@ func melee_target() -> Node3D: var q := PhysicsShapeQueryParameters3D.new() q.shape = box q.transform = Transform3D(cam.global_basis, cam.global_position - cam.global_basis.z * reach * 0.5 - cam.global_basis.y * 0.5) - q.collision_mask = 8 + # Online with PvP on, other players' avatars too (their hitbox, layer 16). + var pvp: bool = Net.state == "connected" and bool(Net.server_info.get("pvp", false)) + q.collision_mask = 8 | (16 if pvp else 0) var best: Node3D = null var best_d := INF for h in cam.get_world_3d().direct_space_state.intersect_shape(q, 8): var c: Node3D = h.collider - if c.is_in_group("creature"): + if c.is_in_group("creature") or c.has_meta("avatar_peer"): var d := c.global_position.distance_to(cam.global_position) if d < best_d: best_d = d diff --git a/scripts/player/survival.gd b/scripts/player/survival.gd index 181e2b1..82bb540 100644 --- a/scripts/player/survival.gd +++ b/scripts/player/survival.gd @@ -39,6 +39,7 @@ var _rest_build := 0.0 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") +var drain_mult := 1.0 ## hunger and diet drain (a server's difficulty, rules.gd) signal hurt(amount: float, from: Vector3) const HURT_NO_REGEN := 6.0 @@ -111,7 +112,7 @@ func step(dt: float) -> void: # Warm up faster than you cool down: getting to a fire should feel good. warmth = move_toward(warmth, target, (7.0 if target > warmth else 1.6) * dt) - hunger = maxf(hunger - HUNGER_DRAIN * dt * (1.5 if warmth < 30.0 else 1.0), 0.0) + hunger = maxf(hunger - HUNGER_DRAIN * drain_mult * dt * (1.5 if warmth < 30.0 else 1.0), 0.0) if _stamina_wait > 0.0: _stamina_wait -= dt @@ -121,7 +122,7 @@ func step(dt: float) -> void: * (0.7 if malnourished else 1.0) * dt, MAX) for g in DIET_GROUPS: - diet[g] = maxf(float(diet[g]) - DIET_DRAIN * dt, 0.0) + diet[g] = maxf(float(diet[g]) - DIET_DRAIN * drain_mult * dt, 0.0) var lacking := diet_lacking() for id in buffs.keys(): diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index d90f80f..c7092ae 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -109,6 +109,7 @@ func bind(p: CharacterBody3D) -> void: root.move_child(_hurt, 0) player.survival.hurt.connect(_on_hurt) Actions.notify.connect(toast) + _build_chat(root) ItemDB.icons_ready.connect(_refresh) _refresh() @@ -175,6 +176,7 @@ func _build_stats(root: Control) -> void: root.add_child(_clock) func _process(_d: float) -> void: + _fade_chat() var s = player.survival for k in _bars: _bars[k].value = s.get(k) @@ -211,6 +213,70 @@ func fade(mid: Callable) -> void: var _death: Control +# --- chat (online: T to talk) ---------------------------------------------------------- + +const CHAT_LINES := 8 +const CHAT_FADE := 14.0 ## seconds a line stays up (all show while typing) +var _chat_log: VBoxContainer +var _chat_input: LineEdit + +func _build_chat(root: Control) -> void: + _chat_log = VBoxContainer.new() + _chat_log.position = Vector2(16, 64) + _chat_log.custom_minimum_size = Vector2(520, 0) + _chat_log.add_theme_constant_override("separation", 2) + _chat_log.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(_chat_log) + _chat_input = LineEdit.new() + _chat_input.position = Vector2(16, 64 + CHAT_LINES * 24 + 6) + _chat_input.custom_minimum_size = Vector2(520, 34) + _chat_input.placeholder_text = "Say something (Enter), or /help for commands. Esc to cancel" + _chat_input.max_length = 200 + _chat_input.visible = false + _chat_input.text_submitted.connect(func(t): + Chat.say(t) + close_chat()) + root.add_child(_chat_input) + Chat.message.connect(chat_line) + +func chat_line(text: String, kind: String) -> void: + var col := UITheme.TEXT + if kind == "server": + col = Color(0.95, 0.82, 0.45) + elif kind == "reply": + col = UITheme.TEXT_DIM + var l := UITheme.label(text, 15, col) + l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + l.custom_minimum_size = Vector2(520, 0) + l.set_meta("t", Time.get_ticks_msec()) + _chat_log.add_child(l) + while _chat_log.get_child_count() > CHAT_LINES: + var old := _chat_log.get_child(0) + _chat_log.remove_child(old) + old.queue_free() + +func open_chat() -> void: + _chat_input.text = "" + _chat_input.visible = true + player.ui_open = true + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _chat_input.grab_focus() + +func close_chat() -> void: + _chat_input.visible = false + _chat_input.release_focus() + player.ui_open = false + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + +func _fade_chat() -> void: + if _chat_log == null: + return + var typing := _chat_input.visible + var now := Time.get_ticks_msec() + for l in _chat_log.get_children(): + var age := (now - int(l.get_meta("t", now))) / 1000.0 + l.modulate.a = 1.0 if typing else clampf((CHAT_FADE - age) / 2.0, 0.0, 1.0) + ## 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`. @@ -286,6 +352,16 @@ 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 _chat_input != null and _chat_input.visible: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + close_chat() + get_viewport().set_input_as_handled() + return # (typing: keys go to the chat line) + if event is InputEventKey and event.pressed and not event.echo and event.physical_keycode == KEY_T \ + and Net.state == "connected" and not player.ui_open and not is_dead_screen(): + open_chat() + get_viewport().set_input_as_handled() + return if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: if is_dead_screen(): return # (respawn first) diff --git a/scripts/world/world.gd b/scripts/world/world.gd index b7a42d1..947f2d9 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -204,9 +204,14 @@ func _on_player_died() -> void: 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"] +static func _death_note(has_items: bool, to_bed: bool, penalty: String = "grave") -> String: + var things := "You had nothing on you." + if penalty == "keep": + things = "You keep everything you were carrying." + elif has_items: + things = "Everything you carried is scattered where you fell: anyone can pick it up." if penalty == "drop_all" \ + else "Your belongings are in a grave where you fell." + return "%s\nYou'll wake %s." % [things, "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: @@ -225,13 +230,13 @@ func respawn_after_death() -> void: player.survival.reset_after_death() ## Online: the server says we died. Same screen; Respawn asks the server. -func died_online(text: String, has_items: bool, to_bed: bool) -> void: +func died_online(text: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> 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")) + hud.show_death(text, _death_note(has_items, to_bed, penalty), RESPAWN_DELAY, func(): PlayerNet.ask("respawn")) else: get_tree().create_timer(RESPAWN_DELAY + 0.1).timeout.connect(func(): PlayerNet.ask("respawn")) @@ -349,6 +354,7 @@ func _on_terrain_ready() -> void: wolves.name = "Wolves" add_child(wolves) wolves.setup(self, den) + apply_rules() return _respawn() if Saves.has_world(): @@ -373,6 +379,16 @@ func _on_terrain_ready() -> void: wolves.setup(self, den) _maybe_capture() +## A server's rules for the island (at start-up, and after an admin's /set). +func apply_rules() -> void: + var R = preload("res://scripts/net/rules.gd") + if wolves != null: + wolves.den_day = R.grace_days() + wolves.hunt_day = R.grace_days() + 1 + wolves.pack_bonus = int(R.difficulty("pack")) + for p in PlayerNet.proxies.values(): + p.survival.drain_mult = R.difficulty("drain") + func _process(delta: float) -> void: if menu_mode: # Slow orbit over the valley, looking toward the lake. @@ -514,6 +530,9 @@ func piece_entry(piece: Node3D) -> Dictionary: entry["extra"] = piece.save_extra() if piece.has_meta("nid"): entry["nid"] = int(piece.get_meta("nid")) + if piece.has_meta("owner"): + entry["owner"] = String(piece.get_meta("owner")) + entry["owner_name"] = String(piece.get_meta("owner_name", "")) return entry func dropped_entry(d: Node3D) -> Dictionary: @@ -543,6 +562,9 @@ func add_piece_entry(e: Dictionary) -> Node3D: piece.load_extra(e.extra) if e.has("nid"): piece.set_meta("nid", int(e.nid)) + if e.has("owner"): + piece.set_meta("owner", String(e.owner)) + piece.set_meta("owner_name", String(e.get("owner_name", ""))) return piece func add_dropped_entry(e: Dictionary) -> Node3D: @@ -730,7 +752,7 @@ func _go_online() -> void: 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" % Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM) + var bar := UITheme.label("Online: %s · T to chat (/help)" % 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")) @@ -799,6 +821,10 @@ var bobbers: Dictionary = {} ## player -> their float # --- who's here ------------------------------------------------------------ +## A server's difficulty for one thing (rules.gd); 1 in single-player. +func difficulty_mult(k: String) -> float: + return preload("res://scripts/net/rules.gd").difficulty(k) if server_mode else 1.0 + ## 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: diff --git a/server/run_server.sh b/server/run_server.sh index e574ed6..49eac94 100755 --- a/server/run_server.sh +++ b/server/run_server.sh @@ -13,4 +13,4 @@ cd "$(dirname "$0")/.." || exit 1 . tools/godot_env.sh -exec "$GODOT" --headless --path . -- --server "$@" +exec "$GODOT" --headless --path . -- --server --console "$@" diff --git a/tests/net_world_bot.gd b/tests/net_world_bot.gd index 16fa210..86b1644 100644 --- a/tests/net_world_bot.gd +++ b/tests/net_world_bot.gd @@ -131,13 +131,21 @@ func _do(cmd: Dictionary) -> Dictionary: return {"asked": true} "life": return life(world, root.get_node("CreatureNet"), root.get_node("PlayerNet")) + # M2.6 (tests/test_net_rules.gd). + "say": + root.get_node("Chat").say(String(cmd.text)) + await create_timer(0.6).timeout + return {"heard": root.get_node("Chat").heard.duplicate()} + "heard": + return {"heard": root.get_node("Chat").heard.duplicate(), "pvp": bool(net.server_info.get("pvp", false))} 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()} + "hours": w.day_night.hours(), "statuses": w.player.survival.statuses.duplicate(), + "weather": w.weather.state} for cid in CN.puppets: var c: Node3D = CN.puppets[cid] if is_instance_valid(c): diff --git a/tests/run_all.sh b/tests/run_all.sh index d830f94..e992339 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_net_life 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_net_rules 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_rules.gd b/tests/test_net_rules.gd new file mode 100644 index 0000000..42f08ff --- /dev/null +++ b/tests/test_net_rules.gd @@ -0,0 +1,419 @@ +extends SceneTree +## M2.6: server rules, chat and admin commands. Two players on a dedicated +## server: chat; who may use and take down whose things (locks, friends, +## raiding); PvP off and on; what dying costs (drop_all, keep); the clock +## and the weather; kicking, banning and unbanning. +## +## A real headless server (with --debug-cheats), Alice (this test: the first +## player, so the admin) and Bob (tests/net_world_bot.gd). +## Run: godot --headless --path . --script res://tests/test_net_rules.gd + +const PORT := 24349 +const DIR := "user://test_net" +const SRV := DIR + "/server7" +const BOBS := DIR + "/bob_rules" +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 A: Node +var C: Node +var world: Node3D +var player: CharacterBody3D +var _bob_dir := "" +var _cmd := 0 +var _bobs := 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") + A = root.get_node("Actions") + C = root.get_node("Chat") + A.notify.connect(func(t): _toasts.append(t)) + for d in [SRV, BOBS]: + _rm_tree(d) + _spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json", + "--server-name", "Rules Test", "--seed", str(SEED), "--debug-cheats", "--no-creatures"]) + 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(BOBS) + 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, the first player, so she's the admin (%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) + var b0 := await _new_bob() + check(bool(b0.get("ok", false)), "Bob joins (%s)" % b0.get("why", "ok")) + if not bool(b0.get("ok", false)): + return + await _chat() + await _locks() + await _pvp() + await _death() + await _clock_and_weather() + await _kick_and_ban() + world.queue_free() + await process_frame + Net.leave() + _finished = true + +# --- chat ------------------------------------------------------------------------ + +func _chat() -> void: + print("chat") + C.say("hello everyone") + var h := await _bob_heard("Alice: hello everyone") + check(h, "Alice says hello: Bob sees 'Alice: hello everyone'") + await _bob({"do": "say", "text": "hi Alice"}) + await _until(func(): return C.heard.has("Bob: hi Alice"), 2.0) + check(C.heard.has("Bob: hi Alice"), "and Alice sees Bob's answer") + await _bob({"do": "say", "text": "/rules"}) + check(await _bob_heard_prefix("PvP off · raiding off · locks owner_and_friends"), "Bob asks for /rules: co-op by default (PvP off, raiding off, locks for owner and friends)") + await _bob({"do": "say", "text": "/set pvp true"}) + check(await _bob_heard("Only admins can use /set"), "Bob isn't an admin: he can't /set") + +# --- locks, friends, raiding --------------------------------------------------------- + +func _locks() -> void: + print("locks and friends") + var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(8, 0, 0), 3.0) + await _tp(site + Vector3(-2.5, 0, 0)) + var chest := await _place("chest", site) + var door := await _place("wood_door", site + Vector3(0, 0, 4)) + check(chest != null and door != null, "Alice places a chest and a door") + if chest == null or door == null: + return + check(String(chest.get_meta("owner_name", "")) == "Alice", "they're hers (the server says so, and everyone's game knows)") + var ss: int = await _give("stone", 6) + A.transfer(player, player.inventory, ss, chest.inventory, chest) + await _until(func(): return chest.inventory.count("stone") == 6, 2.0) + await _bob({"do": "tp", "to": _v(site + Vector3(2.0, 0, 0))}) + var took := await _bob({"do": "chest_take", "nid": WN.nid(chest), "slot": 0}) + check(_count(took.get("inv", []), "stone") == 0 and chest.inventory.count("stone") == 6, "Bob can't take from Alice's chest (locked)") + await _bob({"do": "ask", "what": "door", "args": [WN.nid(door)]}) + await _sleep(0.3) + check(not door.open, "or open her door") + await _bob({"do": "ask", "what": "dismantle", "args": [WN.nid(door)]}) + await _sleep(0.3) + check(WN.pieces.has(WN.nid(door)), "or take it down (raiding is off)") + C.say("/friend Bob") + check(await _bob_heard("Alice added you as a friend: you can use their doors, chests and beds"), "Alice makes Bob a friend") + took = await _bob({"do": "chest_take", "nid": WN.nid(chest), "slot": 0}) + check(_count(took.get("inv", []), "stone") == 6, "now he can take from her chest") + await _bob({"do": "ask", "what": "door", "args": [WN.nid(door)]}) + await _until(func(): return door.open, 2.0) + check(door.open, "and open her door") + C.say("/unfriend Bob") + await _sleep(0.4) + await _bob({"do": "ask", "what": "door", "args": [WN.nid(door)]}) + await _sleep(0.4) + check(door.open, "unfriended: the door stays as it was") + print("raiding") + C.say("/set raiding true") + check(await _bob_heard("Alice set raiding to true"), "Alice turns raiding on (everyone's told)") + var dn: int = WN.nid(door) + await _bob({"do": "ask", "what": "dismantle", "args": [dn]}) + await _until(func(): return not WN.pieces.has(dn), 2.0) + check(not WN.pieces.has(dn), "now Bob can take her door down") + C.say("/set raiding false") + await _sleep(0.4) + +## Places `id` on the ground at `at` (build pieces snap) through Actions. +func _place(id: String, at: Vector3) -> Node3D: + var def: Dictionary = root.get_node("ItemDB").get_item(id) + var xf := Transform3D(Basis(), Vector3(at.x, world.terrain.sample_height(at.x, at.z), at.z)) + if def.place.has("kind"): + var hit := {"position": xf.origin, "normal": Vector3.UP, "collider": world.terrain.get_node("TerrainBody")} + var res: Dictionary = world.build.placement(def, hit, 0.0) + if not res.ok: + print(" (can't place %s: %s)" % [id, res.reason]) + return null + xf = res.xf + var slot: int = await _give(id, 1) + var before: Array = WN.pieces.keys() + A.place(player, slot, xf, world) + var found := {"p": null} + await _until(func(): + for k in WN.pieces: + if not k in before and WN.pieces[k].item_id == id: + found.p = WN.pieces[k] + return true + return false, 3.0) + return found.p + +# --- PvP --------------------------------------------------------------------------- + +func _pvp() -> void: + print("PvP") + PN.ask("debug_stats", [{"health": 100.0}]) + await _bob({"do": "ask", "what": "debug_give", "args": ["club", 1]}) + await _bob({"do": "ask", "what": "debug_stats", "args": [{"stamina": 100.0}]}) + var near := player.global_position + Vector3(1.4, 0, 0) + near.y = world.terrain.sample_height(near.x, near.z) + await _bob({"do": "tp", "to": _v(near)}) + var slot := await _bob_slot("club") + await _bob({"do": "ask", "what": "select", "args": [slot]}) + await _until(func(): return player.survival.health >= 99.0, 2.0) + var h0: float = player.survival.health + var hurts0: int = PN.hurts + await _bob({"do": "ask", "what": "hit_player", "args": [Net.local_peer]}) + await _sleep(0.6) + check(PN.hurts == hurts0 and player.survival.health >= h0 - 0.5, "with PvP off, Bob's club does nothing to Alice") + C.say("/set pvp true") + await _until(func(): return bool(Net.server_info.get("pvp", false)), 2.0) + check(bool(Net.server_info.get("pvp", false)), "Alice turns PvP on, and every game hears the new rules") + await _bob({"do": "ask", "what": "hit_player", "args": [Net.local_peer]}) + await _until(func(): return PN.hurts > hurts0, 2.0) + await _sleep(0.3) + check(PN.hurts > hurts0 and player.survival.health < h0 - 5.0, "now his club hurts her (health %.0f)" % player.survival.health) + # An arrow, too. + await _bob({"do": "ask", "what": "debug_give", "args": ["bow", 1]}) + await _bob({"do": "ask", "what": "debug_give", "args": ["arrow", 3]}) + var far := player.global_position + Vector3(7.0, 0, 0) + far.y = world.terrain.sample_height(far.x, far.z) + await _bob({"do": "tp", "to": _v(far)}) + await _bob({"do": "ask", "what": "select", "args": [await _bob_slot("bow")]}) + var h1: float = player.survival.health + var aim: Vector3 = (player.global_position + Vector3(0, 1.0, 0)) - (far + Vector3(0, 1.6, 0)) + await _bob({"do": "ask", "what": "shoot", "args": [{"v": _v(aim.normalized())}, 1.0]}) + await _until(func(): return player.survival.health < h1 - 1.0, 3.0) + check(player.survival.health < h1 - 1.0, "and so does an arrow (health %.0f)" % player.survival.health) + C.say("/set pvp false") + await _sleep(0.4) + +# --- death penalties ------------------------------------------------------------------- + +func _death() -> void: + print("death penalties") + C.say("/set death_penalty drop_all") + await _sleep(0.4) + await _clear() + await _give("stone", 5) + var at := player.global_position + var graves0 := _graves() + var d0: int = WN.dropped.size() + PN.ask("debug_die") + await _until(func(): return WN.dropped.size() > d0, 3.0) + check(_graves() == graves0 and WN.dropped.size() > d0 and player.inventory.count("stone") == 0, + "drop_all: no grave, her stone is lying on the ground where she fell, for anyone") + var near_drop: bool = WN.dropped.values().any(func(d): return is_instance_valid(d) and d.item_id == "stone" and d.global_position.distance_to(at) < 3.0) + check(near_drop, "(right where she died)") + await _until(func(): return not world.dead_waiting, 6.0) + C.say("/set death_penalty keep") + await _sleep(0.4) + await _give("flint", 4) + PN.ask("debug_die") + await _until(func(): return world.dead_waiting, 3.0) + await _until(func(): return not world.dead_waiting, 6.0) + await _sleep(0.4) + check(player.inventory.count("flint") == 4 and _graves() == graves0, "keep: she wakes with her flint, and there's no grave") + C.say("/set death_penalty grave") + await _sleep(0.4) + +func _graves() -> int: + return WN.pieces.values().filter(func(p): return is_instance_valid(p) and p.item_id == "grave").size() + +# --- clock and weather --------------------------------------------------------------- + +func _clock_and_weather() -> void: + print("the clock and the weather") + C.say("/time 22:30") + await _until(func(): return absf(world.day_night.hours() - 22.5) < 0.2, 2.0) + check(absf(world.day_night.hours() - 22.5) < 0.2, "/time 22:30: it's night for Alice (%s)" % world.day_night.clock_text()) + var life := await _bob({"do": "life"}) + check(absf(float(life.get("hours", 0.0)) - 22.5) < 0.2, "and for Bob (%.2f h)" % float(life.get("hours", 0.0))) + C.say("/weather storm") + await _until(func(): return world.weather.state == "storm", 3.0) + life = await _bob({"do": "life"}) + check(world.weather.state == "storm" and String(life.get("weather", "")) == "storm", "/weather storm: a storm for both (Alice %s, Bob %s)" % [world.weather.state, life.get("weather", "?")]) + C.say("/weather auto") + await _sleep(0.4) + check(world.weather.override == "", "/weather auto: back to the island's own weather") + +# --- kick, ban --------------------------------------------------------------------------- + +func _kick_and_ban() -> void: + print("kick and ban") + C.say("/kick Bob") + await _until(func(): return Net.players.values().all(func(p): return p.name != "Bob"), 4.0) + check(Net.players.values().all(func(p): return p.name != "Bob"), "/kick Bob: he's gone") + var b := await _new_bob() + check(bool(b.get("ok", false)), "he can come back") + C.say("/ban Bob") + await _until(func(): return Net.players.values().all(func(p): return p.name != "Bob"), 4.0) + check(Net.players.values().all(func(p): return p.name != "Bob"), "/ban Bob: gone again") + b = await _new_bob() + check(not bool(b.get("ok", true)) and String(b.get("why", "")).contains("banned"), "and can't come back (%s)" % b.get("why", "")) + C.say("/unban Bob") + await _sleep(0.5) + b = await _new_bob() + check(bool(b.get("ok", false)), "/unban Bob: he can join again") + await _bob({"do": "leave"}) + +# --- helpers -------------------------------------------------------------------- + +## A fresh Bob (his own command folder; the same identity every time). +func _new_bob() -> Dictionary: + _bobs += 1 + _bob_dir = "%s/b%d" % [BOBS, _bobs] + DirAccess.make_dir_recursive_absolute(_bob_dir) + _cmd = 0 + _spawn(["--identity", DIR + "/bob", "--name", "Bob", "--address", "127.0.0.1:%d" % PORT, "--dir", _bob_dir], + "res://tests/net_world_bot.gd") + return await _bob_result(0, 30.0) + +func _bob_heard(line: String) -> bool: + for _i in range(15): + var h := await _bob({"do": "heard"}) + if (h.get("heard", []) as Array).has(line): + return true + await _sleep(0.2) + return false + +func _bob_heard_prefix(start: String) -> bool: + for _i in range(15): + var h := await _bob({"do": "heard"}) + if (h.get("heard", []) as Array).any(func(l): return String(l).begins_with(start)): + return true + await _sleep(0.2) + return false + +func _bob_slot(id: String) -> int: + var rep := await _bob({"do": "report"}) + var inv: Array = rep.get("inv", []) + for i in range(inv.size()): + if inv[i] != null and inv[i].id == id: + return i + return -1 + +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 _clear() -> void: + PN.ask("debug_clear") + await _until(func(): return player.inventory.slots.all(func(s): return s == null), 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() + +static func _count(inv: Array, id: String) -> int: + var n := 0 + for s in inv: + if s != null and s.id == id: + n += int(s.count) + return n + +static func _v(p: Vector3) -> Array: + return [p.x, p.y, p.z] + +func _bob(cmd: Dictionary) -> Dictionary: + _cmd += 1 + var f := FileAccess.open("%s/cmd_%d.json" % [_bob_dir, _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_dir, 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, "heard": [], "inv": [], "pieces": {}} + +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_shared.gd b/tests/test_net_shared.gd index 090f98f..596b84b 100644 --- a/tests/test_net_shared.gd +++ b/tests/test_net_shared.gd @@ -307,8 +307,12 @@ func _containers_and_fires() -> void: var s = _chest.inventory.get_slot(i) if s != null and s.id == "wood": slot = i + # (Chests are locked to their owner and friends by default: M2.6.) + root.get_node("Chat").say("/friend Bob") + await _sleep(0.5) var took := await _bob({"do": "chest_take", "nid": nid, "slot": slot}) - check(_count(took.get("inv", []), "wood") == 10, "Bob takes the wood out (shift-click)") + check(_count(took.get("inv", []), "wood") == 10, "Bob (her friend) takes the wood out (shift-click)") + root.get_node("Chat").say("/unfriend Bob") # (friends can loot each other's graves too) await _sleep(0.3) check(_chest.inventory.count("wood") == 0 and _chest.inventory.count("stone") == 3, "and it's gone from Alice's view of the chest")