diff --git a/docs/replay_design.md b/docs/replay_design.md index c9432c6..07d8deb 100644 --- a/docs/replay_design.md +++ b/docs/replay_design.md @@ -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, **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 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 desync the replay. -This is enforced **structurally, not by discipline**: the `Simulation` mutators -(`tryPlaceBuilding`, `demolish`, `setRecipe`, …) are made private/non-public so the only way to -reach them is `apply(command)`. A stray direct call then fails to compile rather than compiling -and silently desyncing. +This was originally intended to be enforced **structurally** (make the `Simulation` mutators +non-public so the only way to reach them is `apply(command)`). + +> **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 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 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. -- Define `Command` base + derived types (`PlaceBuilding`, `Demolish`, `RotateInPlace`, - `SetRecipe`, `SetShipLayout`, `SetSplitterFilters`, `ClearBeltTiles`, `ApplySchematicChoice`, - `Reset`) in `lib`. Each carries a source/player id (always 0 now) for the future-multiplayer - shape. -- Add `CommandManager` (FIFO queue, drain) in `lib`, holding a `Simulation&`. -- Add `Simulation::apply(const Command&)` dispatching to the existing mutators; then **make - those mutators non-public** so `apply` is the only entry — compile-enforces the completeness - invariant. -- Wire the drain: in `GameWorldView::onFrame`, call `CommandManager::drain()` **once per frame, - before** the tick batch; tag each command with the current completed-tick count. -- Refactor every UI mutation site to **emit a single `CommandRequestedEvent`** (carrying a - `shared_ptr`) via the existing `EventManager`; one dispatcher subscribes and enqueues - onto `CommandManager`. -- **Files:** new `lib` command + `CommandManager`; `Simulation.h/.cpp`; call sites in - `GameWorldView.cpp`, `MainWindow.cpp`, `SelectedBuildingPanel.cpp`; new dispatcher in - `ui`/`app`. -- **Exit criteria:** game plays identically (including build-while-paused), determinism test - still passes, no widget can call a sim mutator directly (won't compile). +- Defined `Command` base + derived types (`PlaceBuilding`, `Demolish`, `RotateInPlace`, + `SetRecipe`, `SetShipLayout`, `SetSiteSplitterFilters`, `SetSplitterFilters`, + `ClearBeltTiles`, `ApplySchematicChoice`, `Reset`) in `lib`, each with a `playerId` (always 0 + now). `PlaceBuilding` is atomic (carries optional config — see the refinement note above). +- Added `CommandManager` (FIFO queue, `enqueue`/`drain`) in `lib`, holding a `Simulation&`. +- Added `Simulation::apply(const Command&)` dispatching by `CommandKind` to the existing + mutators — the single documented chokepoint. (Mutators stay public; enforced by convention, + see the decision note above.) +- 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` + triggers the view reset. +- Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues + directly; `MainWindow` and `SelectedBuildingPanel` emit `CommandRequestedEvent` (carrying a + `shared_ptr`) which `GameWorldView` subscribes to and enqueues. +- **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`; + `Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`, + `SelectedBuildingPanel.cpp`; new `CommandTest.cpp`. +- **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 diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index 80fb640..6a5274d 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -27,6 +27,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h PARENT_SCOPE ) diff --git a/src/lib/eventsystem/event/CommandRequestedEvent.h b/src/lib/eventsystem/event/CommandRequestedEvent.h new file mode 100644 index 0000000..388f5e5 --- /dev/null +++ b/src/lib/eventsystem/event/CommandRequestedEvent.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#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 command) + : command(std::move(command)) + { + } + + const std::shared_ptr command; +}; diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 32c4186..997cf5f 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -1,6 +1,8 @@ SET(HDRS ${HDRS} ${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}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h @@ -18,6 +20,7 @@ SET(HDRS SET(SRCS ${SRCS} ${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp diff --git a/src/lib/sim/Command.h b/src/lib/sim/Command.h new file mode 100644 index 0000000..20240e4 --- /dev/null +++ b/src/lib/sim/Command.h @@ -0,0 +1,141 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#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 recipeId; + std::optional shipLayout; + bool hasSplitterFilters = false; + std::vector splitterFilterA; + std::vector 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 filterA; + std::vector filterB; +}; + +// Splitter filters for an operational splitter, configured by tile via BeltSystem. +struct SetSplitterFiltersCommand : Command +{ + SetSplitterFiltersCommand() : Command(CommandKind::SetSplitterFilters) {} + QPoint tile; + std::vector filterA; + std::vector filterB; +}; + +struct ClearBeltTilesCommand : Command +{ + ClearBeltTilesCommand() : Command(CommandKind::ClearBeltTiles) {} + std::vector 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 config; // null = keep current config + unsigned int seed = 0; +}; diff --git a/src/lib/sim/CommandManager.cpp b/src/lib/sim/CommandManager.cpp new file mode 100644 index 0000000..3e92a17 --- /dev/null +++ b/src/lib/sim/CommandManager.cpp @@ -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 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& command : m_queue) + { + m_simulation.apply(*command); + } + m_queue.clear(); +} + +bool CommandManager::hasPending() const +{ + return !m_queue.empty(); +} diff --git a/src/lib/sim/CommandManager.h b/src/lib/sim/CommandManager.h new file mode 100644 index 0000000..4a128eb --- /dev/null +++ b/src/lib/sim/CommandManager.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +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 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> m_queue; +}; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 351de9e..a3b3c2f 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -4,6 +4,7 @@ #include #include "AiSystem.h" +#include "Command.h" #include "DisplayName.h" #include "BuildingSystem.h" #include "CombatSystem.h" @@ -219,6 +220,92 @@ void Simulation::reset(unsigned int seed) // tick // --------------------------------------------------------------------------- +void Simulation::apply(const Command& command) +{ + switch (command.kind) + { + case CommandKind::PlaceBuilding: + { + const PlaceBuildingCommand& c = static_cast(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(command).id); + break; + case CommandKind::RotateInPlace: + { + const RotateInPlaceCommand& c = static_cast(command); + m_buildingSystem->rotateInPlace(c.id, c.newRotation); + break; + } + case CommandKind::SetRecipe: + { + const SetRecipeCommand& c = static_cast(command); + m_buildingSystem->setRecipe(c.id, c.recipeId); + break; + } + case CommandKind::SetShipLayout: + { + const SetShipLayoutCommand& c = static_cast(command); + m_buildingSystem->setShipLayout(c.id, c.layout); + break; + } + case CommandKind::SetSiteSplitterFilters: + { + const SetSiteSplitterFiltersCommand& c = + static_cast(command); + m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB); + break; + } + case CommandKind::SetSplitterFilters: + { + const SetSplitterFiltersCommand& c = + static_cast(command); + m_beltSystem.setSplitterFilters(c.tile, c.filterA, c.filterB); + break; + } + case CommandKind::ClearBeltTiles: + m_beltSystem.clearTiles(static_cast(command).tiles); + break; + case CommandKind::ApplySchematicChoice: + applySchematicChoice(static_cast(command).choiceIndex); + break; + case CommandKind::Reset: + { + const ResetCommand& c = static_cast(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() { EventManager::getInstance()->processEvents(); diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 51955b5..a375156 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -24,6 +24,7 @@ class AiSystem; class BuildingSystem; +struct Command; class Hasher; class CombatSystem; class DynamicBodySystem; @@ -51,6 +52,12 @@ public: // Advances the simulation by one tick. Tick order per architecture.md §Tick Order. 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 // internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM). std::vector drainBeamFiredEvents(); diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 1b2525f..5d1be1c 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -22,4 +22,5 @@ add_files( ThreatCostCalculatorTest.cpp RecipeSchematicTest.cpp DeterminismTest.cpp + CommandTest.cpp ) diff --git a/src/test/CommandTest.cpp b/src/test/CommandTest.cpp new file mode 100644 index 0000000..b767b83 --- /dev/null +++ b/src/test/CommandTest.cpp @@ -0,0 +1,105 @@ +#include "catch.hpp" + +#include + +#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 first = std::make_shared(); + first->type = BuildingType::Miner; + first->anchor = QPoint(-3, 0); + std::shared_ptr second = std::make_shared(); + 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()); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index fcbe533..ee21b20 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -24,6 +24,7 @@ #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" +#include "Command.h" #include "DemolishModeChangedEvent.h" #include "EntityHitTest.h" #include "EntitySelectedEvent.h" @@ -117,6 +118,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, , m_sim(sim) , m_config(config) , m_visuals(visuals) + , m_commandManager(*sim) , m_gameSpeedMultiplier(1.0) , m_prevNonZeroSpeed(1.0) , m_scrollXTiles(0.0f) @@ -159,6 +161,18 @@ void GameWorldView::onFrame() { 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 { const int ticks = m_tickDriver.advance( @@ -515,12 +529,22 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation); if (rotateTarget.has_value()) { - m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation); + std::shared_ptr rotateCommand = + std::make_shared(); + rotateCommand->id = *rotateTarget; + rotateCommand->newRotation = bb.rotation; + enqueueCommand(rotateCommand); continue; } - const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation); - if (id == kInvalidBuildingId) { continue; } + // Place-and-configure is one atomic command: commands apply at a deferred + // 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 command = std::make_shared(); + command->type = bb.type; + command->anchor = anchor; + command->rotation = bb.rotation; if (!bb.recipeId.empty()) { @@ -528,7 +552,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) { if (m_sim->isSchematicUnlocked(bb.recipeId)) { - m_sim->buildings().setRecipe(id, bb.recipeId); + command->recipeId = bb.recipeId; } } else @@ -537,15 +561,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) || bb.type == BuildingType::Assembler; if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId)) { - m_sim->buildings().setRecipe(id, bb.recipeId); + command->recipeId = bb.recipeId; } } } - if (bb.shipLayout.has_value()) - { - m_sim->buildings().setShipLayout(id, *bb.shipLayout); - } + command->shipLayout = bb.shipLayout; if (bb.type == BuildingType::Splitter && (!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 // over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked // item types are dropped per REQ-LOCK-UI-BLUEPRINT. - m_sim->buildings().setSiteSplitterFilters( - id, - filterUnlockedItems(bb.splitterFilterA, *m_sim), - filterUnlockedItems(bb.splitterFilterB, *m_sim)); + command->hasSplitterFilters = true; + command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim); + command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim); } + + enqueueCommand(command); } } @@ -578,49 +600,50 @@ void GameWorldView::placeAtTile(QPoint tile) m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation); if (rotateTarget.has_value()) { - m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation); + std::shared_ptr command = + std::make_shared(); + command->id = *rotateTarget; + command->newRotation = m_ghostRotation; + enqueueCommand(command); 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 (m_beltDragTiles.count(tile) > 0) - { + { return; } - if (!m_sim->buildings().isTileOccupied(tile)) + if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type)) { - const BuildingId id = m_sim->tryPlaceBuilding( - type, tile, m_ghostRotation); - if (id != kInvalidBuildingId) - { - m_beltDragTiles.insert(tile); - } + enqueuePlaceBuilding(type, tile, m_ghostRotation); + m_beltDragTiles.insert(tile); } } else if (type == BuildingType::Splitter || type == BuildingType::TunnelEntry || 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); - if (id != kInvalidBuildingId) + enqueuePlaceBuilding(type, tile, m_ghostRotation); + if (type == BuildingType::TunnelEntry) { - if (type == BuildingType::TunnelEntry) - { - m_builderType = BuildingType::TunnelExit; - } - else if (type == BuildingType::TunnelExit) - { - m_builderType = BuildingType::TunnelEntry; - } + m_builderType = BuildingType::TunnelExit; + } + else if (type == BuildingType::TunnelExit) + { + m_builderType = BuildingType::TunnelEntry; } } } 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; if (!isProtected) { - m_sim->demolish(hovered); + std::shared_ptr command = + std::make_shared(); + command->id = hovered; + enqueueCommand(command); m_demolishHoverBuildingId = kInvalidBuildingId; } } @@ -1743,3 +1769,35 @@ void GameWorldView::handleEvent(std::shared_ptr { setGameSpeed(event->multiplier); } + +void GameWorldView::handleEvent(std::shared_ptr 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 command) +{ + m_commandManager.enqueue(std::move(command)); +} + +void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation) +{ + std::shared_ptr command = std::make_shared(); + 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; +} diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 082ef52..db117e9 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -26,10 +26,12 @@ #include "ExitBuilderModeRequestedEvent.h" #include "DebugDrawToggledEvent.h" #include "BeamFiredEvent.h" +#include "CommandRequestedEvent.h" #include "SchematicChoiceOption.h" #include "SpeedChangeRequestedEvent.h" #include "entt/entity/entity.hpp" +#include "CommandManager.h" #include "EntitySelectedEvent.h" #include "GameConfig.h" #include "Rotation.h" @@ -37,6 +39,7 @@ #include "TickDriver.h" #include "VisualsConfig.h" +struct Command; class Simulation; class QPainter; @@ -56,7 +59,8 @@ class GameWorldView : public QOpenGLWidget, DemolishModeToggleRequestedEvent, BlueprintPlacementRequestedEvent, ExitBlueprintModeRequestedEvent, - SpeedChangeRequestedEvent> + SpeedChangeRequestedEvent, + CommandRequestedEvent> { Q_OBJECT @@ -91,6 +95,17 @@ private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; + + // Enqueue a sim command onto the CommandManager (the single mutation path). + void enqueueCommand(std::shared_ptr 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 drawBuildings(QPainter& painter); @@ -157,6 +172,11 @@ private: const GameConfig* m_config; 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; QElapsedTimer m_frameTimer; std::mt19937 m_rng; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3a25574..ff68345 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -15,7 +15,10 @@ #include "BuildButtonGrid.h" #include "BuildingBlocksChangedEvent.h" #include "BuildingSystem.h" +#include "Command.h" +#include "CommandRequestedEvent.h" #include "ConfigLoader.h" +#include "EventManager.h" #include "GameWorldView.h" #include "RecipeSelectionDialog.h" #include "SchematicChoiceDialog.h" @@ -138,7 +141,11 @@ void MainWindow::handleEvent(std::shared_ptrchoices, this); dialog.exec(); - m_sim->applySchematicChoice(dialog.getChosenIndex()); + std::shared_ptr command = + std::make_shared(); + command->choiceIndex = dialog.getChosenIndex(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); m_gameWorldView->setGameSpeed(prevSpeed); m_gameWorldView->resetFrameTimer(); @@ -160,12 +167,13 @@ void MainWindow::handleEvent(std::shared_ptr /*e QAbstractButton* clicked = box.clickedButton(); if (clicked == restartBtn) { + std::shared_ptr newConfig; try { - GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); + newConfig = std::make_shared( + ConfigLoader::loadFromDirectory(m_configDir)); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); m_visuals = std::move(newVisuals); - m_sim->reset(std::move(newConfig)); } catch (const std::exception& e) { @@ -175,7 +183,12 @@ void MainWindow::handleEvent(std::shared_ptr /*e m_gameWorldView->resetFrameTimer(); 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 command = std::make_shared(); + command->config = std::move(newConfig); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } else if (clicked == quitBtn) { @@ -234,7 +247,12 @@ void MainWindow::handleEvent(std::shared_ptr e this); if (dialog.exec() == QDialog::Accepted && dialog.result().has_value()) { - m_sim->buildings().setShipLayout(event->shipyardId, *dialog.result()); + std::shared_ptr command = + std::make_shared(); + command->id = event->shipyardId; + command->layout = *dialog.result(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } m_gameWorldView->setGameSpeed(prevSpeed); @@ -268,7 +286,11 @@ void MainWindow::handleEvent(std::shared_ptrbuildings().setRecipe(event->buildingId, *dialog.getChosenId()); + std::shared_ptr command = std::make_shared(); + command->id = event->buildingId; + command->recipeId = *dialog.getChosenId(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } m_gameWorldView->setGameSpeed(prevSpeed); @@ -293,12 +315,13 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) if (box.clickedButton() == restartBtn) { + std::shared_ptr newConfig; try { - GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); + newConfig = std::make_shared( + ConfigLoader::loadFromDirectory(m_configDir)); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); m_visuals = std::move(newVisuals); - m_sim->reset(std::move(newConfig)); } catch (const std::exception& e) { @@ -306,7 +329,11 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) tr("Failed to reload config:\n%1").arg(e.what())); return; } - m_gameWorldView->resetForNewGame(); + // Restart is a command boundary; the view resets when the drain applies it. + std::shared_ptr command = std::make_shared(); + command->config = std::move(newConfig); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } else { diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index 4edc9ab..aef9226 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -12,6 +12,8 @@ #include #include "BeltSystem.h" +#include "Command.h" +#include "CommandRequestedEvent.h" #include "DynamicBodyComponent.h" #include "EntityAdmin.h" #include "EntitySelectedEvent.h" @@ -720,17 +722,23 @@ void SelectedBuildingPanel::onSplitterFilterChanged() if (m_singleIsSite) { - m_sim->buildings().setSiteSplitterFilters( - m_singleBuildingId, - collectFilter(m_filterAList), - collectFilter(m_filterBList)); + std::shared_ptr command = + std::make_shared(); + command->id = m_singleBuildingId; + command->filterA = collectFilter(m_filterAList); + command->filterB = collectFilter(m_filterBList); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } else { - m_sim->belts().setSplitterFilters( - m_splitterTile, - collectFilter(m_filterAList), - collectFilter(m_filterBList)); + std::shared_ptr command = + std::make_shared(); + command->tile = m_splitterTile; + command->filterA = collectFilter(m_filterAList); + command->filterB = collectFilter(m_filterBList); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } } @@ -767,7 +775,11 @@ void SelectedBuildingPanel::onClearBelt() } if (!tiles.empty()) { - m_sim->belts().clearTiles(tiles); + std::shared_ptr command = + std::make_shared(); + command->tiles = std::move(tiles); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } }