implement belt system

This commit is contained in:
2026-04-19 16:18:39 +02:00
parent ffe69f08b5
commit f2d912b4eb
10 changed files with 864 additions and 8 deletions

356
src/test/BeltSystemTest.cpp Normal file
View 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());
}

View File

@@ -6,4 +6,5 @@ add_files(
FormulaTest.cpp
ConfigLoaderTest.cpp
SimulationTest.cpp
BeltSystemTest.cpp
)

View File

@@ -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

View File

@@ -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);
}