replay: route player input through a command chokepoint (Phase 1)

Reshapes every UI-driven sim mutation to flow through one path so it can be
recorded and replayed later, with behaviour unchanged.

- Command model (lib): Command base + derived types (PlaceBuilding, Demolish,
  RotateInPlace, SetRecipe, SetShipLayout, Set[Site]SplitterFilters,
  ClearBeltTiles, ApplySchematicChoice, Reset), each with a playerId for the
  future-multiplayer shape. PlaceBuilding is atomic (carries optional
  recipe/layout/filters) because deferred commands never return the new
  BuildingId to the caller.
- CommandManager (lib): FIFO queue holding a Simulation&; enqueue + drain.
- Simulation::apply(const Command&): the single chokepoint, dispatching by
  kind to the existing mutators. Mutators stay public (enforced by convention,
  not compile-time, so the test suite keeps driving the sim directly).
- Timing: GameWorldView owns the CommandManager and drains it once per frame in
  onFrame, before the tick batch (runs at 0x too, so build-while-paused is
  preserved). A drained Reset triggers the view reset.
- UI fan-in: GameWorldView enqueues its own input directly; MainWindow and
  SelectedBuildingPanel emit CommandRequestedEvent, which GameWorldView
  subscribes to and enqueues. No UI site mutates the sim directly anymore.
- CommandTest: asserts apply(...) yields byte-identical state to the direct
  mutator path, and that CommandManager drains FIFO through apply.

Full suite green (338 cases / 3353 assertions); determinism double-run still
passes. Design doc updated with the atomic-PlaceBuilding and convention-
enforcement decisions.

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 19:44:50 +02:00
parent a45df902aa
commit 82ca9080a5
15 changed files with 654 additions and 77 deletions

View File

@@ -70,6 +70,16 @@ Commands use a **base class + derived classes** (mirroring the existing `Event`
idiom, so it is native to this codebase). They are routed through a dedicated command path, idiom, so it is native to this codebase). They are routed through a dedicated command path,
**not** through `EventManager` (see next section). **not** through `EventManager` (see next section).
> **Implementation refinement (Phase 1).** `PlaceBuilding` is **atomic**: it carries the
> optional recipe / ship-layout / splitter-filters to configure the new building in the same
> command. This is forced by the deferred-drain timing — commands apply at a later tick
> boundary, so the caller never sees the new `BuildingId` and therefore cannot issue a
> follow-up `SetRecipe`/`SetShipLayout` against it. The standalone `SetRecipe`,
> `SetShipLayout`, and the two `SetSplitterFilters` commands remain for the dialog-driven
> edits on *existing* buildings (which reference a known id). `Reset` carries the (move-only)
> `GameConfig` via `shared_ptr` and is moved into the sim on apply; a null config means "keep
> current config".
## Command system: reuse the *pattern*, not the EventManager singleton ## Command system: reuse the *pattern*, not the EventManager singleton
We reuse the **pattern** of the existing event system (a polymorphic base + small derived We reuse the **pattern** of the existing event system (a polymorphic base + small derived
@@ -103,10 +113,20 @@ 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 is enforced **structurally, not by discipline**: the `Simulation` mutators This was originally intended to be enforced **structurally** (make the `Simulation` mutators
(`tryPlaceBuilding`, `demolish`, `setRecipe`, …) are made private/non-public so the only way to non-public so the only way to reach them is `apply(command)`).
reach them is `apply(command)`. A stray direct call then fails to compile rather than compiling
and silently desyncing. > **Implementation decision (Phase 1).** The structural-enforcement plan was **dropped in
> favour of convention**, because the test suite legitimately drives the same mutators
> directly (`sim.tryPlaceBuilding(...)` and its returned id, `buildings().setRecipe(...)`,
> `applySchematicChoice`, `reset`, `placeImmediate`, …) and relies on their return values —
> making them non-public would break ~30 test call sites, and `apply()` cannot hand a new
> `BuildingId` back to a caller. So the mutators stay **public**; the rule "every UI mutation
> goes through a command" is upheld by convention and a documented chokepoint comment on
> `Simulation::apply`. A `[command]` Catch2 suite 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.
@@ -337,28 +357,30 @@ The whole feature rests on a deterministic sim, so prove that before building on
- **Exit criteria:** the double-run test passes. If it fails, fix the nondeterminism here before - **Exit criteria:** the double-run test passes. If it fails, fix the nondeterminism here before
proceeding. proceeding.
### Phase 1 — Command model + enforced chokepoint (no recording yet) ### Phase 1 — Command model + chokepoint (no recording yet) — DONE
Reshape mutations to flow through one path; behaviour unchanged. Reshape mutations to flow through one path; behaviour unchanged.
- Define `Command` base + derived types (`PlaceBuilding`, `Demolish`, `RotateInPlace`, - Defined `Command` base + derived types (`PlaceBuilding`, `Demolish`, `RotateInPlace`,
`SetRecipe`, `SetShipLayout`, `SetSplitterFilters`, `ClearBeltTiles`, `ApplySchematicChoice`, `SetRecipe`, `SetShipLayout`, `SetSiteSplitterFilters`, `SetSplitterFilters`,
`Reset`) in `lib`. Each carries a source/player id (always 0 now) for the future-multiplayer `ClearBeltTiles`, `ApplySchematicChoice`, `Reset`) in `lib`, each with a `playerId` (always 0
shape. now). `PlaceBuilding` is atomic (carries optional config — see the refinement note above).
- Add `CommandManager` (FIFO queue, drain) in `lib`, holding a `Simulation&`. - Added `CommandManager` (FIFO queue, `enqueue`/`drain`) in `lib`, holding a `Simulation&`.
- Add `Simulation::apply(const Command&)` dispatching to the existing mutators; then **make - Added `Simulation::apply(const Command&)` dispatching by `CommandKind` to the existing
those mutators non-public** so `apply` is the only entry — compile-enforces the completeness mutators — the single documented chokepoint. (Mutators stay public; enforced by convention,
invariant. see the decision note above.)
- Wire the drain: in `GameWorldView::onFrame`, call `CommandManager::drain()` **once per frame, - Wired the drain: `GameWorldView::onFrame` calls `CommandManager::drain()` once per frame,
before** the tick batch; tag each command with the current completed-tick count. before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset`
- Refactor every UI mutation site to **emit a single `CommandRequestedEvent`** (carrying a triggers the view reset.
`shared_ptr<Command>`) via the existing `EventManager`; one dispatcher subscribes and enqueues - Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues
onto `CommandManager`. directly; `MainWindow` and `SelectedBuildingPanel` emit `CommandRequestedEvent` (carrying a
- **Files:** new `lib` command + `CommandManager`; `Simulation.h/.cpp`; call sites in `shared_ptr<const Command>`) which `GameWorldView` subscribes to and enqueues.
`GameWorldView.cpp`, `MainWindow.cpp`, `SelectedBuildingPanel.cpp`; new dispatcher in - **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`;
`ui`/`app`. `Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`,
- **Exit criteria:** game plays identically (including build-while-paused), determinism test `SelectedBuildingPanel.cpp`; new `CommandTest.cpp`.
still passes, no widget can call a sim mutator directly (won't compile). - **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
(verified by grep — convention, not compile-enforced).
### Phase 2 — Recording ### Phase 2 — Recording

View File

@@ -27,6 +27,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -0,0 +1,23 @@
#pragma once
#include <memory>
#include "Event.h"
struct Command;
// UI fan-in for the command path: widgets emit this with a built command, and a
// single subscriber (GameWorldView) enqueues it onto the CommandManager. This
// keeps widgets decoupled (consistent with the rest of the UI), while the
// sim-mutating command itself is routed through the dedicated CommandManager
// queue rather than the EventManager bus (see docs/replay_design.md).
class CommandRequestedEvent : public Event
{
public:
explicit CommandRequestedEvent(std::shared_ptr<const Command> command)
: command(std::move(command))
{
}
const std::shared_ptr<const Command> command;
};

View File

@@ -1,6 +1,8 @@
SET(HDRS SET(HDRS
${HDRS} ${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h ${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h
${CMAKE_CURRENT_SOURCE_DIR}/Command.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h
@@ -18,6 +20,7 @@ SET(HDRS
SET(SRCS SET(SRCS
${SRCS} ${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp ${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp

141
src/lib/sim/Command.h Normal file
View File

@@ -0,0 +1,141 @@
#pragma once
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <QPoint>
#include "BuildingId.h"
#include "BuildingType.h"
#include "ItemType.h"
#include "Rotation.h"
#include "ShipLayout.h"
class GameConfig;
// Player intent, resolved to domain ids / tile coordinates and serializable, that
// mutates the simulation. Every sim mutation during play flows through a Command
// applied at the single Simulation::apply chokepoint, so it can be recorded and
// replayed (see docs/replay_design.md).
//
// Commands form a closed set dispatched by kind. They reference stable,
// deterministic ids (BuildingId, tile coordinates, choice indices) — never raw
// entt handles — so a recorded command resolves to the same target on replay.
enum class CommandKind
{
PlaceBuilding,
Demolish,
RotateInPlace,
SetRecipe,
SetShipLayout,
SetSiteSplitterFilters,
SetSplitterFilters,
ClearBeltTiles,
ApplySchematicChoice,
Reset
};
struct Command
{
explicit Command(CommandKind kind) : kind(kind) {}
virtual ~Command() = default;
CommandKind kind;
// Source of the command. Always 0 in single-player; lockstep multiplayer
// later merges commands from multiple sources into one ordered stream.
int playerId = 0;
};
// Places a building and, atomically, configures it. The configuration fields are
// bundled here (rather than as follow-up commands) because commands are applied
// at a deferred tick boundary, so the caller never sees the new BuildingId — the
// place-and-configure must happen as one unit inside apply().
struct PlaceBuildingCommand : Command
{
PlaceBuildingCommand() : Command(CommandKind::PlaceBuilding) {}
BuildingType type = BuildingType::Miner;
QPoint anchor;
Rotation rotation = Rotation::East;
// Optional configuration applied to the freshly placed (still-construction)
// building. The caller (UI) is responsible for unlock/validity pre-filtering;
// only fields that should apply are set.
std::optional<std::string> recipeId;
std::optional<ShipLayoutConfig> shipLayout;
bool hasSplitterFilters = false;
std::vector<ItemType> splitterFilterA;
std::vector<ItemType> splitterFilterB;
};
struct DemolishCommand : Command
{
DemolishCommand() : Command(CommandKind::Demolish) {}
BuildingId id = kInvalidBuildingId;
};
struct RotateInPlaceCommand : Command
{
RotateInPlaceCommand() : Command(CommandKind::RotateInPlace) {}
BuildingId id = kInvalidBuildingId;
Rotation newRotation = Rotation::East;
};
struct SetRecipeCommand : Command
{
SetRecipeCommand() : Command(CommandKind::SetRecipe) {}
BuildingId id = kInvalidBuildingId;
std::string recipeId;
};
struct SetShipLayoutCommand : Command
{
SetShipLayoutCommand() : Command(CommandKind::SetShipLayout) {}
BuildingId id = kInvalidBuildingId;
ShipLayoutConfig layout;
};
// Splitter filters for a queued / under-construction Splitter site (configured by
// BuildingSystem before the splitter is registered with BeltSystem).
struct SetSiteSplitterFiltersCommand : Command
{
SetSiteSplitterFiltersCommand() : Command(CommandKind::SetSiteSplitterFilters) {}
BuildingId id = kInvalidBuildingId;
std::vector<ItemType> filterA;
std::vector<ItemType> filterB;
};
// Splitter filters for an operational splitter, configured by tile via BeltSystem.
struct SetSplitterFiltersCommand : Command
{
SetSplitterFiltersCommand() : Command(CommandKind::SetSplitterFilters) {}
QPoint tile;
std::vector<ItemType> filterA;
std::vector<ItemType> filterB;
};
struct ClearBeltTilesCommand : Command
{
ClearBeltTilesCommand() : Command(CommandKind::ClearBeltTiles) {}
std::vector<QPoint> tiles;
};
struct ApplySchematicChoiceCommand : Command
{
ApplySchematicChoiceCommand() : Command(CommandKind::ApplySchematicChoice) {}
int choiceIndex = 0;
};
// Restart boundary: reinitializes the simulation with a fresh seed and, if
// config is set, a reloaded config (GameConfig is move-only, so it is carried by
// shared_ptr and moved into the sim on apply). A null config keeps the current
// config. One replay file = one run between Reset boundaries.
struct ResetCommand : Command
{
ResetCommand() : Command(CommandKind::Reset) {}
std::shared_ptr<GameConfig> config; // null = keep current config
unsigned int seed = 0;
};

View File

@@ -0,0 +1,34 @@
#include "CommandManager.h"
#include "Command.h"
#include "Simulation.h"
CommandManager::CommandManager(Simulation& simulation)
: m_simulation(simulation)
{
}
void CommandManager::enqueue(std::shared_ptr<const Command> command)
{
if (command)
{
m_queue.push_back(std::move(command));
}
}
void CommandManager::drain()
{
// Apply in FIFO order. A queued ResetCommand reinitializes the simulation in
// place; the reference stays valid and any commands after it apply to the
// fresh state.
for (const std::shared_ptr<const Command>& command : m_queue)
{
m_simulation.apply(*command);
}
m_queue.clear();
}
bool CommandManager::hasPending() const
{
return !m_queue.empty();
}

View File

@@ -0,0 +1,36 @@
#pragma once
#include <memory>
#include <vector>
struct Command;
class Simulation;
// Ordered queue that funnels every player command into the single
// Simulation::apply chokepoint (see docs/replay_design.md). This is deliberately
// NOT the EventManager pub/sub bus: sim mutations must apply in a strict,
// tick-pinned, recordable order to a single recipient.
//
// Live play pushes commands via enqueue(); they are applied at the next drain(),
// which runs once per frame before the tick batch. Draining before the tick
// (even at 0x game speed) lets a paused player see placed construction sites
// immediately while staying deterministic — replay applies each command at its
// recorded tick regardless of frame cadence.
class CommandManager
{
public:
explicit CommandManager(Simulation& simulation);
// Append a command for application at the next drain (FIFO order).
void enqueue(std::shared_ptr<const Command> command);
// Apply all queued commands in FIFO order through Simulation::apply, then
// clear the queue.
void drain();
bool hasPending() const;
private:
Simulation& m_simulation;
std::vector<std::shared_ptr<const Command>> m_queue;
};

View File

@@ -4,6 +4,7 @@
#include <cassert> #include <cassert>
#include "AiSystem.h" #include "AiSystem.h"
#include "Command.h"
#include "DisplayName.h" #include "DisplayName.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "CombatSystem.h" #include "CombatSystem.h"
@@ -219,6 +220,92 @@ void Simulation::reset(unsigned int seed)
// tick // tick
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void Simulation::apply(const Command& command)
{
switch (command.kind)
{
case CommandKind::PlaceBuilding:
{
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
const BuildingId id = tryPlaceBuilding(c.type, c.anchor, c.rotation);
if (id == kInvalidBuildingId)
{
break;
}
if (c.recipeId.has_value())
{
m_buildingSystem->setRecipe(id, *c.recipeId);
}
if (c.shipLayout.has_value())
{
m_buildingSystem->setShipLayout(id, *c.shipLayout);
}
if (c.hasSplitterFilters)
{
m_buildingSystem->setSiteSplitterFilters(id, c.splitterFilterA, c.splitterFilterB);
}
break;
}
case CommandKind::Demolish:
demolish(static_cast<const DemolishCommand&>(command).id);
break;
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
m_buildingSystem->rotateInPlace(c.id, c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
m_buildingSystem->setRecipe(c.id, c.recipeId);
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
m_buildingSystem->setShipLayout(c.id, c.layout);
break;
}
case CommandKind::SetSiteSplitterFilters:
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB);
break;
}
case CommandKind::SetSplitterFilters:
{
const SetSplitterFiltersCommand& c =
static_cast<const SetSplitterFiltersCommand&>(command);
m_beltSystem.setSplitterFilters(c.tile, c.filterA, c.filterB);
break;
}
case CommandKind::ClearBeltTiles:
m_beltSystem.clearTiles(static_cast<const ClearBeltTilesCommand&>(command).tiles);
break;
case CommandKind::ApplySchematicChoice:
applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex);
break;
case CommandKind::Reset:
{
const ResetCommand& c = static_cast<const ResetCommand&>(command);
if (c.config)
{
// operator* on a const shared_ptr yields a mutable GameConfig&, so
// the move-only config moves into reset without a copy. The command
// is applied once, so leaving its config moved-from is fine.
reset(std::move(*c.config), c.seed);
}
else
{
reset(c.seed);
}
break;
}
}
}
void Simulation::tick() void Simulation::tick()
{ {
EventManager::getInstance()->processEvents(); EventManager::getInstance()->processEvents();

View File

@@ -24,6 +24,7 @@
class AiSystem; class AiSystem;
class BuildingSystem; class BuildingSystem;
struct Command;
class Hasher; class Hasher;
class CombatSystem; class CombatSystem;
class DynamicBodySystem; class DynamicBodySystem;
@@ -51,6 +52,12 @@ public:
// Advances the simulation by one tick. Tick order per architecture.md §Tick Order. // Advances the simulation by one tick. Tick order per architecture.md §Tick Order.
void tick(); void tick();
// The single command chokepoint: applies one player command by dispatching
// to the underlying mutators. Every sim mutation during play must flow
// through here so it can be recorded and replayed (see docs/replay_design.md
// and CommandManager). Reached via CommandManager::drain.
void apply(const Command& command);
// Returns all fire events accumulated since the last drain, clearing the // Returns all fire events accumulated since the last drain, clearing the
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM). // internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
std::vector<BeamFiredEvent> drainBeamFiredEvents(); std::vector<BeamFiredEvent> drainBeamFiredEvents();

View File

@@ -22,4 +22,5 @@ add_files(
ThreatCostCalculatorTest.cpp ThreatCostCalculatorTest.cpp
RecipeSchematicTest.cpp RecipeSchematicTest.cpp
DeterminismTest.cpp DeterminismTest.cpp
CommandTest.cpp
) )

105
src/test/CommandTest.cpp Normal file
View File

@@ -0,0 +1,105 @@
#include "catch.hpp"
#include <memory>
#include "BuildingSystem.h"
#include "Command.h"
#include "CommandManager.h"
#include "ConfigLoader.h"
#include "GameConfig.h"
#include "Rotation.h"
#include "Simulation.h"
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
} // namespace
// The command chokepoint (Simulation::apply) must produce exactly the same state
// as driving the underlying mutators directly — that equivalence is what lets a
// recorded command stream reproduce a live run (see docs/replay_design.md).
TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]")
{
Simulation viaCommand(loadConfig(), 99);
Simulation viaDirect(loadConfig(), 99);
PlaceBuildingCommand command;
command.type = BuildingType::Miner;
command.anchor = QPoint(-3, 0);
command.rotation = Rotation::East;
viaCommand.apply(command);
viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East);
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
}
TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe", "[command]")
{
Simulation viaCommand(loadConfig(), 99);
Simulation viaDirect(loadConfig(), 99);
PlaceBuildingCommand command;
command.type = BuildingType::Miner;
command.anchor = QPoint(-2, 0);
command.rotation = Rotation::East;
command.recipeId = "mine_iron_ore";
viaCommand.apply(command);
const BuildingId id =
viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
viaDirect.buildings().setRecipe(id, "mine_iron_ore");
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
}
TEST_CASE("apply(DemolishCommand) matches direct demolish", "[command]")
{
Simulation viaCommand(loadConfig(), 99);
Simulation viaDirect(loadConfig(), 99);
const BuildingId idA =
viaCommand.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East);
const BuildingId idB =
viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East);
REQUIRE(idA == idB);
DemolishCommand command;
command.id = idA;
viaCommand.apply(command);
viaDirect.demolish(idB);
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
}
TEST_CASE("CommandManager drains queued commands in FIFO order through apply", "[command]")
{
Simulation viaManager(loadConfig(), 99);
Simulation viaDirect(loadConfig(), 99);
CommandManager manager(viaManager);
std::shared_ptr<PlaceBuildingCommand> first = std::make_shared<PlaceBuildingCommand>();
first->type = BuildingType::Miner;
first->anchor = QPoint(-3, 0);
std::shared_ptr<PlaceBuildingCommand> second = std::make_shared<PlaceBuildingCommand>();
second->type = BuildingType::Belt;
second->anchor = QPoint(-2, 0);
manager.enqueue(first);
manager.enqueue(second);
REQUIRE(manager.hasPending());
manager.drain();
REQUIRE_FALSE(manager.hasPending());
viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East);
viaDirect.tryPlaceBuilding(BuildingType::Belt, QPoint(-2, 0), Rotation::East);
REQUIRE(viaManager.computeStateChecksum() == viaDirect.computeStateChecksum());
}

View File

@@ -24,6 +24,7 @@
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "Command.h"
#include "DemolishModeChangedEvent.h" #include "DemolishModeChangedEvent.h"
#include "EntityHitTest.h" #include "EntityHitTest.h"
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
@@ -117,6 +118,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
, m_sim(sim) , m_sim(sim)
, m_config(config) , m_config(config)
, m_visuals(visuals) , m_visuals(visuals)
, m_commandManager(*sim)
, m_gameSpeedMultiplier(1.0) , m_gameSpeedMultiplier(1.0)
, m_prevNonZeroSpeed(1.0) , m_prevNonZeroSpeed(1.0)
, m_scrollXTiles(0.0f) , m_scrollXTiles(0.0f)
@@ -159,6 +161,18 @@ void GameWorldView::onFrame()
{ {
const qint64 elapsed = m_frameTimer.restart(); const qint64 elapsed = m_frameTimer.restart();
// Drain queued player commands once per frame, before the tick batch. This
// runs even at 0x so a paused player sees placed construction sites
// immediately, while staying deterministic (see docs/replay_design.md).
m_commandManager.drain();
// A drained Reset reinitialized the simulation; reset the view to match.
if (m_viewResetPending)
{
m_viewResetPending = false;
resetForNewGame();
}
// Advance simulation // Advance simulation
{ {
const int ticks = m_tickDriver.advance( const int ticks = m_tickDriver.advance(
@@ -515,12 +529,22 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation); m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
if (rotateTarget.has_value()) if (rotateTarget.has_value())
{ {
m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation); std::shared_ptr<RotateInPlaceCommand> rotateCommand =
std::make_shared<RotateInPlaceCommand>();
rotateCommand->id = *rotateTarget;
rotateCommand->newRotation = bb.rotation;
enqueueCommand(rotateCommand);
continue; continue;
} }
const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation); // Place-and-configure is one atomic command: commands apply at a deferred
if (id == kInvalidBuildingId) { continue; } // tick boundary, so the caller never sees the new BuildingId. Unlock
// gating stays here (UI-side pre-filter); only fields that should apply
// are set on the command.
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
command->type = bb.type;
command->anchor = anchor;
command->rotation = bb.rotation;
if (!bb.recipeId.empty()) if (!bb.recipeId.empty())
{ {
@@ -528,7 +552,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
{ {
if (m_sim->isSchematicUnlocked(bb.recipeId)) if (m_sim->isSchematicUnlocked(bb.recipeId))
{ {
m_sim->buildings().setRecipe(id, bb.recipeId); command->recipeId = bb.recipeId;
} }
} }
else else
@@ -537,15 +561,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|| bb.type == BuildingType::Assembler; || bb.type == BuildingType::Assembler;
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId)) if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
{ {
m_sim->buildings().setRecipe(id, bb.recipeId); command->recipeId = bb.recipeId;
} }
} }
} }
if (bb.shipLayout.has_value()) command->shipLayout = bb.shipLayout;
{
m_sim->buildings().setShipLayout(id, *bb.shipLayout);
}
if (bb.type == BuildingType::Splitter if (bb.type == BuildingType::Splitter
&& (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty())) && (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
@@ -553,11 +574,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
// The splitter is still a construction site, so the filters carry // The splitter is still a construction site, so the filters carry
// over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked // over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
// item types are dropped per REQ-LOCK-UI-BLUEPRINT. // item types are dropped per REQ-LOCK-UI-BLUEPRINT.
m_sim->buildings().setSiteSplitterFilters( command->hasSplitterFilters = true;
id, command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim);
filterUnlockedItems(bb.splitterFilterA, *m_sim), command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim);
filterUnlockedItems(bb.splitterFilterB, *m_sim));
} }
enqueueCommand(command);
} }
} }
@@ -578,49 +600,50 @@ void GameWorldView::placeAtTile(QPoint tile)
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation); m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
if (rotateTarget.has_value()) if (rotateTarget.has_value())
{ {
m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation); std::shared_ptr<RotateInPlaceCommand> command =
std::make_shared<RotateInPlaceCommand>();
command->id = *rotateTarget;
command->newRotation = m_ghostRotation;
enqueueCommand(command);
return; return;
} }
// For placements whose UI follow-up depends on success (belt-drag bookkeeping,
// tunnel entry/exit toggle), pre-validate occupancy + affordability so the
// optimistic UI update matches what the deferred command will do — isValidPlacement
// (above) already covered terrain/bounds.
if (type == BuildingType::Belt) if (type == BuildingType::Belt)
{ {
if (m_beltDragTiles.count(tile) > 0) if (m_beltDragTiles.count(tile) > 0)
{ {
return; return;
} }
if (!m_sim->buildings().isTileOccupied(tile)) if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
{ {
const BuildingId id = m_sim->tryPlaceBuilding( enqueuePlaceBuilding(type, tile, m_ghostRotation);
type, tile, m_ghostRotation); m_beltDragTiles.insert(tile);
if (id != kInvalidBuildingId)
{
m_beltDragTiles.insert(tile);
}
} }
} }
else if (type == BuildingType::Splitter else if (type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry || type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit) || type == BuildingType::TunnelExit)
{ {
if (!m_sim->buildings().isTileOccupied(tile)) if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
{ {
const BuildingId id = m_sim->tryPlaceBuilding(type, tile, m_ghostRotation); enqueuePlaceBuilding(type, tile, m_ghostRotation);
if (id != kInvalidBuildingId) if (type == BuildingType::TunnelEntry)
{ {
if (type == BuildingType::TunnelEntry) m_builderType = BuildingType::TunnelExit;
{ }
m_builderType = BuildingType::TunnelExit; else if (type == BuildingType::TunnelExit)
} {
else if (type == BuildingType::TunnelExit) m_builderType = BuildingType::TunnelEntry;
{
m_builderType = BuildingType::TunnelEntry;
}
} }
} }
} }
else else
{ {
m_sim->tryPlaceBuilding(type, tile, m_ghostRotation); enqueuePlaceBuilding(type, tile, m_ghostRotation);
} }
} }
@@ -1369,7 +1392,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
const bool isProtected = b && b->type == BuildingType::Hq; const bool isProtected = b && b->type == BuildingType::Hq;
if (!isProtected) if (!isProtected)
{ {
m_sim->demolish(hovered); std::shared_ptr<DemolishCommand> command =
std::make_shared<DemolishCommand>();
command->id = hovered;
enqueueCommand(command);
m_demolishHoverBuildingId = kInvalidBuildingId; m_demolishHoverBuildingId = kInvalidBuildingId;
} }
} }
@@ -1743,3 +1769,35 @@ void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent>
{ {
setGameSpeed(event->multiplier); setGameSpeed(event->multiplier);
} }
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
{
// Other widgets (MainWindow, SelectedBuildingPanel) request commands via this
// event; GameWorldView owns the CommandManager and enqueues them.
if (event->command && event->command->kind == CommandKind::Reset)
{
m_viewResetPending = true;
}
enqueueCommand(event->command);
}
void GameWorldView::enqueueCommand(std::shared_ptr<const Command> command)
{
m_commandManager.enqueue(std::move(command));
}
void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
command->type = type;
command->anchor = anchor;
command->rotation = rotation;
enqueueCommand(command);
}
bool GameWorldView::canAfford(BuildingType type) const
{
const BuildingDef* def = findBuildingDef(type);
if (!def) { return false; }
return m_sim->buildingBlocksStock() >= def->cost;
}

View File

@@ -26,10 +26,12 @@
#include "ExitBuilderModeRequestedEvent.h" #include "ExitBuilderModeRequestedEvent.h"
#include "DebugDrawToggledEvent.h" #include "DebugDrawToggledEvent.h"
#include "BeamFiredEvent.h" #include "BeamFiredEvent.h"
#include "CommandRequestedEvent.h"
#include "SchematicChoiceOption.h" #include "SchematicChoiceOption.h"
#include "SpeedChangeRequestedEvent.h" #include "SpeedChangeRequestedEvent.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "CommandManager.h"
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Rotation.h" #include "Rotation.h"
@@ -37,6 +39,7 @@
#include "TickDriver.h" #include "TickDriver.h"
#include "VisualsConfig.h" #include "VisualsConfig.h"
struct Command;
class Simulation; class Simulation;
class QPainter; class QPainter;
@@ -56,7 +59,8 @@ class GameWorldView : public QOpenGLWidget,
DemolishModeToggleRequestedEvent, DemolishModeToggleRequestedEvent,
BlueprintPlacementRequestedEvent, BlueprintPlacementRequestedEvent,
ExitBlueprintModeRequestedEvent, ExitBlueprintModeRequestedEvent,
SpeedChangeRequestedEvent> SpeedChangeRequestedEvent,
CommandRequestedEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -91,6 +95,17 @@ private:
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override; void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override; void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override; void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
// Enqueue a sim command onto the CommandManager (the single mutation path).
void enqueueCommand(std::shared_ptr<const Command> command);
// Enqueue a plain (unconfigured) building placement.
void enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// True if the player can currently afford to place one building of `type`.
// Used to pre-validate placements whose UI follow-up depends on success.
bool canAfford(BuildingType type) const;
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
@@ -157,6 +172,11 @@ private:
const GameConfig* m_config; const GameConfig* m_config;
const VisualsConfig* m_visuals; const VisualsConfig* m_visuals;
// Funnels all player input into the single Simulation::apply chokepoint.
CommandManager m_commandManager;
// A Reset command was enqueued; reset the view after the next drain applies it.
bool m_viewResetPending = false;
TickDriver m_tickDriver; TickDriver m_tickDriver;
QElapsedTimer m_frameTimer; QElapsedTimer m_frameTimer;
std::mt19937 m_rng; std::mt19937 m_rng;

View File

@@ -15,7 +15,10 @@
#include "BuildButtonGrid.h" #include "BuildButtonGrid.h"
#include "BuildingBlocksChangedEvent.h" #include "BuildingBlocksChangedEvent.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EventManager.h"
#include "GameWorldView.h" #include "GameWorldView.h"
#include "RecipeSelectionDialog.h" #include "RecipeSelectionDialog.h"
#include "SchematicChoiceDialog.h" #include "SchematicChoiceDialog.h"
@@ -138,7 +141,11 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
SchematicChoiceDialog dialog(event->choices, this); SchematicChoiceDialog dialog(event->choices, this);
dialog.exec(); dialog.exec();
m_sim->applySchematicChoice(dialog.getChosenIndex()); std::shared_ptr<ApplySchematicChoiceCommand> command =
std::make_shared<ApplySchematicChoiceCommand>();
command->choiceIndex = dialog.getChosenIndex();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
m_gameWorldView->setGameSpeed(prevSpeed); m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer(); m_gameWorldView->resetFrameTimer();
@@ -160,12 +167,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
QAbstractButton* clicked = box.clickedButton(); QAbstractButton* clicked = box.clickedButton();
if (clicked == restartBtn) if (clicked == restartBtn)
{ {
std::shared_ptr<GameConfig> newConfig;
try try
{ {
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals); m_visuals = std::move(newVisuals);
m_sim->reset(std::move(newConfig));
} }
catch (const std::exception& e) catch (const std::exception& e)
{ {
@@ -175,7 +183,12 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
m_gameWorldView->resetFrameTimer(); m_gameWorldView->resetFrameTimer();
return; return;
} }
m_gameWorldView->resetForNewGame(); // Restart is a command boundary; the view resets when the drain applies
// it (see GameWorldView::onFrame). Seed stays 0 in Phase 1.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
} }
else if (clicked == quitBtn) else if (clicked == quitBtn)
{ {
@@ -234,7 +247,12 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
this); this);
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value()) if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
{ {
m_sim->buildings().setShipLayout(event->shipyardId, *dialog.result()); std::shared_ptr<SetShipLayoutCommand> command =
std::make_shared<SetShipLayoutCommand>();
command->id = event->shipyardId;
command->layout = *dialog.result();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
} }
m_gameWorldView->setGameSpeed(prevSpeed); m_gameWorldView->setGameSpeed(prevSpeed);
@@ -268,7 +286,11 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
RecipeSelectionDialog dialog(options, title, this); RecipeSelectionDialog dialog(options, title, this);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value()) if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
{ {
m_sim->buildings().setRecipe(event->buildingId, *dialog.getChosenId()); std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
command->id = event->buildingId;
command->recipeId = *dialog.getChosenId();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
} }
m_gameWorldView->setGameSpeed(prevSpeed); m_gameWorldView->setGameSpeed(prevSpeed);
@@ -293,12 +315,13 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
if (box.clickedButton() == restartBtn) if (box.clickedButton() == restartBtn)
{ {
std::shared_ptr<GameConfig> newConfig;
try try
{ {
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals); m_visuals = std::move(newVisuals);
m_sim->reset(std::move(newConfig));
} }
catch (const std::exception& e) catch (const std::exception& e)
{ {
@@ -306,7 +329,11 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
tr("Failed to reload config:\n%1").arg(e.what())); tr("Failed to reload config:\n%1").arg(e.what()));
return; return;
} }
m_gameWorldView->resetForNewGame(); // Restart is a command boundary; the view resets when the drain applies it.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
} }
else else
{ {

View File

@@ -12,6 +12,8 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "DynamicBodyComponent.h" #include "DynamicBodyComponent.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
@@ -720,17 +722,23 @@ void SelectedBuildingPanel::onSplitterFilterChanged()
if (m_singleIsSite) if (m_singleIsSite)
{ {
m_sim->buildings().setSiteSplitterFilters( std::shared_ptr<SetSiteSplitterFiltersCommand> command =
m_singleBuildingId, std::make_shared<SetSiteSplitterFiltersCommand>();
collectFilter(m_filterAList), command->id = m_singleBuildingId;
collectFilter(m_filterBList)); command->filterA = collectFilter(m_filterAList);
command->filterB = collectFilter(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
} }
else else
{ {
m_sim->belts().setSplitterFilters( std::shared_ptr<SetSplitterFiltersCommand> command =
m_splitterTile, std::make_shared<SetSplitterFiltersCommand>();
collectFilter(m_filterAList), command->tile = m_splitterTile;
collectFilter(m_filterBList)); command->filterA = collectFilter(m_filterAList);
command->filterB = collectFilter(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
} }
} }
@@ -767,7 +775,11 @@ void SelectedBuildingPanel::onClearBelt()
} }
if (!tiles.empty()) if (!tiles.empty())
{ {
m_sim->belts().clearTiles(tiles); std::shared_ptr<ClearBeltTilesCommand> command =
std::make_shared<ClearBeltTilesCommand>();
command->tiles = std::move(tiles);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
} }
} }