Files
gta6/tests/placesSite.test.js
T
knsoandClaude Sonnet 5 5e445c5a3e test(world): Sperrflächen, Startpunkt und Parkwege gegen die Daten prüfen
Startpunkt und Fahrzeugpunkte stehen jetzt in src/world/spawn.js, damit ein
Datentest sie lesen kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 08:49:42 +02:00

141 lines
7.3 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 { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE, chunkForWorld } from "../src/world/coords.js";
import { PLACES, PLACE_HIDE_RADIUS, PLACE_SHOW_RADIUS, placeTransform } from "../src/world/places.js";
import { lampPoints, pointInPolygon, vergeLampPoints } from "../src/world/props.js";
import { withoutReplacedBuildings } from "../src/world/streaming.js";
// Radien aus ChunkStreamer.update (src/world/streaming.js), gemessen zum Chunk-Mittelpunkt.
const CHUNK_LOAD_RADIUS = 400;
const CHUNK_UNLOAD_RADIUS = 600;
const STEP = 0.5; // Meter zwischen zwei Prüfpunkten auf der Ortsfläche
// Wie weit darf ein ersetzter Grundriss über die Außenmaße seines Orts hinausragen? Die Tribüne liegt
// heute 3,78 m vor der Rückseite ihres alten Kastens, Modell-z −18,60 gegen −14,82.
const REPLACED_OVERHANG = 5; // Meter
const cache = new Map();
function chunk(i, j) {
const key = `${i}_${j}`;
if (!cache.has(key)) {
cache.set(key, JSON.parse(readFileSync(new URL(`../public/data/vice-a/chunk_${key}.json`, import.meta.url), "utf8")));
}
return cache.get(key);
}
// Grundrisse, die ein Ort ersetzt, baut der Streamer nicht (withoutReplacedBuildings, verdrahtet in
// src/main.js) — für die Prüfung auf Gebäude zählen sie deshalb nicht.
const REPLACED = PLACES.flatMap((place) => place.replaces ?? []);
const standing = new Map();
function standingBuildings(i, j) {
const key = `${i}_${j}`;
if (!standing.has(key)) standing.set(key, withoutReplacedBuildings(chunk(i, j), REPLACED).buildings);
return standing.get(key);
}
/** Modellpunkt (x, z) -> Welt (X, Y), mit derselben Drehung wie three (rotation.y). */
function modelToWorld(place, x, z) {
const { position, rotationY } = placeTransform(place);
const cos = Math.cos(rotationY), sin = Math.sin(rotationY);
return { X: position.x + x * cos + z * sin, Y: -(position.z - x * sin + z * cos) };
}
/** Welt (X, Y) -> Modellpunkt (x, z), die Umkehrung von modelToWorld. */
function worldToModel(place, X, Y) {
const { position, rotationY } = placeTransform(place);
const cos = Math.cos(rotationY), sin = Math.sin(rotationY);
const dx = X - position.x, dz = -Y - position.z;
return { x: dx * cos - dz * sin, z: dx * sin + dz * cos };
}
function insideBounds(place, X, Y, margin = 0) {
const { x, z } = worldToModel(place, X, Y);
const { minX, maxX, minZ, maxZ } = place.bounds;
return x >= minX - margin && x <= maxX + margin && z >= minZ - margin && z <= maxZ + margin;
}
describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte", (_id, place) => {
const points = [];
for (let x = place.bounds.minX; x <= place.bounds.maxX + 1e-9; x += STEP) {
for (let z = place.bounds.minZ; z <= place.bounds.maxZ + 1e-9; z += STEP) points.push(modelToWorld(place, x, z));
}
const keys = [...new Set(points.map(({ X, Y }) => {
const { i, j } = chunkForWorld(X, Y);
return `${i}_${j}`;
}))];
const under = keys.map((key) => key.split("_").map(Number));
// Bruch: der Ort steht auf einer Straße, in einem Gebäude oder über Wasser — nach einem Verschieben
// in PLACES oder nach einem neuen Lauf der Extraktion. Gebäude, die ein Ort ersetzt, zählen nicht.
it("liegt ganz auf Land, auf keiner Straße und in keinem Gebäude", () => {
const bad = points.filter(({ X, Y }) => {
const { i, j } = chunkForWorld(X, Y);
const c = chunk(i, j);
const u = X - c.origin[0], v = -Y - c.origin[1];
const onLand = c.land.some((rings) => pointInPolygon(u, v, rings));
const onRoad = c.roads.some((road) => pointInPolygon(u, v, road.poly));
const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly));
return !onLand || onRoad || inBuilding;
});
expect(bad.slice(0, 5)).toEqual([]);
});
// Bruch: ein OSM-Weg läuft durch einen Ort, weil seine Sperrfläche in flamingo-park.toml fehlt oder verrutscht ist.
it("liegt auf keinem Parkweg", () => {
const bad = points.filter(({ X, Y }) => {
const { i, j } = chunkForWorld(X, Y);
const c = chunk(i, j);
return (c.paths ?? []).some((path) => pointInPolygon(X - c.origin[0], -Y - c.origin[1], path.poly));
});
expect(bad.slice(0, 5)).toEqual([]);
});
// Bruch: Nach einem neuen Lauf der Extraktion liegt das ersetzte Gebäude woanders oder ist geteilt, oder
// der Punkt trifft ein fremdes oder mit einem Nachbarn zusammengelegtes Gebäude. Der Punkt träfe dann
// nichts (der alte Kasten stünde wieder im Ort), mehrere Grundrisse, einen außerhalb, oder einen mit
// Ecken weit jenseits der Außenmaße.
it.runIf((place.replaces ?? []).length > 0)("ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich", () => {
const bad = place.replaces.filter(([X, Y]) => {
const { i, j } = chunkForWorld(X, Y);
const c = chunk(i, j);
const hits = c.buildings.filter((building) => pointInPolygon(X - c.origin[0], -Y - c.origin[1], building.poly));
if (hits.length !== 1 || !insideBounds(place, X, Y)) return true;
const corners = hits[0].poly.flat(1);
return corners.some(([u, v]) => !insideBounds(place, c.origin[0] + u, -(c.origin[1] + v), REPLACED_OVERHANG));
});
expect(bad).toEqual([]);
});
// Bruch: eine Palme oder Laterne mitten im Ort. Beide entstehen je Chunk und wissen nichts von Orten;
// Laternen stehen bis 16 m neben ihrer Mittellinie, deshalb zählen auch die Nachbarchunks.
it("enthält keine Palme und keine Laterne", () => {
const nearby = new Set();
for (const [i, j] of under) {
for (let di = -1; di <= 1; di++) {
for (let dj = -1; dj <= 1; dj++) {
if (i + di >= 0 && i + di < CHUNKS_PER_SIDE && j + dj >= 0 && j + dj < CHUNKS_PER_SIDE) nearby.add(`${i + di}_${j + dj}`);
}
}
}
const props = [...nearby].flatMap((key) => {
const c = chunk(...key.split("_").map(Number));
const toWorld = (u, v) => ({ X: c.origin[0] + u, Y: -(c.origin[1] + v) });
const palms = (c.palms ?? []).map(([u, v]) => toWorld(u, v));
const lamps = vergeLampPoints(lampPoints(c.centerlines ?? []), c.roads ?? []).map((p) => toWorld(p.x, p.z));
return [...palms, ...lamps];
});
expect(props.filter(({ X, Y }) => insideBounds(place, X, Y))).toEqual([]);
});
// Bruch: der Ort erscheint, bevor der Boden unter ihm angefordert ist, und steht dann über dem
// Meer — oder der Boden wird abgebaut, während der Ort noch in der Szene ist.
it("ist nur in der Szene, solange die Chunks unter ihm angefordert sind", () => {
const { position } = placeTransform(place);
const farthest = Math.max(...under.map(([i, j]) => Math.hypot(
SLICE.x0 + (i + 0.5) * CHUNK_SIZE - position.x,
-(SLICE.y1 - (j + 0.5) * CHUNK_SIZE) - position.z,
)));
expect(farthest + PLACE_SHOW_RADIUS).toBeLessThanOrEqual(CHUNK_LOAD_RADIUS);
expect(farthest + PLACE_HIDE_RADIUS).toBeLessThanOrEqual(CHUNK_UNLOAD_RADIUS);
});
});