11import os
22import 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
615import mobase
716
817from ..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+
1127class 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