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//. 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//backups//: 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 "#" 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 "#". 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