diff --git a/src/app/App.tsx b/src/app/App.tsx
index e7be2afa..d18ffd3d 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -942,7 +942,12 @@ export function App({ store, initialStatusMessage }: AppProps) {
-
-
-
- Save Draft
-
-
- Load Draft
-
-
- Export JSON
-
-
- Import JSON
-
-
-
store.undo()}>
Undo
diff --git a/tests/domain/build-runtime-scene.test.ts b/tests/domain/build-runtime-scene.test.ts
index fe31ed2a..d514852c 100644
--- a/tests/domain/build-runtime-scene.test.ts
+++ b/tests/domain/build-runtime-scene.test.ts
@@ -139,4 +139,12 @@ describe("buildRuntimeSceneFromDocument", () => {
yawDegrees: 180
});
});
+
+ it("blocks first-person runtime builds when PlayerStart is missing", () => {
+ expect(() =>
+ buildRuntimeSceneFromDocument(createEmptySceneDocument({ name: "Missing Player Start" }), {
+ navigationMode: "firstPerson"
+ })
+ ).toThrow("First-person run requires an authored Player Start");
+ });
});
diff --git a/tests/domain/scene-document-validation.test.ts b/tests/domain/scene-document-validation.test.ts
new file mode 100644
index 00000000..e2348647
--- /dev/null
+++ b/tests/domain/scene-document-validation.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from "vitest";
+
+import { createBoxBrush } from "../../src/document/brushes";
+import { createEmptySceneDocument } from "../../src/document/scene-document";
+import { validateSceneDocument } from "../../src/document/scene-document-validation";
+import { createPlayerStartEntity } from "../../src/entities/entity-instances";
+
+describe("validateSceneDocument", () => {
+ it("accepts a valid first-room document", () => {
+ const brush = createBoxBrush({
+ id: "brush-room-shell"
+ });
+ const playerStart = createPlayerStartEntity({
+ id: "entity-player-start-main"
+ });
+ const document = {
+ ...createEmptySceneDocument({ name: "First Room" }),
+ brushes: {
+ [brush.id]: brush
+ },
+ entities: {
+ [playerStart.id]: playerStart
+ }
+ };
+
+ const validation = validateSceneDocument(document);
+
+ expect(validation.errors).toEqual([]);
+ expect(validation.warnings).toEqual([]);
+ });
+
+ it("detects duplicate authored ids across collections", () => {
+ const brush = createBoxBrush({
+ id: "shared-room-id"
+ });
+ const playerStart = createPlayerStartEntity({
+ id: "shared-room-id"
+ });
+ const document = {
+ ...createEmptySceneDocument(),
+ brushes: {
+ [brush.id]: brush
+ },
+ entities: {
+ "entity-player-start-main": playerStart
+ }
+ };
+
+ const validation = validateSceneDocument(document);
+
+ expect(validation.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "entity-id-mismatch"
+ }),
+ expect.objectContaining({
+ code: "duplicate-authored-id"
+ })
+ ])
+ );
+ });
+
+ it("detects invalid box sizes and missing material references", () => {
+ const brush = createBoxBrush({
+ id: "brush-invalid"
+ });
+ brush.size.x = 0;
+ brush.faces.posZ.materialId = "material-that-does-not-exist";
+
+ const validation = validateSceneDocument({
+ ...createEmptySceneDocument(),
+ brushes: {
+ [brush.id]: brush
+ }
+ });
+
+ expect(validation.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "invalid-box-size",
+ path: "brushes.brush-invalid.size"
+ }),
+ expect.objectContaining({
+ code: "missing-material-ref",
+ path: "brushes.brush-invalid.faces.posZ.materialId"
+ })
+ ])
+ );
+ });
+
+ it("detects invalid Player Start values", () => {
+ const validation = validateSceneDocument({
+ ...createEmptySceneDocument(),
+ entities: {
+ "entity-player-start-main": {
+ id: "entity-player-start-main",
+ kind: "playerStart",
+ position: {
+ x: 0,
+ y: Number.NaN,
+ z: 0
+ },
+ yawDegrees: Number.NaN
+ }
+ }
+ });
+
+ expect(validation.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "invalid-player-start-position"
+ }),
+ expect.objectContaining({
+ code: "invalid-player-start-yaw"
+ })
+ ])
+ );
+ });
+});
diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts
index 25bc13d0..8e4359e8 100644
--- a/tests/serialization/scene-document-json.test.ts
+++ b/tests/serialization/scene-document-json.test.ts
@@ -206,4 +206,57 @@ describe("scene document JSON", () => {
})
).toThrow("Unsupported scene document version");
});
+
+ it("rejects duplicate authored ids after migration and validation", () => {
+ expect(() =>
+ parseSceneDocumentJson(
+ JSON.stringify({
+ version: 4,
+ 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: {
+ "entity-player-start-main": {
+ id: "shared-id",
+ kind: "playerStart",
+ position: {
+ x: 0,
+ y: 0,
+ z: 0
+ },
+ yawDegrees: 0
+ }
+ },
+ interactionLinks: {}
+ })
+ )
+ ).toThrow("Duplicate authored id shared-id");
+ });
});