M2.6: server rules (PvP, raiding, locks and friends, death penalty, difficulty, wolf grace), chat, admin commands, server console
This commit is contained in:
@@ -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
|
||||
|
||||
228
scripts/net/chat.gd
Normal file
228
scripts/net/chat.gd
Normal file
@@ -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 <name> /unfriend <name> /friends
|
||||
## Admins: /kick <name> /ban <name> /unban <name> /admin <name> /save
|
||||
## /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", "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 <name> /unfriend <name> /friends" +
|
||||
(" · admin: /kick /ban /unban /admin /save /time 08:00 /weather <state|auto> /set <rule> <value> /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 <name>)"))
|
||||
"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 <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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
74
scripts/net/rules.gd
Normal file
74
scripts/net/rules.gd
Normal file
@@ -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
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user