diff --git a/source/ps/tests/test_Game.h b/source/ps/tests/test_Game.h new file mode 100644 index 0000000000..de6756fbc5 --- /dev/null +++ b/source/ps/tests/test_Game.h @@ -0,0 +1,231 @@ +/* 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; + } +};