Profile, Startoptionen und vorsichtiges Lesen
Die Befehlszeile wählt ein Profil, einen Namen und einen Direktstart. Zwei Fenster auf einem Rechner sind so zwei Spieler. Ein Schlüssel oder Spielstand, der sich gerade nicht lesen lässt, wird nicht mehr ersetzt. Name und zuletzt genutzte Adresse bleiben gespeichert. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
class_name SaveStore
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
## The game's files on the player's machine: the player key, the save and
|
## The game's files on the player's machine: the player key, the save, the
|
||||||
## the session recordings for bug reports.
|
## session recordings for bug reports and the player's settings.
|
||||||
|
|
||||||
const KEY_FILE := "player.key"
|
const KEY_FILE := "player.key"
|
||||||
const SAVE_FILE := "world.grw"
|
const SAVE_FILE := "world.grw"
|
||||||
## Holds the previous save while write_save replaces it.
|
## Holds the previous save while write_save replaces it.
|
||||||
const BACKUP_FILE := SAVE_FILE + ".bak"
|
const BACKUP_FILE := SAVE_FILE + ".bak"
|
||||||
const RECORDINGS_DIR := "recordings"
|
const RECORDINGS_DIR := "recordings"
|
||||||
|
const SETTINGS_FILE := "settings.cfg"
|
||||||
## Recordings beyond this number are deleted, oldest first.
|
## Recordings beyond this number are deleted, oldest first.
|
||||||
const KEEP_RECORDINGS := 10
|
const KEEP_RECORDINGS := 10
|
||||||
const KEY_BYTES := 16
|
const KEY_BYTES := 16
|
||||||
|
|
||||||
var base_dir: String
|
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
|
## 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
|
## 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:
|
func player_key() -> PackedByteArray:
|
||||||
var path := base_dir.path_join(KEY_FILE)
|
var path := base_dir.path_join(KEY_FILE)
|
||||||
var key := FileAccess.get_file_as_bytes(path)
|
if FileAccess.file_exists(path):
|
||||||
if key.size() != KEY_BYTES:
|
var stored := _read_file(path)
|
||||||
key = Crypto.new().generate_random_bytes(KEY_BYTES)
|
if last_read_error != OK:
|
||||||
if not _write(path, key):
|
return PackedByteArray()
|
||||||
push_error("player key could not be stored at %s" % path)
|
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
|
return key
|
||||||
|
|
||||||
|
|
||||||
@@ -40,8 +50,10 @@ func has_save() -> bool:
|
|||||||
return FileAccess.file_exists(base_dir.path_join(SAVE_FILE))
|
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:
|
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
|
## Writes the save through a temporary file, so that a crash or a failed
|
||||||
@@ -115,6 +127,40 @@ func recordings() -> PackedStringArray:
|
|||||||
return names
|
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.
|
## Moves the backup back to the save if there is no save, see write_save.
|
||||||
func _restore_backup() -> void:
|
func _restore_backup() -> void:
|
||||||
var path := base_dir.path_join(SAVE_FILE)
|
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)
|
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:
|
func _write(path: String, bytes: PackedByteArray) -> bool:
|
||||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||||
if file == null:
|
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(FileAccess.file_exists(paths[paths.size() - 1]), "the newest is there")
|
||||||
check_eq(store.write_recording(PackedByteArray()), "", "an empty recording is not written")
|
check_eq(store.write_recording(PackedByteArray()), "", "an empty recording is not written")
|
||||||
TestFiles.remove_dir(directory)
|
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