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
4 changes: 3 additions & 1 deletion extras/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2005 - 2021 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 @@ -11,7 +11,9 @@ set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)

add_subdirectory(audioDrivers)
add_subdirectory(videoDrivers)
add_subdirectory(headlessConsole)
add_subdirectory(ai-battle)
add_subdirectory(replay-player)
if(RTTR_BUNDLE AND APPLE)
add_subdirectory(macosLauncher)
endif()
Expand Down
4 changes: 2 additions & 2 deletions extras/ai-battle/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# 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

add_executable(ai-battle main.cpp HeadlessGame.cpp)
target_link_libraries(ai-battle PRIVATE s25Main Boost::program_options Boost::nowide)
target_link_libraries(ai-battle PRIVATE s25Main headlessConsole Boost::program_options Boost::nowide)

if(WIN32)
include(GatherDll)
Expand Down
132 changes: 17 additions & 115 deletions extras/ai-battle/HeadlessGame.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,77 +5,47 @@
#include "HeadlessGame.h"
#include "EventManager.h"
#include "GamePlayer.h"
#include "GameSetup.h"
#include "GlobalGameSettings.h"
#include "HeadlessConsole.h"
#include "PlayerInfo.h"
#include "Savegame.h"
#include "factories/AIFactory.h"
#include "network/PlayerGameCommands.h"
#include "world/GameWorld.h"
#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>
#include <sstream>
#ifdef WIN32
# include "Windows.h"
#endif

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
HANDLE setupStdOut();
#endif

#if defined(__MINGW32__) && !defined(__clang__)
void printConsole(const char* fmt, ...) __attribute__((format(gnu_printf, 1, 2)));
#elif defined __GNUC__
void printConsole(const char* fmt, ...) __attribute__((format(printf, 1, 2)));
#else
void printConsole(const char* fmt, ...);
#endif

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();
MapInfo mapInfo;
mapInfo.type = MapType::OldMap;
mapInfo.filepath = map;
mapInfo.luaFilepath = luaPath;
SetupGameWorld(game_, mapInfo, localState_, nullptr);

if(!luaPath.empty())
if(world_.HasLua())
{
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)
players_.push_back(AIFactory::Create(world_.GetPlayer(playerId).aiInfo, playerId, world_));

world_.InitAfterLoad();
}

HeadlessGame::~HeadlessGame()
Expand Down Expand Up @@ -190,56 +160,16 @@ void HeadlessGame::SaveGame(const bfs::path& path) const
bnw::cout << "Savegame written to " << canonical(path) << '\n';
}

std::string ToString(const std::chrono::milliseconds& time)
{
char buffer[90];
const auto hours = std::chrono::duration_cast<std::chrono::hours>(time);
const auto minutes = std::chrono::duration_cast<std::chrono::minutes>(time % std::chrono::hours(1));
const auto seconds = std::chrono::duration_cast<std::chrono::seconds>(time % std::chrono::minutes(1));
snprintf(buffer, std::size(buffer), "%03ld:%02ld:%02ld", static_cast<long int>(hours.count()),
static_cast<long int>(minutes.count()), static_cast<long int>(seconds.count()));
return std::string(buffer);
}

std::string HumanReadableNumber(unsigned num)
{
std::stringstream ss;
ss.imbue(std::locale(""));
ss << std::fixed << num;
return ss.str();
}

void HeadlessGame::PrintState()
{
static bool first_run = true;
if(first_run)
first_run = false;
else
printConsole("\x1b[%dA", 8 + world_.GetNumPlayers()); // Move cursor back up

printConsole("┌───────────────┬───────────────────────┬───────────────────────┬────────────────┐\n");
printConsole(
"│ GF %10s │ Game Clock %s │ Wall Clock %s │ %7s GF/sec │\n", HumanReadableNumber(em_.GetCurrentGF()).c_str(),
ToString(SPEED_GF_LENGTHS[GameSpeed::Normal] * em_.GetCurrentGF()).c_str(), // elapsed time
ToString(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - gameStartTime_))
.c_str(), // wall clock
HumanReadableNumber(em_.GetCurrentGF() - lastReportGf_).c_str()); // GF per second
printConsole("└───────────────┴───────────────────────┴───────────────────────┴────────────────┘\n");
printConsole("\n");
printConsole("┌────────────────────────┬─────────────────┬─────────────┬───────────┬───────────┐\n");
printConsole("│ Player │ Country │ Buildings │ Military │ Gold │\n");
printConsole("├────────────────────────┼─────────────────┼─────────────┼───────────┼───────────┤\n");
for(const auto& player : world_.getPlayers())
{
printConsole("│ %s%-22s%s │ %15s │ %11s │ %9s │ %9s │\n", player.IsDefeated() ? "\x1b[9m" : "",
player.name.c_str(), player.IsDefeated() ? "\x1b[29m" : "",
HumanReadableNumber(player.GetStatisticCurrentValue(StatisticType::Country)).c_str(),
HumanReadableNumber(player.GetStatisticCurrentValue(StatisticType::Buildings)).c_str(),
HumanReadableNumber(player.GetStatisticCurrentValue(StatisticType::Military)).c_str(),
HumanReadableNumber(player.GetStatisticCurrentValue(StatisticType::Gold)).c_str());
}
printConsole("└────────────────────────┴─────────────────┴─────────────┴───────────┴───────────┘\n");

HeadlessStats stats;
stats.currentGF = em_.GetCurrentGF();
stats.gameTime =
std::chrono::duration_cast<std::chrono::milliseconds>(SPEED_GF_LENGTHS[GameSpeed::Normal] * em_.GetCurrentGF());
stats.wallTime =
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - gameStartTime_);
stats.gfPerSecond = em_.GetCurrentGF() - lastReportGf_;
statsPrinter_.print(stats, world_);
lastReportGf_ = em_.GetCurrentGF();
}

Expand All @@ -249,7 +179,7 @@ std::vector<PlayerInfo> GeneratePlayerInfo(const std::vector<AI::Info>& ais, con
for(const AI::Info& ai : ais)
{
PlayerInfo pi;
pi.ps = PlayerState::Occupied;
pi.ps = PlayerState::AI;
pi.aiInfo = ai;
switch(ai.type)
{
Expand All @@ -264,31 +194,3 @@ std::vector<PlayerInfo> GeneratePlayerInfo(const std::vector<AI::Info>& ais, con
}
return ret;
}

#ifdef WIN32
HANDLE setupStdOut()
{
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
SetConsoleMode(h, ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
SetConsoleOutputCP(65001);
return h;
}
#endif

void printConsole(const char* fmt, ...)
{
char buffer[512];
va_list args;
va_start(args, fmt);
const int len = vsnprintf(buffer, sizeof(buffer), fmt, args);
va_end(args);
if(len > 0 && (size_t)len < sizeof(buffer))
{
#ifdef WIN32
static auto h = setupStdOut();
WriteConsoleA(h, buffer, len, 0, 0);
#else
bnw::cout << buffer;
#endif
}
}
12 changes: 3 additions & 9 deletions extras/ai-battle/HeadlessGame.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#pragma once

#include "Game.h"
#include "HeadlessConsole.h"
#include "ILocalGameState.h"
#include "Replay.h"
#include "ai/AIPlayer.h"
Expand Down Expand Up @@ -38,15 +39,7 @@ 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_;
NullLocalGameState localState_;
boost::filesystem::path map_;
Game game_;
GameWorld& world_;
Expand All @@ -59,4 +52,5 @@ class HeadlessGame

unsigned lastReportGf_ = 0;
std::chrono::steady_clock::time_point gameStartTime_;
StatsTablePrinter statsPrinter_;
};
8 changes: 8 additions & 0 deletions extras/headlessConsole/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Copyright (C) 2005 - 2026 Settlers Freaks <sf-team at siedler25.org>
#
# SPDX-License-Identifier: GPL-2.0-or-later

# Console output shared by the headless tools (ai-battle, replay-player)
add_library(headlessConsole STATIC HeadlessConsole.cpp HeadlessConsole.h)
target_link_libraries(headlessConsole PUBLIC s25Main PRIVATE Boost::nowide)
target_include_directories(headlessConsole PUBLIC .)
98 changes: 98 additions & 0 deletions extras/headlessConsole/HeadlessConsole.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org)
//
// SPDX-License-Identifier: GPL-2.0-or-later

#include "HeadlessConsole.h"
#include "GamePlayer.h"
#include "world/GameWorldBase.h"
#include <boost/nowide/iostream.hpp>
#include <cstdarg>
#include <cstdio>
#include <locale>
#include <sstream>

#ifdef _WIN32
# include <windows.h>
#endif

namespace bnw = boost::nowide;

#ifdef _WIN32
static HANDLE setupStdOut()
{
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
SetConsoleMode(h, ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
SetConsoleOutputCP(65001);
return h;
}
#endif

void printConsole(const char* fmt, ...)
{
char buffer[512];
va_list args;
va_start(args, fmt);
const int len = vsnprintf(buffer, sizeof(buffer), fmt, args);
va_end(args);
if(len > 0 && static_cast<size_t>(len) < sizeof(buffer))
{
#ifdef _WIN32
static HANDLE h = setupStdOut();
WriteConsoleA(h, buffer, len, nullptr, nullptr);
#else
bnw::cout << buffer;
#endif
}
}

std::string formatClock(std::chrono::milliseconds time)
{
char buf[32];
const auto h = std::chrono::duration_cast<std::chrono::hours>(time);
const auto m = std::chrono::duration_cast<std::chrono::minutes>(time % std::chrono::hours(1));
const auto s = std::chrono::duration_cast<std::chrono::seconds>(time % std::chrono::minutes(1));
snprintf(buf, sizeof(buf), "%03ld:%02ld:%02ld", static_cast<long>(h.count()), static_cast<long>(m.count()),
static_cast<long>(s.count()));
return buf;
}

std::string formatNumber(unsigned num)
{
std::ostringstream ss;
ss.imbue(std::locale(""));
ss << std::fixed << num;
return ss.str();
}

void StatsTablePrinter::print(const HeadlessStats& stats, const GameWorldBase& world)
{
const unsigned numPlayers = world.GetNumPlayers();
if(!firstPrint_)
printConsole("\x1b[%uA", 8 + numPlayers); // Move back up over the previously printed table
firstPrint_ = false;

const std::string gf = stats.totalGFs ? formatNumber(stats.currentGF) + " / " + formatNumber(*stats.totalGFs) :
formatNumber(stats.currentGF);

printConsole("┌──────────────────────────┬───────────────────────┬───────────────────────┬────────────────┐\n");
printConsole("│ GF %21s │ Game Clock %s │ Wall Clock %s │ %7s GF/sec │\n", gf.c_str(),
formatClock(stats.gameTime).c_str(), formatClock(stats.wallTime).c_str(),
formatNumber(stats.gfPerSecond).c_str());
printConsole("└──────────────────────────┴───────────────────────┴───────────────────────┴────────────────┘\n");
printConsole("\n");

printConsole("┌────────────────────────────┬────────────────────┬───────────────┬────────────┬────────────┐\n");
printConsole("│ %-26s │ %-18s │ %-13s │ %-10s │ %-10s │\n", "Player", "Country", "Buildings", "Military", "Gold");
printConsole("├────────────────────────────┼────────────────────┼───────────────┼────────────┼────────────┤\n");
for(unsigned i = 0; i < numPlayers; ++i)
{
const GamePlayer& p = world.GetPlayer(i);
printConsole("│ %s%-26s%s │ %18s │ %13s │ %10s │ %10s │\n", p.IsDefeated() ? "\x1b[9m" : "", p.name.c_str(),
p.IsDefeated() ? "\x1b[29m" : "",
formatNumber(p.GetStatisticCurrentValue(StatisticType::Country)).c_str(),
formatNumber(p.GetStatisticCurrentValue(StatisticType::Buildings)).c_str(),
formatNumber(p.GetStatisticCurrentValue(StatisticType::Military)).c_str(),
formatNumber(p.GetStatisticCurrentValue(StatisticType::Gold)).c_str());
}
printConsole("└────────────────────────────┴────────────────────┴───────────────┴────────────┴────────────┘\n");
}
Loading
Loading