public virtual void OnNPCAtStockpile(BlockJobInstance instance, ref NPCState state)
        {
            ScientistJobInstance scientist = instance as ScientistJobInstance;

            if (scientist != null)
            {
                scientist.StoredItemCount = ItemsToTakePerHaul;
            }
            state.SetCooldown(0.5);
            state.JobIsDone          = true;
            instance.ShouldTakeItems = false;
        }
        public virtual void OnNPCAtJob(BlockJobInstance blockJobInstance, ref NPCState state)
        {
            ScientistJobInstance instance = blockJobInstance as ScientistJobInstance;

            if (instance == null)
            {
                state.JobIsDone = true;
                return;
            }

            Colony owner = instance.Owner;

            instance.NPC.LookAt(instance.Position.Vector);
            ColonyScienceState scienceData = owner.ScienceData;

            state.JobIsDone = true;

            // if no stored items - check if the stockpile contains the items required for any of the researches
            var completedCycles = scienceData.CompletedCycles;

            if (instance.StoredItemCount <= 0)
            {
                if (completedCycles.Count == 0)
                {
                    // done for now
                    state.SetIndicator(new Shared.IndicatorState(Random.NextFloat(8f, 16f), BuiltinBlocks.Indices.erroridle));
                    return;
                }

                int possibles = 0;
                for (int i = 0; i < completedCycles.Count; i++)
                {
                    ScienceKey cyclesResearch = completedCycles.GetKeyAtIndex(i);
                    if (cyclesResearch.Researchable.TryGetCyclesCondition(out ScientistCyclesCondition cyclesData) && completedCycles.GetValueAtIndex(i) < cyclesData.CycleCount)
                    {
                        possibles++;
                        if (owner.Stockpile.Contains(cyclesData.ItemsPerCycle))
                        {
                            instance.ShouldTakeItems = true;
                            state.SetCooldown(0.3);
                            return;                             // found an in-progress research that we have items for
                        }
                    }
                }

                // missing items, find random requirement to use as indicator
                if (possibles > 0)
                {
                    int possiblesIdx = Random.Next(0, possibles);

                    for (int i = 0; i < completedCycles.Count; i++)
                    {
                        ScienceKey cyclesResearch = completedCycles.GetKeyAtIndex(i);
                        if (cyclesResearch.Researchable.TryGetCyclesCondition(out ScientistCyclesCondition cyclesData) && completedCycles.GetValueAtIndex(i) < cyclesData.CycleCount)
                        {
                            if (possiblesIdx-- <= 0)
                            {
                                MissingItemHelper(cyclesData.ItemsPerCycle, owner.Stockpile, ref state);
                                return;
                            }
                        }
                    }
                    // should be unreachable
                }

                // done for now
                state.SetIndicator(new Shared.IndicatorState(Random.NextFloat(8f, 16f), BuiltinBlocks.Indices.erroridle));
                return;
            }

            // have stored items, try to forward an active science

            if (completedCycles.Count == 0)
            {
                instance.StoredItemCount = 0;
                state.SetCooldown(0.3);
                return;
            }

            var   happyData        = owner.HappinessData;
            float cyclesToAdd      = happyData.ScienceSpeedMultiplierCalculator.GetSpeedMultiplier(happyData.CachedHappiness, instance.NPC);
            int   doneScienceIndex = -1;

            const float MINIMUM_CYCLES_TO_ADD = 0.001f;

            if (cyclesToAdd <= MINIMUM_CYCLES_TO_ADD)
            {
                state.SetIndicator(new Shared.IndicatorState(CraftingCooldown, BuiltinBlocks.Indices.missingerror));
                Log.WriteWarning($"Cycles below minimum for science job at {instance.Position}!");
                return;
            }

            for (int i = 0; i < completedCycles.Count && cyclesToAdd >= 0f; i++)
            {
                AbstractResearchable research = completedCycles.GetKeyAtIndex(i).Researchable;
                float progress = completedCycles.GetValueAtIndex(i);

                if (!research.TryGetCyclesCondition(out ScientistCyclesCondition cyclesCondition) || progress >= cyclesCondition.CycleCount)
                {
                    continue;
                }

                bool  atCycleBoundary = Math.Abs(progress - Math.RoundToInt(progress)) < MINIMUM_CYCLES_TO_ADD;
                float freeCycles      = (float)System.Math.Ceiling(progress) - progress;
                if (!atCycleBoundary)
                {
                    if (cyclesToAdd <= freeCycles)
                    {
                        OnDoFullCycles(ref state);                         // cycles <= freecycles, just add all
                        return;
                    }
                    else
                    {
                        OnDoPartialCycles(ref state, Math.Min(freeCycles, cyclesToAdd));
                    }
                }

                if (progress >= cyclesCondition.CycleCount)
                {
                    // completed on a partial cycle (it wasn't completed a few lines up)
                    continue;
                }

                // at boundary and/or will cross a boundary with the cyclesToAdd

                List <InventoryItem> requirements = cyclesCondition.ItemsPerCycle;

                if (owner.Stockpile.TryRemove(requirements))
                {
                    // got the items, deal with recycling, then just add the full cyclesToAdd
                    int recycled = 0;
                    for (int j = 0; j < requirements.Count; j++)
                    {
                        ushort type = requirements[j].Type;
                        if (type == BuiltinBlocks.Indices.sciencebaglife ||
                            type == BuiltinBlocks.Indices.sciencebagbasic ||
                            type == BuiltinBlocks.Indices.sciencebagmilitary
                            )
                        {
                            recycled += requirements[j].Amount;
                        }
                    }
                    for (int j = recycled; j > 0; j--)
                    {
                        if (Random.NextDouble() > RECYCLE_CHANCE)
                        {
                            recycled--;
                        }
                    }
                    if (recycled > 0)
                    {
                        owner.Stockpile.Add(BuiltinBlocks.Indices.linenbag, recycled);
                    }
                    OnDoFullCycles(ref state);
                    return;
                }
                continue;

                // unreachable
                void OnDoFullCycles(ref NPCState stateCopy)
                {
                    scienceData.CyclesAddProgress(research.AssignedKey, cyclesToAdd);
                    stateCopy.SetIndicator(new Shared.IndicatorState(CraftingCooldown, NPCIndicatorType.Science, (ushort)research.AssignedKey.Index));
                    instance.StoredItemCount--;
                    progress += cyclesToAdd;
                    if (progress >= cyclesCondition.CycleCount && research.AreConditionsMet(scienceData))
                    {
                        SendCompleteMessage();
                    }
                }

                void OnDoPartialCycles(ref NPCState stateCopy, float cyclesToUse)
                {
                    cyclesToAdd -= cyclesToUse;
                    scienceData.CyclesAddProgress(research.AssignedKey, cyclesToUse);
                    progress += cyclesToUse;
                    if (progress >= cyclesCondition.CycleCount && research.AreConditionsMet(scienceData))
                    {
                        SendCompleteMessage();
                    }
                    doneScienceIndex = (int)research.AssignedKey.Index;
                }

                void SendCompleteMessage()
                {
                    Players.Player[] owners = owner.Owners;
                    for (int j = 0; j < owners.Length; j++)
                    {
                        Players.Player player = owners[j];
                        if (player.ConnectionState == Players.EConnectionState.Connected && player.ActiveColony == owner)
                        {
                            string msg = Localization.GetSentence(player.LastKnownLocale, "chat.onscienceready");
                            string res = Localization.GetSentence(player.LastKnownLocale, research.GetKey() + ".name");
                            msg = string.Format(msg, res);
                            Chatting.Chat.Send(player, msg);
                        }
                    }
                }
            }

            if (doneScienceIndex >= 0)
            {
                // did some freecoasting
                state.SetIndicator(new Shared.IndicatorState(CraftingCooldown, NPCIndicatorType.Science, (ushort)doneScienceIndex));
                instance.StoredItemCount--;
                return;
            }

            // had stored items, but for some reason couldn't dump them in existing research
            // reset the job basically
            instance.StoredItemCount = 0;
            state.SetCooldown(0.3);
            return;

            void MissingItemHelper(IList <InventoryItem> requirements, Stockpile stockpile, ref NPCState stateCopy)
            {
                ushort missing = 0;

                for (int i = 0; i < requirements.Count; i++)
                {
                    if (!stockpile.Contains(requirements[i]))
                    {
                        missing = requirements[i].Type;
                        break;
                    }
                }
                float cooldown = Random.NextFloat(8f, 16f);

                stateCopy.SetIndicator(new Shared.IndicatorState(cooldown, missing, true, false));
                stateCopy.JobIsDone = false;
            }
        }