J3b: chat, the player list (with each player's land) and admin commands across lands; sleep needs everyone in every land, with a 'waiting for everyone else' message

This commit is contained in:
Paul Bailey
2026-11-06 23:42:50 +00:00
parent 85bbed249b
commit 86ebe07899
9 changed files with 404 additions and 27 deletions

View File

@@ -57,6 +57,11 @@ func _c2s_say(text: String) -> void:
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.
@@ -68,22 +73,44 @@ func _c2s_say(text: String) -> void:
print("[chat] %s: %s" % [who, text])
_all("%s: %s" % [who, text], "chat")
func _all(text: String, kind: String) -> void:
## 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)
@@ -102,16 +129,24 @@ func run(line: String, peer: int) -> void:
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"
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] = []
for p in Net.players.values():
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else ""))
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
@@ -142,10 +177,14 @@ func run(line: String, peer: int) -> void:
_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)
if kp == 0:
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
Net.kick(kp, "You were kicked by %s" % by)
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)
@@ -156,6 +195,8 @@ func run(line: String, peer: int) -> void:
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)
@@ -173,9 +214,11 @@ func run(line: String, peer: int) -> void:
_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":
@@ -198,6 +241,7 @@ func run(line: String, peer: int) -> void:
_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:

View File

@@ -50,6 +50,9 @@ var _hub_link: Dictionary = {} ## {"conn": _hub, "buf": the partial line
## Called once a server is listening. `args` are the command-line flags.
func server_started(args: Dictionary) -> void:
land = String(args.get("land", ""))
roster = []
if not Net.players_changed.is_connected(rebuild_roster):
Net.players_changed.connect(rebuild_roster)
if not land.is_empty():
hub_port = int(args.get("hub-port", Net.DEFAULT_PORT))
_hub = StreamPeerTCP.new()
@@ -215,6 +218,8 @@ func _hub_tick(delta: float) -> void:
print("[lands] the %s's server stopped" % l)
_drain_output(s)
servers.erase(l)
rebuild_roster()
PlayerNet.beds_changed()
continue
if s.conn != null:
for msg in _read(s):
@@ -253,6 +258,23 @@ func _from_land(l: String, msg: Dictionary) -> void:
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
"host_left":
Net._host_left.call_deferred()
"roster":
servers[l].list = msg.get("list", [])
rebuild_roster()
"line":
# Chat from that land: everyone here, and every other land.
Chat._all(String(msg.text), String(msg.kind), false)
for other in servers:
if other != l:
_send_land(other, msg)
"cmd":
Chat.run_remote(String(msg.line), {"id": String(msg.id), "name": String(msg.name), "land": l, "peer": int(msg.peer)})
"beds":
servers[l].beds = [int(msg.alive), int(msg.asleep)]
# Tell the lands the running total (minus their own), so each can
# say "N of M asleep", then see if the night passes.
_push_bed_totals()
PlayerNet.beds_changed()
## Sends `msg` to land `l`'s server.
func _send_land(l: String, msg: Dictionary) -> void:
@@ -302,9 +324,11 @@ func _land_tick() -> void:
send_hub({"t": "hello", "land": land})
send_hub({"t": "ready"})
var n := Net.players.size()
if n != _last_players:
if n != _last_players or _roster_dirty:
_last_players = n
_roster_dirty = false
send_hub({"t": "players", "n": n})
send_hub({"t": "roster", "list": _here_list()})
for msg in _read(_hub_link):
_from_hub(msg)
elif st == StreamPeerTCP.STATUS_ERROR or (st == StreamPeerTCP.STATUS_NONE and _said_hello):
@@ -336,12 +360,116 @@ func _from_hub(msg: Dictionary) -> void:
Net.push_clock()
"tunnel":
tunnel_dug = int(msg.dug)
"roster":
roster = msg.get("list", [])
Net.push_roster()
"line":
Chat._all(String(msg.text), String(msg.kind), false)
"reply":
if Net.players.has(int(msg.peer)):
Chat._s2c_line.rpc_id(int(msg.peer), String(msg.text), "reply")
"kick":
var kp: int = Net.peer_by_name(String(msg.name))
if kp != 0:
Net.kick(kp, String(msg.reason))
"config":
Net.reload_config()
Net.broadcast_rules()
"weather":
Net.set_weather(String(msg.state))
"save":
WorldNet.save()
"night":
PlayerNet.pass_night_here(float(msg.secs))
"beds_total":
PlayerNet._hub_beds = [int(msg.alive), int(msg.asleep)]
hub_message.emit(msg)
func send_hub(msg: Dictionary) -> void:
if _hub != null and _hub.get_status() == StreamPeerTCP.STATUS_CONNECTED:
_hub.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
# --- everyone, everywhere (J3b) ------------------------------------------------------------
## Everyone on every land: [{"name", "id", "land", "admin"}]. The hub builds
## it from its own players and what each land reports; every land and every
## player's game gets a copy (Net.roster), for the player list and /players.
var roster: Array = []
var _roster_dirty := false
## The players on this server, as the roster lists them.
func _here_list() -> Array:
var out: Array = []
for p in Net.players.values():
out.append({"name": String(p.name), "id": String(p.id), "land": here(), "admin": bool(p.get("admin", false))})
return out
## Hub: the roster again (its players changed, or a land's did), sent out.
func rebuild_roster() -> void:
if not hub:
if not land.is_empty():
_roster_dirty = true # (a land server: tell the hub, next tick)
return
var out: Array = _here_list()
for l in servers:
for e in servers[l].get("list", []):
var e2: Dictionary = (e as Dictionary).duplicate()
e2.land = l
out.append(e2)
roster = out
Net.push_roster()
for l in servers:
_send_land(l, {"t": "roster", "list": out})
## The land a player called `n` is in ("" = not on anywhere).
func land_of_name(n: String) -> String:
for e in roster:
if String(e.name).to_lower() == n.to_lower():
return String(e.land)
return ""
## Chat or a server message, from here to every other land.
func relay_line(text: String, kind: String) -> void:
if hub:
for l in servers:
_send_land(l, {"t": "line", "text": text, "kind": kind})
elif not land.is_empty():
send_hub({"t": "line", "text": text, "kind": kind})
## Hub: sends `msg` to every land.
func tell_lands(msg: Dictionary) -> void:
for l in servers:
_send_land(l, msg)
## Hub: sends `msg` to land `l`.
func tell_land(l: String, msg: Dictionary) -> void:
_send_land(l, msg)
## Hub: tells each land the alive/asleep everywhere BUT that land, so a land
## server can show the right "N of M" and know when all are abed.
func _push_bed_totals() -> void:
var here_alive: int = PlayerNet._alive().size()
var here_asleep: int = PlayerNet._alive().filter(func(q): return q.in_bed).size()
for l in servers:
var a := here_alive
var s := here_asleep
for other in servers:
if other != l:
var b: Array = servers[other].get("beds", [0, 0])
a += int(b[0])
s += int(b[1])
_send_land(l, {"t": "beds_total", "alive": a, "asleep": s})
## Hub: how many are alive, and how many asleep, in every other land.
func beds_elsewhere() -> Array:
var alive := 0
var asleep := 0
for l in servers:
var b: Array = servers[l].get("beds", [0, 0])
alive += int(b[0])
asleep += int(b[1])
return [alive, asleep]
## Land server: a pass for player `id` to go back to the hub.
func pass_home(id: String) -> String:
var p := Crypto.new().generate_random_bytes(16).hex_encode()

View File

@@ -232,6 +232,7 @@ func stop() -> void:
_states.clear()
server_info.clear()
world_info.clear()
roster.clear()
_out_q.clear()
_in_q.clear()
local_peer = 0
@@ -514,6 +515,42 @@ func _quiet_stop() -> void:
func is_travelling() -> bool:
return not _travel.is_empty()
# --- everyone on every land (J3b, lands.gd) -------------------------------------------
## Client: everyone on the server, whichever land they're in: [{"name",
## "id", "land", "admin"}] (the hub's roster). Empty on a server with only
## one land running: then `players` is everyone.
var roster: Array = []
## Server: everyone here gets the roster.
func push_roster() -> void:
for peer in players:
_s2c_roster.rpc_id(peer, Lands.roster)
@rpc("authority", "call_remote", "reliable")
func _s2c_roster(list: Array) -> void:
roster = list
players_changed.emit()
## Everyone on the server to show in a list: the roster, or (one land) just
## those here. [{"name", "admin", "land"}]
func everyone() -> Array:
if not roster.is_empty():
return roster
var out: Array = []
for p in players.values():
out.append({"name": String(p.name), "admin": bool(p.get("admin", false)), "land": String(world_info.get("worldlet", ""))})
return out
## Server: server.json read again (the hub changed it: a /set, a ban, a
## new admin, a friend), keeping this run's command-line overrides.
func reload_config() -> void:
if config == null:
return
var ov: Dictionary = config.overrides
config = ServerConfig.open(config.path)
config.overrides = ov
## Everyone gets the clock now (after a night's sleep, an admin's /time).
func push_clock() -> void:
_clock_left = CLOCK_INTERVAL

View File

@@ -92,6 +92,7 @@ func server_join(peer: int, id: String, display: String) -> Vector3:
print("[server] %s's character loaded" % display)
if at != Vector3.INF:
p.global_position = at
beds_changed.call_deferred() # (one more awake: everyone's count changes)
return at
func place_at(peer: int, pos: Vector3) -> void:
@@ -212,6 +213,7 @@ func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int,
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
p.in_bed = false
tell(p.peer, "You got out of bed")
beds_changed()
func _process(delta: float) -> void:
if not Net.is_server() or proxies.is_empty():
@@ -607,28 +609,66 @@ func go_to_bed(p: Node3D) -> void:
p.in_bed = true
p.bed_at = p.global_position
if not _maybe_pass_night():
var n: int = _alive().size()
var asleep: int = _alive().filter(func(q): return q.in_bed).size()
tell(p.peer, "You're in bed (%d of %d asleep). The night passes when %s." % [asleep, n,
"everyone's in bed" if _beds_needed(n) >= n else "%d are" % _beds_needed(n)])
for q in _alive():
if q != p:
tell(q.peer, "%s is in bed (%d of %d asleep)" % [p.display, asleep, n])
# (Counting everyone, in every land: on another land's server, the
# hub's last count, with this bed in it.)
var counts := sleep_counts()
if not Lands.land.is_empty():
counts = [_alive().size() + _hub_beds[0], _alive().filter(func(q): return q.in_bed).size() + _hub_beds[1]]
var n: int = counts[0]
var asleep: int = counts[1]
var need: int = _beds_needed(n)
tell(p.peer, waiting_text(asleep, n, need))
# Everyone else, in every land, hears it in chat (so whoever's still
# up knows they're being waited for).
Chat._all("%s is in bed (%d of %d asleep%s)" % [p.display, asleep, n,
"" if need >= n else ", %d needed" % need], "server")
## What a player who's just got into bed is told while the others are up.
static func waiting_text(asleep: int, n: int, need: int) -> String:
var left := need - asleep
var who := "everyone else" if need >= n else ("%d more" % left)
return "You're in bed: waiting for %s to go to bed (%d of %d asleep)" % [who, asleep, n]
func _alive() -> Array:
return proxies.values().filter(func(q): return not q.survival.dead)
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
## regrowth and weather move on; sleepers wake rested.
## One clock for every land (J3b): the night passes when enough of everyone,
## in every land, is in bed. Another land's server reports its beds to the
## hub, which decides, and tells every land to pass the night too.
func _maybe_pass_night() -> bool:
var w: Node3D = WorldNet.world
var live := _alive()
if w == null or live.is_empty():
if w == null:
return false
var asleep := live.filter(func(q): return q.in_bed)
if asleep.size() < _beds_needed(live.size()):
if not Lands.land.is_empty():
_report_beds()
return false
Lands._push_bed_totals()
var counts := sleep_counts()
if counts[0] == 0 or counts[1] < _beds_needed(counts[0]):
return false
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
Lands.tell_lands({"t": "night", "secs": secs})
pass_night_here(secs)
return true
## [alive, asleep] on this server and (the hub) every other land.
func sleep_counts() -> Array:
var live := _alive()
var counts := [live.size(), live.filter(func(q): return q.in_bed).size()]
if Lands.hub:
var e: Array = Lands.beds_elsewhere()
counts = [counts[0] + int(e[0]), counts[1] + int(e[1])]
return counts
## The night passes here: until 06:00 for everyone on this server.
func pass_night_here(secs: float) -> void:
var w: Node3D = WorldNet.world
if w == null:
return
var live := _alive()
var asleep := live.filter(func(q): return q.in_bed)
w.skip_time(secs)
for q in live:
if q.in_bed:
@@ -637,7 +677,20 @@ func _maybe_pass_night() -> bool:
_s2c_slept.rpc_id(q.peer, asleep.has(q))
Net.push_clock()
WorldNet.save()
return true
if not Lands.land.is_empty():
_report_beds()
## Someone got into or out of bed, or came or went: (a land server) the hub
## hears the new count; (the hub) the night may pass.
func beds_changed() -> void:
if not Lands.land.is_empty():
_report_beds()
elif Net.is_server():
_maybe_pass_night()
func _report_beds() -> void:
var live := _alive()
Lands.send_hub({"t": "beds", "alive": live.size(), "asleep": live.filter(func(q): return q.in_bed).size()})
## The chest or grave `nid`, if this player may use it right now.
func _container(p: Node3D, nid: int) -> Node3D:
@@ -648,6 +701,10 @@ func _container(p: Node3D, nid: int) -> Node3D:
const Rules = preload("res://scripts/net/rules.gd")
## (Land server) the hub's latest [alive, asleep] in every OTHER land, for
## the "N of M asleep" message and the all-abed check here.
var _hub_beds := [0, 0]
## Tells the player why not, if there's a reason.
func _allowed(why: String) -> bool:
if why.is_empty():