630 lines
18 KiB
TypeScript
630 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { createBoxBrush } from "../../src/document/brushes";
|
|
import {
|
|
ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION,
|
|
FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION,
|
|
SCENE_DOCUMENT_VERSION,
|
|
TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION,
|
|
WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION,
|
|
createEmptySceneDocument
|
|
} from "../../src/document/scene-document";
|
|
import { migrateSceneDocument } from "../../src/document/migrate-scene-document";
|
|
import {
|
|
createInteractableEntity,
|
|
createPlayerStartEntity,
|
|
createSoundEmitterEntity,
|
|
createTeleportTargetEntity,
|
|
createTriggerVolumeEntity
|
|
} from "../../src/entities/entity-instances";
|
|
import { createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink } from "../../src/interactions/interaction-links";
|
|
import { STARTER_MATERIAL_LIBRARY } from "../../src/materials/starter-material-library";
|
|
import { parseSceneDocumentJson, serializeSceneDocument } from "../../src/serialization/scene-document-json";
|
|
|
|
describe("scene document JSON", () => {
|
|
it("round-trips the current empty schema", () => {
|
|
const document = createEmptySceneDocument({ name: "Bootstrap Scene" });
|
|
const serializedDocument = serializeSceneDocument(document);
|
|
|
|
expect(parseSceneDocumentJson(serializedDocument)).toEqual(document);
|
|
});
|
|
|
|
it("round-trips a document containing a canonical box brush", () => {
|
|
const brush = createBoxBrush({
|
|
id: "brush-box-room",
|
|
name: "Entry Room",
|
|
center: {
|
|
x: 0,
|
|
y: 1,
|
|
z: 0
|
|
},
|
|
size: {
|
|
x: 4,
|
|
y: 2,
|
|
z: 6
|
|
}
|
|
});
|
|
const document = {
|
|
...createEmptySceneDocument({ name: "Brush Scene" }),
|
|
brushes: {
|
|
[brush.id]: brush
|
|
}
|
|
};
|
|
|
|
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document);
|
|
});
|
|
|
|
it("round-trips per-face material and UV state", () => {
|
|
const brush = createBoxBrush({
|
|
id: "brush-face-room",
|
|
center: {
|
|
x: 2,
|
|
y: 2,
|
|
z: -1
|
|
},
|
|
size: {
|
|
x: 6,
|
|
y: 4,
|
|
z: 8
|
|
}
|
|
});
|
|
|
|
brush.faces.posX.materialId = "starter-amber-grid";
|
|
brush.faces.posX.uv = {
|
|
offset: {
|
|
x: 0.5,
|
|
y: -0.25
|
|
},
|
|
scale: {
|
|
x: 0.25,
|
|
y: 0.5
|
|
},
|
|
rotationQuarterTurns: 3,
|
|
flipU: true,
|
|
flipV: true
|
|
};
|
|
|
|
const document = {
|
|
...createEmptySceneDocument({ name: "Face UV Scene" }),
|
|
brushes: {
|
|
[brush.id]: brush
|
|
}
|
|
};
|
|
|
|
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document);
|
|
});
|
|
|
|
it("round-trips authored world environment settings", () => {
|
|
const document = createEmptySceneDocument({ name: "World Environment Scene" });
|
|
document.world.background = {
|
|
mode: "verticalGradient",
|
|
topColorHex: "#6a87ab",
|
|
bottomColorHex: "#151b23"
|
|
};
|
|
document.world.ambientLight = {
|
|
colorHex: "#d4e2ff",
|
|
intensity: 0.45
|
|
};
|
|
document.world.sunLight = {
|
|
colorHex: "#ffd8a6",
|
|
intensity: 2.25,
|
|
direction: {
|
|
x: -1,
|
|
y: 0.8,
|
|
z: 0.2
|
|
}
|
|
};
|
|
|
|
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document);
|
|
});
|
|
|
|
it("round-trips a document containing an authored PlayerStart entity", () => {
|
|
const playerStart = createPlayerStartEntity({
|
|
id: "entity-player-start-main",
|
|
position: {
|
|
x: 4,
|
|
y: 0,
|
|
z: -2
|
|
},
|
|
yawDegrees: 135
|
|
});
|
|
const document = {
|
|
...createEmptySceneDocument({ name: "Player Start Scene" }),
|
|
entities: {
|
|
[playerStart.id]: playerStart
|
|
}
|
|
};
|
|
|
|
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document);
|
|
});
|
|
|
|
it("round-trips the initial typed entity registry without mixing entities into model instances", () => {
|
|
const playerStart = createPlayerStartEntity({
|
|
id: "entity-player-start-main"
|
|
});
|
|
const soundEmitter = createSoundEmitterEntity({
|
|
id: "entity-sound-main",
|
|
position: {
|
|
x: 1,
|
|
y: 2,
|
|
z: 3
|
|
},
|
|
radius: 7,
|
|
gain: 0.6,
|
|
autoplay: true,
|
|
loop: true
|
|
});
|
|
const triggerVolume = createTriggerVolumeEntity({
|
|
id: "entity-trigger-main",
|
|
position: {
|
|
x: 0,
|
|
y: 1,
|
|
z: 0
|
|
},
|
|
size: {
|
|
x: 2,
|
|
y: 3,
|
|
z: 4
|
|
}
|
|
});
|
|
const teleportTarget = createTeleportTargetEntity({
|
|
id: "entity-teleport-main",
|
|
position: {
|
|
x: -3,
|
|
y: 0,
|
|
z: 5
|
|
},
|
|
yawDegrees: 180
|
|
});
|
|
const interactable = createInteractableEntity({
|
|
id: "entity-interactable-main",
|
|
position: {
|
|
x: 2,
|
|
y: 1,
|
|
z: -2
|
|
},
|
|
radius: 1.25,
|
|
prompt: "Open Door",
|
|
enabled: true
|
|
});
|
|
const document = {
|
|
...createEmptySceneDocument({ name: "Typed Entity Scene" }),
|
|
entities: {
|
|
[playerStart.id]: playerStart,
|
|
[soundEmitter.id]: soundEmitter,
|
|
[triggerVolume.id]: triggerVolume,
|
|
[teleportTarget.id]: teleportTarget,
|
|
[interactable.id]: interactable
|
|
}
|
|
};
|
|
|
|
const roundTripDocument = parseSceneDocumentJson(serializeSceneDocument(document));
|
|
|
|
expect(roundTripDocument).toEqual(document);
|
|
expect(roundTripDocument.modelInstances).toEqual({});
|
|
});
|
|
|
|
it("round-trips canonical interaction links", () => {
|
|
const triggerVolume = createTriggerVolumeEntity({
|
|
id: "entity-trigger-main"
|
|
});
|
|
const interactable = createInteractableEntity({
|
|
id: "entity-interactable-main",
|
|
prompt: "Use Console"
|
|
});
|
|
const teleportTarget = createTeleportTargetEntity({
|
|
id: "entity-teleport-main"
|
|
});
|
|
const brush = createBoxBrush({
|
|
id: "brush-door"
|
|
});
|
|
const document = {
|
|
...createEmptySceneDocument({ name: "Interaction Scene" }),
|
|
brushes: {
|
|
[brush.id]: brush
|
|
},
|
|
entities: {
|
|
[triggerVolume.id]: triggerVolume,
|
|
[interactable.id]: interactable,
|
|
[teleportTarget.id]: teleportTarget
|
|
},
|
|
interactionLinks: {
|
|
"link-teleport": createTeleportPlayerInteractionLink({
|
|
id: "link-teleport",
|
|
sourceEntityId: triggerVolume.id,
|
|
trigger: "enter",
|
|
targetEntityId: teleportTarget.id
|
|
}),
|
|
"link-hide-door": createToggleVisibilityInteractionLink({
|
|
id: "link-hide-door",
|
|
sourceEntityId: triggerVolume.id,
|
|
trigger: "exit",
|
|
targetBrushId: brush.id,
|
|
visible: false
|
|
}),
|
|
"link-click-teleport": createTeleportPlayerInteractionLink({
|
|
id: "link-click-teleport",
|
|
sourceEntityId: interactable.id,
|
|
trigger: "click",
|
|
targetEntityId: teleportTarget.id
|
|
})
|
|
}
|
|
};
|
|
|
|
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document);
|
|
});
|
|
|
|
it("migrates the foundation schema to the current schema version", () => {
|
|
const migratedDocument = migrateSceneDocument({
|
|
version: 1,
|
|
name: "Foundation Scene",
|
|
world: createEmptySceneDocument().world,
|
|
materials: {},
|
|
textures: {},
|
|
assets: {},
|
|
brushes: {},
|
|
modelInstances: {},
|
|
entities: {},
|
|
interactionLinks: {}
|
|
});
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
|
expect(migratedDocument.brushes).toEqual({});
|
|
expect(migratedDocument.name).toBe("Foundation Scene");
|
|
expect(Object.keys(migratedDocument.materials)).toEqual(STARTER_MATERIAL_LIBRARY.map((material) => material.id));
|
|
});
|
|
|
|
it("migrates slice 1.1 box brushes to explicit per-face UV state", () => {
|
|
const migratedDocument = migrateSceneDocument({
|
|
version: 2,
|
|
name: "Legacy Brush Scene",
|
|
world: createEmptySceneDocument().world,
|
|
materials: {},
|
|
textures: {},
|
|
assets: {},
|
|
brushes: {
|
|
"brush-legacy": {
|
|
id: "brush-legacy",
|
|
kind: "box",
|
|
center: {
|
|
x: 0,
|
|
y: 1,
|
|
z: 0
|
|
},
|
|
size: {
|
|
x: 4,
|
|
y: 2,
|
|
z: 6
|
|
},
|
|
faces: {
|
|
posX: { materialId: null },
|
|
negX: { materialId: null },
|
|
posY: { materialId: null },
|
|
negY: { materialId: null },
|
|
posZ: { materialId: "starter-amber-grid" },
|
|
negZ: { materialId: null }
|
|
}
|
|
}
|
|
},
|
|
modelInstances: {},
|
|
entities: {},
|
|
interactionLinks: {}
|
|
});
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
|
expect(migratedDocument.brushes["brush-legacy"].faces.posZ.materialId).toBe("starter-amber-grid");
|
|
expect(migratedDocument.brushes["brush-legacy"].faces.posZ.uv).toEqual({
|
|
offset: {
|
|
x: 0,
|
|
y: 0
|
|
},
|
|
scale: {
|
|
x: 1,
|
|
y: 1
|
|
},
|
|
rotationQuarterTurns: 0,
|
|
flipU: false,
|
|
flipV: false
|
|
});
|
|
});
|
|
|
|
it("migrates slice 1.2 face materials to the PlayerStart-capable schema", () => {
|
|
const migratedDocument = migrateSceneDocument({
|
|
version: 3,
|
|
name: "Legacy Face Scene",
|
|
world: createEmptySceneDocument().world,
|
|
materials: createEmptySceneDocument().materials,
|
|
textures: {},
|
|
assets: {},
|
|
brushes: {},
|
|
modelInstances: {},
|
|
entities: {},
|
|
interactionLinks: {}
|
|
});
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
|
expect(migratedDocument.entities).toEqual({});
|
|
});
|
|
|
|
it("migrates runner-v1 documents to authored brush names without changing existing content", () => {
|
|
const migratedDocument = migrateSceneDocument({
|
|
version: 4,
|
|
name: "Runner V1 Scene",
|
|
world: createEmptySceneDocument().world,
|
|
materials: createEmptySceneDocument().materials,
|
|
textures: {},
|
|
assets: {},
|
|
brushes: {
|
|
"brush-room-shell": {
|
|
id: "brush-room-shell",
|
|
kind: "box",
|
|
center: {
|
|
x: 0,
|
|
y: 1,
|
|
z: 0
|
|
},
|
|
size: {
|
|
x: 4,
|
|
y: 2,
|
|
z: 6
|
|
},
|
|
faces: {
|
|
posX: { materialId: null, uv: createBoxBrush().faces.posX.uv },
|
|
negX: { materialId: null, uv: createBoxBrush().faces.negX.uv },
|
|
posY: { materialId: null, uv: createBoxBrush().faces.posY.uv },
|
|
negY: { materialId: null, uv: createBoxBrush().faces.negY.uv },
|
|
posZ: { materialId: null, uv: createBoxBrush().faces.posZ.uv },
|
|
negZ: { materialId: null, uv: createBoxBrush().faces.negZ.uv }
|
|
}
|
|
}
|
|
},
|
|
modelInstances: {},
|
|
entities: {
|
|
"entity-player-start-main": {
|
|
id: "entity-player-start-main",
|
|
kind: "playerStart",
|
|
position: {
|
|
x: 2,
|
|
y: 0,
|
|
z: -2
|
|
},
|
|
yawDegrees: 45
|
|
}
|
|
},
|
|
interactionLinks: {}
|
|
});
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
|
expect(migratedDocument.brushes["brush-room-shell"].name).toBeUndefined();
|
|
expect(migratedDocument.entities["entity-player-start-main"]).toMatchObject({
|
|
kind: "playerStart",
|
|
yawDegrees: 45
|
|
});
|
|
});
|
|
|
|
it("migrates slice 1.4 documents to the world-environment schema without changing authored solid backgrounds", () => {
|
|
const migratedDocument = migrateSceneDocument({
|
|
version: FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION,
|
|
name: "First Room Scene",
|
|
world: createEmptySceneDocument().world,
|
|
materials: createEmptySceneDocument().materials,
|
|
textures: {},
|
|
assets: {},
|
|
brushes: {},
|
|
modelInstances: {},
|
|
entities: {},
|
|
interactionLinks: {}
|
|
});
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
|
expect(migratedDocument.world.background).toEqual({
|
|
mode: "solid",
|
|
colorHex: "#2f3947"
|
|
});
|
|
});
|
|
|
|
it("migrates slice 1.5 documents to the typed-entity schema without changing supported authored entities", () => {
|
|
const migratedDocument = migrateSceneDocument({
|
|
version: WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION,
|
|
name: "World Environment Scene",
|
|
world: createEmptySceneDocument().world,
|
|
materials: createEmptySceneDocument().materials,
|
|
textures: {},
|
|
assets: {},
|
|
brushes: {},
|
|
modelInstances: {},
|
|
entities: {
|
|
"entity-player-start-main": {
|
|
id: "entity-player-start-main",
|
|
kind: "playerStart",
|
|
position: {
|
|
x: 2,
|
|
y: 0,
|
|
z: -1
|
|
},
|
|
yawDegrees: 90
|
|
}
|
|
},
|
|
interactionLinks: {}
|
|
});
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
|
expect(migratedDocument.entities["entity-player-start-main"]).toMatchObject({
|
|
kind: "playerStart",
|
|
yawDegrees: 90
|
|
});
|
|
});
|
|
|
|
it("migrates slice 2.1 documents to the interaction-link schema with empty interaction links", () => {
|
|
const migratedDocument = migrateSceneDocument({
|
|
version: ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION,
|
|
name: "Entity Foundation Scene",
|
|
world: createEmptySceneDocument().world,
|
|
materials: createEmptySceneDocument().materials,
|
|
textures: {},
|
|
assets: {},
|
|
brushes: {},
|
|
modelInstances: {},
|
|
entities: {
|
|
"entity-trigger-main": {
|
|
id: "entity-trigger-main",
|
|
kind: "triggerVolume",
|
|
position: {
|
|
x: 0,
|
|
y: 0,
|
|
z: 0
|
|
},
|
|
size: {
|
|
x: 2,
|
|
y: 2,
|
|
z: 2
|
|
},
|
|
triggerOnEnter: true,
|
|
triggerOnExit: false
|
|
}
|
|
},
|
|
interactionLinks: {}
|
|
});
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
|
expect(migratedDocument.interactionLinks).toEqual({});
|
|
expect(migratedDocument.entities["entity-trigger-main"]).toMatchObject({
|
|
kind: "triggerVolume"
|
|
});
|
|
});
|
|
|
|
it("migrates slice 2.2 documents to the click-capable interaction schema without changing existing links", () => {
|
|
const migratedDocument = migrateSceneDocument({
|
|
version: TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION,
|
|
name: "Trigger Action Target Scene",
|
|
world: createEmptySceneDocument().world,
|
|
materials: createEmptySceneDocument().materials,
|
|
textures: {},
|
|
assets: {},
|
|
brushes: {},
|
|
modelInstances: {},
|
|
entities: {
|
|
"entity-trigger-main": {
|
|
id: "entity-trigger-main",
|
|
kind: "triggerVolume",
|
|
position: {
|
|
x: 0,
|
|
y: 0,
|
|
z: 0
|
|
},
|
|
size: {
|
|
x: 2,
|
|
y: 2,
|
|
z: 2
|
|
},
|
|
triggerOnEnter: true,
|
|
triggerOnExit: false
|
|
},
|
|
"entity-teleport-main": {
|
|
id: "entity-teleport-main",
|
|
kind: "teleportTarget",
|
|
position: {
|
|
x: 4,
|
|
y: 0,
|
|
z: -2
|
|
},
|
|
yawDegrees: 90
|
|
}
|
|
},
|
|
interactionLinks: {
|
|
"link-teleport": {
|
|
id: "link-teleport",
|
|
sourceEntityId: "entity-trigger-main",
|
|
trigger: "enter",
|
|
action: {
|
|
type: "teleportPlayer",
|
|
targetEntityId: "entity-teleport-main"
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
|
expect(migratedDocument.interactionLinks).toEqual({
|
|
"link-teleport": {
|
|
id: "link-teleport",
|
|
sourceEntityId: "entity-trigger-main",
|
|
trigger: "enter",
|
|
action: {
|
|
type: "teleportPlayer",
|
|
targetEntityId: "entity-teleport-main"
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
it("rejects unsupported versions", () => {
|
|
expect(() =>
|
|
migrateSceneDocument({
|
|
version: 99,
|
|
name: "Legacy",
|
|
world: {},
|
|
textures: {},
|
|
assets: {},
|
|
brushes: {},
|
|
modelInstances: {},
|
|
entities: {},
|
|
interactionLinks: {}
|
|
})
|
|
).toThrow("Unsupported scene document version");
|
|
});
|
|
|
|
it("rejects duplicate authored ids after migration and validation", () => {
|
|
expect(() =>
|
|
parseSceneDocumentJson(
|
|
JSON.stringify({
|
|
version: SCENE_DOCUMENT_VERSION,
|
|
name: "Duplicate Id Scene",
|
|
world: createEmptySceneDocument().world,
|
|
materials: createEmptySceneDocument().materials,
|
|
textures: {},
|
|
assets: {},
|
|
brushes: {
|
|
"brush-room-shell": {
|
|
id: "shared-id",
|
|
kind: "box",
|
|
center: {
|
|
x: 0,
|
|
y: 1,
|
|
z: 0
|
|
},
|
|
size: {
|
|
x: 2,
|
|
y: 2,
|
|
z: 2
|
|
},
|
|
faces: {
|
|
posX: { materialId: null, uv: createBoxBrush().faces.posX.uv },
|
|
negX: { materialId: null, uv: createBoxBrush().faces.negX.uv },
|
|
posY: { materialId: null, uv: createBoxBrush().faces.posY.uv },
|
|
negY: { materialId: null, uv: createBoxBrush().faces.negY.uv },
|
|
posZ: { materialId: null, uv: createBoxBrush().faces.posZ.uv },
|
|
negZ: { materialId: null, uv: createBoxBrush().faces.negZ.uv }
|
|
}
|
|
}
|
|
},
|
|
modelInstances: {},
|
|
entities: {
|
|
"shared-id": {
|
|
id: "shared-id",
|
|
kind: "playerStart",
|
|
position: {
|
|
x: 0,
|
|
y: 0,
|
|
z: 0
|
|
},
|
|
yawDegrees: 0
|
|
}
|
|
},
|
|
interactionLinks: {}
|
|
})
|
|
)
|
|
).toThrow("Duplicate authored id shared-id");
|
|
});
|
|
});
|