Files
gta6/tests/places.test.js
T
knsoandClaude Sonnet 5 ae0f6106bb feat(world): Materialrollen der Tribüne, Asset-Test je Ort
Fest: Sichtbeton, Estrich, Verkleidung, Stahl, Trapezblech, Riffelblech, Verglasung, utility_box_01. Nahflächen: Sichtbeton, Estrich, Verkleidung, Stahl, Sitzschale. Kabel, Kabeldosen, Wandleuchten und Kästen auf 0,25. Der Asset-Test prüft jede Datei aus PLACES gegen ihre eigene Zeile fester Materialien statt gegen die ganze globale Liste, Bump nur beim Hood Yard.

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

364 lines
17 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 { 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.
it("hat für jeden Ort id, url, at, rotation, lift und bounds", () => {
for (const 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) },
});
}
});
});
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);
});
});