diff --git a/game/scripts/launch_options.gd b/game/scripts/launch_options.gd new file mode 100644 index 0000000..8fe9470 --- /dev/null +++ b/game/scripts/launch_options.gd @@ -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) diff --git a/game/scripts/launch_options.gd.uid b/game/scripts/launch_options.gd.uid new file mode 100644 index 0000000..f046f56 --- /dev/null +++ b/game/scripts/launch_options.gd.uid @@ -0,0 +1 @@ +uid://dkgt2ownscxuj diff --git a/game/scripts/save_store.gd b/game/scripts/save_store.gd index 7736ca5..7a507a0 100644 --- a/game/scripts/save_store.gd +++ b/game/scripts/save_store.gd @@ -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,14 +30,19 @@ 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 not _write(path, key): - push_error("player key could not be stored at %s" % path) + 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: diff --git a/game/tests/support/held_file_store.gd b/game/tests/support/held_file_store.gd new file mode 100644 index 0000000..41be9f9 --- /dev/null +++ b/game/tests/support/held_file_store.gd @@ -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) diff --git a/game/tests/support/held_file_store.gd.uid b/game/tests/support/held_file_store.gd.uid new file mode 100644 index 0000000..c7894aa --- /dev/null +++ b/game/tests/support/held_file_store.gd.uid @@ -0,0 +1 @@ +uid://bprgpcfabdtn4 diff --git a/game/tests/test_launch_options.gd b/game/tests/test_launch_options.gd new file mode 100644 index 0000000..2ac91ba --- /dev/null +++ b/game/tests/test_launch_options.gd @@ -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") diff --git a/game/tests/test_launch_options.gd.uid b/game/tests/test_launch_options.gd.uid new file mode 100644 index 0000000..4da3e81 --- /dev/null +++ b/game/tests/test_launch_options.gd.uid @@ -0,0 +1 @@ +uid://xe7vqgyxi2wm diff --git a/game/tests/test_save_store.gd b/game/tests/test_save_store.gd index 319ff21..47c64e3 100644 --- a/game/tests/test_save_store.gd +++ b/game/tests/test_save_store.gd @@ -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)