make deconstruction its own system

DeconstructionSystem takes over the demolition queue: it runs the front entry's
timer and, when it elapses, removes the building, releases its tiles and credits
the partial refund. Simulation::tick calls it directly, in the position
tickDeconstruction held.

It needs no BeltSystem, unlike its construction counterpart: a belt, splitter or
tunnel end is unregistered the moment it is queued, not when the timer completes.
It does need the refund sink, so it takes the same addBuildingBlocks callback
BuildingSystem holds.

startFrontDeconstruction becomes a shared free function rather than moving:
BuildingSystem::deconstruct starts the timer when it queues the first entry, and
the system restarts it after each completion.

Stubbing the refund sink out in the test helper made two tests fail on the
refund not arriving — correctly. runTicks now threads the caller's stock through
instead.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
This commit is contained in:
2026-08-05 07:01:22 +02:00
parent 56b7248ac7
commit a86ba3428a
8 changed files with 154 additions and 102 deletions

View File

@@ -181,7 +181,7 @@ int BuildingSystem::deconstruct(FactoryState& state, BuildingId id, Tick current
state.deconstructionQueue.push_back(std::move(entry)); state.deconstructionQueue.push_back(std::move(entry));
if (wasEmpty) if (wasEmpty)
{ {
startFrontDeconstruction(state, currentTick); startFrontDeconstruction(state, m_config, currentTick);
} }
return 0; return 0;
} }
@@ -189,17 +189,6 @@ int BuildingSystem::deconstruct(FactoryState& state, BuildingId id, Tick current
return 0; return 0;
} }
void BuildingSystem::startFrontDeconstruction(FactoryState& state, Tick currentTick)
{
if (state.deconstructionQueue.empty()) { return; }
DeconstructionEntry& front = state.deconstructionQueue.front();
if (front.completesAt == 0)
{
front.completesAt =
currentTick + secondsToTicks(m_config.world.deconstructionTimeSeconds);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Set recipe // Set recipe
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -334,53 +323,6 @@ void BuildingSystem::setSiteSplitterFilters(FactoryState& state, BuildingId id,
// Tick hooks // Tick hooks
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void BuildingSystem::tickDeconstruction(FactoryState& state, Tick currentTick)
{
TRACE();
if (state.deconstructionQueue.empty())
{
return;
}
DeconstructionEntry& front = state.deconstructionQueue.front();
// Guard: if the front entry's timer was never started, start it now.
if (front.completesAt == 0)
{
startFrontDeconstruction(state, currentTick);
return;
}
if (currentTick < front.completesAt)
{
return;
}
// Remove the building from the world and credit its refund (REQ-BLD-DECONSTRUCT).
// Belt/tunnel/splitter tiles were already unregistered when the building was
// queued (see deconstruct), so only tile occupancy and the record remain.
for (std::vector<Building>::iterator it = state.buildings.begin();
it != state.buildings.end();
++it)
{
if (it->id != front.id) { continue; }
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
state.grid.release(it->bodyCells);
state.buildings.erase(it);
if (def)
{
m_addBuildingBlocks(def->cost * m_config.world.refundPercentage / 100);
}
break;
}
state.deconstructionQueue.pop_front();
// Start the next queued deconstruction, if any.
startFrontDeconstruction(state, currentTick);
}
void BuildingSystem::cancelDeconstruction(FactoryState& state, BuildingId id) void BuildingSystem::cancelDeconstruction(FactoryState& state, BuildingId id)
{ {
for (std::deque<DeconstructionEntry>::iterator it = state.deconstructionQueue.begin(); for (std::deque<DeconstructionEntry>::iterator it = state.deconstructionQueue.begin();

View File

@@ -17,6 +17,7 @@
#include "Building.h" #include "Building.h"
#include "FactoryState.h" #include "FactoryState.h"
#include "BuildingBuffers.h" #include "BuildingBuffers.h"
#include "DeconstructionSystem.h"
#include "PlacementRules.h" #include "PlacementRules.h"
#include "ProductionRules.h" #include "ProductionRules.h"
#include "BuildingType.h" #include "BuildingType.h"
@@ -107,7 +108,6 @@ public:
// Advances the deconstruction queue (REQ-BLD-DECON-QUEUE): one building at a // Advances the deconstruction queue (REQ-BLD-DECON-QUEUE): one building at a
// time, in parallel with tickConstruction. Removes the front building and // time, in parallel with tickConstruction. Removes the front building and
// credits its refund when its timer elapses. // credits its refund when its timer elapses.
void tickDeconstruction(FactoryState& state, Tick currentTick);
void tickBeltPull(FactoryState& state); void tickBeltPull(FactoryState& state);
void tickProduction(FactoryState& state, Tick currentTick); void tickProduction(FactoryState& state, Tick currentTick);
void tickShipyardProduction(FactoryState& state, Tick currentTick); void tickShipyardProduction(FactoryState& state, Tick currentTick);
@@ -183,7 +183,6 @@ public:
private: private:
// Starts the front deconstruction-queue entry's timer if not yet started // Starts the front deconstruction-queue entry's timer if not yet started
// (mirrors how tickConstruction starts a queued construction site). // (mirrors how tickConstruction starts a queued construction site).
void startFrontDeconstruction(FactoryState& state, Tick currentTick);
// Registers a belt/splitter/tunnel building's tile with the belt subsystem // Registers a belt/splitter/tunnel building's tile with the belt subsystem
// (on construction completion, or when un-queuing a deconstruction). No-op for // (on construction completion, or when un-queuing a deconstruction). No-op for

View File

@@ -14,6 +14,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/ConstructionSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/ConstructionSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructionSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.h
${CMAKE_CURRENT_SOURCE_DIR}/FactoryState.h ${CMAKE_CURRENT_SOURCE_DIR}/FactoryState.h
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.h ${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.h
@@ -45,6 +46,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ConstructionSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ConstructionSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructionSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.cpp ${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.cpp

View File

@@ -0,0 +1,67 @@
#include "DeconstructionSystem.h"
#include <vector>
#include "Building.h"
#include "tracing.h"
void startFrontDeconstruction(FactoryState& state, const GameConfig& config,
Tick currentTick)
{
if (state.deconstructionQueue.empty()) { return; }
DeconstructionEntry& front = state.deconstructionQueue.front();
if (front.completesAt == 0)
{
front.completesAt =
currentTick + secondsToTicks(config.world.deconstructionTimeSeconds);
}
}
void DeconstructionSystem::tick(FactoryState& state, Tick currentTick)
{
TRACE();
if (state.deconstructionQueue.empty())
{
return;
}
DeconstructionEntry& front = state.deconstructionQueue.front();
// Guard: if the front entry's timer was never started, start it now.
if (front.completesAt == 0)
{
startFrontDeconstruction(state, m_config, currentTick);
return;
}
if (currentTick < front.completesAt)
{
return;
}
// Remove the building from the world and credit its refund (REQ-BLD-DECONSTRUCT).
// Belt/tunnel/splitter tiles were already unregistered when the building was
// queued (see deconstruct), so only tile occupancy and the record remain.
for (std::vector<Building>::iterator it = state.buildings.begin();
it != state.buildings.end();
++it)
{
if (it->id != front.id) { continue; }
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
state.grid.release(it->bodyCells);
state.buildings.erase(it);
if (def)
{
m_addBuildingBlocks(def->cost * m_config.world.refundPercentage / 100);
}
break;
}
state.deconstructionQueue.pop_front();
// Start the next queued deconstruction, if any.
startFrontDeconstruction(state, m_config, currentTick);
}

View File

@@ -0,0 +1,36 @@
#pragma once
#include <functional>
#include "FactoryState.h"
#include "GameConfig.h"
#include "Tick.h"
// The queue timer for pending demolitions (REQ-BLD-DECON-QUEUE): one building at a
// time, in parallel with construction. When the front entry's timer elapses the
// building is removed from the world, its tiles are released, and its partial refund
// is credited.
//
// It needs no BeltSystem: a belt, splitter or tunnel end is unregistered the moment
// it is queued (see BuildingSystem::deconstruct), not when the timer completes.
//
// Holds the config and the refund sink; the world arrives per tick.
class DeconstructionSystem
{
public:
DeconstructionSystem(const GameConfig& config,
std::function<void(int)> addBuildingBlocks)
: m_config(config), m_addBuildingBlocks(std::move(addBuildingBlocks)) {}
void tick(FactoryState& state, Tick currentTick);
private:
const GameConfig& m_config;
std::function<void(int)> m_addBuildingBlocks;
};
// Starts the timer on the front entry of the deconstruction queue, if it has one and
// it has not started yet. Shared: BuildingSystem::deconstruct starts the timer when it
// queues the first entry, and DeconstructionSystem restarts it after each completion.
void startFrontDeconstruction(FactoryState& state, const GameConfig& config,
Tick currentTick);

View File

@@ -2,6 +2,7 @@
#include "FactoryQueries.h" #include "FactoryQueries.h"
#include "ConstructionSystem.h" #include "ConstructionSystem.h"
#include "DeconstructionSystem.h"
#include "PlacementRules.h" #include "PlacementRules.h"
#include <algorithm> #include <algorithm>
@@ -125,6 +126,8 @@ void Simulation::initializeSubsystems()
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); }, [this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng); m_rng);
m_constructionSystem = std::make_unique<ConstructionSystem>(m_config); m_constructionSystem = std::make_unique<ConstructionSystem>(m_config);
m_deconstructionSystem = std::make_unique<DeconstructionSystem>(
m_config, [this](int amount) { m_buildingBlocksStock += amount; });
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin); m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>(m_config); m_aiSystem = std::make_unique<AiSystem>(m_config);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>(); m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
@@ -246,7 +249,7 @@ void Simulation::tick()
// Construction + production pipeline // Construction + production pipeline
m_constructionSystem->tick(m_factoryState, m_beltSystem, m_currentTick); m_constructionSystem->tick(m_factoryState, m_beltSystem, m_currentTick);
m_buildingSystem->tickDeconstruction(m_factoryState, m_currentTick); // parallel to construction m_deconstructionSystem->tick(m_factoryState, m_currentTick); // parallel to construction
m_buildingSystem->tickBeltPull(m_factoryState); // step 3 m_buildingSystem->tickBeltPull(m_factoryState); // step 3
m_buildingSystem->tickProduction(m_factoryState, m_currentTick); // step 4 m_buildingSystem->tickProduction(m_factoryState, m_currentTick); // step 4
m_buildingSystem->tickShipyardProduction(m_factoryState, m_currentTick); // step 4b m_buildingSystem->tickShipyardProduction(m_factoryState, m_currentTick); // step 4b

View File

@@ -26,6 +26,7 @@
class AiSystem; class AiSystem;
class BuildingSystem; class BuildingSystem;
class ConstructionSystem; class ConstructionSystem;
class DeconstructionSystem;
struct Command; struct Command;
class Hasher; class Hasher;
class CombatSystem; class CombatSystem;
@@ -220,6 +221,7 @@ private:
BeltSystem m_beltSystem; BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem; std::unique_ptr<BuildingSystem> m_buildingSystem;
std::unique_ptr<ConstructionSystem> m_constructionSystem; std::unique_ptr<ConstructionSystem> m_constructionSystem;
std::unique_ptr<DeconstructionSystem> m_deconstructionSystem;
std::unique_ptr<ShipSystem> m_shipSystem; std::unique_ptr<ShipSystem> m_shipSystem;
std::unique_ptr<AiSystem> m_aiSystem; std::unique_ptr<AiSystem> m_aiSystem;
std::unique_ptr<MovementIntentSystem> m_movementIntentSystem; std::unique_ptr<MovementIntentSystem> m_movementIntentSystem;

View File

@@ -16,6 +16,7 @@
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "ConstructionSystem.h" #include "ConstructionSystem.h"
#include "DeconstructionSystem.h"
#include "FactoryState.h" #include "FactoryState.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
@@ -55,12 +56,12 @@ static Port westPort(QPoint tile)
// Run N full sim ticks: construction, belt-pull, production, belt-push, belt tick. // Run N full sim ticks: construction, belt-pull, production, belt-push, belt tick.
static void runTicks(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state_bs, static void runTicks(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state_bs,
BeltSystem& belts, int n, Tick& tick) BeltSystem& belts, int& stock, int n, Tick& tick)
{ {
for (int i = 0; i < n; ++i) for (int i = 0; i < n; ++i)
{ {
ConstructionSystem(cfg).tick(state_bs, belts, tick); ConstructionSystem(cfg).tick(state_bs, belts, tick);
bs.tickDeconstruction(state_bs, tick); DeconstructionSystem(cfg, [&stock](int n) { stock += n; }).tick(state_bs, tick);
bs.tickBeltPull(state_bs); bs.tickBeltPull(state_bs);
bs.tickProduction(state_bs, tick); bs.tickProduction(state_bs, tick);
bs.tickOutputBelts(state_bs); bs.tickOutputBelts(state_bs);
@@ -241,7 +242,7 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
// Complete construction (1 s). // Complete construction (1 s).
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East)); REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
REQUIRE(getAllBuildings(state_bs).size() == 1); REQUIRE(getAllBuildings(state_bs).size() == 1);
@@ -354,7 +355,7 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[
// Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300. // Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300.
// We need to process tick 300 itself, so run 301 ticks (ticks 0..300). // We need to process tick 300 itself, so run 301 ticks (ticks 0..300).
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(getAllSites(state_bs).empty()); REQUIRE(getAllSites(state_bs).empty());
REQUIRE(findBuilding(state_bs, id) != nullptr); REQUIRE(findBuilding(state_bs, id) != nullptr);
@@ -369,7 +370,7 @@ static void runUntilBuilt(PlacementFixture& f, BuildingId id, Tick& tick)
{ {
for (int i = 0; i < 100000 && findBuilding(f.state, id) == nullptr; ++i) for (int i = 0; i < 100000 && findBuilding(f.state, id) == nullptr; ++i)
{ {
runTicks(f.bs, f.cfg, f.state, f.belts, 1, tick); runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
} }
REQUIRE(findBuilding(f.state, id) != nullptr); REQUIRE(findBuilding(f.state, id) != nullptr);
} }
@@ -394,7 +395,7 @@ TEST_CASE("BuildingSystem: deconstructing a built building queues it; refund cre
// After the deconstruction time (0.1s = 3 ticks) it is removed and the partial // After the deconstruction time (0.1s = 3 ticks) it is removed and the partial
// refund (15 * 75 / 100 = 11) is credited exactly once. // refund (15 * 75 / 100 = 11) is credited exactly once.
runTicks(f.bs, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(0.1)) + 1, tick); runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(0.1)) + 1, tick);
REQUIRE(findBuilding(f.state, id) == nullptr); REQUIRE(findBuilding(f.state, id) == nullptr);
REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0))); REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0)));
REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100); REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100);
@@ -418,14 +419,14 @@ TEST_CASE("BuildingSystem: deconstruction queue removes one building at a time",
// After one deconstruction interval only the front building is gone; the // After one deconstruction interval only the front building is gone; the
// second is still queued and its refund not yet credited. // second is still queued and its refund not yet credited.
runTicks(f.bs, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(0.1)) + 1, tick); runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(0.1)) + 1, tick);
REQUIRE(findBuilding(f.state, a) == nullptr); REQUIRE(findBuilding(f.state, a) == nullptr);
REQUIRE(findBuilding(f.state, b) != nullptr); REQUIRE(findBuilding(f.state, b) != nullptr);
REQUIRE(isQueuedForDeconstruction(f.state, b)); REQUIRE(isQueuedForDeconstruction(f.state, b));
REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100); REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100);
// The second drains next. // The second drains next.
runTicks(f.bs, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(0.1)) + 2, tick); runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(0.1)) + 2, tick);
REQUIRE(findBuilding(f.state, b) == nullptr); REQUIRE(findBuilding(f.state, b) == nullptr);
REQUIRE(f.stock == 2 * (15 * f.cfg.world.refundPercentage / 100)); REQUIRE(f.stock == 2 * (15 * f.cfg.world.refundPercentage / 100));
} }
@@ -451,7 +452,7 @@ TEST_CASE("BuildingSystem: cancelling deconstruction resumes the building with n
REQUIRE(f.stock == 0); REQUIRE(f.stock == 0);
// It is never removed even after more than a deconstruction interval passes. // It is never removed even after more than a deconstruction interval passes.
runTicks(f.bs, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(0.1)) + 5, tick); runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(0.1)) + 5, tick);
REQUIRE(findBuilding(f.state, id) != nullptr); REQUIRE(findBuilding(f.state, id) != nullptr);
REQUIRE(f.stock == 0); REQUIRE(f.stock == 0);
} }
@@ -521,7 +522,7 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil
// Process through tick 300 to complete first miner's construction. // Process through tick 300 to complete first miner's construction.
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(getAllSites(state_bs).size() == 1); REQUIRE(getAllSites(state_bs).size() == 1);
REQUIRE(getAllSites(state_bs).front().id == id2); REQUIRE(getAllSites(state_bs).front().id == id2);
@@ -553,7 +554,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
Tick tick = 0; Tick tick = 0;
// Construction completes on tick 300; production cycle starts tick 300, // Construction completes on tick 300; production cycle starts tick 300,
// completes on tick 330. Process through tick 330: 331 ticks total. // completes on tick 330. Process through tick 330: 331 ticks total.
runTicks(bs, cfg, state_bs, belts, runTicks(bs, cfg, state_bs, belts, stock,
static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1, static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
tick); tick);
@@ -590,7 +591,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
// Cycle 2 starts at tick 331 (completesAt=361). // Cycle 2 starts at tick 331 (completesAt=361).
// Cycle 2 completes at tick 361: deposit item → buffer=2, cycle 3 stalls. // Cycle 2 completes at tick 361: deposit item → buffer=2, cycle 3 stalls.
// Need to process through tick 361: 362 ticks total. // Need to process through tick 361: 362 ticks total.
runTicks(bs, cfg, state_bs, belts, runTicks(bs, cfg, state_bs, belts, stock,
static_cast<int>(secondsToTicks(10.0)) static_cast<int>(secondsToTicks(10.0))
+ 2 * static_cast<int>(secondsToTicks(1.0)) + 2, + 2 * static_cast<int>(secondsToTicks(1.0)) + 2,
tick); tick);
@@ -632,10 +633,10 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites",
// The queue builds one at a time: miner (10s) completes at tick 300, then // The queue builds one at a time: miner (10s) completes at tick 300, then
// the smelter (15s) starts and completes at tick 300 + 450 = 750. // the smelter (15s) starts and completes at tick 300 + 450 = 750.
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(getProductionBuildingCount(state_bs) == 1); REQUIRE(getProductionBuildingCount(state_bs) == 1);
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(15.0)), tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(15.0)), tick);
REQUIRE(getProductionBuildingCount(state_bs) == 2); REQUIRE(getProductionBuildingCount(state_bs) == 2);
// Neither is producing yet: the miner has no recipe selected, and the // Neither is producing yet: the miner has no recipe selected, and the
@@ -643,7 +644,7 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites",
REQUIRE(getActiveProductionBuildingCount(state_bs) == 0); REQUIRE(getActiveProductionBuildingCount(state_bs) == 0);
bs.setRecipe(state_bs, minerId, "mine_iron_ore"); bs.setRecipe(state_bs, minerId, "mine_iron_ore");
runTicks(bs, cfg, state_bs, belts, 1, tick); runTicks(bs, cfg, state_bs, belts, stock, 1, tick);
REQUIRE(getActiveProductionBuildingCount(state_bs) == 1); REQUIRE(getActiveProductionBuildingCount(state_bs) == 1);
} }
@@ -671,12 +672,12 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle
REQUIRE(getActiveProductionBuildingCount(state_bs) == 0); REQUIRE(getActiveProductionBuildingCount(state_bs) == 0);
// Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330). // Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330).
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(getActiveProductionBuildingCount(state_bs) == 1); REQUIRE(getActiveProductionBuildingCount(state_bs) == 1);
// Run cycles 1 and 2 to completion (1s each); cycle 3 stalls once the // Run cycles 1 and 2 to completion (1s each); cycle 3 stalls once the
// output buffer (capacity 2) is full (REQ-MAT-OUTPUT-BUFFER). // output buffer (capacity 2) is full (REQ-MAT-OUTPUT-BUFFER).
runTicks(bs, cfg, state_bs, belts, 2 * static_cast<int>(secondsToTicks(1.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, 2 * static_cast<int>(secondsToTicks(1.0)) + 1, tick);
const Building* b = findBuilding(state_bs, id); const Building* b = findBuilding(state_bs, id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
@@ -714,7 +715,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
// Complete construction (15s → tick 450+1 = 451 ticks). // Complete construction (15s → tick 450+1 = 451 ticks).
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Place west-flowing belt at (2,0): belt flows West, delivers to smelter. // Place west-flowing belt at (2,0): belt flows West, delivers to smelter.
belts.placeBelt(QPoint(2, 0), Rotation::West); belts.placeBelt(QPoint(2, 0), Rotation::West);
@@ -751,7 +752,7 @@ TEST_CASE("BuildingSystem: accepted input travels inward before entering the buf
const BuildingId sid = bs.place(state_bs, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); const BuildingId sid = bs.place(state_bs, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
belts.placeBelt(QPoint(2, 0), Rotation::West); belts.placeBelt(QPoint(2, 0), Rotation::West);
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
@@ -794,7 +795,7 @@ TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at th
const BuildingId id = bs.place(state_bs, BuildingType::ReprocessingPlant, const BuildingId id = bs.place(state_bs, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value(); QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(25.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Feed scrap via an input belt without ever running production (only pull), so // Feed scrap via an input belt without ever running production (only pull), so
// the buffer fills and stays full. Try to over-fill it well past the cap. // the buffer fills and stays full. Try to over-fill it well past the cap.
@@ -837,7 +838,7 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
const BuildingId sid = bs.place(state_bs, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); const BuildingId sid = bs.place(state_bs, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Feed 2 iron_ore (the test-config iron_ingot recipe needs 2) via a // Feed 2 iron_ore (the test-config iron_ingot recipe needs 2) via a
// west-flowing belt at input port (2,0). // west-flowing belt at input port (2,0).
@@ -850,7 +851,7 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
} }
// iron_ingot recipe cycle is 2s; run to completion. // iron_ingot recipe cycle is 2s; run to completion.
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(2.0)) + 2, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(2.0)) + 2, tick);
const Building* b = findBuilding(state_bs, sid); const Building* b = findBuilding(state_bs, sid);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
@@ -884,7 +885,7 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
const BuildingId sid = bs.place(state_bs, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); const BuildingId sid = bs.place(state_bs, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Feed 1 iron_ore (iron_ingot needs 2 — incomplete) then 2 copper_ore // Feed 1 iron_ore (iron_ingot needs 2 — incomplete) then 2 copper_ore
// (copper_ingot needs 2 — satisfiable) via the west-flowing input belt. // (copper_ingot needs 2 — satisfiable) via the west-flowing input belt.
@@ -898,7 +899,7 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
} }
// copper_ingot cycle is 2.5s; run to completion. // copper_ingot cycle is 2.5s; run to completion.
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(2.5)) + 2, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(2.5)) + 2, tick);
const Building* b = findBuilding(state_bs, sid); const Building* b = findBuilding(state_bs, sid);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
@@ -944,13 +945,13 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
Tick tick = 0; Tick tick = 0;
// Construction (10s) + 1 production cycle (1s) + 1 extra tick. // Construction (10s) + 1 production cycle (1s) + 1 extra tick.
runTicks(bs, cfg, state_bs, belts, runTicks(bs, cfg, state_bs, belts, stock,
static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1, static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
tick); tick);
// Item should have been pushed onto the belt this tick or a subsequent one. // Item should have been pushed onto the belt this tick or a subsequent one.
// Run one more tick to ensure tickBeltPush fires after the deposit tick. // Run one more tick to ensure tickBeltPush fires after the deposit tick.
runTicks(bs, cfg, state_bs, belts, 1, tick); runTicks(bs, cfg, state_bs, belts, stock, 1, tick);
const std::optional<Item> item = belts.tryTakeItem(eastPort(QPoint(1, 1))); const std::optional<Item> item = belts.tryTakeItem(eastPort(QPoint(1, 1)));
REQUIRE(item.has_value()); REQUIRE(item.has_value());
@@ -986,7 +987,7 @@ TEST_CASE("BuildingSystem: output port couples directly into an adjacent input p
Tick tick = 0; Tick tick = 0;
// Smelter build (15s) + margin for coupling and a smelt cycle. // Smelter build (15s) + margin for coupling and a smelt cycle.
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(30.0)), tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(30.0)), tick);
const Building* smelter = findBuilding(state_bs, smelterId); const Building* smelter = findBuilding(state_bs, smelterId);
REQUIRE(smelter != nullptr); REQUIRE(smelter != nullptr);
@@ -1026,7 +1027,7 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu
Tick tick = 0; Tick tick = 0;
// Both miners build sequentially (10s each), then the producer runs and jams. // Both miners build sequentially (10s each), then the producer runs and jams.
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(25.0)), tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(25.0)), tick);
const Building* miner = findBuilding(state_bs, minerId); const Building* miner = findBuilding(state_bs, minerId);
const Building* sink = findBuilding(state_bs, sinkId); const Building* sink = findBuilding(state_bs, sinkId);
@@ -1062,7 +1063,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
Tick tick = 0; Tick tick = 0;
// Run until first item is in output buffer. // Run until first item is in output buffer.
runTicks(bs, cfg, state_bs, belts, runTicks(bs, cfg, state_bs, belts, stock,
static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1, static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
tick); tick);
@@ -1108,7 +1109,7 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
// Complete construction (25s). // Complete construction (25s).
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(25.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
const Building* b = findBuilding(state_bs, id); const Building* b = findBuilding(state_bs, id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
@@ -1141,7 +1142,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
// Complete construction (25s). // Complete construction (25s).
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(25.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Feed 5 scrap into the building via a belt at an input port. // Feed 5 scrap into the building via a belt at an input port.
// Reprocessing plant body (East rotation) = 3×3 at (0,0). // Reprocessing plant body (East rotation) = 3×3 at (0,0).
@@ -1163,7 +1164,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
} }
// Run production cycle (3s = 90 ticks + 1 for the completion tick). // Run production cycle (3s = 90 ticks + 1 for the completion tick).
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(3.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(3.0)) + 1, tick);
const Building* b = findBuilding(state_bs, id); const Building* b = findBuilding(state_bs, id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
@@ -1240,7 +1241,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
const BuildingId id = bs.place(state_bs, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); const BuildingId id = bs.place(state_bs, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(getAllSites(state_bs).empty()); REQUIRE(getAllSites(state_bs).empty());
const std::optional<BuildingId> result = const std::optional<BuildingId> result =
@@ -1422,7 +1423,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct
const BuildingId id = bs.place(state_bs, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); const BuildingId id = bs.place(state_bs, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(findBuilding(state_bs, id) != nullptr); REQUIRE(findBuilding(state_bs, id) != nullptr);
const Building& before = *findBuilding(state_bs, id); const Building& before = *findBuilding(state_bs, id);
@@ -1454,7 +1455,7 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
const BuildingId id = bs.place(state_bs, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); const BuildingId id = bs.place(state_bs, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0; Tick tick = 0;
runTicks(bs, cfg, state_bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick); runTicks(bs, cfg, state_bs, belts, stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
bs.rotateInPlace(state_bs, id, Rotation::North); bs.rotateInPlace(state_bs, id, Rotation::North);
@@ -1474,7 +1475,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the output filters of a split
Tick tick = 0; Tick tick = 0;
while (getAllBuildings(f.state).empty() && tick < 100000) while (getAllBuildings(f.state).empty() && tick < 100000)
{ {
runTicks(f.bs, f.cfg, f.state, f.belts, 1, tick); runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
} }
REQUIRE(getAllBuildings(f.state).size() == 1); REQUIRE(getAllBuildings(f.state).size() == 1);
@@ -1519,7 +1520,7 @@ TEST_CASE("BuildingSystem: splitter filters configured on a construction site ca
Tick tick = 0; Tick tick = 0;
while (getAllBuildings(f.state).empty() && tick < 100000) while (getAllBuildings(f.state).empty() && tick < 100000)
{ {
runTicks(f.bs, f.cfg, f.state, f.belts, 1, tick); runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
} }
REQUIRE(getAllBuildings(f.state).size() == 1); REQUIRE(getAllBuildings(f.state).size() == 1);
REQUIRE(getAllBuildings(f.state)[0].type == BuildingType::Splitter); REQUIRE(getAllBuildings(f.state)[0].type == BuildingType::Splitter);
@@ -1674,11 +1675,11 @@ namespace
// Advances the sim until the given site becomes an operational building, or a // Advances the sim until the given site becomes an operational building, or a
// safety cap is reached. // safety cap is reached.
void buildToCompletion(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state, void buildToCompletion(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state,
BeltSystem& belts, BuildingId id, Tick& tick) BeltSystem& belts, int& stock, BuildingId id, Tick& tick)
{ {
for (int i = 0; i < 20000 && findBuilding(state, id) == nullptr; ++i) for (int i = 0; i < 20000 && findBuilding(state, id) == nullptr; ++i)
{ {
runTicks(bs, cfg, state, belts, 1, tick); runTicks(bs, cfg, state, belts, stock, 1, tick);
} }
} }
} }
@@ -1713,7 +1714,7 @@ TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built bu
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
const std::vector<Port> sitePorts = getInputPorts(f.state, f.cfg, id); const std::vector<Port> sitePorts = getInputPorts(f.state, f.cfg, id);
buildToCompletion(f.bs, f.cfg, f.state, f.belts, id, tick); buildToCompletion(f.bs, f.cfg, f.state, f.belts, f.stock, id, tick);
REQUIRE(findBuilding(f.state, id) != nullptr); REQUIRE(findBuilding(f.state, id) != nullptr);
const std::vector<Port> builtPorts = getInputPorts(f.state, f.cfg, id); const std::vector<Port> builtPorts = getInputPorts(f.state, f.cfg, id);