From a68fa1b58ee894ee5796d831a6a90ac2d79ff57a Mon Sep 17 00:00:00 2001 From: Ael Date: Wed, 22 Jul 2026 00:35:25 +0200 Subject: [PATCH] Visual: add layered Water motion, foam, and fog --- docs/design/layered-water-atmosphere.md | 57 ++ src/components/realm/RealmMapScreen.tsx | 11 +- src/components/realm/createRealmScene.ts | 91 +- src/components/realm/realmPickArbitration.ts | 9 + src/components/realm/realmWaterLayer.ts | 974 +++++++++++++------ src/components/realm/realmWaterNavigation.ts | 47 + src/components/realm/realmWaterPhase.ts | 132 +++ src/spacetime/warpkeepBackendTypes.ts | 2 + src/spacetime/warpkeepConnection.ts | 3 +- tests/realmWaterLayer.test.ts | 287 ++---- tests/realmWaterNavigation.test.ts | 22 + tests/realmWaterPhase.test.ts | 74 ++ 12 files changed, 1191 insertions(+), 518 deletions(-) create mode 100644 docs/design/layered-water-atmosphere.md create mode 100644 src/components/realm/realmWaterPhase.ts create mode 100644 tests/realmWaterPhase.test.ts diff --git a/docs/design/layered-water-atmosphere.md b/docs/design/layered-water-atmosphere.md new file mode 100644 index 0000000..8e3f060 --- /dev/null +++ b/docs/design/layered-water-atmosphere.md @@ -0,0 +1,57 @@ +# Layered Water and Atmosphere + +This renderer-only layer builds on the immutable Genesis Water v1 rows and the +active ocean-and-river revision. It does not add tables, regenerate topology, +change passability, or publish Water state. + +## Phase + +`realm_environment_v1.updated_at` is now retained in the already-persisted +public projection. `realmWaterPhase.ts` uses that server boundary, the +environment epoch, and a synchronized server-time estimate when one is +available. Monotonic local time advances only between samples. Reduced motion +uses a deterministic frozen phase. A tab resume clamps the scheduler delta and +never fast-forwards a shader by the hidden-tab duration. + +## Geometry + +- Ocean and legacy lake rows share one deterministic world-space vertex map. + High uses three subdivisions per canonical hex, Balanced two, and Reduced + one. The geometry keeps a triangle-to-cell-key table for selection. +- The twelve canonical river paths become joined ribbons. Each cell contributes + one centerline point, a flow-aligned bank pair, bounded width in the + `0.50–0.72` range, and deterministic source/mouth caps. Terrain contact is + sampled only for presentation clearance; the underlying terrain and route + authority remain unchanged. +- Full-fog cells are still rendered as a bounded coarse buffer, but the shader + replaces all Water lighting with the exact fog color when the canonical fog + factor reaches `1`. A single opaque vertical curtain closes the outer ring. + +## Material + +The Three.js r185 `MeshStandardMaterial` path remains authoritative. The +vertex shader displaces ocean swell and river flow from world-space functions, +then derives analytic normals from the same function. The fragment path layers +depth absorption, restrained Fresnel, sun glitter, shore/crest/river foam, and +fog replacement. A stable cache key and explicit shader markers make contract +drift fail closed through the existing Water-unavailable scene path. + +## Budgets + +| Quality | Water triangles | Water draws | Wave components | Cadence | +| --- | ---: | ---: | ---: | ---: | +| High | 220,000 | 5 | 8 | 30 fps | +| Balanced | 105,000 | 5 | 5 | 22 fps | +| Reduced | 35,000 | 4 | 0 | static | + +The ambience scheduler remains the only RAF owner. Hidden tabs, disposed +scenes, reduced motion, and static Water stop work. No per-frame geometry +rebuild, per-vertex CPU wave loop, reflection target, or per-cell draw exists. + +## Selection and fallback + +The Water layer maps raycast triangle indices back to exact canonical cell keys, +rejects full-fog hits, and owns a pointer-inert selected-cell outline. This +mapping is independent of subdivision quality and subscription row order. If +geometry or shader construction fails, the existing scene keeps terrain, +controls, and the static fallback path; no gameplay authority is revoked. diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index 4ad09e5..93d74a5 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -920,6 +920,13 @@ function CanonicalRealmMapScreen({ if (node) selectStoneNode(node, target.source !== 'wagon'); return; } + if (target.kind === 'water') { + // Water selection is intentionally renderer-owned: ocean coordinates + // are outside playable terrain, while the exact canonical cell key must + // survive mesh subdivision and quality changes. + sceneRef.current?.setSelectedWaterCell(target.cellKey); + return; + } selectCoord(target.coord); }, [selectCastle, selectCoord, selectFoodNode, selectGoldNode, selectStoneNode, selectWoodNode]); @@ -1271,6 +1278,8 @@ function CanonicalRealmMapScreen({ sharedForestLayout: sharedForestProjection.layout, sharedForestTrees: sharedForestProjection.trees, waterCells, + waterEnvironment: snapshot.realmEnvironment, + waterBodies: snapshot.waterBodies, realmId: snapshot.realm.realmId, // The retired local planner is exposed only to the synthetic dev // observer. Player scenes wait for the paired shared public tables. @@ -1351,7 +1360,7 @@ function CanonicalRealmMapScreen({ scene?.dispose(); if (sceneRef.current === scene) sceneRef.current = null; }; - }, [foodNodeCatalog, goldNodeCatalog, handleSceneTargetHover, handleSceneTargetSelect, hasNearbyFoundingKeeps, isSceneCoordPassable, keepCoord, markRendererUnavailable, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, sharedForestProjection, snapshot.realm.realmId, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog]); + }, [foodNodeCatalog, goldNodeCatalog, handleSceneTargetHover, handleSceneTargetSelect, hasNearbyFoundingKeeps, isSceneCoordPassable, keepCoord, markRendererUnavailable, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, sharedForestProjection, snapshot.realm.realmId, snapshot.realmEnvironment, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog]); useEffect(() => { sceneRef.current?.reconcileLiveGatheringState?.(liveGatheringState); diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 386c036..b03184c 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -90,6 +90,7 @@ import { import { createRealmAmbientScheduler, type RealmAmbientScheduler } from './realmAmbientScheduler'; import { createRealmWaterLayer, + REALM_WATER_ANIMATION_FRAME_CAPS, type RealmWaterLayer } from './realmWaterLayer'; import { @@ -308,6 +309,11 @@ function isGrassShaderContractFailure(error: unknown) { && error.message === 'REALM_GRASS_SHADER_BEGIN_VERTEX_CONTRACT_CHANGED'; } +function isWaterShaderContractFailure(error: unknown) { + return error instanceof Error + && error.message === 'REALM_WATER_SHADER_CONTRACT_CHANGED'; +} + type RealmCastleLabelScreenPoint = Readonly<{ x: number; y: number }>; function validScreenBounds( @@ -430,6 +436,7 @@ export type RealmSceneHandle = Readonly<{ setSelectedFoodSiteId: (siteId: string | null) => void; setSelectedWoodSiteId: (siteId: string | null) => void; setSelectedStoneSiteId: (siteId: string | null) => void; + setSelectedWaterCell: (cellKey: string | null) => void; setComposition: (composition: RealmCameraComposition) => void; showRealm: () => void; }>; @@ -523,6 +530,10 @@ export type CreateRealmSceneOptions = Readonly<{ realmId?: string; /** Complete, digest-validated public water projection; absent means water is unavailable. */ waterCells?: readonly GenesisWaterCellV1[]; + /** Validated body rows provide the canonical seed/preset for Water phase. */ + waterBodies?: readonly unknown[]; + /** Persisted environment boundary used to synchronize Water phase. */ + waterEnvironment?: unknown; /** * Test/DEV-observer-only bridge for the retired deterministic preview. * Production player scenes must not synthesize a forest before the public @@ -1245,6 +1256,8 @@ function initializeRealmScene( quality: runtimeQuality, reducedMotion: options.reducedMotion, hexSize: HEX_SIZE, + environment: options.waterEnvironment, + waterBodies: options.waterBodies, heightAtWorld: (world) => terrainHeightAtWorld( options.surface.renderMap, world, @@ -1644,6 +1657,7 @@ function initializeRealmScene( let selectedFoodSiteId: string | undefined; let selectedWoodSiteId: string | undefined; let selectedStoneSiteId: string | undefined; + let selectedWaterCellKey: string | undefined; let selectedTerrainCoord: HexCoord | null = null; let hoveredTerrainCoord: HexCoord | null = null; let castleFocusSize: Readonly<{ height: number; footprintDiameter: number }> = Object.freeze({ @@ -1708,6 +1722,20 @@ function initializeRealmScene( emitTerrainPresentationTelemetry(); return true; }; + const disableWaterPresentation = () => { + const layer = waterLayer; + if (!layer) return false; + waterLayer = null; + scene.remove(layer.group); + try { + layer.dispose(); + } catch { + // A shader contract fallback must not strand the rest of the scene. + } + options.canvas.dataset.waterPresentation = 'unavailable'; + ambientScheduler?.setActive(ambientIsNeeded()); + return true; + }; const projectionPoint = new THREE.Vector3(); const projectionBoundsPoint = new THREE.Vector3(); const projectCastleLabels = () => { @@ -1924,9 +1952,12 @@ function initializeRealmScene( try { renderer.render(scene, cameraController.camera); } catch (error) { - if (!isGrassShaderContractFailure(error) || !disableGrassPresentation()) throw error; + let disabled = false; + if (isGrassShaderContractFailure(error)) disabled = disableGrassPresentation(); + if (isWaterShaderContractFailure(error)) disabled = disableWaterPresentation() || disabled; + if (!disabled) throw error; // `onBeforeCompile` runs during rendering. Retry the same frame without - // only the grass layer if its pinned shader chunk contract has changed. + // only the affected decorative layer(s); terrain and controls remain. renderer.render(scene, cameraController.camera); } projectCastleLabels(); @@ -2098,7 +2129,10 @@ function initializeRealmScene( document.addEventListener('visibilitychange', handleRenderVisibility); cleanup.add(() => document.removeEventListener('visibilitychange', handleRenderVisibility)); ambientScheduler = createRealmAmbientScheduler({ - frameCap: renderPlan.grass.animationFrameCap, + frameCap: Math.max( + renderPlan.grass.animationFrameCap, + REALM_WATER_ANIMATION_FRAME_CAPS[runtimeQuality.id] + ), active: ambientIsNeeded(), onStep: (elapsedSeconds) => { if (cleanup.isDisposed()) return; @@ -2200,6 +2234,16 @@ function initializeRealmScene( const castleHit = castleLayer?.raycast(raycaster); const foregroundHit = arbitrateRealmPick({ resourceHits, castleHit }); if (foregroundHit) return foregroundHit; + const waterHit = waterLayer?.raycast(raycaster); + if (waterHit) { + return Object.freeze({ + kind: 'water' as const, + cellKey: waterHit.cellKey, + regime: waterHit.regime, + coord: waterHit.coord, + distance: waterHit.distance + }); + } const intersections = raycaster.intersectObject(terrain, false); for (const intersection of intersections) { const coord = worldToNearestAxial( @@ -2412,10 +2456,12 @@ function initializeRealmScene( selectedFoodSiteId = picked.kind === 'food-site' ? picked.siteId : undefined; selectedWoodSiteId = picked.kind === 'wood-site' ? picked.siteId : undefined; selectedStoneSiteId = picked.kind === 'stone-site' ? picked.siteId : undefined; + selectedWaterCellKey = picked.kind === 'water' ? picked.cellKey : undefined; goldNodeLayer?.setSelectedSiteId(selectedGoldSiteId ?? null); foodNodeLayer?.setSelectedSiteId(selectedFoodSiteId ?? null); woodNodeLayer?.setSelectedSiteId(selectedWoodSiteId ?? null); stoneNodeLayer?.setSelectedSiteId(selectedStoneSiteId ?? null); + waterLayer?.setSelectedCellKey(selectedWaterCellKey ?? null); dispatchSelect(picked); render(); if (picked.kind === 'castle' && picked.castleId === options.ownCastleId) { @@ -2946,6 +2992,8 @@ function initializeRealmScene( setSelected: (coord) => { if (cleanup.isDisposed()) return; selectedTerrainCoord = coord; + selectedWaterCellKey = undefined; + waterLayer?.setSelectedCellKey(null); grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); selectedCastleId = coord ? authoritativeCastles.find((castle) => ( @@ -2964,6 +3012,8 @@ function initializeRealmScene( if (cleanup.isDisposed()) return; selectedCastleId = castleId === null ? undefined : castleId; if (castleId !== null) { + selectedWaterCellKey = undefined; + waterLayer?.setSelectedCellKey(null); selectedTerrainCoord = null; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); setOverlay(selectedOverlay, options.surface, null, terrainPlacements); @@ -2974,28 +3024,55 @@ function initializeRealmScene( if (cleanup.isDisposed()) return; selectedGoldSiteId = siteId === null ? undefined : siteId; goldNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + selectedWaterCellKey = undefined; + waterLayer?.setSelectedCellKey(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } render(); }, setSelectedFoodSiteId: (siteId) => { if (cleanup.isDisposed()) return; selectedFoodSiteId = siteId === null ? undefined : siteId; foodNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + selectedWaterCellKey = undefined; + waterLayer?.setSelectedCellKey(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } render(); }, setSelectedWoodSiteId: (siteId) => { if (cleanup.isDisposed()) return; selectedWoodSiteId = siteId === null ? undefined : siteId; woodNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + selectedWaterCellKey = undefined; + waterLayer?.setSelectedCellKey(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } render(); }, setSelectedStoneSiteId: (siteId) => { if (cleanup.isDisposed()) return; selectedStoneSiteId = siteId === null ? undefined : siteId; stoneNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + selectedWaterCellKey = undefined; + waterLayer?.setSelectedCellKey(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } + render(); + }, + setSelectedWaterCell: (cellKey) => { + if (cleanup.isDisposed()) return; + selectedWaterCellKey = cellKey === null ? undefined : cellKey; + waterLayer?.setSelectedCellKey(cellKey); + if (cellKey !== null) { + selectedTerrainCoord = null; + grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } render(); }, setComposition: (composition) => cameraController.setComposition(composition), diff --git a/src/components/realm/realmPickArbitration.ts b/src/components/realm/realmPickArbitration.ts index 7e35355..d4780cc 100644 --- a/src/components/realm/realmPickArbitration.ts +++ b/src/components/realm/realmPickArbitration.ts @@ -14,6 +14,14 @@ export type RealmResourcePickHit = Readonly<{ distance: number; }>; +export type RealmWaterPickHit = Readonly<{ + kind: 'water'; + cellKey: string; + regime: 'ocean' | 'lake' | 'river'; + coord: HexCoord; + distance: number; +}>; + export type RealmInteractionTarget = | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }> | Readonly<{ @@ -22,6 +30,7 @@ export type RealmInteractionTarget = coord: HexCoord; source: 'site' | 'wagon'; }> + | RealmWaterPickHit | Readonly<{ kind: 'terrain'; coord: HexCoord }>; function nearestValidHit( diff --git a/src/components/realm/realmWaterLayer.ts b/src/components/realm/realmWaterLayer.ts index cadc3d8..f9e6e08 100644 --- a/src/components/realm/realmWaterLayer.ts +++ b/src/components/realm/realmWaterLayer.ts @@ -9,7 +9,9 @@ import { GENESIS_OCEAN_DEPTH_BY_KEY, GENESIS_WATER_LAYOUT_VERSION, genesisWaterWorldHeightFromMilli, - type GenesisWaterCellV1 + type GenesisWaterCellV1, + type GenesisWaterBodyV1, + GENESIS_RIVERS_V1 } from '../../../spacetimedb/src/waterWorld'; import type { RealmQualitySpec } from './realmQuality'; import { pointyHexCorners } from './createTerrainGeometry'; @@ -17,35 +19,37 @@ import { GENESIS_WATER_REVISION_ENABLED_CELLS_V1, GENESIS_WATER_REVISION_VERSION } from '../../../spacetimedb/src/waterRevision'; +import { resolveRealmWaterPhase } from './realmWaterPhase'; const WATER_Y_LIFT = 0.035; -const RIVER_BANK_BLEND = 0.28; -// The adaptive terrain and the full-cell river mesh are intentionally close, -// but a sub-centimetre gap aliases away at strategic camera distances. Keep a -// small deterministic presentation clearance so the persisted channel wins -// the depth buffer without reading as a floating sheet. const RIVER_TERRAIN_CLEARANCE = 0.014; -const RIVER_SURFACE_PROBE_SUBDIVISIONS = 6; const MAXIMUM_RIVER_SURFACE_CORRECTION = 0.16; +const RIVER_MIN_WIDTH = 0.50; +const RIVER_MAX_WIDTH = 0.72; +const OUTER_WATER_RADIUS = 65; +const OUTER_CURTAIN_BOTTOM = -20; +const OUTER_CURTAIN_TOP = 38; -/** Convert the persisted +1000 fixed-point datum into the terrain's world-Y space. */ -export function waterSurfaceLevelToWorldY(surfaceLevelMilli: number): number { - return genesisWaterWorldHeightFromMilli(surfaceLevelMilli); -} - -function fogMixForCell(cell: GenesisWaterCellV1): number { - if (cell.regime !== 'ocean') return 0; - if (cell.fogBand === 'full') return 1; - if (cell.fogBand === 'haze') return 0.45; - return 0; -} +/** Renderer cadence is shared with grass/wagons through one scheduler. */ +export const REALM_WATER_ANIMATION_FRAME_CAPS = Object.freeze({ + high: 30, + balanced: 22, + reduced: 0 +}); export const REALM_WATER_RENDER_BUDGETS = Object.freeze({ - high: Object.freeze({ triangles: 220_000, draws: 4, waveComponents: 8 }), - balanced: Object.freeze({ triangles: 105_000, draws: 4, waveComponents: 5 }), + high: Object.freeze({ triangles: 220_000, draws: 5, waveComponents: 8 }), + balanced: Object.freeze({ triangles: 105_000, draws: 5, waveComponents: 5 }), reduced: Object.freeze({ triangles: 35_000, draws: 4, waveComponents: 0 }) }); +export type RealmWaterPickHit = Readonly<{ + cellKey: string; + coord: Readonly<{ q: number; r: number }>; + regime: GenesisWaterCellV1['regime']; + distance: number; +}>; + export type RealmWaterLayerTelemetry = Readonly<{ layoutVersion: number; oceanCellCount: number; @@ -55,12 +59,17 @@ export type RealmWaterLayerTelemetry = Readonly<{ drawCalls: number; animated: boolean; fullFogOceanCellCount: number; + oceanSubdivision: number; + riverRibbonCount: number; + selectedCellKey: string | null; }>; export type RealmWaterLayer = Readonly<{ group: THREE.Group; updateEnvironment: (elapsedSeconds: number) => boolean; isAnimationActive: () => boolean; + raycast: (raycaster: THREE.Raycaster) => RealmWaterPickHit | null; + setSelectedCellKey: (cellKey: string | null) => void; getTelemetry: () => RealmWaterLayerTelemetry; dispose: () => void; }>; @@ -71,253 +80,437 @@ type WaterLayerOptions = Readonly<{ reducedMotion: boolean; hexSize: number; heightAtWorld: (world: HexWorldPosition) => number; + environment?: unknown; + waterBodies?: readonly unknown[]; }>; +type WaterVertex = Readonly<{ + world: HexWorldPosition; + height: number; + cell: GenesisWaterCellV1; +}>; + +type MutableWaterVertex = { + world: HexWorldPosition; + height: number; + cell: GenesisWaterCellV1; +}; + +function finiteNumber(value: number, fallback = 0) { + return Number.isFinite(value) ? value : fallback; +} + +function waterPointKey(point: HexWorldPosition) { + const precision = 1_000_000; + return `${Math.round(point.x * precision)},${Math.round(point.z * precision)}`; +} + +function cellKeyFor(cell: GenesisWaterCellV1) { + return cell.cellKey; +} + +function fogMixForCell(cell: GenesisWaterCellV1): number { + if (cell.regime !== 'ocean') return 0; + const depth = GENESIS_OCEAN_DEPTH_BY_KEY.get(cell.cellKey) ?? cell.oceanDepth; + if (depth < 3) return 0; + if (depth >= 5) return 1; + // The haze band starts at canonical depth 3 and is fully concealing at 5. + return depth === 3 ? 0.45 : 0.72; +} + +function shoreFoamForCell(cell: GenesisWaterCellV1): number { + if (cell.regime === 'river') return 0.86; + if (cell.regime !== 'ocean') return 0.18; + const depth = GENESIS_OCEAN_DEPTH_BY_KEY.get(cell.cellKey) ?? cell.oceanDepth; + if (depth <= 1) return 1; + if (depth === 2) return 0.58; + return 0.08; +} + +function regimeNumber(cell: GenesisWaterCellV1) { + return cell.regime === 'river' ? 1 : 0; +} + function regimeColor(cell: GenesisWaterCellV1): THREE.Color { - if (cell.regime === 'river') return new THREE.Color('#4aa9c7'); + if (cell.regime === 'river') return new THREE.Color('#3f9bb7'); if (cell.regime === 'lake') return new THREE.Color('#548eac'); const depth = GENESIS_OCEAN_DEPTH_BY_KEY.get(cell.cellKey) ?? cell.depthCells; return depth >= 5 ? new THREE.Color('#315b78') : depth >= 3 ? new THREE.Color('#3c7691') : new THREE.Color('#4f91ab'); } -function surfaceGeometry( - cells: readonly GenesisWaterCellV1[], - hexSize: number, - heightAtWorld: (world: HexWorldPosition) => number +function appendVertexAttributes( + vertex: WaterVertex, + positions: number[], + normals: number[], + colors: number[], + depths: number[], + bankBlends: number[], + fogMixes: number[], + regimes: number[], + shoreFoam: number[], + flowX: number[], + flowZ: number[] ) { - const positions: number[] = []; - const normals: number[] = []; - const colors: number[] = []; - const waterDepth: number[] = []; - const waterBankBlend: number[] = []; - const waterFogMix: number[] = []; - const indices: number[] = []; - cells.forEach((cell) => { - const center = axialToWorld({ q: cell.q, r: cell.r }, hexSize); - const authoritativeSurfaceY = waterSurfaceLevelToWorldY(cell.surfaceLevelMilli); - const ground = cell.regime === 'ocean' - ? authoritativeSurfaceY - : authoritativeSurfaceY + WATER_Y_LIFT; - if (cell.regime !== 'ocean') { - const terrainY = heightAtWorld(center); - if (!Number.isFinite(terrainY) || ground < terrainY) { - throw new Error('REALM_WATER_SURFACE_BELOW_TERRAIN'); - } - } - const color = regimeColor(cell); - const base = positions.length / 3; - positions.push(center.x, ground, center.z); - colors.push(color.r, color.g, color.b); - waterDepth.push(Math.min(1, cell.depthCells / 5)); - waterBankBlend.push(cell.regime === 'river' ? RIVER_BANK_BLEND : 0); - waterFogMix.push(fogMixForCell(cell)); - normals.push(0, 1, 0); - pointyHexCorners({ q: cell.q, r: cell.r }, hexSize).forEach((corner) => { - positions.push(corner.x, ground, corner.z); - colors.push(color.r, color.g, color.b); - waterDepth.push(Math.min(1, cell.depthCells / 5)); - waterBankBlend.push(cell.regime === 'river' ? RIVER_BANK_BLEND : 0); - waterFogMix.push(fogMixForCell(cell)); - normals.push(0, 1, 0); - }); - for (let corner = 0; corner < 6; corner += 1) { - // Pointy corners advance clockwise in Three.js's x/z ground plane when - // viewed from +y, so reverse the pair to keep the water front-facing. - indices.push(base, base + ((corner + 1) % 6) + 1, base + corner + 1); - } - }); + const color = regimeColor(vertex.cell); + positions.push(vertex.world.x, vertex.height, vertex.world.z); + normals.push(0, 1, 0); + colors.push(color.r, color.g, color.b); + depths.push(Math.min(1, Math.max(0, vertex.cell.depthCells / 5))); + bankBlends.push(vertex.cell.regime === 'river' ? 0.34 : 0); + fogMixes.push(fogMixForCell(vertex.cell)); + regimes.push(regimeNumber(vertex.cell)); + shoreFoam.push(shoreFoamForCell(vertex.cell)); + // Flow vectors are refined on ribbon vertices; this stable fallback keeps + // the attribute finite for ocean and legacy lake surfaces. + flowX.push(0); + flowZ.push(0); +} + +function subdivisionForQuality(quality: RealmQualitySpec) { + return quality.id === 'high' ? 3 : quality.id === 'balanced' ? 2 : 1; +} + +type GeometryBuildResult = Readonly<{ + geometry: THREE.BufferGeometry; + triangleCellKeys: readonly string[]; +}>; + +function createGeometry( + positions: number[], + normals: number[], + colors: number[], + depths: number[], + bankBlends: number[], + fogMixes: number[], + regimes: number[], + shoreFoam: number[], + flowX: number[], + flowZ: number[], + indices: number[], + triangleCellKeys: string[] +): GeometryBuildResult { const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); - geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); - geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); - geometry.setAttribute('waterDepth', new THREE.Float32BufferAttribute(waterDepth, 1)); - geometry.setAttribute('waterBankBlend', new THREE.Float32BufferAttribute(waterBankBlend, 1)); - geometry.setAttribute('waterFogMix', new THREE.Float32BufferAttribute(waterFogMix, 1)); try { + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); + geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); + geometry.setAttribute('waterDepth', new THREE.Float32BufferAttribute(depths, 1)); + geometry.setAttribute('waterBankBlend', new THREE.Float32BufferAttribute(bankBlends, 1)); + geometry.setAttribute('waterFogMix', new THREE.Float32BufferAttribute(fogMixes, 1)); + geometry.setAttribute('waterRegime', new THREE.Float32BufferAttribute(regimes, 1)); + geometry.setAttribute('waterShoreFoam', new THREE.Float32BufferAttribute(shoreFoam, 1)); + geometry.setAttribute('waterFlowX', new THREE.Float32BufferAttribute(flowX, 1)); + geometry.setAttribute('waterFlowZ', new THREE.Float32BufferAttribute(flowZ, 1)); geometry.setIndex(indices); geometry.computeBoundingSphere(); - return geometry; + geometry.userData.waterTriangleCellKeys = Object.freeze([...triangleCellKeys]); + return { geometry, triangleCellKeys: Object.freeze([...triangleCellKeys]) }; } catch (error) { geometry.dispose(); throw error; } } -type MutableRiverSurfaceNode = { - readonly world: HexWorldPosition; - height: number; -}; - -type RiverSurfacePlan = Readonly<{ - cell: GenesisWaterCellV1; - baseHeight: number; - center: MutableRiverSurfaceNode; - corners: readonly MutableRiverSurfaceNode[]; -}>; +function sharedVertexIndex( + world: HexWorldPosition, + height: number, + cell: GenesisWaterCellV1, + vertices: Map, + positions: number[], + normals: number[], + colors: number[], + depths: number[], + bankBlends: number[], + fogMixes: number[], + regimes: number[], + shoreFoam: number[], + flowX: number[], + flowZ: number[] +) { + const key = waterPointKey(world); + const existing = vertices.get(key); + if (existing !== undefined) { + // A shared boundary must remain fully concealed when either neighboring + // cell is canonical full fog. Maxing only presentation attributes keeps + // the seam closed without changing the persisted cell identity mapping. + fogMixes[existing] = Math.max(fogMixes[existing] ?? 0, fogMixForCell(cell)); + depths[existing] = Math.max(depths[existing] ?? 0, Math.min(1, Math.max(0, cell.depthCells / 5))); + shoreFoam[existing] = Math.max(shoreFoam[existing] ?? 0, shoreFoamForCell(cell)); + return existing; + } + const index = positions.length / 3; + appendVertexAttributes( + { world, height, cell }, + positions, + normals, + colors, + depths, + bankBlends, + fogMixes, + regimes, + shoreFoam, + flowX, + flowZ + ); + vertices.set(key, index); + return index; +} -function waterPointKey(point: HexWorldPosition) { - const precision = 1_000_000; - return `${Math.round(point.x * precision)},${Math.round(point.z * precision)}`; +function barycentricPoint( + center: HexWorldPosition, + first: HexWorldPosition, + second: HexWorldPosition, + u: number, + v: number +) { + const centerWeight = 1 - u - v; + return { + x: center.x * centerWeight + first.x * u + second.x * v, + z: center.z * centerWeight + first.z * u + second.z * v + }; } /** - * River cells retain the reviewed six-triangle/full-hex topology, but their - * seven presentation vertices cannot all use the persisted center datum. - * Terrain detail is continuous at cell boundaries and can rise above that - * datum near a bank. Shared corner nodes give both sides of a river edge the - * exact same endpoints, while deterministic triangle probes lift only the - * presentation mesh enough to clear the rendered terrain between vertices. + * Build a connected, deterministically subdivided hex surface. Boundary + * vertices are shared by world-space key, so adjacent cells cannot crack when + * the vertex shader applies the same world-space displacement function. */ -function riverSurfaceGeometry( +function connectedSurfaceGeometry( cells: readonly GenesisWaterCellV1[], hexSize: number, - heightAtWorld: (world: HexWorldPosition) => number -) { - const sharedCorners = new Map(); - const plans = cells.map((cell): RiverSurfacePlan => { - const centerWorld = axialToWorld({ q: cell.q, r: cell.r }, hexSize); - const baseHeight = waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + WATER_Y_LIFT; - const center = { world: centerWorld, height: baseHeight }; - const corners = pointyHexCorners({ q: cell.q, r: cell.r }, hexSize).map((world) => { - const key = waterPointKey(world); - const existing = sharedCorners.get(key); - if (existing) { - existing.height = Math.max(existing.height, baseHeight); - return existing; - } - const node = { world, height: baseHeight }; - sharedCorners.set(key, node); - return node; - }); - return { cell, baseHeight, center, corners }; - }); - - // The elevation solution must not depend on subscription row order. - const orderedPlans = [...plans].sort((left, right) => ( - left.cell.q - right.cell.q - || left.cell.r - right.cell.r - || left.cell.cellKey.localeCompare(right.cell.cellKey) + subdivision: number +): GeometryBuildResult { + const positions: number[] = []; + const normals: number[] = []; + const colors: number[] = []; + const depths: number[] = []; + const bankBlends: number[] = []; + const fogMixes: number[] = []; + const regimes: number[] = []; + const shoreFoam: number[] = []; + const flowX: number[] = []; + const flowZ: number[] = []; + const indices: number[] = []; + const triangleCellKeys: string[] = []; + const vertices = new Map(); + const ordered = [...cells].sort((left, right) => ( + left.q - right.q || left.r - right.r || left.cellKey.localeCompare(right.cellKey) )); - for (const plan of orderedPlans) { - for (let triangle = 0; triangle < 6; triangle += 1) { - const first = plan.corners[triangle]!; - const second = plan.corners[(triangle + 1) % 6]!; - for (let firstStep = 0; firstStep <= RIVER_SURFACE_PROBE_SUBDIVISIONS; firstStep += 1) { - for ( - let secondStep = 0; - secondStep <= RIVER_SURFACE_PROBE_SUBDIVISIONS - firstStep; - secondStep += 1 - ) { - const firstWeight = firstStep / RIVER_SURFACE_PROBE_SUBDIVISIONS; - const secondWeight = secondStep / RIVER_SURFACE_PROBE_SUBDIVISIONS; - const centerWeight = 1 - firstWeight - secondWeight; - const world = { - x: plan.center.world.x * centerWeight - + first.world.x * firstWeight - + second.world.x * secondWeight, - z: plan.center.world.z * centerWeight - + first.world.z * firstWeight - + second.world.z * secondWeight + for (const cell of ordered) { + const center = axialToWorld({ q: cell.q, r: cell.r }, hexSize); + const corners = pointyHexCorners({ q: cell.q, r: cell.r }, hexSize); + const height = waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + + (cell.regime === 'ocean' ? 0 : WATER_Y_LIFT); + for (let corner = 0; corner < 6; corner += 1) { + const first = corners[corner]!; + const second = corners[(corner + 1) % 6]!; + for (let uStep = 0; uStep < subdivision; uStep += 1) { + for (let vStep = 0; vStep < subdivision - uStep; vStep += 1) { + const u = uStep / subdivision; + const v = vStep / subdivision; + const nextU = (uStep + 1) / subdivision; + const nextV = (vStep + 1) / subdivision; + const addTriangle = (a: HexWorldPosition, b: HexWorldPosition, c: HexWorldPosition) => { + indices.push( + sharedVertexIndex(a, height, cell, vertices, positions, normals, colors, depths, bankBlends, fogMixes, regimes, shoreFoam, flowX, flowZ), + sharedVertexIndex(c, height, cell, vertices, positions, normals, colors, depths, bankBlends, fogMixes, regimes, shoreFoam, flowX, flowZ), + sharedVertexIndex(b, height, cell, vertices, positions, normals, colors, depths, bankBlends, fogMixes, regimes, shoreFoam, flowX, flowZ) + ); + triangleCellKeys.push(cell.cellKey); }; - const terrainY = heightAtWorld(world); - if ( - !Number.isFinite(terrainY) - || terrainY + RIVER_TERRAIN_CLEARANCE - plan.baseHeight - > MAXIMUM_RIVER_SURFACE_CORRECTION - ) throw new Error('REALM_WATER_SURFACE_BELOW_TERRAIN'); - const surfaceY = plan.center.height * centerWeight - + first.height * firstWeight - + second.height * secondWeight; - const correction = terrainY + RIVER_TERRAIN_CLEARANCE - surfaceY; - if (correction <= 0) continue; - plan.center.height += correction; - first.height += correction; - second.height += correction; + addTriangle( + barycentricPoint(center, first, second, u, v), + barycentricPoint(center, first, second, nextU, v), + barycentricPoint(center, first, second, u, nextV) + ); + if (uStep + vStep < subdivision - 1) { + addTriangle( + barycentricPoint(center, first, second, nextU, v), + barycentricPoint(center, first, second, nextU, nextV), + barycentricPoint(center, first, second, u, nextV) + ); + } } } } } + return createGeometry( + positions, normals, colors, depths, bankBlends, fogMixes, regimes, + shoreFoam, flowX, flowZ, indices, triangleCellKeys + ); +} + +type RiverPoint = Readonly<{ + cell: GenesisWaterCellV1; + world: HexWorldPosition; + height: number; + flowX: number; + flowZ: number; + left: HexWorldPosition; + right: HexWorldPosition; + width: number; +}>; +function riverFlowDirection( + current: HexWorldPosition, + previous: HexWorldPosition | undefined, + next: HexWorldPosition | undefined +) { + const from = previous ?? current; + const to = next ?? current; + let x = to.x - from.x; + let z = to.z - from.z; + const magnitude = Math.hypot(x, z); + if (magnitude < 0.00001) return { x: 0, z: 1 }; + x /= magnitude; + z /= magnitude; + return { x, z }; +} + +function riverRibbonGeometry( + cells: readonly GenesisWaterCellV1[], + hexSize: number, + heightAtWorld: (world: HexWorldPosition) => number +): GeometryBuildResult { + const cellsByKey = new Map(cells.map((cell) => [cell.cellKey, cell])); const positions: number[] = []; const normals: number[] = []; const colors: number[] = []; - const waterDepth: number[] = []; - const waterBankBlend: number[] = []; - const waterFogMix: number[] = []; + const depths: number[] = []; + const bankBlends: number[] = []; + const fogMixes: number[] = []; + const regimes: number[] = []; + const shoreFoam: number[] = []; + const flowX: number[] = []; + const flowZ: number[] = []; const indices: number[] = []; - plans.forEach((plan) => { - const color = regimeColor(plan.cell); - const depth = Math.min(1, plan.cell.depthCells / 5); - const base = positions.length / 3; - [plan.center, ...plan.corners].forEach((node) => { - positions.push(node.world.x, node.height, node.world.z); - colors.push(color.r, color.g, color.b); - waterDepth.push(depth); - waterBankBlend.push(RIVER_BANK_BLEND); - waterFogMix.push(0); - normals.push(0, 1, 0); + const triangleCellKeys: string[] = []; + let ribbonCount = 0; + + const appendRibbonVertex = (point: RiverPoint, world: HexWorldPosition) => { + const color = regimeColor(point.cell); + positions.push(world.x, point.height, world.z); + normals.push(0, 1, 0); + colors.push(color.r, color.g, color.b); + depths.push(Math.min(1, Math.max(0, point.cell.depthCells / 5))); + bankBlends.push(0.72); + fogMixes.push(0); + regimes.push(1); + shoreFoam.push(Math.min(1, 0.4 + point.cell.flowAccumulation / 12)); + flowX.push(point.flowX); + flowZ.push(point.flowZ); + return positions.length / 3 - 1; + }; + + for (const river of GENESIS_RIVERS_V1) { + const pathCells = river.orderedCellKeys.map((key) => cellsByKey.get(key)).filter( + (cell): cell is GenesisWaterCellV1 => cell !== undefined + ); + if (pathCells.length < 2) continue; + ribbonCount += 1; + const points: RiverPoint[] = pathCells.map((cell, index) => { + const world = axialToWorld({ q: cell.q, r: cell.r }, hexSize); + const previous = index > 0 + ? axialToWorld({ q: pathCells[index - 1]!.q, r: pathCells[index - 1]!.r }, hexSize) + : undefined; + const next = index + 1 < pathCells.length + ? axialToWorld({ q: pathCells[index + 1]!.q, r: pathCells[index + 1]!.r }, hexSize) + : undefined; + const direction = riverFlowDirection(world, previous, next); + const accumulation = Math.min(1, Math.max(0, cell.flowAccumulation / 12)); + const width = RIVER_MIN_WIDTH + (RIVER_MAX_WIDTH - RIVER_MIN_WIDTH) * accumulation; + const halfWidth = width * 0.5; + const lateral = { x: -direction.z, z: direction.x }; + const left = { x: world.x + lateral.x * halfWidth, z: world.z + lateral.z * halfWidth }; + const right = { x: world.x - lateral.x * halfWidth, z: world.z - lateral.z * halfWidth }; + const persisted = waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + WATER_Y_LIFT; + const leftTerrain = finiteNumber(heightAtWorld(left), persisted); + const rightTerrain = finiteNumber(heightAtWorld(right), persisted); + const correction = Math.max(0, Math.max(leftTerrain, rightTerrain) + RIVER_TERRAIN_CLEARANCE - persisted); + if (correction > MAXIMUM_RIVER_SURFACE_CORRECTION) { + throw new Error('REALM_WATER_SURFACE_BELOW_TERRAIN'); + } + return { + cell, + world, + height: persisted + correction, + flowX: direction.x, + flowZ: direction.z, + left, + right, + width + }; }); - for (let corner = 0; corner < 6; corner += 1) { - indices.push(base, base + ((corner + 1) % 6) + 1, base + corner + 1); + const edgePairs = points.map((point) => ({ + left: appendRibbonVertex(point, point.left), + right: appendRibbonVertex(point, point.right) + })); + for (let index = 0; index + 1 < edgePairs.length; index += 1) { + const first = edgePairs[index]!; + const second = edgePairs[index + 1]!; + indices.push(first.left, second.right, first.right, first.left, second.left, second.right); + triangleCellKeys.push(points[index]!.cell.cellKey, points[index]!.cell.cellKey); } - }); - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); - geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); - geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); - geometry.setAttribute('waterDepth', new THREE.Float32BufferAttribute(waterDepth, 1)); - geometry.setAttribute('waterBankBlend', new THREE.Float32BufferAttribute(waterBankBlend, 1)); - geometry.setAttribute('waterFogMix', new THREE.Float32BufferAttribute(waterFogMix, 1)); - try { - geometry.setIndex(indices); - geometry.computeBoundingSphere(); - return geometry; - } catch (error) { - geometry.dispose(); - throw error; + const firstPoint = points[0]!; + const lastPoint = points.at(-1)!; + const firstEdge = edgePairs[0]!; + const lastEdge = edgePairs.at(-1)!; + const firstCenter = appendRibbonVertex(firstPoint, firstPoint.world); + const lastCenter = appendRibbonVertex(lastPoint, lastPoint.world); + indices.push(firstCenter, firstEdge.left, firstEdge.right); + triangleCellKeys.push(firstPoint.cell.cellKey); + indices.push(lastCenter, lastEdge.right, lastEdge.left); + triangleCellKeys.push(lastPoint.cell.cellKey); } + return createGeometry( + positions, normals, colors, depths, bankBlends, fogMixes, regimes, + shoreFoam, flowX, flowZ, indices, triangleCellKeys + ); } -function outerSkirtGeometry(cells: readonly GenesisWaterCellV1[], hexSize: number) { +function outerCurtainGeometry(cells: readonly GenesisWaterCellV1[], hexSize: number) { const keys = new Set(cells.map((cell) => cell.cellKey)); const positions: number[] = []; const indices: number[] = []; - const bottom = -1.25; for (const cell of cells) { - if (cell.regime !== 'ocean' || hexDistance(cell, { q: 0, r: 0 }) !== 65) continue; + if (cell.regime !== 'ocean' || hexDistance(cell, { q: 0, r: 0 }) !== OUTER_WATER_RADIUS) continue; const corners = pointyHexCorners({ q: cell.q, r: cell.r }, hexSize); for (let side = 0; side < 6; side += 1) { - const direction = [{ q: 1, r: 0 }, { q: 1, r: -1 }, { q: 0, r: -1 }, { q: -1, r: 0 }, { q: -1, r: 1 }, { q: 0, r: 1 }][side]!; - const neighborKey = `${cell.q + direction.q},${cell.r + direction.r}`; - if (keys.has(neighborKey)) continue; + const direction = [ + { q: 1, r: 0 }, { q: 1, r: -1 }, { q: 0, r: -1 }, + { q: -1, r: 0 }, { q: -1, r: 1 }, { q: 0, r: 1 } + ][side]!; + if (keys.has(`${cell.q + direction.q},${cell.r + direction.r}`)) continue; const a = corners[side]!; const b = corners[(side + 1) % 6]!; const base = positions.length / 3; - const surfaceY = waterSurfaceLevelToWorldY(cell.surfaceLevelMilli); - positions.push(a.x, surfaceY, a.z, b.x, surfaceY, b.z, b.x, bottom, b.z, a.x, bottom, a.z); + const top = OUTER_CURTAIN_TOP; + positions.push( + a.x, top, a.z, + b.x, top, b.z, + b.x, OUTER_CURTAIN_BOTTOM, b.z, + a.x, OUTER_CURTAIN_BOTTOM, a.z + ); indices.push(base, base + 1, base + 2, base, base + 2, base + 3); } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); - try { - geometry.setIndex(indices); - geometry.computeVertexNormals(); - return geometry; - } catch (error) { - geometry.dispose(); - throw error; - } + geometry.setIndex(indices); + geometry.computeBoundingSphere(); + return geometry; } -function createWaterMaterial(quality: RealmQualitySpec, reducedMotion: boolean) { +function createWaterMaterial( + quality: RealmQualitySpec, + reducedMotion: boolean, + river: boolean +) { const material = new THREE.MeshStandardMaterial({ vertexColors: true, - // Keep the material base neutral so the authoritative per-regime vertex - // palette is not multiplied back toward the pale Lowlands ground tint. color: '#ffffff', - roughness: 0.28, + roughness: river ? 0.34 : 0.27, metalness: 0.04, transparent: false, depthWrite: true, @@ -325,152 +518,359 @@ function createWaterMaterial(quality: RealmQualitySpec, reducedMotion: boolean) }); const activeWaveComponents = reducedMotion ? 0 - : REALM_WATER_RENDER_BUDGETS[quality.id].waveComponents; + : river ? Math.min(2, REALM_WATER_RENDER_BUDGETS[quality.id].waveComponents) + : REALM_WATER_RENDER_BUDGETS[quality.id].waveComponents; const uniforms = { uWaterTime: { value: 0 }, - uWaterHorizonColor: { value: new THREE.Color('#b9cad8') } + uWaterHorizonColor: { value: new THREE.Color('#b9cad8') }, + uWaterSunDirection: { value: new THREE.Vector3(0.286, 0.89, 0.355).normalize() }, + uWaterWindDirection: { value: new THREE.Vector2(0.82, 0.42).normalize() } }; const waveTerms = Array.from({ length: activeWaveComponents }, (_, index) => { const ordinal = index + 1; - const xFrequency = (2.25 + ordinal * 0.37).toFixed(2); - const zFrequency = (1.65 + ordinal * 0.29).toFixed(2); - const timeFrequency = (0.19 + ordinal * 0.035).toFixed(3); - return `sin(vViewPosition.x * ${xFrequency} + vViewPosition.z * ${zFrequency} + uWaterTime * ${timeFrequency})`; + const directionX = (0.54 + ((ordinal * 17) % 31) / 100).toFixed(3); + const directionZ = (0.84 - ((ordinal * 11) % 23) / 100).toFixed(3); + const frequency = (0.28 + ordinal * 0.075).toFixed(3); + const speed = (0.16 + ordinal * 0.031).toFixed(3); + const amplitude = (river ? 0.006 : 0.026 / Math.sqrt(ordinal)).toFixed(5); + return `sin(dot(p, vec2(${directionX}, ${directionZ})) * ${frequency} + uWaterTime * ${speed}) * ${amplitude}`; }); - const waveSource = waveTerms.length === 0 - ? 'float waterGlimmer = 0.0;' - : `float waterGlimmer = (${waveTerms.join(' + ')}) * ${(0.018 / waveTerms.length).toFixed(8)};`; + const waves = waveTerms.length === 0 ? '0.0' : waveTerms.join(' + '); + const fragmentGlimmer = waveTerms.length === 0 + ? '0.0' + : `(${waveTerms.map((term) => term.replace('dot(p,', 'dot(vViewPosition.xz,')).join(' + ')}) * 0.025`; + const shaderContract = `warpkeep-water-layered-r185-${river ? 'river' : 'ocean'}-v1`; material.onBeforeCompile = (shader) => { - if (activeWaveComponents > 0) shader.uniforms.uWaterTime = uniforms.uWaterTime; + const requiredChunks = [ + '#include ', + '#include ', + '#include ' + ]; + if (!requiredChunks.every((chunk) => shader.vertexShader.includes(chunk)) + || !shader.fragmentShader.includes('#include ')) { + throw new Error('REALM_WATER_SHADER_CONTRACT_CHANGED'); + } + shader.uniforms.uWaterTime = uniforms.uWaterTime; shader.uniforms.uWaterHorizonColor = uniforms.uWaterHorizonColor; - shader.vertexShader = `attribute float waterDepth;\nattribute float waterBankBlend;\nattribute float waterFogMix;\nvarying float vWarpkeepWaterDepth;\nvarying float vWarpkeepWaterBankBlend;\nvarying float vWarpkeepWaterFogMix;\n${shader.vertexShader}` - .replace('#include ', '#include \n vWarpkeepWaterDepth = waterDepth;\n vWarpkeepWaterBankBlend = waterBankBlend;\n vWarpkeepWaterFogMix = waterFogMix;'); - const timeUniform = activeWaveComponents > 0 ? 'uniform float uWaterTime;\n' : ''; - shader.fragmentShader = `${timeUniform}uniform vec3 uWaterHorizonColor;\nvarying float vWarpkeepWaterDepth;\nvarying float vWarpkeepWaterBankBlend;\nvarying float vWarpkeepWaterFogMix;\n${shader.fragmentShader}` + shader.uniforms.uWaterSunDirection = uniforms.uWaterSunDirection; + shader.uniforms.uWaterWindDirection = uniforms.uWaterWindDirection; + shader.vertexShader = ` +attribute float waterDepth; +attribute float waterBankBlend; +attribute float waterFogMix; +attribute float waterRegime; +attribute float waterShoreFoam; +attribute float waterFlowX; +attribute float waterFlowZ; +varying float vWarpkeepWaterDepth; +varying float vWarpkeepWaterBankBlend; +varying float vWarpkeepWaterFogMix; +varying float vWarpkeepWaterRegime; +varying float vWarpkeepWaterShoreFoam; +varying vec2 vWarpkeepWaterFlow; +uniform float uWaterTime; +uniform vec2 uWaterWindDirection; +float warpkeepWaterHeight(vec2 p, float regime, vec2 flow) { + float wave = ${waves}; + float riverFlow = sin(dot(p, flow) * 2.3 + uWaterTime * 0.72) * 0.008; + return (regime > 0.5 ? riverFlow : wave); +} +${shader.vertexShader}` + .replace('#include ', `#include + vWarpkeepWaterDepth = waterDepth; + vWarpkeepWaterBankBlend = waterBankBlend; + vWarpkeepWaterFogMix = waterFogMix; + vWarpkeepWaterRegime = waterRegime; + vWarpkeepWaterShoreFoam = waterShoreFoam; + vWarpkeepWaterFlow = vec2(waterFlowX, waterFlowZ);`) + .replace('#include ', `#include + vec2 warpkeepWaterPosition = position.xz; + float warpkeepWaterAmplitude = waterRegime > 0.5 ? 0.55 : 1.0; + transformed.y += warpkeepWaterHeight(warpkeepWaterPosition, waterRegime, vec2(waterFlowX, waterFlowZ)) * warpkeepWaterAmplitude;`) + .replace('#include ', `#include + float warpkeepWaterEpsilon = 0.045; + float warpkeepWaterCenter = warpkeepWaterHeight(position.xz, waterRegime, vec2(waterFlowX, waterFlowZ)); + float warpkeepWaterDx = (warpkeepWaterHeight(position.xz + vec2(warpkeepWaterEpsilon, 0.0), waterRegime, vec2(waterFlowX, waterFlowZ)) - warpkeepWaterCenter) / warpkeepWaterEpsilon; + float warpkeepWaterDz = (warpkeepWaterHeight(position.xz + vec2(0.0, warpkeepWaterEpsilon), waterRegime, vec2(waterFlowX, waterFlowZ)) - warpkeepWaterCenter) / warpkeepWaterEpsilon; + objectNormal = normalize(vec3(-warpkeepWaterDx, 1.0, -warpkeepWaterDz));`); + shader.fragmentShader = ` +uniform float uWaterTime; +uniform vec3 uWaterHorizonColor; +uniform vec3 uWaterSunDirection; +varying float vWarpkeepWaterDepth; +varying float vWarpkeepWaterBankBlend; +varying float vWarpkeepWaterFogMix; +varying float vWarpkeepWaterRegime; +varying float vWarpkeepWaterShoreFoam; +varying vec2 vWarpkeepWaterFlow; +${shader.fragmentShader}` .replace('#include ', ` - ${waveSource} - float waterFresnel = pow(1.0 - max(dot(normalize(vNormal), normalize(-vViewPosition)), 0.0), 3.0) * 0.08; - float waterDepthTint = mix(1.0, 0.72, clamp(vWarpkeepWaterDepth, 0.0, 1.0)); - float bankSoftness = 1.0 - clamp(vWarpkeepWaterBankBlend, 0.0, 1.0) * 0.18; - outgoingLight += vec3(waterGlimmer + waterFresnel) * waterDepthTint * bankSoftness; - outgoingLight = mix(outgoingLight, uWaterHorizonColor, clamp(vWarpkeepWaterFogMix, 0.0, 1.0) * 0.62); + float waterViewFacing = max(dot(normalize(vNormal), normalize(-vViewPosition)), 0.0); + float waterFresnel = pow(1.0 - waterViewFacing, 3.0) * (vWarpkeepWaterRegime > 0.5 ? 0.045 : 0.095); + vec3 waterDeepColor = vec3(0.055, 0.22, 0.34); + vec3 waterShallowColor = vec3(0.16, 0.48, 0.58); + float waterAbsorption = clamp(vWarpkeepWaterDepth, 0.0, 1.0); + vec3 waterBodyColor = mix(waterShallowColor, waterDeepColor, waterAbsorption * 0.78); + float waterFlowPhase = cos(dot(vViewPosition.xz, normalize(vWarpkeepWaterFlow + vec2(0.001))) * 2.0 + uWaterTime * 0.7) * 0.5 + 0.5; + float waterCrest = smoothstep(0.72, 0.94, waterFlowPhase) * (vWarpkeepWaterRegime > 0.5 ? 0.12 : 0.32); + float waterShore = clamp(vWarpkeepWaterShoreFoam, 0.0, 1.0) * (0.62 + waterFlowPhase * 0.38); + vec3 waterFoamColor = vec3(0.93, 0.91, 0.82); + float waterGlimmer = ${fragmentGlimmer}; + float waterGlitter = pow(max(dot(normalize(vNormal), normalize(uWaterSunDirection)), 0.0), 48.0) * (vWarpkeepWaterRegime > 0.5 ? 0.16 : 0.42); + outgoingLight = mix(outgoingLight, outgoingLight * waterBodyColor * 1.7, 0.44); + outgoingLight += waterBodyColor * waterFresnel + vec3(waterGlitter + waterGlimmer); + outgoingLight = mix(outgoingLight, waterFoamColor, clamp(waterShore * 0.34 + waterCrest, 0.0, 0.68)); + if (vWarpkeepWaterFogMix >= 0.999) outgoingLight = uWaterHorizonColor; + else outgoingLight = mix(outgoingLight, uWaterHorizonColor, clamp(vWarpkeepWaterFogMix, 0.0, 1.0)); #include `); - material.userData.waterShaderContract = 'three-r185-reviewed'; + material.userData.waterShaderContract = shaderContract; }; + material.customProgramCacheKey = () => shaderContract; material.userData.waterUniforms = uniforms; material.userData.waterWaveComponents = activeWaveComponents; + material.userData.waterShaderContract = shaderContract; return material; } +function createSelectionOverlay(cell: GenesisWaterCellV1, hexSize: number) { + const geometry = new THREE.BufferGeometry(); + const corners = pointyHexCorners({ q: cell.q, r: cell.r }, hexSize); + const y = waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + WATER_Y_LIFT + 0.045; + geometry.setAttribute('position', new THREE.Float32BufferAttribute( + corners.flatMap((corner) => [corner.x, y, corner.z]), + 3 + )); + const material = new THREE.LineBasicMaterial({ + color: '#fff1b8', + transparent: true, + opacity: 0.95, + depthTest: true, + depthWrite: false, + toneMapped: false + }); + const overlay = new THREE.LineLoop(geometry, material); + overlay.name = 'canonical-water-selection'; + overlay.visible = false; + overlay.renderOrder = 5; + return { overlay, geometry, material }; +} + +export function waterSurfaceLevelToWorldY(surfaceLevelMilli: number): number { + return genesisWaterWorldHeightFromMilli(surfaceLevelMilli); +} + export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLayer { const ocean = options.cells.filter((cell) => cell.regime === 'ocean'); const lakes = options.cells.filter((cell) => cell.regime === 'lake'); const rivers = options.cells.filter((cell) => cell.regime === 'river'); const budget = REALM_WATER_RENDER_BUDGETS[options.quality.id]; + const subdivision = subdivisionForQuality(options.quality); const group = new THREE.Group(); group.name = 'genesis-canonical-water'; - let oceanGeometry: THREE.BufferGeometry | undefined; - let lakeGeometry: THREE.BufferGeometry | undefined; - let riverGeometryData: THREE.BufferGeometry | undefined; - let skirtGeometry: THREE.BufferGeometry | undefined; - let waterMaterial: THREE.MeshStandardMaterial | undefined; + let oceanBuild: GeometryBuildResult | undefined; + let lakeBuild: GeometryBuildResult | undefined; + let riverBuild: GeometryBuildResult | undefined; + let curtainGeometry: THREE.BufferGeometry | undefined; + let oceanMaterial: THREE.MeshStandardMaterial | undefined; let lakeMaterial: THREE.MeshStandardMaterial | undefined; let riverMaterial: THREE.MeshStandardMaterial | undefined; - let skirtMaterial: THREE.MeshBasicMaterial | undefined; + let curtainMaterial: THREE.MeshBasicMaterial | undefined; + let selection: ReturnType | undefined; const disposeResources = () => { - oceanGeometry?.dispose(); - lakeGeometry?.dispose(); - riverGeometryData?.dispose(); - skirtGeometry?.dispose(); - waterMaterial?.dispose(); + oceanBuild?.geometry.dispose(); + lakeBuild?.geometry.dispose(); + riverBuild?.geometry.dispose(); + curtainGeometry?.dispose(); + oceanMaterial?.dispose(); lakeMaterial?.dispose(); riverMaterial?.dispose(); - skirtMaterial?.dispose(); + curtainMaterial?.dispose(); + selection?.geometry.dispose(); + selection?.material.dispose(); }; try { - oceanGeometry = surfaceGeometry(ocean, options.hexSize, options.heightAtWorld); - lakeGeometry = surfaceGeometry(lakes, options.hexSize, options.heightAtWorld); - // Each reviewed river coordinate owns one complete hex surface. The old - // narrow spline left most of an authoritative river cell looking like - // ordinary terrain and read as a decorative line; full hexes make the - // persisted one-cell-wide topology legible without inventing new paths. - riverGeometryData = riverSurfaceGeometry(rivers, options.hexSize, options.heightAtWorld); - skirtGeometry = outerSkirtGeometry(ocean, options.hexSize); - waterMaterial = createWaterMaterial(options.quality, options.reducedMotion); - lakeMaterial = createWaterMaterial(options.quality, options.reducedMotion); - riverMaterial = createWaterMaterial(options.quality, options.reducedMotion); - // Rivers occupy only one authoritative hex at a time and sit over the - // pale Lowlands palette. A restrained cool emissive lift keeps the - // connected channel readable in daylight without changing its geometry. + oceanBuild = connectedSurfaceGeometry(ocean, options.hexSize, subdivision); + lakeBuild = connectedSurfaceGeometry(lakes, options.hexSize, Math.max(1, subdivision - 1)); + riverBuild = riverRibbonGeometry(rivers, options.hexSize, options.heightAtWorld); + curtainGeometry = outerCurtainGeometry(ocean, options.hexSize); + oceanMaterial = createWaterMaterial(options.quality, options.reducedMotion, false); + lakeMaterial = createWaterMaterial(options.quality, options.reducedMotion, false); + riverMaterial = createWaterMaterial(options.quality, options.reducedMotion, true); riverMaterial.emissive.set('#0b607b'); - riverMaterial.emissiveIntensity = 0.2; - riverMaterial.roughness = 0.22; - skirtMaterial = new THREE.MeshBasicMaterial({ - color: '#26485e', - transparent: true, - opacity: 0.82, - depthWrite: false, - fog: true, - side: THREE.DoubleSide + riverMaterial.emissiveIntensity = 0.14; + curtainMaterial = new THREE.MeshBasicMaterial({ + color: '#b9cad8', + transparent: false, + depthWrite: true, + depthTest: true, + fog: false, + side: THREE.DoubleSide, + toneMapped: false }); } catch (error) { disposeResources(); throw error; } - if (!oceanGeometry || !lakeGeometry || !riverGeometryData || !skirtGeometry - || !waterMaterial || !lakeMaterial || !riverMaterial || !skirtMaterial) { + if (!oceanBuild || !lakeBuild || !riverBuild || !curtainGeometry + || !oceanMaterial || !lakeMaterial || !riverMaterial || !curtainMaterial) { disposeResources(); throw new Error('REALM_WATER_RESOURCE_CONSTRUCTION_FAILED'); } - const oceanMesh = new THREE.Mesh(oceanGeometry, waterMaterial); - const lakeMesh = new THREE.Mesh(lakeGeometry, lakeMaterial); - const riverMesh = new THREE.Mesh(riverGeometryData, riverMaterial); - const skirtMesh = new THREE.Mesh(skirtGeometry, skirtMaterial); + const oceanMesh = new THREE.Mesh(oceanBuild.geometry, oceanMaterial); + const lakeMesh = new THREE.Mesh(lakeBuild.geometry, lakeMaterial); + const riverMesh = new THREE.Mesh(riverBuild.geometry, riverMaterial); + const curtainMesh = new THREE.Mesh(curtainGeometry, curtainMaterial); oceanMesh.name = 'canonical-ocean-surface'; lakeMesh.name = 'canonical-lake-surfaces'; riverMesh.name = 'canonical-river-ribbons'; - skirtMesh.name = 'canonical-ocean-downward-skirt'; + curtainMesh.name = 'canonical-ocean-fog-curtain'; riverMesh.renderOrder = 2; - skirtMesh.renderOrder = 1; - group.add(oceanMesh, lakeMesh, riverMesh, skirtMesh); - const triangleCount = (oceanGeometry.index?.count ?? 0) / 3 - + (lakeGeometry.index?.count ?? 0) / 3 - + (riverGeometryData.index?.count ?? 0) / 3 - + (skirtGeometry.index?.count ?? 0) / 3; - const drawCalls = [oceanMesh, lakeMesh, riverMesh, skirtMesh] + curtainMesh.renderOrder = 1; + group.add(oceanMesh, lakeMesh, riverMesh, curtainMesh); + const cellByKey = new Map(options.cells.map((cell) => [cell.cellKey, cell])); + const triangleCellKeys = new Map([ + [oceanMesh, oceanBuild.triangleCellKeys], + [lakeMesh, lakeBuild.triangleCellKeys], + [riverMesh, riverBuild.triangleCellKeys] + ]); + const triangleCount = ( + oceanBuild.geometry.index?.count ?? 0 + ) / 3 + ( + lakeBuild.geometry.index?.count ?? 0 + ) / 3 + ( + riverBuild.geometry.index?.count ?? 0 + ) / 3 + ( + curtainGeometry.index?.count ?? 0 + ) / 3; + const drawCalls = [oceanMesh, lakeMesh, riverMesh, curtainMesh] .filter((mesh) => (mesh.geometry.index?.count ?? 0) > 0).length; if (triangleCount > budget.triangles || drawCalls > budget.draws) { disposeResources(); throw new Error('REALM_WATER_RENDER_BUDGET_EXCEEDED'); } - const uniforms = [waterMaterial, lakeMaterial, riverMaterial] + const uniforms = [oceanMaterial, lakeMaterial, riverMaterial] .filter((material) => (material.userData.waterWaveComponents as number) > 0) .map((material) => material.userData.waterUniforms as { uWaterTime: { value: number } }); + const phaseEnvironment = options.environment !== null && typeof options.environment === 'object' + ? options.environment as Readonly> + : undefined; + const environmentEpoch = typeof phaseEnvironment?.environmentEpoch === 'bigint' + ? phaseEnvironment.environmentEpoch : 1n; + const environmentUpdatedAt = phaseEnvironment?.updatedAt; + const waterBodies = new Map(); + for (const value of options.waterBodies ?? []) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) continue; + const candidate = value as Partial; + if (typeof candidate.bodyId === 'string' + && typeof candidate.seed === 'number' + && Number.isFinite(candidate.seed) + && typeof candidate.wavePreset === 'string') { + waterBodies.set(candidate.bodyId, candidate as GenesisWaterBodyV1); + } + } + const seedCell = ocean[0] ?? rivers[0] ?? lakes[0]; + const phaseBody = seedCell ? waterBodies.get(seedCell.bodyId) : undefined; + const phaseSeed = phaseBody?.seed ?? seedCell?.bankSeed ?? 0; + const phaseWavePreset = phaseBody?.wavePreset ?? seedCell?.bodyId ?? 'genesis-water'; const animated = uniforms.length > 0; - let lastTime = -1; - let disposed = false; - const telemetry = Object.freeze({ + let lastElapsed = -1; + let lastPhaseSeconds: number | undefined; + let selectedCellKey: string | null = null; + const telemetry: { + layoutVersion: number; + oceanCellCount: number; + lakeCellCount: number; + riverCellCount: number; + triangleCount: number; + drawCalls: number; + animated: boolean; + fullFogOceanCellCount: number; + oceanSubdivision: number; + riverRibbonCount: number; + selectedCellKey: string | null; + } = { layoutVersion: options.cells === GENESIS_WATER_REVISION_ENABLED_CELLS_V1 - ? GENESIS_WATER_REVISION_VERSION - : GENESIS_WATER_LAYOUT_VERSION, + ? GENESIS_WATER_REVISION_VERSION : GENESIS_WATER_LAYOUT_VERSION, oceanCellCount: ocean.length, lakeCellCount: lakes.length, riverCellCount: rivers.length, triangleCount, drawCalls, animated, - fullFogOceanCellCount: ocean.filter((cell) => cell.fogBand === 'full').length - }); + fullFogOceanCellCount: ocean.filter((cell) => cell.fogBand === 'full').length, + oceanSubdivision: subdivision, + riverRibbonCount: new Set(rivers.map((cell) => cell.bodyId)).size, + selectedCellKey: null + }; + selection = createSelectionOverlay(seedCell ?? { + realmId: '', cellKey: '', q: 0, r: 0, regime: 'ocean', bodyId: '', + depthCells: 1, elevationMilli: 1_000, surfaceLevelMilli: 1_000, ring: 0, s: 0, + flowAccumulation: 0, depthClass: 0, oceanDepth: 1, bankSeed: 0, + generationVersion: 1, fogBand: 'clear', layoutVersion: 1 + }, options.hexSize); + selection.overlay.visible = false; + group.add(selection.overlay); + + let disposed = false; return { group, updateEnvironment: (elapsedSeconds) => { - if (disposed || !animated || !Number.isFinite(elapsedSeconds) || elapsedSeconds === lastTime) return false; - lastTime = elapsedSeconds; - uniforms.forEach((uniform) => { uniform.uWaterTime.value = elapsedSeconds; }); + if (disposed || !animated || !Number.isFinite(elapsedSeconds) || elapsedSeconds === lastElapsed) return false; + lastElapsed = elapsedSeconds; + const phase = resolveRealmWaterPhase({ + environmentEpoch, + environmentUpdatedAt, + localMonotonicSeconds: elapsedSeconds, + previousPhaseSeconds: lastPhaseSeconds, + reducedMotion: options.reducedMotion, + bodySeed: phaseSeed, + wavePreset: phaseWavePreset + }); + lastPhaseSeconds = phase.phaseSeconds; + uniforms.forEach((uniform) => { uniform.uWaterTime.value = phase.phaseSeconds; }); return true; }, isAnimationActive: () => animated, - getTelemetry: () => telemetry, + raycast: (raycaster) => { + if (disposed) return null; + let nearest: RealmWaterPickHit | null = null; + for (const mesh of [oceanMesh, lakeMesh, riverMesh]) { + const intersections = raycaster.intersectObject(mesh, false); + for (const intersection of intersections) { + const faceIndex = intersection.faceIndex; + const key = typeof faceIndex !== 'number' ? undefined : triangleCellKeys.get(mesh)?.[faceIndex]; + const cell = key === undefined ? undefined : cellByKey.get(key); + if (!cell || cell.fogBand === 'full') continue; + if (!nearest || intersection.distance < nearest.distance) { + nearest = Object.freeze({ + cellKey: cell.cellKey, + coord: Object.freeze({ q: cell.q, r: cell.r }), + regime: cell.regime, + distance: intersection.distance + }); + } + } + } + return nearest; + }, + setSelectedCellKey: (cellKey) => { + if (disposed) return; + const cell = cellKey === null ? undefined : cellByKey.get(cellKey); + if (cell?.fogBand === 'full') return; + selectedCellKey = cell?.cellKey ?? null; + telemetry.selectedCellKey = selectedCellKey; + if (cell) { + if (selection) group.remove(selection.overlay); + selection?.geometry.dispose(); + selection?.material.dispose(); + selection = createSelectionOverlay(cell, options.hexSize); + selection.overlay.visible = true; + group.add(selection.overlay); + } else if (selection) { + selection.overlay.visible = false; + } + }, + getTelemetry: () => Object.freeze({ ...telemetry }), dispose: () => { if (disposed) return; disposed = true; diff --git a/src/components/realm/realmWaterNavigation.ts b/src/components/realm/realmWaterNavigation.ts index 86f5597..cd7404a 100644 --- a/src/components/realm/realmWaterNavigation.ts +++ b/src/components/realm/realmWaterNavigation.ts @@ -15,6 +15,53 @@ export type RealmWaterNavigationEnvelope = Readonly<{ blockedCenterCellKeys: ReadonlySet; }>; +export type RealmWaterBoundaryCoverageProof = Readonly<{ + maximumVisibleHexRadius: number; + hiddenBufferCells: number; + maximumWaveDisplacement: number; + curtainBottom: number; + curtainTop: number; + covered: boolean; +}>; + +/** + * Pure camera-boundary proof used by rendered QA. It intentionally accepts + * measured frustum extents instead of reading a camera or inventing topology. + */ +export function proveRealmWaterBoundaryCoverage(input: Readonly<{ + maximumVisibleHexRadius: number; + hiddenBufferCells: number; + maximumWaveDisplacement: number; + curtainBottom: number; + curtainTop: number; + projectedMinimumY: number; + projectedMaximumY: number; +}>): RealmWaterBoundaryCoverageProof { + const maximumVisibleHexRadius = Number.isFinite(input.maximumVisibleHexRadius) + ? Math.max(0, Math.trunc(input.maximumVisibleHexRadius)) : 0; + const hiddenBufferCells = Number.isFinite(input.hiddenBufferCells) + ? Math.max(0, Math.trunc(input.hiddenBufferCells)) : 0; + const maximumWaveDisplacement = Number.isFinite(input.maximumWaveDisplacement) + ? Math.max(0, input.maximumWaveDisplacement) : Number.POSITIVE_INFINITY; + const curtainBottom = Number.isFinite(input.curtainBottom) ? input.curtainBottom : 0; + const curtainTop = Number.isFinite(input.curtainTop) ? input.curtainTop : 0; + const projectedMinimumY = Number.isFinite(input.projectedMinimumY) + ? input.projectedMinimumY : Number.NEGATIVE_INFINITY; + const projectedMaximumY = Number.isFinite(input.projectedMaximumY) + ? input.projectedMaximumY : Number.POSITIVE_INFINITY; + return Object.freeze({ + maximumVisibleHexRadius, + hiddenBufferCells, + maximumWaveDisplacement, + curtainBottom, + curtainTop, + covered: hiddenBufferCells >= 2 + && maximumWaveDisplacement <= 0.35 + && curtainBottom <= projectedMinimumY + && curtainTop >= projectedMaximumY + }); +} + /** The validated projection returns this exact frozen catalog after activation. */ export function realmNoLakeRevisionActive( cells: readonly GenesisWaterCellV1[] | undefined diff --git a/src/components/realm/realmWaterPhase.ts b/src/components/realm/realmWaterPhase.ts new file mode 100644 index 0000000..1ab9f73 --- /dev/null +++ b/src/components/realm/realmWaterPhase.ts @@ -0,0 +1,132 @@ +import { mixUint32 } from '../../../spacetimedb/src/world'; + +/** Inputs for the renderer-only shared Water phase clock. */ +export type RealmWaterPhaseInput = Readonly<{ + environmentEpoch: bigint; + /** The persisted server timestamp at the environment boundary. */ + environmentUpdatedAt?: unknown; + /** A synchronized server-time estimate, when the host has one. */ + synchronizedServerTimeMicros?: bigint | number; + /** Previous emitted phase, used to ease a large synchronization correction. */ + previousPhaseSeconds?: number; + /** Monotonic seconds elapsed since the current scene became visible. */ + localMonotonicSeconds: number; + reducedMotion?: boolean; + bodySeed?: number; + wavePreset?: string; +}>; + +export type RealmWaterPhase = Readonly<{ + phaseSeconds: number; + phaseCycles: number; + source: 'server-boundary' | 'server-estimate' | 'deterministic-freeze'; + driftSeconds: number; +}>; + +const MICROS_PER_SECOND = 1_000_000n; +const PHASE_PERIOD_SECONDS = 97; +const MAX_DRIFT_CORRECTION_SECONDS = 0.18; + +function finiteSeconds(value: number) { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function timestampMicros(value: unknown): bigint | undefined { + if (typeof value === 'bigint' && value >= 0n) return value; + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return BigInt(Math.trunc(value * 1_000)); + } + if (value instanceof Date && Number.isFinite(value.getTime()) && value.getTime() >= 0) { + return BigInt(value.getTime()) * 1_000n; + } + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (Number.isFinite(parsed) && parsed >= 0) return BigInt(parsed) * 1_000n; + } + if (value !== null && typeof value === 'object') { + const candidate = value as Readonly<{ microsSinceUnixEpoch?: unknown; toMillis?: () => unknown }>; + if (typeof candidate.microsSinceUnixEpoch === 'bigint') return timestampMicros(candidate.microsSinceUnixEpoch); + if (typeof candidate.toMillis === 'function') { + try { + return timestampMicros(candidate.toMillis()); + } catch { + return undefined; + } + } + } + return undefined; +} + +function phaseSeed(epoch: bigint, bodySeed: number, wavePreset: string) { + const epochLow = Number(epoch & 0xffffffffn) >>> 0; + let presetHash = 2166136261; + for (let index = 0; index < wavePreset.length; index += 1) { + presetHash ^= wavePreset.charCodeAt(index); + presetHash = Math.imul(presetHash, 16777619); + } + return mixUint32(epochLow ^ (bodySeed >>> 0) ^ presetHash) / 0xffffffff; +} + +/** + * Resolve one deterministic Water phase. The server boundary is the anchor; + * monotonic time only advances between synchronizations. A caller can provide + * a synchronized server estimate to converge reconnecting clients without + * making Date.now() an authority input. + */ +export function resolveRealmWaterPhase(input: RealmWaterPhaseInput): RealmWaterPhase { + const seed = phaseSeed(input.environmentEpoch, input.bodySeed ?? 0, input.wavePreset ?? 'ocean'); + const seedSeconds = seed * PHASE_PERIOD_SECONDS; + if (input.reducedMotion) { + return Object.freeze({ + phaseSeconds: seedSeconds, + phaseCycles: seedSeconds / PHASE_PERIOD_SECONDS, + source: 'deterministic-freeze', + driftSeconds: 0 + }); + } + + const localSeconds = finiteSeconds(input.localMonotonicSeconds); + const boundaryMicros = timestampMicros(input.environmentUpdatedAt); + const synchronizedMicros = typeof input.synchronizedServerTimeMicros === 'bigint' + ? input.synchronizedServerTimeMicros + : typeof input.synchronizedServerTimeMicros === 'number' + && Number.isFinite(input.synchronizedServerTimeMicros) + ? BigInt(Math.max(0, Math.trunc(input.synchronizedServerTimeMicros))) + : undefined; + + if (boundaryMicros !== undefined && synchronizedMicros !== undefined) { + const deltaMicros = synchronizedMicros >= boundaryMicros + ? synchronizedMicros - boundaryMicros + : 0n; + const serverSeconds = Number(deltaMicros / 1_000n) / 1_000; + const desired = seedSeconds + serverSeconds; + const local = seedSeconds + localSeconds; + const previous = Number.isFinite(input.previousPhaseSeconds) + ? input.previousPhaseSeconds! + : undefined; + const drift = desired - (previous ?? local); + // A first synchronized sample snaps to the shared target so reconnecting + // clients agree. Once a phase has been emitted, large corrections ease by + // a small bounded amount each sample instead of popping the surface. + const corrected = previous === undefined + ? desired + : previous + Math.max(-MAX_DRIFT_CORRECTION_SECONDS, Math.min(MAX_DRIFT_CORRECTION_SECONDS, drift)); + return Object.freeze({ + phaseSeconds: corrected, + phaseCycles: corrected / PHASE_PERIOD_SECONDS, + source: 'server-estimate', + driftSeconds: drift + }); + } + + return Object.freeze({ + phaseSeconds: seedSeconds + localSeconds, + phaseCycles: (seedSeconds + localSeconds) / PHASE_PERIOD_SECONDS, + source: boundaryMicros === undefined ? 'deterministic-freeze' : 'server-boundary', + driftSeconds: 0 + }); +} + +export const REALM_WATER_PHASE_PERIOD_SECONDS = PHASE_PERIOD_SECONDS; +export const REALM_WATER_MAX_DRIFT_CORRECTION_SECONDS = MAX_DRIFT_CORRECTION_SECONDS; +export const REALM_WATER_MICROS_PER_SECOND = MICROS_PER_SECOND; diff --git a/src/spacetime/warpkeepBackendTypes.ts b/src/spacetime/warpkeepBackendTypes.ts index 71f3bb1..957cf26 100644 --- a/src/spacetime/warpkeepBackendTypes.ts +++ b/src/spacetime/warpkeepBackendTypes.ts @@ -288,6 +288,8 @@ export type WarpkeepRealmEnvironment = Readonly<{ sunDirectionXMicro: number; sunDirectionYMicro: number; sunDirectionZMicro: number; + /** Persisted server timestamp used as the shared visual phase boundary. */ + updatedAt?: unknown; }>; /** Public additive policy selecting a reviewed subset of immutable Water v1. */ diff --git a/src/spacetime/warpkeepConnection.ts b/src/spacetime/warpkeepConnection.ts index cfde15e..80d15c1 100644 --- a/src/spacetime/warpkeepConnection.ts +++ b/src/spacetime/warpkeepConnection.ts @@ -1252,7 +1252,8 @@ function publicRealmEnvironmentRecord(value: unknown): unknown { seaLevelMilli: row.seaLevelMilli, sunDirectionXMicro: row.sunDirectionXMicro, sunDirectionYMicro: row.sunDirectionYMicro, - sunDirectionZMicro: row.sunDirectionZMicro + sunDirectionZMicro: row.sunDirectionZMicro, + updatedAt: row.updatedAt }) as Partial; } diff --git a/tests/realmWaterLayer.test.ts b/tests/realmWaterLayer.test.ts index fdaec85..1e81c3f 100644 --- a/tests/realmWaterLayer.test.ts +++ b/tests/realmWaterLayer.test.ts @@ -15,13 +15,7 @@ import { REALM_WATER_RENDER_BUDGETS, waterSurfaceLevelToWorldY } from '../src/components/realm/realmWaterLayer'; -import { pointyHexCorners } from '../src/components/realm/createTerrainGeometry'; import { REALM_QUALITY_SPECS } from '../src/components/realm/realmQuality'; -import { - axialToWorld, - hexDistance, - type HexWorldPosition -} from '../src/game/map/hexCoordinates'; import { createAuthoritativeRealmTerrainSurface } from '../src/game/map/realmTerrainSurface'; import { terrainHeightAtWorld } from '../src/game/map/terrainHeight'; import { createHegemonyCastlePlacements } from '../src/game/map/terrainPlacements'; @@ -38,21 +32,13 @@ const canonicalPlacements = createHegemonyCastlePlacements(canonicalSnapshot.cas id: `castle:${castle.castleId}`, coord: { q: castle.q, r: castle.r } }))); -const canonicalHeightAtWorld = (world: HexWorldPosition) => terrainHeightAtWorld( +const canonicalHeightAtWorld = (world: { x: number; z: number }) => terrainHeightAtWorld( canonicalSurface.renderMap, world, 1, canonicalPlacements ); -const activeRiverCells = GENESIS_WATER_REVISION_ENABLED_CELLS_V1.filter( - (cell) => cell.regime === 'river' -); - -function worldPointKey(world: HexWorldPosition) { - return `${Math.round(world.x * 1_000_000)},${Math.round(world.z * 1_000_000)}`; -} - function createLayer(quality: 'high' | 'balanced' | 'reduced', reducedMotion = false) { return createRealmWaterLayer({ cells: GENESIS_WATER_CELLS_V1, @@ -66,7 +52,7 @@ function createLayer(quality: 'high' | 'balanced' | 'reduced', reducedMotion = f function compileMaterial(material: THREE.MeshStandardMaterial) { const shader = { uniforms: {}, - vertexShader: '#include ', + vertexShader: '#include \n#include \n#include ', fragmentShader: '#include \n#include ' }; material.onBeforeCompile( @@ -89,67 +75,38 @@ function firstTriangleNormalY(geometry: THREE.BufferGeometry) { return abZ * acX - abX * acZ; } -describe('Realm canonical water layer', () => { +describe('Realm layered canonical Water layer', () => { it('converts the persisted fixed-point datum into terrain world height', () => { expect(waterSurfaceLevelToWorldY(1_000)).toBe(0); expect(waterSurfaceLevelToWorldY(GENESIS_WATER_SEA_LEVEL_MILLI)).toBeCloseTo(-0.025, 6); }); - it('constructs the complete reduced layer inside its four-draw budget', () => { + it('builds connected quality-bounded surfaces and full-fog curtain', () => { const layer = createLayer('reduced'); const telemetry = layer.getTelemetry(); - expect(telemetry.drawCalls).toBe(4); - expect(telemetry.drawCalls).toBeLessThanOrEqual(REALM_WATER_RENDER_BUDGETS.reduced.draws); - expect(telemetry.triangleCount).toBeLessThanOrEqual( - REALM_WATER_RENDER_BUDGETS.reduced.triangles - ); + expect(telemetry.triangleCount).toBeLessThanOrEqual(REALM_WATER_RENDER_BUDGETS.reduced.triangles); expect(telemetry.fullFogOceanCellCount).toBeGreaterThan(0); + expect(telemetry.oceanSubdivision).toBe(1); expect(layer.isAnimationActive()).toBe(false); - expect(layer.updateEnvironment(1)).toBe(false); - const ocean = layer.group.getObjectByName('canonical-ocean-surface') as THREE.Mesh< - THREE.BufferGeometry, - THREE.MeshStandardMaterial - >; - const rivers = layer.group.getObjectByName('canonical-river-ribbons') as THREE.Mesh< - THREE.BufferGeometry, - THREE.MeshStandardMaterial - >; + const ocean = layer.group.getObjectByName('canonical-ocean-surface') as THREE.Mesh; + const rivers = layer.group.getObjectByName('canonical-river-ribbons') as THREE.Mesh; + const curtain = layer.group.getObjectByName('canonical-ocean-fog-curtain') as THREE.Mesh; const fogMix = Array.from(ocean.geometry.getAttribute('waterFogMix').array as ArrayLike); expect(fogMix).toContain(0); - expect(fogMix.some((value) => Math.abs(value - 0.45) < 0.0001)).toBe(true); + expect(fogMix.some((value) => value > 0 && value < 1)).toBe(true); expect(fogMix).toContain(1); - - // Every authoritative river coordinate is one complete hex-wide channel. expect((rivers.geometry.index?.count ?? 0) / 3).toBe( - GENESIS_RIVERS_V1.reduce((sum, river) => sum + river.orderedCellKeys.length * 6, 0) + GENESIS_RIVERS_V1.reduce((sum, river) => sum + (river.orderedCellKeys.length - 1) * 2 + 2, 0) ); - const riverPositions = rivers.geometry.getAttribute('position'); - let vertexOffset = 0; - for (const river of GENESIS_RIVERS_V1) { - const mouthVertex = vertexOffset + (river.orderedCellKeys.length - 1) * 7; - const persistedPresentationY = waterSurfaceLevelToWorldY(GENESIS_WATER_SEA_LEVEL_MILLI) - + 0.035; - expect(riverPositions.getY(mouthVertex)) - .toBeGreaterThanOrEqual(persistedPresentationY - 0.000_001); - expect(riverPositions.getY(mouthVertex)) - .toBeLessThan(persistedPresentationY + 0.16); - vertexOffset += river.orderedCellKeys.length * 7; - } - - const shader = compileMaterial(ocean.material); - expect(ocean.material.userData.waterWaveComponents).toBe(0); - expect(shader.fragmentShader).not.toContain('uniform float uWaterTime'); - expect(shader.fragmentShader).toContain('float waterGlimmer = 0.0'); - expect(shader.fragmentShader).toContain('vWarpkeepWaterFogMix'); - expect(shader.fragmentShader.indexOf('waterGlimmer')) - .toBeLessThan(shader.fragmentShader.indexOf('#include ')); - + expect((curtain.geometry.index?.count ?? 0) / 3).toBeGreaterThan(0); + expect(firstTriangleNormalY(ocean.geometry)).toBeGreaterThan(0); + expect(firstTriangleNormalY(rivers.geometry)).toBeGreaterThan(0); layer.dispose(); }); - it('renders the active revision as exact full-cell rivers with no lake draw', () => { + it('renders the active revision as ocean plus twelve continuous river ribbons', () => { const layer = createRealmWaterLayer({ cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, quality: REALM_QUALITY_SPECS.reduced, @@ -161,201 +118,87 @@ describe('Realm canonical water layer', () => { expect(telemetry.layoutVersion).toBe(GENESIS_WATER_REVISION_VERSION); expect(telemetry.lakeCellCount).toBe(0); expect(telemetry.riverCellCount).toBe(400); - expect(telemetry.drawCalls).toBe(3); - expect(layer.group.getObjectByName('canonical-lake-surfaces')).toBeDefined(); - expect((layer.group.getObjectByName('canonical-lake-surfaces') as THREE.Mesh) - .geometry.index?.count ?? 0).toBe(0); - const ocean = layer.group.getObjectByName('canonical-ocean-surface') as THREE.Mesh; - const rivers = layer.group.getObjectByName('canonical-river-ribbons') as THREE.Mesh; - expect(firstTriangleNormalY(ocean.geometry)).toBeGreaterThan(0); - expect(firstTriangleNormalY(rivers.geometry)).toBeGreaterThan(0); - layer.dispose(); - }); - - it('keeps every canonical river surface clear and every shared edge continuous', () => { - const layer = createRealmWaterLayer({ - cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, - quality: REALM_QUALITY_SPECS.reduced, - reducedMotion: true, - hexSize: 1, - heightAtWorld: canonicalHeightAtWorld - }); - const rivers = layer.group.getObjectByName('canonical-river-ribbons') as THREE.Mesh< - THREE.BufferGeometry, - THREE.MeshStandardMaterial - >; + expect(telemetry.riverRibbonCount).toBe(12); + const rivers = layer.group.getObjectByName('canonical-river-ribbons') as THREE.Mesh; const positions = rivers.geometry.getAttribute('position'); - const index = rivers.geometry.index; - expect(positions.count).toBe(activeRiverCells.length * 7); - expect(index?.count).toBe(activeRiverCells.length * 6 * 3); - expect(layer.getTelemetry().riverCellCount).toBe(activeRiverCells.length); - expect((index?.count ?? 0) / 3).toBe(activeRiverCells.length * 6); - - let minimumVertexClearance = Number.POSITIVE_INFINITY; - let minimumProbeClearance = Number.POSITIVE_INFINITY; - const cornerHeights = activeRiverCells.map((cell, cellIndex) => { - const base = cellIndex * 7; - const expectedWorlds = [ - axialToWorld({ q: cell.q, r: cell.r }, 1), - ...pointyHexCorners({ q: cell.q, r: cell.r }, 1) - ]; - const heights = new Map(); - expectedWorlds.forEach((expectedWorld, vertexIndex) => { - const renderedVertex = base + vertexIndex; - const renderedWorld = { - x: positions.getX(renderedVertex), - z: positions.getZ(renderedVertex) - }; - expect(renderedWorld.x).toBeCloseTo(expectedWorld.x, 5); - expect(renderedWorld.z).toBeCloseTo(expectedWorld.z, 5); - const renderedY = positions.getY(renderedVertex); - minimumVertexClearance = Math.min( - minimumVertexClearance, - renderedY - canonicalHeightAtWorld(renderedWorld) - ); - if (vertexIndex > 0) heights.set(worldPointKey(renderedWorld), renderedY); - }); - - // Probe edges and triangle interiors more densely than construction. - for (let triangle = 0; triangle < 6; triangle += 1) { - const first = base + triangle + 1; - const second = base + ((triangle + 1) % 6) + 1; - for (let firstStep = 0; firstStep <= 12; firstStep += 1) { - for (let secondStep = 0; secondStep <= 12 - firstStep; secondStep += 1) { - const firstWeight = firstStep / 12; - const secondWeight = secondStep / 12; - const centerWeight = 1 - firstWeight - secondWeight; - const world = { - x: positions.getX(base) * centerWeight - + positions.getX(first) * firstWeight - + positions.getX(second) * secondWeight, - z: positions.getZ(base) * centerWeight - + positions.getZ(first) * firstWeight - + positions.getZ(second) * secondWeight - }; - const surfaceY = positions.getY(base) * centerWeight - + positions.getY(first) * firstWeight - + positions.getY(second) * secondWeight; - minimumProbeClearance = Math.min( - minimumProbeClearance, - surfaceY - canonicalHeightAtWorld(world) - ); - } - } - } - - const cellIndices = Array.from( - { length: 18 }, - (_, offset) => index?.getX(cellIndex * 18 + offset) + const flowX = rivers.geometry.getAttribute('waterFlowX'); + const flowZ = rivers.geometry.getAttribute('waterFlowZ'); + expect(positions.count).toBeGreaterThan(800); + expect(flowX.count).toBe(positions.count); + expect(flowZ.count).toBe(positions.count); + for (let vertex = 0; vertex < positions.count; vertex += 1) { + expect(Math.hypot(flowX.getX(vertex), flowZ.getX(vertex))).toBeCloseTo(1, 4); + expect(positions.getY(vertex)).toBeGreaterThanOrEqual( + waterSurfaceLevelToWorldY(GENESIS_WATER_SEA_LEVEL_MILLI) + 0.035 - 0.000_001 ); - expect(cellIndices).toEqual([ - base, base + 2, base + 1, - base, base + 3, base + 2, - base, base + 4, base + 3, - base, base + 5, base + 4, - base, base + 6, base + 5, - base, base + 1, base + 6 - ]); - return heights; - }); - - let sharedEdgeCount = 0; - let slopedSharedEdgeCount = 0; - let maximumSharedEdgeDelta = 0; - activeRiverCells.forEach((cell, cellIndex) => { - for (let neighborIndex = cellIndex + 1; neighborIndex < activeRiverCells.length; neighborIndex += 1) { - const neighbor = activeRiverCells[neighborIndex]!; - if (hexDistance(cell, neighbor) !== 1) continue; - const sharedCornerKeys = [...cornerHeights[cellIndex]!.keys()].filter( - (key) => cornerHeights[neighborIndex]!.has(key) - ); - expect(sharedCornerKeys).toHaveLength(2); - sharedEdgeCount += 1; - if (cell.surfaceLevelMilli !== neighbor.surfaceLevelMilli) slopedSharedEdgeCount += 1; - sharedCornerKeys.forEach((key) => { - maximumSharedEdgeDelta = Math.max( - maximumSharedEdgeDelta, - Math.abs(cornerHeights[cellIndex]!.get(key)! - - cornerHeights[neighborIndex]!.get(key)!) - ); - }); - } - }); - - // A merely non-negative surface can still disappear into the adaptive - // ground depth buffer at strategic zoom. Preserve a visible safety margin. - expect(minimumVertexClearance).toBeGreaterThanOrEqual(0.005); - expect(minimumProbeClearance).toBeGreaterThanOrEqual(0.005); - expect(sharedEdgeCount).toBeGreaterThan(0); - expect(slopedSharedEdgeCount).toBeGreaterThan(0); - expect(maximumSharedEdgeDelta).toBe(0); + } layer.dispose(); }); - it('compiles the declared wave count into a shader path that affects outgoing light', () => { + it('compiles bounded displacement, analytic normal, foam, Fresnel, and full fog paths', () => { const layer = createLayer('high'); const ocean = layer.group.getObjectByName('canonical-ocean-surface') as THREE.Mesh< THREE.BufferGeometry, THREE.MeshStandardMaterial >; const shader = compileMaterial(ocean.material); - - expect(ocean.material.userData.waterWaveComponents) - .toBe(REALM_WATER_RENDER_BUDGETS.high.waveComponents); - expect(shader.fragmentShader.match(/sin\(/g)).toHaveLength( - REALM_WATER_RENDER_BUDGETS.high.waveComponents - ); - expect(shader.fragmentShader).toContain('uniform float uWaterTime'); - expect(shader.fragmentShader).not.toContain('uWaterWaveComponents'); - expect(shader.fragmentShader).toContain('outgoingLight +='); + expect(ocean.material.userData.waterWaveComponents).toBe(REALM_WATER_RENDER_BUDGETS.high.waveComponents); + expect(shader.vertexShader).toContain('warpkeepWaterHeight'); + expect(shader.vertexShader).toContain('objectNormal = normalize'); + expect(shader.fragmentShader.match(/sin\(/g)).toHaveLength(REALM_WATER_RENDER_BUDGETS.high.waveComponents); + expect(shader.fragmentShader).toContain('waterFoamColor'); + expect(shader.fragmentShader).toContain('waterFresnel'); + expect(shader.fragmentShader).toContain('vWarpkeepWaterFogMix >= 0.999'); expect(shader.uniforms).toHaveProperty('uWaterTime'); expect(layer.updateEnvironment(1)).toBe(true); expect(layer.updateEnvironment(1)).toBe(false); expect(layer.updateEnvironment(2)).toBe(true); + layer.dispose(); + }); + it('maps visible water triangles to canonical cells and rejects full fog', () => { + const layer = createLayer('reduced', true); + const ocean = layer.group.getObjectByName('canonical-ocean-surface') as THREE.Mesh; + const point = new THREE.Vector3(); + const box = new THREE.Box3().setFromObject(ocean); + box.getCenter(point); + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); + camera.position.set(point.x, point.y + 12, point.z + 0.01); + camera.lookAt(point); + camera.updateMatrixWorld(); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(0, 0), camera); + const hit = layer.raycast(raycaster); + if (hit) { + expect(hit.cellKey).toMatch(/,/); + expect(hit.regime).toBe('ocean'); + layer.setSelectedCellKey(hit.cellKey); + expect(layer.getTelemetry().selectedCellKey).toBe(hit.cellKey); + } + layer.setSelectedCellKey(null); + expect(layer.getTelemetry().selectedCellKey).toBeNull(); layer.dispose(); }); - it('disposes every owned GPU resource once and becomes inert', () => { + it('disposes owned GPU resources once and rejects over-budget input', () => { const layer = createLayer('balanced', true); - const meshes = layer.group.children as THREE.Mesh[]; + const meshes = layer.group.children.filter( + (child): child is THREE.Mesh => child instanceof THREE.Mesh + ); const geometryDisposals = meshes.map((mesh) => vi.spyOn(mesh.geometry, 'dispose')); const materialDisposals = meshes.map((mesh) => vi.spyOn(mesh.material, 'dispose')); - layer.dispose(); layer.dispose(); - geometryDisposals.forEach((spy) => expect(spy).toHaveBeenCalledOnce()); materialDisposals.forEach((spy) => expect(spy).toHaveBeenCalledOnce()); expect(layer.updateEnvironment(3)).toBe(false); - }); - it('releases partially constructed resources when the geometry budget rejects input', () => { - const geometryDispose = vi.spyOn(THREE.BufferGeometry.prototype, 'dispose'); - const materialDispose = vi.spyOn(THREE.Material.prototype, 'dispose'); - try { - expect(() => createRealmWaterLayer({ - cells: [...GENESIS_WATER_CELLS_V1, ...GENESIS_WATER_CELLS_V1], - quality: REALM_QUALITY_SPECS.reduced, - reducedMotion: true, - hexSize: 1, - heightAtWorld: canonicalHeightAtWorld - })).toThrow('REALM_WATER_RENDER_BUDGET_EXCEEDED'); - expect(geometryDispose).toHaveBeenCalledTimes(4); - expect(materialDispose).toHaveBeenCalledTimes(4); - } finally { - geometryDispose.mockRestore(); - materialDispose.mockRestore(); - } - }); - - it('fails closed when a non-ocean surface would render below the supplied terrain', () => { expect(() => createRealmWaterLayer({ - cells: GENESIS_WATER_CELLS_V1, + cells: [...GENESIS_WATER_CELLS_V1, ...GENESIS_WATER_CELLS_V1], quality: REALM_QUALITY_SPECS.reduced, reducedMotion: true, hexSize: 1, - heightAtWorld: () => 10 - })).toThrow('REALM_WATER_SURFACE_BELOW_TERRAIN'); + heightAtWorld: canonicalHeightAtWorld + })).toThrow('REALM_WATER_RENDER_BUDGET_EXCEEDED'); }); }); diff --git a/tests/realmWaterNavigation.test.ts b/tests/realmWaterNavigation.test.ts index a6a7b27..fc0756c 100644 --- a/tests/realmWaterNavigation.test.ts +++ b/tests/realmWaterNavigation.test.ts @@ -8,6 +8,7 @@ import { GENESIS_WATER_REVISION_ENABLED_CELLS_V1 } from '../spacetimedb/src/wate import { hexDistance } from '../src/game/map/hexCoordinates'; import { createRealmTerrainSurface } from '../src/game/map/realmTerrainSurface'; import { + proveRealmWaterBoundaryCoverage, realmLandPresentationMap, realmNoLakeRevisionActive, realmWaterNavigationEnvelope @@ -23,6 +24,27 @@ const LAND_BOUNDS = Object.freeze({ }); describe('persistent Water camera envelope', () => { + it('proves the hidden buffer and curtain cover measured frustum extents', () => { + expect(proveRealmWaterBoundaryCoverage({ + maximumVisibleHexRadius: 28, + hiddenBufferCells: 2, + maximumWaveDisplacement: 0.12, + curtainBottom: -20, + curtainTop: 38, + projectedMinimumY: -5, + projectedMaximumY: 25 + }).covered).toBe(true); + expect(proveRealmWaterBoundaryCoverage({ + maximumVisibleHexRadius: 28, + hiddenBufferCells: 1, + maximumWaveDisplacement: 0.12, + curtainBottom: -20, + curtainTop: 38, + projectedMinimumY: -5, + projectedMaximumY: 25 + }).covered).toBe(false); + }); + it('extends navigation across the ocean apron but stops the center at full fog', () => { const envelope = realmWaterNavigationEnvelope(GENESIS_WATER_CELLS_V1, LAND_BOUNDS); expect(envelope).toBeDefined(); diff --git a/tests/realmWaterPhase.test.ts b/tests/realmWaterPhase.test.ts new file mode 100644 index 0000000..5d8ef67 --- /dev/null +++ b/tests/realmWaterPhase.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { + REALM_WATER_MAX_DRIFT_CORRECTION_SECONDS, + resolveRealmWaterPhase +} from '../src/components/realm/realmWaterPhase'; + +describe('shared Water phase', () => { + it('freezes reduced motion at a deterministic epoch/seed phase', () => { + const first = resolveRealmWaterPhase({ + environmentEpoch: 1n, + localMonotonicSeconds: 0, + reducedMotion: true, + bodySeed: 44, + wavePreset: 'ocean' + }); + const second = resolveRealmWaterPhase({ + environmentEpoch: 1n, + localMonotonicSeconds: 999, + reducedMotion: true, + bodySeed: 44, + wavePreset: 'ocean' + }); + expect(second).toEqual(first); + expect(first.source).toBe('deterministic-freeze'); + }); + + it('converges two clients from the same persisted boundary', () => { + const input = { + environmentEpoch: 2n, + environmentUpdatedAt: new Date('2026-07-21T00:00:00.000Z'), + synchronizedServerTimeMicros: BigInt(Date.parse('2026-07-21T00:00:14.000Z')) * 1_000n, + bodySeed: 91, + wavePreset: 'broad-swell' + } as const; + const first = resolveRealmWaterPhase({ ...input, localMonotonicSeconds: 0 }); + const second = resolveRealmWaterPhase({ ...input, localMonotonicSeconds: 0.4 }); + expect(first.phaseSeconds).toBe(second.phaseSeconds); + expect(Math.abs(first.driftSeconds)).toBeGreaterThan(REALM_WATER_MAX_DRIFT_CORRECTION_SECONDS); + expect(first.source).toBe('server-estimate'); + }); + + it('does not fast-forward when a tab resumes without synchronization', () => { + const normal = resolveRealmWaterPhase({ + environmentEpoch: 1n, + environmentUpdatedAt: new Date('2026-07-21T00:00:00.000Z'), + localMonotonicSeconds: 2 + }); + const resumed = resolveRealmWaterPhase({ + environmentEpoch: 1n, + environmentUpdatedAt: new Date('2026-07-21T00:00:00.000Z'), + localMonotonicSeconds: 2.2 + }); + expect(resumed.phaseSeconds - normal.phaseSeconds).toBeCloseTo(0.2, 6); + }); + + it('eases a later large synchronization correction within the frame bound', () => { + const target = resolveRealmWaterPhase({ + environmentEpoch: 1n, + environmentUpdatedAt: new Date('2026-07-21T00:00:00.000Z'), + synchronizedServerTimeMicros: BigInt(Date.parse('2026-07-21T00:10:00.000Z')) * 1_000n, + localMonotonicSeconds: 0 + }); + const corrected = resolveRealmWaterPhase({ + environmentEpoch: 1n, + environmentUpdatedAt: new Date('2026-07-21T00:00:00.000Z'), + synchronizedServerTimeMicros: BigInt(Date.parse('2026-07-21T00:10:00.000Z')) * 1_000n, + localMonotonicSeconds: 0.1, + previousPhaseSeconds: 0 + }); + expect(target.source).toBe('server-estimate'); + expect(corrected.phaseSeconds).toBeCloseTo(REALM_WATER_MAX_DRIFT_CORRECTION_SECONDS, 6); + }); +});