diff --git a/src/lib/sim/Building.h b/src/lib/sim/Building.h index ac675d0..b3e3bec 100644 --- a/src/lib/sim/Building.h +++ b/src/lib/sim/Building.h @@ -96,6 +96,31 @@ struct Building return count; } + // Items currently travelling inward on each input port's virtual input belt + // (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each + // lane holds slots at progress [0.0, 0.5], front (highest progress) first. An + // in-transit item has reserved a slot in its per-material input buffer but is + // not yet consumable — it enters the buffer only on reaching progress 0.5. + std::vector> incomingItems; + + // Buffered plus in-transit count of one input material. The acceptance/space + // test (REQ-MAT-INPUT-PORTS, REQ-MAT-INPUT-INTAKE) counts in-transit items, so + // buffered + reserved never exceeds the material's cap (REQ-MAT-INPUT-BUFFER). + int pendingInputCount(const ItemType& type) const + { + int count = 0; + const std::map::const_iterator it = inputBuffer.counts.find(type); + if (it != inputBuffer.counts.end()) { count = it->second; } + for (const std::vector& lane : incomingItems) + { + for (const BeltItemSlot& slot : lane) + { + if (slot.item.type == type) { ++count; } + } + } + return count; + } + // Pre-computed from surface mask at placement; in absolute world coordinates. std::vector bodyCells; std::vector outputPorts; diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 5da7e13..98b1807 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -35,6 +35,30 @@ QPoint outputBodyTile(QPoint portTile, Rotation direction) } return portTile; } + +// The building body tile an input port feeds into, given the port's outside belt +// tile (port.tile) and its inward flow direction. The virtual input belt occupies +// this tile and flows from the outer edge (progress 0.0) to the centre (0.5) +// (REQ-MAT-INPUT-INTAKE). +QPoint inputBodyTile(QPoint portTile, Rotation inwardDirection) +{ + switch (inwardDirection) + { + case Rotation::East: return portTile + QPoint( 1, 0); + case Rotation::West: return portTile + QPoint(-1, 0); + case Rotation::North: return portTile + QPoint( 0, -1); + case Rotation::South: return portTile + QPoint( 0, 1); + } + return portTile; +} + +// An input belt accepts a new item at progress 0.0 only when it holds fewer than +// three items and the entry slot is clear (nothing within a quarter tile of 0.0), +// matching the belt packing used elsewhere (REQ-GW-BELT-CAPACITY). +bool inputLaneEntryFree(const std::vector& lane) +{ + return lane.size() < 3 && (lane.empty() || lane.back().progress >= 0.25); +} } // namespace BuildingSystem::BuildingSystem(const GameConfig& config, @@ -539,8 +563,11 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId) building.outputBuffer.items.clear(); building.outputBuffer.capacity = 0; // Emerging items are part of the output buffer, so clearing it on a - // recipe change discards them too (REQ-MAT-OUTPUT-EMERGE). + // recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit + // input items are discarded and their reservations released + // (REQ-MAT-INPUT-INTAKE). for (std::vector& lane : building.emergingItems) { lane.clear(); } + for (std::vector& lane : building.incomingItems) { lane.clear(); } building.production = std::nullopt; if (!recipeId.empty()) @@ -588,6 +615,7 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout building.outputBuffer.items.clear(); building.outputBuffer.capacity = 0; for (std::vector& lane : building.emergingItems) { lane.clear(); } + for (std::vector& lane : building.incomingItems) { lane.clear(); } if (!building.recipeId.empty() && building.type == BuildingType::Shipyard) { initShipyardBuffers(building); @@ -693,6 +721,7 @@ void BuildingSystem::tickConstruction(Tick currentTick) } building.emergingItems.resize(building.outputPorts.size()); building.inputPorts = computeInputPorts(building); + building.incomingItems.assign(building.inputPorts.size(), {}); if (building.type == BuildingType::SalvageBay) { @@ -765,21 +794,53 @@ void BuildingSystem::tickConstruction(Tick currentTick) void BuildingSystem::tickBeltPull() { TRACE(); + // Same per-tick step as the belts, so items travel inward at belt speed + // (REQ-GW-BELT-SPEED, REQ-MAT-INPUT-INTAKE). + const double progressPerTick = m_belts.getProgressPerTick_tpt(); + for (Building& building : m_buildings) { - // HQ: pull building_block items and add to global stock. - if (building.type == BuildingType::Hq) + const bool isHq = (building.type == BuildingType::Hq); + + // 1. Advance every input belt and deliver arrivals (progress >= 0.5) into + // the input buffer — or the global stock for the HQ. Runs for all + // buildings so in-transit items keep moving even when feeding is gated + // off, and arrivals become consumable before tickProduction (step 4). + for (std::size_t i = 0; i < building.incomingItems.size(); ++i) { - for (const Port& port : building.inputPorts) + std::vector& lane = building.incomingItems[i]; + advanceBeltSlots(lane, progressPerTick); + while (!lane.empty() && lane.front().progress >= 0.5) { - const std::optional peeked = m_belts.peekItem(port); - if (peeked && peeked->id == "building_block") + const Item arrived = lane.front().item; + lane.erase(lane.begin()); + if (isHq) { - const std::optional taken = m_belts.tryTakeItem(port); - if (taken) - { - m_addBuildingBlocks(1); - } + m_addBuildingBlocks(1); + } + else + { + building.inputBuffer.counts[arrived.type]++; + } + } + } + + // 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; @@ -805,8 +866,11 @@ void BuildingSystem::tickBeltPull() } } - for (const Port& port : building.inputPorts) + 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) { @@ -815,7 +879,7 @@ void BuildingSystem::tickBeltPull() const ItemType& type = *peeked; - // Accept only if this type is a required input and buffer has space. + // 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) @@ -823,14 +887,14 @@ void BuildingSystem::tickBeltPull() continue; } - const int current = [&]() -> int + // Reservation-aware space test: buffered + in-transit must stay under + // the cap (REQ-MAT-INPUT-INTAKE). + if (building.pendingInputCount(type) >= capIt->second) { - const std::map::const_iterator it = - building.inputBuffer.counts.find(type); - return (it != building.inputBuffer.counts.end()) ? it->second : 0; - }(); + continue; + } - if (current >= capIt->second) + if (!inputLaneEntryFree(lane)) { continue; } @@ -838,7 +902,7 @@ void BuildingSystem::tickBeltPull() const std::optional taken = m_belts.tryTakeItem(port); if (taken) { - building.inputBuffer.counts[taken->type]++; + lane.push_back(BeltItemSlot{*taken, 0.0}); } } } @@ -1148,6 +1212,28 @@ void BuildingSystem::forEachEmergingItem( } } +void BuildingSystem::forEachIncomingItem( + const std::function& visit) const +{ + for (const Building& building : m_buildings) + { + for (std::size_t p = 0; p < building.inputPorts.size(); ++p) + { + const Port& port = building.inputPorts[p]; + const QPoint bodyTile = inputBodyTile(port.tile, port.direction); + const std::vector& lane = building.incomingItems[p]; + + // Render least-progressed first (bottom) → most-progressed last (top), + // matching belt item ordering (REQ-GW-TILE-SIZE). + for (int i = static_cast(lane.size()) - 1; i >= 0; --i) + { + visit(lane[i].item.type, + beltSlotWorldPos(bodyTile, port.direction, lane[i].progress)); + } + } + } +} + // --------------------------------------------------------------------------- // Queries // --------------------------------------------------------------------------- @@ -1342,6 +1428,9 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation) b.emergingItems.clear(); b.emergingItems.resize(b.outputPorts.size()); b.inputPorts = computeInputPorts(b); + // Likewise discard in-transit input items and re-size the input belts to + // the new port set (REQ-MAT-INPUT-INTAKE). + b.incomingItems.assign(b.inputPorts.size(), {}); // Re-register with BeltSystem (items on tile are discarded). if (b.type == BuildingType::Belt) @@ -1449,6 +1538,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type, } building.emergingItems.resize(building.outputPorts.size()); building.inputPorts = computeInputPorts(building); + building.incomingItems.assign(building.inputPorts.size(), {}); if (type == BuildingType::SalvageBay) { @@ -1564,6 +1654,16 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const hasher.append(slot.progress); } } + hasher.append(b.incomingItems.size()); + for (const std::vector& lane : b.incomingItems) + { + hasher.append(lane.size()); + for (const BeltItemSlot& slot : lane) + { + hasher.append(slot.item.type.id); + hasher.append(slot.progress); + } + } hasher.append(b.production.has_value()); if (b.production.has_value()) { diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 83eb149..683ddd0 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -132,6 +132,12 @@ public: void forEachEmergingItem( const std::function& visit) const; + // Visits every item currently travelling inward on a building input port's + // virtual input belt (REQ-MAT-INPUT-INTAKE), passing the item type and its + // world-space centre (in tile units). Least-progressed first (drawn bottom). + void forEachIncomingItem( + const std::function& visit) const; + // Returns the entity id of the building or construction site whose footprint // exactly coincides with the ghost (type, anchor, rot) and is of the same // building type. Returns nullopt otherwise. diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 75e4787..f76f270 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -580,10 +580,93 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing const Building* b = bs.findBuilding(sid); REQUIRE(b != nullptr); - const std::map::const_iterator it = + // The item was accepted; it may still be travelling inward on the input belt, + // so count buffered + in-transit (REQ-MAT-INPUT-INTAKE). + REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1); +} + +// An accepted input item travels inward on its input belt before it becomes usable +// stock: it is reserved (counts against the cap) on entry and only enters the +// buffer on reaching the tile centre (REQ-MAT-INPUT-INTAKE). +TEST_CASE("BuildingSystem: accepted input travels inward before entering the buffer", + "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(static_cast(kTickRateHz)); // fast belt: 1 tile/tick + 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); + + const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0); + Tick tick = 0; + runTicks(bs, belts, static_cast(secondsToTicks(15.0)) + 1, tick); + + belts.placeBelt(QPoint(2, 0), Rotation::West); + belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); + belts.tick(); + bs.tickBeltPull(); // accepts the item onto the input belt at progress 0.0 + + const Building* b = bs.findBuilding(sid); + REQUIRE(b != nullptr); + // Reserved but not yet consumable: nothing in the buffer, but it counts against + // the cap via pendingInputCount. + const std::map::const_iterator it0 = b->inputBuffer.counts.find(ItemType{"iron_ore"}); - REQUIRE(it != b->inputBuffer.counts.end()); - REQUIRE(it->second >= 1); + REQUIRE((it0 == b->inputBuffer.counts.end() || it0->second == 0)); + REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1); + + // One more pull tick advances the input belt to the centre; the item arrives. + bs.tickBeltPull(); + REQUIRE(b->inputBuffer.counts.at(ItemType{"iron_ore"}) == 1); + REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1); +} + +// The acceptance test counts in-transit items, so buffered + reserved never exceeds +// the per-material cap; excess items stay on the belt (REQ-MAT-INPUT-INTAKE). +TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at the cap", + "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(static_cast(kTickRateHz)); // fast belt + 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); + + const BuildingId id = bs.place(BuildingType::ReprocessingPlant, + QPoint(0, 0), Rotation::East, 0); + Tick tick = 0; + runTicks(bs, belts, static_cast(secondsToTicks(25.0)) + 1, tick); + + // Feed scrap via an input belt without ever running production (only pull), so + // the buffer fills and stays full. Try to over-fill it well past the cap. + belts.placeBelt(QPoint(-1, 0), Rotation::East); + for (int i = 0; i < 20; ++i) + { + belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East); + belts.tick(); + bs.tickBeltPull(); + } + + const Building* b = bs.findBuilding(id); + REQUIRE(b != nullptr); + const int cap = b->inputBuffer.caps.at(ItemType{"scrap"}); + REQUIRE(cap > 0); + // buffered + in-transit is capped; the plant never over-pulls. + REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == cap); + // Excess scrap is left stuck on the feeding belt rather than silently dropped. + REQUIRE(belts.peekItem(eastPort(QPoint(-1, 0))).has_value()); } // A smelter auto-selects the matching recipe for whatever it is fed, with no @@ -838,14 +921,12 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta bs.tickBeltPull(); } - // Verify scrap is in input buffer. + // Verify all five scrap were accepted; some may still be travelling inward on + // the input belt (REQ-MAT-INPUT-INTAKE), so count buffered + in-transit. { const Building* b = bs.findBuilding(id); REQUIRE(b != nullptr); - const std::map::const_iterator it = - b->inputBuffer.counts.find(ItemType{"scrap"}); - REQUIRE(it != b->inputBuffer.counts.end()); - REQUIRE(it->second == 5); + REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5); } // Run production cycle (3s = 90 ticks + 1 for the completion tick). diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 3dc7c30..71cc639 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -402,10 +402,11 @@ void GameWorldView::paintGL() painter.setRenderHint(QPainter::Antialiasing, false); drawTiles(painter); - // Emerging items are drawn before the buildings so the building body occludes - // the portion still inside the footprint, making items appear to slide out of - // the output port rather than pop into existence (REQ-MAT-OUTPUT-EMERGE). - drawEmergingItems(painter); + // Port items are drawn before the buildings so the building body occludes the + // portion still inside the footprint: items appear to slide out of the output + // port (REQ-MAT-OUTPUT-EMERGE) and into the input port (REQ-MAT-INPUT-INTAKE) + // rather than popping in or out of existence. + drawPortItems(painter); drawBuildings(painter); drawCopyConfigFeedback(painter); drawStations(painter); @@ -1157,11 +1158,14 @@ void GameWorldView::drawCopyConfigFeedback(QPainter& painter) } } -void GameWorldView::drawEmergingItems(QPainter& painter) +void GameWorldView::drawPortItems(QPainter& painter) { const float halfPx = tilePx() * 0.5f * 0.5f; - m_sim->buildings().forEachEmergingItem( + // Shared with belt items (REQ-GW-TILE-SIZE): a half-tile filled square with an + // outline, occluded by the building drawn afterwards so the item slides out of + // (REQ-MAT-OUTPUT-EMERGE) or into (REQ-MAT-INPUT-INTAKE) the port. + const std::function drawItem = [&](const ItemType& type, QPointF worldPos) { const std::map::const_iterator it = @@ -1177,7 +1181,10 @@ void GameWorldView::drawEmergingItems(QPainter& painter) painter.setPen(QPen(it->second.outline, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(rect); - }); + }; + + m_sim->buildings().forEachEmergingItem(drawItem); + m_sim->buildings().forEachIncomingItem(drawItem); } void GameWorldView::drawBeltItems(QPainter& painter) diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 49e69c0..1edcdee 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -115,7 +115,7 @@ private: bool canAfford(BuildingType type) const; void drawTiles(QPainter& painter); - void drawEmergingItems(QPainter& painter); + void drawPortItems(QPainter& painter); void drawBuildings(QPainter& painter); void drawSelectionHighlights(QPainter& painter); void drawCopyConfigFeedback(QPainter& painter);