Skip to content

Commit d236f48

Browse files
committed
Rewrote Road to Vostok to Support Archives with multiple Mods
1 parent 5cc4b06 commit d236f48

1 file changed

Lines changed: 171 additions & 23 deletions

File tree

games/game_roadtovostok.py

Lines changed: 171 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,197 @@
11
import os
22
import shutil
3+
from typing import TypedDict
34

4-
from PyQt6.QtCore import QDir, QFileInfo
5+
from PyQt6.QtCore import QDir, QFileInfo, Qt
6+
from PyQt6.QtWidgets import (
7+
QDialog,
8+
QDialogButtonBox,
9+
QLabel,
10+
QListWidget,
11+
QListWidgetItem,
12+
QVBoxLayout,
13+
)
514

615
import mobase
716

817
from ..basic_game import BasicGame
918

1019

20+
class ModDetectionCandidate(TypedDict):
21+
tree: mobase.IFileTree | mobase.FileTreeEntry
22+
name: str
23+
display: str
24+
destination: str
25+
26+
1127
class RoadToVostokModDataChecker(mobase.ModDataChecker):
1228
def __init__(self, organizer: mobase.IOrganizer):
1329
super().__init__()
1430
self.organizer: mobase.IOrganizer = organizer
31+
self.modDetectionCandidates: list[ModDetectionCandidate] = []
32+
33+
def moveOverwriteMerge(self, source: str, destination: str):
34+
if not os.path.exists(destination):
35+
shutil.move(source, destination)
36+
return
37+
if os.path.isfile(source):
38+
os.replace(source, destination)
39+
return
40+
for item in os.listdir(source):
41+
s_item = os.path.join(source, item)
42+
d_item = os.path.join(destination, item)
43+
self.moveOverwriteMerge(s_item, d_item)
44+
os.rmdir(source)
45+
46+
def sanitizeFolderName(self, name: str) -> str:
47+
# Remove invalid characters for Windows folder names
48+
invalid_chars = '+&<>:"|?*\\/'
49+
for char in invalid_chars:
50+
name = name.replace(char, "")
51+
# Remove control characters (ASCII 0-31)
52+
name = "".join(c for c in name if ord(c) >= 32)
53+
# Remove trailing periods and spaces
54+
name = name.rstrip(". ")
55+
# If name is empty after sanitization, use a default
56+
if not name:
57+
name = "FOLDERNAME"
58+
self.needsNameFix = True
59+
return name
1560

1661
def dataLooksValid(
1762
self, filetree: mobase.IFileTree
1863
) -> mobase.ModDataChecker.CheckReturn:
19-
if filetree.exists("mods", mobase.IFileTree.DIRECTORY) and not filetree.exists(
20-
"mod.txt", mobase.IFileTree.FILE
21-
):
64+
if filetree.exists("mods", mobase.IFileTree.DIRECTORY):
2265
return mobase.ModDataChecker.VALID
23-
for e in filetree:
24-
if e.isFile() and e.suffix().casefold() == "pck":
25-
return mobase.ModDataChecker.VALID
2666
return mobase.ModDataChecker.FIXABLE
2767

28-
def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree | None:
68+
def moveTreeContent(
69+
self,
70+
filetree: mobase.IFileTree,
71+
file: mobase.IFileTree | mobase.FileTreeEntry,
72+
) -> None:
2973
GameModsPath = getattr(self.organizer.managedGame(), "GameModsPath", "") + "/"
30-
allowedUnzippedExt = ["zip", "vmz"]
31-
32-
for branch in filetree:
74+
if filetree.name() == "":
75+
filetree.move(file, GameModsPath, mobase.IFileTree.MERGE)
76+
else:
3377
mod_name = filetree.name()
34-
if mod_name == "":
35-
mod_name = branch.name()
78+
mod_file = file.name()
3679
mod_path = os.path.join(self.organizer.modsPath(), mod_name)
80+
insideMods = os.path.join(mod_path, GameModsPath)
81+
os.makedirs(insideMods, exist_ok=True)
82+
src = os.path.join(mod_path, mod_file)
83+
dst = os.path.join(mod_path, GameModsPath, mod_file)
84+
print(
85+
f"Mod: {mod_name} with File: {mod_file} at {mod_path} is being moved to: {insideMods}"
86+
)
87+
print(f"Moving {src} to {dst}")
88+
shutil.move(
89+
src,
90+
dst,
91+
)
92+
return None
93+
94+
def addModDetectionCandidate(
95+
self,
96+
tree: mobase.IFileTree | mobase.FileTreeEntry,
97+
name: str,
98+
category: str,
99+
destination: str,
100+
) -> None:
101+
tree_name = tree.name()
102+
tree_path = tree.path()
103+
104+
print(
105+
f"Detected mod candidate: {tree_name} | "
106+
f"path={tree_path} | category={category} | destination={destination}"
107+
)
108+
self.modDetectionCandidates.append(
109+
{
110+
"tree": tree,
111+
"name": tree_name,
112+
"display": f"{name} ({category})",
113+
"destination": destination,
114+
}
115+
)
116+
117+
def showModDetectionDialog(self) -> set[int] | None:
118+
if not self.modDetectionCandidates:
119+
return set()
120+
121+
dialog = QDialog()
122+
dialog.setWindowTitle("Found Mods")
123+
124+
layout = QVBoxLayout(dialog)
125+
layout.addWidget(QLabel("Select the mods to install:"))
126+
127+
listWidget = QListWidget()
128+
listWidget.setSelectionMode(QListWidget.SelectionMode.NoSelection)
129+
for candidate in self.modDetectionCandidates:
130+
item = QListWidgetItem(candidate["display"])
131+
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
132+
item.setCheckState(Qt.CheckState.Checked)
133+
listWidget.addItem(item)
134+
135+
layout.addWidget(listWidget)
136+
137+
buttonBox = QDialogButtonBox(
138+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
139+
)
140+
buttonBox.accepted.connect(lambda: dialog.accept()) # type: ignore
141+
buttonBox.rejected.connect(lambda: dialog.reject()) # type: ignore
142+
layout.addWidget(buttonBox)
143+
144+
if dialog.exec() != QDialog.DialogCode.Accepted:
145+
return None
146+
147+
selectedIndexes: set[int] = set()
148+
for index in range(listWidget.count()):
149+
item = listWidget.item(index)
37150
if (
38-
not filetree.createOrphanTree("OrphanTree")
39-
and os.path.exists(mod_path)
40-
and branch.suffix().casefold() in allowedUnzippedExt
151+
isinstance(item, QListWidgetItem)
152+
and item.checkState() == Qt.CheckState.Checked
41153
):
42-
os.makedirs(os.path.join(mod_path, GameModsPath), exist_ok=True)
43-
shutil.move(
44-
os.path.join(mod_path, branch.name()),
45-
os.path.join(mod_path, GameModsPath, branch.name()),
46-
)
154+
selectedIndexes.add(index)
155+
156+
return selectedIndexes
157+
158+
def collectModCandidates(
159+
self, tree: mobase.IFileTree | mobase.FileTreeEntry
160+
) -> bool:
161+
print(f"Collecting mod candidates in: {tree.path()}")
162+
if os.path.splitext(tree.path())[1] == ".vmz":
163+
print(f"Found vmz file: {tree.name()}")
164+
self.addModDetectionCandidate(
165+
tree,
166+
self.sanitizeFolderName(tree.name()),
167+
"VMZ Archive",
168+
"mods/",
169+
)
170+
return True
171+
return False
172+
173+
def walkEntry(self, path: str, entry: mobase.FileTreeEntry):
174+
self.collectModCandidates(entry)
175+
return mobase.IFileTree.WalkReturn.CONTINUE
176+
177+
def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree | None:
178+
self.modDetectionCandidates = []
179+
180+
self.collectModCandidates(filetree)
181+
filetree.walk(self.walkEntry, "/")
182+
183+
if len(self.modDetectionCandidates) == 1:
184+
selectedIndexes = {0}
185+
else:
186+
selectedIndexes = self.showModDetectionDialog()
187+
if selectedIndexes is None:
188+
return None
189+
190+
for index in selectedIndexes:
191+
candidate = self.modDetectionCandidates[index]
192+
print(f"Installing Mod: {candidate['name']}")
193+
self.moveTreeContent(filetree, candidate["tree"])
194+
47195
return filetree
48196

49197

@@ -56,8 +204,8 @@ class RoadToVostokGame(BasicGame):
56204
GameShortName = "roadtovostok"
57205
GameSteamId = 1963610
58206
GameBinary = "RTV.exe"
59-
GameDataPath = "%GAME_PATH%"
60207
GameModsPath = "mods"
208+
GameDataPath = "%GAME_PATH%"
61209
GameDocumentsDirectory = "%USERPROFILE%/AppData/Roaming/Road to Vostok"
62210
GameSaveExtension = "tres"
63211

@@ -79,7 +227,7 @@ def iniFiles(self):
79227
return ["settings.cfg"]
80228

81229
def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting):
82-
modsPath = self.dataDirectory().absolutePath()
230+
modsPath = self.dataDirectory().absolutePath() + "/mods"
83231
if not os.path.exists(modsPath):
84232
os.mkdir(modsPath)
85233
super().initializeProfile(directory, settings)

0 commit comments

Comments
 (0)