Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions extras/ai-battle/HeadlessGame.cpp
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// 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 "GamePlayer.h"
#include "GlobalGameSettings.h"
#include "PlayerInfo.h"
#include "Savegame.h"
Expand All @@ -13,6 +14,7 @@
#include "world/MapLoader.h"
#include "gameTypes/MapInfo.h"
#include "gameData/GameConsts.h"
#include "s25util/colors.h"
#include <boost/nowide/iostream.hpp>
#include <chrono>
#include <cstdio>
Expand All @@ -21,12 +23,13 @@
# include "Windows.h"
#endif

std::vector<PlayerInfo> GeneratePlayerInfo(const std::vector<AI::Info>& ais);
std::vector<PlayerInfo> GeneratePlayerInfo(const std::vector<AI::Info>& ais, const std::vector<Team>& teams);
std::string ToString(const std::chrono::milliseconds& time);
std::string HumanReadableNumber(unsigned num);

namespace bfs = boost::filesystem;
namespace bnw = boost::nowide;

using bfs::canonical;

#ifdef WIN32
Expand All @@ -41,13 +44,32 @@ 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<AI::Info>& ais)
: map_(map), game_(ggs, std::make_unique<EventManager>(0), GeneratePlayerInfo(ais)), world_(game_.world_),
HeadlessGame::HeadlessGame(const GlobalGameSettings& ggs, const bfs::path& map, const std::vector<AI::Info>& ais,
const bfs::path& luaPath, const std::vector<Team>& teams)
: map_(map), game_(ggs, std::make_unique<EventManager>(0), GeneratePlayerInfo(ais, teams)), world_(game_.world_),
em_(*static_cast<EventManager*>(game_.em_.get()))
{
MapLoader loader(world_);
if(!loader.Load(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))
throw std::runtime_error("Failed to load Lua script: " + luaPath.string());
world_.GetLua().setSuppressStdout(true);
luaPath_ = luaPath;
bnw::cout << "Lua script loaded: " << luaPath << '\n';
}

players_.clear();
for(unsigned playerId = 0; playerId < world_.GetNumPlayers(); ++playerId)
Expand Down Expand Up @@ -138,6 +160,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_;
Expand Down Expand Up @@ -216,7 +244,7 @@ void HeadlessGame::PrintState()
lastReportGf_ = em_.GetCurrentGF();
}

std::vector<PlayerInfo> GeneratePlayerInfo(const std::vector<AI::Info>& ais)
std::vector<PlayerInfo> GeneratePlayerInfo(const std::vector<AI::Info>& ais, const std::vector<Team>& teams)
{
std::vector<PlayerInfo> ret;
for(const AI::Info& ai : ais)
Expand All @@ -231,7 +259,8 @@ std::vector<PlayerInfo> GeneratePlayerInfo(const std::vector<AI::Info>& 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);
}
return ret;
Expand Down
19 changes: 17 additions & 2 deletions extras/ai-battle/HeadlessGame.h
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// 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

#pragma once

#include "Game.h"
#include "ILocalGameState.h"
#include "Replay.h"
#include "ai/AIPlayer.h"
#include "gameTypes/AIInfo.h"
#include "gameTypes/TeamTypes.h"
#include <boost/filesystem.hpp>
#include <chrono>
#include <limits>
Expand All @@ -21,7 +23,10 @@ class EventManager;
class HeadlessGame
{
public:
HeadlessGame(const GlobalGameSettings& ggs, const boost::filesystem::path& map, const std::vector<AI::Info>& ais);
/// 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<AI::Info>& ais,
const boost::filesystem::path& luaPath = {}, const std::vector<Team>& teams = {});
~HeadlessGame();

void Run(unsigned maxGF = std::numeric_limits<unsigned>::max());
Expand All @@ -33,6 +38,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_;
Expand All @@ -41,6 +55,7 @@ class HeadlessGame

Replay replay_;
boost::filesystem::path replayPath_;
boost::filesystem::path luaPath_;

unsigned lastReportGf_ = 0;
std::chrono::steady_clock::time_point gameStartTime_;
Expand Down
138 changes: 128 additions & 10 deletions extras/ai-battle/main.cpp
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -7,16 +7,25 @@
#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 "gameTypes/TeamTypes.h"
#include "random/Random.h"
#include "s25util/StringConversion.h"
#include "s25util/System.h"

#include <boost/filesystem.hpp>
#include <boost/nowide/args.hpp>
#include <boost/nowide/filesystem.hpp>
#include <boost/nowide/iostream.hpp>
#include <boost/program_options.hpp>
#include <boost/property_tree/ini_parser.hpp>
#include <iomanip>
#include <sstream>
#if BOOST_VERSION >= 109000
# include <optional>
using std::optional;
Expand All @@ -29,13 +38,47 @@ namespace bnw = boost::nowide;
namespace bfs = boost::filesystem;
namespace po = boost::program_options;

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<AddonId>(s25util::fromStringClassic<unsigned>(entry.first));
const auto v = entry.second.get_value<unsigned>();
ggs.setSelection(id, v);
++loaded;
} catch(const std::exception&)
{
// Unknown or invalid entry - skip silently

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say this should be a hard error: The user intended to do something which isn't done.
A missing addon section would also be an error, maybe unless the file is fully empty. Otherwise an accidental spelling mistake like "adon" goes unnoticed.

}
}
bnw::cout << "Loaded " << loaded << " addon settings from " << iniPath << '\n';
}

int main(int argc, char** argv)
{
bnw::nowide_filesystem();
bnw::args _(argc, argv);

optional<std::string> replay_path;
optional<std::string> savegame_path;
optional<std::string> lua_path;
optional<std::string> settings_path;
unsigned random_init = static_cast<unsigned>(std::chrono::high_resolution_clock::now().time_since_epoch().count());
unsigned random_ai_init = random_init;

Expand All @@ -44,20 +87,31 @@ int main(int argc, char** argv)
desc.add_options()
("help,h", "Show help")
("map,m", po::value<std::string>()->required(),"Map to load")
("ai", po::value<std::vector<std::string>>()->required(),"AI player(s) to add")
("objective", po::value<std::string>()->default_value("domination"),"domination(default)|conquer")
("ai", po::value<std::vector<std::string>>()->required(),"AI player(s) to add (aijh | dummy)")
("teams", po::value<std::string>(),"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<std::string>()->default_value("domination"),"domination(default) | conquer")
("wares", po::value<std::string>()->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<unsigned>()->default_value(std::numeric_limits<unsigned>::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 <RTTR_USERDATA> placeholder "
"(game data folder: SAVES, REPLAYS, MAPS, PRESETS)."
<< std::endl;
};

if(argc == 1)
{
bnw::cerr << desc << std::endl;
printHelp(bnw::cerr);
return 1;
}

Expand All @@ -68,7 +122,7 @@ int main(int argc, char** argv)

if(options.count("help"))
{
bnw::cout << desc << std::endl;
printHelp(bnw::cout);
return 0;
}
if(options.count("version"))
Expand All @@ -83,7 +137,7 @@ int main(int argc, char** argv)
} catch(const std::exception& e)
{
bnw::cerr << "Error: " << e.what() << std::endl;
bnw::cerr << desc << std::endl;
printHelp(bnw::cerr);
return 1;
}

Expand Down Expand Up @@ -116,15 +170,79 @@ int main(int argc, char** argv)
return 1;
}

ggs.objective = GameObjective::TotalDomination;
HeadlessGame game(ggs, mapPath, ais);
const auto wares = options["wares"].as<std::string>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any specific reason to use this construct here but have lua/settings_path "auto-filled"?

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<unsigned>(addon->getId()) << std::dec << "] " << addon->getName() << " = ";
if(const auto* listAddon = dynamic_cast<const AddonList*>(addon))
bnw::cout << listAddon->getOptionName(status);
else if(dynamic_cast<const AddonBool*>(addon))
bnw::cout << (status ? "True" : "False");
else
bnw::cout << status;
bnw::cout << std::endl;
}
}
}

// Team assignment, e.g. "0,1;2,3". Player index -> Team (Team1, Team2, ...).
std::vector<Team> teams;
if(options.count("teams"))
{
std::stringstream groups(options["teams"].as<std::string>());
std::string group;
unsigned teamIdx = 0;
while(std::getline(groups, group, ';'))
{
const Team team = static_cast<Team>(static_cast<uint8_t>(Team::Team1) + teamIdx);
std::stringstream members(group);
std::string idx;
while(std::getline(members, idx, ','))
{
if(idx.empty())
continue;
const unsigned p = static_cast<unsigned>(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(*replay_path, random_init);
game.RecordReplay(RTTRCONFIG.ExpandPath(*replay_path), random_init);

game.Run(options["maxGF"].as<unsigned>());
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;
Expand Down
2 changes: 1 addition & 1 deletion libs/libGamedata/lua/LuaInterfaceBase.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions libs/libGamedata/lua/LuaInterfaceBase.h
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand All @@ -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<std::string, std::string> translations_;
Expand Down
Loading