Files
dota_factory/src/test/BuildingTest.cpp
2026-04-29 21:32:32 +02:00

538 lines
19 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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());
}