Replay: deterministic record & playback #4
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
23
src/lib/eventsystem/event/CommandRequestedEvent.h
Normal file
23
src/lib/eventsystem/event/CommandRequestedEvent.h
Normal 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;
|
||||
};
|
||||
@@ -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
141
src/lib/sim/Command.h
Normal 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;
|
||||
};
|
||||
34
src/lib/sim/CommandManager.cpp
Normal file
34
src/lib/sim/CommandManager.cpp
Normal 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();
|
||||
}
|
||||
36
src/lib/sim/CommandManager.h
Normal file
36
src/lib/sim/CommandManager.h
Normal 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;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -22,4 +22,5 @@ add_files(
|
||||
ThreatCostCalculatorTest.cpp
|
||||
RecipeSchematicTest.cpp
|
||||
DeterminismTest.cpp
|
||||
CommandTest.cpp
|
||||
)
|
||||
|
||||
105
src/test/CommandTest.cpp
Normal file
105
src/test/CommandTest.cpp
Normal 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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user