ck3_history_extractor/
main.rs

1use clap::Parser;
2use derive_more::From;
3use human_panic::setup_panic;
4use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
5use serde_json;
6use std::{
7    env, error,
8    fmt::{self, Debug, Display, Formatter},
9    fs,
10    io::{stdin, stdout, IsTerminal},
11    ops::Not,
12    time::Duration,
13};
14
15/// A submodule that provides opaque types commonly used in the project
16mod types;
17
18/// A submodule that handles save file parsing
19mod parser;
20use parser::{process_section, yield_section, GameState, SaveFile, SaveFileError};
21
22/// A submodule that provides objects which are serialized and rendered into HTML.
23/// You can think of them like frontend DB view objects into parsed save files.
24mod structures;
25use structures::{GameObjectDerived, Player};
26
27/// The submodule responsible for creating the [minijinja::Environment] and loading of templates.
28mod jinja_env;
29use jinja_env::create_env;
30
31/// A module for handling the display of the parsed data.
32mod display;
33use display::{GetPath, Renderer};
34
35/// A submodule for handling the game data
36mod game_data;
37use game_data::{GameDataLoader, Localizable};
38
39/// A submodule for handling the arguments passed to the program
40mod args;
41use args::Args;
42
43/// A submodule for handling Steam integration
44mod steam;
45
46/// The interval at which the progress bars should update.
47const INTERVAL: Duration = Duration::from_secs(1);
48
49/// An error a user has caused. Shame on him.
50#[derive(From, Debug)]
51enum UserError {
52    /// The program is not running in a terminal
53    NoTerminal,
54    /// The file does not exist
55    FileDoesNotExist,
56    /// An error occurred during file handling
57    FileError(SaveFileError),
58}
59
60impl Display for UserError {
61    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
62        match self {
63            UserError::NoTerminal => write!(f, "The program is not running in a terminal"),
64            UserError::FileDoesNotExist => write!(f, "The file does not exist"),
65            UserError::FileError(e) => write!(f, "An error occurred during file handling: {}", e),
66        }
67    }
68}
69
70impl error::Error for UserError {
71    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
72        match self {
73            UserError::FileError(e) => Some(e),
74            _ => None,
75        }
76    }
77}
78
79/// Main function. This is the entry point of the program.
80///
81/// # Process
82///
83/// 1. Reads user input through the command line arguments or prompts the user for input.
84/// 2. Parses the save file.
85///     1. Initializes a [SaveFile] object using the provided file name
86///     2. Iterates over the Section objects in the save file
87///         If the section is of interest to us (e.g. `living`, `dead_unprunable`, etc.):
88///         1. We parse the section into [SaveFileObject](crate::parser::SaveFileObject) objects
89///         2. We parse the objects into [Derived](structures::GameObjectDerived) objects
90///         3. We store the objects in the [GameState] object
91/// 3. Initializes a [minijinja::Environment] and loads the templates from the `templates` folder
92/// 4. Foreach encountered [structures::Player] in game:
93///     1. Creates a folder with the player's name
94///     2. Renders the objects into HTML using the templates and writes them to the folder
95/// 5. Prints the time taken to parse the save file
96///
97fn main() -> Result<(), UserError> {
98    if cfg!(debug_assertions) {
99        env::set_var("RUST_BACKTRACE", "1");
100    }
101    setup_panic!();
102    //User IO
103    let args = if env::args().len() < 2 {
104        if !stdout().is_terminal() {
105            return Err(UserError::NoTerminal);
106        }
107        Args::get_from_user()
108    } else {
109        Args::parse()
110    };
111    // arguments passed
112    if !args.filename.exists() || !args.filename.is_file() {
113        return Err(UserError::FileDoesNotExist);
114    }
115    let bar_style = ProgressStyle::default_bar()
116        .template("[{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}")
117        .unwrap()
118        .progress_chars("#>-");
119    let spinner_style = ProgressStyle::default_spinner()
120        .template("[{elapsed_precise}] {spinner} {msg}")
121        .unwrap()
122        .tick_chars("|/-\\ ");
123    let mut include_paths = args.include;
124    //even though we don't need these for parsing, we load them here to error out early
125    if let Some(game_path) = args.game_path {
126        include_paths.push(game_path);
127    }
128    let mut loader = GameDataLoader::new(args.no_vis, args.language);
129    if !include_paths.is_empty() {
130        println!("Using game files from: {:?}", include_paths);
131        let progress_bar = ProgressBar::new(include_paths.len() as u64);
132        progress_bar.set_style(bar_style.clone());
133        // "items" in this case are huge, 8s on my ssd, so we enable the steady tick
134        progress_bar.enable_steady_tick(INTERVAL);
135        for path in progress_bar.wrap_iter(include_paths.iter().rev()) {
136            progress_bar.set_message(path.to_str().unwrap().to_owned());
137            loader.process_path(path).unwrap();
138        }
139        progress_bar.finish_with_message("Game files loaded");
140    }
141    let mut data = loader.finalize();
142    //initialize the save file
143    let save = SaveFile::open(args.filename)?;
144    // this is sort of like the first round of filtering where we store the objects we care about
145    let mut game_state: GameState = GameState::default();
146    let mut players: Vec<Player> = Vec::new();
147    let progress_bar = ProgressBar::new_spinner();
148    progress_bar.set_style(spinner_style.clone());
149    progress_bar.enable_steady_tick(INTERVAL);
150    let mut tape = save.tape();
151    while let Some(res) = yield_section(&mut tape) {
152        let mut section = res.unwrap();
153        progress_bar.set_message(section.get_name().to_owned());
154        // if an error occured somewhere here, there's nothing we can do
155        process_section(&mut section, &mut game_state, &mut players).unwrap();
156        progress_bar.inc(1);
157    }
158    progress_bar.finish_with_message("Save parsing complete");
159    //prepare things for rendering
160    game_state.localize(&mut data).unwrap();
161    let grapher = args.no_vis.not().then(|| game_state.new_grapher());
162    let timeline = args.no_vis.not().then(|| game_state.new_timeline());
163    let mut env = create_env(
164        args.use_internal,
165        data.get_map().is_some(),
166        args.no_vis,
167        &data,
168    );
169    // a big progress bar to show the progress of rendering that contains multiple progress bars
170    let rendering_progress_bar = MultiProgress::new();
171    let player_progress = rendering_progress_bar.add(ProgressBar::new(players.len() as u64));
172    player_progress.set_style(bar_style);
173    player_progress.enable_steady_tick(INTERVAL);
174    for player in player_progress.wrap_iter(players.iter_mut()) {
175        player.localize(&mut data).unwrap();
176        //render each player
177        let folder_name = player.get_name().to_string() + "'s history";
178        player_progress.set_message(format!("Rendering {}", folder_name));
179        let path = args.output.join(folder_name);
180        let mut renderer = Renderer::new(
181            path.as_path(),
182            &game_state,
183            &data,
184            grapher.as_ref(),
185            args.depth,
186        );
187        let render_spinner = rendering_progress_bar.add(ProgressBar::new_spinner());
188        render_spinner.set_style(spinner_style.clone());
189        render_spinner.enable_steady_tick(INTERVAL);
190        if !args.no_vis {
191            render_spinner.inc(renderer.add_object(timeline.as_ref().unwrap()) as u64);
192        }
193        render_spinner.inc(renderer.add_object(player) as u64);
194        renderer.render_all(&mut env);
195        render_spinner.finish_with_message("Rendering complete");
196        if stdin().is_terminal() && stdout().is_terminal() && !args.no_interaction {
197            // no need to error out here, its just a convenience feature
198            if let Err(e) = open::that(player.get_path(path.as_path())) {
199                eprintln!("Error opening browser: {}", e);
200            }
201        }
202        rendering_progress_bar.remove(&render_spinner);
203    }
204    player_progress.finish_with_message("Players rendered");
205    if let Some(dump_path) = args.dump {
206        let json = serde_json::to_string_pretty(&game_state).unwrap();
207        fs::write(dump_path, json).unwrap();
208    }
209    if let Some(dump_path) = args.dump_data {
210        let json = serde_json::to_string_pretty(&data).unwrap();
211        fs::write(dump_path, json).unwrap();
212    }
213    return Ok(());
214}