Allow creating blueprints from construction sites

This commit is contained in:
2026-07-13 20:50:13 +02:00
parent 698dd4d13d
commit 8c4fb78fc9
5 changed files with 225 additions and 69 deletions

View File

@@ -1,10 +1,55 @@
#include "BuildingConfig.h"
#include <algorithm>
#include <climits>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "Simulation.h"
namespace
{
// The blueprint-relevant geometry shared by operational buildings and construction
// sites. Resolved from whichever of the two a selected id refers to.
struct SelectedBuilding
{
BuildingId id;
BuildingType type;
Rotation rotation;
QPoint anchor;
const std::vector<QPoint>* bodyCells;
};
// Resolves a selected id to a player-placeable building or construction site, if it
// is one. Returns std::nullopt for an unknown id or a non-player-placeable building
// (the HQ and defence stations, per REQ-UI-BLUEPRINT-CREATE).
std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, BuildingId id)
{
const Building* building = sim.buildings().findBuilding(id);
const ConstructionSite* site = building ? nullptr : sim.buildings().findSite(id);
if (!building && !site)
{
return std::nullopt;
}
const BuildingType type = building ? building->type : site->type;
const BuildingDef* def = sim.config().buildings.findBuildingDef(type);
if (!def || !def->playerPlaceable)
{
return std::nullopt;
}
SelectedBuilding resolved;
resolved.id = id;
resolved.type = type;
resolved.rotation = building ? building->rotation : site->rotation;
resolved.anchor = building ? building->anchor : site->anchor;
resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells;
return resolved;
}
} // namespace
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
{
const Building* building = sim.buildings().findBuilding(id);
@@ -49,3 +94,74 @@ std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, Building
return config;
}
Blueprint captureBlueprintFromSelection(const Simulation& sim,
const std::vector<BuildingId>& selectedIds)
{
std::vector<SelectedBuilding> entries;
entries.reserve(selectedIds.size());
for (const BuildingId id : selectedIds)
{
const std::optional<SelectedBuilding> resolved = resolvePlaceable(sim, id);
if (resolved.has_value())
{
entries.push_back(*resolved);
}
}
if (entries.empty())
{
return Blueprint{};
}
int minX = INT_MAX, maxX = INT_MIN;
int minY = INT_MAX, maxY = INT_MIN;
for (const SelectedBuilding& e : entries)
{
for (const QPoint& cell : *e.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 blueprint;
blueprint.buildings.reserve(entries.size());
for (const SelectedBuilding& e : entries)
{
BlueprintBuilding building;
building.type = e.type;
building.rotation = e.rotation;
building.offset = e.anchor - center;
// Recipe / schematic / layout / splitter-filter capture is shared with the
// copy-settings gesture (REQ-BLD-COPY-CONFIG) via readBuildingConfig, which
// handles operational buildings and construction sites alike.
const std::optional<BuildingConfig> config = readBuildingConfig(sim, e.id);
if (config.has_value())
{
building.recipeId = config->recipeId.value_or(std::string());
building.shipLayout = config->shipLayout;
building.splitterFilterA = config->splitterFilterA;
building.splitterFilterB = config->splitterFilterB;
}
blueprint.buildings.push_back(building);
}
return blueprint;
}
bool selectionHasPlaceableBuilding(const Simulation& sim,
const std::vector<BuildingId>& selectedIds)
{
for (const BuildingId id : selectedIds)
{
if (resolvePlaceable(sim, id).has_value())
{
return true;
}
}
return false;
}

View File

@@ -4,6 +4,7 @@
#include <string>
#include <vector>
#include "Blueprint.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "ItemType.h"
@@ -38,3 +39,16 @@ struct BuildingConfig
// by id, handling operational buildings and sites alike. Returns std::nullopt if
// no such building or site exists.
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id);
// Captures a blueprint from a selection of building / construction-site ids, keeping
// only player-placeable buildings and recording each one's type, rotation, offset
// from the selection's bounding-box center, and configuration. Operational buildings
// and construction sites are treated identically (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE). The returned blueprint is unnamed.
Blueprint captureBlueprintFromSelection(const Simulation& sim,
const std::vector<BuildingId>& selectedIds);
// True if any selected id refers to a player-placeable building or construction site
// (the enable condition for the Create Blueprint button, REQ-UI-BLUEPRINT-CREATE).
bool selectionHasPlaceableBuilding(const Simulation& sim,
const std::vector<BuildingId>& selectedIds);

View File

@@ -8,6 +8,7 @@
#include "Blueprint.h"
#include "Building.h"
#include "BuildingConfig.h"
#include "BuildingsConfig.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
@@ -696,6 +697,92 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
REQUIRE(b->recipeId == "mine_copper_ore");
}
// ---------------------------------------------------------------------------
// Blueprint capture from construction sites (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE): a still-under-construction building is a valid
// blueprint source, captured identically to an operational building.
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
{
Simulation sim(loadConfig());
// Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps
// the body-cell bounding-box centered on the anchor, so a single site → zero offset.
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildings().findSite(id) != nullptr);
REQUIRE(sim.buildings().findBuilding(id) == nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { id });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].type == BuildingType::Belt);
REQUIRE(bp.buildings[0].offset == QPoint(0, 0)); // single 1x1 building → zero offset
}
TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blueprint]")
{
Simulation sim(loadConfig());
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
const Blueprint bp = captureBlueprintFromSelection(sim, { id });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].recipeId == "mine_iron_ore");
}
TEST_CASE("Blueprint creation: mixed operational building and construction site are both captured",
"[blueprint]")
{
Simulation sim(loadConfig());
// Building A: place, configure, and tick to completion so it is operational.
const BuildingId idA =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(idA != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idA, "mine_iron_ore");
for (int i = 0; i <= static_cast<int>(secondsToTicks(10.0)); ++i) { sim.tick(); }
REQUIRE(sim.buildings().findBuilding(idA) != nullptr);
// Building B: place and configure, but leave as a construction site.
const BuildingId idB =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East);
REQUIRE(idB != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idB, "mine_copper_ore");
REQUIRE(sim.buildings().findSite(idB) != nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { idA, idB });
REQUIRE(bp.buildings.size() == 2);
REQUIRE(bp.buildings[0].type == BuildingType::Miner);
REQUIRE(bp.buildings[1].type == BuildingType::Miner);
// Both the operational building's and the site's configs come through.
std::vector<std::string> recipes = { bp.buildings[0].recipeId, bp.buildings[1].recipeId };
std::sort(recipes.begin(), recipes.end());
REQUIRE(recipes == std::vector<std::string>{ "mine_copper_ore", "mine_iron_ore" });
// Distinct anchors → distinct offsets.
REQUIRE(bp.buildings[0].offset != bp.buildings[1].offset);
}
TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction site", "[blueprint]")
{
Simulation sim(loadConfig());
REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {}));
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildings().findSite(id) != nullptr);
REQUIRE(selectionHasPlaceableBuilding(sim, { id }));
}
TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start", "[blueprint]")
{
// "interceptor" has unlock_at_station_level = -1 in the test config.

View File

@@ -146,60 +146,10 @@ void BlueprintPanel::onBlueprintButtonClicked(int index)
Blueprint BlueprintPanel::createBlueprintFromSelection() const
{
struct Entry
{
const Building* building;
};
std::vector<Entry> entries;
entries.reserve(m_selectedBuildingIds.size());
for (const BuildingId id : m_selectedBuildingIds)
{
const Building* b = m_sim->buildings().findBuilding(id);
if (!b) { continue; }
const BuildingDef* def = m_config->buildings.findBuildingDef(b->type);
if (def && def->playerPlaceable) { entries.push_back({ b }); }
}
if (entries.empty()) { return Blueprint{}; }
int minX = INT_MAX, maxX = INT_MIN;
int minY = INT_MAX, maxY = INT_MIN;
for (const Entry& e : entries)
{
for (const QPoint& cell : e.building->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;
bp.buildings.reserve(entries.size());
for (const Entry& e : entries)
{
BlueprintBuilding bb;
bb.type = e.building->type;
bb.rotation = e.building->rotation;
bb.offset = e.building->anchor - center;
// Recipe / schematic / layout / splitter-filter capture is shared with the
// copy-settings gesture (REQ-BLD-COPY-CONFIG) via readBuildingConfig.
const std::optional<BuildingConfig> config =
readBuildingConfig(*m_sim, e.building->id);
if (config.has_value())
{
bb.recipeId = config->recipeId.value_or(std::string());
bb.shipLayout = config->shipLayout;
bb.splitterFilterA = config->splitterFilterA;
bb.splitterFilterB = config->splitterFilterB;
}
bp.buildings.push_back(bb);
}
return bp;
// Capture is shared, testable logic in lib/sim: it resolves each selected id as an
// operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE).
return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
}
int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const
@@ -295,19 +245,8 @@ void BlueprintPanel::loadFromDisk()
void BlueprintPanel::refreshButtonStates()
{
const bool anyPlaceable = [&]() {
for (const BuildingId id : m_selectedBuildingIds)
{
const Building* b = m_sim->buildings().findBuilding(id);
if (!b) { continue; }
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == b->type) { return def.playerPlaceable; }
}
}
return false;
}();
m_createBtn->setEnabled(anyPlaceable);
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
m_createBtn->setEnabled(selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds));
for (int i = 0; i < static_cast<int>(m_blueprintButtons.size()); ++i)
{