diff --git a/export_presets.cfg b/export_presets.cfg index d8bd5b8..adb2dc7 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.3.0-prealpha.2/windows-x86_64/NETfishing.exe" +export_path="builds/v0.3.0-prealpha.3/windows-x86_64/NETfishing.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.3.0.2" -application/product_version="0.3.0.2" +application/file_version="0.3.0.3" +application/product_version="0.3.0.3" application/company_name="" application/product_name="NETfishing" -application/file_description="NETfishing v0.3.0-prealpha.2" +application/file_description="NETfishing v0.3.0-prealpha.3" application/copyright="" application/trademarks="" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.3.0-prealpha.2/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.3.0-prealpha.3/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/network/player_data_root.gd b/network/player_data_root.gd index 37983fd..c0fe26e 100644 --- a/network/player_data_root.gd +++ b/network/player_data_root.gd @@ -14,6 +14,9 @@ const README_FILENAME := "README.txt" const ENVIRONMENT_VARIABLE := "NETFISHING_DATA_DIR" const APPLICATION_ID := "netfishing" const APP_DATA_PORTABLE_PATH := "user://portable-data" +const PATH_ALLOWED: StringName = &"allowed" +const PATH_SOURCE_PROJECT: StringName = &"source_project" +const PATH_INSTALLATION: StringName = &"installation" enum Mode { UNRESOLVED, @@ -226,15 +229,77 @@ func _activate_existing(path: String, expected_id: String, permit_creation: bool func _validate_candidate(path: String, create: bool) -> bool: - if path.is_empty() or not path.is_absolute_path(): + var normalized: String = _normalize(path) + if normalized.is_empty() or not normalized.is_absolute_path(): return _fail("Choose an absolute filesystem folder.") - var project: String = ProjectSettings.globalize_path("res://").trim_suffix("/") - if path == project or path.begins_with(project + "/"): + var classification: StringName = classify_candidate_path( + normalized, + ProjectSettings.globalize_path("res://"), + OS.get_executable_path().get_base_dir(), + ) + if classification == PATH_SOURCE_PROJECT: return _fail("The project folder cannot be used as the player data folder.") - if not DirAccess.dir_exists_absolute(path): - if not create or DirAccess.make_dir_recursive_absolute(path) != OK: + if classification == PATH_INSTALLATION: + return _fail( + "The application installation folder cannot be used as the player data folder." + ) + if not DirAccess.dir_exists_absolute(normalized): + if not create or DirAccess.make_dir_recursive_absolute(normalized) != OK: return _fail("The selected data folder is unavailable.") - return _test_writable(path) + return _test_writable(normalized) + + +static func classify_candidate_path( + candidate_path: String, + project_reference: String, + installation_reference: String, + case_insensitive: bool = OS.get_name() == "Windows", +) -> StringName: + var candidate: String = _normalize_comparison_path( + candidate_path, case_insensitive + ) + if candidate.is_empty() or not candidate.is_absolute_path(): + return PATH_ALLOWED + var project: String = _normalize_reference_path( + project_reference, case_insensitive + ) + if not project.is_empty() and _is_same_or_child_path(candidate, project): + return PATH_SOURCE_PROJECT + var installation: String = _normalize_reference_path( + installation_reference, case_insensitive + ) + if ( + not installation.is_empty() + and _is_same_or_child_path(candidate, installation) + ): + return PATH_INSTALLATION + return PATH_ALLOWED + + +static func _normalize_reference_path(path: String, case_insensitive: bool) -> String: + var normalized: String = _normalize_comparison_path(path, case_insensitive) + if normalized.is_empty() or not normalized.is_absolute_path(): + return "" + if normalized == "/" or _is_windows_drive_root(normalized): + return "" + return normalized + + +static func _normalize_comparison_path(path: String, case_insensitive: bool) -> String: + var normalized: String = path.strip_edges().replace("\\", "/").simplify_path() + while normalized.length() > 1 and normalized.ends_with("/"): + if _is_windows_drive_root(normalized): + break + normalized = normalized.left(-1) + return normalized.to_lower() if case_insensitive else normalized + + +static func _is_windows_drive_root(path: String) -> bool: + return path.length() == 3 and path[1] == ":" and path[2] == "/" + + +static func _is_same_or_child_path(candidate: String, reference: String) -> bool: + return candidate == reference or candidate.begins_with(reference + "/") func _test_writable(path: String) -> bool: @@ -340,7 +405,12 @@ func _directory_is_empty(path: String) -> bool: func _normalize(path: String) -> String: - return path.simplify_path().trim_suffix("/") + var normalized: String = path.replace("\\", "/").simplify_path() + while normalized.length() > 1 and normalized.ends_with("/"): + if _is_windows_drive_root(normalized): + break + normalized = normalized.left(-1) + return normalized func _read_json(path: String, maximum := 1024 * 1024) -> Dictionary: diff --git a/playtest/README-PLAYTEST.txt b/playtest/README-PLAYTEST.txt index 5c93144..590da0f 100644 --- a/playtest/README-PLAYTEST.txt +++ b/playtest/README-PLAYTEST.txt @@ -1,5 +1,5 @@ NETfishing -v0.3.0-prealpha.2 +v0.3.0-prealpha.3 Pre-Alpha 0.3 Thank you for trying this early private playtest. diff --git a/project.godot b/project.godot index c2b8990..94a8d73 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.3.0-prealpha.2" +config/version="0.3.0-prealpha.3" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://icon.svg" diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index d82ee16..74bbece 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,12 +4,12 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.3.0-prealpha.2" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.3.0-prealpha.3" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/playtest/README-PLAYTEST.txt" -readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.3.0-prealpha.2-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.3.0-prealpha.2-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.3.0-prealpha.3-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.3.0-prealpha.3-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/scripts/test_player_data_root_paths.gd b/scripts/test_player_data_root_paths.gd new file mode 100644 index 0000000..69a2485 --- /dev/null +++ b/scripts/test_player_data_root_paths.gd @@ -0,0 +1,130 @@ +extends SceneTree + +const PROJECT_PATH := "/tmp/netfishing" +const INSTALL_PATH := "/opt/NETfishing" + + +func _initialize() -> void: + var root_to_create: String = OS.get_environment("NETFISHING_TEST_CREATE_ROOT") + if not root_to_create.is_empty(): + _create_validation_root(root_to_create) + return + var failures: PackedStringArray = [] + _expect( + "external temporary directory", + "/tmp/NETfishing-test-data", + PlayerDataRoot.PATH_ALLOWED, + failures, + ) + _expect( + "external trailing slash", + "/tmp/NETfishing-test-data/", + PlayerDataRoot.PATH_ALLOWED, + failures, + ) + _expect( + "similar project-name sibling", + "/tmp/netfishing-data", + PlayerDataRoot.PATH_ALLOWED, + failures, + ) + _expect( + "documents directory", + "/home/player/Documents/NETfishing", + PlayerDataRoot.PATH_ALLOWED, + failures, + ) + _expect("source project", PROJECT_PATH, PlayerDataRoot.PATH_SOURCE_PROJECT, failures) + _expect( + "source project child", + PROJECT_PATH.path_join("player-data"), + PlayerDataRoot.PATH_SOURCE_PROJECT, + failures, + ) + _expect( + "installation directory", + INSTALL_PATH, + PlayerDataRoot.PATH_INSTALLATION, + failures, + ) + _expect( + "installation child", + INSTALL_PATH.path_join("portable-data"), + PlayerDataRoot.PATH_INSTALLATION, + failures, + ) + _expect_with_references( + "empty project reference", + "/tmp/NETfishing-test-data", + "", + INSTALL_PATH, + PlayerDataRoot.PATH_ALLOWED, + failures, + ) + _expect_with_references( + "root project reference", + "/tmp/NETfishing-test-data", + "/", + INSTALL_PATH, + PlayerDataRoot.PATH_ALLOWED, + failures, + ) + _expect_with_references( + "Windows component and case normalization", + "C:\\Games\\NETfishing Data", + "C:\\Games\\NETfishing", + "C:\\Program Files\\NETfishing", + PlayerDataRoot.PATH_ALLOWED, + failures, + true, + ) + if failures.is_empty(): + print("PlayerDataRoot path validation: PASS") + quit(0) + return + for failure: String in failures: + push_error(failure) + quit(1) + + +func _create_validation_root(path: String) -> void: + var data_root := PlayerDataRoot.new() + get_root().add_child(data_root) + if data_root.select_new_root(path): + print("PlayerDataRoot validation root created: ", data_root.root_path) + quit(0) + return + push_error(data_root.error_message) + quit(1) + + +func _expect( + label: String, + candidate: String, + expected: StringName, + failures: PackedStringArray, +) -> void: + _expect_with_references( + label, + candidate, + PROJECT_PATH, + INSTALL_PATH, + expected, + failures, + ) + + +func _expect_with_references( + label: String, + candidate: String, + project: String, + installation: String, + expected: StringName, + failures: PackedStringArray, + case_insensitive: bool = false, +) -> void: + var actual: StringName = PlayerDataRoot.classify_candidate_path( + candidate, project, installation, case_insensitive + ) + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) diff --git a/scripts/test_player_data_root_paths.gd.uid b/scripts/test_player_data_root_paths.gd.uid new file mode 100644 index 0000000..90f8e0b --- /dev/null +++ b/scripts/test_player_data_root_paths.gd.uid @@ -0,0 +1 @@ +uid://e8glhlm205au diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 8fe3ddc..4a7c763 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -206,7 +206,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.3.0-prealpha.2" +text = "v0.3.0-prealpha.3" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]