The belt card's only content was a button reading "Clear stuck items", which assumed a state the items need not be in. It now reads "Clear items", and above it the card lists what the selected tiles hold -- one item chip per type, the same chip the buffer sections and the HQ's block stock draw -- so a line's contents can be read before they are removed, and can be read at all: items on a moving belt are too small and too transient to count by eye, and items inside a tunnel are drawn nowhere. BeltSystem gains countItems(tiles), a query of the same kind as forEachVisualItem: a method rather than exposed tile containers, so the per-tile representation stays swappable. It walks the same five containers as clearTiles, in the same order, so the list and the button cannot drift apart. The tunnel's two ends are now told apart. Items in transit are counted on the exit they are travelling toward, and a clear removes exactly what the panel listed for the tile it acts on: the exit discards them, the entry leaves them travelling. BeltSystemTest's tunnel case splits in two accordingly. Splitters now aggregate with belts and tunnel ends. Their output filters are per-object configuration, which an aggregate simply does not show -- a splitter selected alone still gets them. Both cards share one BeltItemList widget, and both derive their tiles from collectBeltTiles, so nothing is stated twice. The mixed count summary loses the clear action it carried: a button acting on part of a selection is worse than no button, and the tiles can be selected by themselves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
1289 lines
47 KiB
C++
1289 lines
47 KiB
C++
#include "catch.hpp"
|
|
|
|
#include <map>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include <QPoint>
|
|
#include <QRect>
|
|
|
|
#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<double>(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<Item> 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<Item> 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<Item> 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<Item> 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<double>(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<ItemType> 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");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// countItems (REQ-UI-BELT-ITEMS)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("BeltSystem: countItems sums one item type over several tiles", "[belt]")
|
|
{
|
|
BeltSystem bs(kFastBeltSpeed);
|
|
const QPoint first(0, 0);
|
|
const QPoint second(1, 0);
|
|
bs.placeBelt(first, Rotation::East);
|
|
bs.placeBelt(second, Rotation::East);
|
|
bs.tryPutItem(first, makeItem("iron_ore"), Rotation::East);
|
|
bs.tryPutItem(second, makeItem("iron_ore"), Rotation::East);
|
|
bs.tryPutItem(second, makeItem("copper_ore"), Rotation::East);
|
|
|
|
const std::map<ItemType, int> counts = bs.countItems({first, second});
|
|
|
|
REQUIRE(counts.size() == 2);
|
|
REQUIRE(counts.at(ItemType{"iron_ore"}) == 2);
|
|
REQUIRE(counts.at(ItemType{"copper_ore"}) == 1);
|
|
}
|
|
|
|
TEST_CASE("BeltSystem: countItems ignores tiles outside the given selection", "[belt]")
|
|
{
|
|
BeltSystem bs(kFastBeltSpeed);
|
|
const QPoint selected(0, 0);
|
|
const QPoint other(1, 0);
|
|
bs.placeBelt(selected, Rotation::East);
|
|
bs.placeBelt(other, Rotation::East);
|
|
bs.tryPutItem(other, makeItem("iron_ore"), Rotation::East);
|
|
|
|
REQUIRE(bs.countItems({selected}).empty());
|
|
}
|
|
|
|
TEST_CASE("BeltSystem: countItems counts a splitter's held items", "[belt]")
|
|
{
|
|
BeltSystem bs(kFastBeltSpeed);
|
|
const QPoint feed(0, 0);
|
|
const QPoint splitter(1, 0);
|
|
bs.placeBelt(feed, Rotation::East);
|
|
bs.placeSplitter(splitter, Rotation::North, Rotation::South);
|
|
|
|
// One item routed onto an output slot, one still unassigned in the back.
|
|
bs.tryPutItem(splitter, makeItem("iron_ore"), Rotation::East);
|
|
bs.tick();
|
|
bs.tryPutItem(splitter, makeItem("iron_ore"), Rotation::East);
|
|
|
|
REQUIRE(bs.countItems({splitter}).at(ItemType{"iron_ore"}) == 2);
|
|
}
|
|
|
|
TEST_CASE("BeltSystem: countItems counts transit items on the exit, not the entry",
|
|
"[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(); // item enters the entry's front slot
|
|
bs.tick(); // entry front -> transit
|
|
|
|
// Invisible in the world and drawn nowhere, so the exit's count is the only place
|
|
// the player can see it (REQ-BLD-TUNNEL-TRANSIT).
|
|
REQUIRE(bs.countItems({entry}).empty());
|
|
REQUIRE(bs.countItems({exit}).at(ItemType{"iron_ore"}) == 1);
|
|
}
|
|
|
|
TEST_CASE("BeltSystem: countItems on empty tiles reports nothing", "[belt]")
|
|
{
|
|
BeltSystem bs(kFastBeltSpeed);
|
|
const QPoint tile(0, 0);
|
|
bs.placeBelt(tile, Rotation::East);
|
|
|
|
REQUIRE(bs.countItems({tile}).empty());
|
|
REQUIRE(bs.countItems({}).empty());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<double>(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<double>
|
|
{
|
|
std::optional<double> 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<double>(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<double>
|
|
{
|
|
std::optional<double> 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<double>(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<double>
|
|
{
|
|
std::optional<double> 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<Item> 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<Item> 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: deconstruct 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());
|
|
}
|
|
|
|
// A clear removes exactly what countItems reports for the tile it acts on
|
|
// (REQ-UI-BELT-CLEAR, REQ-UI-BELT-ITEMS): the transit items belong to the exit, so only
|
|
// the exit's clear discards them.
|
|
TEST_CASE("BeltSystem: clearTiles on a tunnel exit 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);
|
|
bs.tick();
|
|
bs.tick();
|
|
|
|
bs.clearTiles({exit});
|
|
|
|
for (int i = 0; i < 30; ++i)
|
|
{
|
|
bs.tick();
|
|
}
|
|
REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value());
|
|
}
|
|
|
|
TEST_CASE("BeltSystem: clearTiles on a tunnel entry leaves transit items travelling",
|
|
"[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(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<Item> 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<Item> 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<Item> 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());
|
|
}
|