Editor/SoloTestRunner.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using Sandbox.Services;

namespace TeamCreate;

/// <summary>
/// The one-click solo test. It runs only inside a disposable copy of the project (see <see cref="SoloCopy"/>), where one person with one Steam account can
/// host a session and have a scripted collaborator join it over the real transport. The steps exercise the real host session and this editor's real scene:
/// the host admits the collaborator through its normal checks, the collaborator's edits arrive in this editor, this editor's edits reach the collaborator,
/// per-user undo, refused stale edits, play pause, saving, and the relay when one is configured.
///
/// Everything runs from the frame tick. Nothing here starts a thread or a timer, because hotloaded editor code cannot own one; each step is a begin action and a
/// poll that is called once per frame until it settles or times out.
/// </summary>
public static class SoloTestRunner
{
    private enum Phase
    {
        Idle,
        Steps,
        Demo,
        Stopping,
        Done
    }

    private sealed class Step
    {
        public SoloStep View;

        public double TimeoutSeconds = 20;

        public bool Critical;

        public Action Begin;

        /// <summary>Runs once when the step settles, whatever the outcome, so a step that timed out cannot leave a collaborator holding the test identity.</summary>
        public Action Cleanup;

        /// <summary>Returns the step's state: Running keeps waiting, anything else settles it.</summary>
        public Func<(SoloStepState State, string Detail)> Poll;
    }

    public const string ReportFile = "solo-report.json";

    private const double OrbitRadius = 230;

    private static readonly (double X, double Y, double Z) OrbitCenter = (0, 0, 90);

    private static readonly List<Step> steps = new List<Step>();

    private static readonly List<SoloStep> views = new List<SoloStep>();

    private static Phase phase = Phase.Idle;

    private static int index;

    private static DateTime stepStarted;

    private static DateTime startedAt;

    private static DateTime nextPresence;

    private static DateTime nextDemoEdit;

    private static DateTime processStarted = DateTime.UtcNow;

    private static bool autorunHandled;

    private static string runToken;

    private static string scenePath;

    private static string headline = "Not run yet.";

    private static string handshakeProject;

    private static InviteData invite;

    private static byte[] sessionKey;

    private static SoloBot bot;

    private static SoloBot relayBot;

    private static SoloBot demoBot;

    private static readonly List<SoloBot> impostors = new List<SoloBot>();

    private static Task<string> tokenTask;

    private static bool hostStarted;

    private static ObjectState cube;

    private static ObjectState stopObject;

    private static DateTime stopBegan;

    private static bool stopWasDemo;

    private static string stopState;

    private static string stopHeadline;

    private static ObjectState demoCube;

    private static double demoAngle;

    public static IReadOnlyList<SoloStep> Steps => views;

    public static string Headline => headline;

    public static bool Running => phase == Phase.Steps;

    public static bool Watching => phase == Phase.Demo;

    public static bool Finished => phase == Phase.Done;

    public static bool Active => phase != Phase.Idle && phase != Phase.Done;

    public static string Root
    {
        get
        {
            try
            {
                return SceneAdapter.Root;
            }
            catch (Exception)
            {
                return null;
            }
        }
    }

    /// <summary>True only inside a folder the launcher made (right path and marker file); the same test the session applies.</summary>
    public static bool IsSoloCopy => TestHarnessRules.IsDisposableCopy(Root, SoloCopy.HasMarker(Root));

    private static CollaborationSession Collab => CollaborationSession.Current;

    [EditorEvent.Frame]
    public static void Tick()
    {
        try
        {
            if (Project.Current == null)
            {
                return;
            }
            if (IsSoloCopy)
            {
                SoloTestLauncher.Beat(Root);
            }
            if (!autorunHandled && (DateTime.UtcNow - processStarted).TotalSeconds > 10)
            {
                autorunHandled = true;
                if (IsSoloCopy && SoloCopy.WantsAutorun(Root))
                {
                    SoloCopy.ConsumeAutorun(Root);
                    Start();
                }
            }
            if (IsSoloCopy)
            {
                PollCommand();
            }
            if (phase == Phase.Idle || phase == Phase.Done)
            {
                return;
            }
            bot?.Pump();
            relayBot?.Pump();
            demoBot?.Pump();
            foreach (SoloBot impostor in impostors)
            {
                impostor.Pump();
            }
            DriveOrbit();
            if (phase == Phase.Steps)
            {
                RunCurrentStep();
            }
            else if (phase == Phase.Demo)
            {
                RunDemo();
            }
            else if (phase == Phase.Stopping)
            {
                PollStopping();
            }
        }
        catch (Exception ex)
        {
            Abort("The solo test stopped on an unexpected error: " + ex.Message);
        }
    }

    public const string CommandFile = "solo-command.txt";

    private static DateTime nextCommandCheck;

    /// <summary>
    /// Lets Tools/Run-SoloTest.ps1 -Stop end the test from outside the window. A one-word file, read and deleted here; it is honoured only inside a
    /// disposable copy, which is the only place this code runs at all.
    /// </summary>
    private static void PollCommand()
    {
        if (DateTime.UtcNow < nextCommandCheck)
        {
            return;
        }
        nextCommandCheck = DateTime.UtcNow.AddSeconds(1);
        try
        {
            string path = Path.Combine(Root, ".collaboration", CommandFile);
            if (!File.Exists(path))
            {
                return;
            }
            string word = File.ReadAllText(path).Trim().ToLowerInvariant();
            File.Delete(path);
            if (word == "stop")
            {
                Stop();
            }
            else if (word == "start")
            {
                Start();
            }
        }
        catch (Exception)
        {
        }
    }

    public static void Start()
    {
        if (!IsSoloCopy)
        {
            throw new InvalidOperationException("The solo test only runs inside a disposable solo test copy. Use Run solo test in the Collaboration dock to make one.");
        }
        if (Active)
        {
            return;
        }
        CleanupParticipants();
        OpenDock();
        BuildSteps();
        index = 0;
        startedAt = DateTime.UtcNow;
        phase = Phase.Steps;
        BeginStep();
        headline = "Running…";
        WriteReport("running");
    }

    /// <summary>
    /// Ends the test in two phases. First the collaborators go and the test's object is removed; the session stays up until the host has been told, because
    /// leaving a session checkpoints the scene and would save the object into the file. Then the session ends. Safe to call at any time.
    /// </summary>
    public static void Stop()
    {
        if (phase == Phase.Idle || phase == Phase.Done || phase == Phase.Stopping)
        {
            return;
        }
        stopWasDemo = phase == Phase.Demo;
        stopObject = demoCube ?? cube;
        try
        {
            RemoveTestObject(stopObject);
        }
        catch (Exception)
        {
        }
        CleanupParticipants();
        stopBegan = DateTime.UtcNow;
        phase = Phase.Stopping;
    }

    private static void PollStopping()
    {
        bool present = false;
        try
        {
            present = stopObject != null && Find(stopObject) != null;
        }
        catch (Exception)
        {
        }
        bool pending = false;
        try
        {
            pending = Collab.Connected && !Collab.CanSave();
        }
        catch (Exception)
        {
            pending = false;
        }
        if (SoloScript.StopDecision(present, pending, Collab.Connected, (DateTime.UtcNow - stopBegan).TotalSeconds) == SoloStopDecision.Proceed)
        {
            FinishStop();
        }
    }

    private static void FinishStop()
    {
        try
        {
            Collab.Leave();
        }
        catch (Exception)
        {
        }
        CollaborationSession.SoloGuestVoucher = null;
        foreach (Step step in steps)
        {
            if (step.View.State == SoloStepState.Running || step.View.State == SoloStepState.Waiting)
            {
                step.View.State = SoloStepState.Skipped;
                step.View.Detail = "Stopped before this ran.";
            }
        }
        phase = Phase.Done;
        headline = stopHeadline ?? SoloReport.Summary(views, true) + (stopWasDemo ? " Session ended." : string.Empty);
        WriteReport(stopState ?? (stopWasDemo ? "stopped" : "aborted"));
        stopState = null;
        stopHeadline = null;
    }

    private static void Abort(string message)
    {
        if (index < steps.Count && steps[index].View.State == SoloStepState.Running)
        {
            steps[index].View.State = SoloStepState.Failed;
            steps[index].View.Detail = message;
        }
        stopState = "aborted";
        stopHeadline = message;
        Stop();
    }

    private static void CleanupParticipants()
    {
        bot?.Leave();
        relayBot?.Leave();
        demoBot?.Leave();
        foreach (SoloBot impostor in impostors)
        {
            impostor.Dispose();
        }
        impostors.Clear();
        bot = null;
        relayBot = null;
        demoBot = null;
    }

    // ---- stepping ------------------------------------------------------------------------------------------------------

    private static void RunCurrentStep()
    {
        if (index >= steps.Count)
        {
            FinishChecks();
            return;
        }
        Step step = steps[index];
        (SoloStepState state, string detail) = step.Poll();
        if (state == SoloStepState.Running)
        {
            if ((DateTime.UtcNow - stepStarted).TotalSeconds > step.TimeoutSeconds)
            {
                step.View.State = SoloStepState.Failed;
                step.View.Detail = "Timed out after " + step.TimeoutSeconds.ToString("0", CultureInfo.InvariantCulture) + " seconds. " + (string.IsNullOrEmpty(step.View.Detail) ? string.Empty : step.View.Detail);
                Settled(step);
            }
            else if (!string.IsNullOrEmpty(detail))
            {
                step.View.Detail = detail;
            }
            return;
        }
        step.View.State = state;
        step.View.Detail = detail;
        Settled(step);
    }

    private static void Settled(Step step)
    {
        try
        {
            step.Cleanup?.Invoke();
        }
        catch (Exception)
        {
        }
        if (step.View.State == SoloStepState.Failed && step.Critical)
        {
            for (int i = index + 1; i < steps.Count; i++)
            {
                steps[i].View.State = SoloStepState.Skipped;
                steps[i].View.Detail = "Not run because \"" + step.View.Title + "\" failed.";
            }
            index = steps.Count;
            FinishChecks();
            return;
        }
        index++;
        if (index < steps.Count)
        {
            BeginStep();
        }
        WriteReport("running");
    }

    private static void BeginStep()
    {
        Step step = steps[index];
        if (step.View.Id == "demo" && !SoloReport.DemoAllowed(views))
        {
            step.View.State = SoloStepState.Skipped;
            step.View.Detail = "Not started because a check failed.";
            index++;
            if (index < steps.Count)
            {
                BeginStep();
            }
            return;
        }
        step.View.State = SoloStepState.Running;
        step.View.Detail = null;
        stepStarted = DateTime.UtcNow;
        try
        {
            step.Begin?.Invoke();
        }
        catch (Exception ex)
        {
            step.View.State = SoloStepState.Failed;
            step.View.Detail = ex.Message;
            Settled(step);
        }
    }

    private static void FinishChecks()
    {
        bool ok = SoloReport.AllPassed(views.Where((SoloStep s) => s.State != SoloStepState.Skipped).ToList()) && !views.Any((SoloStep s) => s.State == SoloStepState.Failed);
        headline = SoloReport.Summary(views, true);
        if (!ok)
        {
            stopState = "checks-failed";
            stopHeadline = headline;
            phase = Phase.Steps;
            Stop();
            return;
        }
        phase = Phase.Demo;
        headline += " The collaborator is now orbiting your scene. Press Stop to end.";
        WriteReport("checks-passed");
    }

    private static void RemoveTestObject(ObjectState state)
    {
        if (state == null)
        {
            return;
        }
        GameObject go = Find(state);
        if (go != null && go.IsValid())
        {
            go.Destroy();
        }
    }

    // ---- helpers -------------------------------------------------------------------------------------------------------

    private static SceneEditorSession SceneSession()
    {
        return SceneEditorSession.All.FirstOrDefault((SceneEditorSession x) => !(x is GameEditorSession) && SceneAdapter.PathOf(x) == scenePath);
    }

    private static GameObject Find(ObjectState state)
    {
        SceneEditorSession session = SceneSession();
        if (session == null || state == null)
        {
            return null;
        }
        return SceneAdapter.Walk(session.Scene).FirstOrDefault((GameObject x) => x.IsValid() && string.Equals(x.Id.ToString(), state.Id, StringComparison.Ordinal));
    }

    private static Vector3 ParseVector(string text)
    {
        float[] n = text.Split(',').Select((string x) => float.Parse(x, CultureInfo.InvariantCulture)).ToArray();
        return new Vector3(n[0], n[1], n[2]);
    }

    private static bool Near(Vector3 a, Vector3 b)
    {
        return (a - b).Length < 0.5f;
    }

    private static string PositionOf(SoloBot who)
    {
        return who != null && cube != null && who.Known(scenePath).TryGetValue(cube.Id, out ObjectState s) ? s.Data["Position"]?.GetValue<string>() : null;
    }

    private static SoloHandshake Handshake(string steamId, string name, string token)
    {
        (string ProjectId, string Baseline, string Manifest, string OrgIdent, string LibraryVersion) facts = Collab.HandshakeFacts;
        return new SoloHandshake
        {
            Project = facts.ProjectId,
            Baseline = facts.Baseline,
            Manifest = facts.Manifest,
            Org = facts.OrgIdent,
            LibraryVersion = facts.LibraryVersion,
            SteamId = steamId,
            SteamToken = token,
            Name = name,
            SessionToken = Convert.ToHexString(sessionKey)
        };
    }

    private static SoloBot NewDirectBot(string steamId, string name, string token)
    {
        SoloBot made = new SoloBot(Handshake(steamId, name, token));
        int port = invite.Port;
        byte[] key = sessionKey;
        made.Connect(() => Wire.Connect("127.0.0.1", port, key, true));
        return made;
    }

    private static SoloBot NewRelayBot()
    {
        SoloBot made = new SoloBot(Handshake(TestHarnessRules.SoloGuestSteamId, TestHarnessRules.SoloGuestName, runToken));
        string url = invite.Relay;
        byte[] secret = invite.Secret;
        byte[] key = sessionKey;
        made.Connect(() => RelayGuest.ConnectAsync(() => new SandboxRelaySocket(), url, secret, key));
        return made;
    }

    private static (double X, double Y, double Z) OrbitPoint(double seconds)
    {
        return SoloScript.Orbit(OrbitCenter, OrbitRadius, seconds * 0.55, 28);
    }

    /// <summary>The collaborator's marker circles the scene and looks at its middle, so the sphere is easy to find and to watch.</summary>
    private static void DriveOrbit()
    {
        SoloBot who = demoBot ?? bot ?? relayBot;
        if (who == null || !who.IsLive || scenePath == null || DateTime.UtcNow < nextPresence)
        {
            return;
        }
        nextPresence = DateTime.UtcNow.AddMilliseconds(100);
        double t = (DateTime.UtcNow - startedAt).TotalSeconds;
        (double X, double Y, double Z) p = OrbitPoint(t);
        who.SendPresence(scenePath, SoloScript.Presence(p, SoloScript.LookAt(p, OrbitCenter), phase == Phase.Demo ? "editing" : "editing"));
    }

    private static void RunDemo()
    {
        if (demoBot == null || !demoBot.IsLive || demoCube == null || DateTime.UtcNow < nextDemoEdit)
        {
            return;
        }
        nextDemoEdit = DateTime.UtcNow.AddMilliseconds(400);
        demoAngle += 0.16;
        (double X, double Y, double Z) c = SoloScript.Orbit((OrbitCenter.X, OrbitCenter.Y, 45), 90, demoAngle, 14);
        ObjectState next = SoloScript.WithTint(SoloScript.WithPosition(demoCube, SoloScript.SnapVec(c.X, c.Y, c.Z)), SoloScript.TintAt(demoAngle / 6.0));
        if (demoBot.PendingCount == 0)
        {
            demoBot.SendEdit(scenePath, new[] { SoloScript.Update(demoCube, next) });
            demoCube = next;
        }
    }

    /// <summary>
    /// The checklist lives in the Collaboration dock, and a fresh copy starts with the default layout, where that dock is closed. A test that
    /// starts by itself and shows its progress somewhere nobody is looking would appear to do nothing.
    /// </summary>
    private static void OpenDock()
    {
        try
        {
            Sandbox.Internal.GlobalToolsNamespace.EditorWindow.DockManager.OpenDock("Collaboration", DockArea.Right);
        }
        catch (Exception)
        {
        }
    }

    private static void PlaceCamera()
    {
        try
        {
            SceneViewportWidget widget = SceneViewWidget.Current?.LastSelectedViewportWidget;
            if (widget == null)
            {
                return;
            }
            Vector3 target = new Vector3((float)OrbitCenter.X, (float)OrbitCenter.Y, (float)OrbitCenter.Z - 20f);
            Vector3 from = new Vector3(-520f, -520f, 300f);
            widget.State.CameraPosition = from;
            widget.State.CameraRotation = Rotation.LookAt((target - from).Normal);
        }
        catch (Exception)
        {
        }
    }

    // ---- the script ----------------------------------------------------------------------------------------------------

    private static void Add(string id, string title, double timeout, bool critical, Action begin, Func<(SoloStepState, string)> poll, Action cleanup = null)
    {
        SoloStep view = new SoloStep { Id = id, Title = title };
        views.Add(view);
        steps.Add(new Step { View = view, TimeoutSeconds = timeout, Critical = critical, Begin = begin, Poll = poll, Cleanup = cleanup });
    }

    private static (SoloStepState, string) Wait(string detail = null)
    {
        return (SoloStepState.Running, detail);
    }

    private static (SoloStepState, string) Pass(string detail)
    {
        return (SoloStepState.Passed, detail);
    }

    private static (SoloStepState, string) Fail(string detail)
    {
        return (SoloStepState.Failed, detail);
    }

    private static void BuildSteps()
    {
        steps.Clear();
        views.Clear();
        cube = null;
        demoCube = null;
        demoAngle = 0;
        hostStarted = false;
        tokenTask = null;
        scenePath = null;
        invite = null;

        Add("scene", "A saved scene is open and fully loaded", 60, true, () =>
        {
            foreach (SceneEditorSession open in SceneEditorSession.All.Where((SceneEditorSession x) => !(x is GameEditorSession)).ToArray())
            {
                if (open.HasUnsavedChanges)
                {
                    // A disposable copy: unsaved edits here are leftovers, and a session cannot start over them.
                    open.Reload();
                    open.HasUnsavedChanges = false;
                }
            }
            string active = SceneAdapter.PathOf(SceneEditorSession.Active);
            if (active == null || !active.StartsWith("scenes/", StringComparison.Ordinal))
            {
                string folder = Path.Combine(SceneAdapter.Root, "Assets", "scenes");
                string first = Directory.Exists(folder) ? Directory.EnumerateFiles(folder, "*.scene").OrderBy((string x) => x, StringComparer.OrdinalIgnoreCase).FirstOrDefault() : null;
                if (first == null)
                {
                    throw new InvalidOperationException("The copy has no scene under Assets/scenes. Save one in the project first.");
                }
                SceneEditorSession.CreateFromPath("scenes/" + Path.GetFileName(first))?.MakeActive();
            }
        }, () =>
        {
            string active = SceneAdapter.PathOf(SceneEditorSession.Active);
            if (active == null || !active.StartsWith("scenes/", StringComparison.Ordinal) || SceneEditorSession.Active is GameEditorSession)
            {
                return Wait("Opening a scene…");
            }
            scenePath = active;
            // The editor may still be restoring the scene when this runs. Starting then makes the host publish a partial scene, and the save step
            // would write it back over the copy's file. Wait until the editor holds at least as many top-level objects as the file does.
            int? expected = SoloScript.TopLevelObjectCount(Path.Combine(Root, "Assets", active.Replace('/', Path.DirectorySeparatorChar)));
            SceneEditorSession session = SceneSession();
            int loaded = session?.Scene != null && session.Scene.IsValid() ? session.Scene.Children.Count((GameObject x) => x.IsValid()) : 0;
            SoloSceneLoad state = SoloScript.SceneLoadState(expected, loaded);
            string detail = SoloScript.SceneLoadDetail(active, expected, loaded);
            if (state == SoloSceneLoad.Loading)
            {
                return Wait(detail);
            }
            PlaceCamera();
            return Pass(detail);
        });

        Add("host", "Host session starts", 150, true, () =>
        {
            runToken = TestHarnessRules.NewRunToken();
            string token = runToken;
            CollaborationSession.SoloGuestVoucher = (Packet hello) => TestHarnessRules.MayVouchFor(hello.SteamId, hello.SteamToken, token);
            if (Collab.Busy)
            {
                Collab.Leave();
            }
            tokenTask = Auth.GetToken(LocalSteamIdentity.ServiceName);
        }, () =>
        {
            if (!hostStarted)
            {
                // The readiness checks answer asynchronously after the editor loads; hosting before they have is refused for a reason that is gone a
                // moment later. Wait for them, and only call a row blocked once the grace period has passed.
                SoloHostReadiness readiness = SoloScript.HostReadiness(Collab.Readiness, (DateTime.UtcNow - stepStarted).TotalSeconds);
                if (readiness == SoloHostReadiness.Waiting)
                {
                    return Wait("Waiting for the readiness checks (Steam, publication, organization)…");
                }
                if (readiness == SoloHostReadiness.Blocked)
                {
                    return Fail(ReadinessRules.BlockReason(Collab.Readiness) ?? "The readiness checklist never completed. Open the Collaboration dock and check the Readiness card.");
                }
                if (!tokenTask.IsCompleted)
                {
                    return Wait("Waiting for a fresh Steam identity token…");
                }
                if (tokenTask.IsFaulted || tokenTask.IsCanceled || string.IsNullOrWhiteSpace(tokenTask.Result))
                {
                    return Fail("A Steam identity token could not be obtained. Sign in to Steam and try again.");
                }
                if (!LocalSteamIdentity.TryGet(out SteamIdentity identity, out string persona, out string failure))
                {
                    return Fail(failure ?? "No signed-in Steam account is available.");
                }
                Collab.StartHosted(27845, persona, SteamIdentityRules.Canonical(identity), tokenTask.Result, allowUnverifiedPeers: false);
                hostStarted = true;
                return Wait("Starting the host…");
            }
            if (Collab.Error != null)
            {
                return Fail(Collab.Error);
            }
            if (!Collab.SoloHarness)
            {
                return Fail("The test identity is not active in this folder, so a collaborator could not be admitted.");
            }
            if (Collab.Connected && Collab.IsHost && Collab.InviteCode != null)
            {
                return Pass("Hosting with the signed-in Steam account.");
            }
            return Wait(Collab.Status);
        });

        Add("live", "The host's scene is live", 25, true, null, () =>
        {
            if (Collab.Error != null)
            {
                return Fail(Collab.Error);
            }
            return Collab.ReadyScenes.Contains(scenePath) ? Pass(scenePath + " is synchronized.") : Wait("Waiting for the host to publish the scene…");
        });

        Add("relay", "Relay is registered", 20, false, null, () =>
        {
            string status = Collab.RelayStatus;
            if (status == null)
            {
                return (SoloStepState.Skipped, "No relay is configured for this project (RelayUrl), so only direct routes are tested.");
            }
            if (Collab.RelayFailed)
            {
                return Fail(status);
            }
            return status.StartsWith("Relay connected", StringComparison.Ordinal) ? Pass("The relay accepted this session's room.") : Wait(status);
        });

        Add("join", "A collaborator joins and passes the host's checks", 30, true, () =>
        {
            (string ProjectId, string Baseline, string Manifest, string OrgIdent, string LibraryVersion) facts = Collab.HandshakeFacts;
            handshakeProject = facts.ProjectId;
            if (!InviteCode.TryDecode(Collab.InviteCode, facts.ProjectId, out invite, out string error))
            {
                throw new InvalidOperationException("The host's own invite code did not decode: " + error);
            }
            sessionKey = InviteCode.DeriveKey(invite.Secret, facts.ProjectId);
            bot = NewDirectBot(TestHarnessRules.SoloGuestSteamId, TestHarnessRules.SoloGuestName, runToken);
        }, () =>
        {
            if (bot.State == SoloBotState.Refused || bot.State == SoloBotState.Failed)
            {
                return Fail("The host refused the collaborator: " + bot.Refusal);
            }
            if (bot.IsLive)
            {
                bot.Subscribe(scenePath);
                return Pass("Admitted as \"" + TestHarnessRules.SoloGuestName + "\" after the host's normal project, baseline, library and manifest checks.");
            }
            return Wait("Connecting…");
        });

        Add("marker", "Its floating marker appears in your scene view", 20, false, null, () =>
        {
            bool inRoster = Collab.Roster.Values.Any((Packet x) => x.SteamId == TestHarnessRules.SoloGuestSteamId && x.Presence != null);
            if (!inRoster)
            {
                return Wait("Waiting for the collaborator's presence…");
            }
            return Collab.DrawnMarkers > 0 ? Pass("The blue sphere is drawn in the scene view, orbiting the middle of the scene.") : Wait("The collaborator is in the roster; waiting for the scene view to draw its marker. Open a scene view if none is visible.");
        });

        Add("create", "Its new object appears in your scene", 20, true, () =>
        {
            cube = SoloScript.NewCube(Guid.NewGuid(), Guid.NewGuid(), SoloScript.SnapVec(OrbitCenter.X, OrbitCenter.Y, 45), SoloScript.Color(1.0, 0.34375, 0.25));
            bot.SendEdit(scenePath, new[] { SoloScript.Create(cube) });
        }, () => Find(cube) != null ? Pass("\"" + SoloScript.ObjectName + "\" appeared in this editor.") : Wait());

        Vector3 movedTo = new Vector3(70, -50, 80);
        Add("move", "Its move appears in your scene as it happens", 20, true, () =>
        {
            ObjectState next = SoloScript.WithPosition(cube, SoloScript.SnapVec(movedTo.x, movedTo.y, movedTo.z));
            bot.SendEdit(scenePath, new[] { SoloScript.Update(cube, next) });
            cube = next;
        }, () =>
        {
            GameObject go = Find(cube);
            return go != null && Near(go.WorldPosition, movedTo) ? Pass("Position changed to " + go.WorldPosition + ".") : Wait();
        });

        string tintText = SoloScript.TintAt(0.62);
        Add("property", "Its property change appears in your scene", 20, true, () =>
        {
            ObjectState next = SoloScript.WithTint(cube, tintText);
            bot.SendEdit(scenePath, new[] { SoloScript.Update(cube, next) });
            cube = next;
        }, () =>
        {
            GameObject go = Find(cube);
            ModelRenderer renderer = go?.Components.Get<ModelRenderer>();
            if (renderer == null)
            {
                return Wait();
            }
            float[] want = tintText.Split(',').Select((string x) => float.Parse(x, CultureInfo.InvariantCulture)).ToArray();
            Color have = renderer.Tint;
            return Math.Abs(have.r - want[0]) < 0.02f && Math.Abs(have.g - want[1]) < 0.02f && Math.Abs(have.b - want[2]) < 0.02f ? Pass("The renderer tint changed.") : Wait();
        });

        Vector3 hostMoved = new Vector3(-90, 60, 100);
        Add("reverse", "Your move reaches the collaborator as it happens", 20, true, () =>
        {
            GameObject go = Find(cube);
            if (go == null)
            {
                throw new InvalidOperationException("The test object is not in this scene.");
            }
            go.LocalPosition = hostMoved;
        }, () =>
        {
            string seen = PositionOf(bot);
            return seen != null && Near(ParseVector(seen), hostMoved) ? Pass("The collaborator saw the position change to " + seen + ".") : Wait();
        });

        bool undone = false;
        Add("undo", "Undo reverses only your own move", 25, false, () => undone = false, () =>
        {
            if (!undone)
            {
                try
                {
                    Collab.Undo();
                    undone = true;
                }
                catch (InvalidOperationException)
                {
                    return Wait("Waiting for your move to be acknowledged…");
                }
            }
            string seen = PositionOf(bot);
            bool botsTintKept = bot.Known(scenePath).TryGetValue(cube.Id, out ObjectState s) && (s.Data["Components"] as JsonArray)?[0]?["Tint"]?.GetValue<string>() == tintText;
            return seen != null && Near(ParseVector(seen), movedTo) && botsTintKept ? Pass("Back to " + seen + "; the collaborator's own change to the object was not undone.") : Wait();
        });

        int rejectsBefore = 0;
        Add("conflict", "A stale edit is refused, not silently applied", 20, false, () =>
        {
            rejectsBefore = bot.Rejects.Count;
            // Built on a position the object left long ago, so it is out of date by construction.
            ObjectState old = SoloScript.WithPosition(cube, "1,1,1");
            bot.SendEdit(scenePath, new[] { SoloScript.Update(old, SoloScript.WithPosition(old, "5,5,5")) });
        }, () =>
        {
            if (bot.Rejects.Count <= rejectsBefore)
            {
                return Wait();
            }
            GameObject go = Find(cube);
            return go != null && Near(go.WorldPosition, movedTo) ? Pass("The host answered: " + Truncate(bot.Rejects.Last().Error, 110)) : Fail("The refused edit still changed the scene.");
        });

        Vector3 whilePlaying = new Vector3(10, 20, 110);
        int playPhase = 0;
        Add("play", "Playing pauses scene edits; changes wait until you stop", 60, false, () =>
        {
            playPhase = 0;
            EditorScene.Play();
        }, () =>
        {
            if (Collab.Error != null)
            {
                return Fail(Collab.Error);
            }
            if (playPhase == 0)
            {
                if (Collab.PlayDecision != PlaySpectateDecision.PausedForPlay)
                {
                    return Wait("Starting the game…");
                }
                ObjectState next = SoloScript.WithPosition(cube, SoloScript.SnapVec(whilePlaying.x, whilePlaying.y, whilePlaying.z));
                bot.SendEdit(scenePath, new[] { SoloScript.Update(cube, next) });
                cube = next;
                playPhase = 1;
                return Wait("Playing; the collaborator changes the object…");
            }
            if (playPhase == 1)
            {
                GameObject go = Find(cube);
                if (Collab.HeldSceneChanges < 1)
                {
                    return Wait("Waiting for the collaborator's change to reach this editor…");
                }
                if (go != null && Near(go.WorldPosition, whilePlaying))
                {
                    return Fail("The change was applied while playing instead of being held.");
                }
                EditorScene.Stop();
                playPhase = 2;
                return Wait("Held " + Collab.HeldSceneChanges + " change(s); stopping the game…");
            }
            GameObject after = Find(cube);
            return Collab.PlayDecision == PlaySpectateDecision.Normal && after != null && Near(after.WorldPosition, whilePlaying) ? Pass("The change made while you were playing applied when you stopped.") : Wait("Applying held changes…");
        });

        Add("delete", "Its delete appears in your scene", 20, false, () =>
        {
            bot.SendEdit(scenePath, new[] { SoloScript.Delete(cube) });
        }, () => Find(cube) == null ? Pass("The object is gone from this editor.") : Wait());

        DateTime saveAt = default;
        Add("save", "Saving works while a collaborator is connected", 30, false, () => saveAt = default, () =>
        {
            if (Collab.Error != null)
            {
                return Fail(Collab.Error);
            }
            if (saveAt == default)
            {
                if (!Collab.CanSave())
                {
                    return Wait("Waiting to save: " + (Collab.WhyCannotSave() ?? "a check is still settling") + ".");
                }
                Collab.Save();
                saveAt = DateTime.UtcNow;
                return Wait("Saving…");
            }
            if ((DateTime.UtcNow - saveAt).TotalSeconds < 1.5)
            {
                return Wait("Saving…");
            }
            return Collab.Error == null && bot.IsLive ? Pass("Saved the synchronized scene; the collaborator stayed connected.") : Fail(Collab.Error ?? "The collaborator was dropped by the save.");
        });

        Add("leave", "The collaborator leaves cleanly", 15, false, () => bot.Leave(), () =>
        {
            bool gone = !Collab.Roster.Values.Any((Packet x) => x.SteamId == TestHarnessRules.SoloGuestSteamId);
            if (gone)
            {
                bot = null;
            }
            return gone ? Pass("Its row and marker were removed at once.") : Wait();
        });

        int relayPhase = 0;
        Add("via-relay", "A collaborator joins and edits through the relay", 45, false, () =>
        {
            relayPhase = 0;
            if (Collab.RelayStatus == null || Collab.RelayFailed || string.IsNullOrEmpty(invite?.Relay))
            {
                return;
            }
            relayBot = NewRelayBot();
        }, () =>
        {
            if (relayBot == null)
            {
                return (SoloStepState.Skipped, "No working relay for this session, so this route was not tested.");
            }
            if (relayBot.State == SoloBotState.Refused || relayBot.State == SoloBotState.Failed)
            {
                return Fail("The relay route failed: " + relayBot.Refusal);
            }
            if (relayPhase == 0)
            {
                if (!relayBot.IsLive)
                {
                    return Wait("Dialling the relay…");
                }
                relayBot.Subscribe(scenePath);
                relayPhase = 1;
                return Wait("Admitted through the relay; subscribing…");
            }
            if (relayPhase == 1)
            {
                if (!relayBot.HasScene(scenePath))
                {
                    return Wait();
                }
                cube = SoloScript.NewCube(Guid.NewGuid(), Guid.NewGuid(), SoloScript.SnapVec(OrbitCenter.X, OrbitCenter.Y, 45), SoloScript.Color(0.3125, 0.59375, 1.0));
                relayBot.SendEdit(scenePath, new[] { SoloScript.Create(cube) });
                relayPhase = 2;
                return Wait("Sending an edit through the relay…");
            }
            if (relayPhase == 2)
            {
                if (Find(cube) == null)
                {
                    return Wait();
                }
                relayBot.SendEdit(scenePath, new[] { SoloScript.Delete(cube) });
                relayPhase = 3;
                return Wait("The object arrived; removing it…");
            }
            if (Find(cube) != null)
            {
                return Wait();
            }
            relayBot.Leave();
            relayBot = null;
            return Pass("Joined, subscribed, created and deleted an object, all through the relay.");
        }, () =>
        {
            relayBot?.Leave();
            relayBot = null;
        });

        int closedPhase = 0;
        Add("closed", "The test identity opens nothing else", 30, false, () => closedPhase = 0, () =>
        {
            if (closedPhase == 0)
            {
                // The earlier steps' collaborators say goodbye and the host relays it; the reserved account must be gone from the roster before this check
                // starts, or "already connected" would be the refusal and would prove nothing about the token.
                if (Collab.Roster.Values.Any((Packet x) => x.SteamId == TestHarnessRules.SoloGuestSteamId))
                {
                    return Wait("Waiting for the earlier collaborator to finish leaving…");
                }
                impostors.Add(NewDirectBot(TestHarnessRules.SoloGuestSteamId, "Wrong token", TestHarnessRules.NewRunToken()));
                impostors.Add(NewDirectBot("76561198000000002", "Other account", runToken));
                closedPhase = 1;
                return Wait("Trying a wrong token and a different account…");
            }
            if (impostors.Any((SoloBot x) => x.IsLive))
            {
                return Fail("A collaborator without the test token was admitted.");
            }
            if (impostors.Any((SoloBot x) => x.State == SoloBotState.Connecting || x.State == SoloBotState.AwaitingWelcome))
            {
                return Wait();
            }
            // Each must be refused BECAUSE its identity token was rejected. A refusal for any other reason (for example "already connected") would
            // pass a weaker check while proving nothing about the token.
            SoloBot wrong = impostors.FirstOrDefault();
            foreach (SoloBot x in impostors)
            {
                if (x.State != SoloBotState.Refused || x.Refusal == null || x.Refusal.IndexOf("rejected", StringComparison.OrdinalIgnoreCase) < 0)
                {
                    return Fail("Refused for the wrong reason, or not refused: " + x.State + " " + Truncate(x.Refusal, 100));
                }
            }
            return Pass("A wrong token and a different account were both refused as: " + Truncate(wrong?.Refusal, 90));
        }, () =>
        {
            foreach (SoloBot x in impostors)
            {
                x.Dispose();
            }
            impostors.Clear();
        });

        Add("demo", "Live demo: the collaborator moves an object in your scene", 45, false, () =>
        {
            // Pressing Play and Stop earlier resets the scene view, so frame the orbit again for the part meant to be watched.
            PlaceCamera();
            demoBot = NewDirectBot(TestHarnessRules.SoloGuestSteamId, TestHarnessRules.SoloGuestName, runToken);
        }, () =>
        {
            if (demoBot.State == SoloBotState.Refused || demoBot.State == SoloBotState.Failed)
            {
                return Fail("The demo collaborator could not join: " + demoBot.Refusal);
            }
            if (!demoBot.IsLive)
            {
                return Wait("Connecting…");
            }
            if (!demoBot.HasScene(scenePath))
            {
                demoBot.Subscribe(scenePath);
                return Wait("Subscribing…");
            }
            if (demoCube == null)
            {
                demoCube = SoloScript.NewCube(Guid.NewGuid(), Guid.NewGuid(), SoloScript.SnapVec(OrbitCenter.X, OrbitCenter.Y, 45), SoloScript.Color(1, 0.40625, 0.21875));
                cube = demoCube;
                demoBot.SendEdit(scenePath, new[] { SoloScript.Create(demoCube) });
                return Wait();
            }
            return Find(demoCube) != null ? Pass("Running. Watch the blue sphere and the cube; press Stop to end.") : Wait();
        });
    }

    private static string Truncate(string text, int max)
    {
        if (string.IsNullOrEmpty(text))
        {
            return string.Empty;
        }
        return text.Length <= max ? text : text.Substring(0, max - 1) + "…";
    }

    // ---- report --------------------------------------------------------------------------------------------------------

    private static void WriteReport(string state)
    {
        try
        {
            string path = Path.Combine(Root, ".collaboration", ReportFile);
            Directory.CreateDirectory(Path.GetDirectoryName(path));
            string json = JsonSerializer.Serialize(new
            {
                state,
                summary = SoloReport.Summary(views, state != "running"),
                headline,
                updatedUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
                steps = views.Select((SoloStep s) => new { id = s.Id, title = s.Title, state = s.State.ToString(), detail = s.Detail }).ToArray()
            }, new JsonSerializerOptions { WriteIndented = true });
            File.WriteAllText(path + ".tmp", json);
            File.Move(path + ".tmp", path, true);
        }
        catch (Exception)
        {
        }
    }

    /// <summary>The last written report, for the dock's "last run" line. Null when there is none.</summary>
    public static string LastRunSummary(string root)
    {
        try
        {
            string path = Path.Combine(root, ".collaboration", ReportFile);
            if (!File.Exists(path))
            {
                return null;
            }
            JsonNode node = JsonNode.Parse(File.ReadAllText(path));
            return node?["summary"]?.GetValue<string>();
        }
        catch (Exception)
        {
            return null;
        }
    }
}