From 52c997c7caaf848b183bbefe0d591b0e8d701522 Mon Sep 17 00:00:00 2001 From: Ael Date: Wed, 22 Jul 2026 00:27:00 +0200 Subject: [PATCH] Visual: unify lush Lowlands palette and coverage --- .../2026-07-21-lush-lowlands-unification.md | 43 +++++++++++++++++++ src/components/realm/RealmMapScreen.tsx | 20 +++++++++ src/components/realm/createRealmGrassLayer.ts | 30 ++++++++++++- src/components/realm/createRealmScene.ts | 43 ++++++++++++++++++- .../realm/createRealmTerrainFeatures.ts | 37 ++++++---------- src/game/map/realmGrass.ts | 18 ++++---- src/game/map/realmTerrainFeatures.ts | 6 ++- src/game/map/terrainColor.ts | 27 ++++++++++-- tests/realmGrassGenesisBounds.test.ts | 2 +- tests/realmGrassVisualContract.test.ts | 2 + tests/realmSceneCleanup.test.ts | 23 ++++++++++ tests/realmTerrainFeatures.test.ts | 37 +++++++++++++++- tests/terrainColor.test.ts | 16 ++++++- 13 files changed, 259 insertions(+), 45 deletions(-) create mode 100644 docs/plans/2026-07-21-lush-lowlands-unification.md diff --git a/docs/plans/2026-07-21-lush-lowlands-unification.md b/docs/plans/2026-07-21-lush-lowlands-unification.md new file mode 100644 index 00000000..218192fd --- /dev/null +++ b/docs/plans/2026-07-21-lush-lowlands-unification.md @@ -0,0 +1,43 @@ +# Lush Lowlands palette unification + +## Scope + +This draft PR is presentation-only and is based on the reviewed PR5 head +`fc95959d3c2128c267e535a6e6cffec5aaa692ff` (`agent/luminous-broad-grass-v3`). +It does not change terrain membership, passability, structures, resources, +castle state, routes, economy, SpacetimeDB, Pages, releases, or production. + +## Diagnosis and policy + +- Heath cells previously tinted broad ground purple and generated one purple + `heath-bloom` dodecahedron on High/Balanced quality. The historical feature + type remains accepted for compatibility, but ordinary generation and the + live layer now draw zero blooms. +- Heath now shares the Lowlands moss/olive family with a bounded semantic tint; + Ancient Stone is neutral slate/moss. Ridge and monolith readability comes + from neutral geometry, height, and deliberate clearances rather than violet + contrast. +- Meadow, Lowland, Forest, and Heath candidate/retention policies are denser + while remaining deterministic, slope-aware, structure-safe, and within the + existing quality budgets. Ridge and Ancient Stone receive only modest grass. + +## Measurable contracts + +The scene telemetry now exposes feature counts by kind (including an explicit +zero for `heath-bloom`), candidate and active grass cells by terrain, retained +instances by terrain, average vegetation density by terrain, bare-cell and +rejection counts, grass luminance/green ranges, draw calls, triangle totals, +cache state, and overview-hidden state. These remain bounded diagnostics; they +do not become authority or gameplay inputs. + +The active-window cache, shared ambient scheduler, shader coverage path, +selection flattening, no-op grass raycast, lifecycle disposal, and quality +budgets are unchanged. + +## Verification + +- Focused visual/scene contracts: 54 tests passing. +- TypeScript `tsc --noEmit`: passing. +- Remaining build and full-suite results are recorded in the draft PR body. +- Render QA uses the existing fixed-camera harness when available; no runtime + texture or new binary asset is introduced by this PR. diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index 5e5a7e96..c1d7c7f2 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -805,6 +805,7 @@ function CanonicalRealmMapScreen({ root.dataset.semanticTerrainKindCount = String(telemetry.semanticKindCount); root.dataset.semanticTerrainFeatureCount = String(telemetry.semanticFeatureCount); root.dataset.semanticTerrainFeatureDrawCalls = String(telemetry.semanticFeatureDrawCalls); + root.dataset.semanticTerrainFeatureCounts = JSON.stringify(telemetry.semanticFeatureCounts); root.dataset.totalTerrainDetailInstanceCount = String(telemetry.totalDetailInstanceCount); root.dataset.totalTerrainDetailDrawCalls = String(telemetry.totalDetailDrawCalls); root.dataset.forestPlacementSource = telemetry.forestPlacementSource; @@ -817,7 +818,20 @@ function CanonicalRealmMapScreen({ root.dataset.grassCacheEntries = String(telemetry.grassCacheEntries); root.dataset.grassAnimated = String(telemetry.grassAnimated); root.dataset.grassTargetAnimationCadence = String(telemetry.grassTargetAnimationCadence); + root.dataset.grassCandidateCellsByTerrain = JSON.stringify(telemetry.grassCandidateCellsByTerrain); + root.dataset.grassActiveCellsByTerrain = JSON.stringify(telemetry.grassActiveCellsByTerrain); root.dataset.grassCountsByTerrain = JSON.stringify(telemetry.grassCountsByTerrain); + root.dataset.grassAverageVegetationDensityByTerrain = JSON.stringify( + telemetry.grassAverageVegetationDensityByTerrain + ); + root.dataset.grassPaletteLuminance = JSON.stringify({ + min: telemetry.grassPaletteLuminanceMin, + max: telemetry.grassPaletteLuminanceMax + }); + root.dataset.grassPaletteGreen = JSON.stringify({ + min: telemetry.grassPaletteGreenMin, + max: telemetry.grassPaletteGreenMax + }); root.dataset.grassCompletelyBareActiveCells = String(telemetry.grassCompletelyBareActiveCells); root.dataset.grassRejectedByStructureClearance = String( telemetry.grassRejectedByStructureClearance @@ -936,6 +950,7 @@ function CanonicalRealmMapScreen({ rootRef.current.dataset.semanticTerrainKindCount = '0'; rootRef.current.dataset.semanticTerrainFeatureCount = '0'; rootRef.current.dataset.semanticTerrainFeatureDrawCalls = '0'; + rootRef.current.dataset.semanticTerrainFeatureCounts = '{}'; rootRef.current.dataset.totalTerrainDetailInstanceCount = '0'; rootRef.current.dataset.totalTerrainDetailDrawCalls = '0'; rootRef.current.dataset.forestPlacementSource = 'blocked'; @@ -948,7 +963,12 @@ function CanonicalRealmMapScreen({ rootRef.current.dataset.grassCacheEntries = '0'; rootRef.current.dataset.grassAnimated = 'false'; rootRef.current.dataset.grassTargetAnimationCadence = '0'; + rootRef.current.dataset.grassCandidateCellsByTerrain = '{}'; + rootRef.current.dataset.grassActiveCellsByTerrain = '{}'; rootRef.current.dataset.grassCountsByTerrain = '{}'; + rootRef.current.dataset.grassAverageVegetationDensityByTerrain = '{}'; + rootRef.current.dataset.grassPaletteLuminance = '{}'; + rootRef.current.dataset.grassPaletteGreen = '{}'; rootRef.current.dataset.grassCompletelyBareActiveCells = '0'; rootRef.current.dataset.grassRejectedByStructureClearance = '0'; rootRef.current.dataset.grassRejectedBySlope = '0'; diff --git a/src/components/realm/createRealmGrassLayer.ts b/src/components/realm/createRealmGrassLayer.ts index 27c2aa80..4c7a97d1 100644 --- a/src/components/realm/createRealmGrassLayer.ts +++ b/src/components/realm/createRealmGrassLayer.ts @@ -45,11 +45,16 @@ export type RealmGrassTelemetry = Readonly<{ averageBladeHeight: number; paletteLuminanceMin: number; paletteLuminanceMax: number; + paletteGreenMin: number; + paletteGreenMax: number; alphaHashActive: boolean; alphaToCoverageActive: boolean; shaderFallbackActive: boolean; edgeFadeCount: number; + candidateCellsByTerrain: Readonly>; + activeCellsByTerrain: Readonly>; countsByTerrain: Readonly>; + averageVegetationDensityByTerrain: Readonly>; completelyBareActiveCells: number; rejectedByStructureClearance: number; rejectedBySlope: number; @@ -117,11 +122,16 @@ function emptyTelemetry(plan: RealmGrassRenderPlan, alphaToCoverage = false): Re averageBladeHeight: 0, paletteLuminanceMin: 0, paletteLuminanceMax: 0, + paletteGreenMin: 0, + paletteGreenMax: 0, alphaHashActive: true, alphaToCoverageActive: alphaToCoverage, shaderFallbackActive: false, edgeFadeCount: 0, + candidateCellsByTerrain: Object.freeze(emptyCounts()), + activeCellsByTerrain: Object.freeze(emptyCounts()), countsByTerrain: Object.freeze(emptyCounts()), + averageVegetationDensityByTerrain: Object.freeze(emptyCounts()), completelyBareActiveCells: 0, rejectedByStructureClearance: 0, rejectedBySlope: 0, @@ -253,11 +263,15 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re let completelyBareActiveCells = 0; let rejectedByStructureClearance = 0; let rejectedBySlope = 0; + const candidateCellsByTerrain = emptyCounts(); + const activeCellsByTerrain = emptyCounts(); window.cells.forEach((activeCell) => { + const data = cellDataFor(activeCell.cell); + candidateCellsByTerrain[data.terrainKind] += 1; // At zero fade the alpha-hashed geometry is fully discarded. Do not // spend cache/instance capacity on that invisible boundary ring. if (activeCell.edgeFade <= 0) return; - const data = cellDataFor(activeCell.cell); + activeCellsByTerrain[data.terrainKind] += 1; if (data.completelyBare) completelyBareActiveCells += 1; rejectedByStructureClearance += data.rejectedByStructure + data.rejectedByExclusion; rejectedBySlope += data.rejectedBySlope; @@ -282,6 +296,8 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re let heightTotal = 0; let luminanceMin = Number.POSITIVE_INFINITY; let luminanceMax = 0; + let greenMin = Number.POSITIVE_INFINITY; + let greenMax = 0; let edgeFadeCount = 0; packedByVariant.forEach((variantPoints, variant) => { const currentMesh = meshes[variant]!; @@ -304,6 +320,8 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re const luminance = 0.2126 * point.tint.r + 0.7152 * point.tint.g + 0.0722 * point.tint.b; luminanceMin = Math.min(luminanceMin, luminance); luminanceMax = Math.max(luminanceMax, luminance); + greenMin = Math.min(greenMin, point.tint.g); + greenMax = Math.max(greenMax, point.tint.g); if (edgeFade < 0.999) edgeFadeCount += 1; }); currentMesh.count = variantPoints.length; @@ -347,11 +365,21 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re averageBladeHeight: packed.length > 0 ? heightTotal / packed.length : 0, paletteLuminanceMin: Number.isFinite(luminanceMin) ? luminanceMin : 0, paletteLuminanceMax: luminanceMax, + paletteGreenMin: Number.isFinite(greenMin) ? greenMin : 0, + paletteGreenMax: greenMax, alphaHashActive: alphaHash, alphaToCoverageActive: alphaCoverage, shaderFallbackActive: false, edgeFadeCount, + candidateCellsByTerrain: Object.freeze(candidateCellsByTerrain), + activeCellsByTerrain: Object.freeze(activeCellsByTerrain), countsByTerrain: Object.freeze(counts), + averageVegetationDensityByTerrain: Object.freeze(Object.fromEntries( + (Object.keys(candidateCellsByTerrain) as RealmGrassTerrainKind[]).map((kind) => [ + kind, + counts[kind] / Math.max(1, activeCellsByTerrain[kind]) + ]) + ) as Record), completelyBareActiveCells, rejectedByStructureClearance, rejectedBySlope, diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 72f73a64..91a87b0f 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -18,7 +18,10 @@ import { resolveRealmSharedForestLayout } from '../../game/map/realmSharedForest import { generateTerrainDecorations } from '../../game/map/terrainDecorations'; import type { RealmGrassExclusion, RealmGrassTerrainKind } from '../../game/map/realmGrass'; import { generateRealmTerrainFeatures } from '../../game/map/realmTerrainFeatures'; -import type { RealmTerrainFeaturePoint } from '../../game/map/realmTerrainFeatures'; +import type { + RealmTerrainFeatureKind, + RealmTerrainFeaturePoint +} from '../../game/map/realmTerrainFeatures'; import { indexRealmTerrainSemantics, type RealmTerrainKind, @@ -326,6 +329,7 @@ export type RealmTerrainPresentationTelemetry = Readonly<{ semanticKindCount: number; semanticFeatureCount: number; semanticFeatureDrawCalls: number; + semanticFeatureCounts: Readonly>; totalDetailInstanceCount: number; totalDetailDrawCalls: number; /** Canonical shared rows render only when their full layout validates. */ @@ -339,7 +343,14 @@ export type RealmTerrainPresentationTelemetry = Readonly<{ grassCacheEntries: number; grassAnimated: boolean; grassTargetAnimationCadence: number; + grassCandidateCellsByTerrain: Readonly>; + grassActiveCellsByTerrain: Readonly>; grassCountsByTerrain: Readonly>; + grassAverageVegetationDensityByTerrain: Readonly>; + grassPaletteLuminanceMin: number; + grassPaletteLuminanceMax: number; + grassPaletteGreenMin: number; + grassPaletteGreenMax: number; grassCompletelyBareActiveCells: number; grassRejectedByStructureClearance: number; grassRejectedBySlope: number; @@ -1143,10 +1154,24 @@ function initializeRealmScene( alphaToCoverageActive: grassAlphaToCoverage, shaderFallbackActive: false, edgeFadeCount: 0, + paletteGreenMin: 0, + paletteGreenMax: 0, + candidateCellsByTerrain: Object.freeze({ + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }), + activeCellsByTerrain: Object.freeze({ + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }), countsByTerrain: Object.freeze({ meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, 'ancient-stone': 0, apron: 0 }), + averageVegetationDensityByTerrain: Object.freeze({ + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }), completelyBareActiveCells: 0, rejectedByStructureClearance: 0, rejectedBySlope: 0, @@ -1169,6 +1194,7 @@ function initializeRealmScene( + (currentForestTelemetry?.instanceCount ?? 0), semanticFeatureDrawCalls: semanticFeatures.drawCalls + (currentForestTelemetry?.drawCalls ?? 0), + semanticFeatureCounts: semanticFeatures.counts, totalDetailInstanceCount: decorationData.points.length + semanticFeatures.instanceCount + (currentForestTelemetry?.instanceCount ?? 0), @@ -1187,7 +1213,14 @@ function initializeRealmScene( grassCacheEntries: grass.cacheEntries, grassAnimated: grass.animated, grassTargetAnimationCadence: grass.targetAnimationCadence, + grassCandidateCellsByTerrain: grass.candidateCellsByTerrain, + grassActiveCellsByTerrain: grass.activeCellsByTerrain, grassCountsByTerrain: grass.countsByTerrain, + grassAverageVegetationDensityByTerrain: grass.averageVegetationDensityByTerrain, + grassPaletteLuminanceMin: grass.paletteLuminanceMin, + grassPaletteLuminanceMax: grass.paletteLuminanceMax, + grassPaletteGreenMin: grass.paletteGreenMin, + grassPaletteGreenMax: grass.paletteGreenMax, grassCompletelyBareActiveCells: grass.completelyBareActiveCells, grassRejectedByStructureClearance: grass.rejectedByStructureClearance, grassRejectedBySlope: grass.rejectedBySlope, @@ -1200,6 +1233,7 @@ function initializeRealmScene( const signature = [ telemetry.semanticFeatureCount, telemetry.semanticFeatureDrawCalls, + Object.values(telemetry.semanticFeatureCounts).join(','), telemetry.totalDetailInstanceCount, telemetry.totalDetailDrawCalls, telemetry.forestPlacementSource, @@ -1209,7 +1243,14 @@ function initializeRealmScene( telemetry.grassTriangleCount, telemetry.grassCacheEntries, telemetry.grassAnimated, + Object.values(telemetry.grassCandidateCellsByTerrain).join(','), + Object.values(telemetry.grassActiveCellsByTerrain).join(','), Object.values(telemetry.grassCountsByTerrain).join(','), + Object.values(telemetry.grassAverageVegetationDensityByTerrain).join(','), + telemetry.grassPaletteLuminanceMin, + telemetry.grassPaletteLuminanceMax, + telemetry.grassPaletteGreenMin, + telemetry.grassPaletteGreenMax, telemetry.grassCompletelyBareActiveCells, telemetry.grassRejectedByStructureClearance, telemetry.grassRejectedBySlope, diff --git a/src/components/realm/createRealmTerrainFeatures.ts b/src/components/realm/createRealmTerrainFeatures.ts index 09013398..9e3c8732 100644 --- a/src/components/realm/createRealmTerrainFeatures.ts +++ b/src/components/realm/createRealmTerrainFeatures.ts @@ -26,12 +26,6 @@ function geometryForKind(kind: RealmTerrainFeatureKind) { geometry.translate(0, 0.17, 0); return geometry; } - if (kind === 'heath-bloom') { - const geometry = new THREE.DodecahedronGeometry(0.052, 0); - geometry.scale(1.45, 0.78, 1.45); - geometry.translate(0, 0.043, 0); - return geometry; - } if (kind === 'ridge-outcrop') { const geometry = new THREE.DodecahedronGeometry(0.14, 0); geometry.scale(1.28, 0.7, 0.92); @@ -56,15 +50,6 @@ function materialForKind(kind: RealmTerrainFeatureKind): THREE.Material { metalness: 0 }); } - if (kind === 'heath-bloom') { - return new THREE.MeshStandardMaterial({ - color: '#72527d', - emissive: '#2b1538', - emissiveIntensity: 0.12, - roughness: 0.88, - metalness: 0 - }); - } if (kind === 'ridge-outcrop') { return new THREE.MeshStandardMaterial({ color: '#625f5b', @@ -83,11 +68,11 @@ function materialForKind(kind: RealmTerrainFeatureKind): THREE.Material { }); } return new THREE.MeshStandardMaterial({ - color: '#5a5364', - emissive: '#291634', - emissiveIntensity: 0.1, - roughness: 0.78, - metalness: 0.08 + color: '#667263', + emissive: '#1d2a20', + emissiveIntensity: 0.035, + roughness: 0.86, + metalness: 0.02 }); } @@ -106,11 +91,15 @@ export function createRealmTerrainFeatureLayers( const scale = new THREE.Vector3(); const axis = new THREE.Vector3(0, 1, 0); const meshes: THREE.InstancedMesh[] = []; + // Heath bloom remains in the historical type union for replay/fixture + // compatibility, but is no longer a live presentation primitive. + const renderablePoints = data.points.filter((point) => point.kind !== 'heath-bloom'); + const counts = Object.freeze({ ...data.counts, 'heath-bloom': 0 }); let disposed = false; try { - (Object.keys(data.counts) as RealmTerrainFeatureKind[]).forEach((kind) => { - const points = data.points.filter((point) => point.kind === kind); + (Object.keys(counts) as RealmTerrainFeatureKind[]).forEach((kind) => { + const points = renderablePoints.filter((point) => point.kind === kind); if (points.length === 0) return; const geometry = geometryForKind(kind); const material = materialForKind(kind); @@ -173,9 +162,9 @@ export function createRealmTerrainFeatureLayers( return Object.freeze({ group, - counts: data.counts, + counts, drawCalls: meshes.length, - instanceCount: data.points.length, + instanceCount: renderablePoints.length, dispose: () => { if (disposed) return; disposed = true; diff --git a/src/game/map/realmGrass.ts b/src/game/map/realmGrass.ts index 2d1048fc..d65096db 100644 --- a/src/game/map/realmGrass.ts +++ b/src/game/map/realmGrass.ts @@ -45,39 +45,39 @@ export const REALM_GRASS_BIOME_PROFILES: Readonly< Record > = Object.freeze({ meadow: Object.freeze({ - kind: 'meadow', highCandidateCount: 30, completelyBareThreshold: 0.12, retention: 0.92, + kind: 'meadow', highCandidateCount: 34, completelyBareThreshold: 0.08, retention: 0.94, height: Object.freeze([0.11, 0.19]), width: Object.freeze([0.34, 0.52]), palette: palette(['#A8FF67', '#8EF04B', '#C0FF79', '#76DB39']), slopeSoftLimit: 0.42, slopeHardLimit: 0.78, minimumSeparation: 0.07 }), lowland: Object.freeze({ - kind: 'lowland', highCandidateCount: 24, completelyBareThreshold: 0.20, retention: 0.82, + kind: 'lowland', highCandidateCount: 30, completelyBareThreshold: 0.14, retention: 0.88, height: Object.freeze([0.10, 0.18]), width: Object.freeze([0.32, 0.50]), palette: palette(['#8AF052', '#70DC3D', '#A2F966', '#62C934']), slopeSoftLimit: 0.42, slopeHardLimit: 0.78, minimumSeparation: 0.075 }), forest: Object.freeze({ - kind: 'forest', highCandidateCount: 20, completelyBareThreshold: 0.32, retention: 0.76, + kind: 'forest', highCandidateCount: 24, completelyBareThreshold: 0.22, retention: 0.82, height: Object.freeze([0.10, 0.17]), width: Object.freeze([0.30, 0.46]), palette: palette(['#69D849', '#55C43D', '#7BE457', '#48B635']), slopeSoftLimit: 0.40, slopeHardLimit: 0.74, minimumSeparation: 0.08 }), heath: Object.freeze({ - kind: 'heath', highCandidateCount: 14, completelyBareThreshold: 0.47, retention: 0.68, + kind: 'heath', highCandidateCount: 22, completelyBareThreshold: 0.28, retention: 0.80, height: Object.freeze([0.09, 0.16]), width: Object.freeze([0.28, 0.43]), - palette: palette(['#75D84B', '#65C93F', '#7BBC57']), + palette: palette(['#7ED34A', '#67C33E', '#9BE05C']), slopeSoftLimit: 0.34, slopeHardLimit: 0.67, minimumSeparation: 0.085 }), ridge: Object.freeze({ - kind: 'ridge', highCandidateCount: 4, completelyBareThreshold: 0.84, retention: 0.56, + kind: 'ridge', highCandidateCount: 6, completelyBareThreshold: 0.72, retention: 0.62, height: Object.freeze([0.08, 0.13]), width: Object.freeze([0.24, 0.34]), palette: palette(['#8EC85A', '#79B84D']), slopeSoftLimit: 0.22, slopeHardLimit: 0.44, minimumSeparation: 0.10 }), 'ancient-stone': Object.freeze({ - kind: 'ancient-stone', highCandidateCount: 2, completelyBareThreshold: 0.96, retention: 0.46, + kind: 'ancient-stone', highCandidateCount: 4, completelyBareThreshold: 0.86, retention: 0.54, height: Object.freeze([0.07, 0.11]), width: Object.freeze([0.22, 0.30]), - palette: palette(['#8EC85A', '#79B84D']), + palette: palette(['#86B66A', '#6FA05C']), slopeSoftLimit: 0.18, slopeHardLimit: 0.34, minimumSeparation: 0.12 }), lake: Object.freeze({ @@ -86,7 +86,7 @@ export const REALM_GRASS_BIOME_PROFILES: Readonly< slopeSoftLimit: 0, slopeHardLimit: 0, minimumSeparation: 0 }), apron: Object.freeze({ - kind: 'apron', highCandidateCount: 5, completelyBareThreshold: 0.65, retention: 0.52, + kind: 'apron', highCandidateCount: 6, completelyBareThreshold: 0.52, retention: 0.56, height: Object.freeze([0.08, 0.13]), width: Object.freeze([0.24, 0.36]), palette: palette(['#75D84B', '#65C93F']), slopeSoftLimit: 0.30, slopeHardLimit: 0.58, minimumSeparation: 0.11 diff --git a/src/game/map/realmTerrainFeatures.ts b/src/game/map/realmTerrainFeatures.ts index 9942ae16..9316adb1 100644 --- a/src/game/map/realmTerrainFeatures.ts +++ b/src/game/map/realmTerrainFeatures.ts @@ -69,7 +69,9 @@ type RealmTerrainFeatureCandidate = Readonly<{ function featureKindForTerrain(kind: RealmTerrainKind): RealmTerrainFeatureKind | undefined { if (kind === 'forest') return 'forest-tree'; - if (kind === 'heath') return 'heath-bloom'; + // Heath keeps its semantic identity and historical feature type for + // compatibility, but ordinary cells no longer receive a purple bloom. + if (kind === 'heath') return undefined; if (kind === 'ridge') return 'ridge-outcrop'; if (kind === 'lake') return 'lake-sheen'; if (kind === 'ancient-stone') return 'ancient-monolith'; @@ -88,7 +90,7 @@ function featureCountForCell( ? 1 : 0; } - if (terrainKind === 'heath') return quality === 'reduced' ? 0 : 1; + if (terrainKind === 'heath') return 0; return featureKindForTerrain(terrainKind) === undefined ? 0 : 1; } diff --git a/src/game/map/terrainColor.ts b/src/game/map/terrainColor.ts index 605e492c..af01897c 100644 --- a/src/game/map/terrainColor.ts +++ b/src/game/map/terrainColor.ts @@ -32,7 +32,12 @@ export type TerrainColorContext = Readonly<{ placements?: readonly TerrainStructurePlacement[]; }>; -const TERRAIN_KIND_PALETTE: Readonly>> = Object.freeze({ @@ -41,12 +46,26 @@ const TERRAIN_KIND_PALETTE: Readonly; + +/** Stable palette metrics used by visual-contract tests and bounded QA logs. */ +export function terrainColorVisualMetrics(kind: RealmTerrainKind): TerrainColorVisualMetrics { + const color = REALM_TERRAIN_KIND_PALETTE[kind].color; + return Object.freeze({ + luminance: 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b, + greenLead: color.g - Math.max(color.r, color.b) + }); +} + function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, value)); } @@ -122,7 +141,7 @@ export function sampleLowlandsColor( const forestCanopy = clamp(context.forestCanopy ?? 0, 0, 1); if (context.terrainKind) { - const semantic = TERRAIN_KIND_PALETTE[context.terrainKind]; + const semantic = REALM_TERRAIN_KIND_PALETTE[context.terrainKind]; // Sparse canonical forest cells remain semantically forest, but a low // visual canopy keeps their ground from reading as isolated black-green // tiles between open meadows. The stable ecoregion field restores the full diff --git a/tests/realmGrassGenesisBounds.test.ts b/tests/realmGrassGenesisBounds.test.ts index 18edaa88..6f8b6e06 100644 --- a/tests/realmGrassGenesisBounds.test.ts +++ b/tests/realmGrassGenesisBounds.test.ts @@ -121,7 +121,7 @@ describe('canonical Genesis 001 grass bounds', () => { expect(first.drawCalls).toBeLessThanOrEqual(3); expect(first.variantCounts).toHaveLength(3); expect(digestPackedGrass(layer)).toBe( - 'd27befa17b44b0eaba5d24695c983c23c683c723472bb426e38d04ed2273594b' + '41d786fb5f5095bfbddb961b08f4ea363ed99c850865c1a0af8d85ef240b64ef' ); layer.updateView(axialToWorld({ q: 30, r: -10 }, 1), 'keep'); diff --git a/tests/realmGrassVisualContract.test.ts b/tests/realmGrassVisualContract.test.ts index e7b8f3ee..64dfa6f8 100644 --- a/tests/realmGrassVisualContract.test.ts +++ b/tests/realmGrassVisualContract.test.ts @@ -39,6 +39,8 @@ describe('luminous broad grass visual contract', () => { expect(meadow[0]!.r).toBeCloseTo(0.3916, 3); meadow.forEach((colour) => expect(colour.g).toBeGreaterThan(colour.r)); expect(REALM_GRASS_BIOME_PROFILES.heath.palette).toHaveLength(3); + expect(REALM_GRASS_BIOME_PROFILES.heath.highCandidateCount).toBeGreaterThanOrEqual(20); + expect(REALM_GRASS_BIOME_PROFILES.heath.completelyBareThreshold).toBeLessThanOrEqual(0.30); }); it('uses coverage alpha without switching the foliage layer to blending', () => { diff --git a/tests/realmSceneCleanup.test.ts b/tests/realmSceneCleanup.test.ts index 970b9e33..fcf29ebb 100644 --- a/tests/realmSceneCleanup.test.ts +++ b/tests/realmSceneCleanup.test.ts @@ -347,6 +347,13 @@ describe('realm scene setup cleanup', () => { semanticKindCount: 1, semanticFeatureCount: 0, semanticFeatureDrawCalls: 0, + semanticFeatureCounts: { + 'forest-tree': 0, + 'heath-bloom': 0, + 'ridge-outcrop': 0, + 'lake-sheen': 0, + 'ancient-monolith': 0 + }, totalDetailInstanceCount: expect.any(Number), totalDetailDrawCalls: expect.any(Number), forestPlacementSource: 'legacy-fallback', @@ -359,6 +366,14 @@ describe('realm scene setup cleanup', () => { grassCacheEntries: 0, grassAnimated: false, grassTargetAnimationCadence: 0, + grassCandidateCellsByTerrain: { + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }, + grassActiveCellsByTerrain: { + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }, grassCountsByTerrain: { meadow: 0, lowland: 0, @@ -369,6 +384,14 @@ describe('realm scene setup cleanup', () => { 'ancient-stone': 0, apron: 0 }, + grassAverageVegetationDensityByTerrain: { + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }, + grassPaletteLuminanceMin: 0, + grassPaletteLuminanceMax: 0, + grassPaletteGreenMin: 0, + grassPaletteGreenMax: 0, grassCompletelyBareActiveCells: 0, grassRejectedByStructureClearance: 0, grassRejectedBySlope: 0, diff --git a/tests/realmTerrainFeatures.test.ts b/tests/realmTerrainFeatures.test.ts index 52876816..4eedef05 100644 --- a/tests/realmTerrainFeatures.test.ts +++ b/tests/realmTerrainFeatures.test.ts @@ -87,7 +87,10 @@ describe('semantic realm terrain features', () => { expect(high.points.length).toBeLessThanOrEqual(REALM_TERRAIN_FEATURE_BUDGETS.high); expect(balanced.points.length).toBeLessThanOrEqual(REALM_TERRAIN_FEATURE_BUDGETS.balanced); expect(reduced.points.length).toBeLessThanOrEqual(REALM_TERRAIN_FEATURE_BUDGETS.reduced); - expect(Object.values(high.counts).every((count) => count > 0)).toBe(true); + expect(high.counts['heath-bloom']).toBe(0); + expect(Object.entries(high.counts) + .filter(([kind]) => kind !== 'heath-bloom') + .every(([, count]) => count > 0)).toBe(true); expect(high.points.every((point) => !semantics.castleSlotKeys.has(hexKey(point.coord)))) .toBe(true); expect(high.points.every((point) => ( @@ -135,6 +138,36 @@ describe('semantic realm terrain features', () => { } }); + it('removes ordinary heath bloom geometry while keeping ancient stone readable', () => { + const { placements, semantics, surface } = canonicalInput(); + const high = generateRealmTerrainFeatures( + surface.renderMap, + semantics.terrainKindsByKey, + 'high', + 1, + placements, + semantics.castleSlotKeys + ); + expect(high.counts['heath-bloom']).toBe(0); + expect(high.points.some((point) => point.kind === 'heath-bloom')).toBe(false); + + const layer = createRealmTerrainFeatureLayers( + high, + surface.renderMap, + REALM_QUALITY_SPECS.high, + 1, + placements + ); + const monolith = layer.group.children.find( + (child) => child.name === 'realm-ancient-monoliths' + ) as THREE.InstancedMesh | undefined; + expect(monolith).toBeDefined(); + expect((monolith!.material as THREE.MeshStandardMaterial).color.g).toBeGreaterThan( + (monolith!.material as THREE.MeshStandardMaterial).color.r + ); + layer.dispose(); + }); + it('preserves established generation-v2 semantic features and order inside radius twenty', () => { const { placements, semantics, snapshot, surface } = canonicalInput(); const establishedSurface = createAuthoritativeRealmTerrainSurface( @@ -199,7 +232,7 @@ describe('semantic realm terrain features', () => { expect(layer.group.name).toBe('realm-semantic-terrain-features'); expect(layer.instanceCount).toBe(data.points.length); expect(layer.drawCalls).toBeGreaterThan(0); - expect(layer.drawCalls).toBeLessThanOrEqual(5); + expect(layer.drawCalls).toBeLessThanOrEqual(4); layer.group.children.forEach((child) => { const mesh = child as THREE.InstancedMesh; expect(mesh.boundingBox).not.toBeNull(); diff --git a/tests/terrainColor.test.ts b/tests/terrainColor.test.ts index 2e278dfe..d15abb6f 100644 --- a/tests/terrainColor.test.ts +++ b/tests/terrainColor.test.ts @@ -4,10 +4,24 @@ import { generateRealmTerrainMap, terrainCellByCoord } from '../src/game/map/gen import { axialToWorld } from '../src/game/map/hexCoordinates'; import { HEGEMONY_GENESIS_001 } from '../src/game/map/realmSeed'; import type { RealmTerrainKind } from '../src/game/map/realmTerrainSemantics'; -import { sampleLowlandsColor } from '../src/game/map/terrainColor'; +import { + REALM_TERRAIN_KIND_PALETTE, + sampleLowlandsColor, + terrainColorVisualMetrics +} from '../src/game/map/terrainColor'; import { createHegemonyKeepPlacement } from '../src/game/map/terrainPlacements'; describe('lowlands terrain color', () => { + it('keeps heath green-led and ancient stone neutral rather than purple', () => { + const heath = REALM_TERRAIN_KIND_PALETTE.heath.color; + const stone = REALM_TERRAIN_KIND_PALETTE['ancient-stone'].color; + expect(heath.g).toBeGreaterThan(heath.r); + expect(heath.g).toBeGreaterThan(heath.b); + expect(stone.g).toBeGreaterThan(stone.b); + expect(terrainColorVisualMetrics('heath').greenLead).toBeGreaterThan(0); + expect(terrainColorVisualMetrics('ancient-stone').greenLead).toBeGreaterThan(-0.01); + }); + it('is deterministic, finite, and identical from either side of a shared edge', () => { const map = generateRealmTerrainMap(HEGEMONY_GENESIS_001, 5); const first = terrainCellByCoord(map, { q: 0, r: 0 });