import { afterEach, describe, expect, it, vi } from "vitest"; import { BufferGeometry, Group, Mesh } from "three"; import { ChunkStreamer, wantedChunks, withoutReplacedBuildings } from "../src/world/streaming.js"; describe("wantedChunks", () => { it("lädt die Umgebung des Spielers", () => { const { add } = wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() }); expect(add.length).toBeGreaterThan(4); expect(add.length).toBeLessThan(30); }); it("entlädt erst jenseits des größeren Radius", () => { // Chunk 5_5 hat sein Zentrum bei (375, -1125) (SLICE.x0 + 5.5*CHUNK_SIZE, ...). // "near" liegt hier bewusst 500 Einheiten entfernt: außerhalb des Laderadius (400), // aber innerhalb des Entladeradius (600) — genau die Hysterese, die der Testname // behauptet. Der ursprüngliche Brief-Wert {x:1375, z:-1375} traf exakt das Zentrum // von Chunk 9_4 (Distanz zu 5_5: ~1030, weit jenseits von 600) und ließ die eigene // Behauptung "not.toContain" fehlschlagen — siehe Task-8-Report für den RED-Beleg. const active = new Set(["5_5"]); const near = wantedChunks({ x: 875, z: -1125 }, { load: 400, unload: 600, active }); expect(near.remove).not.toContain("5_5"); const far = wantedChunks({ x: -900, z: -2400 }, { load: 400, unload: 600, active }); expect(far.remove).toContain("5_5"); }); it("meldet nichts doppelt an", () => { const active = new Set(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() }).add); expect(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active }).add).toHaveLength(0); }); }); describe("withoutReplacedBuildings", () => { // Chunk 9_5 (J6) hat den Ursprung [1250, -1250]: Welt (x, y) liegt im Chunk bei u = x − 1250, v = −y + 1250. const square = (u, v) => [[[u, v], [u + 10, v], [u + 10, v + 10], [u, v + 10], [u, v]]]; const chunk = { chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [], buildings: [ { poly: square(150, 190), height: 18.6 }, // Welt x 1400–1410, y 1050–1060 { poly: square(100, 100), height: 10 }, // Welt x 1350–1360, y 1140–1150 ], }; // Bruch: Ursprung vergessen oder Welt-y nicht gespiegelt — der Punkt träfe nichts, und der alte Kasten // stünde mitten im Ort; oder die Eingabe wird verändert statt kopiert. it("lässt nur den Grundriss weg, in dem ein Punkt liegt, gerechnet in Chunk-Koordinaten", () => { const result = withoutReplacedBuildings(chunk, [[1405, 1055]]); expect(result.buildings).toEqual([chunk.buildings[1]]); expect(result.land).toBe(chunk.land); expect(chunk.buildings).toHaveLength(2); }); it("gibt ohne Treffer den Chunk selbst zurück", () => { expect(withoutReplacedBuildings(chunk, [])).toBe(chunk); expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk); }); // Bruch: eine Zuordnung des Punkts per chunkForWorld ordnete ihn nur chunk_9_5 zu (i = 9) und ließe die // ungeschnittene Kopie desselben Grundrisses in chunk_10_5 stehen — genau das Gebäude, das der Ort ersetzt. it("lässt denselben, ungeschnittenen Grundriss in beiden berührten Chunks weg", () => { const chunkA = { chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [], buildings: [{ poly: square(245, 190), height: 18.6 }], // Welt x 1495–1505, y 1050–1060 }; const chunkB = { chunk: [10, 5], origin: [1500, -1250], land: [], surfaces: [], roads: [], buildings: [{ poly: square(-5, 190), height: 18.6 }], // dieselbe Welt-Fläche, andere u }; const points = [[1498, 1055]]; expect(withoutReplacedBuildings(chunkA, points).buildings).toEqual([]); expect(withoutReplacedBuildings(chunkB, points).buildings).toEqual([]); }); }); // Gemeinsame Vorrichtung für beide ChunkStreamer-describe-Blöcke unten (mit und ohne PropLayer). // Realer Chunk ohne jede Geometrie — offenes Wasser. Entspricht wörtlich chunk_4_6.json // (eines von vier: 4_6, 4_10, 5_6, 5_10 — per public/data/vice-a nachgezählt, Ruling 51). const emptyChunk = { chunk: [4, 6], origin: [0, -1000], land: [], surfaces: [], roads: [], buildings: [] }; // Zentrum von Chunk 4_6 in three-Koordinaten: SLICE.x0 + 4.5*CHUNK_SIZE, -(SLICE.y1 - 6.5*CHUNK_SIZE). const emptyChunkCenter = { x: 125, z: -875 }; function makeStreamer(chunkData, props, pinned, replacedBuildings) { vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => chunkData }))); const player = { addCollider: vi.fn(() => ({ id: 42 })), removeCollider: vi.fn(), // ready:false hier ist bewusst: ein echter (nicht-null) Handle wäre damit NIE bereit, // sodass "isReadyAt sofort true" nur bestehen kann, wenn Ruling 51 tatsächlich greift // und gar kein Handle erst registriert wird — sonst wäre der Test kein Regressionstest. collisionWorld: { get: vi.fn(() => ({ ready: false })) }, }; const scene = { add: vi.fn(), remove: vi.fn() }; return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene }; } // #load() ist privat und läuft nebenläufig; zwei Mikrotask-Runden reichen, um // fetch() -> res.json() -> Geometrieaufbau vollständig abzuwarten (siehe #fetchChunk/#load). const flush = () => new Promise((r) => setTimeout(r, 0)).then(() => new Promise((r) => setTimeout(r, 0))); describe("ChunkStreamer", () => { afterEach(() => { vi.unstubAllGlobals(); }); it("registriert für einen Chunk ganz ohne Kollisionsgeometrie keinen Collider (Ruling 51)", async () => { const { streamer, player } = makeStreamer(emptyChunk); streamer.update(emptyChunkCenter); // Zentrum -> garantiert der nächstliegende, zuerst geladene Chunk. await flush(); expect(player.addCollider).not.toHaveBeenCalled(); expect(streamer.active.get("4_6")).toMatchObject({ handle: null }); }); it("meldet einen collider-losen Chunk sofort als bereit", async () => { const { streamer } = makeStreamer(emptyChunk); streamer.update(emptyChunkCenter); await flush(); expect(streamer.isReadyAt(emptyChunkCenter)).toBe(true); }); it("entlädt einen collider-losen Chunk, ohne removeCollider aufzurufen", async () => { const { streamer, player } = makeStreamer(emptyChunk); streamer.update(emptyChunkCenter); await flush(); expect(streamer.active.has("4_6")).toBe(true); // Weit weg -> jenseits des Entladeradius (600) -> #unload("4_6") im selben update(). streamer.update({ x: emptyChunkCenter.x + 5000, z: emptyChunkCenter.z }); expect(player.removeCollider).not.toHaveBeenCalled(); expect(streamer.active.has("4_6")).toBe(false); }); // Fix-Runde 2, Befund A / Ruling 62: this.palmGeometry, das Palmenmaterial und die // Laternenteile entstehen einmal in PropLayer.load() und werden von jedem Chunk benutzt; // #unload lief zuvor über die ganze Chunk-Gruppe und entsorgte sie mit, während // Nachbarchunks sie noch anzeigten. mesh.userData.sharedGeometry markiert, was verschont // bleibt — chunk-eigene Geometrie (hier: die Landfläche aus buildChunkGeometry) muss // weiterhin entsorgt werden. it("entsorgt beim Abmelden chunk-eigene Geometrie, aber keine als geteilt markierte (Ruling 62)", async () => { const landChunk = { chunk: [4, 6], origin: [0, -1000], land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]], surfaces: [], roads: [], buildings: [], }; const sharedGeometry = new BufferGeometry(); const sharedDispose = vi.spyOn(sharedGeometry, "dispose"); const sharedMesh = new Mesh(sharedGeometry); sharedMesh.userData.sharedGeometry = true; const props = { build: () => { const visual = new Group(); visual.add(sharedMesh); return { visual, colliders: [] }; } }; const { streamer } = makeStreamer(landChunk, props); streamer.update(emptyChunkCenter); await flush(); // Chunk-eigene Geometrie (aus buildChunkGeometry, nicht als geteilt markiert) abgreifen // und ihr dispose bespitzeln, bevor das Abmelden läuft. const entry = streamer.active.get("4_6"); let ownGeometry; entry.group.traverse((o) => { if (o.isMesh && o !== sharedMesh && o.geometry && !ownGeometry) ownGeometry = o.geometry; }); expect(ownGeometry).toBeTruthy(); // sonst testet dieser Test nichts const ownDispose = vi.spyOn(ownGeometry, "dispose"); // Weit weg -> jenseits des Entladeradius (600) -> #unload("4_6") im selben update(). streamer.update({ x: emptyChunkCenter.x + 5000, z: emptyChunkCenter.z }); expect(ownDispose).toHaveBeenCalledTimes(1); expect(sharedDispose).not.toHaveBeenCalled(); }); // Task 11, Schritt 5: die vier Fahrzeug-Chunks werden festgehalten, weil ein Fahrzeug // ohne Boden ins Nichts fällt — #unload muss sie überspringen, statt sie beim // gewöhnlichen Entfernen-jenseits-des-Radius-Durchlauf trotzdem abzubauen. Mit echter // Landgeometrie (nicht emptyChunk), damit ein echtes Collider-Handle entsteht und die // removeCollider-Zusicherung unten etwas prüft, statt nach Ruling 51 trivial wahr zu sein. it("entlädt einen angehefteten Chunk nicht, auch jenseits des Entladeradius (Task 11)", async () => { const landChunk = { chunk: [4, 6], origin: [0, -1000], land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]], surfaces: [], roads: [], buildings: [], }; const { streamer, player } = makeStreamer(landChunk, undefined, ["4_6"]); streamer.update(emptyChunkCenter); await flush(); expect(streamer.active.has("4_6")).toBe(true); expect(player.addCollider).toHaveBeenCalledTimes(1); // sonst prüft removeCollider unten nichts // Weit weg -> jenseits des Entladeradius (600); ohne pinned wäre "4_6" nun weg // (siehe Test oben, gleiches Setup ohne pinned). streamer.update({ x: emptyChunkCenter.x + 5000, z: emptyChunkCenter.z }); expect(streamer.active.has("4_6")).toBe(true); expect(player.removeCollider).not.toHaveBeenCalled(); }); // Bruch: der Streamer baut die Geometrie aus dem ungefilterten Chunk — der alte Kasten stünde samt // Kollision mitten in dem Ort, der ihn ersetzt. it("baut ein Gebäude, das ein Ort ersetzt, weder als Kasten noch in die Kollision", async () => { const chunk = { chunk: [4, 6], origin: [0, -1000], land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]], surfaces: [], roads: [], buildings: [{ poly: [[[10, 10], [20, 10], [20, 20], [10, 20]]], height: 12 }], }; // Gegenprobe ohne Ersatz: Land und Gebäude liegen in der Kollisionsgruppe. const plain = makeStreamer(chunk); plain.streamer.update(emptyChunkCenter); await flush(); expect(plain.player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(2); // Mitte des Gebäudes: u 15 -> Welt-x 15, v 15 -> Welt-y −(−1000 + 15) = 985. const { streamer, player } = makeStreamer(chunk, undefined, undefined, [[15, 985]]); streamer.update(emptyChunkCenter); await flush(); const source = player.addCollider.mock.calls[0][0].shape.source; expect(source.children).toHaveLength(1); expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude }); // Bruch: eine neue Schicht fehlt in DECALS — Weg oder Feld landete in der Kollision und die Kapsel stolperte // über 2–3 cm hohe Kanten, oder die Schicht fehlte ganz in der Szene. it("legt Parkwege und die neuen Flächenarten als Auflagen ohne Kollision in die Szene", async () => { const square = [[[0, 0], [20, 0], [20, 20], [0, 20]]]; const chunk = { chunk: [4, 6], origin: [0, -1000], land: [square], roads: [], buildings: [], surfaces: ["field", "court", "pool", "parking"].map((kind) => ({ poly: square, kind })), paths: [{ poly: square }], }; const { streamer, player, scene } = makeStreamer(chunk); streamer.update(emptyChunkCenter); await flush(); expect(player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(1); // nur die Landplatte const group = scene.add.mock.calls[0][0]; expect(group.children.filter((child) => child.isMesh)).toHaveLength(5); }); // Teil 2: Fahrbahn und Bordstein sind Kollision — man fährt in der Mulde, und der Bordstein ist die // Stufe zum Gehweg. Gehweg, Markierung und Restflächen bleiben Auflagen. it("legt Fahrbahn und Bordstein in die Kollision, Gehweg, Markierung und Restfläche als Auflagen in die Szene", async () => { const square = [[[0, 0], [20, 0], [20, 20], [0, 20]]]; const chunk = { chunk: [4, 6], origin: [0, -1000], land: [square], surfaces: [], buildings: [], carriageway: [{ poly: [[[0, 20], [20, 20], [20, 30], [0, 30]]] }], curb: [[[0, 20], [20, 20]]], sidewalk: [{ poly: [[[0, 17], [20, 17], [20, 20], [0, 20]]] }], markings: [{ poly: [[[2, 25], [5, 25], [5, 25.1], [2, 25.1]]], art: "gelb_gestrichelt" }], lots: [{ poly: [[[0, 0], [5, 0], [5, 5], [0, 5]]] }], }; const { streamer, player, scene } = makeStreamer(chunk); streamer.update(emptyChunkCenter); await flush(); expect(player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(3); // Landplatte, Fahrbahn, Bordstein const group = scene.add.mock.calls[0][0]; expect(group.children.filter((child) => child.isMesh)).toHaveLength(3); // Gehweg, Markierung, Restfläche }); }); // Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine // automatische Abdeckung — nur von Hand belegt (Entfernen -> drei Tests fallen), siehe // Task-10-Bericht. Ein Platzhalter-PropLayer im Konstruktor deckt das jetzt ab. describe("ChunkStreamer mit PropLayer", () => { afterEach(() => { vi.unstubAllGlobals(); }); it("ruft props.build() je geladenem Chunk einmal auf und hängt sein visual in die Szene", async () => { const propsVisual = new Group(); const props = { build: vi.fn(() => ({ visual: propsVisual, colliders: [] })) }; const { streamer, scene } = makeStreamer(emptyChunk, props); streamer.update(emptyChunkCenter); await flush(); expect(props.build).toHaveBeenCalledTimes(1); expect(props.build).toHaveBeenCalledWith(emptyChunk); expect(scene.add).toHaveBeenCalledTimes(1); const sceneGroup = scene.add.mock.calls[0][0]; expect(sceneGroup.children).toContain(propsVisual); }); it("hängt colliders von props.build() in dieselbe BVH-Gruppe wie die Chunk-Kollision", async () => { const trunk = new Mesh(); const props = { build: vi.fn(() => ({ visual: new Group(), colliders: [trunk] })) }; const { streamer, player } = makeStreamer(emptyChunk, props); streamer.update(emptyChunkCenter); await flush(); // emptyChunk hat sonst keine Kollisionsgeometrie (Ruling 51) — der Stamm allein // macht den Chunk-Collider nicht-leer, addCollider muss also aufgerufen werden. expect(player.addCollider).toHaveBeenCalledTimes(1); const source = player.addCollider.mock.calls[0][0].shape.source; expect(source.children).toContain(trunk); }); it("läuft ohne props weiterhin fehlerfrei (props ist seit Task 8 optional)", async () => { const { streamer } = makeStreamer(emptyChunk /* kein props */); streamer.update(emptyChunkCenter); await flush(); expect(streamer.active.get("4_6")).toMatchObject({ handle: null }); }); });