factorix 0.10.0 → 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: 1f669f6ec36e75ef8f9720e7e09337627d56bc84453233484157643f22a55aa9
4
- data.tar.gz: 2029ef6d075978429ddd533c7ba5aa68eb0c16bf77e9c1802bcbfd159844f767
3
+ metadata.gz: f93238fc1b419e6adf7ec45ac04331f84aba675b94cf4d4e57fd7c8555687678
4
+ data.tar.gz: 2e7d4ed3b5c0b4be85fd5f1012eb4a73b84b160d5f31c1ed326a5830cba41c42
5
5
  SHA512:
6
- metadata.gz: f9a17f3a9a83fdf498555deac57a3d9d99b0b33d77a36c8242e5d78e8e85923ed535f4457bf5976e2e3f1b7209214c84e4eda1f9bf00425657b1c8eb471f34de
7
- data.tar.gz: 0e6c5bfc9e7a09d4ff3529e976b0f7f90cba03d49d2e98ef70ae9eae07031e1c2de90e7f4235dbeb8d68b32d852afa5a674ecc76bcfb048684f9e229454c9c83
6
+ metadata.gz: 254733ebd3ad1b896a84c341d35a49a2d8943114d4fe0f90d0b83efae5399d9f862aedaeb0f7a68d42cdfedad9ab0dae9a4d77625646540de111e77567b74ab6
7
+ data.tar.gz: 733b7da2d624bbf225e9bcbd586a0aa394c593d79743456c2a9b1a8ef06daf05603f1f4091da657038f7bacfdb766061a48026084a1d72290cc3350df2e8e694
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## [0.10.0] - 2026-02-21
4
16
 
5
17
  ### Changed
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,16 @@ 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.
187
191
  .SS factorix mod changelog add ENTRY
188
192
  Add an entry to MOD changelog.
189
193
  .TP
@@ -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
@@ -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.10.0"
4
+ VERSION = "0.11.0"
5
5
  public_constant :VERSION
6
6
  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.10.0
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-21 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