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