Implement direct output-to-input port coupling
When an emerging output item reaches the port edge and there is no belt to hand off to, it now transfers straight into a directly adjacent building whose input edge meets the port (REQ-MAT-DIRECT-COUPLE): the item is placed onto that building's input belt at progress 0.0, reserving a buffer slot. On rejection (not an input, buffer full, entry busy, or an unbuilt site) it stays stuck at the producer's port as before. Factors the input acceptance (HQ building-block case, required-input check, reservation) and the input-belt deposit into shared helpers reused by both tickBeltPull and the coupling handoff. Transit between two touching buildings is occluded by both, so it is not drawn (accepted for now). Adds tests for a valid coupling delivering end to end and for a coupling to a non-consumer leaving the producer jammed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps
This commit is contained in:
@@ -825,89 +825,88 @@ void BuildingSystem::tickBeltPull()
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Auto-recipe buildings (Smelter, Reprocessing Plant) accept any item
|
||||
// that is an input to one of their recipes; their caps already span the
|
||||
// union of those inputs (initAutoBuffers), so no recipe lookup is needed.
|
||||
if (!isAutoRecipeBuildingType(building.type))
|
||||
{
|
||||
if (building.recipeId.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (building.type != BuildingType::Shipyard)
|
||||
{
|
||||
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
|
||||
if (!recipe || recipe->inputs.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Feed accepted items from adjacent belts onto the input belts at
|
||||
// progress 0.0. The acceptance rules — the HQ building-block case, the
|
||||
// required-input check, and the reservation — live in canAcceptInput so
|
||||
// direct coupling (REQ-MAT-DIRECT-COUPLE) shares them exactly.
|
||||
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)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const ItemType& type = *peeked;
|
||||
|
||||
// Accept only if this type is a required input and the buffer has space.
|
||||
const std::map<ItemType, int>::const_iterator capIt =
|
||||
building.inputBuffer.caps.find(type);
|
||||
if (capIt == building.inputBuffer.caps.end() || capIt->second == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reservation-aware space test: buffered + in-transit must stay under
|
||||
// the cap (REQ-MAT-INPUT-INTAKE).
|
||||
if (building.pendingInputCount(type) >= capIt->second)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inputLaneEntryFree(lane))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::optional<Item> taken = m_belts.tryTakeItem(port);
|
||||
const std::optional<ItemType> peeked = m_belts.peekItem(building.inputPorts[i]);
|
||||
if (!peeked) { continue; }
|
||||
if (!canAcceptInput(building, i, *peeked)) { continue; }
|
||||
const std::optional<Item> taken = m_belts.tryTakeItem(building.inputPorts[i]);
|
||||
if (taken)
|
||||
{
|
||||
lane.push_back(BeltItemSlot{*taken, 0.0});
|
||||
depositToInputBelt(building, i, *taken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool BuildingSystem::canAcceptInput(const Building& consumer,
|
||||
std::size_t inputPortIndex,
|
||||
const ItemType& type) const
|
||||
{
|
||||
if (inputPortIndex >= consumer.incomingItems.size()) { return false; }
|
||||
if (!inputLaneEntryFree(consumer.incomingItems[inputPortIndex])) { return false; }
|
||||
|
||||
// The HQ has no input buffer; it accepts building blocks into the global stock
|
||||
// (REQ-HQ-BELT-INPUT) with no reservation.
|
||||
if (consumer.type == BuildingType::Hq)
|
||||
{
|
||||
return type.id == "building_block";
|
||||
}
|
||||
|
||||
// Everyone else: the item must be a required input whose reservation-aware
|
||||
// buffer has room — buffered + in-transit below the cap (REQ-MAT-INPUT-INTAKE).
|
||||
const std::map<ItemType, int>::const_iterator capIt =
|
||||
consumer.inputBuffer.caps.find(type);
|
||||
if (capIt == consumer.inputBuffer.caps.end() || capIt->second == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return consumer.pendingInputCount(type) < capIt->second;
|
||||
}
|
||||
|
||||
void BuildingSystem::depositToInputBelt(Building& consumer,
|
||||
std::size_t inputPortIndex,
|
||||
const Item& item)
|
||||
{
|
||||
consumer.incomingItems[inputPortIndex].push_back(BeltItemSlot{item, 0.0});
|
||||
}
|
||||
|
||||
bool BuildingSystem::tryDirectCoupleDeposit(BuildingId producerId,
|
||||
const Port& outputPort,
|
||||
const Item& item)
|
||||
{
|
||||
const std::map<std::pair<int, int>, BuildingId>::const_iterator occIt =
|
||||
m_tileOccupancy.find({outputPort.tile.x(), outputPort.tile.y()});
|
||||
if (occIt == m_tileOccupancy.end() || occIt->second == producerId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Building* consumer = findBuildingMutable(occIt->second);
|
||||
if (!consumer)
|
||||
{
|
||||
return false; // an unbuilt construction site, or not an operational building
|
||||
}
|
||||
|
||||
// The coupling is the consumer input port meeting this output port: same flow
|
||||
// direction, feeding the producer's output-port tile (REQ-MAT-DIRECT-COUPLE).
|
||||
for (std::size_t j = 0; j < consumer->inputPorts.size(); ++j)
|
||||
{
|
||||
const Port& in = consumer->inputPorts[j];
|
||||
if (in.direction != outputPort.direction) { continue; }
|
||||
if (inputBodyTile(in.tile, in.direction) != outputPort.tile) { continue; }
|
||||
|
||||
if (!canAcceptInput(*consumer, j, item.type)) { return false; }
|
||||
depositToInputBelt(*consumer, j, item);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void BuildingSystem::tickProduction(Tick currentTick)
|
||||
{
|
||||
TRACE();
|
||||
@@ -1167,13 +1166,19 @@ void BuildingSystem::tickOutputBelts()
|
||||
// caps to 0.5 / 0.75 / 1.0 for up to three items).
|
||||
advanceBeltSlots(lane, progressPerTick);
|
||||
|
||||
// 2. Hand the front item off onto the adjacent real belt once it reaches
|
||||
// the output edge (progress 1.0). On refusal — no belt, output-edge
|
||||
// (REQ-MAT-ACCEPT-DIR), or a full belt — it stays stuck at 1.0.
|
||||
if (!lane.empty() && lane.front().progress >= 1.0
|
||||
&& m_belts.tryPutItem(port.tile, lane.front().item, port.direction))
|
||||
// 2. Hand the front item off once it reaches the output edge (progress
|
||||
// 1.0): onto the adjacent real belt, or — if a building's input edge
|
||||
// meets this port — straight into that building (REQ-MAT-DIRECT-COUPLE).
|
||||
// On refusal (no belt/coupling, output-edge per REQ-MAT-ACCEPT-DIR, or
|
||||
// a full target) it stays stuck at 1.0.
|
||||
if (!lane.empty() && lane.front().progress >= 1.0)
|
||||
{
|
||||
lane.erase(lane.begin());
|
||||
const Item item = lane.front().item;
|
||||
if (m_belts.tryPutItem(port.tile, item, port.direction)
|
||||
|| tryDirectCoupleDeposit(building.id, port, item))
|
||||
{
|
||||
lane.erase(lane.begin());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Feed the next buffered item onto the lane at progress 0.5 when the
|
||||
@@ -1250,6 +1255,18 @@ const Building* BuildingSystem::findBuilding(BuildingId id) const
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Building* BuildingSystem::findBuildingMutable(BuildingId id)
|
||||
{
|
||||
for (Building& building : m_buildings)
|
||||
{
|
||||
if (building.id == id)
|
||||
{
|
||||
return &building;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const ConstructionSite* BuildingSystem::findSite(BuildingId id) const
|
||||
{
|
||||
for (const ConstructionSite& site : m_constructionQueue)
|
||||
|
||||
@@ -181,6 +181,25 @@ public:
|
||||
void appendChecksum(Hasher& hasher) const;
|
||||
|
||||
private:
|
||||
Building* findBuildingMutable(BuildingId id);
|
||||
// True if the consumer would accept `type` at the given input port right now:
|
||||
// it is a required input (or a building block for the HQ), the reservation-aware
|
||||
// buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE).
|
||||
bool canAcceptInput(const Building& consumer,
|
||||
std::size_t inputPortIndex,
|
||||
const ItemType& type) const;
|
||||
// Places an accepted item onto the consumer's input belt at progress 0.0,
|
||||
// reserving a per-material buffer slot (REQ-MAT-INPUT-INTAKE).
|
||||
void depositToInputBelt(Building& consumer,
|
||||
std::size_t inputPortIndex,
|
||||
const Item& item);
|
||||
// Attempts to hand an emerging output item straight into a directly adjacent
|
||||
// building whose input edge meets the producer's output port (REQ-MAT-DIRECT-COUPLE).
|
||||
// Returns true if the item was accepted onto the consumer's input belt.
|
||||
bool tryDirectCoupleDeposit(BuildingId producerId,
|
||||
const Port& outputPort,
|
||||
const Item& item);
|
||||
|
||||
const BuildingDef* findBuildingDef(BuildingType type) const;
|
||||
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
|
||||
const ShipDef* findShipDef(const std::string& id) const;
|
||||
|
||||
@@ -807,6 +807,84 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
|
||||
REQUIRE(item->type.id == "iron_ore");
|
||||
}
|
||||
|
||||
// Two directly adjacent buildings whose ports meet transfer items with no belt in
|
||||
// between: a miner's iron_ore output feeds straight into a smelter, which smelts it
|
||||
// (REQ-MAT-DIRECT-COUPLE).
|
||||
TEST_CASE("BuildingSystem: output port couples directly into an adjacent input port",
|
||||
"[building]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||||
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);
|
||||
|
||||
// Miner at (0,0): body (0,0),(1,0),(0,1); output port tile (1,1) flowing East.
|
||||
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
bs.setRecipe(minerId, "mine_iron_ore");
|
||||
// Smelter anchored at (1,1): body (1,1),(2,1),(1,2),(2,2). Its body cell (1,1) is
|
||||
// the miner's output-port tile, and its west input edge there faces East, so the
|
||||
// two ports meet — no belt placed anywhere.
|
||||
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0);
|
||||
|
||||
Tick tick = 0;
|
||||
// Smelter build (15s) + margin for coupling and a smelt cycle.
|
||||
runTicks(bs, belts, static_cast<int>(secondsToTicks(30.0)), tick);
|
||||
|
||||
const Building* smelter = bs.findBuilding(smelterId);
|
||||
REQUIRE(smelter != nullptr);
|
||||
// iron_ore reached the smelter over the direct coupling and was smelted.
|
||||
bool hasIronIngot = false;
|
||||
for (const Item& produced : outputSideItems(*smelter))
|
||||
{
|
||||
if (produced.type.id == "iron_ingot") { hasIronIngot = true; }
|
||||
}
|
||||
REQUIRE(hasIronIngot);
|
||||
}
|
||||
|
||||
// A producer coupled to a building that cannot accept its item delivers nothing; the
|
||||
// item stays stuck at the producer's output port (REQ-MAT-DIRECT-COUPLE acceptance).
|
||||
TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stuck",
|
||||
"[building]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||||
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);
|
||||
|
||||
// Producing miner at (0,0), output port (1,1) East.
|
||||
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
bs.setRecipe(minerId, "mine_iron_ore");
|
||||
// A second, idle miner anchored at (1,1) occupies the output-port tile but takes
|
||||
// no inputs, so it cannot accept the iron_ore.
|
||||
const BuildingId sinkId = bs.place(BuildingType::Miner, QPoint(1, 1), Rotation::East, 0);
|
||||
|
||||
Tick tick = 0;
|
||||
// Both miners build sequentially (10s each), then the producer runs and jams.
|
||||
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)), tick);
|
||||
|
||||
const Building* miner = bs.findBuilding(minerId);
|
||||
const Building* sink = bs.findBuilding(sinkId);
|
||||
REQUIRE(miner != nullptr);
|
||||
REQUIRE(sink != nullptr);
|
||||
// Nothing was delivered, and the producer's output side has backed up to its cap.
|
||||
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
|
||||
REQUIRE(miner->outputItemCount() == miner->outputBuffer.capacity);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setRecipe clears buffers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user