Skip to content

Commit

Permalink
feat: ✨ Add user profiles (#214)
Browse files Browse the repository at this point in the history
* feat: ✨ Created `ModLoaderUserProfile`

* feat: ✨ add `user_profiles` to `ModLoaderStore`

* feat: ✨ implemented `ModLoaderUserProfile.create()`

* feat: ✨ Add `current_user_profile` to `ModLoaderStore`

* feat: ✨ Added `_save()` func

Saves the user profiles and `current_profile` to `user://mod_user_profiles.json`

* feat: ✨ Implemented `_load()`

Added `_create_new_profile(name: String, mod_list: Dictionary)` - used to create new profiles now.

* style: 💡 Add comments to `_save()`

and swapped hardcoded file path with `FILE_PATH_USER_PROFILES`

* refactor: ♻️ Use a `Dictionary` to store user profiles

* feat: 🦺 Verify profile name is not already in use

* feat: ✨ Implemented enabling and disabling

Added `_handle_mod_state()` for this and use it in `enable_mod()` and `disable_mod()`

* feat: ✨ implemented `delete()`

* feat: ✨ Added `_update_mod_lists()`

Updates the mod lists of all user profiles with newly loaded mods that are not already present.

* feat: ✨ Added `delete_mod()`

Deletes a mod from a user profiles mod_list. Also added `_is_mod_id_in_mod_list()` to keep things dry.

* fix: 🐛 Added missing `:` 👀

* feat: ✨ Added `set_profile()`

* refactor: ♻️ return bools

* feat: ✨ Added `_update_disabled_mods()`

Update the global list of disabled mods based on the current user profile

* feat: ✨ Added user profile handling to *mod_loader.gd*

* feat: ✨ Added `get_all_as_array()`

Returns an array containing all user profiles stored in ModLoaderStore

* fix: 🐛 Don't init `current_user_profile` with `default`

An error was caused in the `_update_disabled_mods()` function due to the potential non-existence of the `default` profile at this stage.

* feat: ✨ Added `mandatory_mods`

Allows to specify mandatory mods in the `ml_options`

* fix: 🐛 check `mod_data` has `mod_id` in `disable_mod()`

* style: ✏️ renamed parameter `name` -> `profile_name`

* fix: 🐛 add deactivated mods to new created profiles

* fix: 🐛 Add deactivated mods when updating `mod_list`

* feat: ✨ added `get_profile()`

* feat: ✨ Added `get_current()`

* fix: 🐛 indent error

* fix: 🐛 added missing `not` to `get_profile()`

* fix: 🐛 check if the profile exists in `_is_mod_id_in_mod_list`

* refactor: ♻️ update log function calls

* feat: ✨ Added additional debug logs

* style: 💡 improved comment for `_update_disabled_mods()`

* style: 🎨 removed white-space

* refactor: ♻️ removed `delete_mod()` API function

I integrated it into the `_update_mod_lists()` function, so that any mods that are no longer installed will automatically be deleted from the user's profile mod lists.

* refactor: ♻️ updated for latest `ModLoaderUtils` refactor

* style: ✏️ added `_profile` to function names

`create()` -> `create_profile()` | `delete()` -> `delete_profile()`

* style: ✏️ `_handle_mod_state()` -> `_set_mod_state()`

* fix: 🐛 merge `mod_list`s before deleting uninstalled mods

* fix: 🐛 function call - because of rename

* refactor: ♻️ changed `is_mandatory` to `is_locked`

With this a mod can be locked in the current profile state and can't be disabled or enabled.
  • Loading branch information
KANAjetzt authored Apr 22, 2023
1 parent e88ec1e commit b9ad9a5
Show file tree
Hide file tree
Showing 5 changed files with 354 additions and 0 deletions.
327 changes: 327 additions & 0 deletions addons/mod_loader/api/profile.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
class_name ModLoaderUserProfile
extends Object


# This Class provides methods for working with user profiles.

const LOG_NAME := "ModLoader:UserProfile"
const FILE_PATH_USER_PROFILES = "user://mod_user_profiles.json"

class Profile:
extends Reference

var name := ""
var mod_list := {}


# API profile functions
# =============================================================================

# Enables a mod - it will be loaded on the next game start
static func enable_mod(mod_id: String, profile_name := ModLoaderStore.current_user_profile) -> bool:
return _set_mod_state(mod_id, profile_name, true)


# Disables a mod - it will not be loaded on the next game start
static func disable_mod(mod_id: String, profile_name := ModLoaderStore.current_user_profile) -> bool:
return _set_mod_state(mod_id, profile_name, false)


# Creates a new user profile with the given name, using the currently loaded mods as the mod list.
static func create_profile(profile_name: String) -> bool:
# Verify that the profile name is not already in use
if ModLoaderStore.user_profiles.has(profile_name):
ModLoaderLog.error("User profile with the name of \"%s\" already exists." % profile_name, LOG_NAME)
return false

var mod_list := {}

# Add all currently loaded mods to the mod_list as active
for mod_id in ModLoaderStore.mod_data.keys():
mod_list[mod_id] = true

# Add all deactivated mods to the mod list
for mod_id in ModLoaderStore.ml_options.disabled_mods:
mod_list[mod_id] = false

var new_profile := _create_new_profile(profile_name, mod_list)

# If there was an error creating the new user profile return
if not new_profile:
return false

# Set it as the current profile
ModLoaderStore.current_user_profile = profile_name

# Store the new profile in the ModLoaderStore
ModLoaderStore.user_profiles[profile_name] = new_profile

# Store the new profile in the json file
var is_save_success := _save()

if is_save_success:
ModLoaderLog.debug("Created new user profile \"%s\"" % profile_name, LOG_NAME)

return is_save_success


# Sets the current user profile to the specified profile_name.
static func set_profile(profile_name: String) -> bool:
# Check if there is a user profile with the specified name
if not ModLoaderStore.user_profiles.has(profile_name):
ModLoaderLog.error("User profile with name \"%s\" not found." % profile_name, LOG_NAME)
return false

# Update the current_user_profile in the ModLoaderStore
ModLoaderStore.current_user_profile = profile_name

# Save changes in the json file
var is_save_success := _save()

if is_save_success:
ModLoaderLog.debug("Current user profile set to \"%s\"" % profile_name, LOG_NAME)

return is_save_success


# Deletes a user profile with the given profile_name.
static func delete_profile(profile_name: String) -> bool:
# If the current_profile is about to get deleted change it to default
if ModLoaderStore.current_user_profile == profile_name:
ModLoaderLog.error(str(
"You cannot delete the currently selected user profile \"%s\" " +
"because it is currently in use. Please switch to a different profile before deleting this one.") % profile_name,
LOG_NAME)
return false

# Deleting the default profile is not allowed
if profile_name == "default":
ModLoaderLog.error("You can't delete the default profile", LOG_NAME)
return false

# Delete the user profile
if not ModLoaderStore.user_profiles.erase(profile_name):
# Erase returns false if the the key is not present in user_profiles
ModLoaderLog.error("User profile with name \"%s\" not found." % profile_name, LOG_NAME)
return false

# Save profiles to the user profiles JSON file
var is_save_success := _save()

if is_save_success:
ModLoaderLog.debug("Deleted user profile \"%s\"" % profile_name, LOG_NAME)

return is_save_success


# Returns the current user profile
static func get_current() -> Profile:
return ModLoaderStore.user_profiles[ModLoaderStore.current_user_profile]


# Return the user profile with the given name
static func get_profile(profile_name: String) -> Profile:
if not ModLoaderStore.user_profiles.has(profile_name):
ModLoaderLog.error("User profile with name \"%s\" not found." % profile_name, LOG_NAME)
return null

return ModLoaderStore.user_profiles[profile_name]


# Returns an array containing all user profiles stored in ModLoaderStore
static func get_all_as_array() -> Array:
var user_profiles := []

for user_profile_name in ModLoaderStore.user_profiles.keys():
user_profiles.push_back(ModLoaderStore.user_profiles[user_profile_name])

return user_profiles


# Internal profile functions
# =============================================================================

# Update the global list of disabled mods based on the current user profile
# The user profile will override the disabled_mods property that can be set via the options resource in the editor.
# Example: If "Mod-TestMod" is set in disabled_mods via the editor, the mod will appear disabled in the user profile.
# If the user then enables the mod in the profile the entry in disabled_mods will be removed.
static func _update_disabled_mods() -> void:
var user_profile_disabled_mods := []
var current_user_profile: Profile

# Check if a current user profile is set
if ModLoaderStore.current_user_profile == "":
ModLoaderLog.info("There is no current user profile. The \"default\" profile will be created.", LOG_NAME)
return

current_user_profile = ModLoaderStore.user_profiles[ModLoaderStore.current_user_profile]

# Iterate through the mod list in the current user profile to find disabled mods
for mod_id in current_user_profile.mod_list:
if not current_user_profile.mod_list[mod_id]:
user_profile_disabled_mods.push_back(mod_id)

# Append the disabled mods to the global list of disabled mods
ModLoaderStore.ml_options.disabled_mods.append_array(user_profile_disabled_mods)

ModLoaderLog.debug(
"Updated the global list of disabled mods \"%s\", based on the current user profile \"%s\""
% [ModLoaderStore.ml_options.disabled_mods, current_user_profile.name],
LOG_NAME)


# This function updates the mod lists of all user profiles with newly loaded mods that are not already present.
# It does so by comparing the current set of loaded mods with the mod list of each user profile, and adding any missing mods.
# Additionally, it checks for and deletes any mods from each profile's mod list that are no longer installed on the system.
static func _update_mod_lists() -> bool:
var current_mod_list := {}

# Create a mod_list with the currently loaded mods
for mod_id in ModLoaderStore.mod_data.keys():
current_mod_list[mod_id] = true

# Add the deactivated mods to the list
for mod_id in ModLoaderStore.ml_options.disabled_mods:
current_mod_list[mod_id] = false

# Iterate over all user profiles
for profile_name in ModLoaderStore.user_profiles.keys():
var profile: Profile = ModLoaderStore.user_profiles[profile_name]

# Merge the profiles mod_list with the previously created current_mod_list
profile.mod_list.merge(current_mod_list)

# Delete no longer installed mods
for mod_id in profile.mod_list:
# Check if the mod_dir for the mod-id exists
if not _ModLoaderFile.dir_exists(ModLoader.UNPACKED_DIR + mod_id):
# if not the mod is no longer installed and can be removed
profile.mod_list.erase(mod_id)

# Save the updated user profiles to the JSON file
var is_save_success := _save()

if is_save_success:
ModLoaderLog.debug("Updated the mod lists of all user profiles", LOG_NAME)

return is_save_success


# Handles the activation or deactivation of a mod in a user profile.
static func _set_mod_state(mod_id: String, profile_name: String, activate: bool) -> bool:
# Verify whether the mod_id is present in the profile's mod_list.
if not _is_mod_id_in_mod_list(mod_id, profile_name):
return false

# Check if it is a locked mod
if ModLoaderStore.mod_data.has(mod_id) and ModLoaderStore.mod_data[mod_id].is_locked:
ModLoaderLog.error(
"Unable to disable mod \"%s\" as it is marked as locked. Locked mods: %s"
% [mod_id, ModLoaderStore.ml_options.locked_mods],
LOG_NAME)
return false

# Handle mod state
ModLoaderStore.user_profiles[profile_name].mod_list[mod_id] = activate

# Save profiles to the user profiles JSON file
var is_save_success := _save()

if is_save_success:
ModLoaderLog.debug("Mod activation state changed: mod_id=%s activate=%s profile_name=%s" % [mod_id, activate, profile_name], LOG_NAME)

return is_save_success


# Checks whether a given mod_id is present in the mod_list of the specified user profile.
# Returns True if the mod_id is present, False otherwise.
static func _is_mod_id_in_mod_list(mod_id: String, profile_name: String) -> bool:
# Get the user profile
var user_profile := get_profile(profile_name)
if not user_profile:
# Return false if there is an error getting the user profile
return false

# Return false if the mod_id is not in the profile's mod_list
if not user_profile.mod_list.has(mod_id):
ModLoaderLog.error("Mod id \"%s\" not found in the \"mod_list\" of user profile \"%s\"." % [mod_id, profile_name], LOG_NAME)
return false

# Return true if the mod_id is in the profile's mod_list
return true


# Creates a new Profile with the given name and mod list.
# Returns the newly created Profile object.
static func _create_new_profile(profile_name: String, mod_list: Dictionary) -> Profile:
var new_profile := Profile.new()

# If no name is provided, log an error and return null
if profile_name == "":
ModLoaderLog.error("Please provide a name for the new profile", LOG_NAME)
return null

# Set the profile name
new_profile.name = profile_name

# If no mods are specified in the mod_list, log a warning and return the new profile
if mod_list.keys().size() == 0:
ModLoaderLog.warning("No mod_ids inside \"mod_list\" for user profile \"%s\" " % profile_name, LOG_NAME)
return new_profile

# Set the mod_list
new_profile.mod_list = mod_list

return new_profile


# Loads user profiles from the JSON file and adds them to ModLoaderStore.
static func _load() -> bool:
# Load JSON data from the user profiles file
var data := _ModLoaderFile.get_json_as_dict(FILE_PATH_USER_PROFILES)

# If there is no data, log an error and return
if data.empty():
ModLoaderLog.error("No profile file found at \"%s\"" % FILE_PATH_USER_PROFILES, LOG_NAME)
return false

# Set the current user profile to the one specified in the data
ModLoaderStore.current_user_profile = data.current_profile

# Loop through each profile in the data and add them to ModLoaderStore
for profile_name in data.profiles.keys():
# Get the profile data from the JSON object
var profile_data: Dictionary = data.profiles[profile_name]

# Create a new profile object and add it to ModLoaderStore.user_profiles
var new_profile := _create_new_profile(profile_name, profile_data.mod_list)
ModLoaderStore.user_profiles[profile_name] = new_profile

return true


# Saves the user profiles in the ModLoaderStore to the user profiles JSON file.
static func _save() -> bool:
# Initialize a dictionary to hold the serialized user profiles data
var save_dict := {
"current_profile": "",
"profiles": {}
}

# Set the current profile name in the save_dict
save_dict.current_profile = ModLoaderStore.current_user_profile

# Serialize the mod_list data for each user profile and add it to the save_dict
for profile_name in ModLoaderStore.user_profiles.keys():
var profile: Profile = ModLoaderStore.user_profiles[profile_name]

save_dict.profiles[profile.name] = {}
save_dict.profiles[profile.name].mod_list = {}

# For each mod_id in the mod_list, add its ID and activation status to the save_dict
for mod_id in profile.mod_list:
var is_activated: bool = profile.mod_list[mod_id]
save_dict.profiles[profile.name].mod_list[mod_id] = is_activated

# Save the serialized user profiles data to the user profiles JSON file
return _ModLoaderFile.save_dictionary_to_json_file(save_dict, FILE_PATH_USER_PROFILES)
2 changes: 2 additions & 0 deletions addons/mod_loader/classes/mod_data.gd
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ var dir_path := ""
var is_loadable := true
# True if overwrites.gd exists
var is_overwrite := false
# True if mod can't be disabled or enabled in a user profile
var is_locked := false
# Is increased for every mod depending on this mod. Highest importance is loaded first
var importance := 0
# Contents of the manifest
Expand Down
1 change: 1 addition & 0 deletions addons/mod_loader/classes/options_profile.gd
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ extends Resource
# export (Array, Resource) var elites: = []

export (bool) var enable_mods = true
export (Array, String) var locked_mods = []
export (ModLoaderLog.VERBOSITY_LEVEL) var log_level := ModLoaderLog.VERBOSITY_LEVEL.DEBUG
export (Array, String) var disabled_mods = []
export (bool) var steam_workshop_enabled = false
Expand Down
16 changes: 16 additions & 0 deletions addons/mod_loader/mod_loader.gd
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,26 @@ func _init() -> void:
ModLoaderLog.info("Mods are currently disabled", LOG_NAME)
return

# Load user profiles into ModLoaderStore
var _success_user_profile_load := ModLoaderUserProfile._load()
# Update the list of disabled mods in ModLoaderStore based on the current user profile
ModLoaderUserProfile._update_disabled_mods()

_load_mods()

ModLoaderStore.is_initializing = false


func _ready():
# Create the default user profile if it doesn't exist already
# This should always be present unless the JSON file was manually edited
if not ModLoaderStore.user_profiles.has("default"):
var _success_user_profile_create := ModLoaderUserProfile.create_profile("default")

# Update the mod_list for each user profile
var _success_update_mod_lists := ModLoaderUserProfile._update_mod_lists()


func _load_mods() -> void:
# Loop over "res://mods" and add any mod zips to the unpacked virtual
# directory (UNPACKED_DIR)
Expand Down Expand Up @@ -420,6 +435,7 @@ func _init_mod_data(mod_folder_path: String) -> void:
mod.dir_name = dir_name
var mod_overwrites_path := mod.get_optional_mod_file_path(ModData.optional_mod_files.OVERWRITES)
mod.is_overwrite = _ModLoaderFile.file_exists(mod_overwrites_path)
mod.is_locked = true if dir_name in ModLoaderStore.ml_options.locked_mods else false
ModLoaderStore.mod_data[dir_name] = mod

# Get the mod file paths
Expand Down
Loading

0 comments on commit b9ad9a5

Please # to comment.