/* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D.
*
* 0 A.D. is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* 0 A.D. is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with 0 A.D. If not, see .
*/
// File purpose: Verify that local simulation speed does not affect serialized game state.
#include "lib/self_test.h"
#include "gui/GUIManager.h"
#include "graphics/MapReader.h"
#include "lib/file/vfs/vfs.h"
#include "lib/path.h"
#include "maths/Fixed.h"
#include "maths/FixedVector3D.h"
#include "maths/NUSpline.h"
#include "ps/ConfigDB.h"
#include "ps/Filesystem.h"
#include "ps/Game.h"
#include "ps/Loader.h"
#include "ps/Profile.h"
#include "ps/ProfileViewer.h"
#include "ps/XML/Xeromyces.h"
#include "scriptinterface/Interface.h"
#include "scriptinterface/Object.h"
#include "scriptinterface/Request.h"
#include "simulation2/Simulation2.h"
#include "simulation2/components/ICmpCinemaManager.h"
#include "simulation2/components/ICmpUnitMotion.h"
#include "simulation2/helpers/CinemaPath.h"
#include "simulation2/system/CmpPtr.h"
#include "simulation2/system/Entity.h"
#include "simulation2/system/TurnManager.h"
#include
#include
#include
#include
#include
#include
namespace
{
template
class ScopedGlobal
{
public:
ScopedGlobal(T& target, T replacement) :
m_Target(target), m_Previous(std::exchange(target, std::move(replacement)))
{
}
~ScopedGlobal()
{
m_Target = std::move(m_Previous);
}
private:
T& m_Target;
T m_Previous;
};
CCinemaPath MakeCinemaPath()
{
const CFixedVector3D firstNode(fixed::FromInt(1), fixed::FromInt(0), fixed::FromInt(1));
const CFixedVector3D secondNode(fixed::FromInt(9), fixed::FromInt(0), fixed::FromInt(9));
const CFixedVector3D targetOffset(fixed::FromInt(3), fixed::FromInt(3), fixed::FromInt(3));
CCinemaData pathData;
pathData.m_Name = L"simrate-determinism";
pathData.m_Timescale = fixed::FromInt(1);
pathData.m_Orientation = L"target";
pathData.m_Mode = L"ease_inout";
pathData.m_Style = L"default";
TNSpline positionSpline;
positionSpline.AddNode(firstNode, CFixedVector3D(), fixed::FromInt(0));
positionSpline.AddNode(secondNode, CFixedVector3D(), fixed::FromInt(10));
TNSpline targetSpline;
targetSpline.AddNode(firstNode + targetOffset, CFixedVector3D(), fixed::FromInt(0));
targetSpline.AddNode(secondNode + targetOffset, CFixedVector3D(), fixed::FromInt(10));
return CCinemaPath(pathData, positionSpline, targetSpline);
}
}
class TestGame : public CxxTest::TestSuite
{
struct SimulationState
{
turn_id_t turn;
std::string hash;
std::string serializedState;
bool cinemaPlaying;
fixed cinemaElapsedTime;
bool operator==(const SimulationState&) const = default;
};
std::unique_ptr m_ConfigDB;
std::optional m_ProfileViewer;
std::optional m_ProfileManager;
std::optional m_XeromycesEngine;
std::optional m_GuiScriptInterface;
public:
void setUp()
{
g_VFS = CreateVfs();
TS_ASSERT_OK(g_VFS->Mount(L"", DataDir() / "mods" / "mod" / "", VFS_MOUNT_MUST_EXIST));
TS_ASSERT_OK(g_VFS->Mount(L"", DataDir() / "mods" / "public" / "", VFS_MOUNT_MUST_EXIST));
TS_ASSERT_OK(g_VFS->Mount(L"cache", DataDir() / "_testcache" / "", 0, VFS_MAX_PRIORITY));
m_ConfigDB = std::make_unique();
m_ProfileViewer.emplace();
m_ProfileManager.emplace();
m_XeromycesEngine.emplace();
m_GuiScriptInterface.emplace("Engine", "TestGame", *g_ScriptContext);
g_GUI = new CGUIManager(*g_ScriptContext, *m_GuiScriptInterface);
}
void tearDown()
{
delete g_GUI;
m_GuiScriptInterface.reset();
m_XeromycesEngine.reset();
m_ProfileManager.reset();
m_ProfileViewer.reset();
m_ConfigDB.reset();
g_VFS.reset();
DeleteDirectory(DataDir() / "_testcache");
}
void test_simulation_state_is_independent_of_sim_rate()
{
const auto stateAtHalfSpeed = RunSimulation(0.5f);
const auto stateAtNormalSpeed = RunSimulation(1.0f);
const auto stateAtDoubleSpeed = RunSimulation(2.0f);
TS_ASSERT_EQUALS(stateAtHalfSpeed, stateAtNormalSpeed);
TS_ASSERT_EQUALS(stateAtNormalSpeed, stateAtDoubleSpeed);
}
private:
std::array RunSimulation(float simRate)
{
CGame game(false);
ScopedGlobal scopedGame(g_Game, &game);
{
Script::Request rq(game.GetSimulation2()->GetScriptInterface());
CMapSummaryReader mapSummaryReader;
TS_ASSERT_EQUALS(mapSummaryReader.LoadMap(
L"maps/scenarios/unit_chasing_test.xml"), PSRETURN_OK);
JS::RootedValue mapData(rq.cx);
mapSummaryReader.GetMapSettings(game.GetSimulation2()->GetScriptInterface(), &mapData);
JS::RootedValue settings(rq.cx);
TS_ASSERT(Script::GetProperty(rq, mapData, "settings", &settings));
TS_ASSERT(Script::SetProperty(rq, settings, "AISeed", 2345));
TS_ASSERT(Script::SetProperty(rq, settings, "CheatsEnabled", false));
TS_ASSERT(Script::SetProperty(rq, settings, "Seed", 1234));
JS::RootedValue attributes(rq.cx);
Script::CreateObject(rq, &attributes,
"mapType", "scenario",
"map", "maps/scenarios/unit_chasing_test",
"mapPath", "maps/scenarios/",
"settings", settings);
game.StartGame(&attributes, "");
TS_ASSERT_OK(PS::Loader::NonprogressiveLoad());
TS_ASSERT_EQUALS(game.ReallyStartGame(), PSRETURN_OK);
}
CmpPtr cinema(*game.GetSimulation2(), SYSTEM_ENTITY);
TS_ASSERT(cinema);
cinema->AddPath(MakeCinemaPath());
cinema->PushPathToQueue(L"simrate-determinism");
cinema->StartPlayingQueue();
const size_t initialUnitMotionEntityCount =
game.GetSimulation2()->GetEntitiesWithInterface(IID_UnitMotion).size();
game.SetSimRate(simRate);
constexpr std::array checkpoints{20, 40, 60};
std::array states{};
for (size_t i = 0; i < checkpoints.size(); ++i)
{
const turn_id_t checkpoint = checkpoints[i];
while (game.GetTurnManager()->GetCurrentTurn() < checkpoint)
{
game.Update(DEFAULT_TURN_LENGTH / 1000.0, false);
TS_ASSERT_LESS_THAN_EQUALS(game.GetTurnManager()->GetCurrentTurn(), checkpoint);
}
TS_ASSERT_EQUALS(game.GetTurnManager()->GetCurrentTurn(), checkpoint);
if (i == 0)
TS_ASSERT_LESS_THAN(initialUnitMotionEntityCount,
game.GetSimulation2()->GetEntitiesWithInterface(IID_UnitMotion).size());
SimulationState& state = states[i];
state.turn = game.GetTurnManager()->GetCurrentTurn();
TS_ASSERT(game.GetSimulation2()->ComputeStateHash(state.hash, false));
std::stringstream serializedState;
TS_ASSERT(game.GetSimulation2()->SerializeState(serializedState));
state.serializedState = serializedState.str();
state.cinemaPlaying = cinema->IsPlayingQueue();
state.cinemaElapsedTime = cinema->GetActivePathElapsedTime();
}
TS_ASSERT(std::get<0>(states).cinemaPlaying);
TS_ASSERT(std::get<1>(states).cinemaPlaying);
TS_ASSERT(!std::get<2>(states).cinemaPlaying);
return states;
}
};