Reject items entering a transport tile through its output edge

Belts, splitters, and tunnels accepted items regardless of which edge
they arrived through, so a tile would take an item pushed into its own
output edge (e.g. two facing belts ping-ponged an item forever).

Add oppositeRotation() and an entersThroughOutputEdge() predicate, and
guard every acceptance site (tryPutItem, tryPushToTile, and the belt
transfer in moveItemsToNextTile) so an item entering through an output
edge is refused (REQ-MAT-ACCEPT-DIR). Building output-port deposits now
also refuse a belt that faces back into the building.

Update requirements (new REQ-MAT-ACCEPT-DIR; REQ-MAT-OUTPUT-PORT and
REQ-BLD-TUNNEL-ENTRY clarified) and tests (explicit feed direction on
direct deposits; new output-edge rejection cases per tile type).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps
This commit is contained in:
2026-07-14 10:36:39 +02:00
parent e04883a95f
commit d70eb43dbb
5 changed files with 249 additions and 80 deletions

View File

@@ -27,6 +27,56 @@ QPoint BeltSystem::adjacentTile(QPoint tile, Rotation dir)
return tile;
}
Rotation BeltSystem::oppositeRotation(Rotation dir)
{
switch (dir)
{
case Rotation::North: return Rotation::South;
case Rotation::East: return Rotation::West;
case Rotation::South: return Rotation::North;
case Rotation::West: return Rotation::East;
}
return dir;
}
bool BeltSystem::entersThroughOutputEdge(QPoint tile, Rotation travelDir) const
{
// An item travelling in travelDir crosses into the tile through the edge
// opposite that direction. If that entry edge is one of the tile's output
// edges, the tile must refuse the item (REQ-MAT-ACCEPT-DIR).
const Rotation entryEdge = oppositeRotation(travelDir);
const std::map<std::pair<int, int>, BeltTile>::const_iterator beltIt =
m_belts.find(key(tile));
if (beltIt != m_belts.end())
{
return entryEdge == beltIt->second.direction;
}
const std::map<std::pair<int, int>, SplitterTile>::const_iterator splIt =
m_splitters.find(key(tile));
if (splIt != m_splitters.end())
{
return entryEdge == splIt->second.outputA || entryEdge == splIt->second.outputB;
}
const std::map<std::pair<int, int>, TunnelEntryTile>::const_iterator teIt =
m_tunnelEntries.find(key(tile));
if (teIt != m_tunnelEntries.end())
{
return entryEdge == teIt->second.direction;
}
const std::map<std::pair<int, int>, TunnelExitTile>::const_iterator txIt =
m_tunnelExits.find(key(tile));
if (txIt != m_tunnelExits.end())
{
return entryEdge == txIt->second.direction;
}
return false;
}
QPointF BeltSystem::slotWorldPos(QPoint tile, Rotation dir, double progress)
{
// Map progress [0, 1] along the belt direction to a fractional tile-unit position.
@@ -202,6 +252,12 @@ void BeltSystem::reevaluateTunnelPairing()
bool BeltSystem::tryPutItem(QPoint tile, Item item, Rotation fromDir)
{
// Refuse items that would enter through the tile's output edge (REQ-MAT-ACCEPT-DIR).
if (entersThroughOutputEdge(tile, fromDir))
{
return false;
}
const std::map<std::pair<int, int>, BeltTile>::iterator bIt = m_belts.find(key(tile));
if (bIt != m_belts.end())
{
@@ -579,6 +635,13 @@ void BeltSystem::moveItemsToNextTile()
const QPoint here = QPoint(it->first.first, it->first.second);
const QPoint next = adjacentTile(here, bt.direction);
// Refuse to hand off into a downstream tile's output edge (REQ-MAT-ACCEPT-DIR);
// the item stays blocked at progress 1.0.
if (entersThroughOutputEdge(next, bt.direction))
{
continue;
}
const std::map<std::pair<int, int>, BeltTile>::iterator nextBelt = m_belts.find(key(next));
const std::map<std::pair<int, int>, SplitterTile>::iterator nextSplitter = m_splitters.find(key(next));
@@ -804,6 +867,12 @@ bool BeltSystem::tryPlaceOnBelt(QPoint tile, Item item)
bool BeltSystem::tryPushToTile(QPoint dest, Item item, Rotation fromDir)
{
// Refuse items that would enter through the tile's output edge (REQ-MAT-ACCEPT-DIR).
if (entersThroughOutputEdge(dest, fromDir))
{
return false;
}
if (tryPlaceOnBelt(dest, item))
{
return true;

View File

@@ -74,8 +74,10 @@ public:
// port.direction = direction items flow on that tile
//
// tryPutItem: place item onto tile.
// Returns false if the tile is not a belt/splitter, or tile full.
// fromDir: travel direction of the item (used for splitter animation).
// Returns false if the tile is not a belt/splitter/tunnel entry, tile full,
// or the item would enter through the tile's output edge (REQ-MAT-ACCEPT-DIR).
// fromDir: travel direction of the item (used for splitter animation and for
// the output-edge check).
bool tryPutItem(QPoint tile, Item item, Rotation fromDir = Rotation::West);
// tryTakeItem: remove and return the leading item from port.tile.
@@ -117,6 +119,12 @@ private:
static std::pair<int, int> key(QPoint tile);
static QPoint adjacentTile(QPoint tile, Rotation dir);
static Rotation oppositeRotation(Rotation dir);
// True if an item travelling in travelDir would enter the transport tile at
// `tile` through one of that tile's output edges (and must therefore be
// refused). Returns false if no transport tile occupies `tile`.
bool entersThroughOutputEdge(QPoint tile, Rotation travelDir) const;
// Returns the world-space centre of a slot given tile origin and progress.
static QPointF slotWorldPos(QPoint tile, Rotation dir, double progress);

View File

@@ -44,14 +44,14 @@ TEST_CASE("BeltSystem: tryPutItem succeeds on registered belt", "[belt]")
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore")));
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")));
REQUIRE_FALSE(bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East));
}
TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]")
@@ -61,7 +61,7 @@ TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]")
bs.placeBelt(tile, Rotation::East);
bs.removeTile(tile);
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore")));
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East));
}
// ---------------------------------------------------------------------------
@@ -74,10 +74,10 @@ TEST_CASE("BeltSystem: four items fit in one tile", "[belt]")
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
REQUIRE(bs.tryPutItem(tile, makeItem("a")));
REQUIRE(bs.tryPutItem(tile, makeItem("b")));
REQUIRE(bs.tryPutItem(tile, makeItem("c")));
REQUIRE(bs.tryPutItem(tile, makeItem("d")));
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]")
@@ -86,12 +86,12 @@ TEST_CASE("BeltSystem: fifth tryPutItem on full tile returns false", "[belt]")
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("a"));
bs.tryPutItem(tile, makeItem("b"));
bs.tryPutItem(tile, makeItem("c"));
bs.tryPutItem(tile, makeItem("d"));
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")));
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("e"), Rotation::East));
}
// ---------------------------------------------------------------------------
@@ -103,7 +103,7 @@ TEST_CASE("BeltSystem: tryTakeItem returns placed item after reaching output edg
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
bs.tick(); // advance to output edge
const std::optional<Item> taken = bs.tryTakeItem(eastPort(tile));
@@ -117,7 +117,7 @@ TEST_CASE("BeltSystem: tryTakeItem requires item to reach output edge before yie
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
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());
@@ -133,8 +133,8 @@ TEST_CASE("BeltSystem: tryTakeItem with two items returns both after each reache
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("first"));
bs.tryPutItem(tile, makeItem("second"));
bs.tryPutItem(tile, makeItem("first"), Rotation::East);
bs.tryPutItem(tile, makeItem("second"), Rotation::East);
// Front item reaches output edge after one tick.
bs.tick();
@@ -169,7 +169,7 @@ TEST_CASE("BeltSystem: item transfers from tile A to tile B and becomes availabl
bs.placeBelt(tileA, Rotation::East);
bs.placeBelt(tileB, Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"));
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
@@ -185,7 +185,7 @@ TEST_CASE("BeltSystem: item stays at progress 1.0 when next tile is absent", "[b
const QPoint tileA(0, 0);
bs.placeBelt(tileA, Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"));
bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East);
bs.tick();
// Item should still be on tileA (no registered tile to the east).
@@ -202,7 +202,7 @@ TEST_CASE("BeltSystem: item traverses 3-tile chain in 3 ticks (one per tile)", "
bs.placeBelt(tileB, Rotation::East);
bs.placeBelt(tileC, Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"));
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
@@ -221,13 +221,13 @@ TEST_CASE("BeltSystem: item stays blocked when next tile is full", "[belt]")
bs.placeBelt(tileB, Rotation::East);
// Fill tileB to capacity.
bs.tryPutItem(tileB, makeItem("b1"));
bs.tryPutItem(tileB, makeItem("b2"));
bs.tryPutItem(tileB, makeItem("b3"));
bs.tryPutItem(tileB, makeItem("b4"));
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"));
bs.tryPutItem(tileA, makeItem("a1"), Rotation::East);
bs.tick();
// Item in tileA must still be there.
@@ -246,13 +246,13 @@ TEST_CASE("BeltSystem: belt second slot is capped at progress 0.75", "[belt]")
bs.placeBelt(tile, Rotation::East);
// Advance front item to the output edge; it stays there (no next tile).
bs.tryPutItem(tile, makeItem("front_item"));
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"));
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
@@ -272,8 +272,8 @@ TEST_CASE("BeltSystem: clearTiles removes all items from specified tiles", "[bel
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
bs.tryPutItem(tile, makeItem("copper_ore"));
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tile, makeItem("copper_ore"), Rotation::East);
bs.clearTiles({tile});
@@ -289,7 +289,7 @@ 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"));
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
int count = 0;
bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });
@@ -302,7 +302,7 @@ 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"));
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
int count = 0;
bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });
@@ -315,7 +315,7 @@ 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"));
bs.tryPutItem(tile, makeItem("copper_ingot"), Rotation::East);
std::vector<ItemType> seen;
bs.forEachVisualItem(QRect(-1, -1, 10, 10), [&seen](VisualItem vi)
@@ -348,10 +348,10 @@ TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt]
bs.placeBelt(tileA, Rotation::North);
bs.placeBelt(tileB, Rotation::South);
bs.tryPutItem(tileIn, makeItem("item1"));
bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
bs.tick(); // item1: tileIn -> splitter back (progress 0)
bs.tryPutItem(tileIn, makeItem("item2"));
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
@@ -388,7 +388,7 @@ TEST_CASE("BeltSystem: splitter routes to preferred output when item matches bot
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {});
bs.tryPutItem(tileIn, makeItem("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
@@ -411,7 +411,7 @@ TEST_CASE("BeltSystem: splitter routes item to output A when only filter A match
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"copper_ore"}});
bs.tryPutItem(tileIn, makeItem("iron_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
@@ -433,7 +433,7 @@ TEST_CASE("BeltSystem: splitter routes item to output B when only filter B match
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ore"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tick();
bs.tick();
bs.tick();
@@ -456,17 +456,17 @@ TEST_CASE("BeltSystem: splitter alternates A then B when item matches both expli
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});
// Item 1 → preferred A (nextOutputIsA=true initially).
bs.tryPutItem(tileIn, makeItem("iron_ore"));
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"));
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"));
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());
@@ -486,7 +486,7 @@ TEST_CASE("BeltSystem: splitter routes unmatched item to the unfiltered output",
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {});
bs.tryPutItem(tileIn, makeItem("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());
@@ -506,7 +506,7 @@ TEST_CASE("BeltSystem: splitter stalls when item matches neither filter", "[belt
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ingot"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
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
@@ -530,17 +530,17 @@ TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blo
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});
// Item 1 → preferred A (nextOutputIsA=true → false after routing).
bs.tryPutItem(tileIn, makeItem("iron_ore"));
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"));
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"));
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
@@ -549,7 +549,7 @@ TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blo
// 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"));
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());
@@ -591,14 +591,14 @@ TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75
// 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"));
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"));
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));
@@ -608,7 +608,7 @@ TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75
// 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"));
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));
@@ -648,13 +648,13 @@ TEST_CASE("BeltSystem: splitter with an exclusive filter enters its only output
};
// iron_ore matches filterA only -> sole eligible output A.
bs.tryPutItem(tileSpl, makeItem("iron_ore"));
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"));
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));
@@ -692,13 +692,13 @@ TEST_CASE("BeltSystem: splitter alternation enters the preferred output at progr
};
// First item: preferred A (nextOutputIsA starts true) -> frontA at 0.75.
bs.tryPutItem(tileSpl, makeItem("first"));
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"));
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));
@@ -718,7 +718,7 @@ TEST_CASE("BeltSystem: splitter back slot is capped at 0.5 and waits before rout
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
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.
@@ -743,7 +743,7 @@ TEST_CASE("BeltSystem: splitter delivers item directly to building input via try
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
// No output belts — both outputs lead directly to building inputs.
bs.tryPutItem(tileIn, makeItem("iron_ore"));
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
@@ -766,7 +766,7 @@ TEST_CASE("BeltSystem: splitter accepts new items after building pulls from fron
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.tryPutItem(tileIn, makeItem("item1"));
bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
bs.tick();
bs.tick();
bs.tick(); // item1 now in frontA at 1.0
@@ -775,7 +775,7 @@ TEST_CASE("BeltSystem: splitter accepts new items after building pulls from fron
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
// Feed item2; preferred is now South.
bs.tryPutItem(tileIn, makeItem("item2"));
bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East);
bs.tick();
bs.tick();
bs.tick(); // item2 now in frontB at 1.0
@@ -812,21 +812,21 @@ TEST_CASE("BeltSystem: splitter alternates between two unregistered outputs (bui
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
// item1 → frontA (preferred, nextOutputIsA=true)
bs.tryPutItem(tileIn, makeItem("item1"));
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"));
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"));
bs.tryPutItem(tileIn, makeItem("item3"), Rotation::East);
bs.tick();
bs.tick();
bs.tick();
@@ -847,7 +847,7 @@ TEST_CASE("BeltSystem: tunnel pairing — basic pair within max distance", "[bel
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
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
@@ -873,7 +873,7 @@ TEST_CASE("BeltSystem: tunnel pairing — wrong direction prevents pair", "[belt
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
bs.placeTunnelExit(QPoint(3, 0), Rotation::North);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
for (int i = 0; i < 20; ++i)
{
@@ -891,7 +891,7 @@ TEST_CASE("BeltSystem: tunnel pairing — beyond max distance prevents pair", "[
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 2);
bs.placeTunnelExit(QPoint(3, 0), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
for (int i = 0; i < 20; ++i)
{
@@ -912,7 +912,7 @@ TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing"
bs.placeTunnelExit(QPoint(4, 0), Rotation::East);
// Put item on Entry2 — should reach exit.
bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore"), Rotation::East);
for (int i = 0; i < 20; ++i)
{
bs.tick();
@@ -921,7 +921,7 @@ TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing"
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"));
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
for (int i = 0; i < 20; ++i)
{
bs.tick();
@@ -939,7 +939,7 @@ TEST_CASE("BeltSystem: tunnel pairing — cross-dir entry between is ignored", "
bs.placeTunnelEntry(QPoint(2, 0), Rotation::North, 10);
bs.placeTunnelExit(QPoint(4, 0), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
for (int i = 0; i < 20; ++i)
{
bs.tick();
@@ -958,7 +958,7 @@ TEST_CASE("BeltSystem: unpaired entry blocks items at front", "[belt]")
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
// No exit placed — entry is unpaired.
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
for (int i = 0; i < 10; ++i)
{
bs.tick();
@@ -984,7 +984,7 @@ TEST_CASE("BeltSystem: demolish entry discards transit items", "[belt]")
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
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
@@ -1010,7 +1010,7 @@ TEST_CASE("BeltSystem: clearTiles discards tunnel transit items", "[belt]")
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);
bs.tick();
bs.tick();
@@ -1037,7 +1037,7 @@ TEST_CASE("BeltSystem: belt to entry to transit to exit to belt full chain", "[b
bs.placeTunnelExit(exit, Rotation::East);
bs.placeBelt(beltOut, Rotation::East);
bs.tryPutItem(beltIn, makeItem("iron_ore"));
bs.tryPutItem(beltIn, makeItem("iron_ore"), Rotation::East);
for (int i = 0; i < 30; ++i)
{
@@ -1061,11 +1061,11 @@ TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]")
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("item1"));
bs.tryPutItem(entry, makeItem("item1"), Rotation::East);
bs.tick();
bs.tick(); // item1 enters transit
bs.tryPutItem(entry, makeItem("item2"));
bs.tryPutItem(entry, makeItem("item2"), Rotation::East);
for (int i = 0; i < 30; ++i)
{
@@ -1086,3 +1086,94 @@ TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]")
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());
}

View File

@@ -214,13 +214,13 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
// Belt is queued — not yet in BeltSystem.
REQUIRE_FALSE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore")));
REQUIRE_FALSE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
// Complete construction (1 s).
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore")));
REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
REQUIRE(bs.allBuildings().size() == 1);
REQUIRE(bs.allBuildings()[0].type == BuildingType::Belt);
REQUIRE(bs.allBuildings()[0].anchor == QPoint(5, 5));
@@ -810,7 +810,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 5; ++i)
{
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"));
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
belts.tick();
bs.tickBeltPull();
}