start the next production cycle in the tick the last one completed

tickProduction and tickShipyardProduction cleared the finished cycle and then
refused to start another in the same tick, so every cycle really took its
duration plus one tick. The cost scales inversely with duration: a 1s recipe
ran at 31 ticks instead of 30 and lost 3.2% of its throughput, a 12s recipe
0.3%, which skewed the relative rates the recipe tree is tuned around.

Fall through from completion into the start attempt instead. The start code is
straight-line, so at most one cycle still begins per building per tick, even
for a duration that rounds to zero ticks, and the outputs just deposited count
against the space check, so a cycle whose output no longer fits waits exactly
as it did before.

The balancing model already computed rates as amount / duration_seconds
(tools/threat_report.py), so this brings the simulation up to the documented
numbers rather than moving them. The combat arena never ticks production, so
its tuning is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-12 12:49:56 +02:00
parent 74233f5bc5
commit 1d54ecf578
2 changed files with 83 additions and 32 deletions

View File

@@ -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<int>(secondsToTicks(10.0))
+ 2 * static_cast<int>(secondsToTicks(1.0)) + 2,
+ 2 * static_cast<int>(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<int>(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<int>(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<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
});
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(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
// ---------------------------------------------------------------------------