Compare commits

..

3 Commits

Author SHA1 Message Date
d92ccbfae2 fix tests 2026-04-22 21:21:53 +02:00
f29dc9862a dont require output belts to be aligned with output ports 2026-04-22 21:15:39 +02:00
36d6842f71 show production progress 2026-04-22 20:40:08 +02:00
7 changed files with 72 additions and 61 deletions

View File

@@ -82,7 +82,7 @@ Output port indicators are not building tiles themselves. A building may have mo
- REQ-MAT-BELT-ONLY: Materials are transported exclusively via belts and splitters. - REQ-MAT-BELT-ONLY: Materials are transported exclusively via belts and splitters.
- REQ-MAT-INPUT-PORTS: A building accepts items from any adjacent belt tile on any edge of its footprint (excluding cells occupied by output port(s)) whose direction points toward the building, provided the item is an input required by the currently selected recipe and the matching per-material input buffer has free space. - REQ-MAT-INPUT-PORTS: A building accepts items from any adjacent belt tile on any edge of its footprint (excluding cells occupied by output port(s)) whose direction points toward the building, provided the item is an input required by the currently selected recipe and the matching per-material input buffer has free space.
- REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items are placed onto the belt at the output port. - REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items are placed onto the belt at the output port tile regardless of that belt's direction.
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or blueprint, all items in all input buffers are cleared. - REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or blueprint, all items in all input buffers are cleared.
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or blueprint, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated). - REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or blueprint, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated).
- REQ-MAT-OUTPUT-BUFFER-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll. - REQ-MAT-OUTPUT-BUFFER-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll.

View File

@@ -92,18 +92,14 @@ void BeltSystem::setSplitterFilters(QPoint tile,
// Port interface // Port interface
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
bool BeltSystem::tryPutItem(Port port, Item item) bool BeltSystem::tryPutItem(QPoint tile, Item item)
{ {
const std::map<std::pair<int, int>, BeltTile>::iterator it = m_belts.find(key(port.tile)); const std::map<std::pair<int, int>, BeltTile>::iterator it = m_belts.find(key(tile));
if (it == m_belts.end()) if (it == m_belts.end())
{ {
return false; return false;
} }
if (it->second.direction != port.direction) return tryPlaceOnBelt(tile, item);
{
return false;
}
return tryPlaceOnBelt(port.tile, item);
} }
std::optional<Item> BeltSystem::tryTakeItem(Port port) std::optional<Item> BeltSystem::tryTakeItem(Port port)

View File

@@ -56,9 +56,9 @@ public:
// port.tile = the belt tile adjacent to the building // port.tile = the belt tile adjacent to the building
// port.direction = direction items flow on that tile // port.direction = direction items flow on that tile
// //
// tryPutItem: place item onto port.tile entering from the opposite side. // tryPutItem: place item onto tile.
// Returns false if the tile is not a belt, direction mismatches, or tile full. // Returns false if the tile is not a belt, or tile full.
bool tryPutItem(Port port, Item item); bool tryPutItem(QPoint tile, Item item);
// tryTakeItem: remove and return the leading item from port.tile. // tryTakeItem: remove and return the leading item from port.tile.
// Returns nullopt if tile is not a belt, direction mismatches, or tile empty. // Returns nullopt if tile is not a belt, direction mismatches, or tile empty.
@@ -119,3 +119,4 @@ private:
std::map<std::pair<int, int>, BeltTile> m_belts; std::map<std::pair<int, int>, BeltTile> m_belts;
std::map<std::pair<int, int>, SplitterTile> m_splitters; std::map<std::pair<int, int>, SplitterTile> m_splitters;
}; };

View File

@@ -726,7 +726,7 @@ void BuildingSystem::tickBeltPush()
break; break;
} }
const Item item = building.outputBuffer.items.front(); const Item item = building.outputBuffer.items.front();
if (m_belts.tryPutItem(outputPort, item)) if (m_belts.tryPutItem(outputPort.tile, item))
{ {
building.outputBuffer.items.erase(building.outputBuffer.items.begin()); building.outputBuffer.items.erase(building.outputBuffer.items.begin());
} }

View File

@@ -36,29 +36,20 @@ static Port eastPort(QPoint tile)
// Placement // Placement
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: tryPutItem succeeds on registered belt with matching direction", "[belt]") TEST_CASE("BeltSystem: tryPutItem succeeds on registered belt", "[belt]")
{ {
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0); const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
REQUIRE(bs.tryPutItem(eastPort(tile), makeItem("iron_ore"))); REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore")));
} }
TEST_CASE("BeltSystem: tryPutItem fails on unregistered tile", "[belt]") TEST_CASE("BeltSystem: tryPutItem fails on unregistered tile", "[belt]")
{ {
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
REQUIRE_FALSE(bs.tryPutItem(eastPort(QPoint(0, 0)), makeItem("iron_ore"))); REQUIRE_FALSE(bs.tryPutItem(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]") TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]")
@@ -68,7 +59,7 @@ TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]")
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
bs.removeTile(tile); bs.removeTile(tile);
REQUIRE_FALSE(bs.tryPutItem(eastPort(tile), makeItem("iron_ore"))); REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore")));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -81,8 +72,8 @@ TEST_CASE("BeltSystem: two items fit in one tile", "[belt]")
const QPoint tile(0, 0); const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
REQUIRE(bs.tryPutItem(eastPort(tile), makeItem("iron_ore"))); REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore")));
REQUIRE(bs.tryPutItem(eastPort(tile), makeItem("copper_ore"))); REQUIRE(bs.tryPutItem(tile, makeItem("copper_ore")));
} }
TEST_CASE("BeltSystem: third tryPutItem on full tile returns false", "[belt]") TEST_CASE("BeltSystem: third tryPutItem on full tile returns false", "[belt]")
@@ -91,10 +82,10 @@ TEST_CASE("BeltSystem: third tryPutItem on full tile returns false", "[belt]")
const QPoint tile(0, 0); const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(eastPort(tile), makeItem("a")); bs.tryPutItem(tile, makeItem("a"));
bs.tryPutItem(eastPort(tile), makeItem("b")); bs.tryPutItem(tile, makeItem("b"));
REQUIRE_FALSE(bs.tryPutItem(eastPort(tile), makeItem("c"))); REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("c")));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -106,7 +97,7 @@ TEST_CASE("BeltSystem: tryTakeItem returns placed item after reaching output edg
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0); const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(eastPort(tile), makeItem("iron_ore")); bs.tryPutItem(tile, makeItem("iron_ore"));
bs.tick(); // advance to output edge bs.tick(); // advance to output edge
const std::optional<Item> taken = bs.tryTakeItem(eastPort(tile)); const std::optional<Item> taken = bs.tryTakeItem(eastPort(tile));
@@ -120,7 +111,7 @@ TEST_CASE("BeltSystem: tryTakeItem requires item to reach output edge before yie
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0); const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(eastPort(tile), makeItem("iron_ore")); bs.tryPutItem(tile, makeItem("iron_ore"));
// Item placed but not yet at output edge — must not be available. // Item placed but not yet at output edge — must not be available.
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value()); REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
@@ -136,8 +127,8 @@ TEST_CASE("BeltSystem: tryTakeItem with two items returns both after each reache
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0); const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(eastPort(tile), makeItem("first")); bs.tryPutItem(tile, makeItem("first"));
bs.tryPutItem(eastPort(tile), makeItem("second")); bs.tryPutItem(tile, makeItem("second"));
// Front item reaches output edge after one tick. // Front item reaches output edge after one tick.
bs.tick(); bs.tick();
@@ -160,16 +151,6 @@ TEST_CASE("BeltSystem: tryTakeItem returns nullopt on empty tile", "[belt]")
REQUIRE_FALSE(bs.tryTakeItem(eastPort(QPoint(0, 0))).has_value()); 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 // tick() — item advancement
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -182,7 +163,7 @@ TEST_CASE("BeltSystem: item transfers from tile A to tile B and becomes availabl
bs.placeBelt(tileA, Rotation::East); bs.placeBelt(tileA, Rotation::East);
bs.placeBelt(tileB, Rotation::East); bs.placeBelt(tileB, Rotation::East);
bs.tryPutItem(eastPort(tileA), makeItem("iron_ore")); bs.tryPutItem(tileA, makeItem("iron_ore"));
bs.tick(); // item reaches output edge of A, moves to B at progress 0 bs.tick(); // item reaches output edge of A, moves to B at progress 0
bs.tick(); // item reaches output edge of B bs.tick(); // item reaches output edge of B
@@ -198,7 +179,7 @@ TEST_CASE("BeltSystem: item stays at progress 1.0 when next tile is absent", "[b
const QPoint tileA(0, 0); const QPoint tileA(0, 0);
bs.placeBelt(tileA, Rotation::East); bs.placeBelt(tileA, Rotation::East);
bs.tryPutItem(eastPort(tileA), makeItem("iron_ore")); bs.tryPutItem(tileA, makeItem("iron_ore"));
bs.tick(); bs.tick();
// Item should still be on tileA (no registered tile to the east). // Item should still be on tileA (no registered tile to the east).
@@ -215,7 +196,7 @@ TEST_CASE("BeltSystem: item traverses 3-tile chain in 3 ticks (one per tile)", "
bs.placeBelt(tileB, Rotation::East); bs.placeBelt(tileB, Rotation::East);
bs.placeBelt(tileC, Rotation::East); bs.placeBelt(tileC, Rotation::East);
bs.tryPutItem(eastPort(tileA), makeItem("iron_ore")); bs.tryPutItem(tileA, makeItem("iron_ore"));
bs.tick(); // A output edge → moves to B at progress 0 bs.tick(); // A output edge → moves to B at progress 0
bs.tick(); // B output edge → moves to C at progress 0 bs.tick(); // B output edge → moves to C at progress 0
bs.tick(); // C output edge → available for pickup bs.tick(); // C output edge → available for pickup
@@ -234,11 +215,11 @@ TEST_CASE("BeltSystem: item stays blocked when next tile is full", "[belt]")
bs.placeBelt(tileB, Rotation::East); bs.placeBelt(tileB, Rotation::East);
// Fill tileB to capacity. // Fill tileB to capacity.
bs.tryPutItem(eastPort(tileB), makeItem("b1")); bs.tryPutItem(tileB, makeItem("b1"));
bs.tryPutItem(eastPort(tileB), makeItem("b2")); bs.tryPutItem(tileB, makeItem("b2"));
// Place item in tileA — should be blocked. // Place item in tileA — should be blocked.
bs.tryPutItem(eastPort(tileA), makeItem("a1")); bs.tryPutItem(tileA, makeItem("a1"));
bs.tick(); bs.tick();
// Item in tileA must still be there. // Item in tileA must still be there.
@@ -254,8 +235,8 @@ TEST_CASE("BeltSystem: clearTiles removes all items from specified tiles", "[bel
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0); const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(eastPort(tile), makeItem("iron_ore")); bs.tryPutItem(tile, makeItem("iron_ore"));
bs.tryPutItem(eastPort(tile), makeItem("copper_ore")); bs.tryPutItem(tile, makeItem("copper_ore"));
bs.clearTiles({tile}); bs.clearTiles({tile});
@@ -271,7 +252,7 @@ TEST_CASE("BeltSystem: forEachVisualItem visits items inside viewport", "[belt]"
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tile(5, 5); const QPoint tile(5, 5);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(eastPort(tile), makeItem("iron_ore")); bs.tryPutItem(tile, makeItem("iron_ore"));
int count = 0; int count = 0;
bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; }); bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });
@@ -284,7 +265,7 @@ TEST_CASE("BeltSystem: forEachVisualItem skips items outside viewport", "[belt]"
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tile(50, 50); const QPoint tile(50, 50);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(eastPort(tile), makeItem("iron_ore")); bs.tryPutItem(tile, makeItem("iron_ore"));
int count = 0; int count = 0;
bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; }); bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });
@@ -297,7 +278,7 @@ TEST_CASE("BeltSystem: forEachVisualItem reports correct ItemType", "[belt]")
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0); const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(eastPort(tile), makeItem("copper_ingot")); bs.tryPutItem(tile, makeItem("copper_ingot"));
std::vector<ItemType> seen; std::vector<ItemType> seen;
bs.forEachVisualItem(QRect(-1, -1, 10, 10), [&seen](VisualItem vi) bs.forEachVisualItem(QRect(-1, -1, 10, 10), [&seen](VisualItem vi)
@@ -329,10 +310,10 @@ TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt]
bs.placeBelt(tileA, Rotation::North); bs.placeBelt(tileA, Rotation::North);
bs.placeBelt(tileB, Rotation::South); bs.placeBelt(tileB, Rotation::South);
bs.tryPutItem(eastPort(tileIn), makeItem("item1")); bs.tryPutItem(tileIn, makeItem("item1"));
bs.tick(); // item moves: tileIn -> splitter held bs.tick(); // item moves: tileIn -> splitter held
bs.tryPutItem(eastPort(tileIn), makeItem("item2")); bs.tryPutItem(tileIn, makeItem("item2"));
bs.tick(); // item1 routes to outputA (North=tileA); item2 moves to splitter bs.tick(); // item1 routes to outputA (North=tileA); item2 moves to splitter
bs.tick(); // item2 routes to outputB (South=tileB) bs.tick(); // item2 routes to outputB (South=tileB)
@@ -366,7 +347,7 @@ TEST_CASE("BeltSystem: splitter routes filtered item to matching output", "[belt
// Filter: outputA = iron_ore only; outputB = accept all. // Filter: outputA = iron_ore only; outputB = accept all.
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {}); bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {});
bs.tryPutItem(eastPort(tileIn), makeItem("iron_ore")); bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter held bs.tick(); // tileIn -> splitter held
bs.tick(); // routed to outputA (filter match) bs.tick(); // routed to outputA (filter match)

View File

@@ -107,7 +107,7 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem", "[build
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0); bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
REQUIRE(belts.tryPutItem(eastPort(QPoint(5, 5)), makeItem("iron_ore"))); REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore")));
REQUIRE(bs.allBuildings().empty()); // belts do not create Building instances REQUIRE(bs.allBuildings().empty()); // belts do not create Building instances
} }
@@ -145,6 +145,12 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]
rng); rng);
const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
// Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300.
// We need to process tick 300 itself, so run 301 ticks (ticks 0..300).
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
const int refund = bs.demolish(id); const int refund = bs.demolish(id);
// Miner cost = 15, refund = floor(15 * 75 / 100) = 11. // Miner cost = 15, refund = floor(15 * 75 / 100) = 11.
@@ -341,7 +347,8 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
// Place west-flowing belt at (2,0): belt flows West, delivers to smelter. // Place west-flowing belt at (2,0): belt flows West, delivers to smelter.
belts.placeBelt(QPoint(2, 0), Rotation::West); belts.placeBelt(QPoint(2, 0), Rotation::West);
belts.tryPutItem(westPort(QPoint(2, 0)), makeItem("iron_ore")); belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
belts.tick();
bs.tickBeltPull(); bs.tickBeltPull();
@@ -493,7 +500,8 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
belts.placeBelt(QPoint(-1, 0), Rotation::East); belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 5; ++i) for (int i = 0; i < 5; ++i)
{ {
belts.tryPutItem(eastPort(QPoint(-1, 0)), makeItem("scrap")); belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"));
belts.tick();
bs.tickBeltPull(); bs.tickBeltPull();
} }

View File

@@ -1,5 +1,6 @@
#include "SelectedBuildingPanel.h" #include "SelectedBuildingPanel.h"
#include <algorithm>
#include <cctype> #include <cctype>
#include <map> #include <map>
#include <string> #include <string>
@@ -264,6 +265,30 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
} }
} }
if (isProductionBuilding(b->type) && (recipe || shipDef))
{
const double durationSeconds = recipe
? recipe->durationSeconds
: shipDef->blueprint.productionTimeSeconds;
bufText += QString("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1);
if (b->production.has_value())
{
const Tick cycleTicks = secondsToTicks(durationSeconds);
const Tick completesAt = b->production->completesAt;
const Tick currentTick = m_sim->currentTick();
const Tick elapsed = currentTick - (completesAt - cycleTicks);
const int pct = static_cast<int>(
std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks);
bufText += QString("Progress: %1%\n").arg(pct);
}
else
{
bufText += "Progress: idle\n";
}
}
m_buffersLabel->setText(bufText); m_buffersLabel->setText(bufText);
} }