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,
**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<Command>`) 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<const Command>`) 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

View File

@@ -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
)

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
${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

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 "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<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()
{
EventManager::getInstance()->processEvents();

View File

@@ -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<BeamFiredEvent> drainBeamFiredEvents();

View File

@@ -22,4 +22,5 @@ add_files(
ThreatCostCalculatorTest.cpp
RecipeSchematicTest.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 "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<RotateInPlaceCommand> rotateCommand =
std::make_shared<RotateInPlaceCommand>();
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<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
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<RotateInPlaceCommand> command =
std::make_shared<RotateInPlaceCommand>();
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<DemolishCommand> command =
std::make_shared<DemolishCommand>();
command->id = hovered;
enqueueCommand(command);
m_demolishHoverBuildingId = kInvalidBuildingId;
}
}
@@ -1743,3 +1769,35 @@ void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent>
{
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 "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<const BlueprintPlacementRequestedEvent> 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 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 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;

View File

@@ -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_ptr<const SchematicChoicesAvailableEven
SchematicChoiceDialog dialog(event->choices, this);
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->resetFrameTimer();
@@ -160,12 +167,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
QAbstractButton* clicked = box.clickedButton();
if (clicked == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
{
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
newConfig = std::make_shared<GameConfig>(
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<const EscapeMenuRequestedEvent> /*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<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else if (clicked == quitBtn)
{
@@ -234,7 +247,12 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
this);
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);
@@ -268,7 +286,11 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
RecipeSelectionDialog dialog(options, title, this);
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);
@@ -293,12 +315,13 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
if (box.clickedButton() == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
{
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
newConfig = std::make_shared<GameConfig>(
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<const GameOverEvent> /*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<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else
{

View File

@@ -12,6 +12,8 @@
#include <QVBoxLayout>
#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<SetSiteSplitterFiltersCommand> command =
std::make_shared<SetSiteSplitterFiltersCommand>();
command->id = m_singleBuildingId;
command->filterA = collectFilter(m_filterAList);
command->filterB = collectFilter(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else
{
m_sim->belts().setSplitterFilters(
m_splitterTile,
collectFilter(m_filterAList),
collectFilter(m_filterBList));
std::shared_ptr<SetSplitterFiltersCommand> command =
std::make_shared<SetSplitterFiltersCommand>();
command->tile = m_splitterTile;
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())
{
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));
}
}