ZedScripts 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,374 @@
1
+ import hashlib
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ from lsprotocol import types
6
+
7
+ from . import VersionType
8
+ from .version import Version
9
+ from ..utils import path_to_uri, position_to_texposition
10
+ from ..enums.Diagnostic import DiagnosticType
11
+ from ..structure.lexer import Lexer, TokenCollection
12
+ from ..structure.parser import parse_tokens, chunk_to_root
13
+ from ..providers.diagnostics import DiagnosticReport, WorkspaceDiagnosticReport, DiagnosticCollection
14
+ from ..providers.notifications import ZedNotification, SetZedScriptsNotificationParams
15
+ from ..providers.semantic_tokens import SemanticTokenCollection, build_syntactic_tokens
16
+ from ..providers.actions import make_linked_editing_ranges
17
+
18
+ from typing import TYPE_CHECKING
19
+ if TYPE_CHECKING:
20
+ from pathlib import Path
21
+
22
+ from .workspace import Workspace
23
+ from .mod import Mod
24
+ from ..server import ZedServer
25
+ from ..scripts.block import Block, Root
26
+ from ..scripts.dataset import Dataset
27
+
28
+ class Document:
29
+ documents: list['Document'] = []
30
+ documents_by_version: dict['Version', list['Document']] = {}
31
+ def __init__(self, path: Path, rootType: str, workspace: 'Workspace', version: 'Version') -> None:
32
+ self.rootType: str = rootType
33
+ """The type of the root block of the document. If the document is not identified as a ZedScripts file,
34
+ then this class should not be instantiated."""
35
+ self.path: Path = path
36
+ """The file system path to the document."""
37
+ self.lexical_tokens: TokenCollection = TokenCollection()
38
+ """Holds the lexical tokens of the document after lexing. That is, the raw tokens generated from the source text."""
39
+ self.body: Root | None = None
40
+ """Holds the root block of the document after parsing. None until properly parsed."""
41
+ self.workspace: 'Workspace' = workspace
42
+ """The workspace the document belongs to."""
43
+ self.version: 'Version' = version
44
+ """Holds the version type the document belongs to (versioning or common folder, base game etc)."""
45
+ self.text: str | None = None
46
+ """Holds the raw text content of the document. None until the text is read from the file
47
+ or passed through Document.update_text."""
48
+
49
+ self.mod: 'Mod | None' = None
50
+ """The mod this document belongs to, if any."""
51
+
52
+ self.semantic_tokens: SemanticTokenCollection = SemanticTokenCollection(self)
53
+ """Holds semantic tokens for general highlighting. Populated from the validation process.
54
+
55
+ Without the validation process, this collection will not be populated so users
56
+ will not receive any syntax highlights besides syntactic ones."""
57
+ self.syntactic_semantic_tokens: SemanticTokenCollection = SemanticTokenCollection(self)
58
+ """Holds semantic tokens for syntax highlighting, that is things like `{`, `}` and `,`.
59
+
60
+ This is used to not repopulate this list everytime we revalidate the document."""
61
+
62
+ self.diagnostics: DiagnosticCollection = DiagnosticCollection()
63
+ """Holds general diagnostics for the document after validation."""
64
+ self.syntactic_diagnostics: DiagnosticCollection = DiagnosticCollection()
65
+ """Holds syntactic diagnostics for the document after parsing."""
66
+
67
+ self._validation_version: int = 0
68
+ """Simple identifier to keep track of the version of the document for validation purposes.
69
+ Incremented anytime the document content changed."""
70
+ self._needs_validation: bool = True
71
+ """Indicates whether the document needs to be revalidated. If set to False,
72
+ then the document doesn't have any reasons to be revalidated as its diagnostics and
73
+ semantic tokens are up-to-date."""
74
+
75
+ # cache document instance
76
+ Document.documents.append(self)
77
+ Document.documents_by_version.setdefault(self.version, []).append(self)
78
+
79
+ self.parse()
80
+
81
+ def __repr__(self) -> str:
82
+ return f"Document(version={self.version}, workspace={self.workspace}, mod={self.mod}, path={self.path})"
83
+
84
+ def set_mod(self, mod: 'Mod') -> None:
85
+ self.mod = mod
86
+
87
+ def make_zedscripts(self) -> None:
88
+ # import here to avoid circular dependency
89
+ from ..server import ZedServer
90
+ server = ZedServer.instance
91
+ if server is not None:
92
+ server.send_notification(
93
+ ZedNotification.SET_ZEDSCRIPTS,
94
+ SetZedScriptsNotificationParams(
95
+ uri=path_to_uri(self.path)
96
+ )
97
+ )
98
+
99
+ def get_text(self) -> str:
100
+ if self.text is None:
101
+ self.text = self.path.read_text()
102
+ return self.text
103
+
104
+ def update_text(self, text: str) -> None:
105
+ """Bump the version if it has changed."""
106
+ if self.get_text() != text:
107
+ self.text = text
108
+ self.bump()
109
+
110
+ def get_uri(self) -> str:
111
+ return path_to_uri(self.path)
112
+
113
+ def get_lsp_diagnostics(self) -> list[types.Diagnostic]:
114
+ lsp_diagnostics = self.diagnostics.to_lsp() + self.syntactic_diagnostics.to_lsp()
115
+ logging.debug(f"LSP Diagnostics for {self.get_uri()}: {len(lsp_diagnostics)}")
116
+ return lsp_diagnostics
117
+
118
+
119
+ # document management
120
+
121
+ @staticmethod
122
+ def list_by_workspace() -> dict['Document', 'Workspace']:
123
+ return {document: document.workspace for document in Document.documents}
124
+
125
+ @staticmethod
126
+ def get_documents() -> list['Document']:
127
+ return Document.documents
128
+
129
+ @staticmethod
130
+ def find(path: Path) -> 'Document | None':
131
+ for document in Document.documents:
132
+ if document.path == path:
133
+ return document
134
+ return None
135
+
136
+ @staticmethod
137
+ def make(dataset: 'Dataset', path: Path, workspace: 'Workspace') -> 'Document | None':
138
+ # find the rootType of the document
139
+ rootType = dataset.test_for_root(path)
140
+ if rootType is None:
141
+ return None
142
+
143
+ # find the version and skip if pre 42
144
+ # since we don't validate that
145
+ version = Version.find_or_make_version(path)
146
+ if version.type == VersionType.PRE_42:
147
+ return None
148
+
149
+ logging.debug(f"Creating document for path: {path}, rootType: {rootType}, version: {version}")
150
+
151
+ # if it is a valid ZedScripts document, create a new Document instance
152
+ document = Document(path, rootType, workspace, version)
153
+ document.make_zedscripts()
154
+ Document.documents.append(document)
155
+
156
+ return document
157
+
158
+ @staticmethod
159
+ def find_or_make(dataset: 'Dataset', path: Path, workspace: 'Workspace') -> 'Document | None':
160
+ # if we find one, we don't have to verify it is a ZedScripts file
161
+ # bcs it means the document didn't move
162
+ document = Document.find(path)
163
+ if document is None:
164
+ document = Document.make(dataset, path, workspace)
165
+ return document
166
+
167
+ @staticmethod
168
+ def delete(path: Path) -> None:
169
+ document = Document.find(path)
170
+ if document is not None:
171
+ Document.documents.remove(document)
172
+
173
+ # TODO: there might be some diagnostics cleanup needed here
174
+ # aka remove any diagnostics by the LSP associated with this
175
+ # document
176
+
177
+ @staticmethod
178
+ def rename(old_path: Path, new_path: Path) -> None:
179
+ # no previous document exists for the old path
180
+ document = Document.find(old_path)
181
+ if document is None:
182
+ return
183
+
184
+ # first make sure that the new path is a valid root in the dataset
185
+ rootType = document.workspace.dataset.test_for_root(new_path)
186
+ if rootType is None:
187
+ Document.delete(old_path)
188
+ return
189
+
190
+ # update the document information with new path and new root type
191
+ document.path = new_path
192
+ document.rootType = rootType
193
+ document.make_zedscripts()
194
+
195
+
196
+
197
+ # this should mostly all be handled by the parser, to automatically mark files
198
+ # as having been reparsed, only if tokens changed
199
+
200
+ def mark_changed(self) -> None:
201
+ self._needs_validation = True
202
+ def clear_changed(self) -> None:
203
+ self._needs_validation = False
204
+ def has_changed(self) -> bool:
205
+ if self._needs_validation:
206
+ return True
207
+ return self.body is None
208
+
209
+ def bump(self) -> None:
210
+ """
211
+ Increment the document version and mark it as needing validation.
212
+ This version is used as an identifier for the diagnostic reports.
213
+ """
214
+ self._validation_version += 1
215
+ self.mark_changed()
216
+
217
+ # reset all diagnostics and tokens
218
+ self.diagnostics.clear()
219
+ self.syntactic_diagnostics.clear()
220
+ self.semantic_tokens.clear()
221
+ self.syntactic_semantic_tokens.clear()
222
+
223
+ logging.debug("Bumped document version to %d", self._validation_version)
224
+
225
+ def get_diagnostics_id(self) -> str:
226
+ """
227
+ Generate a result ID based on the hash of the diagnostics.
228
+ This ensures that if diagnostics don't change (even if text changes),
229
+ the client knows to skip processing via the "unchanged" report.
230
+ """
231
+ # Create a deterministic hash of the current diagnostics
232
+ diagnostics_str = str(sorted([
233
+ (d.type.value, d.location, d.args)
234
+ for d in self.diagnostics + self.syntactic_diagnostics
235
+ ]))
236
+ return hashlib.md5(diagnostics_str.encode()).hexdigest()
237
+
238
+ def get_semantic_tokens_id(self) -> str:
239
+ """
240
+ Generate a result ID based on the hash of the semantic tokens.
241
+ This ensures that if tokens don't change (even if text changes),
242
+ the client knows to skip processing via the "unchanged" report.
243
+ """
244
+ # Create a deterministic hash of the current semantic tokens
245
+ tokens_str = str(sorted([
246
+ (t.type.value, t.range)
247
+ for t in self.semantic_tokens
248
+ ]))
249
+ return hashlib.md5(tokens_str.encode()).hexdigest()
250
+
251
+
252
+
253
+ # notification response
254
+
255
+ def parse(self) -> None:
256
+ """
257
+ Parse the new document text and update its syntactic structure and diagnostics.
258
+
259
+ Args:
260
+ text (str): The new text content of the document.
261
+ """
262
+ logging.debug("Parsing document: %s", self.get_uri())
263
+ # tokenize and parse the document text
264
+ self.lexical_tokens = Lexer.tokenize(self.get_text())
265
+ result = parse_tokens(self.lexical_tokens)
266
+
267
+ # set new syntactic diagnostics
268
+ syntactic_diagnostics = self.syntactic_diagnostics
269
+ syntactic_diagnostics.clear() # reset previous syntactic diagnostics
270
+ for error in result.errors:
271
+ syntactic_diagnostics.add(
272
+ type=DiagnosticType(error.type),
273
+ location=error.location
274
+ )
275
+
276
+ # reparse the document body into a block structure for easier diagnostics
277
+ self.body = chunk_to_root(self, result.chunk, self.rootType)
278
+ build_syntactic_tokens(self)
279
+
280
+ def validate(self) -> None:
281
+ logging.debug("Validating document: %s", self.get_uri())
282
+ # clear old diagnostics and semantic tokens
283
+ self.diagnostics.clear()
284
+ self.semantic_tokens.clear()
285
+
286
+ # retrieve the starting point for validation
287
+ body = self.body
288
+ assert body is not None, f"Document ({self.get_uri()}) body should not be None before validation"
289
+
290
+ # validate the root block, which will validate its children
291
+ body.validate(self.workspace.dataset)
292
+
293
+
294
+
295
+ def on_document_changed(self, text: str) -> None:
296
+ self.update_text(text)
297
+
298
+ if not self.has_changed():
299
+ logging.debug("Document was not changed, skipping revalidation.")
300
+ return
301
+ self.clear_changed()
302
+
303
+ # reparse
304
+ logging.debug("Reparsing document: %s", self.get_uri())
305
+ self.parse()
306
+
307
+ def on_document_diagnostics(self, previous_result_id: str | None) -> DiagnosticReport:
308
+ # validate the document
309
+ self.validate()
310
+
311
+ result_id = self.get_diagnostics_id()
312
+ if (previous_result_id is not None
313
+ and previous_result_id == result_id):
314
+ return types.UnchangedDocumentDiagnosticReport(result_id)
315
+
316
+ return types.FullDocumentDiagnosticReport(
317
+ items=self.get_lsp_diagnostics(),
318
+ result_id=result_id)
319
+
320
+ def on_workspace_diagnostics(self, previous_result_id: str | None) -> WorkspaceDiagnosticReport:
321
+ self.validate()
322
+
323
+ # verify that the diagnostics didn't move
324
+ # and if they didn't then send an unchanged report
325
+ result_id = self.get_diagnostics_id()
326
+ if (previous_result_id is not None
327
+ and previous_result_id == result_id):
328
+ return types.WorkspaceUnchangedDocumentDiagnosticReport(
329
+ uri=self.get_uri(),
330
+ result_id=result_id,
331
+ )
332
+
333
+ return types.WorkspaceFullDocumentDiagnosticReport(
334
+ uri=self.get_uri(),
335
+ items=self.get_lsp_diagnostics(),
336
+ result_id=result_id,
337
+ )
338
+
339
+ def on_semantic_tokens(self) -> types.SemanticTokens:
340
+ return types.SemanticTokens(
341
+ data=SemanticTokenCollection(self, *(self.semantic_tokens + self.syntactic_semantic_tokens)).to_lsp(),
342
+ result_id=self.get_semantic_tokens_id(), # useless since they don't send it back ?
343
+ )
344
+
345
+ def on_hover(self, position: types.Position) -> types.Hover | None:
346
+ text_position = position_to_texposition(position)
347
+ if self.body is None:
348
+ return None
349
+ return self.body.get_hover_information(self.workspace.dataset, text_position)
350
+
351
+ def on_linked_editing_range(self, position: types.Position) -> types.LinkedEditingRanges | None:
352
+ if self.body is None:
353
+ return None
354
+ text_position = position_to_texposition(position)
355
+
356
+ root = self.body
357
+ element = root.get_element_at(text_position)
358
+ if element is not None:
359
+ ranges = element.get_linked_editing_ranges(text_position)
360
+ if ranges is None:
361
+ return None
362
+ return make_linked_editing_ranges(ranges)
363
+ return None
364
+
365
+ def on_definition(self, position: types.Position) -> list[types.LocationLink] | None:
366
+ if self.body is None:
367
+ return None
368
+ text_position = position_to_texposition(position)
369
+
370
+ root = self.body
371
+ element = root.get_element_at(text_position)
372
+ if element is not None:
373
+ return element.get_definition(text_position)
374
+ return None
@@ -0,0 +1,62 @@
1
+ from typing import TYPE_CHECKING
2
+ from pathlib import Path
3
+
4
+ from . import VersionType
5
+ from .version import Version
6
+
7
+ if TYPE_CHECKING:
8
+ from .workspace import Workspace
9
+ from .document import Document
10
+
11
+ class Mod:
12
+ """Represents a mod inside a specific workspace.
13
+
14
+ It is generally used to easily manage and retrieve files associated to a specific version.
15
+ Since different mod sources can have different versioning structure, we need to handle them
16
+ independently from each others when searching for block references."""
17
+ mods: dict[Path, 'Mod'] = {}
18
+ def __init__(self, folder: Path, workspace: 'Workspace'):
19
+ self.folder: Path = folder
20
+ self.workspace: 'Workspace' = workspace
21
+ self.documents: dict[Path, 'Document'] = {}
22
+ self.mod_info_files: dict[Path, 'Version'] = {}
23
+
24
+ def add_mod_info_file(self, path: Path, version: 'Version') -> None:
25
+ self.mod_info_files[path] = version
26
+
27
+ @staticmethod
28
+ def find_or_make_mod(folder: Path, workspace: 'Workspace') -> 'Mod':
29
+ folder = folder.resolve()
30
+ for mod_folder, mod in Mod.mods.items():
31
+ if folder.is_relative_to(mod_folder):
32
+ return mod
33
+ mod = Mod(folder, workspace)
34
+ Mod.mods[folder] = mod
35
+ return mod
36
+
37
+ def get_by_version(self) -> dict[Version, list['Document']]:
38
+ """Retrieve documents grouped by their version."""
39
+ result: dict[Version, list['Document']] = {}
40
+ for doc in self.documents.values():
41
+ result.setdefault(doc.version, []).append(doc)
42
+ return result
43
+
44
+ def get_closest_from_version(self, version: 'Version') -> list['Document']:
45
+ """Retrieve the documents of the closest version equal or below of the provided version."""
46
+ # only keep versions that use versioning
47
+ by_version = self.get_by_version()
48
+ versions = by_version.keys()
49
+ versions_filtered = [v for v in versions if v.type == VersionType.VERSIONING]
50
+
51
+ closest_version = version.find_closest_below(versions_filtered)
52
+ if closest_version is None:
53
+ return []
54
+ return by_version[closest_version]
55
+
56
+
57
+ class ModCollection(dict[Path, Mod]):
58
+ def add_document(self, path: Path, document: 'Document') -> None:
59
+ for mod_path, mod in self.items():
60
+ if path.is_relative_to(mod_path):
61
+ mod.documents[path] = document
62
+ return
@@ -0,0 +1,202 @@
1
+ import logging
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from . import VersionType
6
+
7
+
8
+
9
+ SCRIPT_FILE_VERSION_PATTERN = r"(?:(?P<base>\w+))\/(?:(?P<version>(?:4\d(?:\.\d+)*)|common)\/)?(?:(?P<media>media)|(?P<modinfo>mod\.info))"
10
+ """
11
+ Pattern to match script file versions in the file path.
12
+
13
+ - The `base` is the folder before the versioning/common or media folder.
14
+ - The `version` is the folder containing the version number or the `common` folder. (can be absent)
15
+ - The `media` is the folder containing most modding files.
16
+ - The `modinfo` means the path is a `mod.info` file.
17
+ """
18
+
19
+ _DEFAULT_MAJOR = 42
20
+ """Default major version number"""
21
+ _DEFAULT_MINOR = 0
22
+ """Default minor version number"""
23
+
24
+ class Version:
25
+ # static instances
26
+ COMMON: 'Version'
27
+ # POST_42: 'Version' # that one is dynamic based on the source
28
+ PRE_42: 'Version'
29
+ OTHER: 'Version'
30
+ """`Any` in old ZedScripts."""
31
+ BASE_GAME: 'Version'
32
+
33
+ key_format: str = "{type}.{major}.{minor}"
34
+ version_map: dict[str, 'Version'] = {}
35
+
36
+ def __init__(self, source: str):
37
+ self.source: str = source
38
+ type, major, minor = Version.get_type(source)
39
+ self.type: VersionType = type
40
+ self.major: int = major
41
+ self.minor: int = minor
42
+
43
+ self.version: str = Version.format_version(type, major, minor)
44
+ Version.version_map[self.version] = self
45
+
46
+ def __repr__(self) -> str:
47
+ return f"Version(source={self.source}, type={self.type}, major={self.major}, minor={self.minor})"
48
+
49
+ @staticmethod
50
+ def format_version(type: VersionType, major: int, minor: int) -> str:
51
+ """Format a version string for easy comparison and storage."""
52
+ return Version.key_format.format(type=type, major=major, minor=minor)
53
+
54
+ @staticmethod
55
+ def get_type(source: str) -> tuple[VersionType, int, int]:
56
+ # TODO: implement
57
+
58
+ match source:
59
+ case VersionType.COMMON:
60
+ return VersionType.COMMON, _DEFAULT_MAJOR, _DEFAULT_MINOR
61
+ case VersionType.PRE_42:
62
+ return VersionType.PRE_42, _DEFAULT_MAJOR, _DEFAULT_MINOR
63
+ case VersionType.OTHER:
64
+ return VersionType.OTHER, _DEFAULT_MAJOR, _DEFAULT_MINOR
65
+ case VersionType.BASE_GAME:
66
+ return VersionType.BASE_GAME, _DEFAULT_MAJOR, _DEFAULT_MINOR
67
+
68
+ # try to find the version based on the source string
69
+ parts = source.split('.')
70
+ if len(parts) == 0:
71
+ logging.warning("Source string is empty.")
72
+ return VersionType.OTHER, _DEFAULT_MAJOR, _DEFAULT_MINOR
73
+
74
+ # try to convert to int the major part
75
+ try:
76
+ major = int(parts[0])
77
+ except ValueError:
78
+ logging.warning("Source string does not start with a valid integer.")
79
+ return VersionType.OTHER, _DEFAULT_MAJOR, _DEFAULT_MINOR
80
+
81
+ # values under 42 are just invalid and never handled by any version of the game
82
+ if major < 42:
83
+ logging.warning("Source string has a major version under 42, which is invalid.")
84
+ return VersionType.OTHER, _DEFAULT_MAJOR, _DEFAULT_MINOR
85
+
86
+ # retrieve the minor if present
87
+ minor = 0
88
+ if len(parts) > 1:
89
+ try:
90
+ minor = int(parts[1])
91
+ except ValueError:
92
+ logging.warning("Source string has an invalid minor version.")
93
+ return VersionType.OTHER, _DEFAULT_MAJOR, _DEFAULT_MINOR
94
+
95
+ # other values after minor are ignored by the game
96
+ # so no need to retrieve those
97
+
98
+ return VersionType.VERSIONING, major, minor
99
+
100
+ @staticmethod
101
+ def from_string(version_str: str) -> 'Version':
102
+ """
103
+ Convert a version string to a type, major and minor information and check if
104
+ the version string already exists in the version map, which will be returned.
105
+
106
+ Else, a new Version instance will be created and returned.
107
+
108
+ Args:
109
+ version_str (str): The version string to be converted into a Version instance,
110
+ which comes from the file path usually.
111
+
112
+ Returns:
113
+ Version: The Version instance corresponding to the given version string.
114
+ """
115
+ type, major, minor = Version.get_type(version_str)
116
+ key = Version.format_version(type, major, minor)
117
+ if key in Version.version_map:
118
+ return Version.version_map[key]
119
+ return Version(version_str)
120
+
121
+
122
+ @staticmethod
123
+ def find_or_make_version(path: Path) -> 'Version':
124
+ path_posix = path.as_posix()
125
+
126
+ # try to match the version pattern in the path
127
+ m = re.compile(SCRIPT_FILE_VERSION_PATTERN).search(path_posix)
128
+ if not m or len(m.groupdict()) == 0:
129
+ return Version.OTHER
130
+
131
+ groups = m.groupdict()
132
+
133
+ # try to match a version string in the path
134
+ version_str = groups.get("version")
135
+ if version_str:
136
+ return Version.from_string(version_str)
137
+
138
+ # verify if it is the PZ source folder which doesn't use versioning folders
139
+ # linux uses projectzomboid while windows uses ProjectZomboid
140
+ base = groups.get("base")
141
+ if base in ["projectzomboid", "ProjectZomboid"]:
142
+ # we can check that in the base folder there is a `projectzomboid.jar`
143
+ # file which would mean it is B42
144
+ # using the match, we can retrieve the path of the base
145
+ base_path = path_posix[:m.start() + len(base)]
146
+ jar_path = Path(f"{base_path}/projectzomboid.jar")
147
+ if jar_path.exists() and jar_path.is_file():
148
+ return Version.BASE_GAME
149
+
150
+ # else, we see if this is a B41 file
151
+ # that is it has a media folder or it's a mod.info file
152
+ if groups.get("media") or groups.get("modinfo"):
153
+ return Version.PRE_42
154
+
155
+ # we didn't find any indicator for a specific version
156
+ return Version.OTHER
157
+
158
+ def find_closest_below(self, others: list['Version']) -> 'Version | None':
159
+ others_str = [str(other) for other in others]
160
+ others_str.sort()
161
+
162
+ # if self is not using versioning, then we simply retrieve the latest
163
+ if self.type != VersionType.VERSIONING:
164
+ return Version(others_str[-1])
165
+
166
+ # check if it's itself in the list
167
+ if str(self) in others_str:
168
+ return self
169
+
170
+ # we now insert self into the sorted list to find the closest below
171
+ self_str = str(self)
172
+ others_str.append(self_str)
173
+ others_str.sort() # sort again of course
174
+
175
+ # find self in the sorted list
176
+ self_index = others_str.index(self_str)
177
+
178
+ # if self is not the first element, then we return the element just before it
179
+ if self_index > 0:
180
+ return Version(others_str[self_index - 1])
181
+
182
+ # if it's at the first position, we pick the version above
183
+ # TODO: this is generally not a common case, probably should verify how
184
+ # the game handles it
185
+ if self_index == 0 and len(others_str) > 1:
186
+ return Version(others_str[self_index + 1])
187
+
188
+ logging.warning("No closest below version found for %s", self)
189
+
190
+ # if we reach here, it means there is no closest below version
191
+ return None
192
+
193
+
194
+
195
+
196
+
197
+
198
+
199
+ Version.COMMON = Version(VersionType.COMMON)
200
+ Version.PRE_42 = Version(VersionType.PRE_42)
201
+ Version.OTHER = Version(VersionType.OTHER)
202
+ Version.BASE_GAME = Version(VersionType.BASE_GAME)