Skip to content

While you were away

The screen every crafting game needs and nobody plans for: what finished overnight, what could not be delivered, and what is still on the bench.

Recipe

Systems required: Crafting. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists. No scene and no prefab — but it reports on a restore, so it needs a save that restores crafting: see wiring step 1. Public API only. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.

The part that is not obvious

It does not reconcile anything. The framework already does the hard half: CraftingSaveParticipant restores your jobs and credits the time that passed while the game was shut, and the service raises its ordinary completion events as it goes.

So an away report needs no offline maths, no timestamps of its own, and no second source of truth. It needs to be listening when the restore happens. That is the entire composition — and it is easy to miss precisely because it is so much less work than you expect. The instinct is to compute elapsed time and replay it yourself, which is how you end up with two implementations of one clock and a bug report about crafts finishing twice.

Always collecting beats bracketing the load

A Begin() / End() pair around your load looks tidier and puts an ordering hazard into startup code: begin too late and the report is empty for reasons nobody can see from the outside. This subscribes while enabled and hands over everything gathered since the last time you asked, so the only thing the caller has to get right is asking after the load.

Show the failures, or your game looks broken

A craft that finished while the player was away still has to be delivered when they come back, and a full backpack refuses it — NoSpaceAtDelivery. Report only the successes and a player is quietly missing a night's work with nothing on screen to explain it. That reads as a bug in your game rather than as a full bag, and it is the single most valuable line on the whole screen.

Do not promise that the queue advanced

Only a job that was running when the save was written is credited the offline seconds. Anything queued behind it starts its clock when the player returns. Wording like "your queue advanced while you were away" is a lie the framework deliberately stopped telling in 1.2.0, when queued time stopped being credited.

Batches count as their batch size. One completed job with a batchCount of five is five items, and "1 craft finished" is wrong in the way players notice immediately.

Drop it in

OfflineCraftReport.cs
using System.Collections.Generic;

using RevGaming.RevFramework.Crafting.Core;
using RevGaming.RevFramework.Crafting.UnityIntegration;

using UnityEngine;
using UnityEngine.SceneManagement;

namespace RevGaming.RevFramework.Cookbook.OfflineCraftReport
{
    /// <summary>
    /// "While you were away": what finished, what failed and what is still on the bench, ready to show
    /// the player the moment a save finishes loading.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Crafting</b> only. Public API only.</para>
    ///
    /// <para><b>It assumes your project already saves.</b> This reports on a restore; it does not
    /// perform one. You need a <c>RevSaveManager</c> with <c>new CraftingSaveParticipant(craftingService,
    /// recipes)</c> registered on it and a <c>StableId</c> on the crafting owner — the save system is
    /// explicit that it cannot find the framework's own participants for you — and
    /// <c>Enable Offline Progress</c> left on, which is the default. Without that wiring every step
    /// below succeeds and the report is permanently empty.</para>
    ///
    /// <para><b>It does not reconcile anything, and that is the point.</b> The framework already does
    /// the hard half: <c>CraftingSaveParticipant</c> restores the jobs and credits the time that
    /// passed while the game was shut, and the service raises its ordinary completion events as it
    /// goes. So an away report needs no offline maths, no timestamps of its own and no second source
    /// of truth — it needs to be <i>listening</i> when the restore happens. That is the whole
    /// composition, and it is easy to miss precisely because it is so much less work than expected.
    /// </para>
    ///
    /// <para><b>Always collecting, rather than bracketed around the load.</b> A begin/end pair looks
    /// tidier and puts an ordering hazard in your startup code: begin too late and the report is empty
    /// for reasons nobody can see. This subscribes while enabled and hands over everything gathered
    /// since the last time you asked, so the caller's only job is to ask after loading. The trade is
    /// that the buffer is session memory and collects from the moment this enables, not from the moment
    /// a load starts: call <see cref="Discard"/> immediately before you load, or a quickload will
    /// report the crafts it just erased under a heading that says the player was away.</para>
    ///
    /// <para><b>Failures matter more than completions here.</b> A craft that finished while the player
    /// was away still has to be delivered when they return, and there are two ways that fails: a full
    /// bag refuses it (<c>NoSpaceAtDelivery</c>), or the destination container cannot be resolved at all
    /// (<c>NoInventoryAtDelivery</c>). The second is the worse one — the completion has already been
    /// consumed, so the job is gone and no retry is possible. Show only the successes and the player is
    /// quietly missing hours of work with nothing on screen to explain it, which reads as a bug in your
    /// game rather than a full backpack.</para>
    ///
    /// <para><b>Batches count as crafts, not items.</b> One completed job with a <c>batchCount</c> of
    /// five is five crafts, and a report that says "1 craft finished" is wrong in the way players
    /// notice. It is not an item count: multiply by the recipe's output quantity for that, and expect
    /// the result to be approximate, because the output multiplier that was applied at delivery is not
    /// carried on the completion event.</para>
    ///
    /// <para><b>Queued time is not credited, and your wording should not promise it.</b> Only a job
    /// that was <i>running</i> when the save was written is given the offline seconds; anything behind
    /// it in the queue starts its clock on return. Saying "your queue advanced while you were away"
    /// would be a lie the framework deliberately stopped telling in 1.2.0.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class OfflineCraftReport : MonoBehaviour
    {
        /// <summary>One recipe, and how many crafts of it finished or failed.</summary>
        public readonly struct Line
        {
            public Line(RecipeCore recipe, int count, CraftFailReason reason)
            {
                Recipe = recipe;
                Count = count;
                Reason = reason;
            }

            /// <summary>What was being crafted.</summary>
            public RecipeCore Recipe { get; }

            /// <summary>How many crafts, counting a batch as its batch size. Not a count of items.</summary>
            public int Count { get; }

            /// <summary>Why it failed, or <see cref="CraftFailReason.None"/> for a success.</summary>
            public CraftFailReason Reason { get; }
        }

        /// <summary>One job still on the bench, its state, and how long it has left.</summary>
        public readonly struct Pending
        {
            public Pending(RecipeCore recipe, int count, CraftJobState state, float secondsRemaining)
            {
                Recipe = recipe;
                Count = count;
                State = state;
                SecondsRemaining = secondsRemaining;
            }

            /// <summary>What is being crafted.</summary>
            public RecipeCore Recipe { get; }

            /// <summary>How many crafts, counting a batch as its batch size. Not a count of items.</summary>
            public int Count { get; }

            /// <summary>Running, Queued or Paused. Only a Running job's clock is moving.</summary>
            public CraftJobState State { get; }

            /// <summary>
            /// Seconds left, or a negative value when the service does not know. For a Queued or Paused
            /// job this is what it <i>will</i> take, not a countdown: it does not move.
            /// </summary>
            public float SecondsRemaining { get; }
        }

        [Tooltip("Crafting service to listen to. Leave empty to find one in the scene, and to find it " +
                 "again whenever a scene loads.")]
        [SerializeField] private CraftingService crafting;

        [Tooltip("Only report crafts belonging to this owner. Leave empty and every owner's crafts are " +
                 "reported — a village smith's overnight work lands next to the player's. Set it to the " +
                 "same GameObject you pass to GetPending so both halves of the screen agree. A " +
                 "destroyed one reads as empty, so the filter lifts rather than reporting nothing.")]
        [SerializeField] private GameObject reportOwner;

        [Tooltip("How many lines to remember before dropping the oldest. Per list: successes and " +
                 "failures are capped separately, so up to twice this many lines are retained. A player " +
                 "who never opens the report should not grow this without limit.")]
        [SerializeField, Min(1)] private int maxLines = 64;

        private readonly List<Line> _finished = new();
        private readonly List<Line> _failed = new();
        private readonly List<CraftJob> _jobBuffer = new();
        private int _dropped;
        private bool _subscribed;
        private bool _warnedNotListening;

        private void OnEnable()
        {
            // Rebound on every scene load rather than resolved once. This component belongs in the
            // persistent scene, so it outlives the scene its service lives in: a service destroyed by a
            // scene change leaves a fake-null reference behind and takes the subscription with it, and
            // being subscribed at the instant the restore runs is the entire recipe.
            SceneManager.sceneLoaded += OnSceneLoaded;
            Bind();
        }

        private void OnDisable()
        {
            SceneManager.sceneLoaded -= OnSceneLoaded;
            Unbind();
        }

        private void OnSceneLoaded(Scene scene, LoadSceneMode mode) => Bind();

        /// <summary>
        /// Hands over everything gathered since the last call and starts a fresh collection.
        /// </summary>
        /// <remarks>
        /// <para>Call it after your load finishes. Anything that completed or failed during the restore
        /// is in here, because the restore raises the ordinary <c>OnJobCompleted</c> / <c>OnJobFailed</c>
        /// events as it reconciles. The job those carry is synthetic, though — a fresh id that was never
        /// accepted or started and is not in <c>GetJobs()</c> — so read <c>recipe</c> and
        /// <c>batchCount</c> off it and never match on <c>id</c>.</para>
        ///
        /// <para>It is everything since the last call, not only what the restore produced: live crafting
        /// from before it is in here too. <see cref="Discard"/> before a load is what keeps the two
        /// apart.</para>
        /// </remarks>
        /// <param name="finished">Receives one line per recipe that completed.</param>
        /// <param name="failed">Receives one line per recipe that failed, with the reason.</param>
        /// <param name="droppedLines">
        /// Receives how many lines <c>maxLines</c> evicted since the last call — lines, not crafts, so
        /// one dropped line can be a whole batch. It travels out with the report because the handover
        /// resets it.
        /// </param>
        /// <returns>True when there was anything at all to report.</returns>
        public bool TakeReport(List<Line> finished, List<Line> failed, out int droppedLines)
        {
            droppedLines = 0;

            if (finished == null || failed == null)
                return false;

            if (!_subscribed)
                WarnNotListening();

            finished.Clear();
            failed.Clear();

            Merge(_finished, finished);
            Merge(_failed, failed);

            droppedLines = _dropped;

            _finished.Clear();
            _failed.Clear();
            _dropped = 0;

            return finished.Count > 0 || failed.Count > 0;
        }

        /// <summary>
        /// Throws away everything gathered so far without reporting it.
        /// </summary>
        /// <remarks>
        /// Call it immediately before you start a load. A load is a rewind and this buffer does not
        /// rewind with it — the framework's own crafting participant clears its completion history at
        /// that same moment, for that same reason. Skip it and a mid-session quickload reports crafts
        /// the restore erased, and a save taken mid-craft reports one craft twice: once from the live
        /// completion and once from the offline reconcile of the same job.
        /// </remarks>
        public void Discard()
        {
            _finished.Clear();
            _failed.Clear();
            _dropped = 0;
        }

        /// <summary>
        /// Lists what is still on the bench — running, queued or paused — for the other half of the
        /// same screen.
        /// </summary>
        /// <remarks>
        /// Only a <see cref="CraftJobState.Running"/> job is counting down. A queued one reports the
        /// full duration it will take and a paused one its frozen remainder, so render those from
        /// <c>State</c> without a clock rather than showing a timer that never moves. Call this when the
        /// screen opens rather than every frame: the job list is filled into a buffer this component
        /// owns, but the service allocates a small closure per job to look its remaining time up.
        /// </remarks>
        /// <param name="owner">
        /// Whose bench to read, or null for every job the service holds. A <i>destroyed</i> GameObject
        /// reads as null here, so it returns every owner's jobs rather than none.
        /// </param>
        /// <param name="pending">Receives one entry per live job.</param>
        public void GetPending(GameObject owner, List<Pending> pending)
        {
            if (pending == null)
                return;

            pending.Clear();

            if (!crafting)
            {
                WarnNotListening();
                return;
            }

            // FillJobsByState rather than GetJobs: the owner-filtered GetJobs allocates a list per call,
            // and its null-owner branch hands back the read-only view, which does not exist until the
            // service has Awoken. This fills a buffer we own and is safe either way.
            crafting.FillJobsByState(_jobBuffer, owner);

            for (int i = 0; i < _jobBuffer.Count; i++)
            {
                CraftJob job = _jobBuffer[i];
                if (job?.recipe == null)
                    continue;

                pending.Add(new Pending(job.recipe, Mathf.Max(1, job.batchCount), job.state,
                    crafting.GetJobRemainingSeconds(job.id)));
            }
        }

        private void Bind()
        {
            if (crafting && _subscribed)
                return;

            _subscribed = false;

            if (!crafting)
                crafting = FindAnyObjectByType<CraftingService>();

            if (!crafting)
                return;

            crafting.OnJobCompleted += OnCompleted;
            crafting.OnJobFailed += OnFailed;
            _subscribed = true;
        }

        private void Unbind()
        {
            if (!crafting || !_subscribed)
                return;

            crafting.OnJobCompleted -= OnCompleted;
            crafting.OnJobFailed -= OnFailed;
            _subscribed = false;
        }

        private void WarnNotListening()
        {
            if (_warnedNotListening)
                return;

            // "Nothing happened while you were away" and "nobody was listening" are the same empty
            // screen, and this is the one recipe where the difference is invisible from the outside.
            // Said once, at the moment the host actually asks for a report.
            _warnedNotListening = true;
            Debug.LogWarning(
                $"[Cookbook] '{name}' is not subscribed to a CraftingService, so the away report will " +
                "stay empty. Assign the service, or make sure one exists in a loaded scene — this " +
                "re-finds it whenever a scene loads.", this);
        }

        private void OnCompleted(CraftJob job) => Record(_finished, job, CraftFailReason.None);

        private void OnFailed(CraftJob job, CraftFailReason reason) => Record(_failed, job, reason);

        private void Record(List<Line> into, CraftJob job, CraftFailReason reason)
        {
            if (job?.recipe == null)
                return;

            // The events are service-wide; GetPending is not. Unfiltered, one screen shows the smith's
            // crafts as finished and only the player's as pending, and they cannot be reconciled.
            if (reportOwner && job.owner != reportOwner)
                return;

            if (into.Count >= maxLines)
            {
                // Oldest out rather than newest ignored: the most recent crafts are the ones a returning
                // player is looking for, and a silently truncated tail is worse than a counted one.
                into.RemoveAt(0);
                _dropped++;
            }

            into.Add(new Line(job.recipe, Mathf.Max(1, job.batchCount), reason));
        }

        /// <summary>
        /// Collapses repeats into one line each, so twelve separate arrow crafts read as one line of
        /// twelve rather than twelve rows.
        /// </summary>
        private static void Merge(List<Line> source, List<Line> destination)
        {
            for (int i = 0; i < source.Count; i++)
            {
                Line line = source[i];
                bool merged = false;

                for (int j = 0; j < destination.Count; j++)
                {
                    if (destination[j].Recipe != line.Recipe || destination[j].Reason != line.Reason)
                        continue;

                    destination[j] = new Line(line.Recipe, destination[j].Count + line.Count, line.Reason);
                    merged = true;
                    break;
                }

                if (!merged)
                    destination.Add(line);
            }
        }
    }
}

Wiring it up

  1. The prerequisite, and the recipe cannot work without it: your RevSaveManager must be registering new CraftingSaveParticipant(craftingService, ...). That participant is what restores the jobs and credits the time that passed — this component only listens to what the restore raises. Without it there is no offline crafting to report on, and this reports nothing, quietly.
  2. Put the component in your persistent scene. It finds the crafting service itself.
  3. After your load completes, call TakeReport(finished, failed).
  4. Call GetPending(player, pending) for the other half of the screen.
if (report.TakeReport(_finished, _failed))
    ShowAwayScreen(_finished, _failed);

That is all of it. The lists are yours to keep and reuse; nothing is allocated per call.

What it deliberately does not do

It does not tell you how long the player was away. That belongs to your save file, not to the crafting system, and inventing it here would mean a second clock disagreeing with your first.

It does not group by anything but recipe and reason. Twelve arrow crafts become one line reading sixty arrows, which is what a returning player wants. If you need per-station or per-day grouping, the merge step is six lines and the obvious place to change it.

It does not bound memory for free. Lines are capped at maxLines, oldest dropped first, with the count of what was dropped kept — the newest crafts are the ones a returning player is looking for, and a silently truncated tail is worse than a counted one.

  • Crafting — jobs, batches, and the completion and failure events this listens to.
  • Save — the coordinator that drives the restore, and the crafting participant that does the reconciliation this reports on.
  • A shop that remembers — the other side of the save system: writing your own state into the same file.