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 0000000..02ce14d --- /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 0000000..7a8bfa3 Binary files /dev/null and b/public/images/realm/hegemony-worker-record.webp differ diff --git a/src/components/WarpkeepExperience.tsx b/src/components/WarpkeepExperience.tsx index ffa45db..dfec3ee 100644 --- a/src/components/WarpkeepExperience.tsx +++ b/src/components/WarpkeepExperience.tsx @@ -1128,6 +1128,15 @@ export function WarpkeepExperience() { onClaimStoneExpedition={backend.state.stoneExpedition === undefined ? undefined : backend.claimStoneExpedition} + workerProjection={backend.state.workerProjection} + workerRoster={backend.state.workerRoster} + workerResourceState={backend.state.workerResourceState} + onRecallWorker={backend.state.workerProjection?.mode === 'active' + ? backend.recallWorker + : undefined} + onRecallAllWorkers={backend.state.workerProjection?.mode === 'active' + ? backend.recallAllWorkers + : undefined} graphicsPreference={graphicsPreference} resolvedGraphicsQuality={resolvedGraphicsQuality} audioMuted={audioMuted} diff --git a/src/components/realm/RealmHud.tsx b/src/components/realm/RealmHud.tsx index ceb7263..ce55850 100644 --- a/src/components/realm/RealmHud.tsx +++ b/src/components/realm/RealmHud.tsx @@ -35,6 +35,14 @@ import { type RealmEconomicResourceKey } from './realmResourcePresentation'; import './RealmPlayerChrome.css'; +import { WorkerCommandCenter } from './WorkerCommandCenter'; +import { WorkerInspectionPanel } from './WorkerInspectionPanel'; +import type { + ReadyWorkerProjection, + ReadyWorkerResourceState, + WorkerRosterPresentation, + RealmWorkerPublicPresentation +} from './realmWorkerPresentation'; type RealmHudProps = Readonly<{ identity: RealmIdentity; @@ -57,6 +65,11 @@ type RealmHudProps = Readonly<{ onRequestExplore?: () => 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; @@ -282,6 +288,11 @@ function CanonicalRealmMapScreen({ stoneExpedition, onDispatchStoneExpedition, onClaimStoneExpedition, + workerProjection, + workerRoster, + workerResourceState, + onRecallWorker, + onRecallAllWorkers, graphicsPreference, resolvedGraphicsQuality, audioMuted, @@ -1955,6 +1966,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 404e954..25c3945 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 0000000..3167906 --- /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 0000000..df6ab86 --- /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 0000000..1375fb5 --- /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 0000000..a1404b7 --- /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 d6ca352..2796d99 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 7e35355..58e16ab 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 0000000..e450594 --- /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 ae8dac8..7014c75 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 91fbb6c..ec460ff 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 353b711..ecce8cf 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 71f3bb1..1df8469 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 cfde15e..9ab0e3d 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 3c04151..f037ac1 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 9ea8aa1..76332de 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 0000000..83c5f7b --- /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); + }); +});