Close M0: headless CLI, JSON world dump, project-level plugin loading

The last piece of the agent loop from docs/kernel-contract.md §7:

- WorldDumper serializes a World to JSON — every GameObject, its
  transform, its components (arbitrary plugin-defined classes, so this
  leans on System.Text.Json's own reflection with IncludeFields=true,
  since components are public fields, not properties). Components are
  a list of {type, data}, not a dictionary keyed by type name:
  AddComponent<T>() doesn't enforce uniqueness, so a GameObject can
  carry two components of the same type, and a dictionary would throw
  on exactly that case — tested directly.
- PluginHost.LoadProject reads a project.json and resolves each
  referenced plugin id against engine + project-local search paths,
  finally putting ProjectManifest/PluginReference to use — they'd sat
  unused since the very first scaffold commit.
- Engine.Host is a real CLI now: `engine run --headless --plugins <dir>
  --project <project.json> --frames <n> [--dump <path>]`. --scene and
  --assert are explicitly rejected with a message pointing at why
  (no scene format yet — that's M2; no query DSL was ever actually
  specified for --assert, and jq over a plain JSON dump already covers
  that need), rather than silently ignored or generically rejected.
  Same for `engine diag why-pinned`: nothing has ever failed to unload
  in testing, so there's nothing to build that against yet.
- EchoPlugin now seeds one Ping-bearing GameObject in Configure() —
  sandbox.echo is documented as a test fixture, not a real subsystem,
  and there's no scene format yet to seed content any other way.

Verified by hand, not just by unit tests, since Program.cs itself
isn't covered by any: assembled a real flat plugin directory
(plugin.json + both built DLLs) and actually ran the CLI against
sandbox.echo end to end — 3 frames in, Ping.Count came back 3 in the
dump. Also checked every "not implemented" path (--scene, `diag`,
missing required args) prints its intended message rather than a
generic error.

32 tests total now (26 in Engine.Kernel.Tests, 6 in
Engine.ConformanceHarness), all green on a clean build.

README's Status section updated — M0 was the whole reason this
project exists (fast iteration without Unity's domain reload), and
that claim is no longer just architecture on paper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
Emil
2026-09-02 01:07:36 +03:00
co-authored by Claude Sonnet 5
parent 58e37a3482
commit 246b969744
7 changed files with 358 additions and 21 deletions
@@ -0,0 +1,56 @@
using System.Text.Json;
using Engine.Kernel.World;
namespace Engine.Kernel.Diagnostics;
/// <summary>
/// Serializes a World to JSON for the headless introspection loop described
/// in docs/kernel-contract.md §7 — an agent's only way to see the effect of
/// an edit without a screen. Read-only and outside any system's execution,
/// so it isn't subject to SystemAccessScope enforcement.
///
/// Component types are arbitrary plugin-defined classes, so this leans on
/// System.Text.Json's own reflection rather than anything bespoke —
/// including IncludeFields, since components are plain public fields
/// (docs/kernel-contract.md §1), not properties.
/// </summary>
public static class WorldDumper
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
IncludeFields = true,
};
public static string ToJson(IWorld world)
{
var roots = world.Roots.Select(Dump).ToList();
return JsonSerializer.Serialize(roots, Options);
}
private static object Dump(GameObject go) => new
{
name = go.Name,
transform = new
{
position = ToArray(go.Transform.LocalPosition),
rotation = new[]
{
go.Transform.LocalRotation.X, go.Transform.LocalRotation.Y,
go.Transform.LocalRotation.Z, go.Transform.LocalRotation.W,
},
scale = ToArray(go.Transform.LocalScale),
},
// A list, not a dictionary keyed by type name: AddComponent<T>()
// doesn't enforce uniqueness (see the World row in §2), so a
// GameObject can legitimately carry two components of the same
// type. A dictionary would throw on the very case this needs to
// represent correctly.
components = go.Components
.Select(c => new { type = c.GetType().Name, data = (object)c })
.ToList(),
children = go.Children.Select(Dump).ToList(),
};
private static float[] ToArray(System.Numerics.Vector3 v) => [v.X, v.Y, v.Z];
}
+63 -8
View File
@@ -12,14 +12,15 @@ namespace Engine.Kernel.Plugins;
/// Loads, unloads, and (eventually) reloads plugins. See
/// docs/kernel-contract.md §3-§4.
///
/// Scope of this pass: single-plugin load/unload with the correct two-ALC
/// split, and enough bookkeeping that a plugin's Shutdown() can be honest.
/// NOT yet built: resolving a project's or a plugin's <c>dependsOn</c> graph
/// to determine load order across multiple plugins — that needs at least
/// two real interdependent plugins to test against meaningfully, and we
/// only have one (sandbox.echo, deliberately dependency-free). Loading a
/// plugin whose dependencies aren't already loaded will fail wherever the
/// plugin's own code first touches them, not with a host-level error.
/// NOT yet built: resolving a plugin's own <c>dependsOn</c> graph to
/// determine load order — that needs at least two real interdependent
/// plugins to test against meaningfully, and we only have one (sandbox.echo,
/// deliberately dependency-free). <see cref="LoadProject"/> loads a
/// project's plugins in the order its manifest lists them and does not
/// check <c>PluginReference.Version</c> either; loading a plugin whose
/// dependencies aren't already loaded (or aren't the version expected)
/// will fail wherever its own code first touches them, not with a
/// host-level error.
/// </summary>
public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule schedule, IEventBus events)
{
@@ -61,6 +62,38 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
return manifest.Id;
}
/// <summary>
/// Loads every plugin a project's manifest lists, in listed order.
/// Each plugin id is resolved to a directory by checking, in order,
/// every path in <paramref name="engineSearchPaths"/> (the engine's own
/// plugin catalog) and then the project's own <c>pluginPaths</c>
/// (resolved relative to <paramref name="projectManifestPath"/>'s
/// directory) for a <c>&lt;searchPath&gt;/&lt;id&gt;/plugin.json</c>.
/// Returns the loaded ids, same order as the manifest.
/// </summary>
public IReadOnlyList<string> LoadProject(string projectManifestPath, IReadOnlyList<string> engineSearchPaths)
{
var project = ReadProjectManifest(projectManifestPath);
var projectDirectory = Path.GetDirectoryName(Path.GetFullPath(projectManifestPath))!;
var searchPaths = engineSearchPaths
.Concat(project.PluginPaths.Select(p => Path.Combine(projectDirectory, p)))
.ToList();
var loadedIds = new List<string>(project.Plugins.Count);
foreach (var reference in project.Plugins)
{
var directory = ResolvePluginDirectory(reference.Id, searchPaths)
?? throw new InvalidOperationException(
$"Could not find plugin '{reference.Id}' under any of: {string.Join(", ", searchPaths)}.");
loadedIds.Add(Load(directory));
}
return loadedIds;
}
/// <summary>
/// Runs Shutdown(), then unloads the plugin's ALC. Returns a weak
/// reference to the ALC so a caller can verify it actually collected —
@@ -92,6 +125,28 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
?? throw new InvalidOperationException($"'{path}' did not deserialize to a plugin manifest.");
}
private static ProjectManifest ReadProjectManifest(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"No project manifest found at '{path}'.", path);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<ProjectManifest>(json, ManifestOptions)
?? throw new InvalidOperationException($"'{path}' did not deserialize to a project manifest.");
}
private static string? ResolvePluginDirectory(string pluginId, IReadOnlyList<string> searchPaths)
{
foreach (var searchPath in searchPaths)
{
var candidate = Path.Combine(searchPath, pluginId);
if (File.Exists(Path.Combine(candidate, "plugin.json")))
return candidate;
}
return null;
}
private static void LoadContractsIntoDefaultAlc(string pluginDirectory, PluginManifest manifest)
{
var contractsPath = Path.Combine(pluginDirectory, manifest.Contracts);