488 lines
16 KiB
GDScript
488 lines
16 KiB
GDScript
extends Node
|
|
## Autoload "WorldNet": the shared world (M2.3, docs/MULTIPLAYER_PLAN.md §5).
|
|
##
|
|
## A dedicated server runs the real world scene headless, with no player of its
|
|
## own (world.gd `server_mode`). It owns everything in the island that changes:
|
|
## trees and rocks (damage, felling, regrowth), pickups, berry bushes, dropped
|
|
## items, building pieces, chests and graves, fires, doors and the clock. The
|
|
## weather follows from the seed and the clock, so it needs no syncing.
|
|
##
|
|
## Players' requests arrive through PlayerNet (player_net.gd), which runs the
|
|
## Actions rules on that player's character on the server. The results show
|
|
## up here and go to everyone:
|
|
## - pieces placed, removed or collapsed, and items dropped: the server's
|
|
## world reports them (world.gd signals), wherever they came from;
|
|
## - hits, pickups, berries, doors and fires: PlayerNet tells us;
|
|
## - chest and grave contents: whenever they change.
|
|
##
|
|
## On joining, a client's world asks for a snapshot (_c2s_world) once its
|
|
## scenery is up; from then on it gets every change, in order.
|
|
|
|
const FIRE_SYNC := 10.0 ## seconds between fire fuel updates
|
|
const SAVE_AFTER := 10.0 ## seconds after a change before saving
|
|
|
|
var world: Node3D ## server: its world; client: ours while online
|
|
var pieces: Dictionary = {} ## net id -> piece (both sides)
|
|
var dropped: Dictionary = {} ## net id -> dropped pickup (both sides)
|
|
var _next_nid := 1
|
|
var _ready_peers: Dictionary = {} ## server: peers that have their snapshot
|
|
var _fire_left := FIRE_SYNC
|
|
var _blocker_left := 0.0
|
|
var _save_left := INF ## counting down to a save after a change
|
|
var _chests_dirty: Dictionary = {}
|
|
|
|
## Counters the tests read.
|
|
var received := 0 ## client: world events applied
|
|
|
|
func is_server() -> bool:
|
|
return Net.is_server() and world != null
|
|
|
|
func is_client() -> bool:
|
|
return Net.state == "connected" and world != null and is_instance_valid(world)
|
|
|
|
# --- server: the world -----------------------------------------------------------
|
|
|
|
## Loads (or creates) the server's world from its config and adds it to the
|
|
## tree. Saves live next to server.json, in worlds/<world>/.
|
|
func open_server_world(config: RefCounted) -> Node3D:
|
|
Saves.root_dir = config.path.get_base_dir().path_join("worlds")
|
|
var id := String(config.v("world")).validate_filename()
|
|
if id.is_empty():
|
|
id = "server-world"
|
|
Saves.current_id = Saves.open_or_create(id, int(config.v("seed")))
|
|
backup("start-up") # (the state it's about to load, before anyone changes it)
|
|
_backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0
|
|
var w: Node3D = load("res://scenes/world.tscn").instantiate()
|
|
w.server_mode = true
|
|
w.day_length = float(config.v("day_length_minutes")) * 60.0
|
|
w.autosave_seconds = maxf(float(config.v("autosave_minutes")), 0.5) * 60.0
|
|
pieces.clear()
|
|
dropped.clear()
|
|
_ready_peers.clear()
|
|
_chests_dirty.clear()
|
|
_next_nid = 1
|
|
world = w
|
|
get_tree().root.add_child(w) # (loads the save: pieces come in through _on_piece_added)
|
|
for p in w.all_pieces():
|
|
_next_nid = maxi(_next_nid, int(p.get_meta("nid", 0)) + 1)
|
|
for p in w.all_pieces():
|
|
_register(p)
|
|
for d in w.all_dropped():
|
|
_register_dropped(d)
|
|
w.piece_added.connect(_on_piece_added)
|
|
w.pieces_removed.connect(_on_pieces_removed)
|
|
w.dropped_added.connect(_on_dropped_added)
|
|
w.saved.connect(PlayerNet.save_all)
|
|
w.props.regrown.connect(_on_regrown)
|
|
var args: Dictionary = Net.parse_args(OS.get_cmdline_user_args())
|
|
if args.has("debug-regrow"):
|
|
w.props.regrow_scale = float(args["debug-regrow"]) # (tests: things grow back in seconds)
|
|
print("[server] world '%s' (seed %d): %d pieces, %d dropped items. Saves: %s" % [Saves.current_id,
|
|
w.terrain.noise_seed, pieces.size(), dropped.size(), ProjectSettings.globalize_path(Saves.root_dir)])
|
|
return w
|
|
|
|
func close_server_world() -> void:
|
|
if world == null:
|
|
return
|
|
if Net.is_server() and is_instance_valid(world):
|
|
save()
|
|
if is_instance_valid(world):
|
|
world.queue_free()
|
|
world = null
|
|
pieces.clear()
|
|
dropped.clear()
|
|
_ready_peers.clear()
|
|
PlayerNet.server_reset()
|
|
|
|
func save() -> void:
|
|
_save_left = INF
|
|
if world != null and is_instance_valid(world) and world.server_mode:
|
|
world.save_now() # (players too: world.saved)
|
|
|
|
# --- backups (M2.7) ----------------------------------------------------------------
|
|
# A copy of the world (meta, world and every player's file) in
|
|
# worlds/<world>/backups/<yyyy-mm-dd_hh-mm-ss>/: when the server starts (the
|
|
# state it's about to load), every backup_hours while it runs, and on
|
|
# /backup. The newest backups_kept are kept. To restore one, stop the server
|
|
# and copy its files back up two folders (docs/HOSTING.md).
|
|
|
|
var _backup_left := INF
|
|
|
|
func world_dir() -> String:
|
|
return Saves.root_dir.path_join(Saves.current_id)
|
|
|
|
## Makes a backup now. Returns its folder name ("" if there was nothing to back up).
|
|
func backup(reason: String) -> String:
|
|
var src := world_dir()
|
|
if not FileAccess.file_exists(src.path_join("world.json")):
|
|
return ""
|
|
var stamp := Time.get_datetime_string_from_system(false, true).replace(":", "-").replace(" ", "_")
|
|
var dst := src.path_join("backups").path_join(stamp)
|
|
var n := 2
|
|
while DirAccess.dir_exists_absolute(dst):
|
|
dst = src.path_join("backups").path_join("%s_%d" % [stamp, n])
|
|
n += 1
|
|
DirAccess.make_dir_recursive_absolute(dst.path_join("players"))
|
|
for f in ["meta.json", "world.json"]:
|
|
if FileAccess.file_exists(src.path_join(f)):
|
|
DirAccess.copy_absolute(src.path_join(f), dst.path_join(f))
|
|
var pd := DirAccess.open(src.path_join("players"))
|
|
if pd != null:
|
|
for f in pd.get_files():
|
|
if f.ends_with(".json"):
|
|
DirAccess.copy_absolute(src.path_join("players").path_join(f), dst.path_join("players").path_join(f))
|
|
_prune_backups()
|
|
print("[server] backup (%s): %s" % [reason, ProjectSettings.globalize_path(dst)])
|
|
return dst.get_file()
|
|
|
|
func list_backups() -> Array:
|
|
var d := DirAccess.open(world_dir().path_join("backups"))
|
|
if d == null:
|
|
return []
|
|
var out: Array = Array(d.get_directories())
|
|
out.sort()
|
|
return out
|
|
|
|
func _prune_backups() -> void:
|
|
var keep := maxi(int(Net.config.v("backups_kept")), 1)
|
|
var all := list_backups()
|
|
while all.size() > keep:
|
|
_rm_tree(world_dir().path_join("backups").path_join(String(all.pop_front())))
|
|
|
|
static func _rm_tree(d: String) -> void:
|
|
var da := DirAccess.open(d)
|
|
if da == null:
|
|
return
|
|
for sub in da.get_directories():
|
|
_rm_tree(d.path_join(sub))
|
|
for f in da.get_files():
|
|
da.remove(f)
|
|
DirAccess.remove_absolute(d)
|
|
|
|
## Something changed: save within a few seconds. Ctrl+C kills Godot without
|
|
## a chance to save, so this keeps the loss small.
|
|
func mark_changed() -> void:
|
|
if is_server() and _save_left == INF:
|
|
_save_left = SAVE_AFTER
|
|
|
|
func _new_nid() -> int:
|
|
_next_nid += 1
|
|
return _next_nid - 1
|
|
|
|
func _register(p: Node3D) -> void:
|
|
if not p.has_meta("nid"):
|
|
p.set_meta("nid", _new_nid())
|
|
pieces[int(p.get_meta("nid"))] = p
|
|
if p.get("inventory") != null:
|
|
var nid := int(p.get_meta("nid"))
|
|
p.inventory.changed.connect(func(): _chests_dirty[nid] = true; mark_changed())
|
|
|
|
func _register_dropped(d: Node3D) -> void:
|
|
if not d.has_meta("nid"):
|
|
d.set_meta("nid", _new_nid())
|
|
dropped[int(d.get_meta("nid"))] = d
|
|
|
|
## A player left: they no longer get events. Save while it's fresh.
|
|
func peer_left(peer: int) -> void:
|
|
_ready_peers.erase(peer)
|
|
save()
|
|
|
|
func _process(delta: float) -> void:
|
|
if not is_server():
|
|
return
|
|
for nid in _chests_dirty.keys():
|
|
_flush_chest(nid)
|
|
_chests_dirty.clear()
|
|
# Pause the island while nobody's on (fires, regrowth, the clock).
|
|
var paused: bool = Net.players.is_empty() and bool(Net.config.v("pause_when_empty"))
|
|
world.process_mode = Node.PROCESS_MODE_DISABLED if paused else Node.PROCESS_MODE_INHERIT
|
|
if paused:
|
|
return
|
|
_save_left -= delta
|
|
if _save_left <= 0.0:
|
|
save()
|
|
_backup_left -= delta
|
|
if _backup_left <= 0.0:
|
|
_backup_left = maxf(float(Net.config.v("backup_hours")), 0.25) * 3600.0
|
|
save()
|
|
backup("every %s h" % Net.config.v("backup_hours"))
|
|
_blocker_left -= delta
|
|
if _blocker_left <= 0.0:
|
|
_blocker_left = 1.0
|
|
var at: Array = []
|
|
for peer in Net.players:
|
|
at.append(Net.player_pos(peer))
|
|
world.props.blockers = at # nothing regrows on top of a player
|
|
_fire_left -= delta
|
|
if _fire_left <= 0.0:
|
|
_fire_left = FIRE_SYNC
|
|
var fires: Array = []
|
|
for nid in pieces:
|
|
var p: Node3D = pieces[nid]
|
|
if is_instance_valid(p) and p.get("fuel") != null:
|
|
fires.append([nid, p.fuel])
|
|
if not fires.is_empty():
|
|
_all("_s2c_fires", [fires])
|
|
|
|
## Sends to every player who has their snapshot.
|
|
func _all(method: String, args: Array) -> void:
|
|
if method != "_s2c_fires":
|
|
mark_changed()
|
|
for peer in _ready_peers:
|
|
callv("rpc_id", [peer, method] + args)
|
|
|
|
func piece(nid: int) -> Node3D:
|
|
var p: Node3D = pieces.get(nid)
|
|
if p == null or not is_instance_valid(p) or p.is_queued_for_deletion():
|
|
pieces.erase(nid)
|
|
return null
|
|
return p
|
|
|
|
## A pickup by the key players use: its scenery id, or "#<net id>" if it was dropped.
|
|
func find_pickup(key: String) -> Node3D:
|
|
var pk: Node3D
|
|
if key.begins_with("#"):
|
|
pk = dropped.get(int(key.substr(1)))
|
|
else:
|
|
pk = world.props.pickups.get(key) if world != null else null
|
|
return pk if pk != null and is_instance_valid(pk) else null
|
|
|
|
# --- server: telling everyone ----------------------------------------------------
|
|
|
|
func node_hit(n: Node3D, point: Vector3, from: Vector3) -> void:
|
|
_all("_s2c_node", [String(n.uid), n.hp, point, from])
|
|
|
|
func taken(key: String) -> void:
|
|
if key.begins_with("#"):
|
|
dropped.erase(int(key.substr(1)))
|
|
_all("_s2c_taken", [key])
|
|
|
|
func foraged(bush: Node3D) -> void:
|
|
_all("_s2c_forage", [String(bush.uid), bush.regrow_left])
|
|
|
|
func piece_state(p: Node3D) -> void:
|
|
if p.has_method("save_extra"):
|
|
_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)])
|
|
|
|
func _on_pieces_removed(gone: Array, fell: Array) -> void:
|
|
var g: Array = []
|
|
var f: Array = []
|
|
for p in gone:
|
|
var nid := int(p.get_meta("nid", 0))
|
|
pieces.erase(nid)
|
|
_chests_dirty.erase(nid)
|
|
g.append(nid)
|
|
if p in fell:
|
|
f.append(nid)
|
|
_all("_s2c_pieces_removed", [g, f])
|
|
|
|
func _on_dropped_added(d: Node3D) -> void:
|
|
_register_dropped(d)
|
|
_all("_s2c_dropped", [world.dropped_entry(d)])
|
|
|
|
func _on_regrown(uid: String) -> void:
|
|
_all("_s2c_regrown", [uid])
|
|
|
|
func _flush_chest(nid: int) -> void:
|
|
var p := piece(nid)
|
|
if p == null:
|
|
return
|
|
_all("_s2c_chest", [nid, p.inventory.to_data()])
|
|
# An emptied grave crumbles.
|
|
if p.item_id == "grave" and _empty(p.inventory):
|
|
world.remove_piece(p, null)
|
|
|
|
static func _empty(inv: Inventory) -> bool:
|
|
for s in inv.slots:
|
|
if s != null:
|
|
return false
|
|
return true
|
|
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _c2s_world() -> void:
|
|
var peer := multiplayer.get_remote_sender_id()
|
|
if not is_server() or not Net.players.has(peer):
|
|
return
|
|
_ready_peers[peer] = true
|
|
_s2c_world.rpc_id(peer, world.collect_shared())
|
|
PlayerNet.push_all(peer)
|
|
|
|
# --- client ------------------------------------------------------------------------
|
|
|
|
## Our world is up: ask for the shared state.
|
|
func join_world(w: Node3D) -> void:
|
|
world = w
|
|
pieces.clear()
|
|
dropped.clear()
|
|
received = 0
|
|
_c2s_world.rpc_id(1)
|
|
|
|
func leave_world() -> void:
|
|
if not Net.is_server():
|
|
world = null
|
|
pieces.clear()
|
|
dropped.clear()
|
|
|
|
## The key the server knows a pickup by: its scenery id, or "#<net id>".
|
|
func pickup_key(pk: Node3D) -> String:
|
|
return "#%d" % int(pk.get_meta("nid")) if pk.has_meta("nid") else String(pk.uid)
|
|
|
|
static func nid(p: Node3D) -> int:
|
|
return int(p.get_meta("nid", 0))
|
|
|
|
# --- server -> client ------------------------------------------------------------
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_world(state: Dictionary) -> void:
|
|
if not is_client():
|
|
return
|
|
world.apply_shared(state)
|
|
pieces.clear()
|
|
dropped.clear()
|
|
for p in world.all_pieces():
|
|
if p.has_meta("nid"):
|
|
pieces[int(p.get_meta("nid"))] = p
|
|
for d in world.all_dropped():
|
|
if d.has_meta("nid"):
|
|
dropped[int(d.get_meta("nid"))] = d
|
|
received += 1
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void:
|
|
if not is_client():
|
|
return
|
|
var n: Node3D = world.props.resource_nodes.get(uid)
|
|
if n != null and is_instance_valid(n):
|
|
n.apply_remote(hp, point, from)
|
|
received += 1
|
|
|
|
## The standing stones' altar changed (key pieces set, the stag beaten).
|
|
func stones_state(d: Dictionary) -> void:
|
|
_all("_s2c_stones", [d])
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_stones(d: Dictionary) -> void:
|
|
if not is_client() or world.stones == null:
|
|
return
|
|
world.stones.load_data(d)
|
|
received += 1
|
|
|
|
## Lightning struck a tree (hazards.gd, P4): everyone sees it burn. (It
|
|
## falls later through _s2c_node, like any felled tree.)
|
|
func struck(n: Node3D) -> void:
|
|
_all("_s2c_strike", [String(n.uid)])
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_strike(uid: String) -> void:
|
|
if not is_client():
|
|
return
|
|
var n: Node3D = world.props.resource_nodes.get(uid)
|
|
var hz: Node = world.get_node_or_null("Hazards")
|
|
if n != null and is_instance_valid(n) and hz != null:
|
|
hz.show_strike(n)
|
|
received += 1
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_regrown(uid: String) -> void:
|
|
if not is_client():
|
|
return
|
|
var n: Node3D = world.props.pickups.get(uid, world.props.resource_nodes.get(uid))
|
|
if n != null and is_instance_valid(n):
|
|
world.props.regrow.erase(uid)
|
|
n.regrow()
|
|
received += 1
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_taken(key: String) -> void:
|
|
if not is_client():
|
|
return
|
|
var pk := find_pickup(key)
|
|
if key.begins_with("#"):
|
|
dropped.erase(int(key.substr(1)))
|
|
if pk != null:
|
|
pk.collected()
|
|
received += 1
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_forage(uid: String, left: float) -> void:
|
|
if not is_client():
|
|
return
|
|
var bush: Node3D = world.props.forage.get(uid)
|
|
if bush != null:
|
|
bush.restore(left)
|
|
received += 1
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_piece_added(e: Dictionary) -> void:
|
|
if not is_client():
|
|
return
|
|
var p: Node3D = world.add_piece_entry(e)
|
|
if p != null:
|
|
pieces[int(e.nid)] = p
|
|
received += 1
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_pieces_removed(nids: Array, fell: Array) -> void:
|
|
if not is_client():
|
|
return
|
|
var gone: Array = []
|
|
for k in nids:
|
|
var p: Node3D = pieces.get(int(k))
|
|
pieces.erase(int(k))
|
|
if p != null and is_instance_valid(p):
|
|
gone.append(p)
|
|
var collapsed: Array = []
|
|
for k in fell:
|
|
collapsed.append(int(k))
|
|
world.remove_pieces_net(gone, collapsed)
|
|
received += 1
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_piece_state(nid_: int, extra: Dictionary) -> void:
|
|
if not is_client():
|
|
return
|
|
var p: Node3D = pieces.get(nid_)
|
|
if p == null or not is_instance_valid(p):
|
|
return
|
|
if p.has_method("net_state"):
|
|
p.net_state(extra) # (a door)
|
|
elif p.has_method("load_extra"):
|
|
p.load_extra(extra)
|
|
received += 1
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_fires(fires: Array) -> void:
|
|
if not is_client():
|
|
return
|
|
for f in fires:
|
|
var p: Node3D = pieces.get(int(f[0]))
|
|
if p != null and is_instance_valid(p) and p.has_method("load_extra"):
|
|
p.load_extra({"fuel": float(f[1])})
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_chest(nid_: int, data: Array) -> void:
|
|
if not is_client():
|
|
return
|
|
var p: Node3D = pieces.get(nid_)
|
|
if p != null and is_instance_valid(p) and p.get("inventory") != null:
|
|
p.inventory.load_data(data)
|
|
received += 1
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_dropped(e: Dictionary) -> void:
|
|
if not is_client():
|
|
return
|
|
var d: Node3D = world.add_dropped_entry(e)
|
|
if d != null:
|
|
dropped[int(e.nid)] = d
|
|
received += 1
|