diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 98b1807..b08b8a5 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -825,89 +825,88 @@ void BuildingSystem::tickBeltPull() } } - // 2. Feed newly accepted items from adjacent belts onto the input belts at - // progress 0.0. HQ accepts building blocks into the global stock with no - // reservation; other buildings reserve a per-material buffer slot. - if (isHq) - { - for (std::size_t i = 0; i < building.inputPorts.size(); ++i) - { - const Port& port = building.inputPorts[i]; - std::vector& lane = building.incomingItems[i]; - const std::optional peeked = m_belts.peekItem(port); - if (!peeked || peeked->id != "building_block") { continue; } - if (!inputLaneEntryFree(lane)) { continue; } - const std::optional taken = m_belts.tryTakeItem(port); - if (taken) - { - lane.push_back(BeltItemSlot{*taken, 0.0}); - } - } - continue; - } - - // Auto-recipe buildings (Smelter, Reprocessing Plant) accept any item - // that is an input to one of their recipes; their caps already span the - // union of those inputs (initAutoBuffers), so no recipe lookup is needed. - if (!isAutoRecipeBuildingType(building.type)) - { - if (building.recipeId.empty()) - { - continue; - } - - if (building.type != BuildingType::Shipyard) - { - const RecipeDef* recipe = findRecipe(building.recipeId, building.type); - if (!recipe || recipe->inputs.empty()) - { - continue; - } - } - } - + // 2. Feed accepted items from adjacent belts onto the input belts at + // progress 0.0. The acceptance rules — the HQ building-block case, the + // required-input check, and the reservation — live in canAcceptInput so + // direct coupling (REQ-MAT-DIRECT-COUPLE) shares them exactly. for (std::size_t i = 0; i < building.inputPorts.size(); ++i) { - const Port& port = building.inputPorts[i]; - std::vector& lane = building.incomingItems[i]; - - const std::optional peeked = m_belts.peekItem(port); - if (!peeked) - { - continue; - } - - const ItemType& type = *peeked; - - // Accept only if this type is a required input and the buffer has space. - const std::map::const_iterator capIt = - building.inputBuffer.caps.find(type); - if (capIt == building.inputBuffer.caps.end() || capIt->second == 0) - { - continue; - } - - // Reservation-aware space test: buffered + in-transit must stay under - // the cap (REQ-MAT-INPUT-INTAKE). - if (building.pendingInputCount(type) >= capIt->second) - { - continue; - } - - if (!inputLaneEntryFree(lane)) - { - continue; - } - - const std::optional taken = m_belts.tryTakeItem(port); + const std::optional peeked = m_belts.peekItem(building.inputPorts[i]); + if (!peeked) { continue; } + if (!canAcceptInput(building, i, *peeked)) { continue; } + const std::optional taken = m_belts.tryTakeItem(building.inputPorts[i]); if (taken) { - lane.push_back(BeltItemSlot{*taken, 0.0}); + depositToInputBelt(building, i, *taken); } } } } +bool BuildingSystem::canAcceptInput(const Building& consumer, + std::size_t inputPortIndex, + const ItemType& type) const +{ + if (inputPortIndex >= consumer.incomingItems.size()) { return false; } + if (!inputLaneEntryFree(consumer.incomingItems[inputPortIndex])) { return false; } + + // The HQ has no input buffer; it accepts building blocks into the global stock + // (REQ-HQ-BELT-INPUT) with no reservation. + if (consumer.type == BuildingType::Hq) + { + return type.id == "building_block"; + } + + // Everyone else: the item must be a required input whose reservation-aware + // buffer has room — buffered + in-transit below the cap (REQ-MAT-INPUT-INTAKE). + const std::map::const_iterator capIt = + consumer.inputBuffer.caps.find(type); + if (capIt == consumer.inputBuffer.caps.end() || capIt->second == 0) + { + return false; + } + return consumer.pendingInputCount(type) < capIt->second; +} + +void BuildingSystem::depositToInputBelt(Building& consumer, + std::size_t inputPortIndex, + const Item& item) +{ + consumer.incomingItems[inputPortIndex].push_back(BeltItemSlot{item, 0.0}); +} + +bool BuildingSystem::tryDirectCoupleDeposit(BuildingId producerId, + const Port& outputPort, + const Item& item) +{ + const std::map, BuildingId>::const_iterator occIt = + m_tileOccupancy.find({outputPort.tile.x(), outputPort.tile.y()}); + if (occIt == m_tileOccupancy.end() || occIt->second == producerId) + { + return false; + } + + Building* consumer = findBuildingMutable(occIt->second); + if (!consumer) + { + return false; // an unbuilt construction site, or not an operational building + } + + // 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). + for (std::size_t j = 0; j < consumer->inputPorts.size(); ++j) + { + const Port& in = consumer->inputPorts[j]; + if (in.direction != outputPort.direction) { continue; } + if (inputBodyTile(in.tile, in.direction) != outputPort.tile) { continue; } + + if (!canAcceptInput(*consumer, j, item.type)) { return false; } + depositToInputBelt(*consumer, j, item); + return true; + } + return false; +} + void BuildingSystem::tickProduction(Tick currentTick) { TRACE(); @@ -1167,13 +1166,19 @@ void BuildingSystem::tickOutputBelts() // caps to 0.5 / 0.75 / 1.0 for up to three items). advanceBeltSlots(lane, progressPerTick); - // 2. Hand the front item off onto the adjacent real belt once it reaches - // the output edge (progress 1.0). On refusal — no belt, output-edge - // (REQ-MAT-ACCEPT-DIR), or a full belt — it stays stuck at 1.0. - if (!lane.empty() && lane.front().progress >= 1.0 - && m_belts.tryPutItem(port.tile, lane.front().item, port.direction)) + // 2. Hand the front item off once it reaches the output edge (progress + // 1.0): onto the adjacent real belt, or — if a building's input edge + // meets this port — straight into that building (REQ-MAT-DIRECT-COUPLE). + // On refusal (no belt/coupling, output-edge per REQ-MAT-ACCEPT-DIR, or + // a full target) it stays stuck at 1.0. + if (!lane.empty() && lane.front().progress >= 1.0) { - lane.erase(lane.begin()); + const Item item = lane.front().item; + if (m_belts.tryPutItem(port.tile, item, port.direction) + || tryDirectCoupleDeposit(building.id, port, item)) + { + lane.erase(lane.begin()); + } } // 3. Feed the next buffered item onto the lane at progress 0.5 when the @@ -1250,6 +1255,18 @@ const Building* BuildingSystem::findBuilding(BuildingId id) const return nullptr; } +Building* BuildingSystem::findBuildingMutable(BuildingId id) +{ + for (Building& building : m_buildings) + { + if (building.id == id) + { + return &building; + } + } + return nullptr; +} + const ConstructionSite* BuildingSystem::findSite(BuildingId id) const { for (const ConstructionSite& site : m_constructionQueue) diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 683ddd0..623f197 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -181,6 +181,25 @@ public: void appendChecksum(Hasher& hasher) const; private: + 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 + // buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE). + bool canAcceptInput(const Building& consumer, + std::size_t inputPortIndex, + const ItemType& type) const; + // Places an accepted item onto the consumer's input belt at progress 0.0, + // reserving a per-material buffer slot (REQ-MAT-INPUT-INTAKE). + void depositToInputBelt(Building& consumer, + std::size_t inputPortIndex, + const Item& item); + // Attempts to hand an emerging output item straight into a directly adjacent + // building whose input edge meets the producer's output port (REQ-MAT-DIRECT-COUPLE). + // Returns true if the item was accepted onto the consumer's input belt. + bool tryDirectCoupleDeposit(BuildingId producerId, + const Port& outputPort, + const Item& item); + const BuildingDef* findBuildingDef(BuildingType type) const; const RecipeDef* findRecipe(const std::string& id, BuildingType type) const; const ShipDef* findShipDef(const std::string& id) const; diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index f76f270..dfd6fc5 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -807,6 +807,84 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui REQUIRE(item->type.id == "iron_ore"); } +// Two directly adjacent buildings whose ports meet transfer items with no belt in +// between: a miner's iron_ore output feeds straight into a smelter, which smelts it +// (REQ-MAT-DIRECT-COUPLE). +TEST_CASE("BuildingSystem: output port couples directly into an adjacent input port", + "[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); + + // Miner at (0,0): body (0,0),(1,0),(0,1); output port tile (1,1) flowing East. + const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + bs.setRecipe(minerId, "mine_iron_ore"); + // Smelter anchored at (1,1): body (1,1),(2,1),(1,2),(2,2). Its body cell (1,1) is + // the miner's output-port tile, and its west input edge there faces East, so the + // two ports meet — no belt placed anywhere. + const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0); + + Tick tick = 0; + // Smelter build (15s) + margin for coupling and a smelt cycle. + runTicks(bs, belts, static_cast(secondsToTicks(30.0)), tick); + + const Building* smelter = bs.findBuilding(smelterId); + REQUIRE(smelter != nullptr); + // iron_ore reached the smelter over the direct coupling and was smelted. + bool hasIronIngot = false; + for (const Item& produced : outputSideItems(*smelter)) + { + if (produced.type.id == "iron_ingot") { hasIronIngot = true; } + } + REQUIRE(hasIronIngot); +} + +// A producer coupled to a building that cannot accept its item delivers nothing; the +// item stays stuck at the producer's output port (REQ-MAT-DIRECT-COUPLE acceptance). +TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stuck", + "[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); + + // Producing miner at (0,0), output port (1,1) East. + const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + bs.setRecipe(minerId, "mine_iron_ore"); + // A second, idle miner anchored at (1,1) occupies the output-port tile but takes + // no inputs, so it cannot accept the iron_ore. + const BuildingId sinkId = bs.place(BuildingType::Miner, QPoint(1, 1), Rotation::East, 0); + + Tick tick = 0; + // Both miners build sequentially (10s each), then the producer runs and jams. + runTicks(bs, belts, static_cast(secondsToTicks(25.0)), tick); + + const Building* miner = bs.findBuilding(minerId); + const Building* sink = bs.findBuilding(sinkId); + REQUIRE(miner != nullptr); + REQUIRE(sink != nullptr); + // Nothing was delivered, and the producer's output side has backed up to its cap. + REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0); + REQUIRE(miner->outputItemCount() == miner->outputBuffer.capacity); +} + // --------------------------------------------------------------------------- // setRecipe clears buffers // ---------------------------------------------------------------------------