M1c: Gleichschritt im Netz #9
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkgt2ownscxuj
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://xe7vqgyxi2wm
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user