From 1914705ab9c1cb78836447804c81f7eb98e4cc2c Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Wed, 22 Jul 2026 12:59:27 +0200 Subject: [PATCH] Implement the deconstruction queue Demolishing a fully-built building now queues it for timed deconstruction (world.toml deconstruction_time_seconds, default 0.1) running in parallel with the construction queue, instead of removing it instantly. The partial refund is credited on completion. A queued building stops operating at once (belt/tunnel/splitter tiles unregister from the belt subsystem, re-pairing tunnels) but keeps occupying its tiles. Construction sites are still removed instantly with the full refund. Clicking a queued building un-queues it (new CancelDeconstructionCommand); a demolish drag-box un-queues all covered built buildings when they are all already queued, otherwise queues the rest. Queued buildings render with the demolish tint. Implements REQ-BLD-DECON-QUEUE and the updated REQ-BLD-DEMOLISH / REQ-BLD-DEMOLISH-CLICK / REQ-BLD-DEMOLISH-BOX / REQ-BLD-TUNNEL-PAIR. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc --- bin/app/data/config/world.toml | 1 + bin/test/data/config/world.toml | 1 + src/lib/config/ConfigLoader.cpp | 1 + src/lib/config/WorldConfig.h | 1 + src/lib/sim/Building.h | 5 + src/lib/sim/BuildingSystem.cpp | 247 ++++++++++++++++++++++++------ src/lib/sim/BuildingSystem.h | 48 +++++- src/lib/sim/Command.h | 11 ++ src/lib/sim/CommandSerializer.cpp | 13 ++ src/lib/sim/Simulation.cpp | 11 +- src/lib/sim/Simulation.h | 7 +- src/test/BuildingTest.cpp | 177 ++++++++++++++++++--- src/test/CommandTest.cpp | 30 ++++ src/test/ConfigLoaderTest.cpp | 3 + src/test/SimulationTestAccess.h | 5 + src/ui/GameWorldView.cpp | 64 +++++++- 16 files changed, 545 insertions(+), 80 deletions(-) diff --git a/bin/app/data/config/world.toml b/bin/app/data/config/world.toml index b09d5c3..a8eb712 100644 --- a/bin/app/data/config/world.toml +++ b/bin/app/data/config/world.toml @@ -1,6 +1,7 @@ [world] height_tiles = 40 refund_percentage = 100 +deconstruction_time_seconds = 0.1 starting_building_blocks = 200 scrap_despawn_seconds = 120 scrap_per_threat = 0.25 diff --git a/bin/test/data/config/world.toml b/bin/test/data/config/world.toml index c92d99e..74da494 100644 --- a/bin/test/data/config/world.toml +++ b/bin/test/data/config/world.toml @@ -1,6 +1,7 @@ [world] height_tiles = 60 refund_percentage = 75 +deconstruction_time_seconds = 0.1 starting_building_blocks = 100 scrap_despawn_seconds = 30 scrap_per_threat = 1.0 diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index f8a9e64..901170a 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -263,6 +263,7 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path) cfg.heightTiles = static_cast(requireInt(tbl["world"]["height_tiles"], file, "world.height_tiles")); cfg.refundPercentage = static_cast(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage")); + cfg.deconstructionTimeSeconds = requireDouble(tbl["world"]["deconstruction_time_seconds"], file, "world.deconstruction_time_seconds"); cfg.startingBuildingBlocks = static_cast(requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks")); cfg.scrapDespawnSeconds = requireDouble(tbl["world"]["scrap_despawn_seconds"], file, "world.scrap_despawn_seconds"); cfg.scrapPerThreat = requireDouble(tbl["world"]["scrap_per_threat"], file, "world.scrap_per_threat"); diff --git a/src/lib/config/WorldConfig.h b/src/lib/config/WorldConfig.h index 85b7510..70eacca 100644 --- a/src/lib/config/WorldConfig.h +++ b/src/lib/config/WorldConfig.h @@ -68,6 +68,7 @@ struct WorldConfig { int heightTiles; // REQ-GW-HEIGHT int refundPercentage; // REQ-BLD-DEMOLISH + double deconstructionTimeSeconds; // REQ-BLD-DECON-QUEUE int startingBuildingBlocks; // REQ-HQ-STARTING-BLOCKS double scrapDespawnSeconds; // REQ-RES-SCRAP-DROP double scrapPerThreat; // REQ-RES-SCRAP-DROP, REQ-THREAT-SCRAP (scrap dropped per unit threat) diff --git a/src/lib/sim/Building.h b/src/lib/sim/Building.h index 958c2ac..a52f795 100644 --- a/src/lib/sim/Building.h +++ b/src/lib/sim/Building.h @@ -129,5 +129,10 @@ struct Building // Module layout for shipyards (REQ-MOD-LAYOUT). std::optional shipLayout; + + // True while this building sits in the deconstruction queue (REQ-BLD-DECON-QUEUE). + // A queued building stops operating immediately (all tick loops skip it) but keeps + // occupying its tiles until its deconstruction completes. + bool queuedForDeconstruction = false; }; diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 3d4732a..2905479 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -21,6 +21,17 @@ bool isAutoRecipeBuildingType(BuildingType type) || type == BuildingType::ReprocessingPlant; } +// Belts, splitters, and tunnel ends keep their runtime data in the belt subsystem +// rather than in the Building instance, so placing/removing them must register or +// unregister a tile with BeltSystem. +bool isBeltSubsystemType(BuildingType type) +{ + return type == BuildingType::Belt + || type == BuildingType::Splitter + || type == BuildingType::TunnelEntry + || type == BuildingType::TunnelExit; +} + // The building body tile that owns an output port, given the port's outside tile // (port.tile) and its facing direction. The virtual output belt occupies this tile // and flows toward port.tile (REQ-MAT-OUTPUT-EMERGE). @@ -489,9 +500,10 @@ bool BuildingSystem::isPlacementValid(BuildingType type, QPoint anchor, // Demolish // --------------------------------------------------------------------------- -int BuildingSystem::demolish(BuildingId id) +int BuildingSystem::demolish(BuildingId id, Tick currentTick) { - // Construction queue? + // Construction site? Removed instantly with the full refund; never queued + // for deconstruction (REQ-BLD-DEMOLISH). for (std::deque::iterator it = m_constructionQueue.begin(); it != m_constructionQueue.end(); ++it) @@ -512,35 +524,60 @@ int BuildingSystem::demolish(BuildingId id) } } - // Operational building? - for (std::vector::iterator it = m_buildings.begin(); - it != m_buildings.end(); - ++it) + // Operational building? Append it to the deconstruction queue rather than + // removing it now; the partial refund is credited on completion in + // tickDeconstruction (REQ-BLD-DECON-QUEUE). + for (Building& building : m_buildings) { - if (it->id == id) + if (building.id != id) { continue; } + if (building.queuedForDeconstruction) { return 0; } // already queued + + building.queuedForDeconstruction = true; + + DeconstructionEntry entry; + entry.id = id; + + // A queued belt/tunnel/splitter stops transporting at once: capture a + // splitter's filters (so an un-queue can restore them), then unregister + // its tile, discarding items on it and re-pairing tunnels as if it were + // gone (REQ-BLD-TUNNEL-PAIR). + if (building.type == BuildingType::Splitter) { - if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter - || it->type == BuildingType::TunnelEntry || it->type == BuildingType::TunnelExit) + if (const std::optional info = + m_belts.getSplitterInfo(building.anchor)) { - m_belts.removeTile(it->anchor); + entry.splitterFilterA = info->filterA; + entry.splitterFilterB = info->filterB; } - const BuildingDef* def = findBuildingDef(it->type); - for (const QPoint& cell : it->bodyCells) - { - m_tileOccupancy.erase({cell.x(), cell.y()}); - } - m_buildings.erase(it); - if (def) - { - return def->cost * m_config.world.refundPercentage / 100; - } - return 0; } + if (isBeltSubsystemType(building.type)) + { + m_belts.removeTile(building.anchor); + } + + const bool wasEmpty = m_deconstructionQueue.empty(); + m_deconstructionQueue.push_back(std::move(entry)); + if (wasEmpty) + { + startFrontDeconstruction(currentTick); + } + return 0; } return 0; } +void BuildingSystem::startFrontDeconstruction(Tick currentTick) +{ + if (m_deconstructionQueue.empty()) { return; } + DeconstructionEntry& front = m_deconstructionQueue.front(); + if (front.completesAt == 0) + { + front.completesAt = + currentTick + secondsToTicks(m_config.world.deconstructionTimeSeconds); + } +} + // --------------------------------------------------------------------------- // Set recipe // --------------------------------------------------------------------------- @@ -780,31 +817,9 @@ void BuildingSystem::tickConstruction(Tick currentTick) } } - // Register with BeltSystem before the move (mask stays valid). - if (front.type == BuildingType::Belt) - { - m_belts.placeBelt(front.anchor, front.rotation); - } - else if (front.type == BuildingType::Splitter) - { - assert(mask.outputPorts.size() >= 2); - m_belts.placeSplitter(front.anchor, - mask.outputPorts[0].direction, - mask.outputPorts[1].direction); - // Carry over any filters configured while under construction - // (REQ-BLD-SITE-CONFIG). - m_belts.setSplitterFilters(front.anchor, - front.splitterFilterA, - front.splitterFilterB); - } - else if (front.type == BuildingType::TunnelEntry) - { - m_belts.placeTunnelEntry(front.anchor, front.rotation, m_config.world.tunnelMaxDistance_tiles); - } - else if (front.type == BuildingType::TunnelExit) - { - m_belts.placeTunnelExit(front.anchor, front.rotation); - } + // Register with BeltSystem before the move (mask/building stays valid). Any + // filters configured while under construction carry over (REQ-BLD-SITE-CONFIG). + reregisterBeltTile(building, front.splitterFilterA, front.splitterFilterB); m_buildings.push_back(std::move(building)); @@ -822,6 +837,114 @@ void BuildingSystem::tickConstruction(Tick currentTick) } } +void BuildingSystem::reregisterBeltTile(const Building& building, + const std::vector& splitterFilterA, + const std::vector& splitterFilterB) +{ + switch (building.type) + { + case BuildingType::Belt: + m_belts.placeBelt(building.anchor, building.rotation); + break; + case BuildingType::Splitter: + assert(building.outputPorts.size() >= 2); + m_belts.placeSplitter(building.anchor, + building.outputPorts[0].direction, + building.outputPorts[1].direction); + m_belts.setSplitterFilters(building.anchor, splitterFilterA, splitterFilterB); + break; + case BuildingType::TunnelEntry: + m_belts.placeTunnelEntry(building.anchor, building.rotation, + m_config.world.tunnelMaxDistance_tiles); + break; + case BuildingType::TunnelExit: + m_belts.placeTunnelExit(building.anchor, building.rotation); + break; + default: + break; + } +} + +void BuildingSystem::tickDeconstruction(Tick currentTick) +{ + TRACE(); + if (m_deconstructionQueue.empty()) + { + return; + } + + DeconstructionEntry& front = m_deconstructionQueue.front(); + + // Guard: if the front entry's timer was never started, start it now. + if (front.completesAt == 0) + { + startFrontDeconstruction(currentTick); + return; + } + + if (currentTick < front.completesAt) + { + return; + } + + // Remove the building from the world and credit its refund (REQ-BLD-DEMOLISH). + // Belt/tunnel/splitter tiles were already unregistered when the building was + // queued (see demolish), so only tile occupancy and the record remain. + for (std::vector::iterator it = m_buildings.begin(); + it != m_buildings.end(); + ++it) + { + if (it->id != front.id) { continue; } + + const BuildingDef* def = findBuildingDef(it->type); + for (const QPoint& cell : it->bodyCells) + { + m_tileOccupancy.erase({cell.x(), cell.y()}); + } + m_buildings.erase(it); + if (def) + { + m_addBuildingBlocks(def->cost * m_config.world.refundPercentage / 100); + } + break; + } + + m_deconstructionQueue.pop_front(); + + // Start the next queued deconstruction, if any. + startFrontDeconstruction(currentTick); +} + +void BuildingSystem::cancelDeconstruction(BuildingId id) +{ + for (std::deque::iterator it = m_deconstructionQueue.begin(); + it != m_deconstructionQueue.end(); + ++it) + { + if (it->id != id) { continue; } + + // Resume operation: clear the flag and re-register belt/tunnel/splitter + // tiles that were unregistered at enqueue (which re-pairs tunnels, + // REQ-BLD-TUNNEL-PAIR). Deconstruction progress is discarded; no refund. + if (Building* building = findBuildingMutable(id)) + { + building->queuedForDeconstruction = false; + reregisterBeltTile(*building, it->splitterFilterA, it->splitterFilterB); + } + + m_deconstructionQueue.erase(it); + // If the running front was removed, the new front (completesAt == 0) has + // its timer started by the next tickDeconstruction guard. + return; + } +} + +bool BuildingSystem::isQueuedForDeconstruction(BuildingId id) const +{ + const Building* building = findBuilding(id); + return building && building->queuedForDeconstruction; +} + void BuildingSystem::tickBeltPull() { TRACE(); @@ -831,6 +954,9 @@ void BuildingSystem::tickBeltPull() for (Building& building : m_buildings) { + // A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE). + if (building.queuedForDeconstruction) { continue; } + const bool isHq = (building.type == BuildingType::Hq); // 1. Advance every input belt and deliver arrivals (progress >= 0.5) into @@ -922,6 +1048,10 @@ bool BuildingSystem::tryDirectCoupleDeposit(BuildingId producerId, { return false; // an unbuilt construction site, or not an operational building } + if (consumer->queuedForDeconstruction) + { + return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE) + } // The coupling is the consumer input port meeting this output port: same flow // direction, feeding the producer's output-port tile (REQ-MAT-DIRECT-COUPLE). @@ -943,6 +1073,9 @@ void BuildingSystem::tickProduction(Tick currentTick) TRACE(); for (Building& building : m_buildings) { + // A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE). + if (building.queuedForDeconstruction) { continue; } + // Skip types without a recipe-based production loop. if (building.type == BuildingType::Belt || building.type == BuildingType::Splitter || @@ -1043,6 +1176,9 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick) TRACE(); for (Building& building : m_buildings) { + // A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE). + if (building.queuedForDeconstruction) { continue; } + if (building.type != BuildingType::Shipyard) { continue; @@ -1140,6 +1276,9 @@ void BuildingSystem::tickOutputBelts() for (Building& building : m_buildings) { + // A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE). + if (building.queuedForDeconstruction) { continue; } + for (std::size_t p = 0; p < building.outputPorts.size(); ++p) { const Port& port = building.outputPorts[p]; @@ -1647,6 +1786,10 @@ bool BuildingSystem::deliverScrapToSalvageBay(BuildingId bayId) { return false; } + if (bay->queuedForDeconstruction) + { + return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE) + } // Emerging scrap still counts against the bay's holding capacity // (REQ-MAT-OUTPUT-EMERGE). if (bay->getOutputItemCount() >= bay->outputBuffer.capacity) @@ -1820,6 +1963,7 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const appendItems(hasher, b.production->chosenOutputs); } hasher.append(b.shipLayout.has_value()); + hasher.append(b.queuedForDeconstruction); } hasher.append(m_constructionQueue.size()); @@ -1840,6 +1984,17 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const for (const ItemType& type : s.splitterFilterB) { hasher.append(type.id); } } + hasher.append(m_deconstructionQueue.size()); + for (const DeconstructionEntry& e : m_deconstructionQueue) + { + hasher.append(e.id); + hasher.append(e.completesAt); + hasher.append(e.splitterFilterA.size()); + for (const ItemType& type : e.splitterFilterA) { hasher.append(type.id); } + hasher.append(e.splitterFilterB.size()); + for (const ItemType& type : e.splitterFilterB) { hasher.append(type.id); } + } + // std::map iterates in sorted key order. hasher.append(m_tileOccupancy.size()); for (const std::pair, BuildingId>& entry : m_tileOccupancy) diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index b27946a..f12ad27 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -77,10 +77,22 @@ public: // Defaults to world.regions.asteroid_width_tiles at construction. void setAsteroidWidth_tiles(int widthTiles) { m_asteroidWidth_tiles = widthTiles; } - // Remove a building or construction site by id. Returns the refund in - // building blocks (floor(cost * refundPercentage / 100)). Returns 0 for - // unknown ids. - int demolish(BuildingId id); + // Mark a building or construction site for demolition (REQ-BLD-DEMOLISH). + // A construction site is removed instantly and the full cost is returned. + // A fully-built building is instead appended to the deconstruction queue + // (REQ-BLD-DECON-QUEUE) and stops operating at once; its (partial) refund is + // credited later, on completion in tickDeconstruction, so this returns 0 for + // it. Returns 0 for unknown ids and for a building already queued. + int demolish(BuildingId id, Tick currentTick); + + // Take a building back out of the deconstruction queue before it is removed + // (REQ-BLD-DECON-QUEUE). Clears its queued flag and resumes operation + // (re-registering belt/tunnel/splitter tiles); discards deconstruction + // progress and credits no refund. No-op if the id is not queued. + void cancelDeconstruction(BuildingId id); + + // True if the building is currently in the deconstruction queue. + bool isQueuedForDeconstruction(BuildingId id) const; // Set the recipe (or schematic id for shipyard) on a building or queued // construction site. Clears both buffers on an operational building. @@ -104,6 +116,10 @@ public: // -- Tick hooks (called from Simulation::tick in the documented order) --- void tickConstruction(Tick currentTick); + // Advances the deconstruction queue (REQ-BLD-DECON-QUEUE): one building at a + // time, in parallel with tickConstruction. Removes the front building and + // credits its refund when its timer elapses. + void tickDeconstruction(Tick currentTick); void tickBeltPull(); void tickProduction(Tick currentTick); void tickShipyardProduction(Tick currentTick); @@ -205,6 +221,17 @@ public: void appendChecksum(Hasher& hasher) const; private: + // Starts the front deconstruction-queue entry's timer if not yet started + // (mirrors how tickConstruction starts a queued construction site). + void startFrontDeconstruction(Tick currentTick); + + // Registers a belt/splitter/tunnel building's tile with the belt subsystem + // (on construction completion, or when un-queuing a deconstruction). No-op for + // non-belt-subsystem types. Splitter filters are (re)applied after placement. + void reregisterBeltTile(const Building& building, + const std::vector& splitterFilterA, + const std::vector& splitterFilterB); + Building* findBuildingMutable(BuildingId id); // True if the consumer would accept `type` at the given input port right now: // it is a required input (or a building block for the HQ), the reservation-aware @@ -273,6 +300,19 @@ private: std::vector m_buildings; std::deque m_constructionQueue; + // One pending demolition of a fully-built building (REQ-BLD-DECON-QUEUE). + // completesAt == 0 means "queued but its timer has not started yet" + // (mirrors ConstructionSite). For a Splitter, the filters it had are captured + // here so cancelDeconstruction can restore them on re-registration. + struct DeconstructionEntry + { + BuildingId id = kInvalidBuildingId; + Tick completesAt = 0; + std::vector splitterFilterA; + std::vector splitterFilterB; + }; + std::deque m_deconstructionQueue; + // Maps every occupied body-cell coordinate to the entity that owns it. std::map, BuildingId> m_tileOccupancy; }; diff --git a/src/lib/sim/Command.h b/src/lib/sim/Command.h index d76c63e..b32739b 100644 --- a/src/lib/sim/Command.h +++ b/src/lib/sim/Command.h @@ -28,6 +28,7 @@ enum class CommandKind { PlaceBuilding, Demolish, + CancelDeconstruction, RotateInPlace, SetRecipe, SetShipLayout, @@ -72,12 +73,22 @@ struct PlaceBuildingCommand : Command std::vector splitterFilterB; }; +// Marks a building for demolition: a construction site is removed instantly, a +// built building is queued for deconstruction (REQ-BLD-DEMOLISH, REQ-BLD-DECON-QUEUE). struct DemolishCommand : Command { DemolishCommand() : Command(CommandKind::Demolish) {} std::optional id; }; +// Takes a building back out of the deconstruction queue (REQ-BLD-DEMOLISH-CLICK, +// REQ-BLD-DEMOLISH-BOX). No-op if the id is not currently queued. +struct CancelDeconstructionCommand : Command +{ + CancelDeconstructionCommand() : Command(CommandKind::CancelDeconstruction) {} + std::optional id; +}; + struct RotateInPlaceCommand : Command { RotateInPlaceCommand() : Command(CommandKind::RotateInPlace) {} diff --git a/src/lib/sim/CommandSerializer.cpp b/src/lib/sim/CommandSerializer.cpp index b2a4667..1cd5102 100644 --- a/src/lib/sim/CommandSerializer.cpp +++ b/src/lib/sim/CommandSerializer.cpp @@ -131,6 +131,10 @@ std::string serializeCommand(const Command& command) case CommandKind::Demolish: out << "demolish " << static_cast(command).id.value(); break; + case CommandKind::CancelDeconstruction: + out << "cancel_deconstruct " + << static_cast(command).id.value(); + break; case CommandKind::RotateInPlace: { const RotateInPlaceCommand& c = static_cast(command); @@ -247,6 +251,15 @@ std::shared_ptr parseCommand(const std::string& tokens) c->id = id; return c; } + if (verb == "cancel_deconstruct") + { + std::shared_ptr c = + std::make_shared(); + BuildingId id = 0; + if (!(in >> id)) { return nullptr; } + c->id = id; + return c; + } if (verb == "rotate") { std::shared_ptr c = std::make_shared(); diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 458e2f9..dec7caa 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -234,6 +234,9 @@ void Simulation::apply(const Command& command) case CommandKind::Demolish: demolish(*static_cast(command).id); break; + case CommandKind::CancelDeconstruction: + cancelDeconstruction(*static_cast(command).id); + break; case CommandKind::RotateInPlace: { const RotateInPlaceCommand& c = static_cast(command); @@ -307,6 +310,7 @@ void Simulation::tick() // Construction + production pipeline m_buildingSystem->tickConstruction(m_currentTick); + m_buildingSystem->tickDeconstruction(m_currentTick); // parallel to construction m_buildingSystem->tickBeltPull(); // step 3 m_buildingSystem->tickProduction(m_currentTick); // step 4 m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b @@ -1196,7 +1200,12 @@ std::optional Simulation::tryPlaceBuilding(BuildingType type, QPoint void Simulation::demolish(BuildingId id) { - m_buildingBlocksStock += m_buildingSystem->demolish(id); + m_buildingBlocksStock += m_buildingSystem->demolish(id, m_currentTick); +} + +void Simulation::cancelDeconstruction(BuildingId id) +{ + m_buildingSystem->cancelDeconstruction(id); } BuildingSystem& Simulation::getBuildingsMutable() diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 0fed492..adcba29 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -139,9 +139,14 @@ private: // Returns the new entity id, or nullopt if blocks are insufficient. std::optional tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation); - // Demolishes the building with the given id and refunds building blocks. + // Marks the building with the given id for demolition: a construction site is + // removed instantly (full refund), a built building is queued for timed + // deconstruction (REQ-BLD-DECON-QUEUE), refunded on completion. void demolish(BuildingId id); + // Takes a queued building back out of the deconstruction queue (REQ-BLD-DECON-QUEUE). + void cancelDeconstruction(BuildingId id); + // Applies the player's chosen schematic from the pending choices. // choiceIndex must be in [0, pendingChoices.size()). // Clears the pending choices after application. diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 305c8ab..5aa1e98 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -58,6 +58,7 @@ static void runTicks(BuildingSystem& bs, BeltSystem& belts, int n, Tick& tick) for (int i = 0; i < n; ++i) { bs.tickConstruction(tick); + bs.tickDeconstruction(tick); bs.tickBeltPull(); bs.tickProduction(tick); bs.tickOutputBelts(); @@ -265,33 +266,20 @@ TEST_CASE("BuildingSystem: placed building enters construction queue", "[buildin REQUIRE(bs.findSite(id) != nullptr); } -TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]") +TEST_CASE("BuildingSystem: demolishing a construction site removes it instantly with full refund", + "[building]") { - const GameConfig cfg = loadConfig(); - BeltSystem belts(cfg.world.beltSpeed_tps); - int stock = 0; - std::mt19937 rng(0); - BuildingId nextBuildingId = 1; - BuildingSystem bs(cfg, belts, - [&nextBuildingId]() { return nextBuildingId++; }, - [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D, const std::optional&) {}, - [](const std::string&) -> bool { return true; }, - rng); + PlacementFixture f; + const BuildingId id = + f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); - const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); + // Still queued for construction (not yet built): instant removal, full cost + // refunded immediately, never entering the deconstruction queue (REQ-BLD-DEMOLISH). + const int refund = f.bs.demolish(id, 0); - // Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300. - // We need to process tick 300 itself, so run 301 ticks (ticks 0..300). - Tick tick = 0; - runTicks(bs, belts, static_cast(secondsToTicks(10.0)) + 1, tick); - - const int refund = bs.demolish(id); - - // Miner cost = 15, refund = floor(15 * 75 / 100) = 11. - REQUIRE(refund == 15 * cfg.world.refundPercentage / 100); - REQUIRE_FALSE(bs.isTileOccupied(QPoint(0, 0))); - REQUIRE(bs.getAllSites().empty()); + REQUIRE(refund == 15); // Miner cost = 15 + REQUIRE_FALSE(f.bs.isTileOccupied(QPoint(0, 0))); + REQUIRE(f.bs.getAllSites().empty()); } // --------------------------------------------------------------------------- @@ -364,6 +352,147 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[ REQUIRE(bs.findBuilding(id) != nullptr); } +// --------------------------------------------------------------------------- +// Deconstruction queue (REQ-BLD-DECON-QUEUE) +// --------------------------------------------------------------------------- + +// Runs ticks until the building with the given id is operational, or fails. +static void runUntilBuilt(PlacementFixture& f, BuildingId id, Tick& tick) +{ + for (int i = 0; i < 100000 && f.bs.findBuilding(id) == nullptr; ++i) + { + runTicks(f.bs, f.belts, 1, tick); + } + REQUIRE(f.bs.findBuilding(id) != nullptr); +} + +TEST_CASE("BuildingSystem: demolishing a built building queues it; refund credited on completion", + "[building][decon]") +{ + PlacementFixture f; + const BuildingId id = + f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); + + Tick tick = 0; + runUntilBuilt(f, id, tick); + + // Demolishing a built building returns nothing immediately and queues it, + // stopping it operating while its tiles stay occupied (REQ-BLD-DECON-QUEUE). + const int refund = f.bs.demolish(id, tick); + REQUIRE(refund == 0); + REQUIRE(f.bs.isQueuedForDeconstruction(id)); + REQUIRE(f.bs.isTileOccupied(QPoint(0, 0))); + REQUIRE(f.stock == 0); + + // After the deconstruction time (0.1s = 3 ticks) it is removed and the partial + // refund (15 * 75 / 100 = 11) is credited exactly once. + runTicks(f.bs, f.belts, static_cast(secondsToTicks(0.1)) + 1, tick); + REQUIRE(f.bs.findBuilding(id) == nullptr); + REQUIRE_FALSE(f.bs.isTileOccupied(QPoint(0, 0))); + REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100); +} + +TEST_CASE("BuildingSystem: deconstruction queue removes one building at a time", "[building][decon]") +{ + PlacementFixture f; + const BuildingId a = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); + const BuildingId b = f.bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0).value(); + + Tick tick = 0; + runUntilBuilt(f, a, tick); + runUntilBuilt(f, b, tick); + + // Queue both in one tick; 'a' is at the front of the deconstruction queue. + f.bs.demolish(a, tick); + f.bs.demolish(b, tick); + REQUIRE(f.bs.isQueuedForDeconstruction(a)); + REQUIRE(f.bs.isQueuedForDeconstruction(b)); + + // After one deconstruction interval only the front building is gone; the + // second is still queued and its refund not yet credited. + runTicks(f.bs, f.belts, static_cast(secondsToTicks(0.1)) + 1, tick); + REQUIRE(f.bs.findBuilding(a) == nullptr); + REQUIRE(f.bs.findBuilding(b) != nullptr); + REQUIRE(f.bs.isQueuedForDeconstruction(b)); + REQUIRE(f.stock == 15 * f.cfg.world.refundPercentage / 100); + + // The second drains next. + runTicks(f.bs, f.belts, static_cast(secondsToTicks(0.1)) + 2, tick); + REQUIRE(f.bs.findBuilding(b) == nullptr); + REQUIRE(f.stock == 2 * (15 * f.cfg.world.refundPercentage / 100)); +} + +TEST_CASE("BuildingSystem: cancelling deconstruction resumes the building with no refund", + "[building][decon]") +{ + PlacementFixture f; + const BuildingId id = + f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); + + Tick tick = 0; + runUntilBuilt(f, id, tick); + + f.bs.demolish(id, tick); + REQUIRE(f.bs.isQueuedForDeconstruction(id)); + + // Un-queue before it drains: it operates again, no refund, tiles still occupied. + f.bs.cancelDeconstruction(id); + REQUIRE_FALSE(f.bs.isQueuedForDeconstruction(id)); + REQUIRE(f.bs.findBuilding(id) != nullptr); + REQUIRE(f.bs.isTileOccupied(QPoint(0, 0))); + REQUIRE(f.stock == 0); + + // It is never removed even after more than a deconstruction interval passes. + runTicks(f.bs, f.belts, static_cast(secondsToTicks(0.1)) + 5, tick); + REQUIRE(f.bs.findBuilding(id) != nullptr); + REQUIRE(f.stock == 0); +} + +TEST_CASE("BuildingSystem: queued belt stops transporting; cancel restores it", "[building][decon]") +{ + PlacementFixture f; + const BuildingId id = + f.bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); + + Tick tick = 0; + runUntilBuilt(f, id, tick); + REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East)); + + // Queuing a belt unregisters its tile from the belt subsystem, so it no longer + // accepts or transports items, though the tile stays occupied (REQ-BLD-DECON-QUEUE). + f.bs.demolish(id, tick); + REQUIRE_FALSE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East)); + REQUIRE(f.bs.isTileOccupied(QPoint(0, 0))); + + // Un-queuing re-registers the belt tile so it transports again. + f.bs.cancelDeconstruction(id); + REQUIRE(f.belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East)); +} + +TEST_CASE("BuildingSystem: splitter filters survive a queue/un-queue round-trip", "[building][decon]") +{ + PlacementFixture f; + const BuildingId id = + f.bs.place(BuildingType::Splitter, QPoint(0, 0), Rotation::East, 0).value(); + + Tick tick = 0; + runUntilBuilt(f, id, tick); + + f.belts.setSplitterFilters(QPoint(0, 0), {ItemType{"iron_ore"}}, {}); + + // Queue: the belt subsystem tile (and its filters) are unregistered, but the + // filters are captured so an un-queue can restore them. + f.bs.demolish(id, tick); + REQUIRE_FALSE(f.belts.getSplitterInfo(QPoint(0, 0)).has_value()); + + f.bs.cancelDeconstruction(id); + const std::optional info = f.belts.getSplitterInfo(QPoint(0, 0)); + REQUIRE(info.has_value()); + REQUIRE(info->filterA.size() == 1); + REQUIRE(info->filterA[0].id == "iron_ore"); + REQUIRE(info->filterB.empty()); +} + TEST_CASE("BuildingSystem: second building starts after first completes", "[building]") { const GameConfig cfg = loadConfig(); diff --git a/src/test/CommandTest.cpp b/src/test/CommandTest.cpp index a1c44f7..ac152b9 100644 --- a/src/test/CommandTest.cpp +++ b/src/test/CommandTest.cpp @@ -10,6 +10,7 @@ #include "Rotation.h" #include "Simulation.h" #include "SimulationTestAccess.h" +#include "Tick.h" namespace { @@ -78,6 +79,35 @@ TEST_CASE("apply(DemolishCommand) matches direct demolish", "[command]") REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); } +TEST_CASE("apply(CancelDeconstructionCommand) matches direct cancelDeconstruction", "[command]") +{ + Simulation viaCommand(loadConfig(), 99); + Simulation viaDirect(loadConfig(), 99); + + const BuildingId idA = + SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value(); + const BuildingId idB = + SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value(); + REQUIRE(idA == idB); + + // Build the miners, then queue both for deconstruction identically. + for (int i = 0; i < static_cast(secondsToTicks(10.0)) + 1; ++i) + { + viaCommand.tick(); + viaDirect.tick(); + } + SimulationTestAccess::demolish(viaCommand, idA); + SimulationTestAccess::demolish(viaDirect, idB); + + CancelDeconstructionCommand command; + command.id = idA; + viaCommand.apply(command); + + SimulationTestAccess::cancelDeconstruction(viaDirect, idB); + + REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); +} + TEST_CASE("CommandManager drains queued commands in FIFO order through apply", "[command]") { Simulation viaManager(loadConfig(), 99); diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index ec5d659..42d5a39 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.cpp @@ -211,6 +211,7 @@ TEST_CASE("Missing field in world.toml is rejected with the field path", "[confi [world] height_tiles = 60 refund_percentage = 75 +deconstruction_time_seconds = 0.1 scrap_despawn_seconds = 30 scrap_per_threat = 0.01 tile_size_m = 10 @@ -262,6 +263,7 @@ TEST_CASE("Malformed formula in world.toml is rejected with field identification [world] height_tiles = 60 refund_percentage = 75 +deconstruction_time_seconds = 0.1 scrap_despawn_seconds = 30 scrap_per_threat = 0.01 tile_size_m = 10 @@ -314,6 +316,7 @@ TEST_CASE("Inverted wave gap range is rejected", "[config]") [world] height_tiles = 60 refund_percentage = 75 +deconstruction_time_seconds = 0.1 scrap_despawn_seconds = 30 scrap_per_threat = 0.01 tile_size_m = 10 diff --git a/src/test/SimulationTestAccess.h b/src/test/SimulationTestAccess.h index 2a7b913..e06fa0f 100644 --- a/src/test/SimulationTestAccess.h +++ b/src/test/SimulationTestAccess.h @@ -35,6 +35,11 @@ struct SimulationTestAccess static void demolish(Simulation& sim, BuildingId id) { sim.demolish(id); } + static void cancelDeconstruction(Simulation& sim, BuildingId id) + { + sim.cancelDeconstruction(id); + } + static void applySchematicChoice(Simulation& sim, int choiceIndex) { sim.applySchematicChoice(choiceIndex); diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index b3b5eec..3848ab6 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -1977,6 +1977,17 @@ void GameWorldView::drawOverlays(QPainter& painter) } } + // Queued for deconstruction: tint every building currently in the + // deconstruction queue, regardless of mode (REQ-BLD-DECON-QUEUE). + for (const Building& b : m_sim->getBuildings().getAllBuildings()) + { + if (!b.queuedForDeconstruction) { continue; } + for (const QPoint& cell : b.bodyCells) + { + painter.fillRect(tileRect(cell), m_visuals->overlays.demolishTint); + } + } + // Demolish tint: while dragging a demolish box, tint every covered // building/site (REQ-BLD-DEMOLISH-BOX); otherwise tint the hovered one. if (m_demolishMode && m_boxSelecting) @@ -2623,17 +2634,62 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) if (m_demolishMode) { - // Demolish every covered building/site; the HQ is protected - // (REQ-BLD-DEMOLISH, REQ-BLD-DEMOLISH-BOX). + const BuildingSystem& buildings = m_sim->getBuildings(); + + // Split covered ids into construction sites (removed instantly) and + // operational demolishable buildings (the HQ is protected; player + // defence stations are not Buildings and never appear in the box). + std::vector sites; + std::vector operational; for (BuildingId id : boxIds) { - const Building* b = m_sim->getBuildings().findBuilding(id); - if (b && b->type == BuildingType::Hq) { continue; } + if (const Building* b = buildings.findBuilding(id)) + { + if (b->type == BuildingType::Hq) { continue; } + operational.push_back(id); + } + else if (buildings.findSite(id)) + { + sites.push_back(id); + } + } + + // Sites: instant demolition with the full refund (REQ-BLD-DEMOLISH). + for (BuildingId id : sites) + { std::shared_ptr command = std::make_shared(); command->id = id; enqueueCommand(command); } + + // Operational buildings: if every covered one is already queued, + // un-queue them all; otherwise queue each one not yet queued. A plain + // click is the one-element case, giving the queue/un-queue toggle + // (REQ-BLD-DEMOLISH-BOX, REQ-BLD-DEMOLISH-CLICK). + bool allQueued = !operational.empty(); + for (BuildingId id : operational) + { + if (!buildings.isQueuedForDeconstruction(id)) { allQueued = false; break; } + } + for (BuildingId id : operational) + { + if (allQueued) + { + std::shared_ptr command = + std::make_shared(); + command->id = id; + enqueueCommand(command); + } + else if (!buildings.isQueuedForDeconstruction(id)) + { + std::shared_ptr command = + std::make_shared(); + command->id = id; + enqueueCommand(command); + } + } + m_demolishHoverBuildingId = std::nullopt; return; }