diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 3116a99..2f36f08 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -513,17 +513,22 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick) // recipe is selected or auto-chosen. if (building.production) { - if (currentTick >= building.production->completesAt) + if (currentTick < building.production->completesAt) { - for (const Item& item : building.production->chosenOutputs) - { - building.outputBuffer.items.push_back(item); - } - building.production = std::nullopt; + continue; } - // Whether we just completed or are still running, do not start - // another cycle in the same tick. - continue; + for (const Item& item : building.production->chosenOutputs) + { + building.outputBuffer.items.push_back(item); + } + building.production = std::nullopt; + // Fall through to the start attempt below rather than idling for a tick, + // so a cycle takes exactly its recipe duration and a building fed to + // capacity produces at the configured rate (REQ-MAT-CYCLE). The start + // code runs once per building per tick, so at most one cycle begins here + // even when a duration rounds to zero ticks. The outputs just deposited + // count against the space check, so a cycle whose output no longer fits + // waits, exactly as it would have on the following tick. } // Idle: gather the candidate recipes to try. Auto-recipe buildings @@ -612,26 +617,29 @@ void BuildingSystem::tickShipyardProduction(FactoryState& state, Tick currentTic // If a cycle is in progress, check for completion. if (building.production) { - if (currentTick >= building.production->completesAt) + if (currentTick < building.production->completesAt) { - if (!building.outputPorts.empty()) - { - const Port& p = building.outputPorts[0]; - const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f); - // A shipyard builds exactly what the player configured and - // paid for. When no layout is set it produces a bare hull, so - // pass an explicit empty layout rather than nullopt: the latter - // would make ShipSystem fall back to the schematic's - // defaultModules (a wave-only loadout) and yield free weapons. - const std::optional layout = - building.shipLayout.has_value() - ? building.shipLayout - : std::make_optional(); - m_spawnShip(building.recipeId, spawnPos, layout); - } - building.production = std::nullopt; + continue; } - continue; + if (!building.outputPorts.empty()) + { + const Port& p = building.outputPorts[0]; + const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f); + // A shipyard builds exactly what the player configured and + // paid for. When no layout is set it produces a bare hull, so + // pass an explicit empty layout rather than nullopt: the latter + // would make ShipSystem fall back to the schematic's + // defaultModules (a wave-only loadout) and yield free weapons. + const std::optional layout = + building.shipLayout.has_value() + ? building.shipLayout + : std::make_optional(); + m_spawnShip(building.recipeId, spawnPos, layout); + } + building.production = std::nullopt; + // Fall through and start the next cycle in this same tick, so a ship takes + // exactly its computed production time (REQ-BLD-SHIPYARD), as for the + // recipe buildings in tickProduction. } // Build combined materials list (base + modules). diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index fd9cb4f..18b0b00 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -495,13 +495,13 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]") Tick tick = 0; // Construction (10s) then cycle 1 starts at tick 300 (completesAt=330). - // Cycle 1 completes at tick 330: deposit item, continue (no same-tick restart). - // Cycle 2 starts at tick 331 (completesAt=361). - // Cycle 2 completes at tick 361: deposit item → buffer=2, cycle 3 stalls. - // Need to process through tick 361: 362 ticks total. + // Cycle 1 completes at tick 330 and cycle 2 starts in that same tick + // (completesAt=360). Cycle 2 completes at tick 360: deposit item -> 2 items held, + // which fills the buffer (capacity 2), so cycle 3 cannot start. + // Need to process through tick 360: 361 ticks total. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(10.0)) - + 2 * static_cast(secondsToTicks(1.0)) + 2, + + 2 * static_cast(secondsToTicks(1.0)) + 1, tick); const Building* b = findBuilding(f.state, id); @@ -512,6 +512,49 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]") REQUIRE_FALSE(b->production.has_value()); } +TEST_CASE("BuildingSystem: the next cycle starts on the tick the last one completed", + "[building]") +{ + // A cycle takes exactly its recipe duration, so a building whose output keeps + // draining produces at the configured rate (REQ-MAT-CYCLE). An idle tick between + // cycles would cost a one-second recipe about 3% of its throughput. + PlacementFixture f; + + const BuildingId id = + f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); + f.bs.setRecipe(f.state, id, "mine_iron_ore"); + + const Tick cycleTicks = secondsToTicks(1.0); // mine_iron_ore duration + + Tick tick = 0; + // Construction completes at tick 300 and cycle 1 starts in that same tick. + runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, + static_cast(secondsToTicks(10.0)) + 1, tick); + const Building* b = findBuilding(f.state, id); + REQUIRE(b != nullptr); + REQUIRE(b->production.has_value()); + const Tick firstCompletesAt = b->production->completesAt; + + // Process up to and including that completion tick: the next cycle is already + // running, due exactly one duration later rather than one duration plus a tick. + runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(cycleTicks), tick); + b = findBuilding(f.state, id); + REQUIRE(b->getOutputItemCount() == 1); + REQUIRE(b->production.has_value()); + REQUIRE(b->production->completesAt == firstCompletesAt + cycleTicks); + + // Nothing hauls the ore away here, so the buffer (capacity 2) would stall the third + // cycle. Drain it and confirm the cadence holds across the next boundary too. + f.bs.forEachBuilding(f.state, [](Building& building) { + building.outputBuffer.items.clear(); + for (std::vector& lane : building.emergingItems) { lane.clear(); } + }); + runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(cycleTicks), tick); + b = findBuilding(f.state, id); + REQUIRE(b->production.has_value()); + REQUIRE(b->production->completesAt == firstCompletesAt + 2 * cycleTicks); +} + // --------------------------------------------------------------------------- // REQ-UI-DEBUG-OVERLAY production counts // ---------------------------------------------------------------------------