implement belt system
This commit is contained in:
356
src/test/BeltSystemTest.cpp
Normal file
356
src/test/BeltSystemTest.cpp
Normal file
@@ -0,0 +1,356 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#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 with matching direction", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
|
||||
REQUIRE(bs.tryPutItem(eastPort(tile), makeItem("iron_ore")));
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: tryPutItem fails on unregistered tile", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
|
||||
REQUIRE_FALSE(bs.tryPutItem(eastPort(QPoint(0, 0)), makeItem("iron_ore")));
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: tryPutItem fails on direction mismatch", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::North);
|
||||
|
||||
REQUIRE_FALSE(bs.tryPutItem(eastPort(tile), makeItem("iron_ore")));
|
||||
}
|
||||
|
||||
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(eastPort(tile), makeItem("iron_ore")));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capacity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BeltSystem: two items fit in one tile", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
|
||||
REQUIRE(bs.tryPutItem(eastPort(tile), makeItem("iron_ore")));
|
||||
REQUIRE(bs.tryPutItem(eastPort(tile), makeItem("copper_ore")));
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: third tryPutItem on full tile returns false", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
|
||||
bs.tryPutItem(eastPort(tile), makeItem("a"));
|
||||
bs.tryPutItem(eastPort(tile), makeItem("b"));
|
||||
|
||||
REQUIRE_FALSE(bs.tryPutItem(eastPort(tile), makeItem("c")));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tryTakeItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BeltSystem: tryTakeItem returns placed item", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
bs.tryPutItem(eastPort(tile), makeItem("iron_ore"));
|
||||
|
||||
const std::optional<Item> taken = bs.tryTakeItem(eastPort(tile));
|
||||
|
||||
REQUIRE(taken.has_value());
|
||||
REQUIRE(taken->type.id == "iron_ore");
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: tryTakeItem with two items returns both in sequence", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
bs.tryPutItem(eastPort(tile), makeItem("first"));
|
||||
bs.tryPutItem(eastPort(tile), makeItem("second"));
|
||||
|
||||
// First take returns front item (first placed, higher progress).
|
||||
const std::optional<Item> taken1 = bs.tryTakeItem(eastPort(tile));
|
||||
REQUIRE(taken1.has_value());
|
||||
|
||||
// Second take returns the remaining item.
|
||||
const std::optional<Item> taken2 = bs.tryTakeItem(eastPort(tile));
|
||||
REQUIRE(taken2.has_value());
|
||||
|
||||
// Tile is now empty.
|
||||
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());
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: tryTakeItem returns nullopt on direction mismatch", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::North);
|
||||
bs.tryPutItem(Port{tile, Rotation::North}, makeItem("x"));
|
||||
|
||||
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tick() — item advancement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BeltSystem: one tick moves item from tile A to tile B in a 2-tile chain", "[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(eastPort(tileA), makeItem("iron_ore"));
|
||||
bs.tick();
|
||||
|
||||
// Item should have moved to tileB.
|
||||
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(eastPort(tileA), makeItem("iron_ore"));
|
||||
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 2 ticks", "[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(eastPort(tileA), makeItem("iron_ore"));
|
||||
bs.tick(); // A -> B
|
||||
bs.tick(); // B -> C
|
||||
|
||||
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(eastPort(tileB), makeItem("b1"));
|
||||
bs.tryPutItem(eastPort(tileB), makeItem("b2"));
|
||||
|
||||
// Place item in tileA — should be blocked.
|
||||
bs.tryPutItem(eastPort(tileA), makeItem("a1"));
|
||||
bs.tick();
|
||||
|
||||
// Item in tileA must still be there.
|
||||
REQUIRE(bs.tryTakeItem(eastPort(tileA)).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(eastPort(tile), makeItem("iron_ore"));
|
||||
bs.tryPutItem(eastPort(tile), makeItem("copper_ore"));
|
||||
|
||||
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(eastPort(tile), makeItem("iron_ore"));
|
||||
|
||||
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(eastPort(tile), makeItem("iron_ore"));
|
||||
|
||||
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(eastPort(tile), makeItem("copper_ingot"));
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Splitter — basic alternation (no filters)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt]")
|
||||
{
|
||||
// Layout: tileIn -> splitter -> tileA (West output)
|
||||
// -> tileB (East output)
|
||||
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(eastPort(tileIn), makeItem("item1"));
|
||||
bs.tick(); // item moves: tileIn -> splitter held
|
||||
|
||||
bs.tryPutItem(eastPort(tileIn), makeItem("item2"));
|
||||
bs.tick(); // item1 routes to outputA (North=tileA); item2 moves to splitter
|
||||
|
||||
bs.tick(); // item2 routes to outputB (South=tileB)
|
||||
|
||||
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 filtered item to matching output", "[belt]")
|
||||
{
|
||||
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);
|
||||
|
||||
// Filter: outputA = iron_ore only; outputB = accept all.
|
||||
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {});
|
||||
|
||||
bs.tryPutItem(eastPort(tileIn), makeItem("iron_ore"));
|
||||
bs.tick(); // tileIn -> splitter held
|
||||
bs.tick(); // routed to outputA (filter match)
|
||||
|
||||
REQUIRE(bs.tryTakeItem(Port{tileA, Rotation::North}).has_value());
|
||||
REQUIRE_FALSE(bs.tryTakeItem(Port{tileB, Rotation::South}).has_value());
|
||||
}
|
||||
@@ -6,4 +6,5 @@ add_files(
|
||||
FormulaTest.cpp
|
||||
ConfigLoaderTest.cpp
|
||||
SimulationTest.cpp
|
||||
BeltSystemTest.cpp
|
||||
)
|
||||
|
||||
@@ -70,6 +70,7 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
|
||||
// world.toml
|
||||
REQUIRE(cfg.world.heightTiles == 60);
|
||||
REQUIRE(cfg.world.refundPercentage == 75);
|
||||
REQUIRE(cfg.world.beltSpeedTilesPerSecond == Approx(2.0));
|
||||
REQUIRE(cfg.world.regions.asteroidWidth == 40);
|
||||
REQUIRE(cfg.world.regions.playerBufferWidth == 10);
|
||||
REQUIRE(cfg.world.regions.enemyBufferWidth == 15);
|
||||
@@ -161,6 +162,7 @@ TEST_CASE("Missing field in world.toml is rejected with the field path", "[confi
|
||||
height_tiles = 60
|
||||
refund_percentage = 75
|
||||
scrap_despawn_seconds = 30
|
||||
belt_speed_tiles_per_second = 2
|
||||
|
||||
[regions]
|
||||
asteroid_width = 40
|
||||
@@ -204,6 +206,7 @@ TEST_CASE("Malformed formula in world.toml is rejected with field identification
|
||||
height_tiles = 60
|
||||
refund_percentage = 75
|
||||
scrap_despawn_seconds = 30
|
||||
belt_speed_tiles_per_second = 2
|
||||
|
||||
[regions]
|
||||
asteroid_width = 40
|
||||
@@ -220,7 +223,7 @@ push_expand_columns = 20
|
||||
scaling_factor = 1.2
|
||||
|
||||
[waves]
|
||||
threat_rate_formula = "1 * + x"
|
||||
threat_rate_formula = "1 +"
|
||||
ship_level_formula = "1 + x / 120"
|
||||
gap_min_seconds = 15
|
||||
gap_max_seconds = 45
|
||||
@@ -248,6 +251,7 @@ TEST_CASE("Inverted wave gap range is rejected", "[config]")
|
||||
height_tiles = 60
|
||||
refund_percentage = 75
|
||||
scrap_despawn_seconds = 30
|
||||
belt_speed_tiles_per_second = 2
|
||||
|
||||
[regions]
|
||||
asteroid_width = 40
|
||||
|
||||
@@ -42,7 +42,7 @@ TEST_CASE("Formula retains its source string", "[formula]")
|
||||
|
||||
TEST_CASE("Formula throws on malformed source", "[formula]")
|
||||
{
|
||||
REQUIRE_THROWS_AS(Formula::compile("1 * + x"), std::runtime_error);
|
||||
REQUIRE_THROWS_AS(Formula::compile("1 +"), std::runtime_error);
|
||||
REQUIRE_THROWS_AS(Formula::compile(""), std::runtime_error);
|
||||
REQUIRE_THROWS_AS(Formula::compile("unknown_var"), std::runtime_error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user