M2.7: Host Game, backups, launchd/systemd samples, hosting and Tailscale guide
This commit is contained in:
@@ -8,14 +8,14 @@ extends Node
|
||||
##
|
||||
## 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
|
||||
## /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", "time", "weather", "set", "say", "release-name"]
|
||||
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
|
||||
@@ -107,7 +107,7 @@ func run(line: String, peer: int) -> void:
|
||||
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 ""))
|
||||
(" · 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":
|
||||
var names: Array[String] = []
|
||||
for p in Net.players.values():
|
||||
@@ -174,6 +174,13 @@ func run(line: String, peer: int) -> void:
|
||||
"save":
|
||||
WorldNet.save()
|
||||
_reply(peer, "Saved")
|
||||
"backup":
|
||||
WorldNet.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()):
|
||||
|
||||
@@ -240,6 +240,8 @@ func _on_peer_disconnected(id: int) -> void:
|
||||
_s2c_player_left.rpc(id)
|
||||
PlayerNet.server_leave(id) # saves their character
|
||||
WorldNet.peer_left(id)
|
||||
if _hosted_by(String(p.id)):
|
||||
_host_left.call_deferred()
|
||||
players_changed.emit()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
@@ -255,6 +257,7 @@ func _process(delta: float) -> void:
|
||||
_send_left = SEND_INTERVAL
|
||||
_broadcast_snapshot()
|
||||
return
|
||||
_host_tick()
|
||||
if state == "connecting":
|
||||
_join_left -= delta
|
||||
if _join_left <= 0.0:
|
||||
@@ -272,6 +275,135 @@ func _tick_clock(delta: float) -> void:
|
||||
for peer in players:
|
||||
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
||||
|
||||
# --- hosting from the title screen (M2.7) ------------------------------------------
|
||||
# "Host Game" runs a dedicated server as a separate, windowless copy of the
|
||||
# game on this machine, then joins it like anyone else (so you're its first
|
||||
# player, and its admin). Its world and settings live in user://host/. Its
|
||||
# log goes to user://host/server.log. Leaving asks it to save and stop.
|
||||
|
||||
signal host_status(text: String, failed: bool)
|
||||
|
||||
var host_config := "user://host/server.json" ## (tests use their own)
|
||||
var hosting := false
|
||||
var _host_pid := 0
|
||||
var _host_io: FileAccess
|
||||
var _host_log: FileAccess
|
||||
var _host_out := ""
|
||||
var _host_port := DEFAULT_PORT
|
||||
|
||||
## Settings for a hosted game: name, password, port, a rules preset
|
||||
## (co-op / rivals / adversarial) and the seed for a new island.
|
||||
const PRESETS := {
|
||||
"co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
|
||||
"rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
|
||||
"adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"},
|
||||
}
|
||||
|
||||
func host(server_name: String, password: String, port: int, preset: String, seed: int) -> void:
|
||||
if hosting or state != "offline":
|
||||
return
|
||||
if _host_pid != 0 and OS.is_process_running(_host_pid):
|
||||
host_status.emit("The last server is still saving: try again in a moment", true)
|
||||
return
|
||||
var c: RefCounted = ServerConfig.open(host_config)
|
||||
c.set_rule("server_name", server_name)
|
||||
for k in PRESETS.get(preset, PRESETS["co-op"]):
|
||||
c.set_rule(k, PRESETS[preset][k])
|
||||
c.data.password = password
|
||||
c.data.port = port
|
||||
var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json"))
|
||||
if fresh:
|
||||
c.data.seed = seed
|
||||
c.save()
|
||||
_host_port = port
|
||||
var args := PackedStringArray(["--headless"])
|
||||
if not OS.has_feature("template"):
|
||||
args.append_array(["--path", ProjectSettings.globalize_path("res://")]) # (running from the editor build)
|
||||
args.append_array(["--", "--server", "--host-id", String(get_identity().player_id),
|
||||
"--config", ProjectSettings.globalize_path(host_config), "--exit-with", str(OS.get_process_id())])
|
||||
var p: Dictionary = OS.execute_with_pipe(OS.get_executable_path(), args, false)
|
||||
if p.is_empty():
|
||||
host_status.emit("Couldn't start the server", true)
|
||||
return
|
||||
_host_pid = int(p.pid)
|
||||
_host_io = p.stdio
|
||||
_host_out = ""
|
||||
_host_log = FileAccess.open(host_config.get_base_dir().path_join("server.log"), FileAccess.WRITE)
|
||||
hosting = true
|
||||
host_status.emit("Starting the server%s..." % (" (building a new island)" if fresh else ""), false)
|
||||
|
||||
## Reads what the hosted server prints (so its output never fills the pipe),
|
||||
## and joins it once it's listening.
|
||||
func _host_tick() -> void:
|
||||
if _host_io == null:
|
||||
return
|
||||
var chunk := _host_io.get_buffer(8192)
|
||||
if not chunk.is_empty():
|
||||
var s := chunk.get_string_from_utf8()
|
||||
if _host_log != null:
|
||||
_host_log.store_string(s)
|
||||
_host_log.flush()
|
||||
if hosting and state == "offline":
|
||||
_host_out += s
|
||||
if _host_out.contains("listening on UDP"):
|
||||
_host_out = ""
|
||||
host_status.emit("Joining your server...", false)
|
||||
join("127.0.0.1:%d" % _host_port, String(ServerConfig.open(host_config).data.password))
|
||||
elif _host_out.contains("[server] Couldn't open UDP port") or _host_out.contains("Not starting"):
|
||||
var why := "Port %d is already in use (another server running?)" % _host_port \
|
||||
if _host_out.contains("Couldn't open") else "The server couldn't start (see user://host/server.log)"
|
||||
stop_hosting()
|
||||
host_status.emit(why, true)
|
||||
if _host_pid != 0 and not OS.is_process_running(_host_pid):
|
||||
var was_hosting := hosting
|
||||
_host_pid = 0
|
||||
# It's gone: whatever it printed last is still in the pipe.
|
||||
for _i in range(64):
|
||||
var rest := _host_io.get_buffer(8192)
|
||||
if rest.is_empty():
|
||||
break
|
||||
if _host_log != null:
|
||||
_host_log.store_string(rest.get_string_from_utf8())
|
||||
if _host_log != null:
|
||||
_host_log.flush()
|
||||
_host_io = null
|
||||
hosting = false
|
||||
if was_hosting and state == "offline":
|
||||
host_status.emit("The server stopped (see user://host/server.log)", true)
|
||||
|
||||
## Stops the hosted server: asks it to save and quit, then makes sure. Its
|
||||
## output is still read (into the log) until it's gone.
|
||||
func stop_hosting() -> void:
|
||||
if not hosting:
|
||||
return
|
||||
hosting = false
|
||||
var pid := _host_pid
|
||||
if pid != 0:
|
||||
get_tree().create_timer(6.0).timeout.connect(func():
|
||||
if OS.is_process_running(pid):
|
||||
OS.kill(pid))
|
||||
|
||||
## The addresses friends can use to reach this machine: the local network,
|
||||
## and Tailscale (100.x) if it's running.
|
||||
static func my_addresses() -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
for a in IP.get_local_addresses():
|
||||
if a.contains(":") or a.begins_with("127.") or a.begins_with("169.254."):
|
||||
continue
|
||||
out.append(a)
|
||||
out.sort_custom(func(x, y): return (0 if x.begins_with("100.") else 1) < (0 if y.begins_with("100.") else 1))
|
||||
return out
|
||||
|
||||
## A hosted server (started with --host-id): when the player who hosts it
|
||||
## leaves, it saves and stops (it lives on their computer).
|
||||
func _hosted_by(id: String) -> bool:
|
||||
return is_server() and _args.has("host-id") and String(_args["host-id"]) == id
|
||||
|
||||
func _host_left() -> void:
|
||||
print("[server] the host left: saving and stopping")
|
||||
stop() # (saves the world and everyone's characters)
|
||||
get_tree().quit()
|
||||
|
||||
# --- admin (chat.gd) ---------------------------------------------------------------
|
||||
|
||||
signal rules_changed ## clients: the server's rules changed (/set)
|
||||
@@ -566,6 +698,7 @@ var _pending_password := ""
|
||||
|
||||
func leave() -> void:
|
||||
var was := state
|
||||
stop_hosting() # (our own server sees us go, and saves and stops)
|
||||
stop()
|
||||
if was == "connected":
|
||||
last_reason = "You left the server"
|
||||
@@ -659,6 +792,7 @@ func _run_queue(q: Array) -> void:
|
||||
f.call()
|
||||
|
||||
func _fail(reason: String) -> void:
|
||||
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
|
||||
stop()
|
||||
last_reason = reason
|
||||
join_failed.emit(reason)
|
||||
@@ -669,6 +803,7 @@ func _on_connection_failed() -> void:
|
||||
func _on_server_disconnected() -> void:
|
||||
var was := state
|
||||
var reason := _reject_reason
|
||||
stop_hosting()
|
||||
stop()
|
||||
if was == "connecting":
|
||||
last_reason = reason if not reason.is_empty() else "The server hung up"
|
||||
|
||||
@@ -25,6 +25,7 @@ const DEFAULTS := {
|
||||
"difficulty": "normal",
|
||||
"autosave_minutes": 2,
|
||||
"backups_kept": 10,
|
||||
"backup_hours": 6,
|
||||
"admins": [],
|
||||
"allowlist": [],
|
||||
"banned": [],
|
||||
|
||||
@@ -50,6 +50,8 @@ func open_server_world(config: RefCounted) -> Node3D:
|
||||
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
|
||||
@@ -97,6 +99,66 @@ func save() -> void:
|
||||
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:
|
||||
@@ -139,6 +201,11 @@ func _process(delta: float) -> void:
|
||||
_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
|
||||
|
||||
Reference in New Issue
Block a user