implement ship modules

This commit is contained in:
2026-05-18 08:49:51 +02:00
parent b59e392461
commit d08bf5d37b
33 changed files with 1911 additions and 56 deletions

View File

@@ -46,7 +46,7 @@ struct Fixture
, buildings(cfg, belts,
[this]() { return nextId++; },
[this](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng)
, ships(cfg, [this]() { return nextId++; })
, scraps([this]() { return nextId++; })

View File

@@ -78,7 +78,7 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -103,7 +103,7 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
@@ -131,7 +131,7 @@ TEST_CASE("BuildingSystem: placed building enters construction queue", "[buildin
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -151,7 +151,7 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -184,7 +184,7 @@ TEST_CASE("BuildingSystem: first queued building starts construction immediately
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -201,7 +201,7 @@ TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[b
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -222,7 +222,7 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -246,7 +246,7 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -275,7 +275,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -304,7 +304,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -343,7 +343,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
@@ -384,7 +384,7 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -423,7 +423,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -463,7 +463,7 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::ReprocessingPlant,
@@ -493,7 +493,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::ReprocessingPlant,
@@ -551,7 +551,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
REQUIRE_FALSE(
@@ -569,7 +569,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -591,7 +591,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -617,7 +617,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -638,7 +638,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprin
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
// Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1).
@@ -661,7 +661,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
@@ -689,7 +689,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a constru
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -711,7 +711,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -734,7 +734,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -764,7 +764,7 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);

View File

@@ -17,4 +17,6 @@ add_files(
ShipyardTest.cpp
BlueprintTest.cpp
BlueprintSerializerTest.cpp
ModuleConfigTest.cpp
ShipModuleTest.cpp
)

View File

@@ -52,7 +52,7 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
// Spawn an enemy combat ship close to the player side.
@@ -114,7 +114,7 @@ TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]"
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
@@ -163,7 +163,7 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true);
@@ -344,7 +344,7 @@ TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
@@ -401,7 +401,7 @@ TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
@@ -455,7 +455,7 @@ TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[comb
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
@@ -502,7 +502,7 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);

View File

@@ -0,0 +1,57 @@
#include "catch.hpp"
#include "ConfigLoader.h"
#include "ModulesConfig.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]")
{
const GameConfig cfg = loadConfig();
REQUIRE(cfg.modules.modules.size() >= 2);
const ModuleDef& armor = cfg.modules.modules[0];
CHECK(armor.id == "armor_plate");
CHECK(armor.surfaceMask.size() == 1);
CHECK(armor.surfaceMask[0] == "OO");
CHECK(armor.materials.size() == 1);
CHECK(armor.materials[0].item == "iron_ingot");
CHECK(armor.materials[0].amount == 2);
CHECK(armor.playerProductionLevel == 1);
CHECK(armor.productionTimeSeconds == Approx(3.0));
CHECK(armor.threatCost == Approx(2.0));
CHECK(armor.fillColor == "#808080");
CHECK(armor.glyph == "A");
REQUIRE(armor.statModifiers.size() == 1);
CHECK(armor.statModifiers[0].stat == "hp");
CHECK(armor.statModifiers[0].modifierType == "multiplicative");
CHECK(armor.statModifiers[0].formula.evaluate(1.0) == Approx(1.5));
}
TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modules]")
{
const GameConfig cfg = loadConfig();
REQUIRE(cfg.modules.modules.size() >= 2);
const ModuleDef& sensor = cfg.modules.modules[1];
CHECK(sensor.id == "sensor_booster");
REQUIRE(sensor.statModifiers.size() == 1);
CHECK(sensor.statModifiers[0].stat == "sensor_range");
CHECK(sensor.statModifiers[0].modifierType == "additive");
CHECK(sensor.statModifiers[0].formula.evaluate(1.0) == Approx(10.0));
}
TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]")
{
const GameConfig cfg = loadConfig();
REQUIRE(!cfg.ships.ships.empty());
const ShipDef& ship = cfg.ships.ships[0];
REQUIRE(!ship.layout.empty());
CHECK(ship.layout[0] == "XOX");
CHECK(ship.layout[1] == "OOO");
CHECK(ship.layout[2] == "XOX");
}

287
src/test/ShipModuleTest.cpp Normal file
View File

@@ -0,0 +1,287 @@
#include "catch.hpp"
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "GameConfig.h"
#include "ItemType.h"
#include "ModulesConfig.h"
#include "Rotation.h"
#include "Ship.h"
#include "ShipLayout.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "Tick.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id)
{
for (const ShipDef& def : cfg.ships.ships)
{
if (def.id == id)
{
return &def;
}
}
return nullptr;
}
static const BuildingDef* findShipyardDef(const GameConfig& cfg)
{
for (const BuildingDef& def : cfg.buildings.buildings)
{
if (def.type == BuildingType::Shipyard)
{
return &def;
}
}
return nullptr;
}
static EntityId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{
return sim.buildings().placeImmediate(
BuildingType::Shipyard,
yardDef.surfaceMask,
QPoint(0, 0),
Rotation::East,
100.0f, 100.0f);
}
static void fillMaterials(Simulation& sim, EntityId yardId,
const ShipDef& def,
const ShipLayoutConfig& layout)
{
sim.buildings().forEachBuilding([&](Building& b) {
if (b.id != yardId)
{
return;
}
for (const RecipeIngredient& ing : def.schematic.materials)
{
b.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
for (const PlacedModule& pm : layout.placedModules)
{
for (const ModuleDef& modDef : sim.config().modules.modules)
{
if (modDef.id == pm.moduleId)
{
for (const RecipeIngredient& ing : modDef.materials)
{
b.inputBuffer.counts[ItemType{ing.item}] += ing.amount;
}
break;
}
}
}
});
}
// ---------------------------------------------------------------------------
// Ship stat modifiers
// ---------------------------------------------------------------------------
TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float expectedHp = static_cast<float>(def->health.hpFormula.evaluate(x));
const EntityId id = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, std::nullopt);
const Ship* ship = sim.ships().findShip(id);
REQUIRE(ship != nullptr);
CHECK(ship->maxHp == Approx(expectedHp));
}
TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float baseHp = static_cast<float>(def->health.hpFormula.evaluate(x));
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "armor_plate";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
const EntityId id = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout);
const Ship* ship = sim.ships().findShip(id);
REQUIRE(ship != nullptr);
// armor_plate has multiplied_hp_formula = "1.5"
// final = base * (1 + (1.5 - 1)) + 0 = base * 1.5
CHECK(ship->maxHp == Approx(baseHp * 1.5f));
CHECK(ship->hp == ship->maxHp);
}
TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float baseRange = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x));
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "sensor_booster";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
const EntityId id = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout);
const Ship* ship = sim.ships().findShip(id);
REQUIRE(ship != nullptr);
// sensor_booster has added_sensor_range_formula = "10"
// final = base * 1.0 + 10 = base + 10
CHECK(ship->sensorRange == Approx(baseRange + 10.0f));
}
TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float baseHp = static_cast<float>(def->health.hpFormula.evaluate(x));
ShipLayoutConfig layout;
for (int i = 0; i < 2; ++i)
{
PlacedModule pm;
pm.moduleId = "armor_plate";
pm.position = QPoint(i * 2, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
}
const EntityId id = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout);
const Ship* ship = sim.ships().findShip(id);
REQUIRE(ship != nullptr);
// Two armor_plates: each 1.5 multiplier
// total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0
// final = base * 2.0
CHECK(ship->maxHp == Approx(baseHp * 2.0f));
}
// ---------------------------------------------------------------------------
// Shipyard module integration
// ---------------------------------------------------------------------------
TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
const EntityId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor");
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "armor_plate";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(yardId, layout);
const Building* b = sim.buildings().findBuilding(yardId);
REQUIRE(b != nullptr);
// armor_plate needs 2 iron_ingot; interceptor needs 3 iron_ingot + 1 circuit_board
// Total iron_ingot = 5, buffer cap = 2 * 5 = 10
CHECK(b->inputBuffer.caps.at(ItemType{"iron_ingot"}) == 10);
CHECK(b->inputBuffer.caps.at(ItemType{"circuit_board"}) == 2);
}
TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
const EntityId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor");
// Fill materials and tick to start production.
ShipLayoutConfig emptyLayout;
fillMaterials(sim, yardId, *def, emptyLayout);
sim.tick();
const Building* b1 = sim.buildings().findBuilding(yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->production.has_value());
// Now set a layout — should cancel production.
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "sensor_booster";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(yardId, layout);
const Building* b2 = sim.buildings().findBuilding(yardId);
REQUIRE(b2 != nullptr);
CHECK_FALSE(b2->production.has_value());
}
TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
const EntityId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor");
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "armor_plate";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(yardId, layout);
const Building* b1 = sim.buildings().findBuilding(yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value());
sim.buildings().setRecipe(yardId, "destroyer");
const Building* b2 = sim.buildings().findBuilding(yardId);
REQUIRE(b2 != nullptr);
CHECK_FALSE(b2->shipLayout.has_value());
}