From 49e1c0ef20f3a834099f0b32460f2bf1c271e728 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Thu, 6 Aug 2026 19:59:41 +0200 Subject: [PATCH] let any blueprint transfer configuration, not just single-building ones Implements 0593f48. A coinciding building of a configurable type now takes the blueprint's settings whatever the blueprint's size, so dropping a constellation over a partial copy of itself configures what is already standing there instead of leaving it blank. The whole behaviour change is one branch in resolveBlueprintGhost: at a matching rotation, a coinciding building transfers if its type has settings and is a compatible overlap otherwise; at a differing rotation only a single-building blueprint still transfers. GameWorldView and WorldRenderer needed nothing -- they already switch on the action, already exclude transfers from the cost, and already tint them. blueprintHoldsOneBuilding survives only as that rotation carve-out, and the header now says so. Note for anyone testing this by hand: the carve-out is narrower than it reads. It is only reachable for footprints that survive rotation -- 1x1 bodies like the splitter, or fully filled symmetric ones. A miner's body is L-shaped ("AA" / "A>"), so a rotated miner ghost covers different tiles and coincides with nothing at all. That is the coincidence test doing its job, not these rules, but it cost one wrong test before it was noticed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K --- src/lib/sim/PlacementRules.cpp | 31 +++++++++------- src/lib/sim/PlacementRules.h | 8 +++-- src/test/BuildingTest.cpp | 64 +++++++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 24 deletions(-) diff --git a/src/lib/sim/PlacementRules.cpp b/src/lib/sim/PlacementRules.cpp index 97e54bc..28ace96 100644 --- a/src/lib/sim/PlacementRules.cpp +++ b/src/lib/sim/PlacementRules.cpp @@ -144,24 +144,29 @@ BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state, const Ga findCoincidingSameTypeBuilding(state, config, type, anchor, rotation); if (coinciding.has_value()) { - // A single configurable building hands its settings over, whatever way the target - // faces: a transfer never rotates anything, so the target's facing does not matter - // (REQ-UI-BLUEPRINT-TRANSFER). Tested before the overlap rule, which then governs - // only what this does not claim. - if (blueprintHoldsOneBuilding && isConfigurableBuildingType(type)) - { - return BlueprintGhostResolved{BlueprintGhostAction::Transfer, coinciding->id}; - } + const bool configurable = isConfigurableBuildingType(type); - // Otherwise the building the blueprint wants must already be there in full, - // facing the same way, because nothing here may re-orient it - // (REQ-UI-BLUEPRINT-OVERLAP). Tunnels are not excluded: nothing is rotated, so - // the reason for their REQ-BLD-ROTATE-IN-PLACE exception does not arise. + // The building the blueprint wants is already there, facing the same way. It + // takes the blueprint's settings if it has any to take (REQ-UI-BLUEPRINT-TRANSFER) + // and is otherwise left exactly as it is (REQ-UI-BLUEPRINT-OVERLAP). Tunnels are + // not excluded from the latter: nothing is rotated, so the reason for their + // REQ-BLD-ROTATE-IN-PLACE exception does not arise. if (coinciding->rotation == rotation) { - return BlueprintGhostResolved{BlueprintGhostAction::CompatibleOverlap, + return BlueprintGhostResolved{configurable + ? BlueprintGhostAction::Transfer + : BlueprintGhostAction::CompatibleOverlap, coinciding->id}; } + + // Facing the other way. Placement may not re-orient it, so this is invalid -- + // except for the deliberate one-click gesture of a single-building blueprint, + // where a transfer rotates nothing anyway and the target's facing is beside the + // point (REQ-UI-BLUEPRINT-TRANSFER). + if (blueprintHoldsOneBuilding && configurable) + { + return BlueprintGhostResolved{BlueprintGhostAction::Transfer, coinciding->id}; + } return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt}; } diff --git a/src/lib/sim/PlacementRules.h b/src/lib/sim/PlacementRules.h index a8a5c5a..085ed94 100644 --- a/src/lib/sim/PlacementRules.h +++ b/src/lib/sim/PlacementRules.h @@ -82,9 +82,11 @@ struct BlueprintGhostResolved }; // Classifies one ghost of a blueprint against the current factory state -// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). `blueprintHoldsOneBuilding` is -// the blueprint's stored size, counted before locked types are dropped, so the gesture -// does not change behavior as the player unlocks things. +// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). `blueprintHoldsOneBuilding` +// only widens the transfer rule to differently-facing targets; a same-facing target +// transfers whatever the blueprint's size. It is the blueprint's stored size, counted +// before locked types are dropped, so the gesture does not change behavior as the player +// unlocks things. // // Shared by the ghost coloring and the click path so a preview cannot disagree with what // the click then does -- the same reason resolveBeltDragPath is shared. diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index d7c7dc8..e13d898 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -1198,24 +1198,72 @@ TEST_CASE("resolveBlueprintGhost: a single building with no settings overlaps in == BlueprintGhostAction::Invalid); } -TEST_CASE("resolveBlueprintGhost: nothing in a multi-building blueprint transfers", +TEST_CASE("resolveBlueprintGhost: a constellation transfers onto a matching building", "[blueprint]") { - // Even a configurable building coinciding with its twin only overlaps once the - // blueprint holds more than one building (REQ-UI-BLUEPRINT-TRANSFER). + // Blueprint size does not gate the transfer itself: a configurable building already + // standing where the blueprint wants it, facing the same way, takes its settings + // whatever else the blueprint holds (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 = + const BlueprintGhostResolved resolved = resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East); - REQUIRE(matching.action == BlueprintGhostAction::CompatibleOverlap); - CHECK(*matching.targetId == id); + REQUIRE(resolved.action == BlueprintGhostAction::Transfer); + CHECK(*resolved.targetId == id); +} - // ... and a differently-facing twin blocks the whole constellation. - CHECK(resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::North).action +TEST_CASE("resolveBlueprintGhost: only a single-building blueprint ignores target rotation", + "[blueprint]") +{ + // The one thing blueprint size still decides. Inside a constellation a differently + // facing twin cannot be re-oriented, so it blocks the whole placement; alone, the + // gesture transfers anyway because it rotates nothing (REQ-UI-BLUEPRINT-TRANSFER). + // + // A splitter, because the question only arises for a footprint that survives + // rotation. A miner's body is L-shaped ("AA" / "A>"), so a rotated miner ghost covers + // different tiles and coincides with nothing at all -- invalid for a reason that has + // nothing to do with these rules. + PlacementFixture f; + + const BuildingId id = + f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0).value(); + + CHECK(resolveInConstellation(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North).action == BlueprintGhostAction::Invalid); + + const BlueprintGhostResolved alone = + resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North); + REQUIRE(alone.action == BlueprintGhostAction::Transfer); + CHECK(*alone.targetId == id); +} + +TEST_CASE("resolveBlueprintGhost: a constellation mixes transfers and plain overlaps", + "[blueprint]") +{ + // One drop can reconfigure some of the buildings already there while leaving others + // alone: the split is by whether the type has settings at all, not by blueprint size + // (REQ-UI-BLUEPRINT-OVERLAP). + PlacementFixture f; + + const BuildingId minerId = + f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value(); + const BuildingId smelterId = + f.bs.place(f.state, BuildingType::Smelter, QPoint(-5, 0), Rotation::East, 0).value(); + + const BlueprintGhostResolved miner = + resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East); + REQUIRE(miner.action == BlueprintGhostAction::Transfer); + CHECK(*miner.targetId == minerId); + + // A smelter runs an implicit recipe (REQ-BLD-SMELTER), so there is nothing to hand + // over and it is simply left as it is. + const BlueprintGhostResolved smelter = + resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East); + REQUIRE(smelter.action == BlueprintGhostAction::CompatibleOverlap); + CHECK(*smelter.targetId == smelterId); } TEST_CASE("resolveBlueprintGhost: an identical tunnel is a compatible overlap", "[blueprint]")