Allow creating blueprints from construction sites
Blueprint capture and the Create Blueprint enable check previously resolved each selected id via findBuilding() only, silently dropping construction sites. Extract the capture logic into two testable lib/sim helpers (captureBlueprintFromSelection, selectionHasPlaceableBuilding) that resolve each id as an operational building or a construction site alike, reusing readBuildingConfig for recipe/schematic/layout/splitter config. BlueprintPanel now delegates to them. Add regression tests covering site capture, site recipe capture, mixed operational+site selection, and the placeable-selection predicate. Implements REQ-UI-BLUEPRINT-CREATE / REQ-UI-BLUEPRINT-STORAGE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user