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; } }