#include "catch.hpp" #include #include #include #include #include #include "BeltSystem.h" #include "Item.h" #include "ItemType.h" #include "Port.h" #include "Rotation.h" #include "Tick.h" // Belt speed of 30 t/s means progress/tick = 30/30 = 1.0. // One tick() call advances any item exactly one full tile. // This makes test assertions on item positions simple and deterministic. static constexpr double kFastBeltSpeed = static_cast(kTickRateHz); static Item makeItem(const std::string& id) { Item item; item.type.id = id; return item; } static Port eastPort(QPoint tile) { Port p; p.tile = tile; p.direction = Rotation::East; return p; } // --------------------------------------------------------------------------- // Placement // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: tryPutItem succeeds on registered belt", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::East); REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East)); } TEST_CASE("BeltSystem: tryPutItem fails on unregistered tile", "[belt]") { BeltSystem bs(kFastBeltSpeed); REQUIRE_FALSE(bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East)); } TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::East); bs.removeTile(tile); REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East)); } // --------------------------------------------------------------------------- // Capacity // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: four items fit in one tile", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::East); REQUIRE(bs.tryPutItem(tile, makeItem("a"), Rotation::East)); REQUIRE(bs.tryPutItem(tile, makeItem("b"), Rotation::East)); REQUIRE(bs.tryPutItem(tile, makeItem("c"), Rotation::East)); REQUIRE(bs.tryPutItem(tile, makeItem("d"), Rotation::East)); } TEST_CASE("BeltSystem: fifth tryPutItem on full tile returns false", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::East); bs.tryPutItem(tile, makeItem("a"), Rotation::East); bs.tryPutItem(tile, makeItem("b"), Rotation::East); bs.tryPutItem(tile, makeItem("c"), Rotation::East); bs.tryPutItem(tile, makeItem("d"), Rotation::East); REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("e"), Rotation::East)); } // --------------------------------------------------------------------------- // tryTakeItem // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: tryTakeItem returns placed item after reaching output edge", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::East); bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East); bs.tick(); // advance to output edge const std::optional taken = bs.tryTakeItem(eastPort(tile)); REQUIRE(taken.has_value()); REQUIRE(taken->type.id == "iron_ore"); } TEST_CASE("BeltSystem: tryTakeItem requires item to reach output edge before yielding", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::East); bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East); // Item placed but not yet at output edge — must not be available. REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value()); REQUIRE_FALSE(bs.peekItem(eastPort(tile)).has_value()); // After one tick the item has reached progress 1.0 and is available. bs.tick(); REQUIRE(bs.tryTakeItem(eastPort(tile)).has_value()); } TEST_CASE("BeltSystem: tryTakeItem with two items returns both after each reaches output edge", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::East); bs.tryPutItem(tile, makeItem("first"), Rotation::East); bs.tryPutItem(tile, makeItem("second"), Rotation::East); // Front item reaches output edge after one tick. bs.tick(); const std::optional taken1 = bs.tryTakeItem(eastPort(tile)); REQUIRE(taken1.has_value()); // Back item (now promoted to front) needs another tick to reach output edge. bs.tick(); const std::optional taken2 = bs.tryTakeItem(eastPort(tile)); REQUIRE(taken2.has_value()); REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value()); } TEST_CASE("BeltSystem: tryTakeItem returns nullopt on empty tile", "[belt]") { BeltSystem bs(kFastBeltSpeed); bs.placeBelt(QPoint(0, 0), Rotation::East); REQUIRE_FALSE(bs.tryTakeItem(eastPort(QPoint(0, 0))).has_value()); } // --------------------------------------------------------------------------- // tick() — item advancement // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: item transfers from tile A to tile B and becomes available after two ticks", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tileA(0, 0); const QPoint tileB(1, 0); bs.placeBelt(tileA, Rotation::East); bs.placeBelt(tileB, Rotation::East); bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East); bs.tick(); // item reaches output edge of A, moves to B at progress 0 bs.tick(); // item reaches output edge of B REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileA)).has_value()); const std::optional inB = bs.tryTakeItem(eastPort(tileB)); REQUIRE(inB.has_value()); REQUIRE(inB->type.id == "iron_ore"); } TEST_CASE("BeltSystem: item stays at progress 1.0 when next tile is absent", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tileA(0, 0); bs.placeBelt(tileA, Rotation::East); bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East); bs.tick(); // Item should still be on tileA (no registered tile to the east). REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value()); } TEST_CASE("BeltSystem: item traverses 3-tile chain in 3 ticks (one per tile)", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tileA(0, 0); const QPoint tileB(1, 0); const QPoint tileC(2, 0); bs.placeBelt(tileA, Rotation::East); bs.placeBelt(tileB, Rotation::East); bs.placeBelt(tileC, Rotation::East); bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East); bs.tick(); // A output edge → moves to B at progress 0 bs.tick(); // B output edge → moves to C at progress 0 bs.tick(); // C output edge → available for pickup REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileA)).has_value()); REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileB)).has_value()); REQUIRE(bs.tryTakeItem(eastPort(tileC)).has_value()); } TEST_CASE("BeltSystem: item stays blocked when next tile is full", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tileA(0, 0); const QPoint tileB(1, 0); bs.placeBelt(tileA, Rotation::East); bs.placeBelt(tileB, Rotation::East); // Fill tileB to capacity. bs.tryPutItem(tileB, makeItem("b1"), Rotation::East); bs.tryPutItem(tileB, makeItem("b2"), Rotation::East); bs.tryPutItem(tileB, makeItem("b3"), Rotation::East); bs.tryPutItem(tileB, makeItem("b4"), Rotation::East); // Place item in tileA — should be blocked. bs.tryPutItem(tileA, makeItem("a1"), Rotation::East); bs.tick(); // Item in tileA must still be there. REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value()); } TEST_CASE("BeltSystem: belt second slot is capped at progress 0.75", "[belt]") { // Use progress/tick = 0.4 so the cap is observable: without it, slot[1] would // advance to 0.8 while slot[0] is stuck at 1.0. With the 0.75 cap it stays // at 0.75 and needs exactly 1 more tick after promotion. const double medBeltSpeed = 0.4 * static_cast(kTickRateHz); BeltSystem bs(medBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::East); // Advance front item to the output edge; it stays there (no next tile). bs.tryPutItem(tile, makeItem("front_item"), Rotation::East); bs.tick(); // slot[0]: 0.4 bs.tick(); // slot[0]: 0.8 bs.tick(); // slot[0]: 1.0 (capped, stuck) // Place second item; slot[0] is at 1.0. bs.tryPutItem(tile, makeItem("back_item"), Rotation::East); bs.tick(); // slot[1]: 0.4 bs.tick(); // slot[1] would reach 0.8 — capped at 0.75 // Remove front; slot[1] (now promoted to slot[0]) must be at 0.75. REQUIRE(bs.tryTakeItem(eastPort(tile)).has_value()); // At 0.4/tick, 0.75 → 1.0 (capped) after one tick — available. bs.tick(); REQUIRE(bs.tryTakeItem(eastPort(tile)).has_value()); } // --------------------------------------------------------------------------- // clearTiles // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: clearTiles removes all items from specified tiles", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::East); bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East); bs.tryPutItem(tile, makeItem("copper_ore"), Rotation::East); bs.clearTiles({tile}); REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value()); } // --------------------------------------------------------------------------- // forEachVisualItem // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: forEachVisualItem visits items inside viewport", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(5, 5); bs.placeBelt(tile, Rotation::East); bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East); int count = 0; bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; }); REQUIRE(count == 1); } TEST_CASE("BeltSystem: forEachVisualItem skips items outside viewport", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(50, 50); bs.placeBelt(tile, Rotation::East); bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East); int count = 0; bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; }); REQUIRE(count == 0); } TEST_CASE("BeltSystem: forEachVisualItem reports correct ItemType", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::East); bs.tryPutItem(tile, makeItem("copper_ingot"), Rotation::East); std::vector seen; bs.forEachVisualItem(QRect(-1, -1, 10, 10), [&seen](VisualItem vi) { seen.push_back(vi.type); }); REQUIRE(seen.size() == 1); REQUIRE(seen[0].id == "copper_ingot"); } // --------------------------------------------------------------------------- // Splitter — basic alternation (no filters) // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt]") { // 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); const QPoint tileSpl(1, 0); const QPoint tileA(1, -1); // North of splitter const QPoint tileB(1, 1); // South of splitter bs.placeBelt(tileIn, Rotation::East); bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); bs.placeBelt(tileA, Rotation::North); bs.placeBelt(tileB, Rotation::South); bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East); bs.tick(); // item1: tileIn -> splitter back (progress 0) bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East); 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(); // One item in each output — alternation worked. REQUIRE(inA); REQUIRE(inB); } // --------------------------------------------------------------------------- // Splitter — filter routing // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: splitter routes to preferred output when item matches both filters", "[belt]") { // filterA = iron_ore, filterB = {} (accept all) → iron_ore matches both. // With nextOutputIsA=true initially, alternation sends the item to A. BeltSystem bs(kFastBeltSpeed); const QPoint tileIn(0, 0); const QPoint tileSpl(1, 0); const QPoint tileA(1, -1); // North output const QPoint tileB(1, 1); // South output bs.placeBelt(tileIn, Rotation::East); bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); bs.placeBelt(tileA, Rotation::North); bs.placeBelt(tileB, Rotation::South); bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {}); bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); // tileIn -> splitter back bs.tick(); // back -> frontA (both match, alternation, preferred A) 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()); } TEST_CASE("BeltSystem: splitter routes item to output A when only filter A matches", "[belt]") { // filterA = {iron_ore}, filterB = {copper_ore}: iron_ore matches A exclusively → goes to A. 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.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"copper_ore"}}); bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); // tileIn -> splitter back bs.tick(); // back -> frontA (exclusive match to A) bs.tick(); // frontA reaches 1.0; no downstream belt, waits for building pickup REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); } TEST_CASE("BeltSystem: splitter routes item to output B when only filter B matches", "[belt]") { // filterA = {copper_ore}, filterB = {iron_ore}: iron_ore matches B exclusively → goes to B. 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.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ore"}}); bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); bs.tick(); bs.tick(); REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); } TEST_CASE("BeltSystem: splitter alternates A then B when item matches both explicit filters", "[belt]") { // filterA = {iron_ore}, filterB = {iron_ore}: both match → strict alternation A, B, A. 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.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}}); // Item 1 → preferred A (nextOutputIsA=true initially). bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); bs.tick(); bs.tick(); REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); // Item 2 → preferred B (nextOutputIsA toggled to false). bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); bs.tick(); bs.tick(); REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value()); // Item 3 → preferred A again (nextOutputIsA toggled back to true). bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); bs.tick(); bs.tick(); REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); } TEST_CASE("BeltSystem: splitter routes unmatched item to the unfiltered output", "[belt]") { // filterA = {copper_ore} (non-empty), filterB = {} (accept all). // iron_ore: matchesA=false, matchesB=true → goes to B. 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.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {}); bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); bs.tick(); bs.tick(); REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); } TEST_CASE("BeltSystem: splitter stalls when item matches neither filter", "[belt]") { // filterA = {copper_ore}, filterB = {iron_ingot}: iron_ore matches neither → stall. 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.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ingot"}}); bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); // tileIn -> splitter back bs.tick(); // back reaches 0.5; routing fires but stalls (no filter match) bs.tick(); // back stays at 0.5; stall persists REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); } TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blocked", "[belt]") { // filterA = filterB = {iron_ore}: both match → alternation. // When preferred output is occupied, item goes to the other without toggling nextOutputIsA. 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.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}}); // Item 1 → preferred A (nextOutputIsA=true → false after routing). bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); bs.tick(); bs.tick(); // frontA = item1 at 1.0 // Item 2 → preferred B (nextOutputIsA=false → true after routing). Take item2 to free frontB. bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); bs.tick(); bs.tick(); // frontB = item2 at 1.0 REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value()); // frontA still holds item1; nextOutputIsA=true (prefer A). // Item 3: both match, preferred A is occupied → fallback to B without toggling nextOutputIsA. bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); bs.tick(); bs.tick(); // frontB = item3 at 1.0 REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); // item1 still in A REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); // item3 in B via fallback // nextOutputIsA was not toggled by the fallback: next item should still prefer A. REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); // free frontA REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value()); // free frontB bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East); bs.tick(); bs.tick(); bs.tick(); REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); // item4 → A (preferA still true) REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); } TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75", "[belt]") { // When the preferred output is blocked, the diverted item is dropped onto the // open output near its edge (progress 0.75) instead of at progress 0.0. This // closes the large gap that would otherwise appear between items leaving the // open side of a half-blocked splitter. // // Progress/tick = 0.25 so the 0.0-vs-0.75 entry position is observable: a // normally-routed item starts at 0.0, a fallback item starts at 0.75. const double quarterSpeed = 0.25 * static_cast(kTickRateHz); BeltSystem bs(quarterSpeed); const QPoint tileSpl(1, 0); const QPoint tileB(1, 1); // South output belt; North output has no belt (blocked). bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); bs.placeBelt(tileB, Rotation::South); // Reads a named item's progress along the South output via the rendering contract. // slotWorldPos maps a South-bound slot on tileSpl (y = 0) to worldPos.y == progress. // Matching by id avoids the blocked North item, which also renders at worldPos.y 0. auto southProgressOf = [&bs](const std::string& id) -> std::optional { std::optional progress; bs.forEachVisualItem(QRect(-5, -5, 20, 20), [&](VisualItem vi) { if (vi.type.id == id) { progress = vi.worldPos.y(); } }); return progress; }; // Permanently block output A: route one item to frontA where it sticks at 1.0 // (North has no downstream tile, so it can never move out). bs.tryPutItem(tileSpl, makeItem("blockA"), Rotation::East); bs.tick(); // back: 0.25 bs.tick(); // back: 0.5 -> frontA at 0.75 (preferred A), nextOutputIsA = false bs.tick(); bs.tick(); // frontA: 0.75 -> 1.0 (stuck, no North downstream) // Cycle one item through B as the *preferred* output (also enters at 0.75) to // flip nextOutputIsA back to true and free frontB for the fallback case below. bs.tryPutItem(tileSpl, makeItem("toB_pref"), Rotation::East); bs.tick(); // back: 0.25 bs.tick(); // back: 0.5 -> frontB at 0.75 (preferred B), nextOutputIsA = true REQUIRE(southProgressOf("toB_pref") == Approx(0.75)); // One tick reaches the edge and hands off to tileB; the rest just clear frontB. bs.tick(); bs.tick(); // frontB: 0.75 -> 1.0 -> tileB, then empty // Next item prefers A again (nextOutputIsA == true), but A is still blocked, // so it falls back to B — and must enter near the edge at progress 0.75. bs.tryPutItem(tileSpl, makeItem("toB_fallback"), Rotation::East); bs.tick(); // back: 0.25 bs.tick(); // back: 0.5 -> fallback routes to frontB at 0.75 REQUIRE(southProgressOf("toB_fallback") == Approx(0.75)); } TEST_CASE("BeltSystem: splitter with an exclusive filter enters its only output at progress 0.75", "[belt]") { // An item that matches only one filter has a single eligible output. Like the // blocked-fallback case, it must enter near the edge (progress 0.75) so the // one-item-wide front does not throttle that output and open large gaps. const double quarterSpeed = 0.25 * static_cast(kTickRateHz); BeltSystem bs(quarterSpeed); const QPoint tileSpl(1, 0); bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"copper_ore"}}); // Inverts slotWorldPos to recover a named item's progress along the given output. auto progressOf = [&bs, tileSpl](const std::string& id, Rotation dir) -> std::optional { std::optional progress; bs.forEachVisualItem(QRect(-5, -5, 20, 20), [&](VisualItem vi) { if (vi.type.id != id) { return; } switch (dir) { case Rotation::North: progress = (tileSpl.y() + 1.0) - vi.worldPos.y(); break; case Rotation::South: progress = vi.worldPos.y() - tileSpl.y(); break; case Rotation::East: progress = vi.worldPos.x() - tileSpl.x(); break; case Rotation::West: progress = (tileSpl.x() + 1.0) - vi.worldPos.x(); break; } }); return progress; }; // iron_ore matches filterA only -> sole eligible output A. bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East); bs.tick(); // back: 0.25 bs.tick(); // back: 0.5 -> routes to frontA at 0.75 REQUIRE(progressOf("iron_ore", Rotation::North) == Approx(0.75)); // copper_ore matches filterB only -> sole eligible output B. bs.tryPutItem(tileSpl, makeItem("copper_ore"), Rotation::East); bs.tick(); // back: 0.25 bs.tick(); // back: 0.5 -> routes to frontB at 0.75 REQUIRE(progressOf("copper_ore", Rotation::South) == Approx(0.75)); } TEST_CASE("BeltSystem: splitter alternation enters the preferred output at progress 0.75", "[belt]") { // With both outputs eligible and free, the preferred output uses the same // near-edge entry as the diverted paths, so an evenly-split splitter keeps // each side packed instead of throttling it to one in-flight item per tile. const double quarterSpeed = 0.25 * static_cast(kTickRateHz); BeltSystem bs(quarterSpeed); const QPoint tileSpl(1, 0); bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); // no filters: both match auto progressOf = [&bs, tileSpl](const std::string& id, Rotation dir) -> std::optional { std::optional progress; bs.forEachVisualItem(QRect(-5, -5, 20, 20), [&](VisualItem vi) { if (vi.type.id != id) { return; } switch (dir) { case Rotation::North: progress = (tileSpl.y() + 1.0) - vi.worldPos.y(); break; case Rotation::South: progress = vi.worldPos.y() - tileSpl.y(); break; case Rotation::East: progress = vi.worldPos.x() - tileSpl.x(); break; case Rotation::West: progress = (tileSpl.x() + 1.0) - vi.worldPos.x(); break; } }); return progress; }; // First item: preferred A (nextOutputIsA starts true) -> frontA at 0.75. bs.tryPutItem(tileSpl, makeItem("first"), Rotation::East); bs.tick(); // back: 0.25 bs.tick(); // back: 0.5 -> routes to preferred frontA at 0.75, nextOutputIsA = false REQUIRE(progressOf("first", Rotation::North) == Approx(0.75)); // Second item: preference flipped, B is free -> frontB at 0.75. bs.tryPutItem(tileSpl, makeItem("second"), Rotation::East); bs.tick(); // back: 0.25 (first sticks at North 1.0, no downstream) bs.tick(); // back: 0.5 -> routes to preferred frontB at 0.75 REQUIRE(progressOf("second", Rotation::South) == Approx(0.75)); } // --------------------------------------------------------------------------- // 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"), Rotation::East); 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"), Rotation::East); 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"), Rotation::East); 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"), Rotation::East); 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: tryPutItem succeeds directly on a splitter tile", "[belt]") { // Regression: buildings outputting onto a splitter tile were silently dropped // because tryPutItem had no splitter case and returned false. BeltSystem bs(kFastBeltSpeed); const QPoint tileSpl(0, 0); bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East)); // Item should arrive at one of the output fronts after the splitter ticks through. bs.tick(); // back advances to 0.5, routes to frontA bs.tick(); // frontA reaches 1.0 REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).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"), Rotation::East); 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"), Rotation::East); 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"), Rotation::East); bs.tick(); bs.tick(); bs.tick(); REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); } // --------------------------------------------------------------------------- // Tunnel — pairing // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: tunnel pairing — basic pair within max distance", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint entry(0, 0); const QPoint exit(3, 0); bs.placeTunnelEntry(entry, Rotation::East, 10); bs.placeTunnelExit(exit, Rotation::East); bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East); // With kFastBeltSpeed, items cross one tile per tick. // entry tile: 1 tick to reach front progress 1.0 // transit: 3 tiles distance → 3 ticks // exit tile: 1 tick to reach front progress 1.0 // Total: 1 (entry) + 1 (entry→transit) + 3 (transit) + 1 (transit→exit) + 1 (exit advance) = ~5-7 ticks for (int i = 0; i < 20; ++i) { bs.tick(); } REQUIRE(bs.peekItem(Port{exit, Rotation::East}).has_value()); const std::optional taken = bs.tryTakeItem(Port{exit, Rotation::East}); REQUIRE(taken.has_value()); REQUIRE(taken->type.id == "iron_ore"); } TEST_CASE("BeltSystem: tunnel pairing — wrong direction prevents pair", "[belt]") { BeltSystem bs(kFastBeltSpeed); bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10); bs.placeTunnelExit(QPoint(3, 0), Rotation::North); bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East); for (int i = 0; i < 20; ++i) { bs.tick(); } // Exit faces North, not East — no pair formed, item stuck in entry. REQUIRE_FALSE(bs.peekItem(Port{QPoint(3, 0), Rotation::North}).has_value()); } TEST_CASE("BeltSystem: tunnel pairing — beyond max distance prevents pair", "[belt]") { BeltSystem bs(kFastBeltSpeed); bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 2); bs.placeTunnelExit(QPoint(3, 0), Rotation::East); bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East); for (int i = 0; i < 20; ++i) { bs.tick(); } REQUIRE_FALSE(bs.peekItem(Port{QPoint(3, 0), Rotation::East}).has_value()); } TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing", "[belt]") { // Entry1 at (0,0) East, Entry2 at (2,0) East, Exit at (4,0) East. // Entry2 is closer to Exit → Entry2 pairs with Exit; Entry1 is blocked by Entry2. BeltSystem bs(kFastBeltSpeed); bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10); bs.placeTunnelEntry(QPoint(2, 0), Rotation::East, 10); bs.placeTunnelExit(QPoint(4, 0), Rotation::East); // Put item on Entry2 — should reach exit. bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore"), Rotation::East); for (int i = 0; i < 20; ++i) { bs.tick(); } REQUIRE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value()); bs.tryTakeItem(Port{QPoint(4, 0), Rotation::East}); // Put item on Entry1 — should NOT reach exit (Entry1 is unpaired). bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East); for (int i = 0; i < 20; ++i) { bs.tick(); } REQUIRE_FALSE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value()); } TEST_CASE("BeltSystem: tunnel pairing — cross-dir entry between is ignored", "[belt]") { // Entry1 at (0,0) East, Entry2 at (2,0) North (different dir), Exit at (4,0) East. // Entry2 faces North → ignored → Entry1 pairs with Exit. BeltSystem bs(kFastBeltSpeed); bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10); bs.placeTunnelEntry(QPoint(2, 0), Rotation::North, 10); bs.placeTunnelExit(QPoint(4, 0), Rotation::East); bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East); for (int i = 0; i < 20; ++i) { bs.tick(); } REQUIRE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value()); } // --------------------------------------------------------------------------- // Tunnel — item transit // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: unpaired entry blocks items at front", "[belt]") { BeltSystem bs(kFastBeltSpeed); bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10); // No exit placed — entry is unpaired. bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East); for (int i = 0; i < 10; ++i) { bs.tick(); } // Item should not vanish — it stays in the entry. // We can verify by placing an exit and seeing item eventually arrive. bs.placeTunnelExit(QPoint(3, 0), Rotation::East); for (int i = 0; i < 20; ++i) { bs.tick(); } REQUIRE(bs.peekItem(Port{QPoint(3, 0), Rotation::East}).has_value()); } TEST_CASE("BeltSystem: demolish entry discards transit items", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint entry(0, 0); const QPoint exit(5, 0); bs.placeTunnelEntry(entry, Rotation::East, 10); bs.placeTunnelExit(exit, Rotation::East); bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East); // Advance just enough for item to enter transit but not reach exit. bs.tick(); // item enters entry front bs.tick(); // entry front → transit (progress 0) bs.removeTile(entry); // Even with many more ticks, nothing arrives at exit. for (int i = 0; i < 30; ++i) { bs.tick(); } REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value()); } TEST_CASE("BeltSystem: clearTiles discards tunnel transit items", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint entry(0, 0); const QPoint exit(5, 0); bs.placeTunnelEntry(entry, Rotation::East, 10); bs.placeTunnelExit(exit, Rotation::East); bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East); bs.tick(); bs.tick(); bs.clearTiles({entry}); for (int i = 0; i < 30; ++i) { bs.tick(); } REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value()); } TEST_CASE("BeltSystem: belt to entry to transit to exit to belt full chain", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint beltIn(0, 0); const QPoint entry(1, 0); const QPoint exit(4, 0); const QPoint beltOut(5, 0); bs.placeBelt(beltIn, Rotation::East); bs.placeTunnelEntry(entry, Rotation::East, 10); bs.placeTunnelExit(exit, Rotation::East); bs.placeBelt(beltOut, Rotation::East); bs.tryPutItem(beltIn, makeItem("iron_ore"), Rotation::East); for (int i = 0; i < 30; ++i) { bs.tick(); } // Item should have arrived on beltOut. REQUIRE(bs.peekItem(eastPort(beltOut)).has_value()); const std::optional taken = bs.tryTakeItem(eastPort(beltOut)); REQUIRE(taken.has_value()); REQUIRE(taken->type.id == "iron_ore"); } TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint entry(0, 0); const QPoint exit(3, 0); bs.placeTunnelEntry(entry, Rotation::East, 10); bs.placeTunnelExit(exit, Rotation::East); bs.tryPutItem(entry, makeItem("item1"), Rotation::East); bs.tick(); bs.tick(); // item1 enters transit bs.tryPutItem(entry, makeItem("item2"), Rotation::East); for (int i = 0; i < 30; ++i) { bs.tick(); } // item1 should arrive first. const std::optional taken1 = bs.tryTakeItem(Port{exit, Rotation::East}); REQUIRE(taken1.has_value()); REQUIRE(taken1->type.id == "item1"); for (int i = 0; i < 10; ++i) { bs.tick(); } const std::optional taken2 = bs.tryTakeItem(Port{exit, Rotation::East}); REQUIRE(taken2.has_value()); REQUIRE(taken2->type.id == "item2"); } // --------------------------------------------------------------------------- // Output-edge rejection (REQ-MAT-ACCEPT-DIR) // --------------------------------------------------------------------------- TEST_CASE("BeltSystem: belt refuses an item arriving through its output edge", "[belt]") { // Belt A flows East into belt B, but B flows West — so the hand-off would // enter B through its own (West) output edge and must be refused. Without the // guard the item would ping-pong between the two belts forever. BeltSystem bs(kFastBeltSpeed); const QPoint tileA(0, 0); const QPoint tileB(1, 0); bs.placeBelt(tileA, Rotation::East); bs.placeBelt(tileB, Rotation::West); REQUIRE(bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East)); for (int i = 0; i < 5; ++i) { bs.tick(); } // B never accepts the item through its output edge... REQUIRE_FALSE(bs.tryTakeItem(Port{tileB, Rotation::West}).has_value()); // ...and it stays blocked at A's output edge. REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value()); } TEST_CASE("BeltSystem: tryPutItem refuses a deposit onto a belt facing the source", "[belt]") { // A belt whose output edge faces back toward the depositing building must // refuse the item; feeding through a non-output edge still works. BeltSystem bs(kFastBeltSpeed); const QPoint tile(0, 0); bs.placeBelt(tile, Rotation::West); // Item travelling East enters through the West (output) edge -> refused. REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East)); // Item travelling West enters through the East (back) edge -> accepted. REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::West)); } TEST_CASE("BeltSystem: splitter refuses an item arriving through an output edge", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint tileSpl(1, 0); bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); // Entering through the North output edge (item travelling South) -> refused. REQUIRE_FALSE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::South)); // Entering through the South output edge (item travelling North) -> refused. REQUIRE_FALSE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::North)); // Entering through a non-output (West) edge (item travelling East) -> accepted. REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East)); } TEST_CASE("BeltSystem: tunnel entry refuses an item arriving through its mouth edge", "[belt]") { BeltSystem bs(kFastBeltSpeed); const QPoint entry(0, 0); bs.placeTunnelEntry(entry, Rotation::East, 10); // Item travelling West enters through the East mouth (output) edge -> refused. REQUIRE_FALSE(bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::West)); // Item travelling East enters through the back (West) edge -> accepted. REQUIRE(bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East)); } TEST_CASE("BeltSystem: tunnel exit refuses an item pushed into its output edge", "[belt]") { // A splitter's East front sits next to a tunnel exit whose output faces West // (back toward the splitter). The front cannot hand off into the exit's // output edge, so the item stays on the splitter front. BeltSystem bs(kFastBeltSpeed); const QPoint tileSpl(1, 0); const QPoint exitTile(2, 0); bs.placeSplitter(tileSpl, Rotation::East, Rotation::South); bs.placeTunnelExit(exitTile, Rotation::West); // Feed through the West edge (item travelling East); first item routes to the // East front (nextOutputIsA starts true). REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East)); for (int i = 0; i < 5; ++i) { bs.tick(); } // The item is stuck on the splitter's East front; the exit never received it. REQUIRE(bs.peekItem(Port{tileSpl, Rotation::East}).has_value()); REQUIRE_FALSE(bs.peekItem(Port{exitTile, Rotation::West}).has_value()); }