Add Unity playable world projection proof

This commit is contained in:
Meta Vens 2026-07-09 17:56:30 +01:00
commit 09874ad026
16 changed files with 829 additions and 23 deletions

View file

@ -42,6 +42,7 @@ export function buildUnitySceneProjection(surfaceDocument, providerAdvertisement
if (document.surfaceId !== selected.surfaceId) {
throw new Error(`Surface document ${document.surfaceId} does not match advertised Unity scene surface ${selected.surfaceId}.`);
}
const playableWorld = buildPlayableWorld(document.root);
return {
type: "unity-scene-projection",
@ -53,6 +54,7 @@ export function buildUnitySceneProjection(surfaceDocument, providerAdvertisement
commandBoundary: selected.worldInteraction.commandBoundary,
receiptSchema: selected.worldInteraction.receiptSchema,
ownership: selected.worldInteraction.ownership,
...(playableWorld ? { playableWorld } : {}),
root: buildSceneNode(document.root),
};
}
@ -153,6 +155,9 @@ function normalizeEmbeddedDocuments(value) {
function sceneObjectKind(componentKind) {
if (!componentKind) return "empty";
if (componentKind === "world.scene3d") return "playable-world-root";
if (componentKind === "world.entity3d") return "playable-world-entity";
if (componentKind === "field.vector3d" || componentKind === "field.scalar3d") return "world-field-3d";
if (componentKind === "vn.stage") return "sai-vn-scene-stage";
if (componentKind === "panel.dialogue" || componentKind === "text.dialogue") return "sai-vn-scene-dialogue";
if (componentKind === "rail.actions") return "sai-vn-scene-action-rail";
@ -166,6 +171,87 @@ function sceneObjectKind(componentKind) {
return "scene-node";
}
function buildPlayableWorld(root) {
const world = findComponent(root, component => firstString(component?.kind) === "world.scene3d");
if (!world) return null;
const props = objectValue(world.props);
const entities = flattenComponents(world)
.filter(component => firstString(component.kind) === "world.entity3d")
.map(component => buildPlayableEntity(component));
return {
worldRootId: firstString(world.id),
statePointerId: firstString(props.statePointerId, props.worldStatePointerId),
assetManifest: firstString(props.assetManifest, props.assetManifestUri),
inputProfile: firstString(props.inputProfile),
cameraRig: firstString(props.cameraRig),
playerEntityId: firstString(props.playerEntityId),
movementCommand: firstString(props.movementCommand),
focusCommand: firstString(props.focusCommand),
targetCommand: firstString(props.targetCommand),
actionCommand: firstString(props.actionCommand),
entityCount: entities.length,
entities,
};
}
function buildPlayableEntity(component) {
const props = objectValue(component.props);
const [x, y, z] = parseVector3(firstString(props.position));
return {
nodeId: firstString(component.id),
entityId: firstString(props.entityId, component.id),
entityKind: firstString(props.entityKind, props.kind),
label: firstString(props.label, props.name),
faction: firstString(props.faction),
assetRef: firstString(props.assetRef, props.meshRef, props.prefabRef),
position: { x, y, z },
rotationY: parseNumber(firstString(props.rotationY, props.yaw), 0),
radius: parseNumber(firstString(props.radius), 0),
selectable: parseBoolean(props.selectable),
controllable: parseBoolean(props.controllable),
focusCommand: firstString(props.focusCommand),
moveCommand: firstString(props.moveCommand),
targetCommand: firstString(props.targetCommand),
actionCommand: firstString(props.actionCommand),
};
}
function findComponent(component, predicate) {
const source = objectValue(component);
if (predicate(source)) return source;
for (const child of Array.isArray(source.children) ? source.children : []) {
const found = findComponent(child, predicate);
if (found) return found;
}
return null;
}
function flattenComponents(component) {
const source = objectValue(component);
return [
source,
...((Array.isArray(source.children) ? source.children : []).flatMap(flattenComponents)),
];
}
function parseVector3(value) {
const values = String(value || "")
.split(",")
.map(part => parseNumber(part.trim(), 0));
return [values[0] || 0, values[1] || 0, values[2] || 0];
}
function parseNumber(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseBoolean(value) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
return String(value || "").toLowerCase() === "true";
}
function buildPluginProjection(componentKind, component) {
const props = objectValue(component.props);
if (componentKind === "vn.stage") {

View file

@ -34,7 +34,19 @@ test("builds Unity scene projection capture artifact from provider advertisement
assert.equal(projection.artifactKind, "json-projection");
assert.equal(projection.captureKind, "unity-scene-projection-json");
assert.equal(projection.root.id, "aetheria.daemon.game.root");
assert.equal(projection.root.children[1].children[0].sceneObjectKind, "world-projection-node");
assert.equal(projection.playableWorld.worldRootId, "aetheria.daemon.game.playable");
assert.equal(projection.playableWorld.statePointerId, "cultmesh://aetheria/run/current");
assert.equal(projection.playableWorld.assetManifest, "cultmesh://aetheria/assets/manifest");
assert.equal(projection.playableWorld.inputProfile, "arpg-third-person");
assert.equal(projection.playableWorld.cameraRig, "third-person-orbit");
assert.equal(projection.playableWorld.playerEntityId, "player-vanguard");
assert.equal(projection.playableWorld.movementCommand, "aetheria.daemon.move_intent");
assert.equal(projection.playableWorld.entityCount, 3);
const player = projection.playableWorld.entities.find(entity => entity.entityId === "player-vanguard");
assert.equal(player.assetRef, "cultmesh://aetheria/assets/map/entity/player");
assert.deepEqual(player.position, { x: 0, y: 0, z: 0 });
assert.equal(player.controllable, true);
assert.equal(projection.root.children[1].children[0].sceneObjectKind, "playable-world-root");
});
test("builds Unity scene capture artifact for a generic world provider", () => {

View file

@ -1040,7 +1040,7 @@
"splitHandoffPath": "runtimes/incubating/eve-unity-scene/eveunity-scene-split-handoff.json",
"requiredFixtures": ["aetheria-world", "eve-world-smoke"],
"pluginFixtures": ["sai-vn"],
"supportedFeatures": ["providerAdvertisements", "commandTransport", "sceneGraphProjection", "embeddedDocuments"],
"supportedFeatures": ["providerAdvertisements", "commandTransport", "sceneGraphProjection", "playableWorldProjection", "embeddedDocuments"],
"supportedPlugins": [
{
"pluginId": "sai.vn",
@ -1064,7 +1064,7 @@
"targetId": "unity-scene",
"surfaceKinds": ["interactive-world"],
"projectionKinds": ["provider-authored-world-surface"],
"supportLevel": "unity-scene-graph-command-surface",
"supportLevel": "unity-playable-world-scene-command-surface",
"ownership": "runtime-lowers-provider-world-surface-without-owning-world-state",
"evidencePaths": [
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs",
@ -1106,7 +1106,7 @@
"expectedSourceSymbols": [
{
"path": "runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs",
"contains": ["EveUnitySceneSurfaceLowerer", "EveUnitySceneProviderSurfaceAdvertisement", "WorldInteraction", "CommandBoundary", "ReceiptSchema", "EveSurfaceCommandRequest", "BuildSceneGraph", "BuildEmbeddedDocumentSlots", "BuildPluginProjection", "SaiVisualNovelUnitySceneProjectionAdapter", "TeXMathUnitySceneProjectionAdapter", "EveUnitySceneEmbeddedDocumentSlot", "EveUnityScenePluginProjection", "EveUnitySceneNode", "SceneObjectKind", "unity-scene"]
"contains": ["EveUnitySceneSurfaceLowerer", "EveUnitySceneProviderSurfaceAdvertisement", "WorldInteraction", "CommandBoundary", "ReceiptSchema", "EveSurfaceCommandRequest", "BuildSceneGraph", "BuildPlayableWorld", "EveUnityPlayableWorldProjection", "EveUnityPlayableWorldEntity", "BuildEmbeddedDocumentSlots", "BuildPluginProjection", "SaiVisualNovelUnitySceneProjectionAdapter", "TeXMathUnitySceneProjectionAdapter", "EveUnitySceneEmbeddedDocumentSlot", "EveUnityScenePluginProjection", "EveUnitySceneNode", "SceneObjectKind", "playable-world-root", "unity-scene"]
},
{
"path": "runtimes/incubating/eve-unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs",
@ -1122,7 +1122,7 @@
},
{
"path": "runtimes/incubating/eve-unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs",
"contains": ["LowerCarriesProviderAdvertisedWorldBoundary", "LowerBuildsProviderAgnosticSceneGraphFromSurfaceTree", "SaiVisualNovelLowersThroughRuntimeProjectionAdapterWithoutOwningStoryState", "CommandIntentCarriesAdvertisedBoundaryWithoutOwningReceipts", "world-projection-node", "sai-vn-scene-stage", "norn-graph-scene-projection", "tex-math-scene-projection", "norn.map", "cultmesh://aetheria/norn/map", "sai.vn", "norn.graph", "tex.math", "sidecar-advertised-plugin-abi", "aetheria.daemon.commands", "aetheria.eve_command_acceptance_status.v1"]
"contains": ["LowerCarriesProviderAdvertisedWorldBoundary", "LowerBuildsProviderAgnosticSceneGraphFromSurfaceTree", "LowerExtractsPlayableArpgWorldFromGenericScene3dSurface", "SaiVisualNovelLowersThroughRuntimeProjectionAdapterWithoutOwningStoryState", "CommandIntentCarriesAdvertisedBoundaryWithoutOwningReceipts", "world-projection-node", "playable-world-root", "playable-world-entity", "world-field-3d", "arpg-third-person", "cultmesh://aetheria/assets/manifest", "aetheria.daemon.move_intent", "sai-vn-scene-stage", "norn-graph-scene-projection", "tex-math-scene-projection", "norn.map", "cultmesh://aetheria/norn/map", "sai.vn", "norn.graph", "tex.math", "sidecar-advertised-plugin-abi", "aetheria.daemon.commands", "aetheria.eve_command_acceptance_status.v1"]
},
{
"path": "tools/eveunity/eveunity-capture-contract.test.mjs",
@ -1130,11 +1130,11 @@
},
{
"path": "tools/eveunity/eveunity-scene-capture-artifact.mjs",
"contains": ["buildUnitySceneCaptureArtifact", "buildUnitySceneProjection", "gamecult.eve.unity_scene_projection.v1", "unity-scene-projection-json", "writeUnitySceneCaptureArtifact", "request-output"]
"contains": ["buildUnitySceneCaptureArtifact", "buildUnitySceneProjection", "buildPlayableWorld", "gamecult.eve.unity_scene_projection.v1", "unity-scene-projection-json", "writeUnitySceneCaptureArtifact", "request-output"]
},
{
"path": "tools/eveunity/eveunity-scene-capture-artifact.test.mjs",
"contains": ["builds Unity scene projection capture artifact", "builds Unity scene capture artifact for a generic world provider", "marks Sai required and Norn TeX optional sidecar plugin projections", "gamecult.eve.unity_scene_projection.v1", "unity-scene-projection-json", "optional-nested", "eve.world-smoke.commands"]
"contains": ["builds Unity scene projection capture artifact", "builds Unity scene capture artifact for a generic world provider", "marks Sai required and Norn TeX optional sidecar plugin projections", "playableWorld", "arpg-third-person", "aetheria.daemon.move_intent", "gamecult.eve.unity_scene_projection.v1", "unity-scene-projection-json", "optional-nested", "eve.world-smoke.commands"]
},
{
"path": "tools/eveunity/eveunity-release-contract.test.mjs",