Clearing a shipyard with "(None)" opened the layout configuration dialog on a
grid of no cells. The auto-open guard asked only whether the chosen id differs
from the current one, and the "(None)" option carries the empty id, which
differs from every schematic; ShipLayoutDialog then found no ship def and
derived a 0x0 grid. Predates this branch -- 698dd4d, 2026-07-13.
The question all three sites were answering by hand is now one:
findLayoutShipDef() returns the ship to configure a layout against, or nullptr
when no schematic is set, the id names no ship, or the ship defines no grid.
The auto-open path and the LayoutDialogRequestedEvent handler now ask it
before opening, and ShipyardContent asks it instead of spelling the same test
out for the preview and the Configure button.
The event handler was reachable only through a button ShipyardContent already
disables, so guarding it changes nothing today; it is guarded because the
dialog's precondition belongs to the dialog's entry, not to the widget that
happens to be the only caller.
REQ-MOD-UI-AUTO-DIALOG said "differs" and left clearing implicit, which is the
reading the code took. It now says clearing opens nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
721 lines
27 KiB
C++
721 lines
27 KiB
C++
#include "catch.hpp"
|
|
#include "FactoryQueries.h"
|
|
|
|
#include "Building.h"
|
|
#include "BuildingSystem.h"
|
|
#include "BuildingType.h"
|
|
#include "ConfigLoader.h"
|
|
#include "DynamicBodyComponent.h"
|
|
#include "EntityAdmin.h"
|
|
#include "FactionComponent.h"
|
|
#include "GameConfig.h"
|
|
#include "HealthComponent.h"
|
|
#include "ItemType.h"
|
|
#include "ModuleOwnerComponent.h"
|
|
#include "ModulesConfig.h"
|
|
#include "Rotation.h"
|
|
#include "SensorRangeComponent.h"
|
|
#include "ShipIdentityComponent.h"
|
|
#include "ShipLayout.h"
|
|
#include "ShipStatsCalculator.h"
|
|
#include "ShipSystem.h"
|
|
#include "Simulation.h"
|
|
#include "SimulationTestAccess.h"
|
|
#include "Tick.h"
|
|
#include "WeaponComponent.h"
|
|
#include "TestConfig.h"
|
|
|
|
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 entt::entity findFirstWeaponChild(EntityAdmin& admin, entt::entity ship)
|
|
{
|
|
entt::entity result = entt::null;
|
|
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
|
|
[&](entt::entity ce, const WeaponComponent&, const ModuleOwnerComponent& o)
|
|
{
|
|
if (o.owner == ship && result == entt::null) { result = ce; }
|
|
});
|
|
return result;
|
|
}
|
|
|
|
static const BuildingDef* findShipyardDef(const GameConfig& cfg)
|
|
{
|
|
for (const BuildingDef& def : cfg.buildings.buildings)
|
|
{
|
|
if (def.type == BuildingType::Shipyard)
|
|
{
|
|
return &def;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
|
{
|
|
return SimulationTestAccess::buildings(sim).placeImmediate(SimulationTestAccess::state(sim), BuildingType::Shipyard,
|
|
yardDef.surfaceMask,
|
|
QPoint(0, 0),
|
|
Rotation::East);
|
|
}
|
|
|
|
static void fillMaterials(Simulation& sim, BuildingId yardId,
|
|
const ShipDef& def,
|
|
const ShipLayoutConfig& layout)
|
|
{
|
|
SimulationTestAccess::buildings(sim).forEachBuilding(SimulationTestAccess::state(sim), [&](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.getConfig().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(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
const float expectedHp = static_cast<float>(def->health.hp);
|
|
|
|
const entt::entity e = sim.getShips().spawn("interceptor",
|
|
QVector2D(5.0f, 5.0f), false, std::nullopt);
|
|
|
|
REQUIRE(sim.getAdmin().isValid(e));
|
|
CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(expectedHp));
|
|
}
|
|
|
|
TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
const float baseHp = static_cast<float>(def->health.hp);
|
|
|
|
ShipLayoutConfig layout;
|
|
PlacedModule pm;
|
|
pm.moduleId = "armor_plate";
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
layout.placedModules.push_back(pm);
|
|
|
|
const entt::entity e = sim.getShips().spawn("interceptor",
|
|
QVector2D(5.0f, 5.0f), false, layout);
|
|
|
|
REQUIRE(sim.getAdmin().isValid(e));
|
|
// armor_plate has multiplied_hp_formula = "1.5"
|
|
// final = base * (1 + (1.5 - 1)) + 0 = base * 1.5
|
|
CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(baseHp * 1.5f));
|
|
CHECK(sim.getAdmin().get<HealthComponent>(e).hp == sim.getAdmin().get<HealthComponent>(e).maxHp);
|
|
}
|
|
|
|
TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
|
|
const float baseRange_tiles = static_cast<float>(def->sensor.sensorRange_m) / tileSize;
|
|
|
|
ShipLayoutConfig layout;
|
|
PlacedModule pm;
|
|
pm.moduleId = "sensor_booster";
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
layout.placedModules.push_back(pm);
|
|
|
|
const entt::entity e = sim.getShips().spawn("interceptor",
|
|
QVector2D(5.0f, 5.0f), false, layout);
|
|
|
|
REQUIRE(sim.getAdmin().isValid(e));
|
|
// sensor_booster has added_sensor_range_m_formula = "100" m → 100/10 = 10 tiles
|
|
// final = baseRange_tiles * 1.0 + 10 = baseRange_tiles + 10
|
|
CHECK(sim.getAdmin().get<SensorRangeComponent>(e).value_tiles == Approx(baseRange_tiles + 10.0f));
|
|
}
|
|
|
|
TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
const float baseHp = static_cast<float>(def->health.hp);
|
|
|
|
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 entt::entity e = sim.getShips().spawn("interceptor",
|
|
QVector2D(5.0f, 5.0f), false, layout);
|
|
|
|
REQUIRE(sim.getAdmin().isValid(e));
|
|
// Two armor_plates: each 1.5 multiplier
|
|
// total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0
|
|
// final = base * 2.0
|
|
CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(baseHp * 2.0f));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shipyard module integration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
|
"[modules][shipyard]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
|
|
|
|
ShipLayoutConfig layout;
|
|
PlacedModule pm;
|
|
pm.moduleId = "armor_plate";
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
layout.placedModules.push_back(pm);
|
|
|
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
|
|
|
|
const Building* b = findBuilding(sim.getFactoryState(), 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(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
|
|
|
|
// Fill materials and tick to start production.
|
|
ShipLayoutConfig emptyLayout;
|
|
fillMaterials(sim, yardId, *def, emptyLayout);
|
|
sim.tick();
|
|
|
|
const Building* b1 = findBuilding(sim.getFactoryState(), 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);
|
|
|
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
|
|
|
|
const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
|
|
REQUIRE(b2 != nullptr);
|
|
CHECK_FALSE(b2->production.has_value());
|
|
}
|
|
|
|
// Applying a configuration a building already has is a no-op (REQ-MAT-INPUT-BUFFER).
|
|
// setRecipe has always worked this way; setShipLayout did not, and wiped the shipyard
|
|
// on every re-apply. That matters now that a blueprint configuration transfer
|
|
// (REQ-UI-BLUEPRINT-TRANSFER) can be clicked repeatedly onto matching shipyards.
|
|
|
|
TEST_CASE("Shipyard: re-applying the same layout keeps production and buffers",
|
|
"[modules][shipyard]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
|
|
|
|
ShipLayoutConfig layout;
|
|
PlacedModule pm;
|
|
pm.moduleId = "armor_plate";
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
layout.placedModules.push_back(pm);
|
|
|
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
|
|
fillMaterials(sim, yardId, *def, layout);
|
|
sim.tick();
|
|
|
|
const Building* before = findBuilding(sim.getFactoryState(), yardId);
|
|
REQUIRE(before != nullptr);
|
|
REQUIRE(before->production.has_value());
|
|
const Tick completesAt = before->production->completesAt;
|
|
|
|
// A separately built but equal layout: equality is by value, not by identity.
|
|
ShipLayoutConfig sameLayout;
|
|
sameLayout.placedModules.push_back(pm);
|
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, sameLayout);
|
|
|
|
const Building* after = findBuilding(sim.getFactoryState(), yardId);
|
|
REQUIRE(after != nullptr);
|
|
REQUIRE(after->production.has_value());
|
|
CHECK(after->production->completesAt == completesAt);
|
|
CHECK(after->inputBuffer.caps.at(ItemType{"iron_ingot"}) == 10);
|
|
}
|
|
|
|
TEST_CASE("Shipyard: an empty layout on an unconfigured shipyard changes nothing",
|
|
"[modules][shipyard]")
|
|
{
|
|
// An unset layout and an empty one are the same state everywhere that matters, so
|
|
// clearing an already-unconfigured shipyard must not cancel its cycle. This is the
|
|
// case a transfer from a layout-less source shipyard produces.
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
|
|
|
|
ShipLayoutConfig emptyLayout;
|
|
fillMaterials(sim, yardId, *def, emptyLayout);
|
|
sim.tick();
|
|
|
|
const Building* before = findBuilding(sim.getFactoryState(), yardId);
|
|
REQUIRE(before != nullptr);
|
|
REQUIRE(before->production.has_value());
|
|
REQUIRE_FALSE(before->shipLayout.has_value());
|
|
|
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, emptyLayout);
|
|
|
|
const Building* after = findBuilding(sim.getFactoryState(), yardId);
|
|
REQUIRE(after != nullptr);
|
|
CHECK(after->production.has_value());
|
|
CHECK_FALSE(after->shipLayout.has_value());
|
|
}
|
|
|
|
TEST_CASE("Shipyard: the same modules in a different order count as a change",
|
|
"[modules][shipyard]")
|
|
{
|
|
// Layout equality is deliberately order-sensitive (see ShipLayout.h): the safe
|
|
// answer when in doubt is "changed", which costs a buffer reset rather than
|
|
// leaving a shipyard building the wrong ship.
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
|
|
|
|
PlacedModule armor;
|
|
armor.moduleId = "armor_plate";
|
|
armor.position = QPoint(0, 0);
|
|
armor.rotation = Rotation::East;
|
|
PlacedModule sensor;
|
|
sensor.moduleId = "sensor_booster";
|
|
sensor.position = QPoint(1, 0);
|
|
sensor.rotation = Rotation::East;
|
|
|
|
ShipLayoutConfig layout;
|
|
layout.placedModules.push_back(armor);
|
|
layout.placedModules.push_back(sensor);
|
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
|
|
fillMaterials(sim, yardId, *def, layout);
|
|
sim.tick();
|
|
REQUIRE(findBuilding(sim.getFactoryState(), yardId)->production.has_value());
|
|
|
|
ShipLayoutConfig reordered;
|
|
reordered.placedModules.push_back(sensor);
|
|
reordered.placedModules.push_back(armor);
|
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, reordered);
|
|
|
|
CHECK_FALSE(findBuilding(sim.getFactoryState(), yardId)->production.has_value());
|
|
}
|
|
|
|
TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
|
|
"[modules][shipyard]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
// The schematic carries a weapon in its (wave-only) default loadout. This
|
|
// test pins that a player shipyard with no configured layout does NOT hand
|
|
// that weapon out for free: it builds an unarmed bare hull, matching the
|
|
// base-hull materials it was charged.
|
|
REQUIRE_FALSE(def->defaultModules.empty());
|
|
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
|
|
// Deliberately no setShipLayout: recipe set, layout left unconfigured.
|
|
|
|
// Charge only the base-hull materials (an empty layout adds none).
|
|
fillMaterials(sim, yardId, *def, ShipLayoutConfig{});
|
|
|
|
// Tick through one full production cycle so the ship spawns.
|
|
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
|
|
for (Tick i = 0; i <= cycleTicks; ++i)
|
|
{
|
|
sim.tick();
|
|
}
|
|
|
|
// Locate the freshly built player ship.
|
|
entt::entity built = entt::null;
|
|
sim.getAdmin().forEach<ShipIdentityComponent, FactionComponent>(
|
|
[&](entt::entity e, const ShipIdentityComponent& si, const FactionComponent& fac)
|
|
{
|
|
if (!fac.isEnemy && si.schematicId == "interceptor") { built = e; }
|
|
});
|
|
REQUIRE(sim.getAdmin().isValid(built));
|
|
|
|
// Bare hull: the schematic's default weapon must NOT have been installed.
|
|
const bool hasWeapon =
|
|
findFirstWeaponChild(sim.getAdmin(), built) != entt::null;
|
|
CHECK_FALSE(hasWeapon);
|
|
}
|
|
|
|
TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
|
|
|
|
ShipLayoutConfig layout;
|
|
PlacedModule pm;
|
|
pm.moduleId = "armor_plate";
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
layout.placedModules.push_back(pm);
|
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
|
|
|
|
const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
|
|
REQUIRE(b1 != nullptr);
|
|
REQUIRE(b1->shipLayout.has_value());
|
|
|
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"destroyer");
|
|
|
|
const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
|
|
REQUIRE(b2 != nullptr);
|
|
CHECK_FALSE(b2->shipLayout.has_value());
|
|
}
|
|
|
|
TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
|
|
"[modules][shipyard]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
|
|
|
|
ShipLayoutConfig layout;
|
|
PlacedModule pm;
|
|
pm.moduleId = "armor_plate";
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
layout.placedModules.push_back(pm);
|
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
|
|
|
|
const Building* b1 = findBuilding(sim.getFactoryState(), yardId);
|
|
REQUIRE(b1 != nullptr);
|
|
REQUIRE(b1->shipLayout.has_value());
|
|
|
|
// Re-selecting the same recipe must be a no-op and preserve the layout.
|
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId,"interceptor");
|
|
|
|
const Building* b2 = findBuilding(sim.getFactoryState(), yardId);
|
|
REQUIRE(b2 != nullptr);
|
|
REQUIRE(b2->shipLayout.has_value());
|
|
REQUIRE(b2->shipLayout->placedModules.size() == 1);
|
|
CHECK(b2->shipLayout->placedModules[0].moduleId == "armor_plate");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Weapon modifier simulation tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[modules]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
ShipLayoutConfig layout;
|
|
for (const std::string& id : {"laser_cannon", "weapon_primer"})
|
|
{
|
|
PlacedModule pm;
|
|
pm.moduleId = id;
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
layout.placedModules.push_back(pm);
|
|
}
|
|
|
|
const entt::entity ship = sim.getShips().spawn("interceptor",
|
|
QVector2D(5.0f, 5.0f), false, layout);
|
|
|
|
const entt::entity weapon = findFirstWeaponChild(sim.getAdmin(), ship);
|
|
REQUIRE(sim.getAdmin().isValid(weapon));
|
|
// base rate = 2.0 hz; weapon_primer multiplier = 1.2 → 2.4 hz
|
|
CHECK(sim.getAdmin().get<WeaponComponent>(weapon).fireRateHz == Approx(2.4f));
|
|
}
|
|
|
|
TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation", "[modules]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
|
|
|
|
ShipLayoutConfig layout;
|
|
for (const std::string& id : {"laser_cannon", "weapon_stabilizer"})
|
|
{
|
|
PlacedModule pm;
|
|
pm.moduleId = id;
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
layout.placedModules.push_back(pm);
|
|
}
|
|
|
|
const entt::entity ship = sim.getShips().spawn("interceptor",
|
|
QVector2D(5.0f, 5.0f), false, layout);
|
|
|
|
const entt::entity weapon = findFirstWeaponChild(sim.getAdmin(), ship);
|
|
REQUIRE(sim.getAdmin().isValid(weapon));
|
|
// base range = 50 m / tileSize = 5 tiles; weapon_stabilizer multiplier = 1.5 → 7.5 tiles
|
|
CHECK(sim.getAdmin().get<WeaponComponent>(weapon).range_tiles == Approx(50.0f / tileSize * 1.5f));
|
|
}
|
|
|
|
TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s² to tiles/tick", "[modules]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
|
|
const float tickRate = static_cast<float>(kTickRateHz);
|
|
const float base_mpss = static_cast<float>(def->movement.mainAcceleration_mpss);
|
|
|
|
ShipLayoutConfig layout;
|
|
PlacedModule pm;
|
|
pm.moduleId = "afterburner";
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
layout.placedModules.push_back(pm);
|
|
|
|
const entt::entity e = sim.getShips().spawn("interceptor",
|
|
QVector2D(5.0f, 5.0f), false, layout);
|
|
|
|
// added_main_acceleration_mpss = 60; same conversion as base: / tileSize / tickRate
|
|
const float expected = (base_mpss + 60.0f) / tileSize / tickRate;
|
|
CHECK(sim.getAdmin().get<DynamicBodyComponent>(e).mainAcceleration_tptt == Approx(expected));
|
|
}
|
|
|
|
TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/tick", "[modules]")
|
|
{
|
|
Simulation sim(loadTestConfig(), 42);
|
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
|
|
const float tickRate = static_cast<float>(kTickRateHz);
|
|
const float base_mpss = static_cast<float>(def->movement.maneuveringAcceleration_mpss);
|
|
|
|
ShipLayoutConfig layout;
|
|
PlacedModule pm;
|
|
pm.moduleId = "maneuvering_thrusters";
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
layout.placedModules.push_back(pm);
|
|
|
|
const entt::entity e = sim.getShips().spawn("interceptor",
|
|
QVector2D(5.0f, 5.0f), false, layout);
|
|
|
|
// added_maneuvering_acceleration_mpss = 10; same conversion as base: / tileSize / tickRate
|
|
const float expected = (base_mpss + 10.0f) / tileSize / tickRate;
|
|
CHECK(sim.getAdmin().get<DynamicBodyComponent>(e).maneuveringAcceleration_tptt == Approx(expected));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Weapon modifier stats view tests (calculateShipStats)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("calculateShipStats: weapon_primer multiplies attack rate in stats view", "[modules]")
|
|
{
|
|
const GameConfig cfg = loadTestConfig();
|
|
const ShipDef* def = findSchematic(cfg, "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
std::vector<PlacedModule> modules;
|
|
for (const std::string& id : {"laser_cannon", "weapon_primer"})
|
|
{
|
|
PlacedModule pm;
|
|
pm.moduleId = id;
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
modules.push_back(pm);
|
|
}
|
|
|
|
const ShipStats stats = calculateShipStats(cfg, "interceptor",
|
|
modules);
|
|
|
|
REQUIRE(stats.weapons.has_value());
|
|
// base: damage = 2, rate = 2.0 hz; weapon_primer multiplies rate by 1.2 → DPS = 2 * 2.4 = 4.8
|
|
CHECK(stats.weapons->combinedDps == Approx(4.8f));
|
|
}
|
|
|
|
TEST_CASE("calculateShipStats: weapon_stabilizer multiplies attack range in stats view", "[modules]")
|
|
{
|
|
const GameConfig cfg = loadTestConfig();
|
|
const ShipDef* def = findSchematic(cfg, "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
const float tileSize = static_cast<float>(cfg.world.tileSize_m);
|
|
|
|
std::vector<PlacedModule> modules;
|
|
for (const std::string& id : {"laser_cannon", "weapon_stabilizer"})
|
|
{
|
|
PlacedModule pm;
|
|
pm.moduleId = id;
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
modules.push_back(pm);
|
|
}
|
|
|
|
const ShipStats stats = calculateShipStats(cfg, "interceptor",
|
|
modules);
|
|
|
|
REQUIRE(stats.weapons.has_value());
|
|
// base range = 50 m / tileSize; weapon_stabilizer multiplier = 1.5
|
|
CHECK(stats.weapons->maxRange_tiles == Approx(50.0f / tileSize * 1.5f));
|
|
}
|
|
|
|
TEST_CASE("calculateShipStats: afterburner additive main_acceleration is converted m/s² to tiles/s²", "[modules]")
|
|
{
|
|
const GameConfig cfg = loadTestConfig();
|
|
const ShipDef* def = findSchematic(cfg, "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
const float tileSize = static_cast<float>(cfg.world.tileSize_m);
|
|
const float base_mpss = static_cast<float>(def->movement.mainAcceleration_mpss);
|
|
|
|
std::vector<PlacedModule> modules;
|
|
PlacedModule pm;
|
|
pm.moduleId = "afterburner";
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
modules.push_back(pm);
|
|
|
|
const ShipStats stats = calculateShipStats(cfg, "interceptor",
|
|
modules);
|
|
|
|
// added_main_acceleration_mpss = 60; converted to tiles/s²: / tileSize
|
|
const float expected = (base_mpss + 60.0f) / tileSize;
|
|
CHECK(stats.mainAcceleration_tpss == Approx(expected));
|
|
}
|
|
|
|
TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/s²", "[modules]")
|
|
{
|
|
const GameConfig cfg = loadTestConfig();
|
|
const ShipDef* def = findSchematic(cfg, "interceptor");
|
|
REQUIRE(def != nullptr);
|
|
|
|
const float tileSize = static_cast<float>(cfg.world.tileSize_m);
|
|
const float base_mpss = static_cast<float>(def->movement.maneuveringAcceleration_mpss);
|
|
|
|
std::vector<PlacedModule> modules;
|
|
PlacedModule pm;
|
|
pm.moduleId = "maneuvering_thrusters";
|
|
pm.position = QPoint(0, 0);
|
|
pm.rotation = Rotation::East;
|
|
modules.push_back(pm);
|
|
|
|
const ShipStats stats = calculateShipStats(cfg, "interceptor",
|
|
modules);
|
|
|
|
// added_maneuvering_acceleration_mpss = 10; converted to tiles/s²: / tileSize
|
|
const float expected = (base_mpss + 10.0f) / tileSize;
|
|
CHECK(stats.maneuveringAcceleration_tpss == Approx(expected));
|
|
}
|
|
|
|
// The precondition every entry to the layout configuration dialog checks
|
|
// (REQ-MOD-UI-DIALOG, REQ-MOD-UI-AUTO-DIALOG, REQ-MOD-UI-PREVIEW). The empty id is the
|
|
// one that used to slip through: "(None)" clears a shipyard and differs from whatever
|
|
// was set, which is not the same as naming a schematic to configure.
|
|
TEST_CASE("ShipsConfig: a layout is configurable only for a ship that has one",
|
|
"[modules][config]")
|
|
{
|
|
const GameConfig cfg = loadTestConfig();
|
|
|
|
const ShipDef* interceptor = cfg.ships.findLayoutShipDef("interceptor");
|
|
REQUIRE(interceptor != nullptr);
|
|
CHECK(interceptor->id == "interceptor");
|
|
CHECK_FALSE(interceptor->layout.empty());
|
|
|
|
// The "(None)" option's id, and an id naming no ship at all.
|
|
CHECK(cfg.ships.findLayoutShipDef("") == nullptr);
|
|
CHECK(cfg.ships.findLayoutShipDef("no_such_ship") == nullptr);
|
|
|
|
// A ship that exists but defines no grid has nothing to place modules on either,
|
|
// where plain findShipDef still finds it.
|
|
ShipsConfig gridless;
|
|
ShipDef def;
|
|
def.id = "hull_only";
|
|
gridless.ships.push_back(def);
|
|
CHECK(gridless.findShipDef("hull_only") != nullptr);
|
|
CHECK(gridless.findLayoutShipDef("hull_only") == nullptr);
|
|
}
|