Skip to content

Commit

Permalink
Further generalizations to controller.{mod,fomod}
Browse files Browse the repository at this point in the history
Previously, controller.fomod relied on bethesda-specific data, like the
presence of a data directory. In order to remove this dependency,
refactor controller.fomod so it relies on a new mod.fomod_target dir
instead of the ammo_fomod/Data dir.

Add a BethesdaMod dataclass so Bethesda fomods and other fomods can have
a different fomod_target directory. This is needed since all Bethesda
fomods will expect files which are output of fomod configuration wizards
to be deployed under the game's Data directory. Non-bethesda games will
simply deploy to the game's base directory.

Remove unused enums from ammo/component.py.

controller.fomod should be responsible for writing to <mod>/ammo_fomod,
and controller.mod should be responsible for reading <mod>/ammo_fomod.
As such, the responsibility of deleting that directory was moved to
controller.fomod.
  • Loading branch information
cyberrumor committed Dec 13, 2024
1 parent 7eacb02 commit 5d92a5c
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 130 deletions.
109 changes: 68 additions & 41 deletions ammo/component.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,76 @@
#!/usr/bin/env python3
import os
from typing import Union
from enum import Enum
from pathlib import Path
from dataclasses import (
dataclass,
field,
)


class Component(str, Enum):
MOD = "mod"
DOWNLOAD = "download"


class BethesdaComponent(str, Enum):
MOD = "mod"
DOWNLOAD = "download"
PLUGIN = "plugin"


class BethesdaComponentActivatable(str, Enum):
MOD = "mod"
PLUGIN = "plugin"


@dataclass(slots=True, kw_only=True)
class Mod:
# Generic attributes
location: Path
game_root: Path
game_data: Path
name: str = field(default_factory=str, init=False)
visible: bool = field(init=False, default=True, compare=False)
install_dir: Path = field(init=False)
enabled: bool = field(init=False, default=False)
conflict: bool = field(init=False, default=False)
obsolete: bool = field(init=False, default=True)
files: list[Path] = field(default_factory=list, init=False)
# Bethesda attributes
modconf: Union[None, Path] = field(init=False, default=None)
fomod: bool = field(init=False, default=False)
fomod_target: Path = field(init=False, default=False)

def __post_init__(self) -> None:
self.name = self.location.name
self.install_dir = self.game_root
self.fomod_target = Path("ammo_fomod")

# Explicitly set self.files to an empty list in case we're rereshing
# files via manually calling __post_init__.
self.files = []
# Scan the surface level of the mod to determine whether this is a fomod.
for file in self.location.iterdir():
if file.is_dir() and file.name.lower() == "fomod":
# Assign ModuleConfig.xml. Only check surface of fomod folder.
for f in file.iterdir():
if f.name.lower() == "moduleconfig.xml" and f.is_file():
self.modconf = f
self.fomod = True
self.install_dir = self.game_root
break
if self.fomod:
break

# Determine which folder to populate self.files from. For fomods, only
# care about files inside of an ammo_fomod folder.
location = self.location
if self.fomod:
location /= "ammo_fomod"

if not location.exists():
# No files to populate
return

# Populate self.files
for parent_dir, _, files in os.walk(location):
for file in files:
f = Path(file)
loc_parent = Path(parent_dir)
self.files.append(loc_parent / f)


@dataclass(kw_only=True, slots=True)
class BethesdaMod(Mod):
game_data: Path
plugins: list[str] = field(default_factory=list, init=False)

def __post_init__(self) -> None:
self.name = self.location.name
self.install_dir = self.game_data
self.fomod_target = Path("ammo_fomod") / self.game_data.name
# Explicitly set self.files to an empty list in case we're rereshing
# files via manually calling __post_init__.
self.files = []
Expand Down Expand Up @@ -75,32 +100,34 @@ def __post_init__(self) -> None:
self.install_dir = self.game_root

# Determine which folder to populate self.files from. For fomods, only
# care about files inside of an ammo_fomod/self.game_data.name folder
# care about files inside of an ammo_fomod folder
# which may or may not exist.
location = self.location
if self.fomod:
location /= "ammo_fomod"

# Populate self.files
if location.exists():
for parent_dir, _, files in os.walk(location):
for file in files:
f = Path(file)
loc_parent = Path(parent_dir)
self.files.append(loc_parent / f)

# populate plugins
plugin_dir = location

for i in location.iterdir():
if i.name.lower() == self.game_data.name.lower():
plugin_dir /= i.name
break

if plugin_dir.exists():
for f in plugin_dir.iterdir():
if f.suffix.lower() in (".esp", ".esl", ".esm") and not f.is_dir():
self.plugins.append(f)
if not location.exists():
# No files to populate
return

for parent_dir, _, files in os.walk(location):
for file in files:
f = Path(file)
loc_parent = Path(parent_dir)
self.files.append(loc_parent / f)

# populate plugins
plugin_dir = location

for i in location.iterdir():
if i.name.lower() == self.game_data.name.lower():
plugin_dir /= i.name
break

if plugin_dir.exists():
for f in plugin_dir.iterdir():
if f.suffix.lower() in (".esp", ".esl", ".esm") and not f.is_dir():
self.plugins.append(f)


@dataclass(kw_only=True, slots=True)
Expand Down
38 changes: 33 additions & 5 deletions ammo/controller/bethesda.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@
dataclass,
field,
)
from ammo.component import (
BethesdaMod,
Download,
Plugin,
)
from .mod import (
ModController,
Game,
)
from ammo.component import (
Mod,
Download,
Plugin,
from ammo.lib import (
NO_EXTRACT_DIRS,
)

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -164,6 +167,19 @@ def __init__(self, downloads_dir: Path, game: Game, *keywords):
self.do_find(*self.keywords)
self.stage()

def get_mods(self):
# Instance a Mod class for each mod folder in the mod directory.
mods = []
mod_folders = [i for i in self.game.ammo_mods_dir.iterdir() if i.is_dir()]
for path in mod_folders:
mod = BethesdaMod(
location=self.game.ammo_mods_dir / path.name,
game_root=self.game.directory,
game_data=self.game.data,
)
mods.append(mod)
return mods

def __str__(self) -> str:
"""
Output a string representing all downloads, mods and plugins.
Expand Down Expand Up @@ -266,6 +282,18 @@ def save_order(self):
for mod in self.mods:
file.write(f"{'*' if mod.enabled else ''}{mod.name}\n")

def has_extra_folder(self, path) -> bool:
files = list(path.iterdir())
return all(
[
len(files) == 1,
files[0].is_dir(),
files[0].name.lower() != self.game.data.name.lower(),
files[0].name.lower() not in NO_EXTRACT_DIRS,
files[0].suffix.lower() not in [".esp", ".esl", ".esm"],
]
)

def set_mod_state(self, index: int, desired_state: bool):
"""
Activate or deactivate a mod.
Expand Down Expand Up @@ -429,7 +457,7 @@ def do_find(self, *keyword: str) -> None:
component.visible = False

# Hack to filter by fomods
if kw.lower() == "fomods" and isinstance(component, Mod):
if kw.lower() == "fomods" and isinstance(component, BethesdaMod):
if component.fomod:
component.visible = True

Expand Down
26 changes: 17 additions & 9 deletions ammo/controller/fomod.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
from xml.etree import ElementTree
from functools import reduce
from ammo.ui import Controller
from ammo.component import Mod
from ammo.component import (
Mod,
BethesdaMod,
)
from ammo.lib import normalize


Expand Down Expand Up @@ -55,8 +58,14 @@ class Page:


class FomodController(Controller):
def __init__(self, mod: Mod):
self.mod: Mod = mod
def __init__(self, mod: Mod | BethesdaMod):
self.mod: Mod | BethesdaMod = mod

# Clean up previous configuration, if it exists.
try:
shutil.rmtree(self.mod.location / "ammo_fomod")
except FileNotFoundError:
pass

# Parse the fomod installer.
try:
Expand Down Expand Up @@ -375,11 +384,11 @@ def install_files(self, selected_nodes: list) -> None:
Copy the chosen files 'selected_nodes' from given mod at 'index'
to that mod's game files folder.
"""
data = self.mod.location / "ammo_fomod" / self.mod.game_data.name
ammo_fomod = self.mod.location / self.mod.fomod_target

# delete the old configuration if it exists.
shutil.rmtree(data, ignore_errors=True)
Path.mkdir(data, parents=True, exist_ok=True)
shutil.rmtree(ammo_fomod, ignore_errors=True)
Path.mkdir(ammo_fomod, parents=True, exist_ok=True)

stage = {}
for node in selected_nodes:
Expand All @@ -405,13 +414,12 @@ def install_files(self, selected_nodes: list) -> None:
full_destination = reduce(
lambda path, name: path / name,
node.get("destination").split("\\"),
data,
ammo_fomod,
)

# TODO: this is broken :)
# Normalize the capitalization of folder names

full_destination = normalize(full_destination, data.parent)
full_destination = normalize(full_destination, ammo_fomod.parent)

# Handle the mod's file conflicts that are caused by itself.
# There's technically a priority clause in the fomod spec that
Expand Down
Loading

0 comments on commit 5d92a5c

Please sign in to comment.