Editor/Core/SoloScript.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Nodes;
namespace TeamCreate;
public enum SoloStepState
{
Waiting,
Running,
Passed,
Failed,
Skipped
}
/// <summary>One line of the solo test's checklist, worded for the person watching the dock.</summary>
public sealed class SoloStep
{
public string Id;
public string Title;
public SoloStepState State = SoloStepState.Waiting;
public string Detail;
}
public enum SoloHostReadiness
{
Waiting,
Ready,
Blocked
}
public enum SoloStopDecision
{
Wait,
Proceed
}
public enum SoloSceneLoad
{
Unknown,
Loading,
Loaded
}
public static class SoloReport
{
public static string Symbol(SoloStepState state)
{
switch (state)
{
case SoloStepState.Passed:
return "✓";
case SoloStepState.Failed:
return "✗";
case SoloStepState.Running:
return "…";
case SoloStepState.Skipped:
return "–";
default:
return "·";
}
}
/// <summary>One sentence for the header of the card. A skipped step is never counted as a pass.</summary>
public static string Summary(IReadOnlyList<SoloStep> steps, bool finished)
{
if (steps == null || steps.Count == 0)
{
return "Not run yet.";
}
int passed = steps.Count((SoloStep s) => s.State == SoloStepState.Passed);
int failed = steps.Count((SoloStep s) => s.State == SoloStepState.Failed);
int skipped = steps.Count((SoloStep s) => s.State == SoloStepState.Skipped);
int open = steps.Count((SoloStep s) => s.State == SoloStepState.Waiting || s.State == SoloStepState.Running);
if (failed > 0)
{
return failed + " of " + steps.Count + " checks failed. " + passed + " passed" + (skipped > 0 ? ", " + skipped + " skipped" : string.Empty) + ".";
}
if (open > 0)
{
return finished ? passed + " of " + steps.Count + " checks passed; " + open + " did not run." : passed + " of " + steps.Count + " checks passed so far.";
}
return skipped > 0 ? passed + " checks passed, " + skipped + " skipped." : "All " + passed + " checks passed.";
}
/// <summary>
/// The live demo (the collaborator keeps moving an object) only starts when nothing has failed so far. A run that ends in failure tears the
/// session down, so a demo begun before that would announce "Running" and vanish a moment later.
/// </summary>
public static bool DemoAllowed(IReadOnlyList<SoloStep> steps)
{
return steps != null && !steps.Any((SoloStep s) => s.State == SoloStepState.Failed);
}
public static bool AllPassed(IReadOnlyList<SoloStep> steps)
{
return steps != null && steps.Count > 0 && steps.All((SoloStep s) => s.State == SoloStepState.Passed);
}
}
/// <summary>
/// The test collaborator's script, as data. A scripted collaborator joins the host session over the real transport, sends real presence,
/// and makes real edits to ONE object it creates itself, so it never touches the user's objects and, once it deletes that object, the scene
/// is exactly as it was. Everything here is engine-free so CoreTests can run the script against the real host authority.
/// </summary>
public static class SoloScript
{
public const string ObjectName = "Solo Test Cube";
public const double StopWaitSeconds = 6.0;
/// <summary>How long the test waits for the readiness checks (Steam, publication, organization) to answer before it calls a blocked row real.</summary>
public const double ReadinessGraceSeconds = 45.0;
/// <summary>
/// Whether the test may host yet. The checks answer asynchronously after the editor loads, so a blocked or missing row soon after start is
/// waited on; the same row after the grace period is a real problem and is reported with its own reason.
/// </summary>
public static SoloHostReadiness HostReadiness(System.Collections.Generic.IReadOnlyList<ReadinessItem> items, double secondsWaited)
{
bool answered = items != null && items.Count > 0;
if (answered && ReadinessRules.CanStart(items))
{
return SoloHostReadiness.Ready;
}
return secondsWaited >= ReadinessGraceSeconds ? SoloHostReadiness.Blocked : SoloHostReadiness.Waiting;
}
/// <summary>
/// When the test may end its session. It must first remove its own object and have the host acknowledge that, because leaving a session
/// checkpoints (saves) the scene and would otherwise write the still-present object into the file.
/// </summary>
public static SoloStopDecision StopDecision(bool objectPresent, bool editsPending, bool connected, double secondsWaited)
{
if (!connected || secondsWaited >= StopWaitSeconds)
{
return SoloStopDecision.Proceed;
}
return objectPresent || editsPending ? SoloStopDecision.Wait : SoloStopDecision.Proceed;
}
/// <summary>How many top-level objects a scene FILE holds; children nested under them are not counted. Null when the file cannot be read as a scene.</summary>
public static int? TopLevelObjectCount(string sceneFile)
{
try
{
if (string.IsNullOrWhiteSpace(sceneFile) || !System.IO.File.Exists(sceneFile))
{
return null;
}
if (!(JsonNode.Parse(System.IO.File.ReadAllText(sceneFile)) is JsonObject root))
{
return null;
}
return root["GameObjects"] is JsonArray objects ? objects.Count : 0;
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Whether the editor has finished loading a scene, judged against what its file holds. Fewer objects than the file means it is still loading; the
/// test must not begin then, because the host would publish a partial scene and a later save would write it back over the file.
/// </summary>
public static SoloSceneLoad SceneLoadState(int? expectedObjects, int loadedObjects)
{
if (!expectedObjects.HasValue)
{
return SoloSceneLoad.Unknown;
}
return loadedObjects >= expectedObjects.Value ? SoloSceneLoad.Loaded : SoloSceneLoad.Loading;
}
public static string SceneLoadDetail(string scene, int? expectedObjects, int loadedObjects)
{
if (!expectedObjects.HasValue)
{
return "The scene file for " + scene + " could not be read, so its size is unknown; continuing with " + loadedObjects + " object" + (loadedObjects == 1 ? string.Empty : "s") + " loaded.";
}
if (loadedObjects >= expectedObjects.Value)
{
return "Using " + scene + " (" + loadedObjects + " object" + (loadedObjects == 1 ? string.Empty : "s") + " loaded).";
}
return "Waiting for " + scene + " to finish loading (" + loadedObjects + " of " + expectedObjects.Value + " objects)…";
}
public const string BoxModel = "models/dev/box.vmdl";
public static string Vec(double x, double y, double z)
{
return string.Join(",", new[] { x, y, z }.Select((double v) => v.ToString("0.######", CultureInfo.InvariantCulture)));
}
/// <summary>
/// A position for SCENE STATE: whole units only. The engine stores floats in 32 bits, so a value like 12.4 comes back as 12.3999996 and the host
/// editor answers with a corrective edit of its own, after which every edit built on the old text is refused as stale. Whole numbers are exact.
/// </summary>
public static string SnapVec(double x, double y, double z)
{
double S(double v) => Math.Round(v, MidpointRounding.AwayFromZero) + 0.0;
return Vec(S(x), S(y), S(z));
}
public static string Quat(double x, double y, double z, double w)
{
return string.Join(",", new[] { x, y, z, w }.Select((double v) => v.ToString("0.#########", CultureInfo.InvariantCulture)));
}
public static string Color(double r, double g, double b, double a = 1.0)
{
return Quat(r, g, b, a);
}
/// <summary>The same shape the scene serializer writes for a plain box with one model renderer, which is what the host already accepts as a new object.</summary>
public static ObjectState NewCube(Guid objectId, Guid rendererId, string position, string tint)
{
JsonObject data = new JsonObject
{
["__guid"] = objectId.ToString(),
["__version"] = 2,
["Flags"] = 0,
["Name"] = ObjectName,
["Position"] = position,
["Rotation"] = "0,0,0,1",
// 0.5 is exact in 32 bits; the 0.6 this used to write came back as 0.600000024 and made the host editor "correct" it.
["Scale"] = "0.5,0.5,0.5",
["Tags"] = string.Empty,
["Enabled"] = true,
["NetworkMode"] = 2,
["NetworkFlags"] = 0,
["NetworkOrphaned"] = 0,
["NetworkTransmit"] = true,
["OwnerTransfer"] = 1,
["Components"] = new JsonArray
{
new JsonObject
{
// The serializer writes the type first; the host reads it to decide whether a new component is one it supports.
["__type"] = "Sandbox.ModelRenderer",
["__guid"] = rendererId.ToString(),
["__enabled"] = true,
["Flags"] = 0,
["BodyGroups"] = 18446744073709551615UL,
["CreateAttachments"] = false,
["LodOverride"] = null,
["MaterialGroup"] = null,
["MaterialOverride"] = null,
["Materials"] = null,
["Model"] = BoxModel,
["OnComponentDestroy"] = null,
["OnComponentDisabled"] = null,
["OnComponentEnabled"] = null,
["OnComponentFixedUpdate"] = null,
["OnComponentStart"] = null,
["OnComponentUpdate"] = null,
["RenderOptions"] = new JsonObject
{
["GameLayer"] = true,
["OverlayLayer"] = false,
["BloomLayer"] = false,
["AfterUILayer"] = false
},
["RenderType"] = "On",
["Tint"] = tint
}
}
};
return new ObjectState(objectId.ToString(), string.Empty, data);
}
private static ObjectState Clone(ObjectState state, Action<JsonObject> edit)
{
JsonObject data = (JsonObject)JsonNode.Parse(state.Data.ToJsonString());
edit(data);
return new ObjectState(state.Id, state.Parent, data);
}
public static ObjectState WithPosition(ObjectState state, string position)
{
return Clone(state, (JsonObject d) => d["Position"] = position);
}
public static ObjectState WithTint(ObjectState state, string tint)
{
return Clone(state, (JsonObject d) => ((JsonObject)((JsonArray)d["Components"])[0])["Tint"] = tint);
}
public static Change Create(ObjectState after)
{
return new Change(after.Id, null, after);
}
public static Change Update(ObjectState before, ObjectState after)
{
return new Change(before.Id, before, after);
}
public static Change Delete(ObjectState before)
{
return new Change(before.Id, before, null);
}
/// <summary>A point on a circle around <paramref name="center"/>, with a slow rise and fall so the marker reads as floating.</summary>
public static (double X, double Y, double Z) Orbit((double X, double Y, double Z) center, double radius, double angleRadians, double bobHeight)
{
return (center.X + Math.Cos(angleRadians) * radius, center.Y + Math.Sin(angleRadians) * radius, center.Z + Math.Sin(angleRadians * 1.7) * bobHeight);
}
/// <summary>
/// The rotation (x, y, z, w) that turns the +X axis, which is forward in this engine, toward <paramref name="target"/>. Yaw about +Z, then
/// pitch about +Y, where a positive pitch looks down.
/// </summary>
public static (double X, double Y, double Z, double W) LookAt((double X, double Y, double Z) from, (double X, double Y, double Z) target)
{
double dx = target.X - from.X;
double dy = target.Y - from.Y;
double dz = target.Z - from.Z;
double length = Math.Sqrt(dx * dx + dy * dy + dz * dz);
if (length < 1e-9)
{
return (0.0, 0.0, 0.0, 1.0);
}
double fz = dz / length;
double yaw = Math.Atan2(dy, dx);
double pitch = Math.Asin(Math.Clamp(-fz, -1.0, 1.0));
(double X, double Y, double Z, double W) qz = (0.0, 0.0, Math.Sin(yaw / 2.0), Math.Cos(yaw / 2.0));
(double X, double Y, double Z, double W) qy = (0.0, Math.Sin(pitch / 2.0), 0.0, Math.Cos(pitch / 2.0));
return (
qz.W * qy.X + qz.X * qy.W + qz.Y * qy.Z - qz.Z * qy.Y,
qz.W * qy.Y - qz.X * qy.Z + qz.Y * qy.W + qz.Z * qy.X,
qz.W * qy.Z + qz.X * qy.Y - qz.Y * qy.X + qz.Z * qy.W,
qz.W * qy.W - qz.X * qy.X - qz.Y * qy.Y - qz.Z * qy.Z);
}
public static (double X, double Y, double Z) Rotate((double X, double Y, double Z, double W) q, (double X, double Y, double Z) v)
{
// v' = v + 2w(q x v) + 2 q x (q x v)
double cx = q.Y * v.Z - q.Z * v.Y;
double cy = q.Z * v.X - q.X * v.Z;
double cz = q.X * v.Y - q.Y * v.X;
double dx = q.Y * cz - q.Z * cy;
double dy = q.Z * cx - q.X * cz;
double dz = q.X * cy - q.Y * cx;
return (v.X + 2.0 * (q.W * cx + dx), v.Y + 2.0 * (q.W * cy + dy), v.Z + 2.0 * (q.W * cz + dz));
}
/// <summary>The presence a real scene view would send: camera position and facing, a field of view, and a state word.</summary>
public static JsonObject Presence((double X, double Y, double Z) position, (double X, double Y, double Z, double W) rotation, string state)
{
return new JsonObject
{
["position"] = Vec(position.X, position.Y, position.Z),
["rotation"] = Quat(rotation.X, rotation.Y, rotation.Z, rotation.W),
["fov"] = 60.0,
["state"] = state,
["selection"] = new JsonArray()
};
}
/// <summary>A tint that walks around the hue circle, so a property change is visible on screen rather than only in a log.</summary>
public static string TintAt(double turns)
{
double h = (turns % 1.0 + 1.0) % 1.0 * 6.0;
double x = 1.0 - Math.Abs(h % 2.0 - 1.0);
(double r, double g, double b) = h < 1.0 ? (1.0, x, 0.0) : h < 2.0 ? (x, 1.0, 0.0) : h < 3.0 ? (0.0, 1.0, x) : h < 4.0 ? (0.0, x, 1.0) : h < 5.0 ? (x, 0.0, 1.0) : (1.0, 0.0, x);
// Channels are snapped to multiples of 1/32, which a 32-bit float holds exactly and prints in at most five decimals, so the text the host
// holds is the text the editor writes back.
double Q(double v) => Math.Round((0.25 + 0.75 * v) * 32.0) / 32.0;
return Color(Q(r), Q(g), Q(b));
}
/// <summary>Applies accepted changes to a local copy of the scene, the way the host's own state moves.</summary>
public static void ApplyAccepted(Dictionary<string, ObjectState> known, IEnumerable<Change> changes)
{
foreach (Change change in changes ?? Enumerable.Empty<Change>())
{
if ((object)change.After == null)
{
known.Remove(change.Id);
}
else
{
known[change.Id] = change.After;
}
}
}
}