From 2fd8bea133257722281087848a3d9d059d5540e1 Mon Sep 17 00:00:00 2001 From: cyberrumor Date: Mon, 5 Feb 2024 10:13:48 -0800 Subject: [PATCH] Display an asterisk by conflicting mods Enabled mods that have conflicting files now display an asterisk in the "activated" column. --- ammo/component.py | 1 + ammo/fomod_controller.py | 8 +- ammo/mod_controller.py | 63 +++++++---- test/test_conflict_resolution.py | 189 +++++++++++++++++++++++++++---- 4 files changed, 214 insertions(+), 47 deletions(-) diff --git a/ammo/component.py b/ammo/component.py index 06ab1d3..d1b600a 100755 --- a/ammo/component.py +++ b/ammo/component.py @@ -36,6 +36,7 @@ class Mod: install_dir: Path = field(init=False) fomod: bool = field(init=False, default=False) enabled: bool = field(init=False, default=False) + conflict: bool = field(init=False, default=False) files: list[Path] = field(default_factory=list, init=False) plugins: list[str] = field(default_factory=list, init=False) name: str = field(default_factory=str, init=False) diff --git a/ammo/fomod_controller.py b/ammo/fomod_controller.py index 6e25cc7..9db7fc0 100755 --- a/ammo/fomod_controller.py +++ b/ammo/fomod_controller.py @@ -168,7 +168,7 @@ def _get_pages(self) -> list[Page]: visibility_conditions=visibility_conditions, ) - for plugin_index, plugin in enumerate(group_of_plugins): + for i, plugin in enumerate(group_of_plugins): name = plugin.get("name").strip() description = plugin.findtext("description", default="").strip() flags = {} @@ -176,7 +176,7 @@ def _get_pages(self) -> list[Page]: # a selection is required. selected = ( page.archtype in ["SelectExactlyOne", "SelectAtLeastOne"] - ) and plugin_index == 0 + ) and i == 0 # Interpret on/off or 1/0 as true/false if conditional_flags := plugin.find("conditionFlags"): @@ -194,9 +194,7 @@ def _get_pages(self) -> list[Page]: # unconditional install. conditional = False - files = [] - if plugin_files := plugin.find("files"): - files.extend(plugin_files) + files = plugin.find("files") or [] page.selections.append( Selection( diff --git a/ammo/mod_controller.py b/ammo/mod_controller.py index f9c94b0..fe8faad 100755 --- a/ammo/mod_controller.py +++ b/ammo/mod_controller.py @@ -218,6 +218,7 @@ def __init__(self, downloads_dir: Path, game: Game, *keywords): self.downloads = downloads self.changes = False self.find(*self.keywords) + self._stage() def __str__(self) -> str: """ @@ -230,22 +231,28 @@ def __str__(self) -> str: for i, download in enumerate(self.downloads): if download.visible: - index = f"[{i}]" - result += f"{index:<7} {download.name}\n" + priority = f"[{i}]" + result += f"{priority:<7} {download.name}\n" result += "\n" - for index, components in enumerate([self.mods, self.plugins]): - result += ( - f" index | Activated | {'Mod name' if index == 0 else 'Plugin name'}\n" - ) - result += "-------|-----------|------------\n" - for i, component in enumerate(components): - if component.visible: - priority = f"[{i}]" - enabled = f"[{component.enabled}]" - result += f"{priority:<7} {enabled:<11} {component.name}\n" - if index == 0: - result += "\n" + result += " index | Activated | Mod name\n" + result += "-------|-----------|------------\n" + for i, mod in enumerate(self.mods): + if mod.visible: + priority = f"[{i}]" + enabled = f"[{mod.enabled}]" + conflict = "*" if mod.conflict else " " + result += f"{priority:<7} {enabled:<9} {conflict:<1} {mod.name}\n" + + result += "\n" + result += " index | Activated | Plugin name\n" + result += "-------|-----------|------------\n" + for i, plugin in enumerate(self.plugins): + if plugin.visible: + priority = f"[{i}]" + enabled = f"[{plugin.enabled}]" + result += f"{priority:<7} {enabled:<11} {plugin.name}\n" + return result def _prompt(self): @@ -403,12 +410,16 @@ def _set_component_state(self, component: ComponentEnum, index: int, state: bool def _stage(self) -> dict: """ - Returns a dict containing the final symlinks that will be installed. + Responsible for assigning mod.conflict for the staged configuration. + Returns a dict containing the final symlinks that would be installed. """ # destination: (mod_name, source) result = {} # Iterate through enabled mods in order. - for mod in (i for i in self.mods if i.enabled): + for mod in self.mods: + mod.conflict = False + enabled_mods = [i for i in self.mods if i.enabled] + for index, mod in enumerate(enabled_mods): # Iterate through the source files of the mod for src in mod.files: # Get the sanitized full path relative to the game.directory. @@ -420,8 +431,15 @@ def _stage(self) -> dict: dest = mod.install_dir / corrected_name # Add the sanitized full path to the stage, resolving - # conflicts. + # conflicts. Record whether a mod conflicting files. dest = normalize(dest, self.game.directory) + if dest in result: + conflicting_mod = [ + i for i in enabled_mods[:index] if i.name == result[dest][0] + ] + if conflicting_mod and conflicting_mod[0].enabled: + mod.conflict = True + conflicting_mod[0].conflict = True result[dest] = (mod.name, src) return result @@ -514,6 +532,7 @@ def activate(self, component: ComponentEnum, index: Union[int, str]) -> None: except IndexError as e: # Demote IndexErrors raise Warning(e) + self._stage() def deactivate(self, component: ComponentEnum, index: Union[int, str]) -> None: """ @@ -535,6 +554,7 @@ def deactivate(self, component: ComponentEnum, index: Union[int, str]) -> None: except IndexError as e: # Demote IndexErrors raise Warning(e) + self._stage() def sort(self) -> None: """ @@ -825,10 +845,10 @@ def install_download(index, download) -> None: if index == "all": errors = [] - for index, download in enumerate(self.downloads): + for i, download in enumerate(self.downloads): if download.visible: try: - install_download(index, download) + install_download(i, download) except Warning as e: errors.append(str(e)) if errors: @@ -866,6 +886,7 @@ def move(self, component: ComponentEnum, index: int, new_index: int) -> None: comp = components.pop(index) components.insert(new_index, comp) self.changes = True + self._stage() def commit(self) -> None: """ @@ -877,7 +898,7 @@ def commit(self) -> None: count = len(stage) skipped_files = [] - for index, (dest, source) in enumerate(stage.items()): + for i, (dest, source) in enumerate(stage.items()): (name, src) = source assert dest.is_absolute() assert src.is_absolute() @@ -890,7 +911,7 @@ def commit(self) -> None: {str(dest).split(str(self.game.directory))[-1].lstrip('/')}." ) finally: - print(f"files processed: {index+1}/{count}", end="\r", flush=True) + print(f"files processed: {i+1}/{count}", end="\r", flush=True) warn = "" for skipped_file in skipped_files: diff --git a/test/test_conflict_resolution.py b/test/test_conflict_resolution.py index c8207e2..d5ad078 100755 --- a/test/test_conflict_resolution.py +++ b/test/test_conflict_resolution.py @@ -2,7 +2,11 @@ import os from pathlib import Path -from common import AmmoController +from common import ( + AmmoController, + install_mod, + extract_mod, +) from ammo.component import ( ComponentEnum, DeleteEnum, @@ -17,10 +21,7 @@ def test_duplicate_plugin(): with AmmoController() as controller: # Install both mods for mod in ["conflict_1", "conflict_2"]: - mod_index_download = [i.name for i in controller.downloads].index( - mod + ".7z" - ) - controller.install(mod_index_download) + extract_mod(controller, mod) mod_index = [i.name for i in controller.mods].index(mod) @@ -46,10 +47,7 @@ def test_conflict_resolution(): with AmmoController() as controller: # Install both mods for mod in ["conflict_1", "conflict_2"]: - mod_index_download = [i.name for i in controller.downloads].index( - mod + ".7z" - ) - controller.install(mod_index_download) + extract_mod(controller, mod) mod_index = [i.name for i in controller.mods].index(mod) @@ -108,10 +106,7 @@ def test_conflicting_plugins_disable(): with AmmoController() as controller: # Install both mods for mod in ["conflict_1", "conflict_2"]: - mod_index_download = [i.name for i in controller.downloads].index( - mod + ".7z" - ) - controller.install(mod_index_download) + extract_mod(controller, mod) mod_index = [i.name for i in controller.mods].index(mod) @@ -161,10 +156,7 @@ def test_conflicting_plugins_delete(): with AmmoController() as controller: # Install both mods for mod in ["conflict_1", "conflict_2"]: - mod_index_download = [i.name for i in controller.downloads].index( - mod + ".7z" - ) - controller.install(mod_index_download) + extract_mod(controller, mod) mod_index = [i.name for i in controller.mods].index(mod) controller.activate(ComponentEnum.MOD, mod_index) controller.commit() @@ -182,10 +174,7 @@ def test_conflicting_plugins_delete_plugin(): """ with AmmoController() as controller: for mod in ["conflict_1", "conflict_2"]: - mod_index_download = [i.name for i in controller.downloads].index( - mod + ".7z" - ) - controller.install(mod_index_download) + extract_mod(controller, mod) mod_index = [i.name for i in controller.mods].index(mod) controller.activate(ComponentEnum.MOD, mod_index) controller.commit() @@ -202,3 +191,161 @@ def test_conflicting_plugins_delete_plugin(): assert ( len(controller.plugins) == 0 ), "A plugin provided by multiple mods came back from the grave!" + + +def test_conflicting_mods_have_conflict_flag_after_install(): + """ + Test that only conflicting mods have mod.conflict set to True + after install. + """ + with AmmoController() as controller: + for mod in ["conflict_1", "conflict_2", "normal_mod"]: + install_mod(controller, mod) + + assert ( + controller.mods[ + [i.name for i in controller.mods].index("conflict_1") + ].conflict + is True + ) + assert ( + controller.mods[ + [i.name for i in controller.mods].index("conflict_2") + ].conflict + is True + ) + assert ( + controller.mods[ + [i.name for i in controller.mods].index("normal_mod") + ].conflict + is False + ) + + +def test_conflicting_mods_have_conflict_flag_after_move(): + """ + Test that only conflicting mods have mod.conflict set to True + after move. + """ + with AmmoController() as controller: + for mod in ["conflict_1", "conflict_2", "normal_mod"]: + install_mod(controller, mod) + + controller.move(ComponentEnum.MOD, 2, 0) + + assert ( + controller.mods[ + [i.name for i in controller.mods].index("conflict_1") + ].conflict + is True + ) + assert ( + controller.mods[ + [i.name for i in controller.mods].index("conflict_2") + ].conflict + is True + ) + assert ( + controller.mods[ + [i.name for i in controller.mods].index("normal_mod") + ].conflict + is False + ) + + +def test_conflicting_mods_have_conflict_flag_after_actviate(): + """ + Test that only conflicting mods have mod.conflict set to True + after activate. + """ + with AmmoController() as controller: + for mod in ["conflict_1", "conflict_2", "normal_mod"]: + extract_mod(controller, mod) + + controller.activate(ComponentEnum.MOD, 0) + controller.activate(ComponentEnum.MOD, 1) + controller.activate(ComponentEnum.MOD, 2) + + assert ( + controller.mods[ + [i.name for i in controller.mods].index("conflict_1") + ].conflict + is True + ) + assert ( + controller.mods[ + [i.name for i in controller.mods].index("conflict_2") + ].conflict + is True + ) + assert ( + controller.mods[ + [i.name for i in controller.mods].index("normal_mod") + ].conflict + is False + ) + + +def test_conflicting_mods_have_conflict_flag_after_deactivate(): + """ + Test that only conflicting mods have mod.conflict set to True + after deactivate. + """ + with AmmoController() as controller: + for mod in ["conflict_1", "conflict_2", "normal_mod"]: + install_mod(controller, mod) + + controller.deactivate(ComponentEnum.MOD, 3) + + assert ( + controller.mods[ + [i.name for i in controller.mods].index("conflict_1") + ].conflict + is True + ) + assert ( + controller.mods[ + [i.name for i in controller.mods].index("conflict_2") + ].conflict + is True + ) + assert ( + controller.mods[ + [i.name for i in controller.mods].index("normal_mod") + ].conflict + is False + ) + + +def test_conflicting_mods_only_conflict_when_activated(): + """ + Test that only activated mods are considered when determining conflicts. + """ + with AmmoController() as controller: + for mod in ["conflict_1", "conflict_2"]: + extract_mod(controller, mod) + + assert controller.mods[0].conflict is False + assert controller.mods[1].conflict is False + + controller.activate(ComponentEnum.MOD, 0) + assert controller.mods[0].conflict is False + assert controller.mods[1].conflict is False + + controller.activate(ComponentEnum.MOD, 1) + assert controller.mods[0].conflict is True + assert controller.mods[1].conflict is True + + +def test_conflicting_mods_conflict_after_rename(): + """ + Test that conflicting mods still conflict after rename + """ + with AmmoController() as controller: + for mod in ["conflict_1", "conflict_2"]: + install_mod(controller, mod) + + controller.rename(RenameEnum.MOD, 0, "new_name") + + assert controller.mods[0].conflict is True + assert controller.mods[1].conflict is True