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/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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;