using System;
using Codely.Newtonsoft.Json;
using Codely.Newtonsoft.Json.Linq;
namespace UnityTcp.Editor.Helpers
{
///
/// One step of a : the work to perform and the condition for leaving it.
///
/// runs at most one step per editor frame. Each frame it invokes
/// and then evaluates ; while the condition is false the
/// same step runs again next frame, so must be safe to repeat. A null
/// means the step leaves after a single frame.
///
/// is checked first, and a skipped step costs nothing: the runner moves to
/// the next step and keeps going in the same frame, so a run of skipped steps is passed over in
/// one tick and the first step that does not skip runs in that same tick.
///
/// A step built with instead runs a whole child to
/// completion before it may be left — see that method for the ordering.
///
/// Steps are rebuilt from after a domain reload, so their
/// delegates may capture the job instance but must not hold state that has to survive the
/// reload — that state belongs in serialized fields on the subclass.
///
public sealed class JobStep
{
/// Human-readable step name, reported in timeout and progress messages.
public string Name { get; }
/// Work for this step; invoked once per frame until holds.
public Action Run { get; }
/// Leave condition. Null means leave after one frame.
public Func CanLeave { get; }
///
/// Skip condition, re-evaluated at the top of every frame before . When it
/// holds, the step is passed over — its and, for a composite step, its
/// child never execute — and the next step is taken up in the same frame.
///
/// Because it is re-evaluated each frame, a step that is already in progress (its
/// still false) is abandoned if its skip condition becomes true.
///
public Func Skip { get; }
/// Child-job factory for a composite step; null for a plain step.
public Func SubJob { get; }
/// Invoked once when this step's child job finishes, before .
public Action OnSubJobComplete { get; }
public JobStep(string name, Action run, Func canLeave = null, Func skip = null)
{
Name = string.IsNullOrEmpty(name) ? "step" : name;
Run = run;
CanLeave = canLeave;
Skip = skip;
}
private JobStep(string name, Func subJob, Action onSubJobComplete,
Func canLeave, Func skip)
{
Name = string.IsNullOrEmpty(name) ? "sub-job" : name;
SubJob = subJob;
OnSubJobComplete = onSubJobComplete;
CanLeave = canLeave;
Skip = skip;
}
///
/// A composite step: runs — itself a full
/// with its own steps, which may nest further — to completion, then invokes
/// once and evaluates .
///
/// The child's outcome is available to the parent as
/// /
/// from onwards; both survive a domain reload. A non-null
/// that is still false after the child finishes keeps the
/// parent on this step (the child is not restarted).
///
/// passes over the whole step — child included — for free, in the
/// same frame. Alternatively may return null to skip only the
/// child while still honouring ; use
/// when the step should cost nothing, and the null factory when the parent still wants to
/// wait on this step after deciding against the child. Either way
/// does not fire.
///
public static JobStep Nested(string name, Func subJob, Action onComplete = null,
Func canLeave = null, Func skip = null)
{
if (subJob == null) throw new ArgumentNullException(nameof(subJob));
return new JobStep(name, subJob, onComplete, canLeave, skip);
}
}
///
/// A multi-step job that survives domain reloads. Subclass it, declare the steps in
/// , and keep every piece of state the job needs across a reload in
/// public (or [JsonProperty]-attributed) fields — the runner serializes the instance to
/// SessionState and rebuilds it from that JSON in the new domain.
///
/// The subclass must have a parameterless constructor and must not depend on anything captured
/// at construction time that cannot be reconstructed, since only the serialized fields and the
/// current step index carry over.
///
/// Jobs compose: a step created with runs another StepJob to
/// completion before continuing, to any depth up to
/// . The whole nest is persisted, so a child job
/// keeps its own progress across a reload just as the parent does.
///
/// Report the outcome with or . Falling off the end of
/// the last step completes the job with a synthesized success response.
///
public abstract class StepJob
{
[JsonIgnore] private JobStep[] _steps;
/// The job's steps, built lazily and rebuilt after each domain reload.
[JsonIgnore]
public JobStep[] Steps => _steps ?? (_steps = BuildSteps() ?? Array.Empty());
/// Declares the ordered steps. Called once per domain, after deserialization.
protected abstract JobStep[] BuildSteps();
///
/// Hook invoked after the job has been restored from SessionState in a new domain, before
/// its next step runs. Use it to re-acquire anything that could not be serialized.
///
public virtual void OnRestored() { }
// Outcome is kept as serialized JSON (not an object graph) so it survives the reload
// round-trip unchanged, exactly as it will be handed to the client.
[JsonProperty("__outcomeJson")] private string _outcomeJson;
[JsonProperty("__failed")] private bool _failed;
[JsonProperty("__finished")] private bool _finished;
[JsonProperty("__subJobResult")] private string _subJobResponseJson;
[JsonIgnore] private JObject _subJobResponseCache;
/// True once or has been called.
[JsonIgnore] internal bool IsFinished => _finished;
/// True when the job ended via .
[JsonIgnore] public bool HasFailed => _failed;
///
/// Response of the most recently completed nested job, or null if none has run. Set by the
/// runner just before a composite step's onComplete hook, and persisted, so it is
/// still readable after a domain reload that lands mid-step.
///
[JsonIgnore]
protected JObject LastSubJobResponse
{
get
{
if (_subJobResponseCache != null) return _subJobResponseCache;
if (string.IsNullOrEmpty(_subJobResponseJson)) return null;
try { _subJobResponseCache = JObject.Parse(_subJobResponseJson); }
catch { _subJobResponseCache = null; }
return _subJobResponseCache;
}
}
/// True when the last nested job reported success. False when none has run.
[JsonIgnore]
protected bool LastSubJobSucceeded => LastSubJobResponse?["success"]?.ToObject() == true;
/// Raw response JSON of the last nested job, for forwarding verbatim.
[JsonIgnore]
protected internal string LastSubJobResponseJson => _subJobResponseJson;
/// Runner hook: records a finished child job's response on its parent.
internal void SetSubJobResponse(string responseJson)
{
_subJobResponseJson = responseJson;
_subJobResponseCache = null;
}
///
/// Ends the job successfully. Pass a payload, or null to let the
/// runner synthesize a success response from the job name.
///
public void Complete(object response = null)
{
_failed = false;
_outcomeJson = Serialize(response);
_finished = true;
}
///
/// Ends the job with an error. Pass a message (wrapped in ), a
/// ready-made payload via , or null to let the
/// runner synthesize an error response from the job name.
///
public void Fail(string message = null)
=> FailWith(message == null ? null : Response.Error(message));
/// Ends the job with an already-built error response payload.
public void FailWith(object response)
{
_failed = true;
_outcomeJson = Serialize(response);
_finished = true;
}
/// Ends the job with an already-serialized response, kept verbatim.
public void CompleteWithJson(string responseJson, bool failed = false)
{
_failed = failed;
_outcomeJson = responseJson;
_finished = true;
}
/// Builds the response JSON the runner delivers to the client.
internal string ToResponseJson(string name, string jobId)
{
if (!string.IsNullOrEmpty(_outcomeJson)) return _outcomeJson;
var jobData = new { job = name, id = jobId };
object response = _failed
? Response.Error(name ?? "Operation failed.", jobData)
: Response.Success(name ?? "Operation completed.", jobData);
return JsonConvert.SerializeObject(response);
}
private static string Serialize(object response)
=> response == null ? null : JsonConvert.SerializeObject(response);
}
///
/// The "has it started yet?" window used by steps that request something asynchronous from the
/// editor — a compile, a lighting bake — and must not conclude "it never started" while the
/// editor is still busy with the asset refresh the work comes out of.
///
/// The deadline is kept out of the way for as long as the editor reports
/// isUpdating: every frame spent refreshing pushes it forward again, so the window only
/// ever counts down against an editor that is idle and still not doing the work. Merely
/// suppressing the leave condition during the refresh is not enough — the clock would run out
/// underneath it, and the first idle frame after a long refresh would leave the step before the
/// compile it was waiting for had a chance to start.
///
/// The deadline is held in a serialized field on the job so it survives a domain reload; these
/// two methods are the whole rule and are static so they can be tested without an editor.
///
public static class SettleWindow
{
///
/// The deadline to store this frame: plus
/// on the first frame and for as long as the editor is updating,
/// otherwise the deadline already set.
///
public static double Advance(double until, double now, double seconds, bool isUpdating)
=> until <= 0 || isUpdating ? now + seconds : until;
/// True when the window has run out on an editor that is not updating.
public static bool Expired(double until, double now, bool isUpdating)
=> !isUpdating && until > 0 && now >= until;
}
}