Files
Cinderwild/scripts/net/chat.gd

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)