rusty-train 0.1.0

A graphical user interface for constructing 18xx maps and identifying optimal train routes.
/// Find optimal routes for three major companies in the final operating round
/// of the Bankruptcy Club's recorded game of 1867.
///
/// Run this as a test case to calculate the optimal routes and ensure that
/// they match the cached results:
///
///     cargo test --release 1867_bc -- --ignored
///
/// Run this as an example to use the cached results (if they exist) and
/// update the output images in the book directory:
///
///     cargo run --release --example 1867_bc
///
use chrono::Local;
use log::info;
use std::io::Write;
use std::path::Path;

use navig18xx::game::new_1867;
use navig18xx::prelude::*;

mod output;
use output::Dir;

/// The state of the 1867 game.
pub struct GameState {
    example: Example,
    game: Box<dyn Game>,
    companies: Vec<CompanyInfo>,
}

/// The details of each company and their optimal routes.
pub struct CompanyInfo {
    token_name: &'static str,
    trains: Trains,
    train_desc: &'static str,
    num_paths: usize,
    net_revenue: usize,
}

#[test]
#[ignore]
/// Run this example and write the output images to the working directory.
/// This will always calculate the optimal routes, and ensure that they are
/// identical to the cached routes (if they exist).
///
/// Because this example takes minutes to run, it is ignored by default.
/// Ignored tests can be run with:
///
///     cargo test [options] -- --ignored
///
fn test_1867_bc() -> Result<(), Box<dyn std::error::Error>> {
    let use_cached_routes = false;
    save_1867_bc_routes(use_cached_routes)
}

/// Run this example and write the output images to the book directory.
/// This will use the cached routes, if they exist.
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let use_cached_routes = true;
    save_1867_bc_routes(use_cached_routes)
}

/// Default to logging all messages up to ``log::Level::Info``.
fn init_logging() {
    let log_level = "info";
    env_logger::Builder::from_env(
        env_logger::Env::default().default_filter_or(log_level),
    )
    .format(|buf, record| {
        writeln!(
            buf,
            "{} [{}] {}",
            chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"),
            record.level(),
            record.args()
        )
    })
    .init();
}

fn save_1867_bc_routes(
    use_cached_routes: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let image_dir = Dir::BookRoot;
    let json_dir = Dir::Examples;

    init_logging();

    let mut state = game_state();

    // Save the map state.
    let dest_file = json_dir.join("1867_bc.map");
    let descr: navig18xx::map::Descr = state.example.map().into();
    info!("Writing {} ...", dest_file.display());
    navig18xx::io::write_map_descr(dest_file, &descr, true)?;

    // Save the game state.
    let state_file = json_dir.join("1867_bc.game");
    let game_state = state.game.save(state.example.map());
    info!("Writing {} ...", state_file.display());
    navig18xx::io::write_game_state(state_file, game_state, true)?;

    // Save an image of the map prior to drawing any routes.
    state.example.draw_map();
    let out_file = image_dir.join("1867_bc.png");
    save_png(&state.example, &out_file);

    // Draw the best routes for each company in turn.
    for company in &state.companies {
        // Determine the output file name for the best routes.
        let routes_basename = format!("1867_bc_{}.json", company.token_name);
        let routes_file = json_dir.join(routes_basename);
        // Determine the output file name for the best routes image.
        let image_basename = format!("1867_bc_{}.png", company.token_name);
        let image_file = image_dir.join(image_basename);

        // Report when the file does not exist, so we can distinguish between
        // the file not existing and being unable to parse the file contents.
        if !routes_file.exists() {
            info!("File does not exist: {}", routes_file.display());
        }
        // Load the cached routes, if they exist.
        let cached_opt = read_routes(&routes_file).ok();
        // Determine whether to calculate and save the best routes.
        let (routes, save_routes) = if let Some(routes) = cached_opt {
            info!("Reading {}", routes_file.display());
            if use_cached_routes {
                // Use the cached routes, no need to save them.
                info!("Using cached routes for {}", company.token_name);
                (routes, false)
            } else {
                info!("Calculating best routes for {}", company.token_name);
                let new_routes =
                    best_routes(&state.example, &*state.game, company);
                if new_routes == routes {
                    // The calculated routes match the cached routes, no need
                    // to save them.
                    (routes, false)
                } else {
                    // The calculated routes differ from the cached routes.
                    // Save the calculated routes and fail the test.
                    info!("Saving routes to {}", routes_file.display());
                    let pretty = true;
                    write_routes(routes_file, &new_routes, pretty).unwrap();
                    panic!("Calculated routes differ from the cached routes")
                }
            }
        } else {
            // Calculate the best routes and save the results.
            (best_routes(&state.example, &*state.game, company), true)
        };

        // Draw the best routes and save the image to disk.
        state.example.erase_all()?;
        draw_routes(&mut state.example, &*state.game, company, &routes)?;
        save_png(&state.example, image_file);

        // If the best routes were calculated, save them to disk.
        if save_routes {
            info!("Saving routes to {}", routes_file.display());
            let pretty = true;
            write_routes(routes_file, &routes, pretty).unwrap();
        }
    }

    Ok(())
}

fn game_state() -> GameState {
    let mut game = new_1867();
    let hex_max_diameter = 125.0;
    let hex = Hex::new(hex_max_diameter);
    let mut example = Example::new_game(&game, hex);

    // NOTE: the major companies with tokens on the board are:
    // Blue:   Chesapeake and Ohio Railway (C&O)
    // Green:  Canadian Northern Railway (CNoR)
    // Brown:  Great Western Railway (GW)
    // Red:    Canadian Pacific Railway (CPR)
    // Beige:  National Transcontinental Railway (NTR)
    //
    // Not in play:
    // Orange: Grand Trunk Railway (GT)
    // Yellow: Intercolonial Railway of Canada (IRC)
    // Black:  New York Central Railroad (NYC)

    // Set the game phase to "8".
    game.set_phase_name(example.map_mut(), "8");

    // Define the five tokens by company name.
    let cnr = "CNR";
    let gw = "GW";
    let cno = "C&O";
    let cpr = "CPR";
    let ntr = "NTR";

    // Place tiles and tokens.
    let tiles = vec![
        // Top-most diagonal row, starting from Sarnia.
        tile_at("87", "B18").rotate_cw(1),
        tile_at("63", "C17").token(0, cno),
        tile_at("63", "D16").token(0, cnr),
        tile_at("63", "E15").token(0, gw),
        tile_at("42", "F14").rotate_acw(2),
        tile_at("23", "G13").rotate_cw(1),
        tile_at("27", "H12").rotate_acw(2),
        tile_at("23", "I11").rotate_cw(1),
        tile_at("24", "J10").rotate_acw(2),
        tile_at("8", "K9").rotate_cw(1),
        // Second diagonal row, spanning Hamilton to Trois-Rivières.
        tile_at("8", "D18").rotate_acw(2),
        tile_at("623", "E17").token(0, cno).token(1, cnr),
        tile_at("124", "F16")
            .token(0, cno)
            .token(1, cnr)
            .token(2, cpr),
        tile_at("611", "G15").rotate_cw(1).token(0, cpr),
        tile_at("204", "H14").rotate_acw(1),
        tile_at("8", "I13").rotate_cw(2),
        tile_at("X8", "J12").token(0, gw),
        tile_at("31", "K11").rotate_acw(1),
        tile_at("204", "L10").rotate_acw(2),
        tile_at("57", "M9").rotate_cw(1),
        tile_at("9", "N8").rotate_cw(1),
        // Third diagonal row, Kingston to Montreal.
        tile_at("15", "I15").rotate_cw(1).token(0, ntr),
        tile_at("24", "J14").rotate_cw(1),
        tile_at("911", "K13").rotate_acw(2),
        tile_at("639", "L12")
            .token(0, cpr)
            .token(1, ntr)
            .token(2, gw),
        // Fourth diagonal row, connects Montreal to New England.
        tile_at("58", "M13").rotate_cw(2),
    ];
    example.place_tiles(tiles);

    // NOTE: these tiles were placed after CNR and GW ran, and before C&O ran,
    // but placing them does not affect the optimal routes for CNR and GW, so
    // it's simplest to place them now and use the same map configuration for
    // all three companies.
    let extra_tiles = vec![
        tile_at("16", "D18").rotate_acw(3),
        tile_at("7", "C19").rotate_acw(2),
    ];
    example.place_tiles(extra_tiles);

    let cnr_trains = Trains::new(vec![*game.train("5"), *game.train("5+5E")]);
    let gw_trains = Trains::new(vec![*game.train("5"), *game.train("8")]);
    let cno_trains = Trains::new(vec![*game.train("6"), *game.train("8")]);

    let companies = vec![
        CompanyInfo {
            token_name: gw,
            trains: gw_trains,
            train_desc: "5-train, 8-train",
            num_paths: 15_008,
            net_revenue: 840,
        },
        CompanyInfo {
            token_name: cno,
            trains: cno_trains,
            train_desc: "6-train, 8-train",
            num_paths: 46_176,
            net_revenue: 900,
        },
        CompanyInfo {
            token_name: cnr,
            trains: cnr_trains,
            train_desc: "5-train, 5+5E-train",
            num_paths: 67_948,
            net_revenue: 1130,
        },
    ];

    let game: Box<dyn Game> = Box::new(game);
    GameState {
        example,
        game,
        companies,
    }
}

fn best_routes(
    example: &Example,
    game: &dyn Game,
    company: &CompanyInfo,
) -> Routes {
    let bonuses = vec![];
    let token = example.map().token(company.token_name);
    let path_limit = company.trains.path_limit();
    let criteria = Criteria {
        token,
        path_limit,
        conflict_rule: game.single_route_conflicts(),
        route_conflict_rule: game.multiple_routes_conflicts(),
    };
    let map = example.map();
    let start = Local::now();
    let paths = paths_for_token(map, &criteria);
    assert_eq!(paths.len(), company.num_paths);
    let mid = Local::now() - start;
    let routes = company
        .trains
        .select_routes(paths, bonuses)
        .expect("Could not find optimal routes");
    let durn = Local::now() - start;
    info!(
        "Paths duration: {:02}:{:02}.{:03}",
        mid.num_minutes(),
        mid.num_seconds() % 60,
        mid.num_milliseconds() % 1000
    );
    info!(
        "Total duration: {:02}:{:02}.{:03}",
        durn.num_minutes(),
        durn.num_seconds() % 60,
        durn.num_milliseconds() % 1000
    );
    info!("${}", routes.net_revenue);
    for train_route in &routes.train_routes {
        info!("    ${}", train_route.revenue);
    }
    info!("");
    assert_eq!(routes.net_revenue, company.net_revenue);
    routes
}

fn draw_routes(
    example: &mut Example,
    game: &dyn Game,
    company: &CompanyInfo,
    routes: &Routes,
) -> Result<(), Box<dyn std::error::Error>> {
    // Draw the relevant portion of the map.
    example.draw_map_subset(|addr| {
        let row = addr.logical_row();
        let col = addr.logical_column();
        row + col >= 16
    });

    let colours = example.theme().highlight_colours();
    for (tr, colour) in routes.train_routes.iter().zip(colours) {
        example.draw_route(&tr.route, colour)
    }

    let label_text = format!(
        "{}: {} = ${}",
        company.token_name, company.train_desc, routes.net_revenue
    );
    let labeller = example
        .text_style()
        .font_serif()
        .font_size(36.0)
        .bold()
        .halign_left()
        .valign_top()
        .labeller(example.context(), example.hex());

    // NOTE: image coordinates are not the same as map canvas coordinates,
    // because we're only drawing a subset of the map and the full surface
    // will be cropped to the inked portion.
    // Instead, draw the label relative to a known hex address.
    let coords = game.coordinate_system();
    let m = example.map().prepare_to_draw(
        coords.parse("A5").unwrap(),
        example.hex(),
        example.context(),
    );
    // Draw the text, then restore the transformation matrix.
    labeller.draw(&label_text, (0.0, 0.0).into());
    example.context().set_matrix(m);

    Ok(())
}

fn save_png<S: AsRef<Path>>(example: &Example, filename: S) {
    let filename = filename.as_ref();
    // NOTE: don't use a fully-transparent background (alpha = 0.0).
    // Otherwise the revenue label will not be visible in the book when using
    // a dark theme.
    let bg_rgba = Some(Colour::WHITE);
    let margin = 20;
    info!("Writing {} ...", filename.display());
    example.write_png(margin, bg_rgba, filename);
}