diff --git a/addons/mod_loader/mod_loader_store.gd b/addons/mod_loader/mod_loader_store.gd index f9fc89fe..bdf82583 100644 --- a/addons/mod_loader/mod_loader_store.gd +++ b/addons/mod_loader/mod_loader_store.gd @@ -123,11 +123,10 @@ func _init(): # Update ModLoader's options, via the custom options resource -func _update_ml_options_from_options_resource() -> void: - # Path to the options resource - # See: res://addons/mod_loader/resources/options_current.gd - var ml_options_path := "res://addons/mod_loader/options/options.tres" - +# +# Parameters: +# - ml_options_path: Path to the options resource. See: res://addons/mod_loader/resources/options_current.gd +func _update_ml_options_from_options_resource(ml_options_path := "res://addons/mod_loader/options/options.tres") -> void: # Get user options for ModLoader if not _ModLoaderFile.file_exists(ml_options_path) and not ResourceLoader.exists(ml_options_path): ModLoaderLog.fatal(str("A critical file is missing: ", ml_options_path), LOG_NAME) @@ -177,6 +176,9 @@ func _update_ml_options_from_options_resource() -> void: # Update from the options in the resource ml_options = override_options + if not ml_options.customize_script_path.is_empty(): + ml_options.customize_script_instance = load(ml_options.customize_script_path).new(ml_options) + func _exit_tree() -> void: # Save the cache to the cache file. diff --git a/addons/mod_loader/resources/mod_manifest.gd b/addons/mod_loader/resources/mod_manifest.gd index 60f48970..7a55e0a5 100644 --- a/addons/mod_loader/resources/mod_manifest.gd +++ b/addons/mod_loader/resources/mod_manifest.gd @@ -113,7 +113,14 @@ func validate(manifest: Dictionary, path: String) -> bool: config_schema = ModLoaderUtils.get_dict_from_dict(godot_details, "config_schema") steam_workshop_id = ModLoaderUtils.get_string_from_dict(godot_details, "steam_workshop_id") - _is_game_version_compatible(mod_id) + if ModLoaderStore.ml_options.game_version_validation == ModLoaderOptionsProfile.VERSION_VALIDATION.DEFAULT: + _is_game_version_compatible(mod_id) + + if ModLoaderStore.ml_options.game_version_validation == ModLoaderOptionsProfile.VERSION_VALIDATION.CUSTOM: + if ModLoaderStore.ml_options.custom_game_version_validation_callable: + ModLoaderStore.ml_options.custom_game_version_validation_callable.call(self) + else: + ModLoaderLog.error("No custom game version validation callable detected. Please provide a valid validation callable.", LOG_NAME) is_mod_id_array_valid(mod_id, dependencies, "dependency") is_mod_id_array_valid(mod_id, incompatibilities, "incompatibility") diff --git a/addons/mod_loader/resources/options_profile.gd b/addons/mod_loader/resources/options_profile.gd index b87d84d7..3cf5a59a 100644 --- a/addons/mod_loader/resources/options_profile.gd +++ b/addons/mod_loader/resources/options_profile.gd @@ -1,18 +1,52 @@ class_name ModLoaderOptionsProfile extends Resource +## +## Class to define and store Mod Loader Options. +## +## @tutorial(Example Customization Script): https://wiki.godotmodding.com/guides/integration/mod_loader_options/#game-version-validation +## Settings for game version validation. +enum VERSION_VALIDATION { + ## Uses the default semantic versioning (semver) validation. + DEFAULT, + + ## Disables validation of the game version specified in [member semantic_version] + ## and the mod's [member ModManifest.compatible_game_version]. + DISABLED, + + ## Enables custom game version validation. + ## Use [member customize_script_path] to specify a script that customizes the Mod Loader options. + ## In this script, you must set [member custom_game_version_validation_callable] + ## to a custom validation [Callable]. + ## [br] + ## ===[br] + ## [b]Note:[color=note "Easier Mod Loader Updates"][/color][/b][br] + ## Using a custom script allows you to keep your code outside the addons directory, + ## making it easier to update the mod loader without affecting your modifications. [br] + ## ===[br] + CUSTOM, +} + ## Can be used to disable mods for specific plaforms by using feature overrides @export var enable_mods: bool = true ## List of mod ids that can't be turned on or off @export var locked_mods: Array[String] = [] - +## List of mods that will not be loaded @export var disabled_mods: Array[String] = [] ## Disables the requirement for the mod loader autoloads to be first @export var allow_modloader_autoloads_anywhere: bool = false - +## This script is loaded after [member ModLoaderStore.ml_options] has been initialized. +## It is instantiated with [member ModLoaderStore.ml_options] as an argument. +## Use this script to apply settings that cannot be configured through the editor UI. +## +## For an example, see [enum VERSION_VALIDATION] [code]CUSTOM[/code] or +## [code]res://addons/mod_loader/options/example_customize_script.gd[/code]. +@export_file var customize_script_path: String @export_group("Logging") +## Sets the logging verbosity level. +## Refer to [enum ModLoaderLog.VERBOSITY_LEVEL] for more details. @export var log_level := ModLoaderLog.VERBOSITY_LEVEL.DEBUG ## Stops the mod loader from logging any deprecation related errors. @export var ignore_deprecated_errors: bool = false @@ -42,6 +76,7 @@ extends Resource ## Path to a folder containing mods [br] ## Mod zips should be directly in this folder @export_dir var override_path_to_mods = "" +## Use this option to override the default path where configs are stored. @export_dir var override_path_to_configs = "" ## Path to a folder containing workshop items.[br] ## Mods zips are placed in another folder, usually[br] @@ -61,3 +96,17 @@ extends Resource @export_dir var restart_notification_scene_path := "res://addons/mod_loader/restart_notification.tscn" ## Can be used to disable the mod loader's restart logic. Use the [signal ModLoader.new_hooks_created] to implement your own restart logic. @export var disable_restart := false + +@export_group("Mod Validation") +## Defines how the game version should be validated. +## This setting controls validation for the game version specified in [member semantic_version] +## and the mod's [member ModManifest.compatible_game_version]. +@export var game_version_validation := VERSION_VALIDATION.DEFAULT + +## Callable that is executed during [ModManifest] validation +## if [member game_version_validation] is set to [enum VERSION_VALIDATION] [code]CUSTOM[/code]. +## See the example under [enum VERSION_VALIDATION] [code]CUSTOM[/code] to learn how to set this up. +var custom_game_version_validation_callable: Callable + +## Stores the instance of the script specified in [member customize_script_path]. +var customize_script_instance: RefCounted diff --git a/test/Unit/test_options.gd b/test/Unit/test_options.gd new file mode 100644 index 00000000..a7d09d2e --- /dev/null +++ b/test/Unit/test_options.gd @@ -0,0 +1,47 @@ +extends GutTest + + +func load_manifest_test_mod_1() -> ModManifest: + var mod_path := "res://mods-unpacked/test-mod1/" + var manifest_data: Dictionary = _ModLoaderFile.load_manifest_file(mod_path) + + return ModManifest.new(manifest_data, mod_path) + + +func test_customize_script() -> void: + ModLoaderStore._update_ml_options_from_options_resource("res://test_options/customize_script/options_custom_validation.tres") + var manifest := load_manifest_test_mod_1() + + assert_eq( + "".join(manifest.validation_messages_warning), + "! ☞゚ヮ゚)☞ CUSTOM VALIDATION HERE ☜゚ヮ゚☜) !" + ) + + +func test_customize_script_no_callable() -> void: + # Clear saved error logs before testing to prevent false positives. + ModLoaderLog.logged_messages.by_type.error.clear() + + ModLoaderStore._update_ml_options_from_options_resource("res://test_options/customize_script_no_callable_set/options_custom_validation_no_callable_set.tres") + var manifest := load_manifest_test_mod_1() + + var logs := ModLoaderLog.get_by_type_as_string("error") + + assert_string_contains("".join(logs), "No custom game version validation callable detected. Please provide a valid validation callable.") + + +func test_game_verion_validation_disabled() -> void: + ModLoaderStore._update_ml_options_from_options_resource("res://test_options/game_version_validation_disabled/options_game_version_validation_disabled.tres") + var manifest := load_manifest_test_mod_1() + + assert_true(manifest.validation_messages_error.size() == 0) + + +func test_game_verion_validation_default() -> void: + ModLoaderStore._update_ml_options_from_options_resource("res://test_options/game_version_validation_default/options_game_version_validation_default.tres") + var manifest := load_manifest_test_mod_1() + + assert_eq( + "".join(manifest.validation_messages_error).replace("\r", "").replace("\n", "").replace("\t", ""), + "The mod \"test-mod1\" is incompatible with the current game version.(current game version: 1000.0.0, mod compatible with game versions: [\"0.0.1\"])" + ) diff --git a/test/test_options/customize_script/custom_validation.tres b/test/test_options/customize_script/custom_validation.tres new file mode 100644 index 00000000..f390d4c3 --- /dev/null +++ b/test/test_options/customize_script/custom_validation.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="ModLoaderOptionsProfile" load_steps=2 format=3 uid="uid://dky5648t3gmp2"] + +[ext_resource type="Script" path="res://addons/mod_loader/resources/options_profile.gd" id="1_3rpjy"] + +[resource] +script = ExtResource("1_3rpjy") +enable_mods = true +locked_mods = Array[String]([]) +disabled_mods = Array[String]([]) +allow_modloader_autoloads_anywhere = false +customize_script_path = "res://test_options/customize_script/customize_script.gd" +log_level = 3 +ignore_deprecated_errors = false +ignored_mod_names_in_log = Array[String]([]) +steam_id = 0 +semantic_version = "0.0.0" +load_from_steam_workshop = false +load_from_local = true +override_path_to_mods = "" +override_path_to_configs = "" +override_path_to_workshop = "" +override_path_to_hook_pack = "" +override_hook_pack_name = "" +restart_notification_scene_path = "res://addons/mod_loader/restart_notification.tscn" +disable_restart = false +game_version_validation = 2 diff --git a/test/test_options/customize_script/customize_script.gd b/test/test_options/customize_script/customize_script.gd new file mode 100644 index 00000000..ab99c5df --- /dev/null +++ b/test/test_options/customize_script/customize_script.gd @@ -0,0 +1,26 @@ +extends RefCounted + +# This is an example script for the ModLoaderOptionsProfile `customize_script_path`. +# Ideally, place this script outside the `mod_loader` directory to simplify the update process. + + +# This script is loaded after `mod_loader_store.ml_options` has been initialized. +# It receives `ml_options` as an argument, allowing you to apply settings +# that cannot be configured through the editor UI. +func _init(ml_options: ModLoaderOptionsProfile) -> void: + # Use OS.has_feature() to apply changes only for specific platforms, + # or create multiple customization scripts and set their paths accordingly in the option profiles. + if OS.has_feature("Steam"): + pass + elif OS.has_feature("Epic"): + pass + else: + # Set `custom_game_version_validation_callable` to use a custom validation function. + ml_options.custom_game_version_validation_callable = custom_is_game_version_compatible + + +# Custom validation function +# See `ModManifest._is_game_version_compatible()` for the default validation logic. +func custom_is_game_version_compatible(manifest: ModManifest) -> bool: + manifest.validation_messages_warning.push_back("! ☞゚ヮ゚)☞ CUSTOM VALIDATION HERE ☜゚ヮ゚☜) !") + return true diff --git a/test/test_options/customize_script/options_custom_validation.tres b/test/test_options/customize_script/options_custom_validation.tres new file mode 100644 index 00000000..bbcdba86 --- /dev/null +++ b/test/test_options/customize_script/options_custom_validation.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="ModLoaderCurrentOptions" load_steps=3 format=3 uid="uid://d08kklljebrnh"] + +[ext_resource type="Resource" uid="uid://dky5648t3gmp2" path="res://test_options/customize_script/custom_validation.tres" id="1_s4sec"] +[ext_resource type="Script" path="res://addons/mod_loader/resources/options_current.gd" id="2_1rct1"] + +[resource] +script = ExtResource("2_1rct1") +current_options = ExtResource("1_s4sec") +feature_override_options = {} diff --git a/test/test_options/customize_script_no_callable_set/custom_validation_no_callable_set.tres b/test/test_options/customize_script_no_callable_set/custom_validation_no_callable_set.tres new file mode 100644 index 00000000..9e5b01ea --- /dev/null +++ b/test/test_options/customize_script_no_callable_set/custom_validation_no_callable_set.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="ModLoaderOptionsProfile" load_steps=2 format=3 uid="uid://1gab2n8lgi60"] + +[ext_resource type="Script" path="res://addons/mod_loader/resources/options_profile.gd" id="1_d2tfu"] + +[resource] +script = ExtResource("1_d2tfu") +enable_mods = true +locked_mods = Array[String]([]) +disabled_mods = Array[String]([]) +allow_modloader_autoloads_anywhere = false +customize_script_path = "res://test_options/customize_script_no_callable_set/customize_script_no_callable_set.gd" +log_level = 3 +ignore_deprecated_errors = false +ignored_mod_names_in_log = Array[String]([]) +steam_id = 0 +semantic_version = "0.0.0" +load_from_steam_workshop = false +load_from_local = true +override_path_to_mods = "" +override_path_to_configs = "" +override_path_to_workshop = "" +override_path_to_hook_pack = "" +override_hook_pack_name = "" +restart_notification_scene_path = "res://addons/mod_loader/restart_notification.tscn" +disable_restart = false +game_version_validation = 2 diff --git a/test/test_options/customize_script_no_callable_set/customize_script_no_callable_set.gd b/test/test_options/customize_script_no_callable_set/customize_script_no_callable_set.gd new file mode 100644 index 00000000..e0d84a9f --- /dev/null +++ b/test/test_options/customize_script_no_callable_set/customize_script_no_callable_set.gd @@ -0,0 +1,27 @@ +extends RefCounted + +# This is an example script for the ModLoaderOptionsProfile `customize_script_path`. +# Ideally, place this script outside the `mod_loader` directory to simplify the update process. + + +# This script is loaded after `mod_loader_store.ml_options` has been initialized. +# It receives `ml_options` as an argument, allowing you to apply settings +# that cannot be configured through the editor UI. +func _init(ml_options: ModLoaderOptionsProfile) -> void: + # Use OS.has_feature() to apply changes only for specific platforms, + # or create multiple customization scripts and set their paths accordingly in the option profiles. + if OS.has_feature("Steam"): + pass + elif OS.has_feature("Epic"): + pass + else: + pass + # Set `custom_game_version_validation_callable` to use a custom validation function. + #ml_options.custom_game_version_validation_callable = custom_is_game_version_compatible + + +# Custom validation function +# See `ModManifest._is_game_version_compatible()` for the default validation logic. +func custom_is_game_version_compatible(manifest: ModManifest) -> bool: + manifest.validation_messages_warning.push_back("! ☞゚ヮ゚)☞ CUSTOM VALIDATION HERE ☜゚ヮ゚☜) !") + return true diff --git a/test/test_options/customize_script_no_callable_set/options_custom_validation_no_callable_set.tres b/test/test_options/customize_script_no_callable_set/options_custom_validation_no_callable_set.tres new file mode 100644 index 00000000..c5f12d9e --- /dev/null +++ b/test/test_options/customize_script_no_callable_set/options_custom_validation_no_callable_set.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="ModLoaderCurrentOptions" load_steps=3 format=3 uid="uid://c25j7kt7y8ora"] + +[ext_resource type="Resource" uid="uid://1gab2n8lgi60" path="res://test_options/customize_script_no_callable_set/custom_validation_no_callable_set.tres" id="1_xrqi6"] +[ext_resource type="Script" path="res://addons/mod_loader/resources/options_current.gd" id="2_4o6bw"] + +[resource] +script = ExtResource("2_4o6bw") +current_options = ExtResource("1_xrqi6") +feature_override_options = {} diff --git a/test/test_options/game_version_validation_default/game_version_validation_default.tres b/test/test_options/game_version_validation_default/game_version_validation_default.tres new file mode 100644 index 00000000..611aea76 --- /dev/null +++ b/test/test_options/game_version_validation_default/game_version_validation_default.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="ModLoaderOptionsProfile" load_steps=2 format=3 uid="uid://bnc6gslxpnx3y"] + +[ext_resource type="Script" path="res://addons/mod_loader/resources/options_profile.gd" id="1_kdajl"] + +[resource] +script = ExtResource("1_kdajl") +enable_mods = true +locked_mods = Array[String]([]) +disabled_mods = Array[String]([]) +allow_modloader_autoloads_anywhere = false +customize_script_path = "" +log_level = 3 +ignore_deprecated_errors = false +ignored_mod_names_in_log = Array[String]([]) +steam_id = 0 +semantic_version = "1000.0.0" +load_from_steam_workshop = false +load_from_local = true +override_path_to_mods = "" +override_path_to_configs = "" +override_path_to_workshop = "" +override_path_to_hook_pack = "" +override_hook_pack_name = "" +restart_notification_scene_path = "res://addons/mod_loader/restart_notification.tscn" +disable_restart = false +game_version_validation = 0 diff --git a/test/test_options/game_version_validation_default/options_game_version_validation_default.tres b/test/test_options/game_version_validation_default/options_game_version_validation_default.tres new file mode 100644 index 00000000..2ac4f0c2 --- /dev/null +++ b/test/test_options/game_version_validation_default/options_game_version_validation_default.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="ModLoaderCurrentOptions" load_steps=3 format=3 uid="uid://emmn66l0e1n0"] + +[ext_resource type="Resource" uid="uid://bnc6gslxpnx3y" path="res://test_options/game_version_validation_default/game_version_validation_default.tres" id="1_ey6sk"] +[ext_resource type="Script" path="res://addons/mod_loader/resources/options_current.gd" id="2_0ultl"] + +[resource] +script = ExtResource("2_0ultl") +current_options = ExtResource("1_ey6sk") +feature_override_options = {} diff --git a/test/test_options/game_version_validation_disabled/game_version_validation_disabled.tres b/test/test_options/game_version_validation_disabled/game_version_validation_disabled.tres new file mode 100644 index 00000000..5d9dbf34 --- /dev/null +++ b/test/test_options/game_version_validation_disabled/game_version_validation_disabled.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="ModLoaderOptionsProfile" load_steps=2 format=3 uid="uid://d2ktmje1gd5vb"] + +[ext_resource type="Script" path="res://addons/mod_loader/resources/options_profile.gd" id="1_vd02r"] + +[resource] +script = ExtResource("1_vd02r") +enable_mods = true +locked_mods = Array[String]([]) +disabled_mods = Array[String]([]) +allow_modloader_autoloads_anywhere = false +customize_script_path = "" +log_level = 3 +ignore_deprecated_errors = false +ignored_mod_names_in_log = Array[String]([]) +steam_id = 0 +semantic_version = "1000.0.0" +load_from_steam_workshop = false +load_from_local = true +override_path_to_mods = "" +override_path_to_configs = "" +override_path_to_workshop = "" +override_path_to_hook_pack = "" +override_hook_pack_name = "" +restart_notification_scene_path = "res://addons/mod_loader/restart_notification.tscn" +disable_restart = false +game_version_validation = 1 diff --git a/test/test_options/game_version_validation_disabled/options_game_version_validation_disabled.tres b/test/test_options/game_version_validation_disabled/options_game_version_validation_disabled.tres new file mode 100644 index 00000000..a9bc9e1a --- /dev/null +++ b/test/test_options/game_version_validation_disabled/options_game_version_validation_disabled.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="ModLoaderCurrentOptions" load_steps=3 format=3 uid="uid://dsegljus5l2qm"] + +[ext_resource type="Resource" uid="uid://d2ktmje1gd5vb" path="res://test_options/game_version_validation_disabled/game_version_validation_disabled.tres" id="1_18vx8"] +[ext_resource type="Script" path="res://addons/mod_loader/resources/options_current.gd" id="2_5hgrx"] + +[resource] +script = ExtResource("2_5hgrx") +current_options = ExtResource("1_18vx8") +feature_override_options = {}