diff --git a/src/entry_point_nix.cpp b/src/entry_point_nix.cpp index 725054081..e6f71dd93 100644 --- a/src/entry_point_nix.cpp +++ b/src/entry_point_nix.cpp @@ -2,6 +2,9 @@ #include "system_state.hpp" #include "game_scene.hpp" #include "parsers_declarations.hpp" +#include "headless_run.hpp" +#include +#include static sys::state game_state; struct scenario_file { @@ -204,6 +207,21 @@ void enforce_list_order() { } int main(int argc, char* argv[]) { + // headless batch flags: -seed N (fixed game seed), -years N (with -headless: run N game years at full speed, then exit), + // -dump DIR (monthly CSVs, see headless_run.hpp), -threads N (cap worker threads; 1 makes runs reproducible) + int run_years = -1; + std::string dump_dir; + std::unique_ptr thread_cap; + for(int i = 1; i + 1 < argc; ++i) { + if(std::string(argv[i]) == "-seed") + setenv("ALICE_SEED", argv[i + 1], 1); + else if(std::string(argv[i]) == "-years") + run_years = std::atoi(argv[i + 1]); + else if(std::string(argv[i]) == "-dump") + dump_dir = argv[i + 1]; + else if(std::string(argv[i]) == "-threads") + thread_cap = std::make_unique(oneapi::tbb::global_control::max_allowed_parallelism, size_t(std::atoi(argv[i + 1]))); + } add_root(game_state.common_fs, NATIVE(".")); check_mods_folder(); check_scenario_folder(); @@ -370,6 +388,13 @@ int main(int argc, char* argv[]) { game_state.ui_pause.store(false, std::memory_order::release); game_scene::switch_scene(game_state, game_scene::scene_id::in_game_basic); game_state.local_player_nation = dcon::nation_id{}; + // headless: network::init gave the rank-1 nation to a local player; nobody plays it, so hand it back to the AI + for(auto n : game_state.world.in_nation) + n.set_is_player_controlled(false); + if(run_years >= 0) { + headless::run(game_state, run_years, dump_dir); + return EXIT_SUCCESS; + } game_state.game_loop(); } else { std::thread update_thread([&]() { diff --git a/src/entry_point_win.cpp b/src/entry_point_win.cpp index 4fdce8182..b94203c16 100644 --- a/src/entry_point_win.cpp +++ b/src/entry_point_win.cpp @@ -321,6 +321,9 @@ int WINAPI wWinMain(HINSTANCE /*hInstance*/, HINSTANCE /*hPrevInstance*/, LPWSTR game_state.ui_pause.store(false, std::memory_order::release); game_scene::switch_scene(game_state, game_scene::scene_id::in_game_basic); game_state.local_player_nation = dcon::nation_id{}; + // headless: network::init gave the rank-1 nation to a local player; nobody plays it, so hand it back to the AI + for(auto n : game_state.world.in_nation) + n.set_is_player_controlled(false); if(headless_repeat) { std::thread update_thread([&]() { game_state.game_loop(); }); while(!game_state.quit_signaled.load(std::memory_order::acquire)) { diff --git a/src/gamestate/serialization.cpp b/src/gamestate/serialization.cpp index 93d8c4ad0..8e22cc208 100644 --- a/src/gamestate/serialization.cpp +++ b/src/gamestate/serialization.cpp @@ -1,6 +1,7 @@ #include "dcon_generated_ids.hpp" #include "system_state.hpp" #include "serialization.hpp" +#include #include #include @@ -1169,7 +1170,7 @@ bool try_read_scenario_and_save_file(sys::state& state, native_string_view name) buffer_pos = with_decompressed_section(buffer_pos, [&](uint8_t const* ptr_in, uint32_t length) { read_save_section(ptr_in, ptr_in + length, state); }); - state.game_seed = uint32_t(std::random_device()()); + state.game_seed = getenv("ALICE_SEED") ? uint32_t(strtoul(getenv("ALICE_SEED"), nullptr, 10)) : uint32_t(std::random_device()()); // -seed N sets ALICE_SEED state.on_scenario_load(); @@ -1219,7 +1220,7 @@ bool try_read_scenario_as_save_file(sys::state& state, native_string_view name) read_save_section(ptr_in, ptr_in + length, state); }); - state.game_seed = uint32_t(std::random_device()()); + state.game_seed = getenv("ALICE_SEED") ? uint32_t(strtoul(getenv("ALICE_SEED"), nullptr, 10)) : uint32_t(std::random_device()()); // -seed N sets ALICE_SEED // only load gamerule settings if host or singleplayer. A client would have to load the host' settings anyway diff --git a/src/headless_run.hpp b/src/headless_run.hpp new file mode 100644 index 000000000..4ac13f338 --- /dev/null +++ b/src/headless_run.hpp @@ -0,0 +1,115 @@ +#pragma once +// -headless -years N: runs N game years with no speed cap, prints seconds per game year, and with -dump DIR +// writes monthly CSVs (nations.csv, prices.csv, provinces.csv) on the 1st of each month. +#include +#include +#include +#include + +namespace headless { + +inline std::string ymd_str(sys::year_month_day d) { + char buf[16]; + snprintf(buf, sizeof(buf), "%04d-%02d-%02d", d.year, int(d.month), int(d.day)); + return std::string(buf); +} +inline std::string ymd_of(sys::state& state) { + return ymd_str(state.current_date.to_ymd(state.start_date)); +} + +inline std::string tag_of(sys::state& state, dcon::nation_id n) { + if(!n) + return ""; + return nations::int_to_tag( + state.world.national_identity_get_identifying_int(state.world.nation_get_identity_from_identity_holder(n))); +} + +inline void dump_month(sys::state& state, FILE* nations_csv, FILE* prices_csv, FILE* provinces_csv, std::string const& date) { + // province index is Alice's own (land provinces first), not the Vic2 definition.csv number + for(int32_t i = 0; i < state.province_definitions.first_sea_province.index(); ++i) { + dcon::province_id p{dcon::province_id::value_base_t(i)}; + fprintf(provinces_csv, "%s,%d,%s,%s,%.6g\n", date.c_str(), i, + tag_of(state, state.world.province_get_nation_from_province_ownership(p)).c_str(), + tag_of(state, state.world.province_get_nation_from_province_control(p)).c_str(), + state.world.province_get_demographics(p, demographics::total)); + } + state.world.for_each_nation([&](dcon::nation_id n) { + if(state.world.nation_get_owned_province_count(n) == 0) + return; + fprintf(nations_csv, "%s,%s,%.6g,%.6g,%.6g,%d,%d,%d,%.6g,%.6g,%d,%.6g,%.6g\n", date.c_str(), tag_of(state, n).c_str(), + state.world.nation_get_prestige(n), float(state.world.nation_get_industrial_score(n)), + float(state.world.nation_get_military_score(n)), int(state.world.nation_get_rank(n)), + int(state.world.nation_get_is_great_power(n)), int(state.world.nation_get_is_civilized(n)), + state.world.nation_get_stockpiles(n, economy::money), state.world.nation_get_demographics(n, demographics::total), + int(state.world.nation_get_owned_province_count(n)), state.world.nation_get_infamy(n), + state.world.nation_get_war_exhaustion(n)); + }); + state.world.for_each_commodity([&](dcon::commodity_id c) { + if(c == economy::money) + return; + float supply = 0.f; + state.world.for_each_market([&](dcon::market_id m) { supply += state.world.market_get_supply(m, c); }); + // price = supply-weighted mean over the per-state markets; median_price = median over markets + fprintf(prices_csv, "%s,%s,%.6g,%.6g,%.6g\n", date.c_str(), + text::produce_simple_string(state, state.world.commodity_get_name(c)).c_str(), economy::price(state, c), + economy::median_price(state, c), supply); + }); +} + +inline void run(sys::state& state, int years, std::string const& dump_dir) { + using clock = std::chrono::steady_clock; + FILE* nations_csv = nullptr; + FILE* prices_csv = nullptr; + FILE* provinces_csv = nullptr; + if(!dump_dir.empty()) { + std::filesystem::create_directories(dump_dir); + nations_csv = fopen((dump_dir + "/nations.csv").c_str(), "w"); + prices_csv = fopen((dump_dir + "/prices.csv").c_str(), "w"); + provinces_csv = fopen((dump_dir + "/provinces.csv").c_str(), "w"); + fprintf(provinces_csv, "date,province,owner,controller,population\n"); + fprintf(nations_csv, "date,tag,prestige,industrial_score,military_score,rank,is_gp,is_civilized,treasury,population,provinces,infamy,war_exhaustion\n"); + fprintf(prices_csv, "date,good,price,median_price,world_supply\n"); + } + + auto start = state.current_date.to_ymd(state.start_date); + int const end_year = start.year + years; + int year = start.year; + printf("HEADLESS start=%s years=%d seed=%u pops=%u\n", ymd_str(start).c_str(), years, state.game_seed, state.world.pop_size()); + fflush(stdout); + if(nations_csv) + dump_month(state, nations_csv, prices_csv, provinces_csv, ymd_str(start)); + + state.user_settings.autosaves = sys::autosave_frequency::none; // parallel runs would share one autosave slot set + auto t_start = clock::now(); + auto t_year = t_start; + while(years > 0) { + command::execute_pending_commands(state); + state.single_game_tick(); + auto ymd = state.current_date.to_ymd(state.start_date); + if(ymd.day == 1 && nations_csv) { + dump_month(state, nations_csv, prices_csv, provinces_csv, ymd_str(ymd)); + fflush(nations_csv); + fflush(prices_csv); + fflush(provinces_csv); + } + if(ymd.year != year) { + auto now = clock::now(); + printf("YEAR %d secs=%.3f pops=%u wars=%u\n", year, std::chrono::duration(now - t_year).count(), + state.world.pop_size(), state.world.war_size()); + fflush(stdout); + t_year = now; + year = ymd.year; + } + if(ymd.year >= end_year) + break; + } + printf("TOTAL years=%d secs=%.3f\n", years, std::chrono::duration(clock::now() - t_start).count()); + fflush(stdout); + if(nations_csv) { + fclose(nations_csv); + fclose(prices_csv); + fclose(provinces_csv); + } +} + +} // namespace headless