668 lines
24 KiB
C++
668 lines
24 KiB
C++
#include "catch.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <climits>
|
|
#include <vector>
|
|
|
|
#include <QPoint>
|
|
|
|
#include "Blueprint.h"
|
|
#include "Building.h"
|
|
#include "BuildingsConfig.h"
|
|
#include "BuildingSystem.h"
|
|
#include "BuildingType.h"
|
|
#include "ConfigLoader.h"
|
|
#include "BuildingId.h"
|
|
#include "Rotation.h"
|
|
#include "Simulation.h"
|
|
#include "SurfaceMask.h"
|
|
#include "Tick.h"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers that mirror the production implementations under test.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Mirror of the CW / CCW offset transforms in GameWorldView::keyPressEvent.
|
|
static QPoint rotateCW(QPoint p) { return QPoint(-p.y(), p.x()); }
|
|
static QPoint rotateCCW(QPoint p) { return QPoint( p.y(), -p.x()); }
|
|
|
|
// Mirror of the Rotation cycling in GameWorldView (anonymous-namespace helpers).
|
|
static Rotation rotCW(Rotation r)
|
|
{
|
|
switch (r)
|
|
{
|
|
case Rotation::North: return Rotation::East;
|
|
case Rotation::East: return Rotation::South;
|
|
case Rotation::South: return Rotation::West;
|
|
case Rotation::West: return Rotation::North;
|
|
}
|
|
return Rotation::East;
|
|
}
|
|
|
|
static Rotation rotCCW(Rotation r)
|
|
{
|
|
switch (r)
|
|
{
|
|
case Rotation::North: return Rotation::West;
|
|
case Rotation::East: return Rotation::North;
|
|
case Rotation::South: return Rotation::East;
|
|
case Rotation::West: return Rotation::South;
|
|
}
|
|
return Rotation::East;
|
|
}
|
|
|
|
// Mirror of BlueprintPanel::createBlueprintFromSelection: given per-building
|
|
// (anchor, bodyCells, type, rotation), compute Blueprint with floor-division
|
|
// bounding-box center and per-building tile offsets.
|
|
struct BuildingSpec
|
|
{
|
|
QPoint anchor;
|
|
std::vector<QPoint> bodyCells;
|
|
BuildingType type;
|
|
Rotation rotation;
|
|
std::string recipeId; // empty = none
|
|
};
|
|
|
|
static Blueprint buildBlueprint(const std::vector<BuildingSpec>& specs)
|
|
{
|
|
int minX = INT_MAX, maxX = INT_MIN;
|
|
int minY = INT_MAX, maxY = INT_MIN;
|
|
for (const BuildingSpec& s : specs)
|
|
{
|
|
for (const QPoint& cell : s.bodyCells)
|
|
{
|
|
minX = std::min(minX, cell.x());
|
|
maxX = std::max(maxX, cell.x());
|
|
minY = std::min(minY, cell.y());
|
|
maxY = std::max(maxY, cell.y());
|
|
}
|
|
}
|
|
const QPoint center((minX + maxX) / 2, (minY + maxY) / 2);
|
|
|
|
Blueprint bp;
|
|
for (const BuildingSpec& s : specs)
|
|
{
|
|
BlueprintBuilding bb;
|
|
bb.type = s.type;
|
|
bb.rotation = s.rotation;
|
|
bb.offset = s.anchor - center;
|
|
bb.recipeId = s.recipeId;
|
|
bp.buildings.push_back(bb);
|
|
}
|
|
return bp;
|
|
}
|
|
|
|
// Apply one CW/CCW rotation to every building in the constellation, mirroring
|
|
// the corrected Q / E handling in GameWorldView::keyPressEvent.
|
|
// Each building's anchor is recomputed from the rotated body cells so that
|
|
// multi-tile footprints stay correctly aligned after rotation.
|
|
static void applyRotationCW(Blueprint& bp, const GameConfig& cfg)
|
|
{
|
|
for (BlueprintBuilding& bb : bp.buildings)
|
|
{
|
|
const BuildingDef* def = nullptr;
|
|
for (const BuildingDef& d : cfg.buildings.buildings)
|
|
{
|
|
if (d.type == bb.type) { def = &d; break; }
|
|
}
|
|
if (!def) { continue; }
|
|
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
|
|
int minX = INT_MAX, minY = INT_MAX;
|
|
for (const QPoint& cell : mask.bodyCells)
|
|
{
|
|
const QPoint abs = bb.offset + cell;
|
|
minX = std::min(minX, -abs.y());
|
|
minY = std::min(minY, abs.x());
|
|
}
|
|
bb.offset = QPoint(minX, minY);
|
|
bb.rotation = rotCW(bb.rotation);
|
|
}
|
|
}
|
|
|
|
static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
|
|
{
|
|
for (BlueprintBuilding& bb : bp.buildings)
|
|
{
|
|
const BuildingDef* def = nullptr;
|
|
for (const BuildingDef& d : cfg.buildings.buildings)
|
|
{
|
|
if (d.type == bb.type) { def = &d; break; }
|
|
}
|
|
if (!def) { continue; }
|
|
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
|
|
int minX = INT_MAX, minY = INT_MAX;
|
|
for (const QPoint& cell : mask.bodyCells)
|
|
{
|
|
const QPoint abs = bb.offset + cell;
|
|
minX = std::min(minX, abs.y());
|
|
minY = std::min(minY, -abs.x());
|
|
}
|
|
bb.offset = QPoint(minX, minY);
|
|
bb.rotation = rotCCW(bb.rotation);
|
|
}
|
|
}
|
|
|
|
static GameConfig loadConfig()
|
|
{
|
|
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
|
}
|
|
|
|
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter:
|
|
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
|
|
// are silently excluded before the bounding-box center and offsets are computed.
|
|
static Blueprint buildBlueprintFiltered(const std::vector<BuildingSpec>& specs,
|
|
const GameConfig& cfg)
|
|
{
|
|
std::vector<BuildingSpec> filtered;
|
|
for (const BuildingSpec& s : specs)
|
|
{
|
|
for (const BuildingDef& def : cfg.buildings.buildings)
|
|
{
|
|
if (def.type == s.type)
|
|
{
|
|
if (def.playerPlaceable) { filtered.push_back(s); }
|
|
break;
|
|
}
|
|
}
|
|
// If the type is not in buildings (e.g. Hq, defence stations), it is skipped.
|
|
}
|
|
return buildBlueprint(filtered);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Offset computation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Blueprint: single 1x1 building gets zero offset", "[blueprint]")
|
|
{
|
|
// Body = one tile at (-5, 3). Center = (-5, 3). Offset = (0, 0).
|
|
const BuildingSpec spec{ QPoint(-5, 3), {QPoint(-5, 3)}, BuildingType::Belt, Rotation::East };
|
|
const Blueprint bp = buildBlueprint({ spec });
|
|
|
|
REQUIRE(bp.buildings.size() == 1);
|
|
REQUIRE(bp.buildings[0].offset == QPoint(0, 0));
|
|
}
|
|
|
|
TEST_CASE("Blueprint: two 1x1 buildings with odd span get symmetric offsets", "[blueprint]")
|
|
{
|
|
// Anchors at (-6, 0) and (-4, 0). bboxX = [-6, -4], center.x = -5.
|
|
// Offsets: -6 - (-5) = -1, -4 - (-5) = +1.
|
|
const BuildingSpec left { QPoint(-6, 0), {QPoint(-6, 0)}, BuildingType::Belt, Rotation::East };
|
|
const BuildingSpec right{ QPoint(-4, 0), {QPoint(-4, 0)}, BuildingType::Belt, Rotation::East };
|
|
const Blueprint bp = buildBlueprint({ left, right });
|
|
|
|
REQUIRE(bp.buildings[0].offset == QPoint(-1, 0));
|
|
REQUIRE(bp.buildings[1].offset == QPoint( 1, 0));
|
|
}
|
|
|
|
TEST_CASE("Blueprint: even span uses C++ integer truncation for center", "[blueprint]")
|
|
{
|
|
// Anchors at (-5, 0) and (-4, 0). bboxX = [-5, -4], sum = -9.
|
|
// center.x = -9 / 2 = -4 (C++ truncates toward zero, i.e. rounds up for negatives).
|
|
// Offsets: -5 - (-4) = -1, -4 - (-4) = 0.
|
|
const BuildingSpec left { QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
|
|
const BuildingSpec right{ QPoint(-4, 0), {QPoint(-4, 0)}, BuildingType::Belt, Rotation::East };
|
|
const Blueprint bp = buildBlueprint({ left, right });
|
|
|
|
REQUIRE(bp.buildings[0].offset == QPoint(-1, 0));
|
|
REQUIRE(bp.buildings[1].offset == QPoint( 0, 0));
|
|
}
|
|
|
|
TEST_CASE("Blueprint: bounding box is computed from body cells, not anchors alone", "[blueprint]")
|
|
{
|
|
// A 2x1 building: anchor (-6, 0), body cells (-6, 0) and (-5, 0).
|
|
// A 1x1 building: anchor (-3, 0), body cell (-3, 0).
|
|
// All body cells: {(-6,0),(-5,0),(-3,0)}. bboxX = [-6, -3], sum = -9.
|
|
// center.x = -9 / 2 = -4 (C++ truncation).
|
|
// Wide building anchor offset: -6 - (-4) = -2.
|
|
// Small building anchor offset: -3 - (-4) = +1.
|
|
const BuildingSpec wide{
|
|
QPoint(-6, 0),
|
|
{ QPoint(-6, 0), QPoint(-5, 0) },
|
|
BuildingType::Belt,
|
|
Rotation::East
|
|
};
|
|
const BuildingSpec small{
|
|
QPoint(-3, 0),
|
|
{ QPoint(-3, 0) },
|
|
BuildingType::Belt,
|
|
Rotation::East
|
|
};
|
|
const Blueprint bp = buildBlueprint({ wide, small });
|
|
|
|
REQUIRE(bp.buildings[0].offset == QPoint(-2, 0));
|
|
REQUIRE(bp.buildings[1].offset == QPoint( 1, 0));
|
|
}
|
|
|
|
TEST_CASE("Blueprint: 2-D bounding box with buildings on both axes", "[blueprint]")
|
|
{
|
|
// Four 1x1 buildings at corners of a 4x2 rectangle (-7,0),(-5,0),(-7,2),(-5,2).
|
|
// bboxX = [-7, -5], center.x = -6. bboxY = [0, 2], center.y = 1.
|
|
// Offsets: (-1,-1), (1,-1), (-1,1), (1,1).
|
|
const auto belt = BuildingType::Belt;
|
|
const auto east = Rotation::East;
|
|
std::vector<BuildingSpec> specs = {
|
|
{ QPoint(-7, 0), {QPoint(-7, 0)}, belt, east },
|
|
{ QPoint(-5, 0), {QPoint(-5, 0)}, belt, east },
|
|
{ QPoint(-7, 2), {QPoint(-7, 2)}, belt, east },
|
|
{ QPoint(-5, 2), {QPoint(-5, 2)}, belt, east },
|
|
};
|
|
const Blueprint bp = buildBlueprint(specs);
|
|
|
|
REQUIRE(bp.buildings[0].offset == QPoint(-1, -1));
|
|
REQUIRE(bp.buildings[1].offset == QPoint( 1, -1));
|
|
REQUIRE(bp.buildings[2].offset == QPoint(-1, 1));
|
|
REQUIRE(bp.buildings[3].offset == QPoint( 1, 1));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Offset rotation math
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Blueprint: CW rotation in screen space maps right→down, down→left", "[blueprint]")
|
|
{
|
|
// Screen space has Y growing downward. CW means: right→down, down→left, left→up, up→right.
|
|
REQUIRE(rotateCW(QPoint( 1, 0)) == QPoint( 0, 1));
|
|
REQUIRE(rotateCW(QPoint( 0, 1)) == QPoint(-1, 0));
|
|
REQUIRE(rotateCW(QPoint(-1, 0)) == QPoint( 0, -1));
|
|
REQUIRE(rotateCW(QPoint( 0, -1)) == QPoint( 1, 0));
|
|
}
|
|
|
|
TEST_CASE("Blueprint: CCW rotation is the inverse of CW rotation", "[blueprint]")
|
|
{
|
|
REQUIRE(rotateCCW(QPoint( 1, 0)) == QPoint( 0, -1));
|
|
REQUIRE(rotateCCW(QPoint( 0, -1)) == QPoint(-1, 0));
|
|
REQUIRE(rotateCCW(QPoint(-1, 0)) == QPoint( 0, 1));
|
|
REQUIRE(rotateCCW(QPoint( 0, 1)) == QPoint( 1, 0));
|
|
}
|
|
|
|
TEST_CASE("Blueprint: four CW rotations restore any offset to its original", "[blueprint]")
|
|
{
|
|
const QPoint original(-3, 5);
|
|
QPoint p = original;
|
|
for (int i = 0; i < 4; ++i) { p = rotateCW(p); }
|
|
REQUIRE(p == original);
|
|
}
|
|
|
|
TEST_CASE("Blueprint: CW followed by CCW is identity", "[blueprint]")
|
|
{
|
|
const QPoint original(2, -7);
|
|
REQUIRE(rotateCCW(rotateCW(original)) == original);
|
|
REQUIRE(rotateCW(rotateCCW(original)) == original);
|
|
}
|
|
|
|
TEST_CASE("Blueprint: non-axis-aligned offset rotates correctly", "[blueprint]")
|
|
{
|
|
// (2, 3) → CW → (-3, 2) → CW → (-2, -3) → CW → (3, -2) → CW → (2, 3)
|
|
QPoint p(2, 3);
|
|
p = rotateCW(p); REQUIRE(p == QPoint(-3, 2));
|
|
p = rotateCW(p); REQUIRE(p == QPoint(-2, -3));
|
|
p = rotateCW(p); REQUIRE(p == QPoint( 3, -2));
|
|
p = rotateCW(p); REQUIRE(p == QPoint( 2, 3));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constellation rotation: offsets AND building rotations both update
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]")
|
|
{
|
|
const GameConfig cfg = loadConfig();
|
|
// Building one tile to the right, facing East.
|
|
Blueprint bp;
|
|
bp.name = "test";
|
|
BlueprintBuilding bb;
|
|
bb.type = BuildingType::Belt;
|
|
bb.rotation = Rotation::East;
|
|
bb.offset = QPoint(1, 0);
|
|
bp.buildings.push_back(bb);
|
|
|
|
applyRotationCW(bp, cfg);
|
|
|
|
// Offset: right → down.
|
|
REQUIRE(bp.buildings[0].offset == QPoint(0, 1));
|
|
// Building rotation: East → South.
|
|
REQUIRE(bp.buildings[0].rotation == Rotation::South);
|
|
}
|
|
|
|
TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]")
|
|
{
|
|
const GameConfig cfg = loadConfig();
|
|
Blueprint bp;
|
|
bp.name = "test";
|
|
BlueprintBuilding bb;
|
|
bb.type = BuildingType::Belt;
|
|
bb.rotation = Rotation::East;
|
|
bb.offset = QPoint(1, 0);
|
|
bp.buildings.push_back(bb);
|
|
|
|
applyRotationCCW(bp, cfg);
|
|
|
|
// Offset: right → up.
|
|
REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
|
|
// Building rotation: East → North.
|
|
REQUIRE(bp.buildings[0].rotation == Rotation::North);
|
|
}
|
|
|
|
TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]")
|
|
{
|
|
const GameConfig cfg = loadConfig();
|
|
Blueprint bp;
|
|
bp.name = "test";
|
|
BlueprintBuilding bb;
|
|
bb.type = BuildingType::Belt;
|
|
bb.rotation = Rotation::East;
|
|
bb.offset = QPoint(2, -1);
|
|
bp.buildings.push_back(bb);
|
|
|
|
const QPoint originalOffset = bb.offset;
|
|
const Rotation originalRotation = bb.rotation;
|
|
|
|
for (int i = 0; i < 4; ++i) { applyRotationCW(bp, cfg); }
|
|
|
|
REQUIRE(bp.buildings[0].offset == originalOffset);
|
|
REQUIRE(bp.buildings[0].rotation == originalRotation);
|
|
}
|
|
|
|
TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]")
|
|
{
|
|
const GameConfig cfg = loadConfig();
|
|
// Two buildings left and right of center; after CW they should be above and below.
|
|
Blueprint bp;
|
|
bp.name = "test";
|
|
BlueprintBuilding left, right;
|
|
left.type = right.type = BuildingType::Belt;
|
|
left.rotation = right.rotation = Rotation::East;
|
|
left.offset = QPoint(-1, 0);
|
|
right.offset = QPoint( 1, 0);
|
|
bp.buildings = { left, right };
|
|
|
|
applyRotationCW(bp, cfg);
|
|
|
|
// left (-1, 0) → CW → (0, -1) (above center)
|
|
// right ( 1, 0) → CW → (0, 1) (below center)
|
|
REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
|
|
REQUIRE(bp.buildings[1].offset == QPoint(0, 1));
|
|
REQUIRE(bp.buildings[0].rotation == Rotation::South);
|
|
REQUIRE(bp.buildings[1].rotation == Rotation::South);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Regression: multi-tile building rotation (the anchor-offset-only bug)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[blueprint]")
|
|
{
|
|
const GameConfig cfg = loadConfig();
|
|
|
|
// East miner: anchor (0,0), body cells (0,0),(1,0),(0,1).
|
|
// Output port indicator '>' at (1,1) → port tile (1,1), direction East.
|
|
// Belt placed at (1,1) — the port exit tile.
|
|
// Constellation body cells: {(0,0),(1,0),(0,1),(1,1)}.
|
|
// Integer center: ((0+1)/2, (0+1)/2) = (0,0).
|
|
// Miner offset (0,0), belt offset (1,1).
|
|
Blueprint bp;
|
|
bp.name = "test";
|
|
BlueprintBuilding miner;
|
|
miner.type = BuildingType::Miner;
|
|
miner.rotation = Rotation::East;
|
|
miner.offset = QPoint(0, 0);
|
|
BlueprintBuilding belt;
|
|
belt.type = BuildingType::Belt;
|
|
belt.rotation = Rotation::East;
|
|
belt.offset = QPoint(1, 1); // port exit tile of East miner relative to its anchor
|
|
bp.buildings = { miner, belt };
|
|
|
|
applyRotationCW(bp, cfg);
|
|
|
|
// Miner body cells (0,0),(1,0),(0,1) rotated CW: (0,0),(0,1),(-1,0).
|
|
// New miner anchor = min(-1..0, 0..1) = (-1, 0).
|
|
REQUIRE(bp.buildings[0].offset == QPoint(-1, 0));
|
|
REQUIRE(bp.buildings[0].rotation == Rotation::South);
|
|
|
|
// Belt body cell (1,1) rotated CW: (-1, 1). New belt anchor = (-1, 1).
|
|
REQUIRE(bp.buildings[1].offset == QPoint(-1, 1));
|
|
REQUIRE(bp.buildings[1].rotation == Rotation::South);
|
|
|
|
// South miner port tile relative to its anchor is (0,1) (the 'v' indicator in ["AA","vA"]).
|
|
// Miner anchor in constellation = (-1,0). Port exit = (-1,0)+(0,1) = (-1,1) = belt anchor. ✓
|
|
REQUIRE(bp.buildings[1].offset == bp.buildings[0].offset + QPoint(0, 1));
|
|
}
|
|
|
|
TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[blueprint]")
|
|
{
|
|
const GameConfig cfg = loadConfig();
|
|
|
|
Blueprint bp;
|
|
bp.name = "test";
|
|
BlueprintBuilding miner;
|
|
miner.type = BuildingType::Miner;
|
|
miner.rotation = Rotation::East;
|
|
miner.offset = QPoint(0, 0);
|
|
BlueprintBuilding belt;
|
|
belt.type = BuildingType::Belt;
|
|
belt.rotation = Rotation::East;
|
|
belt.offset = QPoint(1, 1);
|
|
bp.buildings = { miner, belt };
|
|
|
|
applyRotationCCW(bp, cfg);
|
|
|
|
// Miner body cells (0,0),(1,0),(0,1) rotated CCW: (0,0),(0,-1),(1,0).
|
|
// New miner anchor = min(0..1, -1..0) = (0, -1).
|
|
REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
|
|
REQUIRE(bp.buildings[0].rotation == Rotation::North);
|
|
|
|
// Belt body cell (1,1) rotated CCW: (1,-1). New belt anchor = (1,-1).
|
|
REQUIRE(bp.buildings[1].offset == QPoint(1, -1));
|
|
REQUIRE(bp.buildings[1].rotation == Rotation::North);
|
|
|
|
// North miner port tile relative to its anchor is (1,0) (the '^' indicator in ["A^","AA"]).
|
|
// Miner anchor in constellation = (0,-1). Port exit = (0,-1)+(1,0) = (1,-1) = belt anchor. ✓
|
|
REQUIRE(bp.buildings[1].offset == bp.buildings[0].offset + QPoint(1, 0));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Player-placeable filter in blueprint creation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty blueprint",
|
|
"[blueprint]")
|
|
{
|
|
const GameConfig cfg = loadConfig();
|
|
|
|
// Hq has no entry in buildings.toml, so it is treated as non-player-placeable.
|
|
const BuildingSpec hq{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Hq, Rotation::East };
|
|
const Blueprint bp = buildBlueprintFiltered({ hq }, cfg);
|
|
|
|
REQUIRE(bp.buildings.empty());
|
|
}
|
|
|
|
TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable buildings",
|
|
"[blueprint]")
|
|
{
|
|
const GameConfig cfg = loadConfig();
|
|
|
|
const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
|
|
const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East };
|
|
const Blueprint bp = buildBlueprintFiltered({ belt, hq }, cfg);
|
|
|
|
REQUIRE(bp.buildings.size() == 1);
|
|
REQUIRE(bp.buildings[0].type == BuildingType::Belt);
|
|
}
|
|
|
|
TEST_CASE("Blueprint creation: bounding box ignores non-player-placeable buildings",
|
|
"[blueprint]")
|
|
{
|
|
const GameConfig cfg = loadConfig();
|
|
|
|
// Belt at (-5, 0). HQ at (-3, 0) — excluded from the blueprint.
|
|
// If HQ were included: bboxX = [-5, -3], center.x = -4, belt offset = -1.
|
|
// With HQ excluded: bboxX = [-5, -5], center.x = -5, belt offset = 0.
|
|
const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
|
|
const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East };
|
|
const Blueprint bp = buildBlueprintFiltered({ belt, hq }, cfg);
|
|
|
|
REQUIRE(bp.buildings.size() == 1);
|
|
REQUIRE(bp.buildings[0].offset == QPoint(0, 0));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Simulation-level: blueprint placement places buildings at correct tiles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor", "[blueprint]")
|
|
{
|
|
// Simulate placing a two-belt blueprint with offsets (-1, 0) and (+1, 0)
|
|
// at cursor tile (-5, 0). Expected anchors: (-6, 0) and (-4, 0).
|
|
// (Belt surface_mask ["A>"] — body at relative (0,0), port at (1,0).)
|
|
Simulation sim(loadConfig());
|
|
|
|
const QPoint cursor(-5, 0);
|
|
const QPoint offsetA(-1, 0);
|
|
const QPoint offsetB( 1, 0);
|
|
|
|
const BuildingId idA = sim.tryPlaceBuilding(
|
|
BuildingType::Belt, cursor + offsetA, Rotation::East);
|
|
const BuildingId idB = sim.tryPlaceBuilding(
|
|
BuildingType::Belt, cursor + offsetB, Rotation::East);
|
|
|
|
REQUIRE(idA != kInvalidBuildingId);
|
|
REQUIRE(idB != kInvalidBuildingId);
|
|
REQUIRE(sim.buildings().isTileOccupied(cursor + offsetA)); // (-6, 0)
|
|
REQUIRE(sim.buildings().isTileOccupied(cursor + offsetB)); // (-4, 0)
|
|
REQUIRE_FALSE(sim.buildings().isTileOccupied(cursor)); // center not occupied
|
|
}
|
|
|
|
TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]")
|
|
{
|
|
Simulation sim(loadConfig());
|
|
|
|
// Find belt cost from config (belt cost = 2 in test config).
|
|
int beltCost = 0;
|
|
for (const BuildingDef& def : sim.config().buildings.buildings)
|
|
{
|
|
if (def.type == BuildingType::Belt) { beltCost = def.cost; break; }
|
|
}
|
|
REQUIRE(beltCost > 0);
|
|
|
|
const int startBlocks = sim.buildingBlocksStock();
|
|
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
|
|
|
|
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-6, 0), Rotation::East);
|
|
REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost);
|
|
|
|
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-4, 0), Rotation::East);
|
|
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
|
|
}
|
|
|
|
TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId and deducts nothing",
|
|
"[blueprint]")
|
|
{
|
|
Simulation sim(loadConfig());
|
|
|
|
// Find miner cost (15 in test config) — expensive enough to exhaust a small stock.
|
|
int minerCost = 0;
|
|
for (const BuildingDef& def : sim.config().buildings.buildings)
|
|
{
|
|
if (def.type == BuildingType::Miner) { minerCost = def.cost; break; }
|
|
}
|
|
REQUIRE(minerCost > 0);
|
|
|
|
// Drain the stock by placing miners until we no longer have enough.
|
|
// Non-overlapping columns: miner body is 2 wide, so step by 2.
|
|
int col = -2;
|
|
while (sim.buildingBlocksStock() >= minerCost)
|
|
{
|
|
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(col, 0), Rotation::East);
|
|
col -= 2;
|
|
}
|
|
|
|
const int blocksBeforeAttempt = sim.buildingBlocksStock();
|
|
const BuildingId id = sim.tryPlaceBuilding(
|
|
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
|
|
|
|
// Placement must fail and leave the stock unchanged.
|
|
REQUIRE(id == kInvalidBuildingId);
|
|
REQUIRE(sim.buildingBlocksStock() == blocksBeforeAttempt);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Recipe / schematic capture and re-application
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Blueprint: recipeId is stored in BlueprintBuilding", "[blueprint]")
|
|
{
|
|
const BuildingSpec spec{
|
|
QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Miner, Rotation::East,
|
|
"mine_iron_ore"
|
|
};
|
|
const Blueprint bp = buildBlueprint({ spec });
|
|
|
|
REQUIRE(bp.buildings.size() == 1);
|
|
REQUIRE(bp.buildings[0].recipeId == "mine_iron_ore");
|
|
}
|
|
|
|
TEST_CASE("Blueprint: building with no recipe has empty recipeId", "[blueprint]")
|
|
{
|
|
const BuildingSpec spec{
|
|
QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East
|
|
// recipeId defaults to ""
|
|
};
|
|
const Blueprint bp = buildBlueprint({ spec });
|
|
|
|
REQUIRE(bp.buildings[0].recipeId.empty());
|
|
}
|
|
|
|
TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "[blueprint]")
|
|
{
|
|
Simulation sim(loadConfig());
|
|
|
|
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
|
|
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
|
REQUIRE(id != kInvalidBuildingId);
|
|
|
|
sim.buildings().setRecipe(id, "mine_iron_ore");
|
|
|
|
const ConstructionSite* site = sim.buildings().findSite(id);
|
|
REQUIRE(site != nullptr);
|
|
REQUIRE(site->recipeId == "mine_iron_ore");
|
|
}
|
|
|
|
TEST_CASE("Blueprint placement: recipe transfers to building after construction completes",
|
|
"[blueprint]")
|
|
{
|
|
Simulation sim(loadConfig());
|
|
|
|
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
|
REQUIRE(id != kInvalidBuildingId);
|
|
sim.buildings().setRecipe(id, "mine_copper_ore");
|
|
|
|
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
|
|
// Run 301 ticks (0..300) to process the completion tick.
|
|
for (int i = 0; i <= static_cast<int>(secondsToTicks(10.0)); ++i)
|
|
{
|
|
sim.tick();
|
|
}
|
|
|
|
const Building* b = sim.buildings().findBuilding(id);
|
|
REQUIRE(b != nullptr);
|
|
REQUIRE(b->recipeId == "mine_copper_ore");
|
|
}
|
|
|
|
TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start", "[blueprint]")
|
|
{
|
|
// "interceptor" has available_from_start = true in the test config.
|
|
// This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics.
|
|
Simulation sim(loadConfig());
|
|
REQUIRE(sim.isSchematicUnlocked("interceptor"));
|
|
}
|
|
|
|
TEST_CASE("Blueprint placement: repair_ship schematic is locked at game start", "[blueprint]")
|
|
{
|
|
// "repair_ship" has available_from_start = false in the test config.
|
|
// This confirms the guard in placeBlueprintAtTile blocks locked schematics,
|
|
// leaving the shipyard's schematic unset.
|
|
Simulation sim(loadConfig());
|
|
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
|
|
}
|