diff --git a/src/lib/sim/BeltSystem.cpp b/src/lib/sim/BeltSystem.cpp index 14e49e2..9a7aec5 100644 --- a/src/lib/sim/BeltSystem.cpp +++ b/src/lib/sim/BeltSystem.cpp @@ -66,6 +66,7 @@ void BeltSystem::placeSplitter(QPoint tile, Rotation outputA, Rotation outputB) st.outputA = outputA; st.outputB = outputB; st.nextOutputIsA = true; + st.backDir = Rotation::North; // irrelevant until back is set m_splitters[key(tile)] = st; } @@ -104,45 +105,79 @@ bool BeltSystem::tryPutItem(QPoint tile, Item item) std::optional BeltSystem::tryTakeItem(Port port) { - const std::map, BeltTile>::iterator it = m_belts.find(key(port.tile)); - if (it == m_belts.end()) - { - return std::nullopt; - } - if (it->second.direction != port.direction) + const std::map, BeltTile>::iterator beltIt = m_belts.find(key(port.tile)); + if (beltIt != m_belts.end()) { + if (beltIt->second.direction != port.direction) + { + return std::nullopt; + } + BeltTile& bt = beltIt->second; + if (bt.front && bt.front->progress >= 1.0) + { + const Item taken = bt.front->item; + bt.front = bt.back; + bt.back = std::nullopt; + return taken; + } return std::nullopt; } - BeltTile& bt = it->second; - if (bt.front && bt.front->progress >= 1.0) + const std::map, SplitterTile>::iterator splIt = + m_splitters.find(key(port.tile)); + if (splIt != m_splitters.end()) { - const Item taken = bt.front->item; - bt.front = bt.back; - bt.back = std::nullopt; - return taken; + SplitterTile& st = splIt->second; + if (port.direction == st.outputA && st.frontA && st.frontA->progress >= 1.0) + { + const Item taken = st.frontA->item; + st.frontA = std::nullopt; + return taken; + } + if (port.direction == st.outputB && st.frontB && st.frontB->progress >= 1.0) + { + const Item taken = st.frontB->item; + st.frontB = std::nullopt; + return taken; + } } + return std::nullopt; } std::optional BeltSystem::peekItem(Port port) const { - const std::map, BeltTile>::const_iterator it = + const std::map, BeltTile>::const_iterator beltIt = m_belts.find(key(port.tile)); - if (it == m_belts.end()) - { - return std::nullopt; - } - if (it->second.direction != port.direction) + if (beltIt != m_belts.end()) { + if (beltIt->second.direction != port.direction) + { + return std::nullopt; + } + const BeltTile& bt = beltIt->second; + if (bt.front && bt.front->progress >= 1.0) + { + return bt.front->item.type; + } return std::nullopt; } - const BeltTile& bt = it->second; - if (bt.front && bt.front->progress >= 1.0) + const std::map, SplitterTile>::const_iterator splIt = + m_splitters.find(key(port.tile)); + if (splIt != m_splitters.end()) { - return bt.front->item.type; + const SplitterTile& st = splIt->second; + if (port.direction == st.outputA && st.frontA && st.frontA->progress >= 1.0) + { + return st.frontA->item.type; + } + if (port.direction == st.outputB && st.frontB && st.frontB->progress >= 1.0) + { + return st.frontB->item.type; + } } + return std::nullopt; } @@ -164,7 +199,9 @@ void BeltSystem::clearTiles(const std::vector& tiles) const std::map, SplitterTile>::iterator sIt = m_splitters.find(key(tile)); if (sIt != m_splitters.end()) { - sIt->second.heldItem = std::nullopt; + sIt->second.back = std::nullopt; + sIt->second.frontA = std::nullopt; + sIt->second.frontB = std::nullopt; } } } @@ -216,10 +253,44 @@ void BeltSystem::advanceProgress() } } } + + for (std::map, SplitterTile>::iterator it = m_splitters.begin(); + it != m_splitters.end(); ++it) + { + SplitterTile& st = it->second; + + if (st.back) + { + st.back->progress += m_progressPerTick; + if (st.back->progress > 0.5) + { + st.back->progress = 0.5; + } + } + + if (st.frontA) + { + st.frontA->progress += m_progressPerTick; + if (st.frontA->progress > 1.0) + { + st.frontA->progress = 1.0; + } + } + + if (st.frontB) + { + st.frontB->progress += m_progressPerTick; + if (st.frontB->progress > 1.0) + { + st.frontB->progress = 1.0; + } + } + } } void BeltSystem::moveItemsToNextTile() { + // Belt items advancing into the next tile. for (std::map, BeltTile>::iterator it = m_belts.begin(); it != m_belts.end(); ++it) { @@ -246,18 +317,47 @@ void BeltSystem::moveItemsToNextTile() } else if (nextSplitter != m_splitters.end()) { - if (!nextSplitter->second.heldItem) + if (!nextSplitter->second.back) { - nextSplitter->second.heldItem = bt.front->item; + nextSplitter->second.back = BeltItemSlot{bt.front->item, 0.0}; + nextSplitter->second.backDir = bt.direction; bt.front = bt.back; bt.back = std::nullopt; } - // else: splitter busy — item stays blocked at progress 1.0. + // else: splitter back occupied — item stays blocked at progress 1.0. } // else: no tile registered (e.g. open space, or building input port). // Items leaving into unregistered tiles are not consumed here — the // building pull step uses tryTakeItem for that. } + + // Splitter front slots advancing into downstream belt tiles. + for (std::map, SplitterTile>::iterator it = m_splitters.begin(); + it != m_splitters.end(); ++it) + { + SplitterTile& st = it->second; + const QPoint here = QPoint(it->first.first, it->first.second); + + if (st.frontA && st.frontA->progress >= 1.0) + { + const QPoint dest = adjacentTile(here, st.outputA); + if (tryPlaceOnBelt(dest, st.frontA->item)) + { + st.frontA = std::nullopt; + } + // else: downstream belt full or absent — item stays at progress 1.0 + // for building pickup via tryTakeItem. + } + + if (st.frontB && st.frontB->progress >= 1.0) + { + const QPoint dest = adjacentTile(here, st.outputB); + if (tryPlaceOnBelt(dest, st.frontB->item)) + { + st.frontB = std::nullopt; + } + } + } } void BeltSystem::routeSplitterItems() @@ -266,12 +366,12 @@ void BeltSystem::routeSplitterItems() it != m_splitters.end(); ++it) { SplitterTile& st = it->second; - if (!st.heldItem) + if (!st.back || st.back->progress < 0.5) { continue; } - const Item& item = *st.heldItem; + const Item& item = st.back->item; const bool matchesA = st.filterA.empty() || std::find(st.filterA.begin(), st.filterA.end(), item.type) != st.filterA.end(); @@ -280,42 +380,52 @@ void BeltSystem::routeSplitterItems() if (matchesA && !matchesB) { - const QPoint dest = adjacentTile(QPoint(it->first.first, it->first.second), st.outputA); - if (tryPlaceOnBelt(dest, item)) + if (!st.frontA) { - st.heldItem = std::nullopt; + st.frontA = BeltItemSlot{item, 0.0}; + st.back = std::nullopt; } } else if (matchesB && !matchesA) { - const QPoint dest = adjacentTile(QPoint(it->first.first, it->first.second), st.outputB); - if (tryPlaceOnBelt(dest, item)) + if (!st.frontB) { - st.heldItem = std::nullopt; + st.frontB = BeltItemSlot{item, 0.0}; + st.back = std::nullopt; } } else if (matchesA && matchesB) { - // Alternation: try preferred output first, fall back to other. - const Rotation preferred = st.nextOutputIsA ? st.outputA : st.outputB; - const Rotation fallback = st.nextOutputIsA ? st.outputB : st.outputA; + // Alternation: try preferred output first, fall back to other if preferred full. + const bool preferA = st.nextOutputIsA; - const QPoint prefDest = adjacentTile(QPoint(it->first.first, it->first.second), preferred); - const QPoint fbDest = adjacentTile(QPoint(it->first.first, it->first.second), fallback); - - if (tryPlaceOnBelt(prefDest, item)) + if (preferA && !st.frontA) { - st.heldItem = std::nullopt; - st.nextOutputIsA = !st.nextOutputIsA; + st.frontA = BeltItemSlot{item, 0.0}; + st.back = std::nullopt; + st.nextOutputIsA = false; } - else if (tryPlaceOnBelt(fbDest, item)) + else if (!preferA && !st.frontB) { - st.heldItem = std::nullopt; - // nextOutputIsA stays: preferred was blocked, so we still owe it next. + st.frontB = BeltItemSlot{item, 0.0}; + st.back = std::nullopt; + st.nextOutputIsA = true; } - // else both blocked — item stays. + else if (preferA && !st.frontB) + { + // Preferred (A) is full — fall back to B; nextOutputIsA stays. + st.frontB = BeltItemSlot{item, 0.0}; + st.back = std::nullopt; + } + else if (!preferA && !st.frontA) + { + // Preferred (B) is full — fall back to A; nextOutputIsA stays. + st.frontA = BeltItemSlot{item, 0.0}; + st.back = std::nullopt; + } + // else both fronts occupied — back stays. } - // else (!matchesA && !matchesB): stall — item stays in splitter. + // else (!matchesA && !matchesB): stall — back stays. } } @@ -381,4 +491,39 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles, visit(vi); } } + + for (const std::pair, SplitterTile>& entry : m_splitters) + { + const QPoint tile(entry.first.first, entry.first.second); + if (!viewportTiles.contains(tile)) + { + continue; + } + + const SplitterTile& st = entry.second; + + if (st.back) + { + VisualItem vi; + vi.type = st.back->item.type; + vi.worldPos = slotWorldPos(tile, st.backDir, st.back->progress); + visit(vi); + } + + if (st.frontA) + { + VisualItem vi; + vi.type = st.frontA->item.type; + vi.worldPos = slotWorldPos(tile, st.outputA, st.frontA->progress); + visit(vi); + } + + if (st.frontB) + { + VisualItem vi; + vi.type = st.frontB->item.type; + vi.worldPos = slotWorldPos(tile, st.outputB, st.frontB->progress); + visit(vi); + } + } } diff --git a/src/lib/sim/BeltSystem.h b/src/lib/sim/BeltSystem.h index fba6219..744f527 100644 --- a/src/lib/sim/BeltSystem.h +++ b/src/lib/sim/BeltSystem.h @@ -108,10 +108,13 @@ private: { Rotation outputA; Rotation outputB; - std::vector filterA; // empty = accept all + std::vector filterA; // empty = accept all std::vector filterB; - bool nextOutputIsA; // alternation state - std::optional heldItem; // item buffered waiting to exit + bool nextOutputIsA; // alternation state + std::optional back; // progress [0, 0.5]; entering from input belt + Rotation backDir; // direction of the feeding belt (for animation) + std::optional frontA; // progress [0, 1]; routed to outputA + std::optional frontB; // progress [0, 1]; routed to outputB }; double m_progressPerTick; // beltSpeedTilesPerSecond / kTickRateHz diff --git a/src/test/BeltSystemTest.cpp b/src/test/BeltSystemTest.cpp index 9439d95..85c93f5 100644 --- a/src/test/BeltSystemTest.cpp +++ b/src/test/BeltSystemTest.cpp @@ -296,8 +296,9 @@ TEST_CASE("BeltSystem: forEachVisualItem reports correct ItemType", "[belt]") TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt]") { - // Layout: tileIn -> splitter -> tileA (West output) - // -> tileB (East output) + // Layout: tileIn -> splitter -> tileA (North output) + // -> tileB (South output) + // Pipeline per item: tileIn(1) -> back(2) -> front(3) -> output belt(4) BeltSystem bs(kFastBeltSpeed); const QPoint tileIn(0, 0); @@ -311,12 +312,14 @@ TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt] bs.placeBelt(tileB, Rotation::South); bs.tryPutItem(tileIn, makeItem("item1")); - bs.tick(); // item moves: tileIn -> splitter held + bs.tick(); // item1: tileIn -> splitter back (progress 0) bs.tryPutItem(tileIn, makeItem("item2")); - bs.tick(); // item1 routes to outputA (North=tileA); item2 moves to splitter - - bs.tick(); // item2 routes to outputB (South=tileB) + bs.tick(); // item1 back -> 0.5 -> frontA; item2 advances but back is occupied + bs.tick(); // item1 frontA -> 1.0 -> tileA; item2 enters splitter back + bs.tick(); // item2 back -> 0.5 -> frontB; item1 at tileA output edge + bs.tick(); // item2 frontB -> 1.0 -> tileB + bs.tick(); // item2 at tileB output edge const bool inA = bs.tryTakeItem(Port{tileA, Rotation::North}).has_value(); const bool inB = bs.tryTakeItem(Port{tileB, Rotation::South}).has_value(); @@ -345,12 +348,126 @@ TEST_CASE("BeltSystem: splitter routes filtered item to matching output", "[belt bs.placeBelt(tileB, Rotation::South); // Filter: outputA = iron_ore only; outputB = accept all. + // iron_ore matches both filters → alternation; preferred = outputA (nextOutputIsA=true). bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {}); bs.tryPutItem(tileIn, makeItem("iron_ore")); - bs.tick(); // tileIn -> splitter held - bs.tick(); // routed to outputA (filter match) + bs.tick(); // tileIn -> splitter back + bs.tick(); // back -> frontA (filter + alternation → preferred outputA) + bs.tick(); // frontA -> tileA + bs.tick(); // item at tileA output edge REQUIRE(bs.tryTakeItem(Port{tileA, Rotation::North}).has_value()); REQUIRE_FALSE(bs.tryTakeItem(Port{tileB, Rotation::South}).has_value()); } + +// --------------------------------------------------------------------------- +// Splitter — direct building input (no output belts) +// --------------------------------------------------------------------------- + +TEST_CASE("BeltSystem: splitter back slot is capped at 0.5 and waits before routing", "[belt]") +{ + BeltSystem bs(kFastBeltSpeed); + + const QPoint tileIn(0, 0); + const QPoint tileSpl(1, 0); + + bs.placeBelt(tileIn, Rotation::East); + bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); + + bs.tryPutItem(tileIn, makeItem("iron_ore")); + bs.tick(); // item enters splitter back at progress 0; routing not yet triggered + + // Back has not yet reached 0.5 — front slots empty, nothing available. + REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); + REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); + + bs.tick(); // back advances to 0.5, routes to frontA at progress 0 + bs.tick(); // frontA advances to 1.0, available for building pickup + + REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); +} + +TEST_CASE("BeltSystem: splitter delivers item directly to building input via tryTakeItem", "[belt]") +{ + // Bug 1: splitter could not insert into a building input with no belt in between. + BeltSystem bs(kFastBeltSpeed); + + const QPoint tileIn(0, 0); + const QPoint tileSpl(1, 0); + + bs.placeBelt(tileIn, Rotation::East); + bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); + // No output belts — both outputs lead directly to building inputs. + + bs.tryPutItem(tileIn, makeItem("iron_ore")); + bs.tick(); // tileIn -> splitter back + bs.tick(); // back -> frontA at progress 0 + bs.tick(); // frontA reaches 1.0; no downstream belt, item waits for building pickup + + REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); + const std::optional taken = bs.tryTakeItem(Port{tileSpl, Rotation::North}); + REQUIRE(taken.has_value()); + REQUIRE(taken->type.id == "iron_ore"); +} + +TEST_CASE("BeltSystem: splitter accepts new items after building pulls from front slot", "[belt]") +{ + // Bug 2: when outputs had no belts, splitter never cleared its held state + // so no new items could enter. + BeltSystem bs(kFastBeltSpeed); + + const QPoint tileIn(0, 0); + const QPoint tileSpl(1, 0); + + bs.placeBelt(tileIn, Rotation::East); + bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); + + bs.tryPutItem(tileIn, makeItem("item1")); + bs.tick(); + bs.tick(); + bs.tick(); // item1 now in frontA at 1.0 + + // Building pulls item1 — clears frontA; nextOutputIsA toggled to false. + REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); + + // Feed item2; preferred is now South. + bs.tryPutItem(tileIn, makeItem("item2")); + bs.tick(); + bs.tick(); + bs.tick(); // item2 now in frontB at 1.0 + + REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); +} + +TEST_CASE("BeltSystem: splitter alternates between two unregistered outputs (building inputs)", "[belt]") +{ + BeltSystem bs(kFastBeltSpeed); + + const QPoint tileIn(0, 0); + const QPoint tileSpl(1, 0); + + bs.placeBelt(tileIn, Rotation::East); + bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); + + // item1 → frontA (preferred, nextOutputIsA=true) + bs.tryPutItem(tileIn, makeItem("item1")); + bs.tick(); + bs.tick(); + bs.tick(); + REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); + + // item2 → frontB (preferred, nextOutputIsA now false) + bs.tryPutItem(tileIn, makeItem("item2")); + bs.tick(); + bs.tick(); + bs.tick(); + REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value()); + + // item3 → frontA again (nextOutputIsA toggled back to true) + bs.tryPutItem(tileIn, makeItem("item3")); + bs.tick(); + bs.tick(); + bs.tick(); + REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); +}