Rename bare-noun value accessors to start with "get", per the coding guideline that a getter's name should begin with "get". Covers simple nullary accessors across the simulation, balancing, and UI layers (e.g. currentTick -> getCurrentTick, config -> getConfig, buildings/belts/ships/scraps/admin, threatLevel, artifactCount, tilePx, viewportRect, Hasher::value -> getValue, Formula::source -> getSource). Left untouched by design: boolean predicates (is*/has*/can*), verb-named lookups/computations (find*/compute*/peek*/create*), coordinate transforms, and existing set*-prefixed setters. Test-only helpers and std/toml++ accessors (optional::value, parse_error::source) were deliberately not renamed. Builds clean; all 406 test cases (3222 assertions) pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
1311 lines
53 KiB
C++
1311 lines
53 KiB
C++
#include "catch.hpp"
|
||
|
||
#include <map>
|
||
#include <random>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
#include <QPoint>
|
||
|
||
#include "BeltSystem.h"
|
||
#include "Building.h"
|
||
#include "BuildingSystem.h"
|
||
#include "BuildingType.h"
|
||
#include "ConfigLoader.h"
|
||
#include "Item.h"
|
||
#include "ItemType.h"
|
||
#include "Port.h"
|
||
#include "Rotation.h"
|
||
#include "Tick.h"
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fixture helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
static GameConfig loadConfig()
|
||
{
|
||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||
}
|
||
|
||
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, BeltSystem& belts, int n, Tick& tick)
|
||
{
|
||
for (int i = 0; i < n; ++i)
|
||
{
|
||
bs.tickConstruction(tick);
|
||
bs.tickBeltPull();
|
||
bs.tickProduction(tick);
|
||
bs.tickOutputBelts();
|
||
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.
|
||
struct PlacementFixture
|
||
{
|
||
GameConfig cfg = loadConfig();
|
||
BeltSystem belts{cfg.world.beltSpeed_tps};
|
||
int stock = 0;
|
||
std::mt19937 rng{0};
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs;
|
||
|
||
PlacementFixture()
|
||
: 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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
REQUIRE(id != kInvalidBuildingId);
|
||
|
||
// Miner mask ["AA","A>"] with East rotation → body at (0,0),(1,0),(0,1).
|
||
REQUIRE(bs.isTileOccupied(QPoint(0, 0)));
|
||
REQUIRE(bs.isTileOccupied(QPoint(1, 0)));
|
||
REQUIRE(bs.isTileOccupied(QPoint(0, 1)));
|
||
// (1,1) is the output-port tile, NOT a body cell.
|
||
REQUIRE_FALSE(bs.isTileOccupied(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 BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, -1), Rotation::East, 0);
|
||
REQUIRE(id == kInvalidBuildingId);
|
||
REQUIRE(f.bs.getAllSites().empty());
|
||
REQUIRE_FALSE(f.bs.isTileOccupied(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 BuildingId id = f.bs.place(BuildingType::Miner,
|
||
QPoint(0, heightTiles - 1), Rotation::East, 0);
|
||
REQUIRE(id == kInvalidBuildingId);
|
||
REQUIRE(f.bs.getAllSites().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 BuildingId id = f.bs.place(BuildingType::Miner,
|
||
QPoint(leftEdgeX - 1, 0), Rotation::East, 0);
|
||
REQUIRE(id == kInvalidBuildingId);
|
||
REQUIRE(f.bs.getAllSites().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(BuildingType::Miner,
|
||
QPoint(leftEdgeX, 0), Rotation::East, 0);
|
||
REQUIRE(id != kInvalidBuildingId);
|
||
REQUIRE(f.bs.isTileOccupied(QPoint(leftEdgeX, 0)));
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: place imposes no right-side bound (space extends rightward)",
|
||
"[building]")
|
||
{
|
||
PlacementFixture f;
|
||
|
||
const BuildingId id = f.bs.place(BuildingType::Miner,
|
||
QPoint(1000, 0), Rotation::East, 0);
|
||
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(f.bs.isPlacementValid(BuildingType::Miner, QPoint(-3, 0), Rotation::East));
|
||
REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(0, 0), Rotation::East)); // A cells in space
|
||
REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(0, -1), Rotation::East)); // above world
|
||
REQUIRE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(leftEdgeX, 0), Rotation::East));
|
||
REQUIRE_FALSE(f.bs.isPlacementValid(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(f.bs.isPlacementValid(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East));
|
||
REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Shipyard, QPoint(0, 0), Rotation::East)); // A cells in space
|
||
REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Shipyard, QPoint(-4, 0), Rotation::East)); // dock on asteroid
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after construction",
|
||
"[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
|
||
|
||
// Belt is queued — not yet in BeltSystem.
|
||
REQUIRE_FALSE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
|
||
|
||
// Complete construction (1 s).
|
||
Tick tick = 0;
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
|
||
|
||
REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
|
||
REQUIRE(bs.getAllBuildings().size() == 1);
|
||
REQUIRE(bs.getAllBuildings()[0].type == BuildingType::Belt);
|
||
REQUIRE(bs.getAllBuildings()[0].anchor == QPoint(5, 5));
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: placed building enters construction queue", "[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
REQUIRE(bs.getAllSites().size() == 1);
|
||
REQUIRE(bs.getAllBuildings().empty());
|
||
REQUIRE(bs.findSite(id) != nullptr);
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
// 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(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
|
||
|
||
const int refund = bs.demolish(id);
|
||
|
||
// Miner cost = 15, refund = floor(15 * 75 / 100) = 11.
|
||
REQUIRE(refund == 15 * cfg.world.refundPercentage / 100);
|
||
REQUIRE_FALSE(bs.isTileOccupied(QPoint(0, 0)));
|
||
REQUIRE(bs.getAllSites().empty());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Construction queue
|
||
// ---------------------------------------------------------------------------
|
||
|
||
TEST_CASE("BuildingSystem: first queued building starts construction immediately",
|
||
"[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
REQUIRE(bs.getAllSites().front().completesAt > 0);
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0);
|
||
|
||
REQUIRE(bs.getAllSites().size() == 2);
|
||
REQUIRE(bs.getAllSites()[0].completesAt > 0);
|
||
REQUIRE(bs.getAllSites()[1].completesAt == 0);
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: construction completes after configured duration", "[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
// 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(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
|
||
|
||
REQUIRE(bs.getAllSites().empty());
|
||
REQUIRE(bs.findBuilding(id) != nullptr);
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: second building starts after first completes", "[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
const BuildingId id2 = bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0);
|
||
|
||
// Process through tick 300 to complete first miner's construction.
|
||
Tick tick = 0;
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
|
||
|
||
REQUIRE(bs.getAllSites().size() == 1);
|
||
REQUIRE(bs.getAllSites().front().id == id2);
|
||
REQUIRE(bs.getAllSites().front().completesAt > 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Miner production cycle
|
||
// ---------------------------------------------------------------------------
|
||
|
||
TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
bs.setRecipe(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(bs, belts,
|
||
static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
|
||
tick);
|
||
|
||
const Building* b = bs.findBuilding(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
bs.setRecipe(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(bs, belts,
|
||
static_cast<int>(secondsToTicks(10.0))
|
||
+ 2 * static_cast<int>(secondsToTicks(1.0)) + 2,
|
||
tick);
|
||
|
||
const Building* b = bs.findBuilding(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0);
|
||
(void)smelterId;
|
||
|
||
Tick tick = 0;
|
||
// Both still under construction.
|
||
REQUIRE(bs.getProductionBuildingCount() == 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(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
|
||
REQUIRE(bs.getProductionBuildingCount() == 1);
|
||
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)), tick);
|
||
REQUIRE(bs.getProductionBuildingCount() == 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(bs.getActiveProductionBuildingCount() == 0);
|
||
|
||
bs.setRecipe(minerId, "mine_iron_ore");
|
||
runTicks(bs, belts, 1, tick);
|
||
REQUIRE(bs.getActiveProductionBuildingCount() == 1);
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state",
|
||
"[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
bs.setRecipe(id, "mine_iron_ore");
|
||
|
||
Tick tick = 0;
|
||
// Not yet operational while under construction.
|
||
REQUIRE(bs.getActiveProductionBuildingCount() == 0);
|
||
|
||
// Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330).
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
|
||
REQUIRE(bs.getActiveProductionBuildingCount() == 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(bs, belts, 2 * static_cast<int>(secondsToTicks(1.0)) + 1, tick);
|
||
|
||
const Building* b = bs.findBuilding(id);
|
||
REQUIRE(b != nullptr);
|
||
REQUIRE(b->getOutputItemCount() == 2);
|
||
REQUIRE_FALSE(b->production.has_value());
|
||
REQUIRE(bs.getActiveProductionBuildingCount() == 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Belt pull → input buffer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing belt",
|
||
"[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
// Fast belt so items are immediately available for peek/take.
|
||
BeltSystem belts(static_cast<double>(kTickRateHz));
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
// 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 = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
|
||
// 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(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
|
||
|
||
// Place west-flowing belt at (2,0): belt flows West, delivers to smelter.
|
||
belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
|
||
belts.tick();
|
||
|
||
bs.tickBeltPull();
|
||
|
||
const Building* b = bs.findBuilding(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
|
||
// 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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt: 1 tile/tick
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
|
||
Tick tick = 0;
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
|
||
|
||
belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
|
||
belts.tick();
|
||
bs.tickBeltPull(); // accepts the item onto the input belt at progress 0.0
|
||
|
||
const Building* b = bs.findBuilding(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.
|
||
bs.tickBeltPull();
|
||
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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
|
||
QPoint(0, 0), Rotation::East, 0);
|
||
Tick tick = 0;
|
||
runTicks(bs, belts, 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.
|
||
belts.placeBelt(QPoint(-1, 0), Rotation::East);
|
||
for (int i = 0; i < 20; ++i)
|
||
{
|
||
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
|
||
belts.tick();
|
||
bs.tickBeltPull();
|
||
}
|
||
|
||
const Building* b = bs.findBuilding(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(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(static_cast<double>(kTickRateHz));
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
Tick tick = 0;
|
||
runTicks(bs, belts, 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).
|
||
belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||
for (int i = 0; i < 2; ++i)
|
||
{
|
||
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
|
||
belts.tick();
|
||
bs.tickBeltPull();
|
||
}
|
||
|
||
// iron_ingot recipe cycle is 2s; run to completion.
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(2.0)) + 2, tick);
|
||
|
||
const Building* b = bs.findBuilding(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(static_cast<double>(kTickRateHz));
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
Tick tick = 0;
|
||
runTicks(bs, belts, 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.
|
||
belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||
const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" };
|
||
for (const char* id : fed)
|
||
{
|
||
belts.tryPutItem(QPoint(2, 0), makeItem(id));
|
||
belts.tick();
|
||
bs.tickBeltPull();
|
||
}
|
||
|
||
// copper_ingot cycle is 2.5s; run to completion.
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(2.5)) + 2, tick);
|
||
|
||
const Building* b = bs.findBuilding(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(static_cast<double>(kTickRateHz));
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
bs.setRecipe(id, "mine_iron_ore");
|
||
|
||
// Belt at the miner's output port tile (1,1) flowing East.
|
||
belts.placeBelt(QPoint(1, 1), Rotation::East);
|
||
|
||
Tick tick = 0;
|
||
// Construction (10s) + 1 production cycle (1s) + 1 extra tick.
|
||
runTicks(bs, belts,
|
||
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(bs, belts, 1, tick);
|
||
|
||
const std::optional<Item> item = 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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
// Miner at (0,0): body (0,0),(1,0),(0,1); output port tile (1,1) flowing East.
|
||
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
bs.setRecipe(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 = bs.place(BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0);
|
||
|
||
Tick tick = 0;
|
||
// Smelter build (15s) + margin for coupling and a smelt cycle.
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(30.0)), tick);
|
||
|
||
const Building* smelter = bs.findBuilding(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
// Producing miner at (0,0), output port (1,1) East.
|
||
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
bs.setRecipe(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 = bs.place(BuildingType::Miner, QPoint(1, 1), Rotation::East, 0);
|
||
|
||
Tick tick = 0;
|
||
// Both miners build sequentially (10s each), then the producer runs and jams.
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)), tick);
|
||
|
||
const Building* miner = bs.findBuilding(minerId);
|
||
const Building* sink = bs.findBuilding(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(static_cast<double>(kTickRateHz));
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
bs.setRecipe(id, "mine_iron_ore");
|
||
|
||
Tick tick = 0;
|
||
// Run until first item is in output buffer.
|
||
runTicks(bs, belts,
|
||
static_cast<int>(secondsToTicks(10.0)) + static_cast<int>(secondsToTicks(1.0)) + 1,
|
||
tick);
|
||
|
||
{
|
||
const Building* b = bs.findBuilding(id);
|
||
REQUIRE(b != nullptr);
|
||
REQUIRE(b->getOutputItemCount() > 0);
|
||
}
|
||
|
||
bs.setRecipe(id, "mine_copper_ore");
|
||
|
||
const Building* b = bs.findBuilding(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
|
||
QPoint(0, 0), Rotation::East, 0);
|
||
// 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(bs, belts, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
|
||
|
||
const Building* b = bs.findBuilding(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(static_cast<double>(kTickRateHz));
|
||
int stock = 0;
|
||
// Seed chosen so first roll produces 2-item output (iron_ingot), filling buffer.
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
|
||
QPoint(0, 0), Rotation::East, 0);
|
||
// 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(bs, belts, 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.
|
||
belts.placeBelt(QPoint(-1, 0), Rotation::East);
|
||
for (int i = 0; i < 5; ++i)
|
||
{
|
||
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
|
||
belts.tick();
|
||
bs.tickBeltPull();
|
||
}
|
||
|
||
// 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 = bs.findBuilding(id);
|
||
REQUIRE(b != nullptr);
|
||
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
|
||
}
|
||
|
||
// Run production cycle (3s = 90 ticks + 1 for the completion tick).
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(3.0)) + 1, tick);
|
||
|
||
const Building* b = bs.findBuilding(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
REQUIRE_FALSE(
|
||
bs.findRotateInPlaceTarget(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
const std::optional<BuildingId> result =
|
||
bs.findRotateInPlaceTarget(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
Tick tick = 0;
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
|
||
REQUIRE(bs.getAllSites().empty());
|
||
|
||
const std::optional<BuildingId> result =
|
||
bs.findRotateInPlaceTarget(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
// Querying with Splitter at the same tile — type mismatch → nullopt.
|
||
REQUIRE_FALSE(
|
||
bs.findRotateInPlaceTarget(BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value());
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap",
|
||
"[building][rotate-in-place]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
// Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1).
|
||
bs.place(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(
|
||
bs.findRotateInPlaceTarget(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
// 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 = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
const std::optional<BuildingId> result =
|
||
bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North);
|
||
REQUIRE(result.has_value());
|
||
REQUIRE(*result == id);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// rotateInPlace
|
||
// ---------------------------------------------------------------------------
|
||
|
||
TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a construction site",
|
||
"[building][rotate-in-place]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||
REQUIRE(bs.findSite(id)->rotation == Rotation::East);
|
||
|
||
bs.rotateInPlace(id, Rotation::North);
|
||
|
||
REQUIRE(bs.findSite(id)->rotation == Rotation::North);
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of a queued site",
|
||
"[building][rotate-in-place]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||
const Tick completesAt = bs.findSite(id)->completesAt;
|
||
REQUIRE(completesAt > 0);
|
||
|
||
bs.rotateInPlace(id, Rotation::South);
|
||
|
||
REQUIRE(bs.findSite(id)->completesAt == completesAt);
|
||
}
|
||
|
||
TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direction on an operational building",
|
||
"[building][rotate-in-place]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
Tick tick = 0;
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
|
||
REQUIRE(bs.findBuilding(id) != nullptr);
|
||
|
||
const Building& before = *bs.findBuilding(id);
|
||
REQUIRE(before.outputPorts[0].direction == Rotation::East);
|
||
|
||
bs.rotateInPlace(id, Rotation::North);
|
||
|
||
const Building& after = *bs.findBuilding(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]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
BuildingId nextBuildingId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextBuildingId]() { return nextBuildingId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||
[](const std::string&) -> bool { return true; },
|
||
rng);
|
||
|
||
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
|
||
|
||
Tick tick = 0;
|
||
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
|
||
|
||
bs.rotateInPlace(id, Rotation::North);
|
||
|
||
// Belt tile must still be registered after rotation — items can be placed on it.
|
||
REQUIRE(belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
|
||
}
|
||
|
||
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(BuildingType::Splitter, tile, Rotation::East, 0);
|
||
REQUIRE(id != kInvalidBuildingId);
|
||
REQUIRE(f.bs.findSite(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(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 = f.bs.getSiteSplitterInfo(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 (f.bs.getAllBuildings().empty() && tick < 100000)
|
||
{
|
||
runTicks(f.bs, f.belts, 1, tick);
|
||
}
|
||
REQUIRE(f.bs.getAllBuildings().size() == 1);
|
||
REQUIRE(f.bs.getAllBuildings()[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());
|
||
}
|