Animate items entering building input ports

Accepted input items now travel inward across the input port's footprint
cell on a virtual input belt (progress 0.0 -> 0.5) before entering the
buffer, mirroring the output emergence (REQ-MAT-INPUT-INTAKE). Uses
reserve-on-entry accounting: pendingInputCount (buffered + in-transit)
gates acceptance so a material never exceeds its 2x cap, and the item
becomes consumable only on arrival at the tile centre. Covers the HQ
global-stock intake, clears in-transit items on recipe/schematic change,
folds the lanes into the checksum, and renders them occluded by the
building (shared draw pass with the output emergence).

Reuses the shared BeltSlot helpers and the belt-speed getter; no new belt
logic. Adds intake travel/arrival and reservation-cap tests, and updates
input-buffer assertions to count buffered + in-transit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps
This commit is contained in:
2026-07-14 18:10:49 +02:00
parent dd9953d149
commit 768298dc7c
6 changed files with 255 additions and 36 deletions

View File

@@ -96,6 +96,31 @@ struct Building
return count; return count;
} }
// Items currently travelling inward on each input port's virtual input belt
// (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each
// lane holds slots at progress [0.0, 0.5], front (highest progress) first. An
// in-transit item has reserved a slot in its per-material input buffer but is
// not yet consumable — it enters the buffer only on reaching progress 0.5.
std::vector<std::vector<BeltItemSlot>> incomingItems;
// Buffered plus in-transit count of one input material. The acceptance/space
// test (REQ-MAT-INPUT-PORTS, REQ-MAT-INPUT-INTAKE) counts in-transit items, so
// buffered + reserved never exceeds the material's cap (REQ-MAT-INPUT-BUFFER).
int pendingInputCount(const ItemType& type) const
{
int count = 0;
const std::map<ItemType, int>::const_iterator it = inputBuffer.counts.find(type);
if (it != inputBuffer.counts.end()) { count = it->second; }
for (const std::vector<BeltItemSlot>& lane : incomingItems)
{
for (const BeltItemSlot& slot : lane)
{
if (slot.item.type == type) { ++count; }
}
}
return count;
}
// Pre-computed from surface mask at placement; in absolute world coordinates. // Pre-computed from surface mask at placement; in absolute world coordinates.
std::vector<QPoint> bodyCells; std::vector<QPoint> bodyCells;
std::vector<Port> outputPorts; std::vector<Port> outputPorts;

View File

@@ -35,6 +35,30 @@ QPoint outputBodyTile(QPoint portTile, Rotation direction)
} }
return portTile; return portTile;
} }
// The building body tile an input port feeds into, given the port's outside belt
// tile (port.tile) and its inward flow direction. The virtual input belt occupies
// this tile and flows from the outer edge (progress 0.0) to the centre (0.5)
// (REQ-MAT-INPUT-INTAKE).
QPoint inputBodyTile(QPoint portTile, Rotation inwardDirection)
{
switch (inwardDirection)
{
case Rotation::East: return portTile + QPoint( 1, 0);
case Rotation::West: return portTile + QPoint(-1, 0);
case Rotation::North: return portTile + QPoint( 0, -1);
case Rotation::South: return portTile + QPoint( 0, 1);
}
return portTile;
}
// An input belt accepts a new item at progress 0.0 only when it holds fewer than
// three items and the entry slot is clear (nothing within a quarter tile of 0.0),
// matching the belt packing used elsewhere (REQ-GW-BELT-CAPACITY).
bool inputLaneEntryFree(const std::vector<BeltItemSlot>& lane)
{
return lane.size() < 3 && (lane.empty() || lane.back().progress >= 0.25);
}
} // namespace } // namespace
BuildingSystem::BuildingSystem(const GameConfig& config, BuildingSystem::BuildingSystem(const GameConfig& config,
@@ -539,8 +563,11 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
building.outputBuffer.items.clear(); building.outputBuffer.items.clear();
building.outputBuffer.capacity = 0; building.outputBuffer.capacity = 0;
// Emerging items are part of the output buffer, so clearing it on a // Emerging items are part of the output buffer, so clearing it on a
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE). // recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit
// input items are discarded and their reservations released
// (REQ-MAT-INPUT-INTAKE).
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); } for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
building.production = std::nullopt; building.production = std::nullopt;
if (!recipeId.empty()) if (!recipeId.empty())
@@ -588,6 +615,7 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout
building.outputBuffer.items.clear(); building.outputBuffer.items.clear();
building.outputBuffer.capacity = 0; building.outputBuffer.capacity = 0;
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); } for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard) if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
{ {
initShipyardBuffers(building); initShipyardBuffers(building);
@@ -693,6 +721,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
} }
building.emergingItems.resize(building.outputPorts.size()); building.emergingItems.resize(building.outputPorts.size());
building.inputPorts = computeInputPorts(building); building.inputPorts = computeInputPorts(building);
building.incomingItems.assign(building.inputPorts.size(), {});
if (building.type == BuildingType::SalvageBay) if (building.type == BuildingType::SalvageBay)
{ {
@@ -765,21 +794,53 @@ void BuildingSystem::tickConstruction(Tick currentTick)
void BuildingSystem::tickBeltPull() void BuildingSystem::tickBeltPull()
{ {
TRACE(); TRACE();
// Same per-tick step as the belts, so items travel inward at belt speed
// (REQ-GW-BELT-SPEED, REQ-MAT-INPUT-INTAKE).
const double progressPerTick = m_belts.getProgressPerTick_tpt();
for (Building& building : m_buildings) for (Building& building : m_buildings)
{ {
// HQ: pull building_block items and add to global stock. const bool isHq = (building.type == BuildingType::Hq);
if (building.type == BuildingType::Hq)
// 1. Advance every input belt and deliver arrivals (progress >= 0.5) into
// the input buffer — or the global stock for the HQ. Runs for all
// buildings so in-transit items keep moving even when feeding is gated
// off, and arrivals become consumable before tickProduction (step 4).
for (std::size_t i = 0; i < building.incomingItems.size(); ++i)
{ {
for (const Port& port : building.inputPorts) std::vector<BeltItemSlot>& lane = building.incomingItems[i];
advanceBeltSlots(lane, progressPerTick);
while (!lane.empty() && lane.front().progress >= 0.5)
{ {
const std::optional<ItemType> peeked = m_belts.peekItem(port); const Item arrived = lane.front().item;
if (peeked && peeked->id == "building_block") lane.erase(lane.begin());
if (isHq)
{ {
const std::optional<Item> taken = m_belts.tryTakeItem(port); m_addBuildingBlocks(1);
if (taken) }
{ else
m_addBuildingBlocks(1); {
} building.inputBuffer.counts[arrived.type]++;
}
}
}
// 2. Feed newly accepted items from adjacent belts onto the input belts at
// progress 0.0. HQ accepts building blocks into the global stock with no
// reservation; other buildings reserve a per-material buffer slot.
if (isHq)
{
for (std::size_t i = 0; i < building.inputPorts.size(); ++i)
{
const Port& port = building.inputPorts[i];
std::vector<BeltItemSlot>& lane = building.incomingItems[i];
const std::optional<ItemType> peeked = m_belts.peekItem(port);
if (!peeked || peeked->id != "building_block") { continue; }
if (!inputLaneEntryFree(lane)) { continue; }
const std::optional<Item> taken = m_belts.tryTakeItem(port);
if (taken)
{
lane.push_back(BeltItemSlot{*taken, 0.0});
} }
} }
continue; continue;
@@ -805,8 +866,11 @@ void BuildingSystem::tickBeltPull()
} }
} }
for (const Port& port : building.inputPorts) for (std::size_t i = 0; i < building.inputPorts.size(); ++i)
{ {
const Port& port = building.inputPorts[i];
std::vector<BeltItemSlot>& lane = building.incomingItems[i];
const std::optional<ItemType> peeked = m_belts.peekItem(port); const std::optional<ItemType> peeked = m_belts.peekItem(port);
if (!peeked) if (!peeked)
{ {
@@ -815,7 +879,7 @@ void BuildingSystem::tickBeltPull()
const ItemType& type = *peeked; const ItemType& type = *peeked;
// Accept only if this type is a required input and buffer has space. // Accept only if this type is a required input and the buffer has space.
const std::map<ItemType, int>::const_iterator capIt = const std::map<ItemType, int>::const_iterator capIt =
building.inputBuffer.caps.find(type); building.inputBuffer.caps.find(type);
if (capIt == building.inputBuffer.caps.end() || capIt->second == 0) if (capIt == building.inputBuffer.caps.end() || capIt->second == 0)
@@ -823,14 +887,14 @@ void BuildingSystem::tickBeltPull()
continue; continue;
} }
const int current = [&]() -> int // Reservation-aware space test: buffered + in-transit must stay under
// the cap (REQ-MAT-INPUT-INTAKE).
if (building.pendingInputCount(type) >= capIt->second)
{ {
const std::map<ItemType, int>::const_iterator it = continue;
building.inputBuffer.counts.find(type); }
return (it != building.inputBuffer.counts.end()) ? it->second : 0;
}();
if (current >= capIt->second) if (!inputLaneEntryFree(lane))
{ {
continue; continue;
} }
@@ -838,7 +902,7 @@ void BuildingSystem::tickBeltPull()
const std::optional<Item> taken = m_belts.tryTakeItem(port); const std::optional<Item> taken = m_belts.tryTakeItem(port);
if (taken) if (taken)
{ {
building.inputBuffer.counts[taken->type]++; lane.push_back(BeltItemSlot{*taken, 0.0});
} }
} }
} }
@@ -1148,6 +1212,28 @@ void BuildingSystem::forEachEmergingItem(
} }
} }
void BuildingSystem::forEachIncomingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const
{
for (const Building& building : m_buildings)
{
for (std::size_t p = 0; p < building.inputPorts.size(); ++p)
{
const Port& port = building.inputPorts[p];
const QPoint bodyTile = inputBodyTile(port.tile, port.direction);
const std::vector<BeltItemSlot>& lane = building.incomingItems[p];
// Render least-progressed first (bottom) → most-progressed last (top),
// matching belt item ordering (REQ-GW-TILE-SIZE).
for (int i = static_cast<int>(lane.size()) - 1; i >= 0; --i)
{
visit(lane[i].item.type,
beltSlotWorldPos(bodyTile, port.direction, lane[i].progress));
}
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Queries // Queries
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1342,6 +1428,9 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
b.emergingItems.clear(); b.emergingItems.clear();
b.emergingItems.resize(b.outputPorts.size()); b.emergingItems.resize(b.outputPorts.size());
b.inputPorts = computeInputPorts(b); b.inputPorts = computeInputPorts(b);
// Likewise discard in-transit input items and re-size the input belts to
// the new port set (REQ-MAT-INPUT-INTAKE).
b.incomingItems.assign(b.inputPorts.size(), {});
// Re-register with BeltSystem (items on tile are discarded). // Re-register with BeltSystem (items on tile are discarded).
if (b.type == BuildingType::Belt) if (b.type == BuildingType::Belt)
@@ -1449,6 +1538,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
} }
building.emergingItems.resize(building.outputPorts.size()); building.emergingItems.resize(building.outputPorts.size());
building.inputPorts = computeInputPorts(building); building.inputPorts = computeInputPorts(building);
building.incomingItems.assign(building.inputPorts.size(), {});
if (type == BuildingType::SalvageBay) if (type == BuildingType::SalvageBay)
{ {
@@ -1564,6 +1654,16 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const
hasher.append(slot.progress); hasher.append(slot.progress);
} }
} }
hasher.append(b.incomingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.incomingItems)
{
hasher.append(lane.size());
for (const BeltItemSlot& slot : lane)
{
hasher.append(slot.item.type.id);
hasher.append(slot.progress);
}
}
hasher.append(b.production.has_value()); hasher.append(b.production.has_value());
if (b.production.has_value()) if (b.production.has_value())
{ {

View File

@@ -132,6 +132,12 @@ public:
void forEachEmergingItem( void forEachEmergingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const; const std::function<void(const ItemType&, QPointF)>& visit) const;
// Visits every item currently travelling inward on a building input port's
// virtual input belt (REQ-MAT-INPUT-INTAKE), passing the item type and its
// world-space centre (in tile units). Least-progressed first (drawn bottom).
void forEachIncomingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const;
// Returns the entity id of the building or construction site whose footprint // Returns the entity id of the building or construction site whose footprint
// exactly coincides with the ghost (type, anchor, rot) and is of the same // exactly coincides with the ghost (type, anchor, rot) and is of the same
// building type. Returns nullopt otherwise. // building type. Returns nullopt otherwise.

View File

@@ -580,10 +580,93 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
const Building* b = bs.findBuilding(sid); const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
const std::map<ItemType, int>::const_iterator it = // The item was accepted; it may still be travelling inward on the input belt,
// so count buffered + in-transit (REQ-MAT-INPUT-INTAKE).
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1);
}
// An accepted input item travels inward on its input belt before it becomes usable
// stock: it is reserved (counts against the cap) on entry and only enters the
// buffer on reaching the tile centre (REQ-MAT-INPUT-INTAKE).
TEST_CASE("BuildingSystem: accepted input travels inward before entering the buffer",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt: 1 tile/tick
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
belts.placeBelt(QPoint(2, 0), Rotation::West);
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
belts.tick();
bs.tickBeltPull(); // accepts the item onto the input belt at progress 0.0
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
// Reserved but not yet consumable: nothing in the buffer, but it counts against
// the cap via pendingInputCount.
const std::map<ItemType, int>::const_iterator it0 =
b->inputBuffer.counts.find(ItemType{"iron_ore"}); b->inputBuffer.counts.find(ItemType{"iron_ore"});
REQUIRE(it != b->inputBuffer.counts.end()); REQUIRE((it0 == b->inputBuffer.counts.end() || it0->second == 0));
REQUIRE(it->second >= 1); REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
// One more pull tick advances the input belt to the centre; the item arrives.
bs.tickBeltPull();
REQUIRE(b->inputBuffer.counts.at(ItemType{"iron_ore"}) == 1);
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
}
// The acceptance test counts in-transit items, so buffered + reserved never exceeds
// the per-material cap; excess items stay on the belt (REQ-MAT-INPUT-INTAKE).
TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at the cap",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Feed scrap via an input belt without ever running production (only pull), so
// the buffer fills and stays full. Try to over-fill it well past the cap.
belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 20; ++i)
{
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
belts.tick();
bs.tickBeltPull();
}
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
const int cap = b->inputBuffer.caps.at(ItemType{"scrap"});
REQUIRE(cap > 0);
// buffered + in-transit is capped; the plant never over-pulls.
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == cap);
// Excess scrap is left stuck on the feeding belt rather than silently dropped.
REQUIRE(belts.peekItem(eastPort(QPoint(-1, 0))).has_value());
} }
// A smelter auto-selects the matching recipe for whatever it is fed, with no // A smelter auto-selects the matching recipe for whatever it is fed, with no
@@ -838,14 +921,12 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
bs.tickBeltPull(); bs.tickBeltPull();
} }
// Verify scrap is in input buffer. // Verify all five scrap were accepted; some may still be travelling inward on
// the input belt (REQ-MAT-INPUT-INTAKE), so count buffered + in-transit.
{ {
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
const std::map<ItemType, int>::const_iterator it = REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
b->inputBuffer.counts.find(ItemType{"scrap"});
REQUIRE(it != b->inputBuffer.counts.end());
REQUIRE(it->second == 5);
} }
// Run production cycle (3s = 90 ticks + 1 for the completion tick). // Run production cycle (3s = 90 ticks + 1 for the completion tick).

View File

@@ -402,10 +402,11 @@ void GameWorldView::paintGL()
painter.setRenderHint(QPainter::Antialiasing, false); painter.setRenderHint(QPainter::Antialiasing, false);
drawTiles(painter); drawTiles(painter);
// Emerging items are drawn before the buildings so the building body occludes // Port items are drawn before the buildings so the building body occludes the
// the portion still inside the footprint, making items appear to slide out of // portion still inside the footprint: items appear to slide out of the output
// the output port rather than pop into existence (REQ-MAT-OUTPUT-EMERGE). // port (REQ-MAT-OUTPUT-EMERGE) and into the input port (REQ-MAT-INPUT-INTAKE)
drawEmergingItems(painter); // rather than popping in or out of existence.
drawPortItems(painter);
drawBuildings(painter); drawBuildings(painter);
drawCopyConfigFeedback(painter); drawCopyConfigFeedback(painter);
drawStations(painter); drawStations(painter);
@@ -1157,11 +1158,14 @@ void GameWorldView::drawCopyConfigFeedback(QPainter& painter)
} }
} }
void GameWorldView::drawEmergingItems(QPainter& painter) void GameWorldView::drawPortItems(QPainter& painter)
{ {
const float halfPx = tilePx() * 0.5f * 0.5f; const float halfPx = tilePx() * 0.5f * 0.5f;
m_sim->buildings().forEachEmergingItem( // Shared with belt items (REQ-GW-TILE-SIZE): a half-tile filled square with an
// outline, occluded by the building drawn afterwards so the item slides out of
// (REQ-MAT-OUTPUT-EMERGE) or into (REQ-MAT-INPUT-INTAKE) the port.
const std::function<void(const ItemType&, QPointF)> drawItem =
[&](const ItemType& type, QPointF worldPos) [&](const ItemType& type, QPointF worldPos)
{ {
const std::map<std::string, ItemVisuals>::const_iterator it = const std::map<std::string, ItemVisuals>::const_iterator it =
@@ -1177,7 +1181,10 @@ void GameWorldView::drawEmergingItems(QPainter& painter)
painter.setPen(QPen(it->second.outline, 1)); painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
painter.drawRect(rect); painter.drawRect(rect);
}); };
m_sim->buildings().forEachEmergingItem(drawItem);
m_sim->buildings().forEachIncomingItem(drawItem);
} }
void GameWorldView::drawBeltItems(QPainter& painter) void GameWorldView::drawBeltItems(QPainter& painter)

View File

@@ -115,7 +115,7 @@ private:
bool canAfford(BuildingType type) const; bool canAfford(BuildingType type) const;
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawEmergingItems(QPainter& painter); void drawPortItems(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
void drawSelectionHighlights(QPainter& painter); void drawSelectionHighlights(QPainter& painter);
void drawCopyConfigFeedback(QPainter& painter); void drawCopyConfigFeedback(QPainter& painter);