1811 lines
74 KiB
C++
1811 lines
74 KiB
C++
#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 "BeltSystem.h"
|
||
#include "Building.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)));
|
||
}
|
||
|
||
// -- 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: deposit item, continue (no same-tick restart).
|
||
// Cycle 2 starts at tick 331 (completesAt=361).
|
||
// Cycle 2 completes at tick 361: deposit item → buffer=2, cycle 3 stalls.
|
||
// Need to process through tick 361: 362 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)) + 2,
|
||
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());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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();
|
||
// Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any
|
||
// ore/scrap that is an input to a smelter recipe.
|
||
|
||
// 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);
|
||
|
||
// 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 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);
|
||
}
|
||
|
||
// With mixed inputs, the smelter runs whichever recipe is currently satisfiable
|
||
// and leaves an incomplete batch of another input waiting (see the union-of-
|
||
// inputs caps in initAutoBuffers).
|
||
TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits",
|
||
"[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 (iron_ingot needs 2 — incomplete) then 2 copper_ore
|
||
// (copper_ingot needs 2 — satisfiable) 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);
|
||
}
|
||
|
||
// copper_ingot cycle is 2.5s; run to completion.
|
||
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);
|
||
|
||
// Copper was smelted; the lone iron_ore still waits for a second unit.
|
||
bool hasCopperIngot = false;
|
||
for (const Item& item : outputSideItems(*b))
|
||
{
|
||
if (item.type.id == "copper_ingot") { hasCopperIngot = true; }
|
||
}
|
||
REQUIRE(hasCopperIngot);
|
||
|
||
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);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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() == miner->outputBuffer.capacity);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 — output buffer capacity (REQ-MAT-OUTPUT-BUFFER-REPROCESSING)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max output per roll",
|
||
"[building]")
|
||
{
|
||
PlacementFixture f;
|
||
|
||
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
|
||
QPoint(0, 0), Rotation::East, 0).value();
|
||
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
|
||
// single reprocessing recipe is applied automatically on completion.
|
||
|
||
// 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);
|
||
|
||
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%). Max per roll = 2. Capacity = 2 (1× max).
|
||
REQUIRE(b->outputBuffer.capacity == 2);
|
||
}
|
||
|
||
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();
|
||
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
|
||
// single reprocessing recipe is applied automatically on completion.
|
||
|
||
// 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 run implicit recipes (REQ-BLD-SMELTER,
|
||
// REQ-BLD-REPROCESSING) and the rest have no settings whatsoever.
|
||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Smelter));
|
||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::ReprocessingPlant));
|
||
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);
|
||
|
||
CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
|
||
== BlueprintGhostAction::Invalid);
|
||
}
|
||
|
||
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 runs an implicit recipe (REQ-BLD-SMELTER), so there is nothing to hand
|
||
// over and it is simply left as it is.
|
||
const BlueprintGhostResolved smelter =
|
||
resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East);
|
||
REQUIRE(smelter.action == BlueprintGhostAction::CompatibleOverlap);
|
||
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.capacity = 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 slot and
|
||
// blocks the cycle again (REQ-MAT-OUTPUT-EMERGE).
|
||
miner.emergingItems.push_back({ BeltItemSlot{ makeItem("iron_ore"), 0.5 } });
|
||
REQUIRE(miner.getOutputItemCount() == 2);
|
||
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
|
||
}
|
||
|
||
SECTION("Assembler: starved, the transient between cycles, then blocked")
|
||
{
|
||
Building assembler; assembler.type = BuildingType::Assembler;
|
||
assembler.recipeId = assemblerRecipe->id;
|
||
|
||
int cycleOutput = 0;
|
||
for (const RecipeOutput& out : assemblerRecipe->outputs)
|
||
{
|
||
cycleOutput += out.amount;
|
||
}
|
||
REQUIRE(cycleOutput > 0);
|
||
// The buffer the simulation would give it (REQ-MAT-OUTPUT-BUFFER).
|
||
assembler.outputBuffer.capacity = 2 * cycleOutput;
|
||
|
||
// 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);
|
||
|
||
// Filled to within less than one cycle's output of capacity: no cycle can start
|
||
// -> yellow.
|
||
for (int i = 0; i < cycleOutput + 1; ++i)
|
||
{
|
||
assembler.outputBuffer.items.push_back(makeItem("x"));
|
||
}
|
||
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;
|
||
}
|
||
|
||
Building assembler; assembler.type = BuildingType::Assembler;
|
||
assembler.recipeId = multiOutputRecipe->id;
|
||
assembler.outputBuffer.capacity = 2 * cycleOutput;
|
||
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("x"));
|
||
}
|
||
REQUIRE(assembler.getOutputItemCount() < assembler.outputBuffer.capacity);
|
||
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: judged by the smallest output a roll could yield")
|
||
{
|
||
// The plant rolls one of its outputs per cycle (REQ-BLD-REPROCESSING) and the
|
||
// roll belongs to the simulation, so the status can only say whether *some*
|
||
// roll could start: it is blocked once not even the smallest output fits.
|
||
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);
|
||
|
||
int smallestOutput = 0;
|
||
int largestOutput = 0;
|
||
for (const RecipeOutput& out : reprocessingRecipe->outputs)
|
||
{
|
||
if (smallestOutput == 0 || out.amount < smallestOutput)
|
||
{
|
||
smallestOutput = out.amount;
|
||
}
|
||
if (out.amount > largestOutput) { largestOutput = out.amount; }
|
||
}
|
||
REQUIRE(smallestOutput > 0);
|
||
|
||
Building plant; plant.type = BuildingType::ReprocessingPlant;
|
||
// One cycle's largest output, as initAutoBuffers sizes it
|
||
// (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
|
||
plant.outputBuffer.capacity = largestOutput;
|
||
for (const RecipeIngredient& ing : reprocessingRecipe->inputs)
|
||
{
|
||
plant.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
||
}
|
||
|
||
// Empty buffer: a roll fits -> green.
|
||
REQUIRE(statusOf(plant) == ProductionStatus::Producing);
|
||
|
||
// Room for the smallest output but not for the largest: some roll can still
|
||
// start, so the plant is waiting on the roll rather than blocked.
|
||
if (smallestOutput < largestOutput)
|
||
{
|
||
plant.outputBuffer.items.push_back(makeItem("iron_ingot"));
|
||
REQUIRE(plant.getOutputItemCount() + largestOutput
|
||
> plant.outputBuffer.capacity);
|
||
REQUIRE(statusOf(plant) == ProductionStatus::Producing);
|
||
plant.outputBuffer.items.pop_back();
|
||
}
|
||
|
||
// Filled so that not even the smallest output fits -> yellow.
|
||
for (int i = 0; i < largestOutput - smallestOutput + 1; ++i)
|
||
{
|
||
plant.outputBuffer.items.push_back(makeItem("iron_ingot"));
|
||
}
|
||
REQUIRE(statusOf(plant) == ProductionStatus::Blocked);
|
||
|
||
// Without the scrap it is starved regardless of the buffer.
|
||
plant.inputBuffer.counts.clear();
|
||
REQUIRE(statusOf(plant) == ProductionStatus::Starved);
|
||
}
|
||
|
||
SECTION("Smelter (auto-recipe) is never grey")
|
||
{
|
||
Building smelter; smelter.type = BuildingType::Smelter;
|
||
// No player-selectable recipe and empty inputs -> red, not grey.
|
||
REQUIRE(statusOf(smelter) == ProductionStatus::Starved);
|
||
}
|
||
|
||
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.capacity = 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);
|
||
}
|
||
}
|