Use this guide when
Use this workflow when one scheduler must submit one runReaction() call and later prove which Store changes came from that reaction. The important 2026 boundary is that the produced amount is not always the base LAB_REACTION_AMOUNT: an active PWR_OPERATE_LAB effect on the output Lab increases the amount processed by the engine.
If preflight, snapshots, and verification all hard-code the base amount, the code becomes internally inconsistent exactly when an operated Lab is most valuable.
Separate output Lab requirements from input Lab requirements
function getVisibleLab(id) {
const lab = typeof id === 'string'
? Game.getObjectById(id)
: null;
return lab && lab.structureType === STRUCTURE_LAB
? lab
: null;
}
function getOwnedActiveOutputLab(id) {
const lab = getVisibleLab(id);
return lab
&& lab.my === true
&& lab.isActive() === true
? lab
: null;
}
The Lab that calls runReaction() is the output Lab and must satisfy the ownership/activity checks enforced by the API. The current engine does not impose the same ownership or isActive() precondition on the two input Lab objects. Requiring owned active input Labs can still be a sensible project policy for a closed production room, but label it as your policy rather than the API contract.
All three Labs must still be valid, distinct Lab objects, and each input must be within range 2 of the output Lab.
Calculate the reaction amount from the output Lab
function getReactionAmount(outputLab) {
let reactionAmount = LAB_REACTION_AMOUNT;
const effect = outputLab.effects?.find(item =>
item.effect === PWR_OPERATE_LAB
&& item.ticksRemaining > 0
);
if (effect) {
reactionAmount +=
POWER_INFO[PWR_OPERATE_LAB].effect[effect.level - 1];
}
return reactionAmount;
}
This mirrors the current official engine boundary: start with LAB_REACTION_AMOUNT, then add the active PWR_OPERATE_LAB effect for the output Lab. Snapshot that value once for the request. Do not calculate one amount for preflight and a different amount for settlement.
Build a plan from current reagents, amount, range, and capacity
function getReactionProduct(inputA, inputB) {
const reagentA = inputA.mineralType;
const reagentB = inputB.mineralType;
if (!reagentA || !reagentB) return null;
return {
reagentA,
reagentB,
product: REACTIONS[reagentA]?.[reagentB] ?? null
};
}
function buildReactionPlan(request) {
const outputLab = getOwnedActiveOutputLab(request.outputLabId);
const inputA = getVisibleLab(request.inputLabAId);
const inputB = getVisibleLab(request.inputLabBId);
if (!outputLab || !inputA || !inputB) {
return { ready: false, reason: 'lab-unavailable' };
}
if (
outputLab.id === inputA.id
|| outputLab.id === inputB.id
|| inputA.id === inputB.id
) {
return { ready: false, reason: 'lab-ids-not-distinct' };
}
if (
!outputLab.pos.inRangeTo(inputA, 2)
|| !outputLab.pos.inRangeTo(inputB, 2)
) {
return { ready: false, reason: 'input-out-of-range' };
}
if (outputLab.cooldown > 0) {
return { ready: false, reason: 'output-cooling-down' };
}
const recipe = getReactionProduct(inputA, inputB);
if (!recipe?.product) {
return { ready: false, reason: 'recipe-invalid' };
}
const reactionAmount = getReactionAmount(outputLab);
if (
inputA.store.getUsedCapacity(recipe.reagentA) < reactionAmount
|| inputB.store.getUsedCapacity(recipe.reagentB) < reactionAmount
) {
return {
ready: false,
reason: 'reagent-insufficient',
product: recipe.product,
reactionAmount
};
}
if (
outputLab.mineralType
&& outputLab.mineralType !== recipe.product
) {
return { ready: false, reason: 'output-mineral-conflict' };
}
if (
outputLab.store.getFreeCapacity(recipe.product) < reactionAmount
) {
return { ready: false, reason: 'output-capacity-insufficient' };
}
return {
ready: true,
reason: 'ready',
outputLab,
inputA,
inputB,
...recipe,
reactionAmount
};
}
Using LAB_REACTION_AMOUNT directly in the reagent or output-capacity checks is only correct when no active operation effect increases the batch. The plan carries reactionAmount forward so every later check uses the same request identity.
Reserve one exclusive three-Store window
Between snapshot and verification, prevent haulers, another reaction scheduler, and any other code from changing the product or reagent amounts in these three Labs. Shared input Labs are legal, but simultaneous consumers make exact attribution impossible unless the scheduler serializes them.
Record exclusiveWindow: true only when the production coordinator actually enforces that promise.
Snapshot the exact amount and submit once
function submitReactionRequest(request) {
const plan = buildReactionPlan(request);
request.lastCheckedAt = Game.time;
request.lastStatus = plan.reason;
if (!plan.ready) {
return { status: 'not-submitted', reason: plan.reason };
}
request.enabled = false;
request.snapshot = {
exclusiveWindow: request.exclusiveWindow === true,
outputLabId: plan.outputLab.id,
inputLabAId: plan.inputA.id,
inputLabBId: plan.inputB.id,
reagentA: plan.reagentA,
reagentB: plan.reagentB,
product: plan.product,
reactionAmount: plan.reactionAmount,
outputBefore:
plan.outputLab.store.getUsedCapacity(plan.product),
inputABefore:
plan.inputA.store.getUsedCapacity(plan.reagentA),
inputBBefore:
plan.inputB.store.getUsedCapacity(plan.reagentB)
};
const result = plan.outputLab.runReaction(
plan.inputA,
plan.inputB
);
request.result = result;
request.resultAt = Game.time;
request.status = result === OK
? 'accepted-pending-reaction'
: 'failed-review-required';
return { status: request.status, result };
}
OK means the reaction intent was accepted. It is not a license to infer later Store contents without checking them.
Verify one output increase and both reagent decreases
function verifyReaction(request) {
const snapshot = request?.snapshot;
if (!snapshot || request.result !== OK) {
return { status: 'submission-not-accepted' };
}
if (snapshot.exclusiveWindow !== true) {
return { status: 'not-exclusive' };
}
const outputLab = getVisibleLab(snapshot.outputLabId);
const inputA = getVisibleLab(snapshot.inputLabAId);
const inputB = getVisibleLab(snapshot.inputLabBId);
if (!outputLab || !inputA || !inputB) {
return { status: 'lab-unavailable' };
}
const outputDelta =
outputLab.store.getUsedCapacity(snapshot.product)
- snapshot.outputBefore;
const inputADelta =
inputA.store.getUsedCapacity(snapshot.reagentA)
- snapshot.inputABefore;
const inputBDelta =
inputB.store.getUsedCapacity(snapshot.reagentB)
- snapshot.inputBBefore;
const exact =
outputDelta === snapshot.reactionAmount
&& inputADelta === -snapshot.reactionAmount
&& inputBDelta === -snapshot.reactionAmount;
if (exact) {
return {
status: 'verified-exact-reaction',
outputDelta,
inputADelta,
inputBDelta
};
}
const unchanged =
outputDelta === 0
&& inputADelta === 0
&& inputBDelta === 0;
return {
status: unchanged
? 'pending-no-store-delta'
: 'state-changed-ambiguous',
outputDelta,
inputADelta,
inputBDelta
};
}
The exact signature is +reactionAmount product and -reactionAmount from each input. Cooldown is supporting evidence, not a unique reaction identifier.
Keep acceptance and settlement states separate
| Status | Meaning |
|---|---|
accepted-pending-reaction | runReaction() returned OK; exact Store evidence is still pending. |
pending-no-store-delta | The later snapshot is unchanged; keep investigating timing and visibility instead of inventing success. |
state-changed-ambiguous | At least one Store changed but the three deltas do not match one isolated reaction. |
verified-exact-reaction | Product and both reagents match the one snapshotted reaction amount. |
Preserve the documented return codes
Documented runReaction() results include ERR_NOT_OWNER, ERR_NOT_ENOUGH_RESOURCES, ERR_INVALID_TARGET, ERR_FULL, ERR_NOT_IN_RANGE, ERR_INVALID_ARGS, ERR_TIRED, and ERR_RCL_NOT_ENOUGH. Log the actual return code instead of collapsing every failure into “reaction failed.”
Keep hauling, boosting, and reaction scheduling separate
This page owns one reaction request and its evidence. Lab assignment, reagent hauling, reaction chains, reverse reactions, boost queues, and multi-output scheduling need separate state machines. Continue with the boost verification guide when the product is intended for a Creep.