From 16eb651308d9eb768d8c374af61c1167b3e40917 Mon Sep 17 00:00:00 2001 From: KNSO Date: Mon, 14 Sep 2026 11:15:14 +0200 Subject: [PATCH] =?UTF-8?q?test(world):=20Lage=20der=20Orte=20gegen=20Stra?= =?UTF-8?q?=C3=9Fen,=20Geb=C3=A4ude,=20Palmen,=20Laternen=20und=20Chunk-Ra?= =?UTF-8?q?dien?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 4.5 --- tests/placesSite.test.js | 100 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/placesSite.test.js diff --git a/tests/placesSite.test.js b/tests/placesSite.test.js new file mode 100644 index 0000000..b45d5e3 --- /dev/null +++ b/tests/placesSite.test.js @@ -0,0 +1,100 @@ +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"; + +// 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 + +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); +} + +/** 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) { + const { x, z } = worldToModel(place, X, Y); + const { minX, maxX, minZ, maxZ } = place.bounds; + return x >= minX && x <= maxX && z >= minZ && z <= maxZ; +} + +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. + 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 = c.buildings.some((building) => pointInPolygon(u, v, building.poly)); + return !onLand || onRoad || inBuilding; + }); + expect(bad.slice(0, 5)).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 sein Boden geladen 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 geladen 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); + }); +});