From b726b539d33b62bb853d5b9f722182eb49c5bfc5 Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 20 Jul 2026 12:23:53 +0200 Subject: [PATCH 01/19] Fix: recover the Realm renderer without full-world fallback --- docs/design/realm-renderer-recovery.md | 28 +++ src/components/realm/RealmMapScreen.tsx | 192 ++++++++++++++++-- src/components/realm/createRealmScene.ts | 133 +++++++++--- src/components/realm/realmCameraController.ts | 15 ++ .../realm/realmMapPresentationHelpers.ts | 49 ++++- src/components/realm/realmRendererRecovery.ts | 123 +++++++++++ tests/realmRendererRecovery.test.ts | 62 ++++++ 7 files changed, 547 insertions(+), 55 deletions(-) create mode 100644 docs/design/realm-renderer-recovery.md create mode 100644 src/components/realm/realmRendererRecovery.ts create mode 100644 tests/realmRendererRecovery.test.ts diff --git a/docs/design/realm-renderer-recovery.md b/docs/design/realm-renderer-recovery.md new file mode 100644 index 00000000..dffb189c --- /dev/null +++ b/docs/design/realm-renderer-recovery.md @@ -0,0 +1,28 @@ +# Realm renderer recovery + +The Realm keeps a real WebGL scene as the source of truth once it has become +ready. The renderer lifecycle is explicit: `probing`, `loading`, `ready`, +`recovering`, `static-unsupported`, and `failed`. + +`static-unsupported` is reserved for a device that cannot create WebGL before +the first successful scene. It is an accessible, bounded illustrated view; it +is never a post-ready error surface. A renderer construction error, failed +castle assembly, castle-count mismatch, or synchronization failure remains an +explicit loading/recovery/failed state instead of silently replacing a real +world with a full-world SVG. + +Context loss calls `preventDefault`, pauses ambient work and rendering, and +retains React selection, camera intent, and the scene attestation. The restored +event starts a bounded scene rebuild. If the browser does not restore the +context in time, the user sees an explicit retry surface. No code intentionally +calls `WEBGL_lose_context` during capability probing. + +Castle loading is staged: Compact is mandatory and retried once for transient +transport failures; Balanced and High are optional upgrades. A missing optional +LOD records the active quality in `data-realm-castle-active-lod` and continues +with Compact. Pairing, integrity, and Compact failures are reported with stable +failure codes for telemetry and QA. + +The recovery contract is intentionally frontend-only. Durable world state, +authorization, and SpacetimeDB subscriptions remain outside the renderer and +are never mutated by recovery code. diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index 4ad09e52..1308d694 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -143,6 +143,15 @@ import { resolveRealmEscape, type RealmCameraTarget } from './realmInteractionState'; +import { + classifyRealmRendererFailure, + initialRealmRendererLifecycle, + REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS, + shouldRetryRealmRenderer, + transitionRealmRendererLifecycle, + type RealmRendererFailure, + type RealmRendererLifecycle +} from './realmRendererRecovery'; import './RealmMapScreen.css'; import './RealmCastlePresentation.css'; @@ -593,9 +602,21 @@ function CanonicalRealmMapScreen({ }), [surface, terrainPlacements, tileMetadataByKey]); const quality = useMemo(() => initialQuality(qualityOverride), [qualityOverride]); const qualitySpec = REALM_QUALITY_SPECS[quality]; - const [rendererMode, setRendererMode] = useState('loading'); + const [rendererLifecycle, setRendererLifecycle] = useState( + initialRealmRendererLifecycle + ); + const rendererMode: RendererMode = rendererLifecycle.state === 'static-unsupported' + ? 'fallback' + : rendererLifecycle.state === 'ready' ? 'webgl' : 'loading'; const rendererModeRef = useRef('loading'); rendererModeRef.current = rendererMode; + const rendererLifecycleRef = useRef(rendererLifecycle); + rendererLifecycleRef.current = rendererLifecycle; + const rendererRecoveryTimerRef = useRef(null); + const rendererRecoveryNonceRef = useRef(0); + const [rendererRecoveryNonce, setRendererRecoveryNonce] = useState(0); + const rendererEverReadyRef = useRef(false); + const rendererAttestationRef = useRef | null>(null); const [cameraMode, setCameraMode] = useState('realm'); const [interaction, dispatchInteraction] = useReducer( realmInteractionReducer, @@ -623,7 +644,13 @@ function CanonicalRealmMapScreen({ const handledKeyboardIntentSequenceRef = useRef(-1); const reducedMotion = useReducedMotionPreference(); const fallbackSurface = useMemo( - () => fallbackSurfacePresentation(surface), + () => fallbackSurfacePresentation(surface, { focusCoord: keepCoord, radius: 16 }), + [keepCoord, surface] + ); + // Keep direct-label accounting complete for assistive technology even when + // the visible unsupported-device SVG is region-bounded around the keep. + const fallbackProjectionViewBox = useMemo( + () => fallbackSurfacePresentation(surface).viewBox, [surface] ); const viewBox = fallbackSurface.viewBox; @@ -866,9 +893,64 @@ function CanonicalRealmMapScreen({ woodNodesBySiteId ]); - const markRendererUnavailable = useCallback(() => { - rendererModeRef.current = 'fallback'; - setRendererMode('fallback'); + const markRendererFailure = useCallback((failureInput?: RealmRendererFailure | unknown) => { + const current = rendererLifecycleRef.current; + const failure = failureInput && typeof failureInput === 'object' && 'code' in failureInput + ? failureInput as RealmRendererFailure + : classifyRealmRendererFailure(failureInput, current.state); + if (failure.code === 'webgl-unavailable') { + setRendererLifecycle(transitionRealmRendererLifecycle(current, { + type: 'webgl-unsupported', + failure + })); + return; + } + if (shouldRetryRealmRenderer(current, failure)) { + const nextAttempt = current.attempt + 1; + setRendererLifecycle(transitionRealmRendererLifecycle(current, { + type: 'recover', + failure, + attempt: nextAttempt + })); + if (failure.code !== 'context-lost') { + rendererRecoveryNonceRef.current += 1; + setRendererRecoveryNonce(rendererRecoveryNonceRef.current); + } + if (failure.code === 'context-lost') { + if (rendererRecoveryTimerRef.current !== null) window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = window.setTimeout(() => { + rendererRecoveryTimerRef.current = null; + const latest = rendererLifecycleRef.current; + const timeoutFailure: RealmRendererFailure = { + code: 'context-restore-timeout', + retryable: false, + phase: latest.state, + message: 'The browser did not restore the Realm graphics context in time.' + }; + setRendererLifecycle(transitionRealmRendererLifecycle(latest, { + type: 'failed', + failure: timeoutFailure + })); + }, REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS); + } + return; + } + setRendererLifecycle(transitionRealmRendererLifecycle(current, { type: 'failed', failure })); + }, []); + + const retryRenderer = useCallback(() => { + if (rendererRecoveryTimerRef.current !== null) { + window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = null; + } + const next: RealmRendererLifecycle = { + state: 'loading', + attempt: 0, + everReady: rendererEverReadyRef.current + }; + setRendererLifecycle(next); + rendererRecoveryNonceRef.current += 1; + setRendererRecoveryNonce(rendererRecoveryNonceRef.current); }, []); const isSceneCoordPassable = useCallback((coord: HexCoord) => ( @@ -1167,7 +1249,7 @@ function CanonicalRealmMapScreen({ height, castles: allCastles.map((castle) => fallbackCastleProjection( castle, - viewBox, + fallbackProjectionViewBox, { width, height }, svgViewport )) @@ -1184,19 +1266,26 @@ function CanonicalRealmMapScreen({ observer?.disconnect(); window.removeEventListener('resize', updateFallbackProjection); }; - }, [allCastles, rendererMode, updateCastleProjection, viewBox]); + }, [allCastles, fallbackProjectionViewBox, rendererMode, updateCastleProjection, viewBox]); useEffect(() => { const canvas = canvasRef.current; if (!canvas || !canUseWebGL()) { - markRendererUnavailable(); + markRendererFailure({ + code: 'webgl-unavailable', + retryable: false, + phase: 'probing', + message: 'WebGL is unavailable on this device.' + }); return undefined; } let scene: RealmSceneHandle | null = null; try { - rendererModeRef.current = 'loading'; - setRendererMode('loading'); + setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + type: 'load-start', + attempt: rendererLifecycleRef.current.attempt + })); latestProjectionRef.current = { width: 0, height: 0, castles: [] }; labelMembershipSignatureRef.current = ''; latestVisibleCastleLabelsRef.current = []; @@ -1257,7 +1346,6 @@ function CanonicalRealmMapScreen({ rootRef.current.dataset.stoneMarkerOnlySiteCount = String(stoneNodeCatalog.length); } setVisibleCastleLabels([]); - setCameraMode('realm'); scene = createRealmScene({ canvas, surface, @@ -1286,11 +1374,23 @@ function CanonicalRealmMapScreen({ onKeepStatusChange: () => undefined, onCastlesReady: (castleCount) => { if (castleCount !== expectedCastleCountRef.current) { - markRendererUnavailable(); + markRendererFailure({ + code: 'castle-count-mismatch', + retryable: true, + phase: 'loading', + message: `Expected ${expectedCastleCountRef.current} castles, received ${castleCount}.` + }); return; } rendererModeRef.current = 'webgl'; - setRendererMode('webgl'); + rendererEverReadyRef.current = true; + const activeLod = canvas.dataset.realmCastleActiveLod; + setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + type: 'ready', + degradedQuality: activeLod === 'compact' || activeLod === 'balanced' + ? activeLod + : undefined + })); updateSceneComposition(); }, onCastlePresentationTelemetry: updateCastlePresentationTelemetry, @@ -1300,7 +1400,16 @@ function CanonicalRealmMapScreen({ onStoneNodePresentationTelemetry: updateStoneNodePresentationTelemetry, onTerrainPresentationTelemetry: updateTerrainPresentationTelemetry, onCastleProjection: updateCastleProjection, - onRendererUnavailable: markRendererUnavailable, + onRendererFailure: markRendererFailure, + onRendererContextRestored: () => { + if (rendererRecoveryTimerRef.current !== null) { + window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = null; + } + rendererRecoveryNonceRef.current += 1; + setRendererRecoveryNonce(rendererRecoveryNonceRef.current); + }, + onRendererUnavailable: () => undefined, onSelect: () => undefined, onTargetSelect: handleSceneTargetSelect }); @@ -1343,15 +1452,30 @@ function CanonicalRealmMapScreen({ else if (cameraTarget.kind === 'keep') scene.recenterKeep(); else if (cameraTarget.kind === 'founding-district') scene.frameFoundingDistrict(); else scene.showRealm(); - } catch { - markRendererUnavailable(); + const attestation = rendererAttestationRef.current; + if (attestation && attestation.canvasId === canvas.dataset.realmCanvasIdentity) { + scene.restoreCameraAttestation?.(attestation); + } + } catch (error) { + markRendererFailure(classifyRealmRendererFailure(error, 'loading')); } return () => { + if (scene) { + try { + rendererAttestationRef.current = scene.getCameraAttestation(); + } catch { + rendererAttestationRef.current = null; + } + } scene?.dispose(); if (sceneRef.current === scene) sceneRef.current = null; + if (rendererRecoveryTimerRef.current !== null) { + window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.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, markRendererFailure, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, rendererRecoveryNonce, sharedForestProjection, snapshot.realm.realmId, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog]); useEffect(() => { sceneRef.current?.reconcileLiveGatheringState?.(liveGatheringState); @@ -1485,6 +1609,11 @@ function CanonicalRealmMapScreen({ className="realm-map-screen" data-presentation-mode={observerMode ? 'observer' : 'player'} data-renderer={rendererMode} + data-renderer-state={rendererLifecycle.state} + data-renderer-ever-ready={String(rendererLifecycle.everReady)} + data-renderer-recovery-attempt={String(rendererLifecycle.attempt)} + data-renderer-failure={rendererLifecycle.failure?.code ?? 'none'} + data-renderer-degraded-quality={rendererLifecycle.degradedQuality ?? 'none'} data-quality={quality} tabIndex={0} aria-label={observerMode ? 'Hegemony realm QA observer' : 'Hegemony realm'} @@ -1738,19 +1867,38 @@ function CanonicalRealmMapScreen({

- Detailed terrain is unavailable. Showing the canonical Genesis 001 realm map. + WebGL is unavailable on this device. Showing a bounded, accessible view of the + canonical Genesis 001 region around your keep. +

) : null} - {rendererMode === 'loading' ? ( + {rendererLifecycle.state !== 'ready' && rendererLifecycle.state !== 'static-unsupported' ? (
- Surveying the bright lowlands… - Preparing every canonical castle before the realm is revealed. + {rendererLifecycle.state === 'failed' ? ( + The 3D realm needs another attempt + ) : rendererLifecycle.state === 'recovering' ? ( + Recovering the 3D realm… + ) : ( + Surveying the bright lowlands… + )} + + {rendererLifecycle.state === 'failed' + ? 'The renderer stopped before the world was ready. Your world state is safe.' + : rendererLifecycle.state === 'recovering' + ? 'Restoring the graphics context without replacing the realm with a flat map.' + : 'Preparing every canonical castle before the realm is revealed.'} + + {rendererLifecycle.state === 'failed' ? ( + + ) : null} diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 386c036a..48ffa5e3 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -148,6 +148,7 @@ import type { RealmCastleProjectionFrame, RealmCastleScreenBounds } from './realmTypes'; +import type { RealmRendererFailure } from './realmRendererRecovery'; const HEX_SIZE = 1; const OVERLAY_LIFT = 0.026; @@ -416,6 +417,7 @@ export type RealmSceneHandle = Readonly<{ dispose: () => void; reconcileLiveGatheringState: (state: RealmLiveGatheringState) => void; getCameraAttestation: () => RealmCameraAttestation; + restoreCameraAttestation?: (attestation: RealmCameraAttestation) => void; getSceneBuildSequence: () => number; focusCastle: (castleId: number) => void; focusCell: (coord: HexCoord) => void; @@ -552,6 +554,12 @@ export type CreateRealmSceneOptions = Readonly<{ onFoodNodePresentationTelemetry?: (telemetry: RealmFoodNodePresentationTelemetry) => void; onWoodNodePresentationTelemetry?: (telemetry: RealmWoodNodePresentationTelemetry) => void; onStoneNodePresentationTelemetry?: (telemetry: RealmStoneNodePresentationTelemetry) => void; + /** Structured renderer lifecycle signal. The scene remains alive during a + * recoverable context loss so camera/selection state can be attested. */ + onRendererFailure?: (failure: RealmRendererFailure) => void; + onRendererContextRestored?: () => void; + /** Legacy callback retained for integrations that only understand a boolean + * renderer-unavailable signal. It is never used for context loss. */ onRendererUnavailable: () => void; /** @deprecated Prefer onTargetSelect for castle identity-aware interaction. */ onSelect: (coord: HexCoord) => void; @@ -974,7 +982,7 @@ function initializeRealmScene( }; // Pure quality policy is needed by the first resize/projection callback; // initialize it before any observer or render loop can run. - const castleLodPolicy = castleLodPolicyForQuality(runtimeQuality); + let castleLodPolicy = castleLodPolicyForQuality(runtimeQuality); const terrainSemantics = indexRealmTerrainSemantics( options.surface, options.terrainMetadata @@ -1680,6 +1688,7 @@ function initializeRealmScene( let presentedCastleKey = '*'; let renderPendingWhileHidden = false; let pendingCastlesReadyCount: number | null = null; + let contextLost = false; let ambientScheduler: RealmAmbientScheduler | null = null; const ambientIsNeeded = () => !options.reducedMotion && renderPlan.grass.animationFrameCap > 0 @@ -1800,6 +1809,7 @@ function initializeRealmScene( }; const render = () => { if (cleanup.isDisposed()) return; + if (contextLost) return; if (document.hidden) { renderPendingWhileHidden = true; return; @@ -1924,7 +1934,16 @@ function initializeRealmScene( try { renderer.render(scene, cameraController.camera); } catch (error) { - if (!isGrassShaderContractFailure(error) || !disableGrassPresentation()) throw error; + if (!isGrassShaderContractFailure(error) || !disableGrassPresentation()) { + options.onRendererFailure?.({ + code: 'scene-build-failed', + retryable: true, + phase: 'ready', + message: error instanceof Error ? error.message : String(error) + }); + disposeScene(); + return; + } // `onBeforeCompile` runs during rendering. Retry the same frame without // only the grass layer if its pinned shader chunk contract has changed. renderer.render(scene, cameraController.camera); @@ -1937,13 +1956,31 @@ function initializeRealmScene( && (presentedCastleIds === null || presentedCastleIds.size > 0) && (castleLayer?.getPacking().totalVisible ?? 0) === 0 ) { - throw new Error('Hegemony castle instances produced no visible rendered packing.'); + pendingCastlesReadyCount = null; + const failure: RealmRendererFailure = { + code: 'castle-pairing-failed', + retryable: false, + phase: 'loading', + message: 'Hegemony castle instances produced no visible rendered packing.' + }; + options.onRendererFailure?.(failure); + options.onRendererUnavailable(); + return; } if ( castleCount > 0 && !castleLayer?.hasExactCastleLandscapeBasePairing() ) { - throw new Error('Hegemony castle landscape-base presentation is incomplete.'); + pendingCastlesReadyCount = null; + const failure: RealmRendererFailure = { + code: 'castle-pairing-failed', + retryable: false, + phase: 'loading', + message: 'Hegemony castle landscape-base presentation is incomplete.' + }; + options.onRendererFailure?.(failure); + options.onRendererUnavailable(); + return; } pendingCastlesReadyCount = null; options.onCastlesReady?.(castleCount); @@ -2540,8 +2577,18 @@ function initializeRealmScene( const handleContextLost = (event: Event) => { event.preventDefault(); if (cleanup.isDisposed()) return; - disposeScene(); - options.onRendererUnavailable(); + contextLost = true; + ambientScheduler?.setActive(false); + options.onRendererFailure?.({ + code: 'context-lost', + retryable: true, + phase: pendingCastlesReadyCount === null ? 'ready' : 'loading', + message: 'The WebGL context was lost; waiting for the browser to restore it.' + }); + }; + const handleContextRestored = () => { + if (cleanup.isDisposed() || !contextLost) return; + options.onRendererContextRestored?.(); }; interactionRoot.addEventListener('pointerdown', handlePointerDown, { @@ -2581,6 +2628,8 @@ function initializeRealmScene( cleanup.add(() => interactionRoot.removeEventListener('wheel', handleWheel, true)); options.canvas.addEventListener('webglcontextlost', handleContextLost); cleanup.add(() => options.canvas.removeEventListener('webglcontextlost', handleContextLost)); + options.canvas.addEventListener('webglcontextrestored', handleContextRestored); + cleanup.add(() => options.canvas.removeEventListener('webglcontextrestored', handleContextRestored)); const resize = () => { if (cleanup.isDisposed()) return; @@ -2653,35 +2702,55 @@ function initializeRealmScene( } const leases: HegemonyKeepPrefabLease[] = []; - let acquisitionStopped = false; - try { - await Promise.all(usedCastleLods.map(async (lod) => { + const compact = usedCastleLods.includes('compact') ? 'compact' : usedCastleLods[0]; + if (!compact) throw new Error('No compact castle LOD is configured.'); + const acquireWithRetry = async (lod: CastleLod) => { + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { try { - const lease = await prefabRepository.acquire( - lod, - castleLoadAbortController.signal - ); - if (acquisitionStopped || cleanup.isDisposed()) { - releaseLeases([lease]); - return; - } - leases.push(lease); + return await prefabRepository.acquire(lod, castleLoadAbortController.signal); } catch (error) { - acquisitionStopped = true; - throw error; + lastError = error; + if (lod !== 'compact' || attempt !== 0) break; } - })); - } catch (error) { - acquisitionStopped = true; - releaseLeases(leases); - throw error; + } + throw lastError ?? new Error(`Unable to load castle ${lod} LOD.`); + }; + const compactLease = await acquireWithRetry(compact); + if (cleanup.isDisposed()) { + releaseLeases([compactLease]); + return; } + leases.push(compactLease); + const optionalLods = usedCastleLods.filter((lod) => lod !== compact); + const optionalResults = await Promise.allSettled( + optionalLods.map((lod) => acquireWithRetry(lod)) + ); + optionalResults.forEach((result, index) => { + if (result.status === 'fulfilled') leases.push(result.value); + else options.canvas.dataset[`realmCastle${optionalLods[index]}Lod`] = 'unavailable'; + }); if (cleanup.isDisposed()) { releaseLeases(leases); return; } const prefabs = new Map(leases.map((lease) => [lease.prefab.lod, lease.prefab])); + const activeLod = prefabs.has(castleLodPolicy.maximumLod) + ? castleLodPolicy.maximumLod + : prefabs.has('balanced') ? 'balanced' : 'compact'; + castleLodPolicy = Object.freeze({ + ...castleLodPolicy, + maximumLod: activeLod, + selectedMinimumLod: activeLod, + highInstanceBudget: activeLod === 'high' + ? DEFAULT_CASTLE_LOD_POLICY.highInstanceBudget + : 0, + balancedInstanceBudget: activeLod === 'compact' + ? 0 + : DEFAULT_CASTLE_LOD_POLICY.balancedInstanceBudget + }); + options.canvas.dataset.realmCastleActiveLod = activeLod; const nextProjectionEnvelopeByLod = new Map([...prefabs].map(([lod, prefab]) => [ lod, prefab.projectionEnvelope @@ -2763,15 +2832,21 @@ function initializeRealmScene( render(); }; - void initializeCastleInstances().catch(() => { + void initializeCastleInstances().catch((error) => { if (cleanup.isDisposed()) return; try { options.onKeepStatusChange('fallback'); } catch { // Renderer fallback still has to engage when a status observer fails. } - disposeScene(); + options.onRendererFailure?.({ + code: 'castle-compact-load-failed', + retryable: true, + phase: 'loading', + message: error instanceof Error ? error.message : String(error) + }); options.onRendererUnavailable(); + disposeScene(); }); function disposeScene() { @@ -2870,6 +2945,10 @@ function initializeRealmScene( dispose: disposeScene, reconcileLiveGatheringState, getCameraAttestation, + restoreCameraAttestation: (attestation) => { + if (cleanup.isDisposed()) return; + cameraController.restorePose?.(attestation); + }, getSceneBuildSequence: () => sceneBuildSequence, focusCastle: (castleId) => { if (cleanup.isDisposed()) return; diff --git a/src/components/realm/realmCameraController.ts b/src/components/realm/realmCameraController.ts index da1ab4e7..10e55c57 100644 --- a/src/components/realm/realmCameraController.ts +++ b/src/components/realm/realmCameraController.ts @@ -983,6 +983,11 @@ export type RealmCameraController = Readonly<{ panByPixels: (deltaX: number, deltaY: number) => void; projectPoint: (point: RealmCameraPoint) => RealmScreenProjection; recenterKeep: () => void; + restorePose?: (pose: Readonly<{ + position: RealmCameraPoint; + target: RealmCameraPoint; + fov: number; + }>) => void; setComposition: (composition: RealmCameraComposition) => void; setKeepFocus: (focus: RealmKeepFocus) => void; setViewport: (width: number, height: number) => void; @@ -1582,6 +1587,16 @@ export function createRealmCameraController( zoomAnchor = null; invalidate(); }, + restorePose: (pose) => { + if (disposed) return; + camera.position.set(pose.position.x, pose.position.y, pose.position.z); + targetVector.set(pose.target.x, pose.target.y, pose.target.z); + camera.fov = clamp(finite(pose.fov, camera.fov), 1, 120); + camera.lookAt(targetVector); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + options.render(); + }, setComposition: (next) => { // Insets change the projection beneath a screen-space zoom anchor. Drop // the old anchor so the camera can converge on the newly composed target diff --git a/src/components/realm/realmMapPresentationHelpers.ts b/src/components/realm/realmMapPresentationHelpers.ts index c4686f99..f1ab5e2b 100644 --- a/src/components/realm/realmMapPresentationHelpers.ts +++ b/src/components/realm/realmMapPresentationHelpers.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import type { HexCoord } from '../../game/map/hexCoordinates'; +import { axialToWorld, type HexCoord } from '../../game/map/hexCoordinates'; import { terrainCellByCoord } from '../../game/map/generateTerrainMap'; import type { RealmTerrainSurface } from '../../game/map/realmTerrainSurface'; import type { TerrainCell } from '../../game/map/terrainTypes'; @@ -28,6 +28,12 @@ export type RealmFallbackSurfacePresentation = Readonly<{ playableHullPoints: string; }>; +export type RealmFallbackSurfaceOptions = Readonly<{ + /** Keep unsupported-device mode readable around the player's region. */ + focusCoord?: HexCoord; + radius?: number; +}>; + function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, value)); } @@ -70,15 +76,27 @@ export function directionForKey(key: string): HexCoord | null { } } +let cachedWebGlCapability: boolean | undefined; + +/** + * Probe once per document without deliberately destroying a context. The old + * probe used WEBGL_lose_context as a feature test, which could leave the next + * real canvas in the exact terminal state we are trying to recover from. + */ export function canUseWebGL() { + if (cachedWebGlCapability !== undefined) return cachedWebGlCapability; try { const canvas = document.createElement('canvas'); - const context = canvas.getContext('webgl2'); - context?.getExtension('WEBGL_lose_context')?.loseContext(); - return Boolean(context); + const context = canvas.getContext('webgl2') ?? canvas.getContext('webgl'); + cachedWebGlCapability = Boolean(context); } catch { - return false; + cachedWebGlCapability = false; } + return cachedWebGlCapability; +} + +export function resetWebGLCapabilityForTests() { + cachedWebGlCapability = undefined; } export function pointsForSvg(coord: HexCoord) { @@ -92,7 +110,8 @@ function svgHullPoints(points: readonly Readonly<{ x: number; z: number }>[]) { } export function fallbackSurfacePresentation( - surface: RealmTerrainSurface + surface: RealmTerrainSurface, + options: RealmFallbackSurfaceOptions = {} ): RealmFallbackSurfacePresentation { const renderHull = createTerrainOverviewHull(surface.renderMap, REALM_HEX_SIZE); const playableHull = createTerrainOverviewHull(surface.playableMap, REALM_HEX_SIZE); @@ -115,6 +134,24 @@ export function fallbackSurfacePresentation( maxZ = Math.max(maxZ, point.z); }); const padding = 0.88; + const focus = options.focusCoord; + const radius = Number.isFinite(options.radius) && (options.radius ?? 0) > 0 + ? options.radius! + : undefined; + if (focus && radius) { + const center = axialToWorld(focus, REALM_HEX_SIZE); + const span = Math.max(4, radius * 2.15); + return { + viewBox: { + x: center.x - span * 0.5, + y: -center.z - span * 0.5, + width: span, + height: span + }, + renderHullPoints: svgHullPoints(renderHull), + playableHullPoints: svgHullPoints(playableHull) + }; + } return { viewBox: { x: minX - padding, diff --git a/src/components/realm/realmRendererRecovery.ts b/src/components/realm/realmRendererRecovery.ts new file mode 100644 index 00000000..973610bd --- /dev/null +++ b/src/components/realm/realmRendererRecovery.ts @@ -0,0 +1,123 @@ +export type RealmRendererLifecycleState = + | 'probing' + | 'loading' + | 'ready' + | 'recovering' + | 'static-unsupported' + | 'failed'; + +export type RealmRendererFailureCode = + | 'webgl-unavailable' + | 'renderer-construction-failed' + | 'context-lost' + | 'context-restore-timeout' + | 'castle-count-mismatch' + | 'castle-prefab-assembly-failed' + | 'castle-pairing-failed' + | 'castle-compact-load-failed' + | 'castle-integrity-failed' + | 'scene-build-failed' + | 'sync-failed'; + +export type RealmRendererFailure = Readonly<{ + code: RealmRendererFailureCode; + message?: string; + retryable: boolean; + phase: RealmRendererLifecycleState; + attempt?: number; +}>; + +export type RealmRendererLifecycle = Readonly<{ + state: RealmRendererLifecycleState; + attempt: number; + failure?: RealmRendererFailure; + everReady: boolean; + degradedQuality?: 'compact' | 'balanced'; +}>; + +export const REALM_RENDERER_MAX_RECOVERY_ATTEMPTS = 2; +export const REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS = 8_000; + +export function initialRealmRendererLifecycle(): RealmRendererLifecycle { + return Object.freeze({ state: 'probing', attempt: 0, everReady: false }); +} + +export function transitionRealmRendererLifecycle( + current: RealmRendererLifecycle, + event: + | { type: 'probe-start' } + | { type: 'webgl-unsupported'; failure?: RealmRendererFailure } + | { type: 'load-start'; attempt?: number } + | { type: 'ready'; degradedQuality?: 'compact' | 'balanced' } + | { type: 'recover'; failure: RealmRendererFailure; attempt?: number } + | { type: 'failed'; failure: RealmRendererFailure } +): RealmRendererLifecycle { + switch (event.type) { + case 'probe-start': + return Object.freeze({ ...current, state: 'probing', failure: undefined }); + case 'webgl-unsupported': + return Object.freeze({ + ...current, + state: 'static-unsupported', + failure: event.failure, + everReady: false + }); + case 'load-start': + return Object.freeze({ + ...current, + state: 'loading', + attempt: event.attempt ?? current.attempt, + failure: undefined + }); + case 'ready': + return Object.freeze({ + ...current, + state: 'ready', + failure: undefined, + everReady: true, + degradedQuality: event.degradedQuality + }); + case 'recover': + return Object.freeze({ + ...current, + state: 'recovering', + attempt: event.attempt ?? current.attempt + 1, + failure: event.failure + }); + case 'failed': + return Object.freeze({ ...current, state: 'failed', failure: event.failure }); + default: + return current; + } +} + +export function classifyRealmRendererFailure( + error: unknown, + phase: RealmRendererLifecycleState, + fallbackCode: RealmRendererFailureCode = 'scene-build-failed' +): RealmRendererFailure { + const message = error instanceof Error ? error.message : String(error ?? 'Unknown renderer failure'); + const normalized = message.toLowerCase(); + let code = fallbackCode; + if (/integrity|sha-?256|content-addressed|digest/.test(normalized)) code = 'castle-integrity-failed'; + else if (/pair|landscape base|landscape-base/.test(normalized)) code = 'castle-pairing-failed'; + else if (/prefab|assembly|no renderable meshes|normalized bounds/.test(normalized)) { + code = 'castle-prefab-assembly-failed'; + } else if (/timeout|timed out|network|fetch|response body/.test(normalized)) { + code = 'castle-compact-load-failed'; + } else if (/webgl|renderer/.test(normalized)) code = 'renderer-construction-failed'; + return Object.freeze({ + code, + message, + retryable: !['castle-integrity-failed', 'castle-pairing-failed'].includes(code), + phase + }); +} + +export function shouldRetryRealmRenderer( + lifecycle: RealmRendererLifecycle, + failure: RealmRendererFailure +) { + return failure.retryable + && lifecycle.attempt < REALM_RENDERER_MAX_RECOVERY_ATTEMPTS; +} diff --git a/tests/realmRendererRecovery.test.ts b/tests/realmRendererRecovery.test.ts new file mode 100644 index 00000000..06235620 --- /dev/null +++ b/tests/realmRendererRecovery.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyRealmRendererFailure, + initialRealmRendererLifecycle, + REALM_RENDERER_MAX_RECOVERY_ATTEMPTS, + shouldRetryRealmRenderer, + transitionRealmRendererLifecycle +} from '../src/components/realm/realmRendererRecovery'; + +describe('Realm renderer recovery lifecycle', () => { + it('keeps no-WebGL devices in the explicit static mode', () => { + const initial = initialRealmRendererLifecycle(); + const state = transitionRealmRendererLifecycle(initial, { + type: 'webgl-unsupported', + failure: { + code: 'webgl-unavailable', + retryable: false, + phase: 'probing' + } + }); + expect(state.state).toBe('static-unsupported'); + expect(state.everReady).toBe(false); + }); + + it('recovers after a loss without changing the ready history', () => { + const ready = transitionRealmRendererLifecycle(initialRealmRendererLifecycle(), { + type: 'ready' + }); + const recovering = transitionRealmRendererLifecycle(ready, { + type: 'recover', + attempt: 1, + failure: { code: 'context-lost', retryable: true, phase: 'ready' } + }); + expect(recovering.state).toBe('recovering'); + expect(recovering.everReady).toBe(true); + expect(transitionRealmRendererLifecycle(recovering, { type: 'ready' }).state).toBe('ready'); + }); + + it('classifies integrity and pairing failures as explicit non-retryable failures', () => { + expect(classifyRealmRendererFailure(new Error('sha256 integrity mismatch'), 'loading').code) + .toBe('castle-integrity-failed'); + expect(classifyRealmRendererFailure(new Error('landscape base pairing failed'), 'loading').code) + .toBe('castle-pairing-failed'); + expect(classifyRealmRendererFailure(new Error('request timed out'), 'loading').code) + .toBe('castle-compact-load-failed'); + }); + + it('bounds automatic retries and leaves manual retry available', () => { + const current = { + ...initialRealmRendererLifecycle(), + state: 'recovering' as const, + attempt: REALM_RENDERER_MAX_RECOVERY_ATTEMPTS, + everReady: true + }; + expect(shouldRetryRealmRenderer(current, { + code: 'scene-build-failed', + retryable: true, + phase: 'recovering' + })).toBe(false); + }); +}); From a40924dd6376996277fc968da819aa0000a50217 Mon Sep 17 00:00:00 2001 From: Ael Date: Tue, 21 Jul 2026 17:35:57 +0200 Subject: [PATCH 02/19] Fix: harden renderer recovery invariants and input handling --- docs/design/realm-renderer-recovery.md | 21 +++++---- src/components/realm/RealmMapScreen.tsx | 38 +++++++++------- src/components/realm/createRealmScene.ts | 44 +++++++++++++++++++ .../realm/realmMapPresentationHelpers.ts | 5 ++- src/components/realm/realmRendererRecovery.ts | 22 +++++++++- tests/realmMapScreen.test.tsx | 5 ++- tests/realmRendererRecovery.test.ts | 33 ++++++++++++++ tests/realmSceneCleanup.test.ts | 40 +++++++++++++++++ 8 files changed, 180 insertions(+), 28 deletions(-) diff --git a/docs/design/realm-renderer-recovery.md b/docs/design/realm-renderer-recovery.md index dffb189c..241af468 100644 --- a/docs/design/realm-renderer-recovery.md +++ b/docs/design/realm-renderer-recovery.md @@ -12,16 +12,21 @@ explicit loading/recovery/failed state instead of silently replacing a real world with a full-world SVG. Context loss calls `preventDefault`, pauses ambient work and rendering, and -retains React selection, camera intent, and the scene attestation. The restored -event starts a bounded scene rebuild. If the browser does not restore the -context in time, the user sees an explicit retry surface. No code intentionally -calls `WEBGL_lose_context` during capability probing. +retains React selection, camera intent, and the scene attestation. Pointer, +wheel, label-click, and camera input are synchronously suspended while the +context is lost so a partially disposed scene cannot consume a gesture. The +restored event starts a bounded scene rebuild and records loss/restore counts on +the canvas for DOM diagnostics. If the browser does not restore the context in +time, the user sees an explicit retry surface. No code intentionally calls +`WEBGL_lose_context` during capability probing; the probe is WebGL2-only. Castle loading is staged: Compact is mandatory and retried once for transient -transport failures; Balanced and High are optional upgrades. A missing optional -LOD records the active quality in `data-realm-castle-active-lod` and continues -with Compact. Pairing, integrity, and Compact failures are reported with stable -failure codes for telemetry and QA. +transport failures after a deterministic short yield; Balanced and High are +optional upgrades. A missing optional LOD records the active quality in +`data-realm-castle-active-lod` and continues with Compact. Pairing, integrity, +and Compact failures are reported with stable failure codes for telemetry and +QA. Each controlled load is assigned a monotonic renderer generation, and a +ready renderer can never transition into static compatibility mode. The recovery contract is intentionally frontend-only. Durable world state, authorization, and SpacetimeDB subscriptions remain outside the renderer and diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index 1308d694..bcff9bf2 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -615,7 +615,6 @@ function CanonicalRealmMapScreen({ const rendererRecoveryTimerRef = useRef(null); const rendererRecoveryNonceRef = useRef(0); const [rendererRecoveryNonce, setRendererRecoveryNonce] = useState(0); - const rendererEverReadyRef = useRef(false); const rendererAttestationRef = useRef | null>(null); const [cameraMode, setCameraMode] = useState('realm'); const [interaction, dispatchInteraction] = useReducer( @@ -899,12 +898,17 @@ function CanonicalRealmMapScreen({ ? failureInput as RealmRendererFailure : classifyRealmRendererFailure(failureInput, current.state); if (failure.code === 'webgl-unavailable') { + rendererModeRef.current = current.everReady ? 'loading' : 'fallback'; setRendererLifecycle(transitionRealmRendererLifecycle(current, { type: 'webgl-unsupported', failure })); return; } + // Stop accepting pointer/camera mutations synchronously, before React has + // committed the loading/recovering state to the DOM. The scene itself + // applies the same guard while a WebGL context is lost. + rendererModeRef.current = 'loading'; if (shouldRetryRealmRenderer(current, failure)) { const nextAttempt = current.attempt + 1; setRendererLifecycle(transitionRealmRendererLifecycle(current, { @@ -943,12 +947,11 @@ function CanonicalRealmMapScreen({ window.clearTimeout(rendererRecoveryTimerRef.current); rendererRecoveryTimerRef.current = null; } - const next: RealmRendererLifecycle = { - state: 'loading', - attempt: 0, - everReady: rendererEverReadyRef.current - }; - setRendererLifecycle(next); + setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + type: 'load-start', + attempt: 0 + })); + rendererModeRef.current = 'loading'; rendererRecoveryNonceRef.current += 1; setRendererRecoveryNonce(rendererRecoveryNonceRef.current); }, []); @@ -1383,7 +1386,6 @@ function CanonicalRealmMapScreen({ return; } rendererModeRef.current = 'webgl'; - rendererEverReadyRef.current = true; const activeLod = canvas.dataset.realmCastleActiveLod; setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { type: 'ready', @@ -1603,6 +1605,9 @@ function CanonicalRealmMapScreen({ if (isPlayableRealmCoord(surface, next)) selectCoord(next); }; + // The scene mirrors these counters onto the canvas so a context event can + // be diagnosed without exposing implementation details in user copy. + const canvasTelemetry = canvasRef.current?.dataset; return (
WebGL is unavailable on this device. Showing a bounded, accessible view of the canonical Genesis 001 region around your keep. -

) : null} @@ -1883,20 +1889,20 @@ function CanonicalRealmMapScreen({ >
{rendererLifecycle.state === 'failed' ? ( - The 3D realm needs another attempt + THE REALM COULD NOT BE RESTORED ) : rendererLifecycle.state === 'recovering' ? ( - Recovering the 3D realm… + RESTORING THE REALM… ) : ( Surveying the bright lowlands… )} {rendererLifecycle.state === 'failed' - ? 'The renderer stopped before the world was ready. Your world state is safe.' + ? 'Warpkeep could not restart 3D rendering. Your server-owned progress was not changed.' : rendererLifecycle.state === 'recovering' - ? 'Restoring the graphics context without replacing the realm with a flat map.' + ? 'The graphics device was interrupted. Your game state is safe while the realm is restored.' : 'Preparing every canonical castle before the realm is revealed.'} - {rendererLifecycle.state === 'failed' ? ( + {rendererLifecycle.state === 'failed' && rendererLifecycle.failure?.retryable !== false ? ( ) : null} +
) : null} diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 728d907b..7da81d3e 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -68,6 +68,7 @@ import { import { createRealmCameraController, DEFAULT_REALM_CAMERA_SPEC, + type RealmCameraControllerState, type RealmCameraComposition, type RealmCameraMode, type RealmKeepFocus @@ -148,7 +149,10 @@ import type { RealmCastleProjectionFrame, RealmCastleScreenBounds } from './realmTypes'; -import type { RealmRendererFailure } from './realmRendererRecovery'; +import { + classifyRealmRendererFailure, + type RealmRendererFailure +} from './realmRendererRecovery'; const HEX_SIZE = 1; const OVERLAY_LIFT = 0.026; @@ -453,6 +457,7 @@ export type RealmCameraAttestation = Readonly<{ target: Readonly<{ x: number; y: number; z: number }>; fov: number; zoom: number; + controllerState: RealmCameraControllerState; selectedTerrainCoord: HexCoord | null; selectedCastleId: number | null; selectedGoldSiteId: string | null; @@ -546,6 +551,8 @@ export type CreateRealmSceneOptions = Readonly<{ onKeepStatusChange: (status: KeepLoadStatus) => void; /** Fired only after every authoritative castle has a real GLB instance. */ onCastlesReady?: (castleCount: number) => void; + /** Reports a progressive contiguous LOD upgrade after initial readiness. */ + onCastleLodChange?: (lod: CastleLod) => void; /** Live counts derived from populated instance meshes after presentation masking. */ onCastlePresentationTelemetry?: ( telemetry: RealmCastleInstancePresentationTelemetry @@ -2400,12 +2407,16 @@ function initializeRealmScene( labelClickSuppressionTimer = window.setTimeout(clearLabelClickSuppression, 0); }; + const contextLostBlocksTarget = (target: EventTarget | null) => ( + laneForTarget(target) !== null + ); + const handlePointerDown = (event: PointerEvent) => { - if (contextLost) { + const lane = laneForTarget(event.target); + if (contextLost && lane !== null) { event.preventDefault(); return; } - const lane = laneForTarget(event.target); if (!lane || (event.pointerType !== 'touch' && event.button !== 0)) return; if (pointerGestures.snapshot().pointerCount === 0) clearLabelClickSuppression(); const result = pointerGestures.start({ @@ -2428,10 +2439,14 @@ function initializeRealmScene( }; const handlePointerMove = (event: PointerEvent) => { - if (contextLost) { + if (contextLost && ( + contextLostBlocksTarget(event.target) + || pointerGestures.snapshot().pointerCount > 0 + )) { event.preventDefault(); return; } + if (contextLost) return; if (pointerGestures.snapshot().pointerCount === 0) { if (event.target === options.canvas) scheduleHover(event.clientX, event.clientY); return; @@ -2477,10 +2492,14 @@ function initializeRealmScene( }; const handlePointerUp = (event: PointerEvent) => { - if (contextLost) { + if (contextLost && ( + contextLostBlocksTarget(event.target) + || pointerGestures.snapshot().pointerCount > 0 + )) { event.preventDefault(); return; } + if (contextLost) return; const labelTarget = labelPointerTargets.get(event.pointerId); labelPointerTargets.delete(event.pointerId); const result = pointerGestures.end({ @@ -2502,10 +2521,14 @@ function initializeRealmScene( }; const handlePointerCancel = (event: PointerEvent) => { - if (contextLost) { + if (contextLost && ( + contextLostBlocksTarget(event.target) + || pointerGestures.snapshot().pointerCount > 0 + )) { event.preventDefault(); return; } + if (contextLost) return; labelPointerTargets.delete(event.pointerId); const result = pointerGestures.cancel(event.pointerId); if (!result.accepted) return; @@ -2542,7 +2565,7 @@ function initializeRealmScene( }; const handleLabelClickCapture = (event: MouseEvent) => { - if (contextLost) { + if (contextLost && contextLostBlocksTarget(event.target)) { event.preventDefault(); event.stopImmediatePropagation(); return; @@ -2568,11 +2591,12 @@ function initializeRealmScene( } }; const handleWheel = (event: WheelEvent) => { - if (contextLost) { + const lane = laneForTarget(event.target); + if (contextLost && lane !== null) { event.preventDefault(); return; } - const lane = laneForTarget(event.target); + if (contextLost) return; if (!lane) return; event.preventDefault(); // Camera motion invalidates the last canvas hit. Clear it immediately so @@ -2721,6 +2745,7 @@ function initializeRealmScene( resize(); const usedCastleLods = castleLodsForQuality(runtimeQuality); + const requestedCastleLodPolicy = castleLodPolicy; const prefabRepository = createHegemonyKeepPrefabRepository({ baseUrl: options.baseUrl, maxAnisotropy: renderer.capabilities.getMaxAnisotropy() @@ -2738,59 +2763,42 @@ function initializeRealmScene( }); }; - const initializeCastleInstances = async () => { - if (authoritativeCastles.length === 0) { - if (!cleanup.isDisposed()) { - pendingCastlesReadyCount = 0; - render(); - } - return; - } - - const leases: HegemonyKeepPrefabLease[] = []; - const compact = usedCastleLods.includes('compact') ? 'compact' : usedCastleLods[0]; - if (!compact) throw new Error('No compact castle LOD is configured.'); - const acquireWithRetry = async (lod: CastleLod) => { - let lastError: unknown; - for (let attempt = 0; attempt < 2; attempt += 1) { + const acquiredCastleLeases = new Map(); + let activeCastleLod: CastleLod | null = null; + const releaseAcquiredCastleLease = (lod: CastleLod) => { + const lease = acquiredCastleLeases.get(lod); + if (!lease) return; + acquiredCastleLeases.delete(lod); + releaseLeases([lease]); + }; + cleanup.add(() => { + const layer = castleLayer; + castleLayer = null; + if (layer) { + try { + scene.remove(layer.group); + } finally { try { - return await prefabRepository.acquire(lod, castleLoadAbortController.signal); - } catch (error) { - lastError = error; - if (lod !== 'compact' || attempt !== 0) break; - // Keep the retry bounded and deterministic. The short yield lets a - // transient browser fetch/decode stall settle without overlapping - // two requests for the same content-addressed lease. - await new Promise((resolve) => window.setTimeout(resolve, 40)); + layer.dispose(); + } catch { + // Continue through repository lease release after layer cleanup. } } - throw lastError ?? new Error(`Unable to load castle ${lod} LOD.`); - }; - const compactLease = await acquireWithRetry(compact); - if (cleanup.isDisposed()) { - releaseLeases([compactLease]); - return; - } - leases.push(compactLease); - const optionalLods = usedCastleLods.filter((lod) => lod !== compact); - const optionalResults = await Promise.allSettled( - optionalLods.map((lod) => acquireWithRetry(lod)) - ); - optionalResults.forEach((result, index) => { - if (result.status === 'fulfilled') leases.push(result.value); - else options.canvas.dataset[`realmCastle${optionalLods[index]}Lod`] = 'unavailable'; - }); - if (cleanup.isDisposed()) { - releaseLeases(leases); - return; } + releaseLeases([...acquiredCastleLeases.values()]); + acquiredCastleLeases.clear(); + }); - const prefabs = new Map(leases.map((lease) => [lease.prefab.lod, lease.prefab])); - const activeLod = prefabs.has(castleLodPolicy.maximumLod) - ? castleLodPolicy.maximumLod - : prefabs.has('balanced') ? 'balanced' : 'compact'; - castleLodPolicy = Object.freeze({ - ...castleLodPolicy, + const installCastleLayer = (activeLod: CastleLod, signalReady: boolean) => { + const activeIndex = usedCastleLods.indexOf(activeLod); + const activeLods = usedCastleLods.slice(0, activeIndex + 1); + const prefabs = new Map(activeLods.map((lod) => { + const lease = acquiredCastleLeases.get(lod); + if (!lease) throw new Error(`Missing contiguous Hegemony keep ${lod} lease.`); + return [lod, lease.prefab] as const; + })); + const nextPolicy: CastleLodPolicy = Object.freeze({ + ...requestedCastleLodPolicy, maximumLod: activeLod, selectedMinimumLod: activeLod, highInstanceBudget: activeLod === 'high' @@ -2800,7 +2808,24 @@ function initializeRealmScene( ? 0 : DEFAULT_CASTLE_LOD_POLICY.balancedInstanceBudget }); - options.canvas.dataset.realmCastleActiveLod = activeLod; + const nextLayer = createRealmCastleInstanceLayer({ + castles: authoritativeCastles, + prefabs, + policy: nextPolicy, + dynamicShadows: renderPlan.dynamicShadows + }); + if (cleanup.isDisposed()) { + nextLayer.dispose(); + return false; + } + if (presentedCastleIds !== null) { + nextLayer.setPresentedCastleIds([...presentedCastleIds]); + } + + const previousLayer = castleLayer; + scene.add(nextLayer.group); + castleLayer = nextLayer; + castleLodPolicy = nextPolicy; const nextProjectionEnvelopeByLod = new Map([...prefabs].map(([lod, prefab]) => [ lod, prefab.projectionEnvelope @@ -2809,60 +2834,27 @@ function initializeRealmScene( lod, prefab.renderProjectionEnvelope ] as const)); - let nextLayer: RealmCastleInstanceLayer; - try { - nextLayer = createRealmCastleInstanceLayer({ - castles: authoritativeCastles, - prefabs, - policy: castleLodPolicy, - dynamicShadows: renderPlan.dynamicShadows - }); - } catch (error) { - releaseLeases(leases); - throw error; - } - if (cleanup.isDisposed()) { - try { - nextLayer.dispose(); - } finally { - releaseLeases(leases); - } - return; - } - - if (presentedCastleIds !== null) { - nextLayer.setPresentedCastleIds([...presentedCastleIds]); - } - castleLayer = nextLayer; castleProjectionEnvelopeByLod = nextProjectionEnvelopeByLod; castleRenderEnvelopeByLod = nextRenderEnvelopeByLod; - fallbackCastleProjectionEnvelope = nextProjectionEnvelopeByLod - .get(castleLodPolicy.maximumLod) - ?? nextProjectionEnvelopeByLod.get('compact') + fallbackCastleProjectionEnvelope = nextProjectionEnvelopeByLod.get(activeLod) ?? DEFAULT_CASTLE_PROJECTION_ENVELOPE; - fallbackCastleRenderEnvelope = nextRenderEnvelopeByLod - .get(castleLodPolicy.maximumLod) - ?? nextRenderEnvelopeByLod.get('compact') + fallbackCastleRenderEnvelope = nextRenderEnvelopeByLod.get(activeLod) ?? DEFAULT_CASTLE_PROJECTION_ENVELOPE; - scene.add(nextLayer.group); - let released = false; - cleanup.add(() => { - if (released) return; - released = true; + options.canvas.dataset.realmCastleActiveLod = activeLod; + lastCastleProjectionKey = ''; + lastCastlePresentationTelemetryKey = ''; + activeCastleLod = activeLod; + + if (previousLayer) { + scene.remove(previousLayer.group); try { - scene.remove(nextLayer.group); - } finally { - try { - nextLayer.dispose(); - } finally { - if (castleLayer === nextLayer) castleLayer = null; - releaseLeases(leases); - } + previousLayer.dispose(); + } catch { + // The replacement is already authoritative; keep its live resources. } - }); + } - const focusPrefab = prefabs.get(castleLodPolicy.maximumLod) - ?? prefabs.get('compact'); + const focusPrefab = prefabs.get(activeLod) ?? prefabs.get('compact'); if (focusPrefab) { castleFocusSize = Object.freeze({ height: focusPrefab.visualHeight, @@ -2876,10 +2868,146 @@ function initializeRealmScene( footprintDiameter: focusPrefab.footprintDiameter }); } - options.onKeepStatusChange('ready'); - if (cleanup.isDisposed()) return; - pendingCastlesReadyCount = authoritativeCastles.length; + if (signalReady) { + options.onKeepStatusChange('ready'); + if (cleanup.isDisposed()) return false; + pendingCastlesReadyCount = authoritativeCastles.length; + } else { + options.onCastleLodChange?.(activeLod); + } render(); + return !cleanup.isDisposed(); + }; + + const initializeCastleInstances = async () => { + if (authoritativeCastles.length === 0) { + if (!cleanup.isDisposed()) { + pendingCastlesReadyCount = 0; + render(); + } + return; + } + + const compact = usedCastleLods.includes('compact') ? 'compact' : usedCastleLods[0]; + if (!compact) throw new Error('No compact castle LOD is configured.'); + const acquireWithRetry = async (lod: CastleLod) => { + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + return await (attempt === 0 + ? prefabRepository.acquire(lod, castleLoadAbortController.signal) + : prefabRepository.retryFailed(lod, castleLoadAbortController.signal)); + } catch (error) { + lastError = error; + const failure = classifyRealmRendererFailure( + error, + 'loading', + 'castle-compact-load-failed' + ); + if (lod !== 'compact' || attempt !== 0 || !failure.retryable) break; + // Keep the retry bounded and deterministic. The short yield lets a + // transient browser fetch/decode stall settle without overlapping + // two requests for the same content-addressed lease. + await new Promise((resolve) => window.setTimeout(resolve, 40)); + } + } + throw lastError ?? new Error(`Unable to load castle ${lod} LOD.`); + }; + const optionalLods = usedCastleLods.filter((lod) => lod !== compact); + const optionalStates = new Map( + optionalLods.map((lod) => [lod, 'pending']) + ); + let compactInstalled = false; + const activeRank = () => activeCastleLod === null + ? -1 + : usedCastleLods.indexOf(activeCastleLod); + const highestContiguousReadyLod = () => { + let highest = compact; + for (const lod of optionalLods) { + if (optionalStates.get(lod) !== 'ready') break; + highest = lod; + } + return highest; + }; + const releaseLodsAboveGap = () => { + let gapFound = false; + optionalLods.forEach((lod) => { + if (gapFound && optionalStates.get(lod) === 'ready') { + releaseAcquiredCastleLease(lod); + optionalStates.set(lod, 'failed'); + options.canvas.dataset[`realmCastle${lod}Lod`] = 'unavailable'; + return; + } + if (optionalStates.get(lod) === 'failed') gapFound = true; + }); + }; + const upgradeToHighestContiguousLod = () => { + if (!compactInstalled || cleanup.isDisposed() || contextLost) return; + releaseLodsAboveGap(); + const nextLod = highestContiguousReadyLod(); + if (usedCastleLods.indexOf(nextLod) <= activeRank()) return; + try { + installCastleLayer(nextLod, false); + } catch { + optionalStates.set(nextLod, 'failed'); + options.canvas.dataset[`realmCastle${nextLod}Lod`] = 'unavailable'; + releaseAcquiredCastleLease(nextLod); + releaseLodsAboveGap(); + const fallbackLod = highestContiguousReadyLod(); + if (usedCastleLods.indexOf(fallbackLod) > activeRank()) { + try { + installCastleLayer(fallbackLod, false); + } catch { + optionalStates.set(fallbackLod, 'failed'); + options.canvas.dataset[`realmCastle${fallbackLod}Lod`] = 'unavailable'; + releaseAcquiredCastleLease(fallbackLod); + } + } + } + }; + + // Start the complete family together. Compact alone gates first playable + // presentation; optional LODs upgrade progressively as a contiguous chain + // and can never impose their network timeout on Realm readiness. + optionalLods.forEach((lod) => { + void prefabRepository.acquire(lod, castleLoadAbortController.signal).then((lease) => { + if (cleanup.isDisposed()) { + releaseLeases([lease]); + return; + } + const prerequisiteFailed = lod === 'high' + && optionalStates.get('balanced') === 'failed'; + if (prerequisiteFailed) { + optionalStates.set(lod, 'failed'); + options.canvas.dataset[`realmCastle${lod}Lod`] = 'unavailable'; + releaseLeases([lease]); + return; + } + acquiredCastleLeases.set(lod, lease); + optionalStates.set(lod, 'ready'); + upgradeToHighestContiguousLod(); + }, () => { + if (cleanup.isDisposed()) return; + optionalStates.set(lod, 'failed'); + options.canvas.dataset[`realmCastle${lod}Lod`] = 'unavailable'; + releaseLodsAboveGap(); + upgradeToHighestContiguousLod(); + }); + }); + + const compactLease = await acquireWithRetry(compact); + if (cleanup.isDisposed()) { + releaseLeases([compactLease]); + return; + } + acquiredCastleLeases.set(compact, compactLease); + try { + compactInstalled = installCastleLayer(compact, true); + } catch (error) { + releaseAcquiredCastleLease(compact); + throw error; + } + upgradeToHighestContiguousLod(); }; void initializeCastleInstances().catch((error) => { @@ -2889,12 +3017,11 @@ function initializeRealmScene( } catch { // Renderer fallback still has to engage when a status observer fails. } - options.onRendererFailure?.({ - code: 'castle-compact-load-failed', - retryable: true, - phase: 'loading', - message: error instanceof Error ? error.message : String(error) - }); + options.onRendererFailure?.(classifyRealmRendererFailure( + error, + 'loading', + 'castle-compact-load-failed' + )); options.onRendererUnavailable(); disposeScene(); }); @@ -2980,6 +3107,7 @@ function initializeRealmScene( target: Object.freeze({ ...pose.target }), fov: pose.fov, zoom: cameraController.getZoom(), + controllerState: cameraController.captureState(), selectedTerrainCoord: selectedTerrainCoord ? Object.freeze({ ...selectedTerrainCoord }) : null, @@ -2997,7 +3125,7 @@ function initializeRealmScene( getCameraAttestation, restoreCameraAttestation: (attestation) => { if (cleanup.isDisposed()) return; - cameraController.restorePose?.(attestation); + cameraController.restoreState(attestation.controllerState); }, getSceneBuildSequence: () => sceneBuildSequence, focusCastle: (castleId) => { diff --git a/src/components/realm/hegemonyKeepPrefabRepository.ts b/src/components/realm/hegemonyKeepPrefabRepository.ts index 959f2f36..3e866672 100644 --- a/src/components/realm/hegemonyKeepPrefabRepository.ts +++ b/src/components/realm/hegemonyKeepPrefabRepository.ts @@ -61,10 +61,16 @@ export type HegemonyKeepPrefabLoader = ( export type HegemonyKeepPrefabRepository = Readonly<{ /** - * Coalesces concurrent acquisition. A retired LOD cannot be reacquired in - * the same realm session because its GPU resources have already been freed. + * Coalesces concurrent acquisition. A successfully loaded, released LOD + * cannot be reacquired in the same realm session because its GPU resources + * have already been freed. */ acquire: (lod: CastleLod, signal?: AbortSignal) => Promise; + /** + * Retires one settled failed request before reacquiring it. Concurrent + * retry callers still coalesce onto the single replacement entry. + */ + retryFailed: (lod: CastleLod, signal?: AbortSignal) => Promise; }>; export type CreateHegemonyKeepPrefabRepositoryOptions = Readonly<{ @@ -94,6 +100,7 @@ type InternalPrefab = Readonly<{ type CacheEntry = { activeLeases: number; abortController: AbortController; + failed: boolean; internal?: InternalPrefab; pendingAcquisitions: number; promise: Promise; @@ -547,6 +554,7 @@ export function createHegemonyKeepPrefabRepository( const entry: CacheEntry = { activeLeases: 0, abortController, + failed: false, pendingAcquisitions: 0, promise: Promise.resolve() .then(() => loader(lod, abortController.signal)) @@ -562,6 +570,10 @@ export function createHegemonyKeepPrefabRepository( // Preserve cancellation after best-effort custom-loader cleanup. } throw createRealmLoadAbortError(`Hegemony keep ${lod} prefab`); + }) + .catch((error: unknown) => { + entry.failed = true; + throw error; }), resourcesReleased: false, retired: false @@ -570,61 +582,77 @@ export function createHegemonyKeepPrefabRepository( return entry; }; - return Object.freeze({ - acquire: (lod, signal) => { - if (signal?.aborted) { - return Promise.reject(createRealmLoadAbortError(`Hegemony keep ${lod} prefab`)); - } - const entry = entryFor(lod); - if (entry.retired) { - return Promise.reject(new Error( - `Hegemony keep ${lod} prefab is retired for this realm session.` - )); - } - entry.pendingAcquisitions += 1; - - return new Promise((resolve, reject) => { - let finished = false; - const finishPending = () => { - if (finished) return false; - finished = true; - signal?.removeEventListener('abort', onAbort); - entry.pendingAcquisitions -= 1; - return true; - }; - const onAbort = () => { - if (!finishPending()) return; - retireEntryIfUnused(entry); - reject(createRealmLoadAbortError(`Hegemony keep ${lod} prefab`)); - }; - - signal?.addEventListener('abort', onAbort, { once: true }); - entry.promise.then((internal) => { - if (!finishPending()) return; - // A previous lease or the final pending cancellation can retire a - // resolved entry. Never revive already-freed resources. - if (entry.retired) { - reject(new Error( - `Hegemony keep ${lod} prefab is retired for this realm session.` - )); - return; + const acquire = (lod: CastleLod, signal?: AbortSignal) => { + if (signal?.aborted) { + return Promise.reject(createRealmLoadAbortError(`Hegemony keep ${lod} prefab`)); + } + const entry = entryFor(lod); + if (entry.retired) { + return Promise.reject(new Error( + `Hegemony keep ${lod} prefab is retired for this realm session.` + )); + } + entry.pendingAcquisitions += 1; + + return new Promise((resolve, reject) => { + let finished = false; + const finishPending = () => { + if (finished) return false; + finished = true; + signal?.removeEventListener('abort', onAbort); + entry.pendingAcquisitions -= 1; + return true; + }; + const onAbort = () => { + if (!finishPending()) return; + retireEntryIfUnused(entry); + reject(createRealmLoadAbortError(`Hegemony keep ${lod} prefab`)); + }; + + signal?.addEventListener('abort', onAbort, { once: true }); + entry.promise.then((internal) => { + if (!finishPending()) return; + // A previous lease or the final pending cancellation can retire a + // resolved entry. Never revive already-freed resources. + if (entry.retired) { + reject(new Error( + `Hegemony keep ${lod} prefab is retired for this realm session.` + )); + return; + } + entry.activeLeases += 1; + let released = false; + resolve(Object.freeze({ + prefab: internal.prefab, + release: () => { + if (released) return; + released = true; + entry.activeLeases -= 1; + retireEntryIfUnused(entry); } - entry.activeLeases += 1; - let released = false; - resolve(Object.freeze({ - prefab: internal.prefab, - release: () => { - if (released) return; - released = true; - entry.activeLeases -= 1; - retireEntryIfUnused(entry); - } - })); - }, (error: unknown) => { - if (!finishPending()) return; - reject(error); - }); + })); + }, (error: unknown) => { + if (!finishPending()) return; + reject(error); }); + }); + }; + + return Object.freeze({ + acquire, + retryFailed: (lod, signal) => { + const failed = entries.get(lod); + if ( + failed?.failed === true + && failed.pendingAcquisitions === 0 + && failed.activeLeases === 0 + && failed.internal === undefined + ) { + failed.retired = true; + failed.abortController.abort(); + entries.delete(lod); + } + return acquire(lod, signal); } }); } diff --git a/src/components/realm/realmCameraController.ts b/src/components/realm/realmCameraController.ts index 10e55c57..2002ef5c 100644 --- a/src/components/realm/realmCameraController.ts +++ b/src/components/realm/realmCameraController.ts @@ -879,6 +879,26 @@ type RealmCompositionState = Readonly<{ focusPadding: number; }>; +/** + * Complete camera-controller state that can survive a renderer rebuild. + * + * A Three.js camera pose alone is not sufficient: the next demand frame is + * derived from these current/target values and would otherwise overwrite a + * directly-mutated camera with the new controller's default state. + */ +export type RealmCameraControllerState = Readonly<{ + currentZoom: number; + targetZoom: number; + currentPan: RealmCameraPan; + targetPan: RealmCameraPan; + currentFocus: RealmKeepFocus; + targetFocus: RealmKeepFocus; + currentComposition: RealmCameraComposition; + targetComposition: RealmCameraComposition; + targetFocusIsKeep: boolean; + manualPlanarControl: boolean; +}>; + function compositionState(composition: RealmCameraComposition = {}): RealmCompositionState { const insets = normalizeInsets(composition.insets); const safeAreaInsets = normalizeInsets(composition.safeAreaInsets); @@ -963,6 +983,7 @@ export type RealmCameraController = Readonly<{ frameAt: (focus: RealmKeepFocus, zoom: number) => void; focusAt: (focus: RealmKeepFocus) => void; focusKeep: () => void; + captureState: () => RealmCameraControllerState; getMode: () => RealmCameraMode; getPose: () => RealmCameraPose; getSafeViewport: () => RealmSafeViewport; @@ -983,11 +1004,7 @@ export type RealmCameraController = Readonly<{ panByPixels: (deltaX: number, deltaY: number) => void; projectPoint: (point: RealmCameraPoint) => RealmScreenProjection; recenterKeep: () => void; - restorePose?: (pose: Readonly<{ - position: RealmCameraPoint; - target: RealmCameraPoint; - fov: number; - }>) => void; + restoreState: (state: RealmCameraControllerState) => void; setComposition: (composition: RealmCameraComposition) => void; setKeepFocus: (focus: RealmKeepFocus) => void; setViewport: (width: number, height: number) => void; @@ -1508,6 +1525,18 @@ export function createRealmCameraController( return { beginDirectManipulation, camera, + captureState: () => Object.freeze({ + currentZoom, + targetZoom, + currentPan: Object.freeze({ ...currentPan }), + targetPan: Object.freeze({ ...targetPan }), + currentFocus: Object.freeze({ ...currentFocus }), + targetFocus: Object.freeze({ ...targetFocus }), + currentComposition: Object.freeze(stateComposition(currentComposition)), + targetComposition: Object.freeze(stateComposition(targetComposition)), + targetFocusIsKeep, + manualPlanarControl + }), dispose: () => { disposed = true; if (frame) window.cancelAnimationFrame(frame); @@ -1587,15 +1616,34 @@ export function createRealmCameraController( zoomAnchor = null; invalidate(); }, - restorePose: (pose) => { + restoreState: (state) => { if (disposed) return; - camera.position.set(pose.position.x, pose.position.y, pose.position.z); - targetVector.set(pose.target.x, pose.target.y, pose.target.z); - camera.fov = clamp(finite(pose.fov, camera.fov), 1, 120); - camera.lookAt(targetVector); - camera.updateProjectionMatrix(); - camera.updateMatrixWorld(true); + if (frame) window.cancelAnimationFrame(frame); + frame = 0; + previousTime = 0; + currentZoom = clamp(finite(state.currentZoom, currentZoom), 0, 1); + targetZoom = clamp(finite(state.targetZoom, targetZoom), 0, 1); + currentPan = { + x: finite(state.currentPan.x, currentPan.x), + z: finite(state.currentPan.z, currentPan.z) + }; + targetPan = { + x: finite(state.targetPan.x, targetPan.x), + z: finite(state.targetPan.z, targetPan.z) + }; + currentFocus = normalizeKeepFocus(state.currentFocus, currentFocus); + targetFocus = normalizeKeepFocus(state.targetFocus, targetFocus); + currentComposition = compositionState(state.currentComposition); + targetComposition = compositionState(state.targetComposition); + targetFocusIsKeep = state.targetFocusIsKeep === true; + manualPlanarControl = state.manualPlanarControl === true; + // Pointer capture cannot survive a disposed canvas. Preserve the + // camera trajectory, but resume it as an ordinary demand transition. + directManipulation = false; + zoomAnchor = null; + applyPose(); options.render(); + if (isUnsettled()) invalidate(); }, setComposition: (next) => { // Insets change the projection beneath a screen-space zoom anchor. Drop diff --git a/src/components/realm/realmCastlePresentation.ts b/src/components/realm/realmCastlePresentation.ts index a35f29d5..9fbaa171 100644 --- a/src/components/realm/realmCastlePresentation.ts +++ b/src/components/realm/realmCastlePresentation.ts @@ -287,6 +287,7 @@ export function fallbackCastleProjection( const centerX = contentLeft + (world.x - viewBox.x) * scale; const centerY = contentTop + (-world.z - viewBox.y) * scale; const markerHalfSize = Math.max(10, Math.abs(scale) * 0.64); + const visible = fallbackCastleIsInViewBox(castle, viewBox); const castleBounds = { left: centerX - markerHalfSize, top: centerY - markerHalfSize, @@ -298,8 +299,19 @@ export function fallbackCastleProjection( x: centerX, y: centerY + markerHalfSize + CASTLE_LABEL_GAP_PIXELS, distance: hexDistance({ q: 0, r: 0 }, castle), - visible: true, + visible, castleBounds, conservativeCastleBounds: castleBounds }; } + +export function fallbackCastleIsInViewBox( + castle: Readonly<{ q: number; r: number }>, + viewBox: Readonly<{ x: number; y: number; width: number; height: number }> +) { + const world = axialToWorld(castle, 1); + return world.x >= viewBox.x + && world.x <= viewBox.x + viewBox.width + && -world.z >= viewBox.y + && -world.z <= viewBox.y + viewBox.height; +} diff --git a/src/components/realm/realmRendererRecovery.ts b/src/components/realm/realmRendererRecovery.ts index f8eb83ec..4fd4aa13 100644 --- a/src/components/realm/realmRendererRecovery.ts +++ b/src/components/realm/realmRendererRecovery.ts @@ -139,7 +139,11 @@ export function classifyRealmRendererFailure( return Object.freeze({ code, message, - retryable: !['castle-integrity-failed', 'castle-pairing-failed'].includes(code), + retryable: ![ + 'castle-integrity-failed', + 'castle-pairing-failed', + 'castle-prefab-assembly-failed' + ].includes(code), phase }); } diff --git a/src/settings/graphicsPreference.ts b/src/settings/graphicsPreference.ts index 17ae3214..785ce60c 100644 --- a/src/settings/graphicsPreference.ts +++ b/src/settings/graphicsPreference.ts @@ -35,14 +35,16 @@ let cachedWebGL2Capability: WebGL2Capability | undefined; * never consume or deliberately lose the context that a real scene needs. */ export function probeWebGL2Capability(): WebGL2Capability { - if (cachedWebGL2Capability !== undefined) return cachedWebGL2Capability; + // A successful probe is stable enough to share across title and Realm. + // A negative probe can be transient (context pressure, background restore, + // browser lifecycle), so leave it uncached for an explicit/manual retry. + if (cachedWebGL2Capability?.available === true) return cachedWebGL2Capability; if (typeof document === 'undefined') return Object.freeze({ available: false }); try { const canvas = document.createElement('canvas'); const context = canvas.getContext('webgl2'); if (!context) { - cachedWebGL2Capability = Object.freeze({ available: false }); - return cachedWebGL2Capability; + return Object.freeze({ available: false }); } let maxTextureSize: number | undefined; try { @@ -56,9 +58,9 @@ export function probeWebGL2Capability(): WebGL2Capability { } cachedWebGL2Capability = Object.freeze({ available: true, maxTextureSize }); } catch { - cachedWebGL2Capability = Object.freeze({ available: false }); + return Object.freeze({ available: false }); } - return cachedWebGL2Capability; + return cachedWebGL2Capability ?? Object.freeze({ available: false }); } /** Test-only reset; production callers should retain the shared probe cache. */ diff --git a/tests/graphicsPreference.test.ts b/tests/graphicsPreference.test.ts index ec38300e..286b59d1 100644 --- a/tests/graphicsPreference.test.ts +++ b/tests/graphicsPreference.test.ts @@ -46,6 +46,23 @@ describe('graphics preference', () => { expect(context.getExtension).not.toHaveBeenCalled(); }); + it('reprobes a transient negative WebGL2 result and caches only recovery success', () => { + const context = { + MAX_TEXTURE_SIZE: 0x0d33, + getParameter: vi.fn(() => 8_192) + }; + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValueOnce(null) + .mockImplementation(((contextId: string) => ( + contextId === 'webgl2' ? context : null + )) as typeof HTMLCanvasElement.prototype.getContext); + + expect(probeWebGL2Capability()).toEqual({ available: false }); + expect(probeWebGL2Capability()).toEqual({ available: true, maxTextureSize: 8_192 }); + expect(probeWebGL2Capability()).toEqual({ available: true, maxTextureSize: 8_192 }); + expect(getContext).toHaveBeenCalledTimes(2); + }); + it('validates and persists only the versioned visual preference', () => { const storage = memoryStorage(); expect(parseGraphicsPreference('obsolete')).toBe(DEFAULT_GRAPHICS_PREFERENCE); diff --git a/tests/hegemonyKeepPrefabRepository.test.ts b/tests/hegemonyKeepPrefabRepository.test.ts index 658e6de3..82005141 100644 --- a/tests/hegemonyKeepPrefabRepository.test.ts +++ b/tests/hegemonyKeepPrefabRepository.test.ts @@ -466,4 +466,28 @@ describe('Hegemony keep prefab repository', () => { await expect(repository.acquire('compact')).rejects.toBe(failure); expect(loader).toHaveBeenCalledTimes(1); }); + + it('replaces a settled failed entry only for an explicit coalesced retry', async () => { + const failure = new Error('transient compact transport failure'); + const root = new THREE.Group(); + root.add(new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + )); + const loader: HegemonyKeepPrefabLoader = vi.fn() + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce(loadedKeep(root, 'compact')); + const repository = createHegemonyKeepPrefabRepository({ loader }); + + await expect(repository.acquire('compact')).rejects.toBe(failure); + const [first, second] = await Promise.all([ + repository.retryFailed('compact'), + repository.retryFailed('compact') + ]); + + expect(loader).toHaveBeenCalledTimes(2); + expect(first.prefab).toBe(second.prefab); + first.release(); + second.release(); + }); }); diff --git a/tests/realmCameraController.test.ts b/tests/realmCameraController.test.ts index c5f9e32e..4b0a1f48 100644 --- a/tests/realmCameraController.test.ts +++ b/tests/realmCameraController.test.ts @@ -832,4 +832,61 @@ describe('realm perspective camera math', () => { expect(scheduled.size).toBe(1); controller.dispose(); }); + + it('restores complete controller state so projections and future frames survive a rebuild', () => { + let nextFrame = 1; + const scheduled = new Map(); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + const id = nextFrame; + nextFrame += 1; + scheduled.set(id, callback); + return id; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { + scheduled.delete(id); + }); + const createController = () => createRealmCameraController({ + bounds: BOUNDS, + keepFocus: KEEP, + fog: new THREE.Fog('#a6bcaf', 1, 2), + reducedMotion: false, + render: vi.fn() + }); + const source = createController(); + source.setViewport(1_280, 720); + source.setComposition({ + insets: { top: 18, right: 260, bottom: 72, left: 180 }, + focusPadding: 20 + }); + source.focusAt(SELECTED_CASTLE); + const firstSourceFrame = [...scheduled.entries()].at(-1); + expect(firstSourceFrame).toBeDefined(); + scheduled.delete(firstSourceFrame![0]); + firstSourceFrame![1](16); + + const state = source.captureState(); + const expectedPose = source.getPose(); + const expectedProjection = source.projectPoint({ x: 1.1, y: 0, z: -0.7 }); + source.dispose(); + + const restored = createController(); + restored.setViewport(1_280, 720); + restored.restoreState(state); + + expect(restored.getZoom()).toBe(state.targetZoom); + expect(restored.getMode()).toBe(expectedPose.mode); + expect(restored.getPose().position).toEqual(expectedPose.position); + expect(restored.getPose().target).toEqual(expectedPose.target); + expect(restored.getPose().safeViewport).toEqual(expectedPose.safeViewport); + expect(restored.projectPoint({ x: 1.1, y: 0, z: -0.7 })).toEqual(expectedProjection); + + const restoredFrame = [...scheduled.entries()].at(-1); + expect(restoredFrame).toBeDefined(); + const distanceBeforeFrame = restored.getPose().distance; + scheduled.delete(restoredFrame![0]); + restoredFrame![1](32); + expect(restored.getPose().distance).not.toBe(distanceBeforeFrame); + expect(restored.getPose().focus.x).toBeGreaterThan(expectedPose.focus.x); + restored.dispose(); + }); }); diff --git a/tests/realmCastlePresentation.test.ts b/tests/realmCastlePresentation.test.ts index 043093e5..f20ce43d 100644 --- a/tests/realmCastlePresentation.test.ts +++ b/tests/realmCastlePresentation.test.ts @@ -369,6 +369,20 @@ describe('realm castle public presentation', () => { expect(projection.conservativeCastleBounds).toEqual(projection.castleBounds); }); + it('marks castles outside the bounded fallback SVG crop as projection-ineligible', () => { + const viewBox = { x: -4, y: -4, width: 8, height: 8 }; + expect(fallbackCastleProjection( + { castleId: 1, q: 0, r: 0 }, + viewBox, + { width: 800, height: 600 } + ).visible).toBe(true); + expect(fallbackCastleProjection( + { castleId: 2, q: 20, r: 20 }, + viewBox, + { width: 800, height: 600 } + ).visible).toBe(false); + }); + it('anchors labels below the calibrated foundation while retaining conservative bounds separately', () => { const conservative = { left: 360, top: 180, right: 440, bottom: 300 }; const bounds = resolveCastleLabelOcclusionBounds(conservative, 240); diff --git a/tests/realmMapScreen.test.tsx b/tests/realmMapScreen.test.tsx index 1d67875d..4750ff5a 100644 --- a/tests/realmMapScreen.test.tsx +++ b/tests/realmMapScreen.test.tsx @@ -179,7 +179,7 @@ describe('RealmMapScreen', () => { expect(screen.getByRole('button', { name: 'CLOSE RECORD' })).toBe(document.activeElement); }); - it('renders every 100-castle fallback marker with complete direct-label coverage', async () => { + it('keeps fallback markers and labels bounded to the same cropped region', async () => { const realm = createRenderedWebglQaFixtureRealm(); const { container } = renderFallbackRealm(realm); const markers = container.querySelectorAll( @@ -190,14 +190,20 @@ describe('RealmMapScreen', () => { ); expect(realm.snapshot.castles).toHaveLength(100); - expect(markers).toHaveLength(realm.snapshot.castles.length); - expect(markerCastleIds.size).toBe(realm.snapshot.castles.length); + expect(markers.length).toBeGreaterThan(0); + expect(markers.length).toBeLessThan(realm.snapshot.castles.length); + expect(markerCastleIds.size).toBe(markers.length); await waitFor(() => { const map = screen.getByRole('main', { name: 'Hegemony realm' }); - expect(map.getAttribute('data-label-placed-count')).toBe('100'); - expect(map.getAttribute('data-label-unplaced-count')).toBe('0'); + const placed = Number(map.getAttribute('data-label-placed-count')); + expect(placed).toBeGreaterThan(0); + expect(placed).toBeLessThanOrEqual(markers.length); expect(map.getAttribute('data-label-clustered-count')).toBe('0'); }); + const labelCastleIds = [...container.querySelectorAll( + '.realm-castle-label[data-castle-id]' + )].map((label) => label.dataset.castleId); + expect(labelCastleIds.every((castleId) => markerCastleIds.has(castleId))).toBe(true); }); it('changes camera presets without opening a castle record', async () => { diff --git a/tests/realmRendererRecovery.test.ts b/tests/realmRendererRecovery.test.ts index 4a35705d..44520183 100644 --- a/tests/realmRendererRecovery.test.ts +++ b/tests/realmRendererRecovery.test.ts @@ -98,10 +98,17 @@ describe('Realm renderer recovery lifecycle', () => { }); it('classifies integrity and pairing failures as explicit non-retryable failures', () => { - expect(classifyRealmRendererFailure(new Error('sha256 integrity mismatch'), 'loading').code) - .toBe('castle-integrity-failed'); + expect(classifyRealmRendererFailure(new Error('sha256 integrity mismatch'), 'loading')) + .toMatchObject({ code: 'castle-integrity-failed', retryable: false }); expect(classifyRealmRendererFailure(new Error('landscape base pairing failed'), 'loading').code) .toBe('castle-pairing-failed'); + expect(classifyRealmRendererFailure( + new Error('Hegemony keep compact prefab contains no renderable meshes.'), + 'loading' + )).toMatchObject({ + code: 'castle-prefab-assembly-failed', + retryable: false + }); expect(classifyRealmRendererFailure(new Error('request timed out'), 'loading').code) .toBe('castle-compact-load-failed'); }); diff --git a/tests/realmRendererRecoveryUi.test.tsx b/tests/realmRendererRecoveryUi.test.tsx new file mode 100644 index 00000000..effe4d81 --- /dev/null +++ b/tests/realmRendererRecoveryUi.test.tsx @@ -0,0 +1,130 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const sceneState = vi.hoisted(() => ({ + create: vi.fn(), + webglAvailable: true +})); + +vi.mock('../src/components/realm/createRealmScene', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createRealmScene: sceneState.create }; +}); + +vi.mock('../src/components/realm/realmMapPresentationHelpers', async (importOriginal) => { + const actual = await importOriginal< + typeof import('../src/components/realm/realmMapPresentationHelpers') + >(); + return { ...actual, canUseWebGL: () => sceneState.webglAvailable }; +}); + +import { RealmMapScreen } from '../src/components/realm/RealmMapScreen'; +import type { CreateRealmSceneOptions } from '../src/components/realm/createRealmScene'; +import { createCanonicalGenesisSnapshot, CANONICAL_TEST_FID } from './fixtures/canonicalGenesisSnapshot'; +import { createReadyResourceState } from './fixtures/resourceState'; + +function sceneHandle() { + const noOp = () => undefined; + return { + dispose: vi.fn(), + reconcileLiveGatheringState: noOp, + getCameraAttestation: () => null, + getSceneBuildSequence: () => 1, + focusCastle: noOp, + focusCell: noOp, + frameFoundingDistrict: noOp, + focusKeep: noOp, + recenterKeep: noOp, + setHovered: noOp, + setPresentedCastleIds: noOp, + setSelected: noOp, + setSelectedCastleId: noOp, + setSelectedGoldSiteId: noOp, + setSelectedFoodSiteId: noOp, + setSelectedWoodSiteId: noOp, + setSelectedStoneSiteId: noOp, + setComposition: noOp, + showRealm: noOp + }; +} + +describe('Realm renderer recovery UI', () => { + beforeEach(() => { + sceneState.create.mockReset(); + sceneState.create.mockImplementation(() => sceneHandle()); + sceneState.webglAvailable = true; + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('offers a functional manual retry after context restoration times out', () => { + vi.useFakeTimers(); + const snapshot = createCanonicalGenesisSnapshot(CANONICAL_TEST_FID); + const onRequestReturn = vi.fn(); + render( + + ); + expect(sceneState.create).toHaveBeenCalledOnce(); + const firstOptions = sceneState.create.mock.calls[0]![0] as CreateRealmSceneOptions; + + act(() => firstOptions.onCastlesReady?.(snapshot.castles.length)); + const realm = screen.getByRole('main', { name: 'Hegemony realm' }); + expect(realm.getAttribute('data-renderer-state')).toBe('ready'); + expect(realm.getAttribute('aria-busy')).toBe('false'); + + act(() => firstOptions.onRendererFailure?.({ + code: 'context-lost', + retryable: true, + phase: 'ready' + })); + expect(screen.getByRole('status').textContent).toMatch(/RESTORING THE REALM/i); + expect(realm.getAttribute('aria-busy')).toBe('true'); + act(() => firstOptions.onCastleLodChange?.('high')); + expect(realm.getAttribute('data-renderer-state')).toBe('recovering'); + + act(() => vi.advanceTimersByTime(8_000)); + expect(screen.getByRole('alert').textContent).toMatch(/THE REALM COULD NOT BE RESTORED/i); + expect(realm.getAttribute('aria-busy')).toBe('false'); + const retry = screen.getByRole('button', { name: 'Retry 3D Realm' }); + fireEvent.click(screen.getByRole('button', { name: 'Return to Menu' })); + expect(onRequestReturn).toHaveBeenCalledOnce(); + + fireEvent.click(retry); + expect(sceneState.create).toHaveBeenCalledTimes(2); + expect(realm.getAttribute('data-renderer-state')).toBe('loading'); + expect(realm.getAttribute('aria-busy')).toBe('true'); + }); + + it('lets a transient negative WebGL probe recover through explicit retry', () => { + sceneState.webglAvailable = false; + const snapshot = createCanonicalGenesisSnapshot(CANONICAL_TEST_FID); + render( + + ); + + const realm = screen.getByRole('main', { name: 'Hegemony realm' }); + expect(realm.getAttribute('data-renderer-state')).toBe('static-unsupported'); + expect(realm.getAttribute('aria-busy')).toBe('false'); + expect(sceneState.create).not.toHaveBeenCalled(); + + sceneState.webglAvailable = true; + fireEvent.click(screen.getByRole('button', { name: 'Retry 3D Realm' })); + expect(sceneState.create).toHaveBeenCalledOnce(); + expect(realm.getAttribute('data-renderer-state')).toBe('loading'); + expect(realm.getAttribute('aria-busy')).toBe('true'); + }); +}); diff --git a/tests/realmSceneCleanup.test.ts b/tests/realmSceneCleanup.test.ts index 18504618..e7edd29c 100644 --- a/tests/realmSceneCleanup.test.ts +++ b/tests/realmSceneCleanup.test.ts @@ -164,6 +164,7 @@ function dispatchPointer( buttons: { value: input.buttons ?? (type === 'pointerup' ? 0 : 1) } }); target.dispatchEvent(event); + return event; } function createOptions( @@ -922,7 +923,19 @@ describe('realm scene setup cleanup', () => { }); it('suspends input and ambience during context loss, then reports restoration', () => { + const root = document.createElement('main'); + root.className = 'realm-map-screen'; const canvas = document.createElement('canvas'); + const castleLabel = document.createElement('button'); + castleLabel.className = 'realm-castle-label'; + const overlayRetry = document.createElement('button'); + overlayRetry.className = 'realm-map-screen__retry'; + root.append(canvas, castleLabel, overlayRetry); + document.body.append(root); + const castleLabelClick = vi.fn(); + const overlayClick = vi.fn(); + castleLabel.addEventListener('click', castleLabelClick); + overlayRetry.addEventListener('click', overlayClick); const onRendererFailure = vi.fn(); const onRendererContextRestored = vi.fn(); const onRendererUnavailable = vi.fn(); @@ -954,11 +967,28 @@ describe('realm scene setup cleanup', () => { canvas.dispatchEvent(wheel); expect(wheel.defaultPrevented).toBe(true); + const canvasPointer = dispatchPointer(canvas, 'pointerdown', { + pointerId: 81, + clientX: 30, + clientY: 30 + }); + expect(canvasPointer.defaultPrevented).toBe(true); + const labelClick = new MouseEvent('click', { bubbles: true, cancelable: true }); + castleLabel.dispatchEvent(labelClick); + expect(labelClick.defaultPrevented).toBe(true); + expect(castleLabelClick).not.toHaveBeenCalled(); + const overlayPointer = new Event('pointerdown', { bubbles: true, cancelable: true }); + overlayRetry.dispatchEvent(overlayPointer); + expect(overlayPointer.defaultPrevented).toBe(false); + overlayRetry.click(); + expect(overlayClick).toHaveBeenCalledOnce(); + canvas.dispatchEvent(new Event('webglcontextrestored')); expect(canvas.dataset.realmRendererContextLost).toBe('false'); expect(canvas.dataset.realmRendererContextRestoreCount).toBe('1'); expect(onRendererContextRestored).toHaveBeenCalledOnce(); scene.dispose(); + root.remove(); }); it('aborts a pending castle-family load when the Realm unmounts', async () => { @@ -1069,6 +1099,120 @@ describe('realm scene setup cleanup', () => { expect(materialDispose).toHaveBeenCalledTimes(1); }); + it('starts optional castle LODs concurrently without gating Compact readiness', async () => { + const resolvers = new Map void>(); + keepLoadState.load.mockImplementation((input: unknown) => { + const quality = (input as { quality: { id: string } }).quality.id; + return new Promise((resolve) => { + resolvers.set(quality, resolve); + }); + }); + const canvas = document.createElement('canvas'); + const onCastlesReady = vi.fn(); + const scene = createRealmScene(createOptions(canvas, { + quality: REALM_QUALITY_SPECS.high, + onCastlesReady + })); + + await vi.waitFor(() => expect(keepLoadState.load).toHaveBeenCalledTimes(3)); + expect(new Set(keepLoadState.load.mock.calls.map(([input]) => ( + (input as { quality: { id: string } }).quality.id + )))).toEqual(new Set(['reduced', 'balanced', 'high'])); + const compactRoot = new THREE.Group(); + compactRoot.add(new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + )); + resolvers.get('reduced')?.(loadedCastleAssembly(compactRoot, 'compact')); + + await vi.waitFor(() => expect(onCastlesReady).toHaveBeenCalledWith(1)); + expect(canvas.dataset.realmCastleActiveLod).toBe('compact'); + scene.dispose(); + }); + + it('keeps the Realm ready at Compact and releases High when Balanced fails', async () => { + const resolvers = new Map void>(); + const rejecters = new Map void>(); + keepLoadState.load.mockImplementation((input: unknown) => { + const quality = (input as { quality: { id: string } }).quality.id; + return new Promise((resolve, reject) => { + resolvers.set(quality, resolve); + rejecters.set(quality, reject); + }); + }); + const highGeometry = new THREE.BoxGeometry(1, 1, 1); + const highGeometryDispose = vi.spyOn(highGeometry, 'dispose'); + const highRoot = new THREE.Group(); + highRoot.add(new THREE.Mesh(highGeometry, new THREE.MeshBasicMaterial())); + const compactRoot = new THREE.Group(); + compactRoot.add(new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + )); + const canvas = document.createElement('canvas'); + const onCastlesReady = vi.fn(); + const onRendererUnavailable = vi.fn(); + const scene = createRealmScene(createOptions(canvas, { + quality: REALM_QUALITY_SPECS.high, + onCastlesReady, + onRendererUnavailable + })); + + await vi.waitFor(() => expect(keepLoadState.load).toHaveBeenCalledTimes(3)); + resolvers.get('high')?.(loadedCastleAssembly(highRoot, 'high')); + await Promise.resolve(); + rejecters.get('balanced')?.(new Error('synthetic Balanced transport failure')); + resolvers.get('reduced')?.(loadedCastleAssembly(compactRoot, 'compact')); + + await vi.waitFor(() => expect(onCastlesReady).toHaveBeenCalledWith(1)); + await vi.waitFor(() => expect(highGeometryDispose).toHaveBeenCalledOnce()); + expect(canvas.dataset.realmCastleActiveLod).toBe('compact'); + expect(canvas.dataset.realmCastlebalancedLod).toBe('unavailable'); + expect(canvas.dataset.realmCastlehighLod).toBe('unavailable'); + expect(onRendererUnavailable).not.toHaveBeenCalled(); + scene.dispose(); + }); + + it('genuinely reloads Compact once after a cached retryable rejection', async () => { + const compactRoot = new THREE.Group(); + compactRoot.add(new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + )); + keepLoadState.load + .mockRejectedValueOnce(new Error('synthetic request timed out')) + .mockResolvedValueOnce(loadedCastleAssembly(compactRoot, 'compact')); + const onCastlesReady = vi.fn(); + const onRendererFailure = vi.fn(); + const scene = createRealmScene(createOptions(document.createElement('canvas'), { + onCastlesReady, + onRendererFailure + })); + + await vi.waitFor(() => expect(onCastlesReady).toHaveBeenCalledWith(1)); + expect(keepLoadState.load).toHaveBeenCalledTimes(2); + expect(onRendererFailure).not.toHaveBeenCalled(); + scene.dispose(); + }); + + it('does not retry or blur a Compact integrity failure into a transport code', async () => { + keepLoadState.load.mockRejectedValue(new Error('sha256 integrity mismatch')); + const onRendererFailure = vi.fn(); + const onRendererUnavailable = vi.fn(); + const scene = createRealmScene(createOptions(document.createElement('canvas'), { + onRendererFailure, + onRendererUnavailable + })); + + await vi.waitFor(() => expect(onRendererUnavailable).toHaveBeenCalledOnce()); + expect(keepLoadState.load).toHaveBeenCalledOnce(); + expect(onRendererFailure).toHaveBeenCalledWith(expect.objectContaining({ + code: 'castle-integrity-failed', + retryable: false + })); + scene.dispose(); + }); + it('marks a direct label visible only after the live instance frustum admits its model', async () => { let resolveLoad: ((value: unknown) => void) | undefined; keepLoadState.load.mockImplementation(() => new Promise((resolve) => { From 8f71132ee302bdc69df1fa4ee09af7a8b67ea0c5 Mon Sep 17 00:00:00 2001 From: ael-dev3 Date: Wed, 22 Jul 2026 12:58:24 +0200 Subject: [PATCH 05/19] Add production document content security policy --- index.html | 5 +++++ scripts/verify-production-dist-exclusions.mjs | 21 +++++++++++++++++++ .../realmObserverProductionExclusion.test.ts | 2 ++ tests/warpkeepRecovery.test.tsx | 12 +++++++++++ vite.config.ts | 19 ++++++++++++++++- 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 41d3e697..35583299 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,11 @@ + { @@ -67,6 +78,16 @@ function filesUnder(directory) { }); } +const productionIndex = readFileSync(resolve(dist, 'index.html'), 'utf8'); +for (const fragment of requiredProductionCspFragments) { + if (!productionIndex.includes(fragment)) { + throw new Error(`Production document CSP is missing ${JSON.stringify(fragment)}.`); + } +} +if (/script-src[^;]*'unsafe-eval'/.test(productionIndex)) { + throw new Error('Production document CSP permits unrestricted unsafe-eval.'); +} + for (const path of filesUnder(dist)) { const relativePath = relative(dist, path).replaceAll('\\', '/'); if (forbiddenPathFragments.some((fragment) => relativePath.includes(fragment))) { diff --git a/tests/realmObserverProductionExclusion.test.ts b/tests/realmObserverProductionExclusion.test.ts index 54326150..bf34e14e 100644 --- a/tests/realmObserverProductionExclusion.test.ts +++ b/tests/realmObserverProductionExclusion.test.ts @@ -159,6 +159,8 @@ describe('local QA production exclusion', () => { expect(runtimeGate).toContain("new Set(['localhost', '127.0.0.1', '::1', '[::1]'])"); expect(viteConfig).toContain("input: resolve(process.cwd(), 'index.html')"); expect(viteConfig).toContain("__WARPKEEP_LOCAL_QA__: JSON.stringify(command === 'serve')"); + expect(viteConfig).toContain('warpkeep-local-serve-csp-boundary'); + expect(viteConfig).toContain('data-warpkeep-production-csp'); expect(existsSync(resolve(root, 'dev/realm-qa.html'))).toBe(false); expect(existsSync(resolve(root, 'src/dev/RealmQaHarness.tsx'))).toBe(false); expect(packageJson.scripts.build).toContain('verify-production-dist-exclusions.mjs'); diff --git a/tests/warpkeepRecovery.test.tsx b/tests/warpkeepRecovery.test.tsx index 288d0f9e..592de6b9 100644 --- a/tests/warpkeepRecovery.test.tsx +++ b/tests/warpkeepRecovery.test.tsx @@ -127,6 +127,9 @@ describe('Warpkeep document fallback', () => { const parsed = new DOMParser().parseFromString(indexHtml, 'text/html'); const root = parsed.querySelector('#root'); const status = root?.querySelector('[role="status"]'); + const contentSecurityPolicy = parsed.querySelector( + 'meta[http-equiv="Content-Security-Policy"]' + )?.getAttribute('content'); expect(root?.querySelector('.warpkeep-boot')).not.toBeNull(); expect(root?.querySelector('#warpkeep-boot-title')).toBeNull(); @@ -138,6 +141,15 @@ describe('Warpkeep document fallback', () => { expect(indexHtml).toContain(''); expect(indexHtml).toContain('href="/favicon.svg"'); expect(mainSource).toContain('WARPKEEP_ROOT_ERROR_HANDLERS'); + expect(contentSecurityPolicy).toContain("default-src 'none'"); + expect(contentSecurityPolicy).toContain("script-src 'self' 'wasm-unsafe-eval'"); + expect(contentSecurityPolicy).toContain("script-src-attr 'none'"); + expect(contentSecurityPolicy).not.toContain("'unsafe-eval'"); + expect(contentSecurityPolicy).toContain("object-src 'none'"); + expect(contentSecurityPolicy).toContain("frame-src 'none'"); + expect(contentSecurityPolicy).toContain("form-action 'none'"); + expect(contentSecurityPolicy).toContain('wss://maincloud.spacetimedb.com'); + expect(parsed.querySelector('[data-warpkeep-production-csp]')).not.toBeNull(); }); it('provides actionable no-JavaScript guidance and motion-safe styling', () => { diff --git a/vite.config.ts b/vite.config.ts index b6534008..392167e7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -68,6 +68,19 @@ export function resolveDeploymentBase(environment: DeploymentEnvironment = proce const deploymentBase = resolveDeploymentBase(); const productVersion = readWarpkeepPackageVersion(); +function stripProductionCspFromLocalServe() { + return { + name: 'warpkeep-local-serve-csp-boundary', + apply: 'serve' as const, + transformIndexHtml(html: string) { + return html.replace( + /\s*/, + '' + ); + } + }; +} + export default defineConfig(({ command }) => ({ base: deploymentBase, build: { @@ -86,7 +99,11 @@ export default defineConfig(({ command }) => ({ __WARPKEEP_LOCAL_QA__: JSON.stringify(command === 'serve'), __WARPKEEP_PRODUCT_VERSION__: JSON.stringify(productVersion) }, - plugins: [react()], + // Vite's React development preamble is an inline module. The public build + // keeps the strict document CSP, while localhost development removes only + // that explicitly marked production meta element. Dedicated QA entries + // retain their own loopback-only CSPs. + plugins: [react(), stripProductionCspFromLocalServe()], test: { environment: 'jsdom', globals: true, From 34c66d6cf4be424b55b91ffda05714129e6f0c66 Mon Sep 17 00:00:00 2001 From: ael-dev3 Date: Wed, 22 Jul 2026 12:58:35 +0200 Subject: [PATCH 06/19] Pin production runtime to immutable database identity --- .env.example | 2 +- .github/workflows/deploy-pages.yml | 2 +- scripts/validate-pages-deploy-config.mjs | 2 +- services/auth-bridge/README.md | 2 +- services/auth-bridge/src/config.ts | 7 +++++-- .../auth-bridge/src/spacetimeAuthEpochResolver.ts | 2 +- services/auth-bridge/test/app.test.ts | 11 ++++++++--- services/auth-bridge/test/qaObserver.test.ts | 7 ++++--- services/auth-bridge/wrangler.toml | 2 +- src/spacetime/warpkeepConfig.ts | 8 ++++++-- tests/WarpkeepExperienceRealm.test.tsx | 3 ++- tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx | 7 +++++-- tests/WarpkeepSpacetimeResources.test.tsx | 7 +++++-- tests/WarpkeepSpacetimeTermsGate.test.tsx | 7 +++++-- tests/pagesDeployConfig.test.ts | 4 ++-- tests/warpkeepConfig.test.ts | 4 ++++ tests/warpkeepConnection.test.ts | 6 ++++-- 17 files changed, 56 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index 0b379543..f6288928 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ # bridge value are valid. No VITE_ variable may contain a secret. VITE_WARPKEEP_SHARED_ALPHA_ENABLED=false VITE_SPACETIMEDB_URI=https://maincloud.spacetimedb.com -VITE_SPACETIMEDB_DATABASE=warpkeep-89e4u +VITE_SPACETIMEDB_DATABASE=c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e VITE_WARPKEEP_AUTH_BRIDGE_URL= VITE_WARPKEEP_OIDC_ISSUER= VITE_WARPKEEP_OIDC_AUDIENCE=warpkeep-spacetimedb diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index e3e995d1..0c8546d8 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -31,7 +31,7 @@ jobs: VITE_WARPKEEP_OIDC_ISSUER: ${{ vars.WARPKEEP_OIDC_ISSUER || '' }} VITE_WARPKEEP_OIDC_AUDIENCE: ${{ vars.WARPKEEP_OIDC_AUDIENCE || 'warpkeep-spacetimedb' }} VITE_SPACETIMEDB_URI: ${{ vars.WARPKEEP_SPACETIMEDB_URI || 'https://maincloud.spacetimedb.com' }} - VITE_SPACETIMEDB_DATABASE: ${{ vars.WARPKEEP_SPACETIMEDB_DATABASE || 'warpkeep-89e4u' }} + VITE_SPACETIMEDB_DATABASE: ${{ vars.WARPKEEP_SPACETIMEDB_DATABASE || 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e' }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/scripts/validate-pages-deploy-config.mjs b/scripts/validate-pages-deploy-config.mjs index e697c132..2a543f3d 100644 --- a/scripts/validate-pages-deploy-config.mjs +++ b/scripts/validate-pages-deploy-config.mjs @@ -3,7 +3,7 @@ const EXPECTED_REPOSITORY_URL = 'https://github.com/ael-dev3/Warpkeep'; const EXPECTED_AUDIENCE = 'warpkeep-spacetimedb'; const EXPECTED_BRIDGE = 'https://auth.warpkeep.com'; const EXPECTED_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com'; -const EXPECTED_SPACETIMEDB_DATABASE = 'warpkeep-89e4u'; +const EXPECTED_SPACETIMEDB_DATABASE = 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e'; const SHA_PATTERN = /^[0-9a-f]{40}$/i; function fail(message) { diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md index 85d6b317..5c7a42c2 100644 --- a/services/auth-bridge/README.md +++ b/services/auth-bridge/README.md @@ -266,7 +266,7 @@ one verified FID being resolved. The module retains a 60-second rejection ceiling. The token has no admin role and is never returned or logged. The Worker calls the fixed documented Maincloud endpoint -`POST https://maincloud.spacetimedb.com/v1/database/warpkeep-89e4u/call/auth_resolver_get_fid_admission_v2` +`POST https://maincloud.spacetimedb.com/v1/database/c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e/call/auth_resolver_get_fid_admission_v2` with `Authorization: Bearer ` and SATS-JSON argument `[]`. The exact HTTP SATS-JSON product response is `["missing"|"disabled"|"enabled", ]`: missing/disabled require epoch `0`, diff --git a/services/auth-bridge/src/config.ts b/services/auth-bridge/src/config.ts index b4822ed0..8d4a6437 100644 --- a/services/auth-bridge/src/config.ts +++ b/services/auth-bridge/src/config.ts @@ -14,7 +14,8 @@ export const MAX_ADMIN_TOKEN_SECRET_BYTES = 512 export const MIN_SESSION_COOKIE_KEY_BYTES = 32 export const MAX_SESSION_COOKIE_KEY_BYTES = 512 export const PRODUCTION_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com' -export const PRODUCTION_SPACETIMEDB_DATABASE = 'warpkeep-89e4u' +/** Immutable public address; unlike a database alias, it cannot drift after a rename. */ +export const PRODUCTION_SPACETIMEDB_DATABASE = 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e' export const PRODUCTION_QA_OBSERVER_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com' export type QaObserverSpacetimeDbConfig = Readonly<{ @@ -186,7 +187,9 @@ function parseSpacetimeDbUri(value: string, production: boolean): string { } function parseSpacetimeDbDatabase(value: string): string { - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) { + const databaseIdentity = /^[a-f0-9]{64}$/ + const databaseAlias = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/ + if (!databaseIdentity.test(value) && !databaseAlias.test(value)) { throw new ConfigurationError() } return value diff --git a/services/auth-bridge/src/spacetimeAuthEpochResolver.ts b/services/auth-bridge/src/spacetimeAuthEpochResolver.ts index 9a83fa18..5b78896f 100644 --- a/services/auth-bridge/src/spacetimeAuthEpochResolver.ts +++ b/services/auth-bridge/src/spacetimeAuthEpochResolver.ts @@ -46,7 +46,7 @@ function resolverFailure(stage: AuthEpochResolverFailureStage): never { } const MAX_SUPPORTED_FID = BigInt(Number.MAX_SAFE_INTEGER) -const DATABASE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const DATABASE_NAME_PATTERN = /^(?:[a-f0-9]{64}|[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)$/ const encoder = new TextEncoder() export type SpacetimeAuthEpochResolverConfig = Readonly<{ diff --git a/services/auth-bridge/test/app.test.ts b/services/auth-bridge/test/app.test.ts index 46ad98f9..f039cfde 100644 --- a/services/auth-bridge/test/app.test.ts +++ b/services/auth-bridge/test/app.test.ts @@ -7,6 +7,7 @@ import { type AuthBridgeDependencies, } from '../src/app' import { MemoryChallengeStore } from '../src/challengeStore' +import { PRODUCTION_SPACETIMEDB_DATABASE } from '../src/config' import { FarcasterVerifierUnavailableError } from '../src/farcaster' import { MemorySessionFamilyStore } from '../src/sessionFamily' import { @@ -55,7 +56,7 @@ function env(overrides: Partial = {}): WorkerEnv { OIDC_AUDIENCE: 'warpkeep-spacetimedb', OIDC_KEY_ID: 'test-es256-2026', SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', - SPACETIMEDB_DATABASE: 'warpkeep-89e4u', + SPACETIMEDB_DATABASE: PRODUCTION_SPACETIMEDB_DATABASE, PUBLIC_AUTH_ENABLED: 'true', QA_OBSERVER_ENABLED: 'false', SIGNING_KEY_JWK: JSON.stringify(privateJwk), @@ -1147,7 +1148,7 @@ describe('Warpkeep auth bridge', () => { audience: 'warpkeep-spacetimedb', keyId: 'test-es256-2026', spacetimeDbUri: 'https://maincloud.spacetimedb.com', - spacetimeDbDatabase: 'warpkeep-89e4u', + spacetimeDbDatabase: PRODUCTION_SPACETIMEDB_DATABASE, publicAuthEnabled: true, qaObserverEnabled: false, qaObserverSpacetimeDbUri: null, @@ -1204,7 +1205,9 @@ describe('Warpkeep auth bridge', () => { await expect(response.json()).resolves.toEqual({ ok: true }) expect(upstream).toHaveBeenCalledOnce() const [input, init] = upstream.mock.calls[0] as unknown as [URL, RequestInit] - expect(input.toString()).toBe('https://maincloud.spacetimedb.com/v1/database/warpkeep-89e4u/call/auth_resolver_get_fid_admission_v2') + expect(input.toString()).toBe( + `https://maincloud.spacetimedb.com/v1/database/${PRODUCTION_SPACETIMEDB_DATABASE}/call/auth_resolver_get_fid_admission_v2`, + ) expect(init.body).toBe('[9007199254740991]') expect(init.redirect).toBe('manual') expect(events).toContain('auth_epoch_probe_succeeded') @@ -1598,6 +1601,8 @@ describe('Warpkeep auth bridge', () => { expect(lookalikeUri.status).toBe(503) const lookalikeDatabase = await h.app.fetch(request('/healthz'), env({ SPACETIMEDB_DATABASE: 'lookalike-database' })) expect(lookalikeDatabase.status).toBe(503) + const mutableFormerAlias = await h.app.fetch(request('/healthz'), env({ SPACETIMEDB_DATABASE: 'warpkeep-89e4u' })) + expect(mutableFormerAlias.status).toBe(503) const development = await h.app.fetch(request('/healthz'), env({ ENVIRONMENT: 'development', diff --git a/services/auth-bridge/test/qaObserver.test.ts b/services/auth-bridge/test/qaObserver.test.ts index f00ddf00..8c11df3b 100644 --- a/services/auth-bridge/test/qaObserver.test.ts +++ b/services/auth-bridge/test/qaObserver.test.ts @@ -1,6 +1,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest' import { createAuthBridge } from '../src/app' import { + PRODUCTION_SPACETIMEDB_DATABASE, QA_OBSERVER_MAX_REGISTRATION_LIFETIME_MILLISECONDS, type PublicEcJwk, } from '../src/config' @@ -116,7 +117,7 @@ function environment(overrides: Partial = {}): WorkerEnv { ADMIN_TOKEN_SECRET: ADMIN_SECRET, SESSION_COOKIE_KEY, SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', - SPACETIMEDB_DATABASE: 'warpkeep-89e4u', + SPACETIMEDB_DATABASE: PRODUCTION_SPACETIMEDB_DATABASE, QA_OBSERVER_SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', QA_OBSERVER_SPACETIMEDB_DATABASE: 'warpkeep-qa-observer-test', QA_OBSERVER_OIDC_AUDIENCE: 'warpkeep-qa-observer-spacetimedb', @@ -257,7 +258,7 @@ describe('machine-bound QA observer bridge', () => { { QA_OBSERVER_SPACETIMEDB_DATABASE: undefined }, { QA_OBSERVER_OIDC_AUDIENCE: undefined }, { QA_OBSERVER_SPACETIMEDB_URI: undefined }, - { QA_OBSERVER_SPACETIMEDB_DATABASE: 'warpkeep-89e4u' }, + { QA_OBSERVER_SPACETIMEDB_DATABASE: PRODUCTION_SPACETIMEDB_DATABASE }, { QA_OBSERVER_OIDC_AUDIENCE: 'warpkeep-spacetimedb' }, { QA_OBSERVER_SPACETIMEDB_URI: 'http://maincloud.spacetimedb.com' }, { QA_OBSERVER_SPACETIMEDB_URI: 'https://attacker.example' }, @@ -303,7 +304,7 @@ describe('machine-bound QA observer bridge', () => { expect(input.toString()).toBe( 'https://maincloud.spacetimedb.com/v1/database/warpkeep-qa-observer-test/call/qa_observer_get_realm_attestation_v2', ) - expect(input.toString()).not.toContain('/database/warpkeep-89e4u/') + expect(input.toString()).not.toContain(`/database/${PRODUCTION_SPACETIMEDB_DATABASE}/`) const authorization = new Headers(init.headers).get('authorization') expect(authorization).toMatch(/^Bearer [^.]+\.[^.]+\.[^.]+$/) const payloadSegment = authorization!.slice('Bearer '.length).split('.')[1] diff --git a/services/auth-bridge/wrangler.toml b/services/auth-bridge/wrangler.toml index e0c600e5..0d39c08d 100644 --- a/services/auth-bridge/wrangler.toml +++ b/services/auth-bridge/wrangler.toml @@ -17,7 +17,7 @@ FARCASTER_SIWE_URI = "https://warpkeep.com/" OIDC_AUDIENCE = "warpkeep-spacetimedb" OIDC_KEY_ID = "warpkeep-alpha-2026-07-01" SPACETIMEDB_URI = "https://maincloud.spacetimedb.com" -SPACETIMEDB_DATABASE = "warpkeep-89e4u" +SPACETIMEDB_DATABASE = "c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e" PUBLIC_AUTH_ENABLED = "false" QA_OBSERVER_ENABLED = "false" diff --git a/src/spacetime/warpkeepConfig.ts b/src/spacetime/warpkeepConfig.ts index 87babcca..78dc480a 100644 --- a/src/spacetime/warpkeepConfig.ts +++ b/src/spacetime/warpkeepConfig.ts @@ -3,7 +3,9 @@ * SpacetimeDB login credentials must never be exposed through Vite. */ export const DEFAULT_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com'; -export const DEFAULT_SPACETIMEDB_DATABASE = 'warpkeep-89e4u'; +/** Immutable public address; unlike a database alias, it cannot drift after a rename. */ +export const DEFAULT_SPACETIMEDB_DATABASE = + 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e'; export const DEFAULT_WARPKEEP_OIDC_AUDIENCE = 'warpkeep-spacetimedb'; export const CANONICAL_WARPKEEP_AUTH_ORIGIN = 'https://auth.warpkeep.com'; export const WARPKEEP_SHARED_ALPHA_UNAVAILABLE_MESSAGE = @@ -89,7 +91,9 @@ function normalizeDatabaseName(value: string | undefined) { if (value === undefined) return DEFAULT_SPACETIMEDB_DATABASE; const candidate = cleanOptionalString(value); if (!candidate) return undefined; - return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(candidate) ? candidate : undefined; + const databaseIdentity = /^[a-f0-9]{64}$/; + const databaseAlias = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; + return databaseIdentity.test(candidate) || databaseAlias.test(candidate) ? candidate : undefined; } function normalizeAudience(value: string | undefined) { diff --git a/tests/WarpkeepExperienceRealm.test.tsx b/tests/WarpkeepExperienceRealm.test.tsx index f41e3ec3..53efa729 100644 --- a/tests/WarpkeepExperienceRealm.test.tsx +++ b/tests/WarpkeepExperienceRealm.test.tsx @@ -38,6 +38,7 @@ import type { WarpkeepRealmSnapshot } from '../src/spacetime/warpkeepBackendTypes'; import { + DEFAULT_SPACETIMEDB_DATABASE, WARPKEEP_SHARED_ALPHA_UNAVAILABLE_MESSAGE, type WarpkeepRuntimeConfig } from '../src/spacetime/warpkeepConfig'; @@ -51,7 +52,7 @@ const TEST_BINDING_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; const TEST_BINDING_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; const TEST_CONFIG: WarpkeepRuntimeConfig = Object.freeze({ spacetimeUri: 'https://maincloud.spacetimedb.com', - spacetimeDatabase: 'warpkeep-89e4u', + spacetimeDatabase: DEFAULT_SPACETIMEDB_DATABASE, bridgeUrl: TEST_ISSUER, issuer: TEST_ISSUER, audience: TEST_AUDIENCE, diff --git a/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx b/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx index c5c0a6b3..552302d2 100644 --- a/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx +++ b/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx @@ -16,7 +16,10 @@ import { type WarpkeepBackendRuntime } from '../src/spacetime/WarpkeepSpacetimeProvider'; import type { WarpkeepRealmSnapshot } from '../src/spacetime/warpkeepBackendTypes'; -import type { WarpkeepRuntimeConfig } from '../src/spacetime/warpkeepConfig'; +import { + DEFAULT_SPACETIMEDB_DATABASE, + type WarpkeepRuntimeConfig +} from '../src/spacetime/warpkeepConfig'; import { createCanonicalGenesisCandidate, createCanonicalGenesisSnapshot @@ -25,7 +28,7 @@ import { createReadyResourceState } from './fixtures/resourceState'; const CONFIG: WarpkeepRuntimeConfig = Object.freeze({ spacetimeUri: 'https://maincloud.spacetimedb.com', - spacetimeDatabase: 'warpkeep-89e4u', + spacetimeDatabase: DEFAULT_SPACETIMEDB_DATABASE, bridgeUrl: 'https://auth.warpkeep.com', issuer: 'https://auth.warpkeep.com', audience: 'warpkeep-spacetimedb', diff --git a/tests/WarpkeepSpacetimeResources.test.tsx b/tests/WarpkeepSpacetimeResources.test.tsx index e8ec580c..ad2a6b3f 100644 --- a/tests/WarpkeepSpacetimeResources.test.tsx +++ b/tests/WarpkeepSpacetimeResources.test.tsx @@ -49,13 +49,16 @@ import { type WarpkeepBackendControllerValue, type WarpkeepBackendRuntime } from '../src/spacetime/WarpkeepSpacetimeProvider'; -import type { WarpkeepRuntimeConfig } from '../src/spacetime/warpkeepConfig'; +import { + DEFAULT_SPACETIMEDB_DATABASE, + type WarpkeepRuntimeConfig +} from '../src/spacetime/warpkeepConfig'; import { createCanonicalGenesisSnapshot } from './fixtures/canonicalGenesisSnapshot'; import { createReadyResourceState } from './fixtures/resourceState'; const CONFIG: WarpkeepRuntimeConfig = Object.freeze({ spacetimeUri: 'https://maincloud.spacetimedb.com', - spacetimeDatabase: 'warpkeep-89e4u', + spacetimeDatabase: DEFAULT_SPACETIMEDB_DATABASE, bridgeUrl: 'https://auth.warpkeep.com', issuer: 'https://auth.warpkeep.com', audience: 'warpkeep-spacetimedb', diff --git a/tests/WarpkeepSpacetimeTermsGate.test.tsx b/tests/WarpkeepSpacetimeTermsGate.test.tsx index 7271e252..fc9107c6 100644 --- a/tests/WarpkeepSpacetimeTermsGate.test.tsx +++ b/tests/WarpkeepSpacetimeTermsGate.test.tsx @@ -14,13 +14,16 @@ import { useWarpkeepBackend, type WarpkeepBackendRuntime } from '../src/spacetime/WarpkeepSpacetimeProvider'; -import type { WarpkeepRuntimeConfig } from '../src/spacetime/warpkeepConfig'; +import { + DEFAULT_SPACETIMEDB_DATABASE, + type WarpkeepRuntimeConfig +} from '../src/spacetime/warpkeepConfig'; import { createCanonicalGenesisSnapshot } from './fixtures/canonicalGenesisSnapshot'; import { createReadyResourceState } from './fixtures/resourceState'; const CONFIG: WarpkeepRuntimeConfig = Object.freeze({ spacetimeUri: 'https://maincloud.spacetimedb.com', - spacetimeDatabase: 'warpkeep-89e4u', + spacetimeDatabase: DEFAULT_SPACETIMEDB_DATABASE, bridgeUrl: 'https://auth.warpkeep.com', issuer: 'https://auth.warpkeep.com', audience: 'warpkeep-spacetimedb', diff --git a/tests/pagesDeployConfig.test.ts b/tests/pagesDeployConfig.test.ts index cf7eb338..ea2b32c8 100644 --- a/tests/pagesDeployConfig.test.ts +++ b/tests/pagesDeployConfig.test.ts @@ -17,7 +17,7 @@ function deploymentEnvironment(overrides: Record = {}) { VITE_WARPKEEP_OIDC_ISSUER: '', VITE_WARPKEEP_OIDC_AUDIENCE: 'warpkeep-spacetimedb', VITE_SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', - VITE_SPACETIMEDB_DATABASE: 'warpkeep-89e4u', + VITE_SPACETIMEDB_DATABASE: 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e', ...overrides }; } @@ -71,6 +71,6 @@ describe('Pages deployment configuration validation', () => { VITE_SPACETIMEDB_DATABASE: 'lookalike-database' }); expect(result.status).not.toBe(0); - expect(result.stderr).toContain('warpkeep-89e4u'); + expect(result.stderr).toContain('c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e'); }); }); diff --git a/tests/warpkeepConfig.test.ts b/tests/warpkeepConfig.test.ts index cca146c7..585cc6a8 100644 --- a/tests/warpkeepConfig.test.ts +++ b/tests/warpkeepConfig.test.ts @@ -61,6 +61,10 @@ describe('Warpkeep runtime configuration', () => { ...complete, VITE_SPACETIMEDB_DATABASE: 'lookalike-database' }))).toBe(false); + expect(hasUsableWarpkeepBridge(readWarpkeepRuntimeConfig({ + ...complete, + VITE_SPACETIMEDB_DATABASE: 'warpkeep-89e4u' + }))).toBe(false); expect(hasUsableWarpkeepBridge(readWarpkeepRuntimeConfig({ ...complete, VITE_SPACETIMEDB_URI: 'https://lookalike.example' diff --git a/tests/warpkeepConnection.test.ts b/tests/warpkeepConnection.test.ts index b8976227..f4259177 100644 --- a/tests/warpkeepConnection.test.ts +++ b/tests/warpkeepConnection.test.ts @@ -61,7 +61,7 @@ import { const config: WarpkeepRuntimeConfig = Object.freeze({ spacetimeUri: 'https://maincloud.spacetimedb.com', - spacetimeDatabase: 'warpkeep-89e4u', + spacetimeDatabase: 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e', bridgeUrl: 'https://auth.warpkeep.example', issuer: 'https://auth.warpkeep.example', audience: 'warpkeep-spacetimedb', @@ -362,7 +362,9 @@ describe('Warpkeep authenticated connection boundary', () => { createWarpkeepConnectionBuilder(config, 'header.payload.signature'); expect(builder.withUri).toHaveBeenCalledWith('https://maincloud.spacetimedb.com'); - expect(builder.withDatabaseName).toHaveBeenCalledWith('warpkeep-89e4u'); + expect(builder.withDatabaseName).toHaveBeenCalledWith( + 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e' + ); expect(builder.withToken).toHaveBeenCalledWith('header.payload.signature'); expect(window.localStorage.length).toBe(0); }); From c339c777e17bc70b372357b023e11af52897cf14 Mon Sep 17 00:00:00 2001 From: ael-dev3 Date: Wed, 22 Jul 2026 13:01:22 +0200 Subject: [PATCH 07/19] Require immutable QA database isolation --- services/auth-bridge/src/config.ts | 7 ++++--- services/auth-bridge/test/qaObserver.test.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/services/auth-bridge/src/config.ts b/services/auth-bridge/src/config.ts index 8d4a6437..2797ffb6 100644 --- a/services/auth-bridge/src/config.ts +++ b/services/auth-bridge/src/config.ts @@ -17,6 +17,8 @@ export const PRODUCTION_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com' /** Immutable public address; unlike a database alias, it cannot drift after a rename. */ export const PRODUCTION_SPACETIMEDB_DATABASE = 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e' export const PRODUCTION_QA_OBSERVER_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com' +const SPACETIMEDB_DATABASE_IDENTITY_PATTERN = /^[a-f0-9]{64}$/ +const SPACETIMEDB_DATABASE_ALIAS_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/ export type QaObserverSpacetimeDbConfig = Readonly<{ uri: string @@ -187,9 +189,7 @@ function parseSpacetimeDbUri(value: string, production: boolean): string { } function parseSpacetimeDbDatabase(value: string): string { - const databaseIdentity = /^[a-f0-9]{64}$/ - const databaseAlias = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/ - if (!databaseIdentity.test(value) && !databaseAlias.test(value)) { + if (!SPACETIMEDB_DATABASE_IDENTITY_PATTERN.test(value) && !SPACETIMEDB_DATABASE_ALIAS_PATTERN.test(value)) { throw new ConfigurationError() } return value @@ -360,6 +360,7 @@ export function readBridgeConfig(env: WorkerEnv): BridgeConfig { qaObserverSpacetimeDb && ( (production && qaObserverSpacetimeDb.uri !== PRODUCTION_QA_OBSERVER_SPACETIMEDB_URI) + || (production && !SPACETIMEDB_DATABASE_IDENTITY_PATTERN.test(qaObserverSpacetimeDb.database)) || qaObserverSpacetimeDb.database === spacetimeDbDatabase || qaObserverSpacetimeDb.audience === audience ) diff --git a/services/auth-bridge/test/qaObserver.test.ts b/services/auth-bridge/test/qaObserver.test.ts index 8c11df3b..9f74fd33 100644 --- a/services/auth-bridge/test/qaObserver.test.ts +++ b/services/auth-bridge/test/qaObserver.test.ts @@ -18,6 +18,7 @@ const ISSUER = 'https://auth.warpkeep.example' const ORIGIN = 'https://warpkeep.example' const ADMIN_SECRET = 'TEST_ONLY_ADMIN_SECRET_'.repeat(2) const SESSION_COOKIE_KEY = 'TEST_ONLY_SESSION_COOKIE_KEY_'.repeat(2) +const QA_DATABASE_IDENTITY = 'd2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e' const NOW = 1_800_000_000_000 let signingPrivateJwk: JsonWebKey @@ -119,7 +120,7 @@ function environment(overrides: Partial = {}): WorkerEnv { SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', SPACETIMEDB_DATABASE: PRODUCTION_SPACETIMEDB_DATABASE, QA_OBSERVER_SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', - QA_OBSERVER_SPACETIMEDB_DATABASE: 'warpkeep-qa-observer-test', + QA_OBSERVER_SPACETIMEDB_DATABASE: QA_DATABASE_IDENTITY, QA_OBSERVER_OIDC_AUDIENCE: 'warpkeep-qa-observer-spacetimedb', PUBLIC_AUTH_ENABLED: 'false', QA_OBSERVER_ENABLED: 'true', @@ -238,7 +239,7 @@ describe('machine-bound QA observer bridge', () => { publicAuthEnabled: false, qaObserverEnabled: true, qaObserverSpacetimeDbUri: 'https://maincloud.spacetimedb.com', - qaObserverSpacetimeDbDatabase: 'warpkeep-qa-observer-test', + qaObserverSpacetimeDbDatabase: QA_DATABASE_IDENTITY, qaObserverAudience: 'warpkeep-qa-observer-spacetimedb', qaObserverKeyFingerprint: await qaObserverKeyThumbprint(qaPublicJwk), qaObserverKeyRegisteredAt: new Date(NOW).toISOString(), @@ -259,6 +260,8 @@ describe('machine-bound QA observer bridge', () => { { QA_OBSERVER_OIDC_AUDIENCE: undefined }, { QA_OBSERVER_SPACETIMEDB_URI: undefined }, { QA_OBSERVER_SPACETIMEDB_DATABASE: PRODUCTION_SPACETIMEDB_DATABASE }, + { QA_OBSERVER_SPACETIMEDB_DATABASE: 'warpkeep-89e4u' }, + { QA_OBSERVER_SPACETIMEDB_DATABASE: 'warpkeep-qa-observer-test' }, { QA_OBSERVER_OIDC_AUDIENCE: 'warpkeep-spacetimedb' }, { QA_OBSERVER_SPACETIMEDB_URI: 'http://maincloud.spacetimedb.com' }, { QA_OBSERVER_SPACETIMEDB_URI: 'https://attacker.example' }, @@ -302,7 +305,7 @@ describe('machine-bound QA observer bridge', () => { expect(upstream).toHaveBeenCalledOnce() const [input, init] = upstream.mock.calls[0] as unknown as [URL, RequestInit] expect(input.toString()).toBe( - 'https://maincloud.spacetimedb.com/v1/database/warpkeep-qa-observer-test/call/qa_observer_get_realm_attestation_v2', + `https://maincloud.spacetimedb.com/v1/database/${QA_DATABASE_IDENTITY}/call/qa_observer_get_realm_attestation_v2`, ) expect(input.toString()).not.toContain(`/database/${PRODUCTION_SPACETIMEDB_DATABASE}/`) const authorization = new Headers(init.headers).get('authorization') From aee2688677636c6bf99b636f7b07cfa48fb881e2 Mon Sep 17 00:00:00 2001 From: ael-dev3 Date: Wed, 22 Jul 2026 13:13:08 +0200 Subject: [PATCH 08/19] Restrict production browser network egress --- index.html | 2 +- scripts/verify-production-dist-exclusions.mjs | 18 +++++++++++++++++- tests/warpkeepRecovery.test.tsx | 7 +++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 35583299..45280d58 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ { expect(contentSecurityPolicy).toContain("script-src 'self' 'wasm-unsafe-eval'"); expect(contentSecurityPolicy).toContain("script-src-attr 'none'"); expect(contentSecurityPolicy).not.toContain("'unsafe-eval'"); + expect(contentSecurityPolicy).not.toMatch(/(?:^|[;\s])https:(?:[;\s]|$)/); + expect(contentSecurityPolicy).not.toMatch(/(?:^|[;\s])wss?:(?:[;\s]|$)/); + expect(contentSecurityPolicy).not.toMatch(/localhost|127\.0\.0\.1|\[::1\]/); expect(contentSecurityPolicy).toContain("object-src 'none'"); expect(contentSecurityPolicy).toContain("frame-src 'none'"); expect(contentSecurityPolicy).toContain("form-action 'none'"); + expect(contentSecurityPolicy).toContain('https://auth.warpkeep.com'); + expect(contentSecurityPolicy).toContain('https://relay.farcaster.xyz'); + expect(contentSecurityPolicy).toContain('https://mainnet.optimism.io'); + expect(contentSecurityPolicy).toContain('https://maincloud.spacetimedb.com'); expect(contentSecurityPolicy).toContain('wss://maincloud.spacetimedb.com'); expect(parsed.querySelector('[data-warpkeep-production-csp]')).not.toBeNull(); }); From 5dd8ea408064df1200254d022a4af9fe0071b6d4 Mon Sep 17 00:00:00 2001 From: Ael Date: Tue, 21 Jul 2026 19:35:01 +0200 Subject: [PATCH 09/19] feat(spacetime): stage generic four-worker authority --- package.json | 1 + ...erify-castle-worker-additive-migration.mjs | 57 ++ spacetimedb/README.md | 20 +- .../additive-v12-schema/package.json | 13 + .../additive-v12-schema/src/index.ts | 608 ++++++++++++++ .../additive-v12-schema/tsconfig.json | 13 + spacetimedb/src/castleWorkerAuthority.ts | 785 ++++++++++++++++++ spacetimedb/src/castleWorkerPolicy.ts | 314 +++++++ spacetimedb/src/castleWorkerRoster.ts | 118 +++ spacetimedb/src/foundingAuthority.ts | 6 + spacetimedb/src/index.ts | 10 + spacetimedb/src/reducers/castleWorkers.ts | 270 ++++++ spacetimedb/src/reducers/resources.ts | 7 + .../resourceExpeditionReservationAuthority.ts | 45 +- spacetimedb/src/schema.ts | 197 +++++ .../castleWorkerMigrationTooling.test.ts | 74 ++ spacetimedb/tests/castleWorkerPolicy.test.ts | 73 ++ .../tests/foodExpeditionReducers.test.ts | 2 +- .../tests/goldExpeditionReducers.test.ts | 4 +- .../tests/playerIdentityPrivacy.test.ts | 4 + spacetimedb/tests/resourceReducers.test.ts | 6 + .../tests/stoneExpeditionReducers.test.ts | 2 +- .../tests/waterRevisionAuthority.test.ts | 2 +- .../tests/woodExpeditionReducers.test.ts | 2 +- ..._get_worker_system_status_v_1_procedure.ts | 19 + .../admin_plan_worker_roster_v_1_procedure.ts | 19 + .../castle_worker_v_1_table.ts | 32 + .../dispatch_worker_v_1_reducer.ts | 18 + .../get_my_resource_state_v_2_procedure.ts | 19 + .../get_my_worker_roster_v_1_procedure.ts | 19 + src/spacetime/module_bindings/index.ts | 77 ++ .../realm_worker_system_v_1_table.ts | 24 + .../recall_all_workers_v_1_reducer.ts | 15 + .../recall_worker_v_1_reducer.ts | 16 + src/spacetime/module_bindings/types.ts | 195 +++++ .../module_bindings/types/procedures.ts | 12 + .../module_bindings/types/reducers.ts | 6 + .../worker_assignment_schedule_v_1_table.ts | 20 + .../worker_node_occupation_v_1_table.ts | 26 + 39 files changed, 3134 insertions(+), 16 deletions(-) create mode 100644 scripts/verify-castle-worker-additive-migration.mjs create mode 100644 spacetimedb/migration-fixtures/additive-v12-schema/package.json create mode 100644 spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts create mode 100644 spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json create mode 100644 spacetimedb/src/castleWorkerAuthority.ts create mode 100644 spacetimedb/src/castleWorkerPolicy.ts create mode 100644 spacetimedb/src/castleWorkerRoster.ts create mode 100644 spacetimedb/src/reducers/castleWorkers.ts create mode 100644 spacetimedb/tests/castleWorkerMigrationTooling.test.ts create mode 100644 spacetimedb/tests/castleWorkerPolicy.test.ts create mode 100644 src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/castle_worker_v_1_table.ts create mode 100644 src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts create mode 100644 src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/realm_worker_system_v_1_table.ts create mode 100644 src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/recall_worker_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts create mode 100644 src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts diff --git a/package.json b/package.json index 556acada..8c0e5c67 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "stdb:generate": "node scripts/generate-spacetime-bindings.mjs", "stdb:verify-bindings": "node scripts/verify-spacetime-bindings.mjs", "stdb:verify-additive-migration": "node scripts/verify-spacetime-additive-migration.mjs", + "stdb:verify-worker-migration": "node scripts/verify-castle-worker-additive-migration.mjs", "stdb:publish:dev": "node scripts/publish-spacetime-dev.mjs", "stdb:seed-world": "tsx scripts/hermes-admin.ts seed-world", "stdb:expand-world-v3": "tsx scripts/hermes-admin.ts expand-world-v3", diff --git a/scripts/verify-castle-worker-additive-migration.mjs b/scripts/verify-castle-worker-additive-migration.mjs new file mode 100644 index 00000000..4ccaf426 --- /dev/null +++ b/scripts/verify-castle-worker-additive-migration.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const schemaPath = resolve(root, 'spacetimedb/src/schema.ts'); +const previousFixturePath = resolve(root, 'spacetimedb/migration-fixtures/additive-v11-schema/src/index.ts'); +const fixturePath = resolve(root, 'spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts'); + +function registrations(source, marker) { + const start = source.indexOf(marker); + const end = source.indexOf('\n});', start); + assert.ok(start >= 0 && end > start, `missing schema marker: ${marker}`); + return source.slice(start + marker.length, end) + .split(/[,\n]/) + .map(value => value.trim()) + .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); +} + +function table(source, name) { + const start = source.indexOf(`const ${name} = table(`); + const end = source.indexOf('\n);', start); + assert.ok(start >= 0 && end > start, `missing table: ${name}`); + return source.slice(start, end); +} + +const [schema, previousFixture, fixture] = await Promise.all([ + readFile(schemaPath, 'utf8'), + readFile(previousFixturePath, 'utf8'), + readFile(fixturePath, 'utf8'), +]); +const current = registrations(schema, 'const warpkeep = schema({'); +const previous = registrations(previousFixture, 'const db = schema({'); +const candidate = registrations(fixture, 'const db = schema({'); +assert.equal(previous.length, 47, 'v11 fixture must end at ref 46'); +assert.deepEqual(current.slice(0, 47), previous, 'current schema changed before the v12 suffix'); +assert.deepEqual(candidate.slice(0, 47), previous, 'v12 fixture changed the deployed prefix'); +assert.deepEqual(candidate.slice(47), [ + 'realmWorkerSystemV1', + 'castleWorkerV1', + 'workerAssignmentV1', + 'workerNodeOccupationV1', + 'workerCommandIdempotencyV1', + 'workerAssignmentScheduleV1', +]); +assert.deepEqual(current.slice(47), candidate.slice(47), 'module and fixture suffix differ'); +for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1', 'workerAssignmentScheduleV1']) { + const definition = table(schema, name); + assert.match(definition, /public: true/); + assert.doesNotMatch(definition, /\bfid\b|accruedAmount|materializedAmount|balance|requestKey|auth/i); +} +for (const name of ['workerAssignmentV1', 'workerCommandIdempotencyV1']) { + assert.doesNotMatch(table(schema, name), /public: true/); +} +assert.match(fixture, /fixture_seed_generic_worker_sentinel_v12/); +console.log('generic worker additive migration proof passed: refs 0–46 preserved, refs 47–52 append-only, populated fixture present, public/private boundary checked'); diff --git a/spacetimedb/README.md b/spacetimedb/README.md index 0735acd3..d4da369c 100644 --- a/spacetimedb/README.md +++ b/spacetimedb/README.md @@ -12,8 +12,9 @@ balance, advance a timer, or decide an expedition outcome. | Browser/backend wire protocol | 3 | | Player authentication contract | 2 | | Genesis world generation | 3 | -| Append-only schema generation | 10 | +| Append-only schema generation | 12 (staged suffix) | | Alpha 0.3.12 suffix | Water refs 37–40; Stone refs 41–45 | +| Generic worker suffix | refs 47–52; staged, not activated | Deployed tables retain their original declaration order and shape. Later features append new tables; they do not rename or delete existing data. The @@ -55,6 +56,8 @@ Public subscriptions contain only shared-world presentation: - activated Water layout, body/cell topology, and shared environment data; - identity-minimized site occupations containing a site, phase, public timeline, and origin castle; +- staged four-worker roster and generic node-lease projections; the public + rows contain no FID, cargo, accrual, balance, request, or auth data; - public Community Marks projection only when its policy permits it. Private tables contain admission, ownership, unclaimed-slot decisions, resource @@ -94,6 +97,21 @@ Gold, Food, Wood, and Stone each have an independent expedition: - private reservations prevent passive collection or another lifecycle from truncating a valid Food, Wood, or Stone award. +The additive generic-worker suffix defines four stable workers per founded +castle. Any idle worker can gather Gold, Food, Wood, or Stone, and multiple +workers may gather the same resource at different nodes. Worker assignments +use the same canonical site catalogs, route authority, 60-second quantum, and +30-day cap as the legacy expeditions. The caller's private read projects exact +server-time availability without a write; scheduled expiry and explicit +dispatch/recall commands materialize complete quanta. There is no per-minute +write loop and no `collect` command for generic workers. + +The suffix is intentionally staged. The module does not seed, activate, +backfill, or migrate production workers in this PR. Activation requires an +admin-reviewed singleton, an exact four-worker roster digest, and zero legacy +expedition, occupation, and schedule rows. The browser wire protocol remains 3 +until a later client-capability release opts into the generic worker methods. + ## Entry agreement and Marks Entry and gameplay require the exact current Alpha Terms and Hegemony Social diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/package.json b/spacetimedb/migration-fixtures/additive-v12-schema/package.json new file mode 100644 index 00000000..0caa87d6 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v12-schema/package.json @@ -0,0 +1,13 @@ +{ + "name": "warpkeep-additive-v12-schema-migration-fixture", + "private": true, + "version": "0.0.0", + "type": "module", + "license": "Apache-2.0", + "dependencies": { + "spacetimedb": "2.6.1" + }, + "devDependencies": { + "typescript": "5.6.3" + } +} diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts new file mode 100644 index 00000000..1b6ac58e --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts @@ -0,0 +1,608 @@ +import { schema, table, t } from 'spacetimedb/server'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { SenderError } from 'spacetimedb/server'; +import { + goldExpeditionErrorCode, + runGoldExpeditionSchedule, +} from '../../../src/goldExpeditionAuthority'; +import { + foodExpeditionErrorCode, + runFoodExpeditionSchedule, +} from '../../../src/foodExpeditionAuthority'; +import { + woodExpeditionErrorCode, + runWoodExpeditionSchedule, +} from '../../../src/woodExpeditionAuthority'; +import { + stoneExpeditionErrorCode, + runStoneExpeditionSchedule, +} from '../../../src/stoneExpeditionAuthority'; + +const allowedFid = table({ name: 'allowed_fid' }, { + fid: t.u64().primaryKey(), enabled: t.bool(), authEpoch: t.u32(), + invitedAt: t.timestamp(), invitedBy: t.string(), note: t.string(), +}); +const worldTile = table({ name: 'world_tile', public: true }, { + key: t.string().primaryKey(), q: t.i32(), r: t.i32(), biome: t.string(), + terrainSeed: t.u32(), occupantCastleId: t.option(t.u64()), +}); +const player = table({ name: 'player', public: true }, { + fid: t.u64().primaryKey(), identity: t.identity().unique(), username: t.option(t.string()), + displayName: t.option(t.string()), pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(), +}); +const castle = table({ name: 'castle', public: true }, { + castleId: t.u64().primaryKey().autoInc(), ownerFid: t.u64().unique(), tileKey: t.string().unique(), + q: t.i32(), r: t.i32(), level: t.i32(), name: t.string(), createdAt: t.timestamp(), +}); +const adminAudit = table({ name: 'admin_audit' }, { + id: t.u64().primaryKey().autoInc(), action: t.string(), targetFid: t.option(t.u64()), + actorSubject: t.string(), createdAt: t.timestamp(), note: t.string(), +}); +const playerV2 = table({ name: 'player_v2', public: true }, { + fid: t.u64().primaryKey(), username: t.option(t.string()), displayName: t.option(t.string()), + pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(), +}); +const playerOwnershipV2 = table({ name: 'player_ownership_v2' }, { + fid: t.u64().primaryKey(), identity: t.identity().unique(), +}); +const realmV1 = table({ name: 'realm_v1', public: true }, { + realmId: t.string().primaryKey(), publicName: t.string(), seedName: t.string(), numericSeed: t.u32(), + generationVersion: t.u32(), authoritativeRadius: t.u32(), renderRadius: t.u32(), playerCapacity: t.u32(), + active: t.bool(), createdAt: t.timestamp(), +}); +const worldTileMetaV1 = table({ + name: 'world_tile_meta_v1', public: true, + indexes: [{ accessor: 'byRealmAndRing', algorithm: 'btree', columns: ['realmId', 'ring'] as const }] as const, +}, { + tileKey: t.string().primaryKey(), realmId: t.string().index(), s: t.i32(), ring: t.u32(), sector: t.u32(), + terrainKind: t.string(), passable: t.bool(), movementCost: t.u32(), staticContentKind: t.string(), generationVersion: t.u32(), +}); +const castleSlotV1 = table({ name: 'castle_slot_v1', public: true }, { + slotId: t.u32().primaryKey(), realmId: t.string().index(), tileKey: t.string().unique(), q: t.i32(), r: t.i32(), generationVersion: t.u32(), +}); +const castleSlotClaimV1 = table({ name: 'castle_slot_claim_v1' }, { + slotId: t.u32().primaryKey(), ownerFid: t.u64().unique(), castleId: t.u64().unique(), claimedAt: t.timestamp(), generationVersion: t.u32(), +}); +const realmProfileV1 = table({ name: 'realm_profile_v1', public: true }, { + fid: t.u64().primaryKey(), canonicalUsername: t.option(t.string()), displayName: t.option(t.string()), pfpUrl: t.option(t.string()), publicBio: t.option(t.string()), + admittedAt: t.timestamp(), firstAuthenticatedAt: t.option(t.timestamp()), profileUpdatedAt: t.timestamp(), publicStatus: t.string(), communityStatsVisible: t.bool(), + totalSnapBurnedMicros: t.option(t.u128()), marksEarnedMicros: t.option(t.u128()), marksSpentMicros: t.option(t.u128()), marksBalanceMicros: t.option(t.u128()), marksPolicyVersion: t.option(t.string()), +}); +const markAccountV1 = table({ name: 'mark_account_v1' }, { + fid: t.u64().primaryKey(), totalSnapBurnedMicros: t.u128(), earnedMicros: t.u128(), spentMicros: t.u128(), balanceMicros: t.u128(), policyVersion: t.string(), updatedAt: t.timestamp(), +}); +const snapBurnCreditV1 = table({ name: 'snap_burn_credit_v1' }, { + eventKey: t.string().primaryKey(), batchId: t.string().index(), chainId: t.u32(), tokenContract: t.string(), transactionHash: t.string(), logIndex: t.u32(), burnReference: t.string().unique(), burnMethod: t.string(), senderAddress: t.string(), blockNumber: t.u64(), blockHash: t.string(), amountMicros: t.u128(), attributedFid: t.u64().index(), attributionPolicyVersion: t.string(), contractCodeHash: t.string(), creditedAt: t.timestamp(), +}); +const fidWalletAttributionV1 = table({ + name: 'fid_wallet_attribution_v1', indexes: [{ accessor: 'bySnapshotAndAddress', algorithm: 'btree', columns: ['snapshotGeneration', 'address'] as const }] as const, +}, { + snapshotAttributionKey: t.string().primaryKey(), attributionKey: t.string(), snapshotGeneration: t.u64(), fid: t.u64().index(), address: t.string(), addressType: t.string(), source: t.string(), snapshotAt: t.timestamp(), attributionPolicyVersion: t.string(), active: t.bool(), +}); +const walletAttributionSnapshotV1 = table({ name: 'wallet_attribution_snapshot_v1' }, { + snapshotKey: t.string().primaryKey(), generation: t.u64(), snapshotId: t.string(), policyVersion: t.string(), attributionCount: t.u32(), snapshotAt: t.timestamp(), +}); +const snapScanCursorV1 = table({ name: 'snap_scan_cursor_v1' }, { + cursorKey: t.string().primaryKey(), chainId: t.u32(), tokenContract: t.string(), policyVersion: t.string(), deploymentStartBlock: t.u64(), lastFinalizedBlock: t.u64(), lastFinalizedBlockHash: t.string(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), scannedAt: t.timestamp(), +}); +const snapScanBatchV1 = table({ + name: 'snap_scan_batch_v1', indexes: [{ accessor: 'byCursorAndStatus', algorithm: 'btree', columns: ['cursorKey', 'status'] as const }] as const, +}, { + batchId: t.string().primaryKey(), cursorKey: t.string(), status: t.string(), previousFinalizedBlock: t.u64(), previousFinalizedBlockHash: t.string(), throughFinalizedBlock: t.u64(), throughFinalizedBlockHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), walletAttributionCount: t.u32(), expectedCredits: t.u32(), expectedMicros: t.u128(), appliedCredits: t.u32(), appliedMicros: t.u128(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), startedAt: t.timestamp(), finalizedAt: t.option(t.timestamp()), +}); +const alphaTermsAcceptanceV1 = table({ name: 'alpha_terms_acceptance_v1' }, { + acceptanceKey: t.string().primaryKey(), fid: t.u64().index(), termsVersion: t.string(), acceptedAt: t.timestamp(), +}); +const resourceAccountV1 = table({ name: 'resource_account_v1' }, { + fid: t.u64().primaryKey(), castleId: t.u64().unique(), realmId: t.string().index(), food: t.u64(), wood: t.u64(), stone: t.u64(), gold: t.u64(), settledThroughMicros: t.u64(), revision: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); + +const goldSiteV1 = table({ name: 'gold_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const goldNodeOccupationV1 = table({ name: 'gold_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const goldExpeditionV1 = table({ name: 'gold_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedGold: t.u64(), creditedGold: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const goldExpeditionIdempotencyV1 = table({ name: 'gold_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const goldExpeditionScheduleV1 = table({ name: 'gold_expedition_schedule_v_1', public: true, scheduled: (): any => runGoldExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmForestLayoutV1 = table({ name: 'realm_forest_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), layoutDigest: t.string(), assetCatalogDigest: t.string(), instanceCount: t.u32(), seededAt: t.timestamp() }); +const realmForestInstanceV1 = table({ name: 'realm_forest_instance_v1', public: true }, { treeId: t.string().primaryKey(), realmId: t.string().index(), tileKey: t.string(), q: t.i32(), r: t.i32(), localXMicrounits: t.i64(), localZMicrounits: t.i64(), worldXMicrounits: t.i64(), worldZMicrounits: t.i64(), rotationMilliDegrees: t.u32(), scaleBasisPoints: t.u32(), speciesId: t.string(), habitat: t.string(), layoutVersion: t.u32() }); + +const foodSiteV1 = table({ name: 'food_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const foodNodeOccupationV1 = table({ name: 'food_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const foodExpeditionV1 = table({ name: 'food_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedFood: t.u64(), creditedFood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const foodExpeditionIdempotencyV1 = table({ name: 'food_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const foodExpeditionScheduleV1 = table({ name: 'food_expedition_schedule_v_1', public: true, scheduled: (): any => runFoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const woodSiteV1 = table({ name: 'wood_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const woodNodeOccupationV1 = table({ name: 'wood_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const woodExpeditionV1 = table({ name: 'wood_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedWood: t.u64(), creditedWood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const woodExpeditionIdempotencyV1 = table({ name: 'wood_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const woodExpeditionScheduleV1 = table({ name: 'wood_expedition_schedule_v_1', public: true, scheduled: (): any => runWoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmWaterLayoutV1 = table({ name: 'realm_water_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), generationVersion: t.u32(), canonicalLandCellCount: t.u32(), oceanCellCount: t.u32(), lakeCellCount: t.u32(), lakeBodyCount: t.u32(), riverCount: t.u32(), riverCellCount: t.u32(), seaLevelMilli: t.i32(), seaLevelPolicyVersion: t.string(), fogStartDepthCells: t.u32(), fogFullDepthCells: t.u32(), hiddenBufferCells: t.u32(), layoutDigest: t.string(), sourceCommit: t.string(), activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()) }); +const realmWaterBodyV1 = table({ name: 'realm_water_body_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }] as const }, { bodyId: t.string().primaryKey(), realmId: t.string().index(), regime: t.string(), cellCount: t.u32(), sourceCellKey: t.string(), mouthCellKey: t.string(), surfaceLevelMilli: t.i32(), flowDirectionXQ15: t.i32(), flowDirectionZQ15: t.i32(), wavePreset: t.string(), ordinal: t.u32(), seed: t.u32(), generationVersion: t.u32(), layoutVersion: t.u32() }); +const realmWaterCellV1 = table({ name: 'realm_water_cell_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }, { accessor: 'byBody', algorithm: 'btree', columns: ['bodyId'] as const }] as const }, { cellKey: t.string().primaryKey(), realmId: t.string().index(), q: t.i32(), r: t.i32(), regime: t.string(), bodyId: t.string(), depthCells: t.u32(), elevationMilli: t.i32(), surfaceLevelMilli: t.i32(), ring: t.u32(), s: t.i32(), underlyingTileKey: t.option(t.string()), riverOrdinal: t.option(t.u32()), riverOrder: t.option(t.u32()), downstreamWaterCellKey: t.option(t.string()), flowAccumulation: t.u32(), depthClass: t.u32(), oceanDepth: t.u32(), bankSeed: t.u32(), generationVersion: t.u32(), fogBand: t.string(), layoutVersion: t.u32() }); +const realmEnvironmentV1 = table({ name: 'realm_environment_v1', public: true }, { realmId: t.string().primaryKey(), environmentEpoch: t.u64(), waterLayoutVersion: t.u32(), seaLevelMilli: t.i32(), sunDirectionXMicro: t.i32(), sunDirectionYMicro: t.i32(), sunDirectionZMicro: t.i32(), updatedAt: t.timestamp() }); + +const stoneSiteV1 = table({ name: 'stone_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const stoneNodeOccupationV1 = table({ name: 'stone_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const stoneExpeditionV1 = table({ name: 'stone_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedStone: t.u64(), creditedStone: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const stoneExpeditionIdempotencyV1 = table({ name: 'stone_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const stoneExpeditionScheduleV1 = table({ name: 'stone_expedition_schedule_v_1', public: true, scheduled: (): any => runStoneExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmWaterRevisionV1 = table({ name: 'realm_water_revision_v1', public: true }, { + realmId: t.string().primaryKey(), revisionVersion: t.u32(), policyVersion: t.string(), + baseLayoutVersion: t.u32(), baseLayoutDigest: t.string(), oceanBodyCount: t.u32(), + riverBodyCount: t.u32(), enabledBodyCount: t.u32(), oceanCellCount: t.u32(), + riverCellCount: t.u32(), enabledCellCount: t.u32(), lakeBodyCount: t.u32(), + lakeCellCount: t.u32(), riverWidthCells: t.u32(), navigationFogBoundaryDepthCells: t.u32(), + hiddenBufferCells: t.u32(), revisionDigest: t.string(), sourceCommit: t.string(), + activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()), +}); + +/** v12 generic-worker suffix. Public rows contain only identity/lifecycle data. */ +const realmWorkerSystemV1 = table({ name: 'realm_worker_system_v1', public: true }, { + realmId: t.string().primaryKey(), policyVersion: t.string(), workersPerCastle: t.u32(), + expectedCastleCount: t.u32(), expectedWorkerCount: t.u32(), rosterDigest: t.string(), + mode: t.string(), legacyDrainRequired: t.bool(), createdAt: t.timestamp(), + activatedAt: t.option(t.timestamp()), +}); +const castleWorkerV1 = table({ + name: 'castle_worker_v1', public: true, + indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const, +}, { + workerId: t.string().primaryKey(), originCastleId: t.u64(), ordinal: t.u32(), status: t.string(), + assignmentId: t.option(t.string()), resourceKind: t.option(t.string()), siteId: t.option(t.string()), + startedAtMicros: t.option(t.u64()), arrivesAtMicros: t.option(t.u64()), gatheringEndsAtMicros: t.option(t.u64()), + returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.option(t.u64()), routeSteps: t.option(t.u32()), + returnStartProgressBasisPoints: t.option(t.u32()), timelineRevision: t.u32(), revision: t.u64(), + createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); +const workerAssignmentV1 = table({ + name: 'worker_assignment_v1', + indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const, +}, { + assignmentId: t.string().primaryKey(), workerId: t.string().unique(), fid: t.u64(), + originCastleId: t.u64(), resourceKind: t.string(), siteId: t.string().index(), phase: t.string(), + startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), + returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.u64(), routeSteps: t.u32(), + returnStartProgressBasisPoints: t.u32(), settledThroughMicros: t.u64(), accruedAmount: t.u64(), + materializedAmount: t.u64(), timelineRevision: t.u32(), policyVersion: t.string(), + createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); +const workerNodeOccupationV1 = table({ + name: 'worker_node_occupation_v1', public: true, + indexes: [ + { accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }, + { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, + ] as const, +}, { + nodeKey: t.string().primaryKey(), resourceKind: t.string(), siteId: t.string(), workerId: t.string(), + workerOrdinal: t.u32(), originCastleId: t.u64(), assignmentId: t.string(), phase: t.string(), + startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), timelineRevision: t.u32(), +}); +const workerCommandIdempotencyV1 = table({ + name: 'worker_command_idempotency_v1', + indexes: [{ accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const }] as const, +}, { + requestKey: t.string().primaryKey(), fid: t.u64(), workerId: t.option(t.string()), commandKind: t.string(), + resourceKind: t.option(t.string()), siteId: t.option(t.string()), assignmentId: t.option(t.string()), + resultRevision: t.u64(), createdAt: t.timestamp(), +}); +const workerAssignmentScheduleV1 = table({ + name: 'worker_assignment_schedule_v_1', public: true, + indexes: [ + { accessor: 'byAssignment', algorithm: 'btree', columns: ['assignmentId'] as const }, + { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, + ] as const, + scheduled: (): any => runWorkerAssignmentScheduleV1, +}, { + scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), assignmentId: t.string(), + workerId: t.string(), timelineRevision: t.u32(), stage: t.string(), +}); + +const db = schema({ + allowedFid, worldTile, player, castle, adminAudit, playerV2, playerOwnershipV2, + realmV1, worldTileMetaV1, castleSlotV1, castleSlotClaimV1, realmProfileV1, markAccountV1, + snapBurnCreditV1, fidWalletAttributionV1, walletAttributionSnapshotV1, snapScanCursorV1, + snapScanBatchV1, alphaTermsAcceptanceV1, resourceAccountV1, goldSiteV1, goldNodeOccupationV1, + goldExpeditionV1, goldExpeditionIdempotencyV1, goldExpeditionScheduleV1, realmForestLayoutV1, + realmForestInstanceV1, foodSiteV1, foodNodeOccupationV1, foodExpeditionV1, + foodExpeditionIdempotencyV1, foodExpeditionScheduleV1, woodSiteV1, woodNodeOccupationV1, + woodExpeditionV1, woodExpeditionIdempotencyV1, woodExpeditionScheduleV1, realmWaterLayoutV1, + realmWaterBodyV1, realmWaterCellV1, realmEnvironmentV1, stoneSiteV1, + stoneNodeOccupationV1, stoneExpeditionV1, stoneExpeditionIdempotencyV1, + stoneExpeditionScheduleV1, realmWaterRevisionV1, realmWorkerSystemV1, castleWorkerV1, + workerAssignmentV1, workerNodeOccupationV1, workerCommandIdempotencyV1, workerAssignmentScheduleV1, +}); + +export const runWorkerAssignmentScheduleV1 = db.reducer( + { name: 'run_worker_assignment_schedule_v_1' }, + { arg: workerAssignmentScheduleV1.rowType }, + () => {}, +); + +export const runGoldExpeditionScheduleV1 = db.reducer( + { name: 'run_gold_expedition_schedule_v_1' }, + { arg: goldExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runGoldExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = goldExpeditionErrorCode(error); throw new SenderError(code ?? 'GOLD_SCHEDULE_ERROR'); } + }, +); +export const runFoodExpeditionScheduleV1 = db.reducer( + { name: 'run_food_expedition_schedule_v_1' }, + { arg: foodExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runFoodExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = foodExpeditionErrorCode(error); throw new SenderError(code ?? 'FOOD_SCHEDULE_ERROR'); } + }, +); +export const runWoodExpeditionScheduleV1 = db.reducer( + { name: 'run_wood_expedition_schedule_v_1' }, + { arg: woodExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runWoodExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = woodExpeditionErrorCode(error); throw new SenderError(code ?? 'WOOD_SCHEDULE_ERROR'); } + }, +); +export const runStoneExpeditionScheduleV1 = db.reducer( + { name: 'run_stone_expedition_schedule_v_1' }, + { arg: stoneExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runStoneExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = stoneExpeditionErrorCode(error); throw new SenderError(code ?? 'STONE_SCHEDULE_ERROR'); } + }, +); + +/** Auth-neutral identity fixture; SQL identity literals are issuer-bound. */ +export const fixtureInsertPlayerOwnershipV9 = db.reducer( + { name: 'fixture_insert_player_ownership_v9' }, + { fid: t.u64() }, + (ctx, { fid }) => { + if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_EXISTS'); + ctx.db.playerOwnershipV2.insert({ fid, identity: ctx.sender }); + }, +); + +/** Bounded identity-row assertion; SQL cannot read identity columns across issuers. */ +export const fixtureAssertPlayerOwnershipV9 = db.reducer( + { name: 'fixture_assert_player_ownership_v9' }, + { fid: t.u64(), expectedCount: t.u64() }, + (ctx, { fid, expectedCount }) => { + if (ctx.db.playerOwnershipV2.count() !== expectedCount) throw new Error('FIXTURE_OWNERSHIP_COUNT_INVALID'); + if (expectedCount === 0n) { + if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_UNEXPECTED'); + return; + } + if (expectedCount !== 1n || ctx.db.playerOwnershipV2.fid.find(fid) === null) { + throw new Error('FIXTURE_OWNERSHIP_ROW_INVALID'); + } + }, +); + +/** Preserve the v9 Water sentinel wire unchanged in the v10 fixture. */ +export const fixtureSeedWaterSentinelV9 = db.reducer( + { name: 'fixture_seed_water_sentinel_v9' }, + ctx => { + if ( + ctx.db.realmWaterLayoutV1.count() !== 0n + || ctx.db.realmWaterBodyV1.count() !== 0n + || ctx.db.realmWaterCellV1.count() !== 0n + || ctx.db.realmEnvironmentV1.count() !== 0n + ) throw new Error('FIXTURE_WATER_NOT_EMPTY'); + const realmId = 'MIGRATION_WATER_SENTINEL'; + const bodyId = 'migration-water-body'; + ctx.db.realmWaterLayoutV1.insert({ + realmId, + layoutVersion: 1, + policyVersion: 'migration-water-sentinel-v1', + generationVersion: 3, + canonicalLandCellCount: 10_000, + oceanCellCount: 1, + lakeCellCount: 0, + lakeBodyCount: 0, + riverCount: 0, + riverCellCount: 0, + seaLevelMilli: 0, + seaLevelPolicyVersion: 'migration-water-sentinel-v1', + fogStartDepthCells: 1, + fogFullDepthCells: 2, + hiddenBufferCells: 1, + layoutDigest: '0'.repeat(64), + sourceCommit: '0'.repeat(40), + activated: false, + seededAt: ctx.timestamp, + activatedAt: undefined, + }); + ctx.db.realmWaterBodyV1.insert({ + bodyId, + realmId, + regime: 'ocean', + cellCount: 1, + sourceCellKey: 'migration-water-cell', + mouthCellKey: 'migration-water-cell', + surfaceLevelMilli: 0, + flowDirectionXQ15: 0, + flowDirectionZQ15: 0, + wavePreset: 'migration', + ordinal: 0, + seed: 0, + generationVersion: 3, + layoutVersion: 1, + }); + ctx.db.realmWaterCellV1.insert({ + cellKey: 'migration-water-cell', + realmId, + q: 0, + r: 0, + regime: 'ocean', + bodyId, + depthCells: 1, + elevationMilli: 0, + surfaceLevelMilli: 0, + ring: 0, + s: 0, + underlyingTileKey: undefined, + riverOrdinal: undefined, + riverOrder: undefined, + downstreamWaterCellKey: undefined, + flowAccumulation: 0, + depthClass: 1, + oceanDepth: 1, + bankSeed: 0, + generationVersion: 3, + fogBand: 'clear', + layoutVersion: 1, + }); + ctx.db.realmEnvironmentV1.insert({ + realmId, + environmentEpoch: 1n, + waterLayoutVersion: 1, + seaLevelMilli: 0, + sunDirectionXMicro: 0, + sunDirectionYMicro: 1_000_000, + sunDirectionZMicro: 0, + updatedAt: ctx.timestamp, + }); + }, +); + +/** One typed row per v10 Stone table for the next additive migration. */ +export const fixtureSeedStoneSentinelV10 = db.reducer( + { name: 'fixture_seed_stone_sentinel_v10' }, + ctx => { + if ( + ctx.db.stoneSiteV1.count() !== 0n + || ctx.db.stoneNodeOccupationV1.count() !== 0n + || ctx.db.stoneExpeditionV1.count() !== 0n + || ctx.db.stoneExpeditionIdempotencyV1.count() !== 0n + || ctx.db.stoneExpeditionScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_STONE_NOT_EMPTY'); + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const arrivesAtMicros = startedAtMicros + 7n * 24n * 60n * 60n * 1_000_000n; + const gatheringEndsAtMicros = arrivesAtMicros + 24n * 60n * 60n * 1_000_000n; + const returnsAtMicros = gatheringEndsAtMicros + 24n * 60n * 60n * 1_000_000n; + const siteId = 'migration-stone-site'; + const expeditionId = 'migration-stone-expedition'; + const originCastleId = 991_001n; + const fid = 991_002n; + ctx.db.stoneSiteV1.insert({ siteId, q: 1, r: -1, tier: 1, active: true }); + ctx.db.stoneNodeOccupationV1.insert({ + siteId, + originCastleId, + phase: 'outbound', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnsAtMicros, + }); + ctx.db.stoneExpeditionV1.insert({ + expeditionId, + fid, + originCastleId, + siteId, + phase: 'outbound', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnsAtMicros, + settledThroughMicros: startedAtMicros, + accruedStone: 0n, + creditedStone: 0n, + policyVersion: 'migration-stone-sentinel-v1', + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.stoneExpeditionIdempotencyV1.insert({ + requestKey: 'migration-stone-sentinel-request-0001', + fid, + siteId, + expeditionId, + createdAt: ctx.timestamp, + }); + ctx.db.stoneExpeditionScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(arrivesAtMicros), + originCastleId, + siteId, + stage: 'arrival', + }); + }, +); + +/** Typed v11 sentinel used only to prove rollback refusal and row survival. */ +export const fixtureSeedWaterRevisionSentinelV11 = db.reducer( + { name: 'fixture_seed_water_revision_sentinel_v11' }, + ctx => { + if (ctx.db.realmWaterRevisionV1.count() !== 0n) { + throw new Error('FIXTURE_WATER_REVISION_NOT_EMPTY'); + } + ctx.db.realmWaterRevisionV1.insert({ + realmId: 'MIGRATION_WATER_SENTINEL', + revisionVersion: 2, + policyVersion: 'migration-water-revision-sentinel-v1', + baseLayoutVersion: 1, + baseLayoutDigest: '0'.repeat(64), + oceanBodyCount: 1, + riverBodyCount: 0, + enabledBodyCount: 1, + oceanCellCount: 1, + riverCellCount: 0, + enabledCellCount: 1, + lakeBodyCount: 0, + lakeCellCount: 0, + riverWidthCells: 1, + navigationFogBoundaryDepthCells: 2, + hiddenBufferCells: 1, + revisionDigest: '1'.repeat(64), + sourceCommit: '1'.repeat(40), + activated: false, + seededAt: ctx.timestamp, + activatedAt: undefined, + }); + }, +); + +const FIXTURE_RESOURCE_QUANTUM_MICROS = 600_000_000n; +const FIXTURE_RESOURCE_POLICY_VERSION = 'genesis-resource-yield-v1'; + +export const fixtureRewindResourceOneQuantum = db.reducer( + { name: 'fixture_rewind_resource_one_quantum' }, + { fid: t.u64() }, + (ctx, { fid }) => { + const row = ctx.db.resourceAccountV1.fid.find(fid); + if ( + row === null + || row.policyVersion !== FIXTURE_RESOURCE_POLICY_VERSION + || row.revision !== 0n + || row.food !== 0n + || row.wood !== 0n + || row.stone !== 0n + || row.gold !== 0n + || row.settledThroughMicros < FIXTURE_RESOURCE_QUANTUM_MICROS + ) throw new Error('FIXTURE_RESOURCE_STATE_INVALID'); + const rewoundMicros = row.settledThroughMicros - FIXTURE_RESOURCE_QUANTUM_MICROS; + ctx.db.resourceAccountV1.fid.update({ + ...row, + settledThroughMicros: rewoundMicros, + createdAt: new Timestamp(rewoundMicros), + updatedAt: ctx.timestamp, + }); + }, +); + +/** Populates every v12 table with bounded, auth-neutral rows for migration proof. */ +export const fixtureSeedGenericWorkerSentinelV12 = db.reducer( + { name: 'fixture_seed_generic_worker_sentinel_v12' }, + ctx => { + if ( + ctx.db.realmWorkerSystemV1.count() !== 0n + || ctx.db.castleWorkerV1.count() !== 0n + || ctx.db.workerAssignmentV1.count() !== 0n + || ctx.db.workerNodeOccupationV1.count() !== 0n + || ctx.db.workerCommandIdempotencyV1.count() !== 0n + || ctx.db.workerAssignmentScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_WORKER_NOT_EMPTY'); + const castleId = 991_101n; + const fid = 991_102n; + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const arrivesAtMicros = startedAtMicros + 30_000_000n; + const gatheringEndsAtMicros = arrivesAtMicros + 86_400_000_000n; + const returnsAtMicros = gatheringEndsAtMicros + 30_000_000n; + const assignmentId = 'migration-worker-assignment-0001'; + const workerId = 'genesis-001-castle-991101-worker-01'; + const siteId = 'migration-worker-site'; + ctx.db.realmWorkerSystemV1.insert({ + realmId: 'GENESIS_001', + policyVersion: 'genesis-001-castle-workers-v1', + workersPerCastle: 4, + expectedCastleCount: 1, + expectedWorkerCount: 4, + rosterDigest: 'migration-worker-roster-digest', + mode: 'staged', + legacyDrainRequired: true, + createdAt: ctx.timestamp, + activatedAt: undefined, + }); + for (let ordinal = 1; ordinal <= 4; ordinal += 1) { + ctx.db.castleWorkerV1.insert({ + workerId: `genesis-001-castle-991101-worker-0${ordinal}`, + originCastleId: castleId, + ordinal, + status: ordinal === 1 ? 'gathering' : 'idle', + assignmentId: ordinal === 1 ? assignmentId : undefined, + resourceKind: ordinal === 1 ? 'stone' : undefined, + siteId: ordinal === 1 ? siteId : undefined, + startedAtMicros: ordinal === 1 ? startedAtMicros : undefined, + arrivesAtMicros: ordinal === 1 ? arrivesAtMicros : undefined, + gatheringEndsAtMicros: ordinal === 1 ? gatheringEndsAtMicros : undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: ordinal === 1 ? returnsAtMicros : undefined, + routeSteps: ordinal === 1 ? 1 : undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 0, + revision: 0n, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } + ctx.db.workerAssignmentV1.insert({ + assignmentId, + workerId, + fid, + originCastleId: castleId, + resourceKind: 'stone', + siteId, + phase: 'gathering', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnStartedAtMicros: undefined, + returnsAtMicros, + routeSteps: 1, + returnStartProgressBasisPoints: 0, + settledThroughMicros: arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + timelineRevision: 0, + policyVersion: 'genesis-001-castle-workers-v1', + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.workerNodeOccupationV1.insert({ + nodeKey: 'stone:migration-worker-site', + resourceKind: 'stone', + siteId, + workerId, + workerOrdinal: 1, + originCastleId: castleId, + assignmentId, + phase: 'gathering', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + timelineRevision: 0, + }); + ctx.db.workerCommandIdempotencyV1.insert({ + requestKey: '991102:migration-worker-request-0001', + fid, + workerId, + commandKind: 'dispatch', + resourceKind: 'stone', + siteId, + assignmentId, + resultRevision: 0n, + createdAt: ctx.timestamp, + }); + ctx.db.workerAssignmentScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(gatheringEndsAtMicros), + assignmentId, + workerId, + timelineRevision: 0, + stage: 'gathering-expiry', + }); + }, +); + +export default db; diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json b/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json new file mode 100644 index 00000000..ff6a5945 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "target": "ESNext", + "lib": ["ES2021", "dom"], + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/spacetimedb/src/castleWorkerAuthority.ts b/spacetimedb/src/castleWorkerAuthority.ts new file mode 100644 index 00000000..0be9eb06 --- /dev/null +++ b/spacetimedb/src/castleWorkerAuthority.ts @@ -0,0 +1,785 @@ +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import { ScheduleAt } from 'spacetimedb'; + +import { assertGenesisResourceForFid } from './resourceAuthority'; +import { RESOURCE_BALANCE_CAP } from './resourceAuthorityPolicy'; +import { + activeExpeditionResourceReservations, + planResourceSettlementForActiveExpeditionReservations, +} from './resourceExpeditionReservationAuthority'; +import type warpkeep from './schema'; +import { + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKER_TRAVEL_MICROS_PER_STEP, + CASTLE_WORKERS_PER_CASTLE, + CastleWorkerPolicyError, + type CastleWorkerPhase, + type CastleWorkerSiteShape, + planCastleWorkerAccrual, + planCastleWorkerTimeline, + rosterDigestForCastleIds, + workerAssignmentStateIsConsistent, + workerIdForCastle, + workerResourcePolicy, + assertCastleWorkerId, + assertWorkerCommandKey, + canonicalWorkerRouteSteps, +} from './castleWorkerPolicy'; +import { + assertCastleWorkerRoster, + workerRosterDigestInput, + workerSystemRowIsStagedOrActive, +} from './castleWorkerRoster'; +import { + CANONICAL_REALM, + canonicalMetaForKey, + canonicalTileForKey, + matchesCanonicalTerrain, + matchesCanonicalWorldMeta, +} from './world'; + +type WarpkeepReducerContext = ReducerCtx>; +type CastleRow = NonNullable>; +type WorkerRow = NonNullable>; +type AssignmentRow = NonNullable>; +type ScheduleRow = NonNullable>; +type ResourceAccountRow = NonNullable>; + +export const WORKER_SCHEDULE_STAGE_ARRIVAL = 'arrival'; +export const WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY = 'gathering-expiry'; +export const WORKER_SCHEDULE_STAGE_RETURN_COMPLETE = 'return-complete'; +const WORKER_SYSTEM_REALM_ID = CANONICAL_REALM.realmId; +const WORKER_TIMELINE_MAX = 0xffff_ffff; + +export class CastleWorkerAuthorityError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'CastleWorkerAuthorityError'; + } +} + +function fail(code: string): never { + throw new CastleWorkerAuthorityError(code); +} + +function safeNextU32(value: number, code: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value >= WORKER_TIMELINE_MAX) fail(code); + return value + 1; +} + +function safeNextU64(value: bigint, code: string): bigint { + if (value < 0n || value >= (1n << 64n) - 1n) fail(code); + return value + 1n; +} + +function assignmentRequestKey(fid: bigint, idempotencyKey: string): string { + assertWorkerCommandKey(idempotencyKey); + return `${fid.toString()}:${idempotencyKey}`; +} + +function resourceField(kind: string): 'food' | 'wood' | 'stone' | 'gold' { + if (kind === 'food' || kind === 'wood' || kind === 'stone' || kind === 'gold') return kind; + fail('WORKER_RESOURCE_UNSUPPORTED'); +} + +function assignmentPhase(value: string): CastleWorkerPhase { + if (value === 'outbound' || value === 'gathering' || value === 'returning') return value; + fail('WORKER_PHASE_INVALID'); +} + +function systemRow(ctx: WarpkeepReducerContext) { + const row = ctx.db.realmWorkerSystemV1.realmId.find(WORKER_SYSTEM_REALM_ID); + if (row === null || !workerSystemRowIsStagedOrActive(row)) fail('WORKER_SYSTEM_NOT_READY'); + return row; +} + +function workerSystemActive(ctx: WarpkeepReducerContext) { + const row = systemRow(ctx); + if (row.mode !== 'active') fail('WORKER_SYSTEM_STAGED'); + if (row.legacyDrainRequired) fail('WORKER_LEGACY_DRAIN_REQUIRED'); + const castleIds = [...ctx.db.castle.iter()].map(castle => castle.castleId); + const expectedWorkerCount = BigInt(castleIds.length * CASTLE_WORKERS_PER_CASTLE); + if ( + BigInt(row.expectedCastleCount) !== BigInt(castleIds.length) + || BigInt(row.expectedWorkerCount) !== expectedWorkerCount + || ctx.db.castleWorkerV1.count() !== expectedWorkerCount + || row.rosterDigest !== rosterDigestForCastleIds(castleIds) + ) fail('WORKER_ROSTER_NOT_READY'); + for (const castleId of castleIds) assertCastleWorkerRoster(ctx, castleId); + for (const worker of ctx.db.castleWorkerV1.iter()) { + if (ctx.db.castle.castleId.find(worker.originCastleId) === null) { + fail('WORKER_ROSTER_ORPHAN'); + } + } + const legacy = legacyActiveCounts(ctx); + if (legacy.expeditions !== 0n || legacy.occupations !== 0n || legacy.schedules !== 0n) { + fail('WORKER_LEGACY_DRAIN_REQUIRED'); + } + return row; +} + +function canonicalSiteFor( + ctx: WarpkeepReducerContext, + resourceKind: string, + siteId: string, +): CastleWorkerSiteShape { + const policy = workerResourcePolicy(resourceKind); + const canonical = policy.canonicalSiteForId(siteId); + if (canonical === undefined || !canonical.active || !policy.matchesCanonicalSite(canonical)) { + fail('WORKER_SITE_UNAVAILABLE'); + } + const stored = resourceKind === 'gold' + ? ctx.db.goldSiteV1.siteId.find(siteId) + : resourceKind === 'food' + ? ctx.db.foodSiteV1.siteId.find(siteId) + : resourceKind === 'wood' + ? ctx.db.woodSiteV1.siteId.find(siteId) + : ctx.db.stoneSiteV1.siteId.find(siteId); + if (stored === null || !policy.matchesCanonicalSite(stored)) fail('WORKER_SITE_INTEGRITY'); + const tileKey = `${canonical.q},${canonical.r}`; + const tile = ctx.db.worldTile.key.find(tileKey); + const meta = ctx.db.worldTileMetaV1.tileKey.find(tileKey); + const expectedTile = canonicalTileForKey(tileKey); + const expectedMeta = canonicalMetaForKey(tileKey); + if ( + tile === null + || meta === null + || expectedTile === undefined + || expectedMeta === undefined + || !matchesCanonicalTerrain(tile) + || !matchesCanonicalWorldMeta(meta) + || !matchesCanonicalTerrain(expectedTile) + || !matchesCanonicalWorldMeta(expectedMeta) + || !meta.passable + || meta.staticContentKind !== 'resource-capable' + || tile.q !== canonical.q + || tile.r !== canonical.r + ) fail('WORKER_SITE_WORLD_INTEGRITY'); + return canonical; +} + +function legacyOccupationAt(ctx: WarpkeepReducerContext, resourceKind: string, siteId: string): boolean { + return resourceKind === 'gold' + ? ctx.db.goldNodeOccupationV1.siteId.find(siteId) !== null + : resourceKind === 'food' + ? ctx.db.foodNodeOccupationV1.siteId.find(siteId) !== null + : resourceKind === 'wood' + ? ctx.db.woodNodeOccupationV1.siteId.find(siteId) !== null + : ctx.db.stoneNodeOccupationV1.siteId.find(siteId) !== null; +} + +function publicWorkerMatchesAssignment(worker: WorkerRow, assignment: AssignmentRow): boolean { + return worker.workerId === assignment.workerId + && worker.originCastleId === assignment.originCastleId + && worker.status === assignment.phase + && worker.assignmentId === assignment.assignmentId + && worker.resourceKind === assignment.resourceKind + && worker.siteId === assignment.siteId + && worker.startedAtMicros === assignment.startedAtMicros + && worker.arrivesAtMicros === assignment.arrivesAtMicros + && worker.gatheringEndsAtMicros === assignment.gatheringEndsAtMicros + && worker.returnsAtMicros === assignment.returnsAtMicros + && worker.routeSteps === assignment.routeSteps + && worker.timelineRevision === assignment.timelineRevision; +} + +function assertAssignmentState(assignment: AssignmentRow): void { + assignmentPhase(assignment.phase); + if ( + assignment.workerId.length === 0 + || assignment.policyVersion !== CASTLE_WORKER_POLICY_VERSION + || !workerAssignmentStateIsConsistent(assignment) + || assignment.returnStartProgressBasisPoints > 10_000 + ) fail('WORKER_ASSIGNMENT_STATE_INVALID'); +} + +function insertSchedule( + ctx: WarpkeepReducerContext, + assignment: AssignmentRow, + stage: string, + atMicros: bigint, +): void { + ctx.db.workerAssignmentScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(atMicros), + assignmentId: assignment.assignmentId, + workerId: assignment.workerId, + timelineRevision: assignment.timelineRevision, + stage, + }); +} + +function updateResourceAccount( + ctx: WarpkeepReducerContext, + resource: ResourceAccountRow, + balances: ResourceAccountRow, + passiveSettledThroughMicros: bigint, + revision: bigint, +): void { + ctx.db.resourceAccountV1.fid.update({ + ...resource, + food: balances.food, + wood: balances.wood, + stone: balances.stone, + gold: balances.gold, + settledThroughMicros: passiveSettledThroughMicros, + revision, + updatedAt: ctx.timestamp, + }); +} + +/** + * Materialize every complete worker quantum for one caller in one transaction. + * Reads use the sibling projection below and never call this writer. No + * per-minute writes occur: schedules and caller reads settle exact quanta. + */ +export function settleAllWorkerAssignmentsForFid( + ctx: WarpkeepReducerContext, + fid: bigint, + observedAtMicros = ctx.timestamp.microsSinceUnixEpoch, +): void { + const resource = assertGenesisResourceForFid(ctx, fid); + const passive = planResourceSettlementForActiveExpeditionReservations( + ctx, + fid, + resource.account, + resource.terrainKind, + observedAtMicros, + ); + const balances = { + ...resource.account, + food: passive.balances.food, + wood: passive.balances.wood, + stone: passive.balances.stone, + gold: passive.balances.gold, + }; + let changed = passive.completedQuanta > 0n; + for (const assignment of ctx.db.workerAssignmentV1.iter()) { + if (assignment.fid !== fid) continue; + assertAssignmentState(assignment); + if (assignment.fid !== fid || assignment.originCastleId !== resource.castle.castleId) fail('WORKER_OWNER_INTEGRITY'); + const plan = planCastleWorkerAccrual(assignment, observedAtMicros); + const credit = plan.accruedAmount - assignment.materializedAmount; + if (credit < 0n) fail('WORKER_MATERIALIZATION_INVALID'); + if (plan.completedQuanta === 0n && credit === 0n) continue; + const field = resourceField(assignment.resourceKind); + if (credit > RESOURCE_BALANCE_CAP - balances[field]) fail('WORKER_ACCOUNT_CAPACITY'); + balances[field] += credit; + ctx.db.workerAssignmentV1.assignmentId.update({ + ...assignment, + settledThroughMicros: plan.settledThroughMicros, + accruedAmount: plan.accruedAmount, + materializedAmount: plan.accruedAmount, + updatedAt: ctx.timestamp, + }); + changed = true; + } + if (changed) { + updateResourceAccount( + ctx, + resource.account, + balances, + passive.settledThroughMicros, + safeNextU64(resource.account.revision, 'WORKER_RESOURCE_REVISION'), + ); + } +} + +export type WorkerPrivateProjection = Readonly<{ + workerId: string; + ordinal: number; + status: string; + resourceKind: string | undefined; + siteId: string | undefined; + accruedAmount: bigint; + materializedAmount: bigint; + availableAmount: bigint; + observedAtMicros: bigint; + revision: bigint; +}>; + +export function projectMyWorkerState( + ctx: WarpkeepReducerContext, + fid: bigint, + observedAtMicros = ctx.timestamp.microsSinceUnixEpoch, +): Readonly<{ resource: ResourceAccountRow; balances: Readonly>; workers: readonly WorkerPrivateProjection[] }> { + const resource = assertGenesisResourceForFid(ctx, fid); + const passive = planResourceSettlementForActiveExpeditionReservations( + ctx, + fid, + resource.account, + resource.terrainKind, + observedAtMicros, + ); + const balances = { + food: passive.balances.food, + wood: passive.balances.wood, + stone: passive.balances.stone, + gold: passive.balances.gold, + }; + const workers = [...assertCastleWorkerRoster(ctx, resource.castle.castleId)] + .sort((left, right) => left.ordinal - right.ordinal) + .map(worker => { + const assignment = worker.assignmentId === undefined + ? undefined + : ctx.db.workerAssignmentV1.assignmentId.find(worker.assignmentId); + if (assignment === undefined || assignment === null) { + if (worker.assignmentId !== undefined) fail('WORKER_ASSIGNMENT_MISSING'); + return Object.freeze({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + resourceKind: worker.resourceKind, + siteId: worker.siteId, + accruedAmount: 0n, + materializedAmount: 0n, + availableAmount: 0n, + observedAtMicros, + revision: worker.revision, + }); + } + assertAssignmentState(assignment); + if (!publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + const plan = planCastleWorkerAccrual(assignment, observedAtMicros); + const availableAmount = plan.accruedAmount - assignment.materializedAmount; + if (availableAmount < 0n) fail('WORKER_MATERIALIZATION_INVALID'); + const field = resourceField(assignment.resourceKind); + if (availableAmount > RESOURCE_BALANCE_CAP - balances[field]) fail('WORKER_ACCOUNT_CAPACITY'); + balances[field] += availableAmount; + return Object.freeze({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + resourceKind: worker.resourceKind, + siteId: worker.siteId, + accruedAmount: plan.accruedAmount, + materializedAmount: assignment.materializedAmount, + availableAmount, + observedAtMicros, + revision: worker.revision, + }); + }); + return Object.freeze({ resource: resource.account, balances: Object.freeze(balances), workers: Object.freeze(workers) }); +} + +function assertDispatchReservations( + ctx: WarpkeepReducerContext, + fid: bigint, + account: ResourceAccountRow, + resourceKind: string, +): void { + const policy = workerResourcePolicy(resourceKind); + const reservations = activeExpeditionResourceReservations(ctx, fid); + const field = resourceField(resourceKind); + const existingReservation = reservations[field]; + if (account[field] > RESOURCE_BALANCE_CAP || existingReservation > RESOURCE_BALANCE_CAP) fail('WORKER_ACCOUNT_STATE_INVALID'); + if (policy.gatheringTotal > RESOURCE_BALANCE_CAP - account[field] - existingReservation) { + fail('WORKER_ACCOUNT_CAPACITY'); + } +} + +export type WorkerDispatchResult = Readonly<{ assignment: AssignmentRow; idempotent: boolean }>; + +export function dispatchCastleWorker( + ctx: WarpkeepReducerContext, + input: Readonly<{ fid: bigint; castle: CastleRow; workerId: string; resourceKind: string; siteId: string; idempotencyKey: string }>, +): WorkerDispatchResult { + const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey); + const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); + if (prior !== null) { + if (prior.fid !== input.fid || prior.commandKind !== 'dispatch' || prior.workerId !== input.workerId || prior.resourceKind !== input.resourceKind || prior.siteId !== input.siteId || prior.assignmentId === undefined) fail('WORKER_IDEMPOTENCY_CONFLICT'); + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(prior.assignmentId); + if (assignment === null) fail('WORKER_IDEMPOTENCY_STALE'); + return Object.freeze({ assignment, idempotent: true }); + } + workerSystemActive(ctx); + settleAllWorkerAssignmentsForFid(ctx, input.fid); + const roster = assertCastleWorkerRoster(ctx, input.castle.castleId); + const worker = ctx.db.castleWorkerV1.workerId.find(input.workerId); + if (worker === null || worker.originCastleId !== input.castle.castleId || !roster.some(row => row.workerId === worker.workerId)) fail('WORKER_NOT_OWNED'); + assertCastleWorkerId(worker.workerId); + if (worker.status !== 'idle' || worker.assignmentId !== undefined) fail('WORKER_NOT_IDLE'); + const site = canonicalSiteFor(ctx, input.resourceKind, input.siteId); + if (legacyOccupationAt(ctx, input.resourceKind, input.siteId)) fail('WORKER_LEGACY_SITE_OCCUPIED'); + const nodeKey = `${input.resourceKind}:${input.siteId}`; + if (ctx.db.workerNodeOccupationV1.nodeKey.find(nodeKey) !== null) fail('WORKER_SITE_OCCUPIED'); + const routeSteps = canonicalWorkerRouteSteps(input.castle, site); + if (routeSteps === undefined || routeSteps <= 0) fail('WORKER_ROUTE_INVALID'); + const resource = assertGenesisResourceForFid(ctx, input.fid); + assertDispatchReservations(ctx, input.fid, resource.account, input.resourceKind); + const timeline = planCastleWorkerTimeline(ctx.timestamp.microsSinceUnixEpoch, routeSteps); + const assignment = ctx.db.workerAssignmentV1.insert({ + assignmentId: ctx.newUuidV7().toString(), + workerId: worker.workerId, + fid: input.fid, + originCastleId: input.castle.castleId, + resourceKind: input.resourceKind, + siteId: input.siteId, + phase: 'outbound', + ...timeline, + returnStartedAtMicros: undefined, + routeSteps, + returnStartProgressBasisPoints: 0, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + timelineRevision: 0, + policyVersion: CASTLE_WORKER_POLICY_VERSION, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'outbound', + assignmentId: assignment.assignmentId, + resourceKind: input.resourceKind, + siteId: input.siteId, + startedAtMicros: assignment.startedAtMicros, + arrivesAtMicros: assignment.arrivesAtMicros, + gatheringEndsAtMicros: assignment.gatheringEndsAtMicros, + returnStartedAtMicros: undefined, + returnsAtMicros: assignment.returnsAtMicros, + routeSteps: assignment.routeSteps, + returnStartProgressBasisPoints: undefined, + updatedAt: ctx.timestamp, + }); + ctx.db.workerNodeOccupationV1.insert({ + nodeKey, + resourceKind: input.resourceKind, + siteId: input.siteId, + workerId: worker.workerId, + workerOrdinal: worker.ordinal, + originCastleId: input.castle.castleId, + assignmentId: assignment.assignmentId, + phase: 'outbound', + startedAtMicros: assignment.startedAtMicros, + arrivesAtMicros: assignment.arrivesAtMicros, + gatheringEndsAtMicros: assignment.gatheringEndsAtMicros, + timelineRevision: assignment.timelineRevision, + }); + insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_ARRIVAL, assignment.arrivesAtMicros); + insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY, assignment.gatheringEndsAtMicros); + insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_RETURN_COMPLETE, assignment.returnsAtMicros); + ctx.db.workerCommandIdempotencyV1.insert({ + requestKey, + fid: input.fid, + workerId: worker.workerId, + commandKind: 'dispatch', + resourceKind: input.resourceKind, + siteId: input.siteId, + assignmentId: assignment.assignmentId, + resultRevision: worker.revision, + createdAt: ctx.timestamp, + }); + return Object.freeze({ assignment, idempotent: false }); +} + +function progressBasisPoints(assignment: AssignmentRow, now: bigint): number { + if (now <= assignment.startedAtMicros) return 0; + if (now >= assignment.arrivesAtMicros) return 10_000; + const elapsed = now - assignment.startedAtMicros; + const duration = assignment.arrivesAtMicros - assignment.startedAtMicros; + return Number((elapsed * 10_000n) / duration); +} + +function remainingTravelMicros(assignment: AssignmentRow, progress: number): bigint { + const travel = BigInt(assignment.routeSteps) * CASTLE_WORKER_TRAVEL_MICROS_PER_STEP; + // The return path starts at the worker's current outbound position: zero + // progress is still at the castle, while 10,000 is at the node. + return (travel * BigInt(progress)) / 10_000n; +} + +function beginWorkerReturn( + ctx: WarpkeepReducerContext, + assignment: AssignmentRow, + progress: number, + now: bigint, +): AssignmentRow { + assertAssignmentState(assignment); + if (assignment.phase !== 'outbound' && assignment.phase !== 'gathering') return assignment; + const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`); + if (occupation !== null) { + if (occupation.assignmentId !== assignment.assignmentId || occupation.workerId !== assignment.workerId || occupation.timelineRevision !== assignment.timelineRevision) fail('WORKER_OCCUPATION_INTEGRITY'); + ctx.db.workerNodeOccupationV1.nodeKey.delete(occupation.nodeKey); + } + const returningAtMicros = now + remainingTravelMicros(assignment, progress); + const timelineRevision = safeNextU32(assignment.timelineRevision, 'WORKER_TIMELINE_REVISION'); + const returning = { + ...assignment, + phase: 'returning', + returnStartedAtMicros: now, + returnsAtMicros: returningAtMicros, + returnStartProgressBasisPoints: progress, + timelineRevision, + updatedAt: ctx.timestamp, + }; + ctx.db.workerAssignmentV1.assignmentId.update(returning); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'returning', + returnStartedAtMicros: now, + returnsAtMicros: returningAtMicros, + returnStartProgressBasisPoints: progress, + timelineRevision, + revision: safeNextU64(worker.revision, 'WORKER_REVISION'), + updatedAt: ctx.timestamp, + }); + insertSchedule(ctx, returning, WORKER_SCHEDULE_STAGE_RETURN_COMPLETE, returningAtMicros); + return returning; +} + +function completeWorkerReturn(ctx: WarpkeepReducerContext, assignment: AssignmentRow, now: bigint): void { + if (now < assignment.returnsAtMicros) return; + if (assignment.phase !== 'returning') fail('WORKER_RETURN_STATE'); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + ctx.db.workerAssignmentV1.assignmentId.delete(assignment.assignmentId); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'idle', + assignmentId: undefined, + resourceKind: undefined, + siteId: undefined, + startedAtMicros: undefined, + arrivesAtMicros: undefined, + gatheringEndsAtMicros: undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: undefined, + routeSteps: undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: safeNextU32(worker.timelineRevision, 'WORKER_TIMELINE_REVISION'), + revision: safeNextU64(worker.revision, 'WORKER_REVISION'), + updatedAt: ctx.timestamp, + }); +} + +function transitionWorkerArrival(ctx: WarpkeepReducerContext, assignment: AssignmentRow, now: bigint): AssignmentRow { + if (now < assignment.arrivesAtMicros) return assignment; + if (assignment.phase !== 'outbound') return assignment; + const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`); + if (occupation === null || occupation.assignmentId !== assignment.assignmentId || occupation.timelineRevision !== assignment.timelineRevision) fail('WORKER_OCCUPATION_MISSING'); + const gathering = { ...assignment, phase: 'gathering', updatedAt: ctx.timestamp }; + ctx.db.workerAssignmentV1.assignmentId.update(gathering); + ctx.db.workerNodeOccupationV1.nodeKey.update({ ...occupation, phase: 'gathering' }); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + ctx.db.castleWorkerV1.workerId.update({ ...worker, status: 'gathering', updatedAt: ctx.timestamp }); + return gathering; +} + +function settleAndBeginReturnAt( + ctx: WarpkeepReducerContext, + assignment: AssignmentRow, + now: bigint, + progress: number, +): AssignmentRow { + settleAllWorkerAssignmentsForFid(ctx, assignment.fid, now); + const fresh = ctx.db.workerAssignmentV1.assignmentId.find(assignment.assignmentId); + if (fresh === null) fail('WORKER_ASSIGNMENT_MISSING'); + return beginWorkerReturn(ctx, fresh, progress, now); +} + +export function runCastleWorkerSchedule(ctx: WarpkeepReducerContext, schedule: ScheduleRow): void { + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(schedule.assignmentId); + if (assignment === null || assignment.workerId !== schedule.workerId || assignment.timelineRevision !== schedule.timelineRevision) return; + assertAssignmentState(assignment); + const now = ctx.timestamp.microsSinceUnixEpoch; + if (schedule.stage === WORKER_SCHEDULE_STAGE_ARRIVAL) { + transitionWorkerArrival(ctx, assignment, now); + return; + } + if (schedule.stage === WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY) { + const gathering = transitionWorkerArrival(ctx, assignment, now); + if (now < gathering.gatheringEndsAtMicros || gathering.phase === 'returning') return; + settleAndBeginReturnAt(ctx, gathering, gathering.gatheringEndsAtMicros, 10_000); + return; + } + if (schedule.stage !== WORKER_SCHEDULE_STAGE_RETURN_COMPLETE) return; + let current = assignment; + if (current.phase === 'outbound' && now >= current.arrivesAtMicros) current = transitionWorkerArrival(ctx, current, now); + if ((current.phase === 'outbound' || current.phase === 'gathering') && now >= current.gatheringEndsAtMicros) { + current = settleAndBeginReturnAt(ctx, current, current.gatheringEndsAtMicros, 10_000); + } + completeWorkerReturn(ctx, current, now); +} + +export function recallCastleWorker( + ctx: WarpkeepReducerContext, + input: Readonly<{ fid: bigint; castle: CastleRow; workerId: string; idempotencyKey: string }>, +): void { + const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey); + const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); + if (prior !== null) { + if (prior.fid !== input.fid || prior.commandKind !== 'recall' || prior.workerId !== input.workerId) fail('WORKER_IDEMPOTENCY_CONFLICT'); + return; + } + workerSystemActive(ctx); + settleAllWorkerAssignmentsForFid(ctx, input.fid); + const worker = ctx.db.castleWorkerV1.workerId.find(input.workerId); + if (worker === null || worker.originCastleId !== input.castle.castleId) fail('WORKER_NOT_OWNED'); + assertCastleWorkerRoster(ctx, input.castle.castleId); + if (worker.assignmentId === undefined) { + ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: worker.workerId, commandKind: 'recall', resourceKind: undefined, siteId: undefined, assignmentId: undefined, resultRevision: worker.revision, createdAt: ctx.timestamp }); + return; + } + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(worker.assignmentId); + if (assignment === null || assignment.fid !== input.fid) fail('WORKER_ASSIGNMENT_MISSING'); + assertAssignmentState(assignment); + if (assignment.phase === 'outbound') { + beginWorkerReturn(ctx, assignment, progressBasisPoints(assignment, ctx.timestamp.microsSinceUnixEpoch), ctx.timestamp.microsSinceUnixEpoch); + } else if (assignment.phase === 'gathering') { + beginWorkerReturn(ctx, assignment, 10_000, ctx.timestamp.microsSinceUnixEpoch); + } + ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: worker.workerId, commandKind: 'recall', resourceKind: assignment.resourceKind, siteId: assignment.siteId, assignmentId: assignment.assignmentId, resultRevision: worker.revision, createdAt: ctx.timestamp }); +} + +export function recallAllCastleWorkers( + ctx: WarpkeepReducerContext, + input: Readonly<{ fid: bigint; castle: CastleRow; idempotencyKey: string }>, +): void { + const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey); + const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); + if (prior !== null) { + if (prior.fid !== input.fid || prior.commandKind !== 'recall-all' || prior.workerId !== undefined) fail('WORKER_IDEMPOTENCY_CONFLICT'); + return; + } + workerSystemActive(ctx); + const roster = assertCastleWorkerRoster(ctx, input.castle.castleId); + settleAllWorkerAssignmentsForFid(ctx, input.fid); + const now = ctx.timestamp.microsSinceUnixEpoch; + let lastAssignmentId: string | undefined; + for (const worker of [...roster].sort((left, right) => left.ordinal - right.ordinal)) { + const fresh = ctx.db.castleWorkerV1.workerId.find(worker.workerId); + if (fresh === null || fresh.originCastleId !== input.castle.castleId) fail('WORKER_ROSTER_INTEGRITY'); + if (fresh.assignmentId === undefined) continue; + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(fresh.assignmentId); + if (assignment === null || assignment.fid !== input.fid || !publicWorkerMatchesAssignment(fresh, assignment)) fail('WORKER_ASSIGNMENT_INTEGRITY'); + if (assignment.phase === 'outbound') { + lastAssignmentId = beginWorkerReturn(ctx, assignment, progressBasisPoints(assignment, now), now).assignmentId; + } else if (assignment.phase === 'gathering') { + lastAssignmentId = beginWorkerReturn(ctx, assignment, 10_000, now).assignmentId; + } + } + ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: undefined, commandKind: 'recall-all', resourceKind: undefined, siteId: undefined, assignmentId: lastAssignmentId, resultRevision: 0n, createdAt: ctx.timestamp }); +} + +export type WorkerGraphAggregate = Readonly<{ + systemRows: bigint; + mode: string; + expectedCastleCount: bigint; + expectedWorkerCount: bigint; + actualWorkerCount: bigint; + castlesMissingWorkers: bigint; + castlesWithExtraWorkers: bigint; + duplicateOrdinals: bigint; + malformedWorkerIds: bigint; + idleWorkers: bigint; + outboundWorkers: bigint; + gatheringWorkers: bigint; + returningWorkers: bigint; + assignments: bigint; + occupations: bigint; + schedules: bigint; + orphanWorkers: bigint; + orphanAssignments: bigint; + orphanOccupations: bigint; + assignmentPublicMismatches: bigint; + occupationSiteMismatches: bigint; + legacyExpeditions: bigint; + legacyOccupations: bigint; + legacySchedules: bigint; + rosterDigest: string; + rosterDigestExpected: string; +}>; + +function legacyActiveCounts(ctx: WarpkeepReducerContext): Readonly<{ expeditions: bigint; occupations: bigint; schedules: bigint }> { + return Object.freeze({ + expeditions: ctx.db.goldExpeditionV1.count() + ctx.db.foodExpeditionV1.count() + ctx.db.woodExpeditionV1.count() + ctx.db.stoneExpeditionV1.count(), + occupations: ctx.db.goldNodeOccupationV1.count() + ctx.db.foodNodeOccupationV1.count() + ctx.db.woodNodeOccupationV1.count() + ctx.db.stoneNodeOccupationV1.count(), + schedules: ctx.db.goldExpeditionScheduleV1.count() + ctx.db.foodExpeditionScheduleV1.count() + ctx.db.woodExpeditionScheduleV1.count() + ctx.db.stoneExpeditionScheduleV1.count(), + }); +} + +export function inspectCastleWorkerGraph(ctx: WarpkeepReducerContext): WorkerGraphAggregate { + const system = ctx.db.realmWorkerSystemV1.realmId.find(WORKER_SYSTEM_REALM_ID); + const castles = [...ctx.db.castle.iter()].sort((left, right) => left.castleId < right.castleId ? -1 : left.castleId > right.castleId ? 1 : 0); + let castlesMissingWorkers = 0n; + let castlesWithExtraWorkers = 0n; + let duplicateOrdinals = 0n; + let malformedWorkerIds = 0n; + let orphanWorkers = 0n; + let idleWorkers = 0n; + let outboundWorkers = 0n; + let gatheringWorkers = 0n; + let returningWorkers = 0n; + for (const castle of castles) { + const rows = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castle.castleId)]; + if (rows.length < CASTLE_WORKERS_PER_CASTLE) castlesMissingWorkers += 1n; + if (rows.length > CASTLE_WORKERS_PER_CASTLE) castlesWithExtraWorkers += 1n; + const ordinals = new Set(); + for (const row of rows) { + try { assertCastleWorkerId(row.workerId); } catch { malformedWorkerIds += 1n; } + if (ordinals.has(row.ordinal)) duplicateOrdinals += 1n; + ordinals.add(row.ordinal); + if (row.status === 'idle') idleWorkers += 1n; + if (row.status === 'outbound') outboundWorkers += 1n; + if (row.status === 'gathering') gatheringWorkers += 1n; + if (row.status === 'returning') returningWorkers += 1n; + } + } + for (const row of ctx.db.castleWorkerV1.iter()) { + if (ctx.db.castle.castleId.find(row.originCastleId) === null) orphanWorkers += 1n; + } + let orphanAssignments = 0n; + let assignmentPublicMismatches = 0n; + for (const assignment of ctx.db.workerAssignmentV1.iter()) { + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null) orphanAssignments += 1n; + else if (!publicWorkerMatchesAssignment(worker, assignment)) assignmentPublicMismatches += 1n; + } + let orphanOccupations = 0n; + let occupationSiteMismatches = 0n; + for (const occupation of ctx.db.workerNodeOccupationV1.iter()) { + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(occupation.assignmentId); + if (assignment === null) orphanOccupations += 1n; + if (occupation.nodeKey !== `${occupation.resourceKind}:${occupation.siteId}`) occupationSiteMismatches += 1n; + if (assignment !== null && (assignment.phase === 'returning' || assignment.siteId !== occupation.siteId || assignment.resourceKind !== occupation.resourceKind)) occupationSiteMismatches += 1n; + } + const legacy = legacyActiveCounts(ctx); + const castleIds = castles.map(castle => castle.castleId); + return Object.freeze({ + systemRows: ctx.db.realmWorkerSystemV1.count(), + mode: system?.mode ?? 'absent', + expectedCastleCount: BigInt(system?.expectedCastleCount ?? 0), + expectedWorkerCount: BigInt(system?.expectedWorkerCount ?? 0), + actualWorkerCount: ctx.db.castleWorkerV1.count(), + castlesMissingWorkers, + castlesWithExtraWorkers, + duplicateOrdinals, + malformedWorkerIds, + idleWorkers, + outboundWorkers, + gatheringWorkers, + returningWorkers, + assignments: ctx.db.workerAssignmentV1.count(), + occupations: ctx.db.workerNodeOccupationV1.count(), + schedules: ctx.db.workerAssignmentScheduleV1.count(), + orphanWorkers, + orphanAssignments, + orphanOccupations, + assignmentPublicMismatches, + occupationSiteMismatches, + legacyExpeditions: legacy.expeditions, + legacyOccupations: legacy.occupations, + legacySchedules: legacy.schedules, + rosterDigest: system?.rosterDigest ?? '', + rosterDigestExpected: rosterDigestForCastleIds(castleIds), + }); +} + +export function castleWorkerErrorCode(error: unknown): string | undefined { + if (error instanceof CastleWorkerAuthorityError || error instanceof CastleWorkerPolicyError) return error.code; + return undefined; +} diff --git a/spacetimedb/src/castleWorkerPolicy.ts b/spacetimedb/src/castleWorkerPolicy.ts new file mode 100644 index 00000000..cc396a64 --- /dev/null +++ b/spacetimedb/src/castleWorkerPolicy.ts @@ -0,0 +1,314 @@ +import { + FOOD_EXPEDITION_POLICY_VERSION, + FOOD_GATHERING_DURATION_MICROS, + FOOD_GATHER_QUANTUM_MICROS, + FOOD_GATHER_RATE_PER_QUANTUM, +} from './foodExpeditionPolicy'; +import { + GENESIS_TIER_I_FOOD_SITE_COUNT, + GENESIS_TIER_I_FOOD_SITE_DIGEST, + FOOD_SITE_POLICY_VERSION, + canonicalFoodSiteV1ForId, + matchesCanonicalTierIFoodSiteV1, +} from './foodSitePolicy'; +import { + GOLD_EXPEDITION_POLICY_VERSION, + GOLD_GATHERING_DURATION_MICROS, + GOLD_GATHER_QUANTUM_MICROS, + GOLD_GATHER_RATE_PER_QUANTUM, +} from './goldExpeditionPolicy'; +import { + GENESIS_TIER_I_GOLD_SITE_COUNT, + GENESIS_TIER_I_GOLD_SITE_DIGEST, + GOLD_SITE_POLICY_VERSION, + canonicalGoldSiteV1ForId, + matchesCanonicalTierIGoldSiteV1, + canonicalPassableRouteSteps, +} from './goldSitePolicy'; +import { + STONE_EXPEDITION_POLICY_VERSION, + STONE_GATHERING_DURATION_MICROS, + STONE_GATHER_QUANTUM_MICROS, + STONE_GATHER_RATE_PER_QUANTUM, +} from './stoneExpeditionPolicy'; +import { + GENESIS_TIER_I_STONE_SITE_COUNT, + GENESIS_TIER_I_STONE_SITE_DIGEST, + STONE_SITE_POLICY_VERSION, + canonicalStoneSiteV1ForId, + matchesCanonicalTierIStoneSiteV1, +} from './stoneSitePolicy'; +import { + WOOD_EXPEDITION_POLICY_VERSION, + WOOD_GATHERING_DURATION_MICROS, + WOOD_GATHER_QUANTUM_MICROS, + WOOD_GATHER_RATE_PER_QUANTUM, +} from './woodExpeditionPolicy'; +import { + GENESIS_TIER_I_WOOD_SITE_COUNT, + GENESIS_TIER_I_WOOD_SITE_DIGEST, + WOOD_SITE_POLICY_VERSION, + canonicalWoodSiteV1ForId, + matchesCanonicalTierIWoodSiteV1, +} from './woodSitePolicy'; + +export const CASTLE_WORKERS_PER_CASTLE = 4; +export const CASTLE_WORKER_POLICY_VERSION = 'genesis-001-castle-workers-v1'; +export const CASTLE_WORKER_GATHER_QUANTUM_MICROS = 60_000_000n; +export const CASTLE_WORKER_TRAVEL_MICROS_PER_STEP = 30_000_000n; +export const CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS = 30n * 24n * 60n * 60n * 1_000_000n; +export const CASTLE_WORKER_U64_MAX = (1n << 64n) - 1n; +export const CASTLE_WORKER_PROTOCOL_CAPABILITY = 'generic-castle-workers-v1'; + +export type WorkerResourceKind = 'gold' | 'food' | 'wood' | 'stone'; +export type CastleWorkerPhase = 'outbound' | 'gathering' | 'returning'; +export type CastleWorkerStatus = 'idle' | CastleWorkerPhase; + +export type CastleWorkerSiteShape = Readonly<{ + siteId: string; + q: number; + r: number; + tier: number; + active: boolean; +}>; + +export type CastleWorkerResourcePolicy = Readonly<{ + kind: WorkerResourceKind; + siteTable: string; + sitePolicyVersion: string; + siteCatalogDigest: string; + canonicalSiteCount: number; + expeditionPolicyVersion: string; + quantumMicros: bigint; + ratePerQuantum: bigint; + gatheringDurationMicros: bigint; + gatheringTotal: bigint; + canonicalSiteForId: (siteId: string) => CastleWorkerSiteShape | undefined; + matchesCanonicalSite: (site: CastleWorkerSiteShape) => boolean; +}>; + +const RESOURCE_POLICIES: Readonly> = Object.freeze({ + gold: Object.freeze({ + kind: 'gold', + siteTable: 'goldSiteV1', + sitePolicyVersion: GOLD_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_GOLD_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_GOLD_SITE_COUNT, + expeditionPolicyVersion: GOLD_EXPEDITION_POLICY_VERSION, + quantumMicros: GOLD_GATHER_QUANTUM_MICROS, + ratePerQuantum: GOLD_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: GOLD_GATHERING_DURATION_MICROS, + gatheringTotal: (GOLD_GATHERING_DURATION_MICROS / GOLD_GATHER_QUANTUM_MICROS) * GOLD_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalGoldSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIGoldSiteV1, + }), + food: Object.freeze({ + kind: 'food', + siteTable: 'foodSiteV1', + sitePolicyVersion: FOOD_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_FOOD_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_FOOD_SITE_COUNT, + expeditionPolicyVersion: FOOD_EXPEDITION_POLICY_VERSION, + quantumMicros: FOOD_GATHER_QUANTUM_MICROS, + ratePerQuantum: FOOD_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: FOOD_GATHERING_DURATION_MICROS, + gatheringTotal: (FOOD_GATHERING_DURATION_MICROS / FOOD_GATHER_QUANTUM_MICROS) * FOOD_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalFoodSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIFoodSiteV1, + }), + wood: Object.freeze({ + kind: 'wood', + siteTable: 'woodSiteV1', + sitePolicyVersion: WOOD_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_WOOD_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_WOOD_SITE_COUNT, + expeditionPolicyVersion: WOOD_EXPEDITION_POLICY_VERSION, + quantumMicros: WOOD_GATHER_QUANTUM_MICROS, + ratePerQuantum: WOOD_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: WOOD_GATHERING_DURATION_MICROS, + gatheringTotal: (WOOD_GATHERING_DURATION_MICROS / WOOD_GATHER_QUANTUM_MICROS) * WOOD_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalWoodSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIWoodSiteV1, + }), + stone: Object.freeze({ + kind: 'stone', + siteTable: 'stoneSiteV1', + sitePolicyVersion: STONE_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_STONE_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_STONE_SITE_COUNT, + expeditionPolicyVersion: STONE_EXPEDITION_POLICY_VERSION, + quantumMicros: STONE_GATHER_QUANTUM_MICROS, + ratePerQuantum: STONE_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: STONE_GATHERING_DURATION_MICROS, + gatheringTotal: (STONE_GATHERING_DURATION_MICROS / STONE_GATHER_QUANTUM_MICROS) * STONE_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalStoneSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIStoneSiteV1, + }), +}); + +export class CastleWorkerPolicyError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'CastleWorkerPolicyError'; + } +} + +function fail(code: string): never { + throw new CastleWorkerPolicyError(code); +} + +function assertU64(value: unknown, code: string): asserts value is bigint { + if (typeof value !== 'bigint' || value < 0n || value > CASTLE_WORKER_U64_MAX) fail(code); +} + +function checkedSum(left: bigint, right: bigint, code: string): bigint { + assertU64(left, code); + assertU64(right, code); + if (right > CASTLE_WORKER_U64_MAX - left) fail(code); + return left + right; +} + +function checkedProduct(left: bigint, right: bigint, code: string): bigint { + assertU64(left, code); + assertU64(right, code); + if (left !== 0n && right > CASTLE_WORKER_U64_MAX / left) fail(code); + return left * right; +} + +export function workerResourcePolicy(kind: string): CastleWorkerResourcePolicy { + if (kind !== 'gold' && kind !== 'food' && kind !== 'wood' && kind !== 'stone') { + fail('WORKER_RESOURCE_UNSUPPORTED'); + } + return RESOURCE_POLICIES[kind]; +} + +export function workerResourceKinds(): readonly WorkerResourceKind[] { + return Object.freeze(['gold', 'food', 'wood', 'stone']); +} + +export function workerIdForCastle(castleId: bigint, ordinal: number): string { + if (castleId < 0n || !Number.isSafeInteger(ordinal) || ordinal < 1 || ordinal > CASTLE_WORKERS_PER_CASTLE) { + fail('WORKER_ROSTER_ORDINAL_INVALID'); + } + return `genesis-001-castle-${castleId.toString()}-worker-${String(ordinal).padStart(2, '0')}`; +} + +export function assertCastleWorkerId(workerId: string): void { + if (!/^genesis-001-castle-[0-9]+-worker-0[1-4]$/.test(workerId)) { + fail('WORKER_ID_INVALID'); + } +} + +export function assertWorkerCommandKey(value: string): void { + if (!/^[a-z0-9][a-z0-9-]{15,79}$/.test(value)) fail('WORKER_COMMAND_KEY_INVALID'); +} + +export type CastleWorkerTimeline = Readonly<{ + startedAtMicros: bigint; + arrivesAtMicros: bigint; + gatheringEndsAtMicros: bigint; + returnsAtMicros: bigint; +}>; + +export function planCastleWorkerTimeline(startedAtMicros: bigint, routeSteps: number): CastleWorkerTimeline { + assertU64(startedAtMicros, 'WORKER_START_TIME_INVALID'); + if (!Number.isSafeInteger(routeSteps) || routeSteps <= 0) fail('WORKER_ROUTE_INVALID'); + const travelMicros = checkedProduct(BigInt(routeSteps), CASTLE_WORKER_TRAVEL_MICROS_PER_STEP, 'WORKER_TIME_OVERFLOW'); + const arrivesAtMicros = checkedSum(startedAtMicros, travelMicros, 'WORKER_TIME_OVERFLOW'); + const gatheringEndsAtMicros = checkedSum(arrivesAtMicros, CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS, 'WORKER_TIME_OVERFLOW'); + const returnsAtMicros = checkedSum(gatheringEndsAtMicros, travelMicros, 'WORKER_TIME_OVERFLOW'); + return Object.freeze({ startedAtMicros, arrivesAtMicros, gatheringEndsAtMicros, returnsAtMicros }); +} + +export type CastleWorkerAccrualState = Readonly<{ + phase: string; + startedAtMicros: bigint; + arrivesAtMicros: bigint; + gatheringEndsAtMicros: bigint; + returnsAtMicros: bigint; + settledThroughMicros: bigint; + accruedAmount: bigint; + materializedAmount: bigint; + resourceKind: string; + policyVersion: string; +}>; + +export type CastleWorkerAccrualPlan = Readonly<{ + accruedAmount: bigint; + newlyAccruedAmount: bigint; + completedQuanta: bigint; + settledThroughMicros: bigint; +}>; + +export function workerAssignmentStateIsConsistent(state: CastleWorkerAccrualState): boolean { + try { + const policy = workerResourcePolicy(state.resourceKind); + assertU64(state.startedAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.arrivesAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.gatheringEndsAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.returnsAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.settledThroughMicros, 'WORKER_CURSOR_INVALID'); + assertU64(state.accruedAmount, 'WORKER_ACCRUAL_INVALID'); + assertU64(state.materializedAmount, 'WORKER_MATERIALIZED_INVALID'); + if ( + state.policyVersion !== CASTLE_WORKER_POLICY_VERSION + || (state.phase !== 'outbound' && state.phase !== 'gathering' && state.phase !== 'returning') + || !(state.startedAtMicros < state.arrivesAtMicros + && state.arrivesAtMicros < state.gatheringEndsAtMicros + && state.gatheringEndsAtMicros < state.returnsAtMicros) + || state.arrivesAtMicros > state.settledThroughMicros + || state.settledThroughMicros > state.gatheringEndsAtMicros + || state.materializedAmount > state.accruedAmount + || state.accruedAmount > policy.gatheringTotal + ) return false; + // A recall can begin during outbound travel, so a returning assignment may + // legitimately have zero or partial gathering accrual. Scheduled expiry + // still settles the full cap before opening the return timeline. + return true; + } catch { + return false; + } +} + +export function planCastleWorkerAccrual( + state: CastleWorkerAccrualState, + observedAtMicros: bigint, +): CastleWorkerAccrualPlan { + if (!workerAssignmentStateIsConsistent(state)) fail('WORKER_ASSIGNMENT_STATE_INVALID'); + assertU64(observedAtMicros, 'WORKER_OBSERVED_TIME_INVALID'); + const policy = workerResourcePolicy(state.resourceKind); + const ceiling = observedAtMicros < state.gatheringEndsAtMicros ? observedAtMicros : state.gatheringEndsAtMicros; + if (ceiling <= state.settledThroughMicros) { + return Object.freeze({ accruedAmount: state.accruedAmount, newlyAccruedAmount: 0n, completedQuanta: 0n, settledThroughMicros: state.settledThroughMicros }); + } + const completedQuanta = (ceiling - state.settledThroughMicros) / policy.quantumMicros; + const elapsed = checkedProduct(completedQuanta, policy.quantumMicros, 'WORKER_ACCRUAL_OVERFLOW'); + const settledThroughMicros = checkedSum(state.settledThroughMicros, elapsed, 'WORKER_ACCRUAL_OVERFLOW'); + const newlyAccruedAmount = checkedProduct(completedQuanta, policy.ratePerQuantum, 'WORKER_ACCRUAL_OVERFLOW'); + const accruedAmount = checkedSum(state.accruedAmount, newlyAccruedAmount, 'WORKER_ACCRUAL_OVERFLOW'); + if (accruedAmount > policy.gatheringTotal) fail('WORKER_ACCRUAL_CAP'); + return Object.freeze({ accruedAmount, newlyAccruedAmount, completedQuanta, settledThroughMicros }); +} + +/** Route authority is shared across all four canonical site catalogs. */ +export function canonicalWorkerRouteSteps( + origin: Readonly<{ q: number; r: number }>, + destination: Readonly<{ q: number; r: number }>, +): number | undefined { + return canonicalPassableRouteSteps(origin, destination); +} + +/** Stable roster digest; order and worker identity are part of the boundary. */ +export function rosterDigestForCastleIds(castleIds: readonly bigint[]): string { + const ids = [...castleIds].sort((a, b) => a < b ? -1 : a > b ? 1 : 0); + let hash = 0xcbf29ce484222325n; + for (const castleId of ids) { + for (const workerId of Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, i) => workerIdForCastle(castleId, i + 1))) { + for (const byte of new TextEncoder().encode(workerId)) { + hash ^= BigInt(byte); + hash = (hash * 0x100000001b3n) & CASTLE_WORKER_U64_MAX; + } + } + } + return hash.toString(16).padStart(16, '0'); +} diff --git a/spacetimedb/src/castleWorkerRoster.ts b/spacetimedb/src/castleWorkerRoster.ts new file mode 100644 index 00000000..29a6c0c7 --- /dev/null +++ b/spacetimedb/src/castleWorkerRoster.ts @@ -0,0 +1,118 @@ +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; + +import { + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKERS_PER_CASTLE, + workerIdForCastle, + assertCastleWorkerId, +} from './castleWorkerPolicy'; +import type warpkeep from './schema'; + +type WarpkeepReducerContext = ReducerCtx>; +type CastleRow = NonNullable>; +type CastleWorkerRow = NonNullable>; + +function fail(code = 'WORKER_ROSTER_INTEGRITY'): never { + throw new Error(code); +} + +export function expectedWorkerRowsForCastle( + castle: Pick, + timestamp: WarpkeepReducerContext['timestamp'], +): readonly CastleWorkerRow[] { + return Object.freeze(Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => { + const ordinal = index + 1; + return Object.freeze({ + workerId: workerIdForCastle(castle.castleId, ordinal), + originCastleId: castle.castleId, + ordinal, + status: 'idle', + assignmentId: undefined, + resourceKind: undefined, + siteId: undefined, + startedAtMicros: undefined, + arrivesAtMicros: undefined, + gatheringEndsAtMicros: undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: undefined, + routeSteps: undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 0, + revision: 0n, + createdAt: timestamp, + updatedAt: timestamp, + }); + })); +} + +export function workerSystemRowIsStagedOrActive( + row: NonNullable>, +): boolean { + return row.realmId === 'GENESIS_001' + && row.policyVersion === CASTLE_WORKER_POLICY_VERSION + && row.workersPerCastle === CASTLE_WORKERS_PER_CASTLE + && (row.mode === 'staged' || row.mode === 'active') + && row.expectedCastleCount >= 0 + && row.expectedWorkerCount === row.expectedCastleCount * CASTLE_WORKERS_PER_CASTLE; +} + +export function assertCastleWorkerRoster( + ctx: WarpkeepReducerContext, + castleId: bigint, +): readonly CastleWorkerRow[] { + const castle = ctx.db.castle.castleId.find(castleId); + if (castle === null) fail('WORKER_CASTLE_MISSING'); + const rows = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castleId)] + .sort((left, right) => left.ordinal - right.ordinal || left.workerId.localeCompare(right.workerId)); + if (rows.length !== CASTLE_WORKERS_PER_CASTLE) fail('WORKER_ROSTER_INCOMPLETE'); + const expectedIds = new Set(); + for (const row of rows) { + assertCastleWorkerId(row.workerId); + if ( + row.originCastleId !== castleId + || row.ordinal < 1 + || row.ordinal > CASTLE_WORKERS_PER_CASTLE + || expectedIds.has(row.workerId) + || row.workerId !== workerIdForCastle(castleId, row.ordinal) + || row.revision < 0n + || row.timelineRevision < 0 + ) fail('WORKER_ROSTER_INTEGRITY'); + expectedIds.add(row.workerId); + } + for (let ordinal = 1; ordinal <= CASTLE_WORKERS_PER_CASTLE; ordinal += 1) { + if (!expectedIds.has(workerIdForCastle(castleId, ordinal))) fail('WORKER_ROSTER_INTEGRITY'); + } + return Object.freeze(rows); +} + +/** + * Founding calls this only when generic mode is active. In staged mode the + * function is a no-op, so this PR cannot seed production workers accidentally. + */ +export function ensureCastleWorkerRoster( + ctx: WarpkeepReducerContext, + castle: CastleRow, +): void { + const system = ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001'); + if (system === null) return; + if (!workerSystemRowIsStagedOrActive(system)) fail('WORKER_SYSTEM_INTEGRITY'); + if (system.mode !== 'active') return; + const existing = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castle.castleId)]; + if (existing.length > 0) { + assertCastleWorkerRoster(ctx, castle.castleId); + return; + } + for (const row of expectedWorkerRowsForCastle(castle, ctx.timestamp)) { + ctx.db.castleWorkerV1.insert(row); + } + assertCastleWorkerRoster(ctx, castle.castleId); +} + +export function workerRosterDigestInput(castleIds: readonly bigint[]): string { + return [...castleIds] + .sort((left, right) => left < right ? -1 : left > right ? 1 : 0) + .flatMap(castleId => Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => ( + workerIdForCastle(castleId, index + 1) + ))) + .join('|'); +} diff --git a/spacetimedb/src/foundingAuthority.ts b/spacetimedb/src/foundingAuthority.ts index bf979100..ea4e5188 100644 --- a/spacetimedb/src/foundingAuthority.ts +++ b/spacetimedb/src/foundingAuthority.ts @@ -14,6 +14,7 @@ import { GENESIS_RESOURCE_POLICY_VERSION, GENESIS_STARTING_RESOURCE_BALANCES, } from './resourceAuthorityPolicy'; +import { ensureCastleWorkerRoster } from './castleWorkerRoster'; import { admissionProfileIsComplete, trustedProfilesEqual, @@ -314,6 +315,11 @@ export function ensureGenesisFounder( updatedAt: ctx.timestamp, }); + // Generic workers are created only after a separately authorized active + // system row exists. The PR ships staged authority and therefore performs + // no production roster backfill or activation. + ensureCastleWorkerRoster(ctx, castle); + assertGenesisFoundingGraph(ctx); return 'created'; } diff --git a/spacetimedb/src/index.ts b/spacetimedb/src/index.ts index 429d630a..b4b3c138 100644 --- a/spacetimedb/src/index.ts +++ b/spacetimedb/src/index.ts @@ -41,6 +41,15 @@ export { adminBackfillResourceAccountsV1, adminGetAlphaStatusV4, } from './reducers/resources'; +export { + getMyWorkerRosterV1, + getMyResourceStateV2, + dispatchWorkerV1, + recallWorkerV1, + recallAllWorkersV1, + adminGetWorkerSystemStatusV1, + adminPlanWorkerRosterV1, +} from './reducers/castleWorkers'; export { getMyGoldExpeditionStateV1, dispatchGoldExpeditionV1, @@ -83,4 +92,5 @@ export { runFoodExpeditionScheduleV1, runWoodExpeditionScheduleV1, runStoneExpeditionScheduleV1, + runCastleWorkerScheduleV1, } from './schema'; diff --git a/spacetimedb/src/reducers/castleWorkers.ts b/spacetimedb/src/reducers/castleWorkers.ts new file mode 100644 index 00000000..f55026f2 --- /dev/null +++ b/spacetimedb/src/reducers/castleWorkers.ts @@ -0,0 +1,270 @@ +import { SenderError, t } from 'spacetimedb/server'; + +import { requireAdmin, requireGameplayPlayerV1 } from '../auth'; +import { + castleWorkerErrorCode, + dispatchCastleWorker, + inspectCastleWorkerGraph, + projectMyWorkerState, + recallAllCastleWorkers, + recallCastleWorker, +} from '../castleWorkerAuthority'; +import warpkeep from '../schema'; + +const workerPrivate = t.object('WorkerPrivateV1', { + workerId: t.string(), + ordinal: t.u32(), + status: t.string(), + resourceKind: t.option(t.string()), + siteId: t.option(t.string()), + accruedAmount: t.u64(), + materializedAmount: t.u64(), + availableAmount: t.u64(), + observedAtMicros: t.u64(), + revision: t.u64(), +}); + +const myWorkerRoster = t.object('MyWorkerRosterV1', { + fid: t.u64(), + castleId: t.u64(), + observedAtMicros: t.u64(), + workers: t.array(workerPrivate), +}); + +const myResourceStateV2 = t.object('MyResourceStateV2', { + fid: t.u64(), + food: t.u64(), + wood: t.u64(), + stone: t.u64(), + gold: t.u64(), + workerPendingFood: t.u64(), + workerPendingWood: t.u64(), + workerPendingStone: t.u64(), + workerPendingGold: t.u64(), + observedAtMicros: t.u64(), + settledThroughMicros: t.u64(), + revision: t.u64(), + resourcePolicyVersion: t.string(), + workerPolicyVersion: t.string(), + workerSystemMode: t.string(), +}); + +const adminWorkerSystemStatus = t.object('AdminWorkerSystemStatusV1', { + systemRows: t.u64(), + mode: t.string(), + expectedCastleCount: t.u64(), + expectedWorkerCount: t.u64(), + actualWorkerCount: t.u64(), + castlesMissingWorkers: t.u64(), + castlesWithExtraWorkers: t.u64(), + duplicateOrdinals: t.u64(), + malformedWorkerIds: t.u64(), + idleWorkers: t.u64(), + outboundWorkers: t.u64(), + gatheringWorkers: t.u64(), + returningWorkers: t.u64(), + assignments: t.u64(), + occupations: t.u64(), + schedules: t.u64(), + orphanWorkers: t.u64(), + orphanAssignments: t.u64(), + orphanOccupations: t.u64(), + assignmentPublicMismatches: t.u64(), + occupationSiteMismatches: t.u64(), + legacyExpeditions: t.u64(), + legacyOccupations: t.u64(), + legacySchedules: t.u64(), + rosterDigest: t.string(), + rosterDigestExpected: t.string(), +}); + +const adminWorkerRosterPlan = t.object('AdminWorkerRosterPlanV1', { + ready: t.bool(), + activationBlockedByLegacyRows: t.bool(), + mode: t.string(), + expectedCastleCount: t.u64(), + expectedWorkerCount: t.u64(), + actualWorkerCount: t.u64(), + castlesMissingWorkers: t.u64(), + castlesWithExtraWorkers: t.u64(), + orphanWorkers: t.u64(), + orphanAssignments: t.u64(), + orphanOccupations: t.u64(), + assignmentPublicMismatches: t.u64(), + occupationSiteMismatches: t.u64(), + legacyExpeditions: t.u64(), + legacyOccupations: t.u64(), + legacySchedules: t.u64(), + rosterDigest: t.string(), + rosterDigestExpected: t.string(), +}); + +function senderPolicyError(error: unknown): never { + const code = castleWorkerErrorCode(error); + if (code !== undefined) throw new SenderError(code); + throw error; +} + +function workerSystemMode(ctx: Parameters[0]): string { + return ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001')?.mode ?? 'absent'; +} + +function aggregateResult(aggregate: ReturnType) { + return { ...aggregate }; +} + +export const getMyWorkerRosterV1 = warpkeep.procedure( + { name: 'get_my_worker_roster_v1' }, + myWorkerRoster, + ctx => ctx.withTx(tx => { + try { + const { claims, castle } = requireGameplayPlayerV1(tx); + const observedAtMicros = tx.timestamp.microsSinceUnixEpoch; + const projection = projectMyWorkerState(tx, claims.fid, observedAtMicros); + return { + fid: claims.fid, + castleId: castle.castleId, + observedAtMicros, + workers: projection.workers.map(worker => ({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + resourceKind: worker.resourceKind, + siteId: worker.siteId, + accruedAmount: worker.accruedAmount, + materializedAmount: worker.materializedAmount, + availableAmount: worker.availableAmount, + observedAtMicros: worker.observedAtMicros, + revision: worker.revision, + })), + }; + } catch (error) { + return senderPolicyError(error); + } + }), +); + +export const getMyResourceStateV2 = warpkeep.procedure( + { name: 'get_my_resource_state_v2' }, + myResourceStateV2, + ctx => ctx.withTx(tx => { + try { + const { claims } = requireGameplayPlayerV1(tx); + const observedAtMicros = tx.timestamp.microsSinceUnixEpoch; + const projection = projectMyWorkerState(tx, claims.fid, observedAtMicros); + const pending = { food: 0n, wood: 0n, stone: 0n, gold: 0n }; + for (const worker of projection.workers) { + if (worker.resourceKind === 'food') pending.food += worker.availableAmount; + if (worker.resourceKind === 'wood') pending.wood += worker.availableAmount; + if (worker.resourceKind === 'stone') pending.stone += worker.availableAmount; + if (worker.resourceKind === 'gold') pending.gold += worker.availableAmount; + } + return { + fid: claims.fid, + food: projection.balances.food, + wood: projection.balances.wood, + stone: projection.balances.stone, + gold: projection.balances.gold, + workerPendingFood: pending.food, + workerPendingWood: pending.wood, + workerPendingStone: pending.stone, + workerPendingGold: pending.gold, + observedAtMicros, + settledThroughMicros: projection.resource.settledThroughMicros, + revision: projection.resource.revision, + resourcePolicyVersion: projection.resource.policyVersion, + workerPolicyVersion: 'genesis-001-castle-workers-v1', + workerSystemMode: workerSystemMode(tx), + }; + } catch (error) { + return senderPolicyError(error); + } + }), +); + +export const dispatchWorkerV1 = warpkeep.reducer( + { name: 'dispatch_worker_v1' }, + { workerId: t.string(), resourceKind: t.string(), siteId: t.string(), idempotencyKey: t.string() }, + (ctx, { workerId, resourceKind, siteId, idempotencyKey }) => { + try { + const { claims, castle } = requireGameplayPlayerV1(ctx); + dispatchCastleWorker(ctx, { fid: claims.fid, castle, workerId, resourceKind, siteId, idempotencyKey }); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +export const recallWorkerV1 = warpkeep.reducer( + { name: 'recall_worker_v1' }, + { workerId: t.string(), idempotencyKey: t.string() }, + (ctx, { workerId, idempotencyKey }) => { + try { + const { claims, castle } = requireGameplayPlayerV1(ctx); + recallCastleWorker(ctx, { fid: claims.fid, castle, workerId, idempotencyKey }); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +export const recallAllWorkersV1 = warpkeep.reducer( + { name: 'recall_all_workers_v1' }, + { idempotencyKey: t.string() }, + (ctx, { idempotencyKey }) => { + try { + const { claims, castle } = requireGameplayPlayerV1(ctx); + recallAllCastleWorkers(ctx, { fid: claims.fid, castle, idempotencyKey }); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +export const adminGetWorkerSystemStatusV1 = warpkeep.procedure( + { name: 'admin_get_worker_system_status_v1' }, + adminWorkerSystemStatus, + ctx => ctx.withTx(tx => { + requireAdmin(tx); + return aggregateResult(inspectCastleWorkerGraph(tx)); + }), +); + +export const adminPlanWorkerRosterV1 = warpkeep.procedure( + { name: 'admin_plan_worker_roster_v1' }, + adminWorkerRosterPlan, + ctx => ctx.withTx(tx => { + requireAdmin(tx); + const aggregate = inspectCastleWorkerGraph(tx); + const legacyRows = aggregate.legacyExpeditions + aggregate.legacyOccupations + aggregate.legacySchedules; + return { + ready: aggregate.systemRows === 1n + && aggregate.mode === 'active' + && aggregate.castlesMissingWorkers === 0n + && aggregate.castlesWithExtraWorkers === 0n + && aggregate.orphanWorkers === 0n + && aggregate.orphanAssignments === 0n + && aggregate.orphanOccupations === 0n + && aggregate.assignmentPublicMismatches === 0n + && aggregate.occupationSiteMismatches === 0n + && legacyRows === 0n, + activationBlockedByLegacyRows: legacyRows !== 0n, + mode: aggregate.mode, + expectedCastleCount: aggregate.expectedCastleCount, + expectedWorkerCount: aggregate.expectedWorkerCount, + actualWorkerCount: aggregate.actualWorkerCount, + castlesMissingWorkers: aggregate.castlesMissingWorkers, + castlesWithExtraWorkers: aggregate.castlesWithExtraWorkers, + orphanWorkers: aggregate.orphanWorkers, + orphanAssignments: aggregate.orphanAssignments, + orphanOccupations: aggregate.orphanOccupations, + assignmentPublicMismatches: aggregate.assignmentPublicMismatches, + occupationSiteMismatches: aggregate.occupationSiteMismatches, + legacyExpeditions: aggregate.legacyExpeditions, + legacyOccupations: aggregate.legacyOccupations, + legacySchedules: aggregate.legacySchedules, + rosterDigest: aggregate.rosterDigest, + rosterDigestExpected: aggregate.rosterDigestExpected, + }; + }), +); diff --git a/spacetimedb/src/reducers/resources.ts b/spacetimedb/src/reducers/resources.ts index 5c6393cc..3c4077af 100644 --- a/spacetimedb/src/reducers/resources.ts +++ b/spacetimedb/src/reducers/resources.ts @@ -2,6 +2,7 @@ import { SenderError, t } from 'spacetimedb/server'; import { WARPKEEP_BACKEND_PROTOCOL_VERSION } from '../config'; import { requireAdmin, requireGameplayPlayerV1 } from '../auth'; +import { castleWorkerErrorCode, settleAllWorkerAssignmentsForFid } from '../castleWorkerAuthority'; import { markAccountIsConsistent } from '../marksAuthorityPolicy'; import { ResourceAuthorityError, @@ -73,6 +74,8 @@ const adminAlphaStatusV4 = t.object('AdminAlphaStatusV4', { }); function senderPolicyError(error: unknown): never { + const workerCode = castleWorkerErrorCode(error); + if (workerCode !== undefined) throw new SenderError(workerCode); const foodExpeditionCode = foodExpeditionErrorCode(error); if (foodExpeditionCode !== undefined) throw new SenderError(foodExpeditionCode); const woodExpeditionCode = woodExpeditionErrorCode(error); @@ -165,6 +168,10 @@ export const collectResourcesV1 = warpkeep.reducer( collectActiveFoodExpedition(ctx, claims.fid); collectActiveWoodExpedition(ctx, claims.fid); collectActiveStoneExpedition(ctx, claims.fid); + // Generic workers settle into the same private inventory at this exact + // server timestamp. This keeps the legacy collect reducer compatible + // while new clients use get_my_resource_state_v2 for no-write reads. + settleAllWorkerAssignmentsForFid(ctx, claims.fid); const resourceAfterExpeditions = assertGenesisResourceForFid(ctx, claims.fid); const settlement = planResourceSettlementForActiveExpeditionReservations( ctx, diff --git a/spacetimedb/src/resourceExpeditionReservationAuthority.ts b/spacetimedb/src/resourceExpeditionReservationAuthority.ts index d1e7ae2a..c70355b1 100644 --- a/spacetimedb/src/resourceExpeditionReservationAuthority.ts +++ b/spacetimedb/src/resourceExpeditionReservationAuthority.ts @@ -1,9 +1,17 @@ import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import { + workerResourcePolicy, +} from './castleWorkerPolicy'; + import { FOOD_GATHERING_TOTAL_FOOD, foodExpeditionStateIsConsistent, } from './foodExpeditionPolicy'; +import { + GOLD_GATHERING_TOTAL_GOLD, + goldExpeditionStateIsConsistent, +} from './goldExpeditionPolicy'; import { WOOD_GATHERING_TOTAL_WOOD, woodExpeditionStateIsConsistent, @@ -41,13 +49,14 @@ export type ActiveExpeditionResourceReservations = Readonly<{ food: bigint; wood: bigint; stone: bigint; + gold: bigint; }>; /** - * Return exact uncredited thirty-day awards for the caller's active Food, Wood, - * and Stone wagons. A returning row has already credited its whole award and - * thus reserves zero. Independent tables permit one wagon of each resource - * type. + * Return exact uncredited thirty-day awards for every active legacy wagon and + * generic assignment. A returning row has already credited its whole award and + * thus reserves zero. Independent tables permit one legacy wagon of each + * resource type while generic workers add their own private reservations. */ export function activeExpeditionResourceReservations( ctx: WarpkeepReducerContext, @@ -65,11 +74,29 @@ export function activeExpeditionResourceReservations( if (stone !== null && !stoneExpeditionStateIsConsistent(stone)) { fail('STONE_EXPEDITION_RESERVATION_STATE_INVALID'); } - return Object.freeze({ - food: food === null ? 0n : FOOD_GATHERING_TOTAL_FOOD - food.creditedFood, - wood: wood === null ? 0n : WOOD_GATHERING_TOTAL_WOOD - wood.creditedWood, - stone: stone === null ? 0n : STONE_GATHERING_TOTAL_STONE - stone.creditedStone, - }); + const gold = ctx.db.goldExpeditionV1.fid.find(fid); + if (gold !== null && !goldExpeditionStateIsConsistent(gold)) { + fail('GOLD_EXPEDITION_RESERVATION_STATE_INVALID'); + } + let foodReservation = food === null ? 0n : FOOD_GATHERING_TOTAL_FOOD - food.creditedFood; + let woodReservation = wood === null ? 0n : WOOD_GATHERING_TOTAL_WOOD - wood.creditedWood; + let stoneReservation = stone === null ? 0n : STONE_GATHERING_TOTAL_STONE - stone.creditedStone; + let goldReservation = gold === null ? 0n : GOLD_GATHERING_TOTAL_GOLD - gold.creditedGold; + for (const assignment of ctx.db.workerAssignmentV1.iter()) { + if (assignment.fid !== fid) continue; + if (assignment.phase === 'returning') continue; + const total = workerResourcePolicy(assignment.resourceKind).gatheringTotal; + // Reserve the complete remaining award, not only the currently accrued + // amount. This leaves room for lazy server-time settlement to materialize + // the exact future output without truncation. + const fullRemaining = total - assignment.materializedAmount; + if (fullRemaining < 0n) throw new ResourceExpeditionReservationAuthorityError('WORKER_RESERVATION_INVALID'); + if (assignment.resourceKind === 'food') foodReservation += fullRemaining; + if (assignment.resourceKind === 'wood') woodReservation += fullRemaining; + if (assignment.resourceKind === 'stone') stoneReservation += fullRemaining; + if (assignment.resourceKind === 'gold') goldReservation += fullRemaining; + } + return Object.freeze({ food: foodReservation, wood: woodReservation, stone: stoneReservation, gold: goldReservation }); } /** diff --git a/spacetimedb/src/schema.ts b/spacetimedb/src/schema.ts index 92f17dde..e9664761 100644 --- a/spacetimedb/src/schema.ts +++ b/spacetimedb/src/schema.ts @@ -16,6 +16,10 @@ import { runStoneExpeditionSchedule, stoneExpeditionErrorCode, } from './stoneExpeditionAuthority'; +import { + castleWorkerErrorCode, + runCastleWorkerSchedule, +} from './castleWorkerAuthority'; /** * Private closed-alpha admission list. This table is intentionally omitted @@ -1001,6 +1005,171 @@ export const realmWaterRevisionV1 = table( activatedAt: t.option(t.timestamp()), }, ); + +/** Public singleton for the staged generic-worker readiness boundary. */ +export const realmWorkerSystemV1 = table( + { name: 'realm_worker_system_v1', public: true }, + { + realmId: t.string().primaryKey(), + policyVersion: t.string(), + workersPerCastle: t.u32(), + expectedCastleCount: t.u32(), + expectedWorkerCount: t.u32(), + rosterDigest: t.string(), + mode: t.string(), + legacyDrainRequired: t.bool(), + createdAt: t.timestamp(), + activatedAt: t.option(t.timestamp()), + }, +); + +/** Public identity-safe worker roster and lifecycle presentation. */ +export const castleWorkerV1 = table( + { + name: 'castle_worker_v1', + public: true, + indexes: [{ + accessor: 'byOriginCastle', + algorithm: 'btree', + columns: ['originCastleId'] as const, + }] as const, + }, + { + workerId: t.string().primaryKey(), + originCastleId: t.u64(), + ordinal: t.u32(), + status: t.string(), + assignmentId: t.option(t.string()), + resourceKind: t.option(t.string()), + siteId: t.option(t.string()), + startedAtMicros: t.option(t.u64()), + arrivesAtMicros: t.option(t.u64()), + gatheringEndsAtMicros: t.option(t.u64()), + returnStartedAtMicros: t.option(t.u64()), + returnsAtMicros: t.option(t.u64()), + routeSteps: t.option(t.u32()), + returnStartProgressBasisPoints: t.option(t.u32()), + timelineRevision: t.u32(), + revision: t.u64(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + }, +); + +/** Private generic assignment authority. Never expose through subscriptions. */ +export const workerAssignmentV1 = table( + { + name: 'worker_assignment_v1', + indexes: [{ + accessor: 'byFidAndPhase', + algorithm: 'btree', + columns: ['fid', 'phase'] as const, + }] as const, + }, + { + assignmentId: t.string().primaryKey(), + workerId: t.string().unique(), + fid: t.u64(), + originCastleId: t.u64(), + resourceKind: t.string(), + siteId: t.string().index(), + phase: t.string(), + startedAtMicros: t.u64(), + arrivesAtMicros: t.u64(), + gatheringEndsAtMicros: t.u64(), + returnStartedAtMicros: t.option(t.u64()), + returnsAtMicros: t.u64(), + routeSteps: t.u32(), + returnStartProgressBasisPoints: t.u32(), + settledThroughMicros: t.u64(), + accruedAmount: t.u64(), + materializedAmount: t.u64(), + timelineRevision: t.u32(), + policyVersion: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + }, +); + +/** Public generic node lease. It is deleted when a worker starts returning. */ +export const workerNodeOccupationV1 = table( + { + name: 'worker_node_occupation_v1', + public: true, + indexes: [{ + accessor: 'byOriginCastle', + algorithm: 'btree', + columns: ['originCastleId'] as const, + }, { + accessor: 'byWorker', + algorithm: 'btree', + columns: ['workerId'] as const, + }] as const, + }, + { + nodeKey: t.string().primaryKey(), + resourceKind: t.string(), + siteId: t.string(), + workerId: t.string(), + workerOrdinal: t.u32(), + originCastleId: t.u64(), + assignmentId: t.string(), + phase: t.string(), + startedAtMicros: t.u64(), + arrivesAtMicros: t.u64(), + gatheringEndsAtMicros: t.u64(), + timelineRevision: t.u32(), + }, +); + +/** Private exactly-once command receipts for dispatch and recall commands. */ +export const workerCommandIdempotencyV1 = table( + { + name: 'worker_command_idempotency_v1', + indexes: [{ + accessor: 'byFid', + algorithm: 'btree', + columns: ['fid'] as const, + }] as const, + }, + { + requestKey: t.string().primaryKey(), + fid: t.u64(), + workerId: t.option(t.string()), + commandKind: t.string(), + resourceKind: t.option(t.string()), + siteId: t.option(t.string()), + assignmentId: t.option(t.string()), + resultRevision: t.u64(), + createdAt: t.timestamp(), + }, +); + +/** Public-safe lifecycle schedule projection for generic worker assignments. */ +export const workerAssignmentScheduleV1 = table( + { + name: 'worker_assignment_schedule_v_1', + public: true, + indexes: [{ + accessor: 'byAssignment', + algorithm: 'btree', + columns: ['assignmentId'] as const, + }, { + accessor: 'byWorker', + algorithm: 'btree', + columns: ['workerId'] as const, + }] as const, + scheduled: (): any => runCastleWorkerScheduleV1, + }, + { + scheduleId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + assignmentId: t.string(), + workerId: t.string(), + timelineRevision: t.u32(), + stage: t.string(), + }, +); const warpkeep = schema({ // Preserve the original production schema prefix exactly. New tables are // append-only so SpacetimeDB can apply this migration without rewriting it. @@ -1051,6 +1220,12 @@ const warpkeep = schema({ stoneExpeditionIdempotencyV1, stoneExpeditionScheduleV1, realmWaterRevisionV1, + realmWorkerSystemV1, + castleWorkerV1, + workerAssignmentV1, + workerNodeOccupationV1, + workerCommandIdempotencyV1, + workerAssignmentScheduleV1, }); /** @@ -1124,6 +1299,21 @@ export const runStoneExpeditionScheduleV1 = warpkeep.reducer( }, ); +/** Scheduler-only lifecycle reducer for staged generic castle workers. */ +export const runCastleWorkerScheduleV1 = warpkeep.reducer( + { name: 'run_worker_assignment_schedule_v_1' }, + { arg: workerAssignmentScheduleV1.rowType }, + (ctx, { arg }) => { + try { + runCastleWorkerSchedule(ctx, arg); + } catch (error) { + const code = castleWorkerErrorCode(error); + if (code !== undefined) throw new SenderError(code); + throw error; + } + }, +); + // SpacetimeDB 2.6's default case converter separates a trailing digit from // its prefix (`v2` -> `v_2`). Pin every versioned wire spelling explicitly. for (const name of [ @@ -1168,6 +1358,13 @@ for (const name of [ 'admin_seed_genesis_water_layout_v1', 'admin_activate_genesis_water_layout_v1', 'admin_inspect_genesis_water_layout_v1', + 'get_my_worker_roster_v1', + 'get_my_resource_state_v2', + 'dispatch_worker_v1', + 'recall_worker_v1', + 'recall_all_workers_v1', + 'admin_get_worker_system_status_v1', + 'admin_plan_worker_roster_v1', ]) { warpkeep.moduleDef.explicitNames.entries.push({ tag: 'Function', diff --git a/spacetimedb/tests/castleWorkerMigrationTooling.test.ts b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts new file mode 100644 index 00000000..453dd975 --- /dev/null +++ b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +function source(path: string): string { + return readFileSync(new URL(path, import.meta.url), 'utf8'); +} + +function schemaRegistrations(text: string, marker: string): string[] { + const start = text.indexOf(marker); + const end = text.indexOf('\n);', start); + assert.ok(start >= 0 && end > start); + return text.slice(start + marker.length, end) + .split(/[,\n]/) + .map(value => value.trim()) + .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); +} + +function tableDefinition(text: string, name: string): string { + const start = text.indexOf(`const ${name} = table(`); + const end = text.indexOf('\n);', start); + assert.ok(start >= 0 && end > start); + return text.slice(start, end); +} + +test('v12 fixture appends six generic-worker tables after the exact v11 prefix', () => { + const v11 = source('../migration-fixtures/additive-v11-schema/src/index.ts'); + const v12 = source('../migration-fixtures/additive-v12-schema/src/index.ts'); + const v11Registrations = schemaRegistrations(v11, 'const db = schema({'); + const v12Registrations = schemaRegistrations(v12, 'const db = schema({'); + assert.equal(v11Registrations.length, 47); + assert.deepEqual(v12Registrations.slice(0, 47), v11Registrations); + assert.deepEqual(v12Registrations.slice(47), [ + 'realmWorkerSystemV1', + 'castleWorkerV1', + 'workerAssignmentV1', + 'workerNodeOccupationV1', + 'workerCommandIdempotencyV1', + 'workerAssignmentScheduleV1', + ]); + assert.match(v12, /fixture_seed_generic_worker_sentinel_v12/); + assert.match(source('../migration-fixtures/additive-v12-schema/package.json'), /additive-v12-schema/); +}); + +test('public generic-worker rows exclude private ownership and accrual fields', () => { + const schema = source('../src/schema.ts'); + for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1', 'workerAssignmentScheduleV1']) { + const definition = tableDefinition(schema, name); + assert.match(definition, /public: true/); + assert.doesNotMatch(definition, /\bfid\b|accruedAmount|materializedAmount|balance|requestKey|auth/i); + } + const assignment = tableDefinition(schema, 'workerAssignmentV1'); + const idempotency = tableDefinition(schema, 'workerCommandIdempotencyV1'); + assert.doesNotMatch(assignment, /public: true/); + assert.doesNotMatch(idempotency, /public: true/); + assert.match(assignment, /fid: t\.u64\(\)/); + assert.match(assignment, /accruedAmount: t\.u64\(\)/); + assert.match(idempotency, /requestKey: t\.string\(\)\.primaryKey\(\)/); +}); + +test('worker reducers are caller-bound and activation remains explicitly gated', () => { + const reducers = source('../src/reducers/castleWorkers.ts'); + const authority = source('../src/castleWorkerAuthority.ts'); + assert.match(reducers, /name: 'dispatch_worker_v1'/); + assert.match(reducers, /requireGameplayPlayerV1\(ctx\)/); + assert.match(reducers, /dispatchCastleWorker\(ctx, \{ fid: claims\.fid, castle/); + assert.match(reducers, /name: 'recall_all_workers_v1'/); + assert.match(reducers, /name: 'admin_plan_worker_roster_v1'/); + assert.match(authority, /if \(row\.mode !== 'active'\) fail\('WORKER_SYSTEM_STAGED'\)/); + assert.match(authority, /legacy\.expeditions !== 0n \|\| legacy\.occupations !== 0n \|\| legacy\.schedules !== 0n/); + assert.match(authority, /workerNodeOccupationV1\.nodeKey\.delete\(occupation\.nodeKey\)/); + assert.match(authority, /planCastleWorkerAccrual\(assignment, observedAtMicros\)/); + assert.match(authority, /No[\s\S]{0,20}per-minute writes/); +}); diff --git a/spacetimedb/tests/castleWorkerPolicy.test.ts b/spacetimedb/tests/castleWorkerPolicy.test.ts new file mode 100644 index 00000000..e9865438 --- /dev/null +++ b/spacetimedb/tests/castleWorkerPolicy.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS, + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKERS_PER_CASTLE, + CastleWorkerPolicyError, + planCastleWorkerAccrual, + planCastleWorkerTimeline, + rosterDigestForCastleIds, + workerIdForCastle, + workerResourceKinds, + workerResourcePolicy, +} from '../src/castleWorkerPolicy'; + +test('generic worker roster IDs are stable and exactly four per castle', () => { + assert.equal(CASTLE_WORKERS_PER_CASTLE, 4); + assert.deepEqual( + Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => workerIdForCastle(42n, index + 1)), + [ + 'genesis-001-castle-42-worker-01', + 'genesis-001-castle-42-worker-02', + 'genesis-001-castle-42-worker-03', + 'genesis-001-castle-42-worker-04', + ], + ); + assert.notEqual(rosterDigestForCastleIds([42n, 7n]), rosterDigestForCastleIds([42n])); + assert.equal(rosterDigestForCastleIds([42n, 7n]), rosterDigestForCastleIds([7n, 42n])); +}); + +test('all four resource policies use the shared 60-second quantum and 30-day cap', () => { + assert.deepEqual(workerResourceKinds(), ['gold', 'food', 'wood', 'stone']); + for (const kind of workerResourceKinds()) { + const policy = workerResourcePolicy(kind); + assert.equal(policy.quantumMicros, 60_000_000n); + assert.equal(policy.gatheringDurationMicros, CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS); + assert.equal(policy.gatheringTotal, 43_200n * policy.ratePerQuantum); + } +}); + +test('timeline and accrual are server-time-only and quantum aligned', () => { + const timeline = planCastleWorkerTimeline(1_000_000n, 3); + assert.equal(timeline.arrivesAtMicros, 91_000_000n); + assert.equal(timeline.gatheringEndsAtMicros, 2_592_091_000_000n); + assert.equal(timeline.returnsAtMicros, 2_592_181_000_000n); + const policy = workerResourcePolicy('stone'); + const state = { + phase: 'gathering', + ...timeline, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + resourceKind: 'stone', + policyVersion: CASTLE_WORKER_POLICY_VERSION, + } as const; + const plan = planCastleWorkerAccrual(state, timeline.arrivesAtMicros + 2n * policy.quantumMicros + 1n); + assert.equal(plan.completedQuanta, 2n); + assert.equal(plan.newlyAccruedAmount, 2n * policy.ratePerQuantum); + assert.equal(plan.settledThroughMicros, timeline.arrivesAtMicros + 2n * policy.quantumMicros); +}); + +test('policy rejects invalid resource kinds, roster ordinals, and routes', () => { + assert.throws(() => workerResourcePolicy('mana'), (error: unknown) => ( + error instanceof CastleWorkerPolicyError && error.code === 'WORKER_RESOURCE_UNSUPPORTED' + )); + assert.throws(() => workerIdForCastle(1n, 5), (error: unknown) => ( + error instanceof CastleWorkerPolicyError && error.code === 'WORKER_ROSTER_ORDINAL_INVALID' + )); + assert.throws(() => planCastleWorkerTimeline(0n, 0), (error: unknown) => ( + error instanceof CastleWorkerPolicyError && error.code === 'WORKER_ROUTE_INVALID' + )); +}); diff --git a/spacetimedb/tests/foodExpeditionReducers.test.ts b/spacetimedb/tests/foodExpeditionReducers.test.ts index dc88f138..101d6024 100644 --- a/spacetimedb/tests/foodExpeditionReducers.test.ts +++ b/spacetimedb/tests/foodExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v7 Food tables remain intact through later additive suffixes', () => { const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-27), [ + assert.deepEqual(registrations.slice(-33, -6), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', diff --git a/spacetimedb/tests/goldExpeditionReducers.test.ts b/spacetimedb/tests/goldExpeditionReducers.test.ts index 86218d53..0c9e3de9 100644 --- a/spacetimedb/tests/goldExpeditionReducers.test.ts +++ b/spacetimedb/tests/goldExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v5 Gold authority prefix remains intact through later additive suffixes', const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-27), [ + assert.deepEqual(registrations.slice(-33, -6), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', @@ -69,7 +69,7 @@ test('v5 Gold authority prefix remains intact through later additive suffixes', 'stoneExpeditionScheduleV1', 'realmWaterRevisionV1', ]); - assert.deepEqual(registrations.slice(-27, -22), [ + assert.deepEqual(registrations.slice(-33, -28), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', diff --git a/spacetimedb/tests/playerIdentityPrivacy.test.ts b/spacetimedb/tests/playerIdentityPrivacy.test.ts index 464485c1..b55ddbb8 100644 --- a/spacetimedb/tests/playerIdentityPrivacy.test.ts +++ b/spacetimedb/tests/playerIdentityPrivacy.test.ts @@ -128,6 +128,7 @@ test('generated bindings contain the public projections and omit every private e const publicTableFiles = [ 'castle_slot_v_1_table.ts', 'castle_table.ts', + 'castle_worker_v_1_table.ts', 'food_expedition_schedule_v_1_table.ts', 'food_node_occupation_v_1_table.ts', 'food_site_v_1_table.ts', @@ -145,12 +146,15 @@ test('generated bindings contain the public projections and omit every private e 'realm_water_cell_v_1_table.ts', 'realm_water_layout_v_1_table.ts', 'realm_water_revision_v_1_table.ts', + 'realm_worker_system_v_1_table.ts', 'stone_expedition_schedule_v_1_table.ts', 'stone_node_occupation_v_1_table.ts', 'stone_site_v_1_table.ts', 'wood_expedition_schedule_v_1_table.ts', 'wood_node_occupation_v_1_table.ts', 'wood_site_v_1_table.ts', + 'worker_assignment_schedule_v_1_table.ts', + 'worker_node_occupation_v_1_table.ts', 'world_tile_meta_v_1_table.ts', 'world_tile_table.ts', ]; diff --git a/spacetimedb/tests/resourceReducers.test.ts b/spacetimedb/tests/resourceReducers.test.ts index 67308336..b5ae9377 100644 --- a/spacetimedb/tests/resourceReducers.test.ts +++ b/spacetimedb/tests/resourceReducers.test.ts @@ -78,6 +78,12 @@ test('resource and Gold prefixes remain intact through later additive suffixes', 'stoneExpeditionIdempotencyV1', 'stoneExpeditionScheduleV1', 'realmWaterRevisionV1', + 'realmWorkerSystemV1', + 'castleWorkerV1', + 'workerAssignmentV1', + 'workerNodeOccupationV1', + 'workerCommandIdempotencyV1', + 'workerAssignmentScheduleV1', ]); const account = tableDefinition(schema, 'resourceAccountV1'); diff --git a/spacetimedb/tests/stoneExpeditionReducers.test.ts b/spacetimedb/tests/stoneExpeditionReducers.test.ts index 9810701a..ed68b295 100644 --- a/spacetimedb/tests/stoneExpeditionReducers.test.ts +++ b/spacetimedb/tests/stoneExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v10 Stone tables remain before the additive Water revision suffix', () => const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-10), [ + assert.deepEqual(registrations.slice(-16, -6), [ 'realmWaterLayoutV1', 'realmWaterBodyV1', 'realmWaterCellV1', diff --git a/spacetimedb/tests/waterRevisionAuthority.test.ts b/spacetimedb/tests/waterRevisionAuthority.test.ts index 0a800f33..83a52c0c 100644 --- a/spacetimedb/tests/waterRevisionAuthority.test.ts +++ b/spacetimedb/tests/waterRevisionAuthority.test.ts @@ -62,5 +62,5 @@ test('the append-only public revision table stores policy without topology', () assert.match(revision, /navigationFogBoundaryDepthCells: t\.u32\(\)/); assert.match(revision, /activatedAt: t\.option\(t\.timestamp\(\)\)/); assert.doesNotMatch(revision, /\n\s*q:|\n\s*r:|cellKey:|bodyId:/); - assert.match(schema, /stoneExpeditionScheduleV1,\n\s*realmWaterRevisionV1,\n\}\);/); + assert.match(schema, /stoneExpeditionScheduleV1,\n\s*realmWaterRevisionV1,\n\s*realmWorkerSystemV1,\n\s*castleWorkerV1,\n\s*workerAssignmentV1,\n\s*workerNodeOccupationV1,\n\s*workerCommandIdempotencyV1,\n\s*workerAssignmentScheduleV1,\n\}\);/); }); diff --git a/spacetimedb/tests/woodExpeditionReducers.test.ts b/spacetimedb/tests/woodExpeditionReducers.test.ts index 71109bf0..42ed2b47 100644 --- a/spacetimedb/tests/woodExpeditionReducers.test.ts +++ b/spacetimedb/tests/woodExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v8 Wood tables remain intact through later additive suffixes', () => { const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-27), [ + assert.deepEqual(registrations.slice(-33, -6), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', diff --git a/src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts b/src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts new file mode 100644 index 00000000..3ca8b3fc --- /dev/null +++ b/src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AdminWorkerSystemStatusV1, +} from "./types"; + +export const params = { +}; +export const returnType = AdminWorkerSystemStatusV1 \ No newline at end of file diff --git a/src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts b/src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts new file mode 100644 index 00000000..5ebf2110 --- /dev/null +++ b/src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AdminWorkerRosterPlanV1, +} from "./types"; + +export const params = { +}; +export const returnType = AdminWorkerRosterPlanV1 \ No newline at end of file diff --git a/src/spacetime/module_bindings/castle_worker_v_1_table.ts b/src/spacetime/module_bindings/castle_worker_v_1_table.ts new file mode 100644 index 00000000..88a4efcc --- /dev/null +++ b/src/spacetime/module_bindings/castle_worker_v_1_table.ts @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + workerId: __t.string().primaryKey().name("worker_id"), + originCastleId: __t.u64().name("origin_castle_id"), + ordinal: __t.u32(), + status: __t.string(), + assignmentId: __t.option(__t.string()).name("assignment_id"), + resourceKind: __t.option(__t.string()).name("resource_kind"), + siteId: __t.option(__t.string()).name("site_id"), + startedAtMicros: __t.option(__t.u64()).name("started_at_micros"), + arrivesAtMicros: __t.option(__t.u64()).name("arrives_at_micros"), + gatheringEndsAtMicros: __t.option(__t.u64()).name("gathering_ends_at_micros"), + returnStartedAtMicros: __t.option(__t.u64()).name("return_started_at_micros"), + returnsAtMicros: __t.option(__t.u64()).name("returns_at_micros"), + routeSteps: __t.option(__t.u32()).name("route_steps"), + returnStartProgressBasisPoints: __t.option(__t.u32()).name("return_start_progress_basis_points"), + timelineRevision: __t.u32().name("timeline_revision"), + revision: __t.u64(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts b/src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts new file mode 100644 index 00000000..6aa02ee2 --- /dev/null +++ b/src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workerId: __t.string(), + resourceKind: __t.string(), + siteId: __t.string(), + idempotencyKey: __t.string(), +}; diff --git a/src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts b/src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts new file mode 100644 index 00000000..d5ad5da5 --- /dev/null +++ b/src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MyResourceStateV2, +} from "./types"; + +export const params = { +}; +export const returnType = MyResourceStateV2 \ No newline at end of file diff --git a/src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts b/src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts new file mode 100644 index 00000000..d64bde64 --- /dev/null +++ b/src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MyWorkerRosterV1, +} from "./types"; + +export const params = { +}; +export const returnType = MyWorkerRosterV1 \ No newline at end of file diff --git a/src/spacetime/module_bindings/index.ts b/src/spacetime/module_bindings/index.ts index 93cd2e51..65862c62 100644 --- a/src/spacetime/module_bindings/index.ts +++ b/src/spacetime/module_bindings/index.ts @@ -68,6 +68,9 @@ import DispatchFoodExpeditionV1Reducer from "./dispatch_food_expedition_v_1_redu import DispatchGoldExpeditionV1Reducer from "./dispatch_gold_expedition_v_1_reducer"; import DispatchStoneExpeditionV1Reducer from "./dispatch_stone_expedition_v_1_reducer"; import DispatchWoodExpeditionV1Reducer from "./dispatch_wood_expedition_v_1_reducer"; +import DispatchWorkerV1Reducer from "./dispatch_worker_v_1_reducer"; +import RecallAllWorkersV1Reducer from "./recall_all_workers_v_1_reducer"; +import RecallWorkerV1Reducer from "./recall_worker_v_1_reducer"; // Import all procedure arg schemas import * as AdminGetAlphaStatusProcedure from "./admin_get_alpha_status_procedure"; @@ -78,8 +81,10 @@ import * as AdminGetAlphaStatusV8Procedure from "./admin_get_alpha_status_v_8_pr import * as AdminGetAlphaStatusV10Procedure from "./admin_get_alpha_status_v_10_procedure"; import * as AdminGetFidAuthEpochProcedure from "./admin_get_fid_auth_epoch_procedure"; import * as AdminGetSnapScanBatchAggregateV1Procedure from "./admin_get_snap_scan_batch_aggregate_v_1_procedure"; +import * as AdminGetWorkerSystemStatusV1Procedure from "./admin_get_worker_system_status_v_1_procedure"; import * as AdminInspectGenesisWaterLayoutV1Procedure from "./admin_inspect_genesis_water_layout_v_1_procedure"; import * as AdminInspectGenesisWaterRevisionV1Procedure from "./admin_inspect_genesis_water_revision_v_1_procedure"; +import * as AdminPlanWorkerRosterV1Procedure from "./admin_plan_worker_roster_v_1_procedure"; import * as AuthResolverGetFidAdmissionV2Procedure from "./auth_resolver_get_fid_admission_v_2_procedure"; import * as GetAlphaBackendInfoProcedure from "./get_alpha_backend_info_procedure"; import * as GetMyAdmissionStatusProcedure from "./get_my_admission_status_procedure"; @@ -87,14 +92,17 @@ import * as GetMyAdmissionStatusV2Procedure from "./get_my_admission_status_v_2_ import * as GetMyFoodExpeditionStateV1Procedure from "./get_my_food_expedition_state_v_1_procedure"; import * as GetMyGoldExpeditionStateV1Procedure from "./get_my_gold_expedition_state_v_1_procedure"; import * as GetMyResourceStateV1Procedure from "./get_my_resource_state_v_1_procedure"; +import * as GetMyResourceStateV2Procedure from "./get_my_resource_state_v_2_procedure"; import * as GetMyStoneExpeditionStateV1Procedure from "./get_my_stone_expedition_state_v_1_procedure"; import * as GetMyWoodExpeditionStateV1Procedure from "./get_my_wood_expedition_state_v_1_procedure"; +import * as GetMyWorkerRosterV1Procedure from "./get_my_worker_roster_v_1_procedure"; import * as QaObserverGetRealmAttestationV2Procedure from "./qa_observer_get_realm_attestation_v_2_procedure"; import * as QaObserverGetRealmSnapshotV1Procedure from "./qa_observer_get_realm_snapshot_v_1_procedure"; // Import all table schema definitions import CastleRow from "./castle_table"; import CastleSlotV1Row from "./castle_slot_v_1_table"; +import CastleWorkerV1Row from "./castle_worker_v_1_table"; import FoodExpeditionScheduleV1Row from "./food_expedition_schedule_v_1_table"; import FoodNodeOccupationV1Row from "./food_node_occupation_v_1_table"; import FoodSiteV1Row from "./food_site_v_1_table"; @@ -112,12 +120,15 @@ import RealmWaterBodyV1Row from "./realm_water_body_v_1_table"; import RealmWaterCellV1Row from "./realm_water_cell_v_1_table"; import RealmWaterLayoutV1Row from "./realm_water_layout_v_1_table"; import RealmWaterRevisionV1Row from "./realm_water_revision_v_1_table"; +import RealmWorkerSystemV1Row from "./realm_worker_system_v_1_table"; import StoneExpeditionScheduleV1Row from "./stone_expedition_schedule_v_1_table"; import StoneNodeOccupationV1Row from "./stone_node_occupation_v_1_table"; import StoneSiteV1Row from "./stone_site_v_1_table"; import WoodExpeditionScheduleV1Row from "./wood_expedition_schedule_v_1_table"; import WoodNodeOccupationV1Row from "./wood_node_occupation_v_1_table"; import WoodSiteV1Row from "./wood_site_v_1_table"; +import WorkerAssignmentScheduleV1Row from "./worker_assignment_schedule_v_1_table"; +import WorkerNodeOccupationV1Row from "./worker_node_occupation_v_1_table"; import WorldTileRow from "./world_tile_table"; import WorldTileMetaV1Row from "./world_tile_meta_v_1_table"; @@ -162,6 +173,20 @@ const tablesSchema = __schema({ { name: 'castle_slot_v1_tile_key_key', constraint: 'unique', columns: ['tileKey'] }, ], }, CastleSlotV1Row), + castleWorkerV1: __table({ + name: 'castle_worker_v1', + indexes: [ + { accessor: 'byOriginCastle', name: 'castle_worker_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: [ + 'originCastleId', + ] }, + { accessor: 'workerId', name: 'castle_worker_v1_worker_id_idx_btree', algorithm: 'btree', columns: [ + 'workerId', + ] }, + ], + constraints: [ + { name: 'castle_worker_v1_worker_id_key', constraint: 'unique', columns: ['workerId'] }, + ], + }, CastleWorkerV1Row), foodExpeditionScheduleV1: __table({ name: 'food_expedition_schedule_v_1', indexes: [ @@ -391,6 +416,17 @@ const tablesSchema = __schema({ { name: 'realm_water_revision_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, ], }, RealmWaterRevisionV1Row), + realmWorkerSystemV1: __table({ + name: 'realm_worker_system_v1', + indexes: [ + { accessor: 'realmId', name: 'realm_worker_system_v1_realm_id_idx_btree', algorithm: 'btree', columns: [ + 'realmId', + ] }, + ], + constraints: [ + { name: 'realm_worker_system_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, + ], + }, RealmWorkerSystemV1Row), stoneExpeditionScheduleV1: __table({ name: 'stone_expedition_schedule_v_1', indexes: [ @@ -475,6 +511,40 @@ const tablesSchema = __schema({ { name: 'wood_site_v1_site_id_key', constraint: 'unique', columns: ['siteId'] }, ], }, WoodSiteV1Row), + workerAssignmentScheduleV1: __table({ + name: 'worker_assignment_schedule_v_1', + indexes: [ + { accessor: 'byAssignment', name: 'worker_assignment_schedule_v_1_assignment_id_idx_btree', algorithm: 'btree', columns: [ + 'assignmentId', + ] }, + { accessor: 'scheduleId', name: 'worker_assignment_schedule_v_1_schedule_id_idx_btree', algorithm: 'btree', columns: [ + 'scheduleId', + ] }, + { accessor: 'byWorker', name: 'worker_assignment_schedule_v_1_worker_id_idx_btree', algorithm: 'btree', columns: [ + 'workerId', + ] }, + ], + constraints: [ + { name: 'worker_assignment_schedule_v_1_schedule_id_key', constraint: 'unique', columns: ['scheduleId'] }, + ], + }, WorkerAssignmentScheduleV1Row), + workerNodeOccupationV1: __table({ + name: 'worker_node_occupation_v1', + indexes: [ + { accessor: 'nodeKey', name: 'worker_node_occupation_v1_node_key_idx_btree', algorithm: 'btree', columns: [ + 'nodeKey', + ] }, + { accessor: 'byOriginCastle', name: 'worker_node_occupation_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: [ + 'originCastleId', + ] }, + { accessor: 'byWorker', name: 'worker_node_occupation_v1_worker_id_idx_btree', algorithm: 'btree', columns: [ + 'workerId', + ] }, + ], + constraints: [ + { name: 'worker_node_occupation_v1_node_key_key', constraint: 'unique', columns: ['nodeKey'] }, + ], + }, WorkerNodeOccupationV1Row), worldTile: __table({ name: 'world_tile', indexes: [ @@ -542,6 +612,9 @@ const reducersSchema = __reducers( __reducerSchema("dispatch_gold_expedition_v1", DispatchGoldExpeditionV1Reducer), __reducerSchema("dispatch_stone_expedition_v1", DispatchStoneExpeditionV1Reducer), __reducerSchema("dispatch_wood_expedition_v1", DispatchWoodExpeditionV1Reducer), + __reducerSchema("dispatch_worker_v1", DispatchWorkerV1Reducer), + __reducerSchema("recall_all_workers_v1", RecallAllWorkersV1Reducer), + __reducerSchema("recall_worker_v1", RecallWorkerV1Reducer), ); /** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ @@ -554,8 +627,10 @@ const proceduresSchema = __procedures( __procedureSchema("admin_get_alpha_status_v_10", AdminGetAlphaStatusV10Procedure.params, AdminGetAlphaStatusV10Procedure.returnType), __procedureSchema("admin_get_fid_auth_epoch", AdminGetFidAuthEpochProcedure.params, AdminGetFidAuthEpochProcedure.returnType), __procedureSchema("admin_get_snap_scan_batch_aggregate_v1", AdminGetSnapScanBatchAggregateV1Procedure.params, AdminGetSnapScanBatchAggregateV1Procedure.returnType), + __procedureSchema("admin_get_worker_system_status_v1", AdminGetWorkerSystemStatusV1Procedure.params, AdminGetWorkerSystemStatusV1Procedure.returnType), __procedureSchema("admin_inspect_genesis_water_layout_v1", AdminInspectGenesisWaterLayoutV1Procedure.params, AdminInspectGenesisWaterLayoutV1Procedure.returnType), __procedureSchema("admin_inspect_genesis_water_revision_v_1", AdminInspectGenesisWaterRevisionV1Procedure.params, AdminInspectGenesisWaterRevisionV1Procedure.returnType), + __procedureSchema("admin_plan_worker_roster_v1", AdminPlanWorkerRosterV1Procedure.params, AdminPlanWorkerRosterV1Procedure.returnType), __procedureSchema("auth_resolver_get_fid_admission_v2", AuthResolverGetFidAdmissionV2Procedure.params, AuthResolverGetFidAdmissionV2Procedure.returnType), __procedureSchema("get_alpha_backend_info", GetAlphaBackendInfoProcedure.params, GetAlphaBackendInfoProcedure.returnType), __procedureSchema("get_my_admission_status", GetMyAdmissionStatusProcedure.params, GetMyAdmissionStatusProcedure.returnType), @@ -563,8 +638,10 @@ const proceduresSchema = __procedures( __procedureSchema("get_my_food_expedition_state_v1", GetMyFoodExpeditionStateV1Procedure.params, GetMyFoodExpeditionStateV1Procedure.returnType), __procedureSchema("get_my_gold_expedition_state_v1", GetMyGoldExpeditionStateV1Procedure.params, GetMyGoldExpeditionStateV1Procedure.returnType), __procedureSchema("get_my_resource_state_v1", GetMyResourceStateV1Procedure.params, GetMyResourceStateV1Procedure.returnType), + __procedureSchema("get_my_resource_state_v2", GetMyResourceStateV2Procedure.params, GetMyResourceStateV2Procedure.returnType), __procedureSchema("get_my_stone_expedition_state_v1", GetMyStoneExpeditionStateV1Procedure.params, GetMyStoneExpeditionStateV1Procedure.returnType), __procedureSchema("get_my_wood_expedition_state_v1", GetMyWoodExpeditionStateV1Procedure.params, GetMyWoodExpeditionStateV1Procedure.returnType), + __procedureSchema("get_my_worker_roster_v1", GetMyWorkerRosterV1Procedure.params, GetMyWorkerRosterV1Procedure.returnType), __procedureSchema("qa_observer_get_realm_attestation_v2", QaObserverGetRealmAttestationV2Procedure.params, QaObserverGetRealmAttestationV2Procedure.returnType), __procedureSchema("qa_observer_get_realm_snapshot_v1", QaObserverGetRealmSnapshotV1Procedure.params, QaObserverGetRealmSnapshotV1Procedure.returnType), ); diff --git a/src/spacetime/module_bindings/realm_worker_system_v_1_table.ts b/src/spacetime/module_bindings/realm_worker_system_v_1_table.ts new file mode 100644 index 00000000..cdd73634 --- /dev/null +++ b/src/spacetime/module_bindings/realm_worker_system_v_1_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + realmId: __t.string().primaryKey().name("realm_id"), + policyVersion: __t.string().name("policy_version"), + workersPerCastle: __t.u32().name("workers_per_castle"), + expectedCastleCount: __t.u32().name("expected_castle_count"), + expectedWorkerCount: __t.u32().name("expected_worker_count"), + rosterDigest: __t.string().name("roster_digest"), + mode: __t.string(), + legacyDrainRequired: __t.bool().name("legacy_drain_required"), + createdAt: __t.timestamp().name("created_at"), + activatedAt: __t.option(__t.timestamp()).name("activated_at"), +}); diff --git a/src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts b/src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts new file mode 100644 index 00000000..35750985 --- /dev/null +++ b/src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + idempotencyKey: __t.string(), +}; diff --git a/src/spacetime/module_bindings/recall_worker_v_1_reducer.ts b/src/spacetime/module_bindings/recall_worker_v_1_reducer.ts new file mode 100644 index 00000000..f0d65bbd --- /dev/null +++ b/src/spacetime/module_bindings/recall_worker_v_1_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workerId: __t.string(), + idempotencyKey: __t.string(), +}; diff --git a/src/spacetime/module_bindings/types.ts b/src/spacetime/module_bindings/types.ts index 90aa16df..93d252e6 100644 --- a/src/spacetime/module_bindings/types.ts +++ b/src/spacetime/module_bindings/types.ts @@ -231,6 +231,58 @@ export const AdminWaterRevisionStatusV1 = __t.object("AdminWaterRevisionStatusV1 }); export type AdminWaterRevisionStatusV1 = __Infer; +export const AdminWorkerRosterPlanV1 = __t.object("AdminWorkerRosterPlanV1", { + ready: __t.bool(), + activationBlockedByLegacyRows: __t.bool(), + mode: __t.string(), + expectedCastleCount: __t.u64(), + expectedWorkerCount: __t.u64(), + actualWorkerCount: __t.u64(), + castlesMissingWorkers: __t.u64(), + castlesWithExtraWorkers: __t.u64(), + orphanWorkers: __t.u64(), + orphanAssignments: __t.u64(), + orphanOccupations: __t.u64(), + assignmentPublicMismatches: __t.u64(), + occupationSiteMismatches: __t.u64(), + legacyExpeditions: __t.u64(), + legacyOccupations: __t.u64(), + legacySchedules: __t.u64(), + rosterDigest: __t.string(), + rosterDigestExpected: __t.string(), +}); +export type AdminWorkerRosterPlanV1 = __Infer; + +export const AdminWorkerSystemStatusV1 = __t.object("AdminWorkerSystemStatusV1", { + systemRows: __t.u64(), + mode: __t.string(), + expectedCastleCount: __t.u64(), + expectedWorkerCount: __t.u64(), + actualWorkerCount: __t.u64(), + castlesMissingWorkers: __t.u64(), + castlesWithExtraWorkers: __t.u64(), + duplicateOrdinals: __t.u64(), + malformedWorkerIds: __t.u64(), + idleWorkers: __t.u64(), + outboundWorkers: __t.u64(), + gatheringWorkers: __t.u64(), + returningWorkers: __t.u64(), + assignments: __t.u64(), + occupations: __t.u64(), + schedules: __t.u64(), + orphanWorkers: __t.u64(), + orphanAssignments: __t.u64(), + orphanOccupations: __t.u64(), + assignmentPublicMismatches: __t.u64(), + occupationSiteMismatches: __t.u64(), + legacyExpeditions: __t.u64(), + legacyOccupations: __t.u64(), + legacySchedules: __t.u64(), + rosterDigest: __t.string(), + rosterDigestExpected: __t.string(), +}); +export type AdminWorkerSystemStatusV1 = __Infer; + export const AllowedFid = __t.object("AllowedFid", { fid: __t.u64(), enabled: __t.bool(), @@ -293,6 +345,28 @@ export const CastleSlotV1 = __t.object("CastleSlotV1", { }); export type CastleSlotV1 = __Infer; +export const CastleWorkerV1 = __t.object("CastleWorkerV1", { + workerId: __t.string(), + originCastleId: __t.u64(), + ordinal: __t.u32(), + status: __t.string(), + assignmentId: __t.option(__t.string()), + resourceKind: __t.option(__t.string()), + siteId: __t.option(__t.string()), + startedAtMicros: __t.option(__t.u64()), + arrivesAtMicros: __t.option(__t.u64()), + gatheringEndsAtMicros: __t.option(__t.u64()), + returnStartedAtMicros: __t.option(__t.u64()), + returnsAtMicros: __t.option(__t.u64()), + routeSteps: __t.option(__t.u32()), + returnStartProgressBasisPoints: __t.option(__t.u32()), + timelineRevision: __t.u32(), + revision: __t.u64(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type CastleWorkerV1 = __Infer; + export const FidWalletAttributionV1 = __t.object("FidWalletAttributionV1", { snapshotAttributionKey: __t.string(), attributionKey: __t.string(), @@ -491,6 +565,25 @@ export const MyResourceStateV1 = __t.object("MyResourceStateV1", { }); export type MyResourceStateV1 = __Infer; +export const MyResourceStateV2 = __t.object("MyResourceStateV2", { + fid: __t.u64(), + food: __t.u64(), + wood: __t.u64(), + stone: __t.u64(), + gold: __t.u64(), + workerPendingFood: __t.u64(), + workerPendingWood: __t.u64(), + workerPendingStone: __t.u64(), + workerPendingGold: __t.u64(), + observedAtMicros: __t.u64(), + settledThroughMicros: __t.u64(), + revision: __t.u64(), + resourcePolicyVersion: __t.string(), + workerPolicyVersion: __t.string(), + workerSystemMode: __t.string(), +}); +export type MyResourceStateV2 = __Infer; + export const MyStoneExpeditionStateV1 = __t.object("MyStoneExpeditionStateV1", { active: __t.bool(), expeditionId: __t.option(__t.string()), @@ -529,6 +622,16 @@ export const MyWoodExpeditionStateV1 = __t.object("MyWoodExpeditionStateV1", { }); export type MyWoodExpeditionStateV1 = __Infer; +export const MyWorkerRosterV1 = __t.object("MyWorkerRosterV1", { + fid: __t.u64(), + castleId: __t.u64(), + observedAtMicros: __t.u64(), + get workers() { + return __t.array(WorkerPrivateV1); + }, +}); +export type MyWorkerRosterV1 = __Infer; + export const Player = __t.object("Player", { fid: __t.u64(), identity: __t.identity(), @@ -798,6 +901,20 @@ export const RealmWaterRevisionV1 = __t.object("RealmWaterRevisionV1", { }); export type RealmWaterRevisionV1 = __Infer; +export const RealmWorkerSystemV1 = __t.object("RealmWorkerSystemV1", { + realmId: __t.string(), + policyVersion: __t.string(), + workersPerCastle: __t.u32(), + expectedCastleCount: __t.u32(), + expectedWorkerCount: __t.u32(), + rosterDigest: __t.string(), + mode: __t.string(), + legacyDrainRequired: __t.bool(), + createdAt: __t.timestamp(), + activatedAt: __t.option(__t.timestamp()), +}); +export type RealmWorkerSystemV1 = __Infer; + export const ResourceAccountV1 = __t.object("ResourceAccountV1", { fid: __t.u64(), castleId: __t.u64(), @@ -1009,6 +1126,84 @@ export const WoodSiteV1 = __t.object("WoodSiteV1", { }); export type WoodSiteV1 = __Infer; +export const WorkerAssignmentScheduleV1 = __t.object("WorkerAssignmentScheduleV1", { + scheduleId: __t.u64(), + scheduledAt: __t.scheduleAt(), + assignmentId: __t.string(), + workerId: __t.string(), + timelineRevision: __t.u32(), + stage: __t.string(), +}); +export type WorkerAssignmentScheduleV1 = __Infer; + +export const WorkerAssignmentV1 = __t.object("WorkerAssignmentV1", { + assignmentId: __t.string(), + workerId: __t.string(), + fid: __t.u64(), + originCastleId: __t.u64(), + resourceKind: __t.string(), + siteId: __t.string(), + phase: __t.string(), + startedAtMicros: __t.u64(), + arrivesAtMicros: __t.u64(), + gatheringEndsAtMicros: __t.u64(), + returnStartedAtMicros: __t.option(__t.u64()), + returnsAtMicros: __t.u64(), + routeSteps: __t.u32(), + returnStartProgressBasisPoints: __t.u32(), + settledThroughMicros: __t.u64(), + accruedAmount: __t.u64(), + materializedAmount: __t.u64(), + timelineRevision: __t.u32(), + policyVersion: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type WorkerAssignmentV1 = __Infer; + +export const WorkerCommandIdempotencyV1 = __t.object("WorkerCommandIdempotencyV1", { + requestKey: __t.string(), + fid: __t.u64(), + workerId: __t.option(__t.string()), + commandKind: __t.string(), + resourceKind: __t.option(__t.string()), + siteId: __t.option(__t.string()), + assignmentId: __t.option(__t.string()), + resultRevision: __t.u64(), + createdAt: __t.timestamp(), +}); +export type WorkerCommandIdempotencyV1 = __Infer; + +export const WorkerNodeOccupationV1 = __t.object("WorkerNodeOccupationV1", { + nodeKey: __t.string(), + resourceKind: __t.string(), + siteId: __t.string(), + workerId: __t.string(), + workerOrdinal: __t.u32(), + originCastleId: __t.u64(), + assignmentId: __t.string(), + phase: __t.string(), + startedAtMicros: __t.u64(), + arrivesAtMicros: __t.u64(), + gatheringEndsAtMicros: __t.u64(), + timelineRevision: __t.u32(), +}); +export type WorkerNodeOccupationV1 = __Infer; + +export const WorkerPrivateV1 = __t.object("WorkerPrivateV1", { + workerId: __t.string(), + ordinal: __t.u32(), + status: __t.string(), + resourceKind: __t.option(__t.string()), + siteId: __t.option(__t.string()), + accruedAmount: __t.u64(), + materializedAmount: __t.u64(), + availableAmount: __t.u64(), + observedAtMicros: __t.u64(), + revision: __t.u64(), +}); +export type WorkerPrivateV1 = __Infer; + export const WorldTile = __t.object("WorldTile", { key: __t.string(), q: __t.i32(), diff --git a/src/spacetime/module_bindings/types/procedures.ts b/src/spacetime/module_bindings/types/procedures.ts index dcfd21fe..e08675cb 100644 --- a/src/spacetime/module_bindings/types/procedures.ts +++ b/src/spacetime/module_bindings/types/procedures.ts @@ -14,8 +14,10 @@ import * as AdminGetAlphaStatusV8Procedure from "../admin_get_alpha_status_v_8_p import * as AdminGetAlphaStatusV10Procedure from "../admin_get_alpha_status_v_10_procedure"; import * as AdminGetFidAuthEpochProcedure from "../admin_get_fid_auth_epoch_procedure"; import * as AdminGetSnapScanBatchAggregateV1Procedure from "../admin_get_snap_scan_batch_aggregate_v_1_procedure"; +import * as AdminGetWorkerSystemStatusV1Procedure from "../admin_get_worker_system_status_v_1_procedure"; import * as AdminInspectGenesisWaterLayoutV1Procedure from "../admin_inspect_genesis_water_layout_v_1_procedure"; import * as AdminInspectGenesisWaterRevisionV1Procedure from "../admin_inspect_genesis_water_revision_v_1_procedure"; +import * as AdminPlanWorkerRosterV1Procedure from "../admin_plan_worker_roster_v_1_procedure"; import * as AuthResolverGetFidAdmissionV2Procedure from "../auth_resolver_get_fid_admission_v_2_procedure"; import * as GetAlphaBackendInfoProcedure from "../get_alpha_backend_info_procedure"; import * as GetMyAdmissionStatusProcedure from "../get_my_admission_status_procedure"; @@ -23,8 +25,10 @@ import * as GetMyAdmissionStatusV2Procedure from "../get_my_admission_status_v_2 import * as GetMyFoodExpeditionStateV1Procedure from "../get_my_food_expedition_state_v_1_procedure"; import * as GetMyGoldExpeditionStateV1Procedure from "../get_my_gold_expedition_state_v_1_procedure"; import * as GetMyResourceStateV1Procedure from "../get_my_resource_state_v_1_procedure"; +import * as GetMyResourceStateV2Procedure from "../get_my_resource_state_v_2_procedure"; import * as GetMyStoneExpeditionStateV1Procedure from "../get_my_stone_expedition_state_v_1_procedure"; import * as GetMyWoodExpeditionStateV1Procedure from "../get_my_wood_expedition_state_v_1_procedure"; +import * as GetMyWorkerRosterV1Procedure from "../get_my_worker_roster_v_1_procedure"; import * as QaObserverGetRealmAttestationV2Procedure from "../qa_observer_get_realm_attestation_v_2_procedure"; import * as QaObserverGetRealmSnapshotV1Procedure from "../qa_observer_get_realm_snapshot_v_1_procedure"; @@ -44,10 +48,14 @@ export type AdminGetFidAuthEpochArgs = __Infer; export type AdminGetSnapScanBatchAggregateV1Args = __Infer; export type AdminGetSnapScanBatchAggregateV1Result = __Infer; +export type AdminGetWorkerSystemStatusV1Args = __Infer; +export type AdminGetWorkerSystemStatusV1Result = __Infer; export type AdminInspectGenesisWaterLayoutV1Args = __Infer; export type AdminInspectGenesisWaterLayoutV1Result = __Infer; export type AdminInspectGenesisWaterRevisionV1Args = __Infer; export type AdminInspectGenesisWaterRevisionV1Result = __Infer; +export type AdminPlanWorkerRosterV1Args = __Infer; +export type AdminPlanWorkerRosterV1Result = __Infer; export type AuthResolverGetFidAdmissionV2Args = __Infer; export type AuthResolverGetFidAdmissionV2Result = __Infer; export type GetAlphaBackendInfoArgs = __Infer; @@ -62,10 +70,14 @@ export type GetMyGoldExpeditionStateV1Args = __Infer; export type GetMyResourceStateV1Args = __Infer; export type GetMyResourceStateV1Result = __Infer; +export type GetMyResourceStateV2Args = __Infer; +export type GetMyResourceStateV2Result = __Infer; export type GetMyStoneExpeditionStateV1Args = __Infer; export type GetMyStoneExpeditionStateV1Result = __Infer; export type GetMyWoodExpeditionStateV1Args = __Infer; export type GetMyWoodExpeditionStateV1Result = __Infer; +export type GetMyWorkerRosterV1Args = __Infer; +export type GetMyWorkerRosterV1Result = __Infer; export type QaObserverGetRealmAttestationV2Args = __Infer; export type QaObserverGetRealmAttestationV2Result = __Infer; export type QaObserverGetRealmSnapshotV1Args = __Infer; diff --git a/src/spacetime/module_bindings/types/reducers.ts b/src/spacetime/module_bindings/types/reducers.ts index 5f35bdf7..fec9620b 100644 --- a/src/spacetime/module_bindings/types/reducers.ts +++ b/src/spacetime/module_bindings/types/reducers.ts @@ -40,6 +40,9 @@ import DispatchFoodExpeditionV1Reducer from "../dispatch_food_expedition_v_1_red import DispatchGoldExpeditionV1Reducer from "../dispatch_gold_expedition_v_1_reducer"; import DispatchStoneExpeditionV1Reducer from "../dispatch_stone_expedition_v_1_reducer"; import DispatchWoodExpeditionV1Reducer from "../dispatch_wood_expedition_v_1_reducer"; +import DispatchWorkerV1Reducer from "../dispatch_worker_v_1_reducer"; +import RecallAllWorkersV1Reducer from "../recall_all_workers_v_1_reducer"; +import RecallWorkerV1Reducer from "../recall_worker_v_1_reducer"; export type AcceptAlphaTermsV1Params = __Infer; export type AdminActivateGenesisWaterLayoutV1Params = __Infer; @@ -75,4 +78,7 @@ export type DispatchFoodExpeditionV1Params = __Infer; export type DispatchStoneExpeditionV1Params = __Infer; export type DispatchWoodExpeditionV1Params = __Infer; +export type DispatchWorkerV1Params = __Infer; +export type RecallAllWorkersV1Params = __Infer; +export type RecallWorkerV1Params = __Infer; diff --git a/src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts b/src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts new file mode 100644 index 00000000..e06a3e34 --- /dev/null +++ b/src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + scheduleId: __t.u64().primaryKey().name("schedule_id"), + scheduledAt: __t.scheduleAt().name("scheduled_at"), + assignmentId: __t.string().name("assignment_id"), + workerId: __t.string().name("worker_id"), + timelineRevision: __t.u32().name("timeline_revision"), + stage: __t.string(), +}); diff --git a/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts b/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts new file mode 100644 index 00000000..f2c6c549 --- /dev/null +++ b/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + nodeKey: __t.string().primaryKey().name("node_key"), + resourceKind: __t.string().name("resource_kind"), + siteId: __t.string().name("site_id"), + workerId: __t.string().name("worker_id"), + workerOrdinal: __t.u32().name("worker_ordinal"), + originCastleId: __t.u64().name("origin_castle_id"), + assignmentId: __t.string().name("assignment_id"), + phase: __t.string(), + startedAtMicros: __t.u64().name("started_at_micros"), + arrivesAtMicros: __t.u64().name("arrives_at_micros"), + gatheringEndsAtMicros: __t.u64().name("gathering_ends_at_micros"), + timelineRevision: __t.u32().name("timeline_revision"), +}); From 1aa7346056afa61cbea007b6b9c2f20bad4613de Mon Sep 17 00:00:00 2001 From: ael-dev3 Date: Wed, 22 Jul 2026 13:34:27 +0200 Subject: [PATCH 10/19] Harden generic worker authority and migration proof --- .github/workflows/verify.yml | 1 + .../spacetime-additive-migration-proof.mjs | 2 +- ...erify-castle-worker-additive-migration.mjs | 8 +- .../verify-spacetime-additive-migration.mjs | 316 ++++++++++--- .../additive-v12-schema/src/index.ts | 13 +- spacetimedb/pnpm-lock.yaml | 10 + spacetimedb/src/castleWorkerAuthority.ts | 433 +++++++++++++++--- spacetimedb/src/castleWorkerPolicy.ts | 83 +++- spacetimedb/src/castleWorkerRoster.ts | 89 +++- spacetimedb/src/foodExpeditionAuthority.ts | 2 + spacetimedb/src/foundingAuthority.ts | 1 + spacetimedb/src/goldExpeditionAuthority.ts | 2 + spacetimedb/src/reducers/castleWorkers.ts | 58 ++- .../resourceExpeditionReservationAuthority.ts | 9 +- spacetimedb/src/schema.ts | 9 +- spacetimedb/src/stoneExpeditionAuthority.ts | 2 + spacetimedb/src/woodExpeditionAuthority.ts | 2 + .../tests/castleWorkerAuthority.test.ts | 140 ++++++ .../castleWorkerMigrationTooling.test.ts | 8 +- spacetimedb/tests/castleWorkerPolicy.test.ts | 95 ++++ .../tests/playerIdentityPrivacy.test.ts | 4 +- .../waterRevisionMigrationTooling.test.ts | 10 +- .../castle_worker_v_1_table.ts | 1 - src/spacetime/module_bindings/index.ts | 18 - src/spacetime/module_bindings/types.ts | 27 +- .../worker_assignment_schedule_v_1_table.ts | 20 - .../worker_node_occupation_v_1_table.ts | 1 - 27 files changed, 1164 insertions(+), 200 deletions(-) create mode 100644 spacetimedb/tests/castleWorkerAuthority.test.ts delete mode 100644 src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index bb240426..32ffe093 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -147,6 +147,7 @@ jobs: run: | pnpm --dir spacetimedb run verify npm run stdb:verify-bindings + npm run stdb:verify-worker-migration npm run stdb:verify-additive-migration - name: Audit module dependencies diff --git a/scripts/spacetime-additive-migration-proof.mjs b/scripts/spacetime-additive-migration-proof.mjs index dffdb1e6..4632b9f8 100644 --- a/scripts/spacetime-additive-migration-proof.mjs +++ b/scripts/spacetime-additive-migration-proof.mjs @@ -3,7 +3,7 @@ const RECEIPT_FIELD = 'artifact_sha256'; const INVALID_RECEIPT_MESSAGE = 'The current additive migration proof did not produce its exact success receipt.'; -export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 11; +export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 12; export const ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION = '2.6.1'; // The compiled lifecycle lane includes a nine-minute route and one complete // gathering minute. Keep a bounded margin for server startup and cleanup. diff --git a/scripts/verify-castle-worker-additive-migration.mjs b/scripts/verify-castle-worker-additive-migration.mjs index 4ccaf426..2a7d073c 100644 --- a/scripts/verify-castle-worker-additive-migration.mjs +++ b/scripts/verify-castle-worker-additive-migration.mjs @@ -45,13 +45,13 @@ assert.deepEqual(candidate.slice(47), [ 'workerAssignmentScheduleV1', ]); assert.deepEqual(current.slice(47), candidate.slice(47), 'module and fixture suffix differ'); -for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1', 'workerAssignmentScheduleV1']) { +for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1']) { const definition = table(schema, name); assert.match(definition, /public: true/); - assert.doesNotMatch(definition, /\bfid\b|accruedAmount|materializedAmount|balance|requestKey|auth/i); + assert.doesNotMatch(definition, /\bfid\b|assignmentId|accruedAmount|materializedAmount|balance|requestKey|auth/i); } -for (const name of ['workerAssignmentV1', 'workerCommandIdempotencyV1']) { +for (const name of ['workerAssignmentV1', 'workerCommandIdempotencyV1', 'workerAssignmentScheduleV1']) { assert.doesNotMatch(table(schema, name), /public: true/); } assert.match(fixture, /fixture_seed_generic_worker_sentinel_v12/); -console.log('generic worker additive migration proof passed: refs 0–46 preserved, refs 47–52 append-only, populated fixture present, public/private boundary checked'); +console.log('generic worker additive migration proof passed: refs 0–46 preserved, refs 47–52 append-only, populated fixture present, assignment correlation remains private'); diff --git a/scripts/verify-spacetime-additive-migration.mjs b/scripts/verify-spacetime-additive-migration.mjs index 29286a25..c2d9c455 100644 --- a/scripts/verify-spacetime-additive-migration.mjs +++ b/scripts/verify-spacetime-additive-migration.mjs @@ -63,6 +63,10 @@ const additiveV11SchemaFixture = resolve( repositoryRoot, 'spacetimedb/migration-fixtures/additive-v11-schema', ); +const additiveV12SchemaFixture = resolve( + repositoryRoot, + 'spacetimedb/migration-fixtures/additive-v12-schema', +); const additiveModule = resolve(repositoryRoot, 'spacetimedb'); const command = process.env.SPACETIME_BIN || 'spacetime'; const expectedCliVersion = ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION; @@ -243,6 +247,14 @@ const additiveV10Tables = Object.freeze([ const additiveV11Tables = Object.freeze([ 'realm_water_revision_v1', ]); +const additiveV12Tables = Object.freeze([ + 'realm_worker_system_v1', + 'castle_worker_v1', + 'worker_assignment_v1', + 'worker_node_occupation_v1', + 'worker_command_idempotency_v1', + 'worker_assignment_schedule_v_1', +]); const deployedV3Tables = Object.freeze([ ...existingTables, ...additiveV3Tables, @@ -279,6 +291,10 @@ const deployedV11Tables = Object.freeze([ ...deployedV10Tables, ...additiveV11Tables, ]); +const deployedV12Tables = Object.freeze([ + ...deployedV11Tables, + ...additiveV12Tables, +]); const expectedProductTypeRefs = Object.freeze({ allowed_fid: 0, world_tile: 1, @@ -327,6 +343,12 @@ const expectedProductTypeRefs = Object.freeze({ stone_expedition_idempotency_v1: 44, stone_expedition_schedule_v_1: 45, realm_water_revision_v1: 46, + realm_worker_system_v1: 47, + castle_worker_v1: 48, + worker_assignment_v1: 49, + worker_node_occupation_v1: 50, + worker_command_idempotency_v1: 51, + worker_assignment_schedule_v_1: 52, }); const childEnvironmentKeys = Object.freeze([ 'PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'TMP', 'TEMP', @@ -1194,6 +1216,87 @@ function assertAdditiveV11Schema(before, after) { ); } +function assertDeployedV11TablesUnchanged(before, after) { + for (const name of deployedV11Tables) { + assert.deepEqual(tableSignature(after, name), tableSignature(before, name)); + assert.equal( + tableSignature(after, name).product_type_ref, + expectedProductTypeRefs[name], + ); + } +} + +function assertAdditiveV12Schema(before, after) { + assertDeployedV11TablesUnchanged(before, after); + const beforeNames = new Set(before.tables.map(table => table.name)); + const added = after.tables + .map(table => table.name) + .filter(name => !beforeNames.has(name)) + .sort(); + assert.deepEqual(added, [...additiveV12Tables].sort()); + const contracts = { + realm_worker_system_v1: { + access: 'Public', + fields: [ + 'realm_id', 'policy_version', 'workers_per_castle', 'expected_castle_count', + 'expected_worker_count', 'roster_digest', 'mode', 'legacy_drain_required', + 'created_at', 'activated_at', + ], + }, + castle_worker_v1: { + access: 'Public', + fields: [ + 'worker_id', 'origin_castle_id', 'ordinal', 'status', 'resource_kind', + 'site_id', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'return_started_at_micros', + 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points', + 'timeline_revision', 'revision', 'created_at', 'updated_at', + ], + }, + worker_assignment_v1: { + access: 'Private', + fields: [ + 'assignment_id', 'worker_id', 'fid', 'origin_castle_id', 'resource_kind', + 'site_id', 'phase', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'return_started_at_micros', + 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points', + 'settled_through_micros', 'accrued_amount', 'materialized_amount', + 'timeline_revision', 'policy_version', 'created_at', 'updated_at', + ], + }, + worker_node_occupation_v1: { + access: 'Public', + fields: [ + 'node_key', 'resource_kind', 'site_id', 'worker_id', 'worker_ordinal', + 'origin_castle_id', 'phase', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'timeline_revision', + ], + }, + worker_command_idempotency_v1: { + access: 'Private', + fields: [ + 'request_key', 'fid', 'worker_id', 'command_kind', 'resource_kind', + 'site_id', 'assignment_id', 'result_revision', 'created_at', + ], + }, + worker_assignment_schedule_v_1: { + access: 'Private', + fields: [ + 'schedule_id', 'scheduled_at', 'assignment_id', 'worker_id', + 'timeline_revision', 'stage', + ], + }, + }; + for (const [name, contract] of Object.entries(contracts)) { + assert.deepEqual(fieldNames(after, name), contract.fields); + assert.equal(access(after, name), contract.access); + assert.equal( + tableSignature(after, name).product_type_ref, + expectedProductTypeRefs[name], + ); + } +} + async function freeLoopbackPort() { return new Promise((resolvePromise, rejectPromise) => { const server = createServer(); @@ -2029,9 +2132,9 @@ async function verifyActualModuleResourceLifecycle(server, database, privateKey, let stage = 'seed'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - // Keep inspection on the complete v11 candidate schema. Reverting to a + // Keep inspection on the complete v12 candidate schema. Reverting to a // predecessor fixture after Stone is appended would be destructive. - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const useActualModule = async () => { if (activeModule === 'actual') return; await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database); @@ -2846,9 +2949,9 @@ async function verifyActualModuleExpeditionLifecycles( let stage = 'seed-world'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - // Reusing the candidate fixture preserves the complete v11 suffix during + // Reusing the candidate fixture preserves the complete v12 suffix during // SQL inspection; publishing any predecessor would request a downgrade. - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const useActualModule = async () => { if (activeModule === 'actual') return; await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database); @@ -3286,7 +3389,7 @@ async function verifyActualModuleWaterLifecycle(server, database, privateKey, ow let stage = 'publish'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims()); const useActualModule = async () => { if (activeModule === 'actual') return; @@ -3757,7 +3860,7 @@ async function verifyGenesisWorldExpansionLifecycle( // Wood append. Reverting to an earlier protocol after publishing the // candidate would correctly be rejected as a destructive schema downgrade. const fixtureArtifactPath = join(additiveV8SchemaFixture, 'dist', 'bundle.js'); - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims()); await publishBuiltArtifact(server, ownerToken, fixtureArtifactPath, database); @@ -4548,28 +4651,130 @@ async function main() { populatedWaterStoneV11Rows, ); - // Advance every database to the real v11 candidate so the implementation - // is exercised against the exact v11 table contract without production. + // Freeze v12 independently, then prove a populated canonical v11 -> v12 + // migration before exercising the real candidate artifact. + await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, actualModuleDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, resourceLifecycleDatabase); + await publish( + server, + owner.token, + additiveV12SchemaFixture, + populatedWaterStoneMigrationDatabase, + ); + const emptyV12 = await describe(server, owner.token, emptyDatabase); + const nonemptyV12 = await describe(server, owner.token, nonemptyDatabase); + const actualModuleV12 = await describe(server, owner.token, actualModuleDatabase); + const populatedWaterStoneV12 = await describe( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + ); + assertAdditiveV12Schema(emptyV11, emptyV12); + assertAdditiveV12Schema(nonemptyV11, nonemptyV12); + assertAdditiveV12Schema(actualModuleV11, actualModuleV12); + assertAdditiveV12Schema(populatedWaterStoneV11, populatedWaterStoneV12); + for (const name of deployedV12Tables) { + assert.deepEqual( + tableSignature(actualModuleV12, name), + tableSignature(emptyV12, name), + ); + } + assert.deepEqual( + await tableRowDigests( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + deployedV11Tables, + ), + populatedWaterStoneV11Rows, + ); + for (const table of additiveV12Tables) { + assert.equal(await count( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + table, + ), 0n); + } + await callLoopbackReducer( + server, + populatedWaterStoneMigrationDatabase, + 'fixture_seed_generic_worker_sentinel_v12', + owner.token, + '[]', + 200, + ); + const expectedPopulatedV12Counts = new Map([ + ['realm_worker_system_v1', 1n], + ['castle_worker_v1', 4n], + ['worker_assignment_v1', 1n], + ['worker_node_occupation_v1', 1n], + ['worker_command_idempotency_v1', 1n], + ['worker_assignment_schedule_v_1', 1n], + ]); + for (const [table, expectedCount] of expectedPopulatedV12Counts) { + assert.equal(await count( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + table, + ), expectedCount); + } + const populatedWaterStoneV12Rows = await tableRowDigests( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + deployedV12Tables, + ); + const populatedWaterStoneV12SchemaDigest = schemaDigest( + await describe(server, owner.token, populatedWaterStoneMigrationDatabase), + ); + await publish( + server, + owner.token, + additiveV11SchemaFixture, + populatedWaterStoneMigrationDatabase, + false, + /break|delete|remove|migration|incompatible|data loss|table/i, + ); + assert.equal( + schemaDigest(await describe(server, owner.token, populatedWaterStoneMigrationDatabase)), + populatedWaterStoneV12SchemaDigest, + ); + assert.deepEqual( + await tableRowDigests( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + deployedV12Tables, + ), + populatedWaterStoneV12Rows, + ); + + // Advance every database to the real v12 candidate so the implementation + // is exercised against the exact v12 table contract without production. await publish(server, owner.token, additiveModule, emptyDatabase); await publish(server, owner.token, additiveModule, nonemptyDatabase); await publish(server, owner.token, additiveModule, actualModuleDatabase); await publish(server, owner.token, additiveModule, resourceLifecycleDatabase); await publish(server, owner.token, additiveModule, populatedWaterStoneMigrationDatabase); - const populatedWaterStoneCandidateV11 = await describe( + const populatedWaterStoneCandidateV12 = await describe( server, owner.token, populatedWaterStoneMigrationDatabase, ); - for (const name of deployedV11Tables) { + for (const name of deployedV12Tables) { assert.deepEqual( - tableSignature(populatedWaterStoneCandidateV11, name), - tableSignature(emptyV11, name), + tableSignature(populatedWaterStoneCandidateV12, name), + tableSignature(emptyV12, name), ); } await publish( server, owner.token, - additiveV11SchemaFixture, + additiveV12SchemaFixture, populatedWaterStoneMigrationDatabase, ); assert.deepEqual( @@ -4577,9 +4782,9 @@ async function main() { server, owner.token, populatedWaterStoneMigrationDatabase, - deployedV11Tables, + deployedV12Tables, ), - populatedWaterStoneV11Rows, + populatedWaterStoneV12Rows, ); await verifyResolverHttpLifecycle(server, actualModuleDatabase, privateKey); const worldExpansionDurationMilliseconds = await verifyGenesisWorldExpansionLifecycle( @@ -4616,30 +4821,30 @@ async function main() { const builtArtifactDigest = createHash('sha256') .update(await readFile(builtArtifactPath)) .digest('hex'); - const emptyCandidateV11 = await describe(server, owner.token, emptyDatabase); - const nonemptyCandidateV11 = await describe(server, owner.token, nonemptyDatabase); - const actualCandidateV11 = await describe(server, owner.token, actualModuleDatabase); - for (const name of deployedV11Tables) { + const emptyCandidateV12 = await describe(server, owner.token, emptyDatabase); + const nonemptyCandidateV12 = await describe(server, owner.token, nonemptyDatabase); + const actualCandidateV12 = await describe(server, owner.token, actualModuleDatabase); + for (const name of deployedV12Tables) { assert.deepEqual( - tableSignature(actualCandidateV11, name), - tableSignature(emptyV11, name), + tableSignature(actualCandidateV12, name), + tableSignature(emptyV12, name), ); assert.deepEqual( - tableSignature(nonemptyCandidateV11, name), - tableSignature(nonemptyV11, name), + tableSignature(nonemptyCandidateV12, name), + tableSignature(nonemptyV12, name), ); assert.deepEqual( - tableSignature(actualCandidateV11, name), - tableSignature(actualModuleV11, name), + tableSignature(actualCandidateV12, name), + tableSignature(actualModuleV12, name), ); } // The candidate's on-connect policy intentionally rejects the disposable - // owner identity. Reuse the table-identical, auth-neutral v11 fixture before + // owner identity. Reuse the table-identical, auth-neutral v12 fixture before // owner SQL reads and never downgrade the schema suffix. - await publish(server, owner.token, additiveV11SchemaFixture, emptyDatabase); - await publish(server, owner.token, additiveV11SchemaFixture, nonemptyDatabase); - await publish(server, owner.token, additiveV11SchemaFixture, actualModuleDatabase); - // SQL preservation reads remain on the complete v11 candidate. No reducer + await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, actualModuleDatabase); + // SQL preservation reads remain on the complete v12 candidate. No reducer // is invoked by these owner-only queries. for (const [database, beforeRows] of [ [emptyDatabase, emptyV7Rows], @@ -4652,7 +4857,7 @@ async function main() { ); } - const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV11); + const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV12); await publishBuiltArtifact( server, owner.token, @@ -4663,10 +4868,10 @@ async function main() { schemaDigest(await describe(server, owner.token, nonemptyDatabase)), idempotentSchemaBefore, ); - await publish(server, owner.token, additiveV11SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase); // The actual module correctly rejects the disposable local identity at its - // on-connect boundary; owner SQL still reads the unchanged v11 rows. + // on-connect boundary; owner SQL still reads the unchanged v12 rows. assert.equal(await count(server, owner.token, emptyDatabase, 'player'), 0n); assert.equal(await count(server, owner.token, emptyDatabase, 'player_v2'), 0n); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 0); @@ -4691,6 +4896,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, database, table), 0n); } @@ -4721,7 +4927,7 @@ async function main() { )), actualModuleWorldBefore); // Identity columns reject arbitrary SQL literals after the candidate's - // issuer boundary is active. The auth-neutral v11 fixture inserts the + // issuer boundary is active. The auth-neutral v12 fixture inserts the // caller's verified sender identity through a disposable reducer instead. await callLoopbackReducer( server, @@ -4743,10 +4949,11 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } - const populatedV11SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase)); + const populatedV12SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase)); await callLoopbackReducer( server, @@ -4768,7 +4975,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); @@ -4781,6 +4988,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4794,7 +5002,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); @@ -4807,6 +5015,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4820,7 +5029,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4832,7 +5041,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4844,10 +5053,10 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); - // The immediate v11 -> v10 rollback must be refused before it can remove - // the Water revision. Older boundaries continue protecting Stone and Water. + // The v12 boundary must refuse every predecessor before any generic-worker + // or Water table can be removed. await publish( server, owner.token, @@ -4858,7 +5067,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4870,7 +5079,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4882,7 +5091,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); // Older fixture rollbacks remain refused as well. await publish( @@ -4895,17 +5104,17 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish(server, owner.token, additiveModule, emptyDatabase); - assertAdditiveV11Schema( - emptyV10, + assertAdditiveV12Schema( + emptyV11, await describe(server, owner.token, emptyDatabase), ); // Reuse the table-identical auth-neutral fixture for the final bounded // identity assertion; the candidate itself deliberately rejects the // disposable owner issuer before any private identity SQL can run. - await publish(server, owner.token, additiveV11SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); for (const table of [ @@ -4917,6 +5126,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4939,7 +5149,9 @@ async function main() { + 'public Tier-I Stone sites, identity-minimized occupations, and public-safe lifecycle schedule projection plus private Stone expedition and idempotency ' + 'tables appended at exact refs 41-45, ' + 'public ocean-and-river Water revision policy appended at exact ref 46, ' - + '61-tile empty, synthetic nonempty, and populated v10 Water/Stone fixtures remained preserved through v11, ' + + 'identity-safe generic worker readiness, roster, assignment, occupation, bounded receipt, and private schedule tables appended at exact refs 47-52, ' + + '61-tile empty, synthetic nonempty, and populated Water/Stone/Water-revision fixtures remained preserved through v12, ' + + 'every v12 table was populated, retained through the real candidate, and protected from a v12-to-v11 downgrade, ' + 'exact resolver HTTP lifecycle enforced without mutation, ' + `atomic 1,261-to-10,000 world expansion proved in ${worldExpansionDurationMilliseconds}ms with an idempotent retry, ` + `actual Water administration exercised with ${waterLifecycleProof}, ` @@ -4951,8 +5163,8 @@ async function main() { + 'presentation-independent founder monitoring and bootstrap, ' + 'legacy first-time admission rejection and complete-graph re-enable preservation, ' + 'and guarded backfill rejection/idempotence held, ' - + 'prebuilt-artifact republish idempotent, populated v3-prefix state retained through v11, ' - + 'and guarded v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.', + + 'prebuilt-artifact republish idempotent, populated v3-prefix state retained through v12, ' + + 'and guarded v11/v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.', artifactDigest: builtArtifactDigest, })); } finally { diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts index 1b6ac58e..fd747802 100644 --- a/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts +++ b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts @@ -151,7 +151,7 @@ const castleWorkerV1 = table({ indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const, }, { workerId: t.string().primaryKey(), originCastleId: t.u64(), ordinal: t.u32(), status: t.string(), - assignmentId: t.option(t.string()), resourceKind: t.option(t.string()), siteId: t.option(t.string()), + resourceKind: t.option(t.string()), siteId: t.option(t.string()), startedAtMicros: t.option(t.u64()), arrivesAtMicros: t.option(t.u64()), gatheringEndsAtMicros: t.option(t.u64()), returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.option(t.u64()), routeSteps: t.option(t.u32()), returnStartProgressBasisPoints: t.option(t.u32()), timelineRevision: t.u32(), revision: t.u64(), @@ -159,7 +159,10 @@ const castleWorkerV1 = table({ }); const workerAssignmentV1 = table({ name: 'worker_assignment_v1', - indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const, + indexes: [ + { accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const }, + { accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }, + ] as const, }, { assignmentId: t.string().primaryKey(), workerId: t.string().unique(), fid: t.u64(), originCastleId: t.u64(), resourceKind: t.string(), siteId: t.string().index(), phase: t.string(), @@ -177,7 +180,7 @@ const workerNodeOccupationV1 = table({ ] as const, }, { nodeKey: t.string().primaryKey(), resourceKind: t.string(), siteId: t.string(), workerId: t.string(), - workerOrdinal: t.u32(), originCastleId: t.u64(), assignmentId: t.string(), phase: t.string(), + workerOrdinal: t.u32(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), timelineRevision: t.u32(), }); const workerCommandIdempotencyV1 = table({ @@ -189,7 +192,7 @@ const workerCommandIdempotencyV1 = table({ resultRevision: t.u64(), createdAt: t.timestamp(), }); const workerAssignmentScheduleV1 = table({ - name: 'worker_assignment_schedule_v_1', public: true, + name: 'worker_assignment_schedule_v_1', indexes: [ { accessor: 'byAssignment', algorithm: 'btree', columns: ['assignmentId'] as const }, { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, @@ -530,7 +533,6 @@ export const fixtureSeedGenericWorkerSentinelV12 = db.reducer( originCastleId: castleId, ordinal, status: ordinal === 1 ? 'gathering' : 'idle', - assignmentId: ordinal === 1 ? assignmentId : undefined, resourceKind: ordinal === 1 ? 'stone' : undefined, siteId: ordinal === 1 ? siteId : undefined, startedAtMicros: ordinal === 1 ? startedAtMicros : undefined, @@ -576,7 +578,6 @@ export const fixtureSeedGenericWorkerSentinelV12 = db.reducer( workerId, workerOrdinal: 1, originCastleId: castleId, - assignmentId, phase: 'gathering', startedAtMicros, arrivesAtMicros, diff --git a/spacetimedb/pnpm-lock.yaml b/spacetimedb/pnpm-lock.yaml index e3e7575d..f39761b7 100644 --- a/spacetimedb/pnpm-lock.yaml +++ b/spacetimedb/pnpm-lock.yaml @@ -39,6 +39,16 @@ importers: specifier: 5.6.3 version: 5.6.3 + migration-fixtures/additive-v12-schema: + dependencies: + spacetimedb: + specifier: 2.6.1 + version: 2.6.1 + devDependencies: + typescript: + specifier: 5.6.3 + version: 5.6.3 + migration-fixtures/additive-v2-schema: dependencies: spacetimedb: diff --git a/spacetimedb/src/castleWorkerAuthority.ts b/spacetimedb/src/castleWorkerAuthority.ts index 0be9eb06..dc668f69 100644 --- a/spacetimedb/src/castleWorkerAuthority.ts +++ b/spacetimedb/src/castleWorkerAuthority.ts @@ -27,7 +27,7 @@ import { } from './castleWorkerPolicy'; import { assertCastleWorkerRoster, - workerRosterDigestInput, + castleWorkerPublicStateIsConsistent, workerSystemRowIsStagedOrActive, } from './castleWorkerRoster'; import { @@ -43,6 +43,7 @@ type CastleRow = NonNullable>; type AssignmentRow = NonNullable>; type ScheduleRow = NonNullable>; +type WorkerReceiptRow = NonNullable>; type ResourceAccountRow = NonNullable>; export const WORKER_SCHEDULE_STAGE_ARRIVAL = 'arrival'; @@ -50,6 +51,8 @@ export const WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY = 'gathering-expiry'; export const WORKER_SCHEDULE_STAGE_RETURN_COMPLETE = 'return-complete'; const WORKER_SYSTEM_REALM_ID = CANONICAL_REALM.realmId; const WORKER_TIMELINE_MAX = 0xffff_ffff; +export const WORKER_IDEMPOTENCY_RECEIPTS_PER_FID = 64; +const BOUNDED_WORKER_ERROR_CODE = /^[A-Z][A-Z0-9_]{0,63}$/; export class CastleWorkerAuthorityError extends Error { constructor(readonly code: string) { @@ -88,15 +91,25 @@ function assignmentPhase(value: string): CastleWorkerPhase { } function systemRow(ctx: WarpkeepReducerContext) { + if (ctx.db.realmWorkerSystemV1.count() !== 1n) fail('WORKER_SYSTEM_NOT_READY'); const row = ctx.db.realmWorkerSystemV1.realmId.find(WORKER_SYSTEM_REALM_ID); if (row === null || !workerSystemRowIsStagedOrActive(row)) fail('WORKER_SYSTEM_NOT_READY'); return row; } -function workerSystemActive(ctx: WarpkeepReducerContext) { +function workerSettlementActive(ctx: WarpkeepReducerContext) { const row = systemRow(ctx); if (row.mode !== 'active') fail('WORKER_SYSTEM_STAGED'); if (row.legacyDrainRequired) fail('WORKER_LEGACY_DRAIN_REQUIRED'); + const legacy = legacyActiveCounts(ctx); + if (legacy.expeditions !== 0n || legacy.occupations !== 0n || legacy.schedules !== 0n) { + fail('WORKER_LEGACY_DRAIN_REQUIRED'); + } + return row; +} + +function workerSystemActive(ctx: WarpkeepReducerContext) { + const row = workerSettlementActive(ctx); const castleIds = [...ctx.db.castle.iter()].map(castle => castle.castleId); const expectedWorkerCount = BigInt(castleIds.length * CASTLE_WORKERS_PER_CASTLE); if ( @@ -111,10 +124,29 @@ function workerSystemActive(ctx: WarpkeepReducerContext) { fail('WORKER_ROSTER_ORPHAN'); } } - const legacy = legacyActiveCounts(ctx); - if (legacy.expeditions !== 0n || legacy.occupations !== 0n || legacy.schedules !== 0n) { - fail('WORKER_LEGACY_DRAIN_REQUIRED'); - } + const graph = inspectCastleWorkerGraph(ctx); + if ( + !graph.systemConfigValid + || !graph.expectedCountsMatch + || !graph.rosterDigestMatches + || graph.castlesMissingWorkers !== 0n + || graph.castlesWithExtraWorkers !== 0n + || graph.duplicateOrdinals !== 0n + || graph.malformedWorkerIds !== 0n + || graph.invalidWorkerStates !== 0n + || graph.orphanWorkers !== 0n + || graph.orphanAssignments !== 0n + || graph.assignmentsMissingOccupation !== 0n + || graph.assignmentsWithoutSingleSchedule !== 0n + || graph.orphanOccupations !== 0n + || graph.orphanSchedules !== 0n + || graph.invalidSchedules !== 0n + || graph.assignmentPublicMismatches !== 0n + || graph.occupationSiteMismatches !== 0n + || graph.invalidAssignments !== 0n + || graph.invalidIdempotencyReceipts !== 0n + || graph.idempotencyOverflowFids !== 0n + ) fail('WORKER_SYSTEM_INTEGRITY'); return row; } @@ -169,27 +201,122 @@ function legacyOccupationAt(ctx: WarpkeepReducerContext, resourceKind: string, s } function publicWorkerMatchesAssignment(worker: WorkerRow, assignment: AssignmentRow): boolean { + const expectedReturnProgress = assignment.phase === 'returning' + ? assignment.returnStartProgressBasisPoints + : undefined; return worker.workerId === assignment.workerId + && worker.ordinal >= 1 + && worker.ordinal <= CASTLE_WORKERS_PER_CASTLE + && worker.workerId === workerIdForCastle(assignment.originCastleId, worker.ordinal) && worker.originCastleId === assignment.originCastleId && worker.status === assignment.phase - && worker.assignmentId === assignment.assignmentId && worker.resourceKind === assignment.resourceKind && worker.siteId === assignment.siteId && worker.startedAtMicros === assignment.startedAtMicros && worker.arrivesAtMicros === assignment.arrivesAtMicros && worker.gatheringEndsAtMicros === assignment.gatheringEndsAtMicros + && worker.returnStartedAtMicros === assignment.returnStartedAtMicros && worker.returnsAtMicros === assignment.returnsAtMicros && worker.routeSteps === assignment.routeSteps + && worker.returnStartProgressBasisPoints === expectedReturnProgress && worker.timelineRevision === assignment.timelineRevision; } +function occupationMatchesAssignment( + occupation: NonNullable>, + assignment: AssignmentRow, +): boolean { + // The occupation is only the outbound/gathering site lease and is deleted + // before return starts. Return chronology therefore belongs to the worker + // projection; every field that the occupation does expose is matched here. + return occupation.nodeKey === `${assignment.resourceKind}:${assignment.siteId}` + && occupation.resourceKind === assignment.resourceKind + && occupation.siteId === assignment.siteId + && occupation.workerId === assignment.workerId + && occupation.workerOrdinal >= 1 + && occupation.workerOrdinal <= CASTLE_WORKERS_PER_CASTLE + && assignment.workerId === workerIdForCastle(assignment.originCastleId, occupation.workerOrdinal) + && occupation.originCastleId === assignment.originCastleId + && assignment.phase !== 'returning' + && occupation.phase === assignment.phase + && occupation.startedAtMicros === assignment.startedAtMicros + && occupation.arrivesAtMicros === assignment.arrivesAtMicros + && occupation.gatheringEndsAtMicros === assignment.gatheringEndsAtMicros + && occupation.timelineRevision === assignment.timelineRevision; +} + +function canonicalCastleOwnershipMatches( + ctx: WarpkeepReducerContext, + fid: bigint, + castleId: bigint, +): boolean { + const castle = ctx.db.castle.castleId.find(castleId); + const account = ctx.db.resourceAccountV1.fid.find(fid); + return castle !== null + && castle.ownerFid === fid + && account !== null + && account.castleId === castleId; +} + +function assignmentOwnerIsCanonical(ctx: WarpkeepReducerContext, assignment: AssignmentRow): boolean { + return canonicalCastleOwnershipMatches(ctx, assignment.fid, assignment.originCastleId); +} + +function receiptOwnerIsCanonical( + ctx: WarpkeepReducerContext, + receipt: WorkerReceiptRow, + expectedCastleId?: bigint, +): boolean { + if (receipt.workerId !== undefined) { + const worker = ctx.db.castleWorkerV1.workerId.find(receipt.workerId); + return worker !== null + && worker.ordinal >= 1 + && worker.ordinal <= CASTLE_WORKERS_PER_CASTLE + && worker.workerId === workerIdForCastle(worker.originCastleId, worker.ordinal) + && (expectedCastleId === undefined || worker.originCastleId === expectedCastleId) + && canonicalCastleOwnershipMatches(ctx, receipt.fid, worker.originCastleId); + } + const castle = ctx.db.castle.ownerFid.find(receipt.fid); + return castle !== null + && (expectedCastleId === undefined || castle.castleId === expectedCastleId) + && canonicalCastleOwnershipMatches(ctx, receipt.fid, castle.castleId); +} + +function workerReceiptShapeIsValid(receipt: WorkerReceiptRow): boolean { + if ( + !receipt.requestKey.startsWith(`${receipt.fid.toString()}:`) + || receipt.resultRevision < 0n + ) return false; + if (receipt.commandKind === 'dispatch' || receipt.commandKind === 'recall') { + return receipt.workerId !== undefined + && receipt.resourceKind !== undefined + && ['gold', 'food', 'wood', 'stone'].includes(receipt.resourceKind) + && receipt.siteId !== undefined + && receipt.siteId.length > 0 + && receipt.assignmentId !== undefined + && receipt.assignmentId.length > 0; + } + return receipt.commandKind === 'recall-all' + && receipt.workerId === undefined + && receipt.resourceKind === undefined + && receipt.siteId === undefined + && receipt.assignmentId !== undefined + && receipt.assignmentId.length > 0; +} + function assertAssignmentState(assignment: AssignmentRow): void { assignmentPhase(assignment.phase); + assertCastleWorkerId(assignment.workerId); if ( - assignment.workerId.length === 0 + assignment.assignmentId.length === 0 + || assignment.fid <= 0n + || assignment.originCastleId < 0n + || assignment.siteId.length === 0 || assignment.policyVersion !== CASTLE_WORKER_POLICY_VERSION || !workerAssignmentStateIsConsistent(assignment) || assignment.returnStartProgressBasisPoints > 10_000 + || !Number.isSafeInteger(assignment.timelineRevision) + || assignment.timelineRevision < 0 ) fail('WORKER_ASSIGNMENT_STATE_INVALID'); } @@ -209,6 +336,44 @@ function insertSchedule( }); } +function deleteSchedulesForAssignment(ctx: WarpkeepReducerContext, assignmentId: string): void { + for (const schedule of [...ctx.db.workerAssignmentScheduleV1.byAssignment.filter(assignmentId)]) { + ctx.db.workerAssignmentScheduleV1.scheduleId.delete(schedule.scheduleId); + } +} + +function scheduleMatchesAssignment(schedule: ScheduleRow, assignment: AssignmentRow): boolean { + const expectedStage = assignment.phase === 'outbound' + ? WORKER_SCHEDULE_STAGE_ARRIVAL + : assignment.phase === 'gathering' + ? WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY + : WORKER_SCHEDULE_STAGE_RETURN_COMPLETE; + const expectedAtMicros = expectedStage === WORKER_SCHEDULE_STAGE_ARRIVAL + ? assignment.arrivesAtMicros + : expectedStage === WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY + ? assignment.gatheringEndsAtMicros + : assignment.returnsAtMicros; + return schedule.stage === expectedStage + && schedule.workerId === assignment.workerId + && schedule.timelineRevision === assignment.timelineRevision + && schedule.scheduledAt.tag === 'Time' + && schedule.scheduledAt.value.microsSinceUnixEpoch === expectedAtMicros; +} + +function pruneWorkerIdempotencyReceipts(ctx: WarpkeepReducerContext, fid: bigint): void { + const receipts = [...ctx.db.workerCommandIdempotencyV1.byFid.filter(fid)] + .sort((left, right) => { + const timeOrder = left.createdAt.microsSinceUnixEpoch < right.createdAt.microsSinceUnixEpoch + ? -1 + : left.createdAt.microsSinceUnixEpoch > right.createdAt.microsSinceUnixEpoch ? 1 : 0; + return timeOrder || left.requestKey.localeCompare(right.requestKey); + }); + const deleteCount = Math.max(0, receipts.length - WORKER_IDEMPOTENCY_RECEIPTS_PER_FID + 1); + for (const receipt of receipts.slice(0, deleteCount)) { + ctx.db.workerCommandIdempotencyV1.requestKey.delete(receipt.requestKey); + } +} + function updateResourceAccount( ctx: WarpkeepReducerContext, resource: ResourceAccountRow, @@ -239,6 +404,8 @@ export function settleAllWorkerAssignmentsForFid( observedAtMicros = ctx.timestamp.microsSinceUnixEpoch, ): void { const resource = assertGenesisResourceForFid(ctx, fid); + const assignments = [...ctx.db.workerAssignmentV1.byFid.filter(fid)]; + if (assignments.length > 0) workerSettlementActive(ctx); const passive = planResourceSettlementForActiveExpeditionReservations( ctx, fid, @@ -254,8 +421,7 @@ export function settleAllWorkerAssignmentsForFid( gold: passive.balances.gold, }; let changed = passive.completedQuanta > 0n; - for (const assignment of ctx.db.workerAssignmentV1.iter()) { - if (assignment.fid !== fid) continue; + for (const assignment of assignments) { assertAssignmentState(assignment); if (assignment.fid !== fid || assignment.originCastleId !== resource.castle.castleId) fail('WORKER_OWNER_INTEGRITY'); const plan = planCastleWorkerAccrual(assignment, observedAtMicros); @@ -303,6 +469,7 @@ export function projectMyWorkerState( fid: bigint, observedAtMicros = ctx.timestamp.microsSinceUnixEpoch, ): Readonly<{ resource: ResourceAccountRow; balances: Readonly>; workers: readonly WorkerPrivateProjection[] }> { + workerSystemActive(ctx); const resource = assertGenesisResourceForFid(ctx, fid); const passive = planResourceSettlementForActiveExpeditionReservations( ctx, @@ -320,11 +487,9 @@ export function projectMyWorkerState( const workers = [...assertCastleWorkerRoster(ctx, resource.castle.castleId)] .sort((left, right) => left.ordinal - right.ordinal) .map(worker => { - const assignment = worker.assignmentId === undefined - ? undefined - : ctx.db.workerAssignmentV1.assignmentId.find(worker.assignmentId); - if (assignment === undefined || assignment === null) { - if (worker.assignmentId !== undefined) fail('WORKER_ASSIGNMENT_MISSING'); + const assignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId); + if (assignment === null) { + if (worker.status !== 'idle') fail('WORKER_ASSIGNMENT_MISSING'); return Object.freeze({ workerId: worker.workerId, ordinal: worker.ordinal, @@ -388,17 +553,36 @@ export function dispatchCastleWorker( const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { if (prior.fid !== input.fid || prior.commandKind !== 'dispatch' || prior.workerId !== input.workerId || prior.resourceKind !== input.resourceKind || prior.siteId !== input.siteId || prior.assignmentId === undefined) fail('WORKER_IDEMPOTENCY_CONFLICT'); + if ( + !workerReceiptShapeIsValid(prior) + || !canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId) + || !receiptOwnerIsCanonical(ctx, prior, input.castle.castleId) + ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID'); const assignment = ctx.db.workerAssignmentV1.assignmentId.find(prior.assignmentId); - if (assignment === null) fail('WORKER_IDEMPOTENCY_STALE'); + if ( + assignment === null + || assignment.fid !== input.fid + || assignment.workerId !== input.workerId + || assignment.resourceKind !== input.resourceKind + || assignment.siteId !== input.siteId + || assignment.originCastleId !== input.castle.castleId + || !assignmentOwnerIsCanonical(ctx, assignment) + ) fail('WORKER_IDEMPOTENCY_STALE'); + assertAssignmentState(assignment); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) { + fail('WORKER_IDEMPOTENCY_STALE'); + } return Object.freeze({ assignment, idempotent: true }); } workerSystemActive(ctx); + if (!canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)) fail('WORKER_NOT_OWNED'); settleAllWorkerAssignmentsForFid(ctx, input.fid); const roster = assertCastleWorkerRoster(ctx, input.castle.castleId); const worker = ctx.db.castleWorkerV1.workerId.find(input.workerId); if (worker === null || worker.originCastleId !== input.castle.castleId || !roster.some(row => row.workerId === worker.workerId)) fail('WORKER_NOT_OWNED'); assertCastleWorkerId(worker.workerId); - if (worker.status !== 'idle' || worker.assignmentId !== undefined) fail('WORKER_NOT_IDLE'); + if (worker.status !== 'idle' || ctx.db.workerAssignmentV1.workerId.find(worker.workerId) !== null) fail('WORKER_NOT_IDLE'); const site = canonicalSiteFor(ctx, input.resourceKind, input.siteId); if (legacyOccupationAt(ctx, input.resourceKind, input.siteId)) fail('WORKER_LEGACY_SITE_OCCUPIED'); const nodeKey = `${input.resourceKind}:${input.siteId}`; @@ -408,6 +592,7 @@ export function dispatchCastleWorker( const resource = assertGenesisResourceForFid(ctx, input.fid); assertDispatchReservations(ctx, input.fid, resource.account, input.resourceKind); const timeline = planCastleWorkerTimeline(ctx.timestamp.microsSinceUnixEpoch, routeSteps); + const timelineRevision = safeNextU32(worker.timelineRevision, 'WORKER_TIMELINE_REVISION'); const assignment = ctx.db.workerAssignmentV1.insert({ assignmentId: ctx.newUuidV7().toString(), workerId: worker.workerId, @@ -423,7 +608,7 @@ export function dispatchCastleWorker( settledThroughMicros: timeline.arrivesAtMicros, accruedAmount: 0n, materializedAmount: 0n, - timelineRevision: 0, + timelineRevision, policyVersion: CASTLE_WORKER_POLICY_VERSION, createdAt: ctx.timestamp, updatedAt: ctx.timestamp, @@ -431,7 +616,6 @@ export function dispatchCastleWorker( ctx.db.castleWorkerV1.workerId.update({ ...worker, status: 'outbound', - assignmentId: assignment.assignmentId, resourceKind: input.resourceKind, siteId: input.siteId, startedAtMicros: assignment.startedAtMicros, @@ -441,6 +625,8 @@ export function dispatchCastleWorker( returnsAtMicros: assignment.returnsAtMicros, routeSteps: assignment.routeSteps, returnStartProgressBasisPoints: undefined, + timelineRevision, + revision: safeNextU64(worker.revision, 'WORKER_REVISION'), updatedAt: ctx.timestamp, }); ctx.db.workerNodeOccupationV1.insert({ @@ -450,7 +636,6 @@ export function dispatchCastleWorker( workerId: worker.workerId, workerOrdinal: worker.ordinal, originCastleId: input.castle.castleId, - assignmentId: assignment.assignmentId, phase: 'outbound', startedAtMicros: assignment.startedAtMicros, arrivesAtMicros: assignment.arrivesAtMicros, @@ -458,8 +643,9 @@ export function dispatchCastleWorker( timelineRevision: assignment.timelineRevision, }); insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_ARRIVAL, assignment.arrivesAtMicros); - insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY, assignment.gatheringEndsAtMicros); - insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_RETURN_COMPLETE, assignment.returnsAtMicros); + const updatedWorker = ctx.db.castleWorkerV1.workerId.find(worker.workerId); + if (updatedWorker === null || !publicWorkerMatchesAssignment(updatedWorker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + pruneWorkerIdempotencyReceipts(ctx, input.fid); ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, @@ -468,7 +654,7 @@ export function dispatchCastleWorker( resourceKind: input.resourceKind, siteId: input.siteId, assignmentId: assignment.assignmentId, - resultRevision: worker.revision, + resultRevision: updatedWorker.revision, createdAt: ctx.timestamp, }); return Object.freeze({ assignment, idempotent: false }); @@ -498,10 +684,9 @@ function beginWorkerReturn( assertAssignmentState(assignment); if (assignment.phase !== 'outbound' && assignment.phase !== 'gathering') return assignment; const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`); - if (occupation !== null) { - if (occupation.assignmentId !== assignment.assignmentId || occupation.workerId !== assignment.workerId || occupation.timelineRevision !== assignment.timelineRevision) fail('WORKER_OCCUPATION_INTEGRITY'); - ctx.db.workerNodeOccupationV1.nodeKey.delete(occupation.nodeKey); - } + if (occupation === null || !occupationMatchesAssignment(occupation, assignment)) fail('WORKER_OCCUPATION_INTEGRITY'); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); const returningAtMicros = now + remainingTravelMicros(assignment, progress); const timelineRevision = safeNextU32(assignment.timelineRevision, 'WORKER_TIMELINE_REVISION'); const returning = { @@ -513,9 +698,9 @@ function beginWorkerReturn( timelineRevision, updatedAt: ctx.timestamp, }; + deleteSchedulesForAssignment(ctx, assignment.assignmentId); + ctx.db.workerNodeOccupationV1.nodeKey.delete(occupation.nodeKey); ctx.db.workerAssignmentV1.assignmentId.update(returning); - const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); - if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); ctx.db.castleWorkerV1.workerId.update({ ...worker, status: 'returning', @@ -535,11 +720,14 @@ function completeWorkerReturn(ctx: WarpkeepReducerContext, assignment: Assignmen if (assignment.phase !== 'returning') fail('WORKER_RETURN_STATE'); const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + if (ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`) !== null) { + fail('WORKER_OCCUPATION_INTEGRITY'); + } + deleteSchedulesForAssignment(ctx, assignment.assignmentId); ctx.db.workerAssignmentV1.assignmentId.delete(assignment.assignmentId); ctx.db.castleWorkerV1.workerId.update({ ...worker, status: 'idle', - assignmentId: undefined, resourceKind: undefined, siteId: undefined, startedAtMicros: undefined, @@ -559,13 +747,22 @@ function transitionWorkerArrival(ctx: WarpkeepReducerContext, assignment: Assign if (now < assignment.arrivesAtMicros) return assignment; if (assignment.phase !== 'outbound') return assignment; const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`); - if (occupation === null || occupation.assignmentId !== assignment.assignmentId || occupation.timelineRevision !== assignment.timelineRevision) fail('WORKER_OCCUPATION_MISSING'); - const gathering = { ...assignment, phase: 'gathering', updatedAt: ctx.timestamp }; - ctx.db.workerAssignmentV1.assignmentId.update(gathering); - ctx.db.workerNodeOccupationV1.nodeKey.update({ ...occupation, phase: 'gathering' }); + if (occupation === null || !occupationMatchesAssignment(occupation, assignment)) fail('WORKER_OCCUPATION_MISSING'); const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); - ctx.db.castleWorkerV1.workerId.update({ ...worker, status: 'gathering', updatedAt: ctx.timestamp }); + const timelineRevision = safeNextU32(assignment.timelineRevision, 'WORKER_TIMELINE_REVISION'); + const gathering = { ...assignment, phase: 'gathering', timelineRevision, updatedAt: ctx.timestamp }; + deleteSchedulesForAssignment(ctx, assignment.assignmentId); + ctx.db.workerAssignmentV1.assignmentId.update(gathering); + ctx.db.workerNodeOccupationV1.nodeKey.update({ ...occupation, phase: 'gathering', timelineRevision }); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'gathering', + timelineRevision, + revision: safeNextU64(worker.revision, 'WORKER_REVISION'), + updatedAt: ctx.timestamp, + }); + insertSchedule(ctx, gathering, WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY, gathering.gatheringEndsAtMicros); return gathering; } @@ -583,7 +780,10 @@ function settleAndBeginReturnAt( export function runCastleWorkerSchedule(ctx: WarpkeepReducerContext, schedule: ScheduleRow): void { const assignment = ctx.db.workerAssignmentV1.assignmentId.find(schedule.assignmentId); - if (assignment === null || assignment.workerId !== schedule.workerId || assignment.timelineRevision !== schedule.timelineRevision) return; + if (assignment === null || !scheduleMatchesAssignment(schedule, assignment)) { + ctx.db.workerAssignmentScheduleV1.scheduleId.delete(schedule.scheduleId); + return; + } assertAssignmentState(assignment); const now = ctx.timestamp.microsSinceUnixEpoch; if (schedule.stage === WORKER_SCHEDULE_STAGE_ARRIVAL) { @@ -596,13 +796,11 @@ export function runCastleWorkerSchedule(ctx: WarpkeepReducerContext, schedule: S settleAndBeginReturnAt(ctx, gathering, gathering.gatheringEndsAtMicros, 10_000); return; } - if (schedule.stage !== WORKER_SCHEDULE_STAGE_RETURN_COMPLETE) return; - let current = assignment; - if (current.phase === 'outbound' && now >= current.arrivesAtMicros) current = transitionWorkerArrival(ctx, current, now); - if ((current.phase === 'outbound' || current.phase === 'gathering') && now >= current.gatheringEndsAtMicros) { - current = settleAndBeginReturnAt(ctx, current, current.gatheringEndsAtMicros, 10_000); + if (schedule.stage === WORKER_SCHEDULE_STAGE_RETURN_COMPLETE) { + completeWorkerReturn(ctx, assignment, now); + return; } - completeWorkerReturn(ctx, current, now); + ctx.db.workerAssignmentScheduleV1.scheduleId.delete(schedule.scheduleId); } export function recallCastleWorker( @@ -613,26 +811,39 @@ export function recallCastleWorker( const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { if (prior.fid !== input.fid || prior.commandKind !== 'recall' || prior.workerId !== input.workerId) fail('WORKER_IDEMPOTENCY_CONFLICT'); + if ( + !workerReceiptShapeIsValid(prior) + || !canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId) + || !receiptOwnerIsCanonical(ctx, prior, input.castle.castleId) + ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID'); return; } workerSystemActive(ctx); + if (!canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)) fail('WORKER_NOT_OWNED'); settleAllWorkerAssignmentsForFid(ctx, input.fid); const worker = ctx.db.castleWorkerV1.workerId.find(input.workerId); if (worker === null || worker.originCastleId !== input.castle.castleId) fail('WORKER_NOT_OWNED'); assertCastleWorkerRoster(ctx, input.castle.castleId); - if (worker.assignmentId === undefined) { - ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: worker.workerId, commandKind: 'recall', resourceKind: undefined, siteId: undefined, assignmentId: undefined, resultRevision: worker.revision, createdAt: ctx.timestamp }); + const assignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId); + if (assignment === null) { + if (worker.status !== 'idle') fail('WORKER_ASSIGNMENT_MISSING'); return; } - const assignment = ctx.db.workerAssignmentV1.assignmentId.find(worker.assignmentId); - if (assignment === null || assignment.fid !== input.fid) fail('WORKER_ASSIGNMENT_MISSING'); + if (assignment.fid !== input.fid) fail('WORKER_ASSIGNMENT_MISSING'); assertAssignmentState(assignment); - if (assignment.phase === 'outbound') { - beginWorkerReturn(ctx, assignment, progressBasisPoints(assignment, ctx.timestamp.microsSinceUnixEpoch), ctx.timestamp.microsSinceUnixEpoch); - } else if (assignment.phase === 'gathering') { - beginWorkerReturn(ctx, assignment, 10_000, ctx.timestamp.microsSinceUnixEpoch); - } - ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: worker.workerId, commandKind: 'recall', resourceKind: assignment.resourceKind, siteId: assignment.siteId, assignmentId: assignment.assignmentId, resultRevision: worker.revision, createdAt: ctx.timestamp }); + if (assignment.phase === 'returning') return; + const now = ctx.timestamp.microsSinceUnixEpoch; + const returnStartedAtMicros = now < assignment.gatheringEndsAtMicros + ? now + : assignment.gatheringEndsAtMicros; + const progress = returnStartedAtMicros < assignment.arrivesAtMicros + ? progressBasisPoints(assignment, returnStartedAtMicros) + : 10_000; + const returning = beginWorkerReturn(ctx, assignment, progress, returnStartedAtMicros); + const updatedWorker = ctx.db.castleWorkerV1.workerId.find(worker.workerId); + if (updatedWorker === null || !publicWorkerMatchesAssignment(updatedWorker, returning)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + pruneWorkerIdempotencyReceipts(ctx, input.fid); + ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: worker.workerId, commandKind: 'recall', resourceKind: assignment.resourceKind, siteId: assignment.siteId, assignmentId: assignment.assignmentId, resultRevision: updatedWorker.revision, createdAt: ctx.timestamp }); } export function recallAllCastleWorkers( @@ -643,38 +854,62 @@ export function recallAllCastleWorkers( const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { if (prior.fid !== input.fid || prior.commandKind !== 'recall-all' || prior.workerId !== undefined) fail('WORKER_IDEMPOTENCY_CONFLICT'); + if ( + !workerReceiptShapeIsValid(prior) + || !canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId) + || !receiptOwnerIsCanonical(ctx, prior, input.castle.castleId) + ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID'); return; } workerSystemActive(ctx); + if (!canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)) fail('WORKER_NOT_OWNED'); const roster = assertCastleWorkerRoster(ctx, input.castle.castleId); settleAllWorkerAssignmentsForFid(ctx, input.fid); const now = ctx.timestamp.microsSinceUnixEpoch; let lastAssignmentId: string | undefined; + let resultRevision = 0n; for (const worker of [...roster].sort((left, right) => left.ordinal - right.ordinal)) { const fresh = ctx.db.castleWorkerV1.workerId.find(worker.workerId); if (fresh === null || fresh.originCastleId !== input.castle.castleId) fail('WORKER_ROSTER_INTEGRITY'); - if (fresh.assignmentId === undefined) continue; - const assignment = ctx.db.workerAssignmentV1.assignmentId.find(fresh.assignmentId); - if (assignment === null || assignment.fid !== input.fid || !publicWorkerMatchesAssignment(fresh, assignment)) fail('WORKER_ASSIGNMENT_INTEGRITY'); - if (assignment.phase === 'outbound') { - lastAssignmentId = beginWorkerReturn(ctx, assignment, progressBasisPoints(assignment, now), now).assignmentId; - } else if (assignment.phase === 'gathering') { - lastAssignmentId = beginWorkerReturn(ctx, assignment, 10_000, now).assignmentId; + const assignment = ctx.db.workerAssignmentV1.workerId.find(fresh.workerId); + if (assignment === null) { + if (fresh.status !== 'idle') fail('WORKER_ASSIGNMENT_INTEGRITY'); + continue; } + if (assignment.fid !== input.fid || !publicWorkerMatchesAssignment(fresh, assignment)) fail('WORKER_ASSIGNMENT_INTEGRITY'); + if (assignment.phase === 'returning') continue; + const returnStartedAtMicros = now < assignment.gatheringEndsAtMicros + ? now + : assignment.gatheringEndsAtMicros; + const progress = returnStartedAtMicros < assignment.arrivesAtMicros + ? progressBasisPoints(assignment, returnStartedAtMicros) + : 10_000; + const returning = beginWorkerReturn(ctx, assignment, progress, returnStartedAtMicros); + const updatedWorker = ctx.db.castleWorkerV1.workerId.find(fresh.workerId); + if (updatedWorker === null || !publicWorkerMatchesAssignment(updatedWorker, returning)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + lastAssignmentId = returning.assignmentId; + if (updatedWorker.revision > resultRevision) resultRevision = updatedWorker.revision; } - ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: undefined, commandKind: 'recall-all', resourceKind: undefined, siteId: undefined, assignmentId: lastAssignmentId, resultRevision: 0n, createdAt: ctx.timestamp }); + if (lastAssignmentId === undefined) return; + pruneWorkerIdempotencyReceipts(ctx, input.fid); + ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: undefined, commandKind: 'recall-all', resourceKind: undefined, siteId: undefined, assignmentId: lastAssignmentId, resultRevision, createdAt: ctx.timestamp }); } export type WorkerGraphAggregate = Readonly<{ systemRows: bigint; mode: string; + systemConfigValid: boolean; + legacyDrainRequired: boolean; expectedCastleCount: bigint; expectedWorkerCount: bigint; actualWorkerCount: bigint; + expectedCountsMatch: boolean; + rosterDigestMatches: boolean; castlesMissingWorkers: bigint; castlesWithExtraWorkers: bigint; duplicateOrdinals: bigint; malformedWorkerIds: bigint; + invalidWorkerStates: bigint; idleWorkers: bigint; outboundWorkers: bigint; gatheringWorkers: bigint; @@ -684,9 +919,17 @@ export type WorkerGraphAggregate = Readonly<{ schedules: bigint; orphanWorkers: bigint; orphanAssignments: bigint; + assignmentsMissingOccupation: bigint; + assignmentsWithoutSingleSchedule: bigint; orphanOccupations: bigint; + orphanSchedules: bigint; + invalidSchedules: bigint; assignmentPublicMismatches: bigint; occupationSiteMismatches: bigint; + invalidAssignments: bigint; + idempotencyReceipts: bigint; + invalidIdempotencyReceipts: bigint; + idempotencyOverflowFids: bigint; legacyExpeditions: bigint; legacyOccupations: bigint; legacySchedules: bigint; @@ -709,6 +952,7 @@ export function inspectCastleWorkerGraph(ctx: WarpkeepReducerContext): WorkerGra let castlesWithExtraWorkers = 0n; let duplicateOrdinals = 0n; let malformedWorkerIds = 0n; + let invalidWorkerStates = 0n; let orphanWorkers = 0n; let idleWorkers = 0n; let outboundWorkers = 0n; @@ -721,6 +965,7 @@ export function inspectCastleWorkerGraph(ctx: WarpkeepReducerContext): WorkerGra const ordinals = new Set(); for (const row of rows) { try { assertCastleWorkerId(row.workerId); } catch { malformedWorkerIds += 1n; } + if (!castleWorkerPublicStateIsConsistent(row)) invalidWorkerStates += 1n; if (ordinals.has(row.ordinal)) duplicateOrdinals += 1n; ordinals.add(row.ordinal); if (row.status === 'idle') idleWorkers += 1n; @@ -734,31 +979,78 @@ export function inspectCastleWorkerGraph(ctx: WarpkeepReducerContext): WorkerGra } let orphanAssignments = 0n; let assignmentPublicMismatches = 0n; + let invalidAssignments = 0n; + let assignmentsMissingOccupation = 0n; + let assignmentsWithoutSingleSchedule = 0n; + let occupationSiteMismatches = 0n; for (const assignment of ctx.db.workerAssignmentV1.iter()) { const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); if (worker === null) orphanAssignments += 1n; else if (!publicWorkerMatchesAssignment(worker, assignment)) assignmentPublicMismatches += 1n; + try { + assertAssignmentState(assignment); + if (!assignmentOwnerIsCanonical(ctx, assignment)) fail('WORKER_OWNER_INTEGRITY'); + } catch { + invalidAssignments += 1n; + } + const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`); + if (assignment.phase === 'returning') { + if (occupation !== null) occupationSiteMismatches += 1n; + } else if (occupation === null || !occupationMatchesAssignment(occupation, assignment)) { + assignmentsMissingOccupation += 1n; + } + const schedules = [...ctx.db.workerAssignmentScheduleV1.byAssignment.filter(assignment.assignmentId)]; + if (schedules.length !== 1 || !schedules.every(schedule => scheduleMatchesAssignment(schedule, assignment))) { + assignmentsWithoutSingleSchedule += 1n; + } } let orphanOccupations = 0n; - let occupationSiteMismatches = 0n; for (const occupation of ctx.db.workerNodeOccupationV1.iter()) { - const assignment = ctx.db.workerAssignmentV1.assignmentId.find(occupation.assignmentId); + const assignment = ctx.db.workerAssignmentV1.workerId.find(occupation.workerId); if (assignment === null) orphanOccupations += 1n; if (occupation.nodeKey !== `${occupation.resourceKind}:${occupation.siteId}`) occupationSiteMismatches += 1n; - if (assignment !== null && (assignment.phase === 'returning' || assignment.siteId !== occupation.siteId || assignment.resourceKind !== occupation.resourceKind)) occupationSiteMismatches += 1n; + if (assignment !== null && !occupationMatchesAssignment(occupation, assignment)) occupationSiteMismatches += 1n; + } + let orphanSchedules = 0n; + let invalidSchedules = 0n; + for (const schedule of ctx.db.workerAssignmentScheduleV1.iter()) { + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(schedule.assignmentId); + if (assignment === null) orphanSchedules += 1n; + else if (!scheduleMatchesAssignment(schedule, assignment)) invalidSchedules += 1n; + } + const receiptsPerFid = new Map(); + let invalidIdempotencyReceipts = 0n; + for (const receipt of ctx.db.workerCommandIdempotencyV1.iter()) { + receiptsPerFid.set(receipt.fid, (receiptsPerFid.get(receipt.fid) ?? 0) + 1); + if ( + !workerReceiptShapeIsValid(receipt) + || !receiptOwnerIsCanonical(ctx, receipt) + ) invalidIdempotencyReceipts += 1n; } + const idempotencyOverflowFids = BigInt([...receiptsPerFid.values()] + .filter(count => count > WORKER_IDEMPOTENCY_RECEIPTS_PER_FID).length); const legacy = legacyActiveCounts(ctx); const castleIds = castles.map(castle => castle.castleId); + const expectedWorkerCount = BigInt(castleIds.length * CASTLE_WORKERS_PER_CASTLE); + const expectedRosterDigest = rosterDigestForCastleIds(castleIds); return Object.freeze({ systemRows: ctx.db.realmWorkerSystemV1.count(), mode: system?.mode ?? 'absent', + systemConfigValid: system !== null && workerSystemRowIsStagedOrActive(system), + legacyDrainRequired: system?.legacyDrainRequired ?? true, expectedCastleCount: BigInt(system?.expectedCastleCount ?? 0), expectedWorkerCount: BigInt(system?.expectedWorkerCount ?? 0), actualWorkerCount: ctx.db.castleWorkerV1.count(), + expectedCountsMatch: system !== null + && BigInt(system.expectedCastleCount) === BigInt(castleIds.length) + && BigInt(system.expectedWorkerCount) === expectedWorkerCount + && ctx.db.castleWorkerV1.count() === expectedWorkerCount, + rosterDigestMatches: system !== null && system.rosterDigest === expectedRosterDigest, castlesMissingWorkers, castlesWithExtraWorkers, duplicateOrdinals, malformedWorkerIds, + invalidWorkerStates, idleWorkers, outboundWorkers, gatheringWorkers, @@ -768,18 +1060,29 @@ export function inspectCastleWorkerGraph(ctx: WarpkeepReducerContext): WorkerGra schedules: ctx.db.workerAssignmentScheduleV1.count(), orphanWorkers, orphanAssignments, + assignmentsMissingOccupation, + assignmentsWithoutSingleSchedule, orphanOccupations, + orphanSchedules, + invalidSchedules, assignmentPublicMismatches, occupationSiteMismatches, + invalidAssignments, + idempotencyReceipts: ctx.db.workerCommandIdempotencyV1.count(), + invalidIdempotencyReceipts, + idempotencyOverflowFids, legacyExpeditions: legacy.expeditions, legacyOccupations: legacy.occupations, legacySchedules: legacy.schedules, rosterDigest: system?.rosterDigest ?? '', - rosterDigestExpected: rosterDigestForCastleIds(castleIds), + rosterDigestExpected: expectedRosterDigest, }); } export function castleWorkerErrorCode(error: unknown): string | undefined { - if (error instanceof CastleWorkerAuthorityError || error instanceof CastleWorkerPolicyError) return error.code; + if ( + (error instanceof CastleWorkerAuthorityError || error instanceof CastleWorkerPolicyError) + && BOUNDED_WORKER_ERROR_CODE.test(error.code) + ) return error.code; return undefined; } diff --git a/spacetimedb/src/castleWorkerPolicy.ts b/spacetimedb/src/castleWorkerPolicy.ts index cc396a64..13dd6414 100644 --- a/spacetimedb/src/castleWorkerPolicy.ts +++ b/spacetimedb/src/castleWorkerPolicy.ts @@ -225,7 +225,10 @@ export type CastleWorkerAccrualState = Readonly<{ startedAtMicros: bigint; arrivesAtMicros: bigint; gatheringEndsAtMicros: bigint; + returnStartedAtMicros: bigint | undefined; returnsAtMicros: bigint; + routeSteps: number; + returnStartProgressBasisPoints: number; settledThroughMicros: bigint; accruedAmount: bigint; materializedAmount: bigint; @@ -247,24 +250,84 @@ export function workerAssignmentStateIsConsistent(state: CastleWorkerAccrualStat assertU64(state.arrivesAtMicros, 'WORKER_TIME_INVALID'); assertU64(state.gatheringEndsAtMicros, 'WORKER_TIME_INVALID'); assertU64(state.returnsAtMicros, 'WORKER_TIME_INVALID'); + if (state.returnStartedAtMicros !== undefined) { + assertU64(state.returnStartedAtMicros, 'WORKER_TIME_INVALID'); + } assertU64(state.settledThroughMicros, 'WORKER_CURSOR_INVALID'); assertU64(state.accruedAmount, 'WORKER_ACCRUAL_INVALID'); assertU64(state.materializedAmount, 'WORKER_MATERIALIZED_INVALID'); + if ( + !Number.isSafeInteger(state.routeSteps) + || state.routeSteps <= 0 + || !Number.isSafeInteger(state.returnStartProgressBasisPoints) + || state.returnStartProgressBasisPoints < 0 + || state.returnStartProgressBasisPoints > 10_000 + ) return false; + const travelMicros = checkedProduct( + BigInt(state.routeSteps), + CASTLE_WORKER_TRAVEL_MICROS_PER_STEP, + 'WORKER_TIME_OVERFLOW', + ); + const canonicalArrivesAtMicros = checkedSum( + state.startedAtMicros, + travelMicros, + 'WORKER_TIME_OVERFLOW', + ); + const canonicalGatheringEndsAtMicros = checkedSum( + canonicalArrivesAtMicros, + CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS, + 'WORKER_TIME_OVERFLOW', + ); if ( state.policyVersion !== CASTLE_WORKER_POLICY_VERSION || (state.phase !== 'outbound' && state.phase !== 'gathering' && state.phase !== 'returning') || !(state.startedAtMicros < state.arrivesAtMicros - && state.arrivesAtMicros < state.gatheringEndsAtMicros - && state.gatheringEndsAtMicros < state.returnsAtMicros) + && state.arrivesAtMicros < state.gatheringEndsAtMicros) || state.arrivesAtMicros > state.settledThroughMicros || state.settledThroughMicros > state.gatheringEndsAtMicros || state.materializedAmount > state.accruedAmount || state.accruedAmount > policy.gatheringTotal + || state.arrivesAtMicros !== canonicalArrivesAtMicros + || state.gatheringEndsAtMicros !== canonicalGatheringEndsAtMicros ) return false; - // A recall can begin during outbound travel, so a returning assignment may - // legitimately have zero or partial gathering accrual. Scheduled expiry - // still settles the full cap before opening the return timeline. - return true; + if (state.phase !== 'returning') { + return state.returnStartedAtMicros === undefined + && state.returnStartProgressBasisPoints === 0 + && state.returnsAtMicros === checkedSum( + state.gatheringEndsAtMicros, + travelMicros, + 'WORKER_TIME_OVERFLOW', + ); + } + if ( + state.returnStartedAtMicros === undefined + || state.returnStartedAtMicros < state.startedAtMicros + || state.returnStartedAtMicros > state.gatheringEndsAtMicros + || state.returnsAtMicros < state.returnStartedAtMicros + ) return false; + const expectedProgress = state.returnStartedAtMicros >= state.arrivesAtMicros + ? 10_000 + : Number( + ((state.returnStartedAtMicros - state.startedAtMicros) * 10_000n) + / travelMicros, + ); + if (state.returnStartProgressBasisPoints !== expectedProgress) return false; + const expectedReturnsAtMicros = checkedSum( + state.returnStartedAtMicros, + (travelMicros * BigInt(expectedProgress)) / 10_000n, + 'WORKER_TIME_OVERFLOW', + ); + if (state.returnsAtMicros !== expectedReturnsAtMicros) return false; + // An outbound recall starts before gathering can begin. Its settlement + // cursor remains pinned to arrival and it can never have earned value. + if (state.returnStartedAtMicros < state.arrivesAtMicros) { + return state.settledThroughMicros === state.arrivesAtMicros + && state.accruedAmount === 0n + && state.materializedAmount === 0n; + } + // Gathering recalls may retain only complete quanta observed no later + // than the immutable return-start boundary. + return state.settledThroughMicros <= state.returnStartedAtMicros; } catch { return false; } @@ -277,7 +340,13 @@ export function planCastleWorkerAccrual( if (!workerAssignmentStateIsConsistent(state)) fail('WORKER_ASSIGNMENT_STATE_INVALID'); assertU64(observedAtMicros, 'WORKER_OBSERVED_TIME_INVALID'); const policy = workerResourcePolicy(state.resourceKind); - const ceiling = observedAtMicros < state.gatheringEndsAtMicros ? observedAtMicros : state.gatheringEndsAtMicros; + const phaseCeiling = state.phase === 'returning' + ? state.returnStartedAtMicros + : observedAtMicros; + if (phaseCeiling === undefined) fail('WORKER_ASSIGNMENT_STATE_INVALID'); + const ceiling = phaseCeiling < state.gatheringEndsAtMicros + ? phaseCeiling + : state.gatheringEndsAtMicros; if (ceiling <= state.settledThroughMicros) { return Object.freeze({ accruedAmount: state.accruedAmount, newlyAccruedAmount: 0n, completedQuanta: 0n, settledThroughMicros: state.settledThroughMicros }); } diff --git a/spacetimedb/src/castleWorkerRoster.ts b/spacetimedb/src/castleWorkerRoster.ts index 29a6c0c7..d909035d 100644 --- a/spacetimedb/src/castleWorkerRoster.ts +++ b/spacetimedb/src/castleWorkerRoster.ts @@ -3,6 +3,7 @@ import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; import { CASTLE_WORKER_POLICY_VERSION, CASTLE_WORKERS_PER_CASTLE, + rosterDigestForCastleIds, workerIdForCastle, assertCastleWorkerId, } from './castleWorkerPolicy'; @@ -11,6 +12,7 @@ import type warpkeep from './schema'; type WarpkeepReducerContext = ReducerCtx>; type CastleRow = NonNullable>; type CastleWorkerRow = NonNullable>; +const MAX_U32 = 0xffff_ffff; function fail(code = 'WORKER_ROSTER_INTEGRITY'): never { throw new Error(code); @@ -27,7 +29,6 @@ export function expectedWorkerRowsForCastle( originCastleId: castle.castleId, ordinal, status: 'idle', - assignmentId: undefined, resourceKind: undefined, siteId: undefined, startedAtMicros: undefined, @@ -76,6 +77,7 @@ export function assertCastleWorkerRoster( || row.workerId !== workerIdForCastle(castleId, row.ordinal) || row.revision < 0n || row.timelineRevision < 0 + || !castleWorkerPublicStateIsConsistent(row) ) fail('WORKER_ROSTER_INTEGRITY'); expectedIds.add(row.workerId); } @@ -85,6 +87,45 @@ export function assertCastleWorkerRoster( return Object.freeze(rows); } +export function castleWorkerPublicStateIsConsistent(row: CastleWorkerRow): boolean { + const optionalTimeline = [ + row.resourceKind, + row.siteId, + row.startedAtMicros, + row.arrivesAtMicros, + row.gatheringEndsAtMicros, + row.returnStartedAtMicros, + row.returnsAtMicros, + row.routeSteps, + row.returnStartProgressBasisPoints, + ]; + if (row.status === 'idle') return optionalTimeline.every(value => value === undefined); + if (row.status !== 'outbound' && row.status !== 'gathering' && row.status !== 'returning') return false; + if ( + row.resourceKind === undefined + || !['gold', 'food', 'wood', 'stone'].includes(row.resourceKind) + || row.siteId === undefined + || row.startedAtMicros === undefined + || row.arrivesAtMicros === undefined + || row.gatheringEndsAtMicros === undefined + || row.returnsAtMicros === undefined + || row.routeSteps === undefined + || row.routeSteps <= 0 + || !(row.startedAtMicros < row.arrivesAtMicros && row.arrivesAtMicros < row.gatheringEndsAtMicros) + ) return false; + if (row.status !== 'returning') { + return row.returnStartedAtMicros === undefined + && row.returnStartProgressBasisPoints === undefined + && row.gatheringEndsAtMicros < row.returnsAtMicros; + } + return row.returnStartedAtMicros !== undefined + && row.returnStartProgressBasisPoints !== undefined + && row.returnStartProgressBasisPoints <= 10_000 + && row.returnStartedAtMicros >= row.startedAtMicros + && row.returnStartedAtMicros <= row.gatheringEndsAtMicros + && row.returnsAtMicros >= row.returnStartedAtMicros; +} + /** * Founding calls this only when generic mode is active. In staged mode the * function is a no-op, so this PR cannot seed production workers accidentally. @@ -95,17 +136,53 @@ export function ensureCastleWorkerRoster( ): void { const system = ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001'); if (system === null) return; - if (!workerSystemRowIsStagedOrActive(system)) fail('WORKER_SYSTEM_INTEGRITY'); + if (ctx.db.realmWorkerSystemV1.count() !== 1n || !workerSystemRowIsStagedOrActive(system)) { + fail('WORKER_SYSTEM_INTEGRITY'); + } if (system.mode !== 'active') return; + if (system.legacyDrainRequired) fail('WORKER_LEGACY_DRAIN_REQUIRED'); + if ( + ctx.db.goldExpeditionV1.count() + ctx.db.foodExpeditionV1.count() + + ctx.db.woodExpeditionV1.count() + ctx.db.stoneExpeditionV1.count() !== 0n + || ctx.db.goldNodeOccupationV1.count() + ctx.db.foodNodeOccupationV1.count() + + ctx.db.woodNodeOccupationV1.count() + ctx.db.stoneNodeOccupationV1.count() !== 0n + || ctx.db.goldExpeditionScheduleV1.count() + ctx.db.foodExpeditionScheduleV1.count() + + ctx.db.woodExpeditionScheduleV1.count() + ctx.db.stoneExpeditionScheduleV1.count() !== 0n + ) fail('WORKER_LEGACY_DRAIN_REQUIRED'); const existing = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castle.castleId)]; + const castleIds = [...ctx.db.castle.iter()].map(row => row.castleId); + const priorCastleIds = existing.length === 0 + ? castleIds.filter(castleId => castleId !== castle.castleId) + : castleIds; + if ( + system.expectedCastleCount !== priorCastleIds.length + || system.expectedWorkerCount !== priorCastleIds.length * CASTLE_WORKERS_PER_CASTLE + || system.rosterDigest !== rosterDigestForCastleIds(priorCastleIds) + ) fail('WORKER_SYSTEM_INTEGRITY'); if (existing.length > 0) { assertCastleWorkerRoster(ctx, castle.castleId); - return; + } else { + for (const row of expectedWorkerRowsForCastle(castle, ctx.timestamp)) { + ctx.db.castleWorkerV1.insert(row); + } + assertCastleWorkerRoster(ctx, castle.castleId); + } + if (castleIds.length > MAX_U32 || castleIds.length > Math.floor(MAX_U32 / CASTLE_WORKERS_PER_CASTLE)) { + fail('WORKER_ROSTER_CAPACITY'); + } + for (const castleId of castleIds) assertCastleWorkerRoster(ctx, castleId); + if (ctx.db.castleWorkerV1.count() !== BigInt(castleIds.length * CASTLE_WORKERS_PER_CASTLE)) { + fail('WORKER_ROSTER_INTEGRITY'); } - for (const row of expectedWorkerRowsForCastle(castle, ctx.timestamp)) { - ctx.db.castleWorkerV1.insert(row); + for (const worker of ctx.db.castleWorkerV1.iter()) { + if (ctx.db.castle.castleId.find(worker.originCastleId) === null) fail('WORKER_ROSTER_ORPHAN'); } - assertCastleWorkerRoster(ctx, castle.castleId); + ctx.db.realmWorkerSystemV1.realmId.update({ + ...system, + expectedCastleCount: castleIds.length, + expectedWorkerCount: castleIds.length * CASTLE_WORKERS_PER_CASTLE, + rosterDigest: rosterDigestForCastleIds(castleIds), + }); } export function workerRosterDigestInput(castleIds: readonly bigint[]): string { diff --git a/spacetimedb/src/foodExpeditionAuthority.ts b/spacetimedb/src/foodExpeditionAuthority.ts index 8de8bc37..3771ef69 100644 --- a/spacetimedb/src/foodExpeditionAuthority.ts +++ b/spacetimedb/src/foodExpeditionAuthority.ts @@ -27,6 +27,7 @@ import { } from './resourceAuthorityPolicy'; import { assertGenesisResourceForFid } from './resourceAuthority'; import { + assertLegacyExpeditionDispatchAllowed, ResourceExpeditionReservationAuthorityError, activeExpeditionResourceReservations, planResourceSettlementForActiveExpeditionReservations, @@ -397,6 +398,7 @@ export function dispatchGenesisFoodExpedition( }>, ): FoodExpeditionDispatch { const resource = assertGenesisResourceForFid(ctx, input.fid); + assertLegacyExpeditionDispatchAllowed(ctx); const requestKey = requestKeyFor(input.fid, input.idempotencyKey); const prior = ctx.db.foodExpeditionIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { diff --git a/spacetimedb/src/foundingAuthority.ts b/spacetimedb/src/foundingAuthority.ts index ea4e5188..ab59a3d4 100644 --- a/spacetimedb/src/foundingAuthority.ts +++ b/spacetimedb/src/foundingAuthority.ts @@ -220,6 +220,7 @@ export function ensureGenesisFounder( || !trustedProfilesEqual(existingProfile, admissionProfile) ) fail(); assertGenesisFoundingGraph(ctx); + ensureCastleWorkerRoster(ctx, existingCastle); return 'preserved'; } if ( diff --git a/spacetimedb/src/goldExpeditionAuthority.ts b/spacetimedb/src/goldExpeditionAuthority.ts index 2ab554ea..a8fd969d 100644 --- a/spacetimedb/src/goldExpeditionAuthority.ts +++ b/spacetimedb/src/goldExpeditionAuthority.ts @@ -25,6 +25,7 @@ import { } from './resourceAuthorityPolicy'; import { assertGenesisResourceForFid } from './resourceAuthority'; import { + assertLegacyExpeditionDispatchAllowed, ResourceExpeditionReservationAuthorityError, planResourceSettlementForActiveExpeditionReservations, } from './resourceExpeditionReservationAuthority'; @@ -377,6 +378,7 @@ export function dispatchGenesisGoldExpedition( idempotencyKey: string; }>, ): GoldExpeditionDispatch { + assertLegacyExpeditionDispatchAllowed(ctx); const requestKey = requestKeyFor(input.fid, input.idempotencyKey); const prior = ctx.db.goldExpeditionIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { diff --git a/spacetimedb/src/reducers/castleWorkers.ts b/spacetimedb/src/reducers/castleWorkers.ts index f55026f2..d8b8ebde 100644 --- a/spacetimedb/src/reducers/castleWorkers.ts +++ b/spacetimedb/src/reducers/castleWorkers.ts @@ -52,13 +52,18 @@ const myResourceStateV2 = t.object('MyResourceStateV2', { const adminWorkerSystemStatus = t.object('AdminWorkerSystemStatusV1', { systemRows: t.u64(), mode: t.string(), + systemConfigValid: t.bool(), + legacyDrainRequired: t.bool(), expectedCastleCount: t.u64(), expectedWorkerCount: t.u64(), actualWorkerCount: t.u64(), + expectedCountsMatch: t.bool(), + rosterDigestMatches: t.bool(), castlesMissingWorkers: t.u64(), castlesWithExtraWorkers: t.u64(), duplicateOrdinals: t.u64(), malformedWorkerIds: t.u64(), + invalidWorkerStates: t.u64(), idleWorkers: t.u64(), outboundWorkers: t.u64(), gatheringWorkers: t.u64(), @@ -68,9 +73,17 @@ const adminWorkerSystemStatus = t.object('AdminWorkerSystemStatusV1', { schedules: t.u64(), orphanWorkers: t.u64(), orphanAssignments: t.u64(), + assignmentsMissingOccupation: t.u64(), + assignmentsWithoutSingleSchedule: t.u64(), orphanOccupations: t.u64(), + orphanSchedules: t.u64(), + invalidSchedules: t.u64(), assignmentPublicMismatches: t.u64(), occupationSiteMismatches: t.u64(), + invalidAssignments: t.u64(), + idempotencyReceipts: t.u64(), + invalidIdempotencyReceipts: t.u64(), + idempotencyOverflowFids: t.u64(), legacyExpeditions: t.u64(), legacyOccupations: t.u64(), legacySchedules: t.u64(), @@ -82,16 +95,28 @@ const adminWorkerRosterPlan = t.object('AdminWorkerRosterPlanV1', { ready: t.bool(), activationBlockedByLegacyRows: t.bool(), mode: t.string(), + systemConfigValid: t.bool(), + legacyDrainRequired: t.bool(), expectedCastleCount: t.u64(), expectedWorkerCount: t.u64(), actualWorkerCount: t.u64(), + expectedCountsMatch: t.bool(), + rosterDigestMatches: t.bool(), castlesMissingWorkers: t.u64(), castlesWithExtraWorkers: t.u64(), orphanWorkers: t.u64(), orphanAssignments: t.u64(), + assignmentsMissingOccupation: t.u64(), + assignmentsWithoutSingleSchedule: t.u64(), orphanOccupations: t.u64(), + orphanSchedules: t.u64(), + invalidSchedules: t.u64(), assignmentPublicMismatches: t.u64(), occupationSiteMismatches: t.u64(), + invalidWorkerStates: t.u64(), + invalidAssignments: t.u64(), + invalidIdempotencyReceipts: t.u64(), + idempotencyOverflowFids: t.u64(), legacyExpeditions: t.u64(), legacyOccupations: t.u64(), legacySchedules: t.u64(), @@ -102,7 +127,8 @@ const adminWorkerRosterPlan = t.object('AdminWorkerRosterPlanV1', { function senderPolicyError(error: unknown): never { const code = castleWorkerErrorCode(error); if (code !== undefined) throw new SenderError(code); - throw error; + if (error instanceof SenderError) throw error; + throw new SenderError('WORKER_REQUEST_FAILED'); } function workerSystemMode(ctx: Parameters[0]): string { @@ -239,27 +265,53 @@ export const adminPlanWorkerRosterV1 = warpkeep.procedure( const legacyRows = aggregate.legacyExpeditions + aggregate.legacyOccupations + aggregate.legacySchedules; return { ready: aggregate.systemRows === 1n - && aggregate.mode === 'active' + && aggregate.systemConfigValid + && (aggregate.mode === 'staged' || aggregate.mode === 'active') + && !aggregate.legacyDrainRequired + && aggregate.expectedCountsMatch + && aggregate.rosterDigestMatches && aggregate.castlesMissingWorkers === 0n && aggregate.castlesWithExtraWorkers === 0n + && aggregate.duplicateOrdinals === 0n + && aggregate.malformedWorkerIds === 0n + && aggregate.invalidWorkerStates === 0n && aggregate.orphanWorkers === 0n && aggregate.orphanAssignments === 0n + && aggregate.assignmentsMissingOccupation === 0n + && aggregate.assignmentsWithoutSingleSchedule === 0n && aggregate.orphanOccupations === 0n + && aggregate.orphanSchedules === 0n + && aggregate.invalidSchedules === 0n && aggregate.assignmentPublicMismatches === 0n && aggregate.occupationSiteMismatches === 0n + && aggregate.invalidAssignments === 0n + && aggregate.invalidIdempotencyReceipts === 0n + && aggregate.idempotencyOverflowFids === 0n && legacyRows === 0n, - activationBlockedByLegacyRows: legacyRows !== 0n, + activationBlockedByLegacyRows: aggregate.legacyDrainRequired || legacyRows !== 0n, mode: aggregate.mode, + systemConfigValid: aggregate.systemConfigValid, + legacyDrainRequired: aggregate.legacyDrainRequired, expectedCastleCount: aggregate.expectedCastleCount, expectedWorkerCount: aggregate.expectedWorkerCount, actualWorkerCount: aggregate.actualWorkerCount, + expectedCountsMatch: aggregate.expectedCountsMatch, + rosterDigestMatches: aggregate.rosterDigestMatches, castlesMissingWorkers: aggregate.castlesMissingWorkers, castlesWithExtraWorkers: aggregate.castlesWithExtraWorkers, orphanWorkers: aggregate.orphanWorkers, orphanAssignments: aggregate.orphanAssignments, + assignmentsMissingOccupation: aggregate.assignmentsMissingOccupation, + assignmentsWithoutSingleSchedule: aggregate.assignmentsWithoutSingleSchedule, orphanOccupations: aggregate.orphanOccupations, + orphanSchedules: aggregate.orphanSchedules, + invalidSchedules: aggregate.invalidSchedules, assignmentPublicMismatches: aggregate.assignmentPublicMismatches, occupationSiteMismatches: aggregate.occupationSiteMismatches, + invalidWorkerStates: aggregate.invalidWorkerStates, + invalidAssignments: aggregate.invalidAssignments, + invalidIdempotencyReceipts: aggregate.invalidIdempotencyReceipts, + idempotencyOverflowFids: aggregate.idempotencyOverflowFids, legacyExpeditions: aggregate.legacyExpeditions, legacyOccupations: aggregate.legacyOccupations, legacySchedules: aggregate.legacySchedules, diff --git a/spacetimedb/src/resourceExpeditionReservationAuthority.ts b/spacetimedb/src/resourceExpeditionReservationAuthority.ts index c70355b1..01282fd6 100644 --- a/spacetimedb/src/resourceExpeditionReservationAuthority.ts +++ b/spacetimedb/src/resourceExpeditionReservationAuthority.ts @@ -52,6 +52,12 @@ export type ActiveExpeditionResourceReservations = Readonly<{ gold: bigint; }>; +/** Once generic workers are active, legacy wagon creation is permanently closed. */ +export function assertLegacyExpeditionDispatchAllowed(ctx: WarpkeepReducerContext): void { + const workerSystem = ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001'); + if (workerSystem?.mode === 'active') fail('LEGACY_EXPEDITION_DISPATCH_RETIRED'); +} + /** * Return exact uncredited thirty-day awards for every active legacy wagon and * generic assignment. A returning row has already credited its whole award and @@ -82,8 +88,7 @@ export function activeExpeditionResourceReservations( let woodReservation = wood === null ? 0n : WOOD_GATHERING_TOTAL_WOOD - wood.creditedWood; let stoneReservation = stone === null ? 0n : STONE_GATHERING_TOTAL_STONE - stone.creditedStone; let goldReservation = gold === null ? 0n : GOLD_GATHERING_TOTAL_GOLD - gold.creditedGold; - for (const assignment of ctx.db.workerAssignmentV1.iter()) { - if (assignment.fid !== fid) continue; + for (const assignment of ctx.db.workerAssignmentV1.byFid.filter(fid)) { if (assignment.phase === 'returning') continue; const total = workerResourcePolicy(assignment.resourceKind).gatheringTotal; // Reserve the complete remaining award, not only the currently accrued diff --git a/spacetimedb/src/schema.ts b/spacetimedb/src/schema.ts index e9664761..5ebe4abd 100644 --- a/spacetimedb/src/schema.ts +++ b/spacetimedb/src/schema.ts @@ -1039,7 +1039,6 @@ export const castleWorkerV1 = table( originCastleId: t.u64(), ordinal: t.u32(), status: t.string(), - assignmentId: t.option(t.string()), resourceKind: t.option(t.string()), siteId: t.option(t.string()), startedAtMicros: t.option(t.u64()), @@ -1061,6 +1060,10 @@ export const workerAssignmentV1 = table( { name: 'worker_assignment_v1', indexes: [{ + accessor: 'byFid', + algorithm: 'btree', + columns: ['fid'] as const, + }, { accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const, @@ -1113,7 +1116,6 @@ export const workerNodeOccupationV1 = table( workerId: t.string(), workerOrdinal: t.u32(), originCastleId: t.u64(), - assignmentId: t.string(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), @@ -1145,11 +1147,10 @@ export const workerCommandIdempotencyV1 = table( }, ); -/** Public-safe lifecycle schedule projection for generic worker assignments. */ +/** Private scheduler authority. Assignment correlation never reaches clients. */ export const workerAssignmentScheduleV1 = table( { name: 'worker_assignment_schedule_v_1', - public: true, indexes: [{ accessor: 'byAssignment', algorithm: 'btree', diff --git a/spacetimedb/src/stoneExpeditionAuthority.ts b/spacetimedb/src/stoneExpeditionAuthority.ts index 4b96bee9..49bc887d 100644 --- a/spacetimedb/src/stoneExpeditionAuthority.ts +++ b/spacetimedb/src/stoneExpeditionAuthority.ts @@ -27,6 +27,7 @@ import { } from './resourceAuthorityPolicy'; import { assertGenesisResourceForFid } from './resourceAuthority'; import { + assertLegacyExpeditionDispatchAllowed, ResourceExpeditionReservationAuthorityError, activeExpeditionResourceReservations, planResourceSettlementForActiveExpeditionReservations, @@ -397,6 +398,7 @@ export function dispatchGenesisStoneExpedition( }>, ): StoneExpeditionDispatch { const resource = assertGenesisResourceForFid(ctx, input.fid); + assertLegacyExpeditionDispatchAllowed(ctx); const requestKey = requestKeyFor(input.fid, input.idempotencyKey); const prior = ctx.db.stoneExpeditionIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { diff --git a/spacetimedb/src/woodExpeditionAuthority.ts b/spacetimedb/src/woodExpeditionAuthority.ts index 17d7bf73..59767d61 100644 --- a/spacetimedb/src/woodExpeditionAuthority.ts +++ b/spacetimedb/src/woodExpeditionAuthority.ts @@ -27,6 +27,7 @@ import { } from './resourceAuthorityPolicy'; import { assertGenesisResourceForFid } from './resourceAuthority'; import { + assertLegacyExpeditionDispatchAllowed, ResourceExpeditionReservationAuthorityError, activeExpeditionResourceReservations, planResourceSettlementForActiveExpeditionReservations, @@ -397,6 +398,7 @@ export function dispatchGenesisWoodExpedition( }>, ): WoodExpeditionDispatch { const resource = assertGenesisResourceForFid(ctx, input.fid); + assertLegacyExpeditionDispatchAllowed(ctx); const requestKey = requestKeyFor(input.fid, input.idempotencyKey); const prior = ctx.db.woodExpeditionIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { diff --git a/spacetimedb/tests/castleWorkerAuthority.test.ts b/spacetimedb/tests/castleWorkerAuthority.test.ts new file mode 100644 index 00000000..dcd64bb9 --- /dev/null +++ b/spacetimedb/tests/castleWorkerAuthority.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +function source(path: string): string { + return readFileSync(new URL(path, import.meta.url), 'utf8'); +} + +function section(text: string, startNeedle: string, endNeedle: string): string { + const start = text.indexOf(startNeedle); + const end = text.indexOf(endNeedle, start + startNeedle.length); + assert.ok(start >= 0 && end > start, `missing section ${startNeedle}`); + return text.slice(start, end); +} + +test('worker lifecycle advances one synchronized revision and one schedule at a time', () => { + const authority = source('../src/castleWorkerAuthority.ts'); + const dispatch = section(authority, 'export function dispatchCastleWorker', 'function progressBasisPoints'); + const arrival = section(authority, 'function transitionWorkerArrival', 'function settleAndBeginReturnAt'); + const returning = section(authority, 'function beginWorkerReturn', 'function completeWorkerReturn'); + const complete = section(authority, 'function completeWorkerReturn', 'function transitionWorkerArrival'); + + assert.match(dispatch, /timelineRevision = safeNextU32\(worker\.timelineRevision/); + assert.equal(dispatch.match(/insertSchedule\(/g)?.length, 1); + assert.match(dispatch, /WORKER_SCHEDULE_STAGE_ARRIVAL/); + assert.match(dispatch, /revision: safeNextU64\(worker\.revision/); + + for (const transition of [arrival, returning]) { + assert.match(transition, /timelineRevision = safeNextU32\(assignment\.timelineRevision/); + assert.match(transition, /deleteSchedulesForAssignment\(ctx, assignment\.assignmentId\)/); + assert.match(transition, /revision: safeNextU64\(worker\.revision/); + assert.equal(transition.match(/insertSchedule\(/g)?.length, 1); + } + assert.match(arrival, /WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY/); + assert.match(returning, /WORKER_SCHEDULE_STAGE_RETURN_COMPLETE/); + assert.match(complete, /deleteSchedulesForAssignment\(ctx, assignment\.assignmentId\)/); + assert.match(complete, /workerAssignmentV1\.assignmentId\.delete\(assignment\.assignmentId\)/); +}); + +test('recall caps server-time accrual, deletes stale schedules, and stores no no-op receipt', () => { + const authority = source('../src/castleWorkerAuthority.ts'); + const recall = section(authority, 'export function recallCastleWorker', 'export function recallAllCastleWorkers'); + const recallAll = section(authority, 'export function recallAllCastleWorkers', 'export type WorkerGraphAggregate'); + const accrual = source('../src/castleWorkerPolicy.ts'); + + assert.match(recall, /now < assignment\.gatheringEndsAtMicros[\s\S]*assignment\.gatheringEndsAtMicros/); + assert.match(recall, /if \(assignment === null\)[\s\S]*return;/); + assert.match(recall, /if \(assignment\.phase === 'returning'\) return;/); + assert.match(recallAll, /if \(lastAssignmentId === undefined\) return;/); + assert.match(authority, /WORKER_IDEMPOTENCY_RECEIPTS_PER_FID = 64/); + assert.match(authority, /workerCommandIdempotencyV1\.byFid\.filter\(fid\)/); + assert.match(accrual, /state\.phase === 'returning'[\s\S]*state\.returnStartedAtMicros/); +}); + +test('founding updates active roster readiness atomically and legacy dispatch is retired', () => { + const roster = source('../src/castleWorkerRoster.ts'); + const founding = source('../src/foundingAuthority.ts'); + const reservations = source('../src/resourceExpeditionReservationAuthority.ts'); + + assert.match(roster, /realmWorkerSystemV1\.realmId\.update\(\{[\s\S]*expectedCastleCount: castleIds\.length,[\s\S]*expectedWorkerCount: castleIds\.length \* CASTLE_WORKERS_PER_CASTLE,[\s\S]*rosterDigest: rosterDigestForCastleIds\(castleIds\)/); + assert.match(founding, /ensureCastleWorkerRoster\(ctx, existingCastle\)/); + assert.match(founding, /ensureCastleWorkerRoster\(ctx, castle\)/); + assert.match(reservations, /if \(workerSystem\?\.mode === 'active'\) fail\('LEGACY_EXPEDITION_DISPATCH_RETIRED'\)/); + for (const kind of ['gold', 'food', 'wood', 'stone']) { + const legacy = source(`../src/${kind}ExpeditionAuthority.ts`); + const dispatch = section(legacy, `export function dispatchGenesis${kind[0].toUpperCase()}${kind.slice(1)}Expedition`, 'const requestKey'); + assert.match(dispatch, /assertLegacyExpeditionDispatchAllowed\(ctx\)/); + } +}); + +test('worker reads use bounded indexes and public tables omit assignment correlation', () => { + const authority = source('../src/castleWorkerAuthority.ts'); + const reservations = source('../src/resourceExpeditionReservationAuthority.ts'); + const schema = source('../src/schema.ts'); + const settlement = section(authority, 'export function settleAllWorkerAssignmentsForFid', 'export type WorkerPrivateProjection'); + const publicMatch = section(authority, 'function publicWorkerMatchesAssignment', 'function occupationMatchesAssignment'); + const occupationMatch = section(authority, 'function occupationMatchesAssignment', 'function canonicalCastleOwnershipMatches'); + + assert.match(settlement, /workerAssignmentV1\.byFid\.filter\(fid\)/); + assert.doesNotMatch(settlement, /workerAssignmentV1\.iter\(\)/); + assert.match(reservations, /workerAssignmentV1\.byFid\.filter\(fid\)/); + assert.match(publicMatch, /worker\.workerId === workerIdForCastle\(assignment\.originCastleId, worker\.ordinal\)/); + assert.match(publicMatch, /worker\.returnStartedAtMicros === assignment\.returnStartedAtMicros/); + assert.match(publicMatch, /worker\.returnStartProgressBasisPoints === expectedReturnProgress/); + for (const field of [ + 'nodeKey', 'resourceKind', 'siteId', 'workerId', 'workerOrdinal', + 'originCastleId', 'phase', 'startedAtMicros', 'arrivesAtMicros', + 'gatheringEndsAtMicros', 'timelineRevision', + ]) assert.match(occupationMatch, new RegExp(`occupation\\.${field}`)); + assert.match(occupationMatch, /assignment\.phase !== 'returning'/); + assert.doesNotMatch(section(schema, 'export const castleWorkerV1', 'export const workerAssignmentV1'), /assignmentId/); + assert.doesNotMatch(section(schema, 'export const workerNodeOccupationV1', 'export const workerCommandIdempotencyV1'), /assignmentId/); + assert.doesNotMatch(section(schema, 'export const workerAssignmentScheduleV1', 'const warpkeep = schema'), /public: true/); +}); + +test('worker assignments and replay receipts remain bound to canonical castle ownership', () => { + const authority = source('../src/castleWorkerAuthority.ts'); + const graph = section(authority, 'export function inspectCastleWorkerGraph', 'export function castleWorkerErrorCode'); + const dispatch = section(authority, 'export function dispatchCastleWorker', 'function progressBasisPoints'); + const recall = section(authority, 'export function recallCastleWorker', 'export function recallAllCastleWorkers'); + const recallAll = section(authority, 'export function recallAllCastleWorkers', 'export type WorkerGraphAggregate'); + + assert.match(authority, /function canonicalCastleOwnershipMatches[\s\S]*castle\.castleId\.find\(castleId\)[\s\S]*resourceAccountV1\.fid\.find\(fid\)/); + assert.match(graph, /if \(!assignmentOwnerIsCanonical\(ctx, assignment\)\) fail\('WORKER_OWNER_INTEGRITY'\)/); + assert.match(graph, /!workerReceiptShapeIsValid\(receipt\)[\s\S]*!receiptOwnerIsCanonical\(ctx, receipt\)/); + assert.match(graph, /!receiptOwnerIsCanonical\(ctx, receipt\)/); + for (const replay of [dispatch, recall, recallAll]) { + assert.match(replay, /workerReceiptShapeIsValid\(prior\)/); + assert.match(replay, /receiptOwnerIsCanonical\(ctx, prior, input\.castle\.castleId\)/); + assert.match(replay, /canonicalCastleOwnershipMatches\(ctx, input\.fid, input\.castle\.castleId\)/); + } + assert.match(dispatch, /!assignmentOwnerIsCanonical\(ctx, assignment\)/); +}); + +test('admin readiness and sender errors fail closed on every bounded graph signal', () => { + const reducers = source('../src/reducers/castleWorkers.ts'); + const authority = source('../src/castleWorkerAuthority.ts'); + + for (const signal of [ + 'systemConfigValid', 'legacyDrainRequired', 'expectedCountsMatch', + 'rosterDigestMatches', 'invalidWorkerStates', 'assignmentsMissingOccupation', + 'assignmentsWithoutSingleSchedule', 'orphanSchedules', 'invalidSchedules', + 'invalidAssignments', 'invalidIdempotencyReceipts', 'idempotencyOverflowFids', + ]) assert.match(reducers, new RegExp(signal)); + assert.match(section(reducers, 'export const adminPlanWorkerRosterV1', '\n);'), /requireAdmin\(tx\)/); + assert.match(reducers, /throw new SenderError\('WORKER_REQUEST_FAILED'\)/); + assert.match(authority, /BOUNDED_WORKER_ERROR_CODE = \/\^\[A-Z\]\[A-Z0-9_\]\{0,63\}\$\//); +}); + +test('CI runs both static and real populated v11 to v12 migration proofs', () => { + const workflow = source('../../.github/workflows/verify.yml'); + const verifier = source('../../scripts/verify-spacetime-additive-migration.mjs'); + + assert.match(workflow, /npm run stdb:verify-worker-migration[\s\S]*npm run stdb:verify-additive-migration/); + assert.match(verifier, /function assertAdditiveV12Schema\(before, after\)/); + assert.match(verifier, /fixture_seed_generic_worker_sentinel_v12/); + assert.match(verifier, /populatedWaterStoneV12Rows/); + assert.match(verifier, /additiveV11SchemaFixture,[\s\S]{0,120}populatedWaterStoneMigrationDatabase,[\s\S]{0,40}false/); + assert.match(verifier, /every v12 table was populated, retained through the real candidate/); +}); diff --git a/spacetimedb/tests/castleWorkerMigrationTooling.test.ts b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts index 453dd975..9305b9c0 100644 --- a/spacetimedb/tests/castleWorkerMigrationTooling.test.ts +++ b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts @@ -44,15 +44,17 @@ test('v12 fixture appends six generic-worker tables after the exact v11 prefix', test('public generic-worker rows exclude private ownership and accrual fields', () => { const schema = source('../src/schema.ts'); - for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1', 'workerAssignmentScheduleV1']) { + for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1']) { const definition = tableDefinition(schema, name); assert.match(definition, /public: true/); - assert.doesNotMatch(definition, /\bfid\b|accruedAmount|materializedAmount|balance|requestKey|auth/i); + assert.doesNotMatch(definition, /\bfid\b|assignmentId|accruedAmount|materializedAmount|balance|requestKey|auth/i); } const assignment = tableDefinition(schema, 'workerAssignmentV1'); const idempotency = tableDefinition(schema, 'workerCommandIdempotencyV1'); + const schedule = tableDefinition(schema, 'workerAssignmentScheduleV1'); assert.doesNotMatch(assignment, /public: true/); assert.doesNotMatch(idempotency, /public: true/); + assert.doesNotMatch(schedule, /public: true/); assert.match(assignment, /fid: t\.u64\(\)/); assert.match(assignment, /accruedAmount: t\.u64\(\)/); assert.match(idempotency, /requestKey: t\.string\(\)\.primaryKey\(\)/); @@ -69,6 +71,8 @@ test('worker reducers are caller-bound and activation remains explicitly gated', assert.match(authority, /if \(row\.mode !== 'active'\) fail\('WORKER_SYSTEM_STAGED'\)/); assert.match(authority, /legacy\.expeditions !== 0n \|\| legacy\.occupations !== 0n \|\| legacy\.schedules !== 0n/); assert.match(authority, /workerNodeOccupationV1\.nodeKey\.delete\(occupation\.nodeKey\)/); + assert.match(authority, /deleteSchedulesForAssignment/); + assert.match(authority, /WORKER_IDEMPOTENCY_RECEIPTS_PER_FID = 64/); assert.match(authority, /planCastleWorkerAccrual\(assignment, observedAtMicros\)/); assert.match(authority, /No[\s\S]{0,20}per-minute writes/); }); diff --git a/spacetimedb/tests/castleWorkerPolicy.test.ts b/spacetimedb/tests/castleWorkerPolicy.test.ts index e9865438..a7358540 100644 --- a/spacetimedb/tests/castleWorkerPolicy.test.ts +++ b/spacetimedb/tests/castleWorkerPolicy.test.ts @@ -9,6 +9,7 @@ import { planCastleWorkerAccrual, planCastleWorkerTimeline, rosterDigestForCastleIds, + workerAssignmentStateIsConsistent, workerIdForCastle, workerResourceKinds, workerResourcePolicy, @@ -48,6 +49,9 @@ test('timeline and accrual are server-time-only and quantum aligned', () => { const state = { phase: 'gathering', ...timeline, + returnStartedAtMicros: undefined, + routeSteps: 3, + returnStartProgressBasisPoints: 0, settledThroughMicros: timeline.arrivesAtMicros, accruedAmount: 0n, materializedAmount: 0n, @@ -60,6 +64,97 @@ test('timeline and accrual are server-time-only and quantum aligned', () => { assert.equal(plan.settledThroughMicros, timeline.arrivesAtMicros + 2n * policy.quantumMicros); }); +test('early recall is structurally valid and permanently caps accrual at return start', () => { + const timeline = planCastleWorkerTimeline(1_000_000n, 3); + const policy = workerResourcePolicy('wood'); + const outboundRecall = { + phase: 'returning', + ...timeline, + returnStartedAtMicros: timeline.startedAtMicros + 30_000_000n, + returnsAtMicros: timeline.startedAtMicros + 59_997_000n, + routeSteps: 3, + returnStartProgressBasisPoints: 3_333, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + resourceKind: 'wood', + policyVersion: CASTLE_WORKER_POLICY_VERSION, + } as const; + assert.equal(workerAssignmentStateIsConsistent(outboundRecall), true); + assert.deepEqual(planCastleWorkerAccrual(outboundRecall, timeline.gatheringEndsAtMicros), { + accruedAmount: 0n, + newlyAccruedAmount: 0n, + completedQuanta: 0n, + settledThroughMicros: timeline.arrivesAtMicros, + }); + + const returnStartedAtMicros = timeline.arrivesAtMicros + 2n * policy.quantumMicros + 30_000_000n; + const gatheringRecall = { + ...outboundRecall, + returnStartedAtMicros, + returnsAtMicros: returnStartedAtMicros + 90_000_000n, + returnStartProgressBasisPoints: 10_000, + }; + const capped = planCastleWorkerAccrual(gatheringRecall, timeline.gatheringEndsAtMicros); + assert.equal(capped.completedQuanta, 2n); + assert.equal(capped.accruedAmount, 2n * policy.ratePerQuantum); + assert.equal(capped.settledThroughMicros, timeline.arrivesAtMicros + 2n * policy.quantumMicros); + assert.equal( + planCastleWorkerAccrual({ + ...gatheringRecall, + settledThroughMicros: capped.settledThroughMicros, + accruedAmount: capped.accruedAmount, + materializedAmount: capped.accruedAmount, + }, timeline.gatheringEndsAtMicros).newlyAccruedAmount, + 0n, + ); +}); + +test('returning assignments fail closed without a bounded return-start cursor', () => { + const timeline = planCastleWorkerTimeline(1_000_000n, 1); + assert.equal(workerAssignmentStateIsConsistent({ + phase: 'returning', + ...timeline, + returnStartedAtMicros: undefined, + routeSteps: 1, + returnStartProgressBasisPoints: 0, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + resourceKind: 'food', + policyVersion: CASTLE_WORKER_POLICY_VERSION, + }), false); +}); + +test('assignment timing cannot forge a shorter route or return-progress award', () => { + const timeline = planCastleWorkerTimeline(1_000_000n, 2); + const base = { + phase: 'outbound', + ...timeline, + returnStartedAtMicros: undefined, + routeSteps: 2, + returnStartProgressBasisPoints: 0, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + resourceKind: 'stone', + policyVersion: CASTLE_WORKER_POLICY_VERSION, + } as const; + assert.equal(workerAssignmentStateIsConsistent(base), true); + assert.equal(workerAssignmentStateIsConsistent({ + ...base, + arrivesAtMicros: base.arrivesAtMicros - 1n, + }), false); + const returnStartedAtMicros = base.startedAtMicros + 30_000_000n; + assert.equal(workerAssignmentStateIsConsistent({ + ...base, + phase: 'returning', + returnStartedAtMicros, + returnStartProgressBasisPoints: 5_001, + returnsAtMicros: returnStartedAtMicros + 30_000_000n, + }), false); +}); + test('policy rejects invalid resource kinds, roster ordinals, and routes', () => { assert.throws(() => workerResourcePolicy('mana'), (error: unknown) => ( error instanceof CastleWorkerPolicyError && error.code === 'WORKER_RESOURCE_UNSUPPORTED' diff --git a/spacetimedb/tests/playerIdentityPrivacy.test.ts b/spacetimedb/tests/playerIdentityPrivacy.test.ts index b55ddbb8..f6b5d7b1 100644 --- a/spacetimedb/tests/playerIdentityPrivacy.test.ts +++ b/spacetimedb/tests/playerIdentityPrivacy.test.ts @@ -153,7 +153,6 @@ test('generated bindings contain the public projections and omit every private e 'wood_expedition_schedule_v_1_table.ts', 'wood_node_occupation_v_1_table.ts', 'wood_site_v_1_table.ts', - 'worker_assignment_schedule_v_1_table.ts', 'worker_node_occupation_v_1_table.ts', 'world_tile_meta_v_1_table.ts', 'world_tile_table.ts', @@ -232,6 +231,9 @@ test('generated bindings contain the public projections and omit every private e 'wallet_attribution_snapshot_v_1', 'wood_expedition_idempotency_v_1', 'wood_expedition_v_1', + 'worker_assignment_schedule_v_1', + 'worker_assignment_v_1', + 'worker_command_idempotency_v_1', ]; for (const stem of privateTableStems) { assert.equal(existsSync(new URL(`${stem}_table.ts`, bindingsRoot)), false); diff --git a/spacetimedb/tests/waterRevisionMigrationTooling.test.ts b/spacetimedb/tests/waterRevisionMigrationTooling.test.ts index 04d2a5d2..6034984d 100644 --- a/spacetimedb/tests/waterRevisionMigrationTooling.test.ts +++ b/spacetimedb/tests/waterRevisionMigrationTooling.test.ts @@ -57,11 +57,11 @@ test('the auth-neutral v11 fixture extends the exact v10 table prefix at ref 46' assert.match(v11, /name: 'fixture_seed_water_revision_sentinel_v11'/); }); -test('the migration verifier proves v10 to v11 with populated state and no downgrade', () => { +test('the migration verifier retains the populated v10 to v11 proof inside protocol v12', () => { const verifier = source('../../scripts/verify-spacetime-additive-migration.mjs'); const receipt = source('../../scripts/spacetime-additive-migration-proof.mjs'); - assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 11/); + assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 12/); assert.match(verifier, /spacetimedb\/migration-fixtures\/additive-v11-schema/); assert.match(verifier, /const additiveV11Tables = Object\.freeze\(\[\s*'realm_water_revision_v1'/); assert.match(verifier, /realm_water_revision_v1: 46/); @@ -72,9 +72,9 @@ test('the migration verifier proves v10 to v11 with populated state and no downg assert.match(verifier, /populatedWaterStoneV10Rows/); assert.match(verifier, /populatedWaterStoneV11Rows/); assert.match(verifier, /additiveV10SchemaFixture,[\s\S]{0,120}populatedWaterStoneMigrationDatabase,[\s\S]{0,40}false/); - assert.match(verifier, /The immediate v11 -> v10 rollback must be refused/); + assert.match(verifier, /v12 boundary must refuse every predecessor/); assert.match(verifier, /deployedV11Tables/); - assert.match(verifier, /populated v10 Water\/Stone fixtures remained preserved through v11/); + assert.match(verifier, /populated Water\/Stone\/Water-revision fixtures remained preserved through v12/); assert.match(verifier, /stage = 'revision-base-precondition'/); assert.match(verifier, /stage = 'revision-inert-base-rejection'/); assert.match(verifier, /stage = 'revision-admin-denial'/); @@ -90,6 +90,6 @@ test('the migration verifier proves v10 to v11 with populated state and no downg assert.ok(inspectionFixtures.length >= 4); assert.deepEqual( new Set(inspectionFixtures), - new Set(['additiveV11SchemaFixture']), + new Set(['additiveV12SchemaFixture']), ); }); diff --git a/src/spacetime/module_bindings/castle_worker_v_1_table.ts b/src/spacetime/module_bindings/castle_worker_v_1_table.ts index 88a4efcc..6265b8fc 100644 --- a/src/spacetime/module_bindings/castle_worker_v_1_table.ts +++ b/src/spacetime/module_bindings/castle_worker_v_1_table.ts @@ -15,7 +15,6 @@ export default __t.row({ originCastleId: __t.u64().name("origin_castle_id"), ordinal: __t.u32(), status: __t.string(), - assignmentId: __t.option(__t.string()).name("assignment_id"), resourceKind: __t.option(__t.string()).name("resource_kind"), siteId: __t.option(__t.string()).name("site_id"), startedAtMicros: __t.option(__t.u64()).name("started_at_micros"), diff --git a/src/spacetime/module_bindings/index.ts b/src/spacetime/module_bindings/index.ts index 65862c62..0334651f 100644 --- a/src/spacetime/module_bindings/index.ts +++ b/src/spacetime/module_bindings/index.ts @@ -127,7 +127,6 @@ import StoneSiteV1Row from "./stone_site_v_1_table"; import WoodExpeditionScheduleV1Row from "./wood_expedition_schedule_v_1_table"; import WoodNodeOccupationV1Row from "./wood_node_occupation_v_1_table"; import WoodSiteV1Row from "./wood_site_v_1_table"; -import WorkerAssignmentScheduleV1Row from "./worker_assignment_schedule_v_1_table"; import WorkerNodeOccupationV1Row from "./worker_node_occupation_v_1_table"; import WorldTileRow from "./world_tile_table"; import WorldTileMetaV1Row from "./world_tile_meta_v_1_table"; @@ -511,23 +510,6 @@ const tablesSchema = __schema({ { name: 'wood_site_v1_site_id_key', constraint: 'unique', columns: ['siteId'] }, ], }, WoodSiteV1Row), - workerAssignmentScheduleV1: __table({ - name: 'worker_assignment_schedule_v_1', - indexes: [ - { accessor: 'byAssignment', name: 'worker_assignment_schedule_v_1_assignment_id_idx_btree', algorithm: 'btree', columns: [ - 'assignmentId', - ] }, - { accessor: 'scheduleId', name: 'worker_assignment_schedule_v_1_schedule_id_idx_btree', algorithm: 'btree', columns: [ - 'scheduleId', - ] }, - { accessor: 'byWorker', name: 'worker_assignment_schedule_v_1_worker_id_idx_btree', algorithm: 'btree', columns: [ - 'workerId', - ] }, - ], - constraints: [ - { name: 'worker_assignment_schedule_v_1_schedule_id_key', constraint: 'unique', columns: ['scheduleId'] }, - ], - }, WorkerAssignmentScheduleV1Row), workerNodeOccupationV1: __table({ name: 'worker_node_occupation_v1', indexes: [ diff --git a/src/spacetime/module_bindings/types.ts b/src/spacetime/module_bindings/types.ts index 93d252e6..92e50da1 100644 --- a/src/spacetime/module_bindings/types.ts +++ b/src/spacetime/module_bindings/types.ts @@ -235,16 +235,28 @@ export const AdminWorkerRosterPlanV1 = __t.object("AdminWorkerRosterPlanV1", { ready: __t.bool(), activationBlockedByLegacyRows: __t.bool(), mode: __t.string(), + systemConfigValid: __t.bool(), + legacyDrainRequired: __t.bool(), expectedCastleCount: __t.u64(), expectedWorkerCount: __t.u64(), actualWorkerCount: __t.u64(), + expectedCountsMatch: __t.bool(), + rosterDigestMatches: __t.bool(), castlesMissingWorkers: __t.u64(), castlesWithExtraWorkers: __t.u64(), orphanWorkers: __t.u64(), orphanAssignments: __t.u64(), + assignmentsMissingOccupation: __t.u64(), + assignmentsWithoutSingleSchedule: __t.u64(), orphanOccupations: __t.u64(), + orphanSchedules: __t.u64(), + invalidSchedules: __t.u64(), assignmentPublicMismatches: __t.u64(), occupationSiteMismatches: __t.u64(), + invalidWorkerStates: __t.u64(), + invalidAssignments: __t.u64(), + invalidIdempotencyReceipts: __t.u64(), + idempotencyOverflowFids: __t.u64(), legacyExpeditions: __t.u64(), legacyOccupations: __t.u64(), legacySchedules: __t.u64(), @@ -256,13 +268,18 @@ export type AdminWorkerRosterPlanV1 = __Infer; export const AdminWorkerSystemStatusV1 = __t.object("AdminWorkerSystemStatusV1", { systemRows: __t.u64(), mode: __t.string(), + systemConfigValid: __t.bool(), + legacyDrainRequired: __t.bool(), expectedCastleCount: __t.u64(), expectedWorkerCount: __t.u64(), actualWorkerCount: __t.u64(), + expectedCountsMatch: __t.bool(), + rosterDigestMatches: __t.bool(), castlesMissingWorkers: __t.u64(), castlesWithExtraWorkers: __t.u64(), duplicateOrdinals: __t.u64(), malformedWorkerIds: __t.u64(), + invalidWorkerStates: __t.u64(), idleWorkers: __t.u64(), outboundWorkers: __t.u64(), gatheringWorkers: __t.u64(), @@ -272,9 +289,17 @@ export const AdminWorkerSystemStatusV1 = __t.object("AdminWorkerSystemStatusV1", schedules: __t.u64(), orphanWorkers: __t.u64(), orphanAssignments: __t.u64(), + assignmentsMissingOccupation: __t.u64(), + assignmentsWithoutSingleSchedule: __t.u64(), orphanOccupations: __t.u64(), + orphanSchedules: __t.u64(), + invalidSchedules: __t.u64(), assignmentPublicMismatches: __t.u64(), occupationSiteMismatches: __t.u64(), + invalidAssignments: __t.u64(), + idempotencyReceipts: __t.u64(), + invalidIdempotencyReceipts: __t.u64(), + idempotencyOverflowFids: __t.u64(), legacyExpeditions: __t.u64(), legacyOccupations: __t.u64(), legacySchedules: __t.u64(), @@ -350,7 +375,6 @@ export const CastleWorkerV1 = __t.object("CastleWorkerV1", { originCastleId: __t.u64(), ordinal: __t.u32(), status: __t.string(), - assignmentId: __t.option(__t.string()), resourceKind: __t.option(__t.string()), siteId: __t.option(__t.string()), startedAtMicros: __t.option(__t.u64()), @@ -1181,7 +1205,6 @@ export const WorkerNodeOccupationV1 = __t.object("WorkerNodeOccupationV1", { workerId: __t.string(), workerOrdinal: __t.u32(), originCastleId: __t.u64(), - assignmentId: __t.string(), phase: __t.string(), startedAtMicros: __t.u64(), arrivesAtMicros: __t.u64(), diff --git a/src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts b/src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts deleted file mode 100644 index e06a3e34..00000000 --- a/src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -/* eslint-disable */ -/* tslint:disable */ -import { - TypeBuilder as __TypeBuilder, - t as __t, - type AlgebraicTypeType as __AlgebraicTypeType, - type Infer as __Infer, -} from "spacetimedb"; - -export default __t.row({ - scheduleId: __t.u64().primaryKey().name("schedule_id"), - scheduledAt: __t.scheduleAt().name("scheduled_at"), - assignmentId: __t.string().name("assignment_id"), - workerId: __t.string().name("worker_id"), - timelineRevision: __t.u32().name("timeline_revision"), - stage: __t.string(), -}); diff --git a/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts b/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts index f2c6c549..c2b24d2a 100644 --- a/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts +++ b/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts @@ -17,7 +17,6 @@ export default __t.row({ workerId: __t.string().name("worker_id"), workerOrdinal: __t.u32().name("worker_ordinal"), originCastleId: __t.u64().name("origin_castle_id"), - assignmentId: __t.string().name("assignment_id"), phase: __t.string(), startedAtMicros: __t.u64().name("started_at_micros"), arrivesAtMicros: __t.u64().name("arrives_at_micros"), From f4fee51bf212e9f60524c69b246f27edd8f5a950 Mon Sep 17 00:00:00 2001 From: Ael Date: Tue, 21 Jul 2026 20:49:44 +0200 Subject: [PATCH 11/19] feat: add worker command center and active roster HUD --- .../record-art/manifest.json | 80 ++++ .../images/realm/hegemony-worker-record.webp | Bin 0 -> 86984 bytes src/components/WarpkeepExperience.tsx | 9 + src/components/realm/RealmHud.tsx | 78 +++- src/components/realm/RealmMapScreen.tsx | 16 + src/components/realm/RealmPlayerChrome.css | 1 + src/components/realm/WorkerCommandCenter.css | 1 + src/components/realm/WorkerCommandCenter.tsx | 60 +++ .../realm/WorkerInspectionPanel.css | 1 + .../realm/WorkerInspectionPanel.tsx | 66 ++++ src/components/realm/realmInteractionState.ts | 37 +- src/components/realm/realmPickArbitration.ts | 19 + .../realm/realmWorkerPresentation.ts | 345 ++++++++++++++++++ src/spacetime/WarpkeepSpacetimeProvider.tsx | 180 ++++++++- src/spacetime/canonicalGenesisSnapshot.ts | 10 + src/spacetime/playerModuleBindings.ts | 53 +++ src/spacetime/warpkeepBackendTypes.ts | 21 ++ src/spacetime/warpkeepConnection.ts | 176 ++++++++- tests/playerModuleBindings.test.ts | 15 + tests/realmPickArbitration.test.ts | 9 + tests/realmWorkerPresentation.test.ts | 75 ++++ 21 files changed, 1235 insertions(+), 17 deletions(-) create mode 100644 docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json create mode 100644 public/images/realm/hegemony-worker-record.webp create mode 100644 src/components/realm/WorkerCommandCenter.css create mode 100644 src/components/realm/WorkerCommandCenter.tsx create mode 100644 src/components/realm/WorkerInspectionPanel.css create mode 100644 src/components/realm/WorkerInspectionPanel.tsx create mode 100644 src/components/realm/realmWorkerPresentation.ts create mode 100644 tests/realmWorkerPresentation.test.ts diff --git a/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json b/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json new file mode 100644 index 00000000..02ce14d8 --- /dev/null +++ b/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 1, + "id": "hegemony-worker-record-art-v1", + "recordedAt": "2026-07-19", + "purpose": "Transparent decorative Worker art for the Worker inspection panel", + "projectAuthorization": { + "authorizedBy": "Warpkeep project owner", + "instructionDate": "2026-07-19", + "scope": "Use the supplied transparent Worker illustration in the Warpkeep Worker UI slice stacked on the stable Realm PR. This authorizes repository/runtime integration only after review; it does not authorize merge, deployment, production activation, worker seeding, or generic worker authority.", + "notGranted": [ + "proof of underlying copyright ownership", + "a public open-content licence", + "general third-party derivative or redistribution permission", + "trademark or canonical-identity rights", + "worker ownership, status, route, cargo, reward, or settlement authority" + ] + }, + "sourceInputs": [ + { + "role": "owner-supplied transparent Worker illustration", + "repositoryRetained": false, + "originalName": "codex-clipboard-1312042e-175b-466f-adda-9bef7b12c1f0.png", + "bytes": 478174, + "sha256": "f6ae700affb5ce981074c7952bc81f90b60e2dab947b94867c5394d3e23b4d6d", + "image": { + "format": "png", + "width": 1024, + "height": 1024, + "channels": 4, + "alpha": true + } + } + ], + "processing": { + "tool": "sharp 0.35.3", + "operation": "lossy WebP derivative with alpha preserved", + "outputEncoding": "quality 92, alphaQuality 100, effort 6, smartSubsample true", + "runtimeAsset": { + "path": "public/images/realm/hegemony-worker-record.webp", + "format": "webp", + "width": 1024, + "height": 1024, + "bytes": 86984, + "sha256": "ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e", + "decodedRgbaSha256": "2e77492f76801576adcc9cfe660fa15494123bc43fcd767e005cd5ad95d8b047", + "alpha": { + "transparentPixels": 858605, + "partiallyTransparentPixels": 39551, + "opaquePixels": 150420 + }, + "visibleBoundsAlpha16": { + "minX": 256, + "minY": 50, + "maxX": 863, + "maxY": 934 + } + } + }, + "presentationBoundary": { + "component": "src/components/realm/WorkerInspectionPanel.tsx", + "runtimeUse": "same-origin decorative hero art in a focus-safe Worker inspector", + "forbiddenClaims": [ + "private FID", + "private cargo or account balance", + "browser-invented worker ownership", + "browser-derived dispatch or recall authority", + "reward or Marks linkage" + ] + }, + "licence": { + "spdx": "LicenseRef-Warpkeep-Provenance-Required", + "policy": "The exact project instruction authorizes this Warpkeep runtime use but does not establish a public relicensing grant. See ASSETS-LICENSE.md." + }, + "visualQa": { + "transparentSourceVerified": true, + "visibleBoundsRecorded": true, + "runtimeDerivativeViewedOnTransparentViewer": true, + "externalRuntimeUrl": false + } +} diff --git a/public/images/realm/hegemony-worker-record.webp b/public/images/realm/hegemony-worker-record.webp new file mode 100644 index 0000000000000000000000000000000000000000..7a8bfa3ab6f0d6411cc6ee53b9cb25e47a755fd6 GIT binary patch literal 86984 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 +*lsVZ=1k29F} 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 void; activeWagons?: readonly RealmActiveWagonMenuItem[]; onOpenActiveWagon?: (wagon: RealmActiveWagonMenuItem) => void; + workerProjection?: ReadyWorkerProjection; + workerRoster?: WorkerRosterPresentation; + workerResourceState?: ReadyWorkerResourceState; + onRecallWorker?: (workerId: string) => Promise; + onRecallAllWorkers?: () => Promise; onRecenterKeep: () => void; onRequestReturn: () => void; }>; @@ -142,8 +155,9 @@ function assignRef(ref: Ref | undefined, value: T | null) { } function RealmResourceRail({ - resources -}: Readonly<{ resources: ReadyRealmResourcePresentation }>) { + resources, + workerResourceState +}: Readonly<{ resources: ReadyRealmResourcePresentation; workerResourceState?: ReadyWorkerResourceState }>) { const tooltipIdPrefix = `realm-resource-tooltip-${useId().replace(/:/g, '')}`; const railRef = useRef(null); const [activeTooltip, setActiveTooltip] = useState(null); @@ -185,7 +199,9 @@ function RealmResourceRail({ } return { label: RESOURCE_LABELS[resource], - status: `${formatExactRealmResourceQuantity(resources.balances[resource]) ?? '0'} stored · ${formatExactRealmResourceQuantity(resources.pendingBalances[resource]) ?? '0'} ready to collect` + status: workerResourceState + ? `${formatExactRealmResourceQuantity(workerResourceState.available[resource]) ?? '0'} available` + : `${formatExactRealmResourceQuantity(resources.balances[resource]) ?? '0'} stored · ${formatExactRealmResourceQuantity(resources.pendingBalances[resource]) ?? '0'} ready to collect` }; }; @@ -219,14 +235,17 @@ function RealmResourceRail({ >

    {REALM_ECONOMIC_RESOURCE_ORDER.map((resource) => { - const compact = formatCompactRealmResourceQuantity(resources.balances[resource])!; - const exact = formatExactRealmResourceQuantity(resources.balances[resource])!; + const railValue = workerResourceState?.available[resource] ?? resources.balances[resource]; + const compact = formatCompactRealmResourceQuantity(railValue)!; + const exact = formatExactRealmResourceQuantity(railValue)!; const pending = formatExactRealmResourceQuantity(resources.pendingBalances[resource])!; return (
  • - {resources ? : null} + {genericWorkersActive ? ( + + ) : null} + + {resources ? : null}

    setSurface('closed')} onCollect={() => void collect()} onExplore={() => closeThen(() => onRequestExplore?.())} @@ -559,6 +599,26 @@ export function RealmHud({ /> ) : null} + {genericWorkersActive && workerProjection && workerSurface === 'center' ? ( + setWorkerSurface('closed')} + onRecallAllWorkers={onRecallAllWorkers} + onRecallWorker={onRecallWorker} + onSelectWorker={(worker) => { setSelectedWorker(worker); setWorkerSurface('inspection'); }} + roster={workerRoster} + workers={workerProjection.workers} + /> + ) : null} + {genericWorkersActive && selectedWorker && workerSurface === 'inspection' ? ( + { setSelectedWorker(undefined); setWorkerSurface('center'); }} + worker={selectedWorker} + /> + ) : null} + {surface === 'settings' && onGraphicsPreferenceChange ? ( Promise; /** Guarded owner-only Stone settlement reducer; never supplied to observers. */ onClaimStoneExpedition?: () => Promise; + workerProjection?: ReadyWorkerProjection; + workerRoster?: WorkerRosterPresentation; + workerResourceState?: ReadyWorkerResourceState; + onRecallWorker?: (workerId: string) => Promise; + onRecallAllWorkers?: () => Promise; graphicsPreference?: GraphicsPreference; resolvedGraphicsQuality?: GraphicsQualityTier; audioMuted?: boolean; @@ -283,6 +289,11 @@ function CanonicalRealmMapScreen({ stoneExpedition, onDispatchStoneExpedition, onClaimStoneExpedition, + workerProjection, + workerRoster, + workerResourceState, + onRecallWorker, + onRecallAllWorkers, graphicsPreference, resolvedGraphicsQuality, audioMuted, @@ -2021,6 +2032,11 @@ function CanonicalRealmMapScreen({ onRequestExplore={() => dispatchInteraction({ type: 'open-navigator' })} activeWagons={activeWagons} onOpenActiveWagon={openActiveWagon} + workerProjection={observerMode ? undefined : workerProjection} + workerRoster={observerMode ? undefined : workerRoster} + workerResourceState={observerMode ? undefined : workerResourceState} + onRecallWorker={observerMode ? undefined : onRecallWorker} + onRecallAllWorkers={observerMode ? undefined : onRecallAllWorkers} keepCoord={keepCoord} selectedCell={selectedCell} selectedTerrainKind={selectedTerrainKind} diff --git a/src/components/realm/RealmPlayerChrome.css b/src/components/realm/RealmPlayerChrome.css index 404e9544..25c39458 100644 --- a/src/components/realm/RealmPlayerChrome.css +++ b/src/components/realm/RealmPlayerChrome.css @@ -436,3 +436,4 @@ transition: none; } } +.realm-worker-control{position:fixed;top:clamp(.75rem,2.2vw,1.5rem);right:clamp(4.8rem,9vw,7rem);z-index:8;display:flex;align-items:center;gap:.3rem;min-width:3.15rem;min-height:2.8rem;padding:.35rem .55rem;border:1px solid rgb(240 207 130 / 58%);border-radius:999px;background:linear-gradient(180deg,rgb(49 34 63 / 94%),rgb(16 16 24 / 96%));color:#f7dda0;box-shadow:0 .35rem 1rem rgb(0 0 0 / 28%);font-size:.7rem;cursor:pointer}.realm-worker-control span{display:grid;width:1.35rem;height:1.35rem;place-items:center;border:1px solid rgb(205 158 240 / 56%);border-radius:50%;color:#d7b7ee;font-size:.63rem;font-weight:900}.realm-worker-control strong{font-size:.68rem;letter-spacing:.04em}.realm-worker-control:focus-visible{outline:2px solid #f5d994;outline-offset:2px} diff --git a/src/components/realm/WorkerCommandCenter.css b/src/components/realm/WorkerCommandCenter.css new file mode 100644 index 00000000..31679061 --- /dev/null +++ b/src/components/realm/WorkerCommandCenter.css @@ -0,0 +1 @@ +.worker-command-center__scrim{position:fixed;z-index:13;inset:0;display:grid;place-items:start end;padding:clamp(4.5rem,12vh,7rem) clamp(.75rem,3vw,2rem);background:rgb(2 3 8 / 28%);overscroll-behavior:contain;touch-action:pan-y}.worker-command-center{width:min(29rem,100%);max-height:min(75vh,42rem);overflow:auto;border:1px solid rgb(240 207 130 / 42%);border-radius:.75rem;background:linear-gradient(180deg,#15111d,#08090f);box-shadow:0 1.25rem 3rem rgb(0 0 0 / 48%);color:#f9f0df;padding:.85rem;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}.worker-command-center__header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.worker-command-center__header p{margin:0;color:#caa6ec;font-size:.62rem;font-weight:800;letter-spacing:.18em}.worker-command-center__header h2{margin:.16rem 0;color:#f6d98e;font:500 1.65rem/1 Georgia,serif;letter-spacing:.08em}.worker-command-center__header span{color:#bbb1c3;font-size:.75rem}.worker-command-center__header button{display:grid;width:2.6rem;height:2.6rem;place-items:center;border:1px solid rgb(240 207 130 / 40%);border-radius:50%;background:#090a10;color:#fff;font-size:1.55rem;cursor:pointer}.worker-command-center__header button:focus-visible,.worker-command-center__worker:focus-visible,.worker-command-center__recall:focus-visible,.worker-command-center__footer button:focus-visible{outline:2px solid #f6d98e;outline-offset:2px}.worker-command-center__summary{margin:.75rem 0;padding:.65rem .75rem;border-left:2px solid #a477c1;color:#d4c9d8;font-size:.76rem;line-height:1.45}.worker-command-center__roster{display:grid;gap:.5rem;margin:.8rem 0;padding:0;list-style:none}.worker-command-center__roster li{display:flex;gap:.4rem;align-items:stretch}.worker-command-center__worker{display:flex;flex:1;align-items:center;gap:.65rem;min-width:0;padding:.55rem .6rem;border:1px solid rgb(242 213 143 / 22%);border-radius:.45rem;background:#11121a;color:#fff;cursor:pointer;text-align:left}.worker-command-center__ordinal{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:50%;background:#4a2f5d;color:#f7dda0;font-weight:800}.worker-command-center__identity{display:grid;min-width:0;gap:.18rem}.worker-command-center__identity strong{font-size:.8rem}.worker-command-center__identity small{overflow:hidden;color:#bdb4c5;font-size:.65rem;text-overflow:ellipsis;white-space:nowrap}.worker-command-center__amount{margin-left:auto;color:#f2d48a;font-size:.68rem}.worker-command-center__recall{min-width:4.2rem;padding:.3rem;border:1px solid rgb(240 207 130 / 38%);border-radius:.4rem;background:#2b1c2f;color:#f9df9c;font-size:.62rem;font-weight:800;cursor:pointer}.worker-command-center__footer{display:flex;justify-content:flex-end;border-top:1px solid rgb(240 207 130 / 16%);padding-top:.75rem}.worker-command-center__footer button{min-height:2.6rem;padding:.5rem .8rem;border:1px solid rgb(240 207 130 / 55%);border-radius:.4rem;background:linear-gradient(180deg,#c08c42,#704029);color:#171016;font-size:.67rem;font-weight:850;letter-spacing:.08em;cursor:pointer}.worker-command-center__footer button:disabled{opacity:.5;cursor:default}@media (max-width:40rem){.worker-command-center__scrim{place-items:stretch;padding:4.1rem .5rem .5rem}.worker-command-center{width:100%;max-height:calc(100dvh - 4.6rem)}} diff --git a/src/components/realm/WorkerCommandCenter.tsx b/src/components/realm/WorkerCommandCenter.tsx new file mode 100644 index 00000000..df6ab864 --- /dev/null +++ b/src/components/realm/WorkerCommandCenter.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react'; +import { + realmWorkerCanRecall, + realmWorkerLabel, + realmWorkerStatusLabel, + workerAvailabilityCount, + type RealmWorkerPublicPresentation, + type WorkerRosterPresentation +} from './realmWorkerPresentation'; +import './WorkerCommandCenter.css'; + +export type WorkerCommandCenterProps = Readonly<{ + id: string; + workers: readonly RealmWorkerPublicPresentation[]; + roster?: WorkerRosterPresentation; + onRecallWorker?: (workerId: string) => Promise; + onRecallAllWorkers?: () => Promise; + onSelectWorker: (worker: RealmWorkerPublicPresentation) => void; + onClose: () => void; +}>; + +export function WorkerCommandCenter({ id, workers, roster, onRecallWorker, onRecallAllWorkers, onSelectWorker, onClose }: WorkerCommandCenterProps) { + const headingRef = useRef(null); + const [recallingAll, setRecallingAll] = useState(false); + const available = workerAvailabilityCount(workers); + useEffect(() => { headingRef.current?.focus({ preventScroll: true }); }, [id]); + const recallAll = async () => { + if (!onRecallAllWorkers || recallingAll) return; + setRecallingAll(true); + try { await onRecallAllWorkers(); } finally { setRecallingAll(false); } + }; + return ( +

    { if (event.target === event.currentTarget) onClose(); }}> + +
    + ); +} diff --git a/src/components/realm/WorkerInspectionPanel.css b/src/components/realm/WorkerInspectionPanel.css new file mode 100644 index 00000000..1375fb53 --- /dev/null +++ b/src/components/realm/WorkerInspectionPanel.css @@ -0,0 +1 @@ +.worker-inspection{position:fixed;z-index:14;inset:0 0 0 auto;width:min(25rem,100vw);overflow:hidden;color:#f7f0e2;background:radial-gradient(circle at 50% 18%,rgb(118 75 156 / 22%),transparent 42%),linear-gradient(180deg,#0d0d14,#08080d 72%);box-shadow:-1rem 0 3rem rgb(0 0 0 / 42%);font-family:Inter,ui-sans-serif,system-ui,sans-serif}.worker-inspection__art-stage{position:absolute;z-index:0;top:-1.65rem;left:50%;width:min(30rem,124%);height:clamp(16rem,43vw,20.5rem);transform:translateX(-50%);pointer-events:none;background:radial-gradient(circle at 50% 56%,rgb(149 90 196 / 24%),transparent 54%),radial-gradient(circle at 50% 18%,rgb(242 207 136 / 13%),transparent 56%)}.worker-inspection__hero-art{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 .14rem .18rem rgb(10 8 16 / 72%)) drop-shadow(0 1rem 1.3rem rgb(17 8 26 / 64%));user-select:none}.worker-inspection__drawer{position:relative;z-index:1;display:flex;height:100%;flex-direction:column;padding-top:clamp(7.25rem,21vw,9rem)}.worker-inspection__hero{position:relative;min-height:8.2rem;padding:1.25rem 1rem .8rem;background:linear-gradient(180deg,rgb(22 17 31 / 25%),rgb(8 8 13 / 96%) 84%),radial-gradient(circle at 50% 6%,rgb(175 127 218 / 21%),transparent 58%)}.worker-inspection__title-lockup{padding:0 2.8rem;text-align:center}.worker-inspection__title-lockup p{margin:0;color:#d4b8ef;font-size:.64rem;font-weight:800;letter-spacing:.17em}.worker-inspection__title-lockup h2{margin:.24rem 0 0;color:#f7dda0;font:500 2.1rem/1.05 Georgia,serif;letter-spacing:.065em}.worker-inspection__dismiss{position:absolute;z-index:2;top:.68rem;right:.7rem;display:grid;width:2.8rem;height:2.8rem;place-items:center;border:1px solid rgb(243 213 142 / 42%);border-radius:50%;background:rgb(8 8 13 / 82%);color:#fff8e8;font:400 1.8rem/1 Georgia,serif;cursor:pointer}.worker-inspection__dismiss:focus-visible,.worker-inspection__recall:focus-visible{outline:2px solid #fff0bc;outline-offset:2px}.worker-inspection__body{flex:1;min-height:0;padding:.72rem 1rem 1.15rem;overflow:auto}.worker-inspection__description,.worker-inspection__read-only{margin:0;padding:.72rem .78rem;border-left:2px solid rgb(188 146 228 / 66%);background:linear-gradient(90deg,rgb(91 51 119 / 25%),transparent);color:#d8cede;font-size:.76rem;line-height:1.5}.worker-inspection__fields{display:grid;margin:.9rem 0 0;border:1px solid rgb(231 199 121 / 23%);border-radius:.56rem;overflow:hidden}.worker-inspection__fields>div{display:grid;grid-template-columns:minmax(7.1rem,.82fr) minmax(0,1.18fr);gap:.75rem;padding:.66rem .74rem;border-bottom:1px solid rgb(231 199 121 / 12%)}.worker-inspection__fields>div:last-child{border-bottom:0}.worker-inspection__fields dt,.worker-inspection__fields dd{margin:0}.worker-inspection__fields dt{color:#c8bdca;font-size:.72rem}.worker-inspection__fields dd{color:#f7dda0;font-size:.74rem;font-weight:760;text-align:right;overflow-wrap:anywhere}.worker-inspection__read-only{margin-top:.82rem;border-left-color:rgb(218 177 246 / 56%)}.worker-inspection__recall{width:100%;min-height:2.8rem;margin-top:.9rem;padding:.56rem .7rem;border:1px solid rgb(246 215 145 / 72%);border-radius:.44rem;background:linear-gradient(180deg,rgb(229 186 93 / 97%),rgb(121 76 37 / 97%));color:#171016;font-size:.7rem;font-weight:840;letter-spacing:.12em;cursor:pointer}@media (max-width:40rem){.worker-inspection{width:100%;}.worker-inspection__drawer{padding-top:clamp(6.5rem,24vw,8rem)}} diff --git a/src/components/realm/WorkerInspectionPanel.tsx b/src/components/realm/WorkerInspectionPanel.tsx new file mode 100644 index 00000000..a1404b7c --- /dev/null +++ b/src/components/realm/WorkerInspectionPanel.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState, type Ref } from 'react'; +import { + realmWorkerCanRecall, + realmWorkerLabel, + realmWorkerStatusLabel, + type RealmWorkerPublicPresentation +} from './realmWorkerPresentation'; +import './WorkerInspectionPanel.css'; + +function publicAssetUrl(path: string) { + const base = import.meta.env.BASE_URL || '/'; + return `${base.endsWith('/') ? base : `${base}/`}${path.replace(/^\/+/, '')}`; +} + +export type WorkerInspectionPanelProps = Readonly<{ + id: string; + worker: RealmWorkerPublicPresentation; + onRecallWorker?: (workerId: string) => Promise; + onRequestClose: () => void; + focusTargetRef?: Ref; +}>; + +export function WorkerInspectionPanel({ id, worker, onRecallWorker, onRequestClose, focusTargetRef }: WorkerInspectionPanelProps) { + const closeRef = useRef(null); + const [state, setState] = useState<'idle' | 'submitting' | 'failed'>('idle'); + const canRecall = realmWorkerCanRecall(worker) && onRecallWorker !== undefined; + useEffect(() => { + closeRef.current?.focus({ preventScroll: true }); + setState('idle'); + }, [id, worker.workerId, worker.status]); + const recall = async () => { + if (!canRecall || !onRecallWorker) return; + setState('submitting'); + try { await onRecallWorker(worker.workerId); setState('idle'); } + catch { setState('failed'); } + }; + const ref = (element: HTMLButtonElement | null) => { + closeRef.current = element; + if (typeof focusTargetRef === 'function') focusTargetRef(element); + else if (focusTargetRef) focusTargetRef.current = element; + }; + const title = realmWorkerLabel(worker.ordinal); + return ( + + ); +} diff --git a/src/components/realm/realmInteractionState.ts b/src/components/realm/realmInteractionState.ts index d6ca3527..2796d99f 100644 --- a/src/components/realm/realmInteractionState.ts +++ b/src/components/realm/realmInteractionState.ts @@ -5,6 +5,13 @@ export type RealmCastleTarget = Readonly<{ coord: HexCoord; }>; +export type RealmWorkerTarget = Readonly<{ + workerId: string; + workerOrdinal: number; + originCastleId: number; + coord: HexCoord; +}>; + /** A public world-site target; it contains no economy or authorization data. */ export type RealmGoldSiteTarget = Readonly<{ siteId: string; @@ -30,6 +37,7 @@ export type RealmStoneSiteTarget = Readonly<{ }>; export type RealmInspectorTarget = + | RealmWorkerTarget | RealmCastleTarget | RealmGoldSiteTarget | RealmFoodSiteTarget @@ -41,11 +49,13 @@ export type RealmCameraTarget = | Readonly<{ kind: 'founding-district' }> | Readonly<{ kind: 'keep' }> | Readonly<{ kind: 'cell'; coord: HexCoord }> - | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }>; + | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }> + | Readonly<{ kind: 'worker'; workerId: string; coord: HexCoord }>; export type RealmKeyboardTarget = | Readonly<{ kind: 'map' }> | Readonly<{ kind: 'inspector'; castleId: number }> + | Readonly<{ kind: 'worker-inspector'; workerId: string }> | Readonly<{ kind: 'gold-mine-inspector'; siteId: string }> | Readonly<{ kind: 'food-farm-inspector'; siteId: string }> | Readonly<{ kind: 'logging-camp-inspector'; siteId: string }> @@ -76,6 +86,7 @@ export type RealmInteractionState = Readonly<{ export type RealmInteractionAction = | Readonly<{ type: 'select-cell'; coord: HexCoord }> | Readonly<{ type: 'activate-castle'; castleId: number; coord: HexCoord }> + | Readonly<{ type: 'activate-worker'; workerId: string; workerOrdinal: number; originCastleId: number; coord: HexCoord }> | Readonly<{ type: 'activate-gold-site'; siteId: string; @@ -123,6 +134,15 @@ function copyCastleTarget(target: RealmCastleTarget): RealmCastleTarget { return { castleId: target.castleId, coord: copyCoord(target.coord) }; } +function copyWorkerTarget(target: RealmWorkerTarget): RealmWorkerTarget { + return { + workerId: target.workerId, + workerOrdinal: target.workerOrdinal, + originCastleId: target.originCastleId, + coord: copyCoord(target.coord) + }; +} + function copyGoldSiteTarget(target: RealmGoldSiteTarget): RealmGoldSiteTarget { return { siteId: target.siteId, coord: copyCoord(target.coord) }; } @@ -148,6 +168,7 @@ function copyCameraTarget(target: RealmCameraTarget): RealmCameraTarget { if (target.kind === 'founding-district') return { kind: 'founding-district' }; if (target.kind === 'keep') return { kind: 'keep' }; if (target.kind === 'cell') return { kind: 'cell', coord: copyCoord(target.coord) }; + if (target.kind === 'worker') return { kind: 'worker', workerId: target.workerId, coord: copyCoord(target.coord) }; return { kind: 'castle', castleId: target.castleId, coord: copyCoord(target.coord) }; } @@ -212,6 +233,20 @@ export function realmInteractionReducer( }; } + case 'activate-worker': { + const target = copyWorkerTarget(action); + return { + ...state, + selectedCell: copyCoord(target.coord), + selectedCastle: null, + inspectorTarget: target, + inspectorOpen: true, + cameraTarget: { kind: 'worker', workerId: target.workerId, coord: copyCoord(target.coord) }, + navigatorOpen: false, + keyboardIntent: withKeyboardIntent(state, { kind: 'worker-inspector', workerId: target.workerId }) + }; + } + case 'activate-gold-site': { const target = copyGoldSiteTarget({ siteId: action.siteId, coord: action.coord }); return { diff --git a/src/components/realm/realmPickArbitration.ts b/src/components/realm/realmPickArbitration.ts index 7e353552..58e16abf 100644 --- a/src/components/realm/realmPickArbitration.ts +++ b/src/components/realm/realmPickArbitration.ts @@ -13,8 +13,16 @@ export type RealmResourcePickHit = Readonly<{ source: 'site' | 'wagon'; distance: number; }>; +export type RealmWorkerPickHit = Readonly<{ + workerId: string; + workerOrdinal: number; + originCastleId: number; + coord: HexCoord; + distance: number; +}>; export type RealmInteractionTarget = + | Readonly<{ kind: 'worker'; workerId: string; workerOrdinal: number; originCastleId: number; coord: HexCoord }> | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }> | Readonly<{ kind: RealmResourcePickKind; @@ -47,9 +55,20 @@ function nearestValidHit( */ export function arbitrateRealmPick(input: Readonly<{ resourceHits: readonly RealmResourcePickHit[]; + workerHits?: readonly RealmWorkerPickHit[]; castleHit?: Readonly<{ castleId: number; coord: HexCoord }> | null; terrainHit?: Readonly<{ coord: HexCoord }> | null; }>): RealmInteractionTarget | null { + const worker = (input.workerHits ?? []).find((hit) => Number.isFinite(hit.distance) && hit.distance >= 0); + if (worker) { + return Object.freeze({ + kind: 'worker', + workerId: worker.workerId, + workerOrdinal: worker.workerOrdinal, + originCastleId: worker.originCastleId, + coord: worker.coord + }); + } const wagon = nearestValidHit(input.resourceHits, 'wagon'); if (wagon) { return Object.freeze({ diff --git a/src/components/realm/realmWorkerPresentation.ts b/src/components/realm/realmWorkerPresentation.ts new file mode 100644 index 00000000..e4505945 --- /dev/null +++ b/src/components/realm/realmWorkerPresentation.ts @@ -0,0 +1,345 @@ +import type { RealmEconomicResourceKey } from './realmResourcePresentation'; + +export const CASTLE_WORKER_ORDINALS = Object.freeze([1, 2, 3, 4] as const); +export type RealmWorkerOrdinal = typeof CASTLE_WORKER_ORDINALS[number]; +export type RealmWorkerStatus = 'idle' | 'outbound' | 'gathering' | 'returning'; +export type RealmWorkerSystemMode = 'staged' | 'active'; + +export type RealmWorkerSystemPresentation = Readonly<{ + realmId: string; + policyVersion: string; + workersPerCastle: 4; + expectedCastleCount: number; + expectedWorkerCount: number; + rosterDigest: string; + mode: RealmWorkerSystemMode; + legacyDrainRequired: boolean; +}>; + +export type RealmWorkerPublicPresentation = Readonly<{ + workerId: string; + ordinal: RealmWorkerOrdinal; + originCastleId: number; + originCastleName: string; + status: RealmWorkerStatus; + resourceKind?: RealmEconomicResourceKey; + destinationLabel?: string; + startedAtMicros?: bigint; + arrivesAtMicros?: bigint; + gatheringEndsAtMicros?: bigint; + returnStartedAtMicros?: bigint; + returnsAtMicros?: bigint; + routeSteps?: number; + returnStartProgressBasisPoints?: number; + timelineRevision: number; + revision: bigint; + ownedByViewer: boolean; + claimableAmount?: bigint; +}>; + +export type RealmWorkerNodeOccupation = Readonly<{ + nodeKey: string; + resourceKind: RealmEconomicResourceKey; + siteId: string; + workerId: string; + workerOrdinal: RealmWorkerOrdinal; + originCastleId: number; + assignmentId: string; + phase: Exclude; + startedAtMicros: bigint; + arrivesAtMicros: bigint; + gatheringEndsAtMicros: bigint; + timelineRevision: number; +}>; + +export type WorkerRosterPresentation = Readonly<{ + castleId: number; + observedAtMicros: bigint; + workers: readonly Readonly<{ + workerId: string; + ordinal: RealmWorkerOrdinal; + status: RealmWorkerStatus; + resourceKind?: RealmEconomicResourceKey; + siteId?: string; + accruedAmount: bigint; + materializedAmount: bigint; + availableAmount: bigint; + observedAtMicros: bigint; + revision: bigint; + }>[]; +}>; + +export type ReadyWorkerResourceState = Readonly<{ + status: 'ready'; + fid: bigint; + available: Readonly>; + pending: Readonly>; + observedAtMicros: bigint; + settledThroughMicros: bigint; + revision: bigint; + workerPolicyVersion: string; + workerSystemMode: RealmWorkerSystemMode; +}>; + +export type ReadyWorkerProjection = Readonly<{ + mode: RealmWorkerSystemMode; + system: RealmWorkerSystemPresentation; + workers: readonly RealmWorkerPublicPresentation[]; + occupations: readonly RealmWorkerNodeOccupation[]; +}>; + +const resourceKinds = new Set(['food', 'wood', 'stone', 'gold']); +const workerStatuses = new Set(['idle', 'outbound', 'gathering', 'returning']); +const occupationPhases = new Set(['outbound', 'gathering', 'returning']); + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function safeNumber(value: unknown, positive = false) { + const number = typeof value === 'bigint' + && value <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(value) + : value; + return typeof number === 'number' + && Number.isSafeInteger(number) + && (!positive || number > 0) + ? number + : undefined; +} + +function safeBigInt(value: unknown) { + return typeof value === 'bigint' && value >= 0n ? value : undefined; +} + +function optionalBigInt(value: unknown) { + return value === undefined || value === null ? undefined : safeBigInt(value); +} + +function optionalResource(value: unknown) { + return typeof value === 'string' && resourceKinds.has(value as RealmEconomicResourceKey) + ? value as RealmEconomicResourceKey + : value === undefined || value === null ? undefined : null; +} + +function optionalString(value: unknown) { + return value === undefined || value === null ? undefined : typeof value === 'string' ? value : null; +} + +export function decodeRealmWorkerSystem(value: unknown): RealmWorkerSystemPresentation | undefined { + if (!record(value)) return undefined; + const workersPerCastle = safeNumber(value.workersPerCastle); + const expectedCastleCount = safeNumber(value.expectedCastleCount); + const expectedWorkerCount = safeNumber(value.expectedWorkerCount); + const mode = value.mode === 'active' || value.mode === 'staged' ? value.mode : undefined; + if ( + typeof value.realmId !== 'string' || value.realmId.length === 0 + || typeof value.policyVersion !== 'string' || value.policyVersion.length === 0 + || workersPerCastle !== 4 || expectedCastleCount === undefined || expectedWorkerCount === undefined + || expectedWorkerCount !== expectedCastleCount * 4 || typeof value.rosterDigest !== 'string' + || mode === undefined || typeof value.legacyDrainRequired !== 'boolean' + ) return undefined; + return Object.freeze({ + realmId: value.realmId, + policyVersion: value.policyVersion, + workersPerCastle: 4 as const, + expectedCastleCount, + expectedWorkerCount, + rosterDigest: value.rosterDigest, + mode, + legacyDrainRequired: value.legacyDrainRequired + }); +} + +export function decodeRealmWorkerPublicRows( + rows: readonly unknown[], + castleNames: ReadonlyMap, + ownCastleId: number +): readonly RealmWorkerPublicPresentation[] | undefined { + const decoded: RealmWorkerPublicPresentation[] = []; + const ids = new Set(); + for (const value of rows) { + if (!record(value)) return undefined; + const ordinal = safeNumber(value.ordinal); + const originCastleId = safeNumber(value.originCastleId, true); + const timelineRevision = safeNumber(value.timelineRevision); + const revision = safeBigInt(value.revision); + const status = workerStatuses.has(value.status as RealmWorkerStatus) + ? value.status as RealmWorkerStatus + : undefined; + const resourceKind = optionalResource(value.resourceKind); + const siteId = optionalString(value.siteId); + if ( + typeof value.workerId !== 'string' || value.workerId.length === 0 || ids.has(value.workerId) + || ordinal === undefined || !CASTLE_WORKER_ORDINALS.includes(ordinal as RealmWorkerOrdinal) + || originCastleId === undefined || status === undefined || resourceKind === null + || siteId === null || timelineRevision === undefined || revision === undefined + || !castleNames.has(originCastleId) + ) return undefined; + ids.add(value.workerId); + const optional = (key: string) => optionalBigInt(value[key]); + const worker: RealmWorkerPublicPresentation = { + workerId: value.workerId, + ordinal: ordinal as RealmWorkerOrdinal, + originCastleId, + originCastleName: castleNames.get(originCastleId)!, + status, + ...(resourceKind === undefined ? {} : { resourceKind }), + ...(siteId === undefined ? {} : { destinationLabel: siteId }), + ...(optional('startedAtMicros') === undefined ? {} : { startedAtMicros: optional('startedAtMicros') }), + ...(optional('arrivesAtMicros') === undefined ? {} : { arrivesAtMicros: optional('arrivesAtMicros') }), + ...(optional('gatheringEndsAtMicros') === undefined ? {} : { gatheringEndsAtMicros: optional('gatheringEndsAtMicros') }), + ...(optional('returnStartedAtMicros') === undefined ? {} : { returnStartedAtMicros: optional('returnStartedAtMicros') }), + ...(optional('returnsAtMicros') === undefined ? {} : { returnsAtMicros: optional('returnsAtMicros') }), + ...(safeNumber(value.routeSteps) === undefined ? {} : { routeSteps: safeNumber(value.routeSteps) }), + ...(safeNumber(value.returnStartProgressBasisPoints) === undefined ? {} : { returnStartProgressBasisPoints: safeNumber(value.returnStartProgressBasisPoints) }), + timelineRevision, + revision, + ownedByViewer: originCastleId === ownCastleId + }; + decoded.push(Object.freeze(worker)); + } + const byCastle = new Map(); + for (const worker of decoded) byCastle.set(worker.originCastleId, (byCastle.get(worker.originCastleId) ?? 0) + 1); + if (decoded.length > 0 && [...byCastle.values()].some((count) => count !== 4)) return undefined; + return Object.freeze(decoded); +} + +export function decodeRealmWorkerOccupations(rows: readonly unknown[]): readonly RealmWorkerNodeOccupation[] | undefined { + const decoded: RealmWorkerNodeOccupation[] = []; + const keys = new Set(); + for (const value of rows) { + if (!record(value)) return undefined; + const workerOrdinal = safeNumber(value.workerOrdinal); + const originCastleId = safeNumber(value.originCastleId, true); + const timelineRevision = safeNumber(value.timelineRevision); + const resourceKind = optionalResource(value.resourceKind); + const phase = occupationPhases.has(value.phase as RealmWorkerNodeOccupation['phase']) + ? value.phase as RealmWorkerNodeOccupation['phase'] : undefined; + const startedAtMicros = safeBigInt(value.startedAtMicros); + const arrivesAtMicros = safeBigInt(value.arrivesAtMicros); + const gatheringEndsAtMicros = safeBigInt(value.gatheringEndsAtMicros); + if ( + typeof value.nodeKey !== 'string' || keys.has(value.nodeKey) + || typeof value.siteId !== 'string' || typeof value.workerId !== 'string' + || typeof value.assignmentId !== 'string' || resourceKind === undefined || phase === undefined + || workerOrdinal === undefined || !CASTLE_WORKER_ORDINALS.includes(workerOrdinal as RealmWorkerOrdinal) + || originCastleId === undefined || timelineRevision === undefined + || startedAtMicros === undefined || arrivesAtMicros === undefined || gatheringEndsAtMicros === undefined + ) return undefined; + const resourceKindValue = resourceKind as RealmEconomicResourceKey; + keys.add(value.nodeKey); + decoded.push(Object.freeze({ + nodeKey: value.nodeKey, + resourceKind: resourceKindValue, + siteId: value.siteId, + workerId: value.workerId, + workerOrdinal: workerOrdinal as RealmWorkerOrdinal, + originCastleId, + assignmentId: value.assignmentId, + phase, + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + timelineRevision + })); + } + return Object.freeze(decoded); +} + +export function decodeWorkerRoster(value: unknown, expectedFid: bigint): WorkerRosterPresentation | undefined { + if (!record(value) || value.fid !== expectedFid || safeNumber(value.castleId, true) === undefined + || safeBigInt(value.observedAtMicros) === undefined || !Array.isArray(value.workers)) return undefined; + const workers: WorkerRosterPresentation['workers'][number][] = []; + const ids = new Set(); + for (const workerValue of value.workers) { + if (!record(workerValue)) return undefined; + const ordinal = safeNumber(workerValue.ordinal); + const resourceKind = optionalResource(workerValue.resourceKind); + const siteId = optionalString(workerValue.siteId); + if ( + typeof workerValue.workerId !== 'string' || ids.has(workerValue.workerId) + || ordinal === undefined || !CASTLE_WORKER_ORDINALS.includes(ordinal as RealmWorkerOrdinal) + || !workerStatuses.has(workerValue.status as RealmWorkerStatus) || resourceKind === null || siteId === null + || safeBigInt(workerValue.accruedAmount) === undefined || safeBigInt(workerValue.materializedAmount) === undefined + || safeBigInt(workerValue.availableAmount) === undefined || safeBigInt(workerValue.observedAtMicros) === undefined + || safeBigInt(workerValue.revision) === undefined + ) return undefined; + const accruedAmount = safeBigInt(workerValue.accruedAmount)!; + const materializedAmount = safeBigInt(workerValue.materializedAmount)!; + const availableAmount = safeBigInt(workerValue.availableAmount)!; + const observedAtMicros = safeBigInt(workerValue.observedAtMicros)!; + const revision = safeBigInt(workerValue.revision)!; + ids.add(workerValue.workerId); + workers.push(Object.freeze({ + workerId: workerValue.workerId, + ordinal: ordinal as RealmWorkerOrdinal, + status: workerValue.status as RealmWorkerStatus, + ...(resourceKind === undefined ? {} : { resourceKind }), + ...(siteId === undefined ? {} : { siteId }), + accruedAmount, + materializedAmount, + availableAmount, + observedAtMicros, + revision + })); + } + if (workers.length !== 4) return undefined; + const castleId = safeNumber(value.castleId, true)!; + const observedAtMicros = safeBigInt(value.observedAtMicros)!; + return Object.freeze({ + castleId, + observedAtMicros, + workers: Object.freeze(workers) + }); +} + +export function decodeWorkerResourceState(value: unknown, expectedFid: bigint): ReadyWorkerResourceState | undefined { + if (!record(value) || value.fid !== expectedFid) return undefined; + const mode = value.workerSystemMode === 'active' || value.workerSystemMode === 'staged' + ? value.workerSystemMode : undefined; + const names = ['food', 'wood', 'stone', 'gold'] as const; + const available = {} as Record; + const pending = {} as Record; + const observedAtMicros = safeBigInt(value.observedAtMicros); + const settledThroughMicros = safeBigInt(value.settledThroughMicros); + const revision = safeBigInt(value.revision); + for (const name of names) { + const availableValue = safeBigInt(value[name]); + const pendingValue = safeBigInt(value[`workerPending${name[0]!.toUpperCase()}${name.slice(1)}`]); + if (availableValue === undefined || pendingValue === undefined) return undefined; + available[name] = availableValue; + pending[name] = pendingValue; + } + if ( + mode === undefined || typeof value.resourcePolicyVersion !== 'string' + || typeof value.workerPolicyVersion !== 'string' || observedAtMicros === undefined + || settledThroughMicros === undefined || revision === undefined + || settledThroughMicros > observedAtMicros + ) return undefined; + return Object.freeze({ + status: 'ready' as const, + fid: expectedFid, + available: Object.freeze(available), + pending: Object.freeze(pending), + observedAtMicros, + settledThroughMicros, + revision, + workerPolicyVersion: value.workerPolicyVersion, + workerSystemMode: mode + }); +} + +export function workerAvailabilityCount(workers: readonly RealmWorkerPublicPresentation[]) { + return workers.filter((worker) => worker.status === 'idle').length; +} + +export function realmWorkerLabel(ordinal: RealmWorkerOrdinal) { return `Worker ${ordinal}`; } +export function realmWorkerStatusLabel(worker: RealmWorkerPublicPresentation) { + if (worker.status === 'outbound' && worker.destinationLabel) return `TRAVELLING TO ${worker.destinationLabel.toUpperCase()}`; + if (worker.status === 'gathering' && worker.resourceKind) return `GATHERING ${worker.resourceKind.toUpperCase()}`; + return ({ idle: 'READY AT KEEP', outbound: 'TRAVELLING TO RESOURCE', gathering: 'GATHERING RESOURCE', returning: 'RETURNING TO KEEP' } as const)[worker.status]; +} +export function realmWorkerCanRecall(worker: RealmWorkerPublicPresentation) { + return worker.ownedByViewer && (worker.status === 'outbound' || worker.status === 'gathering'); +} diff --git a/src/spacetime/WarpkeepSpacetimeProvider.tsx b/src/spacetime/WarpkeepSpacetimeProvider.tsx index ae8dac82..7014c751 100644 --- a/src/spacetime/WarpkeepSpacetimeProvider.tsx +++ b/src/spacetime/WarpkeepSpacetimeProvider.tsx @@ -34,6 +34,11 @@ import { readWarpkeepWoodExpeditionState, readWarpkeepStoneExpeditionState, readWarpkeepResourceState, + readWarpkeepResourceStateV2, + readWarpkeepWorkerRoster, + dispatchWarpkeepWorker, + recallWarpkeepWorker, + recallAllWarpkeepWorkers, readWarpkeepRealmSnapshot, subscribeToWarpkeepRealm, type WarpkeepConnection @@ -58,6 +63,11 @@ import type { ReadyGoldExpeditionPresentation } from '../components/realm/realmG import type { ReadyFoodExpeditionPresentation } from '../components/realm/realmFoodExpeditionPresentation'; import type { ReadyWoodExpeditionPresentation } from '../components/realm/realmWoodExpeditionPresentation'; import type { ReadyStoneExpeditionPresentation } from '../components/realm/realmStoneExpeditionPresentation'; +import type { + ReadyWorkerProjection, + ReadyWorkerResourceState, + WorkerRosterPresentation +} from '../components/realm/realmWorkerPresentation'; import { createExpeditionIdempotencyKey } from './expeditionIdempotencyKey'; /** @@ -119,6 +129,12 @@ export type WarpkeepBackendControllerValue = Readonly<{ dispatchStoneExpedition: (siteId: string) => Promise; /** Settle only the caller's Stone expedition and refresh both private views. */ claimStoneExpedition: () => Promise; + workerProjection?: ReadyWorkerProjection; + workerRoster?: WorkerRosterPresentation; + workerResourceState?: ReadyWorkerResourceState; + dispatchWorker: (workerId: string, resourceKind: string, siteId: string) => Promise; + recallWorker: (workerId: string) => Promise; + recallAllWorkers: () => Promise; }>; /** @@ -135,6 +151,11 @@ export type WarpkeepBackendRuntime = Readonly<{ acceptAlphaTerms: typeof acceptWarpkeepAlphaTerms; readResourceState: typeof readWarpkeepResourceState; collectResources: typeof collectWarpkeepResources; + readWorkerRoster?: typeof readWarpkeepWorkerRoster; + readResourceStateV2?: typeof readWarpkeepResourceStateV2; + dispatchWorker?: typeof dispatchWarpkeepWorker; + recallWorker?: typeof recallWarpkeepWorker; + recallAllWorkers?: typeof recallAllWarpkeepWorkers; /** Optional only for older deterministic test/QA runtimes without v5 Gold. */ readGoldExpeditionState?: typeof readWarpkeepGoldExpeditionState; /** Optional only for older deterministic test/QA runtimes without v5 Gold. */ @@ -171,6 +192,11 @@ const DEFAULT_WARPKEEP_BACKEND_RUNTIME: WarpkeepBackendRuntime = Object.freeze({ acceptAlphaTerms: acceptWarpkeepAlphaTerms, readResourceState: readWarpkeepResourceState, collectResources: collectWarpkeepResources, + readWorkerRoster: readWarpkeepWorkerRoster, + readResourceStateV2: readWarpkeepResourceStateV2, + dispatchWorker: dispatchWarpkeepWorker, + recallWorker: recallWarpkeepWorker, + recallAllWorkers: recallAllWarpkeepWorkers, readGoldExpeditionState: readWarpkeepGoldExpeditionState, dispatchGoldExpedition: dispatchWarpkeepGoldExpedition, collectGoldExpedition: collectWarpkeepGoldExpedition, @@ -275,6 +301,29 @@ function resourceProjectionIsAtLeastAsNew( ); } +function activeWorkerProjection( + snapshot: WarpkeepRealmSnapshot, + roster: WorkerRosterPresentation | undefined +): ReadyWorkerProjection | undefined { + if ( + snapshot.workerSystem?.mode !== 'active' + || snapshot.workerSystem.workersPerCastle !== 4 + || snapshot.workerSystem.expectedWorkerCount !== snapshot.workerSystem.expectedCastleCount * 4 + || snapshot.workerWorkers === undefined + || snapshot.workerOccupations === undefined + || roster === undefined + || roster.workers.length !== 4 + ) return undefined; + const publicIds = new Set(snapshot.workerWorkers.map((worker) => worker.workerId)); + if (publicIds.size !== snapshot.workerSystem.expectedWorkerCount || roster.workers.some((worker) => !publicIds.has(worker.workerId))) return undefined; + return Object.freeze({ + mode: 'active' as const, + system: snapshot.workerSystem, + workers: snapshot.workerWorkers, + occupations: snapshot.workerOccupations + }); +} + /** * A focused React provider around the generated client bindings. It bypasses * the SDK's URI/database-only connection cache so a sign-out or changed bridge @@ -338,6 +387,10 @@ export function WarpkeepSpacetimeProvider({ }> | undefined>(undefined); const stoneExpeditionOperationGenerationRef = useRef(undefined); const stoneDispatchAttemptRef = useRef(undefined); + const workerRosterStateRef = useRef | undefined>(undefined); + const workerResourceStateRef = useRef | undefined>(undefined); + const workerCommandGenerationRef = useRef(undefined); + const workerDispatchAttemptRef = useRef(undefined); const processTermsAttemptRef = useRef<() => void>(() => undefined); stateRef.current = state; @@ -367,6 +420,10 @@ export function WarpkeepSpacetimeProvider({ stoneExpeditionStateRef.current = undefined; stoneExpeditionOperationGenerationRef.current = undefined; stoneDispatchAttemptRef.current = undefined; + workerRosterStateRef.current = undefined; + workerResourceStateRef.current = undefined; + workerCommandGenerationRef.current = undefined; + workerDispatchAttemptRef.current = undefined; canonicalRealmSourceRef.current = undefined; processTermsAttemptRef.current = () => undefined; runActiveTeardown(); @@ -446,6 +503,70 @@ export function WarpkeepSpacetimeProvider({ } }, [runActiveTeardown, runtime]); + const runWorkerCommand = useCallback(async ( + command: (connection: WarpkeepConnection, idempotencyKey: string) => Promise + ) => { + const generation = generationRef.current; + const currentState = stateRef.current; + const connection = connectionRef.current; + const fid = currentState.identity?.fid; + if ( + currentState.phase !== 'ready' || currentState.admission !== 'ready' + || currentState.workerProjection?.mode !== 'active' || connection === undefined || fid === undefined + || runtime.readWorkerRoster === undefined || runtime.readResourceStateV2 === undefined + || runtime.dispatchWorker === undefined && runtime.recallWorker === undefined && runtime.recallAllWorkers === undefined + || workerCommandGenerationRef.current === generation + ) throw new Error('Worker command is unavailable.'); + const idempotencyKey = workerDispatchAttemptRef.current?.generation === generation + ? workerDispatchAttemptRef.current.idempotencyKey + : createExpeditionIdempotencyKey(); + if (idempotencyKey === undefined) throw new Error('Worker command is unavailable.'); + workerCommandGenerationRef.current = generation; + workerDispatchAttemptRef.current = Object.freeze({ generation, siteId: 'worker-command', idempotencyKey }); + try { + await withResourceOperationDeadline(command(connection, idempotencyKey)); + const [roster, resourceState] = await Promise.all([ + withResourceOperationDeadline(runtime.readWorkerRoster(connection, fid)), + withResourceOperationDeadline(runtime.readResourceStateV2(connection, fid)) + ]); + if (generationRef.current !== generation || roster === undefined || resourceState === undefined) { + throw new Error('Worker command is unavailable.'); + } + workerRosterStateRef.current = Object.freeze({ generation, value: roster }); + workerResourceStateRef.current = Object.freeze({ generation, value: resourceState }); + setState((latest) => { + if (generationRef.current !== generation || latest.phase !== 'ready') return latest; + return { ...latest, workerRoster: roster, workerResourceState: resourceState }; + }); + workerDispatchAttemptRef.current = undefined; + } catch { + throw new Error('Worker command is unavailable.'); + } finally { + if (workerCommandGenerationRef.current === generation) workerCommandGenerationRef.current = undefined; + } + }, [runtime]); + + const dispatchWorker = useCallback((workerId: string, resourceKind: string, siteId: string) => ( + runWorkerCommand((connection, idempotencyKey) => { + if (runtime.dispatchWorker === undefined) return Promise.reject(new Error('Worker command is unavailable.')); + return runtime.dispatchWorker(connection, workerId, resourceKind, siteId, idempotencyKey); + }) + ), [runWorkerCommand, runtime]); + + const recallWorker = useCallback((workerId: string) => ( + runWorkerCommand((connection, idempotencyKey) => { + if (runtime.recallWorker === undefined) return Promise.reject(new Error('Worker command is unavailable.')); + return runtime.recallWorker(connection, workerId, idempotencyKey); + }) + ), [runWorkerCommand, runtime]); + + const recallAllWorkers = useCallback(() => ( + runWorkerCommand((connection, idempotencyKey) => { + if (runtime.recallAllWorkers === undefined) return Promise.reject(new Error('Worker command is unavailable.')); + return runtime.recallAllWorkers(connection, idempotencyKey); + }) + ), [runWorkerCommand, runtime]); + const dispatchGoldExpedition = useCallback(async (siteId: string) => { const generation = generationRef.current; const currentState = stateRef.current; @@ -1342,6 +1463,12 @@ export function WarpkeepSpacetimeProvider({ const stoneExpedition = stoneExpeditionStateRef.current?.generation === generation ? stoneExpeditionStateRef.current.value : undefined; + const workerRoster = workerRosterStateRef.current?.generation === generation + ? workerRosterStateRef.current.value + : undefined; + const workerResourceState = workerResourceStateRef.current?.generation === generation + ? workerResourceStateRef.current.value + : undefined; if ( !current() || !subscriptionApplied @@ -1358,6 +1485,7 @@ export function WarpkeepSpacetimeProvider({ readinessTimeout = undefined; } canonicalRealmSourceRef.current = canonicalRealmSource; + const workerProjection = activeWorkerProjection(realm, workerRoster); setState({ phase: 'ready', identity, @@ -1367,7 +1495,10 @@ export function WarpkeepSpacetimeProvider({ ...(goldExpedition === undefined ? {} : { goldExpedition }), ...(foodExpedition === undefined ? {} : { foodExpedition }), ...(woodExpedition === undefined ? {} : { woodExpedition }), - ...(stoneExpedition === undefined ? {} : { stoneExpedition }) + ...(stoneExpedition === undefined ? {} : { stoneExpedition }), + ...(workerRoster === undefined ? {} : { workerRoster }), + ...(workerResourceState === undefined ? {} : { workerResourceState }), + ...(workerProjection === undefined ? {} : { workerProjection }) }); } catch { fail(); @@ -1427,6 +1558,16 @@ export function WarpkeepSpacetimeProvider({ : withResourceOperationDeadline( runtime.readStoneExpeditionState(activeConnection) ).catch(() => undefined); + const initialWorkerRosterPromise = runtime.readWorkerRoster === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline( + runtime.readWorkerRoster(activeConnection, bridgeFid!) + ).catch(() => undefined); + const initialWorkerResourceStatePromise = runtime.readResourceStateV2 === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline( + runtime.readResourceStateV2(activeConnection, bridgeFid!) + ).catch(() => undefined); // A synchronous observer/subscription exception can terminate this // generation before the await below. Pre-handle the same promise so // its eventual rejection cannot escape as an unhandled rejection; @@ -1457,13 +1598,17 @@ export function WarpkeepSpacetimeProvider({ initialGoldExpedition, initialFoodExpedition, initialWoodExpedition, - initialStoneExpedition + initialStoneExpedition, + initialWorkerRoster, + initialWorkerResourceState ] = await Promise.all([ initialResourcePromise, initialGoldExpeditionPromise, initialFoodExpeditionPromise, initialWoodExpeditionPromise, - initialStoneExpeditionPromise + initialStoneExpeditionPromise, + initialWorkerRosterPromise, + initialWorkerResourceStatePromise ]); if (!current()) return; resourceStateRef.current = Object.freeze({ @@ -1486,6 +1631,8 @@ export function WarpkeepSpacetimeProvider({ generation, value: initialStoneExpedition }); + workerRosterStateRef.current = Object.freeze({ generation, value: initialWorkerRoster }); + workerResourceStateRef.current = Object.freeze({ generation, value: initialWorkerResourceState }); realmActivated = true; const refreshResources = async () => { if (!current() || !realmActivated || resourceRefreshInFlight) return; @@ -1511,14 +1658,22 @@ export function WarpkeepSpacetimeProvider({ : withResourceOperationDeadline( runtime.readStoneExpeditionState(activeConnection) ).catch(() => undefined); - const [refreshed, refreshedGoldExpedition, refreshedFoodExpedition, refreshedWoodExpedition, refreshedStoneExpedition] = await Promise.all([ + const workerRosterRefresh = runtime.readWorkerRoster === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline(runtime.readWorkerRoster(activeConnection, bridgeFid!)).catch(() => undefined); + const workerResourceRefresh = runtime.readResourceStateV2 === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline(runtime.readResourceStateV2(activeConnection, bridgeFid!)).catch(() => undefined); + const [refreshed, refreshedGoldExpedition, refreshedFoodExpedition, refreshedWoodExpedition, refreshedStoneExpedition, refreshedWorkerRoster, refreshedWorkerResourceState] = await Promise.all([ withResourceOperationDeadline( runtime.readResourceState(activeConnection, bridgeFid!) ), goldRefresh, foodRefresh, woodRefresh, - stoneRefresh + stoneRefresh, + workerRosterRefresh, + workerResourceRefresh ]); if (!current()) return; if (refreshed.fid !== BigInt(bridgeFid!)) { @@ -1545,6 +1700,8 @@ export function WarpkeepSpacetimeProvider({ generation, value: refreshedStoneExpedition }); + workerRosterStateRef.current = Object.freeze({ generation, value: refreshedWorkerRoster }); + workerResourceStateRef.current = Object.freeze({ generation, value: refreshedWorkerResourceState }); setState((latest) => { const latestRetained = resourceStateRef.current?.generation === generation ? resourceStateRef.current.value @@ -1567,6 +1724,11 @@ export function WarpkeepSpacetimeProvider({ foodExpedition: refreshedFoodExpedition, woodExpedition: refreshedWoodExpedition, stoneExpedition: refreshedStoneExpedition + , workerRoster: refreshedWorkerRoster + , workerResourceState: refreshedWorkerResourceState + , ...(latest.realm === undefined ? {} : { + workerProjection: activeWorkerProjection(latest.realm, refreshedWorkerRoster) + }) }; }); } catch { @@ -1627,7 +1789,10 @@ export function WarpkeepSpacetimeProvider({ dispatchWoodExpedition, claimWoodExpedition, dispatchStoneExpedition, - claimStoneExpedition + claimStoneExpedition, + dispatchWorker, + recallWorker, + recallAllWorkers }), [ beginAlphaTermsAcceptance, cancelAlphaTermsAcceptance, @@ -1642,6 +1807,9 @@ export function WarpkeepSpacetimeProvider({ dispatchFoodExpedition, dispatchWoodExpedition, dispatchStoneExpedition, + dispatchWorker, + recallWorker, + recallAllWorkers, sharedAlphaAvailable, state ]); diff --git a/src/spacetime/canonicalGenesisSnapshot.ts b/src/spacetime/canonicalGenesisSnapshot.ts index 91fbb6c1..ec460ffb 100644 --- a/src/spacetime/canonicalGenesisSnapshot.ts +++ b/src/spacetime/canonicalGenesisSnapshot.ts @@ -596,6 +596,13 @@ export function validateCanonicalGenesisSnapshot( const stoneNodeOccupations = stoneSites !== undefined ? freezeRows(candidate.stoneNodeOccupations!) : undefined; + // Generic workers are a staged additive projection. Preserve only the + // complete public trio; absent or malformed rows keep the legacy renderer + // path instead of revoking the canonical world snapshot. + const rawWorkerSystem = candidate.workerSystem; + const workerSystem = rawWorkerSystem === undefined ? undefined : freezePresentationValue(rawWorkerSystem); + const workerWorkers = candidate.workerWorkers === undefined ? undefined : freezePresentationValue(candidate.workerWorkers); + const workerOccupations = candidate.workerOccupations === undefined ? undefined : freezePresentationValue(candidate.workerOccupations); // The shared forest is another additive presentation pair. Keep each // field's presence intact for the browser-safe layout policy to verify. A // v6-but-unseeded `{ forestTrees: [] }`, a one-sided table, or malformed @@ -631,6 +638,9 @@ export function validateCanonicalGenesisSnapshot( ...(woodNodeOccupations === undefined ? {} : { woodNodeOccupations }), ...(stoneSites === undefined ? {} : { stoneSites }), ...(stoneNodeOccupations === undefined ? {} : { stoneNodeOccupations }), + ...(workerSystem === undefined ? {} : { workerSystem }), + ...(workerWorkers === undefined ? {} : { workerWorkers }), + ...(workerOccupations === undefined ? {} : { workerOccupations }), ...(forestLayout === undefined ? {} : { forestLayout }), ...(forestTrees === undefined ? {} : { forestTrees }), ...(waterProjection.layout === undefined ? {} : { waterLayout: waterProjection.layout }), diff --git a/src/spacetime/playerModuleBindings.ts b/src/spacetime/playerModuleBindings.ts index 353b711b..ecce8cfc 100644 --- a/src/spacetime/playerModuleBindings.ts +++ b/src/spacetime/playerModuleBindings.ts @@ -32,12 +32,14 @@ import { import AcceptAlphaTermsV1Reducer from './module_bindings/accept_alpha_terms_v_1_reducer' import BootstrapPlayerV2Reducer from './module_bindings/bootstrap_player_v_2_reducer' +import DispatchWorkerV1Reducer from './module_bindings/dispatch_worker_v_1_reducer' import CollectFoodExpeditionV1Reducer from './module_bindings/collect_food_expedition_v_1_reducer' import CollectGoldExpeditionV1Reducer from './module_bindings/collect_gold_expedition_v_1_reducer' import CollectWoodExpeditionV1Reducer from './module_bindings/collect_wood_expedition_v_1_reducer' import CollectStoneExpeditionV1Reducer from './module_bindings/collect_stone_expedition_v_1_reducer' import CollectResourcesV1Reducer from './module_bindings/collect_resources_v_1_reducer' import CastleRow from './module_bindings/castle_table' +import CastleWorkerV1Row from './module_bindings/castle_worker_v_1_table' import * as GetAlphaBackendInfoProcedure from './module_bindings/get_alpha_backend_info_procedure' import * as GetMyAdmissionStatusV2Procedure from './module_bindings/get_my_admission_status_v_2_procedure' import * as GetMyFoodExpeditionStateV1Procedure from './module_bindings/get_my_food_expedition_state_v_1_procedure' @@ -45,10 +47,14 @@ import * as GetMyGoldExpeditionStateV1Procedure from './module_bindings/get_my_g import * as GetMyWoodExpeditionStateV1Procedure from './module_bindings/get_my_wood_expedition_state_v_1_procedure' import * as GetMyStoneExpeditionStateV1Procedure from './module_bindings/get_my_stone_expedition_state_v_1_procedure' import * as GetMyResourceStateV1Procedure from './module_bindings/get_my_resource_state_v_1_procedure' +import * as GetMyResourceStateV2Procedure from './module_bindings/get_my_resource_state_v_2_procedure' +import * as GetMyWorkerRosterV1Procedure from './module_bindings/get_my_worker_roster_v_1_procedure' import DispatchFoodExpeditionV1Reducer from './module_bindings/dispatch_food_expedition_v_1_reducer' import DispatchGoldExpeditionV1Reducer from './module_bindings/dispatch_gold_expedition_v_1_reducer' import DispatchWoodExpeditionV1Reducer from './module_bindings/dispatch_wood_expedition_v_1_reducer' import DispatchStoneExpeditionV1Reducer from './module_bindings/dispatch_stone_expedition_v_1_reducer' +import RecallAllWorkersV1Reducer from './module_bindings/recall_all_workers_v_1_reducer' +import RecallWorkerV1Reducer from './module_bindings/recall_worker_v_1_reducer' import FoodNodeOccupationV1Row from './module_bindings/food_node_occupation_v_1_table' import FoodSiteV1Row from './module_bindings/food_site_v_1_table' import GoldNodeOccupationV1Row from './module_bindings/gold_node_occupation_v_1_table' @@ -58,6 +64,7 @@ import RealmForestInstanceV1Row from './module_bindings/realm_forest_instance_v_ import RealmForestLayoutV1Row from './module_bindings/realm_forest_layout_v_1_table' import RealmProfileV1Row from './module_bindings/realm_profile_v_1_table' import RealmV1Row from './module_bindings/realm_v_1_table' +import RealmWorkerSystemV1Row from './module_bindings/realm_worker_system_v_1_table' import RealmEnvironmentV1Row from './module_bindings/realm_environment_v_1_table' import WorldTileMetaV1Row from './module_bindings/world_tile_meta_v_1_table' import WorldTileRow from './module_bindings/world_tile_table' @@ -69,6 +76,7 @@ import WoodNodeOccupationV1Row from './module_bindings/wood_node_occupation_v_1_ import WoodSiteV1Row from './module_bindings/wood_site_v_1_table' import StoneNodeOccupationV1Row from './module_bindings/stone_node_occupation_v_1_table' import StoneSiteV1Row from './module_bindings/stone_site_v_1_table' +import WorkerNodeOccupationV1Row from './module_bindings/worker_node_occupation_v_1_table' const tablesSchema = __schema({ castle: __table({ @@ -90,6 +98,18 @@ const tablesSchema = __schema({ { name: 'castle_tile_key_key', constraint: 'unique', columns: ['tileKey'] }, ], }, CastleRow), + // Generic workers expose only stable identity, timing, and public node + // occupation. Private assignments and cargo remain procedure-only. + castleWorkerV1: __table({ + name: 'castle_worker_v1', + indexes: [ + { accessor: 'byOriginCastle', name: 'castle_worker_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: ['originCastleId'] }, + { accessor: 'workerId', name: 'castle_worker_v1_worker_id_idx_btree', algorithm: 'btree', columns: ['workerId'] }, + ], + constraints: [ + { name: 'castle_worker_v1_worker_id_key', constraint: 'unique', columns: ['workerId'] }, + ], + }, CastleWorkerV1Row), // The Food catalog mirrors the Gold boundary: player-visible site geometry // and occupation timing only. Its internal scheduler stays out of Vite. foodNodeOccupationV1: __table({ @@ -206,6 +226,15 @@ const tablesSchema = __schema({ { name: 'realm_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, ], }, RealmV1Row), + realmWorkerSystemV1: __table({ + name: 'realm_worker_system_v1', + indexes: [ + { accessor: 'realmId', name: 'realm_worker_system_v1_realm_id_idx_btree', algorithm: 'btree', columns: ['realmId'] }, + ], + constraints: [ + { name: 'realm_worker_system_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, + ], + }, RealmWorkerSystemV1Row), realmEnvironmentV1: __table({ name: 'realm_environment_v1', indexes: [ @@ -309,6 +338,17 @@ const tablesSchema = __schema({ { name: 'stone_site_v1_site_id_key', constraint: 'unique', columns: ['siteId'] }, ], }, StoneSiteV1Row), + workerNodeOccupationV1: __table({ + name: 'worker_node_occupation_v1', + indexes: [ + { accessor: 'nodeKey', name: 'worker_node_occupation_v1_node_key_idx_btree', algorithm: 'btree', columns: ['nodeKey'] }, + { accessor: 'byOriginCastle', name: 'worker_node_occupation_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: ['originCastleId'] }, + { accessor: 'byWorker', name: 'worker_node_occupation_v1_worker_id_idx_btree', algorithm: 'btree', columns: ['workerId'] }, + ], + constraints: [ + { name: 'worker_node_occupation_v1_node_key_key', constraint: 'unique', columns: ['nodeKey'] }, + ], + }, WorkerNodeOccupationV1Row), worldTile: __table({ name: 'world_tile', indexes: [ @@ -352,6 +392,9 @@ const reducersSchema = __reducers( __reducerSchema('dispatch_gold_expedition_v1', DispatchGoldExpeditionV1Reducer), __reducerSchema('dispatch_wood_expedition_v1', DispatchWoodExpeditionV1Reducer), __reducerSchema('dispatch_stone_expedition_v1', DispatchStoneExpeditionV1Reducer), + __reducerSchema('dispatch_worker_v1', DispatchWorkerV1Reducer), + __reducerSchema('recall_worker_v1', RecallWorkerV1Reducer), + __reducerSchema('recall_all_workers_v1', RecallAllWorkersV1Reducer), ) const proceduresSchema = __procedures( @@ -390,6 +433,16 @@ const proceduresSchema = __procedures( GetMyResourceStateV1Procedure.params, GetMyResourceStateV1Procedure.returnType, ), + __procedureSchema( + 'get_my_resource_state_v2', + GetMyResourceStateV2Procedure.params, + GetMyResourceStateV2Procedure.returnType, + ), + __procedureSchema( + 'get_my_worker_roster_v1', + GetMyWorkerRosterV1Procedure.params, + GetMyWorkerRosterV1Procedure.returnType, + ), ) const PLAYER_REMOTE_MODULE = { diff --git a/src/spacetime/warpkeepBackendTypes.ts b/src/spacetime/warpkeepBackendTypes.ts index 71f3bb1d..1df84693 100644 --- a/src/spacetime/warpkeepBackendTypes.ts +++ b/src/spacetime/warpkeepBackendTypes.ts @@ -4,6 +4,14 @@ import type { ReadyGoldExpeditionPresentation } from '../components/realm/realmG import type { ReadyFoodExpeditionPresentation } from '../components/realm/realmFoodExpeditionPresentation'; import type { ReadyWoodExpeditionPresentation } from '../components/realm/realmWoodExpeditionPresentation'; import type { ReadyStoneExpeditionPresentation } from '../components/realm/realmStoneExpeditionPresentation'; +import type { + RealmWorkerNodeOccupation, + RealmWorkerPublicPresentation, + RealmWorkerSystemPresentation, + WorkerRosterPresentation, + ReadyWorkerResourceState, + ReadyWorkerProjection +} from '../components/realm/realmWorkerPresentation'; export type WarpkeepAdmissionStatus = | 'not_admitted' @@ -179,6 +187,10 @@ export type WarpkeepStoneNodeOccupation = Readonly<{ returnsAtMicros: bigint; }>; +export type WarpkeepRealmWorkerSystem = RealmWorkerSystemPresentation; +export type WarpkeepCastleWorker = RealmWorkerPublicPresentation; +export type WarpkeepWorkerNodeOccupation = RealmWorkerNodeOccupation; + /** * Public, immutable realm-wide forest layout metadata. This is visual state * only: server-side seeding authority and all administrative reducers remain @@ -342,6 +354,10 @@ export type WarpkeepRealmSnapshotCandidate = Readonly<{ stoneSites?: readonly WarpkeepStoneSite[]; /** Omitted with `stoneSites`; absent/invalid data renders no Stone nodes. */ stoneNodeOccupations?: readonly WarpkeepStoneNodeOccupation[]; + /** Additive generic-worker public projection; absent keeps legacy mode. */ + workerSystem?: WarpkeepRealmWorkerSystem; + workerWorkers?: readonly WarpkeepCastleWorker[]; + workerOccupations?: readonly WarpkeepWorkerNodeOccupation[]; /** * Additive public forest metadata. The connection publishes the pair only * after one atomic subscription applies; a one-sided test/malformed value @@ -389,6 +405,11 @@ export type WarpkeepBackendState = Readonly<{ woodExpedition?: ReadyWoodExpeditionPresentation; /** Caller-only, exact procedure projection for the active Stone expedition. */ stoneExpedition?: ReadyStoneExpeditionPresentation; + /** Caller-private generic roster, never copied into the public snapshot. */ + workerRoster?: WorkerRosterPresentation; + /** v2 resource balances used by the active worker HUD. */ + workerResourceState?: ReadyWorkerResourceState; + workerProjection?: ReadyWorkerProjection; }>; export const IDLE_WARPKEEP_BACKEND_STATE: WarpkeepBackendState = Object.freeze({ diff --git a/src/spacetime/warpkeepConnection.ts b/src/spacetime/warpkeepConnection.ts index cfde15e2..9ab0e3d2 100644 --- a/src/spacetime/warpkeepConnection.ts +++ b/src/spacetime/warpkeepConnection.ts @@ -83,6 +83,15 @@ import { isRealmStoneNodeOccupationPublicRecord, isRealmStoneSitePublicRecord } from '../components/realm/realmStoneNodePresentation'; +import { + decodeRealmWorkerOccupations, + decodeRealmWorkerPublicRows, + decodeRealmWorkerSystem, + decodeWorkerResourceState, + decodeWorkerRoster, + type ReadyWorkerResourceState, + type WorkerRosterPresentation +} from '../components/realm/realmWorkerPresentation'; import { REALM_FOOD_SITE_COUNT, REALM_GOLD_SITE_COUNT, @@ -165,6 +174,11 @@ type WaterProjectionAvailability = | typeof WATER_PROJECTION_PENDING | typeof WATER_PROJECTION_READY; const waterProjectionAvailability = new WeakMap(); +const WORKER_PROJECTION_UNAVAILABLE = 'unavailable' as const; +const WORKER_PROJECTION_PENDING = 'pending' as const; +const WORKER_PROJECTION_READY = 'ready' as const; +type WorkerProjectionAvailability = typeof WORKER_PROJECTION_UNAVAILABLE | typeof WORKER_PROJECTION_PENDING | typeof WORKER_PROJECTION_READY; +const workerProjectionAvailability = new WeakMap(); const GOLD_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; const GOLD_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; const FOOD_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; @@ -173,6 +187,8 @@ const WOOD_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; const WOOD_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; const STONE_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; const STONE_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; +const WORKER_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; +const WORKER_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; export { WARPKEEP_ALPHA_TERMS_VERSION } from '../legal/alphaTermsPolicy'; const admissionStatuses = new Set([ @@ -362,6 +378,76 @@ export async function readWarpkeepResourceState( return decoded; } +/** Read the generic worker's caller-private roster; public rows never carry cargo. */ +export async function readWarpkeepWorkerRoster( + connection: WarpkeepConnection, + ownFid: number +): Promise { + if (!Number.isSafeInteger(ownFid) || ownFid <= 0) return undefined; + const procedure = (connection.procedures as unknown as { + getMyWorkerRosterV1?: (input: Readonly>) => Promise; + }).getMyWorkerRosterV1; + if (typeof procedure !== 'function') return undefined; + return decodeWorkerRoster(await procedure({}), BigInt(ownFid)); +} + +/** v2 balances are the only resource values consumed by the active worker rail. */ +export async function readWarpkeepResourceStateV2( + connection: WarpkeepConnection, + ownFid: number +): Promise { + if (!Number.isSafeInteger(ownFid) || ownFid <= 0) return undefined; + const procedure = (connection.procedures as unknown as { + getMyResourceStateV2?: (input: Readonly>) => Promise; + }).getMyResourceStateV2; + if (typeof procedure !== 'function') return undefined; + return decodeWorkerResourceState(await procedure({}), BigInt(ownFid)); +} + +function workerReducerSurface(connection: WarpkeepConnection) { + return connection.reducers as unknown as { + dispatchWorkerV1?: (input: Readonly<{ workerId: string; resourceKind: string; siteId: string; idempotencyKey: string }>) => Promise | unknown; + recallWorkerV1?: (input: Readonly<{ workerId: string; idempotencyKey: string }>) => Promise | unknown; + recallAllWorkersV1?: (input: Readonly<{ idempotencyKey: string }>) => Promise | unknown; + }; +} + +function assertWorkerIdempotency(workerId: string, idempotencyKey: string) { + if (!WORKER_ID_PATTERN.test(workerId) || !WORKER_IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) { + throw new Error('Worker command is unavailable.'); + } +} + +export async function dispatchWarpkeepWorker( + connection: WarpkeepConnection, + workerId: string, + resourceKind: string, + siteId: string, + idempotencyKey: string +) { + assertWorkerIdempotency(workerId, idempotencyKey); + if (!/^(food|wood|stone|gold)$/.test(resourceKind) || !/^[a-z0-9][a-z0-9:_-]{0,95}$/i.test(siteId)) { + throw new Error('Worker command is unavailable.'); + } + const reducer = workerReducerSurface(connection).dispatchWorkerV1; + if (typeof reducer !== 'function') throw new Error('Worker command is unavailable.'); + await reducer({ workerId, resourceKind, siteId, idempotencyKey }); +} + +export async function recallWarpkeepWorker(connection: WarpkeepConnection, workerId: string, idempotencyKey: string) { + assertWorkerIdempotency(workerId, idempotencyKey); + const reducer = workerReducerSurface(connection).recallWorkerV1; + if (typeof reducer !== 'function') throw new Error('Worker command is unavailable.'); + await reducer({ workerId, idempotencyKey }); +} + +export async function recallAllWarpkeepWorkers(connection: WarpkeepConnection, idempotencyKey: string) { + if (!WORKER_IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) throw new Error('Worker command is unavailable.'); + const reducer = workerReducerSurface(connection).recallAllWorkersV1; + if (typeof reducer !== 'function') throw new Error('Worker command is unavailable.'); + await reducer({ idempotencyKey }); +} + /** Settle server-authoritative yield, then fetch the exact committed view. */ export async function collectWarpkeepResources( connection: WarpkeepConnection, @@ -788,6 +874,33 @@ export function subscribeToWarpkeepRealm( stoneProjectionAvailability.set(connection, STONE_PROJECTION_UNAVAILABLE); } + let workerSubscription: SubscriptionHandle | undefined; + const workerTables = publicWorkerTables(connection); + if (workerTables !== undefined) { + try { + workerSubscription = connection + .subscriptionBuilder() + .onApplied(() => { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_READY); + if (coreApplied) onApplied(); + }) + .onError(() => { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_UNAVAILABLE); + if (coreApplied) onApplied(); + }) + .subscribe([ + tables.realmWorkerSystemV1, + tables.castleWorkerV1, + tables.workerNodeOccupationV1 + ]); + } catch { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_UNAVAILABLE); + if (coreApplied) onApplied(); + } + } else { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_UNAVAILABLE); + } + let forestSubscription: SubscriptionHandle | undefined; const forestTables = publicForestTables(connection); // A pre-forest service (or a deliberately narrow test double) cannot make @@ -858,6 +971,7 @@ export function subscribeToWarpkeepRealm( foodProjectionAvailability.delete(connection); woodProjectionAvailability.delete(connection); stoneProjectionAvailability.delete(connection); + workerProjectionAvailability.delete(connection); forestProjectionAvailability.delete(connection); waterProjectionAvailability.delete(connection); try { @@ -871,7 +985,11 @@ export function subscribeToWarpkeepRealm( try { woodSubscription?.unsubscribe(); } finally { - stoneSubscription?.unsubscribe(); + try { + stoneSubscription?.unsubscribe(); + } finally { + workerSubscription?.unsubscribe(); + } } } } @@ -1385,6 +1503,35 @@ function publicStoneSubscriptionTables() { }); } +type PublicWorkerTable = PublicFoodTable; +function publicWorkerTables(connection: WarpkeepConnection) { + const db = connection.db as unknown as Readonly<{ + realmWorkerSystemV1?: PublicWorkerTable; + castleWorkerV1?: PublicWorkerTable; + workerNodeOccupationV1?: PublicWorkerTable; + }> | undefined; + if (!db?.realmWorkerSystemV1 || !db.castleWorkerV1 || !db.workerNodeOccupationV1) return undefined; + return db; +} + +function readPublicWorkerProjection( + connection: WarpkeepConnection, + castles: readonly WarpkeepCastle[], + ownCastleId: number +) { + if (workerProjectionAvailability.get(connection) !== WORKER_PROJECTION_READY) return undefined; + const db = publicWorkerTables(connection); + if (!db) return undefined; + const systems = [...db.realmWorkerSystemV1!.iter()]; + if (systems.length !== 1) return undefined; + const system = decodeRealmWorkerSystem(systems[0]); + const castleNames = new Map(castles.map((castle) => [castle.castleId, castle.name] as const)); + const workers = decodeRealmWorkerPublicRows([...db.castleWorkerV1!.iter()], castleNames, ownCastleId); + const occupations = decodeRealmWorkerOccupations([...db.workerNodeOccupationV1!.iter()]); + if (!system || !workers || !occupations) return undefined; + return Object.freeze({ system, workers, occupations }); +} + /** * Read the additive public Gold catalog as an all-or-nothing visual * projection. Any malformed or duplicate row omits the complete Gold layer; @@ -1770,6 +1917,9 @@ export function readWarpkeepRealmSnapshot( const publicStone = readPublicStoneProjection(connection); const publicForest = readPublicForestProjection(connection); const publicWater = readPublicWaterProjection(connection); + const publicWorkers = ownCastle === undefined + ? undefined + : readPublicWorkerProjection(connection, castles, ownCastle.castleId); const candidate: WarpkeepRealmSnapshotCandidate = { tiles: readWorldTiles(connection), tileMetadata: readWorldTileMetadata(connection), @@ -1806,6 +1956,11 @@ export function readWarpkeepRealmSnapshot( waterRevision: publicWater.waterRevision }) }), + ...(publicWorkers === undefined ? {} : { + workerSystem: publicWorkers.system, + workerWorkers: publicWorkers.workers, + workerOccupations: publicWorkers.occupations + }), ...(ownCastle ? { ownCastle } : {}) }; return validateCanonicalGenesisSnapshot(candidate, { @@ -1891,6 +2046,16 @@ export function observeWarpkeepRealm( stoneTables?.stoneNodeOccupationV1?.onInsert?.(sync); stoneTables?.stoneNodeOccupationV1?.onDelete?.(sync); stoneTables?.stoneNodeOccupationV1?.onUpdate?.(sync); + const workerTables = publicWorkerTables(connection); + workerTables?.realmWorkerSystemV1?.onInsert?.(sync); + workerTables?.realmWorkerSystemV1?.onDelete?.(sync); + workerTables?.realmWorkerSystemV1?.onUpdate?.(sync); + workerTables?.castleWorkerV1?.onInsert?.(sync); + workerTables?.castleWorkerV1?.onDelete?.(sync); + workerTables?.castleWorkerV1?.onUpdate?.(sync); + workerTables?.workerNodeOccupationV1?.onInsert?.(sync); + workerTables?.workerNodeOccupationV1?.onDelete?.(sync); + workerTables?.workerNodeOccupationV1?.onUpdate?.(sync); const forestTables = publicForestTables(connection); forestTables?.realmForestLayoutV1?.onInsert?.(sync); forestTables?.realmForestLayoutV1?.onDelete?.(sync); @@ -1959,6 +2124,15 @@ export function observeWarpkeepRealm( stoneTables?.stoneNodeOccupationV1?.removeOnInsert?.(sync); stoneTables?.stoneNodeOccupationV1?.removeOnDelete?.(sync); stoneTables?.stoneNodeOccupationV1?.removeOnUpdate?.(sync); + workerTables?.realmWorkerSystemV1?.removeOnInsert?.(sync); + workerTables?.realmWorkerSystemV1?.removeOnDelete?.(sync); + workerTables?.realmWorkerSystemV1?.removeOnUpdate?.(sync); + workerTables?.castleWorkerV1?.removeOnInsert?.(sync); + workerTables?.castleWorkerV1?.removeOnDelete?.(sync); + workerTables?.castleWorkerV1?.removeOnUpdate?.(sync); + workerTables?.workerNodeOccupationV1?.removeOnInsert?.(sync); + workerTables?.workerNodeOccupationV1?.removeOnDelete?.(sync); + workerTables?.workerNodeOccupationV1?.removeOnUpdate?.(sync); forestTables?.realmForestLayoutV1?.removeOnInsert?.(sync); forestTables?.realmForestLayoutV1?.removeOnDelete?.(sync); forestTables?.realmForestLayoutV1?.removeOnUpdate?.(sync); diff --git a/tests/playerModuleBindings.test.ts b/tests/playerModuleBindings.test.ts index 3c041514..f037ac17 100644 --- a/tests/playerModuleBindings.test.ts +++ b/tests/playerModuleBindings.test.ts @@ -8,6 +8,7 @@ import { DbConnection, tables as playerTables } from '../src/spacetime/playerMod const PLAYER_TABLE_KEYS = [ 'castle', + 'castleWorkerV1', 'foodNodeOccupationV1', 'foodSiteV1', 'goldNodeOccupationV1', @@ -18,6 +19,7 @@ const PLAYER_TABLE_KEYS = [ 'realmForestLayoutV1', 'realmProfileV1', 'realmV1', + 'realmWorkerSystemV1', 'realmWaterBodyV1', 'realmWaterCellV1', 'realmWaterLayoutV1', @@ -26,6 +28,7 @@ const PLAYER_TABLE_KEYS = [ 'stoneSiteV1', 'woodNodeOccupationV1', 'woodSiteV1', + 'workerNodeOccupationV1', 'worldTile', 'worldTileMetaV1', ] as const @@ -80,6 +83,11 @@ describe('player SpacetimeDB bindings', () => { expect(playerBindings).toContain("'get_my_wood_expedition_state_v1'") expect(playerBindings).toContain("'get_my_stone_expedition_state_v1'") expect(playerBindings).toContain("'get_my_resource_state_v1'") + expect(playerBindings).toContain("'get_my_resource_state_v2'") + expect(playerBindings).toContain("'get_my_worker_roster_v1'") + expect(playerBindings).toContain("'dispatch_worker_v1'") + expect(playerBindings).toContain("'recall_worker_v1'") + expect(playerBindings).toContain("'recall_all_workers_v1'") expect(playerBindings).toContain("'realm_forest_layout_v1'") expect(playerBindings).toContain("'realm_forest_instance_v1'") expect(playerBindings).toContain("'realm_water_revision_v1'") @@ -103,6 +111,8 @@ describe('player SpacetimeDB bindings', () => { expect(playerBindings).not.toContain('qa_observer_') expect(playerBindings).not.toContain('QA_OBSERVER') expect(playerBindings).not.toContain('/v1/qa/') + expect(playerBindings).not.toContain('worker_assignment_schedule_v_1') + expect(playerBindings).not.toContain('worker_assignment_idempotency_v_1') }) it('exposes only the player reducer and procedure accessors at runtime', () => { @@ -134,6 +144,9 @@ describe('player SpacetimeDB bindings', () => { 'dispatchGoldExpeditionV1', 'dispatchStoneExpeditionV1', 'dispatchWoodExpeditionV1', + 'dispatchWorkerV1', + 'recallAllWorkersV1', + 'recallWorkerV1', ]) expect(Object.keys(connection.procedures).sort()).toEqual([ 'getAlphaBackendInfo', @@ -141,8 +154,10 @@ describe('player SpacetimeDB bindings', () => { 'getMyFoodExpeditionStateV1', 'getMyGoldExpeditionStateV1', 'getMyResourceStateV1', + 'getMyResourceStateV2', 'getMyStoneExpeditionStateV1', 'getMyWoodExpeditionStateV1', + 'getMyWorkerRosterV1', ]) connection.disconnect() diff --git a/tests/realmPickArbitration.test.ts b/tests/realmPickArbitration.test.ts index 9ea8aa1c..76332dec 100644 --- a/tests/realmPickArbitration.test.ts +++ b/tests/realmPickArbitration.test.ts @@ -13,6 +13,15 @@ const RESOURCE_KINDS = [ ] as const satisfies readonly RealmResourcePickKind[]; describe('realm scene pick arbitration', () => { + it('gives a worker identity lane priority over resource and castle colliders', () => { + expect(arbitrateRealmPick({ + workerHits: [{ workerId: 'worker-1', workerOrdinal: 1, originCastleId: 77, coord: { q: 1, r: 0 }, distance: 20 }], + resourceHits: [], + castleHit: { castleId: 77, coord: { q: 0, r: 0 } }, + terrainHit: { coord: { q: 2, r: 0 } } + })).toEqual({ kind: 'worker', workerId: 'worker-1', workerOrdinal: 1, originCastleId: 77, coord: { q: 1, r: 0 } }); + }); + it.each(RESOURCE_KINDS)( 'lets the nearest moving %s wagon win over castle, static site, and terrain', (kind) => { diff --git a/tests/realmWorkerPresentation.test.ts b/tests/realmWorkerPresentation.test.ts new file mode 100644 index 00000000..83c5f7be --- /dev/null +++ b/tests/realmWorkerPresentation.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { + decodeRealmWorkerOccupations, + decodeRealmWorkerPublicRows, + decodeRealmWorkerSystem, + decodeWorkerResourceState, + decodeWorkerRoster, + workerAvailabilityCount +} from '../src/components/realm/realmWorkerPresentation'; + +const publicWorker = (ordinal: number, originCastleId = 7) => ({ + workerId: `castle-7-worker-${ordinal}`, + originCastleId, + ordinal, + status: ordinal === 1 ? 'idle' : 'gathering', + assignmentId: ordinal === 1 ? undefined : `assignment-${ordinal}`, + resourceKind: ordinal === 1 ? undefined : 'stone', + siteId: ordinal === 1 ? undefined : `stone:${ordinal}`, + startedAtMicros: undefined, + arrivesAtMicros: undefined, + gatheringEndsAtMicros: undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: undefined, + routeSteps: undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 1, + revision: 1n +}); + +describe('generic worker presentation boundary', () => { + it('requires the exact four-worker active system contract', () => { + expect(decodeRealmWorkerSystem({ + realmId: 'genesis-001', policyVersion: 'worker-v1', workersPerCastle: 4, + expectedCastleCount: 1, expectedWorkerCount: 4, rosterDigest: 'digest', mode: 'active', + legacyDrainRequired: false + })?.mode).toBe('active'); + expect(decodeRealmWorkerSystem({ workersPerCastle: 3 })).toBeUndefined(); + }); + + it('decodes public workers without private assignment or cargo fields', () => { + const workers = decodeRealmWorkerPublicRows( + [1, 2, 3, 4].map((ordinal) => publicWorker(ordinal)), + new Map([[7, 'Keep']]), + 7 + ); + expect(workers).toHaveLength(4); + expect(workers?.[0]).toMatchObject({ workerId: 'castle-7-worker-1', ownedByViewer: true }); + expect(Object.keys(workers?.[0] ?? {})).not.toContain('assignmentId'); + expect(workerAvailabilityCount(workers ?? [])).toBe(1); + expect(decodeRealmWorkerPublicRows([publicWorker(1), publicWorker(1)], new Map([[7, 'Keep']]), 7)).toBeUndefined(); + }); + + it('keeps occupation and caller roster projections bounded', () => { + const occupation = decodeRealmWorkerOccupations([{ + nodeKey: 'stone:1', resourceKind: 'stone', siteId: 'stone:1', workerId: 'worker-1', workerOrdinal: 1, + originCastleId: 7, assignmentId: 'private-id', phase: 'gathering', startedAtMicros: 1n, + arrivesAtMicros: 2n, gatheringEndsAtMicros: 3n, timelineRevision: 1 + }]); + expect(occupation?.[0].resourceKind).toBe('stone'); + const roster = decodeWorkerRoster({ + fid: 42n, castleId: 7n, observedAtMicros: 9n, + workers: [1, 2, 3, 4].map((ordinal) => ({ + workerId: `worker-${ordinal}`, ordinal, status: 'idle', resourceKind: undefined, siteId: undefined, + accruedAmount: 0n, materializedAmount: 0n, availableAmount: BigInt(ordinal), observedAtMicros: 9n, revision: 1n + })) + }, 42n); + expect(roster?.workers[3].availableAmount).toBe(4n); + expect(decodeWorkerResourceState({ + fid: 42n, food: 1n, wood: 2n, stone: 3n, gold: 4n, + workerPendingFood: 0n, workerPendingWood: 0n, workerPendingStone: 0n, workerPendingGold: 0n, + observedAtMicros: 5n, settledThroughMicros: 5n, revision: 1n, + resourcePolicyVersion: 'resource-v2', workerPolicyVersion: 'worker-v1', workerSystemMode: 'active' + }, 42n)?.available.stone).toBe(3n); + }); +}); From c09f434831e5477f181f42d36592dfcbddb8930d Mon Sep 17 00:00:00 2001 From: ael-dev3 Date: Wed, 22 Jul 2026 13:14:11 +0200 Subject: [PATCH 12/19] Harden worker command center integration --- src/components/WarpkeepExperience.tsx | 3 + src/components/realm/RealmHud.tsx | 88 +++-- src/components/realm/RealmMapScreen.tsx | 52 ++- src/components/realm/RealmPlayerChrome.css | 1 - src/components/realm/WorkerCommandCenter.css | 1 + src/components/realm/WorkerCommandCenter.tsx | 148 ++++++-- .../realm/WorkerInspectionPanel.css | 1 + .../realm/WorkerInspectionPanel.tsx | 180 +++++++-- src/components/realm/realmPickArbitration.ts | 11 +- .../realm/realmWorkerPresentation.ts | 345 ++++++++++++++--- src/spacetime/WarpkeepSpacetimeProvider.tsx | 160 +++++--- src/spacetime/warpkeepConnection.ts | 76 +++- src/spacetime/workerCommandIdempotency.ts | 47 +++ tests/WarpkeepSpacetimeResources.test.tsx | 159 ++++++++ tests/realmHud.test.tsx | 225 +++++++++++ tests/realmPickArbitration.test.ts | 6 +- tests/realmWorkerPresentation.test.ts | 356 +++++++++++++++--- tests/warpkeepConnection.test.ts | 141 +++++++ tests/workerCommandIdempotency.test.ts | 44 +++ 19 files changed, 1802 insertions(+), 242 deletions(-) create mode 100644 src/spacetime/workerCommandIdempotency.ts create mode 100644 tests/workerCommandIdempotency.test.ts diff --git a/src/components/WarpkeepExperience.tsx b/src/components/WarpkeepExperience.tsx index dfec3eee..7d61fb4a 100644 --- a/src/components/WarpkeepExperience.tsx +++ b/src/components/WarpkeepExperience.tsx @@ -1131,6 +1131,9 @@ export function WarpkeepExperience() { workerProjection={backend.state.workerProjection} workerRoster={backend.state.workerRoster} workerResourceState={backend.state.workerResourceState} + onDispatchWorker={backend.state.workerProjection?.mode === 'active' + ? backend.dispatchWorker + : undefined} onRecallWorker={backend.state.workerProjection?.mode === 'active' ? backend.recallWorker : undefined} diff --git a/src/components/realm/RealmHud.tsx b/src/components/realm/RealmHud.tsx index 3c6daa95..03c60a78 100644 --- a/src/components/realm/RealmHud.tsx +++ b/src/components/realm/RealmHud.tsx @@ -38,10 +38,10 @@ import './RealmPlayerChrome.css'; import { WorkerCommandCenter } from './WorkerCommandCenter'; import { WorkerInspectionPanel } from './WorkerInspectionPanel'; import type { + RealmWorkerDestinationPresentation, ReadyWorkerProjection, ReadyWorkerResourceState, - WorkerRosterPresentation, - RealmWorkerPublicPresentation + WorkerRosterPresentation } from './realmWorkerPresentation'; type RealmHudProps = Readonly<{ @@ -68,6 +68,11 @@ type RealmHudProps = Readonly<{ workerProjection?: ReadyWorkerProjection; workerRoster?: WorkerRosterPresentation; workerResourceState?: ReadyWorkerResourceState; + workerDestinations?: readonly RealmWorkerDestinationPresentation[]; + onDispatchWorker?: ( + workerId: string, + destination: RealmWorkerDestinationPresentation + ) => Promise; onRecallWorker?: (workerId: string) => Promise; onRecallAllWorkers?: () => Promise; onRecenterKeep: () => void; @@ -128,7 +133,7 @@ const RESOURCE_ICON_PATHS: Readonly< }) }); -type RealmMenuSurface = 'closed' | 'menu' | 'settings'; +type RealmMenuSurface = 'closed' | 'menu' | 'settings' | 'workers' | 'worker-inspection'; function publicAssetUrl(path: string) { const base = import.meta.env.BASE_URL || '/'; @@ -323,6 +328,8 @@ type RealmCommandDialogProps = Readonly<{ pendingYield: boolean; canCollect: boolean; activeWagons: readonly RealmActiveWagonMenuItem[]; + workerAvailability?: number; + onWorkers?: () => void; onClose: () => void; onCollect: () => void; onExplore: () => void; @@ -340,6 +347,8 @@ function RealmCommandDialog({ pendingYield, canCollect, activeWagons, + workerAvailability, + onWorkers, onClose, onCollect, onExplore, @@ -382,6 +391,12 @@ function RealmCommandDialog({ EXPLORE {castleCount} founded {castleCount === 1 ? 'castle' : 'castles'} + {onWorkers && workerAvailability !== undefined ? ( + + ) : null} {onOpenActiveWagon ? (
    ('closed'); - const [workerSurface, setWorkerSurface] = useState<'closed' | 'center' | 'inspection'>('closed'); - const [selectedWorker, setSelectedWorker] = useState(undefined); + const [selectedWorkerId, setSelectedWorkerId] = useState(undefined); const [collecting, setCollecting] = useState(false); const triggerRef = useRef(null); const wasOpenRef = useRef(false); @@ -503,8 +519,20 @@ export function RealmHud({ const pendingYield = resources !== undefined && REALM_ECONOMIC_RESOURCE_ORDER.some( (resource) => resources.pendingBalances[resource] > 0n ); - const genericWorkersActive = workerProjection?.mode === 'active'; - const availableWorkers = workerProjection?.workers.filter((worker) => worker.status === 'idle').length ?? 0; + const ownedWorkersForUi = workerProjection?.ownedWorkers.filter((worker) => worker.ownedByViewer); + const privateWorkerIds = new Set(workerRoster?.workers.map((worker) => worker.workerId) ?? []); + const genericWorkersActive = workerProjection?.mode === 'active' + && ownedWorkersForUi?.length === 4 + && workerProjection.ownedWorkers.length === 4 + && workerRoster?.workers.length === 4 + && ownedWorkersForUi.every((worker) => privateWorkerIds.has(worker.workerId)) + && workerResourceState?.workerSystemMode === 'active'; + const availableWorkers = genericWorkersActive + ? ownedWorkersForUi.filter((worker) => worker.status === 'idle').length + : 0; + const selectedWorker = genericWorkersActive && selectedWorkerId + ? ownedWorkersForUi.find((worker) => worker.workerId === selectedWorkerId) + : undefined; if (selectionAnnouncementRef.current.key !== selectionAnnouncementKey) { selectionAnnouncementRef.current = { @@ -520,6 +548,19 @@ export function RealmHud({ wasOpenRef.current = surface !== 'closed'; }, [surface]); + useEffect(() => { + if (genericWorkersActive) { + if (surface === 'worker-inspection' && selectedWorker === undefined) { + setSurface('workers'); + } + return; + } + if (surface === 'workers' || surface === 'worker-inspection') { + setSelectedWorkerId(undefined); + setSurface('menu'); + } + }, [genericWorkersActive, selectedWorker, surface]); + const closeThen = (action: () => void) => { setSurface('closed'); action(); @@ -542,7 +583,7 @@ export function RealmHud({
    - {genericWorkersActive ? ( - - ) : null} - {resources ? : null}

    setSurface('closed')} onCollect={() => void collect()} onExplore={() => closeThen(() => onRequestExplore?.())} @@ -596,25 +626,31 @@ export function RealmHud({ onRecenter={() => closeThen(onRecenterKeep)} onRequestReturn={() => closeThen(onRequestReturn)} onSettings={() => setSurface('settings')} + onWorkers={genericWorkersActive ? () => setSurface('workers') : undefined} /> ) : null} - {genericWorkersActive && workerProjection && workerSurface === 'center' ? ( + {genericWorkersActive && workerRoster && surface === 'workers' ? ( setWorkerSurface('closed')} + onClose={() => setSurface('menu')} onRecallAllWorkers={onRecallAllWorkers} onRecallWorker={onRecallWorker} - onSelectWorker={(worker) => { setSelectedWorker(worker); setWorkerSurface('inspection'); }} + onSelectWorker={(worker) => { + setSelectedWorkerId(worker.workerId); + setSurface('worker-inspection'); + }} roster={workerRoster} - workers={workerProjection.workers} + workers={ownedWorkersForUi} /> ) : null} - {genericWorkersActive && selectedWorker && workerSurface === 'inspection' ? ( + {genericWorkersActive && selectedWorker && surface === 'worker-inspection' ? ( { setSelectedWorker(undefined); setWorkerSurface('center'); }} + onRequestClose={() => setSurface('workers')} worker={selectedWorker} /> ) : null} diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index 2aad4d19..7b68cfae 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -32,7 +32,10 @@ import type { } from '../../settings/graphicsPreference'; import type { CanonicalWarpkeepRealmSnapshot } from '../../spacetime/warpkeepBackendTypes'; import { isCanonicalGenesisSnapshot } from '../../spacetime/canonicalGenesisSnapshot'; -import type { ReadyRealmResourcePresentation } from './realmResourcePresentation'; +import type { + ReadyRealmResourcePresentation, + RealmEconomicResourceKey +} from './realmResourcePresentation'; import { CastleInspectionPanel } from './CastleInspectionPanel'; import { FoodFarmInspectionPanel } from './FoodFarmInspectionPanel'; import { GoldMineInspectionPanel } from './GoldMineInspectionPanel'; @@ -155,7 +158,12 @@ import { } from './realmRendererRecovery'; import './RealmMapScreen.css'; import './RealmCastlePresentation.css'; -import type { ReadyWorkerProjection, ReadyWorkerResourceState, WorkerRosterPresentation } from './realmWorkerPresentation'; +import type { + ReadyWorkerProjection, + ReadyWorkerResourceState, + RealmWorkerDestinationPresentation, + WorkerRosterPresentation +} from './realmWorkerPresentation'; export { BLOCKED_SHARED_FOREST_PROJECTION_SIGNATURE, @@ -197,6 +205,11 @@ type RealmMapScreenProps = Readonly<{ workerProjection?: ReadyWorkerProjection; workerRoster?: WorkerRosterPresentation; workerResourceState?: ReadyWorkerResourceState; + onDispatchWorker?: ( + workerId: string, + resourceKind: RealmEconomicResourceKey, + siteId: string + ) => Promise; onRecallWorker?: (workerId: string) => Promise; onRecallAllWorkers?: () => Promise; graphicsPreference?: GraphicsPreference; @@ -292,6 +305,7 @@ function CanonicalRealmMapScreen({ workerProjection, workerRoster, workerResourceState, + onDispatchWorker, onRecallWorker, onRecallAllWorkers, graphicsPreference, @@ -477,6 +491,32 @@ function CanonicalRealmMapScreen({ const stoneNodesBySiteId = useMemo(() => new Map( stoneNodes.map((node) => [node.siteId, node] as const) ), [stoneNodes]); + const workerDestinations = useMemo(() => { + if (workerProjection?.mode !== 'active') return Object.freeze([]); + const occupied = new Set(workerProjection.occupations.map((occupation) => occupation.nodeKey)); + const destinations: RealmWorkerDestinationPresentation[] = []; + const append = ( + resourceKind: RealmEconomicResourceKey, + resourceLabel: string, + nodes: readonly GatheringNodePresentation[] + ) => { + for (const node of nodes) { + if (node.availability !== 'available' || occupied.has(`${resourceKind}:${node.siteId}`)) { + continue; + } + destinations.push(Object.freeze({ + resourceKind, + siteId: node.siteId, + label: `${resourceLabel} · Tier ${node.tier} · cell ${node.coord.q}, ${node.coord.r}` + })); + } + }; + append('food', 'Wheat Farm', foodNodes); + append('wood', 'Logging Camp', woodNodes); + append('stone', 'Stone Quarry', stoneNodes); + append('gold', 'Gold Mine', goldNodes); + return Object.freeze(destinations); + }, [foodNodes, goldNodes, stoneNodes, woodNodes, workerProjection]); const activeWagons = useMemo(() => { if (observerMode) return Object.freeze([]); const items: RealmActiveWagonMenuItem[] = []; @@ -2035,6 +2075,14 @@ function CanonicalRealmMapScreen({ workerProjection={observerMode ? undefined : workerProjection} workerRoster={observerMode ? undefined : workerRoster} workerResourceState={observerMode ? undefined : workerResourceState} + workerDestinations={observerMode ? undefined : workerDestinations} + onDispatchWorker={observerMode || !onDispatchWorker + ? undefined + : (workerId, destination) => onDispatchWorker( + workerId, + destination.resourceKind, + destination.siteId + )} onRecallWorker={observerMode ? undefined : onRecallWorker} onRecallAllWorkers={observerMode ? undefined : onRecallAllWorkers} keepCoord={keepCoord} diff --git a/src/components/realm/RealmPlayerChrome.css b/src/components/realm/RealmPlayerChrome.css index 25c39458..404e9544 100644 --- a/src/components/realm/RealmPlayerChrome.css +++ b/src/components/realm/RealmPlayerChrome.css @@ -436,4 +436,3 @@ transition: none; } } -.realm-worker-control{position:fixed;top:clamp(.75rem,2.2vw,1.5rem);right:clamp(4.8rem,9vw,7rem);z-index:8;display:flex;align-items:center;gap:.3rem;min-width:3.15rem;min-height:2.8rem;padding:.35rem .55rem;border:1px solid rgb(240 207 130 / 58%);border-radius:999px;background:linear-gradient(180deg,rgb(49 34 63 / 94%),rgb(16 16 24 / 96%));color:#f7dda0;box-shadow:0 .35rem 1rem rgb(0 0 0 / 28%);font-size:.7rem;cursor:pointer}.realm-worker-control span{display:grid;width:1.35rem;height:1.35rem;place-items:center;border:1px solid rgb(205 158 240 / 56%);border-radius:50%;color:#d7b7ee;font-size:.63rem;font-weight:900}.realm-worker-control strong{font-size:.68rem;letter-spacing:.04em}.realm-worker-control:focus-visible{outline:2px solid #f5d994;outline-offset:2px} diff --git a/src/components/realm/WorkerCommandCenter.css b/src/components/realm/WorkerCommandCenter.css index 31679061..a54cd994 100644 --- a/src/components/realm/WorkerCommandCenter.css +++ b/src/components/realm/WorkerCommandCenter.css @@ -1 +1,2 @@ .worker-command-center__scrim{position:fixed;z-index:13;inset:0;display:grid;place-items:start end;padding:clamp(4.5rem,12vh,7rem) clamp(.75rem,3vw,2rem);background:rgb(2 3 8 / 28%);overscroll-behavior:contain;touch-action:pan-y}.worker-command-center{width:min(29rem,100%);max-height:min(75vh,42rem);overflow:auto;border:1px solid rgb(240 207 130 / 42%);border-radius:.75rem;background:linear-gradient(180deg,#15111d,#08090f);box-shadow:0 1.25rem 3rem rgb(0 0 0 / 48%);color:#f9f0df;padding:.85rem;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}.worker-command-center__header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.worker-command-center__header p{margin:0;color:#caa6ec;font-size:.62rem;font-weight:800;letter-spacing:.18em}.worker-command-center__header h2{margin:.16rem 0;color:#f6d98e;font:500 1.65rem/1 Georgia,serif;letter-spacing:.08em}.worker-command-center__header span{color:#bbb1c3;font-size:.75rem}.worker-command-center__header button{display:grid;width:2.6rem;height:2.6rem;place-items:center;border:1px solid rgb(240 207 130 / 40%);border-radius:50%;background:#090a10;color:#fff;font-size:1.55rem;cursor:pointer}.worker-command-center__header button:focus-visible,.worker-command-center__worker:focus-visible,.worker-command-center__recall:focus-visible,.worker-command-center__footer button:focus-visible{outline:2px solid #f6d98e;outline-offset:2px}.worker-command-center__summary{margin:.75rem 0;padding:.65rem .75rem;border-left:2px solid #a477c1;color:#d4c9d8;font-size:.76rem;line-height:1.45}.worker-command-center__roster{display:grid;gap:.5rem;margin:.8rem 0;padding:0;list-style:none}.worker-command-center__roster li{display:flex;gap:.4rem;align-items:stretch}.worker-command-center__worker{display:flex;flex:1;align-items:center;gap:.65rem;min-width:0;padding:.55rem .6rem;border:1px solid rgb(242 213 143 / 22%);border-radius:.45rem;background:#11121a;color:#fff;cursor:pointer;text-align:left}.worker-command-center__ordinal{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:50%;background:#4a2f5d;color:#f7dda0;font-weight:800}.worker-command-center__identity{display:grid;min-width:0;gap:.18rem}.worker-command-center__identity strong{font-size:.8rem}.worker-command-center__identity small{overflow:hidden;color:#bdb4c5;font-size:.65rem;text-overflow:ellipsis;white-space:nowrap}.worker-command-center__amount{margin-left:auto;color:#f2d48a;font-size:.68rem}.worker-command-center__recall{min-width:4.2rem;padding:.3rem;border:1px solid rgb(240 207 130 / 38%);border-radius:.4rem;background:#2b1c2f;color:#f9df9c;font-size:.62rem;font-weight:800;cursor:pointer}.worker-command-center__footer{display:flex;justify-content:flex-end;border-top:1px solid rgb(240 207 130 / 16%);padding-top:.75rem}.worker-command-center__footer button{min-height:2.6rem;padding:.5rem .8rem;border:1px solid rgb(240 207 130 / 55%);border-radius:.4rem;background:linear-gradient(180deg,#c08c42,#704029);color:#171016;font-size:.67rem;font-weight:850;letter-spacing:.08em;cursor:pointer}.worker-command-center__footer button:disabled{opacity:.5;cursor:default}@media (max-width:40rem){.worker-command-center__scrim{place-items:stretch;padding:4.1rem .5rem .5rem}.worker-command-center{width:100%;max-height:calc(100dvh - 4.6rem)}} +.worker-command-center__error{margin:.7rem 0 0;padding:.55rem .65rem;border:1px solid rgb(223 133 120 / 48%);border-radius:.4rem;background:rgb(97 35 38 / 26%);color:#ffd8cf;font-size:.72rem;line-height:1.4}.worker-command-center button:disabled{cursor:default;opacity:.52} diff --git a/src/components/realm/WorkerCommandCenter.tsx b/src/components/realm/WorkerCommandCenter.tsx index df6ab864..2b1a5c84 100644 --- a/src/components/realm/WorkerCommandCenter.tsx +++ b/src/components/realm/WorkerCommandCenter.tsx @@ -1,4 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; + +import { useModalFocusBoundary } from '../menu/useModalFocusBoundary'; import { realmWorkerCanRecall, realmWorkerLabel, @@ -12,47 +14,143 @@ import './WorkerCommandCenter.css'; export type WorkerCommandCenterProps = Readonly<{ id: string; workers: readonly RealmWorkerPublicPresentation[]; - roster?: WorkerRosterPresentation; + roster: WorkerRosterPresentation; onRecallWorker?: (workerId: string) => Promise; onRecallAllWorkers?: () => Promise; onSelectWorker: (worker: RealmWorkerPublicPresentation) => void; onClose: () => void; }>; -export function WorkerCommandCenter({ id, workers, roster, onRecallWorker, onRecallAllWorkers, onSelectWorker, onClose }: WorkerCommandCenterProps) { +type PendingCommand = 'all' | string | undefined; + +export function WorkerCommandCenter({ + id, + workers, + roster, + onRecallWorker, + onRecallAllWorkers, + onSelectWorker, + onClose +}: WorkerCommandCenterProps) { + const dialogRef = useRef(null); const headingRef = useRef(null); - const [recallingAll, setRecallingAll] = useState(false); + const [pendingCommand, setPendingCommand] = useState(undefined); + const [commandFailed, setCommandFailed] = useState(false); const available = workerAvailabilityCount(workers); - useEffect(() => { headingRef.current?.focus({ preventScroll: true }); }, [id]); + const hasRecallableWorker = workers.some(realmWorkerCanRecall); + useModalFocusBoundary({ + dialogRef, + initialFocusRef: headingRef, + onEscape: () => { + if (pendingCommand === undefined) onClose(); + } + }); + + const recall = async (workerId: string) => { + if (!onRecallWorker || pendingCommand !== undefined) return; + setPendingCommand(workerId); + setCommandFailed(false); + try { + await onRecallWorker(workerId); + } catch { + setCommandFailed(true); + } finally { + setPendingCommand(undefined); + } + }; + const recallAll = async () => { - if (!onRecallAllWorkers || recallingAll) return; - setRecallingAll(true); - try { await onRecallAllWorkers(); } finally { setRecallingAll(false); } + if (!onRecallAllWorkers || pendingCommand !== undefined) return; + setPendingCommand('all'); + setCommandFailed(false); + try { + await onRecallAllWorkers(); + } catch { + setCommandFailed(true); + } finally { + setPendingCommand(undefined); + } }; + return ( -

    { if (event.target === event.currentTarget) onClose(); }}> -
    +
    +
  • + ))} +
+ + ) : null} + {coordinateJump ? (
diff --git a/src/components/realm/RealmMapScreen.css b/src/components/realm/RealmMapScreen.css index 7bd66c02..f48b09ea 100644 --- a/src/components/realm/RealmMapScreen.css +++ b/src/components/realm/RealmMapScreen.css @@ -520,6 +520,39 @@ font-weight: 800; } +.realm-cell-navigator__water { + display: grid; + gap: 0.35rem; + margin-top: 0.8rem; +} + +.realm-cell-navigator__water > span { + color: var(--realm-muted); + font-size: 0.6875rem; + font-weight: 800; + letter-spacing: 0.12em; +} + +.realm-cell-navigator__water-row { + display: grid; + gap: 0.28rem; + padding: 0.58rem 0.65rem; + border: 1px solid rgb(145 217 216 / 22%); + border-radius: 0.35rem; + background: rgb(19 59 70 / 24%); +} + +.realm-cell-navigator__water-row > div { + display: flex; + gap: 0.35rem; +} + +.realm-cell-navigator__water-row > div button { + width: auto; + padding: 0.28rem 0.45rem; + font-size: 0.625rem; +} + .realm-cell-navigator__community { display: grid; gap: 0.22rem; diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index 7b68cfae..f46fe0a0 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -41,6 +41,7 @@ import { FoodFarmInspectionPanel } from './FoodFarmInspectionPanel'; import { GoldMineInspectionPanel } from './GoldMineInspectionPanel'; import { LoggingCampInspectionPanel } from './LoggingCampInspectionPanel'; import { StoneQuarryInspectionPanel } from './StoneQuarryInspectionPanel'; +import { WaterInspectionPanel } from './WaterInspectionPanel'; import { RealmAccessibilityControls } from './RealmAccessibilityControls'; import { RealmCastleLabels, @@ -57,6 +58,11 @@ import { } from './createRealmScene'; import { resolveCanonicalWaterProjection } from './realmWaterProjection'; import { projectRealmWaterRevisionTerrainMetadata } from './realmWaterTerrainProjection'; +import { + realmWaterNavigatorBodies, + resolveRealmWaterInspectionRecords, + type RealmWaterInspectionRecord +} from './realmWaterInspectionPresentation'; import { resolveRealmGoldNodePresentations, type RealmGoldNodePresentation @@ -357,6 +363,20 @@ function CanonicalRealmMapScreen({ () => projectRealmWaterRevisionTerrainMetadata(sharedTileMetadata, waterCells), [sharedTileMetadata, waterCells] ); + const waterRecords = useMemo( + () => resolveRealmWaterInspectionRecords(waterCells, projectedTileMetadata), + [projectedTileMetadata, waterCells] + ); + const waterRecordsByKey = useMemo( + () => new Map(waterRecords.map((record) => [record.cellKey, record] as const)), + [waterRecords] + ); + const waterRecordsByKeyRef = useRef>(waterRecordsByKey); + waterRecordsByKeyRef.current = waterRecordsByKey; + const navigatorWaterBodies = useMemo( + () => realmWaterNavigatorBodies(waterRecords), + [waterRecords] + ); const otherCastles = snapshot.castles; const surface = useMemo( () => createAuthoritativeRealmTerrainSurface( @@ -745,10 +765,15 @@ function CanonicalRealmMapScreen({ && 'stoneSiteId' in selectedInspectorTarget ? stoneNodesBySiteId.get(selectedInspectorTarget.stoneSiteId) : undefined; + const inspectorWater = selectedInspectorTarget !== null + && 'cellKey' in selectedInspectorTarget + ? waterRecordsByKey.get(selectedInspectorTarget.cellKey) + : undefined; const goldNodeAtSelectedCell = goldNodes.find((node) => sameCoord(node.coord, selectedCoord)); const foodNodeAtSelectedCell = foodNodes.find((node) => sameCoord(node.coord, selectedCoord)); const woodNodeAtSelectedCell = woodNodes.find((node) => sameCoord(node.coord, selectedCoord)); const stoneNodeAtSelectedCell = stoneNodes.find((node) => sameCoord(node.coord, selectedCoord)); + const waterAtSelectedCell = waterRecords.find((record) => sameCoord(record.coord, selectedCoord)); const ownProfile = profileRecords.get(ownCastle.castleId)?.profile; const focusedCastleId = interaction.cameraTarget.kind === 'castle' ? interaction.cameraTarget.castleId @@ -819,6 +844,8 @@ function CanonicalRealmMapScreen({ inspectorFocusRef.current?.focus({ preventScroll: true }); } else if (target.kind === 'stone-quarry-inspector') { inspectorFocusRef.current?.focus({ preventScroll: true }); + } else if (target.kind === 'water-inspector') { + inspectorFocusRef.current?.focus({ preventScroll: true }); } else if (target.kind === 'castle-label') { const label = rootRef.current ?.querySelector(`.realm-castle-label[data-castle-id="${target.castleId}"]`) @@ -921,6 +948,19 @@ function CanonicalRealmMapScreen({ if (focusSite) sceneRef.current?.focusCell(node.coord); }, []); + const selectWaterCell = useCallback((record: RealmWaterInspectionRecord, focusWater = true) => { + selectedCoordRef.current = { ...record.coord }; + dispatchInteraction({ + type: 'activate-water-cell', + cellKey: record.cellKey, + bodyId: record.bodyId, + regime: record.regime, + coord: record.coord, + cameraIntent: focusWater ? 'focus-water' : 'preserve' + }); + if (focusWater) sceneRef.current?.focusWaterCell?.(record.cellKey); + }, []); + const openActiveWagon = useCallback((wagon: RealmActiveWagonMenuItem) => { if (!activeWagons.some((candidate) => ( candidate.resource === wagon.resource && candidate.siteId === wagon.siteId @@ -1046,6 +1086,14 @@ function CanonicalRealmMapScreen({ // The scene reserves its restrained ground outline for unoccupied terrain; // castle identity and raycasting provide the occupied-cell cue without a // depth-tested line cutting through the wider authored landscape base. + if (target?.kind === 'water-cell') { + updateHoveredCastleId(undefined); + hoveredCoordRef.current = null; + sceneRef.current?.setHoveredWaterCellKey?.(target.cellKey); + sceneRef.current?.setHovered(null); + return; + } + sceneRef.current?.setHoveredWaterCellKey?.(null); updateHoveredCoord(target?.coord ?? null); updateHoveredCastleId(target?.kind === 'castle' ? target.castleId : undefined); }, [updateHoveredCastleId, updateHoveredCoord]); @@ -1077,8 +1125,13 @@ function CanonicalRealmMapScreen({ if (node) selectStoneNode(node, target.source !== 'wagon'); return; } + if (target.kind === 'water-cell') { + const record = waterRecordsByKeyRef.current.get(target.cellKey); + if (record) selectWaterCell(record); + return; + } selectCoord(target.coord); - }, [selectCastle, selectCoord, selectFoodNode, selectGoldNode, selectStoneNode, selectWoodNode]); + }, [selectCastle, selectCoord, selectFoodNode, selectGoldNode, selectStoneNode, selectWaterCell, selectWoodNode]); const updateCastleProjection = useCallback((frame: RealmCastleProjectionFrame) => { latestProjectionRef.current = frame; @@ -1287,7 +1340,7 @@ function CanonicalRealmMapScreen({ root.querySelectorAll( '.realm-hud, .realm-hud__actions, .realm-profile-trigger, .realm-resource-rail, ' + '.castle-inspection, .gold-mine-inspection, .food-farm-inspection, .logging-camp-inspection, ' - + '.stone-quarry-inspection, .realm-cell-navigator' + + '.stone-quarry-inspection, .realm-cell-navigator, .water-inspection' ).forEach((element) => observer?.observe(element)); } window.addEventListener('resize', updateSceneComposition, { passive: true }); @@ -1551,10 +1604,18 @@ function CanonicalRealmMapScreen({ ? interactionRef.current.inspectorTarget.stoneSiteId : null ); + scene.setSelectedWaterCellKey?.( + interactionRef.current.inspectorOpen + && interactionRef.current.inspectorTarget !== null + && 'cellKey' in interactionRef.current.inspectorTarget + ? interactionRef.current.inspectorTarget.cellKey + : null + ); scene.setHovered(hoveredCoordRef.current); const cameraTarget: RealmCameraTarget = interactionRef.current.cameraTarget; if (cameraTarget.kind === 'castle') scene.focusCastle(cameraTarget.castleId); else if (cameraTarget.kind === 'cell') scene.focusCell(cameraTarget.coord); + else if (cameraTarget.kind === 'water') scene.focusWaterCell?.(cameraTarget.cellKey); else if (cameraTarget.kind === 'keep') scene.recenterKeep(); else if (cameraTarget.kind === 'founding-district') scene.frameFoundingDistrict(); else scene.showRealm(); @@ -1616,6 +1677,10 @@ function CanonicalRealmMapScreen({ sceneRef.current?.setSelectedStoneSiteId?.(inspectorStoneNode?.siteId ?? null); }, [inspectorStoneNode?.siteId]); + useEffect(() => { + sceneRef.current?.setSelectedWaterCellKey?.(inspectorWater?.cellKey ?? null); + }, [inspectorWater?.cellKey]); + useEffect(() => { const handleEscape = (event: KeyboardEvent) => { // Treat one physical Escape press as one hierarchy step. Ignoring the @@ -1698,6 +1763,8 @@ function CanonicalRealmMapScreen({ selectWoodNode(woodNodeAtSelectedCell); } else if (stoneNodeAtSelectedCell) { selectStoneNode(stoneNodeAtSelectedCell); + } else if (waterAtSelectedCell) { + selectWaterCell(waterAtSelectedCell); } else { sceneRef.current?.focusCell(selectedCoord); dispatchInteraction({ @@ -2161,10 +2228,27 @@ function CanonicalRealmMapScreen({ /> ) : null} + {inspectorWater ? ( + dispatchInteraction({ type: 'close-inspector' })} + onFocusCell={(cellKey) => { + const record = waterRecordsByKeyRef.current.get(cellKey); + if (record) selectWaterCell(record); + }} + onViewUnderlyingCell={inspectorWater.underlyingTileKey + ? () => selectCoord(inspectorWater.coord) + : undefined} + /> + ) : null} + candidate.castleId === entry.castleId); if (castle) selectCastle(castle); }} + onActivateWaterCell={(cellKey) => { + const record = waterRecordsByKeyRef.current.get(cellKey); + if (record) { + selectWaterCell(record); + dispatchInteraction({ type: 'close-navigator' }); + } + }} coordinateJump={{ validate: (coord) => ( isPlayableRealmCoord(surface, coord) diff --git a/src/components/realm/WaterInspectionPanel.css b/src/components/realm/WaterInspectionPanel.css new file mode 100644 index 00000000..0a48d8eb --- /dev/null +++ b/src/components/realm/WaterInspectionPanel.css @@ -0,0 +1 @@ +.water-inspection{position:fixed;z-index:14;inset:0 0 0 auto;width:min(25rem,100vw);overflow:hidden;color:#eef7f3;background:radial-gradient(circle at 50% 15%,rgb(68 170 191 / 24%),transparent 44%),linear-gradient(180deg,#0b1a22,#071015 74%);box-shadow:-1rem 0 3rem rgb(0 0 0 / 42%);font-family:Inter,ui-sans-serif,system-ui,sans-serif}.water-inspection__drawer{display:flex;height:100%;flex-direction:column}.water-inspection__hero{position:relative;min-height:15.8rem;padding:10.5rem 1rem .8rem;background:linear-gradient(180deg,rgb(9 28 38 / 10%),rgb(7 16 21 / 97%) 88%)}.water-inspection__hero-art-stage{position:absolute;z-index:0;top:-1.8rem;left:50%;width:min(29rem,126%);height:16rem;transform:translateX(-50%);pointer-events:none}.water-inspection__art{position:relative;width:100%;height:100%;overflow:hidden;background:radial-gradient(circle at 50% 50%,rgb(190 244 238 / 21%),transparent 48%),linear-gradient(180deg,#2a5662,#0b1a23 68%);filter:drop-shadow(0 .4rem .2rem rgb(0 0 0 / 36%)) drop-shadow(0 1rem 1.3rem rgb(0 0 0 / 52%));clip-path:polygon(2% 4%,98% 4%,100% 86%,80% 100%,20% 100%,0 86%)}.water-inspection__art--river{background:radial-gradient(ellipse at 53% 39%,rgb(246 226 157 / 33%),transparent 23%),linear-gradient(180deg,#6baab2,#1b4b62 55%,#0a1d29)}.water-inspection__art-sun{position:absolute;top:2.3rem;left:54%;width:4rem;height:4rem;border-radius:50%;background:#f6df96;box-shadow:0 0 2.8rem 1rem rgb(247 222 148 / 42%)}.water-inspection__art-horizon{position:absolute;right:0;bottom:4.6rem;left:0;height:3rem;background:linear-gradient(180deg,transparent,rgb(11 38 46 / 60%))}.water-inspection__art-wave{position:absolute;left:-10%;width:120%;height:3.2rem;border:2px solid rgb(173 237 231 / 54%);border-color:rgb(173 237 231 / 54%) transparent transparent;border-radius:50%;transform:rotate(-7deg)}.water-inspection__art-wave--one{bottom:3.4rem}.water-inspection__art-wave--two{bottom:1rem;transform:rotate(5deg);opacity:.72}.water-inspection__art-crest{position:absolute;right:16%;bottom:3.2rem;color:#d7f3e9;font:500 5.4rem/1 Georgia,serif;text-shadow:0 0 1rem rgb(177 255 242 / 68%);transform:rotate(-8deg)}.water-inspection__title-lockup{position:relative;z-index:1;padding:0 2.8rem;text-align:center}.water-inspection__title-lockup p{margin:0;color:#b8e7dd;font-size:.62rem;font-weight:800;letter-spacing:.15em}.water-inspection__title-lockup h2{margin:.24rem 0 0;color:#e6d79b;font:500 1.88rem/1.05 Georgia,serif;letter-spacing:.055em}.water-inspection__dismiss{position:absolute;z-index:2;top:.68rem;right:.7rem;display:grid;width:2.8rem;height:2.8rem;place-items:center;border:1px solid rgb(215 240 231 / 42%);border-radius:50%;background:rgb(5 16 21 / 82%);color:#f4f7e8;font:400 1.8rem/1 Georgia,serif;cursor:pointer}.water-inspection__dismiss:focus-visible,.water-inspection__actions button:focus-visible{outline:2px solid #d7f1c5;outline-offset:2px}.water-inspection__body{flex:1;min-height:0;padding:.72rem 1rem 1.15rem;overflow:auto}.water-inspection__description,.water-inspection__read-only{margin:0;padding:.72rem .78rem;border-left:2px solid rgb(113 212 206 / 66%);background:linear-gradient(90deg,rgb(35 119 133 / 26%),transparent);color:#cbe4df;font-size:.76rem;line-height:1.5}.water-inspection__fields{display:grid;margin:.9rem 0 0;border:1px solid rgb(215 240 231 / 23%);border-radius:.56rem;overflow:hidden}.water-inspection__field{display:grid;grid-template-columns:minmax(7.1rem,.82fr) minmax(0,1.18fr);gap:.75rem;padding:.66rem .74rem;border-bottom:1px solid rgb(215 240 231 / 12%)}.water-inspection__field:last-child{border-bottom:0}.water-inspection__field dt,.water-inspection__field dd{margin:0}.water-inspection__field dt{color:#aec3c3;font-size:.72rem}.water-inspection__field dd{color:#e8d99e;font-size:.73rem;font-weight:760;text-align:right;overflow-wrap:anywhere}.water-inspection__read-only{margin-top:.82rem;border-left-color:rgb(212 228 255 / 56%)}.water-inspection__actions{display:grid;gap:.52rem;margin-top:.9rem}.water-inspection__actions button{min-height:2.65rem;padding:.56rem .7rem;border:1px solid rgb(214 239 216 / 62%);border-radius:.44rem;background:linear-gradient(180deg,rgb(85 170 173 / 97%),rgb(27 75 89 / 97%));color:#f0f6e6;font-size:.66rem;font-weight:840;letter-spacing:.1em;cursor:pointer}@media (max-width:40rem){.water-inspection{width:100%}.water-inspection__hero{min-height:14rem;padding-top:9.4rem}} diff --git a/src/components/realm/WaterInspectionPanel.tsx b/src/components/realm/WaterInspectionPanel.tsx new file mode 100644 index 00000000..dfd4af18 --- /dev/null +++ b/src/components/realm/WaterInspectionPanel.tsx @@ -0,0 +1,150 @@ +import { + useCallback, + useEffect, + useRef, + type Ref +} from 'react'; + +import type { RealmWaterInspectionRecord } from './realmWaterInspectionPresentation'; +import './WaterInspectionPanel.css'; + +export type WaterInspectionPanelProps = Readonly<{ + id: string; + record: RealmWaterInspectionRecord; + focusTargetRef?: Ref; + onRequestClose: () => void; + onFocusCell?: (cellKey: string) => void; + onViewUnderlyingCell?: () => void; +}>; + +function assignRef(ref: Ref | undefined, value: T | null) { + if (typeof ref === 'function') ref(value); + else if (ref) (ref as { current: T | null }).current = value; +} + +function WaterRecordArt({ record }: Readonly<{ record: RealmWaterInspectionRecord }>) { + return ( + + ); +} + +function PublicField({ label, value }: Readonly<{ label: string; value: string }>) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function WaterInspectionPanel({ + id, + record, + focusTargetRef, + onRequestClose, + onFocusCell, + onViewUnderlyingCell +}: WaterInspectionPanelProps) { + const closeButtonRef = useRef(null); + const titleId = `${id}-title`; + const descriptionId = `${id}-description`; + const setCloseButtonRef = useCallback((element: HTMLButtonElement | null) => { + closeButtonRef.current = element; + assignRef(focusTargetRef, element); + }, [focusTargetRef]); + + useEffect(() => { + closeButtonRef.current?.focus({ preventScroll: true }); + }, [id, record.cellKey]); + + const eyebrow = record.regime === 'river' + ? 'RIVER' + : record.displayType === 'coast' ? 'COAST' : 'OUTER SEA'; + const position = record.riverPosition + ? `${record.riverPosition} · ${record.flowClass}` + : `${record.oceanDepthClass} · ${record.fogBand} view`; + + return ( + + ); +} diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 7da81d3e..5aa29edf 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -91,6 +91,7 @@ import { import { createRealmAmbientScheduler, type RealmAmbientScheduler } from './realmAmbientScheduler'; import { createRealmWaterLayer, + waterSurfaceLevelToWorldY, type RealmWaterLayer } from './realmWaterLayer'; import { @@ -136,7 +137,8 @@ import { import { arbitrateRealmPick, type RealmInteractionTarget, - type RealmResourcePickHit + type RealmResourcePickHit, + type RealmWaterPickHit } from './realmPickArbitration'; import { REALM_LIGHTING_SPECS, @@ -425,6 +427,7 @@ export type RealmSceneHandle = Readonly<{ getSceneBuildSequence: () => number; focusCastle: (castleId: number) => void; focusCell: (coord: HexCoord) => void; + focusWaterCell: (cellKey: string) => void; frameFoundingDistrict: () => void; focusKeep: () => void; recenterKeep: () => void; @@ -436,6 +439,8 @@ export type RealmSceneHandle = Readonly<{ setSelectedFoodSiteId: (siteId: string | null) => void; setSelectedWoodSiteId: (siteId: string | null) => void; setSelectedStoneSiteId: (siteId: string | null) => void; + setSelectedWaterCellKey: (cellKey: string | null) => void; + setHoveredWaterCellKey: (cellKey: string | null) => void; setComposition: (composition: RealmCameraComposition) => void; showRealm: () => void; }>; @@ -1258,6 +1263,9 @@ function initializeRealmScene( scene.add(terrain); let waterLayer: RealmWaterLayer | null = null; + const waterCellByKey = new Map( + (options.waterCells ?? []).map((cell) => [cell.cellKey, cell] as const) + ); if (options.waterCells !== undefined) { try { waterLayer = createRealmWaterLayer({ @@ -1624,6 +1632,11 @@ function initializeRealmScene( const stoneNodeCoordinateKeys = new Set( (options.stoneNodes ?? []).map((node) => hexKey(node.coord)) ); + const waterCellCoordinateKeys = new Set( + (options.waterCells ?? []) + .filter((cell) => cell.regime === 'ocean' || cell.regime === 'river') + .map((cell) => `${cell.q},${cell.r}`) + ); const terrainOverlayCoord = (coord: HexCoord | null) => ( coord && !occupiedCastleCoordinateKeys.has(hexKey(coord)) @@ -1631,6 +1644,7 @@ function initializeRealmScene( && !foodNodeCoordinateKeys.has(hexKey(coord)) && !woodNodeCoordinateKeys.has(hexKey(coord)) && !stoneNodeCoordinateKeys.has(hexKey(coord)) + && !waterCellCoordinateKeys.has(hexKey(coord)) ? coord : null ); @@ -2250,7 +2264,8 @@ function initializeRealmScene( const stoneNodeHit = stoneNodeLayer?.raycast(raycaster); if (stoneNodeHit) resourceHits.push({ kind: 'stone-site', ...stoneNodeHit }); const castleHit = castleLayer?.raycast(raycaster); - const foregroundHit = arbitrateRealmPick({ resourceHits, castleHit }); + const waterHit: RealmWaterPickHit | null = waterLayer?.raycast(raycaster) ?? null; + const foregroundHit = arbitrateRealmPick({ resourceHits, castleHit, waterHit }); if (foregroundHit) return foregroundHit; const intersections = raycaster.intersectObject(terrain, false); for (const intersection of intersections) { @@ -3162,6 +3177,28 @@ function initializeRealmScene( footprintDiameter: 1.24 }); }, + focusWaterCell: (cellKey) => { + if (cleanup.isDisposed()) return; + const cell = waterCellByKey.get(cellKey); + if (!cell || (cell.regime === 'ocean' && cell.fogBand === 'full')) return; + const world = axialToWorld({ q: cell.q, r: cell.r }, HEX_SIZE); + const terrainY = terrainHeightAtWorld( + options.surface.renderMap, + world, + HEX_SIZE, + terrainPlacements + ); + const surfaceY = cell.regime === 'river' + ? Math.max(waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + 0.035, terrainY + 0.04) + : waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + 0.035; + cameraController.focusAt({ + x: world.x, + y: surfaceY, + z: world.z, + height: 0.18, + footprintDiameter: 1.24 + }); + }, frameFoundingDistrict: () => { const width = Math.max(1, options.canvas.clientWidth || window.innerWidth || 1); const height = Math.max(1, options.canvas.clientHeight || window.innerHeight || 1); @@ -3175,6 +3212,7 @@ function initializeRealmScene( recenterKeep: cameraController.recenterKeep, setHovered: (coord) => { if (cleanup.isDisposed()) return; + waterLayer?.setHoveredCellKey(null); hoveredTerrainCoord = coord; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); // A terrain hex runs through the wider authored landscape-base mesh. @@ -3202,6 +3240,7 @@ function initializeRealmScene( }, setSelected: (coord) => { if (cleanup.isDisposed()) return; + waterLayer?.setSelectedCellKey(null); selectedTerrainCoord = coord; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); selectedCastleId = coord @@ -3221,6 +3260,7 @@ function initializeRealmScene( if (cleanup.isDisposed()) return; selectedCastleId = castleId === null ? undefined : castleId; if (castleId !== null) { + waterLayer?.setSelectedCellKey(null); selectedTerrainCoord = null; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); setOverlay(selectedOverlay, options.surface, null, terrainPlacements); @@ -3231,28 +3271,68 @@ 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) { + 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) { + 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) { + 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) { + waterLayer?.setSelectedCellKey(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } + render(); + }, + setSelectedWaterCellKey: (cellKey) => { + if (cleanup.isDisposed()) return; + waterLayer?.setSelectedCellKey(cellKey); + if (cellKey !== null) { + selectedTerrainCoord = null; + selectedCastleId = undefined; + selectedGoldSiteId = undefined; + selectedFoodSiteId = undefined; + selectedWoodSiteId = undefined; + selectedStoneSiteId = undefined; + goldNodeLayer?.setSelectedSiteId(null); + foodNodeLayer?.setSelectedSiteId(null); + woodNodeLayer?.setSelectedSiteId(null); + stoneNodeLayer?.setSelectedSiteId(null); + grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } + render(); + }, + setHoveredWaterCellKey: (cellKey) => { + if (cleanup.isDisposed()) return; + waterLayer?.setHoveredCellKey(cellKey); + if (cellKey !== null) { + hoveredTerrainCoord = null; + setOverlay(hoverOverlay, options.surface, null, terrainPlacements); + } render(); }, setComposition: (composition) => cameraController.setComposition(composition), diff --git a/src/components/realm/realmInteractionState.ts b/src/components/realm/realmInteractionState.ts index 2796d99f..db0d18ad 100644 --- a/src/components/realm/realmInteractionState.ts +++ b/src/components/realm/realmInteractionState.ts @@ -36,13 +36,21 @@ export type RealmStoneSiteTarget = Readonly<{ coord: HexCoord; }>; +export type RealmWaterCellTarget = Readonly<{ + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; +}>; + export type RealmInspectorTarget = | RealmWorkerTarget | RealmCastleTarget | RealmGoldSiteTarget | RealmFoodSiteTarget | RealmWoodSiteTarget - | RealmStoneSiteTarget; + | RealmStoneSiteTarget + | RealmWaterCellTarget; export type RealmCameraTarget = | Readonly<{ kind: 'realm' }> @@ -50,7 +58,10 @@ export type RealmCameraTarget = | Readonly<{ kind: 'keep' }> | Readonly<{ kind: 'cell'; coord: HexCoord }> | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }> - | Readonly<{ kind: 'worker'; workerId: string; coord: HexCoord }>; + | Readonly<{ kind: 'worker'; workerId: string; coord: HexCoord }> + // Water focus is bounded by the validated visible projection, not land + // passability; full-fog cells never become camera targets. + | Readonly<{ kind: 'water'; cellKey: string; coord: HexCoord }>; export type RealmKeyboardTarget = | Readonly<{ kind: 'map' }> @@ -60,6 +71,7 @@ export type RealmKeyboardTarget = | Readonly<{ kind: 'food-farm-inspector'; siteId: string }> | Readonly<{ kind: 'logging-camp-inspector'; siteId: string }> | Readonly<{ kind: 'stone-quarry-inspector'; siteId: string }> + | Readonly<{ kind: 'water-inspector'; cellKey: string }> | Readonly<{ kind: 'castle-label'; castleId: number }> | Readonly<{ kind: 'navigator' }> | Readonly<{ kind: 'navigator-trigger' }>; @@ -111,6 +123,14 @@ export type RealmInteractionAction = coord: HexCoord; cameraIntent?: 'focus-site' | 'preserve'; }> + | Readonly<{ + type: 'activate-water-cell'; + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; + cameraIntent?: 'focus-water' | 'preserve'; + }> | Readonly<{ type: 'close-inspector' }> | Readonly<{ type: 'recenter-keep'; coord: HexCoord }> | Readonly<{ type: 'set-camera-target'; target: RealmCameraTarget }> @@ -159,6 +179,15 @@ function copyStoneSiteTarget(target: RealmStoneSiteTarget): RealmStoneSiteTarget return { stoneSiteId: target.stoneSiteId, coord: copyCoord(target.coord) }; } +function copyWaterCellTarget(target: RealmWaterCellTarget): RealmWaterCellTarget { + return { + cellKey: target.cellKey, + bodyId: target.bodyId, + regime: target.regime, + coord: copyCoord(target.coord) + }; +} + function isCastleTarget(target: RealmInspectorTarget | null): target is RealmCastleTarget { return target !== null && 'castleId' in target; } @@ -169,6 +198,7 @@ function copyCameraTarget(target: RealmCameraTarget): RealmCameraTarget { if (target.kind === 'keep') return { kind: 'keep' }; if (target.kind === 'cell') return { kind: 'cell', coord: copyCoord(target.coord) }; if (target.kind === 'worker') return { kind: 'worker', workerId: target.workerId, coord: copyCoord(target.coord) }; + if (target.kind === 'water') return { kind: 'water', cellKey: target.cellKey, coord: copyCoord(target.coord) }; return { kind: 'castle', castleId: target.castleId, coord: copyCoord(target.coord) }; } @@ -315,6 +345,25 @@ export function realmInteractionReducer( }; } + case 'activate-water-cell': { + const target = copyWaterCellTarget(action); + return { + ...state, + selectedCell: copyCoord(target.coord), + selectedCastle: null, + inspectorTarget: target, + inspectorOpen: true, + cameraTarget: action.cameraIntent === 'preserve' + ? state.cameraTarget + : { kind: 'water', cellKey: target.cellKey, coord: copyCoord(target.coord) }, + navigatorOpen: false, + keyboardIntent: withKeyboardIntent(state, { + kind: 'water-inspector', + cellKey: target.cellKey + }) + }; + } + case 'close-inspector': { if (!state.inspectorOpen) return state; const castle = isCastleTarget(state.inspectorTarget) diff --git a/src/components/realm/realmPickArbitration.ts b/src/components/realm/realmPickArbitration.ts index fa61c7cc..46e8c92f 100644 --- a/src/components/realm/realmPickArbitration.ts +++ b/src/components/realm/realmPickArbitration.ts @@ -13,6 +13,7 @@ export type RealmResourcePickHit = Readonly<{ source: 'site' | 'wagon'; distance: number; }>; + export type RealmWorkerPickHit = Readonly<{ workerId: string; workerOrdinal: number; @@ -21,6 +22,23 @@ export type RealmWorkerPickHit = Readonly<{ distance: number; }>; +export type RealmWaterPickHit = Readonly<{ + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; + distance: number; +}>; + +/** A visible, public water identity that can be handed to the interaction lane. */ +export type RealmWaterInteractionTarget = Readonly<{ + kind: 'water-cell'; + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; +}>; + export type RealmInteractionTarget = | Readonly<{ kind: 'worker'; workerId: string; workerOrdinal: number; originCastleId: number; coord: HexCoord }> | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }> @@ -30,6 +48,7 @@ export type RealmInteractionTarget = coord: HexCoord; source: 'site' | 'wagon'; }> + | RealmWaterInteractionTarget | Readonly<{ kind: 'terrain'; coord: HexCoord }>; function nearestValidHit( @@ -65,6 +84,7 @@ function nearestValidWorkerHit(hits: readonly RealmWorkerPickHit[]) { export function arbitrateRealmPick(input: Readonly<{ resourceHits: readonly RealmResourcePickHit[]; workerHits?: readonly RealmWorkerPickHit[]; + waterHit?: RealmWaterPickHit | null; castleHit?: Readonly<{ castleId: number; coord: HexCoord }> | null; terrainHit?: Readonly<{ coord: HexCoord }> | null; }>): RealmInteractionTarget | null { @@ -103,6 +123,15 @@ export function arbitrateRealmPick(input: Readonly<{ source: site.source }); } + if (input.waterHit && Number.isFinite(input.waterHit.distance) && input.waterHit.distance >= 0) { + return Object.freeze({ + kind: 'water-cell', + cellKey: input.waterHit.cellKey, + bodyId: input.waterHit.bodyId, + regime: input.waterHit.regime, + coord: input.waterHit.coord + }); + } return input.terrainHit ? Object.freeze({ kind: 'terrain', coord: input.terrainHit.coord }) : null; diff --git a/src/components/realm/realmWaterInspectionPresentation.ts b/src/components/realm/realmWaterInspectionPresentation.ts new file mode 100644 index 00000000..937934c9 --- /dev/null +++ b/src/components/realm/realmWaterInspectionPresentation.ts @@ -0,0 +1,170 @@ +import { axialToWorld, type HexCoord } from '../../game/map/hexCoordinates'; +import { + GENESIS_WATER_BODIES_V1, + type GenesisWaterBodyV1, + type GenesisWaterCellV1 +} from '../../../spacetimedb/src/waterWorld'; +import type { RealmTerrainSemanticRow } from '../../game/map/realmTerrainSemantics'; + +export type RealmWaterInspectionRecord = Readonly<{ + cellKey: string; + coord: HexCoord; + bodyId: string; + regime: 'ocean' | 'river'; + displayType: 'river' | 'coast' | 'outer-sea'; + displayName: string; + description: string; + riverOrdinal?: number; + riverPosition?: 'source' | 'upper reach' | 'middle reach' | 'lower reach' | 'mouth'; + riverOrder?: number; + riverCellCount?: number; + sourceCellKey?: string; + mouthCellKey?: string; + sourceCoord?: HexCoord; + mouthCoord?: HexCoord; + downstreamWaterCellKey?: string; + flowClass?: 'headwater' | 'branching reach' | 'main reach' | 'lower reach'; + oceanDepthClass?: 'coast' | 'open water'; + depthCells: number; + fogBand: 'clear' | 'haze'; + underlyingTileKey?: string; + underlyingPassable?: boolean; + worldPosition: Readonly<{ x: number; z: number }>; + gameplayBoundary: string; +}>; + +export type RealmWaterNavigatorBody = Readonly<{ + bodyId: string; + label: string; + sourceCellKey: string; + mouthCellKey: string; + sourceCoord: HexCoord; + mouthCoord: HexCoord; +}>; + +function safeBodyMap(bodies: readonly GenesisWaterBodyV1[]) { + return new Map(bodies.map((body) => [body.bodyId, body] as const)); +} + +function riverPosition(cell: GenesisWaterCellV1, body: GenesisWaterBodyV1) { + const order = cell.riverOrder ?? 0; + const progress = body.cellCount <= 1 ? 0 : order / Math.max(1, body.cellCount - 1); + if (order === 0) return 'source' as const; + if (order >= body.cellCount - 1) return 'mouth' as const; + if (progress < 0.34) return 'upper reach' as const; + if (progress < 0.67) return 'middle reach' as const; + return 'lower reach' as const; +} + +function riverFlowClass(cell: GenesisWaterCellV1) { + if (cell.flowAccumulation <= 1) return 'headwater' as const; + if (cell.flowAccumulation <= 3) return 'branching reach' as const; + if (cell.flowAccumulation <= 8) return 'main reach' as const; + return 'lower reach' as const; +} + +function coordForCell( + cellsByKey: ReadonlyMap, + cellKey: string +) { + const cell = cellsByKey.get(cellKey); + return cell ? { q: cell.q, r: cell.r } : undefined; +} + +/** + * Build bounded, read-only records from the already validated Water subset. + * Full-fog cells and any unexpected lake rows fail closed rather than creating + * browser-local identity or a misleading inspection surface. + */ +export function resolveRealmWaterInspectionRecords( + cells: readonly GenesisWaterCellV1[] | undefined, + terrainMetadata: readonly RealmTerrainSemanticRow[] = [], + bodies: readonly GenesisWaterBodyV1[] = GENESIS_WATER_BODIES_V1 +): readonly RealmWaterInspectionRecord[] { + void terrainMetadata; + if (!cells || cells.some((cell) => cell.regime === 'lake')) return Object.freeze([]); + const bodyMap = safeBodyMap(bodies); + const cellsByKey = new Map(cells.map((cell) => [cell.cellKey, cell] as const)); + const records: RealmWaterInspectionRecord[] = []; + + for (const cell of cells) { + if ( + cell.regime !== 'ocean' && cell.regime !== 'river' + || cell.fogBand === 'full' + || !Number.isSafeInteger(cell.q) + || !Number.isSafeInteger(cell.r) + ) continue; + const body = bodyMap.get(cell.bodyId); + if (!body || body.regime !== cell.regime) return Object.freeze([]); + const coord = { q: cell.q, r: cell.r }; + if (cell.regime === 'river') { + const ordinal = body.ordinal; + const riverName = `Genesis River ${String(ordinal).padStart(2, '0')}`; + const sourceCoord = coordForCell(cellsByKey, body.sourceCellKey); + const mouthCoord = coordForCell(cellsByKey, body.mouthCellKey); + records.push(Object.freeze({ + cellKey: cell.cellKey, + coord, + bodyId: cell.bodyId, + regime: 'river', + displayType: 'river', + displayName: riverName, + description: 'A persistent Genesis watercourse crossing the Lowlands from source to mouth.', + riverOrdinal: ordinal, + riverPosition: riverPosition(cell, body), + riverOrder: cell.riverOrder, + riverCellCount: body.cellCount, + sourceCellKey: body.sourceCellKey, + mouthCellKey: body.mouthCellKey, + sourceCoord, + mouthCoord, + downstreamWaterCellKey: cell.downstreamWaterCellKey, + flowClass: riverFlowClass(cell), + depthCells: cell.depthCells, + fogBand: cell.fogBand, + underlyingTileKey: cell.underlyingTileKey, + underlyingPassable: undefined, + worldPosition: axialToWorld(coord, 1), + gameplayBoundary: 'Water is visual and does not add boats, swimming, current force, or resource rewards.' + })); + continue; + } + const isCoast = cell.oceanDepth <= 2; + records.push(Object.freeze({ + cellKey: cell.cellKey, + coord, + bodyId: cell.bodyId, + regime: 'ocean', + displayType: isCoast ? 'coast' : 'outer-sea', + displayName: isCoast ? 'Lowlands Coast' : 'Outer Sea', + description: isCoast + ? 'A clear coastal water cell at the edge of the Lowlands.' + : 'Open water beyond the coast, presented within the public fog boundary.', + oceanDepthClass: isCoast ? 'coast' : 'open water', + depthCells: cell.depthCells, + fogBand: cell.fogBand, + worldPosition: axialToWorld(coord, 1), + gameplayBoundary: 'The sea is a shared visual boundary; it is not claimable territory.' + })); + } + return Object.freeze(records); +} + +export function realmWaterNavigatorBodies( + records: readonly RealmWaterInspectionRecord[] +): readonly RealmWaterNavigatorBody[] { + const byBody = new Map(); + records.forEach((record) => { + if (record.regime === 'river' && !byBody.has(record.bodyId)) byBody.set(record.bodyId, record); + }); + return Object.freeze([...byBody.values()] + .sort((left, right) => (left.riverOrdinal ?? 0) - (right.riverOrdinal ?? 0)) + .map((record) => Object.freeze({ + bodyId: record.bodyId, + label: record.displayName, + sourceCellKey: record.sourceCellKey!, + mouthCellKey: record.mouthCellKey!, + sourceCoord: record.sourceCoord!, + mouthCoord: record.mouthCoord! + }))); +} diff --git a/src/components/realm/realmWaterLayer.ts b/src/components/realm/realmWaterLayer.ts index cadc3d88..633a011f 100644 --- a/src/components/realm/realmWaterLayer.ts +++ b/src/components/realm/realmWaterLayer.ts @@ -57,8 +57,20 @@ export type RealmWaterLayerTelemetry = Readonly<{ fullFogOceanCellCount: number; }>; +export type RealmWaterCellHit = Readonly<{ + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: Readonly<{ q: number; r: number }>; + distance: number; +}>; + export type RealmWaterLayer = Readonly<{ group: THREE.Group; + raycast: (raycaster: THREE.Raycaster) => RealmWaterCellHit | null; + getCellPresentation: (cellKey: string) => GenesisWaterCellV1 | undefined; + setSelectedCellKey: (cellKey: string | null) => void; + setHoveredCellKey: (cellKey: string | null) => void; updateEnvironment: (elapsedSeconds: number) => boolean; isAnimationActive: () => boolean; getTelemetry: () => RealmWaterLayerTelemetry; @@ -134,6 +146,7 @@ function surfaceGeometry( geometry.setAttribute('waterDepth', new THREE.Float32BufferAttribute(waterDepth, 1)); geometry.setAttribute('waterBankBlend', new THREE.Float32BufferAttribute(waterBankBlend, 1)); geometry.setAttribute('waterFogMix', new THREE.Float32BufferAttribute(waterFogMix, 1)); + geometry.userData.realmWaterCellKeys = cells.map((cell) => cell.cellKey); try { geometry.setIndex(indices); geometry.computeBoundingSphere(); @@ -269,6 +282,7 @@ function riverSurfaceGeometry( geometry.setAttribute('waterDepth', new THREE.Float32BufferAttribute(waterDepth, 1)); geometry.setAttribute('waterBankBlend', new THREE.Float32BufferAttribute(waterBankBlend, 1)); geometry.setAttribute('waterFogMix', new THREE.Float32BufferAttribute(waterFogMix, 1)); + geometry.userData.realmWaterCellKeys = cells.map((cell) => cell.cellKey); try { geometry.setIndex(indices); geometry.computeBoundingSphere(); @@ -432,6 +446,84 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay skirtMesh.name = 'canonical-ocean-downward-skirt'; riverMesh.renderOrder = 2; skirtMesh.renderOrder = 1; + const cellsByKey = new Map(options.cells.map((cell) => [cell.cellKey, cell] as const)); + const visiblePickCells = new Set(options.cells + .filter((cell) => cell.regime !== 'ocean' || cell.fogBand !== 'full') + .map((cell) => cell.cellKey)); + const selectedWaterOverlay = new THREE.LineLoop( + new THREE.BufferGeometry(), + new THREE.LineBasicMaterial({ + color: '#e8fbce', + transparent: true, + opacity: 0.94, + depthTest: false, + depthWrite: false, + toneMapped: false + }) + ); + const hoveredWaterOverlay = new THREE.LineLoop( + new THREE.BufferGeometry(), + new THREE.LineBasicMaterial({ + color: '#d3f4ec', + transparent: true, + opacity: 0.6, + depthTest: false, + depthWrite: false, + toneMapped: false + }) + ); + selectedWaterOverlay.name = 'selected-water-cell-outline'; + hoveredWaterOverlay.name = 'hovered-water-cell-outline'; + selectedWaterOverlay.renderOrder = 6; + hoveredWaterOverlay.renderOrder = 5; + selectedWaterOverlay.visible = false; + hoveredWaterOverlay.visible = false; + group.add(selectedWaterOverlay, hoveredWaterOverlay); + const updateWaterOverlay = ( + overlay: THREE.LineLoop, + cellKey: string | null, + opacity: number + ) => { + const cell = cellKey ? cellsByKey.get(cellKey) : undefined; + if (!cell || !visiblePickCells.has(cell.cellKey)) { + overlay.visible = false; + return; + } + const center = axialToWorld({ q: cell.q, r: cell.r }, options.hexSize); + const corners = pointyHexCorners({ q: cell.q, r: cell.r }, options.hexSize); + const ground = cell.regime === 'river' + ? Math.max( + waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + WATER_Y_LIFT, + options.heightAtWorld(center) + 0.035 + ) + : waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + WATER_Y_LIFT; + const positions = new Float32Array(corners.length * 3); + corners.forEach((corner, index) => { + positions[index * 3] = corner.x; + positions[index * 3 + 1] = ground + (opacity > 0.8 ? 0.018 : 0.012); + positions[index * 3 + 2] = corner.z; + }); + overlay.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + overlay.geometry.computeBoundingSphere(); + (overlay.material as THREE.LineBasicMaterial).opacity = opacity; + overlay.visible = true; + }; + const raycastMesh = (mesh: THREE.Mesh, raycaster: THREE.Raycaster) => { + const intersection = raycaster.intersectObject(mesh, false)[0]; + if (!intersection || intersection.faceIndex === undefined || intersection.faceIndex === null) return null; + const keys = mesh.geometry.userData.realmWaterCellKeys as readonly string[] | undefined; + const cellKey = keys?.[Math.floor(intersection.faceIndex / 6)]; + if (!cellKey || !visiblePickCells.has(cellKey)) return null; + const cell = cellsByKey.get(cellKey); + if (!cell || (cell.regime !== 'ocean' && cell.regime !== 'river')) return null; + return { + cellKey, + bodyId: cell.bodyId, + regime: cell.regime, + coord: { q: cell.q, r: cell.r }, + distance: intersection.distance + } satisfies RealmWaterCellHit; + }; group.add(oceanMesh, lakeMesh, riverMesh, skirtMesh); const triangleCount = (oceanGeometry.index?.count ?? 0) / 3 + (lakeGeometry.index?.count ?? 0) / 3 @@ -440,6 +532,10 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay const drawCalls = [oceanMesh, lakeMesh, riverMesh, skirtMesh] .filter((mesh) => (mesh.geometry.index?.count ?? 0) > 0).length; if (triangleCount > budget.triangles || drawCalls > budget.draws) { + selectedWaterOverlay.geometry.dispose(); + (selectedWaterOverlay.material as THREE.Material).dispose(); + hoveredWaterOverlay.geometry.dispose(); + (hoveredWaterOverlay.material as THREE.Material).dispose(); disposeResources(); throw new Error('REALM_WATER_RENDER_BUDGET_EXCEEDED'); } @@ -474,7 +570,27 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay dispose: () => { if (disposed) return; disposed = true; + selectedWaterOverlay.geometry.dispose(); + (selectedWaterOverlay.material as THREE.Material).dispose(); + hoveredWaterOverlay.geometry.dispose(); + (hoveredWaterOverlay.material as THREE.Material).dispose(); disposeResources(); + }, + raycast: (raycaster) => { + if (disposed) return null; + const hits = [raycastMesh(oceanMesh, raycaster), raycastMesh(lakeMesh, raycaster), raycastMesh(riverMesh, raycaster)] + .filter((hit): hit is RealmWaterCellHit => hit !== null) + .sort((left, right) => left.distance - right.distance); + return hits[0] ?? null; + }, + getCellPresentation: (cellKey) => cellsByKey.get(cellKey), + setSelectedCellKey: (cellKey) => { + if (disposed) return; + updateWaterOverlay(selectedWaterOverlay, cellKey, 0.94); + }, + setHoveredCellKey: (cellKey) => { + if (disposed) return; + updateWaterOverlay(hoveredWaterOverlay, cellKey, 0.6); } }; } diff --git a/tests/realmInteractionState.test.ts b/tests/realmInteractionState.test.ts index 92a13ff7..54e53bf5 100644 --- a/tests/realmInteractionState.test.ts +++ b/tests/realmInteractionState.test.ts @@ -86,6 +86,34 @@ describe('realm interaction state', () => { expect(resolveRealmEscape(state).state.keyboardIntent.target).toEqual({ kind: 'map' }); }); + it('opens a read-only water record and preserves its canonical body identity', () => { + const state = realmInteractionReducer(createRealmInteractionState({ q: 0, r: 0 }), { + type: 'activate-water-cell', + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 4, r: -2 } + }); + + expect(state.selectedCell).toEqual({ q: 4, r: -2 }); + expect(state.selectedCastle).toBeNull(); + expect(state.inspectorTarget).toEqual({ + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 4, r: -2 } + }); + expect(state.cameraTarget).toEqual({ + kind: 'water', + cellKey: 'genesis-001:river:01:0001', + coord: { q: 4, r: -2 } + }); + expect(state.keyboardIntent).toEqual({ + sequence: 1, + target: { kind: 'water-inspector', cellKey: 'genesis-001:river:01:0001' } + }); + }); + it('opens a Food Farm inspector through a distinct target shape from Gold', () => { const state = realmInteractionReducer(createRealmInteractionState({ q: 0, r: 0 }), { type: 'activate-food-site', diff --git a/tests/realmPickArbitration.test.ts b/tests/realmPickArbitration.test.ts index 436153a3..6307b5d7 100644 --- a/tests/realmPickArbitration.test.ts +++ b/tests/realmPickArbitration.test.ts @@ -105,4 +105,45 @@ describe('realm scene pick arbitration', () => { terrainHit: { coord: { q: -2, r: 1 } } })).toEqual({ kind: 'terrain', coord: { q: -2, r: 1 } }); }); + + it('places a visible water cell after static sites and before terrain', () => { + expect(arbitrateRealmPick({ + resourceHits: [], + waterHit: { + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 2, r: -1 }, + distance: 8 + }, + terrainHit: { coord: { q: 2, r: -1 } } + })).toEqual({ + kind: 'water-cell', + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 2, r: -1 } + }); + expect(arbitrateRealmPick({ + resourceHits: [{ + kind: 'gold-site', + siteId: 'gold-1', + coord: { q: 2, r: -1 }, + source: 'site', + distance: 12 + }], + waterHit: { + cellKey: 'ocean:1', + bodyId: 'genesis-001:ocean', + regime: 'ocean', + coord: { q: 2, r: -1 }, + distance: 1 + } + })).toEqual({ + kind: 'gold-site', + siteId: 'gold-1', + coord: { q: 2, r: -1 }, + source: 'site' + }); + }); }); diff --git a/tests/realmWaterInspectionPresentation.test.ts b/tests/realmWaterInspectionPresentation.test.ts new file mode 100644 index 00000000..7a54332e --- /dev/null +++ b/tests/realmWaterInspectionPresentation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { + GENESIS_WATER_BODIES_V1, + GENESIS_WATER_CELLS_V1 +} from '../spacetimedb/src/waterWorld'; +import { GENESIS_WATER_REVISION_ENABLED_CELLS_V1 } from '../spacetimedb/src/waterRevision'; +import { + realmWaterNavigatorBodies, + resolveRealmWaterInspectionRecords +} from '../src/components/realm/realmWaterInspectionPresentation'; + +describe('public water inspection presentation', () => { + it('creates bounded river records with source, mouth, flow, and fog facts', () => { + const records = resolveRealmWaterInspectionRecords(GENESIS_WATER_REVISION_ENABLED_CELLS_V1); + const rivers = records.filter((record) => record.regime === 'river'); + expect(rivers).toHaveLength(400); + expect(rivers[0]).toMatchObject({ + displayType: 'river', + displayName: expect.stringMatching(/^Genesis River /), + sourceCellKey: expect.any(String), + mouthCellKey: expect.any(String), + riverCellCount: expect.any(Number), + riverPosition: expect.any(String), + flowClass: expect.any(String), + gameplayBoundary: expect.stringContaining('does not add') + }); + }); + + it('shows clear and haze public ocean cells but never full-fog ocean cells', () => { + const records = resolveRealmWaterInspectionRecords(GENESIS_WATER_REVISION_ENABLED_CELLS_V1); + expect(records.some((record) => record.regime === 'ocean' && record.fogBand === 'clear')).toBe(true); + expect(records.some((record) => record.regime === 'ocean' && record.fogBand === 'haze')).toBe(true); + }); + + it('creates one navigator entry per river body rather than one row per cell', () => { + const records = resolveRealmWaterInspectionRecords(GENESIS_WATER_REVISION_ENABLED_CELLS_V1); + const bodies = realmWaterNavigatorBodies(records); + expect(bodies).toHaveLength(GENESIS_WATER_BODIES_V1.filter((body) => body.regime === 'river').length); + expect(new Set(bodies.map((body) => body.bodyId)).size).toBe(bodies.length); + expect(bodies[0]).toMatchObject({ + label: expect.stringMatching(/^Genesis River /), + sourceCellKey: expect.any(String), + mouthCellKey: expect.any(String) + }); + }); + + it('fails closed for lake rows or malformed body identity', () => { + expect(resolveRealmWaterInspectionRecords(GENESIS_WATER_CELLS_V1)).toEqual([]); + const lake = GENESIS_WATER_CELLS_V1.find((cell) => cell.regime === 'lake'); + expect(lake).toBeDefined(); + expect(resolveRealmWaterInspectionRecords([lake!])).toEqual([]); + const river = GENESIS_WATER_REVISION_ENABLED_CELLS_V1.find((cell) => cell.regime === 'river'); + expect(river).toBeDefined(); + expect(resolveRealmWaterInspectionRecords([ + { ...river!, bodyId: 'unexpected-body' } + ])).toEqual([]); + }); +}); diff --git a/tests/realmWaterLayer.test.ts b/tests/realmWaterLayer.test.ts index fdaec85e..c8f1aed3 100644 --- a/tests/realmWaterLayer.test.ts +++ b/tests/realmWaterLayer.test.ts @@ -172,6 +172,39 @@ describe('Realm canonical water layer', () => { layer.dispose(); }); + it('maps bounded ray hits back to one visible water cell and excludes full fog', () => { + const layer = createRealmWaterLayer({ + cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, + quality: REALM_QUALITY_SPECS.reduced, + reducedMotion: true, + hexSize: 1, + heightAtWorld: canonicalHeightAtWorld + }); + const river = activeRiverCells[0]!; + const riverWorld = axialToWorld(river, 1); + const raycaster = new THREE.Raycaster( + new THREE.Vector3(riverWorld.x, 10, riverWorld.z), + new THREE.Vector3(0, -1, 0) + ); + expect(layer.raycast(raycaster)).toMatchObject({ + cellKey: river.cellKey, + bodyId: river.bodyId, + regime: 'river', + coord: { q: river.q, r: river.r } + }); + const fullFog = GENESIS_WATER_REVISION_ENABLED_CELLS_V1.find( + (cell) => cell.regime === 'ocean' && cell.fogBand === 'full' + ); + expect(fullFog).toBeDefined(); + const fogWorld = axialToWorld(fullFog!, 1); + const fogRaycaster = new THREE.Raycaster( + new THREE.Vector3(fogWorld.x, 10, fogWorld.z), + new THREE.Vector3(0, -1, 0) + ); + expect(layer.raycast(fogRaycaster)).toBeNull(); + layer.dispose(); + }); + it('keeps every canonical river surface clear and every shared edge continuous', () => { const layer = createRealmWaterLayer({ cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, @@ -341,8 +374,8 @@ describe('Realm canonical water layer', () => { hexSize: 1, heightAtWorld: canonicalHeightAtWorld })).toThrow('REALM_WATER_RENDER_BUDGET_EXCEEDED'); - expect(geometryDispose).toHaveBeenCalledTimes(4); - expect(materialDispose).toHaveBeenCalledTimes(4); + expect(geometryDispose).toHaveBeenCalledTimes(6); + expect(materialDispose).toHaveBeenCalledTimes(6); } finally { geometryDispose.mockRestore(); materialDispose.mockRestore(); From c2febe6ca67c21b866dcd2226fbc9295095ff016 Mon Sep 17 00:00:00 2001 From: Ael Date: Wed, 22 Jul 2026 00:47:19 +0200 Subject: [PATCH 15/19] fix: expose underlying terrain in water records --- src/components/realm/WaterInspectionPanel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/realm/WaterInspectionPanel.tsx b/src/components/realm/WaterInspectionPanel.tsx index dfd4af18..578c47a7 100644 --- a/src/components/realm/WaterInspectionPanel.tsx +++ b/src/components/realm/WaterInspectionPanel.tsx @@ -117,6 +117,7 @@ export function WaterInspectionPanel({ + Date: Wed, 22 Jul 2026 13:36:48 +0200 Subject: [PATCH 16/19] Repair water interaction and presentation --- .../rendered-webgl-browser-probe.d.mts | 2 + .../rendered-webgl-browser-probe.mjs | 48 +- .../realm/RealmAccessibilityControls.tsx | 4 +- src/components/realm/RealmMapScreen.tsx | 4 +- src/components/realm/WaterInspectionPanel.tsx | 24 +- src/components/realm/createRealmScene.ts | 37 +- .../realm/realmWaterInspectionPresentation.ts | 43 +- src/components/realm/realmWaterLayer.ts | 459 +++++++++++++++--- src/components/realm/realmWaterPhase.ts | 165 +++++++ src/game/map/realmTerrainSemantics.ts | 2 + src/spacetime/canonicalGenesisSnapshot.ts | 41 +- src/spacetime/warpkeepBackendTypes.ts | 4 + src/spacetime/warpkeepConnection.ts | 3 +- tests/canonicalGenesisSnapshot.test.ts | 28 ++ tests/qaJourneyLab.test.tsx | 2 +- tests/realmAccessibilityControls.test.tsx | 6 +- tests/realmObserverUi.test.tsx | 2 +- tests/realmQualityRecreation.test.tsx | 43 ++ tests/realmSceneCleanup.test.ts | 35 ++ .../realmWaterInspectionPresentation.test.ts | 39 ++ tests/realmWaterLayer.test.ts | 150 +++++- tests/realmWaterNavigation.test.ts | 11 +- tests/realmWaterPhase.test.ts | 88 ++++ tests/renderedWebglBrowserProbe.test.ts | 26 +- tests/warpkeepConnection.test.ts | 4 + tests/waterInspectionPanel.test.tsx | 99 ++++ 26 files changed, 1247 insertions(+), 122 deletions(-) create mode 100644 src/components/realm/realmWaterPhase.ts create mode 100644 tests/realmWaterPhase.test.ts create mode 100644 tests/waterInspectionPanel.test.tsx diff --git a/scripts/qa-observer/rendered-webgl-browser-probe.d.mts b/scripts/qa-observer/rendered-webgl-browser-probe.d.mts index 742aea21..fcb5c6e5 100644 --- a/scripts/qa-observer/rendered-webgl-browser-probe.d.mts +++ b/scripts/qa-observer/rendered-webgl-browser-probe.d.mts @@ -7,6 +7,8 @@ export const RENDERED_WEBGL_QA_CHROME_TEAM_ID: 'EQHXZ8M8AV'; export const RENDERED_WEBGL_QA_CASE_COUNT: 14; /** Exact authoritative terrain count for the Genesis generation-v3 render target. */ export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT: 10000; +/** Exact visible terrain-kind count after canonical no-lake revision activation. */ +export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT: 6; export const RENDERED_WEBGL_QA_LABEL_MAX_ANCHOR_DISPLACEMENT_PIXELS: 0; export const RENDERED_WEBGL_QA_LABEL_COORDINATE_SERIALIZATION_EPSILON_PIXELS: 0.015; /** Vite 8 default deny patterns plus the local asset-cache boundary. */ diff --git a/scripts/qa-observer/rendered-webgl-browser-probe.mjs b/scripts/qa-observer/rendered-webgl-browser-probe.mjs index a676c50c..c569d110 100644 --- a/scripts/qa-observer/rendered-webgl-browser-probe.mjs +++ b/scripts/qa-observer/rendered-webgl-browser-probe.mjs @@ -57,6 +57,10 @@ export const RENDERED_WEBGL_QA_CASE_COUNT = 14; // count prevents a complete generation-v2 surface (1,261 cells), a partial // expansion, or a mixed snapshot from being accepted as current render proof. export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT = 10_000; +// The synthetic observer activates canonical Water revision v1. Its 409 +// former lake rows are presented as lowland, leaving exactly six live terrain +// kinds while the immutable authority metadata remains seven-kind. +export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT = 6; // Every projection-visible keeper name is locked to its castle foundation. // Dense overviews may overlap, but camera motion cannot aggregate, displace, // or hide founded identities. @@ -103,22 +107,34 @@ const RENDERED_WEBGL_QA_MAP_DRAG_OFFSETS = Object.freeze([ Object.freeze({ x: 52, y: 14 }), ]); const RENDERED_WEBGL_QA_MAX_POINTER_COORDINATE_PIXELS = 10_000; -// The canonical shared forest is a separate static world layer, not part of -// the quality-scaled procedural feature budget. Presentation telemetry includes -// it, so the browser ceiling must include its exact 210 instances as well. +// Shared forest and the reviewed outer-Realm infill are separate real-tree +// layers, not part of the quality-scaled procedural feature budget. Telemetry +// includes both; mirror their exact source budgets rather than widening the +// browser gate with an arbitrary allowance. const RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT = 210; +const RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS = Object.freeze({ + high: 240, + balanced: 90, + reduced: 0, +}); const TERRAIN_PRESENTATION_BUDGETS = Object.freeze({ high: Object.freeze({ - semanticFeatureCount: 1_100 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, - totalDetailInstanceCount: 7_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, + semanticFeatureCount: 1_100 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.high, + totalDetailInstanceCount: 7_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.high, }), balanced: Object.freeze({ - semanticFeatureCount: 800 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, - totalDetailInstanceCount: 5_500 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, + semanticFeatureCount: 800 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.balanced, + totalDetailInstanceCount: 5_500 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.balanced, }), reduced: Object.freeze({ - semanticFeatureCount: 400 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, - totalDetailInstanceCount: 3_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, + semanticFeatureCount: 400 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.reduced, + totalDetailInstanceCount: 3_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.reduced, }), }); const LABEL_CULL_REASONS = new Set([ @@ -876,7 +892,8 @@ export function parseRenderedWebglBrowserDom(value, expected) { candidate.environmentLighting !== 'procedural' ? 'environment-lighting' : '', candidate.semanticTerrainCellCount !== RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT ? 'semantic-terrain-cell-count' : '', - candidate.semanticTerrainKindCount !== 7 ? 'semantic-terrain-kind-count' : '', + candidate.semanticTerrainKindCount !== RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT + ? 'semantic-terrain-kind-count' : '', !terrainBudgets || !Number.isSafeInteger(candidate.semanticTerrainFeatureCount) || candidate.semanticTerrainFeatureCount < 1 @@ -2149,8 +2166,11 @@ const READ_DOM_EXPRESSION = `(() => { const inspectorProfileImage = inspector?.querySelector( 'canvas[data-profile-image-state]' ); + // Water endpoint controls intentionally share some list styling. Count only + // the semantically named castle list so source/mouth buttons cannot inflate + // or invalidate the exact 100-castle accessibility gate. const exploreCastleButtons = [...document.querySelectorAll( - '.realm-cell-navigator__castles button' + '.realm-cell-navigator__castles[aria-label="Founded castles"] > li > button' )].filter(visible); const exploreAccessibleCastleButtons = exploreCastleButtons.filter((button) => ( button instanceof HTMLButtonElement @@ -2362,7 +2382,11 @@ async function waitForAcceptedRenderedDom(session, expected, state) { `overflow=${String(value.labelClusterOverflowCount)}`, `portrait=${String(value.inspectorProfileImageState)}`, `models=${String(value.presentedModelCount)}`, - `bases=${String(value.presentedLandscapeBaseCount)}` + `bases=${String(value.presentedLandscapeBaseCount)}`, + `terrainKinds=${String(value.semanticTerrainKindCount)}`, + `terrainFeatures=${String(value.semanticTerrainFeatureCount)}`, + `exploreCastles=${String(value.exploreCastleCount)}`, + `exploreAccessible=${String(value.exploreAccessibleCastleCount)}` ].join(','); try { parseRenderedWebglBrowserDom(value, expected); diff --git a/src/components/realm/RealmAccessibilityControls.tsx b/src/components/realm/RealmAccessibilityControls.tsx index 69c2d433..c4676134 100644 --- a/src/components/realm/RealmAccessibilityControls.tsx +++ b/src/components/realm/RealmAccessibilityControls.tsx @@ -268,7 +268,7 @@ export function RealmAccessibilityControls({ ) : null} - + setSearch(event.currentTarget.value)} - placeholder="Player, castle, or coordinates" + placeholder="Player, castle, resource, water, or coordinates" /> {visibleCastles.length > 0 ? ( diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index f46fe0a0..faa0c098 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -1492,6 +1492,8 @@ function CanonicalRealmMapScreen({ sharedForestLayout: sharedForestProjection.layout, sharedForestTrees: sharedForestProjection.trees, waterCells, + waterBodies: snapshot.waterBodies, + waterEnvironment: snapshot.realmEnvironment, realmId: snapshot.realm.realmId, rendererGeneration, // The retired local planner is exposed only to the synthetic dev @@ -1647,7 +1649,7 @@ function CanonicalRealmMapScreen({ rendererRecoveryTimerRef.current = null; } }; - }, [foodNodeCatalog, goldNodeCatalog, handleSceneTargetHover, handleSceneTargetSelect, hasNearbyFoundingKeeps, isSceneCoordPassable, keepCoord, markRendererFailure, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, rendererRecoveryNonce, sharedForestProjection, snapshot.realm.realmId, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog]); + }, [foodNodeCatalog, goldNodeCatalog, handleSceneTargetHover, handleSceneTargetSelect, hasNearbyFoundingKeeps, isSceneCoordPassable, keepCoord, markRendererFailure, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, rendererRecoveryNonce, sharedForestProjection, snapshot.realm.realmId, snapshot.realmEnvironment, snapshot.waterBodies, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog]); useEffect(() => { sceneRef.current?.reconcileLiveGatheringState?.(liveGatheringState); diff --git a/src/components/realm/WaterInspectionPanel.tsx b/src/components/realm/WaterInspectionPanel.tsx index 578c47a7..1b7fc40b 100644 --- a/src/components/realm/WaterInspectionPanel.tsx +++ b/src/components/realm/WaterInspectionPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, + type KeyboardEvent, type Ref } from 'react'; @@ -75,6 +76,13 @@ export function WaterInspectionPanel({ ? `${record.riverPosition} · ${record.flowClass}` : `${record.oceanDepthClass} · ${record.fogBand} view`; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.repeat) return; + event.preventDefault(); + event.stopPropagation(); + onRequestClose(); + }; + return (