implement blueprint compatible overlap and configuration transfer

Implements the two rules specified in 128bf81, which restore the
copy-settings capability removed in 648241b as a property of blueprint
placement: press C on one configured building, then click same-type
buildings to stamp its settings across the factory.

The ghost colour and the click path already shared one predicate,
canPlaceBuilding, which is what kept preview and outcome from disagreeing.
The new rules make that answer four-valued, so the shared predicate becomes
a shared classifier: resolveBlueprintGhost in PlacementRules returns
PlaceNew / CompatibleOverlap / Transfer / Invalid, and both callers switch
on it. Putting it in lib rather than in the view is the whole testability
story -- src/ui is off the test include path.

findRotateInPlaceTarget is now the tunnel guard plus a shared
findCoincidingSameTypeBuilding core, so the two rules cannot drift apart;
its existing tests pass untouched. Blueprint placement no longer emits
RotateInPlaceCommand at all. Builder mode and the belt drag are unchanged.

transferConfigTo sends every field unconditionally, so a field the
blueprint stores nothing for clears the target's rather than leaving it.
The simulation's unchanged-value guards absorb the no-op case, which is
what keeps clicking an already-matching building free of buffer and
production-progress loss.

drawBuildingGhost's bool valid becomes GhostTint{Normal,Invalid,Transfer};
the transfer colour is taken at full RGB like the invalid one so it does
not double-dim against the ghost opacity. visuals.toml regains the overlay
colour under the name config_transfer, with its VisualsConfig field and
loader line -- overlay keys are mandatory, so the three move together.

The six new resolveBlueprintGhost cases failed on first run because the
test buildings were anchored in space: BuildingSystem::place skips the
terrain rules, resolveBlueprintGhost applies them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
This commit is contained in:
2026-08-06 19:20:34 +02:00
parent 250da8c9aa
commit 3cf35b669e
12 changed files with 508 additions and 59 deletions

View File

@@ -1039,6 +1039,206 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
REQUIRE(*result == id);
}
// ---------------------------------------------------------------------------
// resolveBlueprintGhost
// ---------------------------------------------------------------------------
// What a blueprint ghost does where it meets an existing building
// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). Blueprint placement never
// rotates anything, so a coinciding building must either take the blueprint's settings
// or already match it exactly.
namespace
{
BlueprintGhostResolved resolveOne(const PlacementFixture& f, BuildingType type,
QPoint anchor, Rotation rotation)
{
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation,
/*blueprintHoldsOneBuilding*/ true);
}
BlueprintGhostResolved resolveInConstellation(const PlacementFixture& f, BuildingType type,
QPoint anchor, Rotation rotation)
{
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation,
/*blueprintHoldsOneBuilding*/ false);
}
} // namespace
TEST_CASE("isConfigurableBuildingType: only types with player-facing settings",
"[blueprint]")
{
// The gate on whether a single-building blueprint transfers anything at all.
CHECK(isConfigurableBuildingType(BuildingType::Miner));
CHECK(isConfigurableBuildingType(BuildingType::Assembler));
CHECK(isConfigurableBuildingType(BuildingType::Shipyard));
CHECK(isConfigurableBuildingType(BuildingType::Splitter));
// Smelter and Reprocessing Plant run implicit recipes (REQ-BLD-SMELTER,
// REQ-BLD-REPROCESSING) and the rest have no settings whatsoever.
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Smelter));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::ReprocessingPlant));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::SalvageBay));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Belt));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelEntry));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelExit));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Hq));
}
TEST_CASE("resolveBlueprintGhost: free valid cells place a new building", "[blueprint]")
{
PlacementFixture f;
// Anchored on the asteroid (x < 0): a miner is all-asteroid cells. BuildingSystem's
// place() skips the terrain rules, but resolveBlueprintGhost applies them.
const BlueprintGhostResolved resolved =
resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
CHECK(resolved.action == BlueprintGhostAction::PlaceNew);
CHECK_FALSE(resolved.targetId.has_value());
}
TEST_CASE("resolveBlueprintGhost: terrain-invalid positions are invalid", "[blueprint]")
{
PlacementFixture f;
// A miner is all-asteroid (A) cells, so it cannot sit out in space (x >= 0).
CHECK(resolveOne(f, BuildingType::Miner, QPoint(5, 0), Rotation::East).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: overlapping a different building type is invalid",
"[blueprint]")
{
PlacementFixture f;
f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0);
CHECK(resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::East).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: a partial overlap of the same type is invalid",
"[blueprint]")
{
PlacementFixture f;
// Smelter at (-3,0) covers (-3,0),(-2,0),(-3,1),(-2,1); a ghost at (-2,0) covers only
// two of those, so it coincides with nothing and is an ordinary occupied overlap.
// Both footprints stay on the asteroid, so terrain is not what fails here.
f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);
CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its settings",
"[blueprint]")
{
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
const BlueprintGhostResolved resolved =
resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
REQUIRE(resolved.targetId.has_value());
CHECK(*resolved.targetId == id);
}
TEST_CASE("resolveBlueprintGhost: a transfer ignores the target's rotation", "[blueprint]")
{
// The rule transfer does not share with compatible overlap: a transfer never rotates
// anything, so which way the target faces cannot matter (REQ-UI-BLUEPRINT-TRANSFER).
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0).value();
const BlueprintGhostResolved resolved =
resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North);
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
REQUIRE(resolved.targetId.has_value());
CHECK(*resolved.targetId == id);
}
TEST_CASE("resolveBlueprintGhost: a construction site is a transfer target too",
"[blueprint]")
{
PlacementFixture f;
// Not ticked to completion, so it is still queued (REQ-BLD-SITE-CONFIG).
const BuildingId id =
f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value();
REQUIRE_FALSE(getAllSites(f.state).empty());
const BlueprintGhostResolved resolved =
resolveOne(f, BuildingType::Assembler, QPoint(-3, 0), Rotation::East);
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
CHECK(*resolved.targetId == id);
}
TEST_CASE("resolveBlueprintGhost: a single building with no settings overlaps instead",
"[blueprint]")
{
// A belt carries nothing to transfer, so the same footprint is a compatible overlap
// when the facings match -- and invalid when they do not, since nothing here may
// re-orient it (REQ-UI-BLUEPRINT-OVERLAP).
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0).value();
const BlueprintGhostResolved matching =
resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::East);
REQUIRE(matching.action == BlueprintGhostAction::CompatibleOverlap);
CHECK(*matching.targetId == id);
CHECK(resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::North).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: nothing in a multi-building blueprint transfers",
"[blueprint]")
{
// Even a configurable building coinciding with its twin only overlaps once the
// blueprint holds more than one building (REQ-UI-BLUEPRINT-TRANSFER).
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
const BlueprintGhostResolved matching =
resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(matching.action == BlueprintGhostAction::CompatibleOverlap);
CHECK(*matching.targetId == id);
// ... and a differently-facing twin blocks the whole constellation.
CHECK(resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::North).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: an identical tunnel is a compatible overlap", "[blueprint]")
{
// findRotateInPlaceTarget refuses tunnels because re-orienting one is unsupported.
// Nothing is re-oriented here, so that reason does not apply and the tunnel the
// blueprint wants -- already there, same facing -- is simply left alone.
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0).value();
f.bs.place(f.state, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0);
REQUIRE_FALSE(
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East)
.has_value());
const BlueprintGhostResolved resolved =
resolveInConstellation(f, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East);
REQUIRE(resolved.action == BlueprintGhostAction::CompatibleOverlap);
CHECK(*resolved.targetId == id);
}
// ---------------------------------------------------------------------------
// rotateInPlace
// ---------------------------------------------------------------------------