Files
gta6/docs/superpowers/plans/2026-09-14-tribuene.md
T
knsoandClaude Opus 5 f7506ac556 docs: Umsetzungsplan für die Tribüne im Park J6
Vier Tasks: ersetzte Gebäude beim Chunk-Aufbau weglassen, Materialrollen und Asset-Test je Ort, Tribüne aufbereiten und eintragen (am Prototyp gemessen: 1,7 MB, 69 557 Dreiecke, 14 Primitive, 49 MB), Prüfung im Browser mit vorberechneten Blick- und Kapselpunkten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:08:18 +02:00

37 KiB
Raw Blame History

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 <noreply@anthropic.com>.
  • Tests: einzeln mit npx vitest run <datei>, 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:

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):

function makeStreamer(chunkData, props, pinned, replacedBuildings) {

und in derselben Funktion die Rückgabe:

    return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene };

Direkt nach describe("wantedChunks", …) (vor dem Kommentar „Gemeinsame Vorrichtung …“) einfügen:

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:

    // 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:

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:

/**
 * 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:

    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:

            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:

import { PLACES, PlaceLayer } from "./world/places.js";

und Zeile 239 (const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });) ersetzen durch:

// 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
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 <noreply@anthropic.com>"

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:

    // 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:

import { FAR_TEXTURE_SIZE, NEAR_MATERIALS, NEAR_TEXTURE_SIZE, SIMPLIFY_RATIOS, optimizePlace, placeTarget }
    from "../tools/assets/places.mjs";

und am Dateiende anhängen:

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:

/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Sitzschalen, Kleinteile — läuft man durch. */
export const SOLID_MATERIALS = [
    // 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:

    // 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:

/**
 * 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:

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

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 <noreply@anthropic.com>"

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 <quelle.glb> <id>
  • 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:

/**
 * 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:

    {
        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:

    // 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):

import { withoutReplacedBuildings } from "../src/world/streaming.js";

Nach der Funktion chunk(i, j) einfügen:

// 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:

    // 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.
            const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly));

Direkt nach diesem Test einfügen:

    // 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:

## 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

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 <noreply@anthropic.com>"

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.