Define the Mineral harvest contract
A valid Mineral operation binds three objects: one Miner, the room's Mineral, and the active Extractor occupying that Mineral's tile. A room-level Extractor lookup is not enough, and a later Store increase does not identify which action produced it.
The workflow should therefore separate current-tick submission from next-tick verification:
- resolve the current objects and validate their relationship;
- move when the Miner is outside range 1;
- save IDs only after
harvest()returnsOK; - on the next tick, match
EVENT_HARVESTby exact Miner and Mineral IDs.
Resolve the exact Mineral and Extractor
function resolveMineralStation(room) {
const mineral = room?.find(FIND_MINERALS)[0] || null;
if (!mineral) {
return { ready: false, status: 'mineral-missing' };
}
const extractor = mineral.pos
.lookFor(LOOK_STRUCTURES)
.find(structure =>
structure.structureType === STRUCTURE_EXTRACTOR
) || null;
if (!extractor) {
return { ready: false, status: 'extractor-missing', mineral };
}
if (extractor.my !== true || extractor.isActive() !== true) {
return { ready: false, status: 'extractor-inactive', mineral, extractor };
}
return { ready: true, status: 'station-ready', mineral, extractor };
}
When mineralAmount is zero, keep the Mineral object as the source of truth. Its regeneration state belongs to the object, not to a Memory countdown copied on an earlier tick.
Check the Miner without inventing success
function inspectMinerForStation(creep, station) {
if (!creep || creep.my !== true || creep.spawning === true) {
return { ready: false, status: 'miner-unavailable' };
}
if (creep.room.name !== station.mineral.pos.roomName) {
return { ready: false, status: 'miner-in-wrong-room' };
}
if (station.mineral.mineralAmount <= 0) {
return {
ready: false,
status: 'mineral-regenerating',
ticksToRegeneration:
station.mineral.ticksToRegeneration ?? null
};
}
if (station.extractor.cooldown > 0) {
return {
ready: false,
status: 'extractor-cooldown',
cooldown: station.extractor.cooldown
};
}
if (creep.getActiveBodyparts(WORK) <= 0) {
return { ready: false, status: 'no-active-work' };
}
if (
creep.store.getFreeCapacity(
station.mineral.mineralType
) <= 0
) {
return { ready: false, status: 'no-mineral-capacity' };
}
return { ready: true, status: 'miner-ready' };
}
The free-capacity check above is only a coarse gate. The current Mineral harvest() submission path does not return ERR_FULL because the Creep has too little Store space. The checked processor can harvest a full WORK batch and drop the overflow. The submission function below therefore adds a conservative project policy for an unboosted Miner: do not submit the next harvest when the visible remaining Mineral output for that batch is larger than the current free Store capacity.
Save only accepted harvest identity
function submitMineralHarvest(creep) {
const station = resolveMineralStation(creep?.room);
if (!station.ready) return station;
const state = inspectMinerForStation(creep, station);
if (!state.ready) return state;
const { mineral, extractor } = station;
const hasBoostedWork = creep.body.some(part =>
part.type === WORK
&& part.hits > 0
&& Boolean(part.boost)
);
if (hasBoostedWork) {
return {
status: 'boosted-work-batch-out-of-scope',
mineralId: mineral.id
};
}
const activeWork = creep.getActiveBodyparts(WORK);
const harvestBatch =
activeWork * HARVEST_MINERAL_POWER;
const plannedOutput = Math.min(
mineral.mineralAmount,
harvestBatch
);
const freeCapacity =
creep.store.getFreeCapacity(mineral.mineralType) ?? 0;
if (freeCapacity < plannedOutput) {
return {
status: 'deliver-before-next-harvest',
mineralId: mineral.id,
plannedOutput,
freeCapacity
};
}
if (!creep.pos.isNearTo(mineral)) {
return {
status: 'moving-to-mineral',
moveResult: creep.moveTo(mineral, {
range: 1,
reusePath: 10
})
};
}
const result = creep.harvest(mineral);
if (result !== OK) {
return {
status: 'harvest-rejected',
result,
mineralId: mineral.id,
extractorId: extractor.id
};
}
Memory.pendingMineralHarvests ??= {};
Memory.pendingMineralHarvests[creep.id] = {
submittedAt: Game.time,
roomName: creep.room.name,
creepId: creep.id,
creepName: creep.name,
mineralId: mineral.id,
mineralType: mineral.mineralType,
extractorId: extractor.id,
mineralBefore: mineral.mineralAmount,
storeBefore:
creep.store.getUsedCapacity(mineral.mineralType),
plannedOutput
};
return {
status: 'harvest-accepted',
result,
mineralId: mineral.id,
extractorId: extractor.id
};
}
ERR_NOT_IN_RANGE belongs to the attempted harvest action. This example checks range first so movement and harvesting remain separate intents with separate statuses.
Match the exact harvest event
function verifyMineralHarvest(pending) {
if (!pending) return { status: 'no-pending-harvest' };
if (Game.time <= pending.submittedAt) {
return { status: 'wait-for-next-tick' };
}
if (Game.time > pending.submittedAt + 1) {
return { status: 'harvest-event-window-missed' };
}
const room = Game.rooms[pending.roomName];
if (!room) return { status: 'room-not-visible' };
const matches = room.getEventLog().filter(event =>
event.event === EVENT_HARVEST
&& event.objectId === pending.creepId
&& event.data?.targetId === pending.mineralId
);
if (matches.length === 0) {
return { status: 'accepted-harvest-event-missing' };
}
if (matches.length > 1) {
return { status: 'harvest-event-ambiguous', count: matches.length };
}
const mineral = Game.getObjectById(pending.mineralId);
const creep = Game.getObjectById(pending.creepId);
return {
status: 'harvest-event-observed',
eventAmount: matches[0].data?.amount ?? null,
mineralBefore: pending.mineralBefore,
mineralNow: mineral?.mineralAmount ?? null,
storeBefore: pending.storeBefore,
storeNow: creep
? creep.store.getUsedCapacity(pending.mineralType)
: null
};
}
Run this verifier before replacing the pending record with a new action. Room events are available for a narrow window; a later net comparison cannot reconstruct exact actor-target identity.
Separate event amount from retained Store gain
The official engine records the Miner as objectId and the Mineral as targetId. In the checked engine snapshot 80977824199a596d174d392fd0cf8c458c21fcbd, the Mineral event amount is based on body harvest power, while the actual Mineral removal is capped by remaining mineralAmount and Store overflow can be dropped. On the final depletion harvest, event.data.amount can therefore be larger than the Mineral actually removed even when Store capacity is not the limiting factor. Therefore:
- use the event to prove actor-target identity;
- use current
mineralAmountto describe depletion; - use the Creep Store to describe retained resources;
- do not require all three numeric deltas to equal the event amount at the final harvest.
Treat depletion as a state transition
function describeMineralAvailability(mineral) {
if (!mineral) return { status: 'mineral-missing' };
if (mineral.mineralAmount <= 0) {
return {
status: 'mineral-regenerating',
ticksToRegeneration:
mineral.ticksToRegeneration ?? null
};
}
return {
status: 'mineral-available',
mineralAmount: mineral.mineralAmount,
mineralType: mineral.mineralType
};
}
Stop generating harvest intents while the Mineral is depleted. Keep logistics and replacement decisions separate from the harvest verifier so a delivery action cannot overwrite pending harvest evidence.
Production integration boundary
Use one role runner for each fixed Mineral station, verify the prior accepted action before submitting the next one, and keep hauling in another state. The next-batch guard above is intentionally scoped to unboosted WORK parts; a boosted Miner needs an explicit boost-aware batch calculation or a deliberate drop-mining policy. Console execution, near-full Store overflow, final-depletion event amount, real cooldown timing, regeneration and screenshots remain pending.
Official documentation
Review the official Screeps API for Minerals, Extractors, Creep.harvest(), Store capacity, Room.getEventLog(), EVENT_HARVEST and action return codes.