diff --git a/extras/ai-battle/HeadlessGame.cpp b/extras/ai-battle/HeadlessGame.cpp index 444b9e8b92..4724d09e84 100644 --- a/extras/ai-battle/HeadlessGame.cpp +++ b/extras/ai-battle/HeadlessGame.cpp @@ -4,6 +4,7 @@ #include "HeadlessGame.h" #include "EventManager.h" +#include "GamePlayer.h" #include "GlobalGameSettings.h" #include "PlayerInfo.h" #include "Savegame.h" @@ -22,7 +23,7 @@ # include "Windows.h" #endif -std::vector GeneratePlayerInfo(const std::vector& ais); +std::vector GeneratePlayerInfo(const std::vector& ais, const std::vector& teams); std::string ToString(const std::chrono::milliseconds& time); std::string HumanReadableNumber(unsigned num); @@ -44,8 +45,8 @@ void printConsole(const char* fmt, ...); #endif HeadlessGame::HeadlessGame(const GlobalGameSettings& ggs, const bfs::path& map, const std::vector& ais, - const bfs::path& luaPath) - : map_(map), game_(ggs, std::make_unique(0), GeneratePlayerInfo(ais)), world_(game_.world_), + const bfs::path& luaPath, const std::vector& teams) + : map_(map), game_(ggs, std::make_unique(0), GeneratePlayerInfo(ais, teams)), world_(game_.world_), em_(*static_cast(game_.em_.get())) { MapLoader loader(world_); @@ -53,6 +54,14 @@ HeadlessGame::HeadlessGame(const GlobalGameSettings& ggs, const bfs::path& map, throw std::runtime_error("Could not load " + map.string()); MapLoader::SetupResources(world_); + // Establish the team alliances (ally + non-aggression pacts) exactly like GameClient::StartGame does + // for a fresh map. Without this, teammates have no pacts and are mutually attackable, so the AIs attack + // their own team; on replay GameClient *does* set up the pacts, so those recorded attack commands are + // handled differently and the replay desyncs (object-count divergence). MakeStartPacts is a no-op for + // teamless players, so this is safe regardless of whether --teams was given. + for(unsigned i = 0; i < world_.GetNumPlayers(); ++i) + world_.GetPlayer(i).MakeStartPacts(); + if(!luaPath.empty()) { if(!loader.LoadLuaScript(game_, localState_, luaPath)) @@ -234,7 +243,7 @@ void HeadlessGame::PrintState() lastReportGf_ = em_.GetCurrentGF(); } -std::vector GeneratePlayerInfo(const std::vector& ais) +std::vector GeneratePlayerInfo(const std::vector& ais, const std::vector& teams) { std::vector ret; for(const AI::Info& ai : ais) @@ -249,7 +258,7 @@ std::vector GeneratePlayerInfo(const std::vector& ais) default: pi.name = "Dummy " + std::to_string(ret.size()); break; } pi.nation = Nation::Romans; - pi.team = Team::None; + pi.team = (ret.size() < teams.size()) ? teams[ret.size()] : Team::None; pi.color = PLAYER_COLORS[ret.size() % PLAYER_COLORS.size()]; ret.push_back(pi); } diff --git a/extras/ai-battle/HeadlessGame.h b/extras/ai-battle/HeadlessGame.h index 578b0a446c..69e9931f03 100644 --- a/extras/ai-battle/HeadlessGame.h +++ b/extras/ai-battle/HeadlessGame.h @@ -9,6 +9,7 @@ #include "Replay.h" #include "ai/AIPlayer.h" #include "gameTypes/AIInfo.h" +#include "gameTypes/TeamTypes.h" #include #include #include @@ -22,8 +23,10 @@ class EventManager; class HeadlessGame { public: + /// teams: optional per-player team assignment (index -> Team). Players sharing a team get start + /// pacts (ally + non-aggression), so only opposing teams fight. Defaults to no teams. HeadlessGame(const GlobalGameSettings& ggs, const boost::filesystem::path& map, const std::vector& ais, - const boost::filesystem::path& luaPath = {}); + const boost::filesystem::path& luaPath = {}, const std::vector& teams = {}); ~HeadlessGame(); void Run(unsigned maxGF = std::numeric_limits::max()); diff --git a/extras/ai-battle/main.cpp b/extras/ai-battle/main.cpp index 497e824a9a..e23e38a9ca 100644 --- a/extras/ai-battle/main.cpp +++ b/extras/ai-battle/main.cpp @@ -14,6 +14,7 @@ #include "ai/random.h" #include "files.h" #include "random/Random.h" +#include "gameTypes/TeamTypes.h" #include "s25util/Log.h" #include "s25util/StringConversion.h" #include "s25util/System.h" @@ -25,6 +26,7 @@ #include #include #include +#include #if BOOST_VERSION >= 109000 # include using std::optional; @@ -45,26 +47,32 @@ static void loadAddonsFromIni(GlobalGameSettings& ggs, const bfs::path& iniPath) boost::property_tree::ptree tree; boost::property_tree::read_ini(iniPath.string(), tree); + if(tree.empty()) // empty file -> nothing to configure, that's fine + return; + + // Anything else is intentional configuration, so surface mistakes as hard errors instead of + // silently ignoring them (a mistyped section or key/value would otherwise go unnoticed). const auto addons = tree.get_child_optional("addons"); if(!addons) - { - bnw::cout << "Note: no [addons] section in " << iniPath << ", using defaults.\n"; - return; - } + throw std::runtime_error("No [addons] section in " + iniPath.string()); unsigned loaded = 0; for(const auto& entry : *addons) { + AddonId id{}; + unsigned value = 0; try { - const auto id = static_cast(s25util::fromStringClassic(entry.first)); - const auto v = entry.second.get_value(); - ggs.setSelection(id, v); - ++loaded; + id = static_cast(s25util::fromStringClassic(entry.first)); + value = entry.second.get_value(); } catch(const std::exception&) { - // Unknown or invalid entry - skip silently + throw std::runtime_error("Invalid addon entry '" + entry.first + "' in " + iniPath.string()); } + if(!ggs.getAddon(id)) // unknown/unsupported addon id + throw std::runtime_error("Unknown addon id '" + entry.first + "' in " + iniPath.string()); + ggs.setSelection(id, value); + ++loaded; } bnw::cout << "Loaded " << loaded << " addon settings from " << iniPath << '\n'; } @@ -74,28 +82,37 @@ int main(int argc, char** argv) bnw::nowide_filesystem(); bnw::args _(argc, argv); - optional replay_path; - optional savegame_path; - optional lua_path; - optional settings_path; - unsigned random_init = static_cast(std::chrono::high_resolution_clock::now().time_since_epoch().count()); - unsigned random_ai_init = random_init; - po::options_description desc("Allowed options"); + struct + { + std::string map; + std::vector ais; + optional teams; + std::string objective, wares; + optional replay_path; + optional savegame_path; + optional lua_path; + optional settings_path; + unsigned random_init = + static_cast(std::chrono::high_resolution_clock::now().time_since_epoch().count()); + unsigned random_ai_init = random_init; + unsigned maxGF = std::numeric_limits::max(); + } opts; // clang-format off desc.add_options() ("help,h", "Show help") - ("map,m", po::value()->required(),"Map to load") - ("ai", po::value>()->required(),"AI player(s) to add (aijh | dummy)") - ("objective", po::value()->default_value("domination"),"domination(default) | conquer") - ("wares", po::value()->default_value("normal"),"Starting wares: vlow | low | normal (default) | alot") - ("settings", po::value(&settings_path),"INI file with an [addons] section to configure addon settings (optional)") - ("replay", po::value(&replay_path),"Filename to write replay to (optional)") - ("save", po::value(&savegame_path),"Filename to write savegame to (optional)") - ("lua", po::value(&lua_path),"Lua script to execute during the game (optional)") - ("random_init", po::value(&random_init),"Seed value for the random number generator (optional)") - ("random_ai_init", po::value(&random_ai_init),"Seed value for the AI random number generator (optional)") - ("maxGF", po::value()->default_value(std::numeric_limits::max()),"Maximum number of game frames to run (optional)") + ("map,m", po::value(&opts.map)->required(),"Map to load") + ("ai", po::value(&opts.ais)->required(),"AI player(s) to add (aijh | dummy)") + ("teams", po::value(&opts.teams),"Team assignment, e.g. \"0,1;2,3\" for a 2v2 (groups separated by ';', player indices by ','). Allied players get start pacts.") + ("objective", po::value(&opts.objective)->default_value("domination"),"domination(default) | conquer") + ("wares", po::value(&opts.wares)->default_value("normal"),"Starting wares: vlow | low | normal (default) | alot") + ("settings", po::value(&opts.settings_path),"INI file with an [addons] section to configure addon settings (optional)") + ("replay", po::value(&opts.replay_path),"Filename to write replay to (optional)") + ("save", po::value(&opts.savegame_path),"Filename to write savegame to (optional)") + ("lua", po::value(&opts.lua_path),"Lua script to execute during the game (optional)") + ("random_init", po::value(&opts.random_init),"Seed value for the random number generator (optional)") + ("random_ai_init", po::value(&opts.random_ai_init),"Seed value for the AI random number generator (optional)") + ("maxGF", po::value(&opts.maxGF),"Maximum number of game frames to run (optional)") ("version", "Show version information and exit") ; // clang-format on @@ -113,9 +130,9 @@ int main(int argc, char** argv) return 1; } - po::variables_map options; try { + po::variables_map options; po::store(po::command_line_parser(argc, argv).options(desc).run(), options); if(options.count("help")) @@ -145,8 +162,8 @@ int main(int argc, char** argv) for(int i = 0; i < argc; ++i) bnw::cout << argv[i] << " "; bnw::cout << std::endl; - bnw::cout << "random_init: " << random_init << std::endl; - bnw::cout << "random_ai_init: " << random_ai_init << std::endl; + bnw::cout << "random_init: " << opts.random_init << std::endl; + bnw::cout << "random_ai_init: " << opts.random_ai_init << std::endl; bnw::cout << std::endl; RTTRCONFIG.Init(); @@ -155,44 +172,42 @@ int main(int argc, char** argv) const bfs::path logDir = RTTRCONFIG.ExpandPath(s25::folders::logs); bfs::create_directories(logDir); LOG.setLogFilepath(logDir); - RANDOM.Init(random_init); - AI::getRandomGenerator().seed(random_ai_init); + RANDOM.Init(opts.random_init); + AI::getRandomGenerator().seed(opts.random_ai_init); - const bfs::path mapPath = RTTRCONFIG.ExpandPath(options["map"].as()); - const std::vector ais = ParseAIOptions(options["ai"].as>()); + const bfs::path mapPath = RTTRCONFIG.ExpandPath(opts.map); + const std::vector ais = ParseAIOptions(opts.ais); GlobalGameSettings ggs; - const auto objective = options["objective"].as(); - if(objective == "domination") + if(opts.objective == "domination") ggs.objective = GameObjective::TotalDomination; - else if(objective == "conquer") + else if(opts.objective == "conquer") ggs.objective = GameObjective::Conquer3_4; else { - bnw::cerr << "unknown objective: " << objective << std::endl; + bnw::cerr << "unknown objective: " << opts.objective << std::endl; return 1; } - const auto wares = options["wares"].as(); - if(wares == "vlow") + if(opts.wares == "vlow") ggs.startWares = StartWares::VLow; - else if(wares == "low") + else if(opts.wares == "low") ggs.startWares = StartWares::Low; - else if(wares == "normal") + else if(opts.wares == "normal") ggs.startWares = StartWares::Normal; - else if(wares == "alot") + else if(opts.wares == "alot") ggs.startWares = StartWares::ALot; else { - bnw::cerr << "Unknown wares value: " << wares << std::endl; + bnw::cerr << "Unknown wares value: " << opts.wares << std::endl; return 1; } - if(settings_path) + if(opts.settings_path) { - loadAddonsFromIni(ggs, RTTRCONFIG.ExpandPath(*settings_path)); + loadAddonsFromIni(ggs, RTTRCONFIG.ExpandPath(*opts.settings_path)); - bnw::cout << "settings: " << RTTRCONFIG.ExpandPath(*settings_path) << std::endl; + bnw::cout << "settings: " << RTTRCONFIG.ExpandPath(*opts.settings_path) << std::endl; bnw::cout << "addon selections (non-default only):" << std::endl; for(unsigned i = 0; i < ggs.getNumAddons(); ++i) { @@ -213,14 +228,40 @@ int main(int argc, char** argv) } } - HeadlessGame game(ggs, mapPath, ais, lua_path ? RTTRCONFIG.ExpandPath(*lua_path) : bfs::path{}); - if(replay_path) - game.RecordReplay(RTTRCONFIG.ExpandPath(*replay_path), random_init); + // Team assignment, e.g. "0,1;2,3". Player index -> Team (Team1, Team2, ...). + std::vector teams; + if(opts.teams) + { + std::stringstream groups(*opts.teams); + std::string group; + unsigned teamIdx = 0; + while(std::getline(groups, group, ';')) + { + const Team team = static_cast(static_cast(Team::Team1) + teamIdx); + std::stringstream members(group); + std::string idx; + while(std::getline(members, idx, ',')) + { + if(idx.empty()) + continue; + const auto p = static_cast(std::stoul(idx)); + if(p >= teams.size()) + teams.resize(p + 1, Team::None); + teams[p] = team; + } + ++teamIdx; + } + } + + HeadlessGame game(ggs, mapPath, ais, opts.lua_path ? RTTRCONFIG.ExpandPath(*opts.lua_path) : bfs::path{}, + teams); + if(opts.replay_path) + game.RecordReplay(RTTRCONFIG.ExpandPath(*opts.replay_path), opts.random_init); - game.Run(options["maxGF"].as()); + game.Run(opts.maxGF); game.Close(); - if(savegame_path) - game.SaveGame(RTTRCONFIG.ExpandPath(*savegame_path)); + if(opts.savegame_path) + game.SaveGame(RTTRCONFIG.ExpandPath(*opts.savegame_path)); } catch(const std::exception& e) { bnw::cerr << e.what() << std::endl; diff --git a/libs/s25main/GlobalGameSettings.cpp b/libs/s25main/GlobalGameSettings.cpp index 7ccd6ba437..d0fa0d3e0c 100644 --- a/libs/s25main/GlobalGameSettings.cpp +++ b/libs/s25main/GlobalGameSettings.cpp @@ -152,13 +152,29 @@ const Addon* GlobalGameSettings::getAddon(unsigned idx) const return addons[idx].addon.get(); } -GlobalGameSettings::AddonWithState* GlobalGameSettings::getAddon(AddonId id) +const Addon* GlobalGameSettings::getAddon(AddonId idx, unsigned& status) const { - auto it = helpers::find_if(addons, [id](const AddonWithState& cur) { return cur.addon->getId() == id; }); - return it != addons.end() ? &*it : nullptr; + const AddonWithState* addon = findAddon(idx); + if(addon) + { + status = addon->status; + return addon->addon.get(); + } + return nullptr; +} + +const Addon* GlobalGameSettings::getAddon(AddonId idx) const +{ + unsigned status; + return getAddon(idx, status); +} + +GlobalGameSettings::AddonWithState* GlobalGameSettings::findAddon(AddonId id) +{ + return const_cast(static_cast(*this).findAddon(id)); } -const GlobalGameSettings::AddonWithState* GlobalGameSettings::getAddon(AddonId id) const +const GlobalGameSettings::AddonWithState* GlobalGameSettings::findAddon(AddonId id) const { auto it = helpers::find_if(addons, [id](const AddonWithState& cur) { return cur.addon->getId() == id; }); return it != addons.end() ? &*it : nullptr; @@ -166,13 +182,13 @@ const GlobalGameSettings::AddonWithState* GlobalGameSettings::getAddon(AddonId i bool GlobalGameSettings::isEnabled(AddonId id) const { - const auto* addon = getAddon(id); + const auto* addon = findAddon(id); return addon && addon->status != addon->addon->getDefaultStatus(); } unsigned GlobalGameSettings::getSelection(AddonId id) const { - const auto* addon = getAddon(id); + const auto* addon = findAddon(id); return addon ? addon->status : 0; } @@ -300,9 +316,9 @@ void GlobalGameSettings::applyLegacyInexhaustibleMines() void GlobalGameSettings::setSelection(AddonId id, unsigned selection) { - auto* addon = getAddon(id); + auto* addon = findAddon(id); if(!addon) - LOG.write(_("Addon %1$#x not found!\n"), LogTarget::FileAndStderr) % static_cast(id); + LOG.write(_("Addon %1$#x not found!\n"), LogTarget::FileAndStderr) % rttr::enum_cast(id); else addon->status = selection; } diff --git a/libs/s25main/GlobalGameSettings.h b/libs/s25main/GlobalGameSettings.h index 111c0c04df..d471b94e5a 100644 --- a/libs/s25main/GlobalGameSettings.h +++ b/libs/s25main/GlobalGameSettings.h @@ -38,6 +38,8 @@ class GlobalGameSettings unsigned getNumAddons() const { return addons.size(); } const Addon* getAddon(unsigned idx, unsigned& status) const; const Addon* getAddon(unsigned idx) const; + const Addon* getAddon(AddonId id, unsigned& status) const; + const Addon* getAddon(AddonId id) const; void registerAllAddons(); @@ -72,8 +74,8 @@ class GlobalGameSettings }; void registerAddon(std::unique_ptr addon); - const AddonWithState* getAddon(AddonId id) const; - AddonWithState* getAddon(AddonId id); + AddonWithState* findAddon(AddonId id); + const AddonWithState* findAddon(AddonId id) const; std::vector addons; };