replay: compile-enforce the command chokepoint

Privatize the Simulation player-action mutators (tryPlaceBuilding, demolish,
applySchematicChoice) and the mutable subsystem accessors (now buildingsMutable/
beltsMutable; only the const buildings()/belts() stay public). Production's only
handle to a subsystem is through Simulation, and every production buildings()/
belts() call is a const query, so this fully locks gameplay out of any
command-bypassing mutation path -- such code now fails to compile instead of
silently desyncing replays.

Tests reach the private mutators through SimulationTestAccess, a friend struct
under src/test (off the lib/ui/app include path), so they keep calling the real
mutators and keep their return values -- no id-by-position recovery needed. The
BuildingSystem subsystem mutators stay public (BuildingTest unit-tests a bare
subsystem; unreachable from production anyway).

Upgrades the Phase 1 "convention only" decision to structural enforcement.
All targets build; 354 cases / 3418 assertions pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
This commit is contained in:
2026-06-30 22:56:51 +02:00
parent bd9f550b67
commit c2196cb34e
11 changed files with 159 additions and 81 deletions

View File

@@ -113,20 +113,36 @@ types), but the sim-mutating command path is a **dedicated, ordered queue**, not
chokepoint. Any path that mutates the sim directly would not be recorded and would silently chokepoint. Any path that mutates the sim directly would not be recorded and would silently
desync the replay. desync the replay.
This was originally intended to be enforced **structurally** (make the `Simulation` mutators This is enforced **structurally**: the `Simulation` player-action mutators are **private**, so
non-public so the only way to reach them is `apply(command)`). the only way production code can reach them is `apply(command)`.
> **Implementation decision (Phase 1).** The structural-enforcement plan was **dropped in > **Implementation decision (Phase 1, revised post-Phase 4).** Structural enforcement was
> favour of convention**, because the test suite legitimately drives the same mutators > initially deferred in favour of convention, because the test suite legitimately drives the
> directly (`sim.tryPlaceBuilding(...)` and its returned id, `buildings().setRecipe(...)`, > same mutators directly and relies on their return values (notably the `BuildingId` from
> `applySchematicChoice`, `reset`, `placeImmediate`, …) and relies on their return values — > placement, which `apply()` cannot hand back to a caller). It was later restored once a key
> making them non-public would break ~30 test call sites, and `apply()` cannot hand a new > observation made the change cheap: **the UI's only handle to a mutable subsystem is through
> `BuildingId` back to a caller. So the mutators stay **public**; the rule "every UI mutation > `Simulation`** — no production code in `ui`/`app`/`balancing` holds a `BuildingSystem`/
> goes through a command" is upheld by convention and a documented chokepoint comment on > `BeltSystem` directly, and every production `buildings()`/`belts()` call is a const query.
> `Simulation::apply`. A `[command]` Catch2 suite asserts `apply(...)` produces byte-identical > So:
> state to the direct mutator path, guarding the equivalence the replay relies on. Tests are >
> not gameplay (they never record), so direct mutator use there does not affect replay > - `Simulation::tryPlaceBuilding`, `demolish`, and `applySchematicChoice` are **private**.
> correctness. > - The mutable subsystem accessors are private and renamed `buildingsMutable()` /
> `beltsMutable()`; only `const BuildingSystem& buildings() const` / `belts() const` are
> public (queries). UI query sites bind to the const overload unchanged.
> - `Simulation::apply` still mutates through the private members directly, so the chokepoint
> itself is unaffected.
> - Tests reach the private mutators through `SimulationTestAccess` (src/test, a `friend struct`
> of `Simulation`), so they keep calling the real mutators **and keep getting return values**
> — no id-by-position recovery needed. This header is not on the lib/ui/app include path, so
> only test translation units can use it.
>
> The `BuildingSystem` subsystem mutators (`place`, `setRecipe`, `placeImmediate`,
> `forEachBuilding`, …) stay **public**: `BuildingTest` unit-tests a bare `BuildingSystem` with
> no `Simulation`/command layer, and that surface is unreachable from production anyway (you
> cannot obtain a mutable subsystem without the private accessor). A `[command]` Catch2 suite
> still asserts `apply(...)` produces byte-identical state to the direct mutator path, guarding
> the equivalence the replay relies on. Tests are not gameplay (they never record), so direct
> mutator use there does not affect replay correctness.
Recording happens **at the apply chokepoint**, not at the UI gesture — so only commands that Recording happens **at the apply chokepoint**, not at the UI gesture — so only commands that
actually reached the sim are recorded, and they replay through the identical apply path. actually reached the sim are recorded, and they replay through the identical apply path.
@@ -366,9 +382,10 @@ Reshape mutations to flow through one path; behaviour unchanged.
`ClearBeltTiles`, `ApplySchematicChoice`, `Reset`) in `lib`, each with a `playerId` (always 0 `ClearBeltTiles`, `ApplySchematicChoice`, `Reset`) in `lib`, each with a `playerId` (always 0
now). `PlaceBuilding` is atomic (carries optional config — see the refinement note above). now). `PlaceBuilding` is atomic (carries optional config — see the refinement note above).
- Added `CommandManager` (FIFO queue, `enqueue`/`drain`) in `lib`, holding a `Simulation&`. - Added `CommandManager` (FIFO queue, `enqueue`/`drain`) in `lib`, holding a `Simulation&`.
- Added `Simulation::apply(const Command&)` dispatching by `CommandKind` to the existing - Added `Simulation::apply(const Command&)` dispatching by `CommandKind` to the underlying
mutators — the single documented chokepoint. (Mutators stay public; enforced by convention, mutators — the single chokepoint. The `Simulation` player-action mutators are **private**
see the decision note above.) (compile-time enforced; tests reach them via the `SimulationTestAccess` friend) — see the
decision note above.
- Wired the drain: `GameWorldView::onFrame` calls `CommandManager::drain()` once per frame, - Wired the drain: `GameWorldView::onFrame` calls `CommandManager::drain()` once per frame,
before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset` before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset`
triggers the view reset. triggers the view reset.
@@ -379,8 +396,9 @@ Reshape mutations to flow through one path; behaviour unchanged.
`Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`, `Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`,
`SelectedBuildingPanel.cpp`; new `CommandTest.cpp`. `SelectedBuildingPanel.cpp`; new `CommandTest.cpp`.
- **Exit criteria:** game plays identically (including build-while-paused); determinism test - **Exit criteria:** game plays identically (including build-while-paused); determinism test
still passes; `[command]` equivalence tests pass; no UI call site mutates the sim directly still passes; `[command]` equivalence tests pass; no production call site can mutate the sim
(verified by grep — convention, not compile-enforced). directly (compile-enforced: the `Simulation` mutators are private, tests excepted via
`SimulationTestAccess`).
### Phase 2 — Recording — DONE ### Phase 2 — Recording — DONE

View File

@@ -1182,7 +1182,7 @@ void Simulation::demolish(BuildingId id)
m_buildingBlocksStock += m_buildingSystem->demolish(id); m_buildingBlocksStock += m_buildingSystem->demolish(id);
} }
BuildingSystem& Simulation::buildings() BuildingSystem& Simulation::buildingsMutable()
{ {
return *m_buildingSystem; return *m_buildingSystem;
} }
@@ -1192,7 +1192,7 @@ const BuildingSystem& Simulation::buildings() const
return *m_buildingSystem; return *m_buildingSystem;
} }
BeltSystem& Simulation::belts() BeltSystem& Simulation::beltsMutable()
{ {
return m_beltSystem; return m_beltSystem;
} }

View File

@@ -68,11 +68,6 @@ public:
// Returns true if there are pending schematic choices waiting for player input. // Returns true if there are pending schematic choices waiting for player input.
bool hasSchematicChoicesPending() const; bool hasSchematicChoicesPending() const;
// Applies the player's chosen schematic from the pending choices.
// choiceIndex must be in [0, pendingChoices.size()).
// Clears the pending choices after application.
void applySchematicChoice(int choiceIndex);
Tick currentTick() const; Tick currentTick() const;
// The seed this run was (re)initialized with; written to the replay header. // The seed this run was (re)initialized with; written to the replay header.
unsigned int getSeed() const; unsigned int getSeed() const;
@@ -108,16 +103,11 @@ public:
// a superset of rngFingerprint(). // a superset of rngFingerprint().
unsigned long long computeStateChecksum() const; unsigned long long computeStateChecksum() const;
// Checks affordability, deducts building blocks, and places the building. // Const subsystem accessors (queries only). The mutable counterparts are
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient. // private and reachable only through Simulation::apply (the command
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation); // chokepoint) or, in tests, SimulationTestAccess — so production code cannot
// mutate the factory outside the recorded command path (docs/replay_design.md).
// Demolishes the building with the given id and refunds building blocks.
void demolish(BuildingId id);
BuildingSystem& buildings();
const BuildingSystem& buildings() const; const BuildingSystem& buildings() const;
BeltSystem& belts();
const BeltSystem& belts() const; const BeltSystem& belts() const;
ShipSystem& ships(); ShipSystem& ships();
const ShipSystem& ships() const; const ShipSystem& ships() const;
@@ -127,6 +117,29 @@ public:
const EntityAdmin& admin() const; const EntityAdmin& admin() const;
private: private:
// Grants tests access to the private player-action mutators below without
// opening them to production code (see src/test/SimulationTestAccess.h).
friend struct SimulationTestAccess;
// -- Player-action mutators (command chokepoint only) --------------------
// Reached during play exclusively via apply(); never called by UI/app code.
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// Demolishes the building with the given id and refunds building blocks.
void demolish(BuildingId id);
// Applies the player's chosen schematic from the pending choices.
// choiceIndex must be in [0, pendingChoices.size()).
// Clears the pending choices after application.
void applySchematicChoice(int choiceIndex);
// Mutable subsystem accessors; same chokepoint rule as the mutators above.
BuildingSystem& buildingsMutable();
BeltSystem& beltsMutable();
void handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event) override; void handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event) override;
BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId. BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.

View File

@@ -16,6 +16,7 @@
#include "Rotation.h" #include "Rotation.h"
#include "ShipLayout.h" #include "ShipLayout.h"
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "Tick.h" #include "Tick.h"
@@ -524,9 +525,9 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
const QPoint offsetA(-1, 0); const QPoint offsetA(-1, 0);
const QPoint offsetB( 1, 0); const QPoint offsetB( 1, 0);
const BuildingId idA = sim.tryPlaceBuilding( const BuildingId idA = SimulationTestAccess::place(sim,
BuildingType::Belt, cursor + offsetA, Rotation::East); BuildingType::Belt, cursor + offsetA, Rotation::East);
const BuildingId idB = sim.tryPlaceBuilding( const BuildingId idB = SimulationTestAccess::place(sim,
BuildingType::Belt, cursor + offsetB, Rotation::East); BuildingType::Belt, cursor + offsetB, Rotation::East);
REQUIRE(idA != kInvalidBuildingId); REQUIRE(idA != kInvalidBuildingId);
@@ -551,10 +552,10 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
const int startBlocks = sim.buildingBlocksStock(); const int startBlocks = sim.buildingBlocksStock();
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-6, 0), Rotation::East); SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-6, 0), Rotation::East);
REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost); REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost);
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-4, 0), Rotation::East); SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-4, 0), Rotation::East);
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost); REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
} }
@@ -576,12 +577,12 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
int col = -2; int col = -2;
while (sim.buildingBlocksStock() >= minerCost) while (sim.buildingBlocksStock() >= minerCost)
{ {
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(col, 0), Rotation::East); SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(col, 0), Rotation::East);
col -= 2; col -= 2;
} }
const int blocksBeforeAttempt = sim.buildingBlocksStock(); const int blocksBeforeAttempt = sim.buildingBlocksStock();
const BuildingId id = sim.tryPlaceBuilding( const BuildingId id = SimulationTestAccess::place(sim,
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East); BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
// Placement must fail and leave the stock unchanged. // Placement must fail and leave the stock unchanged.
@@ -598,7 +599,7 @@ TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and ch
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain // A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
// rule, so it must be rejected without consuming building blocks. // rule, so it must be rejected without consuming building blocks.
const BuildingId id = const BuildingId id =
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(0, 0), Rotation::East); SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(0, 0), Rotation::East);
REQUIRE(id == kInvalidBuildingId); REQUIRE(id == kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == startBlocks); REQUIRE(sim.buildingBlocksStock() == startBlocks);
@@ -621,7 +622,7 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies
// Miner mask ["AA","A>"] East at (-3,0) → all-asteroid body at // Miner mask ["AA","A>"] East at (-3,0) → all-asteroid body at
// (-3,0),(-2,0),(-3,1); a valid spot. // (-3,0),(-2,0),(-3,1); a valid spot.
const BuildingId id = const BuildingId id =
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == startBlocks - minerCost); REQUIRE(sim.buildingBlocksStock() == startBlocks - minerCost);
@@ -664,10 +665,10 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
Simulation sim(loadConfig()); Simulation sim(loadConfig());
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid. // Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East); const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
sim.buildings().setRecipe(id, "mine_iron_ore"); SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
const ConstructionSite* site = sim.buildings().findSite(id); const ConstructionSite* site = sim.buildings().findSite(id);
REQUIRE(site != nullptr); REQUIRE(site != nullptr);
@@ -679,9 +680,9 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East); const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
sim.buildings().setRecipe(id, "mine_copper_ore"); SimulationTestAccess::buildings(sim).setRecipe(id, "mine_copper_ore");
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300. // Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
// Run 301 ticks (0..300) to process the completion tick. // Run 301 ticks (0..300) to process the completion tick.
@@ -759,7 +760,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East: // Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles. // A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
// S-tile at (0,0) and (0,1) — x >= 0, valid space tiles. // S-tile at (0,0) and (0,1) — x >= 0, valid space tiles.
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East); const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout; ShipLayoutConfig layout;
@@ -769,7 +770,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(id, layout); SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
const ConstructionSite* site = sim.buildings().findSite(id); const ConstructionSite* site = sim.buildings().findSite(id);
REQUIRE(site != nullptr); REQUIRE(site != nullptr);
@@ -783,7 +784,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East); const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId); REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout; ShipLayoutConfig layout;
@@ -793,7 +794,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
pm.rotation = Rotation::North; pm.rotation = Rotation::North;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(id, layout); SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
// Shipyard construction_time_seconds = 30 in the test config. // Shipyard construction_time_seconds = 30 in the test config.
double constructionTime = 0.0; double constructionTime = 0.0;

View File

@@ -9,6 +9,7 @@
#include "GameConfig.h" #include "GameConfig.h"
#include "Rotation.h" #include "Rotation.h"
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h"
namespace namespace
{ {
@@ -33,7 +34,7 @@ TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]")
command.rotation = Rotation::East; command.rotation = Rotation::East;
viaCommand.apply(command); viaCommand.apply(command);
viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
} }
@@ -51,8 +52,8 @@ TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe"
viaCommand.apply(command); viaCommand.apply(command);
const BuildingId id = const BuildingId id =
viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East); SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
viaDirect.buildings().setRecipe(id, "mine_iron_ore"); SimulationTestAccess::buildings(viaDirect).setRecipe(id, "mine_iron_ore");
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
} }
@@ -63,16 +64,16 @@ TEST_CASE("apply(DemolishCommand) matches direct demolish", "[command]")
Simulation viaDirect(loadConfig(), 99); Simulation viaDirect(loadConfig(), 99);
const BuildingId idA = const BuildingId idA =
viaCommand.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
const BuildingId idB = const BuildingId idB =
viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
REQUIRE(idA == idB); REQUIRE(idA == idB);
DemolishCommand command; DemolishCommand command;
command.id = idA; command.id = idA;
viaCommand.apply(command); viaCommand.apply(command);
viaDirect.demolish(idB); SimulationTestAccess::demolish(viaDirect, idB);
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
} }
@@ -98,8 +99,8 @@ TEST_CASE("CommandManager drains queued commands in FIFO order through apply", "
manager.drain(); manager.drain();
REQUIRE_FALSE(manager.hasPending()); REQUIRE_FALSE(manager.hasPending());
viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
viaDirect.tryPlaceBuilding(BuildingType::Belt, QPoint(-2, 0), Rotation::East); SimulationTestAccess::place(viaDirect, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
REQUIRE(viaManager.computeStateChecksum() == viaDirect.computeStateChecksum()); REQUIRE(viaManager.computeStateChecksum() == viaDirect.computeStateChecksum());
} }

View File

@@ -8,6 +8,7 @@
#include "GameConfig.h" #include "GameConfig.h"
#include "Rotation.h" #include "Rotation.h"
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h"
#include "StateChecksum.h" #include "StateChecksum.h"
#include "Tick.h" #include "Tick.h"
@@ -28,9 +29,9 @@ std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
Simulation sim(loadConfig(), seed); Simulation sim(loadConfig(), seed);
// Tick 0: a miner feeding a short belt line on the asteroid. // Tick 0: a miner feeding a short belt line on the asteroid.
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-2, 0), Rotation::East); SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-1, 0), Rotation::East); SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-1, 0), Rotation::East);
std::vector<std::uint64_t> checksums; std::vector<std::uint64_t> checksums;
checksums.reserve(kScriptTicks); checksums.reserve(kScriptTicks);
@@ -40,7 +41,7 @@ std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
if (t == 500) if (t == 500)
{ {
// Demolish the second belt mid-run to exercise the removal paths. // Demolish the second belt mid-run to exercise the removal paths.
sim.tryPlaceBuilding(BuildingType::Smelter, QPoint(-3, 3), Rotation::East); SimulationTestAccess::place(sim, BuildingType::Smelter, QPoint(-3, 3), Rotation::East);
} }
sim.tick(); sim.tick();

View File

@@ -10,6 +10,7 @@
#include "RecipesConfig.h" #include "RecipesConfig.h"
#include "SchematicChoiceOption.h" #include "SchematicChoiceOption.h"
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
static GameConfig loadConfig() static GameConfig loadConfig()
@@ -38,7 +39,7 @@ static void killEnemyStationsAndApply(Simulation& sim)
killEnemyStations(sim); killEnemyStations(sim);
if (sim.hasSchematicChoicesPending()) if (sim.hasSchematicChoicesPending())
{ {
sim.applySchematicChoice(0); SimulationTestAccess::applySchematicChoice(sim, 0);
} }
} }
@@ -246,7 +247,7 @@ TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
break; break;
} }
} }
sim.applySchematicChoice(0); SimulationTestAccess::applySchematicChoice(sim, 0);
} }
} }
CHECK(foundRecipeChoice); CHECK(foundRecipeChoice);
@@ -308,7 +309,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and
} }
} }
sim.applySchematicChoice(0); SimulationTestAccess::applySchematicChoice(sim, 0);
} }
} }
@@ -340,7 +341,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually
const std::set<std::string> unlockedBefore = unlockedTrackedRecipeIds(); const std::set<std::string> unlockedBefore = unlockedTrackedRecipeIds();
const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0]; const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0];
sim.applySchematicChoice(0); SimulationTestAccess::applySchematicChoice(sim, 0);
std::set<std::string> expectedNames; std::set<std::string> expectedNames;
for (const RecipeDef& def : cfg.recipes.recipes) for (const RecipeDef& def : cfg.recipes.recipes)

View File

@@ -17,6 +17,7 @@
#include "ShipStatsCalculator.h" #include "ShipStatsCalculator.h"
#include "ShipSystem.h" #include "ShipSystem.h"
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h"
#include "Tick.h" #include "Tick.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
@@ -62,7 +63,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef) static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{ {
return sim.buildings().placeImmediate( return SimulationTestAccess::buildings(sim).placeImmediate(
BuildingType::Shipyard, BuildingType::Shipyard,
yardDef.surfaceMask, yardDef.surfaceMask,
QPoint(0, 0), QPoint(0, 0),
@@ -73,7 +74,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
const ShipDef& def, const ShipDef& def,
const ShipLayoutConfig& layout) const ShipLayoutConfig& layout)
{ {
sim.buildings().forEachBuilding([&](Building& b) { SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b) {
if (b.id != yardId) if (b.id != yardId)
{ {
return; return;
@@ -216,7 +217,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor"); SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
ShipLayoutConfig layout; ShipLayoutConfig layout;
PlacedModule pm; PlacedModule pm;
@@ -225,7 +226,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(yardId, layout); SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b = sim.buildings().findBuilding(yardId); const Building* b = sim.buildings().findBuilding(yardId);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
@@ -245,7 +246,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor"); SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
// Fill materials and tick to start production. // Fill materials and tick to start production.
ShipLayoutConfig emptyLayout; ShipLayoutConfig emptyLayout;
@@ -264,7 +265,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(yardId, layout); SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b2 = sim.buildings().findBuilding(yardId); const Building* b2 = sim.buildings().findBuilding(yardId);
REQUIRE(b2 != nullptr); REQUIRE(b2 != nullptr);
@@ -278,7 +279,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor"); SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
ShipLayoutConfig layout; ShipLayoutConfig layout;
PlacedModule pm; PlacedModule pm;
@@ -286,13 +287,13 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
pm.position = QPoint(0, 0); pm.position = QPoint(0, 0);
pm.rotation = Rotation::East; pm.rotation = Rotation::East;
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(yardId, layout); SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b1 = sim.buildings().findBuilding(yardId); const Building* b1 = sim.buildings().findBuilding(yardId);
REQUIRE(b1 != nullptr); REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value()); REQUIRE(b1->shipLayout.has_value());
sim.buildings().setRecipe(yardId, "destroyer"); SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer");
const Building* b2 = sim.buildings().findBuilding(yardId); const Building* b2 = sim.buildings().findBuilding(yardId);
REQUIRE(b2 != nullptr); REQUIRE(b2 != nullptr);

View File

@@ -12,6 +12,7 @@
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "ShipSystem.h" #include "ShipSystem.h"
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h"
#include "Tick.h" #include "Tick.h"
static GameConfig loadConfig() static GameConfig loadConfig()
@@ -45,7 +46,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef) static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{ {
return sim.buildings().placeImmediate( return SimulationTestAccess::buildings(sim).placeImmediate(
BuildingType::Shipyard, BuildingType::Shipyard,
yardDef.surfaceMask, yardDef.surfaceMask,
QPoint(0, 0), QPoint(0, 0),
@@ -62,7 +63,7 @@ static int countShips(Simulation& sim)
static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def) static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def)
{ {
sim.buildings().forEachBuilding([&](Building& b) SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b)
{ {
if (b.id != yardId) if (b.id != yardId)
{ {
@@ -94,7 +95,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
const BuildingId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
REQUIRE(yardId != kInvalidBuildingId); REQUIRE(yardId != kInvalidBuildingId);
sim.buildings().setRecipe(yardId, def->id); SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
fillMaterials(sim, yardId, *def); fillMaterials(sim, yardId, *def);
// First tick: materials consumed, production cycle starts — no ship yet. // First tick: materials consumed, production cycle starts — no ship yet.
@@ -153,7 +154,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
const int shipsBefore = countShips(sim); const int shipsBefore = countShips(sim);
const BuildingId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, def->id); SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
// Materials remain at zero (default after setRecipe); no cycle starts. // Materials remain at zero (default after setRecipe); no cycle starts.
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds); const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
@@ -175,7 +176,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
REQUIRE(yardDef != nullptr); REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef); const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, def->id); SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds); const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);

View File

@@ -0,0 +1,40 @@
#pragma once
#include <QPoint>
#include "BuildingId.h"
#include "BuildingType.h"
#include "Rotation.h"
#include "Simulation.h"
class BeltSystem;
class BuildingSystem;
// Test-only backdoor to Simulation's private player-action mutators and mutable
// subsystem accessors. Declared a friend of Simulation, so it can hand tests the
// same mutation surface that Simulation::apply uses internally — without exposing
// those mutators to production (UI/app) code, which must go through the command
// chokepoint (docs/replay_design.md).
//
// This header lives under src/test and is not on the lib/ui/app include path, so
// only test translation units can reach it. Non-player test setup that has no
// command equivalent (e.g. placeImmediate, forEachBuilding buffer injection) is
// reached via buildings(sim)/belts(sim).
struct SimulationTestAccess
{
static BuildingSystem& buildings(Simulation& sim) { return sim.buildingsMutable(); }
static BeltSystem& belts(Simulation& sim) { return sim.beltsMutable(); }
static BuildingId place(Simulation& sim, BuildingType type, QPoint anchor,
Rotation rotation)
{
return sim.tryPlaceBuilding(type, anchor, rotation);
}
static void demolish(Simulation& sim, BuildingId id) { sim.demolish(id); }
static void applySchematicChoice(Simulation& sim, int choiceIndex)
{
sim.applySchematicChoice(choiceIndex);
}
};

View File

@@ -21,6 +21,7 @@
#include "SchematicChoiceOption.h" #include "SchematicChoiceOption.h"
#include "ShipsConfig.h" #include "ShipsConfig.h"
#include "Simulation.h" #include "Simulation.h"
#include "SimulationTestAccess.h"
#include "Tick.h" #include "Tick.h"
#include "ThreatCostCalculator.h" #include "ThreatCostCalculator.h"
#include "WaveSystem.h" #include "WaveSystem.h"
@@ -359,7 +360,7 @@ TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]
sim.tick(); sim.tick();
REQUIRE(sim.hasSchematicChoicesPending()); REQUIRE(sim.hasSchematicChoicesPending());
sim.applySchematicChoice(0); SimulationTestAccess::applySchematicChoice(sim, 0);
REQUIRE_FALSE(sim.hasSchematicChoicesPending()); REQUIRE_FALSE(sim.hasSchematicChoicesPending());
} }