M2, part one: scene format — World actually saves and loads now
The first half of M2's "done when" (a scene loads and saves) rather than the whole milestone — the asset hot-reload half is a comparably sized, separate chunk of work, staged on its own rather than crammed in alongside this. SceneFormat replaces WorldDumper rather than sitting next to it: there was never a real reason for "what an agent reads to check a frame" (the existing --dump) and "what a scene file actually is" to be two different JSON shapes, and keeping them one removes the question of which shape a save/load round trip is supposed to match. Moved from Engine.Kernel.Diagnostics to Engine.Kernel.World to match — this is core content loading now, not a debug tool that happens to also serialize things. Components are tagged "TypeFullName, AssemblyName" (partial-name form, deliberately no version) so Type.GetType resolves them against whatever's loaded regardless of an incidental version bump on the plugin that defines them — full four-part AssemblyQualifiedName would have made every saved scene brittle against that. Loading a scene whose component type isn't loaded fails loudly, naming the missing type, rather than silently dropping data. New kernel API this needed: GameObject.AddComponent(Component) — attaches an already-constructed instance, for a caller (the deserializer) that only has a runtime Type from a file, not a compile-time T. Deserializing straight into a real instance via JsonSerializer.Deserialize(json, componentType) and attaching that is simpler and more certain than constructing an empty component through reflection and then trying to populate it after the fact. Engine.Host: --scene now does something (was an explicit "not implemented yet" since the very first CLI pass) — loads additively after every plugin in --project, since a scene's component type tags only resolve once the plugin defining them has loaded its Contracts assembly. Verified beyond the round-trip unit tests: two separate real CLI runs, sandbox.echo both times. Run 1 ticks 3 frames and dumps a scene (Ping.Count: 3). Run 2 loads that scene fresh alongside its own newly-seeded Ping (Count: 0) and ticks 2 more frames — dump shows Count: 2 for the fresh one and Count: 5 for the loaded one. Not just "the file round-trips" — the loaded component's state kept being a real, live, Scheduler-ticked object across the save/load boundary. 44 tests in Engine.Kernel.Tests now (up from 40 — 3 carried over from WorldDumperTests plus 4 new round-trip/error-path tests), 8 in Engine.ConformanceHarness unaffected. All green on a clean build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
// The runtime host — drives both halves of the loop described in
|
||||
// docs/kernel-contract.md §7 and the M1 windowed case in §8:
|
||||
//
|
||||
// engine run --headless --plugins <dir> --project <project.json> --frames <n> [--dump <path>]
|
||||
// engine run --windowed --plugins <dir> --project <project.json> [--dump <path>]
|
||||
// engine run --headless --plugins <dir> --project <project.json> --frames <n> [--scene <path>] [--dump <path>]
|
||||
// engine run --windowed --plugins <dir> --project <project.json> [--scene <path>] [--dump <path>]
|
||||
//
|
||||
// --scene loads after every plugin in --project, not before: a scene file
|
||||
// names its components by type ("TypeFullName, AssemblyName" — see
|
||||
// SceneFormat), and that only resolves once the plugin that defines the
|
||||
// type has loaded its Contracts assembly into the Default ALC. It's
|
||||
// additive onto whatever's already in World — nothing pre-clears it.
|
||||
//
|
||||
// --windowed needs a loaded plugin that provides IEngineWindow (engine.
|
||||
// windowing) — Engine.Host references that plugin's *Contracts* assembly
|
||||
@@ -11,10 +17,8 @@
|
||||
// loop is the host's job, not the kernel's: Engine.Kernel never hears about
|
||||
// Silk.NET at all.
|
||||
//
|
||||
// Deliberately not implemented yet, both noted explicitly below rather than
|
||||
// Deliberately not implemented yet, noted explicitly below rather than
|
||||
// silently accepted or rejected as gibberish:
|
||||
// --scene no scene format exists (that's M2) — a plugin that needs
|
||||
// world content seeds it itself; see sandbox.echo's Configure.
|
||||
// --assert no query DSL exists to parse the doc's illustrative
|
||||
// `count(Rigidbody where sleeping) == 12` syntax; a dump is
|
||||
// plain JSON, so external tooling (jq, a test script) already
|
||||
@@ -54,6 +58,7 @@ if (args[0] != "run")
|
||||
string? projectPath = null;
|
||||
string? pluginsPath = null;
|
||||
string? dumpPath = null;
|
||||
string? scenePath = null;
|
||||
string? screenshotPath = null;
|
||||
var frames = 0;
|
||||
var screenshotAfterFrames = 1;
|
||||
@@ -82,13 +87,15 @@ for (var i = 1; i < args.Length; i++)
|
||||
case "--dump" when i + 1 < args.Length:
|
||||
dumpPath = args[++i];
|
||||
break;
|
||||
case "--scene" when i + 1 < args.Length:
|
||||
scenePath = args[++i];
|
||||
break;
|
||||
case "--screenshot" when i + 1 < args.Length:
|
||||
screenshotPath = args[++i];
|
||||
break;
|
||||
case "--screenshot-after-frames" when i + 1 < args.Length:
|
||||
screenshotAfterFrames = int.Parse(args[++i]);
|
||||
break;
|
||||
case "--scene":
|
||||
case "--assert":
|
||||
Console.Error.WriteLine($"'{args[i]}' isn't implemented yet — see the notes at the top of Program.cs.");
|
||||
return 1;
|
||||
@@ -133,6 +140,20 @@ catch (Exception ex)
|
||||
|
||||
Console.WriteLine($"Loaded {loaded.Count} plugin(s): {string.Join(", ", loaded)}");
|
||||
|
||||
if (scenePath is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
SceneFormat.Load(world, scenePath);
|
||||
Console.WriteLine($"Loaded scene '{scenePath}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to load scene '{scenePath}': {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (windowed)
|
||||
{
|
||||
if (!services.TryRequire<IEngineWindow>(out var window))
|
||||
@@ -268,7 +289,7 @@ else
|
||||
|
||||
if (dumpPath is not null)
|
||||
{
|
||||
File.WriteAllText(dumpPath, WorldDumper.ToJson(world));
|
||||
SceneFormat.Save(world, dumpPath);
|
||||
Console.WriteLine($"Wrote world dump to '{dumpPath}'.");
|
||||
}
|
||||
|
||||
@@ -279,8 +300,8 @@ static void PrintUsage()
|
||||
Console.Error.WriteLine(
|
||||
"""
|
||||
Usage:
|
||||
engine run --headless --plugins <dir> --project <project.json> --frames <n> [--dump <path>]
|
||||
engine run --windowed --plugins <dir> --project <project.json> [--dump <path>]
|
||||
engine run --headless --plugins <dir> --project <project.json> --frames <n> [--scene <path>] [--dump <path>]
|
||||
engine run --windowed --plugins <dir> --project <project.json> [--scene <path>] [--dump <path>]
|
||||
[--screenshot <path> [--screenshot-after-frames <n>]]
|
||||
|
||||
--screenshot captures once, after <n> frames (default 1), then exits
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -108,6 +108,24 @@ public sealed class GameObject
|
||||
return component;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches an already-constructed component rather than building an
|
||||
/// empty one — for a caller that only has a runtime <see cref="Type"/>,
|
||||
/// not a compile-time <c>T</c>. Scene loading is the reason this
|
||||
/// exists: it deserializes a component straight from JSON into a real
|
||||
/// instance via <c>JsonSerializer.Deserialize(json, componentType)</c>,
|
||||
/// and would otherwise need reflection just to call the generic
|
||||
/// overload above.
|
||||
/// </summary>
|
||||
public Component AddComponent(Component component)
|
||||
{
|
||||
SystemAccessScope.CheckWrite(component.GetType());
|
||||
|
||||
_components.Add(component);
|
||||
Owner?.IndexComponentAdded(this, component);
|
||||
return component;
|
||||
}
|
||||
|
||||
public void RemoveComponent<T>() where T : Component
|
||||
{
|
||||
SystemAccessScope.CheckWrite(typeof(T));
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Numerics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Engine.Kernel.World;
|
||||
|
||||
/// <summary>
|
||||
/// Save and load a World. Doubles as the headless introspection dump from
|
||||
/// docs/kernel-contract.md §7 (previously a separate WorldDumper) — there
|
||||
/// was never a real reason for "what an agent reads to check a frame" and
|
||||
/// "what a scene file actually is" to be two different JSON shapes, and
|
||||
/// keeping them one removes the question of which one a save/load round
|
||||
/// trip is supposed to match. Read/write both happen outside any system's
|
||||
/// execution, so neither is 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. A component is tagged
|
||||
/// with "TypeFullName, AssemblyName" (partial-name form, no version) —
|
||||
/// exact enough for Type.GetType to resolve it against whatever's loaded,
|
||||
/// loose enough that a plugin's incidental version bump doesn't strand
|
||||
/// every scene that references it.
|
||||
/// </summary>
|
||||
public static class SceneFormat
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
IncludeFields = true,
|
||||
};
|
||||
|
||||
public static void Save(IWorld world, string path) =>
|
||||
File.WriteAllText(path, ToJson(world));
|
||||
|
||||
public static string ToJson(IWorld world)
|
||||
{
|
||||
var roots = world.Roots.Select(Dump).ToList();
|
||||
return JsonSerializer.Serialize(roots, Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additive: creates whatever this file describes in <paramref
|
||||
/// name="world"/> without touching what's already there. A "replace
|
||||
/// everything" load is the caller's call to make (Destroy the existing
|
||||
/// roots first) — additive is the more fundamental operation, and nothing
|
||||
/// today needs the other one.
|
||||
/// </summary>
|
||||
public static void Load(IWorld world, string path) =>
|
||||
FromJson(world, File.ReadAllText(path));
|
||||
|
||||
public static void FromJson(IWorld world, string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
foreach (var element in doc.RootElement.EnumerateArray())
|
||||
Build(world, element, parent: null);
|
||||
}
|
||||
|
||||
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 = TypeTag(c.GetType()), data = (object)c })
|
||||
.ToList(),
|
||||
children = go.Children.Select(Dump).ToList(),
|
||||
};
|
||||
|
||||
private static void Build(IWorld world, JsonElement element, GameObject? parent)
|
||||
{
|
||||
var go = world.CreateGameObject(element.GetProperty("name").GetString()!);
|
||||
|
||||
var transform = element.GetProperty("transform");
|
||||
var position = transform.GetProperty("position");
|
||||
var rotation = transform.GetProperty("rotation");
|
||||
var scale = transform.GetProperty("scale");
|
||||
|
||||
go.Transform = new Transform
|
||||
{
|
||||
LocalPosition = new Vector3(
|
||||
position[0].GetSingle(), position[1].GetSingle(), position[2].GetSingle()),
|
||||
LocalRotation = new Quaternion(
|
||||
rotation[0].GetSingle(), rotation[1].GetSingle(),
|
||||
rotation[2].GetSingle(), rotation[3].GetSingle()),
|
||||
LocalScale = new Vector3(scale[0].GetSingle(), scale[1].GetSingle(), scale[2].GetSingle()),
|
||||
};
|
||||
|
||||
foreach (var componentElement in element.GetProperty("components").EnumerateArray())
|
||||
{
|
||||
var typeTag = componentElement.GetProperty("type").GetString()!;
|
||||
var componentType = Type.GetType(typeTag)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Scene references component type '{typeTag}', which isn't loaded. " +
|
||||
"Load the plugin that provides it before loading this scene.");
|
||||
|
||||
var component = (Component)JsonSerializer.Deserialize(
|
||||
componentElement.GetProperty("data").GetRawText(), componentType, Options)!;
|
||||
|
||||
go.AddComponent(component);
|
||||
}
|
||||
|
||||
if (parent is not null)
|
||||
go.SetParent(parent);
|
||||
|
||||
foreach (var childElement in element.GetProperty("children").EnumerateArray())
|
||||
Build(world, childElement, go);
|
||||
}
|
||||
|
||||
private static string TypeTag(Type type) => $"{type.FullName}, {type.Assembly.GetName().Name}";
|
||||
|
||||
private static float[] ToArray(Vector3 v) => [v.X, v.Y, v.Z];
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Numerics;
|
||||
using System.Text.Json;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Kernel.Tests;
|
||||
|
||||
public class SceneFormatTests
|
||||
{
|
||||
private sealed class Health : Component
|
||||
{
|
||||
public int Value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_Includes_Name_Transform_And_Component_Fields()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var go = world.CreateGameObject("Hero");
|
||||
go.Transform.LocalPosition = new Vector3(1, 2, 3);
|
||||
go.AddComponent<Health>().Value = 42;
|
||||
|
||||
using var doc = JsonDocument.Parse(SceneFormat.ToJson(world));
|
||||
var root = doc.RootElement[0];
|
||||
|
||||
Assert.Equal("Hero", root.GetProperty("name").GetString());
|
||||
|
||||
var position = root.GetProperty("transform").GetProperty("position");
|
||||
Assert.Equal(1, position[0].GetSingle());
|
||||
Assert.Equal(2, position[1].GetSingle());
|
||||
Assert.Equal(3, position[2].GetSingle());
|
||||
|
||||
var component = root.GetProperty("components")[0];
|
||||
Assert.Contains("Health", component.GetProperty("type").GetString());
|
||||
Assert.Equal(42, component.GetProperty("data").GetProperty("Value").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_Nests_Children_Under_Their_Parent_Rather_Than_Listing_Them_At_The_Top_Level()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var parent = world.CreateGameObject("Parent");
|
||||
var child = world.CreateGameObject("Child");
|
||||
child.SetParent(parent);
|
||||
|
||||
using var doc = JsonDocument.Parse(SceneFormat.ToJson(world));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
var root = doc.RootElement[0];
|
||||
Assert.Equal("Parent", root.GetProperty("name").GetString());
|
||||
Assert.Equal("Child", root.GetProperty("children")[0].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_Represents_Duplicate_Components_Of_The_Same_Type_As_A_List_Not_A_Dictionary()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var go = world.CreateGameObject("A");
|
||||
go.AddComponent<Health>().Value = 1;
|
||||
go.AddComponent<Health>().Value = 2;
|
||||
|
||||
// A dictionary keyed by type name would throw on this exact case.
|
||||
using var doc = JsonDocument.Parse(SceneFormat.ToJson(world));
|
||||
|
||||
var components = doc.RootElement[0].GetProperty("components");
|
||||
Assert.Equal(2, components.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_Trip_Recreates_Name_Transform_And_Component_Field_Values()
|
||||
{
|
||||
var source = new GameWorld();
|
||||
var go = source.CreateGameObject("Hero");
|
||||
go.Transform.LocalPosition = new Vector3(1, 2, 3);
|
||||
go.Transform.LocalScale = new Vector3(2, 2, 2);
|
||||
go.AddComponent<Health>().Value = 42;
|
||||
|
||||
var json = SceneFormat.ToJson(source);
|
||||
|
||||
var loaded = new GameWorld();
|
||||
SceneFormat.FromJson(loaded, json);
|
||||
|
||||
var root = Assert.Single(loaded.Roots);
|
||||
Assert.Equal("Hero", root.Name);
|
||||
Assert.Equal(new Vector3(1, 2, 3), root.Transform.LocalPosition);
|
||||
Assert.Equal(new Vector3(2, 2, 2), root.Transform.LocalScale);
|
||||
Assert.Equal(42, root.GetComponent<Health>()!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_Trip_Recreates_The_Parent_Child_Hierarchy()
|
||||
{
|
||||
var source = new GameWorld();
|
||||
var parent = source.CreateGameObject("Parent");
|
||||
var child = source.CreateGameObject("Child");
|
||||
child.SetParent(parent);
|
||||
|
||||
var json = SceneFormat.ToJson(source);
|
||||
|
||||
var loaded = new GameWorld();
|
||||
SceneFormat.FromJson(loaded, json);
|
||||
|
||||
var root = Assert.Single(loaded.Roots);
|
||||
Assert.Equal("Parent", root.Name);
|
||||
var loadedChild = Assert.Single(root.Children);
|
||||
Assert.Equal("Child", loadedChild.Name);
|
||||
Assert.Same(root, loadedChild.Parent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Is_Additive_Onto_An_Already_Populated_World()
|
||||
{
|
||||
var source = new GameWorld();
|
||||
source.CreateGameObject("FromScene");
|
||||
|
||||
var json = SceneFormat.ToJson(source);
|
||||
|
||||
var target = new GameWorld();
|
||||
target.CreateGameObject("AlreadyThere");
|
||||
SceneFormat.FromJson(target, json);
|
||||
|
||||
Assert.Equal(2, target.Roots.Count);
|
||||
Assert.Contains(target.Roots, go => go.Name == "AlreadyThere");
|
||||
Assert.Contains(target.Roots, go => go.Name == "FromScene");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Throws_A_Clear_Error_When_A_Component_Type_Is_Not_Loaded()
|
||||
{
|
||||
const string json = """
|
||||
[
|
||||
{
|
||||
"name": "Broken",
|
||||
"transform": { "position": [0,0,0], "rotation": [0,0,0,1], "scale": [1,1,1] },
|
||||
"components": [ { "type": "Nonexistent.Ghost, Nonexistent", "data": {} } ],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var world = new GameWorld();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => SceneFormat.FromJson(world, json));
|
||||
Assert.Contains("Nonexistent.Ghost", exception.Message);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using System.Numerics;
|
||||
using System.Text.Json;
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Kernel.Tests;
|
||||
|
||||
public class WorldDumperTests
|
||||
{
|
||||
private sealed class Health : Component
|
||||
{
|
||||
public int Value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dump_Includes_Name_Transform_And_Component_Fields()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var go = world.CreateGameObject("Hero");
|
||||
go.Transform.LocalPosition = new Vector3(1, 2, 3);
|
||||
go.AddComponent<Health>().Value = 42;
|
||||
|
||||
using var doc = JsonDocument.Parse(WorldDumper.ToJson(world));
|
||||
var root = doc.RootElement[0];
|
||||
|
||||
Assert.Equal("Hero", root.GetProperty("name").GetString());
|
||||
|
||||
var position = root.GetProperty("transform").GetProperty("position");
|
||||
Assert.Equal(1, position[0].GetSingle());
|
||||
Assert.Equal(2, position[1].GetSingle());
|
||||
Assert.Equal(3, position[2].GetSingle());
|
||||
|
||||
var component = root.GetProperty("components")[0];
|
||||
Assert.Equal("Health", component.GetProperty("type").GetString());
|
||||
Assert.Equal(42, component.GetProperty("data").GetProperty("Value").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dump_Nests_Children_Under_Their_Parent_Rather_Than_Listing_Them_At_The_Top_Level()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var parent = world.CreateGameObject("Parent");
|
||||
var child = world.CreateGameObject("Child");
|
||||
child.SetParent(parent);
|
||||
|
||||
using var doc = JsonDocument.Parse(WorldDumper.ToJson(world));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
var root = doc.RootElement[0];
|
||||
Assert.Equal("Parent", root.GetProperty("name").GetString());
|
||||
Assert.Equal("Child", root.GetProperty("children")[0].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dump_Represents_Duplicate_Components_Of_The_Same_Type_As_A_List_Not_A_Dictionary()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var go = world.CreateGameObject("A");
|
||||
go.AddComponent<Health>().Value = 1;
|
||||
go.AddComponent<Health>().Value = 2;
|
||||
|
||||
// A dictionary keyed by type name would throw on this exact case.
|
||||
using var doc = JsonDocument.Parse(WorldDumper.ToJson(world));
|
||||
|
||||
var components = doc.RootElement[0].GetProperty("components");
|
||||
Assert.Equal(2, components.GetArrayLength());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user