diff --git a/msu/hooks/states/world_state.nut b/msu/hooks/states/world_state.nut index 98a40d5c1..d3440aee5 100644 --- a/msu/hooks/states/world_state.nut +++ b/msu/hooks/states/world_state.nut @@ -197,26 +197,19 @@ q.onBeforeSerialize = @(__original) function( _out ) { __original(_out); - local meta = _out.getMetaData(); - local modIDsString = ""; - foreach (mod in ::MSU.System.Serialization.Mods) - { - meta.setString(mod.getID() + "Version", mod.getVersionString()); - ::MSU.Mod.Debug.printLog(format("MSU Serialization: Saving %s (%s), Version: %s", mod.getName(), mod.getID(), mod.getVersionString())); - } - foreach (mod in ::Hooks.getMods()) modIDsString += mod.getID() + ","; - meta.setString("MSU.SavedModIDs", modIDsString.slice(0, -1)); + ::MSU.Class.SavedModsInfo().saveToMetaData(_out.getMetaData()); } q.onBeforeDeserialize = @(__original) function( _in ) { __original(_in); + local modsInfo = _in.getMetaData().hasData(::MSU.Class.SavedModsInfo.MetaDataSavedIDsKey) ? ::MSU.Class.SavedModsInfo(_in.getMetaData()) : null; + if (::MSU.Mod.Serialization.isSavedVersionAtLeast("1.1.0", _in.getMetaData())) { - local modIDs = split(_in.getMetaData().getString("MSU.SavedModIDs"), ","); - local hooksMods = ::Hooks.getMods(); - foreach (mod in hooksMods) + local modIDs = modsInfo != null ? split(_in.getMetaData().getString(::MSU.Class.SavedModsInfo.MetaDataSavedIDsKey), ::MSU.Class.SavedModsInfo.ModIDsSeparator) : split(_in.getMetaData().getString("MSU.SavedModIDs"), ","); + foreach (mod in ::Hooks.getMods()) { local IDIdx = modIDs.find(mod.getID()); if (IDIdx != null) @@ -224,7 +217,16 @@ modIDs.remove(IDIdx); if (::MSU.System.Registry.hasMod(mod.getID())) { - local oldVersion = _in.getMetaData().getString(mod.getID() + "Version"); + local oldVersion; + if (modsInfo != null) + { + oldVersion = modsInfo.hasMod(mod.getID()) ? modsInfo.getMod(mod.getID()).getVersionString() : ""; + } + else + { + oldVersion = _in.getMetaData().getString(mod.getID() + "Version"); + } + if (oldVersion == "") { ::logInfo(format("MSU Serialization: First time this save has been loaded with an MSU version of %s (%s)", mod.getName(), mod.getID())); diff --git a/msu/hooks/ui/global/data_helper.nut b/msu/hooks/ui/global/data_helper.nut index 1218bc88c..9b0e9f54f 100644 --- a/msu/hooks/ui/global/data_helper.nut +++ b/msu/hooks/ui/global/data_helper.nut @@ -10,5 +10,20 @@ ::PersistenceManager.queryStorages = queryStorages; return ret; } + + q.convertCampaignStorageToUIData = @(__original) { function convertCampaignStorageToUIData( _meta ) + { + local ret = __original(_meta); + // ret.MSU_ModIncompatibility <- []; + + local modsInfo = ::MSU.Class.SavedModsInfo(_meta); + ret.MSU_ModIncompatibility <- modsInfo.validateMods(); + if (ret.MSU_ModIncompatibility.len() != 0) + { + ret.isIncompatibleVersion = true; + } + + return ret; + }}.convertCampaignStorageToUIData ; }); diff --git a/msu/msu_mod/msu_tooltips.nut b/msu/msu_mod/msu_tooltips.nut index 925192e47..3675528ee 100644 --- a/msu/msu_mod/msu_tooltips.nut +++ b/msu/msu_mod/msu_tooltips.nut @@ -1,4 +1,29 @@ ::MSU.Mod.Tooltips.setTooltips({ + LoadCampaign = ::MSU.Class.CustomTooltip(function( _data ) { + local ret = [ + { contentType = "settlement-status-effect"}, + { + id = 1, + type = "title", + text = "Mod Incompatibility" + }, + { + id = 2, + type = "description", + text = "Detected by MSU" + } + ]; + foreach (e in _data.errors) + { + ret.push({ + id = 2, + type = "text", + icon = "ui/icons/warning.png", + text = e + }); + } + return ret; + }), ModSettings = { Main = { Cancel = ::MSU.Class.BasicTooltip("Cancel", "Don't save changes."), diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut new file mode 100644 index 000000000..abbf77dfd --- /dev/null +++ b/msu/systems/serialization/saved_mods_info.nut @@ -0,0 +1,284 @@ +::MSU.Class.SavedModsInfo <- class +{ + static ModIDsSeparator = ","; + static ModInfoSeparator = "&&"; + static CompatInfoSeparator = "^^"; + static CompatModSeparator = ","; + static MetaDataSavedIDsKey = "MSU.SavedModsInfoIDs"; + static MetaDataSavedInfoPrefix = "MSU.SavedModInfo"; + // Used to pass the required _metadata arg in Hooks Mod constructor + static EmptyTable = {}; + + // Table + // Key: ModID + // Value: Instance of ::Hooks.SQClass.Mod + Mods = null; + + // Pass metadata to load saved mods info from that metadata. + constructor( _metadata = null ) + { + this.Mods = {}; + + if (_metadata == null) + return; + + local ids = _metadata.hasData(this.MetaDataSavedIDsKey) ? _metadata.getString(this.MetaDataSavedIDsKey) : ""; + if (ids == "") + { + this.__loadOldData(_metadata); + return; + } + + foreach (id in ::MSU.String.split(ids, this.ModIDsSeparator)) + { + this.Mods[id] <- this.__getModFromInfoString(_metadata.getString(this.MetaDataSavedInfoPrefix + id)); + } + } + + // Used to load saved mod data from MSU 1.8.0 and older. + function __loadOldData( _metadata ) + { + if (!_metadata.hasData("MSU.SavedModIDs")) + return; + + local ids = ::MSU.String.split(_metadata.getString("MSU.SavedModIDs"), ","); + foreach (id in ids) + { + this.Mods[id] <- ::Hooks.SQClass.Mod(id, _metadata.getString(id + "Version") == "" ? "1.0.0" : _metadata.getString(id + "Version"), "", this.EmptyTable); + } + } + + // Converts a saved string to a new string which can + // be passed to a HooksMod.require or .conflictWith function. + // _str must be formatted as follows: + // id,name,operator,version + // where "commas" represent this.CompatInfoSeparator. + // Return example: "mod_msu >= 1.8.0" + function __getCompatString( _str ) + { + local info = ::MSU.String.split(_str, this.CompatInfoSeparator); + local operator = info[2]; + local version = info[3]; + local name = info[1]; + return format("%s%s%s%s", info[0], operator == "x" ? "" : " " + this.__parseOperatorToString(operator.tointeger()) + " ", version == "x" ? "" : version, name == "x" ? "" : " (" + name + ")"); + } + + // Converts a mod to the following string: + // id,name,version,requirements,incompatibilities + // where "commas" represent this.CompatModSeparator. + function __getInfoStringFromMod( _mod ) + { + local reqStr = ""; + local conflictStr = ""; + foreach (data in ::Hooks.getMod(_mod.getID()).getCompatibilityData()) + { + local name = data.getModName(); + if (name == data.getModID() && ::Hooks.hasMod(data.getModID())) + { + name = ::Hooks.getMod(data.getModID()).getName(); + } + name = ::String.replace(name, "(", ""); + name = ::String.replace(name, ")", ""); + local str = format("%s%s%s%s%s%s%s", + data.getModID(), this.CompatInfoSeparator, + name, this.CompatInfoSeparator, + data.Operator == null ? "x" : data.Operator + "", this.CompatInfoSeparator, + data.Version == null ? "x" : data.Version + ""); + switch (data.CompatibilityType) + { + case ::Hooks.CompatibilityType.Requirement: + reqStr += str + this.CompatModSeparator; + break; + case ::Hooks.CompatibilityType.Incompatibility: + conflictStr += str + this.CompatModSeparator; + break; + } + } + return format("%s%s%s%s%s%s%s%s%s", + _mod.getID(), this.ModInfoSeparator, + _mod.getName(), this.ModInfoSeparator, + _mod.getVersionString(), this.ModInfoSeparator, + reqStr == "" ? "x" : reqStr.slice(0, -this.CompatModSeparator.len()), this.ModInfoSeparator, + conflictStr == "" ? "x" : conflictStr.slice(0, -this.CompatModSeparator.len())); + } + + // Creates and returns a Hooks.SQClass.Mod instance from the info string. + // _str must be formatted as follows: + // id,name,version,requirements,incompatibilities + // where "commas" represent this.CompatModSeparator. + function __getModFromInfoString( _str ) + { + local info = ::MSU.String.split(_str, this.ModInfoSeparator); + + local ret = ::Hooks.SQClass.Mod(info[0], info[2], info[1], this.EmptyTable); + if (info[3] != "x") + { + foreach (req in ::MSU.String.split(info[3], this.CompatModSeparator)) + { + ret.require(this.__getCompatString(req)); + } + } + if (info[4] != "x") + { + foreach (conflict in ::MSU.String.split(info[4], this.CompatModSeparator)) + { + ret.conflictWith(this.__getCompatString(conflict)); + } + } + + return ret; + } + + function getMods() + { + return this.Mods; + } + + function hasMod( _id ) + { + return _id in this.Mods; + } + + function getMod( _id ) + { + return this.Mods[_id]; + } + + // Mods can use this to clear compatibility data from existing save for a mod. + // Use Case: You create a compatibility patch for 2 mods + // and want to remove the incompatibility declaration from the saved mod. + // Use Case: You create a fork of a mod and want different compatibility data. + // How to use: After clearing the compatibility data, you should define your own for that mod using + // getMod(_id) and then using the Modern Hooks .require and .conflictWith functions on that mod. + function clearCompatibilityData( _sourceModID, _targetModID ) + { + local sourceMod = this.getMod(_sourceModID); + for (local i = sourceMod.CompatibilityData.len() - 1; i >= 0; i--) + { + if (sourceMod.CompatibilityData[i].getModID() == _targetModID) + { + sourceMod.CompatibilityData.remove(i); + } + } + } + + function validateMods() + { + // We allow mods to adjust the compatibility data before we validate it. + ::MSU.System.Serialization.onValidateSavedMods(this); + + local compatErrors = []; + + // Check compatibility of saved mods with installed mods. + foreach (mod in this.getMods()) + { + foreach (compatibilityData in mod.getCompatibilityData()) + { + local result = compatibilityData.validate(::Hooks.getMods()); + if (result == ::Hooks.CompatibilityCheckResult.Success) + continue; + compatErrors.push({ + Source = mod, + Target = compatibilityData, + Reason = result, + IsTargetSaved = false + }); + } + } + + // Check compatibility of installed mods with saved mods. + // For this we only look at CompatibilityType.Incompatibility + // because for "required" mods you won't even be able to load + // into main menu if you are missing required mods. + foreach (mod in ::Hooks.getMods()) + { + foreach (compatibilityData in mod.getCompatibilityData()) + { + // We allow "requiring" your own mod in save games. This is a method for + // modders to declare mods that are unsafe to add to existing runs. + if (compatibilityData.CompatibilityType != ::Hooks.CompatibilityType.Incompatibility && compatibilityData.getModID() != mod.getID()) + continue; + + local result = compatibilityData.validate(this.getMods()); + if (result == ::Hooks.CompatibilityCheckResult.Success) + continue; + compatErrors.push({ + Source = mod, + Target = compatibilityData, + Reason = result, + IsTargetSaved = true + }); + } + } + + if (compatErrors.len() == 0) + return compatErrors; + + foreach (i, error in compatErrors) + { + local requireString = error.Target.CompatibilityType == ::Hooks.CompatibilityType.Requirement ? "requires" : "conflicts with"; + switch (error.Reason) + { + case ::Hooks.CompatibilityCheckResult.ModMissing: + local name = error.Target.getModID() in ::Hooks.CachedModNames ? ::Hooks.CachedModNames[error.Target.getModID()] : error.Target.getModName(); + compatErrors[i] = format("%s %s (%s) %s %s %s (%s)%s", error.IsTargetSaved ? "Installed" : "Saved", error.Source.getID(), error.Source.getName(), requireString, error.IsTargetSaved ? "Saved" : "Installed", error.Target.getModID(), name, error.Target.getFormattedDetails()); + break; + case ::Hooks.CompatibilityCheckResult.ModPresent: + local mod = error.IsTargetSaved ? this.getMod(error.Target.getModID()) : ::Hooks.getMod(error.Target.getModID()); + compatErrors[i] = format("%s %s (%s) is incompatible with %s %s (%s)%s", error.IsTargetSaved ? "Saved" : "Installed", error.Source.getID(), error.Source.getName(), error.IsTargetSaved ? "Installed" : "Saved", mod.getID(), mod.getName(), error.Target.getFormattedDetails()); + break; + case ::Hooks.CompatibilityCheckResult.TooSmall: + local mod = error.IsTargetSaved ? this.getMod(error.Target.getModID()) : ::Hooks.getMod(error.Target.getModID()); + compatErrors[i] = format("%s %s (%s) version %s is outdated for %s %s (%s), which %s versions %s", error.IsTargetSaved ? "Saved" : "Installed", mod.getID(), mod.getName(), mod.getVersionString(), error.IsTargetSaved ? "Installed" : "Saved", error.Source.getID(), error.Source.getName(), requireString, error.Target.getErrorString()); + break; + case ::Hooks.CompatibilityCheckResult.TooBig: + local mod = error.IsTargetSaved ? this.getMod(error.Target.getModID()) : ::Hooks.getMod(error.Target.getModID()); + compatErrors[i] = format("%s %s (%s) version %s is too new for %s %s (%s), which %s versions %s", error.IsTargetSaved ? "Saved" : "Installed", mod.getID(), mod.getName(), mod.getVersionString(), error.IsTargetSaved ? "Installed" : "Saved", error.Source.getID(), error.Source.getName(), requireString, error.Target.getErrorString()); + break; + case ::Hooks.CompatibilityCheckResult.Incorrect: + local mod = error.IsTargetSaved ? this.getMod(error.Target.getModID()) : ::Hooks.getMod(error.Target.getModID()); + compatErrors[i] = format("%s %s (%s) version %s is wrong for %s %s (%s), which %s (a) version %s", error.IsTargetSaved ? "Saved" : "Installed", mod.getID(), mod.getName(), mod.getVersionString(), error.IsTargetSaved ? "Installed" : "Saved", error.Source.getID(), error.Source.getName(), requireString, error.Target.getErrorString()); + break; + } + } + + return compatErrors; + } + + function __parseOperatorToString( _operator ) + { + switch (_operator) + { + case ::Hooks.Operator.LT: + return "<"; + case ::Hooks.Operator.LE: + return "<="; + case ::Hooks.Operator.EQ: + return "=="; + case ::Hooks.Operator.NE: + return "!="; + case ::Hooks.Operator.GE: + return ">="; + case ::Hooks.Operator.GT: + return ">"; + } + return ""; + } + + function saveToMetaData( _metadata ) + { + local modIds = ""; + foreach (mod in ::Hooks.getMods()) + { + ::MSU.Mod.Debug.printLog(format("MSU Serialization: Saving %s (%s), Version: %s", mod.getName(), mod.getID(), mod.getVersionString())); + + modIds += mod.getID() + this.ModIDsSeparator; + _metadata.setString(this.MetaDataSavedInfoPrefix + mod.getID(), this.__getInfoStringFromMod(mod)); + } + if (modIds != "") + { + modIds.slice(0, -this.ModIDsSeparator.len()); + } + _metadata.setString(this.MetaDataSavedIDsKey, modIds); + } +} diff --git a/msu/systems/serialization/serialization_mod_addon.nut b/msu/systems/serialization/serialization_mod_addon.nut index af1a9109f..7a036269e 100644 --- a/msu/systems/serialization/serialization_mod_addon.nut +++ b/msu/systems/serialization/serialization_mod_addon.nut @@ -2,10 +2,20 @@ { function isSavedVersionAtLeast( _version, _metaData ) { - local savedVersion = _metaData.getString(this.Mod.getID() + "Version"); + local savedVersion = ""; + local info = ::MSU.Class.SavedModsInfo(_metaData); + if (info.hasMod(this.Mod.getID())) + { + savedVersion = info.getMod(this.Mod.getID()).getVersionString(); + } + return savedVersion != "" && ::MSU.SemVer.compareVersionWithOperator(savedVersion, ">=", _version); } + function onValidateSavedMods( _modsInfo ) + { + } + function flagSerialize( _id, _object, _flags = null ) { ::MSU.System.Serialization.flagSerialize(this.Mod, _id, _object, _flags); diff --git a/msu/systems/serialization/serialization_system.nut b/msu/systems/serialization/serialization_system.nut index 04e1898d7..dbe25a333 100644 --- a/msu/systems/serialization/serialization_system.nut +++ b/msu/systems/serialization/serialization_system.nut @@ -20,6 +20,14 @@ _mod.Serialization = ::MSU.Class.SerializationModAddon(_mod); } + function onValidateSavedMods( _modsInfo ) + { + foreach (mod in this.Mods) + { + mod.Serialization.onValidateSavedMods(_modsInfo); + } + } + function flagSerialize( _mod, _id, _object, _flags = null ) { if (::MSU.isBBObject(_object)) diff --git a/msu/utils/string.nut b/msu/utils/string.nut index 895ddd6be..a94a1fcbe 100644 --- a/msu/utils/string.nut +++ b/msu/utils/string.nut @@ -34,4 +34,32 @@ { return _end.len() <= _string.len() && _string.slice(-_end.len()) == _end; } + + function split( _string, _delimiter, _skipEmpty = true ) + { + if (_string == "") + return []; + + if (_delimiter.len() == 1 && _skipEmpty) + return ::split(_string, _delimiter); + + local ret = []; + local idx; + while ((idx = _string.find(_delimiter)) != null) + { + local val = _string.slice(0, idx); + if (!_skipEmpty || val != "") + { + ret.push(val); + } + _string = _string.slice(idx + _delimiter.len()); + } + + if (!_skipEmpty || _string != "") + { + ret.push(_string); + } + + return ret; + } } diff --git a/ui/mods/msu/ui_hooks/load_campaign_menu_module.js b/ui/mods/msu/ui_hooks/load_campaign_menu_module.js new file mode 100644 index 000000000..4a039a79f --- /dev/null +++ b/ui/mods/msu/ui_hooks/load_campaign_menu_module.js @@ -0,0 +1,12 @@ +MSU.Hooks.LoadCampaignMenuModule_addCampaignEntryToList = LoadCampaignMenuModule.prototype.addCampaignEntryToList; +LoadCampaignMenuModule.prototype.addCampaignEntryToList = function (_data) +{ + MSU.Hooks.LoadCampaignMenuModule_addCampaignEntryToList.call(this, _data); + if (_data.isIncompatibleVersion && _data.MSU_ModIncompatibility.length !== 0) + { + var entry = this.mListScrollContainer.find('.ui-control.campaign:last'); + console.error("binding tooltip to " + entry); + entry.bindTooltip({ contentType: "msu-generic", modId: MSU.ID, elementId: "LoadCampaign", errors: _data.MSU_ModIncompatibility }) + entry.assignListCampaignDayName("- Incompatible Version / DLC Missing / Mod Conflict -"); + } +};