#include "catch.hpp" #include #include #include #include #include #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.tickBeltPush(); belts.tick(); ++tick; } } // --------------------------------------------------------------------------- // 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&) {}, [](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))); } 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&) {}, [](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"))); // Complete construction (1 s). Tick tick = 0; runTicks(bs, belts, static_cast(secondsToTicks(1.0)) + 1, tick); REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"))); REQUIRE(bs.allBuildings().size() == 1); REQUIRE(bs.allBuildings()[0].type == BuildingType::Belt); REQUIRE(bs.allBuildings()[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&) {}, [](const std::string&) -> bool { return true; }, rng); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); REQUIRE(bs.allSites().size() == 1); REQUIRE(bs.allBuildings().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&) {}, [](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(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.allSites().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&) {}, [](const std::string&) -> bool { return true; }, rng); bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); REQUIRE(bs.allSites().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&) {}, [](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.allSites().size() == 2); REQUIRE(bs.allSites()[0].completesAt > 0); REQUIRE(bs.allSites()[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&) {}, [](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(secondsToTicks(10.0)) + 1, tick); REQUIRE(bs.allSites().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&) {}, [](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(secondsToTicks(10.0)) + 1, tick); REQUIRE(bs.allSites().size() == 1); REQUIRE(bs.allSites().front().id == id2); REQUIRE(bs.allSites().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&) {}, [](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(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, tick); const Building* b = bs.findBuilding(id); REQUIRE(b != nullptr); REQUIRE_FALSE(b->outputBuffer.items.empty()); REQUIRE(b->outputBuffer.items.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&) {}, [](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(secondsToTicks(10.0)) + 2 * static_cast(secondsToTicks(1.0)) + 2, tick); const Building* b = bs.findBuilding(id); REQUIRE(b != nullptr); REQUIRE(static_cast(b->outputBuffer.items.size()) == 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&) {}, [](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.productionBuildingCount() == 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(secondsToTicks(10.0)) + 1, tick); REQUIRE(bs.productionBuildingCount() == 1); runTicks(bs, belts, static_cast(secondsToTicks(15.0)), tick); REQUIRE(bs.productionBuildingCount() == 2); // Neither has a recipe selected, so neither has an active cycle. REQUIRE(bs.activeProductionBuildingCount() == 0); bs.setRecipe(minerId, "mine_iron_ore"); runTicks(bs, belts, 1, tick); REQUIRE(bs.activeProductionBuildingCount() == 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&) {}, [](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.activeProductionBuildingCount() == 0); // Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330). runTicks(bs, belts, static_cast(secondsToTicks(10.0)) + 1, tick); REQUIRE(bs.activeProductionBuildingCount() == 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(secondsToTicks(1.0)) + 1, tick); const Building* b = bs.findBuilding(id); REQUIRE(b != nullptr); REQUIRE(static_cast(b->outputBuffer.items.size()) == 2); REQUIRE_FALSE(b->production.has_value()); REQUIRE(bs.activeProductionBuildingCount() == 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(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&) {}, [](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); bs.setRecipe(sid, "iron_ingot"); // Complete construction (15s → tick 450+1 = 451 ticks). Tick tick = 0; runTicks(bs, belts, static_cast(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); const std::map::const_iterator it = b->inputBuffer.counts.find(ItemType{"iron_ore"}); REQUIRE(it != b->inputBuffer.counts.end()); REQUIRE(it->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(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&) {}, [](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(secondsToTicks(10.0)) + static_cast(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 = belts.tryTakeItem(eastPort(QPoint(1, 1))); REQUIRE(item.has_value()); REQUIRE(item->type.id == "iron_ore"); } // --------------------------------------------------------------------------- // setRecipe clears buffers // --------------------------------------------------------------------------- TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production", "[building]") { const GameConfig cfg = loadConfig(); BeltSystem belts(static_cast(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&) {}, [](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(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, tick); { const Building* b = bs.findBuilding(id); REQUIRE(b != nullptr); REQUIRE_FALSE(b->outputBuffer.items.empty()); } bs.setRecipe(id, "mine_copper_ore"); const Building* b = bs.findBuilding(id); REQUIRE(b->outputBuffer.items.empty()); 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&) {}, [](const std::string&) -> bool { return true; }, rng); const BuildingId id = bs.place(BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0); bs.setRecipe(id, "reprocessing_cycle"); // Complete construction (25s). Tick tick = 0; runTicks(bs, belts, static_cast(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(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&) {}, [](const std::string&) -> bool { return true; }, rng); const BuildingId id = bs.place(BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0); bs.setRecipe(id, "reprocessing_cycle"); // Complete construction (25s). Tick tick = 0; runTicks(bs, belts, static_cast(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")); belts.tick(); bs.tickBeltPull(); } // Verify scrap is in input buffer. { const Building* b = bs.findBuilding(id); REQUIRE(b != nullptr); const std::map::const_iterator it = b->inputBuffer.counts.find(ItemType{"scrap"}); REQUIRE(it != b->inputBuffer.counts.end()); REQUIRE(it->second == 5); } // Run production cycle (3s = 90 ticks + 1 for the completion tick). runTicks(bs, belts, static_cast(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&) {}, [](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&) {}, [](const std::string&) -> bool { return true; }, rng); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const std::optional 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&) {}, [](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(secondsToTicks(1.0)) + 1, tick); REQUIRE(bs.allSites().empty()); const std::optional 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&) {}, [](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&) {}, [](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&) {}, [](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 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&) {}, [](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&) {}, [](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&) {}, [](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(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&) {}, [](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(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"))); }