Files
gta6/tests/streaming.test.js
T
knsoandClaude Opus 5 e33c1817ec feat(world): Gebäude, die ein Ort ersetzt, beim Chunk-Aufbau weglassen
withoutReplacedBuildings filtert die Grundrisse, in denen ein Weltpunkt aus PLACES[].replaces liegt; ChunkStreamer baut die Geometrie daraus, main.js übergibt die Punkte aller Orte. Damit entfallen Kasten, Schatten und Kollision des alten Gebäudes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:13:47 +02:00

255 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
// 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
});
});
// 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 });
});
});