538 lines
19 KiB
C++
538 lines
19 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.tickBeltPush();
|
||
belts.tick();
|
||
++tick;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Placement
|
||
// ---------------------------------------------------------------------------
|
||
|
||
TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
REQUIRE(id != kInvalidEntityId);
|
||
|
||
// 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.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
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<int>(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.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
const EntityId 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.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
const EntityId 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.allSites().empty());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Construction queue
|
||
// ---------------------------------------------------------------------------
|
||
|
||
TEST_CASE("BuildingSystem: first queued building starts construction immediately",
|
||
"[building]")
|
||
{
|
||
const GameConfig cfg = loadConfig();
|
||
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
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.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
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.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
const EntityId 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.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.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||
const EntityId 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.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.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
const EntityId 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);
|
||
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.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
const EntityId 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);
|
||
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2);
|
||
REQUIRE_FALSE(b->production.has_value());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
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 EntityId 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<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);
|
||
const std::map<ItemType, int>::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<double>(kTickRateHz));
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
const EntityId 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");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
const EntityId 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_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.beltSpeedTilesPerSecond);
|
||
int stock = 0;
|
||
std::mt19937 rng(0);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
const EntityId 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<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);
|
||
EntityId nextId = 1;
|
||
BuildingSystem bs(cfg, belts,
|
||
[&nextId]() { return nextId++; },
|
||
[&stock](int n) { stock += n; },
|
||
[](const std::string&, QVector2D) {},
|
||
rng);
|
||
|
||
const EntityId 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<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"));
|
||
belts.tick();
|
||
bs.tickBeltPull();
|
||
}
|
||
|
||
// Verify scrap is in input buffer.
|
||
{
|
||
const Building* b = bs.findBuilding(id);
|
||
REQUIRE(b != nullptr);
|
||
const std::map<ItemType, int>::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<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());
|
||
}
|