factorix 0.5.0

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.
Files changed (202) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/completion/_factorix.bash +202 -0
  6. data/completion/_factorix.fish +197 -0
  7. data/completion/_factorix.zsh +376 -0
  8. data/doc/factorix.1 +377 -0
  9. data/exe/factorix +20 -0
  10. data/lib/factorix/api/category.rb +69 -0
  11. data/lib/factorix/api/image.rb +35 -0
  12. data/lib/factorix/api/license.rb +71 -0
  13. data/lib/factorix/api/mod_download_api.rb +66 -0
  14. data/lib/factorix/api/mod_info.rb +166 -0
  15. data/lib/factorix/api/mod_management_api.rb +237 -0
  16. data/lib/factorix/api/mod_portal_api.rb +204 -0
  17. data/lib/factorix/api/release.rb +49 -0
  18. data/lib/factorix/api/tag.rb +95 -0
  19. data/lib/factorix/api.rb +7 -0
  20. data/lib/factorix/api_credential.rb +54 -0
  21. data/lib/factorix/application.rb +218 -0
  22. data/lib/factorix/cache/file_system.rb +307 -0
  23. data/lib/factorix/cli/commands/backup_support.rb +46 -0
  24. data/lib/factorix/cli/commands/base.rb +90 -0
  25. data/lib/factorix/cli/commands/cache/evict.rb +180 -0
  26. data/lib/factorix/cli/commands/cache/stat.rb +201 -0
  27. data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
  28. data/lib/factorix/cli/commands/completion.rb +83 -0
  29. data/lib/factorix/cli/commands/confirmable.rb +53 -0
  30. data/lib/factorix/cli/commands/download_support.rb +123 -0
  31. data/lib/factorix/cli/commands/launch.rb +79 -0
  32. data/lib/factorix/cli/commands/man.rb +29 -0
  33. data/lib/factorix/cli/commands/mod/check.rb +99 -0
  34. data/lib/factorix/cli/commands/mod/disable.rb +188 -0
  35. data/lib/factorix/cli/commands/mod/download.rb +291 -0
  36. data/lib/factorix/cli/commands/mod/edit.rb +114 -0
  37. data/lib/factorix/cli/commands/mod/enable.rb +216 -0
  38. data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
  39. data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
  40. data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
  41. data/lib/factorix/cli/commands/mod/install.rb +443 -0
  42. data/lib/factorix/cli/commands/mod/list.rb +372 -0
  43. data/lib/factorix/cli/commands/mod/search.rb +134 -0
  44. data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
  45. data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
  46. data/lib/factorix/cli/commands/mod/show.rb +202 -0
  47. data/lib/factorix/cli/commands/mod/sync.rb +299 -0
  48. data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
  49. data/lib/factorix/cli/commands/mod/update.rb +222 -0
  50. data/lib/factorix/cli/commands/mod/upload.rb +90 -0
  51. data/lib/factorix/cli/commands/path.rb +79 -0
  52. data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
  53. data/lib/factorix/cli/commands/version.rb +25 -0
  54. data/lib/factorix/cli.rb +42 -0
  55. data/lib/factorix/dependency/edge.rb +89 -0
  56. data/lib/factorix/dependency/entry.rb +124 -0
  57. data/lib/factorix/dependency/graph/builder.rb +108 -0
  58. data/lib/factorix/dependency/graph.rb +210 -0
  59. data/lib/factorix/dependency/list.rb +244 -0
  60. data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
  61. data/lib/factorix/dependency/node.rb +60 -0
  62. data/lib/factorix/dependency/parser.rb +148 -0
  63. data/lib/factorix/dependency/validation_result.rb +138 -0
  64. data/lib/factorix/dependency/validator.rb +190 -0
  65. data/lib/factorix/errors.rb +112 -0
  66. data/lib/factorix/formatting.rb +56 -0
  67. data/lib/factorix/game_version.rb +98 -0
  68. data/lib/factorix/http/cache_decorator.rb +106 -0
  69. data/lib/factorix/http/cached_response.rb +37 -0
  70. data/lib/factorix/http/client.rb +187 -0
  71. data/lib/factorix/http/response.rb +31 -0
  72. data/lib/factorix/http/retry_decorator.rb +59 -0
  73. data/lib/factorix/http/retry_strategy.rb +80 -0
  74. data/lib/factorix/info_json.rb +90 -0
  75. data/lib/factorix/installed_mod.rb +239 -0
  76. data/lib/factorix/mod.rb +55 -0
  77. data/lib/factorix/mod_list.rb +174 -0
  78. data/lib/factorix/mod_settings.rb +278 -0
  79. data/lib/factorix/mod_state.rb +34 -0
  80. data/lib/factorix/mod_version.rb +99 -0
  81. data/lib/factorix/portal.rb +185 -0
  82. data/lib/factorix/progress/download_handler.rb +46 -0
  83. data/lib/factorix/progress/multi_presenter.rb +45 -0
  84. data/lib/factorix/progress/presenter.rb +67 -0
  85. data/lib/factorix/progress/presenter_adapter.rb +46 -0
  86. data/lib/factorix/progress/scan_handler.rb +33 -0
  87. data/lib/factorix/progress/upload_handler.rb +33 -0
  88. data/lib/factorix/runtime/base.rb +233 -0
  89. data/lib/factorix/runtime/linux.rb +32 -0
  90. data/lib/factorix/runtime/mac_os.rb +53 -0
  91. data/lib/factorix/runtime/user_configurable.rb +69 -0
  92. data/lib/factorix/runtime/windows.rb +85 -0
  93. data/lib/factorix/runtime/wsl.rb +118 -0
  94. data/lib/factorix/runtime.rb +32 -0
  95. data/lib/factorix/save_file.rb +178 -0
  96. data/lib/factorix/ser_des/deserializer.rb +198 -0
  97. data/lib/factorix/ser_des/serializer.rb +231 -0
  98. data/lib/factorix/ser_des/signed_integer.rb +63 -0
  99. data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
  100. data/lib/factorix/service_credential.rb +127 -0
  101. data/lib/factorix/transfer/downloader.rb +162 -0
  102. data/lib/factorix/transfer/uploader.rb +232 -0
  103. data/lib/factorix/version.rb +6 -0
  104. data/lib/factorix.rb +38 -0
  105. data/sig/dry/auto_inject.rbs +15 -0
  106. data/sig/dry/cli.rbs +19 -0
  107. data/sig/dry/configurable.rbs +13 -0
  108. data/sig/dry/core/container.rbs +17 -0
  109. data/sig/dry/events/publisher.rbs +22 -0
  110. data/sig/dry/logger.rbs +16 -0
  111. data/sig/factorix/api/category.rbs +15 -0
  112. data/sig/factorix/api/image.rbs +15 -0
  113. data/sig/factorix/api/license.rbs +20 -0
  114. data/sig/factorix/api/mod_download_api.rbs +18 -0
  115. data/sig/factorix/api/mod_info.rbs +67 -0
  116. data/sig/factorix/api/mod_management_api.rbs +25 -0
  117. data/sig/factorix/api/mod_portal_api.rbs +31 -0
  118. data/sig/factorix/api/release.rbs +27 -0
  119. data/sig/factorix/api/tag.rbs +15 -0
  120. data/sig/factorix/api.rbs +8 -0
  121. data/sig/factorix/api_credential.rbs +17 -0
  122. data/sig/factorix/application.rbs +86 -0
  123. data/sig/factorix/cache/file_system.rbs +35 -0
  124. data/sig/factorix/cli/commands/base.rbs +13 -0
  125. data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
  126. data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
  127. data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
  128. data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
  129. data/sig/factorix/cli/commands/confirmable.rbs +12 -0
  130. data/sig/factorix/cli/commands/download_support.rbs +12 -0
  131. data/sig/factorix/cli/commands/launch.rbs +15 -0
  132. data/sig/factorix/cli/commands/mod/check.rbs +18 -0
  133. data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
  134. data/sig/factorix/cli/commands/mod/download.rbs +18 -0
  135. data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
  136. data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
  137. data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
  138. data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
  139. data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
  140. data/sig/factorix/cli/commands/mod/install.rbs +19 -0
  141. data/sig/factorix/cli/commands/mod/list.rbs +30 -0
  142. data/sig/factorix/cli/commands/mod/search.rbs +18 -0
  143. data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
  144. data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
  145. data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
  146. data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
  147. data/sig/factorix/cli/commands/mod/update.rbs +19 -0
  148. data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
  149. data/sig/factorix/cli/commands/path.rbs +18 -0
  150. data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
  151. data/sig/factorix/cli/commands/version.rbs +13 -0
  152. data/sig/factorix/cli.rbs +11 -0
  153. data/sig/factorix/dependency/edge.rbs +32 -0
  154. data/sig/factorix/dependency/entry.rbs +30 -0
  155. data/sig/factorix/dependency/graph/builder.rbs +17 -0
  156. data/sig/factorix/dependency/graph.rbs +39 -0
  157. data/sig/factorix/dependency/list.rbs +69 -0
  158. data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
  159. data/sig/factorix/dependency/node.rbs +24 -0
  160. data/sig/factorix/dependency/parser.rbs +11 -0
  161. data/sig/factorix/dependency/validation_result.rbs +56 -0
  162. data/sig/factorix/dependency/validator.rbs +13 -0
  163. data/sig/factorix/errors.rbs +132 -0
  164. data/sig/factorix/formatting.rbs +8 -0
  165. data/sig/factorix/game_version.rbs +24 -0
  166. data/sig/factorix/http/cache_decorator.rbs +64 -0
  167. data/sig/factorix/http/client.rbs +55 -0
  168. data/sig/factorix/http/response.rbs +28 -0
  169. data/sig/factorix/http/retry_decorator.rbs +44 -0
  170. data/sig/factorix/http/retry_strategy.rbs +42 -0
  171. data/sig/factorix/info_json.rbs +19 -0
  172. data/sig/factorix/installed_mod.rbs +34 -0
  173. data/sig/factorix/mod.rbs +20 -0
  174. data/sig/factorix/mod_list.rbs +44 -0
  175. data/sig/factorix/mod_settings.rbs +47 -0
  176. data/sig/factorix/mod_state.rbs +18 -0
  177. data/sig/factorix/mod_version.rbs +23 -0
  178. data/sig/factorix/portal.rbs +37 -0
  179. data/sig/factorix/progress/download_handler.rbs +19 -0
  180. data/sig/factorix/progress/multi_presenter.rbs +15 -0
  181. data/sig/factorix/progress/presenter.rbs +17 -0
  182. data/sig/factorix/progress/presenter_adapter.rbs +17 -0
  183. data/sig/factorix/progress/scan_handler.rbs +16 -0
  184. data/sig/factorix/progress/upload_handler.rbs +17 -0
  185. data/sig/factorix/runtime/base.rbs +45 -0
  186. data/sig/factorix/runtime/linux.rbs +15 -0
  187. data/sig/factorix/runtime/mac_os.rbs +15 -0
  188. data/sig/factorix/runtime/user_configurable.rbs +13 -0
  189. data/sig/factorix/runtime/windows.rbs +23 -0
  190. data/sig/factorix/runtime/wsl.rbs +19 -0
  191. data/sig/factorix/runtime.rbs +9 -0
  192. data/sig/factorix/save_file.rbs +40 -0
  193. data/sig/factorix/ser_des/deserializer.rbs +49 -0
  194. data/sig/factorix/ser_des/serializer.rbs +45 -0
  195. data/sig/factorix/ser_des/signed_integer.rbs +37 -0
  196. data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
  197. data/sig/factorix/service_credential.rbs +19 -0
  198. data/sig/factorix/transfer/downloader.rbs +15 -0
  199. data/sig/factorix/transfer/uploader.rbs +21 -0
  200. data/sig/factorix.rbs +9 -0
  201. data/sig/tty/progressbar.rbs +18 -0
  202. metadata +431 -0
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/executor/fixed_thread_pool"
4
+ require "concurrent/future"
5
+ require "dry/events"
6
+
7
+ module Factorix
8
+ InstalledMOD = Data.define(:mod, :version, :form, :path, :info)
9
+
10
+ # Represents a MOD installed in the MOD directory or data directory
11
+ #
12
+ # InstalledMOD represents an actual MOD package found in either:
13
+ # - The MOD directory (user-installed MODs as ZIP files or directories)
14
+ # - The data directory (base and expansion MODs bundled with the game)
15
+ #
16
+ # This is distinct from MOD (which is just a name identifier) and
17
+ # MODState (which represents desired state in mod-list.json).
18
+ class InstalledMOD
19
+ # @!attribute [r] mod
20
+ # @return [Factorix::MOD] The MOD identifier
21
+ # @!attribute [r] version
22
+ # @return [Factorix::MODVersion] The MOD version
23
+ # @!attribute [r] form
24
+ # @return [Symbol] :zip or :directory
25
+ # @!attribute [r] path
26
+ # @return [Pathname] The path to the ZIP file or directory
27
+ # @!attribute [r] info
28
+ # @return [Factorix::InfoJSON] The parsed info.json metadata
29
+
30
+ include Comparable
31
+
32
+ # Make the class itself enumerable over all installed MODs
33
+ extend Enumerable
34
+
35
+ # Form constants
36
+ ZIP_FORM = :zip
37
+ public_constant :ZIP_FORM
38
+ DIRECTORY_FORM = :directory
39
+ public_constant :DIRECTORY_FORM
40
+
41
+ # Get all installed MODs
42
+ #
43
+ # @param handler [Progress::ScanHandler, nil] optional event handler for progress tracking
44
+ # @return [Array<InstalledMOD>] Array of all installed MODs
45
+ def self.all(handler: nil)
46
+ scanner = Scanner.new
47
+ scanner.subscribe(handler) if handler
48
+ result = scanner.scan
49
+ scanner.unsubscribe(handler) if handler
50
+ result
51
+ end
52
+
53
+ # Enumerate over all installed MODs
54
+ #
55
+ # @yieldparam [InstalledMOD] mod Each installed MOD
56
+ # @return [Enumerator, Array] Enumerator if no block given, otherwise the result of the block
57
+ def self.each(&) = all.each(&)
58
+
59
+ # Create InstalledMOD from a ZIP file
60
+ #
61
+ # @param path [Pathname] Path to the ZIP file
62
+ # @return [InstalledMOD] New InstalledMOD instance
63
+ # @raise [FileFormatError] if ZIP file is invalid
64
+ def self.from_zip(path)
65
+ info = InfoJSON.from_zip(path)
66
+
67
+ expected_filename = "#{info.name}_#{info.version}.zip"
68
+ actual_filename = path.basename.to_s
69
+
70
+ unless actual_filename == expected_filename
71
+ raise FileFormatError, "Filename mismatch: expected #{expected_filename}, got #{actual_filename}"
72
+ end
73
+
74
+ new(mod: MOD[name: info.name], version: info.version, form: ZIP_FORM, path:, info:)
75
+ end
76
+
77
+ # Create InstalledMOD from a directory
78
+ #
79
+ # @param path [Pathname] Path to the directory
80
+ # @return [InstalledMOD] New InstalledMOD instance
81
+ # @raise [FileFormatError] if directory is invalid
82
+ def self.from_directory(path)
83
+ info_path = path + "info.json"
84
+ raise FileFormatError, "Missing info.json" unless info_path.file?
85
+
86
+ info = InfoJSON.from_json(info_path.read)
87
+
88
+ dirname = path.basename.to_s
89
+ expected_unversioned = info.name
90
+ expected_versioned = "#{info.name}_#{info.version}"
91
+
92
+ unless dirname == expected_unversioned || dirname == expected_versioned
93
+ raise FileFormatError, "Directory name mismatch: expected #{expected_unversioned} or #{expected_versioned}, got #{dirname}"
94
+ end
95
+
96
+ new(mod: MOD[name: info.name], version: info.version, form: DIRECTORY_FORM, path:, info:)
97
+ end
98
+
99
+ # Scanner for finding installed MODs
100
+ #
101
+ # Scans MOD directory and data directory for installed MODs.
102
+ # Gets directory paths from Runtime automatically.
103
+ # Publishes progress events during scan.
104
+ class Scanner
105
+ include Import[:runtime, :logger]
106
+ include Dry::Events::Publisher[:scanner]
107
+
108
+ register_event("scan.started")
109
+ register_event("scan.progress")
110
+ register_event("scan.completed")
111
+
112
+ DEFAULT_PARALLEL_JOBS = 4
113
+ private_constant :DEFAULT_PARALLEL_JOBS
114
+
115
+ # Scan directories for installed MODs
116
+ #
117
+ # Scans the MOD directory for both ZIP and directory form MODs.
118
+ # Also scans the data directory for base/expansion MODs.
119
+ # Invalid packages are skipped with debug logging.
120
+ # Publishes scan.started, scan.progress, and scan.completed events.
121
+ #
122
+ # @return [Array<InstalledMOD>] Array of installed MODs
123
+ def scan
124
+ mod_dir = runtime.mod_dir
125
+ data_dir = runtime.data_dir
126
+
127
+ mod_paths = mod_dir.children.select {|path| (path.file? && path.extname == ".zip") || path.directory? }
128
+ data_paths = data_dir.children.select {|path|
129
+ next false unless path.directory?
130
+
131
+ mod_name = path.basename.to_s
132
+ candidate_mod = MOD[name: mod_name]
133
+ candidate_mod.base? || candidate_mod.expansion?
134
+ }
135
+
136
+ total = mod_paths.size + data_paths.size
137
+ current = 0
138
+ mutex = Mutex.new
139
+
140
+ publish("scan.started", total:)
141
+
142
+ pool = Concurrent::FixedThreadPool.new(DEFAULT_PARALLEL_JOBS)
143
+
144
+ begin
145
+ futures = mod_paths.map {|path|
146
+ Concurrent::Future.execute(executor: pool) do
147
+ result = scan_mod_path(path)
148
+ mutex.synchronize do
149
+ current += 1
150
+ publish("scan.progress", current:, total:)
151
+ end
152
+ result
153
+ end
154
+ }
155
+
156
+ installed_mods = futures.filter_map(&:value)
157
+ ensure
158
+ pool.shutdown
159
+ pool.wait_for_termination
160
+ end
161
+
162
+ data_paths.each do |path|
163
+ result = scan_mod_path(path)
164
+ installed_mods << result if result
165
+ current += 1
166
+ publish("scan.progress", current:, total:)
167
+ end
168
+
169
+ publish("scan.completed", total: installed_mods.size)
170
+
171
+ resolved = resolve_duplicates(installed_mods)
172
+ resolved.sort_by(&:version).reverse
173
+ end
174
+
175
+ # Scan a single MOD path (ZIP file or directory)
176
+ #
177
+ # @param path [Pathname] Path to scan
178
+ # @return [InstalledMOD, nil] The installed MOD, or nil if invalid
179
+ private def scan_mod_path(path)
180
+ if path.file? && path.extname == ".zip"
181
+ InstalledMOD.from_zip(path)
182
+ elsif path.directory?
183
+ InstalledMOD.from_directory(path)
184
+ end
185
+ rescue ArgumentError => e
186
+ logger.debug("Skipping invalid MOD package", path: path.to_s, reason: e.message)
187
+ nil
188
+ rescue => e
189
+ logger.debug("Error loading MOD package", path: path.to_s, error: e.message)
190
+ nil
191
+ end
192
+
193
+ # Resolve duplicate MODs (same name and version)
194
+ #
195
+ # When multiple MODs with the same name and version exist, prefer
196
+ # directory form over ZIP form.
197
+ #
198
+ # @param mods [Array<InstalledMOD>] Array of installed MODs
199
+ # @return [Array<InstalledMOD>] Array with duplicates resolved
200
+ private def resolve_duplicates(mods)
201
+ groups = mods.group_by {|mod| [mod.mod, mod.version] }
202
+ groups.map {|_key, group_mods| group_mods.max }
203
+ end
204
+ end
205
+ private_constant :Scanner
206
+
207
+ # Compare with another InstalledMOD
208
+ #
209
+ # Comparison is by version (ascending), then by form (directory > ZIP)
210
+ #
211
+ # @param other [InstalledMOD] The other InstalledMOD
212
+ # @return [Integer, nil] -1, 0, 1 for less than, equal to, greater than; nil if not comparable
213
+ def <=>(other)
214
+ return nil unless other.is_a?(InstalledMOD)
215
+ return nil unless mod == other.mod
216
+
217
+ # Compare by version (ascending), then by form priority (directory > ZIP)
218
+ (version <=> other.version).nonzero? || form_priority(form) <=> form_priority(other.form)
219
+ end
220
+
221
+ # Check if this is the base MOD
222
+ #
223
+ # @return [Boolean] true if this is the base MOD
224
+ def base? = mod.base?
225
+
226
+ # Check if this is an expansion MOD
227
+ #
228
+ # @return [Boolean] true if this is an expansion MOD
229
+ def expansion? = mod.expansion?
230
+
231
+ private def form_priority(form)
232
+ case form
233
+ when DIRECTORY_FORM then 1
234
+ when ZIP_FORM then 0
235
+ else -1
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ MOD = Data.define(:name)
5
+
6
+ # Represents a local MOD
7
+ #
8
+ # This class encapsulates a MOD's name and provides utility methods
9
+ # for MOD identification and comparison.
10
+ class MOD
11
+ include Comparable
12
+
13
+ # @!attribute [r] name
14
+ # @return [String] the name of the MOD
15
+
16
+ # Expansion MOD names
17
+ EXPANSION_MODS = %w[space-age quality elevated-rails].freeze
18
+ private_constant :EXPANSION_MODS
19
+
20
+ # Check if this MOD is the base MOD
21
+ #
22
+ # @return [Boolean] true if this MOD is the base MOD
23
+ # @note The check is case-sensitive, only "base" (not "BASE" or "Base") is considered the base MOD
24
+ def base? = name == "base"
25
+
26
+ # Check if this MOD is an expansion MOD
27
+ #
28
+ # @return [Boolean] true if this MOD is an expansion MOD (space-age, quality, or elevated-rails)
29
+ # @note The check is case-sensitive
30
+ def expansion? = EXPANSION_MODS.include?(name)
31
+
32
+ # Return the name of the MOD
33
+ #
34
+ # @return [String] the name of the MOD
35
+ def to_s = name
36
+
37
+ # Compare this MOD with another MOD by name
38
+ #
39
+ # @param other [MOD] the other MOD
40
+ # @return [Integer] -1 if this MOD precedes the other, 0 if they are equal, 1 if this MOD follows the other
41
+ # @note Comparison is case-sensitive for MOD names.
42
+ # @note The base MOD (exactly "base", case-sensitive) always comes before any other MOD.
43
+ def <=>(other)
44
+ return nil unless other.is_a?(MOD)
45
+
46
+ if base?
47
+ other.base? ? 0 : -1
48
+ elsif other.base?
49
+ 1
50
+ else
51
+ name <=> other.name
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Factorix
6
+ # Represents a list of MODs and their enabled status
7
+ #
8
+ # This class manages the mod-list.json file, which contains the list of MODs
9
+ # and their enabled/disabled states.
10
+ class MODList
11
+ include Enumerable
12
+
13
+ # Raised when a MOD is not found in the list
14
+ class MODNotInListError < MODNotFoundError; end
15
+
16
+ # Load the MOD list from the given file
17
+ #
18
+ # @param path [Pathname] the path to the file to load the MOD list from (default: runtime.mod_list_path)
19
+ # @return [Factorix::MODList] the loaded MOD list
20
+ # @raise [MODSettingsError] if the base MOD is disabled
21
+ def self.load(path=Application[:runtime].mod_list_path)
22
+ raw_data = JSON.parse(path.read, symbolize_names: true)
23
+ mods_hash = raw_data[:mods].to_h {|entry|
24
+ mod = MOD[name: entry[:name]]
25
+ version = entry[:version] ? MODVersion.from_string(entry[:version]) : nil
26
+ state = MODState[enabled: entry[:enabled], version:]
27
+
28
+ # Validate that base MOD is not disabled
29
+ if mod.base? && !entry[:enabled]
30
+ raise MODSettingsError, "base MOD cannot be disabled"
31
+ end
32
+
33
+ [mod, state]
34
+ }
35
+ new(mods_hash)
36
+ end
37
+
38
+ # Initialize the MOD list
39
+ #
40
+ # @param mods [Hash{Factorix::MOD => Factorix::MODState}] the MODs and their state
41
+ # @return [void]
42
+ def initialize(mods={})
43
+ @mods = {}
44
+ mods.each do |mod, state|
45
+ @mods[mod] = state
46
+ end
47
+ end
48
+
49
+ # Save the MOD list to the given file
50
+ #
51
+ # @param path [Pathname] the path to the file to save the MOD list to (default: runtime.mod_list_path)
52
+ # @return [void]
53
+ def save(path=Application[:runtime].mod_list_path)
54
+ mods_data = @mods.map {|mod, state|
55
+ data = {name: mod.name, enabled: state.enabled?}
56
+ # Only include version in the output if it exists
57
+ data[:version] = state.version.to_s if state.version
58
+ data
59
+ }
60
+ path.write(JSON.pretty_generate({mods: mods_data}))
61
+ end
62
+
63
+ # Iterate through all MOD-state pairs
64
+ #
65
+ # @yieldparam mod [Factorix::MOD] the MOD
66
+ # @yieldparam state [Factorix::MODState] the MOD state
67
+ # @return [Enumerator] if no block is given
68
+ # @return [Factorix::MODList] if a block is given
69
+ def each(&block)
70
+ return @mods.to_enum unless block
71
+
72
+ @mods.each(&block)
73
+ self
74
+ end
75
+
76
+ # Iterate through all MODs
77
+ #
78
+ # @yieldparam mod [Factorix::MOD] the MOD
79
+ # @return [Enumerator] if no block is given
80
+ # @return [Factorix::MODList] if a block is given
81
+ def each_mod(&block)
82
+ return @mods.keys.to_enum unless block
83
+
84
+ @mods.each_key(&block)
85
+ self
86
+ end
87
+
88
+ # Alias for each_mod
89
+ #
90
+ # @yieldparam mod [Factorix::MOD] the MOD
91
+ # @return [Enumerator] if no block is given
92
+ # @return [Factorix::MODList] if a block is given
93
+ alias each_key each_mod
94
+
95
+ # Add the MOD to the list
96
+ #
97
+ # @param mod [Factorix::MOD] the MOD to add
98
+ # @param enabled [Boolean] the enabled status. Default to true
99
+ # @param version [Factorix::MODVersion, nil] the version of the MOD. Default to nil
100
+ # @return [void]
101
+ # @raise [MODSettingsError] if the MOD is the base MOD and the enabled status is false
102
+ def add(mod, enabled: true, version: nil)
103
+ raise MODSettingsError, "can't disable the base MOD" if mod.base? && enabled == false
104
+
105
+ @mods[mod] = MODState[enabled:, version:]
106
+ end
107
+
108
+ # Remove the MOD from the list
109
+ #
110
+ # @param mod [Factorix::MOD] the MOD to remove
111
+ # @return [void]
112
+ # @raise [MODSettingsError] if the MOD is the base MOD or an expansion MOD
113
+ def remove(mod)
114
+ raise MODSettingsError, "can't remove the base MOD" if mod.base?
115
+ raise MODSettingsError, "can't remove expansion MOD: #{mod}" if mod.expansion?
116
+
117
+ @mods.delete(mod)
118
+ end
119
+
120
+ # Check if the MOD is in the list
121
+ #
122
+ # @param mod [Factorix::MOD] the MOD to check
123
+ # @return [Boolean] true if the MOD is in the list, false otherwise
124
+ def exist?(mod) = @mods.key?(mod)
125
+
126
+ # Check if the MOD is enabled
127
+ #
128
+ # @param mod [Factorix::MOD] the MOD to check
129
+ # @return [Boolean] true if the MOD is enabled, false otherwise
130
+ # @raise [Factorix::MODList::MODNotInListError] if the MOD is not in the list
131
+ def enabled?(mod)
132
+ raise MODNotInListError, "MOD not in the list: #{mod}" unless exist?(mod)
133
+
134
+ @mods[mod].enabled?
135
+ end
136
+
137
+ # Get the version of the MOD
138
+ #
139
+ # @param mod [Factorix::MOD] the MOD to check
140
+ # @return [Factorix::MODVersion, nil] the version of the MOD, or nil if not specified
141
+ # @raise [Factorix::MODList::MODNotInListError] if the MOD is not in the list
142
+ def version(mod)
143
+ raise MODNotInListError, "MOD not in the list: #{mod}" unless exist?(mod)
144
+
145
+ @mods[mod].version
146
+ end
147
+
148
+ # Enable the MOD
149
+ #
150
+ # @param mod [Factorix::MOD] the MOD to enable
151
+ # @return [void]
152
+ # @raise [Factorix::MODList::MODNotInListError] if the MOD is not in the list
153
+ def enable(mod)
154
+ raise MODNotInListError, "MOD not in the list: #{mod}" unless exist?(mod)
155
+
156
+ current_state = @mods[mod]
157
+ @mods[mod] = MODState[enabled: true, version: current_state.version]
158
+ end
159
+
160
+ # Disable the MOD
161
+ #
162
+ # @param mod [Factorix::MOD] the MOD to disable
163
+ # @return [void]
164
+ # @raise [MODSettingsError] if the MOD is the base MOD
165
+ # @raise [Factorix::MODList::MODNotInListError] if the MOD is not in the list
166
+ def disable(mod)
167
+ raise MODSettingsError, "can't disable the base MOD" if mod.base?
168
+ raise MODNotInListError, "MOD not in the list: #{mod}" unless exist?(mod)
169
+
170
+ current_state = @mods[mod]
171
+ @mods[mod] = MODState[enabled: false, version: current_state.version]
172
+ end
173
+ end
174
+ end