Files
EmilandClaude Sonnet 5 45daa6e114 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
2026-09-02 05:07:14 +03:00

146 lines
4.9 KiB
C#

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