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,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tint_me"
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module MOD
9
+ # Show detailed MOD information from portal
10
+ class Show < Base
11
+ # Style for MOD title (bold + underline)
12
+ TITLE_STYLE = TIntMe[:bold, :underline]
13
+ private_constant :TITLE_STYLE
14
+
15
+ # Style for section headers (bold)
16
+ HEADER_STYLE = TIntMe[:bold]
17
+ private_constant :HEADER_STYLE
18
+
19
+ # Style for incompatible MODs (red)
20
+ INCOMPATIBLE_MOD_STYLE = TIntMe[:red]
21
+ private_constant :INCOMPATIBLE_MOD_STYLE
22
+ # @!parse
23
+ # # @return [Portal]
24
+ # attr_reader :portal
25
+ # # @return [Runtime]
26
+ # attr_reader :runtime
27
+ include Import[:portal, :runtime]
28
+
29
+ desc "Show MOD details from Factorio MOD Portal"
30
+
31
+ example [
32
+ "some-mod # Show details for some-mod"
33
+ ]
34
+
35
+ argument :mod_name, type: :string, required: true, desc: "MOD name to show"
36
+
37
+ # Execute the show command
38
+ #
39
+ # @param mod_name [String] MOD name to show details for
40
+ # @return [void]
41
+ # @raise [BundledMODError] if mod_name is base or an expansion MOD
42
+ def call(mod_name:, **)
43
+ mod = Factorix::MOD[mod_name]
44
+ raise BundledMODError, "Cannot show base MOD" if mod.base?
45
+ raise BundledMODError, "Cannot show expansion MOD: #{mod_name}" if mod.expansion?
46
+
47
+ mod_info = portal.get_mod_full(mod_name)
48
+ local_status = fetch_local_status(mod_name)
49
+
50
+ display_header(mod_info)
51
+ display_basic_info(mod_info, local_status)
52
+ display_links(mod_info)
53
+ display_dependencies(mod_info)
54
+ display_incompatibilities(mod_info)
55
+ end
56
+
57
+ private def fetch_local_status(mod_name)
58
+ mod_list = MODList.load
59
+ mod = Factorix::MOD[mod_name]
60
+ installed_mod = find_installed_mod(mod_name)
61
+
62
+ enabled = mod_list.exist?(mod) && mod_list.enabled?(mod)
63
+
64
+ {
65
+ installed: !installed_mod.nil?,
66
+ enabled:,
67
+ local_version: installed_mod&.version
68
+ }
69
+ end
70
+
71
+ private def find_installed_mod(mod_name)
72
+ InstalledMOD.all.find {|m| m.mod.name == mod_name }
73
+ rescue
74
+ nil
75
+ end
76
+
77
+ private def display_header(mod_info)
78
+ puts TITLE_STYLE[mod_info.title]
79
+ puts
80
+ puts mod_info.summary unless mod_info.summary.empty?
81
+ puts
82
+ end
83
+
84
+ private def display_basic_info(mod_info, local_status)
85
+ latest_release = mod_info.latest_release || mod_info.releases.max_by(&:version)
86
+ factorio_version = latest_release&.info_json&.dig(:factorio_version)
87
+
88
+ rows = []
89
+ rows << ["Status", format_status(local_status)]
90
+ rows << ["Version", latest_release&.version&.to_s || "N/A"]
91
+ if local_status[:installed] && local_status[:local_version]
92
+ local_ver = local_status[:local_version].to_s
93
+ latest_ver = latest_release&.version&.to_s
94
+ if latest_ver && local_ver != latest_ver
95
+ rows << ["Installed Version", "#{local_ver} (update available)"]
96
+ end
97
+ end
98
+ rows << ["Author", mod_info.owner]
99
+ rows << ["Category", mod_info.category.name]
100
+ rows << ["License", format_license(mod_info)]
101
+ rows << ["Factorio Version", factorio_version || "N/A"]
102
+ rows << ["Downloads", mod_info.downloads_count.to_s]
103
+
104
+ max_label_width = rows.map {|label, _| label.length }.max
105
+ rows.each do |label, value|
106
+ puts "#{label.ljust(max_label_width)} #{value}"
107
+ end
108
+ puts
109
+ end
110
+
111
+ private def format_status(local_status)
112
+ if local_status[:installed]
113
+ local_status[:enabled] ? "Enabled" : "Disabled"
114
+ else
115
+ "Not installed"
116
+ end
117
+ end
118
+
119
+ private def format_license(mod_info)
120
+ return "N/A" unless mod_info.detail&.license
121
+
122
+ mod_info.detail.license.title
123
+ end
124
+
125
+ private def display_links(mod_info)
126
+ puts HEADER_STYLE["Links"]
127
+ puts " MOD Portal: https://mods.factorio.com/mod/#{mod_info.name}"
128
+
129
+ if mod_info.detail
130
+ if mod_info.detail.source_url
131
+ puts " Source: #{mod_info.detail.source_url}"
132
+ end
133
+ if mod_info.detail.homepage
134
+ puts " Homepage: #{mod_info.detail.homepage}"
135
+ end
136
+ end
137
+ puts
138
+ end
139
+
140
+ private def display_dependencies(mod_info)
141
+ latest_release = mod_info.latest_release || mod_info.releases.max_by(&:version)
142
+ return unless latest_release
143
+
144
+ dependencies = latest_release.info_json[:dependencies] || []
145
+ return if dependencies.empty?
146
+
147
+ parsed = dependencies.filter_map {|dep_str| parse_dependency(dep_str) }
148
+ required = parsed.select {|d| d[:type] == :required }
149
+ optional = parsed.select {|d| d[:type] == :optional }
150
+
151
+ unless required.empty?
152
+ puts HEADER_STYLE["Dependencies"]
153
+ required.each {|dep| display_dependency(dep) }
154
+ puts
155
+ end
156
+
157
+ return if optional.empty?
158
+
159
+ puts HEADER_STYLE["Optional Dependencies"]
160
+ optional.each {|dep| display_dependency(dep) }
161
+ puts
162
+ end
163
+
164
+ private def parse_dependency(dep_str)
165
+ # Handle prefixes: ! (incompatible), ? (optional), (?) (hidden optional), ~ (load neutral)
166
+ case dep_str
167
+ when /\A!\s*(.+)/
168
+ {type: :incompatible, spec: ::Regexp.last_match(1).strip}
169
+ when /\A\(\?\)\s*(.+)/
170
+ {type: :hidden_optional, spec: ::Regexp.last_match(1).strip}
171
+ when /\A\?\s*(.+)/
172
+ {type: :optional, spec: ::Regexp.last_match(1).strip}
173
+ when /\A~\s*(.+)/
174
+ {type: :load_neutral, spec: ::Regexp.last_match(1).strip}
175
+ else
176
+ {type: :required, spec: dep_str.strip}
177
+ end
178
+ end
179
+
180
+ private def display_dependency(dep)
181
+ puts " #{dep[:spec]}"
182
+ end
183
+
184
+ private def display_incompatibilities(mod_info)
185
+ latest_release = mod_info.latest_release || mod_info.releases.max_by(&:version)
186
+ return unless latest_release
187
+
188
+ dependencies = latest_release.info_json[:dependencies] || []
189
+ parsed = dependencies.filter_map {|dep_str| parse_dependency(dep_str) }
190
+ incompatible = parsed.select {|d| d[:type] == :incompatible }
191
+
192
+ return if incompatible.empty?
193
+
194
+ puts HEADER_STYLE["Incompatibilities"]
195
+ incompatible.each {|dep| puts " #{INCOMPATIBLE_MOD_STYLE[dep[:spec]]}" }
196
+ puts
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/executor/fixed_thread_pool"
4
+ require "concurrent/future"
5
+
6
+ module Factorix
7
+ class CLI
8
+ module Commands
9
+ module MOD
10
+ # Sync MOD states and startup settings from a save file
11
+ class Sync < Base
12
+ confirmable!
13
+ require_game_stopped!
14
+ backup_support!
15
+
16
+ include DownloadSupport
17
+
18
+ # @!parse
19
+ # # @return [Portal]
20
+ # attr_reader :portal
21
+ # # @return [Dry::Logger::Dispatcher]
22
+ # attr_reader :logger
23
+ # # @return [Factorix::Runtime]
24
+ # attr_reader :runtime
25
+ include Import[:portal, :logger, :runtime]
26
+
27
+ desc "Sync MOD states and startup settings from a save file"
28
+
29
+ example [
30
+ "save.zip # Sync MOD(s) from save file",
31
+ "-j 8 save.zip # Use 8 parallel downloads"
32
+ ]
33
+
34
+ argument :save_file, type: :string, required: true, desc: "Path to Factorio save file (.zip)"
35
+ option :jobs, type: :integer, aliases: ["-j"], default: 4, desc: "Number of parallel downloads"
36
+
37
+ # Execute the sync command
38
+ #
39
+ # @param save_file [String] Path to save file
40
+ # @param jobs [Integer] Number of parallel downloads
41
+ # @return [void]
42
+ def call(save_file:, jobs: 4, **)
43
+ # Load save file
44
+ say "Loading save file: #{save_file}", prefix: :info
45
+ save_data = SaveFile.load(Pathname(save_file))
46
+ say "Loaded save file (version: #{save_data.version}, MOD(s): #{save_data.mods.size})", prefix: :info
47
+
48
+ # Load current state
49
+ mod_list = MODList.load
50
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: $stderr)
51
+ handler = Progress::ScanHandler.new(presenter)
52
+ installed_mods = InstalledMOD.all(handler:)
53
+ graph = Dependency::Graph::Builder.build(installed_mods:, mod_list:)
54
+
55
+ raise DirectoryNotFoundError, "MOD directory does not exist: #{runtime.mod_dir}" unless runtime.mod_dir.exist?
56
+
57
+ # Find MODs that need to be installed
58
+ mods_to_install = find_mods_to_install(save_data.mods, installed_mods)
59
+
60
+ if mods_to_install.any?
61
+ say "#{mods_to_install.size} MOD(s) need to be installed", prefix: :info
62
+
63
+ # Plan installation
64
+ install_targets = plan_installation(mods_to_install, graph, jobs)
65
+
66
+ # Show plan
67
+ show_install_plan(install_targets)
68
+ return unless confirm?("Do you want to install these MOD(s)?")
69
+
70
+ # Execute installation
71
+ execute_installation(install_targets, jobs)
72
+ say "Installed #{install_targets.size} MOD(s)", prefix: :success
73
+ else
74
+ say "All MOD(s) from save file are already installed", prefix: :info
75
+ end
76
+
77
+ # Resolve conflicts: disable existing MODs that conflict with new ones
78
+ resolve_conflicts(mod_list, save_data.mods, graph)
79
+
80
+ # Update mod-list.json
81
+ update_mod_list(mod_list, save_data.mods)
82
+ backup_if_exists(runtime.mod_list_path)
83
+ mod_list.save
84
+ say "Updated mod-list.json", prefix: :success
85
+
86
+ # Update mod-settings.dat
87
+ update_mod_settings(save_data.startup_settings, save_data.version)
88
+ say "Updated mod-settings.dat", prefix: :success
89
+
90
+ say "Sync completed successfully", prefix: :success
91
+ end
92
+
93
+ private def find_mods_to_install(save_mods, installed_mods)
94
+ save_mods.reject do |mod_name, _mod_state|
95
+ # Skip base MOD (always installed)
96
+ next true if mod_name == "base"
97
+
98
+ # Check if MOD is installed
99
+ mod = Factorix::MOD[name: mod_name]
100
+ installed_mods.any? {|installed| installed.mod == mod }
101
+ end
102
+ end
103
+
104
+ # Plan the installation by fetching MOD info and extending the graph
105
+ #
106
+ # @param mods_to_install [Hash<String, MODState>] MODs to install
107
+ # @param graph [Dependency::Graph] Current dependency graph
108
+ # @param jobs [Integer] Number of parallel jobs
109
+ # @return [Array<Hash>] Installation targets with MOD info and releases
110
+ private def plan_installation(mods_to_install, graph, jobs)
111
+ # Create progress presenter for info fetching
112
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Fetching MOD info", output: $stderr)
113
+
114
+ # Fetch info for MODs to install
115
+ target_infos = fetch_target_mod_info(mods_to_install, jobs, presenter)
116
+
117
+ # Add to graph
118
+ target_infos.each do |info|
119
+ graph.add_uninstalled_mod(info[:mod_info], info[:release])
120
+ end
121
+
122
+ # Build install targets
123
+ build_install_targets(target_infos, runtime.mod_dir)
124
+ end
125
+
126
+ # Fetch MOD information for MODs to install
127
+ #
128
+ # @param mods_to_install [Hash<String, MODState>] MODs to install
129
+ # @param jobs [Integer] Number of parallel jobs
130
+ # @param presenter [Progress::Presenter] Progress presenter
131
+ # @return [Array<Hash>] Array of {mod_name:, mod_info:, release:, version:}
132
+ private def fetch_target_mod_info(mods_to_install, jobs, presenter)
133
+ presenter.start(total: mods_to_install.size)
134
+
135
+ pool = Concurrent::FixedThreadPool.new(jobs)
136
+
137
+ futures = mods_to_install.map {|mod_name, mod_state|
138
+ Concurrent::Future.execute(executor: pool) do
139
+ result = fetch_single_mod_info(mod_name, mod_state.version)
140
+ presenter.update
141
+ result
142
+ end
143
+ }
144
+
145
+ results = futures.map(&:value!)
146
+ results
147
+ ensure
148
+ pool&.shutdown
149
+ pool&.wait_for_termination
150
+ end
151
+
152
+ # Fetch information for a single MOD
153
+ #
154
+ # @param mod_name [String] MOD name
155
+ # @param version [MODVersion] Target version
156
+ # @return [Hash] {mod_name:, mod_info:, release:, version:}
157
+ private def fetch_single_mod_info(mod_name, version)
158
+ # Fetch full MOD info from portal
159
+ mod_info = portal.get_mod_full(mod_name)
160
+
161
+ # Find the specific version release
162
+ release = mod_info.releases.find {|r| r.version == version }
163
+
164
+ unless release
165
+ raise MODNotOnPortalError, "Release not found for #{mod_name}@#{version}"
166
+ end
167
+
168
+ {mod_name:, mod_info:, release:, version:}
169
+ end
170
+
171
+ # Show the installation plan
172
+ #
173
+ # @param targets [Array<Hash>] Installation targets
174
+ # @return [void]
175
+ private def show_install_plan(targets)
176
+ say "Planning to install #{targets.size} MOD(s):", prefix: :info
177
+ targets.each do |target|
178
+ say " - #{target[:mod]}@#{target[:release].version}"
179
+ end
180
+ end
181
+
182
+ # Execute the installation
183
+ #
184
+ # @param targets [Array<Hash>] Installation targets
185
+ # @param jobs [Integer] Number of parallel jobs
186
+ # @return [void]
187
+ private def execute_installation(targets, jobs)
188
+ # Download all MODs
189
+ download_mods(targets, jobs)
190
+ end
191
+
192
+ # Resolve conflicts between save file MODs and existing enabled MODs
193
+ #
194
+ # @param mod_list [MODList] Current MOD list
195
+ # @param save_mods [Hash<String, MODState>] MODs from save file
196
+ # @param graph [Dependency::Graph] Dependency graph
197
+ # @return [void]
198
+ private def resolve_conflicts(mod_list, save_mods, graph)
199
+ save_mods.each do |mod_name, mod_state|
200
+ next unless mod_state.enabled?
201
+
202
+ mod = Factorix::MOD[name: mod_name]
203
+
204
+ graph.edges_from(mod).each do |edge|
205
+ next unless edge.incompatible?
206
+
207
+ conflicting_mod = edge.to_mod
208
+ next unless mod_list.exist?(conflicting_mod) && mod_list.enabled?(conflicting_mod)
209
+
210
+ mod_list.disable(conflicting_mod)
211
+ say "Disabled #{conflicting_mod} (conflicts with #{mod} from save file)", prefix: :warn
212
+ logger.debug("Disabled conflicting MOD", mod_name: conflicting_mod.name, conflicts_with: mod.name)
213
+ end
214
+
215
+ graph.edges_to(mod).each do |edge|
216
+ next unless edge.incompatible?
217
+
218
+ conflicting_mod = edge.from_mod
219
+ next unless mod_list.exist?(conflicting_mod) && mod_list.enabled?(conflicting_mod)
220
+
221
+ mod_list.disable(conflicting_mod)
222
+ say "Disabled #{conflicting_mod} (conflicts with #{mod} from save file)", prefix: :warn
223
+ logger.debug("Disabled conflicting MOD", mod_name: conflicting_mod.name, conflicts_with: mod.name)
224
+ end
225
+ end
226
+ end
227
+
228
+ # Update mod-list.json with MODs from save file
229
+ #
230
+ # @param mod_list [MODList] Current MOD list
231
+ # @param save_mods [Hash<String, MODState>] MODs from save file
232
+ # @return [void]
233
+ private def update_mod_list(mod_list, save_mods)
234
+ save_mods.each do |mod_name, mod_state|
235
+ mod = Factorix::MOD[name: mod_name]
236
+
237
+ # base MOD: don't update version or enabled state
238
+ if mod.base?
239
+ logger.debug("Skipping base MOD (no changes allowed)", mod_name:)
240
+ next
241
+ end
242
+
243
+ if mod_list.exist?(mod)
244
+ # expansion MOD: only update enabled state (not version)
245
+ if mod.expansion?
246
+ if mod_state.enabled? && !mod_list.enabled?(mod)
247
+ mod_list.enable(mod)
248
+ logger.debug("Enabled expansion MOD in mod-list.json", mod_name:)
249
+ elsif !mod_state.enabled? && mod_list.enabled?(mod)
250
+ mod_list.disable(mod)
251
+ logger.debug("Disabled expansion MOD in mod-list.json", mod_name:)
252
+ end
253
+ else
254
+ # Regular MOD: update both version and enabled state
255
+ # Remove and re-add to update version
256
+ mod_list.remove(mod)
257
+ mod_list.add(mod, enabled: mod_state.enabled?, version: mod_state.version)
258
+ logger.debug("Updated MOD in mod-list.json", mod_name:, version: mod_state.version&.to_s, enabled: mod_state.enabled?)
259
+ end
260
+ else
261
+ # Add new entry (version from save file)
262
+ mod_list.add(mod, enabled: mod_state.enabled?, version: mod_state.version)
263
+ logger.debug("Added to mod-list.json", mod_name:, version: mod_state.version&.to_s)
264
+ end
265
+ end
266
+ end
267
+
268
+ # Update mod-settings.dat with startup settings from save file
269
+ #
270
+ # @param startup_settings [MODSettings::Section] Startup settings from save file
271
+ # @param game_version [GameVersion] Game version from save file
272
+ # @return [void]
273
+ private def update_mod_settings(startup_settings, game_version)
274
+ # Load existing settings or create new
275
+ mod_settings = if runtime.mod_settings_path.exist?
276
+ MODSettings.load(runtime.mod_settings_path)
277
+ else
278
+ # Create new MODSettings with all sections
279
+ sections = MODSettings::VALID_SECTIONS.to_h {|section_name|
280
+ [section_name, MODSettings::Section.new(section_name)]
281
+ }
282
+ MODSettings.new(game_version, sections)
283
+ end
284
+
285
+ # Merge startup settings from save file
286
+ startup_section = mod_settings["startup"]
287
+ startup_settings.each do |key, value|
288
+ startup_section[key] = value
289
+ end
290
+
291
+ # Save updated settings
292
+ backup_if_exists(runtime.mod_settings_path)
293
+ mod_settings.save(runtime.mod_settings_path)
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end