#include "catch.hpp" #include #include #include #include "ConfigLoader.h" #include "FactionComponent.h" #include "GameConfig.h" #include "HealthComponent.h" #include "Rotation.h" #include "SchematicChoiceOption.h" #include "Simulation.h" #include "SimulationTestAccess.h" #include "StateChecksum.h" #include "StationBodyComponent.h" #include "Tick.h" #include "TestConfig.h" namespace { constexpr int kScriptTicks = 2000; // Ticks at which the scripted session destroys the enemy stations, and the ticks // on which the resulting schematic choice is taken. A station dying triggers the // choice generation (REQ-DEF-SCHEMATIC-DROP), which lands during that same tick, // so the choice is applied on the tick after. constexpr int kFirstStationKillTick = 800; constexpr int kFirstChoiceTick = kFirstStationKillTick + 1; constexpr int kSecondStationKillTick = 1400; constexpr int kSecondChoiceTick = kSecondStationKillTick + 1; // Zeroes the HP of every enemy station, so the next tick processes their death. void killEnemyStations(Simulation& sim) { sim.getAdmin().forEach( [](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health) { if (faction.isEnemy) { health.hp = 0.0f; } }); } // Runs a fixed scripted session and returns the full-state checksum after every // tick. The script places a small factory, deconstructs part of it mid-run, and // otherwise lets waves/combat run so the RNG stream and ECS state are exercised. // It also destroys the enemy stations twice and takes the offered schematic // choice, so that unlock state (awarded groups, per-schematic levels, and the // implicit recipe/item sets derived from them) is exercised as well and reaches // the checksum via UnlockState::appendChecksum. std::vector runScriptedSession(unsigned int seed) { Simulation sim(loadTestConfig(), seed); // Tick 0: a miner feeding a short belt line on the asteroid. SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East); SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East); SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-1, 0), Rotation::East); std::vector checksums; checksums.reserve(kScriptTicks); for (int t = 0; t < kScriptTicks; ++t) { if (t == 500) { // Deconstruct the second belt mid-run to exercise the removal paths. SimulationTestAccess::place(sim, BuildingType::Smelter, QPoint(-3, 3), Rotation::East); } if (t == kFirstStationKillTick || t == kSecondStationKillTick) { killEnemyStations(sim); } if (t == kFirstChoiceTick) { // Guarded rather than assumed: if the test config ever stops offering // a group here, this script would silently stop covering unlock state. REQUIRE(sim.hasSchematicChoicesPending()); SimulationTestAccess::applySchematicChoice(sim, 0); } // The second award is opportunistic — whether a group is still eligible // depends on what the first one granted and on the prerequisite gating. if (t == kSecondChoiceTick && sim.hasSchematicChoicesPending()) { SimulationTestAccess::applySchematicChoice(sim, 0); } sim.tick(); checksums.push_back(sim.computeStateChecksum()); } return checksums; } } // namespace // --------------------------------------------------------------------------- // Hasher // --------------------------------------------------------------------------- TEST_CASE("Hasher: identical inputs produce identical values", "[determinism]") { Hasher a; Hasher b; a.append(42); a.append(3.5f); a.append(std::string("ore")); b.append(42); b.append(3.5f); b.append(std::string("ore")); REQUIRE(a.getValue() == b.getValue()); } TEST_CASE("Hasher: differing inputs produce differing values", "[determinism]") { Hasher a; Hasher b; a.append(42); b.append(43); REQUIRE(a.getValue() != b.getValue()); } TEST_CASE("Hasher: string concatenation does not collide", "[determinism]") { Hasher a; Hasher b; a.append(std::string("ab")); a.append(std::string("c")); b.append(std::string("a")); b.append(std::string("bc")); REQUIRE(a.getValue() != b.getValue()); } TEST_CASE("Hasher: negative and positive zero hash equally", "[determinism]") { Hasher a; Hasher b; a.append(-0.0f); b.append(0.0f); REQUIRE(a.getValue() == b.getValue()); } // --------------------------------------------------------------------------- // RNG fingerprint // --------------------------------------------------------------------------- TEST_CASE("fingerprintRng: equal states match, advanced states differ", "[determinism]") { std::mt19937 a(12345); std::mt19937 b(12345); REQUIRE(fingerprintRng(a) == fingerprintRng(b)); a(); // advance one draw REQUIRE(fingerprintRng(a) != fingerprintRng(b)); b(); // advance b to the same point REQUIRE(fingerprintRng(a) == fingerprintRng(b)); } TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]") { const Simulation a(loadTestConfig(), 777); const Simulation b(loadTestConfig(), 777); REQUIRE(a.getRngFingerprint() == b.getRngFingerprint()); } // --------------------------------------------------------------------------- // Double-run determinism // --------------------------------------------------------------------------- TEST_CASE("Simulation: two runs from the same seed produce identical per-tick state", "[determinism]") { const std::vector first = runScriptedSession(424242); const std::vector second = runScriptedSession(424242); REQUIRE(first.size() == second.size()); REQUIRE(first.size() == static_cast(kScriptTicks)); for (std::size_t i = 0; i < first.size(); ++i) { INFO("divergence at tick " << i); REQUIRE(first[i] == second[i]); } } TEST_CASE("Simulation: different seeds diverge in state checksum", "[determinism]") { const std::vector a = runScriptedSession(111); const std::vector b = runScriptedSession(222); // The two sessions must differ at some point (the checksum is sensitive to // the RNG-driven divergence; a constant checksum would be a broken hash). REQUIRE(a != b); } // --------------------------------------------------------------------------- // Unlock state coverage // --------------------------------------------------------------------------- TEST_CASE("Simulation: unlock state contributes to the state checksum", "[determinism][unlock]") { // Two sessions with identical history up to the schematic choice; only one // takes the choice. This pins down that awarding an unlock group actually // reaches the checksum, which the scripted-session tests above rely on but // cannot show on their own: they would still pass if UnlockState were left // out of the fold entirely. Simulation taken(loadTestConfig(), 12345u); Simulation skipped(loadTestConfig(), 12345u); for (int t = 0; t < kFirstStationKillTick; ++t) { taken.tick(); skipped.tick(); } killEnemyStations(taken); killEnemyStations(skipped); taken.tick(); skipped.tick(); // In lockstep before the choice, so the divergence below has one cause. REQUIRE(taken.computeStateChecksum() == skipped.computeStateChecksum()); REQUIRE(taken.hasSchematicChoicesPending()); // An artifact choice bumps m_artifactCount, which is folded separately; the // divergence would then not be attributable to unlock state. REQUIRE_FALSE(taken.getPendingSchematicChoices()[0].isArtifact); SimulationTestAccess::applySchematicChoice(taken, 0); // The pending-choice list is not itself folded into the checksum, so the // only state that changed is the unlock bookkeeping. REQUIRE(taken.computeStateChecksum() != skipped.computeStateChecksum()); }