factorix 0.9.1 → 0.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5212e69bd41c45ceffb0fd42e889868b706b0481e6a07567c98b2cce439bb5fc
4
- data.tar.gz: e2386ba4154617497efbeddea3537b58eb164dd5af66e963c325d06626536895
3
+ metadata.gz: f93238fc1b419e6adf7ec45ac04331f84aba675b94cf4d4e57fd7c8555687678
4
+ data.tar.gz: 2e7d4ed3b5c0b4be85fd5f1012eb4a73b84b160d5f31c1ed326a5830cba41c42
5
5
  SHA512:
6
- metadata.gz: 62ece69fa0e7028367dff967cfb6e5d93ba4c5413be84b9a38cd13466687202a9417b57c09b4b137ca26e149a556b1063d086342747ae95590ace736c492efa6
7
- data.tar.gz: 8687814b5cb8f3af5e695db5765117c113de95d09fa361a87e04222f106e30abb935be426eb775751410d0181ee73c9171af9380fea5358d2ae936e9c12ec267
6
+ metadata.gz: 254733ebd3ad1b896a84c341d35a49a2d8943114d4fe0f90d0b83efae5399d9f862aedaeb0f7a68d42cdfedad9ab0dae9a4d77625646540de111e77567b74ab6
7
+ data.tar.gz: 733b7da2d624bbf225e9bcbd586a0aa394c593d79743456c2a9b1a8ef06daf05603f1f4091da657038f7bacfdb766061a48026084a1d72290cc3350df2e8e694
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.11.0] - 2026-03-03
4
+
5
+ ### Added
6
+
7
+ - Add `blueprint decode` command to decode Factorio blueprint strings to JSON (#76)
8
+ - Add `blueprint encode` command to encode JSON to Factorio blueprint strings (#76)
9
+
10
+ ### Changed
11
+
12
+ - `mod sync` now disables enabled MODs (including expansion MODs) not listed in the save file by default (#70)
13
+ - Add `--keep-unlisted` option to `mod sync` to preserve MODs not listed in the save file (#70)
14
+
15
+ ## [0.10.0] - 2026-02-21
16
+
17
+ ### Changed
18
+
19
+ - Refactor `License` to use flyweight pattern for standard licenses with `.for(id)` method
20
+ - Add `.identifiers` class method to `Category`, `License`, and `Tag`
21
+ - Replace `License.identifier_values` with `License.identifiers`
22
+
3
23
  ## [0.9.1] - 2026-02-20
4
24
 
5
25
  ### Added
data/README.md CHANGED
@@ -18,6 +18,7 @@ Factorix simplifies Factorio MOD management by providing:
18
18
  - **Installation & Uninstallation**: Install MODs directly from the portal or uninstall existing MODs
19
19
  - **Save File Sync**: Synchronize MOD states and startup settings from Factorio save files
20
20
  - **Settings Management**: Export/import MOD settings in JSON format
21
+ - **Blueprint Conversion**: Decode/encode Factorio blueprint strings to/from JSON
21
22
  - **MOD Portal Integration**: Upload new MODs or update existing ones, edit metadata
22
23
  - **Game Control**: Launch Factorio from the command line
23
24
  - **Game Download**: Download Factorio game files (alpha, expansion, demo, headless)
@@ -17,7 +17,10 @@ _factorix() {
17
17
  local confirmable_opts="-y --yes"
18
18
 
19
19
  # Top-level commands
20
- local commands="version man launch path download mod cache completion"
20
+ local commands="version man launch path download blueprint mod cache completion"
21
+
22
+ # blueprint subcommands
23
+ local blueprint_commands="decode encode"
21
24
 
22
25
  # mod subcommands
23
26
  local mod_commands="changelog check list show enable disable install uninstall update download upload edit search sync image settings"
@@ -66,6 +69,25 @@ _factorix() {
66
69
  fi
67
70
  return
68
71
  ;;
72
+ blueprint)
73
+ if [[ $cword -eq 2 ]]; then
74
+ COMPREPLY=($(compgen -W "$blueprint_commands" -- "$cur"))
75
+ else
76
+ case "${words[2]}" in
77
+ decode|encode)
78
+ if [[ "$cur" == -* ]]; then
79
+ COMPREPLY=($(compgen -W "$global_opts -o --output" -- "$cur"))
80
+ else
81
+ COMPREPLY=($(compgen -f -- "$cur"))
82
+ fi
83
+ ;;
84
+ *)
85
+ COMPREPLY=($(compgen -W "$global_opts" -- "$cur"))
86
+ ;;
87
+ esac
88
+ fi
89
+ return
90
+ ;;
69
91
  cache)
70
92
  if [[ $cword -eq 2 ]]; then
71
93
  COMPREPLY=($(compgen -W "$cache_commands" -- "$cur"))
@@ -165,7 +187,7 @@ _factorix() {
165
187
  ;;
166
188
  sync)
167
189
  if [[ "$cur" == -* ]]; then
168
- COMPREPLY=($(compgen -W "$global_opts $confirmable_opts -j --jobs" -- "$cur"))
190
+ COMPREPLY=($(compgen -W "$global_opts $confirmable_opts -j --jobs --keep-unlisted" -- "$cur"))
169
191
  else
170
192
  COMPREPLY=($(compgen -f -X '!*.zip' -- "$cur"))
171
193
  fi
@@ -107,14 +107,25 @@ complete -c factorix -l log-level -d 'Set log level' -xa 'debug info warn error
107
107
  complete -c factorix -s q -l quiet -d 'Suppress non-essential output'
108
108
 
109
109
  # Top-level commands
110
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a version -d 'Display Factorix version'
111
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a man -d 'Display the Factorix manual page'
112
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a launch -d 'Launch Factorio game'
113
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a path -d 'Display Factorio and Factorix paths'
114
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a download -d 'Download Factorio game files'
115
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a mod -d 'MOD management commands'
116
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a cache -d 'Cache management commands'
117
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a completion -d 'Generate shell completion script'
110
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command blueprint; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a version -d 'Display Factorix version'
111
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command blueprint; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a man -d 'Display the Factorix manual page'
112
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command blueprint; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a launch -d 'Launch Factorio game'
113
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command blueprint; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a path -d 'Display Factorio and Factorix paths'
114
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command blueprint; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a download -d 'Download Factorio game files'
115
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command blueprint; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a blueprint -d 'Blueprint decode/encode commands'
116
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command blueprint; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a mod -d 'MOD management commands'
117
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command blueprint; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a cache -d 'Cache management commands'
118
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command blueprint; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a completion -d 'Generate shell completion script'
119
+
120
+ # blueprint subcommands
121
+ complete -c factorix -n "__factorix_using_command blueprint" -a decode -d 'Decode a Factorio blueprint string to JSON'
122
+ complete -c factorix -n "__factorix_using_command blueprint" -a encode -d 'Encode JSON to a Factorio blueprint string'
123
+
124
+ # blueprint decode/encode options
125
+ complete -c factorix -n "__factorix_using_subcommand blueprint decode" -s o -l output -d 'Output file path' -rF
126
+ complete -c factorix -n "__factorix_using_subcommand blueprint decode" -rF
127
+ complete -c factorix -n "__factorix_using_subcommand blueprint encode" -s o -l output -d 'Output file path' -rF
128
+ complete -c factorix -n "__factorix_using_subcommand blueprint encode" -rF
118
129
 
119
130
  # launch options
120
131
  complete -c factorix -n "__factorix_using_command launch" -s w -l wait -d 'Wait for the game to finish'
@@ -228,6 +239,7 @@ complete -c factorix -n "__factorix_using_subcommand mod search" -l json -d 'Out
228
239
  # mod sync options
229
240
  complete -c factorix -n "__factorix_using_subcommand mod sync" -s y -l yes -d 'Skip confirmation prompts'
230
241
  complete -c factorix -n "__factorix_using_subcommand mod sync" -s j -l jobs -d 'Number of parallel downloads' -r
242
+ complete -c factorix -n "__factorix_using_subcommand mod sync" -l keep-unlisted -d 'Keep MODs not listed in save file enabled'
231
243
  complete -c factorix -n "__factorix_using_subcommand mod sync" -ra '(__fish_complete_suffix .zip)'
232
244
 
233
245
  # mod changelog subcommands
@@ -40,6 +40,7 @@ _factorix() {
40
40
  'launch:Launch Factorio game'
41
41
  'path:Display Factorio and Factorix paths'
42
42
  'download:Download Factorio game files'
43
+ 'blueprint:Blueprint decode/encode commands'
43
44
  'mod:MOD management commands'
44
45
  'cache:Cache management commands'
45
46
  'completion:Generate shell completion script'
@@ -62,6 +63,9 @@ _factorix() {
62
63
  $global_opts \
63
64
  '--json[Output in JSON format]'
64
65
  ;;
66
+ blueprint)
67
+ _factorix_blueprint
68
+ ;;
65
69
  download)
66
70
  _factorix_download
67
71
  ;;
@@ -79,6 +83,43 @@ _factorix() {
79
83
  esac
80
84
  }
81
85
 
86
+ _factorix_blueprint() {
87
+ local context state state_descr line
88
+ typeset -A opt_args
89
+
90
+ local -a global_opts
91
+ global_opts=(
92
+ '(-c --config-path)'{-c,--config-path}'[Path to configuration file]:config file:_files'
93
+ '--log-level[Set log level]:level:(debug info warn error fatal)'
94
+ '(-q --quiet)'{-q,--quiet}'[Suppress non-essential output]'
95
+ )
96
+
97
+ _arguments -C \
98
+ '1:subcommand:->subcommand' \
99
+ '*::arg:->args'
100
+
101
+ case $state in
102
+ subcommand)
103
+ local -a subcommands
104
+ subcommands=(
105
+ 'decode:Decode a Factorio blueprint string to JSON'
106
+ 'encode:Encode JSON to a Factorio blueprint string'
107
+ )
108
+ _describe -t subcommands 'blueprint subcommand' subcommands
109
+ ;;
110
+ args)
111
+ case $line[1] in
112
+ decode|encode)
113
+ _arguments \
114
+ $global_opts \
115
+ '(-o --output)'{-o,--output}'[Output file path]:output file:_files' \
116
+ '1:input file:_files'
117
+ ;;
118
+ esac
119
+ ;;
120
+ esac
121
+ }
122
+
82
123
  _factorix_completion() {
83
124
  local -a global_opts
84
125
  global_opts=(
@@ -256,6 +297,7 @@ _factorix_mod() {
256
297
  $global_opts \
257
298
  $confirmable_opts \
258
299
  '(-j --jobs)'{-j,--jobs}'[Number of parallel downloads]:jobs:' \
300
+ '--keep-unlisted[Keep MODs not listed in save file enabled]' \
259
301
  '1:save file:_files -g "*.zip"'
260
302
  ;;
261
303
  changelog)
data/doc/factorix.1 CHANGED
@@ -178,12 +178,63 @@ Disable all MOD(s) except base.
178
178
  Validate MOD dependencies. Reports missing, disabled, or incompatible dependencies.
179
179
  .SS factorix mod sync SAVE_FILE
180
180
  Sync MOD states and startup settings from a save file.
181
+ Enabled MODs not listed in the save file are disabled by default.
181
182
  .TP
182
183
  .BR \-y ", " \-\-yes
183
184
  Skip confirmation prompts.
184
185
  .TP
185
186
  .BR \-j ", " \-\-jobs =\fIVALUE\fR
186
187
  Number of parallel downloads (default: 4).
188
+ .TP
189
+ .B \-\-keep\-unlisted
190
+ Keep MODs not listed in the save file enabled.
191
+ .SS factorix mod changelog add ENTRY
192
+ Add an entry to MOD changelog.
193
+ .TP
194
+ .BR \-\-version =\fIVALUE\fR
195
+ Version (X.Y.Z or Unreleased, default: Unreleased).
196
+ .TP
197
+ .BR \-\-category =\fIVALUE\fR
198
+ Category (e.g., Features, Bugfixes). Required.
199
+ .TP
200
+ .BR \-\-changelog =\fIVALUE\fR
201
+ Path to changelog file (default: changelog.txt).
202
+ .SS factorix mod changelog extract
203
+ Extract a changelog section for a specific version.
204
+ .TP
205
+ .BR \-\-version =\fIVALUE\fR
206
+ Version (X.Y.Z or Unreleased). Required.
207
+ .TP
208
+ .B \-\-json
209
+ Output in JSON format.
210
+ .TP
211
+ .BR \-\-changelog =\fIVALUE\fR
212
+ Path to changelog file (default: changelog.txt).
213
+ .SS factorix mod changelog check
214
+ Validate MOD changelog structure.
215
+ .TP
216
+ .B \-\-release
217
+ Disallow Unreleased section.
218
+ .TP
219
+ .BR \-\-changelog =\fIVALUE\fR
220
+ Path to changelog file (default: changelog.txt).
221
+ .TP
222
+ .BR \-\-info\-json =\fIVALUE\fR
223
+ Path to info.json file (default: info.json).
224
+ .SS factorix mod changelog release
225
+ Convert Unreleased changelog section to a versioned section.
226
+ .TP
227
+ .BR \-\-version =\fIVALUE\fR
228
+ Version (X.Y.Z, default: from info.json).
229
+ .TP
230
+ .BR \-\-date =\fIVALUE\fR
231
+ Release date (YYYY-MM-DD, default: today UTC).
232
+ .TP
233
+ .BR \-\-changelog =\fIVALUE\fR
234
+ Path to changelog file (default: changelog.txt).
235
+ .TP
236
+ .BR \-\-info\-json =\fIVALUE\fR
237
+ Path to info.json file (default: info.json).
187
238
  .SH MOD PORTAL COMMANDS
188
239
  These commands interact with the Factorio MOD Portal and require authentication.
189
240
  .SS factorix mod upload FILE
@@ -53,6 +53,9 @@ module Factorix
53
53
  }.freeze
54
54
  private_constant :CATEGORIES
55
55
 
56
+ # @return [Array<String>] all category identifiers
57
+ def self.identifiers = CATEGORIES.keys
58
+
56
59
  # Get Category instance for the given value
57
60
  #
58
61
  # Returns predefined instance for known categories (flyweight pattern).
@@ -9,10 +9,11 @@ module Factorix
9
9
  # License object from MOD Portal API
10
10
  #
11
11
  # Represents a MOD license information.
12
+ # Uses flyweight pattern for standard licenses.
12
13
  # Also provides valid license identifiers for edit_details API.
13
14
  #
14
15
  # @see https://wiki.factorio.com/Mod_portal_API
15
- # @see https://wiki.factorio.com/Mod_details_API
16
+ # @see https://wiki.factorio.com/Mod_details_API#License
16
17
  class License
17
18
  # @!attribute [r] id
18
19
  # @return [String] license ID
@@ -25,37 +26,92 @@ module Factorix
25
26
  # @!attribute [r] url
26
27
  # @return [URI::HTTPS] license URL
27
28
 
28
- # Valid license identifiers for edit_details API
29
- # Custom licenses (custom_$ID) are not included
30
- IDENTIFIERS = {
31
- "default_mit" => "MIT",
32
- "default_gnugplv3" => "GNU GPLv3",
33
- "default_gnulgplv3" => "GNU LGPLv3",
34
- "default_mozilla2" => "Mozilla Public License 2.0",
35
- "default_apache2" => "Apache License 2.0",
36
- "default_unlicense" => "The Unlicense"
29
+ # Predefined standard license instances
30
+ DEFAULT_MIT = new(
31
+ id: "default_mit",
32
+ name: "MIT",
33
+ title: "MIT License",
34
+ description: "A permissive license that is short and to the point. It lets people do anything with your code with proper attribution and without warranty.",
35
+ url: "https://raw.githubusercontent.com/spdx/license-list-data/main/text/MIT.txt"
36
+ )
37
+ private_constant :DEFAULT_MIT
38
+ DEFAULT_GNUGPLV3 = new(
39
+ id: "default_gnugplv3",
40
+ name: "GNU GPLv3",
41
+ title: "GNU General Public License v3.0",
42
+ description: "The GNU GPL is the most widely used free software license and has a strong copyleft requirement.",
43
+ url: "https://raw.githubusercontent.com/spdx/license-list-data/main/text/GPL-3.0-or-later.txt"
44
+ )
45
+ private_constant :DEFAULT_GNUGPLV3
46
+ DEFAULT_GNULGPLV3 = new(
47
+ id: "default_gnulgplv3",
48
+ name: "GNU LGPLv3",
49
+ title: "GNU Lesser General Public License v3.0",
50
+ description: "Version 3 of the GNU LGPL is an additional set of permissions to the GNU GPLv3 license that requires derived works use the same license.",
51
+ url: "https://raw.githubusercontent.com/spdx/license-list-data/main/text/LGPL-3.0-or-later.txt"
52
+ )
53
+ private_constant :DEFAULT_GNULGPLV3
54
+ DEFAULT_MOZILLA2 = new(
55
+ id: "default_mozilla2",
56
+ name: "Mozilla Public License 2.0",
57
+ title: "Mozilla Public License Version 2.0",
58
+ description: "The Mozilla Public License (MPL 2.0) attempts to be a compromise between the permissive BSD license and the reciprocal GPL license.",
59
+ url: "https://raw.githubusercontent.com/spdx/license-list-data/main/text/MPL-2.0.txt"
60
+ )
61
+ private_constant :DEFAULT_MOZILLA2
62
+ DEFAULT_APACHE2 = new(
63
+ id: "default_apache2",
64
+ name: "Apache License 2.0",
65
+ title: "Apache License, Version 2.0",
66
+ description: "A permissive license that also provides an express grant of patent rights from contributors to users.",
67
+ url: "https://raw.githubusercontent.com/spdx/license-list-data/main/text/Apache-2.0.txt"
68
+ )
69
+ private_constant :DEFAULT_APACHE2
70
+ DEFAULT_UNLICENSE = new(
71
+ id: "default_unlicense",
72
+ name: "The Unlicense",
73
+ title: "The Unlicense",
74
+ description: "The Unlicense is a template to waive copyright interest in software and dedicate it to the public domain.",
75
+ url: "https://raw.githubusercontent.com/spdx/license-list-data/main/text/Unlicense.txt"
76
+ )
77
+ private_constant :DEFAULT_UNLICENSE
78
+
79
+ # Lookup table for flyweight pattern
80
+ LICENSES = {
81
+ "default_mit" => DEFAULT_MIT,
82
+ "default_gnugplv3" => DEFAULT_GNUGPLV3,
83
+ "default_gnulgplv3" => DEFAULT_GNULGPLV3,
84
+ "default_mozilla2" => DEFAULT_MOZILLA2,
85
+ "default_apache2" => DEFAULT_APACHE2,
86
+ "default_unlicense" => DEFAULT_UNLICENSE
37
87
  }.freeze
38
- private_constant :IDENTIFIERS
88
+ private_constant :LICENSES
39
89
 
40
90
  # Pattern for custom license identifiers (custom_ + 24 lowercase hex chars)
41
91
  CUSTOM_LICENSE_PATTERN = /\Acustom_[0-9a-f]{24}\z/
42
92
  private_constant :CUSTOM_LICENSE_PATTERN
43
93
 
94
+ # @return [Array<String>] all license identifiers
95
+ def self.identifiers = LICENSES.keys
96
+
97
+ # Get License instance for the given identifier
98
+ #
99
+ # Returns predefined instance for known licenses (flyweight pattern).
100
+ # Raises an error for unknown license identifiers.
101
+ #
102
+ # @param id [String] license identifier
103
+ # @return [License] License instance
104
+ # @raise [KeyError] if license identifier is unknown
105
+ def self.for(id) = LICENSES.fetch(id.to_s)
106
+
44
107
  # Check if the given value is a valid license identifier
45
108
  #
46
109
  # @param value [String] license identifier
47
110
  # @return [Boolean] true if valid (standard or custom license)
48
111
  def self.valid_identifier?(value)
49
- IDENTIFIERS.key?(value) || CUSTOM_LICENSE_PATTERN.match?(value)
112
+ LICENSES.key?(value) || CUSTOM_LICENSE_PATTERN.match?(value)
50
113
  end
51
114
 
52
- # List all valid license identifier values
53
- #
54
- # @return [Array<String>] array of license identifiers
55
- def self.identifier_values = IDENTIFIERS.keys
56
-
57
- # Create License from API response hash
58
- #
59
115
  # @param id [String] license ID
60
116
  # @param name [String] license name
61
117
  # @param title [String] license title
@@ -79,6 +79,9 @@ module Factorix
79
79
  }.freeze
80
80
  private_constant :TAGS
81
81
 
82
+ # @return [Array<String>] all tag identifiers
83
+ def self.identifiers = TAGS.keys
84
+
82
85
  # Get Tag instance for the given value
83
86
  #
84
87
  # Returns predefined instance for known tags (flyweight pattern).
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "zlib"
6
+
7
+ module Factorix
8
+ # Represents a Factorio blueprint
9
+ #
10
+ # A blueprint string has the format: version_byte + Base64(zlib(JSON))
11
+ # Only version byte '0' is supported.
12
+ class Blueprint
13
+ # The only supported version byte
14
+ SUPPORTED_VERSION = "0"
15
+ private_constant :SUPPORTED_VERSION
16
+
17
+ # @return [Hash] The blueprint data
18
+ attr_reader :data
19
+
20
+ # Decode a blueprint string into a Blueprint
21
+ #
22
+ # @param string [String] The blueprint string
23
+ # @return [Blueprint]
24
+ # @raise [UnsupportedBlueprintVersionError] if the version byte is not '0'
25
+ # @raise [BlueprintFormatError] if the string is not a valid blueprint
26
+ def self.decode(string)
27
+ version = string[0]
28
+ raise UnsupportedBlueprintVersionError, "Unsupported blueprint version: #{version.inspect}" unless version == SUPPORTED_VERSION
29
+
30
+ compressed = Base64.strict_decode64(string[1..])
31
+ json_string = Zlib::Inflate.inflate(compressed)
32
+ new(JSON.parse(json_string))
33
+ rescue ArgumentError => e
34
+ raise BlueprintFormatError, "Invalid Base64 encoding: #{e.message}"
35
+ rescue Zlib::Error => e
36
+ raise BlueprintFormatError, "Invalid zlib data: #{e.message}"
37
+ rescue JSON::ParserError => e
38
+ raise BlueprintFormatError, "Invalid JSON: #{e.message}"
39
+ end
40
+
41
+ # @param data [Hash] The blueprint data
42
+ def initialize(data)
43
+ @data = data
44
+ end
45
+
46
+ # Encode this blueprint to a blueprint string
47
+ #
48
+ # @return [String]
49
+ def encode
50
+ json_string = JSON.generate(@data)
51
+ compressed = Zlib::Deflate.deflate(json_string, Zlib::BEST_COMPRESSION)
52
+ SUPPORTED_VERSION + Base64.strict_encode64(compressed)
53
+ end
54
+
55
+ # Serialize this blueprint to pretty-printed JSON
56
+ #
57
+ # @return [String]
58
+ def to_json(*)
59
+ JSON.pretty_generate(@data)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module Blueprint
7
+ # Decode a Factorio blueprint string to JSON
8
+ class Decode < Base
9
+ desc "Decode a Factorio blueprint string to JSON"
10
+
11
+ example [
12
+ " # Decode from stdin",
13
+ "blueprint.txt # Decode from file",
14
+ "blueprint.txt -o decoded.json # Decode to file"
15
+ ]
16
+
17
+ argument :file, required: false, desc: "Path to file containing blueprint string (default: stdin)"
18
+ option :output, aliases: ["-o"], desc: "Output file path (default: stdout)"
19
+
20
+ # Execute the decode command
21
+ #
22
+ # @param file [String, nil] Path to file containing blueprint string
23
+ # @param output [String, nil] Output file path
24
+ # @return [void]
25
+ def call(file: nil, output: nil, **)
26
+ blueprint_string = file ? Pathname(file).read.strip : $stdin.read.strip
27
+ blueprint = Factorix::Blueprint.decode(blueprint_string)
28
+ json_string = blueprint.to_json
29
+
30
+ if output
31
+ Pathname(output).write(json_string)
32
+ else
33
+ out.puts json_string
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module Blueprint
9
+ # Encode JSON to a Factorio blueprint string
10
+ class Encode < Base
11
+ desc "Encode JSON to a Factorio blueprint string"
12
+
13
+ example [
14
+ " # Encode from stdin",
15
+ "decoded.json # Encode from file",
16
+ "decoded.json -o blueprint.txt # Encode to file"
17
+ ]
18
+
19
+ argument :file, required: false, desc: "Path to JSON file (default: stdin)"
20
+ option :output, aliases: ["-o"], desc: "Output file path (default: stdout)"
21
+
22
+ # Execute the encode command
23
+ #
24
+ # @param file [String, nil] Path to JSON file
25
+ # @param output [String, nil] Output file path
26
+ # @return [void]
27
+ def call(file: nil, output: nil, **)
28
+ json_string = file ? Pathname(file).read : $stdin.read
29
+ blueprint = Factorix::Blueprint.new(JSON.parse(json_string))
30
+ blueprint_string = blueprint.encode
31
+
32
+ if output
33
+ Pathname(output).write(blueprint_string)
34
+ else
35
+ out.print blueprint_string
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -100,7 +100,7 @@ module Factorix
100
100
  return if API::License.valid_identifier?(license)
101
101
 
102
102
  say "Invalid license identifier: #{license}", prefix: :error
103
- say "Valid identifiers: #{API::License.identifier_values.join(", ")}"
103
+ say "Valid identifiers: #{API::License.identifiers.join(", ")}"
104
104
  say "Custom licenses: custom_<24 hex chars> (e.g., custom_0123456789abcdef01234567)"
105
105
  raise InvalidArgumentError, "Invalid license identifier"
106
106
  end
@@ -26,26 +26,27 @@ module Factorix
26
26
  desc "Sync MOD states and startup settings from a save file"
27
27
 
28
28
  example [
29
- "save.zip # Sync MOD(s) from save file",
30
- "-j 8 save.zip # Use 8 parallel downloads"
29
+ "save.zip # Sync MOD(s) from save file",
30
+ "-j 8 save.zip # Use 8 parallel downloads",
31
+ "--keep-unlisted save.zip # Keep MOD(s) not in save file enabled"
31
32
  ]
32
33
 
33
34
  argument :save_file, required: true, desc: "Path to Factorio save file (.zip)"
34
35
  option :jobs, aliases: ["-j"], default: "4", desc: "Number of parallel downloads"
36
+ option :keep_unlisted, type: :flag, default: false, desc: "Keep MOD(s) not listed in save file enabled"
35
37
 
36
38
  # Execute the sync command
37
39
  #
38
40
  # @param save_file [String] Path to save file
39
41
  # @param jobs [Integer] Number of parallel downloads
42
+ # @param keep_unlisted [Boolean] Whether to keep unlisted MODs enabled
40
43
  # @return [void]
41
- def call(save_file:, jobs: "4", **)
44
+ def call(save_file:, jobs: "4", keep_unlisted: false, **)
42
45
  jobs = Integer(jobs)
43
- # Load save file
44
46
  say "Loading save file: #{save_file}", prefix: :info
45
47
  save_data = SaveFile.load(Pathname(save_file))
46
48
  say "Loaded save file (version: #{save_data.version}, MOD(s): #{save_data.mods.size})", prefix: :info
47
49
 
48
- # Load current state
49
50
  mod_list = MODList.load
50
51
  presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: err)
51
52
  handler = Progress::ScanHandler.new(presenter)
@@ -54,36 +55,30 @@ module Factorix
54
55
 
55
56
  raise DirectoryNotFoundError, "MOD directory does not exist: #{runtime.mod_dir}" unless runtime.mod_dir.exist?
56
57
 
57
- # Find MODs that need to be installed
58
+ # Plan phase (no side effects)
58
59
  mods_to_install = find_mods_to_install(save_data.mods, installed_mods)
60
+ install_targets = mods_to_install.any? ? plan_installation(mods_to_install, graph, jobs) : []
61
+ conflict_mods = find_conflict_mods(mod_list, save_data.mods, graph)
62
+ changes = plan_mod_list_changes(mod_list, save_data.mods)
63
+ unlisted_mods = keep_unlisted ? [] : find_unlisted_mods(mod_list, save_data.mods, conflict_mods)
59
64
 
60
- if mods_to_install.any?
61
- say "#{mods_to_install.size} MOD(s) need to be installed", prefix: :info
65
+ # Show combined plan and ask once
66
+ show_sync_plan(install_targets, conflict_mods, changes, unlisted_mods)
62
67
 
63
- # Plan installation
64
- install_targets = plan_installation(mods_to_install, graph, jobs)
68
+ return if needs_confirmation?(install_targets, conflict_mods, changes, unlisted_mods) &&
69
+ !confirm?("Do you want to apply these changes?")
65
70
 
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 phase
72
+ if install_targets.any?
71
73
  execute_installation(install_targets, jobs)
72
74
  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
75
  end
76
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)
77
+ apply_mod_list_changes(mod_list, conflict_mods, changes, unlisted_mods)
82
78
  backup_if_exists(runtime.mod_list_path)
83
79
  mod_list.save
84
80
  say "Updated mod-list.json", prefix: :success
85
81
 
86
- # Update mod-settings.dat
87
82
  update_mod_settings(save_data.startup_settings, save_data.version)
88
83
  say "Updated mod-settings.dat", prefix: :success
89
84
 
@@ -92,10 +87,8 @@ module Factorix
92
87
 
93
88
  private def find_mods_to_install(save_mods, installed_mods)
94
89
  save_mods.reject do |mod_name, _mod_state|
95
- # Skip base MOD (always installed)
96
90
  next true if mod_name == "base"
97
91
 
98
- # Check if MOD is installed
99
92
  mod = Factorix::MOD[name: mod_name]
100
93
  installed_mods.any? {|installed| installed.mod == mod }
101
94
  end
@@ -108,18 +101,13 @@ module Factorix
108
101
  # @param jobs [Integer] Number of parallel jobs
109
102
  # @return [Array<Hash>] Installation targets with MOD info and releases
110
103
  private def plan_installation(mods_to_install, graph, jobs)
111
- # Create progress presenter for info fetching
112
104
  presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Fetching MOD info", output: err)
113
-
114
- # Fetch info for MODs to install
115
105
  target_infos = fetch_target_mod_info(mods_to_install, jobs, presenter)
116
106
 
117
- # Add to graph
118
107
  target_infos.each do |info|
119
108
  graph.add_uninstalled_mod(info[:mod_info], info[:release])
120
109
  end
121
110
 
122
- # Build install targets
123
111
  build_install_targets(target_infos, runtime.mod_dir)
124
112
  end
125
113
 
@@ -155,10 +143,7 @@ module Factorix
155
143
  # @param version [MODVersion] Target version
156
144
  # @return [Hash] {mod_name:, mod_info:, release:, version:}
157
145
  private def fetch_single_mod_info(mod_name, version)
158
- # Fetch full MOD info from portal
159
146
  mod_info = portal.get_mod_full(mod_name)
160
-
161
- # Find the specific version release
162
147
  release = mod_info.releases.find {|r| r.version == version }
163
148
 
164
149
  unless release
@@ -168,100 +153,240 @@ module Factorix
168
153
  {mod_name:, mod_info:, release:, version:}
169
154
  end
170
155
 
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
156
  # Execute the installation
183
157
  #
184
158
  # @param targets [Array<Hash>] Installation targets
185
159
  # @param jobs [Integer] Number of parallel jobs
186
160
  # @return [void]
187
161
  private def execute_installation(targets, jobs)
188
- # Download all MODs
189
162
  download_mods(targets, jobs)
190
163
  end
191
164
 
192
- # Resolve conflicts between save file MODs and existing enabled MODs
165
+ # Find MODs that conflict with enabled MODs from the save file
193
166
  #
194
167
  # @param mod_list [MODList] Current MOD list
195
168
  # @param save_mods [Hash<String, MODState>] MODs from save file
196
169
  # @param graph [Dependency::Graph] Dependency graph
197
- # @return [void]
198
- private def resolve_conflicts(mod_list, save_mods, graph)
170
+ # @return [Array<Hash>] Conflict entries: {mod:, conflicts_with:}
171
+ private def find_conflict_mods(mod_list, save_mods, graph)
172
+ conflicts = []
173
+ seen = Set.new
174
+
199
175
  save_mods.each do |mod_name, mod_state|
200
176
  next unless mod_state.enabled?
201
177
 
202
- mod = Factorix::MOD[name: mod_name]
178
+ save_mod = Factorix::MOD[name: mod_name]
203
179
 
204
- graph.edges_from(mod).each do |edge|
180
+ graph.edges_from(save_mod).each do |edge|
205
181
  next unless edge.incompatible?
206
182
 
207
183
  conflicting_mod = edge.to_mod
208
184
  next unless mod_list.exist?(conflicting_mod) && mod_list.enabled?(conflicting_mod)
185
+ next unless seen.add?(conflicting_mod)
209
186
 
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)
187
+ conflicts << {mod: conflicting_mod, conflicts_with: save_mod}
213
188
  end
214
189
 
215
- graph.edges_to(mod).each do |edge|
190
+ graph.edges_to(save_mod).each do |edge|
216
191
  next unless edge.incompatible?
217
192
 
218
193
  conflicting_mod = edge.from_mod
219
194
  next unless mod_list.exist?(conflicting_mod) && mod_list.enabled?(conflicting_mod)
195
+ next unless seen.add?(conflicting_mod)
220
196
 
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)
197
+ conflicts << {mod: conflicting_mod, conflicts_with: save_mod}
224
198
  end
225
199
  end
200
+
201
+ conflicts
226
202
  end
227
203
 
228
- # Update mod-list.json with MODs from save file
204
+ # Compute changes needed to bring mod-list.json in sync with save file
229
205
  #
230
206
  # @param mod_list [MODList] Current MOD list
231
207
  # @param save_mods [Hash<String, MODState>] MODs from save file
232
- # @return [void]
233
- private def update_mod_list(mod_list, save_mods)
208
+ # @return [Array<Hash>] Change entries: {mod:, action:, ...}
209
+ private def plan_mod_list_changes(mod_list, save_mods)
210
+ changes = []
211
+
234
212
  save_mods.each do |mod_name, mod_state|
235
213
  mod = Factorix::MOD[name: mod_name]
214
+ next if mod.base?
236
215
 
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
216
+ if mod_list.exist?(mod)
217
+ changes.concat(plan_existing_mod_changes(mod_list, mod, mod_state))
218
+ else
219
+ # Not in list: add it (silently if disabled)
220
+ changes << {mod:, action: :add, to_enabled: mod_state.enabled?, to_version: mod_state.version}
241
221
  end
222
+ end
223
+
224
+ changes
225
+ end
226
+
227
+ # Plan changes for a single MOD that already exists in mod-list.json
228
+ #
229
+ # @param mod_list [MODList] Current MOD list
230
+ # @param mod [MOD] The MOD to plan changes for
231
+ # @param mod_state [MODState] MOD state from save file
232
+ # @return [Array<Hash>] Change entries for this MOD
233
+ private def plan_existing_mod_changes(mod_list, mod, mod_state)
234
+ current_enabled = mod_list.enabled?(mod)
235
+ return plan_expansion_mod_changes(mod, mod_state, current_enabled) if mod.expansion?
236
+
237
+ current_version = mod_list.version(mod)
238
+ to_enabled = mod_state.enabled?
239
+ to_version = mod_state.version
240
+ enabled_changed = current_enabled != to_enabled
241
+ version_changed = current_version && current_version != to_version
242
+
243
+ if enabled_changed
244
+ action = to_enabled ? :enable : :disable
245
+ [{mod:, action:, from_version: current_version, to_version:, from_enabled: current_enabled}]
246
+ elsif version_changed
247
+ [{mod:, action: :update, from_version: current_version, to_version:, from_enabled: current_enabled}]
248
+ else
249
+ []
250
+ end
251
+ end
252
+
253
+ # Plan enable/disable changes for an expansion MOD
254
+ #
255
+ # @param mod [MOD] The expansion MOD
256
+ # @param mod_state [MODState] MOD state from save file
257
+ # @param current_enabled [Boolean] Current enabled state in mod-list.json
258
+ # @return [Array<Hash>] Change entries (0 or 1 element)
259
+ private def plan_expansion_mod_changes(mod, mod_state, current_enabled)
260
+ if mod_state.enabled? && !current_enabled
261
+ [{mod:, action: :enable}]
262
+ elsif !mod_state.enabled? && current_enabled
263
+ [{mod:, action: :disable}]
264
+ else
265
+ []
266
+ end
267
+ end
268
+
269
+ # Find enabled MODs not listed in the save file (excluding conflict MODs)
270
+ #
271
+ # @param mod_list [MODList] Current MOD list
272
+ # @param save_mods [Hash<String, MODState>] MODs from save file
273
+ # @param conflict_mods [Array<Hash>] Already-planned conflict disables
274
+ # @return [Array<MOD>] MODs to disable
275
+ private def find_unlisted_mods(mod_list, save_mods, conflict_mods)
276
+ conflict_mod_set = Set.new(conflict_mods.map {|c| c[:mod] })
277
+
278
+ mod_list.each_mod.select do |mod|
279
+ !mod.base? &&
280
+ mod_list.enabled?(mod) &&
281
+ !save_mods.key?(mod.name) &&
282
+ !conflict_mod_set.include?(mod)
283
+ end
284
+ end
285
+
286
+ # Show the combined sync plan
287
+ #
288
+ # @param install_targets [Array<Hash>] MODs to install
289
+ # @param conflict_mods [Array<Hash>] MODs to disable due to conflicts
290
+ # @param changes [Array<Hash>] MOD list changes from save file
291
+ # @param unlisted_mods [Array<MOD>] MODs to disable as unlisted
292
+ # @return [void]
293
+ private def show_sync_plan(install_targets, conflict_mods, changes, unlisted_mods)
294
+ unless needs_confirmation?(install_targets, conflict_mods, changes, unlisted_mods)
295
+ say "Nothing to change", prefix: :info
296
+ return
297
+ end
298
+
299
+ say "Planning to sync MOD(s):", prefix: :info
242
300
 
301
+ if install_targets.any?
302
+ say " Install:"
303
+ install_targets.each {|t| say " - #{t[:mod]}@#{t[:release].version}" }
304
+ end
305
+
306
+ enable_changes = changes.select {|c| c[:action] == :enable }
307
+ if enable_changes.any?
308
+ say " Enable:"
309
+ enable_changes.each {|c| say " - #{c[:mod]}" }
310
+ end
311
+
312
+ disable_changes = changes.select {|c| c[:action] == :disable }
313
+ all_disables = conflict_mods.map {|c| {mod: c[:mod], reason: "(conflicts with #{c[:conflicts_with]})"} } +
314
+ disable_changes.map {|c| {mod: c[:mod], reason: "(disabled in save file)"} } +
315
+ unlisted_mods.map {|m| {mod: m, reason: "(not listed in save file)"} }
316
+ if all_disables.any?
317
+ say " Disable:"
318
+ all_disables.each {|d| say " - #{d[:mod]} #{d[:reason]}" }
319
+ end
320
+
321
+ update_changes = changes.select {|c| c[:action] == :update }
322
+ return if update_changes.none?
323
+
324
+ say " Update:"
325
+ update_changes.each {|c| say " - #{c[:mod]} (#{c[:from_version]} \u2192 #{c[:to_version]})" }
326
+ end
327
+
328
+ # Apply all mod-list.json changes
329
+ #
330
+ # @param mod_list [MODList] MOD list to modify
331
+ # @param conflict_mods [Array<Hash>] Conflict entries to disable
332
+ # @param changes [Array<Hash>] MOD list changes
333
+ # @param unlisted_mods [Array<MOD>] Unlisted MODs to disable
334
+ # @return [void]
335
+ private def apply_mod_list_changes(mod_list, conflict_mods, changes, unlisted_mods)
336
+ conflict_mods.each do |conflict|
337
+ mod_list.disable(conflict[:mod])
338
+ logger.debug("Disabled conflicting MOD", mod_name: conflict[:mod].name, conflicts_with: conflict[:conflicts_with].name)
339
+ end
340
+
341
+ changes.each {|change| apply_single_change(mod_list, change) }
342
+
343
+ unlisted_mods.each do |mod|
344
+ mod_list.disable(mod)
345
+ logger.debug("Disabled unlisted MOD", mod_name: mod.name)
346
+ end
347
+ end
348
+
349
+ # Apply a single change entry to mod-list.json
350
+ #
351
+ # @param mod_list [MODList] MOD list to modify
352
+ # @param change [Hash] Change entry from plan_mod_list_changes
353
+ # @return [void]
354
+ private def apply_single_change(mod_list, change)
355
+ mod = change[:mod]
356
+ case change[:action]
357
+ when :enable
358
+ if mod_list.exist?(mod)
359
+ if mod.expansion?
360
+ mod_list.enable(mod)
361
+ else
362
+ mod_list.remove(mod)
363
+ mod_list.add(mod, enabled: true, version: change[:to_version])
364
+ end
365
+ else
366
+ mod_list.add(mod, enabled: true, version: change[:to_version])
367
+ end
368
+ logger.debug("Enabled MOD in mod-list.json", mod_name: mod.name)
369
+ when :disable
243
370
  if mod_list.exist?(mod)
244
- # expansion MOD: only update enabled state (not version)
245
371
  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
372
+ mod_list.disable(mod)
253
373
  else
254
- # Regular MOD: update both version and enabled state
255
- # Remove and re-add to update version
256
374
  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?)
375
+ mod_list.add(mod, enabled: false, version: change[:to_version])
259
376
  end
260
377
  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)
378
+ mod_list.add(mod, enabled: false, version: change[:to_version])
264
379
  end
380
+ logger.debug("Disabled MOD in mod-list.json", mod_name: mod.name)
381
+ when :update
382
+ mod_list.remove(mod)
383
+ mod_list.add(mod, enabled: change[:from_enabled], version: change[:to_version])
384
+ logger.debug("Updated MOD in mod-list.json", mod_name: mod.name, version: change[:to_version]&.to_s)
385
+ when :add
386
+ mod_list.add(mod, enabled: change[:to_enabled], version: change[:to_version])
387
+ logger.debug("Added MOD to mod-list.json", mod_name: mod.name, version: change[:to_version]&.to_s, enabled: change[:to_enabled])
388
+ else
389
+ raise ArgumentError, "Unexpected change action: #{change[:action]}"
265
390
  end
266
391
  end
267
392
 
@@ -271,27 +396,37 @@ module Factorix
271
396
  # @param game_version [GameVersion] Game version from save file
272
397
  # @return [void]
273
398
  private def update_mod_settings(startup_settings, game_version)
274
- # Load existing settings or create new
275
399
  mod_settings = if runtime.mod_settings_path.exist?
276
400
  MODSettings.load(runtime.mod_settings_path)
277
401
  else
278
- # Create new MODSettings with all sections
279
402
  sections = MODSettings::VALID_SECTIONS.to_h {|section_name|
280
403
  [section_name, MODSettings::Section.new(section_name)]
281
404
  }
282
405
  MODSettings.new(game_version, sections)
283
406
  end
284
407
 
285
- # Merge startup settings from save file
286
408
  startup_section = mod_settings["startup"]
287
409
  startup_settings.each do |key, value|
288
410
  startup_section[key] = value
289
411
  end
290
412
 
291
- # Save updated settings
292
413
  backup_if_exists(runtime.mod_settings_path)
293
414
  mod_settings.save(runtime.mod_settings_path)
294
415
  end
416
+
417
+ # Check whether user-visible changes exist that require confirmation
418
+ #
419
+ # @param install_targets [Array<Hash>] MODs to install
420
+ # @param conflict_mods [Array<Hash>] MODs to disable due to conflicts
421
+ # @param changes [Array<Hash>] MOD list changes
422
+ # @param unlisted_mods [Array<MOD>] MODs to disable as unlisted
423
+ # @return [Boolean]
424
+ private def needs_confirmation?(install_targets, conflict_mods, changes, unlisted_mods)
425
+ install_targets.any? ||
426
+ conflict_mods.any? ||
427
+ changes.any? {|c| c[:action] != :add || c[:to_enabled] } ||
428
+ unlisted_mods.any?
429
+ end
295
430
  end
296
431
  end
297
432
  end
data/lib/factorix/cli.rb CHANGED
@@ -13,6 +13,8 @@ module Factorix
13
13
  class CLI
14
14
  extend Dry::CLI::Registry
15
15
 
16
+ register "blueprint decode", Commands::Blueprint::Decode
17
+ register "blueprint encode", Commands::Blueprint::Encode
16
18
  register "version", Commands::Version
17
19
  register "man", Commands::Man
18
20
  register "launch", Commands::Launch
@@ -71,6 +71,10 @@ module Factorix
71
71
 
72
72
  class ChangelogParseError < FileFormatError; end
73
73
 
74
+ # Blueprint format errors
75
+ class BlueprintFormatError < FileFormatError; end
76
+ class UnsupportedBlueprintVersionError < BlueprintFormatError; end
77
+
74
78
  # =====================================
75
79
  # Domain layer errors
76
80
  # =====================================
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Factorix
4
- VERSION = "0.9.1"
4
+ VERSION = "0.11.0"
5
5
  public_constant :VERSION
6
6
  end
@@ -9,6 +9,7 @@ module Factorix
9
9
  attr_reader name: String
10
10
  attr_reader description: String
11
11
 
12
+ def self.identifiers: () -> Array[String]
12
13
  def self.for: (String value) -> Category
13
14
  end
14
15
  end
@@ -11,8 +11,9 @@ module Factorix
11
11
  attr_reader description: String
12
12
  attr_reader url: URI::HTTPS
13
13
 
14
+ def self.identifiers: () -> Array[String]
15
+ def self.for: (String id) -> License
14
16
  def self.valid_identifier?: (String value) -> bool
15
- def self.identifier_values: () -> Array[String]
16
17
 
17
18
  def initialize: (id: String, name: String, title: String, description: String, url: String) -> void
18
19
  end
@@ -9,6 +9,7 @@ module Factorix
9
9
  attr_reader name: String
10
10
  attr_reader description: String
11
11
 
12
+ def self.identifiers: () -> Array[String]
12
13
  def self.for: (String value) -> Tag
13
14
  end
14
15
  end
@@ -0,0 +1,14 @@
1
+ # RBS type signature file
2
+ # NOTE: Do not include private method definitions in RBS files.
3
+ # Only public interfaces should be documented here.
4
+
5
+ module Factorix
6
+ class Blueprint
7
+ attr_reader data: Hash[String, untyped]
8
+
9
+ def initialize: (Hash[String, untyped] data) -> void
10
+ def self.decode: (String string) -> Blueprint
11
+ def encode: () -> String
12
+ def to_json: (*untyped) -> String
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # RBS type signature file
2
+ # NOTE: Do not include private method definitions in RBS files.
3
+ # Only public interfaces should be documented here.
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module Blueprint
9
+ class Decode < Base
10
+ def call: (?file: String?, ?output: String?, **untyped) -> void
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # RBS type signature file
2
+ # NOTE: Do not include private method definitions in RBS files.
3
+ # Only public interfaces should be documented here.
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module Blueprint
9
+ class Encode < Base
10
+ def call: (?file: String?, ?output: String?, **untyped) -> void
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -79,6 +79,12 @@ module Factorix
79
79
  class ChangelogParseError < FileFormatError
80
80
  end
81
81
 
82
+ class BlueprintFormatError < FileFormatError
83
+ end
84
+
85
+ class UnsupportedBlueprintVersionError < BlueprintFormatError
86
+ end
87
+
82
88
  # =====================================
83
89
  # Domain layer errors
84
90
  # =====================================
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: factorix
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OZAWA Sakuro
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-20 00:00:00.000000000 Z
11
+ date: 2026-03-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -250,6 +250,7 @@ files:
250
250
  - lib/factorix/api/release.rb
251
251
  - lib/factorix/api/tag.rb
252
252
  - lib/factorix/api_credential.rb
253
+ - lib/factorix/blueprint.rb
253
254
  - lib/factorix/cache/base.rb
254
255
  - lib/factorix/cache/entry.rb
255
256
  - lib/factorix/cache/file_system.rb
@@ -259,6 +260,8 @@ files:
259
260
  - lib/factorix/cli.rb
260
261
  - lib/factorix/cli/commands/backup_support.rb
261
262
  - lib/factorix/cli/commands/base.rb
263
+ - lib/factorix/cli/commands/blueprint/decode.rb
264
+ - lib/factorix/cli/commands/blueprint/encode.rb
262
265
  - lib/factorix/cli/commands/cache/evict.rb
263
266
  - lib/factorix/cli/commands/cache/stat.rb
264
267
  - lib/factorix/cli/commands/command_wrapper.rb
@@ -362,6 +365,7 @@ files:
362
365
  - sig/factorix/api/release.rbs
363
366
  - sig/factorix/api/tag.rbs
364
367
  - sig/factorix/api_credential.rbs
368
+ - sig/factorix/blueprint.rbs
365
369
  - sig/factorix/cache/base.rbs
366
370
  - sig/factorix/cache/entry.rbs
367
371
  - sig/factorix/cache/file_system.rbs
@@ -370,6 +374,8 @@ files:
370
374
  - sig/factorix/changelog.rbs
371
375
  - sig/factorix/cli.rbs
372
376
  - sig/factorix/cli/commands/base.rbs
377
+ - sig/factorix/cli/commands/blueprint/decode.rbs
378
+ - sig/factorix/cli/commands/blueprint/encode.rbs
373
379
  - sig/factorix/cli/commands/cache/evict.rbs
374
380
  - sig/factorix/cli/commands/cache/stat.rbs
375
381
  - sig/factorix/cli/commands/command_wrapper.rbs