implement building system
This commit is contained in:
504
src/test/BuildingTest.cpp
Normal file
504
src/test/BuildingTest.cpp
Normal file
@@ -0,0 +1,504 @@
|
||||
#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(DOTA_FACTORY_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; },
|
||||
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", "[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; },
|
||||
rng);
|
||||
|
||||
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
|
||||
|
||||
REQUIRE(belts.tryPutItem(eastPort(QPoint(5, 5)), makeItem("iron_ore")));
|
||||
REQUIRE(bs.allBuildings().empty()); // belts do not create Building instances
|
||||
}
|
||||
|
||||
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; },
|
||||
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; },
|
||||
rng);
|
||||
|
||||
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
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; },
|
||||
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; },
|
||||
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; },
|
||||
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; },
|
||||
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; },
|
||||
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; },
|
||||
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; },
|
||||
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(westPort(QPoint(2, 0)), makeItem("iron_ore"));
|
||||
|
||||
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; },
|
||||
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; },
|
||||
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; },
|
||||
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; },
|
||||
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(eastPort(QPoint(-1, 0)), makeItem("scrap"));
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user