Files
gta6/tests/placesSite.test.js
T
knsoandClaude Sonnet 5 237a4ed389 test(world): ersetzte Grundrisse und feste Materialien je Ort strenger prüfen
Behebt Befunde 2-6 aus dem Abschluss-Review: der replaces-Test prüft
jetzt zusätzlich jede Ecke des getroffenen Grundrisses gegen die
Außenmaße plus REPLACED_OVERHANG (5 m), der Verteilungstest vergleicht
als Mengen und bekommt eine Gegenrichtung je Datei, BUMP_BY_PLACE
erzwingt eine Zeile je Ort statt sie mit "?? []" zu verdecken, ein
Gebäude an einer Chunkgrenze bekommt einen Test über zwei Chunks samt
JSDoc-Hinweis, und der Rückwand-Messwert in der Spec nennt Kapselmitte,
Wandfläche und Radius einzeln.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 03:57:31 +02:00

131 lines
6.8 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: 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);
});
});