Files
dota_factory/src/test/DeterminismTest.cpp
Malte Langkabel cb5572ffdd cover unlock state in the determinism tests
The scripted session only placed buildings, so every unlock container stayed at
its initial value for the whole run and the checksum never saw them change. It
now destroys the enemy stations twice and takes the offered schematic choice,
so awarded groups, per-schematic levels and the derived recipe/item sets are
exercised too.

Adds a test that pins down that unlock state actually reaches the checksum: two
sessions in lockstep, one takes the choice, checksums must diverge. The
scripted-session tests cannot show this themselves — they compare runs against
each other, so they pass whether or not UnlockState is in the fold. Verified by
temporarily removing the fold: the new test fails, the old two do not.

The first choice is asserted rather than assumed, so a config change that stops
offering a group fails loudly instead of silently dropping the coverage.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 15:08:04 +02:00

244 lines
8.1 KiB
C++

#include "catch.hpp"
#include <cstdint>
#include <random>
#include <vector>
#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<StationBodyComponent, FactionComponent, HealthComponent>(
[](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<std::uint64_t> 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<std::uint64_t> 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<std::uint64_t> first = runScriptedSession(424242);
const std::vector<std::uint64_t> second = runScriptedSession(424242);
REQUIRE(first.size() == second.size());
REQUIRE(first.size() == static_cast<std::size_t>(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<std::uint64_t> a = runScriptedSession(111);
const std::vector<std::uint64_t> 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());
}