From 3cf35b669e6c5982ac7497afe9ac6a22d47b0c3b Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Thu, 6 Aug 2026 19:20:34 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K --- bin/app/data/config/visuals.toml | 1 + src/lib/core/BuildingType.cpp | 14 +++ src/lib/core/BuildingType.h | 6 + src/lib/sim/PlacementRules.cpp | 83 +++++++++++-- src/lib/sim/PlacementRules.h | 50 +++++++- src/test/BuildingTest.cpp | 200 +++++++++++++++++++++++++++++++ src/ui/GameWorldView.cpp | 137 ++++++++++++++++----- src/ui/GameWorldView.h | 10 ++ src/ui/VisualsConfig.h | 1 + src/ui/VisualsLoader.cpp | 1 + src/ui/WorldRenderer.cpp | 52 +++++--- src/ui/WorldRenderer.h | 12 +- 12 files changed, 508 insertions(+), 59 deletions(-) diff --git a/bin/app/data/config/visuals.toml b/bin/app/data/config/visuals.toml index 513e49b..2669cfa 100644 --- a/bin/app/data/config/visuals.toml +++ b/bin/app/data/config/visuals.toml @@ -348,6 +348,7 @@ deconstruct_tint = "#ff000033" # deconstruct-mode hover tint selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT) tile_highlight = "#ffffff22" # tile under cursor selected_outline = "#ffff00" # outline drawn around currently-selected building(s) +config_transfer = "#33ccff66" # blueprint ghost over a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER) locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion) modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM) tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE) diff --git a/src/lib/core/BuildingType.cpp b/src/lib/core/BuildingType.cpp index bbbb819..8682360 100644 --- a/src/lib/core/BuildingType.cpp +++ b/src/lib/core/BuildingType.cpp @@ -53,6 +53,20 @@ bool isBeltSubsystemType(BuildingType type) || type == BuildingType::TunnelExit; } +bool isConfigurableBuildingType(BuildingType type) +{ + switch (type) + { + case BuildingType::Miner: // recipe (REQ-BLD-MINER) + case BuildingType::Assembler: // recipe (REQ-BLD-ASSEMBLER) + case BuildingType::Shipyard: // schematic and layout (REQ-BLD-SHIPYARD, REQ-MOD-LAYOUT) + case BuildingType::Splitter: // output filters (REQ-BLD-SPLITTER) + return true; + default: + return false; + } +} + bool isProductionBuildingType(BuildingType type) { switch (type) diff --git a/src/lib/core/BuildingType.h b/src/lib/core/BuildingType.h index a54e696..a5c648e 100644 --- a/src/lib/core/BuildingType.h +++ b/src/lib/core/BuildingType.h @@ -43,3 +43,9 @@ bool isProductionBuildingType(BuildingType type); // rather than in the Building instance, so placing/removing them must register or // unregister a tile with BeltSystem. bool isBeltSubsystemType(BuildingType type); + +// Building types with player-facing settings that a blueprint can carry and hand to an +// existing building (REQ-UI-BLUEPRINT-TRANSFER): Miner and Assembler (recipe), Shipyard +// (schematic and module layout), Splitter (output filters). Every other type has nothing +// to configure, so a blueprint of one has nothing to transfer. +bool isConfigurableBuildingType(BuildingType type); diff --git a/src/lib/sim/PlacementRules.cpp b/src/lib/sim/PlacementRules.cpp index 135d351..97e54bc 100644 --- a/src/lib/sim/PlacementRules.cpp +++ b/src/lib/sim/PlacementRules.cpp @@ -69,16 +69,9 @@ bool isPlacementValid(const FactoryState& state, const GameConfig& config,Buildi } -std::optional findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config, - BuildingType type, QPoint anchor, Rotation rot) +std::optional findCoincidingSameTypeBuilding(const FactoryState& state, + const GameConfig& config, BuildingType type, QPoint anchor, Rotation rot) { - // Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a - // tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE). - if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) - { - return std::nullopt; - } - const BuildingDef* def = config.buildings.findBuildingDef(type); if (!def) { return std::nullopt; } @@ -106,20 +99,88 @@ std::optional findRotateInPlaceTarget(const FactoryState& state, con if (site.id != candidateId) { continue; } if (site.type != type) { return std::nullopt; } if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; } - return candidateId; + return CoincidingBuilding{candidateId, site.rotation}; } for (const Building& b : state.buildings) { if (b.id != candidateId) { continue; } if (b.type != type) { return std::nullopt; } if (b.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; } - return candidateId; + return CoincidingBuilding{candidateId, b.rotation}; } return std::nullopt; } +std::optional findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config, + BuildingType type, QPoint anchor, Rotation rot) +{ + // Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a + // tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE). + if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) + { + return std::nullopt; + } + + const std::optional target = + findCoincidingSameTypeBuilding(state, config, type, anchor, rot); + if (!target.has_value()) { return std::nullopt; } + return target->id; +} + + +BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state, const GameConfig& config, + BuildingType type, QPoint anchor, Rotation rotation, bool blueprintHoldsOneBuilding) +{ + // Terrain and world bounds first: nothing rescues a ghost hanging off the asteroid + // (REQ-BLD-PLACE-VALID condition (a)). + if (!isPlacementValid(state, config, type, anchor, rotation)) + { + return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt}; + } + + const std::optional coinciding = + 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}; + } + + // 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. + if (coinciding->rotation == rotation) + { + return BlueprintGhostResolved{BlueprintGhostAction::CompatibleOverlap, + coinciding->id}; + } + return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt}; + } + + // No coincidence: any occupancy at all is an ordinary overlap + // (REQ-BLD-PLACE-VALID condition (b)). + const BuildingDef* def = config.buildings.findBuildingDef(type); + if (!def) { return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt}; } + const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation); + for (const QPoint& relativeCell : parsed.bodyCells) + { + if (isTileOccupied(state, anchor + relativeCell)) + { + return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt}; + } + } + return BlueprintGhostResolved{BlueprintGhostAction::PlaceNew, std::nullopt}; +} + + bool canPlaceBuilding(const FactoryState& state, const GameConfig& config, BuildingType type, QPoint anchor, Rotation rotation) { diff --git a/src/lib/sim/PlacementRules.h b/src/lib/sim/PlacementRules.h index 8e964e1..a8a5c5a 100644 --- a/src/lib/sim/PlacementRules.h +++ b/src/lib/sim/PlacementRules.h @@ -30,9 +30,27 @@ bool bodyCellsWithinWorldBounds(const FactoryState& state, const GameConfig& con bool isPlacementValid(const FactoryState& state, const GameConfig& config, BuildingType type, QPoint anchor, Rotation rotation); +// An existing building or site whose footprint a ghost exactly covers. +struct CoincidingBuilding +{ + BuildingId id; + Rotation rotation; +}; + +// The building or site whose footprint a ghost of the given type/anchor/rotation +// exactly covers: same type, same body cells, a single owner. Operational buildings +// and construction sites alike. Rotation is not part of the test -- the target's own +// facing is reported so each caller can apply its own rule to it. +std::optional findCoincidingSameTypeBuilding(const FactoryState& state, + const GameConfig& config, + BuildingType type, + QPoint anchor, + Rotation rot); + // The building or site that a ghost of the given type/anchor/rotation would // rotate in place rather than replace: same type, same body cells, one owner -// (REQ-BLD-ROTATE-IN-PLACE). Tunnels never qualify. +// (REQ-BLD-ROTATE-IN-PLACE). Tunnels never qualify. Builder mode only; blueprint +// placement mode never rotates anything -- see resolveBlueprintGhost. std::optional findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config, BuildingType type, QPoint anchor, @@ -46,6 +64,36 @@ std::optional findRotateInPlaceTarget(const FactoryState& state, bool canPlaceBuilding(const FactoryState& state, const GameConfig& config, BuildingType type, QPoint anchor, Rotation rotation); +// What one ghost of a blueprint would do at its resolved tile. +enum class BlueprintGhostAction +{ + PlaceNew, // free, valid cells: a new construction site, charged for + CompatibleOverlap, // the same building is already there: left untouched, free + // (REQ-UI-BLUEPRINT-OVERLAP) + Transfer, // hand this blueprint's settings to the building already there, + // free (REQ-UI-BLUEPRINT-TRANSFER) + Invalid // terrain, bounds, or an overlap that is neither of the above +}; + +struct BlueprintGhostResolved +{ + BlueprintGhostAction action; + std::optional targetId; // set for CompatibleOverlap and Transfer +}; + +// 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. +// +// 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. +BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state, + const GameConfig& config, + BuildingType type, QPoint anchor, + Rotation rotation, + bool blueprintHoldsOneBuilding); + // What a belt drag would do to one tile of its path (REQ-BLD-BELT-DRAG). enum class BeltTileAction { diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 5af4252..d7c7dc8 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -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 // --------------------------------------------------------------------------- diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 4d73db9..a1e63cd 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -563,16 +563,21 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) // Locked building types are excluded from this placement entirely // (REQ-LOCK-BUILDING, REQ-LOCK-UI-BLUEPRINT): not validity-checked here. if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } - if (!canPlaceBuildingHere(bb.type, center + bb.offset, bb.rotation)) { return; } + if (resolveBlueprintGhostHere(bb, center).action == BlueprintGhostAction::Invalid) + { + return; + } } - // Cost only applies to buildings that are genuinely new (not rotate-in-place), - // and excludes locked building types (REQ-LOCK-UI-BLUEPRINT). + // Only genuinely new buildings are charged for: a compatible overlap places nothing + // (REQ-UI-BLUEPRINT-OVERLAP) and a transfer changes settings only + // (REQ-UI-BLUEPRINT-TRANSFER). Locked building types are excluded from the total + // as well (REQ-LOCK-UI-BLUEPRINT). int totalCost = 0; for (const BlueprintBuilding& bb : bp.buildings) { if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } - if (findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, center + bb.offset, bb.rotation).has_value()) + if (resolveBlueprintGhostHere(bb, center).action != BlueprintGhostAction::PlaceNew) { continue; } @@ -585,15 +590,15 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) { if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } const QPoint anchor = center + bb.offset; - const std::optional rotateTarget = - findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, anchor, bb.rotation); - if (rotateTarget.has_value()) + const BlueprintGhostResolved resolved = resolveBlueprintGhostHere(bb, center); + + // The building the blueprint wants is already there, facing the same way: leave + // it exactly as it is (REQ-UI-BLUEPRINT-OVERLAP). + if (resolved.action == BlueprintGhostAction::CompatibleOverlap) { continue; } + + if (resolved.action == BlueprintGhostAction::Transfer) { - std::shared_ptr rotateCommand = - std::make_shared(); - rotateCommand->id = *rotateTarget; - rotateCommand->newRotation = bb.rotation; - enqueueCommand(rotateCommand); + transferConfigTo(*resolved.targetId, bb); continue; } @@ -606,26 +611,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) command->anchor = anchor; command->rotation = bb.rotation; - if (!bb.recipeId.empty()) - { - if (bb.type == BuildingType::Shipyard) - { - if (m_sim->isSchematicUnlocked(bb.recipeId)) - { - command->recipeId = bb.recipeId; - } - } - else - { - const bool needsUnlockCheck = bb.type == BuildingType::Miner - || bb.type == BuildingType::Assembler; - if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId)) - { - command->recipeId = bb.recipeId; - } - } - } - + command->recipeId = unlockedRecipeId(bb); command->shipLayout = bb.shipLayout; if (bb.type == BuildingType::Splitter @@ -643,6 +629,93 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) } } +BlueprintGhostResolved GameWorldView::resolveBlueprintGhostHere(const BlueprintBuilding& building, + QPoint center) const +{ + // The one-building test reads the blueprint as stored, before locked types are + // dropped, so the gesture behaves the same however much the player has unlocked + // (REQ-UI-BLUEPRINT-TRANSFER). + return resolveBlueprintGhost(m_sim->getFactoryState(), m_sim->getConfig(), + building.type, center + building.offset, building.rotation, + m_buildMode.getBlueprint().buildings.size() == 1); +} + +std::string GameWorldView::unlockedRecipeId(const BlueprintBuilding& building) const +{ + // A stored recipe or schematic is applied only while it is unlocked; a locked one + // yields no id at all rather than a stale one (REQ-LOCK-UI-BLUEPRINT). Shared by + // placement and configuration transfer so the two cannot gate differently. + if (building.recipeId.empty()) { return std::string(); } + + if (building.type == BuildingType::Shipyard) + { + return m_sim->isSchematicUnlocked(building.recipeId) ? building.recipeId + : std::string(); + } + + const bool needsUnlockCheck = building.type == BuildingType::Miner + || building.type == BuildingType::Assembler; + if (!needsUnlockCheck || m_sim->isRecipeUnlocked(building.recipeId)) + { + return building.recipeId; + } + return std::string(); +} + +void GameWorldView::transferConfigTo(BuildingId id, const BlueprintBuilding& source) +{ + // Hands the blueprint's settings to a building that is already there, changing + // nothing else -- no construction site, no cost, no rotation + // (REQ-UI-BLUEPRINT-TRANSFER). Every field is sent unconditionally, so a field the + // blueprint has nothing stored for clears the target's rather than leaving it: the + // target ends up identical to the source. Setting a value the building already holds + // is a no-op in the simulation (REQ-MAT-INPUT-BUFFER), which is what keeps clicking + // an already-matching building free of buffer and production-progress loss. + if (source.type == BuildingType::Splitter) + { + // Operational splitters are configured by tile, sites by BuildingId (mirrors + // SelectedBuildingPanel::onSplitterFilterChanged). Locked item types are dropped + // per REQ-LOCK-UI-BLUEPRINT. + const std::vector filterA = filterUnlockedItems(source.splitterFilterA, *m_sim); + const std::vector filterB = filterUnlockedItems(source.splitterFilterB, *m_sim); + if (const Building* building = findBuilding(m_sim->getFactoryState(), id)) + { + std::shared_ptr command = + std::make_shared(); + command->tile = building->anchor; + command->filterA = filterA; + command->filterB = filterB; + enqueueCommand(command); + } + else + { + std::shared_ptr command = + std::make_shared(); + command->id = id; + command->filterA = filterA; + command->filterB = filterB; + enqueueCommand(command); + } + return; + } + + std::shared_ptr recipeCommand = std::make_shared(); + recipeCommand->id = id; + recipeCommand->recipeId = unlockedRecipeId(source); + enqueueCommand(recipeCommand); + + // After the schematic, never before: a genuine schematic change resets the layout, + // and both commands drain in order at the next tick boundary. + if (source.type == BuildingType::Shipyard) + { + std::shared_ptr layoutCommand = + std::make_shared(); + layoutCommand->id = id; + layoutCommand->layout = source.shipLayout.value_or(ShipLayoutConfig{}); + enqueueCommand(layoutCommand); + } +} + void GameWorldView::updateTunnelGhost() { // The connection preview and entry/exit switch only apply at a valid placement diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 4412bfc..6190727 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -187,6 +187,16 @@ private: std::optional siteAtTile(QPoint tile) const; void placeBlueprintAtTile(QPoint center); + // resolveBlueprintGhost (PlacementRules) for one building of the blueprint currently + // in placement mode, anchored at the cursor tile `center`. + BlueprintGhostResolved resolveBlueprintGhostHere(const BlueprintBuilding& building, + QPoint center) const; + // The blueprint's stored recipe or schematic id, or empty when it stores none or the + // stored one is currently locked (REQ-LOCK-UI-BLUEPRINT). + std::string unlockedRecipeId(const BlueprintBuilding& building) const; + // Applies a single-building blueprint's settings to the building it is hovering, + // instead of placing anything (REQ-UI-BLUEPRINT-TRANSFER). + void transferConfigTo(BuildingId id, const BlueprintBuilding& source); // Drops despawned or fully-collected debris from the selection // (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame(). diff --git a/src/ui/VisualsConfig.h b/src/ui/VisualsConfig.h index cb3d94f..39b29f9 100644 --- a/src/ui/VisualsConfig.h +++ b/src/ui/VisualsConfig.h @@ -48,6 +48,7 @@ struct OverlayVisuals QColor selectionRect; QColor tileHighlight; QColor selectedOutline; + QColor configTransfer; // blueprint ghost over a transfer target (REQ-UI-BLUEPRINT-TRANSFER) QColor lockedAsteroid; QColor modalDim; QColor tunnelPreview; // tunnel connection preview highlight (REQ-BLD-TUNNEL-MODE) diff --git a/src/ui/VisualsLoader.cpp b/src/ui/VisualsLoader.cpp index 3b6462a..5a0bc87 100644 --- a/src/ui/VisualsLoader.cpp +++ b/src/ui/VisualsLoader.cpp @@ -224,6 +224,7 @@ VisualsConfig VisualsLoader::load(const std::string& path) cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect"); cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight"); cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline"); + cfg.overlays.configTransfer = parseColor(requireString(ov, "config_transfer", "overlays"), "overlays.config_transfer"); cfg.overlays.lockedAsteroid = parseColor(requireString(ov, "locked_asteroid", "overlays"), "overlays.locked_asteroid"); cfg.overlays.modalDim = parseColor(requireString(ov, "modal_dim", "overlays"), "overlays.modal_dim"); cfg.overlays.tunnelPreview = parseColor(requireString(ov, "tunnel_preview", "overlays"), "overlays.tunnel_preview"); diff --git a/src/ui/WorldRenderer.cpp b/src/ui/WorldRenderer.cpp index 7fd4063..531c2e2 100644 --- a/src/ui/WorldRenderer.cpp +++ b/src/ui/WorldRenderer.cpp @@ -928,7 +928,8 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor const BeltPathTile& entry = frame.buildMode.getBeltDragPath()[index]; drawBuildingGhost(painter, coordinates, BuildingType::Belt, entry.tile, entry.rotation, - /*valid*/ item.action != BeltTileAction::Invalid, + item.action != BeltTileAction::Invalid + ? GhostTint::Normal : GhostTint::Invalid, /*showPortTargetGlyphs*/ true); } } @@ -960,7 +961,8 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor drawBuildingGhost(painter, coordinates, frame.buildMode.getEffectiveBuilderType(), ghostTile, frame.buildMode.getGhostRotation(), - frame.buildMode.isGhostValid(), + frame.buildMode.isGhostValid() + ? GhostTint::Normal : GhostTint::Invalid, /*showPortTargetGlyphs*/ true); } } @@ -968,15 +970,34 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor // Blueprint placement ghost if (frame.buildMode.isBlueprintMode()) { + // The stored building count, not the count after locked types are dropped, so the + // transfer rule does not shift as the player unlocks things + // (REQ-UI-BLUEPRINT-TRANSFER). + const bool holdsOneBuilding = + frame.buildMode.getBlueprint().buildings.size() == 1; for (const BlueprintBuilding& bb : frame.buildMode.getBlueprint().buildings) { // Locked building types are omitted from the blueprint (REQ-LOCK-BUILDING, // REQ-LOCK-UI-BLUEPRINT), so they are not ghosted either. if (!m_sim.isBuildingUnlocked(bb.type)) { continue; } const QPoint anchor = frame.buildMode.getBlueprintGhostTile() + bb.offset; - const bool valid = canPlaceBuilding(m_sim.getFactoryState(), m_sim.getConfig(), bb.type, anchor, bb.rotation); + // The same classifier the click path uses, so the color always predicts what + // clicking would do (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). A + // compatible overlap is an ordinary valid ghost. + const BlueprintGhostResolved resolved = resolveBlueprintGhost( + m_sim.getFactoryState(), m_sim.getConfig(), bb.type, anchor, bb.rotation, + holdsOneBuilding); + GhostTint tint = GhostTint::Normal; + if (resolved.action == BlueprintGhostAction::Transfer) + { + tint = GhostTint::Transfer; + } + else if (resolved.action == BlueprintGhostAction::Invalid) + { + tint = GhostTint::Invalid; + } drawBuildingGhost(painter, coordinates, bb.type, anchor, bb.rotation, - valid, /*showPortTargetGlyphs*/ false); + tint, /*showPortTargetGlyphs*/ false); } } @@ -1048,7 +1069,7 @@ void WorldRenderer::drawBuildingGhost(QPainter& painter, const WorldCoordinates& coordinates, BuildingType type, QPoint anchorTile, Rotation rotation, - bool valid, bool showPortTargetGlyphs) + GhostTint tint, bool showPortTargetGlyphs) { const BuildingDef* def = m_sim.getConfig().buildings.findBuildingDef(type); if (!def) { return; } @@ -1058,15 +1079,18 @@ void WorldRenderer::drawBuildingGhost(QPainter& painter, if (it == m_visuals.buildings.end()) { return; } const BuildingVisuals& bv = it->second; - // Valid ghosts show the building type's own colors; invalid ghosts override - // with the distinct invalid color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The - // invalid color's RGB is taken at full opacity so it does not double-dim - // against the setOpacity below (the configured color carries its own alpha). - const QColor invalidColor(m_visuals.overlays.ghostInvalid.red(), - m_visuals.overlays.ghostInvalid.green(), - m_visuals.overlays.ghostInvalid.blue()); - const QColor fillColor = valid ? bv.fill : invalidColor; - const QColor lineColor = valid ? bv.outline : invalidColor; + // Normal ghosts show the building type's own colors; the other two tints override + // them with a single flat color -- invalid (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID) or + // configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). An override's RGB is taken at + // full opacity so it does not double-dim against the setOpacity below (the + // configured color carries its own alpha). + const QColor& configured = tint == GhostTint::Transfer + ? m_visuals.overlays.configTransfer + : m_visuals.overlays.ghostInvalid; + const QColor overrideColor(configured.red(), configured.green(), configured.blue()); + const bool useOwnColors = tint == GhostTint::Normal; + const QColor fillColor = useOwnColors ? bv.fill : overrideColor; + const QColor lineColor = useOwnColors ? bv.outline : overrideColor; const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation); if (parsed.bodyCells.empty()) { return; } diff --git a/src/ui/WorldRenderer.h b/src/ui/WorldRenderer.h index 842a01d..d8be56c 100644 --- a/src/ui/WorldRenderer.h +++ b/src/ui/WorldRenderer.h @@ -35,6 +35,16 @@ struct ActiveBeam QVector2D targetOffset; }; +// How a placement ghost is colored. Normal shows the building type's own colors +// (REQ-BLD-GHOST); the other two override them (REQ-BLD-PLACE-VALID, +// REQ-UI-BLUEPRINT-TRANSFER). +enum class GhostTint +{ + Normal, + Invalid, + Transfer +}; + // Everything the renderer draws that the simulation does not know about: what the // player has selected, which build mode is active, and the other transient bits of // interaction state the view owns. Assembled fresh each frame and passed by @@ -113,7 +123,7 @@ private: bool centered); void drawBuildingGhost(QPainter& painter, const WorldCoordinates& coordinates, BuildingType type, QPoint anchorTile, Rotation rotation, - bool valid, bool showPortTargetGlyphs); + GhostTint tint, bool showPortTargetGlyphs); // Loads the per-building world icons (REQ-UI-WORLD-ICON) from // /../icons/buildings once at construction. Only the building