implement building system

This commit is contained in:
2026-04-19 20:50:42 +02:00
parent c70b5c8f08
commit bf29cc40e3
19 changed files with 1818 additions and 7 deletions

504
src/test/BuildingTest.cpp Normal file
View 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());
}

View File

@@ -7,4 +7,6 @@ add_files(
ConfigLoaderTest.cpp
SimulationTest.cpp
BeltSystemTest.cpp
SurfaceMaskTest.cpp
BuildingTest.cpp
)

View File

@@ -163,6 +163,7 @@ height_tiles = 60
refund_percentage = 75
scrap_despawn_seconds = 30
belt_speed_tiles_per_second = 2
starting_building_blocks = 100
[regions]
asteroid_width = 40
@@ -207,6 +208,7 @@ height_tiles = 60
refund_percentage = 75
scrap_despawn_seconds = 30
belt_speed_tiles_per_second = 2
starting_building_blocks = 100
[regions]
asteroid_width = 40

View File

@@ -0,0 +1,184 @@
#include "catch.hpp"
#include <algorithm>
#include <vector>
#include <QPoint>
#include "Port.h"
#include "Rotation.h"
#include "SurfaceMask.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static bool hasBodyCell(const ParsedSurfaceMask& mask, int x, int y)
{
const QPoint pt(x, y);
return std::find(mask.bodyCells.begin(), mask.bodyCells.end(), pt)
!= mask.bodyCells.end();
}
static bool hasOutputPort(const ParsedSurfaceMask& mask, int x, int y, Rotation dir)
{
for (const Port& port : mask.outputPorts)
{
if (port.tile == QPoint(x, y) && port.direction == dir)
{
return true;
}
}
return false;
}
static bool hasShipDockCell(const ParsedSurfaceMask& mask, int x, int y)
{
const QPoint pt(x, y);
return std::find(mask.shipDockCells.begin(), mask.shipDockCells.end(), pt)
!= mask.shipDockCells.end();
}
// ---------------------------------------------------------------------------
// Belt ["A>"] — 1×1 body, East output
// ---------------------------------------------------------------------------
TEST_CASE("SurfaceMask: belt East — 1 body cell, port at (1,0) East", "[surface_mask]")
{
const ParsedSurfaceMask mask = parseSurfaceMask({"A>"}, Rotation::East);
REQUIRE(mask.footprint.width() == 1);
REQUIRE(mask.footprint.height() == 1);
REQUIRE(mask.bodyCells.size() == 1);
REQUIRE(hasBodyCell(mask, 0, 0));
REQUIRE(mask.outputPorts.size() == 1);
REQUIRE(hasOutputPort(mask, 1, 0, Rotation::East));
}
TEST_CASE("SurfaceMask: belt South — body at (0,0), port at (0,1) South", "[surface_mask]")
{
const ParsedSurfaceMask mask = parseSurfaceMask({"A>"}, Rotation::South);
REQUIRE(mask.bodyCells.size() == 1);
REQUIRE(hasBodyCell(mask, 0, 0));
REQUIRE(mask.outputPorts.size() == 1);
REQUIRE(hasOutputPort(mask, 0, 1, Rotation::South));
}
TEST_CASE("SurfaceMask: belt West — body at (0,0), port at (-1,0) West", "[surface_mask]")
{
const ParsedSurfaceMask mask = parseSurfaceMask({"A>"}, Rotation::West);
REQUIRE(mask.bodyCells.size() == 1);
REQUIRE(hasBodyCell(mask, 0, 0));
REQUIRE(mask.outputPorts.size() == 1);
REQUIRE(hasOutputPort(mask, -1, 0, Rotation::West));
}
TEST_CASE("SurfaceMask: belt North — body at (0,0), port at (0,-1) North", "[surface_mask]")
{
const ParsedSurfaceMask mask = parseSurfaceMask({"A>"}, Rotation::North);
REQUIRE(mask.bodyCells.size() == 1);
REQUIRE(hasBodyCell(mask, 0, 0));
REQUIRE(mask.outputPorts.size() == 1);
REQUIRE(hasOutputPort(mask, 0, -1, Rotation::North));
}
// ---------------------------------------------------------------------------
// Miner ["AA", "A>"] — 3 body cells, East output
// ---------------------------------------------------------------------------
TEST_CASE("SurfaceMask: miner East — 3 body cells, port at (1,1) East, footprint 2×2",
"[surface_mask]")
{
const ParsedSurfaceMask mask = parseSurfaceMask({"AA", "A>"}, Rotation::East);
REQUIRE(mask.footprint.width() == 2);
REQUIRE(mask.footprint.height() == 2);
REQUIRE(mask.bodyCells.size() == 3);
REQUIRE(hasBodyCell(mask, 0, 0));
REQUIRE(hasBodyCell(mask, 1, 0));
REQUIRE(hasBodyCell(mask, 0, 1));
REQUIRE(mask.outputPorts.size() == 1);
REQUIRE(hasOutputPort(mask, 1, 1, Rotation::East));
}
TEST_CASE("SurfaceMask: miner South — port faces South", "[surface_mask]")
{
const ParsedSurfaceMask mask = parseSurfaceMask({"AA", "A>"}, Rotation::South);
REQUIRE(mask.outputPorts.size() == 1);
REQUIRE(mask.outputPorts[0].direction == Rotation::South);
}
TEST_CASE("SurfaceMask: miner West — port faces West", "[surface_mask]")
{
const ParsedSurfaceMask mask = parseSurfaceMask({"AA", "A>"}, Rotation::West);
REQUIRE(mask.outputPorts.size() == 1);
REQUIRE(mask.outputPorts[0].direction == Rotation::West);
}
TEST_CASE("SurfaceMask: miner North — port faces North", "[surface_mask]")
{
const ParsedSurfaceMask mask = parseSurfaceMask({"AA", "A>"}, Rotation::North);
REQUIRE(mask.outputPorts.size() == 1);
REQUIRE(mask.outputPorts[0].direction == Rotation::North);
}
// ---------------------------------------------------------------------------
// Splitter ["<A>"] — 1 body cell, West + East outputs
// ---------------------------------------------------------------------------
TEST_CASE("SurfaceMask: splitter East — 1 body cell, ports at (-1,0) West and (1,0) East",
"[surface_mask]")
{
const ParsedSurfaceMask mask = parseSurfaceMask({"<A>"}, Rotation::East);
REQUIRE(mask.footprint.width() == 1);
REQUIRE(mask.footprint.height() == 1);
REQUIRE(mask.bodyCells.size() == 1);
REQUIRE(hasBodyCell(mask, 0, 0));
REQUIRE(mask.outputPorts.size() == 2);
REQUIRE(hasOutputPort(mask, -1, 0, Rotation::West));
REQUIRE(hasOutputPort(mask, 1, 0, Rotation::East));
}
// ---------------------------------------------------------------------------
// Shipyard ["AAAS>", "AAAS "] — 6 A + 2 S body cells, 1 port
// ---------------------------------------------------------------------------
TEST_CASE("SurfaceMask: shipyard East — 6 A bodyCells, 2 S shipDockCells, port at (4,0) East",
"[surface_mask]")
{
const std::vector<std::string> rows = {"AAAS>", "AAAS "};
const ParsedSurfaceMask mask = parseSurfaceMask(rows, Rotation::East);
REQUIRE(mask.footprint.width() == 4);
REQUIRE(mask.footprint.height() == 2);
REQUIRE(mask.bodyCells.size() == 8); // 6 A + 2 S
REQUIRE(mask.shipDockCells.size() == 2);
REQUIRE(hasShipDockCell(mask, 3, 0));
REQUIRE(hasShipDockCell(mask, 3, 1));
REQUIRE(mask.outputPorts.size() == 1);
REQUIRE(hasOutputPort(mask, 4, 0, Rotation::East));
}
// ---------------------------------------------------------------------------
// Assembler ["AAA ", "AAA>", "AAA "] — 9 body cells, East output
// ---------------------------------------------------------------------------
TEST_CASE("SurfaceMask: assembler East — 9 body cells, port at (3,1) East, footprint 3×3",
"[surface_mask]")
{
const std::vector<std::string> rows = {"AAA ", "AAA>", "AAA "};
const ParsedSurfaceMask mask = parseSurfaceMask(rows, Rotation::East);
REQUIRE(mask.footprint.width() == 3);
REQUIRE(mask.footprint.height() == 3);
REQUIRE(mask.bodyCells.size() == 9);
REQUIRE(mask.outputPorts.size() == 1);
REQUIRE(hasOutputPort(mask, 3, 1, Rotation::East));
}