mirror of
https://github.com/ModOrganizer2/modorganizer-basic_games.git
synced 2026-07-27 14:07:29 -07:00
435 lines
15 KiB
Python
435 lines
15 KiB
Python
# -*- encoding: utf-8 -*-
|
|
|
|
from __future__ import annotations
|
|
|
|
import itertools
|
|
import re
|
|
import shutil
|
|
from collections.abc import Collection, Container, Iterable, Mapping, Sequence
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional, TextIO, Union
|
|
|
|
from PyQt5.QtCore import QDir
|
|
|
|
import mobase
|
|
|
|
from ..basic_features import BasicModDataChecker
|
|
from ..basic_features.basic_save_game_info import BasicGameSaveGame
|
|
from ..basic_game import BasicGame
|
|
|
|
|
|
def move_file(source: Path, target: Path):
|
|
"""Move `source` to `target`. Creates missing (parent) directories and
|
|
overwrites existing `target`."""
|
|
if not target.parent.exists():
|
|
target.parent.mkdir(parents=True)
|
|
shutil.move(str(source.resolve()), str(target.resolve()))
|
|
|
|
|
|
@dataclass
|
|
class PartialMatch:
|
|
partial_match_regex: re.Pattern = re.compile(r"[A-Z]?[a-z]+")
|
|
"""Matches words, for e.g. 'Camel' and 'Case' in 'CamelCase'."""
|
|
|
|
exclude: Container = field(default_factory=set)
|
|
min_length: int = 3
|
|
|
|
def partial_match(self, str_with_parts: str, search_string: str) -> Collection[str]:
|
|
"""Returns partial matches of the first string (`str_with_parts`)
|
|
in the `search_string`.
|
|
|
|
See:
|
|
`partial_match_regex`
|
|
"""
|
|
parts = self.partial_match_regex.finditer(str_with_parts)
|
|
search_string_lower = search_string.casefold()
|
|
return set(
|
|
p_lower
|
|
for p in parts
|
|
if len(p_lower := p[0].casefold()) >= self.min_length
|
|
and p_lower not in self.exclude
|
|
and p_lower in search_string_lower
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class ContentMatch:
|
|
file_glob_patterns: Iterable[str]
|
|
"""File patterns (glob) for content files and `content_regex`."""
|
|
content_regex: re.Pattern[str]
|
|
"""Regex to get mod name from content. with (?P<`match_group`>) group."""
|
|
match_group: str
|
|
|
|
def match_content(self, file_path: Path) -> str:
|
|
if (
|
|
self.content_regex
|
|
and self.file_glob_patterns
|
|
and any(file_path.match(p) for p in self.file_glob_patterns)
|
|
):
|
|
with open(file_path) as file:
|
|
if match := self.content_regex.search(file.read()):
|
|
return match.group(self.match_group)
|
|
return ""
|
|
|
|
|
|
class DebugTable:
|
|
"""Debug in markdown table."""
|
|
|
|
def __init__(self, column_keys: Collection[str]) -> None:
|
|
"""Init the table, adds the header.
|
|
|
|
Args:
|
|
column_keys: The column keys for the table.
|
|
"""
|
|
self.new_table(column_keys)
|
|
|
|
def __call__(self, **kwargs) -> None:
|
|
self.add(**kwargs)
|
|
|
|
def new_table(self, column_keys: Optional[Collection[str]] = None) -> None:
|
|
if column_keys:
|
|
self._column_keys = column_keys
|
|
self._table: list[dict[str, str]] = [
|
|
{c: c for c in self._column_keys},
|
|
{c: "-" * len(c) for c in self._column_keys},
|
|
]
|
|
|
|
def add(
|
|
self,
|
|
**kwargs,
|
|
) -> None:
|
|
"""Add data to the table. Adds a new line if the last row has already data in
|
|
for the column key.
|
|
|
|
Args:
|
|
**kwargs: the values per column key for a row.
|
|
"""
|
|
for k, v in kwargs.items():
|
|
if not self._table or self._table[-1].get(k, ""):
|
|
# Append line if element in last list is set.
|
|
self._table.append(dict.fromkeys(self._column_keys, ""))
|
|
self._table[-1][k] = str(v)
|
|
|
|
def print(self, output_file: Optional[TextIO] = None):
|
|
if self._table:
|
|
for line in self._table:
|
|
print("|", " | ".join(line.values()), "|", file=output_file)
|
|
if output_file:
|
|
output_file.flush()
|
|
self._table = []
|
|
|
|
|
|
class OverwriteSync:
|
|
organizer: mobase.IOrganizer
|
|
game: mobase.IPluginGame
|
|
search_file_contents: bool = True
|
|
|
|
overwrite_file_pattern: Iterable[str] = ["BepInEx/config/*"]
|
|
"""File pattern (glob) in overwrite folder."""
|
|
partial_match: PartialMatch = PartialMatch(exclude={"valheim", "mod"})
|
|
content_match: Optional[ContentMatch] = ContentMatch(
|
|
file_glob_patterns=["*.cfg"],
|
|
content_regex=re.compile(r"\A.*plugin (?P<mod>.+) v[\d\.]+?$", re.I | re.M),
|
|
match_group="mod",
|
|
)
|
|
|
|
_debug = DebugTable("overwrite_file | mod | target_path | matches".split(" | "))
|
|
|
|
def __init__(self, organizer: mobase.IOrganizer, game: mobase.IPluginGame) -> None:
|
|
self.organizer = organizer
|
|
self.game = game
|
|
|
|
def sync(self) -> None:
|
|
"""Sync the Overwrite folder (back) to the mods."""
|
|
print("Syncing Overwrite with mods")
|
|
modlist = self.organizer.modList()
|
|
mod_map = self._get_active_mods(modlist)
|
|
mod_dll_map = self._get_mod_dll_map(mod_map)
|
|
overwrite_path = Path(self.organizer.overwritePath())
|
|
self._debug.new_table()
|
|
for pattern in self.overwrite_file_pattern:
|
|
for file_path in overwrite_path.glob(pattern):
|
|
self._debug(overwrite_file=file_path.name)
|
|
if mod := self._find_mod_for_overwrite_file(file_path, mod_dll_map):
|
|
# Move cfg to mod folder
|
|
mod_path = Path(modlist.getMod(mod).absolutePath())
|
|
target_path = mod_path / file_path.relative_to(overwrite_path)
|
|
self._debug(mod=mod, target_path=target_path)
|
|
move_file(file_path, target_path)
|
|
self._debug.print()
|
|
|
|
def _get_active_mods(
|
|
self, modlist: Optional[mobase.IModList] = None
|
|
) -> Mapping[str, mobase.IModInterface]:
|
|
"""Get all active mods.
|
|
|
|
Args: modlist (optional): the `mobase.IModList`. Defaults to None (get it from
|
|
`self._organizer`).
|
|
|
|
Returns: `{mod_name: mobase.IModInterface}`
|
|
"""
|
|
modlist = modlist or self.organizer.modList()
|
|
|
|
return {
|
|
name: mod
|
|
for name in modlist.allMods() # allModsByProfilePriority ?
|
|
if (mod := modlist.getMod(name)).gameName() == self.game.gameShortName()
|
|
and not mod.isForeign()
|
|
and not mod.isBackup()
|
|
and not mod.isSeparator()
|
|
and (modlist.state(name) & mobase.ModState.ACTIVE)
|
|
}
|
|
|
|
def _get_mod_dll_map(self, mod_map):
|
|
return {name: self._get_mod_dlls(mod) for name, mod in mod_map.items()}
|
|
|
|
def _get_mod_dlls(self, mod: Union[str, mobase.IModInterface]) -> Sequence[str]:
|
|
"""Get all BepInEx/plugins/*.dll files of a mod."""
|
|
if isinstance(mod, str):
|
|
mod = self.organizer.modList().getMod(mod)
|
|
plugins = mod.fileTree().find("BepInEx/plugins/", mobase.IFileTree.DIRECTORY)
|
|
if isinstance(plugins, mobase.IFileTree):
|
|
return [name for p in plugins if (name := p.name()).endswith(".dll")]
|
|
else:
|
|
return []
|
|
|
|
def _find_mod_for_overwrite_file(
|
|
self,
|
|
file_path: Path,
|
|
mod_dll_map: Mapping[str, Collection[str]],
|
|
) -> str:
|
|
"""Find the mod (name) matching a file in Overwrite (using the mods dll name).
|
|
|
|
Args:
|
|
file_name: The name of the file.
|
|
mod_dll_map: Mods names and their dll files `{mod_name: ["ModName.dll"]}`.
|
|
|
|
Returns:
|
|
The name of the mod matching the given file_name best.
|
|
If there is no clear mod match for the file, an empty string "" is returned.
|
|
"""
|
|
if file_path.is_dir():
|
|
return ""
|
|
file_name = file_path.stem
|
|
# matching metric: combined length of partial matches per mod.
|
|
matching_mods = self._get_matching_mods(file_name, mod_dll_map)
|
|
if len(matching_mods) == 0:
|
|
if self.search_file_contents and self.content_match:
|
|
# Get mod name from file content.
|
|
long_mod_name = self.content_match.match_content(file_path)
|
|
matching_mods = self._get_matching_mods(long_mod_name, mod_dll_map)
|
|
if len(matching_mods) == 1:
|
|
# Only a single mod match found.
|
|
return matching_mods[0][0]
|
|
elif len(matching_mods) > 1:
|
|
# Find mod with longest (combined) partial_match.
|
|
self._debug.add(matches=matching_mods)
|
|
# Do not return a mod for multiple "equal" matches.
|
|
if matching_mods[0][1] > matching_mods[1][1]:
|
|
return matching_mods[0][0]
|
|
return ""
|
|
|
|
def _get_matching_mods(
|
|
self, search_str: str, mod_dll_map: Mapping[str, Collection[str]]
|
|
) -> Sequence[tuple[str, int, set[str]]]:
|
|
"""Find matching mods for the given `search_str`.
|
|
|
|
Args:
|
|
search_name: A string to find a mod match for. mod_dll_map: Mods names and
|
|
their dll files `{mod_name: ["ModName.dll"]}`.
|
|
|
|
Returns:
|
|
Mods with partial matches, sorted descending by their metric
|
|
(length of combined partial matches):
|
|
{mod_name: (len_of_combined_partial_matches, {partial_matches, ...}), ...}
|
|
"""
|
|
return sorted(
|
|
(
|
|
(name, sum(len(s) for s in partial_matches), partial_matches)
|
|
for name, dlls in mod_dll_map.items()
|
|
if len(
|
|
partial_matches := set(
|
|
itertools.chain.from_iterable(
|
|
self.partial_match.partial_match(search_str, dll)
|
|
for dll in dlls
|
|
)
|
|
)
|
|
)
|
|
> 0
|
|
),
|
|
key=lambda x: x[1],
|
|
reverse=True,
|
|
)
|
|
|
|
|
|
class ValheimSaveGame(BasicGameSaveGame):
|
|
def getName(self) -> str:
|
|
return f"[{self.getSaveGroupIdentifier().rstrip('s')}] {self._filepath.stem}"
|
|
|
|
def getSaveGroupIdentifier(self) -> str:
|
|
return self._filepath.parent.name
|
|
|
|
def allFiles(self) -> list[str]:
|
|
files = super().allFiles()
|
|
files.extend(
|
|
self._filepath.with_suffix(suffix).as_posix()
|
|
for suffix in [self._filepath.suffix + ".old"]
|
|
)
|
|
return files
|
|
|
|
|
|
class ValheimWorldSaveGame(ValheimSaveGame):
|
|
def allFiles(self) -> list[str]:
|
|
files = super().allFiles()
|
|
files.extend(
|
|
self._filepath.with_suffix(suffix).as_posix()
|
|
for suffix in [".db", ".db.old"]
|
|
)
|
|
return files
|
|
|
|
|
|
class ValheimLocalSavegames(mobase.LocalSavegames):
|
|
def __init__(self, myGameSaveDir):
|
|
super().__init__()
|
|
self._savesDir = myGameSaveDir.absolutePath()
|
|
|
|
def mappings(self, profile_save_dir):
|
|
return [
|
|
mobase.Mapping(
|
|
source=profile_save_dir.absolutePath(),
|
|
destination=self._savesDir,
|
|
is_directory=True,
|
|
create_target=True,
|
|
)
|
|
]
|
|
|
|
def prepareProfile(self, profile):
|
|
return profile.localSavesEnabled()
|
|
|
|
|
|
class ValheimGame(BasicGame):
|
|
|
|
Name = "Valheim Support Plugin"
|
|
Author = "Zash"
|
|
Version = "1.2"
|
|
|
|
GameName = "Valheim"
|
|
GameShortName = "valheim"
|
|
GameNexusId = 3667
|
|
GameSteamId = [892970, 896660, 1223920]
|
|
GameBinary = "valheim.exe"
|
|
GameDataPath = ""
|
|
GameSavesDirectory = r"%USERPROFILE%/AppData/LocalLow/IronGate/Valheim"
|
|
|
|
_forced_libraries = ["winhttp.dll"]
|
|
|
|
def init(self, organizer: mobase.IOrganizer) -> bool:
|
|
super().init(organizer)
|
|
self._featureMap[mobase.ModDataChecker] = BasicModDataChecker(
|
|
{
|
|
"unfold": [
|
|
"BepInExPack_Valheim",
|
|
],
|
|
"valid": [
|
|
"meta.ini", # Included in installed mod folder.
|
|
"BepInEx",
|
|
"doorstop_libs",
|
|
"unstripped_corlib",
|
|
"doorstop_config.ini",
|
|
"start_game_bepinex.sh",
|
|
"start_server_bepinex.sh",
|
|
"winhttp.dll",
|
|
#
|
|
"InSlimVML",
|
|
"valheim_Data",
|
|
"inslimvml.ini",
|
|
#
|
|
"unstripped_managed",
|
|
#
|
|
"AdvancedBuilder",
|
|
],
|
|
"delete": [
|
|
"*.txt",
|
|
"*.md",
|
|
"icon.png",
|
|
"license",
|
|
"manifest.json",
|
|
],
|
|
"move": {
|
|
"*_VML.dll": "InSlimVML/Mods/",
|
|
#
|
|
"plugins": "BepInEx/",
|
|
"*.dll": "BepInEx/plugins/",
|
|
"config": "BepInEx/",
|
|
"*.cfg": "BepInEx/config/",
|
|
#
|
|
"CustomTextures": "BepInEx/plugins/",
|
|
"*.png": "BepInEx/plugins/CustomTextures/",
|
|
#
|
|
"Builds": "AdvancedBuilder/",
|
|
"*.vbuild": "AdvancedBuilder/Builds/",
|
|
#
|
|
"*.assets": "valheim_Data/",
|
|
},
|
|
}
|
|
)
|
|
self._featureMap[mobase.LocalSavegames] = ValheimLocalSavegames(
|
|
self.savesDirectory()
|
|
)
|
|
self._overwrite_sync = OverwriteSync(organizer=self._organizer, game=self)
|
|
self._register_event_handler()
|
|
return True
|
|
|
|
def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]:
|
|
return [
|
|
mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True)
|
|
for lib in self._forced_libraries
|
|
]
|
|
|
|
def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]:
|
|
save_games = super().listSaves(folder)
|
|
path = Path(folder.absolutePath())
|
|
save_games.extend(ValheimSaveGame(f) for f in path.glob("characters/*.fch"))
|
|
save_games.extend(ValheimWorldSaveGame(f) for f in path.glob("worlds/*.fwl"))
|
|
return save_games
|
|
|
|
def settings(self) -> list[mobase.PluginSetting]:
|
|
settings = super().settings()
|
|
settings.extend(
|
|
[
|
|
mobase.PluginSetting(
|
|
"sync_overwrite", "Sync overwrite with mods", True
|
|
),
|
|
mobase.PluginSetting(
|
|
"search_overwrite_file_content",
|
|
"Search content of files in overwrite for matching mod",
|
|
True,
|
|
),
|
|
]
|
|
)
|
|
return settings
|
|
|
|
def _register_event_handler(self):
|
|
self._organizer.onUserInterfaceInitialized(lambda win: self._sync_overwrite())
|
|
self._organizer.onFinishedRun(self._game_finished_event_handler)
|
|
|
|
def _game_finished_event_handler(self, app_path: str, *args) -> None:
|
|
"""Sync overwrite folder with mods after game was closed."""
|
|
if Path(app_path) == Path(
|
|
self.gameDirectory().absolutePath(), self.binaryName()
|
|
):
|
|
self._sync_overwrite()
|
|
|
|
def _sync_overwrite(self) -> None:
|
|
if self._organizer.managedGame() is not self:
|
|
return
|
|
if self._organizer.pluginSetting(self.name(), "sync_overwrite") is not False:
|
|
self._overwrite_sync.search_file_contents = (
|
|
self._organizer.pluginSetting(
|
|
self.name(), "search_overwrite_file_content"
|
|
)
|
|
is not False
|
|
)
|
|
self._overwrite_sync.sync()
|