M1c: Gleichschritt im Netz #9

Merged
steven merged 20 commits from m1c-gleichschritt-im-netz into main 2026-10-02 06:41:02 +00:00
8 changed files with 229 additions and 9 deletions
Showing only changes of commit d5debc5bcb - Show all commits
+73
View File
@@ -0,0 +1,73 @@
class_name LaunchOptions
extends RefCounted
## What the command line asks for. Godot passes the arguments after "--":
## --profile=NAME keeps key, save and recordings in a folder of their own,
## so that two windows on one machine are two players
## --name=NAME the player's name for this start
## --solo starts playing alone at once, without the menu
## --host opens the world for guests at once
## --port=PORT the port an open world listens on
## --join=ADDRESS joins a host at once; ADDRESS may end in ":PORT"
## Longest profile name.
const MAX_PROFILE_LENGTH := 32
## Characters a profile name may contain.
const PROFILE_CHARACTERS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
var profile := ""
var player_name := ""
## How to start without the menu: "", "solo", "host" or "join".
var start := ""
var port := NetLink.DEFAULT_PORT
var address := ""
## Arguments that were not understood and are ignored.
var ignored := PackedStringArray()
## Reads `arguments`. Unknown arguments and bad values end up in `ignored`.
static func parse(arguments: PackedStringArray) -> LaunchOptions:
var options := LaunchOptions.new()
for argument in arguments:
var value := argument.get_slice("=", 1) if argument.contains("=") else ""
match argument.get_slice("=", 0):
"--profile":
if is_valid_profile(value):
options.profile = value
else:
options.ignored.append(argument)
"--name":
options.player_name = value
"--solo":
options.start = "solo"
"--host":
options.start = "host"
"--port":
if value.is_valid_int() and value.to_int() >= 0 and value.to_int() <= 65535:
options.port = value.to_int()
else:
options.ignored.append(argument)
"--join":
options.start = "join"
options.address = value
_:
options.ignored.append(argument)
return options
## Whether `name` can name a profile folder: up to 32 of the letters A to Z,
## digits, "-" and "_".
static func is_valid_profile(name: String) -> bool:
if name.is_empty() or name.length() > MAX_PROFILE_LENGTH:
return false
for character in name:
if not PROFILE_CHARACTERS.contains(character):
return false
return true
## The folder for the player's files. The default profile uses the user
## folder itself, so that saves of earlier versions stay where they are.
func storage_dir() -> String:
if profile.is_empty():
return "user://"
return "user://profiles".path_join(profile)
+1
View File
@@ -0,0 +1 @@
uid://dkgt2ownscxuj
+60 -7
View File
@@ -1,18 +1,23 @@
class_name SaveStore
extends RefCounted
## The game's files on the player's machine: the player key, the save and
## the session recordings for bug reports.
## The game's files on the player's machine: the player key, the save, the
## session recordings for bug reports and the player's settings.
const KEY_FILE := "player.key"
const SAVE_FILE := "world.grw"
## Holds the previous save while write_save replaces it.
const BACKUP_FILE := SAVE_FILE + ".bak"
const RECORDINGS_DIR := "recordings"
const SETTINGS_FILE := "settings.cfg"
## Recordings beyond this number are deleted, oldest first.
const KEEP_RECORDINGS := 10
const KEY_BYTES := 16
var base_dir: String
## The result of the last attempt to read the key or the save: OK, or why
## the file could not be read. A file that exists but cannot be read right
## now, for example because another program holds it, is never replaced.
var last_read_error: Error = OK
## Opens the folder and creates it if it is missing. If a crash interrupted a
@@ -25,12 +30,17 @@ func _init(directory: String = "user://") -> void:
## The key that identifies this installation in every world. It is created
## on first use and kept from then on.
## on first use and kept from then on. Returns an empty key if the key file
## exists but cannot be read; last_read_error then says why.
func player_key() -> PackedByteArray:
var path := base_dir.path_join(KEY_FILE)
var key := FileAccess.get_file_as_bytes(path)
if key.size() != KEY_BYTES:
key = Crypto.new().generate_random_bytes(KEY_BYTES)
if FileAccess.file_exists(path):
var stored := _read_file(path)
if last_read_error != OK:
return PackedByteArray()
if stored.size() == KEY_BYTES:
return stored
var key := Crypto.new().generate_random_bytes(KEY_BYTES)
if not _write(path, key):
push_error("player key could not be stored at %s" % path)
return key
@@ -40,8 +50,10 @@ func has_save() -> bool:
return FileAccess.file_exists(base_dir.path_join(SAVE_FILE))
## The bytes of the save. If it cannot be read, they are empty and
## last_read_error says why.
func read_save() -> PackedByteArray:
return FileAccess.get_file_as_bytes(base_dir.path_join(SAVE_FILE))
return _read_file(base_dir.path_join(SAVE_FILE))
## Writes the save through a temporary file, so that a crash or a failed
@@ -115,6 +127,40 @@ func recordings() -> PackedStringArray:
return names
## The name the player gave last time, or an empty text.
func player_name() -> String:
return _setting("player", "name")
func set_player_name(player_name: String) -> void:
_set_setting("player", "name", player_name)
## The address of the host the player joined last time, or an empty text.
func last_address() -> String:
return _setting("network", "address")
func set_last_address(address: String) -> void:
_set_setting("network", "address", address)
func _setting(section: String, key: String) -> String:
var settings := ConfigFile.new()
if settings.load(base_dir.path_join(SETTINGS_FILE)) != OK:
return ""
return str(settings.get_value(section, key, ""))
func _set_setting(section: String, key: String, value: String) -> void:
var path := base_dir.path_join(SETTINGS_FILE)
var settings := ConfigFile.new()
settings.load(path)
settings.set_value(section, key, value)
if settings.save(path) != OK:
push_warning("settings could not be stored at %s" % path)
## Moves the backup back to the save if there is no save, see write_save.
func _restore_backup() -> void:
var path := base_dir.path_join(SAVE_FILE)
@@ -125,6 +171,13 @@ func _restore_backup() -> void:
push_warning("the previous save could not be restored from %s" % backup)
## Reads a whole file and records the result in last_read_error.
func _read_file(path: String) -> PackedByteArray:
var bytes := FileAccess.get_file_as_bytes(path)
last_read_error = FileAccess.get_open_error()
return bytes
func _write(path: String, bytes: PackedByteArray) -> bool:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
+13
View File
@@ -0,0 +1,13 @@
class_name HeldFileStore
extends SaveStore
## A SaveStore that cannot read the file named `unreadable`, as if another
## program held it.
var unreadable := ""
func _read_file(path: String) -> PackedByteArray:
if path.get_file() == unreadable:
last_read_error = ERR_FILE_CANT_READ
return PackedByteArray()
return super(path)
@@ -0,0 +1 @@
uid://bprgpcfabdtn4
+30
View File
@@ -0,0 +1,30 @@
extends TestCase
func test_without_arguments_the_menu_shows_and_the_default_profile_is_used() -> void:
var options := LaunchOptions.parse(PackedStringArray())
check_eq(options.start, "", "no direct start")
check_eq(options.storage_dir(), "user://", "the user folder itself")
check_eq(options.port, NetLink.DEFAULT_PORT, "the default port")
func test_every_argument_is_read() -> void:
var options := LaunchOptions.parse(PackedStringArray(["--profile=second", "--name=Grace", "--host", "--port=25000"]))
check_eq(options.storage_dir(), "user://profiles/second", "a folder of its own")
check_eq(options.player_name, "Grace", "name")
check_eq(options.start, "host", "opens the world")
check_eq(options.port, 25000, "port")
var joining := LaunchOptions.parse(PackedStringArray(["--join=192.168.1.20:25000"]))
check_eq(joining.start, "join", "joins")
check_eq(joining.address, "192.168.1.20:25000", "address")
check_eq(LaunchOptions.parse(PackedStringArray(["--solo"])).start, "solo", "plays alone")
func test_profile_names_cannot_leave_the_profile_folder() -> void:
check(LaunchOptions.is_valid_profile("Zweiter_2-b"), "letters, digits, - and _")
for name in ["", "../save", "a/b", "a b", "x".repeat(33)]:
check(not LaunchOptions.is_valid_profile(name), "refused: '%s'" % name)
var options := LaunchOptions.parse(PackedStringArray(["--profile=../oops", "--port=70000", "--fly"]))
check_eq(options.storage_dir(), "user://", "a bad profile is ignored")
check_eq(options.port, NetLink.DEFAULT_PORT, "a bad port is ignored")
check_eq(options.ignored, PackedStringArray(["--profile=../oops", "--port=70000", "--fly"]), "everything ignored is listed")
+1
View File
@@ -0,0 +1 @@
uid://xe7vqgyxi2wm
+48
View File
@@ -126,3 +126,51 @@ func test_only_the_newest_recordings_are_kept() -> void:
check(FileAccess.file_exists(paths[paths.size() - 1]), "the newest is there")
check_eq(store.write_recording(PackedByteArray()), "", "an empty recording is not written")
TestFiles.remove_dir(directory)
func test_a_key_that_cannot_be_read_right_now_is_not_replaced() -> void:
var directory := TestFiles.temporary_dir("held-key")
var key := SaveStore.new(directory).player_key()
var store := HeldFileStore.new(directory)
store.unreadable = SaveStore.KEY_FILE
check_eq(store.player_key(), PackedByteArray(), "no key for now")
check_eq(store.last_read_error, ERR_FILE_CANT_READ, "the reason is kept")
check_eq(SaveStore.new(directory).player_key(), key, "the key on disk is the old one")
TestFiles.remove_dir(directory)
func test_a_damaged_key_is_replaced() -> void:
var directory := TestFiles.temporary_dir("damaged-key")
var file := FileAccess.open(directory.path_join(SaveStore.KEY_FILE), FileAccess.WRITE)
file.store_buffer(PackedByteArray([1, 2, 3]))
file.close()
var store := SaveStore.new(directory)
var key := store.player_key()
check_eq(key.size(), SaveStore.KEY_BYTES, "a new key")
check_eq(SaveStore.new(directory).player_key(), key, "which is kept")
TestFiles.remove_dir(directory)
func test_a_save_that_cannot_be_read_right_now_reports_why() -> void:
var directory := TestFiles.temporary_dir("held-save")
SaveStore.new(directory).write_save(PackedByteArray([1, 2, 3]))
var store := HeldFileStore.new(directory)
store.unreadable = SaveStore.SAVE_FILE
check_eq(store.read_save(), PackedByteArray(), "nothing read")
check_eq(store.last_read_error, ERR_FILE_CANT_READ, "the reason")
store.unreadable = ""
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "readable again later")
check_eq(store.last_read_error, OK, "no error now")
TestFiles.remove_dir(directory)
func test_the_settings_are_kept() -> void:
var directory := TestFiles.temporary_dir("settings")
var store := SaveStore.new(directory)
check_eq(store.player_name(), "", "no name at first")
store.set_player_name("Ada")
store.set_last_address("192.168.1.20")
var later := SaveStore.new(directory)
check_eq(later.player_name(), "Ada", "name")
check_eq(later.last_address(), "192.168.1.20", "address")
TestFiles.remove_dir(directory)