44import os
55import sys
66from pathlib import Path
7+ from typing import Sequence
78
89from dotenv import load_dotenv
910
10- from .build_order import BuildOrderOptions , format_build_order , player_refs , replay_paths , resolve_player
11+ from .build_order import BuildOrderItem , BuildOrderOptions , PlayerRef , format_build_order , player_refs , replay_paths , resolve_player
12+ from .defaults import load_defaults , save_defaults
1113from .replaystats import compare_reference , fetch_reference , format_comparison
1214from .salt import format_salt_encoding
1315from .sc2reader_backend import build_order_for_replay , load_replay , replay_players , replay_time_scale
1416
1517
18+ DEFAULT_MAX_MINUTES = 9.0
19+ ALL_PLAYERS = object ()
20+
21+
1622def main (argv : list [str ] | None = None ) -> int :
1723 load_dotenv ()
1824 parser = argparse .ArgumentParser (description = "Print a readable build order from StarCraft II replay files." )
19- parser .add_argument ("path " , type = Path , help = "Replay file or directory containing .SC2Replay files." )
20- parser .add_argument ("--player" , help = "Player id or exact player name. Defaults to the first player." )
25+ parser .add_argument ("paths " , nargs = "*" , type = Path , help = "Replay files or folders containing .SC2Replay files." )
26+ parser .add_argument ("--player" , help = "Player id or exact player name. Defaults to the remembered player when possible ." )
2127 parser .add_argument ("--limit" , type = int , help = "Maximum number of replays to analyze from a directory." )
2228 parser .add_argument ("--no-prompt" , action = "store_true" , help = "Do not ask for a player in interactive terminals." )
2329 parser .add_argument ("--include-starting-state" , action = "store_true" , help = "Include units present at frame 0." )
2430 parser .add_argument ("--include-type-changes" , action = "store_true" , help = "Include every unit type-change event." )
2531 parser .add_argument ("--include-workers" , action = "store_true" , help = "Include worker production." )
26- parser .add_argument ("--max-minutes" , type = float , help = "Only print events up to this game minute." )
32+ parser .add_argument ("--max-minutes" , type = float , help = f"Only print events up to this game minute. Defaults to { DEFAULT_MAX_MINUTES :g} ." )
33+ parser .add_argument ("--full-game" , action = "store_true" , help = "Do not apply the default 9-minute build-order limit." )
2734 parser .add_argument ("--salt" , action = "store_true" , help = "Print SALT encoding instead of a readable build order." )
35+ parser .add_argument ("--both" , action = "store_true" , help = "Print readable build order and SALT encoding together." )
2836 parser .add_argument ("--salt-title" , help = "Title to embed in SALT output. Defaults to the replay file name." )
37+ parser .add_argument ("--easy" , action = "store_true" , help = "Friendly drag-and-drop mode: write build-order text files next to the replays." )
2938 parser .add_argument ("--compare-replaystats" , help = "SC2ReplayStats replay URL to compare decoded state against." )
3039 parser .add_argument ("--replaystats-team" , type = int , default = 0 , help = "SC2ReplayStats team slot to compare, zero-based." )
3140 args = parser .parse_args (argv )
3241
42+ if not args .paths :
43+ message = "Drop one or more .SC2Replay files onto 'Drop replays here.bat' or pass a replay path."
44+ print (message , file = sys .stderr if not args .easy else sys .stdout )
45+ return 2
46+
47+ defaults = load_defaults ()
3348 try :
34- paths = replay_paths (args .path )
35- except FileNotFoundError :
36- print (f"Path does not exist: { args . path } " , file = sys .stderr )
49+ paths = _collect_replay_paths (args .paths )
50+ except FileNotFoundError as exc :
51+ print (f"Path does not exist: { exc } " , file = sys .stderr )
3752 return 2
3853
3954 if args .limit is not None :
4055 paths = paths [: args .limit ]
4156 if not paths :
42- print (f "No .SC2Replay files found in { args . path } " , file = sys .stderr )
57+ print ("No .SC2Replay files found. " , file = sys .stderr )
4358 return 2
4459
4560 exit_code = 0
61+ max_seconds = _max_seconds (args .max_minutes , args .full_game )
4662 options = BuildOrderOptions (
4763 include_starting_state = args .include_starting_state ,
4864 include_type_changes = args .include_type_changes ,
4965 include_workers = args .include_workers ,
50- max_seconds = args . max_minutes * 60 if args . max_minutes is not None else None ,
66+ max_seconds = max_seconds ,
5167 )
5268 for index , path in enumerate (paths ):
53- if index :
69+ if index and not args . easy :
5470 print ("\n " + "=" * 72 + "\n " )
5571 try :
56- player_selector = args .player or _prompt_for_player (path , args .no_prompt )
72+ player_selector = args .player or _player_selector (
73+ path ,
74+ args .no_prompt ,
75+ defaults ,
76+ all_on_no_prompt = args .easy ,
77+ )
78+ if args .easy and player_selector is ALL_PLAYERS :
79+ _write_easy_outputs_for_all_players (path , options , args .salt_title , defaults )
80+ continue
81+ if player_selector is ALL_PLAYERS :
82+ player_selector = None
5783 replay , player , items = build_order_for_replay (path , player_selector , options )
5884 replay_name = str (getattr (replay , "filename" , None ) or path .name )
59- if args .salt :
60- print (format_salt_encoding (items , args .salt_title or Path (replay_name ).stem ))
85+ salt_title = args .salt_title or Path (replay_name ).stem
86+ if args .easy or args .both :
87+ output = format_combined_output (replay_name , player , items , salt_title )
88+ elif args .salt :
89+ output = format_salt_encoding (items , salt_title )
6190 else :
62- print (format_build_order (replay_name , player , items ))
91+ output = format_build_order (replay_name , player , items )
92+
93+ _remember_player (defaults , player .name )
94+ if args .easy :
95+ output_path = _easy_output_path (path )
96+ output_path .write_text (output + "\n " , encoding = "utf-8" )
97+ print (f"Wrote { output_path } " )
98+ else :
99+ print (output )
63100 if args .compare_replaystats :
64101 reference = fetch_reference (
65102 args .compare_replaystats ,
66103 session_id = os .getenv ("SC2REPLAYSTATS_PHPSESSID" ),
67104 )
68- max_seconds = int (args .max_minutes * 60 ) if args .max_minutes is not None else None
69105 result = compare_reference (
70106 list (getattr (replay , "tracker_events" , None ) or []),
71107 player ,
@@ -83,20 +119,99 @@ def main(argv: list[str] | None = None) -> int:
83119 return exit_code
84120
85121
86- def _prompt_for_player (path : Path , no_prompt : bool ) -> str | None :
87- if no_prompt or not sys .stdin .isatty ():
122+ def format_combined_output (
123+ replay_name : str ,
124+ player : PlayerRef ,
125+ items : Sequence [BuildOrderItem ],
126+ salt_title : str ,
127+ ) -> str :
128+ return f"{ format_build_order (replay_name , player , items )} \n \n SALT:\n { format_salt_encoding (items , salt_title )} "
129+
130+
131+ def _collect_replay_paths (paths : Sequence [Path ]) -> list [Path ]:
132+ replay_files : list [Path ] = []
133+ for path in paths :
134+ replay_files .extend (replay_paths (path ))
135+ return replay_files
136+
137+
138+ def _write_easy_outputs_for_all_players (
139+ path : Path ,
140+ options : BuildOrderOptions ,
141+ salt_title : str | None ,
142+ defaults : dict [str , object ],
143+ ) -> None :
144+ replay = load_replay (path )
145+ refs = player_refs (replay_players (replay ))
146+ for ref in refs :
147+ replay , player , items = build_order_for_replay (path , str (ref .pid ), options )
148+ replay_name = str (getattr (replay , "filename" , None ) or path .name )
149+ output = format_combined_output (replay_name , player , items , salt_title or Path (replay_name ).stem )
150+ output_path = _easy_output_path (path , player )
151+ output_path .write_text (output + "\n " , encoding = "utf-8" )
152+ print (f"Wrote { output_path } " )
153+
154+
155+ def _max_seconds (max_minutes : float | None , full_game : bool ) -> float | None :
156+ if full_game :
88157 return None
158+ return (max_minutes if max_minutes is not None else DEFAULT_MAX_MINUTES ) * 60
89159
160+
161+ def _player_selector (
162+ path : Path ,
163+ no_prompt : bool ,
164+ defaults : dict [str , object ],
165+ all_on_no_prompt : bool = False ,
166+ ) -> str | object | None :
167+ remembered = _remembered_player (defaults )
90168 replay = load_replay (path )
91169 refs = player_refs (replay_players (replay ))
170+ if remembered :
171+ for ref in refs :
172+ if ref .name .casefold () == remembered .casefold () or str (ref .pid ) == remembered :
173+ return remembered
174+
175+ if no_prompt or not sys .stdin .isatty ():
176+ if all_on_no_prompt and len (refs ) > 1 :
177+ return ALL_PLAYERS
178+ return None
179+
92180 if len (refs ) <= 1 :
93181 return None
94182
95183 print (f"{ path .name } has multiple players:" )
96184 for ref in refs :
97185 print (f" { ref .label } " )
98- choice = input ("Analyze player id/name [default first]: " ).strip ()
186+ try :
187+ choice = input ("Analyze player id/name [default first; remembered next time]: " ).strip ()
188+ except EOFError :
189+ return ALL_PLAYERS
99190 if not choice :
100191 return None
101192 resolve_player (refs , choice )
102193 return choice
194+
195+
196+ def _remembered_player (defaults : dict [str , object ]) -> str | None :
197+ player = defaults .get ("player" )
198+ return player if isinstance (player , str ) and player .strip () else None
199+
200+
201+ def _remember_player (defaults : dict [str , object ], player : str ) -> None :
202+ if defaults .get ("player" ) == player :
203+ return
204+ defaults ["player" ] = player
205+ try :
206+ save_defaults (defaults )
207+ except OSError :
208+ pass
209+
210+
211+ def _easy_output_path (path : Path , player : PlayerRef | None = None ) -> Path :
212+ player_suffix = f" - { _safe_filename_part (player .name )} " if player is not None else ""
213+ return path .with_name (f"{ path .stem } { player_suffix } build order.txt" )
214+
215+
216+ def _safe_filename_part (value : str ) -> str :
217+ return "" .join (character if character not in '<>:"/\\ |?*' else "_" for character in value ).strip () or "player"
0 commit comments