From f2d912b4ebb50c00b929e0800a7914c3b23aa677 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Sun, 19 Apr 2026 16:18:39 +0200 Subject: [PATCH] implement belt system --- bin/config/world.toml | 1 + src/lib/config/ConfigLoader.cpp | 7 +- src/lib/config/WorldConfig.h | 7 +- src/lib/sim/BeltSystem.cpp | 373 ++++++++++++++++++++++++++++++++ src/lib/sim/BeltSystem.h | 117 ++++++++++ src/lib/sim/CMakeLists.txt | 2 + src/test/BeltSystemTest.cpp | 356 ++++++++++++++++++++++++++++++ src/test/CMakeLists.txt | 1 + src/test/ConfigLoaderTest.cpp | 6 +- src/test/FormulaTest.cpp | 2 +- 10 files changed, 864 insertions(+), 8 deletions(-) create mode 100644 src/lib/sim/BeltSystem.cpp create mode 100644 src/lib/sim/BeltSystem.h create mode 100644 src/test/BeltSystemTest.cpp diff --git a/bin/config/world.toml b/bin/config/world.toml index d7ba7ed..fdf6572 100644 --- a/bin/config/world.toml +++ b/bin/config/world.toml @@ -2,6 +2,7 @@ height_tiles = 60 refund_percentage = 75 scrap_despawn_seconds = 30 +belt_speed_tiles_per_second = 2 [regions] asteroid_width = 40 diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index e942160..39ddb03 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -219,9 +219,10 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path) WorldConfig cfg; - cfg.heightTiles = static_cast(requireInt(tbl["world"]["height_tiles"], file, "world.height_tiles")); - cfg.refundPercentage = static_cast(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage")); - cfg.scrapDespawnSeconds = requireDouble(tbl["world"]["scrap_despawn_seconds"], file, "world.scrap_despawn_seconds"); + cfg.heightTiles = static_cast(requireInt(tbl["world"]["height_tiles"], file, "world.height_tiles")); + cfg.refundPercentage = static_cast(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage")); + cfg.scrapDespawnSeconds = requireDouble(tbl["world"]["scrap_despawn_seconds"], file, "world.scrap_despawn_seconds"); + cfg.beltSpeedTilesPerSecond = requireDouble(tbl["world"]["belt_speed_tiles_per_second"], file, "world.belt_speed_tiles_per_second"); cfg.regions.asteroidWidth = static_cast(requireInt(tbl["regions"]["asteroid_width"], file, "regions.asteroid_width")); cfg.regions.playerBufferWidth = static_cast(requireInt(tbl["regions"]["player_buffer_width"], file, "regions.player_buffer_width")); diff --git a/src/lib/config/WorldConfig.h b/src/lib/config/WorldConfig.h index 0854378..49c75f1 100644 --- a/src/lib/config/WorldConfig.h +++ b/src/lib/config/WorldConfig.h @@ -37,9 +37,10 @@ struct WorldWaves struct WorldConfig { - int heightTiles; // REQ-GW-HEIGHT - int refundPercentage; // REQ-BLD-DEMOLISH - double scrapDespawnSeconds; // REQ-RES-SCRAP-DROP + int heightTiles; // REQ-GW-HEIGHT + int refundPercentage; // REQ-BLD-DEMOLISH + double scrapDespawnSeconds; // REQ-RES-SCRAP-DROP + double beltSpeedTilesPerSecond; // REQ-GW-BELT-SPEED WorldRegions regions; WorldExpansion expansion; diff --git a/src/lib/sim/BeltSystem.cpp b/src/lib/sim/BeltSystem.cpp new file mode 100644 index 0000000..c069c36 --- /dev/null +++ b/src/lib/sim/BeltSystem.cpp @@ -0,0 +1,373 @@ +#include "BeltSystem.h" + +#include + +#include "Tick.h" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +std::pair BeltSystem::key(QPoint tile) +{ + return {tile.x(), tile.y()}; +} + +QPoint BeltSystem::adjacentTile(QPoint tile, Rotation dir) +{ + switch (dir) + { + case Rotation::North: return {tile.x(), tile.y() - 1}; + case Rotation::East: return {tile.x() + 1, tile.y() }; + case Rotation::South: return {tile.x(), tile.y() + 1}; + case Rotation::West: return {tile.x() - 1, tile.y() }; + } + return tile; +} + +QPointF BeltSystem::slotWorldPos(QPoint tile, Rotation dir, double progress) +{ + // Map progress [0, 1] along the belt direction to a fractional tile-unit position. + // Progress 0 = entered from opposite side; 1 = at output edge. + double baseX = tile.x() + 0.5; + double baseY = tile.y() + 0.5; + + switch (dir) + { + case Rotation::North: return {baseX, baseY - (progress - 0.5)}; + case Rotation::East: return {baseX + (progress - 0.5), baseY}; + case Rotation::South: return {baseX, baseY + (progress - 0.5)}; + case Rotation::West: return {baseX - (progress - 0.5), baseY}; + } + return {baseX, baseY}; +} + +// --------------------------------------------------------------------------- +// Construction / placement +// --------------------------------------------------------------------------- + +BeltSystem::BeltSystem(double beltSpeedTilesPerSecond) + : m_progressPerTick(beltSpeedTilesPerSecond * kTickDurationSeconds) +{ +} + +void BeltSystem::placeBelt(QPoint tile, Rotation direction) +{ + m_splitters.erase(key(tile)); + BeltTile bt; + bt.direction = direction; + m_belts[key(tile)] = bt; +} + +void BeltSystem::placeSplitter(QPoint tile, Rotation outputA, Rotation outputB) +{ + m_belts.erase(key(tile)); + SplitterTile st; + st.outputA = outputA; + st.outputB = outputB; + st.nextOutputIsA = true; + m_splitters[key(tile)] = st; +} + +void BeltSystem::removeTile(QPoint tile) +{ + m_belts.erase(key(tile)); + m_splitters.erase(key(tile)); +} + +void BeltSystem::setSplitterFilters(QPoint tile, + const std::vector& filterA, + const std::vector& filterB) +{ + const std::map, SplitterTile>::iterator it = m_splitters.find(key(tile)); + if (it == m_splitters.end()) + { + return; + } + it->second.filterA = filterA; + it->second.filterB = filterB; +} + +// --------------------------------------------------------------------------- +// Port interface +// --------------------------------------------------------------------------- + +bool BeltSystem::tryPutItem(Port port, Item item) +{ + const std::map, BeltTile>::iterator it = m_belts.find(key(port.tile)); + if (it == m_belts.end()) + { + return false; + } + if (it->second.direction != port.direction) + { + return false; + } + return tryPlaceOnBelt(port.tile, item); +} + +std::optional BeltSystem::tryTakeItem(Port port) +{ + const std::map, BeltTile>::iterator it = m_belts.find(key(port.tile)); + if (it == m_belts.end()) + { + return std::nullopt; + } + if (it->second.direction != port.direction) + { + return std::nullopt; + } + + BeltTile& bt = it->second; + if (bt.front) + { + const Item taken = bt.front->item; + bt.front = bt.back; + bt.back = std::nullopt; + return taken; + } + if (bt.back) + { + const Item taken = bt.back->item; + bt.back = std::nullopt; + return taken; + } + return std::nullopt; +} + +// --------------------------------------------------------------------------- +// Maintenance +// --------------------------------------------------------------------------- + +void BeltSystem::clearTiles(const std::vector& tiles) +{ + for (const QPoint& tile : tiles) + { + const std::map, BeltTile>::iterator bIt = m_belts.find(key(tile)); + if (bIt != m_belts.end()) + { + bIt->second.front = std::nullopt; + bIt->second.back = std::nullopt; + } + + const std::map, SplitterTile>::iterator sIt = m_splitters.find(key(tile)); + if (sIt != m_splitters.end()) + { + sIt->second.heldItem = std::nullopt; + } + } +} + +// --------------------------------------------------------------------------- +// Tick +// --------------------------------------------------------------------------- + +void BeltSystem::tick() +{ + advanceProgress(); + moveItemsToNextTile(); + routeSplitterItems(); +} + +void BeltSystem::advanceProgress() +{ + for (std::map, BeltTile>::iterator it = m_belts.begin(); + it != m_belts.end(); ++it) + { + BeltTile& bt = it->second; + + if (bt.front) + { + bt.front->progress += m_progressPerTick; + if (bt.front->progress > 1.0) + { + bt.front->progress = 1.0; + } + } + + if (bt.back) + { + bt.back->progress += m_progressPerTick; + + // Back must not overtake front. + if (bt.front && bt.back->progress >= bt.front->progress) + { + bt.back->progress = bt.front->progress - m_progressPerTick; + if (bt.back->progress < 0.0) + { + bt.back->progress = 0.0; + } + } + + if (bt.back->progress > 1.0) + { + bt.back->progress = 1.0; + } + } + } +} + +void BeltSystem::moveItemsToNextTile() +{ + for (std::map, BeltTile>::iterator it = m_belts.begin(); + it != m_belts.end(); ++it) + { + BeltTile& bt = it->second; + if (!bt.front || bt.front->progress < 1.0) + { + continue; + } + + const QPoint here = QPoint(it->first.first, it->first.second); + const QPoint next = adjacentTile(here, bt.direction); + + const std::map, BeltTile>::iterator nextBelt = m_belts.find(key(next)); + const std::map, SplitterTile>::iterator nextSplitter = m_splitters.find(key(next)); + + if (nextBelt != m_belts.end()) + { + if (tryPlaceOnBelt(next, bt.front->item)) + { + bt.front = bt.back; + bt.back = std::nullopt; + } + // else: next belt is full — item stays blocked at progress 1.0. + } + else if (nextSplitter != m_splitters.end()) + { + if (!nextSplitter->second.heldItem) + { + nextSplitter->second.heldItem = bt.front->item; + bt.front = bt.back; + bt.back = std::nullopt; + } + // else: splitter busy — item stays blocked at progress 1.0. + } + // else: no tile registered (e.g. open space, or building input port). + // Items leaving into unregistered tiles are not consumed here — the + // building pull step uses tryTakeItem for that. + } +} + +void BeltSystem::routeSplitterItems() +{ + for (std::map, SplitterTile>::iterator it = m_splitters.begin(); + it != m_splitters.end(); ++it) + { + SplitterTile& st = it->second; + if (!st.heldItem) + { + continue; + } + + const Item& item = *st.heldItem; + + const bool matchesA = st.filterA.empty() || + std::find(st.filterA.begin(), st.filterA.end(), item.type) != st.filterA.end(); + const bool matchesB = st.filterB.empty() || + std::find(st.filterB.begin(), st.filterB.end(), item.type) != st.filterB.end(); + + if (matchesA && !matchesB) + { + const QPoint dest = adjacentTile(QPoint(it->first.first, it->first.second), st.outputA); + if (tryPlaceOnBelt(dest, item)) + { + st.heldItem = std::nullopt; + } + } + else if (matchesB && !matchesA) + { + const QPoint dest = adjacentTile(QPoint(it->first.first, it->first.second), st.outputB); + if (tryPlaceOnBelt(dest, item)) + { + st.heldItem = std::nullopt; + } + } + else if (matchesA && matchesB) + { + // Alternation: try preferred output first, fall back to other. + const Rotation preferred = st.nextOutputIsA ? st.outputA : st.outputB; + const Rotation fallback = st.nextOutputIsA ? st.outputB : st.outputA; + + const QPoint prefDest = adjacentTile(QPoint(it->first.first, it->first.second), preferred); + const QPoint fbDest = adjacentTile(QPoint(it->first.first, it->first.second), fallback); + + if (tryPlaceOnBelt(prefDest, item)) + { + st.heldItem = std::nullopt; + st.nextOutputIsA = !st.nextOutputIsA; + } + else if (tryPlaceOnBelt(fbDest, item)) + { + st.heldItem = std::nullopt; + // nextOutputIsA stays: preferred was blocked, so we still owe it next. + } + // else both blocked — item stays. + } + // else (!matchesA && !matchesB): stall — item stays in splitter. + } +} + +bool BeltSystem::tryPlaceOnBelt(QPoint tile, Item item) +{ + const std::map, BeltTile>::iterator it = m_belts.find(key(tile)); + if (it == m_belts.end()) + { + return false; + } + + BeltTile& bt = it->second; + + if (!bt.front) + { + bt.front = BeltItemSlot{item, 0.0}; + return true; + } + if (!bt.back) + { + bt.back = BeltItemSlot{item, 0.0}; + + // Ensure ordering invariant: front has higher progress. + if (bt.back->progress > bt.front->progress) + { + std::swap(bt.front, bt.back); + } + return true; + } + return false; // both slots occupied +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +void BeltSystem::forEachVisualItem(QRect viewportTiles, + std::function visit) const +{ + for (const std::pair, BeltTile>& entry : m_belts) + { + const QPoint tile(entry.first.first, entry.first.second); + if (!viewportTiles.contains(tile)) + { + continue; + } + + const BeltTile& bt = entry.second; + + if (bt.front) + { + VisualItem vi; + vi.type = bt.front->item.type; + vi.worldPos = slotWorldPos(tile, bt.direction, bt.front->progress); + visit(vi); + } + + if (bt.back) + { + VisualItem vi; + vi.type = bt.back->item.type; + vi.worldPos = slotWorldPos(tile, bt.direction, bt.back->progress); + visit(vi); + } + } +} diff --git a/src/lib/sim/BeltSystem.h b/src/lib/sim/BeltSystem.h new file mode 100644 index 0000000..6dbbbb4 --- /dev/null +++ b/src/lib/sim/BeltSystem.h @@ -0,0 +1,117 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "Item.h" +#include "ItemType.h" +#include "Port.h" +#include "Rotation.h" + +// Carries item type and fractional world position for the renderer. +// worldPos is in tile units (1 tile = 1.0 unit); origin matches tile coords. +struct VisualItem +{ + ItemType type; + QPointF worldPos; +}; + +// Isolated belt-and-splitter transport layer. See architecture.md §Belt Subsystem. +// +// Buildings interact only through tryPutItem / tryTakeItem. +// Rendering reads only through forEachVisualItem. +// No other system inspects tile contents. +class BeltSystem +{ +public: + explicit BeltSystem(double beltSpeedTilesPerSecond); + + // -- Placement ----------------------------------------------------------- + // Register a new belt tile. Any items already on this tile are cleared. + void placeBelt(QPoint tile, Rotation direction); + + // Register a new splitter tile. outputA and outputB are the two exit + // directions (e.g. West and East for a default-rotation splitter). + // Items entering from any adjacent belt whose direction points into this + // tile are held and routed to one of the two outputs. + void placeSplitter(QPoint tile, Rotation outputA, Rotation outputB); + + // Remove a belt or splitter tile (on demolish). Items are discarded. + void removeTile(QPoint tile); + + // -- Splitter filter configuration (REQ-BLD-SPLITTER) ------------------- + // filterA / filterB: empty means "accept all". + void setSplitterFilters(QPoint tile, + const std::vector& filterA, + const std::vector& filterB); + + // -- Port interface (buildings <-> belts) -------------------------------- + // port.tile = the belt tile adjacent to the building + // port.direction = direction items flow on that tile + // + // tryPutItem: place item onto port.tile entering from the opposite side. + // Returns false if the tile is not a belt, direction mismatches, or tile full. + bool tryPutItem(Port port, Item item); + + // tryTakeItem: remove and return the leading item from port.tile. + // Returns nullopt if tile is not a belt, direction mismatches, or tile empty. + std::optional tryTakeItem(Port port); + + // -- Maintenance --------------------------------------------------------- + void clearTiles(const std::vector& tiles); // REQ-UI-BELT-CLEAR + void tick(); + + // -- Rendering ----------------------------------------------------------- + void forEachVisualItem(QRect viewportTiles, + std::function visit) const; + +private: + void advanceProgress(); + void moveItemsToNextTile(); + void routeSplitterItems(); + + // Place item into back slot of an existing belt tile at progress 0. + // Returns false if tile is not a belt or is full. + bool tryPlaceOnBelt(QPoint tile, Item item); + + static std::pair key(QPoint tile); + static QPoint adjacentTile(QPoint tile, Rotation dir); + + // Returns the world-space centre of a slot given tile origin and progress. + static QPointF slotWorldPos(QPoint tile, Rotation dir, double progress); + + struct BeltItemSlot + { + Item item; + double progress; // [0.0, 1.0]: 0 = just entered, 1 = at output edge + }; + + struct BeltTile + { + Rotation direction; + std::optional front; // higher progress; closer to output + std::optional back; // lower progress; closer to input + }; + + struct SplitterTile + { + Rotation outputA; + Rotation outputB; + std::vector filterA; // empty = accept all + std::vector filterB; + bool nextOutputIsA; // alternation state + std::optional heldItem; // item buffered waiting to exit + }; + + double m_progressPerTick; // beltSpeedTilesPerSecond / kTickRateHz + + std::map, BeltTile> m_belts; + std::map, SplitterTile> m_splitters; +}; diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index e935faa..459d85b 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -2,6 +2,7 @@ SET(HDRS ${HDRS} ${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h + ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h PARENT_SCOPE ) @@ -9,6 +10,7 @@ SET(SRCS ${SRCS} ${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp PARENT_SCOPE ) diff --git a/src/test/BeltSystemTest.cpp b/src/test/BeltSystemTest.cpp new file mode 100644 index 0000000..a6befc9 --- /dev/null +++ b/src/test/BeltSystemTest.cpp @@ -0,0 +1,356 @@ +#include "catch.hpp" + +#include + +#include +#include + +#include "BeltSystem.h" +#include "Item.h" +#include "ItemType.h" +#include "Port.h" +#include "Rotation.h" +#include "Tick.h" + +// Belt speed of 30 t/s means progress/tick = 30/30 = 1.0. +// One tick() call advances any item exactly one full tile. +// This makes test assertions on item positions simple and deterministic. +static constexpr double kFastBeltSpeed = static_cast(kTickRateHz); + +static Item makeItem(const std::string& id) +{ + Item item; + item.type.id = id; + return item; +} + +static Port eastPort(QPoint tile) +{ + Port p; + p.tile = tile; + p.direction = Rotation::East; + return p; +} + +// --------------------------------------------------------------------------- +// Placement +// --------------------------------------------------------------------------- + +TEST_CASE("BeltSystem: tryPutItem succeeds on registered belt 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 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 taken1 = bs.tryTakeItem(eastPort(tile)); + REQUIRE(taken1.has_value()); + + // Second take returns the remaining item. + const std::optional 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 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 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()); +} diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 56deb94..8ae2bf7 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -6,4 +6,5 @@ add_files( FormulaTest.cpp ConfigLoaderTest.cpp SimulationTest.cpp + BeltSystemTest.cpp ) diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index 9c4599c..ad7fe74 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.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 diff --git a/src/test/FormulaTest.cpp b/src/test/FormulaTest.cpp index ed5344b..52dd12f 100644 --- a/src/test/FormulaTest.cpp +++ b/src/test/FormulaTest.cpp @@ -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); }