import { afterEach, describe, expect, it, vi } from "vitest"; import { BoxGeometry, BufferAttribute, BufferGeometry, Group, InterleavedBuffer, InterleavedBufferAttribute, Mesh, MeshStandardMaterial, Scene, Vector3 } from "three"; import { PLACES, PlaceLayer, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel, wantedPlaces } from "../src/world/places.js"; describe("placeTransform", () => { // Bruch: Welt-y nicht negiert (der Hof landete 2 km weiter südlich im Meer) oder lift nicht in y. it("rechnet Welt-x/-y in three um und hebt um lift", () => { const transform = placeTransform({ at: [1489, 1067.5], rotation: 0, lift: -0.28 }); expect(transform.position).toEqual({ x: 1489, y: -0.28, z: -1067.5 }); expect(transform.rotationY).toBe(0); }); // Bruch: Grad nicht ins Bogenmaß umgerechnet oder falsche Drehrichtung. "Gegen den Uhrzeigersinn, // Norden oben" heißt in three: +x (Osten) wird zu −z (Norden). it("dreht mit positivem Winkel Osten nach Norden", () => { const root = new Group(); root.rotation.y = placeTransform({ at: [0, 0], rotation: 90, lift: 0 }).rotationY; root.updateMatrixWorld(); const east = new Vector3(1, 0, 0).applyMatrix4(root.matrixWorld); expect(east.x).toBeCloseTo(0); expect(east.z).toBeCloseTo(-1); }); }); describe("wantedPlaces", () => { const place = { id: "hof", at: [1000, 1000], rotation: 0, lift: 0 }; const south = (distance) => ({ x: 1000, z: -1000 + distance }); const radii = { show: 250, hide: 450 }; it("setzt innerhalb des show-Radius ein", () => { expect(wantedPlaces(south(240), [place], { ...radii, active: new Set() }).add).toEqual(["hof"]); expect(wantedPlaces(south(260), [place], { ...radii, active: new Set() }).add).toEqual([]); }); // Bruch: ein Radius für Ein- und Ausblenden — an der Grenze flackerte der Hof mit jedem Schritt. it("nimmt erst jenseits des hide-Radius heraus", () => { const active = new Set(["hof"]); expect(wantedPlaces(south(400), [place], { ...radii, active }).remove).toEqual([]); expect(wantedPlaces(south(460), [place], { ...radii, active }).remove).toEqual(["hof"]); }); it("meldet einen aktiven Ort nicht doppelt an", () => { expect(wantedPlaces(south(10), [place], { ...radii, active: new Set(["hof"]) }).add).toEqual([]); }); }); describe("isSolidMaterial", () => { // Bruch: Gras oder Blätter im Collider (man bliebe im Gras hängen), oder ein fester Teil fehlt // (man liefe durch Container, Schuppen oder Zaun). it("lässt feste Teile kollidieren, Gras, Blätter und Kleinteile nicht", () => { for (const name of ["gravel", "cont_a_side", "ironTeal", "chainlink_wire", "barrel_03", "quiver_tree_01_trunk"]) { expect(isSolidMaterial(name)).toBe(true); } for (const name of ["grass_medium_01", "quiver_tree_01_leaf", "searsia_lucida_leaves", "trashbag", undefined]) { expect(isSolidMaterial(name)).toBe(false); } }); // Bruch: Die Tribüne wäre an Beton, Stahl, Dach, Glas oder den Anschlusskästen durchlässig, oder man // bliebe an Sitzschalen, Kabeln und Wandleuchten hängen (zwischen den Sitzreihen sind nur 38 cm). it("lässt Beton, Stahl, Dach, Glas und Anschlusskästen der Tribüne kollidieren, Sitze, Kabel und Leuchten nicht", () => { for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01"]) { expect(isSolidMaterial(name)).toBe(true); } for (const name of ["Sitzschale", "modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp", "industrial_wall_lamp_glass", "power_box_01"]) { expect(isSolidMaterial(name)).toBe(false); } }); it("kennt jedes Material der Liste", () => { expect(SOLID_MATERIALS.every(isSolidMaterial)).toBe(true); }); }); /** Ein Mesh je Materialname unter einer Wurzel, wie GLTFLoader es nach join() liefert. */ function fakeModel(names = ["cont_a_side", "grass_medium_01"]) { const root = new Group(); for (const name of names) root.add(new Mesh(new BoxGeometry(), new MeshStandardMaterial({ name }))); return root; } describe("prepareModel", () => { // Bruch: Lage nicht gesetzt, Schatten aus, oder feste Teile fälschlich aus der Kollision genommen. it("setzt Lage und Schatten und nimmt nur nicht feste Teile aus der Kollision", () => { const root = fakeModel(); const [container, grass] = root.children; expect(prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 })).toBe(true); expect(root.position.toArray()).toEqual([1489, -0.28, -1067.5]); expect(container.userData.excludeFromCollider).toBeUndefined(); expect(grass.userData.excludeFromCollider).toBe(true); for (const mesh of [container, grass]) { expect(mesh.castShadow).toBe(true); expect(mesh.receiveShadow).toBe(true); } }); it("meldet, wenn kein Teil fest ist", () => { expect(prepareModel(fakeModel(["grass_medium_01"]), { at: [0, 0], rotation: 0, lift: 0 })).toBe(false); }); // Bruch: colliderBuild.ts im three-player-controller klont die Geometrie und rechnet matrixWorld // per applyMatrix4 hinein. Bei einer normalisierten Int16-Position (KHR_mesh_quantization aus // meshopt() in tools/assets/places.mjs) klemmt das Zurückschreiben die Weltkoordinaten auf ±1 — // der Hof kollidierte dann nahe dem Weltursprung statt an seiner Stelle im Hood Yard. it("entquantisiert feste Positionen, bevor der Collider die Weltmatrix hineinrechnet", () => { const geometry = new BufferGeometry(); geometry.setAttribute("position", new BufferAttribute(new Int16Array([-32767, 0, -32767, 32767, 32767, 32767]), 3, true)); const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "cont_a_side" })); mesh.scale.setScalar(17); const root = new Group(); root.add(mesh); prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 }); expect(mesh.geometry.attributes.position.array).toBeInstanceOf(Float32Array); expect(mesh.geometry.attributes.position.normalized).toBe(false); // Wie colliderBuild.ts: Geometrie klonen und die Weltmatrix hineinrechnen. const worldGeometry = mesh.geometry.clone(); worldGeometry.applyMatrix4(mesh.matrixWorld); worldGeometry.computeBoundingBox(); expect(worldGeometry.boundingBox.min.x).toBeCloseTo(1472); expect(worldGeometry.boundingBox.max.x).toBeCloseTo(1506); expect(worldGeometry.boundingBox.min.z).toBeCloseTo(-1084.5); expect(worldGeometry.boundingBox.max.z).toBeCloseTo(-1050.5); }); // Bruch: GLTFLoader liefert quantisierte Positionen in echt als InterleavedBufferAttribute // (gemeinsamer Puffer mit weiteren Attributen wie Normale und UV), nicht als eigene // BufferAttribute — eine Entquantisierung, die nur den einfachen Fall abdeckt, ließe die echten // Modelldaten quantisiert und der Hof kollidierte wie oben nahe dem Weltursprung. it("entquantisiert feste Positionen auch als interleaved Attribut", () => { const buffer = new InterleavedBuffer( new Int16Array([-32767, 0, -32767, 0, 0, 0, 0, 0, 32767, 32767, 32767, 0, 0, 0, 0, 0]), 8); const geometry = new BufferGeometry(); geometry.setAttribute("position", new InterleavedBufferAttribute(buffer, 3, 0, true)); const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "cont_a_side" })); mesh.scale.setScalar(17); const root = new Group(); root.add(mesh); prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 }); expect(mesh.geometry.attributes.position.array).toBeInstanceOf(Float32Array); expect(mesh.geometry.attributes.position.normalized).toBe(false); // Wie colliderBuild.ts: Geometrie klonen und die Weltmatrix hineinrechnen. const worldGeometry = mesh.geometry.clone(); worldGeometry.applyMatrix4(mesh.matrixWorld); worldGeometry.computeBoundingBox(); expect(worldGeometry.boundingBox.min.x).toBeCloseTo(1472); expect(worldGeometry.boundingBox.max.x).toBeCloseTo(1506); expect(worldGeometry.boundingBox.min.z).toBeCloseTo(-1084.5); expect(worldGeometry.boundingBox.max.z).toBeCloseTo(-1050.5); }); it("lässt eine durchlässige Position quantisiert", () => { const geometry = new BufferGeometry(); const position = new BufferAttribute(new Int16Array([0, 0, 0, 32767, 32767, 32767]), 3, true); geometry.setAttribute("position", position); const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "grass_medium_01" })); const root = new Group(); root.add(mesh); prepareModel(root, { at: [0, 0], rotation: 0, lift: 0 }); expect(mesh.geometry.attributes.position).toBe(position); }); }); describe("PLACES", () => { // Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds // prüften nichts; oder replaces ist keine Liste von Punkten [x, y], und der Streamer ließe nichts weg. it("hat für jeden Ort id, url, at, rotation, lift und bounds, dazu optional replaces mit Weltpunkten", () => { for (const { replaces = [], ...entry } of PLACES) { expect(entry).toEqual({ id: expect.any(String), url: expect.stringMatching(/^\.\/models\/places\/.+\.glb$/), at: [expect.any(Number), expect.any(Number)], rotation: expect.any(Number), lift: expect.any(Number), bounds: { minX: expect.any(Number), maxX: expect.any(Number), minZ: expect.any(Number), maxZ: expect.any(Number) }, }); for (const point of replaces) expect(point).toEqual([expect.any(Number), expect.any(Number)]); } }); }); describe("PlaceLayer", () => { afterEach(() => vi.restoreAllMocks()); const place = { id: "hof", url: "./hof.glb", at: [1489, 1067.5], rotation: 0, lift: -0.28 }; // Spieler distance Meter südlich der Ortsmitte (three: +z ist Süden). const south = (distance) => ({ x: 1489, y: 0, z: -1067.5 + distance }); function makeLayer(load, { warmUp } = {}) { const scene = new Scene(); const player = { addCollider: vi.fn(() => ({ id: 7 })), removeCollider: vi.fn() }; const layer = new PlaceLayer({ scene, player, places: [place], load, warmUp }); return { scene, player, layer }; } // Bruch: Einsetzen schon beim Vorladen (der Hof stünde über ungeladenem Boden) oder erst nach // einem zweiten Laden. it("lädt ab 600 m vor und setzt erst ab 250 m ein", async () => { const model = fakeModel(); const load = vi.fn(async () => model); const { scene, player, layer } = makeLayer(load); layer.update(south(500)); await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true)); expect(load).toHaveBeenCalledWith("./hof.glb"); expect(scene.children).not.toContain(model); expect(player.addCollider).not.toHaveBeenCalled(); layer.update(south(240)); expect(scene.children).toContain(model); expect(player.addCollider).toHaveBeenCalledWith({ motion: "static", useWorker: true, shape: { kind: "mesh", source: model }, }); }); // Bruch: Collider bleibt nach dem Herausnehmen stehen (unsichtbare Wand) oder Flackern an der Grenze. it("nimmt den Ort erst jenseits von 450 m heraus und meldet den Collider ab", async () => { const model = fakeModel(); const { scene, player, layer } = makeLayer(vi.fn(async () => model)); layer.update(south(100)); await vi.waitFor(() => expect(scene.children).toContain(model)); layer.update(south(400)); expect(scene.children).toContain(model); expect(player.removeCollider).not.toHaveBeenCalled(); layer.update(south(460)); expect(scene.children).not.toContain(model); expect(player.removeCollider).toHaveBeenCalledWith({ id: 7 }); }); it("setzt nach der Rückkehr ohne neues Laden wieder ein", async () => { const model = fakeModel(); const load = vi.fn(async () => model); const { scene, player, layer } = makeLayer(load); layer.update(south(100)); await vi.waitFor(() => expect(scene.children).toContain(model)); layer.update(south(700)); layer.update(south(100)); expect(scene.children).toContain(model); expect(load).toHaveBeenCalledTimes(1); expect(player.addCollider).toHaveBeenCalledTimes(2); }); // Bruch: ein spät fertiges Laden setzt den Ort ein, obwohl der Spieler längst weitergefahren ist. it("lässt den Ort draußen, wenn der Spieler beim Ende des Ladens schon fort ist", async () => { const model = fakeModel(); let finish; const { scene, player, layer } = makeLayer(vi.fn(() => new Promise((resolve) => { finish = resolve; }))); layer.update(south(100)); layer.update(south(500)); finish(model); await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true)); expect(scene.children).not.toContain(model); expect(player.addCollider).not.toHaveBeenCalled(); layer.update(south(100)); expect(scene.children).toContain(model); }); it("meldet ohne feste Teile keinen Collider an", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const model = fakeModel(["grass_medium_01"]); const { scene, player, layer } = makeLayer(vi.fn(async () => model)); layer.update(south(100)); await vi.waitFor(() => expect(scene.children).toContain(model)); layer.update(south(460)); expect(player.addCollider).not.toHaveBeenCalled(); expect(player.removeCollider).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof")); }); // Bruch: ein fehlender Ort bricht das Spiel ab oder wird in jedem Frame neu angefragt. it("warnt bei einem Ladefehler, wirft nicht und versucht es nicht erneut", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const load = vi.fn(async () => { throw new Error("404"); }); const { scene, layer } = makeLayer(load); expect(() => layer.update(south(100))).not.toThrow(); await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error))); layer.update(south(100)); expect(load).toHaveBeenCalledTimes(1); expect(scene.children).toHaveLength(0); }); // Bruch: warmUp läuft erst nach dem Einsetzen oder gar nicht — der einmalige Stall durch // Texturupload und Shader-Übersetzung (~130 ms, gemessen im Spiel) bliebe dann im ersten Bild, // statt im Vorladen zu verschwinden. it("wärmt ein Modell vor dem Einsetzen einmalig vor", async () => { const model = fakeModel(); const load = vi.fn(async () => model); const warmUp = vi.fn(async (root) => { expect(scene.children).not.toContain(root); }); const { scene, player, layer } = makeLayer(load, { warmUp }); layer.update(south(100)); await vi.waitFor(() => expect(scene.children).toContain(model)); expect(warmUp).toHaveBeenCalledTimes(1); expect(warmUp).toHaveBeenCalledWith(model); }); // Bruch: eine fehlschlagende Vorwärmung lässt den Ort für immer draußen, obwohl das Modell // längst geladen und vorbereitet ist. it("setzt einen Ort trotz fehlgeschlagener Vorwärmung ein und warnt", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const model = fakeModel(); const load = vi.fn(async () => model); const warmUp = vi.fn(async () => { throw new Error("Kontextverlust"); }); const { scene, layer } = makeLayer(load, { warmUp }); layer.update(south(100)); await vi.waitFor(() => expect(scene.children).toContain(model)); expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error)); }); // Bruch: addCollider wirft, scene.add lief aber schon davor — die Wurzel bliebe unsichtbar und // für immer in der Szene, ohne active-Eintrag also nie mehr entfernbar; oder die Ausnahme entkäme // synchron aus update() in die Spielschleife (#show läuft hier direkt, nicht über die Ladekette). it("hält die Szene unverändert und warnt, wenn addCollider beim Einsetzen wirft", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const model = fakeModel(); const load = vi.fn(async () => model); const { scene, player, layer } = makeLayer(load); player.addCollider.mockImplementation(() => { throw new Error("Worker tot"); }); layer.update(south(500)); await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true)); expect(() => layer.update(south(100))).not.toThrow(); expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error)); expect(scene.children).not.toContain(model); const callsBefore = player.addCollider.mock.calls.length; layer.update(south(500)); layer.update(south(100)); expect(scene.children).not.toContain(model); expect(player.addCollider.mock.calls.length).toBe(callsBefore); }); });