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 ## /backup /backups /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", "backup", "backups", "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]) if not Lands.land.is_empty(): # (Another land's server: commands run on the hub, which keeps # server.json, and answers back here.) Lands.send_hub({"t": "cmd", "line": text.substr(1), "id": String(Net.players[peer].id), "name": who, "peer": peer}) return 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") ## A line for everyone here, and (`relay`) in every other land too (J3b). func _all(text: String, kind: String, relay: bool = true) -> void: if kind == "server": print("[server] ", text) for peer in Net.players: _s2c_line.rpc_id(peer, text, kind) if relay: Lands.relay_line(text, kind) ## A command typed by someone in another land (J3b): who they are, and where ## to send the answer. `run()` sees them as REMOTE. const Terrain = preload("res://scripts/world/terrain.gd") const REMOTE := -1 var _caller: Dictionary = {} func run_remote(line: String, caller: Dictionary) -> void: print("[server] %s (in the %s): /%s" % [caller.name, caller.land, line]) _caller = caller run(line, REMOTE) _caller = {} ## An answer to whoever gave the command (peer 0 = the console). func _reply(peer: int, text: String) -> void: if peer == 0: print(text) elif peer == REMOTE: Lands.tell_land(String(_caller.land), {"t": "reply", "peer": int(_caller.peer), "text": text}) else: _s2c_line.rpc_id(peer, text, "reply") func _is_admin(peer: int) -> bool: if peer == REMOTE: return Net.config.is_admin(String(_caller.id)) return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id))) ## Another land's server should know: this command changed server.json. const CHANGES_CONFIG := ["friend", "unfriend", "ban", "unban", "admin", "set", "release-name"] ## 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 String(_caller.get("id", "")) var by: String = String(Net.players[peer].name) if Net.players.has(peer) else String(_caller.get("name", "The server")) _run_cmd(cmd, parts, rest, arg, peer, me, by) if cmd in CHANGES_CONFIG: Lands.tell_lands({"t": "config"}) func _run_cmd(cmd: String, parts: PackedStringArray, rest: String, arg: String, peer: int, me: String, by: String) -> void: match cmd: "help": _reply(peer, "Commands: /players /rules /friend /unfriend /friends" + (" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather /set /say /release-name" if _is_admin(peer) else "")) "players": # Everyone, in every land (J3b: the hub's roster), with where they are. var names: Array[String] = [] var many := Lands.roster.any(func(e): return String(e.land) != Lands.here()) for p in (Lands.roster if not Lands.roster.is_empty() else Lands._here_list()): names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else "") + (" in the %s" % Terrain.worldlet_name(String(p.land)) if many 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) var away := Lands.land_of_name(arg) if kp == 0 else "" if kp == 0 and (away.is_empty() or away == Lands.here()): _reply(peer, "Nobody called '%s' is online" % arg) return if kp != 0: Net.kick(kp, "You were kicked by %s" % by) else: Lands.tell_land(away, {"t": "kick", "name": arg, "reason": "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) elif not Lands.land_of_name(arg).is_empty(): Lands.tell_land(Lands.land_of_name(arg), {"t": "kick", "name": arg, "reason": "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() Lands.tell_lands({"t": "save"}) _reply(peer, "Saved") "backup": WorldNet.save() Lands.tell_lands({"t": "save"}) var b: String = WorldNet.backup("by %s" % by) _reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet") "backups": var all: Array = WorldNet.list_backups() _reply(peer, "%d backups, newest last: %s" % [all.size(), ", ".join(PackedStringArray(all.slice(-5)))] if not all.is_empty() else "No backups yet") "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) Lands.tell_lands({"t": "weather", "state": "" 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)