# Tribüne im Park J6 — Umsetzungsplan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Die Tribüne aus `tribuene.glb` ersetzt 1:1 den Gebäudekasten in J6 (Flamingo Park Baseball Stadium): an der Stelle des alten Gebäudes, mit Schatten und Kollision, und den alten Kasten gibt es im Spiel nicht mehr. **Architecture:** Die Orte-Schicht aus dem Hood Yard bleibt, wie sie ist. Neu ist das Feld `replaces` im `PLACES`-Eintrag, eine Liste von Weltpunkten. `ChunkStreamer` bekommt diese Punkte aus `main.js` und lässt über die reine Funktion `withoutReplacedBuildings` jeden Grundriss weg, in dem ein Punkt liegt. Die Tribüne selbst wird mit `tools/assets/places.mjs` aufbereitet und über die globalen Materiallisten eingeordnet. Der Asset-Test prüft danach jeden Ort einzeln. **Tech Stack:** three.js 0.184, Vite 7, vitest 3.2, `@gltf-transform/core|extensions|functions` 4.5.0, `sharp` 0.35.4, `meshoptimizer` 1.2.0. **Spec:** `docs/superpowers/specs/2026-09-14-tribuene-design.md` ## Global Constraints - **Lage:** `at: [1409.9, 1051.1]`, `rotation: 45`, `lift: 0`, `bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 }`, `replaces: [[1403.25, 1057.75]]`. `at` und `replaces` sind Welt-x und Welt-y, nie three-z. - **Radien** bleiben: `PLACE_PRELOAD_RADIUS = 600`, `PLACE_SHOW_RADIUS = 250`, `PLACE_HIDE_RADIUS = 450`. - **Materialrollen:** - fest: `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01` - Nahflächen: `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Sitzschale` - Vereinfachung 0,25: `modular_electric_cables`, `modular_electric_cables_boxes`, `industrial_wall_lamp`, `industrial_wall_lamp_glass`, `power_box_01`, `utility_box_01` - **Quelle:** `/Users/Kenso/Downloads/tribuene.glb` (6,3 MB) kommt nie ins Repo. Die Kopie `tribuene.glb` im Hauptordner bleibt unberührt. - **Aufbereitung** nur mit `node tools/assets/places.mjs`, nie mit der gltf-transform-CLI. - **Nicht anfassen:** `public/data/`, `tools/extract/`, die Minimap, `src/world/geometry.js`, `src/world/props.js`, die Radien. - **Arbeitsbaum** `/Users/Kenso/Developer/gta6/.claude/worktrees/tribuene`, Branch `leonida/tribuene`, alle Befehle von dort aus. - Gestaget wird immer mit Pfaden, nie mit `git add -A`. - Die Isolationswache verweigert Befehle mit Variablen als Befehl oder mit Skriptpfaden, die Leerzeichen enthalten. Deshalb Pfade ausschreiben und einfache Einzelbefehle nutzen. - **Stil:** Kommentare und Testnamen auf Deutsch, wie in den vorhandenen Dateien. Über Tests, die eine Fehlerart abfangen, steht ein Kommentar „Bruch:“. - **Commit-Nachrichten** enden mit einer Leerzeile und `Co-Authored-By: Claude Opus 5 `. - **Tests:** einzeln mit `npx vitest run `, alle mit `npm test`. Ausgangsstand: `Test Files 17 passed (17)`, `Tests 170 passed (170)`. ## Vorbereitung (erledigt) Der Arbeitsbaum steht auf `6a05626` plus dem Spec-Commit `4a1b337`. `node_modules` sowie `three-player-controller/package.json` und `dist/` sind hineinkopiert, der Ausgangsstand ist grün. - [ ] **Prüfen:** `git status --short` gibt nichts aus, `npm test` meldet 17 Dateien und 170 Tests grün. --- ### Task 1: Ersetzte Gebäude beim Chunk-Aufbau weglassen **Files:** - Modify: `src/world/streaming.js` (Import, neue Funktion, Konstruktor, `#load`) - Modify: `src/main.js:11` (Import) und `src/main.js:239` (Konstruktion des `ChunkStreamer`) - Test: `tests/streaming.test.js` **Interfaces:** - Consumes: `pointInPolygon(x, z, rings)` aus `src/world/props.js`, `PLACES` aus `src/world/places.js` (bisher ohne `replaces`) - Produces: - `withoutReplacedBuildings(chunk, points)` in `src/world/streaming.js`. `points` ist `Array<[x, y]>` in Weltkoordinaten. Die Funktion gibt `chunk` selbst zurück, wenn nichts entfällt, sonst `{ ...chunk, buildings }` mit gefilterter Liste. - `new ChunkStreamer({ ..., replacedBuildings = [] })` - `main.js` übergibt `PLACES.flatMap((place) => place.replaces ?? [])`. - [ ] **Schritt 1: Failing Tests schreiben** In `tests/streaming.test.js` den Import ändern: ```js import { ChunkStreamer, wantedChunks, withoutReplacedBuildings } from "../src/world/streaming.js"; ``` `makeStreamer` bekommt einen vierten Parameter (die übrigen Aufrufe bleiben gültig, `undefined` greift auf die Vorgabe zurück): ```js function makeStreamer(chunkData, props, pinned, replacedBuildings) { ``` und in derselben Funktion die Rückgabe: ```js return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene }; ``` Direkt nach `describe("wantedChunks", …)` (vor dem Kommentar „Gemeinsame Vorrichtung …“) einfügen: ```js describe("withoutReplacedBuildings", () => { // Chunk 9_5 (J6) hat den Ursprung [1250, -1250]: Welt (x, y) liegt im Chunk bei u = x − 1250, v = −y + 1250. const square = (u, v) => [[[u, v], [u + 10, v], [u + 10, v + 10], [u, v + 10], [u, v]]]; const chunk = { chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [], buildings: [ { poly: square(150, 190), height: 18.6 }, // Welt x 1400–1410, y 1050–1060 { poly: square(100, 100), height: 10 }, // Welt x 1350–1360, y 1140–1150 ], }; // Bruch: Ursprung vergessen oder Welt-y nicht gespiegelt — der Punkt träfe nichts, und der alte Kasten // stünde mitten im Ort; oder die Eingabe wird verändert statt kopiert. it("lässt nur den Grundriss weg, in dem ein Punkt liegt, gerechnet in Chunk-Koordinaten", () => { const result = withoutReplacedBuildings(chunk, [[1405, 1055]]); expect(result.buildings).toEqual([chunk.buildings[1]]); expect(result.land).toBe(chunk.land); expect(chunk.buildings).toHaveLength(2); }); it("gibt ohne Treffer den Chunk selbst zurück", () => { expect(withoutReplacedBuildings(chunk, [])).toBe(chunk); expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk); }); }); ``` Im `describe("ChunkStreamer", …)` als letzten Test (nach „entlädt einen angehefteten Chunk nicht …“) einfügen: ```js // Bruch: der Streamer baut die Geometrie aus dem ungefilterten Chunk — der alte Kasten stünde samt // Kollision mitten in dem Ort, der ihn ersetzt. it("baut ein Gebäude, das ein Ort ersetzt, weder als Kasten noch in die Kollision", async () => { const chunk = { chunk: [4, 6], origin: [0, -1000], land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]], surfaces: [], roads: [], buildings: [{ poly: [[[10, 10], [20, 10], [20, 20], [10, 20]]], height: 12 }], }; // Gegenprobe ohne Ersatz: Land und Gebäude liegen in der Kollisionsgruppe. const plain = makeStreamer(chunk); plain.streamer.update(emptyChunkCenter); await flush(); expect(plain.player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(2); // Mitte des Gebäudes: u 15 -> Welt-x 15, v 15 -> Welt-y −(−1000 + 15) = 985. const { streamer, player } = makeStreamer(chunk, undefined, undefined, [[15, 985]]); streamer.update(emptyChunkCenter); await flush(); const source = player.addCollider.mock.calls[0][0].shape.source; expect(source.children).toHaveLength(1); expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude }); ``` (Gebäudegeometrie trägt als einzige ein `color`-Attribut, siehe `buildBuildings` in `src/world/geometry.js`.) - [ ] **Schritt 2: Tests laufen lassen, sie müssen fehlschlagen** Run: `npx vitest run tests/streaming.test.js` Erwartet: Die zwei `withoutReplacedBuildings`-Tests schlagen fehl (`withoutReplacedBuildings is not a function`). „baut ein Gebäude, das ein Ort ersetzt, …“ scheitert an `expected [ …(2) ] to have a length of 1`. - [ ] **Schritt 3: Umsetzen** In `src/world/streaming.js` die Importe ergänzen: ```js import { Group, Mesh } from "three"; import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js"; import { buildChunkGeometry } from "./geometry.js"; import { pointInPolygon } from "./props.js"; ``` Nach `function dist(k, position) { … }` einfügen: ```js /** * Der Chunk ohne die Gebäudegrundrisse, die ein Ort ersetzt (PLACES[].replaces in src/world/places.js): * jeder Grundriss, in dem einer der Weltpunkte [x, y] liegt, entfällt — und mit ihm Kasten, Schatten und * Kollision. Wirkt wie remove_building der Feinkalibrierung, nur zur Laufzeit. Trifft kein Punkt, kommt * derselbe Chunk zurück. */ export function withoutReplacedBuildings(chunk, points) { if (points.length === 0) return chunk; const [ox, oz] = chunk.origin; const buildings = chunk.buildings.filter((building) => !points.some(([x, y]) => pointInPolygon(x - ox, -y - oz, building.poly))); return buildings.length === chunk.buildings.length ? chunk : { ...chunk, buildings }; } ``` Konstruktor: ```js constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [], replacedBuildings = [] }) { Object.assign(this, { scene, player, materials, props, baseUrl, replacedBuildings }); ``` In `#load` die Zeile `const geos = buildChunkGeometry(chunk);` ersetzen durch: ```js const geos = buildChunkGeometry(withoutReplacedBuildings(chunk, this.replacedBuildings)); ``` `this.props.build(chunk)` bleibt beim ungefilterten Chunk, Palmen und Laternen kennen keine Gebäude. In `src/main.js` Zeile 11: ```js import { PLACES, PlaceLayer } from "./world/places.js"; ``` und Zeile 239 (`const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });`) ersetzen durch: ```js // Gebäudekästen aus der Extraktion, an deren Stelle ein Ort steht (PLACES[].replaces), baut der Streamer // gar nicht erst — sonst stünde zum Beispiel die Tribüne mitten in ihrem alten Kasten. const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS, replacedBuildings: PLACES.flatMap((place) => place.replaces ?? []), }); ``` - [ ] **Schritt 4: Tests laufen lassen, sie müssen bestehen** Run: `npx vitest run tests/streaming.test.js` Erwartet: alle Tests grün. Run: `npm test` Erwartet: `Test Files 17 passed (17)`, `Tests 173 passed (173)`. - [ ] **Schritt 5: Commit** ```bash git add src/world/streaming.js src/main.js tests/streaming.test.js git commit -m "feat(world): Gebäude, die ein Ort ersetzt, beim Chunk-Aufbau weglassen" -m "withoutReplacedBuildings filtert die Grundrisse, in denen ein Weltpunkt aus PLACES[].replaces liegt; ChunkStreamer baut die Geometrie daraus, main.js übergibt die Punkte aller Orte. Damit entfallen Kasten, Schatten und Kollision des alten Gebäudes." -m "Co-Authored-By: Claude Opus 5 " ``` --- ### Task 2: Materialrollen der Tribüne und Asset-Test je Ort **Files:** - Modify: `src/world/places.js:37-52` (`SOLID_MATERIALS` und Kommentar darüber) - Modify: `tools/assets/places.mjs:26-45` (`NEAR_MATERIALS`, `SIMPLIFY_RATIOS` und Kommentar) - Test: `tests/places.test.js` (`describe("isSolidMaterial")`), `tests/placesPipeline.test.js` (neuer Block), `tests/placesAsset.test.js` (neu geschrieben) **Interfaces:** - Consumes: nichts aus Task 1 - Produces: - `SOLID_MATERIALS`, `NEAR_MATERIALS` und `SIMPLIFY_RATIOS` enthalten die Tribünen-Namen. - `tests/placesAsset.test.js` prüft jede Datei aus `PLACES` gegen die Tabellen `SOLID_BY_PLACE` und `BUMP_BY_PLACE`. Task 3 fügt `tribuene` zu `PLACES` hinzu, die Zeile in `SOLID_BY_PLACE` steht dann schon. - [ ] **Schritt 1: Failing Tests schreiben** In `tests/places.test.js` im `describe("isSolidMaterial", …)` nach dem ersten `it` einfügen: ```js // 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); } }); ``` In `tests/placesPipeline.test.js` den Import ändern: ```js import { FAR_TEXTURE_SIZE, NEAR_MATERIALS, NEAR_TEXTURE_SIZE, SIMPLIFY_RATIOS, optimizePlace, placeTarget } from "../tools/assets/places.mjs"; ``` und am Dateiende anhängen: ```js describe("Materiallisten der Tribüne", () => { // Bruch: Beton, Estrich, Verkleidung, Stahl oder Sitze fielen auf 512 px, obwohl der Spieler direkt // davorsteht — oder Dach und Kabinenboden, die man nur von weitem sieht, behielten 768 px. it("führt Beton, Estrich, Verkleidung, Stahl und Sitze als Nahflächen, Dach und Kabinenboden nicht", () => { for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale"]) expect(NEAR_MATERIALS.has(name)).toBe(true); for (const name of ["Trapezblech", "Riffelblech"]) expect(NEAR_MATERIALS.has(name)).toBe(false); }); // Bruch: Kabel, Kästen und Leuchten blieben bei 64 000 Dreiecken, oder die Vereinfachung träfe Körper, // Dach, Geländer oder Sitze (Löcher in Wänden, schiefe Stufenkanten). it("vereinfacht nur die Kleinteile der Tribüne, auf 0,25", () => { for (const name of ["modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp", "industrial_wall_lamp_glass", "power_box_01", "utility_box_01"]) { expect(SIMPLIFY_RATIOS.get(name)).toBe(0.25); } for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "Sitzschale"]) { expect(SIMPLIFY_RATIOS.has(name)).toBe(false); } }); }); ``` - [ ] **Schritt 2: Tests laufen lassen, sie müssen fehlschlagen** Run: `npx vitest run tests/places.test.js tests/placesPipeline.test.js` Erwartet: 3 fehlgeschlagene Tests, nämlich der neue `isSolidMaterial`-Test (`Sichtbeton`: expected false to be true) und beide Tests in „Materiallisten der Tribüne“. - [ ] **Schritt 3: Listen ergänzen** In `src/world/places.js` den Kommentar über `SOLID_MATERIALS` ändern und am Listenende (nach der Zeile mit den Bäumen) eine Gruppe anhängen: ```js /** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Sitzschalen, Kleinteile — läuft man durch. */ export const SOLID_MATERIALS = [ ``` ```js // Bäume "quiver_tree_01_trunk", "quiver_tree_02", // Tribüne: Betonkörper und Tunnel, Verkleidung, Stahl (Geländer, Dachtragwerk, Rinne), Dach, Kabinenboden, // Glas der Pressekabine, Anschlusskästen am Boden "Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01", ]; ``` In `tools/assets/places.mjs` in `NEAR_MATERIALS` nach `"chainlink_post", "chainlink_wire",` anhängen: ```js // Tribüne: die Quelle hat höchstens 768 px, compressTexture vergrößert nicht "Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale", ``` Den Kommentar über `SIMPLIFY_RATIOS` und die Liste so ändern: ```js /** * Anteil der Dreiecke, der je Material bleibt; Gebäude, Boden und Zäune (Container, Schuppen, Tribüne mit * Sitzen) fehlen hier und bleiben unverändert. Gemessen am Hood Yard nach flatten/join: die vier Köcherbäume * haben zusammen 2,2 Mio. Dreiecke (quiver_tree_01_leaf 1,13 Mio., quiver_tree_02 739 000, * quiver_tree_01_trunk 370 000), Gras je 60 000–150 000, die Mast-Kleinteile 92 000. An der Tribüne haben * Kabel, Kabeldosen, Wandleuchten und Kästen zusammen 64 298. */ export const SIMPLIFY_RATIOS = new Map([ ["quiver_tree_01_leaf", 0.15], ["quiver_tree_01_trunk", 0.15], ["quiver_tree_02", 0.15], ["searsia_lucida", 0.5], ["searsia_lucida_leaves", 0.5], ["searsia_lucida_twigs", 0.5], ["grass_medium_01", 0.5], ["grass_medium_01_dry", 0.5], ["grass_medium_02", 0.5], ["modular_electricity_poles_pieces", 0.25], ["security_camera_01", 0.25], ["modular_electric_cables", 0.25], ["modular_electric_cables_boxes", 0.25], ["industrial_wall_lamp", 0.25], ["industrial_wall_lamp_glass", 0.25], ["power_box_01", 0.25], ["utility_box_01", 0.25], ]); ``` - [ ] **Schritt 4: Tests laufen lassen** Run: `npx vitest run tests/places.test.js tests/placesPipeline.test.js` Erwartet: grün. Run: `npx vitest run tests/placesAsset.test.js` Erwartet: 1 Fehlschlag in „enthält jedes feste Material der Kollisionsliste“ mit den acht Tribünen-Namen. Das ist der bekannte Fallstrick: Der Test gleicht die ganze globale Liste mit der Datei des Hood Yard ab. - [ ] **Schritt 5: Asset-Test auf alle Orte umbauen** `tests/placesAsset.test.js` vollständig ersetzen durch: ```js import { statSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { getBounds } from "@gltf-transform/functions"; import { MeshoptDecoder } from "meshoptimizer"; import { PLACES, SOLID_MATERIALS } from "../src/world/places.js"; import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs"; // Feste Materialien je Ort. SOLID_MATERIALS gilt für alle Orte zusammen, aber keine Datei enthält alle // Namen — geprüft wird deshalb jede Datei gegen ihre eigene Zeile. const SOLID_BY_PLACE = { "hood-yard": [ "gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete", "cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame", "ironPlain", "ironTeal", "ironBlue", "galv", "rust", "darkTrim", "osb", "osbB", "rollershutter_window_01", "rollershutter_window_01_graffiti", "chainlink_post", "chainlink_wire", "modular_electricity_poles", "barrel_03", "plastic_container", "quiver_tree_01_trunk", "quiver_tree_02", ], tribuene: ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01"], }; // Materialien, deren Relief nur aus EXT_materials_bump kommt. Die Tribüne hat keine Bump-Maps. const BUMP_BY_PLACE = { "hood-yard": ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"], }; // Bruch: ein festes Material steht in keiner Zeile — die Datei seines Orts würde nie darauf geprüft. it("verteilt SOLID_MATERIALS vollständig auf die Orte", () => { expect(Object.values(SOLID_BY_PLACE).flat().sort()).toEqual([...SOLID_MATERIALS].sort()); }); describe.each(PLACES.map((place) => [place.id, place]))("%s.glb", (id, place) => { const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url)); const bump = BUMP_BY_PLACE[id] ?? []; let document; beforeAll(async () => { await MeshoptDecoder.ready; const io = new NodeIO() .registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump]) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); document = await io.read(file); }, 30000); // Bruch: Aufbereitung übersprungen oder falsch eingestellt — die Quelle des Hood Yard hatte 653 MB. it("ist kleiner als 30 MB", () => { expect(statSync(file).size).toBeLessThan(30e6); }); // Bruch: Geometrie unkomprimiert oder GPU-Instanzen, deren Matrizen die Kollision des Controllers nicht liest. it("nutzt meshopt und WebP und hat keine Instanzen", () => { const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp"])); expect(used).not.toContain("EXT_mesh_gpu_instancing"); }); it("hat nur WebP-Texturen bis 1024 px", () => { for (const texture of document.getRoot().listTextures()) { expect(texture.getMimeType()).toBe("image/webp"); expect(Math.max(...texture.getSize())).toBeLessThanOrEqual(1024); } }); // Bruch: die Aufbereitung verwirft EXT_materials_bump (wie die gltf-transform-CLI) — das Wellblech der // Container wäre flach. it.runIf(bump.length > 0)("gibt den Bump-Materialien ihre Bump-Textur", () => { const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); const materials = new Map(document.getRoot().listMaterials().map((material) => [material.getName(), material])); expect(used).toContain(EXT_MATERIALS_BUMP); for (const name of bump) { expect(materials.get(name)?.getExtension(EXT_MATERIALS_BUMP)?.getBumpTexture()).toBeTruthy(); } }); // Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Ort wäre dort ohne Warnung // durchlässig, weil die Kollision nach Namen auswählt. Oder der Ort hat keine Zeile in SOLID_BY_PLACE. it("enthält jedes feste Material seines Orts", () => { const names = document.getRoot().listMaterials().map((material) => material.getName()); expect(SOLID_BY_PLACE[id]).toBeDefined(); expect(SOLID_BY_PLACE[id].filter((name) => !names.includes(name))).toEqual([]); }); // Bruch: bounds in PLACES passt nicht mehr zur Datei — der Datentest prüfte dann die falsche Fläche. it("passt auf 5 cm zu den Außenmaßen in PLACES", () => { const { min, max } = getBounds(document.getRoot().listScenes()[0]); const { minX, maxX, minZ, maxZ } = place.bounds; expect(Math.abs(min[0] - minX)).toBeLessThan(0.05); expect(Math.abs(max[0] - maxX)).toBeLessThan(0.05); expect(Math.abs(min[2] - minZ)).toBeLessThan(0.05); expect(Math.abs(max[2] - maxZ)).toBeLessThan(0.05); }); }); ``` - [ ] **Schritt 6: Alle Tests laufen lassen** Run: `npm test` Erwartet: `Test Files 17 passed (17)`, `Tests 177 passed (177)`. Gegenüber dem Ausgangsstand kommen hinzu: - 3 Tests aus Task 1 - 1 Test aus `places.test.js` - 2 Tests aus `placesPipeline.test.js` - der neue Verteilungstest im Asset-Test - [ ] **Schritt 7: Commit** ```bash git add src/world/places.js tools/assets/places.mjs tests/places.test.js tests/placesPipeline.test.js tests/placesAsset.test.js git commit -m "feat(world): Materialrollen der Tribüne, Asset-Test je Ort" -m "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." -m "Co-Authored-By: Claude Opus 5 " ``` --- ### Task 3: Tribüne aufbereiten und eintragen **Files:** - Create: `public/models/places/tribuene.glb` (Ergebnis von `places.mjs`) - Modify: `src/world/places.js` (Kommentar über `PLACES`, neuer Eintrag) - Modify: `docs/superpowers/specs/2026-09-14-tribuene-design.md` (Abschnitt „Budget“) - Test: `tests/places.test.js` (`describe("PLACES")`), `tests/placesSite.test.js` **Interfaces:** - Consumes: - `withoutReplacedBuildings(chunk, points)` aus Task 1 - die Materiallisten und `SOLID_BY_PLACE.tribuene` aus Task 2 - `node tools/assets/places.mjs ` - Produces: `PLACES` mit dem Eintrag `tribuene`, den `main.js` (Task 1) und `PlaceLayer` verwenden, und `public/models/places/tribuene.glb`. - [ ] **Schritt 1: Aufbereiten** Run: `node tools/assets/places.mjs /Users/Kenso/Downloads/tribuene.glb tribuene` Erwartet (am Prototyp gemessen, Laufzeit unter 5 s): - `vorher 6.3 MB`, darin `triangles: 117916`, `gpuMB: 53` - `nachher 1.7 MB`, darin: - `materials: 14`, `primitives: 14`, `triangles: 69557` - `textures: { '512x512 image/webp': 23, '256x256 image/webp': 12, '768x768 image/webp': 4 }` - `gpuMB: 49`, `bounds: { min: [ -20.8, 0, -14.82 ], max: [ 20.8, 12.57, 0.8 ] }` Weicht eine Zahl deutlich ab, anhalten und melden. - [ ] **Schritt 2: Eintrag in `PLACES`** In `src/world/places.js` den Kommentar über `PLACES` ergänzen: ```js /** * at: Welt-x und Welt-y in Metern, wie auf der Minimap — nie three-z. rotation: Grad gegen den * Uhrzeigersinn, von oben mit Norden oben (wie add_building in corrections.toml). lift: Meter auf die * Modellwurzel. bounds: Außenmaße in Modellkoordinaten (x, z); das Spiel braucht sie nicht, die Tests * prüfen damit Datei (placesAsset.test.js) und Lage (placesSite.test.js) gegeneinander. replaces * (optional): Weltpunkte [x, y]; jeder Gebäudegrundriss aus der Extraktion, in dem einer liegt, wird beim * Chunk-Aufbau weggelassen (withoutReplacedBuildings in streaming.js). */ ``` und nach dem Eintrag `hood-yard` anfügen: ```js { id: "tribuene", url: "./models/places/tribuene.glb", // Längs mittig auf dem alten Gebäudekasten, Front 1 m vor dessen Feldseite (Vergleichsbild des Nutzers); // längs bleiben bis zur Fahrbahn nur 1,25 m nach Nordosten und 1,8 m nach Südwesten. at: [1409.9, 1051.1], // Die Sitzreihen schauen wie in echt nach Südosten auf die Home Plate. rotation: 45, // Keine Bodenplatte: der Betonkörper steht mit seiner Unterseite auf 0, flach auf 0 liegt nichts. lift: 0, bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 }, // Der 18,6 m hohe Gebäudekasten aus der Extraktion an dieser Stelle (J6). replaces: [[1403.25, 1057.75]], }, ``` - [ ] **Schritt 3: Tests laufen lassen, die vorhandenen Datentests müssen anschlagen** Run: `npx vitest run tests/places.test.js tests/placesSite.test.js tests/placesAsset.test.js` Erwartet: - `tribuene.glb` im Asset-Test: 5 Tests grün, 1 übersprungen (Bump). - Rot wird „hat für jeden Ort id, url, at, rotation, lift und bounds“ in `places.test.js`, weil `replaces` ein unerwartetes Feld ist. - Rot wird „Ort tribuene auf der Karte > liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“. Die Liste zeigt Punkte im alten Gebäude. - Alles andere bleibt grün. - [ ] **Schritt 4: Form-Test lässt `replaces` zu** In `tests/places.test.js` den Test im `describe("PLACES", …)` ersetzen durch: ```js // Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds // prüften nichts; oder replaces ist keine Liste von Punkten [x, y], und der Streamer ließe nichts weg. it("hat für jeden Ort id, url, at, rotation, lift und bounds, dazu optional replaces mit Weltpunkten", () => { for (const { replaces = [], ...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) }, }); for (const point of replaces) expect(point).toEqual([expect.any(Number), expect.any(Number)]); } }); ``` - [ ] **Schritt 5: Lage-Test kennt ersetzte Gebäude** In `tests/placesSite.test.js`: Import ergänzen (nach der Zeile mit `props.js`): ```js import { withoutReplacedBuildings } from "../src/world/streaming.js"; ``` Nach der Funktion `chunk(i, j)` einfügen: ```js // 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); } ``` Im Test „liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“ den Kommentar und die Gebäudezeile ändern: ```js // 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. ``` ```js const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly)); ``` Direkt nach diesem Test einfügen: ```js // Bruch: Nach einem neuen Lauf der Extraktion liegt das ersetzte Gebäude woanders oder ist geteilt. Der // Punkt träfe dann nichts (der alte Kasten stünde wieder im Ort), mehrere Grundrisse oder einen außerhalb. 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)); return hits.length !== 1 || !insideBounds(place, X, Y); }); expect(bad).toEqual([]); }); ``` - [ ] **Schritt 6: Tests laufen lassen, sie müssen bestehen** Run: `npx vitest run tests/places.test.js tests/placesSite.test.js tests/placesAsset.test.js` Erwartet: alles grün. Übersprungen sind nur „gibt den Bump-Materialien ihre Bump-Textur“ bei `tribuene.glb` und „ersetzt mit jedem replaces-Punkt …“ beim Hood Yard. - [ ] **Schritt 7: Gegenprobe, der neue Datentest muss anschlagen** In `src/world/places.js` den Punkt vorübergehend auf `replaces: [[1403.25, 1087.75]]` setzen (30 m nördlich, auf der Straße). Run: `npx vitest run tests/placesSite.test.js` Erwartet für `tribuene`: rot sind „liegt ganz auf Land, … in keinem Gebäude“ (der Kasten zählt wieder) und „ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich“ (`[[1403.25, 1087.75]]`). Danach zurück auf `replaces: [[1403.25, 1057.75]]`. Run: `npx vitest run tests/placesSite.test.js`, erwartet grün. - [ ] **Schritt 8: Budget in der Spec** In `docs/superpowers/specs/2026-09-14-tribuene-design.md` den Abschnitt „## Budget“ vollständig ersetzen, mit den Werten aus dem Bericht von Schritt 1: ```markdown ## Budget - **Gemessen an der Aufbereitung (2026-09-14):** - 1,7 MB, 69 557 Dreiecke, 14 Primitive (Draw Calls) - 39 WebP-Texturen: 4 × 768, 23 × 512, 12 × 256 px - geschätzt 49 MB Grafikspeicher, Außenmaße unverändert - Die sechs Kleinteil-Materialien haben 15 939 Dreiecke statt 64 298. - **Zum Vergleich der Hood Yard:** 657 653 Dreiecke, 43 Primitive, 231 MB. ``` Weicht der Bericht vom Prototyp ab, gelten die Werte des Berichts. - [ ] **Schritt 9: Alle Tests laufen lassen** Run: `npm test` Erwartet: `Test Files 17 passed (17)`, `Tests 186 passed | 2 skipped (188)`. Gegenüber Task 2 kommen hinzu: - 6 Asset-Tests der Tribüne, davon 5 grün und 1 übersprungen - 4 Lage-Tests der Tribüne, alle grün - beim Hood Yard der neue Lage-Test, übersprungen - [ ] **Schritt 10: Commit** ```bash git add public/models/places/tribuene.glb src/world/places.js tests/places.test.js tests/placesSite.test.js docs/superpowers/specs/2026-09-14-tribuene-design.md git commit -m "feat(world): Tribüne im Park J6 statt des alten Gebäudekastens" -m "tribuene.glb mit places.mjs aufbereitet (6,3 MB -> 1,7 MB, 69 557 Dreiecke, 14 Primitive, 49 MB Grafikspeicher). PLACES-Eintrag bei (1409,9 | 1051,1), 45° gedreht, lift 0, replaces auf den 18,6 m hohen Kasten. Der Lage-Test übergeht ersetzte Grundrisse und prüft, dass jeder replaces-Punkt genau ein Gebäude unter dem Ort trifft." -m "Co-Authored-By: Claude Opus 5 " ``` --- ### Task 4: Prüfung im Browser (Controller, nicht an Subagenten) Das übernimmt die Sitzung selbst. Sie braucht den Browser-Bereich, einen Eintrag in `.claude/launch.json` des Hauptordners (vorher den Nutzer fragen) und Rückfragen an den Nutzer. Grundlage ist `.claude/skills/placing-editor-models/browser-checks.md`, die Abschnitte werden unten mit §N zitiert. **Files:** - Create, nie committen, am Ende löschen: `index-debug.html` (§2) - Modify, am Ende zurücknehmen: `/Users/Kenso/Developer/gta6/.claude/launch.json` (§1, Eintrag `place-check`, Port 5181, `cd /Users/Kenso/Developer/gta6/.claude/worktrees/tribuene && npx vite --port 5181 --strictPort`) - Modify: `docs/superpowers/specs/2026-09-14-tribuene-design.md` (neuer Punkt „Gemessen im Spiel“ unter „Fehlerfälle und Risiken“) **Werte** (Welt → three: z = −y): | Zweck | Welt (x \| y) | three | |---|---|---| | Wurzel der Tribüne | (1409,9 \| 1051,1) | `position` (1409.9, 0, −1051.1), `rotation.y` = π/4 | | Sprungziel auf dem Spielfeld | (1420,5 \| 1040,5) | — | | Draufsicht | Mitte (1404,94 \| 1056,06) | `position [1404.94, 60, -1056.06]`, `target [1404.94, 0, -1056.06]`, `up [0, 0, -1]` | | Blick vom Spielfeld | (1427,58 \| 1033,42) | `position [1427.58, 1.7, -1033.42]`, `target [1404.94, 5, -1056.06]` | | Blick von hinten | (1388,69 \| 1072,31) | `position [1388.69, 1.7, -1072.31]`, `target [1404.94, 5, -1056.06]` | | Streifen des alten Kastens hinter der Tribüne | (1397,88 \| 1063,12) | Strahl von (1397.88, 60, −1063.12) nach unten | | Kapsel hinter der Rückwand | (1389,39 \| 1054,64) | `capsule.position.set(1389.39, 1, -1054.64)`, Blick 135° | | Kapsel vor dem Tunnel (x 6–8) | (1402,83 \| 1068,07) | `capsule.position.set(1402.83, 1, -1068.07)`, Blick 135° | | Kapsel auf der Sitzstufe y 6,0 | (1394,34 \| 1049,69) | `capsule.position.set(1394.34, 7, -1049.69)`, Blick 45° (längs der Reihe) | - [ ] **Schritt 1: Server und Debug-Seite** - Den Nutzer fragen, ob der Eintrag `place-check` in `.claude/launch.json` des Hauptordners gesetzt werden darf. - Dann `ExitWorktree(keep)`, Eintrag setzen, `preview_start({ name: "place-check" })`, `EnterWorktree({ path: "/Users/Kenso/Developer/gta6/.claude/worktrees/tribuene" })`. - `index-debug.html` nach §2 anlegen, `resize_window` auf 1280 × 800 setzen, `http://localhost:5181/index-debug.html` öffnen und den Hook nach §3 einrichten. - [ ] **Schritt 2: Vorwärmen am Startpunkt** - Warten, bis `renderer.info.memory.textures` 5 s lang gleich bleibt. - Die verschiedenen Texturen zählen, die über `scene.traverse` an Materialien hängen. - Erwartet: Die GPU-Zahl liegt um mindestens 108 höher (69 Hood Yard + 39 Tribüne), weil beide Orte vorgeladen, aber noch nicht in der Szene sind. - [ ] **Schritt 3: Sprung** - Minimap nach §4 öffnen und einen Screenshot machen. - Bei (1420,5 \| 1040,5) klicken und warten, bis die Wurzel der Tribüne in `scene.children` hängt. - Die Konsole mit `read_console_messages({ onlyErrors: true })` lesen: keine Fehler. - [ ] **Schritt 4: Alter Kasten weg** - Mit `THREE.Raycaster` von (1397.88, 60, −1063.12) senkrecht nach unten gegen `scene.children` schießen. - Erwartet: Der erste Treffer mit `object.isMesh` liegt unter y 1. Die Oberkante des alten Kastens läge bei 18,6 m. - Denselben Strahl an der alten Mitte (1403.25, 60, −1057.75) schießen: Der erste Treffer liegt höchstens bei 12,6 m, also auf dem Dach der Tribüne. - [ ] **Schritt 5: Kollisionsmarken und Quantisierung (§5)** - Die Meshes ohne `userData.excludeFromCollider` haben genau die Materialnamen `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01`. - Alle haben `Float32Array`-Positionen. - [ ] **Schritt 6: Blicke (§6)** - Draufsicht, Blick vom Spielfeld und Blick von hinten aufnehmen und mit `decode-view.py` speichern. - Die Bilder per `SendUserFile` an den Nutzer schicken. - Achten auf: zerrissene Kabel oder Leuchten, Schatten des Glases, Helligkeit. - [ ] **Schritt 7: Kollision (§7)**, jede Sekunde die Lage notieren, danach `keyup`: - **Rückwand:** Kapsel hinter die Rückwand setzen, Blick 135°, 15 s `KeyW`. Erwartet: Sie steht bei y 0,75 und bleibt wenige cm vor der Wand stehen, also Modell-z knapp unter −14,8. - **Tunnel:** Kapsel vor den Tunnel setzen, Blick 135°, 25 s `KeyW`. Festhalten, ob sie über die 25-cm-Kante hineinkommt und die Treppe hinaufsteigt. Nur feststellen, nichts ändern. - **Sitzstufe:** Kapsel auf die Sitzstufe setzen, Blick 45°, 15 s `KeyW`. Erwartet: Sie steht bei etwa y 6,75 und läuft durch die Sitzschalen hindurch, ohne Halt bis zum nächsten Geländer. - [ ] **Schritt 8: Kosten (§8)** - Den Median aus 60 Bildern mit `gl.finish` messen: Blick vom Spielfeld, einmal mit und einmal ohne die Wurzel der Tribüne in der Szene. - Dasselbe in der Draufsicht. - Erwartet: deutlich unter dem Hood Yard (+0,5 ms). - [ ] **Schritt 9: Aufräumen** - `window.__view = null`, `index-debug.html` löschen, `preview_stop`. - `ExitWorktree(keep)`, den Eintrag `place-check` aus `.claude/launch.json` entfernen, `EnterWorktree(path)`. - `git status --short` zeigt nur die Spec. - [ ] **Schritt 10: Messwerte in die Spec und Commit** - Unter „Fehlerfälle und Risiken“ einen Punkt **Gemessen im Spiel (2026-09-14)** anlegen: - Kosten mit und ohne Tribüne - Vorwärmen (Differenz des Texturzählers) - Ergebnis der drei Kollisionsläufe - Auffälligkeiten aus den Blicken - Commit mit `git add docs/superpowers/specs/2026-09-14-tribuene-design.md`, Nachricht `docs: Tribünen-Spec mit Messwerten aus dem Spiel` plus Co-Authored-By-Zeile.