From 06ab6846f38b32f1a2c6cde9895c5f534bb0a262 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:52:44 +0200 Subject: [PATCH 1/9] Fix and extend AI-Battle --- extras/ai-battle/HeadlessGame.cpp | 32 +++++- extras/ai-battle/HeadlessGame.h | 4 +- extras/ai-battle/main.cpp | 163 ++++++++++++++++++++++++++---- libs/s25main/addons/AddonList.cpp | 5 + libs/s25main/addons/AddonList.h | 1 + 5 files changed, 186 insertions(+), 19 deletions(-) diff --git a/extras/ai-battle/HeadlessGame.cpp b/extras/ai-battle/HeadlessGame.cpp index e4aedbdbd8..1cbe16efda 100644 --- a/extras/ai-battle/HeadlessGame.cpp +++ b/extras/ai-battle/HeadlessGame.cpp @@ -1,10 +1,11 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later #include "HeadlessGame.h" #include "EventManager.h" #include "GlobalGameSettings.h" +#include "ILocalGameState.h" #include "PlayerInfo.h" #include "Savegame.h" #include "factories/AIFactory.h" @@ -13,6 +14,7 @@ #include "world/MapLoader.h" #include "gameTypes/MapInfo.h" #include "gameData/GameConsts.h" +#include "s25util/colors.h" #include #include #include @@ -27,6 +29,16 @@ std::string HumanReadableNumber(unsigned num); namespace bfs = boost::filesystem; namespace bnw = boost::nowide; + +namespace { +struct HeadlessLocalState : ILocalGameState +{ + unsigned GetPlayerId() const override { return 0; } + bool IsHost() const override { return true; } + std::string FormatGFTime(unsigned) const override { return ""; } + void SystemChat(const std::string& msg) override { bnw::cout << "[lua] " << msg << "\n"; } +}; +} // namespace using bfs::canonical; #ifdef WIN32 @@ -48,6 +60,7 @@ HeadlessGame::HeadlessGame(const GlobalGameSettings& ggs, const bfs::path& map, MapLoader loader(world_); if(!loader.Load(map)) throw std::runtime_error("Could not load " + map.string()); + MapLoader::SetupResources(world_); players_.clear(); for(unsigned playerId = 0; playerId < world_.GetNumPlayers(); ++playerId) @@ -138,6 +151,12 @@ void HeadlessGame::RecordReplay(const bfs::path& path, unsigned random_init) mapInfo.mapData.CompressFromFile(mapInfo.filepath, &mapInfo.mapChecksum); mapInfo.type = MapType::OldMap; + if(!luaPath_.empty() && bfs::exists(luaPath_)) + { + mapInfo.luaFilepath = luaPath_; + mapInfo.luaData.CompressFromFile(luaPath_, &mapInfo.luaChecksum); + } + for(unsigned playerId = 0; playerId < world_.GetNumPlayers(); ++playerId) replay_.AddPlayer(world_.GetPlayer(playerId)); replay_.ggs = game_.ggs_; @@ -145,6 +164,16 @@ void HeadlessGame::RecordReplay(const bfs::path& path, unsigned random_init) throw std::runtime_error("Replayfile could not be opened!"); } +void HeadlessGame::LoadLuaScript(const bfs::path& luaPath) +{ + HeadlessLocalState state; + MapLoader loader(world_); + if(!loader.LoadLuaScript(game_, state, luaPath)) + throw std::runtime_error("Failed to load Lua script: " + luaPath.string()); + luaPath_ = luaPath; // remember for embedding in the replay + bnw::cout << "Lua script loaded: " << luaPath << '\n'; +} + void HeadlessGame::SaveGame(const bfs::path& path) const { // Remove old savegame @@ -232,6 +261,7 @@ std::vector GeneratePlayerInfo(const std::vector& ais) } pi.nation = Nation::Romans; pi.team = Team::None; + pi.color = PLAYER_COLORS[ret.size() % PLAYER_COLORS.size()]; ret.push_back(pi); } return ret; diff --git a/extras/ai-battle/HeadlessGame.h b/extras/ai-battle/HeadlessGame.h index c52c906257..7d66d95ea0 100644 --- a/extras/ai-battle/HeadlessGame.h +++ b/extras/ai-battle/HeadlessGame.h @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -29,6 +29,7 @@ class HeadlessGame void RecordReplay(const boost::filesystem::path& path, unsigned random_init); void SaveGame(const boost::filesystem::path& path) const; + void LoadLuaScript(const boost::filesystem::path& luaPath); private: void PrintState(); @@ -41,6 +42,7 @@ class HeadlessGame Replay replay_; boost::filesystem::path replayPath_; + boost::filesystem::path luaPath_; unsigned lastReportGf_ = 0; std::chrono::steady_clock::time_point gameStartTime_; diff --git a/extras/ai-battle/main.cpp b/extras/ai-battle/main.cpp index cab8fd28de..6104267ecb 100644 --- a/extras/ai-battle/main.cpp +++ b/extras/ai-battle/main.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -7,6 +7,10 @@ #include "QuickStartGame.h" #include "RTTR_Version.h" #include "RttrConfig.h" +#include "addons/Addon.h" +#include "addons/AddonBool.h" +#include "addons/AddonList.h" +#include "addons/const_addons.h" #include "ai/random.h" #include "files.h" #include "random/Random.h" @@ -18,11 +22,74 @@ #include #include #include +#include +#include namespace bnw = boost::nowide; namespace bfs = boost::filesystem; namespace po = boost::program_options; +static void printBriefUsage(const char* prog) +{ + bnw::cerr << "Usage: " << prog + << " -m --ai aijh|dummy [--ai ...] [options]\n" + "Run with --help or -h for a full list of options and examples.\n"; +} + +static const char PATH_EXPANSION_HELP[] = + "\nPath expansion (all path options):\n" + " All paths support placeholders and a leading ~.\n" + " ~ is expanded by the application: %USERPROFILE%\\Saved Games on Windows,\n" + " $HOME on Linux/macOS.\n" + " Available placeholders:\n" + " user data dir\n" + " Windows : %USERPROFILE%\\Saved Games\\Return To The Roots\n" + " Linux : $HOME/.s25rttr\n" + " macOS : $HOME/Library/Application Support/Return To The Roots\n" + " RTTR data directory\n" + " directory containing S2 DATA/ and GFX/ folders\n" + " /RTTR sub-directory\n" + " binary directory\n" + " extra binary directory\n" + " library directory\n" + " driver directory\n" + " Each placeholder can be overridden with the RTTR__DIR environment variable.\n" + "\nExample:\n" + " ai-battle -m maps/Wal5.swd --ai 3 --ai 3 --wares alot" + " --settings \"/CONFIG.INI\"\n"; + +static void loadAddonsFromIni(GlobalGameSettings& ggs, const bfs::path& iniPath) +{ + if(!bfs::exists(iniPath)) + throw std::runtime_error("Settings file not found: " + iniPath.string()); + + boost::property_tree::ptree tree; + boost::property_tree::read_ini(iniPath.string(), tree); + + const auto addons = tree.get_child_optional("addons"); + if(!addons) + { + bnw::cout << "Note: no [addons] section in " << iniPath << ", using defaults.\n"; + return; + } + + unsigned loaded = 0; + for(const auto& entry : *addons) + { + try + { + const auto id = static_cast(std::stoul(entry.first)); + const unsigned v = entry.second.get_value(); + ggs.setSelection(id, v); + ++loaded; + } catch(const std::exception&) + { + // Unknown or invalid entry - skip silently + } + } + bnw::cout << "Loaded " << loaded << " addon settings from " << iniPath << '\n'; +} + int main(int argc, char** argv) { bnw::nowide_filesystem(); @@ -30,28 +97,49 @@ int main(int argc, char** argv) boost::optional replay_path; boost::optional savegame_path; + boost::optional lua_path; + boost::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"); // 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") - ("objective", po::value()->default_value("domination"),"domination(default)|conquer") - ("replay", po::value(&replay_path),"Filename to write replay to (optional)") - ("save", po::value(&savegame_path),"Filename to write savegame to (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)") - ("version", "Show version information and exit") + ("help,h", "Show this detailed help and exit.") + ("map,m", po::value()->required(), + "Path to the map file (.swd/.wld).") + ("ai", po::value>()->required(), + "AI player to add: aijh (the AIJH AI) | dummy (does nothing).\n" + "Case-insensitive. Repeat the flag once per player (e.g. --ai aijh --ai aijh).") + ("objective", po::value()->default_value("domination"), + "Win condition: domination (default) | conquer") + ("wares", po::value()->default_value("normal"), + "Starting wares for all players: vlow | low | normal (default) | alot") + ("settings", po::value(&settings_path), + "INI file with an [addons] section to configure addon settings (optional).\n" + "Keys are numeric AddonId values; values are the option index to select.\n" + "When omitted, all addons use their default values.") + ("replay", po::value(&replay_path), + "File to write a replay to (optional). If a Lua script was loaded via --lua\n" + "it is embedded into the replay automatically.") + ("save", po::value(&savegame_path), + "File to write a savegame to after the run (optional).") + ("lua", po::value(&lua_path), + "Lua script to execute during the game (optional). The script is also embedded\n" + "into the replay when --replay is used.") + ("random_init", po::value(&random_init), + "Seed for the main random number generator (optional, default: time-based).") + ("random_ai_init", po::value(&random_ai_init), + "Seed for the AI random number generator (optional, defaults to random_init).") + ("maxGF", po::value()->default_value(std::numeric_limits::max()), + "Maximum number of game frames to simulate before stopping (optional).") + ("version", "Show version information and exit.") ; // clang-format on if(argc == 1) { - bnw::cerr << desc << std::endl; + printBriefUsage(argv[0]); return 1; } @@ -62,7 +150,7 @@ int main(int argc, char** argv) if(options.count("help")) { - bnw::cout << desc << std::endl; + bnw::cout << desc << PATH_EXPANSION_HELP << std::endl; return 0; } if(options.count("version")) @@ -77,7 +165,7 @@ int main(int argc, char** argv) } catch(const std::exception& e) { bnw::cerr << "Error: " << e.what() << std::endl; - bnw::cerr << desc << std::endl; + printBriefUsage(argv[0]); return 1; } @@ -110,15 +198,56 @@ int main(int argc, char** argv) return 1; } - ggs.objective = GameObjective::TotalDomination; + const auto wares = options["wares"].as(); + if(wares == "vlow") + ggs.startWares = StartWares::VLow; + else if(wares == "low") + ggs.startWares = StartWares::Low; + else if(wares == "normal") + ggs.startWares = StartWares::Normal; + else if(wares == "alot") + ggs.startWares = StartWares::ALot; + else + { + bnw::cerr << "Unknown wares value: " << wares << std::endl; + return 1; + } + + if(settings_path) + { + loadAddonsFromIni(ggs, RTTRCONFIG.ExpandPath(*settings_path)); + + bnw::cout << "settings: " << RTTRCONFIG.ExpandPath(*settings_path) << std::endl; + bnw::cout << "addon selections (non-default only):" << std::endl; + for(unsigned i = 0; i < ggs.getNumAddons(); ++i) + { + unsigned status = 0; + const Addon* addon = ggs.getAddon(i, status); + if(addon && status != addon->getDefaultStatus()) + { + bnw::cout << " [0x" << std::hex << std::setw(8) << std::setfill('0') + << static_cast(addon->getId()) << std::dec << "] " << addon->getName() << " = "; + if(const auto* listAddon = dynamic_cast(addon)) + bnw::cout << listAddon->getOptionName(status); + else if(dynamic_cast(addon)) + bnw::cout << (status ? "True" : "False"); + else + bnw::cout << status; + bnw::cout << std::endl; + } + } + } + HeadlessGame game(ggs, mapPath, ais); + if(lua_path) + game.LoadLuaScript(RTTRCONFIG.ExpandPath(*lua_path)); if(replay_path) - game.RecordReplay(*replay_path, random_init); + game.RecordReplay(RTTRCONFIG.ExpandPath(*replay_path), random_init); game.Run(options["maxGF"].as()); game.Close(); if(savegame_path) - game.SaveGame(*savegame_path); + game.SaveGame(RTTRCONFIG.ExpandPath(*savegame_path)); } catch(const std::exception& e) { bnw::cerr << e.what() << std::endl; diff --git a/libs/s25main/addons/AddonList.cpp b/libs/s25main/addons/AddonList.cpp index cfe0945162..4b5ac199c4 100644 --- a/libs/s25main/addons/AddonList.cpp +++ b/libs/s25main/addons/AddonList.cpp @@ -26,6 +26,11 @@ unsigned AddonList::getNumOptions() const return options.size(); } +const std::string& AddonList::getOptionName(unsigned status) const +{ + return options.at(status); +} + AddonList::Gui::Gui(const AddonList& addon, Window& window, bool readonly) : AddonGui(addon, window, readonly) { DrawPoint cbPos(430, 0); diff --git a/libs/s25main/addons/AddonList.h b/libs/s25main/addons/AddonList.h index c0bd778a7c..c5f488d0fd 100644 --- a/libs/s25main/addons/AddonList.h +++ b/libs/s25main/addons/AddonList.h @@ -25,6 +25,7 @@ class AddonList : public Addon std::vector options, unsigned defaultStatus = 0); unsigned getNumOptions() const override; + const std::string& getOptionName(unsigned status) const; std::unique_ptr createGui(Window& window, bool readonly) const override; From 0b918230ee74ade8e1c4c1112cd06f9e56394863 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:10:03 +0200 Subject: [PATCH 2/9] Fix LoadLuaScript dangling ref, restore original help text style with additions --- extras/ai-battle/HeadlessGame.cpp | 13 +---- extras/ai-battle/HeadlessGame.h | 10 ++++ extras/ai-battle/main.cpp | 84 +++++++++---------------------- 3 files changed, 34 insertions(+), 73 deletions(-) diff --git a/extras/ai-battle/HeadlessGame.cpp b/extras/ai-battle/HeadlessGame.cpp index 1cbe16efda..3a5a684529 100644 --- a/extras/ai-battle/HeadlessGame.cpp +++ b/extras/ai-battle/HeadlessGame.cpp @@ -5,7 +5,6 @@ #include "HeadlessGame.h" #include "EventManager.h" #include "GlobalGameSettings.h" -#include "ILocalGameState.h" #include "PlayerInfo.h" #include "Savegame.h" #include "factories/AIFactory.h" @@ -30,15 +29,6 @@ std::string HumanReadableNumber(unsigned num); namespace bfs = boost::filesystem; namespace bnw = boost::nowide; -namespace { -struct HeadlessLocalState : ILocalGameState -{ - unsigned GetPlayerId() const override { return 0; } - bool IsHost() const override { return true; } - std::string FormatGFTime(unsigned) const override { return ""; } - void SystemChat(const std::string& msg) override { bnw::cout << "[lua] " << msg << "\n"; } -}; -} // namespace using bfs::canonical; #ifdef WIN32 @@ -166,9 +156,8 @@ void HeadlessGame::RecordReplay(const bfs::path& path, unsigned random_init) void HeadlessGame::LoadLuaScript(const bfs::path& luaPath) { - HeadlessLocalState state; MapLoader loader(world_); - if(!loader.LoadLuaScript(game_, state, luaPath)) + if(!loader.LoadLuaScript(game_, localState_, luaPath)) throw std::runtime_error("Failed to load Lua script: " + luaPath.string()); luaPath_ = luaPath; // remember for embedding in the replay bnw::cout << "Lua script loaded: " << luaPath << '\n'; diff --git a/extras/ai-battle/HeadlessGame.h b/extras/ai-battle/HeadlessGame.h index 7d66d95ea0..960efdb760 100644 --- a/extras/ai-battle/HeadlessGame.h +++ b/extras/ai-battle/HeadlessGame.h @@ -5,6 +5,7 @@ #pragma once #include "Game.h" +#include "ILocalGameState.h" #include "Replay.h" #include "ai/AIPlayer.h" #include "gameTypes/AIInfo.h" @@ -34,6 +35,15 @@ class HeadlessGame private: void PrintState(); + struct LocalState : ILocalGameState + { + unsigned GetPlayerId() const override { return 0; } + bool IsHost() const override { return true; } + std::string FormatGFTime(unsigned) const override { return ""; } + void SystemChat(const std::string&) override {} + }; + + LocalState localState_; boost::filesystem::path map_; Game game_; GameWorld& world_; diff --git a/extras/ai-battle/main.cpp b/extras/ai-battle/main.cpp index 6104267ecb..5961d46fa4 100644 --- a/extras/ai-battle/main.cpp +++ b/extras/ai-battle/main.cpp @@ -29,35 +29,6 @@ namespace bnw = boost::nowide; namespace bfs = boost::filesystem; namespace po = boost::program_options; -static void printBriefUsage(const char* prog) -{ - bnw::cerr << "Usage: " << prog - << " -m --ai aijh|dummy [--ai ...] [options]\n" - "Run with --help or -h for a full list of options and examples.\n"; -} - -static const char PATH_EXPANSION_HELP[] = - "\nPath expansion (all path options):\n" - " All paths support placeholders and a leading ~.\n" - " ~ is expanded by the application: %USERPROFILE%\\Saved Games on Windows,\n" - " $HOME on Linux/macOS.\n" - " Available placeholders:\n" - " user data dir\n" - " Windows : %USERPROFILE%\\Saved Games\\Return To The Roots\n" - " Linux : $HOME/.s25rttr\n" - " macOS : $HOME/Library/Application Support/Return To The Roots\n" - " RTTR data directory\n" - " directory containing S2 DATA/ and GFX/ folders\n" - " /RTTR sub-directory\n" - " binary directory\n" - " extra binary directory\n" - " library directory\n" - " driver directory\n" - " Each placeholder can be overridden with the RTTR__DIR environment variable.\n" - "\nExample:\n" - " ai-battle -m maps/Wal5.swd --ai 3 --ai 3 --wares alot" - " --settings \"/CONFIG.INI\"\n"; - static void loadAddonsFromIni(GlobalGameSettings& ggs, const bfs::path& iniPath) { if(!bfs::exists(iniPath)) @@ -105,41 +76,32 @@ int main(int argc, char** argv) po::options_description desc("Allowed options"); // clang-format off desc.add_options() - ("help,h", "Show this detailed help and exit.") - ("map,m", po::value()->required(), - "Path to the map file (.swd/.wld).") - ("ai", po::value>()->required(), - "AI player to add: aijh (the AIJH AI) | dummy (does nothing).\n" - "Case-insensitive. Repeat the flag once per player (e.g. --ai aijh --ai aijh).") - ("objective", po::value()->default_value("domination"), - "Win condition: domination (default) | conquer") - ("wares", po::value()->default_value("normal"), - "Starting wares for all players: vlow | low | normal (default) | alot") - ("settings", po::value(&settings_path), - "INI file with an [addons] section to configure addon settings (optional).\n" - "Keys are numeric AddonId values; values are the option index to select.\n" - "When omitted, all addons use their default values.") - ("replay", po::value(&replay_path), - "File to write a replay to (optional). If a Lua script was loaded via --lua\n" - "it is embedded into the replay automatically.") - ("save", po::value(&savegame_path), - "File to write a savegame to after the run (optional).") - ("lua", po::value(&lua_path), - "Lua script to execute during the game (optional). The script is also embedded\n" - "into the replay when --replay is used.") - ("random_init", po::value(&random_init), - "Seed for the main random number generator (optional, default: time-based).") - ("random_ai_init", po::value(&random_ai_init), - "Seed for the AI random number generator (optional, defaults to random_init).") - ("maxGF", po::value()->default_value(std::numeric_limits::max()), - "Maximum number of game frames to simulate before stopping (optional).") - ("version", "Show version information and exit.") + ("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)") + ("version", "Show version information and exit") ; // clang-format on + const auto printHelp = [&](std::ostream& os) { + os << desc + << "\nNote: path arguments support the placeholder " + "(game data folder: SAVES, REPLAYS, MAPS, PRESETS)." + << std::endl; + }; + if(argc == 1) { - printBriefUsage(argv[0]); + printHelp(bnw::cerr); return 1; } @@ -150,7 +112,7 @@ int main(int argc, char** argv) if(options.count("help")) { - bnw::cout << desc << PATH_EXPANSION_HELP << std::endl; + printHelp(bnw::cout); return 0; } if(options.count("version")) @@ -165,7 +127,7 @@ int main(int argc, char** argv) } catch(const std::exception& e) { bnw::cerr << "Error: " << e.what() << std::endl; - printBriefUsage(argv[0]); + printHelp(bnw::cerr); return 1; } From 9c00cab26c49e768fc3b97cde71faef6eab57d50 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:30:15 +0200 Subject: [PATCH 3/9] Move lua script loading to the HeadlessGame constructor --- extras/ai-battle/HeadlessGame.cpp | 20 ++++++++++---------- extras/ai-battle/HeadlessGame.h | 4 ++-- extras/ai-battle/main.cpp | 4 +--- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/extras/ai-battle/HeadlessGame.cpp b/extras/ai-battle/HeadlessGame.cpp index 3a5a684529..e957462e94 100644 --- a/extras/ai-battle/HeadlessGame.cpp +++ b/extras/ai-battle/HeadlessGame.cpp @@ -43,7 +43,8 @@ void printConsole(const char* fmt, ...) __attribute__((format(printf, 1, 2))); void printConsole(const char* fmt, ...); #endif -HeadlessGame::HeadlessGame(const GlobalGameSettings& ggs, const bfs::path& map, const std::vector& ais) +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_), em_(*static_cast(game_.em_.get())) { @@ -52,6 +53,14 @@ HeadlessGame::HeadlessGame(const GlobalGameSettings& ggs, const bfs::path& map, throw std::runtime_error("Could not load " + map.string()); MapLoader::SetupResources(world_); + if(!luaPath.empty()) + { + if(!loader.LoadLuaScript(game_, localState_, luaPath)) + throw std::runtime_error("Failed to load Lua script: " + luaPath.string()); + luaPath_ = luaPath; + bnw::cout << "Lua script loaded: " << luaPath << '\n'; + } + players_.clear(); for(unsigned playerId = 0; playerId < world_.GetNumPlayers(); ++playerId) players_.push_back(AIFactory::Create(world_.GetPlayer(playerId).aiInfo, playerId, world_)); @@ -154,15 +163,6 @@ void HeadlessGame::RecordReplay(const bfs::path& path, unsigned random_init) throw std::runtime_error("Replayfile could not be opened!"); } -void HeadlessGame::LoadLuaScript(const bfs::path& luaPath) -{ - MapLoader loader(world_); - if(!loader.LoadLuaScript(game_, localState_, luaPath)) - throw std::runtime_error("Failed to load Lua script: " + luaPath.string()); - luaPath_ = luaPath; // remember for embedding in the replay - bnw::cout << "Lua script loaded: " << luaPath << '\n'; -} - void HeadlessGame::SaveGame(const bfs::path& path) const { // Remove old savegame diff --git a/extras/ai-battle/HeadlessGame.h b/extras/ai-battle/HeadlessGame.h index 960efdb760..578b0a446c 100644 --- a/extras/ai-battle/HeadlessGame.h +++ b/extras/ai-battle/HeadlessGame.h @@ -22,7 +22,8 @@ class EventManager; class HeadlessGame { public: - HeadlessGame(const GlobalGameSettings& ggs, const boost::filesystem::path& map, const std::vector& ais); + HeadlessGame(const GlobalGameSettings& ggs, const boost::filesystem::path& map, const std::vector& ais, + const boost::filesystem::path& luaPath = {}); ~HeadlessGame(); void Run(unsigned maxGF = std::numeric_limits::max()); @@ -30,7 +31,6 @@ class HeadlessGame void RecordReplay(const boost::filesystem::path& path, unsigned random_init); void SaveGame(const boost::filesystem::path& path) const; - void LoadLuaScript(const boost::filesystem::path& luaPath); private: void PrintState(); diff --git a/extras/ai-battle/main.cpp b/extras/ai-battle/main.cpp index 5961d46fa4..bab746c6ac 100644 --- a/extras/ai-battle/main.cpp +++ b/extras/ai-battle/main.cpp @@ -200,9 +200,7 @@ int main(int argc, char** argv) } } - HeadlessGame game(ggs, mapPath, ais); - if(lua_path) - game.LoadLuaScript(RTTRCONFIG.ExpandPath(*lua_path)); + HeadlessGame game(ggs, mapPath, ais, lua_path ? RTTRCONFIG.ExpandPath(*lua_path) : bfs::path{}); if(replay_path) game.RecordReplay(RTTRCONFIG.ExpandPath(*replay_path), random_init); From 8e40140dfdd75fbd66875f3107336cc1f94014f2 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:41:32 +0200 Subject: [PATCH 4/9] Add suppressStdout flag to LuaInterfaceBase to redirect Lua log output to file-only in headless mode --- extras/ai-battle/HeadlessGame.cpp | 1 + libs/libGamedata/lua/LuaInterfaceBase.cpp | 2 +- libs/libGamedata/lua/LuaInterfaceBase.h | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/extras/ai-battle/HeadlessGame.cpp b/extras/ai-battle/HeadlessGame.cpp index e957462e94..86c544ca25 100644 --- a/extras/ai-battle/HeadlessGame.cpp +++ b/extras/ai-battle/HeadlessGame.cpp @@ -57,6 +57,7 @@ HeadlessGame::HeadlessGame(const GlobalGameSettings& ggs, const bfs::path& map, { if(!loader.LoadLuaScript(game_, localState_, luaPath)) throw std::runtime_error("Failed to load Lua script: " + luaPath.string()); + world_.GetLua().setSuppressStdout(true); luaPath_ = luaPath; bnw::cout << "Lua script loaded: " << luaPath << '\n'; } diff --git a/libs/libGamedata/lua/LuaInterfaceBase.cpp b/libs/libGamedata/lua/LuaInterfaceBase.cpp index 573fe1ad96..ae74da7659 100644 --- a/libs/libGamedata/lua/LuaInterfaceBase.cpp +++ b/libs/libGamedata/lua/LuaInterfaceBase.cpp @@ -168,5 +168,5 @@ bool LuaInterfaceBase::validateUTF8(const std::string& scriptTxt) void LuaInterfaceBase::log(const std::string& msg) { - logger_.write("%s\n") % msg; + logger_.write("%s\n", suppressStdout_ ? LogTarget::File : LogTarget::FileAndStdout) % msg; } diff --git a/libs/libGamedata/lua/LuaInterfaceBase.h b/libs/libGamedata/lua/LuaInterfaceBase.h index 3876f3fefc..5134299202 100644 --- a/libs/libGamedata/lua/LuaInterfaceBase.h +++ b/libs/libGamedata/lua/LuaInterfaceBase.h @@ -35,6 +35,7 @@ class LuaInterfaceBase /// Disable or re-enable throwing an exception on error. /// Note: If error throwing is disabled you have to use HasErrorOccurred to detect an error situation void setThrowOnError(bool doThrow); + void setSuppressStdout(bool suppress) { suppressStdout_ = suppress; } bool hasErrorOccurred() const { return errorOccured_; } void clearErrorOccured() { errorOccured_ = false; } @@ -60,6 +61,7 @@ class LuaInterfaceBase private: Log& logger_; + bool suppressStdout_ = false; /// Sticky flag to signal an occurred error during execution of lua code bool errorOccured_; std::map translations_; From 28b52fd41605cfedaf43d4c93c4a9d0fd6a5b7b7 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:43:42 +0200 Subject: [PATCH 5/9] Apply clang-tidy: use auto for get_value() result --- extras/ai-battle/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extras/ai-battle/main.cpp b/extras/ai-battle/main.cpp index bab746c6ac..9ab26c43e2 100644 --- a/extras/ai-battle/main.cpp +++ b/extras/ai-battle/main.cpp @@ -50,7 +50,7 @@ static void loadAddonsFromIni(GlobalGameSettings& ggs, const bfs::path& iniPath) try { const auto id = static_cast(std::stoul(entry.first)); - const unsigned v = entry.second.get_value(); + const auto v = entry.second.get_value(); ggs.setSelection(id, v); ++loaded; } catch(const std::exception&) From 02cc02719601699f1339fd5979f93b9c401ab109 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:46:07 +0200 Subject: [PATCH 6/9] Use s25util::fromStringClassic to parse addon IDs locale-independently --- extras/ai-battle/main.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extras/ai-battle/main.cpp b/extras/ai-battle/main.cpp index 242bf355ca..b75ee4766b 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 "s25util/StringConversion.h" #include "s25util/System.h" #include @@ -55,7 +56,7 @@ static void loadAddonsFromIni(GlobalGameSettings& ggs, const bfs::path& iniPath) { try { - const auto id = static_cast(std::stoul(entry.first)); + const auto id = static_cast(s25util::fromStringClassic(entry.first)); const auto v = entry.second.get_value(); ggs.setSelection(id, v); ++loaded; From 94ae86c19085dd35750634565c3b23339a8b44f4 Mon Sep 17 00:00:00 2001 From: ApexFlex Date: Sun, 28 Jun 2026 12:04:54 +0200 Subject: [PATCH 7/9] ai-battle: add --teams option for team assignment Adds a --teams option (e.g. "0,1;2,3" for a 2v2) that assigns players to teams in the headless harness, so the AI can be verified under real team rules instead of a free-for-all. Player indices map to Team1, Team2, ... in the order the groups are given; unlisted players stay teamless. The assignment is threaded through HeadlessGame into GeneratePlayerInfo, which sets PlayerInfo::team. Establishing the actual alliances follows in the next commit. --- extras/ai-battle/HeadlessGame.cpp | 10 +++++----- extras/ai-battle/HeadlessGame.h | 5 ++++- extras/ai-battle/main.cpp | 30 +++++++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/extras/ai-battle/HeadlessGame.cpp b/extras/ai-battle/HeadlessGame.cpp index 86c544ca25..1bcb20a15d 100644 --- a/extras/ai-battle/HeadlessGame.cpp +++ b/extras/ai-battle/HeadlessGame.cpp @@ -22,7 +22,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 +44,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_); @@ -235,7 +235,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) @@ -250,7 +250,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 b75ee4766b..b46c13bf0e 100644 --- a/extras/ai-battle/main.cpp +++ b/extras/ai-battle/main.cpp @@ -13,6 +13,7 @@ #include "addons/const_addons.h" #include "ai/random.h" #include "files.h" +#include "gameTypes/TeamTypes.h" #include "random/Random.h" #include "s25util/StringConversion.h" #include "s25util/System.h" @@ -24,6 +25,7 @@ #include #include #include +#include #if BOOST_VERSION >= 109000 # include using std::optional; @@ -86,6 +88,7 @@ int main(int argc, char** argv) ("help,h", "Show help") ("map,m", po::value()->required(),"Map to load") ("ai", po::value>()->required(),"AI player(s) to add (aijh | dummy)") + ("teams", po::value(),"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()->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)") @@ -207,7 +210,32 @@ int main(int argc, char** argv) } } - HeadlessGame game(ggs, mapPath, ais, lua_path ? RTTRCONFIG.ExpandPath(*lua_path) : bfs::path{}); + // Team assignment, e.g. "0,1;2,3". Player index -> Team (Team1, Team2, ...). + std::vector teams; + if(options.count("teams")) + { + std::stringstream groups(options["teams"].as()); + 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 unsigned 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, lua_path ? RTTRCONFIG.ExpandPath(*lua_path) : bfs::path{}, teams); if(replay_path) game.RecordReplay(RTTRCONFIG.ExpandPath(*replay_path), random_init); From 2841770ec06b1da85fb2b36911fe5df6e6e183a3 Mon Sep 17 00:00:00 2001 From: ApexFlex Date: Sun, 28 Jun 2026 12:05:43 +0200 Subject: [PATCH 8/9] ai-battle: establish team start-pacts in HeadlessGame to match GameClient GameClient::StartGame calls GamePlayer::MakeStartPacts() for every player on a fresh map, creating the ally + non-aggression pacts that bind a team together. HeadlessGame never did this, so in a recorded ai-battle with teams the teammates had no pacts and GamePlayer::IsAttackable() returned true for them: the AIs attacked their own team. On replay GameClient *does* set up the pacts, so the recorded inter-team attack commands hit allied players and were resolved differently, producing an object-count divergence (and replay desync) without any RANDOM divergence. MakeStartPacts() early-returns for Team::None, so this is a no-op until players are actually assigned to teams via --teams. --- extras/ai-battle/HeadlessGame.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/extras/ai-battle/HeadlessGame.cpp b/extras/ai-battle/HeadlessGame.cpp index 1bcb20a15d..f8810717e1 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" @@ -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)) From 42ba31a41feedd79018f8c129da07b10d38227e8 Mon Sep 17 00:00:00 2001 From: FlexApex Date: Sun, 16 Aug 2026 13:01:48 +0200 Subject: [PATCH 9/9] ai-battle: fail on invalid/unknown/missing addon settings instead of skipping Silently skipping unparseable or unknown [addons] entries hid exactly the mistakes the settings file is meant to catch (a mistyped key/value or an accidental section name went unnoticed while the user's intent was not applied). Make loadAddonsFromIni strict: - an entry that doesn't parse -> hard error naming the entry, - an unknown/unsupported addon id (getAddon(id) == null) -> hard error, - a missing [addons] section -> hard error, except for a fully empty file. --- extras/ai-battle/main.cpp | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/extras/ai-battle/main.cpp b/extras/ai-battle/main.cpp index b46c13bf0e..9f63d51fdc 100644 --- a/extras/ai-battle/main.cpp +++ b/extras/ai-battle/main.cpp @@ -46,26 +46,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'; }