280 lines
11 KiB
GDScript
280 lines
11 KiB
GDScript
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 <name> /unfriend <name> /friends
|
|
## Admins: /kick <name> /ban <name> /unban <name> /admin <name> /save
|
|
## /backup /backups /time 08:00 /weather clear|cloudy|rain|storm|snow|blizzard|auto
|
|
## /set <rule> <value> /say <message> /release-name <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 <name> /unfriend <name> /friends" +
|
|
(" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /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 <name>)"))
|
|
"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 <rule> <value>, 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)
|