Files
dota_factory/src/test/BuildingTest.cpp

2132 lines
88 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "catch.hpp"
#include "PlacementRules.h"
#include "FactoryQueries.h"
#include "ProductionRules.h"
#include <map>
#include <random>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include <QPoint>
#include <QRectF>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingBuffers.h"
#include "BuildingSystem.h"
#include "ConstructionSystem.h"
#include "DeconstructionSystem.h"
#include "FactoryState.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "Item.h"
#include "ItemType.h"
#include "Port.h"
#include "Rotation.h"
#include "Tick.h"
#include "TestConfig.h"
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
static Item makeItem(const std::string& id)
{
Item item;
item.type.id = id;
return item;
}
static Port eastPort(QPoint tile)
{
Port p;
p.tile = tile;
p.direction = Rotation::East;
return p;
}
static Port westPort(QPoint tile)
{
Port p;
p.tile = tile;
p.direction = Rotation::West;
return p;
}
// Run N full sim ticks: construction, belt-pull, production, belt-push, belt tick.
static void runTicks(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state_bs,
BeltSystem& belts, int& stock, int n, Tick& tick)
{
for (int i = 0; i < n; ++i)
{
ConstructionSystem(cfg).tick(state_bs, belts, tick);
DeconstructionSystem(cfg, [&stock](int n) { stock += n; }).tick(state_bs, tick);
bs.tickBeltPull(state_bs);
bs.tickProduction(state_bs, tick);
bs.tickOutputBelts(state_bs);
belts.tick();
++tick;
}
}
// All items currently on a building's output side: buffered plus still-emerging on
// the virtual output belts (REQ-MAT-OUTPUT-EMERGE). A produced item leaves the
// output buffer the moment it starts emerging, so tests count both.
static std::vector<Item> outputSideItems(const Building& b)
{
std::vector<Item> items = b.outputBuffer.items;
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{
for (const BeltItemSlot& slot : lane)
{
items.push_back(slot.item);
}
}
return items;
}
// Owns a BuildingSystem and its dependencies for placement-bounds tests.
// Belt speed for the tests that need an item to cross a tile in a single tick, so
// it is available to peek or take on the next one.
constexpr double kFastBeltSpeed_tps = static_cast<double>(kTickRateHz);
struct PlacementFixture
{
GameConfig cfg = loadTestConfig();
FactoryState state = makeFactoryState(cfg);
BeltSystem belts;
int stock = 0;
std::mt19937 rng{0};
BuildingId nextBuildingId = 1;
BuildingSystem bs;
// Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test
// needs items to arrive immediately.
explicit PlacementFixture(std::optional<double> beltSpeed_tps = std::nullopt)
: belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
, bs(cfg, belts,
[this]() { return nextBuildingId++; },
[this](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng)
{
}
};
// ---------------------------------------------------------------------------
// Placement
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
// Miner mask ["AA","A>"] with East rotation → body at (0,0),(1,0),(0,1).
REQUIRE(isTileOccupied(f.state, QPoint(0, 0)));
REQUIRE(isTileOccupied(f.state, QPoint(1, 0)));
REQUIRE(isTileOccupied(f.state, QPoint(0, 1)));
// (1,1) is the output-port tile, NOT a body cell.
REQUIRE_FALSE(isTileOccupied(f.state, QPoint(1, 1)));
}
TEST_CASE("buildingsInBox covers a body cell the box only reaches into", "[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0),
Rotation::East, 0).value();
// Body at (0,0),(1,0),(0,1). The box is unsnapped and lies wholly within cell
// (1,0) without filling it, which is enough: a building is covered when the box
// overlaps any of its body cells (REQ-UI-MULTI-SELECT, Coverage).
const std::vector<BuildingId> grazed =
buildingsInBox(f.state, QRectF(1.6, 0.4, 0.2, 0.2));
REQUIRE(grazed.size() == 1);
REQUIRE(grazed.front() == id);
// (1,1) is the output-port tile, not a body cell, so a box inside it covers
// nothing even though it is surrounded by the miner's cells.
REQUIRE(buildingsInBox(f.state, QRectF(1.2, 1.2, 0.5, 0.5)).empty());
}
// -- World-bounds rejection (REQ-BLD-PLACE-VALID) ---------------------------
TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[building]")
{
PlacementFixture f;
// Miner mask ["AA","A>"] East → body at (0,0),(1,0),(0,1); at y=-1 the top
// row sits above the world.
const std::optional<BuildingId> id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, -1), Rotation::East, 0);
REQUIRE_FALSE(id.has_value());
REQUIRE(getAllSites(f.state).empty());
REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0)));
}
TEST_CASE("BuildingSystem: place rejects a building below the world (y >= height)", "[building]")
{
PlacementFixture f;
const int heightTiles = f.cfg.world.heightTiles;
// Anchored on the last in-bounds row, the miner's lower body row reaches
// y == heightTiles, which is outside the world.
const std::optional<BuildingId> id = f.bs.place(f.state, BuildingType::Miner,
QPoint(0, heightTiles - 1), Rotation::East, 0);
REQUIRE_FALSE(id.has_value());
REQUIRE(getAllSites(f.state).empty());
}
TEST_CASE("BuildingSystem: place rejects a building left of the asteroid edge", "[building]")
{
PlacementFixture f;
const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles;
const std::optional<BuildingId> id = f.bs.place(f.state, BuildingType::Miner,
QPoint(leftEdgeX - 1, 0), Rotation::East, 0);
REQUIRE_FALSE(id.has_value());
REQUIRE(getAllSites(f.state).empty());
}
TEST_CASE("BuildingSystem: place accepts a building flush against the world's left edge",
"[building]")
{
PlacementFixture f;
const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles;
// Miner body min relative x is 0, so its leftmost cell sits exactly on the edge.
const BuildingId id = f.bs.place(f.state, BuildingType::Miner,
QPoint(leftEdgeX, 0), Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(isTileOccupied(f.state, QPoint(leftEdgeX, 0)));
}
TEST_CASE("BuildingSystem: place imposes no right-side bound (space extends rightward)",
"[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Miner,
QPoint(1000, 0), Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
}
TEST_CASE("BuildingSystem: isPlacementValid enforces terrain and world bounds", "[building]")
{
PlacementFixture f;
const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles;
// Miner is all-asteroid (A): valid only fully on the asteroid (x < 0).
REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(-3, 0), Rotation::East));
REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(0, 0), Rotation::East)); // A cells in space
REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(0, -1), Rotation::East)); // above world
REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(leftEdgeX, 0), Rotation::East));
REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(leftEdgeX - 1, 0), Rotation::East)); // past left edge
// Shipyard mask ["AAAS>","AAAS "] straddles the boundary: A cells on the
// asteroid, the S (dock) cell in space. At anchor (-3,0) the A cells land at
// x=-3..-1 and the dock at x=0.
REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(-3, 0), Rotation::East));
REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(0, 0), Rotation::East)); // A cells in space
REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(-4, 0), Rotation::East)); // dock on asteroid
}
TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after construction",
"[building]")
{
PlacementFixture f;
f.bs.place(f.state, BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
// Belt is queued — not yet in BeltSystem.
REQUIRE_FALSE(f.belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
// Complete construction (1 s).
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(f.belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
REQUIRE(getAllBuildings(f.state).size() == 1);
REQUIRE(getAllBuildings(f.state)[0].type == BuildingType::Belt);
REQUIRE(getAllBuildings(f.state)[0].anchor == QPoint(5, 5));
}
TEST_CASE("BuildingSystem: placed building enters construction queue", "[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
REQUIRE(getAllSites(f.state).size() == 1);
REQUIRE(getAllBuildings(f.state).empty());
REQUIRE(findSite(f.state, id) != nullptr);
}
TEST_CASE("BuildingSystem: deconstructing a construction site removes it instantly with full refund",
"[building]")
{
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
// Still queued for construction (not yet built): instant removal, full cost
// refunded immediately, never entering the deconstruction queue (REQ-BLD-DECONSTRUCT).
const int refund = f.bs.deconstruct(f.state, id, 0);
REQUIRE(refund == 15); // Miner cost = 15
REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0)));
REQUIRE(getAllSites(f.state).empty());
}
// ---------------------------------------------------------------------------
// Construction queue
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: first queued building starts construction immediately",
"[building]")
{
PlacementFixture f;
f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
REQUIRE(getAllSites(f.state).front().completesAt > 0);
}
TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[building]")
{
PlacementFixture f;
f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
f.bs.place(f.state, BuildingType::Miner, QPoint(5, 5), Rotation::East, 0);
REQUIRE(getAllSites(f.state).size() == 2);
REQUIRE(getAllSites(f.state)[0].completesAt > 0);
REQUIRE(getAllSites(f.state)[1].completesAt == 0);
}
TEST_CASE("BuildingSystem: construction completes after configured duration", "[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
// Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300.
// We need to process tick 300 itself, so run 301 ticks (ticks 0..300).
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(getAllSites(f.state).empty());
REQUIRE(findBuilding(f.state, id) != nullptr);
}
// ---------------------------------------------------------------------------
// Deconstruction queue (REQ-BLD-DECON-QUEUE)
// ---------------------------------------------------------------------------
// Runs ticks until the building with the given id is operational, or fails.
static void runUntilBuilt(PlacementFixture& f, BuildingId id, Tick& tick)
{
for (int i = 0; i < 100000 && findBuilding(f.state, id) == nullptr; ++i)
{
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
}
REQUIRE(findBuilding(f.state, id) != nullptr);
}
TEST_CASE("BuildingSystem: deconstructing a built building queues it; refund credited on completion",
"[building][decon]")
{
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runUntilBuilt(f, id, tick);
// Deconstructing a built building returns nothing immediately and queues it,
// stopping it operating while its tiles stay occupied (REQ-BLD-DECON-QUEUE).
const int refund = f.bs.deconstruct(f.state, id, tick);
REQUIRE(refund == 0);
REQUIRE(isQueuedForDeconstruction(f.state, id));
REQUIRE(isTileOccupied(f.state, QPoint(0, 0)));
REQUIRE(f.stock == 0);
// After the deconstruction time (0.1s = 3 ticks) it is removed and the partial
// refund (15 * 75 / 100 = 11) is credited exactly once.
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_FALSE(isTileOccupied(f.state, QPoint(0, 0)));
REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100);
}
TEST_CASE("BuildingSystem: deconstruction queue removes one building at a time", "[building][decon]")
{
PlacementFixture f;
const BuildingId a = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
const BuildingId b = f.bs.place(f.state, BuildingType::Miner, QPoint(5, 5), Rotation::East, 0).value();
Tick tick = 0;
runUntilBuilt(f, a, tick);
runUntilBuilt(f, b, tick);
// Queue both in one tick; 'a' is at the front of the deconstruction queue.
f.bs.deconstruct(f.state, a, tick);
f.bs.deconstruct(f.state, b, tick);
REQUIRE(isQueuedForDeconstruction(f.state, a));
REQUIRE(isQueuedForDeconstruction(f.state, b));
// After one deconstruction interval only the front building is gone; the
// second is still queued and its refund not yet credited.
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, b) != nullptr);
REQUIRE(isQueuedForDeconstruction(f.state, b));
REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100);
// The second drains next.
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(f.stock == 2 * (15 * f.cfg.world.refundPercentage / 100));
}
TEST_CASE("BuildingSystem: cancelling deconstruction resumes the building with no refund",
"[building][decon]")
{
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runUntilBuilt(f, id, tick);
f.bs.deconstruct(f.state, id, tick);
REQUIRE(isQueuedForDeconstruction(f.state, id));
// Un-queue before it drains: it operates again, no refund, tiles still occupied.
f.bs.cancelDeconstruction(f.state, id);
REQUIRE_FALSE(isQueuedForDeconstruction(f.state, id));
REQUIRE(findBuilding(f.state, id) != nullptr);
REQUIRE(isTileOccupied(f.state, QPoint(0, 0)));
REQUIRE(f.stock == 0);
// It is never removed even after more than a deconstruction interval passes.
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(f.stock == 0);
}
TEST_CASE("BuildingSystem: queued belt stops transporting; cancel restores it", "[building][decon]")
{
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runUntilBuilt(f, id, tick);
REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East));
// Queuing a belt unregisters its tile from the belt subsystem, so it no longer
// accepts or transports items, though the tile stays occupied (REQ-BLD-DECON-QUEUE).
f.bs.deconstruct(f.state, id, tick);
REQUIRE_FALSE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East));
REQUIRE(isTileOccupied(f.state, QPoint(0, 0)));
// Un-queuing re-registers the belt tile so it transports again.
f.bs.cancelDeconstruction(f.state, id);
REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East));
}
TEST_CASE("BuildingSystem: splitter filters survive a queue/un-queue round-trip", "[building][decon]")
{
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Splitter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runUntilBuilt(f, id, tick);
f.belts.setSplitterFilters(QPoint(0, 0), {ItemType{"iron_ore"}}, {});
// Queue: the belt subsystem tile (and its filters) are unregistered, but the
// filters are captured so an un-queue can restore them.
f.bs.deconstruct(f.state, id, tick);
REQUIRE_FALSE(f.belts.getSplitterInfo(QPoint(0, 0)).has_value());
f.bs.cancelDeconstruction(f.state, id);
const std::optional<BeltSystem::SplitterInfo> info = f.belts.getSplitterInfo(QPoint(0, 0));
REQUIRE(info.has_value());
REQUIRE(info->filterA.size() == 1);
REQUIRE(info->filterA[0].id == "iron_ore");
REQUIRE(info->filterB.empty());
}
TEST_CASE("BuildingSystem: second building starts after first completes", "[building]")
{
PlacementFixture f;
f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id2 = f.bs.place(f.state, BuildingType::Miner, QPoint(5, 5), Rotation::East, 0).value();
// Process through tick 300 to complete first miner's construction.
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(getAllSites(f.state).size() == 1);
REQUIRE(getAllSites(f.state).front().id == id2);
REQUIRE(getAllSites(f.state).front().completesAt > 0);
}
// ---------------------------------------------------------------------------
// Miner production cycle
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
f.bs.setRecipe(f.state, id, "mine_iron_ore");
Tick tick = 0;
// Construction completes on tick 300; production cycle starts tick 300,
// completes on tick 330. Process through tick 330: 331 ticks total.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
// No belt at the output port, so the produced item emerges and stays on the
// building's virtual output belt (REQ-MAT-OUTPUT-EMERGE).
const std::vector<Item> out = outputSideItems(*b);
REQUIRE(out.size() == 1);
REQUIRE(out.front().type.id == "iron_ore");
}
TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
f.bs.setRecipe(f.state, id, "mine_iron_ore");
Tick tick = 0;
// Construction (10s) then cycle 1 starts at tick 300 (completesAt=330).
// Cycle 1 completes at tick 330 and cycle 2 starts in that same tick
// (completesAt=360). Cycle 2 completes at tick 360: deposit item -> 2 items held,
// which fills the buffer (capacity 2), so cycle 3 cannot start.
// Need to process through tick 360: 361 ticks total.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(10.0))
+ 2 * static_cast<int>(secondsToTicks(1.0)) + 1,
tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
// Both produced items are held on the output side (buffer + emerging lane),
// which is what the capacity rule counts (REQ-MAT-OUTPUT-EMERGE).
REQUIRE(b->getOutputItemCount() == 2);
REQUIRE_FALSE(b->production.has_value());
}
TEST_CASE("BuildingSystem: the next cycle starts on the tick the last one completed",
"[building]")
{
// A cycle takes exactly its recipe duration, so a building whose output keeps
// draining produces at the configured rate (REQ-MAT-CYCLE). An idle tick between
// cycles would cost a one-second recipe about 3% of its throughput.
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
f.bs.setRecipe(f.state, id, "mine_iron_ore");
const Tick cycleTicks = secondsToTicks(1.0); // mine_iron_ore duration
Tick tick = 0;
// Construction completes at tick 300 and cycle 1 starts in that same tick.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(10.0)) + 1, tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
REQUIRE(b->production.has_value());
const Tick firstCompletesAt = b->production->completesAt;
// Process up to and including that completion tick: the next cycle is already
// running, due exactly one duration later rather than one duration plus a tick.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(cycleTicks), tick);
b = findBuilding(f.state, id);
REQUIRE(b->getOutputItemCount() == 1);
REQUIRE(b->production.has_value());
REQUIRE(b->production->completesAt == firstCompletesAt + cycleTicks);
// Nothing hauls the ore away here, so the buffer (capacity 2) would stall the third
// cycle. Drain it and confirm the cadence holds across the next boundary too.
f.bs.forEachBuilding(f.state, [](Building& building) {
building.outputBuffer.items.clear();
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
});
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(cycleTicks), tick);
b = findBuilding(f.state, id);
REQUIRE(b->production.has_value());
REQUIRE(b->production->completesAt == firstCompletesAt + 2 * cycleTicks);
}
// ---------------------------------------------------------------------------
// REQ-UI-DEBUG-OVERLAY production counts
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites", "[building]")
{
PlacementFixture f;
const BuildingId minerId = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
const BuildingId smelterId = f.bs.place(f.state, BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0).value();
(void)smelterId;
Tick tick = 0;
// Both still under construction.
REQUIRE(getProductionBuildingCount(f.state) == 0);
// 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.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(getProductionBuildingCount(f.state) == 1);
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)), tick);
REQUIRE(getProductionBuildingCount(f.state) == 2);
// Neither is producing yet: the miner has no recipe selected, and the
// smelter (auto-recipe, REQ-BLD-SMELTER) has no input feeding it.
REQUIRE(getActiveProductionBuildingCount(f.state) == 0);
f.bs.setRecipe(f.state, minerId, "mine_iron_ore");
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
REQUIRE(getActiveProductionBuildingCount(f.state) == 1);
}
TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state",
"[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
f.bs.setRecipe(f.state, id, "mine_iron_ore");
Tick tick = 0;
// Not yet operational while under construction.
REQUIRE(getActiveProductionBuildingCount(f.state) == 0);
// Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330).
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(getActiveProductionBuildingCount(f.state) == 1);
// Run cycles 1 and 2 to completion (1s each); cycle 3 stalls once the
// output buffer (capacity 2) is full (REQ-MAT-OUTPUT-BUFFER).
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 2 * static_cast<int>(secondsToTicks(1.0)) + 1, tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
REQUIRE(b->getOutputItemCount() == 2);
REQUIRE_FALSE(b->production.has_value());
REQUIRE(getActiveProductionBuildingCount(f.state) == 0);
}
// ---------------------------------------------------------------------------
// Belt pull → input buffer
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing belt",
"[building]")
{
// Fast belt so items are immediately available for peek/take.
PlacementFixture f(kFastBeltSpeed_tps);
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
// Output port (2,1) East. Input port example: (2,0) West.
const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
// A smelter starts with no recipe and picks one from the first material offered to
// it (REQ-BLD-AUTO-RECIPE), which is what lets it accept the ore below.
// Complete construction (15s → tick 450+1 = 451 ticks).
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
REQUIRE(findBuilding(f.state, sid)->recipeId.empty());
// Place west-flowing belt at (2,0): belt flows West, delivers to smelter.
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
const Building* b = findBuilding(f.state, sid);
REQUIRE(b != nullptr);
// The ore selected the recipe that consumes it, and its buffers were sized for that
// recipe alone -- copper ore is not one of its inputs any more.
REQUIRE(b->recipeId == "iron_ingot");
REQUIRE(b->inputBuffer.caps.count(ItemType{"iron_ore"}) == 1);
REQUIRE(b->inputBuffer.caps.count(ItemType{"copper_ore"}) == 0);
REQUIRE(b->outputBuffer.caps.count(ItemType{"iron_ingot"}) == 1);
REQUIRE(b->outputBuffer.caps.count(ItemType{"copper_ingot"}) == 0);
// The item was accepted; it may still be travelling inward on the input belt,
// so count buffered + in-transit (REQ-MAT-INPUT-INTAKE).
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1);
}
// An accepted input item travels inward on its input belt before it becomes usable
// f.stock: it is reserved (counts against the cap) on entry and only enters the
// buffer on reaching the tile centre (REQ-MAT-INPUT-INTAKE).
TEST_CASE("BuildingSystem: accepted input travels inward before entering the buffer",
"[building]")
{
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state); // accepts the item onto the input belt at progress 0.0
const Building* b = findBuilding(f.state, sid);
REQUIRE(b != nullptr);
// Reserved but not yet consumable: nothing in the buffer, but it counts against
// the cap via pendingInputCount.
const std::map<ItemType, int>::const_iterator it0 =
b->inputBuffer.counts.find(ItemType{"iron_ore"});
REQUIRE((it0 == b->inputBuffer.counts.end() || it0->second == 0));
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
// One more pull tick advances the input belt to the centre; the item arrives.
f.bs.tickBeltPull(f.state);
REQUIRE(b->inputBuffer.counts.at(ItemType{"iron_ore"}) == 1);
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
}
// The acceptance test counts in-transit items, so buffered + reserved never exceeds
// the per-material cap; excess items stay on the belt (REQ-MAT-INPUT-INTAKE).
TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at the cap",
"[building]")
{
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// 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.
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 20; ++i)
{
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
f.belts.tick();
f.bs.tickBeltPull(f.state);
}
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
const int cap = b->inputBuffer.caps.at(ItemType{"scrap"});
REQUIRE(cap > 0);
// buffered + in-transit is capped; the plant never over-pulls.
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == cap);
// Excess scrap is left stuck on the feeding belt rather than silently dropped.
REQUIRE(f.belts.peekItem(eastPort(QPoint(-1, 0))).has_value());
}
// A smelter auto-selects the matching recipe for whatever it is fed, with no
// player recipe selection (REQ-BLD-SMELTER).
TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
"[building]")
{
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Feed 2 iron_ore (the test-config iron_ingot recipe needs 2) via a
// west-flowing belt at input port (2,0).
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
for (int i = 0; i < 2; ++i)
{
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
}
// iron_ingot recipe cycle is 2s; run to completion.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(2.0)) + 2, tick);
const Building* b = findBuilding(f.state, sid);
REQUIRE(b != nullptr);
bool hasIronIngot = false;
for (const Item& item : outputSideItems(*b))
{
if (item.type.id == "iron_ingot") { hasIronIngot = true; }
}
REQUIRE(hasIronIngot);
}
// A belt carrying mixed ore is the realistic case for REQ-BLD-AUTO-RECIPE: the first ore
// to arrive settles the recipe, and everything else on that belt is refused rather than
// smelted alongside it.
TEST_CASE("BuildingSystem: mixed ore on one belt leaves the smelter on the first ore's recipe",
"[building]")
{
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Feed 1 iron_ore, then 2 copper_ore, via the west-flowing input belt.
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" };
for (const char* id : fed)
{
f.belts.tryPutItem(QPoint(2, 0), makeItem(id));
f.belts.tick();
f.bs.tickBeltPull(f.state);
}
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(2.5)) + 2, tick);
const Building* b = findBuilding(f.state, sid);
REQUIRE(b != nullptr);
// The iron ore came first, so the smelter smelts iron and nothing else. The copper
// was never taken in, so no copper ingot was made.
REQUIRE(b->recipeId == "iron_ingot");
for (const Item& item : outputSideItems(*b))
{
REQUIRE(item.type.id != "copper_ingot");
}
REQUIRE(b->pendingInputCount(ItemType{"copper_ore"}) == 0);
// The lone iron ore still waits for a second unit: the recipe needs two.
const std::map<ItemType, int>::const_iterator ironIt =
b->inputBuffer.counts.find(ItemType{"iron_ore"});
REQUIRE(ironIt != b->inputBuffer.counts.end());
REQUIRE(ironIt->second == 1);
REQUIRE_FALSE(b->production.has_value());
}
// ---------------------------------------------------------------------------
// Belt push → belt tile
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[building]")
{
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
f.bs.setRecipe(f.state, id, "mine_iron_ore");
// Belt at the miner's output port tile (1,1) flowing East.
f.belts.placeBelt(QPoint(1, 1), Rotation::East);
Tick tick = 0;
// Construction (10s) + 1 production cycle (1s) + 1 extra tick.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
tick);
// 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.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
const std::optional<Item> item = f.belts.tryTakeItem(eastPort(QPoint(1, 1)));
REQUIRE(item.has_value());
REQUIRE(item->type.id == "iron_ore");
}
// Two directly adjacent buildings whose ports meet transfer items with no belt in
// between: a miner's iron_ore output feeds straight into a smelter, which smelts it
// (REQ-MAT-DIRECT-COUPLE).
TEST_CASE("BuildingSystem: output port couples directly into an adjacent input port",
"[building]")
{
PlacementFixture f;
// Miner at (0,0): body (0,0),(1,0),(0,1); output port tile (1,1) flowing East.
const BuildingId minerId = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
f.bs.setRecipe(f.state, minerId, "mine_iron_ore");
// Smelter anchored at (1,1): body (1,1),(2,1),(1,2),(2,2). Its body cell (1,1) is
// the miner's output-port tile, and its west input edge there faces East, so the
// two ports meet — no belt placed anywhere.
const BuildingId smelterId = f.bs.place(f.state, BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0).value();
Tick tick = 0;
// Smelter build (15s) + margin for coupling and a smelt cycle.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(30.0)), tick);
const Building* smelter = findBuilding(f.state, smelterId);
REQUIRE(smelter != nullptr);
// iron_ore reached the smelter over the direct coupling and was smelted.
bool hasIronIngot = false;
for (const Item& produced : outputSideItems(*smelter))
{
if (produced.type.id == "iron_ingot") { hasIronIngot = true; }
}
REQUIRE(hasIronIngot);
}
// A producer coupled to a building that cannot accept its item delivers nothing; the
// item stays stuck at the producer's output port (REQ-MAT-DIRECT-COUPLE acceptance).
TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stuck",
"[building]")
{
PlacementFixture f;
// Producing miner at (0,0), output port (1,1) East.
const BuildingId minerId = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
f.bs.setRecipe(f.state, minerId, "mine_iron_ore");
// A second, idle miner anchored at (1,1) occupies the output-port tile but takes
// no inputs, so it cannot accept the iron_ore.
const BuildingId sinkId = f.bs.place(f.state, BuildingType::Miner, QPoint(1, 1), Rotation::East, 0).value();
Tick tick = 0;
// Both miners build sequentially (10s each), then the producer runs and jams.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)), tick);
const Building* miner = findBuilding(f.state, minerId);
const Building* sink = findBuilding(f.state, sinkId);
REQUIRE(miner != nullptr);
REQUIRE(sink != nullptr);
// Nothing was delivered, and the producer's output side has backed up to its cap.
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
REQUIRE(miner->getOutputItemCount(ItemType{"iron_ore"})
== miner->outputBuffer.caps.at(ItemType{"iron_ore"}));
}
// ---------------------------------------------------------------------------
// setRecipe clears buffers
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production",
"[building]")
{
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
f.bs.setRecipe(f.state, id, "mine_iron_ore");
Tick tick = 0;
// Run until first item is in output buffer.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
tick);
{
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
REQUIRE(b->getOutputItemCount() > 0);
}
f.bs.setRecipe(f.state, id, "mine_copper_ore");
const Building* b = findBuilding(f.state, id);
// Clearing the output buffer on a recipe change also discards emerging items
// (REQ-MAT-OUTPUT-EMERGE).
REQUIRE(b->getOutputItemCount() == 0);
REQUIRE_FALSE(b->production.has_value());
}
// ---------------------------------------------------------------------------
// Reprocessing plant -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER)
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
"[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
// Complete construction (25s).
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// A plant holds no buffers until it has a recipe (REQ-BLD-AUTO-RECIPE); selecting
// one sizes them, exactly as the first scrap offered to it would.
REQUIRE(findBuilding(f.state, id)->outputBuffer.caps.empty());
f.bs.setRecipe(f.state, id, "reprocessing_cycle");
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
// reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%),
// 1 advanced_alloy (10%). One roll yields one of them, so each buffer holds twice
// that outcome's own amount (REQ-MAT-OUTPUT-BUFFER).
REQUIRE(b->outputBuffer.caps.size() == 3);
REQUIRE(b->outputBuffer.caps.at(ItemType{"iron_ingot"}) == 4);
REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) == 2);
REQUIRE(b->outputBuffer.caps.at(ItemType{"advanced_alloy"}) == 2);
}
TEST_CASE("BuildingSystem: one full output buffer stops the plant even when the others have room",
"[building]")
{
// The gate that replaced the old one-item cap: a cycle may only start when *every*
// outcome would fit, because the roll is committed once it starts (REQ-MAT-CYCLE).
// Were the plant to roll first and skip a result that does not fit, a player could
// stall one output belt to filter the distribution towards the other items.
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Feed a full cycle's scrap (5) so only the output side can hold it back.
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 5; ++i)
{
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
f.belts.tick();
f.bs.tickBeltPull(f.state);
}
// Fill the iron_ingot buffer to its cap and leave the other two empty.
f.bs.forEachBuilding(f.state, [](Building& building) {
if (building.type != BuildingType::ReprocessingPlant) { return; }
const int cap = building.outputBuffer.caps.at(ItemType{"iron_ingot"});
for (int i = 0; i < cap; ++i)
{
building.outputBuffer.items.push_back(makeItem("iron_ingot"));
}
});
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 5, tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
// circuit_board and advanced_alloy have room, but iron_ingot does not, so no cycle
// starts at all and the scrap is still waiting.
REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) > 0);
REQUIRE(outputBufferHasRoom(*b, ItemType{"circuit_board"}, 1));
REQUIRE_FALSE(outputBufferHasRoom(*b, ItemType{"iron_ingot"}, 1));
REQUIRE_FALSE(b->production.has_value());
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Blocked);
}
TEST_CASE("BuildingSystem: reprocessing plant runs a second cycle while holding the first output",
"[building]")
{
// Its buffers hold twice each outcome's amount (REQ-MAT-OUTPUT-BUFFER), so a held
// result no longer stops the next cycle. The old one-item cap made this impossible:
// whatever the first roll was, the plant stalled until that item left the building.
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Two cycles' worth of scrap (5 each), which is exactly the input cap.
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 10; ++i)
{
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
f.belts.tick();
f.bs.tickBeltPull(f.state);
}
REQUIRE(findBuilding(f.state, id)->pendingInputCount(ItemType{"scrap"}) == 10);
// No belt carries the output away, so the first cycle's result is still held.
// reprocessing_cycle runs 3s; run through the completion tick.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(3.0)) + 1, tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
REQUIRE(b->getOutputItemCount() > 0);
// Whichever outcome was rolled, every outcome still fits, so the second cycle is
// already running rather than the plant sitting blocked.
REQUIRE(b->production.has_value());
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Producing);
}
// ---------------------------------------------------------------------------
// Automatic recipe selection (REQ-BLD-AUTO-RECIPE)
// ---------------------------------------------------------------------------
// Places a smelter and runs it to completion, leaving it with no recipe.
static BuildingId buildSmelter(PlacementFixture& f, QPoint anchor, Tick& tick)
{
const BuildingId id =
f.bs.place(f.state, BuildingType::Smelter, anchor, Rotation::East, 0).value();
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(15.0)) + 1, tick);
return id;
}
TEST_CASE("BuildingSystem: an unset auto-recipe building is unconfigured", "[building]")
{
// It holds no recipe until one is offered to it, so it reads grey like any other
// unconfigured building (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
PlacementFixture f;
Tick tick = 0;
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
REQUIRE(b->recipeId.empty());
REQUIRE(b->inputBuffer.caps.empty());
REQUIRE(b->outputBuffer.caps.empty());
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Unconfigured);
}
TEST_CASE("BuildingSystem: a set recipe is never replaced by a later material",
"[building]")
{
// Once set the recipe is the player's to change: a material belonging to another of
// its recipes is simply not an accepted input (REQ-BLD-AUTO-RECIPE).
PlacementFixture f(kFastBeltSpeed_tps);
Tick tick = 0;
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
// Iron ore first, which selects the iron recipe.
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot");
// Copper ore next: refused, and the recipe stands.
f.belts.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
const Building* b = findBuilding(f.state, id);
REQUIRE(b->recipeId == "iron_ingot");
REQUIRE(b->pendingInputCount(ItemType{"copper_ore"}) == 0);
// The copper is still sitting on the belt, refused rather than swallowed.
REQUIRE(f.belts.peekItem(westPort(QPoint(2, 0))).has_value());
}
TEST_CASE("BuildingSystem: a manually selected recipe is not overridden", "[building]")
{
// The player's selection is a recipe like any other, so auto-selection stays out of
// the way and the smelter refuses ore it does not smelt (REQ-BLD-AUTO-RECIPE).
PlacementFixture f(kFastBeltSpeed_tps);
Tick tick = 0;
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
f.bs.setRecipe(f.state, id, "copper_ingot");
REQUIRE(findBuilding(f.state, id)->recipeId == "copper_ingot");
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
const Building* b = findBuilding(f.state, id);
REQUIRE(b->recipeId == "copper_ingot");
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 0);
}
TEST_CASE("BuildingSystem: selecting a different recipe frees a stuck auto-recipe building",
"[building]")
{
// A smelter left holding part of a cycle nothing feeds any more is freed by
// selecting another recipe, which clears the buffers -- that is why no separate
// clear action exists (REQ-BLD-AUTO-RECIPE, REQ-MAT-INPUT-BUFFER).
PlacementFixture f(kFastBeltSpeed_tps);
Tick tick = 0;
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
// One iron ore, where the recipe needs two: it can never run.
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 30, tick);
const Building* stuck = findBuilding(f.state, id);
REQUIRE(stuck->recipeId == "iron_ingot");
REQUIRE(stuck->pendingInputCount(ItemType{"iron_ore"}) == 1);
REQUIRE_FALSE(stuck->production.has_value());
f.bs.setRecipe(f.state, id, "copper_ingot");
const Building* freed = findBuilding(f.state, id);
REQUIRE(freed->recipeId == "copper_ingot");
REQUIRE(freed->pendingInputCount(ItemType{"iron_ore"}) == 0);
REQUIRE(freed->inputBuffer.caps.count(ItemType{"copper_ore"}) == 1);
}
TEST_CASE("BuildingSystem: selecting (Auto) returns the building to automatic selection",
"[building]")
{
// The dialog's clearing option unsets the recipe rather than leaving the building
// idle for good: the next material offered selects one again (REQ-BLD-AUTO-RECIPE).
PlacementFixture f(kFastBeltSpeed_tps);
Tick tick = 0;
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
f.bs.setRecipe(f.state, id, "copper_ingot");
f.bs.setRecipe(f.state, id, std::string()); // the "(Auto)" option
REQUIRE(findBuilding(f.state, id)->recipeId.empty());
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot");
}
TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls",
"[building]")
{
// Seed chosen so first roll produces 2-item output (iron_ingot), filling buffer.
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
// The plant selects its recipe from the first scrap offered to it
// (REQ-BLD-AUTO-RECIPE), which is what the belt feeding below does.
// Complete construction (25s).
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Feed 5 scrap into the building via a belt at an input port.
// Reprocessing plant body (East rotation) = 3×3 at (0,0).
// Valid input port: tile (-1,0) flowing East.
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 5; ++i)
{
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
f.belts.tick();
f.bs.tickBeltPull(f.state);
}
// Verify all five scrap were accepted; some may still be travelling inward on
// the input belt (REQ-MAT-INPUT-INTAKE), so count buffered + in-transit.
{
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
}
// Run production cycle (3s = 90 ticks + 1 for the completion tick).
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(3.0)) + 1, tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
// Cycle should have completed and output deposited.
REQUIRE_FALSE(b->outputBuffer.items.empty());
// No new production: inputs were consumed and not replenished.
REQUIRE_FALSE(b->production.has_value());
}
// ---------------------------------------------------------------------------
// findRotateInPlaceTarget
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is empty",
"[building][rotate-in-place]")
{
PlacementFixture f;
REQUIRE_FALSE(
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Belt, QPoint(0, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a queued belt (same type, different rotation)",
"[building][rotate-in-place]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
const std::optional<BuildingId> result =
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Belt, QPoint(0, 0), Rotation::North);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a completed operational belt",
"[building][rotate-in-place]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(getAllSites(f.state).empty());
const std::optional<BuildingId> result =
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Belt, QPoint(0, 0), Rotation::South);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building type differs",
"[building][rotate-in-place]")
{
PlacementFixture f;
f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
// Querying with Splitter at the same tile — type mismatch → nullopt.
REQUIRE_FALSE(
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in place",
"[building][rotate-in-place]")
{
PlacementFixture f;
// Even with a coincident same-type tunnel under the ghost, rotate-in-place is
// never offered for tunnels (REQ-BLD-ROTATE-IN-PLACE exception).
f.bs.place(f.state, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0);
f.bs.place(f.state, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0);
REQUIRE_FALSE(
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::North).has_value());
REQUIRE_FALSE(
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::North).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap",
"[building][rotate-in-place]")
{
PlacementFixture f;
// Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1).
f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
// Ghost anchored at (1,0) would cover (1,0),(2,0),(1,1),(2,1):
// only (1,0) and (1,1) are occupied — not a full coincidence.
REQUIRE_FALSE(
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Smelter, QPoint(1, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-tile building with rotated ghost",
"[building][rotate-in-place]")
{
PlacementFixture f;
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
// same four body tiles, so findRotateInPlaceTarget must still return the id.
const BuildingId id = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
const std::optional<BuildingId> result =
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::Smelter, QPoint(0, 0), Rotation::North);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
// ---------------------------------------------------------------------------
// resolveBlueprintGhost
// ---------------------------------------------------------------------------
// What a blueprint ghost does where it meets an existing building
// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). Blueprint placement never
// rotates anything, so a coinciding building must either take the blueprint's settings
// or already match it exactly.
namespace
{
// A single-building blueprint, whose cursor sits on the ghost's own anchor unless a test
// says otherwise. That gesture hit-tests the cursor for its transfer target.
BlueprintGhostResolved resolveOne(const PlacementFixture& f, BuildingType type,
QPoint anchor, Rotation rotation)
{
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, anchor);
}
BlueprintGhostResolved resolveOneHovering(const PlacementFixture& f, BuildingType type,
QPoint anchor, Rotation rotation, QPoint cursorTile)
{
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, cursorTile);
}
// One ghost of a constellation: no cursor hit-test, judged purely on where it sits.
BlueprintGhostResolved resolveInConstellation(const PlacementFixture& f, BuildingType type,
QPoint anchor, Rotation rotation)
{
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, std::nullopt);
}
} // namespace
TEST_CASE("isConfigurableBuildingType: only types with player-facing settings",
"[blueprint]")
{
// The gate on whether a single-building blueprint transfers anything at all.
CHECK(isConfigurableBuildingType(BuildingType::Miner));
CHECK(isConfigurableBuildingType(BuildingType::Assembler));
CHECK(isConfigurableBuildingType(BuildingType::Shipyard));
CHECK(isConfigurableBuildingType(BuildingType::Splitter));
// Smelter and Reprocessing Plant carry a recipe like any other, even though they can
// also select it themselves (REQ-BLD-AUTO-RECIPE).
CHECK(isConfigurableBuildingType(BuildingType::Smelter));
CHECK(isConfigurableBuildingType(BuildingType::ReprocessingPlant));
// The rest have no settings whatsoever.
CHECK_FALSE(isConfigurableBuildingType(BuildingType::SalvageBay));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Belt));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelEntry));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelExit));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Hq));
}
TEST_CASE("resolveBlueprintGhost: free valid cells place a new building", "[blueprint]")
{
PlacementFixture f;
// Anchored on the asteroid (x < 0): a miner is all-asteroid cells. BuildingSystem's
// place() skips the terrain rules, but resolveBlueprintGhost applies them.
const BlueprintGhostResolved resolved =
resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
CHECK(resolved.action == BlueprintGhostAction::PlaceNew);
CHECK_FALSE(resolved.targetId.has_value());
}
TEST_CASE("resolveBlueprintGhost: terrain-invalid positions are invalid", "[blueprint]")
{
PlacementFixture f;
// A miner is all-asteroid (A) cells, so it cannot sit out in space (x >= 0).
CHECK(resolveOne(f, BuildingType::Miner, QPoint(5, 0), Rotation::East).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: overlapping a different building type is invalid",
"[blueprint]")
{
PlacementFixture f;
f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0);
CHECK(resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::East).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: a partial overlap of the same type is invalid",
"[blueprint]")
{
PlacementFixture f;
// Smelter at (-3,0) covers (-3,0),(-2,0),(-3,1),(-2,1); a ghost at (-2,0) covers only
// two of those, so it coincides with nothing and is an ordinary occupied overlap.
// Both footprints stay on the asteroid, so terrain is not what fails here.
f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);
// Judged on where it sits, with no cursor to hit-test: the overlap is what decides.
CHECK(resolveInConstellation(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
== BlueprintGhostAction::Invalid);
// With the cursor on the existing smelter the single-building gesture answers first
// and hands it the settings, since a smelter carries a recipe (REQ-BLD-AUTO-RECIPE,
// REQ-UI-BLUEPRINT-TRANSFER) -- the same as for a miner.
CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
== BlueprintGhostAction::Transfer);
}
TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its settings",
"[blueprint]")
{
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
const BlueprintGhostResolved resolved =
resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
REQUIRE(resolved.targetId.has_value());
CHECK(*resolved.targetId == id);
}
TEST_CASE("resolveBlueprintGhost: a transfer ignores the target's rotation", "[blueprint]")
{
// A transfer never rotates anything, so which way the target faces cannot matter
// (REQ-UI-BLUEPRINT-TRANSFER). The ghost snaps to the target's facing rather than
// keeping the blueprint's.
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0).value();
const BlueprintGhostResolved resolved =
resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North);
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
REQUIRE(resolved.targetId.has_value());
CHECK(*resolved.targetId == id);
CHECK(resolved.ghostRotation == Rotation::East);
}
TEST_CASE("resolveBlueprintGhost: a single-building blueprint transfers from anywhere on the target",
"[blueprint]")
{
// The point of hit-testing the cursor instead of comparing footprints. Coincidence
// needs the ghost's anchor to land on the target's own anchor, so with a 2x2 body
// three of its four tiles missed and read as an ordinary overlap. Hovering any body
// tile now targets it, and the ghost snaps onto the building
// (REQ-UI-BLUEPRINT-TRANSFER).
PlacementFixture f;
// Assembler body covers (-3,0),(-2,0),(-3,1),(-2,1); its anchor is (-3,0).
const BuildingId id =
f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value();
const QPoint offAnchorTile(-2, 1);
const BlueprintGhostResolved resolved =
resolveOneHovering(f, BuildingType::Assembler, offAnchorTile, Rotation::East,
offAnchorTile);
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
CHECK(*resolved.targetId == id);
CHECK(resolved.ghostAnchor == QPoint(-3, 0));
// The same misaligned ghost inside a constellation still just overlaps invalidly:
// a layout is placed where the blueprint puts it, and nothing snaps.
CHECK(resolveInConstellation(f, BuildingType::Assembler, offAnchorTile, Rotation::East).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: hovering a different building type does not transfer",
"[blueprint]")
{
PlacementFixture f;
f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);
// A miner blueprint over a smelter: the cursor hit-test only matches its own type.
CHECK(resolveOne(f, BuildingType::Miner, QPoint(-3, 0), Rotation::East).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: a construction site is a transfer target too",
"[blueprint]")
{
PlacementFixture f;
// Not ticked to completion, so it is still queued (REQ-BLD-SITE-CONFIG).
const BuildingId id =
f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value();
REQUIRE_FALSE(getAllSites(f.state).empty());
const BlueprintGhostResolved resolved =
resolveOne(f, BuildingType::Assembler, QPoint(-3, 0), Rotation::East);
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
CHECK(*resolved.targetId == id);
}
TEST_CASE("resolveBlueprintGhost: a single building with no settings overlaps instead",
"[blueprint]")
{
// A belt carries nothing to transfer, so the same footprint is a compatible overlap
// when the facings match -- and invalid when they do not, since nothing here may
// re-orient it (REQ-UI-BLUEPRINT-OVERLAP).
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0).value();
const BlueprintGhostResolved matching =
resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::East);
REQUIRE(matching.action == BlueprintGhostAction::CompatibleOverlap);
CHECK(*matching.targetId == id);
CHECK(resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::North).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: a constellation transfers onto a matching building",
"[blueprint]")
{
// Blueprint size does not gate the transfer itself: a configurable building already
// standing where the blueprint wants it, facing the same way, takes its settings
// whatever else the blueprint holds (REQ-UI-BLUEPRINT-TRANSFER).
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
const BlueprintGhostResolved resolved =
resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
CHECK(*resolved.targetId == id);
}
TEST_CASE("resolveBlueprintGhost: a constellation still requires a matching rotation",
"[blueprint]")
{
// Only the single-building gesture is forgiving about facing. A constellation's
// ghosts stay where the blueprint puts them, and one that cannot be re-oriented to
// match blocks the whole placement (REQ-UI-BLUEPRINT-OVERLAP).
PlacementFixture f;
f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0);
CHECK(resolveInConstellation(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: a constellation mixes transfers and plain overlaps",
"[blueprint]")
{
// One drop can reconfigure some of the buildings already there while leaving others
// alone: the split is by whether the type has settings at all, not by blueprint size
// (REQ-UI-BLUEPRINT-OVERLAP).
PlacementFixture f;
const BuildingId minerId =
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
const BuildingId smelterId =
f.bs.place(f.state, BuildingType::Smelter, QPoint(-5, 0), Rotation::East, 0).value();
const BlueprintGhostResolved miner =
resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(miner.action == BlueprintGhostAction::Transfer);
CHECK(*miner.targetId == minerId);
// A smelter carries a recipe too now (REQ-BLD-AUTO-RECIPE), so a blueprint of one
// has something to hand over just as the miner does.
const BlueprintGhostResolved smelter =
resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East);
REQUIRE(smelter.action == BlueprintGhostAction::Transfer);
CHECK(*smelter.targetId == smelterId);
}
TEST_CASE("resolveBlueprintGhost: an identical tunnel is a compatible overlap", "[blueprint]")
{
// findRotateInPlaceTarget refuses tunnels because re-orienting one is unsupported.
// Nothing is re-oriented here, so that reason does not apply and the tunnel the
// blueprint wants -- already there, same facing -- is simply left alone.
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0).value();
f.bs.place(f.state, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0);
REQUIRE_FALSE(
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East)
.has_value());
const BlueprintGhostResolved resolved =
resolveInConstellation(f, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East);
REQUIRE(resolved.action == BlueprintGhostAction::CompatibleOverlap);
CHECK(*resolved.targetId == id);
}
// ---------------------------------------------------------------------------
// rotateInPlace
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a construction site",
"[building][rotate-in-place]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
REQUIRE(findSite(f.state, id)->rotation == Rotation::East);
f.bs.rotateInPlace(f.state, id, Rotation::North);
REQUIRE(findSite(f.state, id)->rotation == Rotation::North);
}
TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of a queued site",
"[building][rotate-in-place]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
const Tick completesAt = findSite(f.state, id)->completesAt;
REQUIRE(completesAt > 0);
f.bs.rotateInPlace(f.state, id, Rotation::South);
REQUIRE(findSite(f.state, id)->completesAt == completesAt);
}
TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direction on an operational building",
"[building][rotate-in-place]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(findBuilding(f.state, id) != nullptr);
const Building& before = *findBuilding(f.state, id);
REQUIRE(before.outputPorts[0].direction == Rotation::East);
f.bs.rotateInPlace(f.state, id, Rotation::North);
const Building& after = *findBuilding(f.state, id);
REQUIRE(after.rotation == Rotation::North);
REQUIRE(after.outputPorts[0].direction == Rotation::North);
}
TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSystem so it still accepts items",
"[building][rotate-in-place]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
f.bs.rotateInPlace(f.state, id, Rotation::North);
// Belt tile must still be registered after rotation — items can be placed on it.
REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
}
TEST_CASE("BuildingSystem: rotateInPlace preserves the output filters of a splitter "
"(REQ-BLD-SPLITTER)", "[building][rotate-in-place]")
{
PlacementFixture f;
const QPoint tile(5, 5);
const BuildingId id = f.bs.place(f.state, BuildingType::Splitter, tile, Rotation::East, 0).value();
// Run until construction completes, so the splitter is registered with BeltSystem.
Tick tick = 0;
while (getAllBuildings(f.state).empty() && tick < 100000)
{
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
}
REQUIRE(getAllBuildings(f.state).size() == 1);
const std::vector<ItemType> filterA{ ItemType{"iron_ore"} };
const std::vector<ItemType> filterB{ ItemType{"copper_ore"} };
f.belts.setSplitterFilters(tile, filterA, filterB);
f.bs.rotateInPlace(f.state, id, Rotation::North);
// The tile is re-registered with BeltSystem carrying the filters it had before
// the rotation — rotating must not reset a configured splitter to "accept all".
const std::optional<BeltSystem::SplitterInfo> info = f.belts.getSplitterInfo(tile);
REQUIRE(info.has_value());
REQUIRE(info->filterA == filterA);
REQUIRE(info->filterB == filterB);
}
TEST_CASE("BuildingSystem: splitter filters configured on a construction site carry over "
"to the built splitter (REQ-BLD-SITE-CONFIG)", "[building]")
{
PlacementFixture f;
const QPoint tile(5, 5);
const BuildingId id = f.bs.place(f.state, BuildingType::Splitter, tile, Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(findSite(f.state, id) != nullptr);
// Configure an output filter on the still-queued splitter site.
const std::vector<ItemType> filterA{ ItemType{"iron_ore"} };
const std::vector<ItemType> filterB{};
f.bs.setSiteSplitterFilters(f.state, id, filterA, filterB);
// The site reports its two output directions and the stored filters before
// it is built; it is not yet registered with BeltSystem.
const std::optional<BeltSystem::SplitterInfo> siteInfo = getSiteSplitterInfo(f.state, f.cfg, id);
REQUIRE(siteInfo.has_value());
REQUIRE(siteInfo->filterA == filterA);
REQUIRE(siteInfo->filterB.empty());
REQUIRE_FALSE(f.belts.getSplitterInfo(tile).has_value());
// Run until construction completes.
Tick tick = 0;
while (getAllBuildings(f.state).empty() && tick < 100000)
{
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 1, tick);
}
REQUIRE(getAllBuildings(f.state).size() == 1);
REQUIRE(getAllBuildings(f.state)[0].type == BuildingType::Splitter);
// The built splitter is registered with BeltSystem carrying the filters.
const std::optional<BeltSystem::SplitterInfo> builtInfo = f.belts.getSplitterInfo(tile);
REQUIRE(builtInfo.has_value());
REQUIRE(builtInfo->filterA == filterA);
REQUIRE(builtInfo->filterB.empty());
}
// ---------------------------------------------------------------------------
// Production status classifier (REQ-UI-STATUS-LIGHT)
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[building]")
{
PlacementFixture f;
// Pick representative config ids so the test survives content edits.
std::string minerRecipeId;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
if (r.building == BuildingType::Miner) { minerRecipeId = r.id; break; }
}
REQUIRE_FALSE(minerRecipeId.empty());
const RecipeDef* assemblerRecipe = nullptr;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
if (r.building == BuildingType::Assembler && !r.inputs.empty())
{
assemblerRecipe = &r;
break;
}
}
REQUIRE(assemblerRecipe != nullptr);
std::string shipId;
for (const ShipDef& s : f.cfg.ships.ships)
{
if (!s.schematic.materials.empty()) { shipId = s.id; break; }
}
REQUIRE_FALSE(shipId.empty());
const auto statusOf = [&f](const Building& b) { return getProductionStatus(f.cfg, b); };
SECTION("non-production buildings show no status light")
{
Building belt; belt.type = BuildingType::Belt;
Building hq; hq.type = BuildingType::Hq;
REQUIRE_FALSE(statusOf(belt).has_value());
REQUIRE_FALSE(statusOf(hq).has_value());
}
SECTION("Miner: unconfigured, producing, output-blocked")
{
Building miner; miner.type = BuildingType::Miner;
REQUIRE(statusOf(miner) == ProductionStatus::Unconfigured); // no recipe -> grey
miner.recipeId = minerRecipeId;
miner.production = Production{};
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // active cycle -> green
// A miner has no inputs, so its only idle reason is an output buffer with no
// room for the next cycle's output.
miner.production = std::nullopt;
miner.outputBuffer.caps[ItemType{"iron_ore"}] = 2;
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
// One item handed off: the next cycle fits again, so the idle tick between two
// cycles reads as producing rather than blinking yellow (REQ-UI-STATUS-LIGHT).
miner.outputBuffer.items.pop_back();
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // -> green
// An emerging item has not left the building, so it fills the freed room and
// blocks the cycle again (REQ-MAT-OUTPUT-EMERGE).
miner.emergingItems.push_back({ BeltItemSlot{ makeItem("iron_ore"), 0.5 } });
REQUIRE(miner.getOutputItemCount(ItemType{"iron_ore"}) == 2);
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
// Another item's backlog is measured against its own buffer, so it changes
// nothing here (REQ-MAT-OUTPUT-BUFFER).
miner.outputBuffer.caps[ItemType{"copper_ore"}] = 2;
miner.outputBuffer.items.push_back(makeItem("copper_ore"));
REQUIRE(statusOf(miner) == ProductionStatus::Blocked);
miner.outputBuffer.items.clear();
REQUIRE(statusOf(miner) == ProductionStatus::Producing);
}
SECTION("Assembler: starved, the transient between cycles, then blocked")
{
Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = assemblerRecipe->id;
// Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER).
initBuffers(assembler, *assemblerRecipe);
const std::string outputItemId = assemblerRecipe->outputs.front().item;
int cycleOutput = 0;
for (const RecipeOutput& out : assemblerRecipe->outputs)
{
cycleOutput += out.amount;
}
REQUIRE(cycleOutput > 0);
// Idle with inputs missing -> red.
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
// Inputs present and the output fits: nothing blocks a cycle, so the building
// is merely between cycles -> green, not yellow (REQ-UI-STATUS-LIGHT).
for (const RecipeIngredient& ing : assemblerRecipe->inputs)
{
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
// That item's own buffer filled to within less than one cycle's output of its
// capacity: no cycle can start -> yellow.
for (int i = 0; i < cycleOutput + 1; ++i)
{
assembler.outputBuffer.items.push_back(makeItem(outputItemId));
}
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
// Inputs missing AND output blocked -> red wins over yellow.
assembler.inputBuffer.counts.clear();
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
}
SECTION("A multi-item cycle blocks before the output buffer is full")
{
// Free space smaller than one cycle's output stops the cycle even though the
// buffer still has room, so yellow is not the same as "full" (REQ-MAT-CYCLE).
const RecipeDef* multiOutputRecipe = nullptr;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; }
int total = 0;
for (const RecipeOutput& out : r.outputs) { total += out.amount; }
if (total >= 2) { multiOutputRecipe = &r; break; }
}
REQUIRE(multiOutputRecipe != nullptr);
int cycleOutput = 0;
for (const RecipeOutput& out : multiOutputRecipe->outputs)
{
cycleOutput += out.amount;
}
const std::string outputItemId = multiOutputRecipe->outputs.front().item;
Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = multiOutputRecipe->id;
initBuffers(assembler, *multiOutputRecipe);
for (const RecipeIngredient& ing : multiOutputRecipe->inputs)
{
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
// One item short of a full cycle's worth of free space.
for (int i = 0; i < cycleOutput + 1; ++i)
{
assembler.outputBuffer.items.push_back(makeItem(outputItemId));
}
REQUIRE(assembler.getOutputItemCount(ItemType{outputItemId})
< assembler.outputBuffer.caps.at(ItemType{outputItemId}));
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
// Exactly one cycle's worth of free space: the cycle fits again.
assembler.outputBuffer.items.pop_back();
REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
}
SECTION("Reprocessing Plant: blocked once any possible roll has no room")
{
// The plant rolls one of its outputs per cycle (REQ-BLD-REPROCESSING) and the
// roll is committed at cycle start, so every outcome has to fit before it may
// begin: one full buffer blocks it whatever room the others have (REQ-MAT-CYCLE).
const RecipeDef* reprocessingRecipe = nullptr;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
if (r.building == BuildingType::ReprocessingPlant && !r.inputs.empty())
{
reprocessingRecipe = &r;
break;
}
}
REQUIRE(reprocessingRecipe != nullptr);
REQUIRE(reprocessingRecipe->outputs.size() >= 2);
Building plant; plant.type = BuildingType::ReprocessingPlant;
plant.recipeId = reprocessingRecipe->id;
initBuffers(plant, *reprocessingRecipe);
for (const RecipeIngredient& ing : reprocessingRecipe->inputs)
{
plant.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
// Every buffer empty: whatever the roll turns out to be, it fits -> green.
REQUIRE(statusOf(plant) == ProductionStatus::Producing);
// Fill one outcome's buffer and leave the rest untouched -> yellow, even though
// the other outcomes still have room.
const std::string firstItemId = reprocessingRecipe->outputs.front().item;
const std::string lastItemId = reprocessingRecipe->outputs.back().item;
for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
{
plant.outputBuffer.items.push_back(makeItem(firstItemId));
}
REQUIRE(outputBufferHasRoom(plant, ItemType{lastItemId}, 1));
REQUIRE_FALSE(outputBufferHasRoom(plant, ItemType{firstItemId}, 1));
REQUIRE(statusOf(plant) == ProductionStatus::Blocked);
// Without the scrap it is starved regardless of the buffers.
plant.inputBuffer.counts.clear();
REQUIRE(statusOf(plant) == ProductionStatus::Starved);
}
SECTION("Smelter: grey until it has a recipe, then judged like any other building")
{
// It holds no recipe until one is offered to it, so grey now applies to it too
// (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
Building smelter; smelter.type = BuildingType::Smelter;
REQUIRE(statusOf(smelter) == ProductionStatus::Unconfigured);
const RecipeDef* smelterRecipe = nullptr;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
if (r.building == BuildingType::Smelter && !r.inputs.empty())
{
smelterRecipe = &r;
break;
}
}
REQUIRE(smelterRecipe != nullptr);
smelter.recipeId = smelterRecipe->id;
initBuffers(smelter, *smelterRecipe);
REQUIRE(statusOf(smelter) == ProductionStatus::Starved); // recipe, but no ore
for (const RecipeIngredient& ing : smelterRecipe->inputs)
{
smelter.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
REQUIRE(statusOf(smelter) == ProductionStatus::Producing);
}
SECTION("Shipyard: unconfigured, then starved without materials, then producing")
{
Building yard; yard.type = BuildingType::Shipyard;
REQUIRE(statusOf(yard) == ProductionStatus::Unconfigured); // no schematic -> grey
yard.recipeId = shipId;
REQUIRE(statusOf(yard) == ProductionStatus::Starved); // no materials -> red
yard.production = Production{};
REQUIRE(statusOf(yard) == ProductionStatus::Producing); // active cycle -> green
}
SECTION("Salvage Bay: red when empty, green when holding scrap")
{
Building bay; bay.type = BuildingType::SalvageBay;
bay.outputBuffer.caps[ItemType{"scrap"}] = 20;
REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red
bay.outputBuffer.items = { makeItem("scrap") };
REQUIRE(statusOf(bay) == ProductionStatus::Producing); // holding scrap -> green
}
}
// ---------------------------------------------------------------------------
// getInputPorts (REQ-BLD-BELT-DRAG snapping, REQ-MAT-INPUT-PORTS)
// ---------------------------------------------------------------------------
namespace
{
QPoint directionDelta(Rotation direction)
{
switch (direction)
{
case Rotation::North: return QPoint(0, -1);
case Rotation::East: return QPoint(1, 0);
case Rotation::South: return QPoint(0, 1);
case Rotation::West: return QPoint(-1, 0);
}
return QPoint(0, 0);
}
bool hasInputPort(const std::vector<Port>& ports, QPoint tile, Rotation direction)
{
for (const Port& port : ports)
{
if (port.tile == tile && port.direction == direction) { return true; }
}
return false;
}
// Advances the sim until the given site becomes an operational building, or a
// safety cap is reached.
void buildToCompletion(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state,
BeltSystem& belts, int& stock, BuildingId id, Tick& tick)
{
for (int i = 0; i < 20000 && findBuilding(state, id) == nullptr; ++i)
{
runTicks(bs, cfg, state, belts, stock, 1, tick);
}
}
}
TEST_CASE("BuildingSystem: getInputPorts on a miner site lists every input edge", "[building]")
{
PlacementFixture f;
// Miner mask ["AA","A>"] East → body (0,0),(1,0),(0,1); output tile (1,1) East.
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
const std::vector<Port> ports = getInputPorts(f.state, f.cfg, id);
// Every perimeter edge except the output-port edge at (1,1), each pointing in.
REQUIRE(ports.size() == 6);
REQUIRE(hasInputPort(ports, QPoint(-1, 0), Rotation::East));
REQUIRE(hasInputPort(ports, QPoint(0, -1), Rotation::South));
REQUIRE(hasInputPort(ports, QPoint(2, 0), Rotation::West));
REQUIRE(hasInputPort(ports, QPoint(1, -1), Rotation::South));
REQUIRE(hasInputPort(ports, QPoint(-1, 1), Rotation::East));
REQUIRE(hasInputPort(ports, QPoint(0, 2), Rotation::North));
// The output-port tile is never an input port.
REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::North));
REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::West));
}
TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built building",
"[building]")
{
PlacementFixture f;
Tick tick = 0;
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);
buildToCompletion(f.bs, f.cfg, f.state, f.belts, f.stock, id, tick);
REQUIRE(findBuilding(f.state, id) != nullptr);
const std::vector<Port> builtPorts = getInputPorts(f.state, f.cfg, id);
// The operational path (stored inputPorts) agrees with the site path (mask-derived).
REQUIRE(builtPorts.size() == sitePorts.size());
for (const Port& port : sitePorts)
{
REQUIRE(hasInputPort(builtPorts, port.tile, port.direction));
}
}
TEST_CASE("BuildingSystem: getInputPorts invariants hold for a rotated site", "[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::South, 0).value();
const ConstructionSite* site = findSite(f.state, id);
REQUIRE(site != nullptr);
std::set<std::pair<int, int>> bodySet;
for (const QPoint& cell : site->bodyCells) { bodySet.insert({cell.x(), cell.y()}); }
const std::vector<Port> ports = getInputPorts(f.state, f.cfg, id);
REQUIRE_FALSE(ports.empty());
for (const Port& port : ports)
{
// Each port tile is outside the footprint...
REQUIRE(bodySet.count({port.tile.x(), port.tile.y()}) == 0);
// ...and its direction points into an adjacent body cell.
const QPoint into = port.tile + directionDelta(port.direction);
REQUIRE(bodySet.count({into.x(), into.y()}) == 1);
}
}