Ruling 62: PropLayer-Geometrien (Palme, Laternenteile, Stammkollider) sind Singletons über alle Chunks; #unload gab bei jedem Nachbarabbau ihre GPU-Puffer frei. mesh.userData.sharedGeometry markiert sie, #unload überspringt sie jetzt beim Entsorgen. Ruling 63: geschlossene Ringe (Laufzeitform) waren in keiner Testvorrichtung gepinnt, obwohl der Code sie bereits korrekt behandelte. Ruling 64: der Zweig `if (this.props)` bekommt direkte Testabdeckung statt nur der manuellen Probe aus Fix-Runde 1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
8.7 KiB
JavaScript
178 lines
8.7 KiB
JavaScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { BufferGeometry, Group, Mesh } from "three";
|
|
import { ChunkStreamer, wantedChunks } 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);
|
|
});
|
|
});
|
|
|
|
// 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) {
|
|
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 }), 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();
|
|
});
|
|
});
|
|
|
|
// 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 });
|
|
});
|
|
});
|