diff --git a/.gitignore b/.gitignore index 4e4b9b7b7..ce9e18618 100644 --- a/.gitignore +++ b/.gitignore @@ -117,6 +117,20 @@ Aetheria.State.Replica/bin/ Aetheria.State.Replica/obj/ Aetheria.State.Verify/bin/ Aetheria.State.Verify/obj/ +Aetheria.Rts.Web/bin/ +Aetheria.Rts.Web/obj/ +Aetheria.Rts.Web/electron-dist/ +Aetheria.Rts.Web/logs/ +Aetheria.Rts.Web/node_modules/ +Aetheria.Rts.Web/runtime/ +Aetheria.Rts.Web/Aetheria.Rts.Web/logs/ +Aetheria.Rts.Web/*.log +Aetheria.Rts.Web/wwwroot/*.js +Aetheria.State.AuthoritySmoke/bin/ +Aetheria.State.AuthoritySmoke/obj/ +Aetheria.State.Freeze/bin/ +Aetheria.State.Freeze/obj/ +Aetheria.State.Daemon/*.log # JetBrains Rider .idea folder .idea/ diff --git a/Aetheria.Rts.Web/Client/aetheria-rts-contract.ts b/Aetheria.Rts.Web/Client/aetheria-rts-contract.ts new file mode 100644 index 000000000..b75d06ea9 --- /dev/null +++ b/Aetheria.Rts.Web/Client/aetheria-rts-contract.ts @@ -0,0 +1,278 @@ +// +// Generated by scripts/generate-rts-bindings.mjs from the RTS CultMesh surface contract. + +import type { + CultMeshOperationReceipt, + CultMeshSurfaceCatalogDiagnostic, + CultMeshSurfaceCatalogIndexDiagnostic, + CultMeshViewportRequest, +} from "cultmesh-ts"; + +export type Viewport = CultMeshViewportRequest; + +export type AetheriaRuntimeSetMoveVectorRequest = { + actorEntityKey: string; + directionX: number; + directionY: number; + scalar: number; + observedFrameId?: number; +}; + +export type AetheriaRuntimeSetTargetRequest = { + actorEntityKey: string; + targetEntityKey: string; + observedFrameId?: number; +}; + +export type AetheriaRuntimeDaemonCommandReceipt = CultMeshOperationReceipt & { + commandId: string; +}; + +export type AetheriaRuntimeSelectedObjectRequest = { + entityIndex: number; +}; + +export type AetheriaRuntimeViewportFeedRequest = { + viewport: Viewport; + selectedEntityIndex?: number; +}; + +export type AetheriaRuntimeViewportFeedSnapshot = { + viewport: ViewportResponse; + selectedObject: SelectedObjectProjection | null; + inventory: InventoryProjection | null; + daemonHealth: DaemonHealthProjection; + authorityStatus: AuthorityStatusProjection; + starbridgeSession: StarbridgeSessionProjection; + receivedAtUtc: string; + sampleMs: number; +}; + +export type ViewportResponse = { + schema: string; + frameId: number; + publishedAtUtc: string; + simulationTimeSeconds: number; + runId: string; + zoneIndex: number; + zoneName: string; + currentEntityKey: string; + viewport: Viewport; + controlledEntityIndices: number[]; + objects: ViewObject[]; + gravityInfluences: GravityInfluence[]; + bodies: BodyView[]; +}; + +export type ObjectsViewportResponse = { + schema: string; + frameId: number; + publishedAtUtc: string; + simulationTimeSeconds: number; + runId: string; + zoneIndex: number; + zoneName: string; + currentEntityKey: string; + viewport: Viewport; + controlledEntityIndices: number[]; + objects: ViewObject[]; +}; + +export type GravityViewportResponse = { + schema: string; + frameId: number; + publishedAtUtc: string; + simulationTimeSeconds: number; + runId: string; + zoneIndex: number; + zoneName: string; + viewport: Viewport; + gravityInfluences: GravityInfluence[]; + bodies: BodyView[]; +}; + +export type ViewObject = { + entityIndex: number; + entityKey: string; + displayName: string; + kind: string; + factionKey: string; + x: number; + y: number; + z: number; + directionX: number; + directionY: number; + velocityX: number; + velocityY: number; + controlled: boolean; + targetEntityIndex: number; + isActive: boolean; + visibility: number; + status: EntityStatus; + inventory: InventoryItem[]; +}; + +export type EntityStatus = { + hull: number; + shield: number; + heat: number; +}; + +export type InventoryItem = { + source: string; + itemKey: string; + quantity: number; + quality: number; + durability: number; + enabled: boolean; +}; + +export type SelectedObjectProjection = { + schema: string; + frameId: number; + runId: string; + zoneIndex: number; + entityIndex: number; + selected: ViewObject | null; +}; + +export type InventoryProjection = { + schema: string; + frameId: number; + runId: string; + zoneIndex: number; + entityIndex: number; + entityKey: string; + items: InventoryItem[]; + equipment: InventoryItem[]; + cargo: InventoryItem[]; +}; + +export type DaemonHealthProjection = { + schema: string; + daemonId: string; + verseId: string; + publishedAtUtc: string; + statePath: string; + frameId: number; + observedCommandCount: number; + appliedCommandCount: number; + rejectedCommandCount: number; + status: string; + publicationSource: string; + transport: string; + commandBoundaryPath: string; +}; + +export type AuthorityStatusProjection = { + schema: string; + verseId: string; + policyId: string; + ruleVersion: string; + hostRuntimeId: string; + defaultMode: string; + updatedAtUtc: string; + rules: AuthorityRuleProjection[]; +}; + +export type AuthorityRuleProjection = { + ruleId: string; + subjectPrefix: string; + claimKinds: string[]; + mode: string; + runtimeIds: string[]; + leaseScope: string; + priority: number; +}; + +export type StarbridgeSessionProjection = { + schema: string; + frameId: number; + publishedAtUtc: string; + sessionId: string; + scenarioId: string; + scenarioName: string; + runId: string; + zoneIndex: number; + zoneName: string; + phase: string; + currentWaveIndex: number; + baseStatus: StarbridgeBaseStatusProjection; + stationStock: StarbridgeStationStockItemProjection[]; + waveForecast: StarbridgeWaveForecastProjection[]; + runtimeRoles: StarbridgeRuntimeRoleProjection[]; +}; + +export type StarbridgeBaseStatusProjection = { + entityKey: string; + displayName: string; + hull: number; + shield: number; + heat: number; + isActive: boolean; +}; + +export type StarbridgeStationStockItemProjection = { + itemKey: string; + quantity: number; + quality: number; + durability: number; + source: string; +}; + +export type StarbridgeWaveForecastProjection = { + waveIndex: number; + displayName: string; + attackerKeys: string[]; + bossKey: string; + recoveredTechnologyKeys: string[]; +}; + +export type StarbridgeRuntimeRoleProjection = { + runtimeId: string; + role: string; + entityKey: string; +}; + +export type GravityInfluence = { + bodyKey: string; + orbitKey: string; + kind: string; + x: number; + y: number; + radius: number; + gravityDepth: number; + gravityDepthExponent: number; + waveRadius: number; + waveDepth: number; + waveSpeed: number; +}; + +export type BodyView = { + bodyKey: string; + orbitKey: string; + name: string; + kind: string; + x: number; + y: number; + radius: number; + isAsteroidBelt: boolean; +}; + + +export type AetheriaRtsApi = { + mapViewport(request: Viewport): Promise; + objectsViewport(request: Viewport): Promise; + gravityViewport(request: Viewport): Promise; + selectedObject(request: AetheriaRuntimeSelectedObjectRequest): Promise; + inventory(request: AetheriaRuntimeSelectedObjectRequest): Promise; + daemonHealth(): Promise; + authorityStatus(): Promise; + starbridgeSession(): Promise; + watchViewportFeed(request: AetheriaRuntimeViewportFeedRequest, callback: (snapshot: AetheriaRuntimeViewportFeedSnapshot) => void): () => void; + setMoveVector(request: AetheriaRuntimeSetMoveVectorRequest): Promise; + setTarget(request: AetheriaRuntimeSetTargetRequest): Promise; + surfaceCatalog(): Promise; + surfaceCatalogIndex(): Promise; + health(): Promise; +}; diff --git a/Aetheria.Rts.Web/Client/app.ts b/Aetheria.Rts.Web/Client/app.ts new file mode 100644 index 000000000..30aade699 --- /dev/null +++ b/Aetheria.Rts.Web/Client/app.ts @@ -0,0 +1,590 @@ +import type { + AetheriaRtsApi, + AuthorityStatusProjection, + AetheriaRuntimeViewportFeedSnapshot, + BodyView, + DaemonHealthProjection, + GravityInfluence, + InventoryItem, + InventoryProjection, + SelectedObjectProjection, + StarbridgeSessionProjection, + ViewObject, + Viewport, + ViewportResponse, +} from "./aetheria-rts-contract.js"; + +export {}; + +declare global { + interface Window { + aetheriaRts: AetheriaRtsApi; + } +} + +const canvas = requiredElement("#map"); +const statusEl = requiredElement("#status"); +const zoomLabel = requiredElement("#zoom-label"); +const selectionTitle = requiredElement("#selection-title"); +const selectionDetails = requiredElement("#selection-details"); +const frameDetails = requiredElement("#frame-details"); +const starbridgeDetails = requiredElement("#starbridge-details"); +const runtimeSurfaceDetails = requiredElement("#runtime-surface-details"); +const controlledList = requiredElement("#controlled-list"); +const ctx = requiredContext(canvas); + +let zoom = 1; +let centerX = 0; +let centerY = 0; +let selectedEntityIndex = -1; +let latest: ViewportResponse | null = null; +let latestSelected: SelectedObjectProjection | null = null; +let latestInventory: InventoryProjection | null = null; +let latestDaemonHealth: DaemonHealthProjection | null = null; +let latestAuthority: AuthorityStatusProjection | null = null; +let latestStarbridge: StarbridgeSessionProjection | null = null; +let latestSurfaceIndex: Awaited> | null = null; +let latestOperationReceipt: string | null = null; +let latestReceivedAt = 0; +let latestPollMs = 0; +let refreshInFlight = false; +let viewportFeedUnsubscribe: (() => void) | null = null; + +function requiredElement(selector: string): TElement { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`Aetheria RTS shell is missing ${selector}.`); + } + + return element; +} + +function requiredContext(target: HTMLCanvasElement): CanvasRenderingContext2D { + const context = target.getContext("2d"); + if (!context) { + throw new Error("Canvas 2D context is unavailable."); + } + + return context; +} + +const worldSpan = () => 2200 / zoom; + +function currentViewport(): Viewport { + const span = worldSpan(); + const aspect = Math.max(0.25, canvas.clientWidth / Math.max(1, canvas.clientHeight)); + const width = span * aspect; + const height = span; + return { + minX: centerX - width * 0.5, + minY: centerY - height * 0.5, + maxX: centerX + width * 0.5, + maxY: centerY + height * 0.5 + }; +} + +function setStatus(text: string): void { + statusEl.textContent = text; +} + +function startViewportFeed(): void { + const viewport = currentViewport(); + viewportFeedUnsubscribe?.(); + viewportFeedUnsubscribe = window.aetheriaRts.watchViewportFeed({ + viewport, + selectedEntityIndex + }, applyViewportSnapshot); +} + +function applyViewportSnapshot(snapshot: AetheriaRuntimeViewportFeedSnapshot): void { + latestPollMs = snapshot.sampleMs; + latestReceivedAt = Date.parse(snapshot.receivedAtUtc) || Date.now(); + latest = snapshot.viewport; + latestSelected = snapshot.selectedObject; + latestInventory = snapshot.inventory; + latestDaemonHealth = snapshot.daemonHealth; + latestAuthority = snapshot.authorityStatus; + latestStarbridge = snapshot.starbridgeSession; + + const controlled = snapshot.viewport.objects.find(object => object.controlled); + if (controlled && selectedEntityIndex < 0) { + selectedEntityIndex = controlled.entityIndex; + centerX = controlled.x; + centerY = controlled.y; + startViewportFeed(); + return; + } + + setStatus(`${snapshot.viewport.zoneName || `Zone ${snapshot.viewport.zoneIndex}`} frame ${snapshot.viewport.frameId}`); + render(); + renderPanels(); +} + +async function refreshRuntimeSurfaces(): Promise { + try { + latestSurfaceIndex = await window.aetheriaRts.surfaceCatalogIndex(); + renderPanels(); + } catch (error) { + runtimeSurfaceDetails.innerHTML = details([ + ["State", error instanceof Error ? error.message : "unavailable"] + ]); + } +} + +function resizeCanvas(): void { + const ratio = Math.max(1, window.devicePixelRatio || 1); + const width = Math.max(1, Math.floor(canvas.clientWidth * ratio)); + const height = Math.max(1, Math.floor(canvas.clientHeight * ratio)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); +} + +function worldToScreen(x: number, y: number): { x: number; y: number } { + const viewport = currentViewport(); + const sx = (x - viewport.minX) / (viewport.maxX - viewport.minX) * canvas.clientWidth; + const sy = canvas.clientHeight - (y - viewport.minY) / (viewport.maxY - viewport.minY) * canvas.clientHeight; + return { x: sx, y: sy }; +} + +function screenToWorld(x: number, y: number): { x: number; y: number } { + const viewport = currentViewport(); + return { + x: viewport.minX + x / Math.max(1, canvas.clientWidth) * (viewport.maxX - viewport.minX), + y: viewport.minY + (1 - y / Math.max(1, canvas.clientHeight)) * (viewport.maxY - viewport.minY) + }; +} + +function worldRadiusToScreen(radius: number): number { + const viewport = currentViewport(); + return radius / (viewport.maxX - viewport.minX) * canvas.clientWidth; +} + +function render(): void { + resizeCanvas(); + ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); + drawGrid(); + if (!latest) { + return; + } + + for (const influence of latest.gravityInfluences) { + drawGravity(influence); + } + for (const body of latest.bodies) { + drawBody(body); + } + for (const object of latest.objects) { + drawObject(object); + } +} + +function drawGrid(): void { + const viewport = currentViewport(); + const spacing = chooseGridSpacing((viewport.maxX - viewport.minX) / 8); + const startX = Math.floor(viewport.minX / spacing) * spacing; + const startY = Math.floor(viewport.minY / spacing) * spacing; + ctx.lineWidth = 1; + ctx.strokeStyle = "#1b242a"; + ctx.beginPath(); + for (let x = startX; x <= viewport.maxX; x += spacing) { + const screen = worldToScreen(x, viewport.minY); + ctx.moveTo(screen.x, 0); + ctx.lineTo(screen.x, canvas.clientHeight); + } + for (let y = startY; y <= viewport.maxY; y += spacing) { + const screen = worldToScreen(viewport.minX, y); + ctx.moveTo(0, screen.y); + ctx.lineTo(canvas.clientWidth, screen.y); + } + ctx.stroke(); +} + +function chooseGridSpacing(target: number): number { + const power = 10 ** Math.floor(Math.log10(Math.max(1, target))); + const scaled = target / power; + if (scaled > 5) return power * 10; + if (scaled > 2) return power * 5; + return power * 2; +} + +function drawGravity(influence: GravityInfluence): void { + const screen = worldToScreen(influence.x, influence.y); + const radius = worldRadiusToScreen(influence.radius); + ctx.lineWidth = 1; + ctx.strokeStyle = influence.kind === "Sun" ? "rgba(246, 192, 82, 0.28)" : "rgba(97, 174, 197, 0.24)"; + ctx.fillStyle = influence.kind === "Sun" ? "rgba(246, 192, 82, 0.05)" : "rgba(97, 174, 197, 0.04)"; + ctx.beginPath(); + ctx.arc(screen.x, screen.y, Math.max(1, radius), 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); +} + +function drawBody(body: BodyView): void { + const screen = worldToScreen(body.x, body.y); + const radius = Math.max(3, Math.min(28, worldRadiusToScreen(body.radius) * 0.025)); + ctx.fillStyle = body.kind === "sun" ? "#f4bc5d" : body.isAsteroidBelt ? "#9d9080" : "#71a8b7"; + ctx.beginPath(); + ctx.arc(screen.x, screen.y, radius, 0, Math.PI * 2); + ctx.fill(); +} + +function drawObject(object: ViewObject): void { + const screen = worldToScreen(object.x, object.y); + const selected = object.entityIndex === selectedEntityIndex; + const station = object.kind.toLowerCase().includes("station"); + const size = selected ? 9 : object.controlled ? 7 : 5; + ctx.save(); + ctx.translate(screen.x, screen.y); + ctx.rotate(Math.atan2(object.directionY, object.directionX)); + ctx.fillStyle = object.isActive + ? selected ? "#ffffff" : object.controlled ? "#54d18a" : object.factionKey === "raider" ? "#e36b4b" : "#aeb7bd" + : "#56616a"; + ctx.strokeStyle = "#0d1013"; + ctx.lineWidth = 2; + ctx.beginPath(); + if (station) { + ctx.rect(-size, -size, size * 2, size * 2); + } else { + ctx.moveTo(size + 4, 0); + ctx.lineTo(-size, size * 0.7); + ctx.lineTo(-size * 0.45, 0); + ctx.lineTo(-size, -size * 0.7); + } + ctx.closePath(); + ctx.stroke(); + ctx.fill(); + ctx.restore(); + + if (object.targetEntityIndex >= 0) { + const target = latest?.objects.find(candidate => candidate.entityIndex === object.targetEntityIndex); + if (target) { + const targetScreen = worldToScreen(target.x, target.y); + ctx.strokeStyle = object.controlled ? "rgba(84, 209, 138, 0.42)" : "rgba(227, 107, 75, 0.36)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(screen.x, screen.y); + ctx.lineTo(targetScreen.x, targetScreen.y); + ctx.stroke(); + } + } + + if (selected) { + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(screen.x, screen.y, 14, 0, Math.PI * 2); + ctx.stroke(); + } +} + +function renderPanels(): void { + if (!latest) { + return; + } + + const selected = latestSelected?.selected ?? null; + selectionTitle.textContent = selected?.displayName || "No Selection"; + selectionDetails.innerHTML = selected + ? details([ + ["Entity", selected.entityIndex.toString()], + ["Key", selected.entityKey], + ["Kind", selected.kind || "unknown"], + ["Faction", selected.factionKey || "unknown"], + ["State", selected.isActive ? "active" : "disabled"], + ["Hull", selected.status.hull.toFixed(1)], + ["Shield", selected.status.shield.toFixed(1)], + ["Heat", selected.status.heat.toFixed(1)], + ["Position", `${selected.x.toFixed(1)}, ${selected.y.toFixed(1)}, z ${selected.z.toFixed(1)}`], + ["Velocity", `${selected.velocityX.toFixed(2)}, ${selected.velocityY.toFixed(2)}`], + ["Target", selected.targetEntityIndex >= 0 ? `#${selected.targetEntityIndex}` : "none"], + ["Controlled", selected.controlled ? "yes" : "no"], + ["Equipment", inventoryText(latestInventory?.equipment ?? [])], + ["Cargo", inventoryText(latestInventory?.cargo ?? [])] + ]) + : `
State
Click a visible object on the map.
`; + + frameDetails.innerHTML = details([ + ["Run", latest.runId], + ["Zone", `${latest.zoneIndex}`], + ["Frame", latest.frameId.toString()], + ["Sim Time", `${latest.simulationTimeSeconds.toFixed(2)}s`], + ["Frame Age", `${Math.max(0, Date.now() - latestReceivedAt)}ms`], + ["Viewport Poll", `${latestPollMs.toFixed(1)}ms`], + ["Visible", latest.objects.length.toString()], + ["Daemon", latestDaemonHealth ? `${latestDaemonHealth.status} / ${latestDaemonHealth.transport}` : "unknown"], + ["Authority", latestAuthority ? latestAuthority.policyId : "unknown"], + ["Mode", latestAuthority ? latestAuthority.defaultMode : "unknown"], + ["Last Op", latestOperationReceipt ?? "none"] + ]); + + runtimeSurfaceDetails.innerHTML = latestSurfaceIndex + ? details([ + ["Catalog", latestSurfaceIndex.catalogId], + ["Queries", surfaceListText(latestSurfaceIndex.queries)], + ["Live Feeds", surfaceListText(latestSurfaceIndex.liveFeeds)], + ["Operations", surfaceListText(latestSurfaceIndex.operations)], + ["Pointers", surfaceListText(latestSurfaceIndex.statePointers)], + ["Native Views", surfaceListText(latestSurfaceIndex.nativeSliceViews)] + ]) + : `
State
Discovering runtime surfaces.
`; + + starbridgeDetails.innerHTML = latestStarbridge + ? details([ + ["Scenario", latestStarbridge.scenarioName || latestStarbridge.scenarioId], + ["Phase", latestStarbridge.phase], + ["Wave", latestStarbridge.currentWaveIndex.toString()], + ["Base", latestStarbridge.baseStatus.displayName || "unknown"], + ["Hull", latestStarbridge.baseStatus.hull.toFixed(1)], + ["Shield", latestStarbridge.baseStatus.shield.toFixed(1)], + ["Heat", latestStarbridge.baseStatus.heat.toFixed(1)], + ["Next", waveText(latestStarbridge)], + ["Stock", stockText(latestStarbridge)], + ["Roles", roleText(latestStarbridge)] + ]) + : `
State
No Starbridge session.
`; + + controlledList.innerHTML = ""; + const controlledObjects = latest.objects.filter(object => object.controlled); + if (controlledObjects.length === 0) { + controlledList.innerHTML = `No controlled units in viewport.`; + return; + } + + for (const object of controlledObjects) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "controlled-row"; + row.innerHTML = `${escapeHtml(object.displayName || object.entityKey)}#${object.entityIndex}`; + row.addEventListener("click", () => { + selectedEntityIndex = object.entityIndex; + centerX = object.x; + centerY = object.y; + void refresh(); + }); + controlledList.append(row); + } +} + +function inventoryText(items: InventoryItem[]): string { + if (items.length === 0) { + return "empty"; + } + + return items + .map(item => `${item.itemKey} x${item.quantity}`) + .join(", "); +} + +function details(rows: Array<[string, string]>): string { + return rows + .map(([key, value]) => `
${escapeHtml(key)}
${escapeHtml(value)}
`) + .join(""); +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, character => { + switch (character) { + case "&": return "&"; + case "<": return "<"; + case ">": return ">"; + case "\"": return """; + case "'": return "'"; + default: return character; + } + }); +} + +canvas.addEventListener("click", event => { + if (!latest) { + return; + } + + const rect = canvas.getBoundingClientRect(); + const click = screenToWorld(event.clientX - rect.left, event.clientY - rect.top); + const nearest = nearestObject(click.x, click.y); + + if (nearest && worldRadiusToScreen(nearest.distance) < 24) { + selectedEntityIndex = nearest.object.entityIndex; + void refresh(); + } +}); + +canvas.addEventListener("contextmenu", event => { + event.preventDefault(); + if (!latest) { + return; + } + + const actor = latest.objects.find(object => object.entityIndex === selectedEntityIndex && object.controlled); + if (!actor) { + setStatus("Select an owned pawn or station first."); + return; + } + + const rect = canvas.getBoundingClientRect(); + const click = screenToWorld(event.clientX - rect.left, event.clientY - rect.top); + const nearest = nearestObject(click.x, click.y); + if (nearest && nearest.object.entityIndex !== actor.entityIndex && worldRadiusToScreen(nearest.distance) < 28) { + void issueTargetCommand(actor, nearest.object); + return; + } + + void issueMoveCommand(actor, click.x, click.y); +}); + +function nearestObject(x: number, y: number): { object: ViewObject; distance: number } | null { + if (!latest) { + return null; + } + + let nearest: ViewObject | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const object of latest.objects) { + const dx = object.x - x; + const dy = object.y - y; + const distance = Math.hypot(dx, dy); + if (distance < nearestDistance) { + nearest = object; + nearestDistance = distance; + } + } + + return nearest ? { object: nearest, distance: nearestDistance } : null; +} + +async function issueTargetCommand(actor: ViewObject, target: ViewObject): Promise { + const receipt = await window.aetheriaRts.setTarget({ + actorEntityKey: actor.entityKey, + targetEntityKey: target.entityKey, + observedFrameId: latest?.frameId + }); + latestOperationReceipt = formatOperationReceipt("setTarget", receipt, `${actor.entityKey} -> ${target.entityKey}`); + setStatus(latestOperationReceipt); + await refresh(); +} + +async function issueMoveCommand(actor: ViewObject, x: number, y: number): Promise { + const dx = x - actor.x; + const dy = y - actor.y; + const length = Math.max(0.001, Math.hypot(dx, dy)); + const receipt = await window.aetheriaRts.setMoveVector({ + actorEntityKey: actor.entityKey, + directionX: dx / length, + directionY: dy / length, + scalar: 1, + observedFrameId: latest?.frameId + }); + latestOperationReceipt = formatOperationReceipt("setMoveVector", receipt, `${actor.entityKey} ${dx.toFixed(1)},${dy.toFixed(1)}`); + setStatus(latestOperationReceipt); + await refresh(); +} + +function formatOperationReceipt( + label: string, + receipt: Awaited>, + detail: string +): string { + const accepted = receipt.accepted ? "accepted" : "rejected"; + return `${label} ${receipt.operationId} ${accepted} via ${receipt.route.kind} ${receipt.commandId} ${detail} @ frame ${latest?.frameId ?? "?"}`; +} + +document.querySelector("#zoom-in")?.addEventListener("click", () => { + zoom = Math.min(12, zoom * 1.4); + updateZoomLabel(); + void refresh(); +}); + +document.querySelector("#zoom-out")?.addEventListener("click", () => { + zoom = Math.max(0.2, zoom / 1.4); + updateZoomLabel(); + void refresh(); +}); + +document.querySelector("#refresh")?.addEventListener("click", () => { + void refresh(); +}); + +window.addEventListener("resize", () => { + render(); +}); + +function updateZoomLabel(): void { + zoomLabel.value = `${zoom.toFixed(1)}x`; +} + +async function refresh(): Promise { + if (refreshInFlight) { + return; + } + + refreshInFlight = true; + try { + startViewportFeed(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "Unable to fetch viewport"); + } finally { + refreshInFlight = false; + } +} + +function surfaceListText(surfaces: ReadonlyArray<{ + readonly surfaceId: string; + readonly routeHint: { readonly kind: string; readonly description?: string }; + readonly sources: ReadonlyArray<{ readonly sourceId: string; readonly schemaId?: string }>; +}>): string { + if (surfaces.length === 0) { + return "none"; + } + + return surfaces + .map(surface => { + const route = surface.routeHint.description + ? `${surface.routeHint.kind} ${surface.routeHint.description}` + : surface.routeHint.kind; + const sources = surface.sources.length === 0 + ? "no sources" + : surface.sources + .map(source => source.schemaId ? `${source.sourceId} (${source.schemaId})` : source.sourceId) + .join(" + "); + return `${surface.surfaceId} -> ${route}; ${sources}`; + }) + .join(", "); +} + +function stockText(session: StarbridgeSessionProjection): string { + if (session.stationStock.length === 0) { + return "empty"; + } + + return session.stationStock + .slice(0, 4) + .map(item => `${item.itemKey} x${item.quantity}`) + .join(", "); +} + +function waveText(session: StarbridgeSessionProjection): string { + const wave = session.waveForecast[0]; + if (!wave) { + return "none"; + } + + const attackers = wave.attackerKeys.length > 0 ? ` / ${wave.attackerKeys.join(", ")}` : ""; + return `${wave.displayName || `Wave ${wave.waveIndex}`}${attackers}`; +} + +function roleText(session: StarbridgeSessionProjection): string { + if (session.runtimeRoles.length === 0) { + return "none"; + } + + return session.runtimeRoles + .map(role => `${role.role}:${role.runtimeId}`) + .join(", "); +} + +updateZoomLabel(); +void refreshRuntimeSurfaces(); +void refresh(); diff --git a/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts b/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts new file mode 100644 index 000000000..f410c7206 --- /dev/null +++ b/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts @@ -0,0 +1,443 @@ +import { encode } from "@msgpack/msgpack"; +import { performance } from "node:perf_hooks"; +import { CultMesh } from "cultmesh-ts"; +import type { + CultMeshOperationContext, + CultMeshQueryContext, + CultMeshSurfaceCatalogIndexDiagnostic, + CultMeshVerse, +} from "cultmesh-ts"; +import type { + CultNetDocumentPutRawMessage, + CultNetPeer, +} from "cultnet-ts"; +import { + AetheriaRtsSchemas, + createAetheriaRuntimeRtsOperationHandles, + createAetheriaRuntimeRtsVerseFacade, + createAetheriaRuntimeRtsQueryHandles, + createAetheriaRuntimeRtsStatePointers, + describeAetheriaRuntimeRtsLiveFeedSurface, + describeAetheriaRuntimeRtsQueryHandles, + describeAetheriaRuntimeRtsSurfaceCatalog, + type AetheriaRuntimeDaemonCommandReceipt, + type AuthorityStatusProjection, + type DaemonHealthProjection, + type GravityViewportResponse, + type InventoryProjection, + type ObjectsViewportResponse, + type AetheriaRuntimeRtsLiveFeedDiagnostic, + type AetheriaRuntimeRtsProjectionDiagnostic, + type AetheriaRuntimeRtsSurfaceCatalogDiagnostic, + type AetheriaRuntimeRtsOperationHandles, + type AetheriaRuntimeRtsVerseFacade, + type AetheriaRuntimeRtsStatePointers, + type AetheriaRuntimeViewportFeedRequest, + type AetheriaRuntimeViewportFeedSnapshot, + type RtsSetMoveVectorRequest, + type RtsSetTargetRequest, + type SelectedObjectProjection, + type SelectedObjectRequest, + type StarbridgeSessionProjection, + type ViewportRequest, + type ViewportResponse, +} from "./aetheria-rts-bindings.js"; +import { AetheriaLocalPublicationReader } from "./aetheria-local-publication-reader.js"; +import { AetheriaRemotePublicationReader } from "./aetheria-remote-publication-reader.js"; +import { + projectAuthorityStatus, + projectDaemonHealth, + projectGravityViewportFromFrame, + projectInventoryFromFrame, + projectObjectsViewportFromFrame, + projectSelectedObjectFromFrame, + projectStarbridgeSessionSummary, + projectViewportFromFrame, +} from "./aetheria-rts-local-projection.js"; + +const connectionId = 0x43554c54; + +type AetheriaPublicationReader = { + readonly statePathDescription: string; + readDaemonFrame(): Promise; + readDaemonHealth(): Promise; + readAuthorityPolicy(): Promise; + readStarbridgeSessionSummary(): Promise; + close?(): Promise; +}; + +export type AetheriaCultMeshClientOptions = { + publicationMode?: "local" | "remote"; + snapshotTimeoutMs?: number; +}; + +export type { + BodyView, + EntityStatus, + GravityViewportResponse, + GravityInfluence, + InventoryItem, + ObjectsViewportResponse, + AetheriaRuntimeViewportFeedRequest, + AetheriaRuntimeViewportFeedSnapshot, + RtsSetMoveVectorRequest, + RtsSetTargetRequest, + SelectedObjectProjection, + SelectedObjectRequest, + StarbridgeSessionProjection, + ViewObject, + ViewportRequest, + ViewportResponse, +} from "./aetheria-rts-bindings.js"; + +export class AetheriaCultMeshClient { + #peer: CultNetPeer | null = null; + private readonly verse: CultMeshVerse; + private readonly queryVerse: CultMeshVerse; + private readonly commandVerse: CultMeshVerse; + + public constructor( + private readonly endpoint: string, + statePath: string, + private readonly runtimeId = "aetheria-rts-electron", + options: AetheriaCultMeshClientOptions = {}, + ) { + const publicationMode = options.publicationMode ?? "local"; + this.publications = publicationMode === "remote" + ? new AetheriaRemotePublicationReader( + this.endpoint, + `${this.runtimeId}-reader`, + options.snapshotTimeoutMs) + : new AetheriaLocalPublicationReader(statePath); + this.verse = CultMesh.verse("aetheria.local", this.runtimeId); + this.queryVerse = publicationMode === "remote" + ? this.verse.withRoute("network", this.publications.statePathDescription) + : this.verse.withRoute("shared-memory", this.publications.statePathDescription); + this.commandVerse = this.verse + .withRoute("network", this.endpoint) + .withClaim("commander-control", { shardId: "aetheria.local" }); + const executors = { + mapViewport: async (request: ViewportRequest) => projectViewportFromFrame(await this.fetchLatestFrameDocument(), request), + objectsViewport: async (request: ViewportRequest) => projectObjectsViewportFromFrame(await this.fetchLatestFrameDocument(), request), + gravityViewport: async (request: ViewportRequest) => projectGravityViewportFromFrame(await this.fetchLatestFrameDocument(), request), + selectedObject: async (request: SelectedObjectRequest) => projectSelectedObjectFromFrame(await this.fetchLatestFrameDocument(), request), + inventory: async (request: SelectedObjectRequest) => projectInventoryFromFrame(await this.fetchLatestFrameDocument(), request), + daemonHealth: async () => projectDaemonHealth(await this.fetchDaemonHealthDocument()), + authorityStatus: async () => projectAuthorityStatus(await this.fetchAuthorityPolicyDocument()), + starbridgeSession: async () => projectStarbridgeSessionSummary(await this.fetchStarbridgeSessionSummaryDocument()), + }; + this.queries = createAetheriaRuntimeRtsQueryHandles( + executors, + this.queryVerse.context.routeHint, + { + objectsViewport: CultMesh.pollingQueryWatcher(executors.objectsViewport, { intervalMs: 50 }), + gravityViewport: CultMesh.pollingQueryWatcher(executors.gravityViewport, { intervalMs: 50 }), + selectedObject: CultMesh.pollingQueryWatcher(executors.selectedObject, { intervalMs: 50 }), + inventory: CultMesh.pollingQueryWatcher(executors.inventory, { intervalMs: 50 }), + daemonHealth: CultMesh.pollingQueryWatcher(executors.daemonHealth, { intervalMs: 250 }), + authorityStatus: CultMesh.pollingQueryWatcher(executors.authorityStatus, { intervalMs: 250 }), + starbridgeSession: CultMesh.pollingQueryWatcher(executors.starbridgeSession, { intervalMs: 250 }), + }, + ); + this.statePointers = createAetheriaRuntimeRtsStatePointers( + this.queryVerse.context.routeHint, + { + daemonFrame: async () => this.fetchLatestFrameDocument(), + daemonHealth: async () => this.fetchDaemonHealthDocument(), + authorityPolicy: async () => this.fetchAuthorityPolicyDocument(), + starbridgeSession: async () => this.fetchStarbridgeSessionSummaryDocument(), + }, + ); + this.operations = createAetheriaRuntimeRtsOperationHandles( + (commandId, issuedAtUtc, command, context) => + this.sendCommandDocument(commandId, issuedAtUtc, command, context), + ); + this.aetheria = createAetheriaRuntimeRtsVerseFacade( + this.queryVerse.context, + this.commandVerse.context, + this.queries, + this.operations, + this.statePointers, + ); + } + + private readonly publications: AetheriaPublicationReader; + private readonly queries: ReturnType; + private readonly statePointers: AetheriaRuntimeRtsStatePointers; + private readonly operations: AetheriaRuntimeRtsOperationHandles; + private readonly aetheria: AetheriaRuntimeRtsVerseFacade; + + public async close(): Promise { + await this.publications.close?.(); + this.#peer?.close(); + this.#peer = null; + } + + public async waitForFrame(timeoutMs: number): Promise { + const started = Date.now(); + let lastError = ""; + while (Date.now() - started < timeoutMs) { + try { + await this.mapViewport(CultMesh.viewportRequest(CultMesh.rectFromBounds(-1000, -1000, 1000, 1000))); + return; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + await delay(250); + } + } + + throw new Error(`Timed out waiting for Aetheria CultMesh frame at ${this.endpoint}. ${lastError}`); + } + + public async mapViewport(request: ViewportRequest): Promise { + return this.aetheria.zone().viewport.within(request); + } + + public async objectsViewport(request: ViewportRequest): Promise { + return this.aetheria.zone().objects.visibleWithin(request); + } + + public async gravityViewport(request: ViewportRequest): Promise { + return this.aetheria.zone().gravity.within(request); + } + + public async selectedObject(request: SelectedObjectRequest): Promise { + return this.aetheria.selectedObject(request.entityIndex); + } + + public async inventory(request: SelectedObjectRequest): Promise { + return this.aetheria.inventory(request.entityIndex); + } + + public async daemonHealth(): Promise { + return this.aetheria.daemon.health(); + } + + public async authorityStatus(): Promise { + return this.aetheria.daemon.authorityStatus(); + } + + public async starbridgeSession(): Promise { + return this.aetheria.daemon.starbridgeSession(); + } + + public projectionDiagnostics(): Readonly> { + return describeAetheriaRuntimeRtsQueryHandles(this.queries); + } + + public liveFeedDiagnostics(): Readonly> { + return { + viewportFeed: describeAetheriaRuntimeRtsLiveFeedSurface(this.createViewportFeed()), + }; + } + + public surfaceCatalogDiagnostics(): AetheriaRuntimeRtsSurfaceCatalogDiagnostic { + const catalog = describeAetheriaRuntimeRtsSurfaceCatalog(this.queries, this.operations, this.statePointers); + return CultMesh.describeSurfaceCatalog(catalog.catalogId, [ + ...catalog.surfaces, + CultMesh.describeSurface(this.createViewportFeed()), + ]); + } + + public surfaceCatalogIndexDiagnostics(): CultMeshSurfaceCatalogIndexDiagnostic { + return CultMesh.surfaceCatalogIndex(this.surfaceCatalogDiagnostics()); + } + + public watchViewportFeed( + request: AetheriaRuntimeViewportFeedRequest, + callback: (snapshot: AetheriaRuntimeViewportFeedSnapshot) => void, + ): () => void { + return this.createViewportFeed().watch(request, this.queryContext(), callback); + } + + public async setMoveVector(request: RtsSetMoveVectorRequest): Promise { + return this.aetheria.entity(request.actorEntityKey).pilot.move( + CultMesh.vec2(request.directionX, request.directionY), + { + scalar: request.scalar, + observedFrameId: request.observedFrameId, + }, + ); + } + + public async setTarget(request: RtsSetTargetRequest): Promise { + return this.aetheria.entity(request.actorEntityKey).pilot.target(request.targetEntityKey, { + observedFrameId: request.observedFrameId, + }); + } + + private async fetchLatestFrameDocument(): Promise { + return this.publications.readDaemonFrame(); + } + + private async fetchDaemonHealthDocument(): Promise { + return this.publications.readDaemonHealth(); + } + + private async fetchAuthorityPolicyDocument(): Promise { + return this.publications.readAuthorityPolicy(); + } + + private async fetchStarbridgeSessionSummaryDocument(): Promise { + return this.publications.readStarbridgeSessionSummary(); + } + + private createViewportFeed() { + return CultMesh.liveFeed( + "gamecult.aetheria.rts.viewport_feed.v1", + async (request, context) => { + const objects = await this.queries.objectsViewport.execute(request.viewport, context); + return this.createViewportFeedSnapshot(request, objects); + }, + { + sources: [ + ...this.queries.objectsViewport.sources, + ...this.queries.gravityViewport.sources, + ...this.queries.daemonHealth.sources, + ...this.queries.authorityStatus.sources, + ...this.queries.starbridgeSession.sources, + ], + routeHint: this.queryVerse.context.routeHint, + watchFeed: (request, context, callback) => { + let disposed = false; + let sampling = false; + const unsubscribe = this.queries.objectsViewport.watch(request.viewport, context, objects => { + if (disposed || sampling) { + return; + } + + sampling = true; + void this.createViewportFeedSnapshot(request, objects) + .then(snapshot => { + if (!disposed) { + callback(snapshot); + } + }) + .finally(() => { + sampling = false; + }); + }); + + return () => { + disposed = true; + unsubscribe(); + }; + }, + }, + ); + } + + private async createViewportFeedSnapshot( + request: AetheriaRuntimeViewportFeedRequest, + objects: ObjectsViewportResponse, + ): Promise { + const startedAt = performance.now(); + const selectedEntityIndex = request.selectedEntityIndex ?? -1; + const [ + gravity, + daemonHealth, + authorityStatus, + starbridgeSession, + selectedObject, + inventory, + ] = await Promise.all([ + this.aetheria.zone().gravity.within(request.viewport), + this.aetheria.daemon.health(), + this.aetheria.daemon.authorityStatus(), + this.aetheria.daemon.starbridgeSession(), + selectedEntityIndex >= 0 + ? this.aetheria.selectedObject(selectedEntityIndex) + : Promise.resolve(null), + selectedEntityIndex >= 0 + ? this.aetheria.inventory(selectedEntityIndex) + : Promise.resolve(null), + ]); + + return { + viewport: composeViewport(objects, gravity), + selectedObject, + inventory, + daemonHealth, + authorityStatus, + starbridgeSession, + receivedAtUtc: new Date().toISOString(), + sampleMs: performance.now() - startedAt, + }; + } + + private queryContext(): CultMeshQueryContext { + return this.queryVerse.queryContext(); + } + + private async sendCommandDocument( + commandId: string, + issuedAtUtc: string, + command: unknown[], + context: CultMeshOperationContext, + ): Promise { + const message: CultNetDocumentPutRawMessage = { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: commandId, + document: { + schemaId: AetheriaRtsSchemas.daemonCommand, + recordKey: `daemon:commands:${stableToken(commandId)}:${AetheriaRtsSchemas.daemonCommand}`, + storedAt: issuedAtUtc, + payloadEncoding: "messagepack", + payload: encode(command), + sourceRuntimeId: context.runtimeId, + sourceRole: "rts-client", + tags: ["aetheria-rts"], + }, + }; + const peer = await this.peer(); + peer.send(message); + await delay(80); + } + + private async peer(): Promise { + if (this.#peer) + return this.#peer; + this.#peer = await CultMesh.createRudpPeer(this.runtimeId, connectionId, this.endpoint, { + connectTimeoutMs: 2000, + maxFragmentBytes: 1200, + maxPendingReliablePackets: 512, + }); + this.#peer.on("close", () => { + this.#peer = null; + }); + return this.#peer; + } +} + +function stableToken(value: string): string { + return value + .replace(/[^a-zA-Z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .replace(/--+/gu, "-") + .toLowerCase() || "empty"; +} + +function composeViewport( + objects: ObjectsViewportResponse, + gravity: GravityViewportResponse, +): ViewportResponse { + return { + schema: "gamecult.aetheria.rts_viewport.v1", + frameId: objects.frameId, + publishedAtUtc: objects.publishedAtUtc, + simulationTimeSeconds: objects.simulationTimeSeconds, + runId: objects.runId, + zoneIndex: objects.zoneIndex, + zoneName: objects.zoneName, + currentEntityKey: objects.currentEntityKey, + viewport: objects.viewport, + controlledEntityIndices: objects.controlledEntityIndices, + objects: objects.objects, + gravityInfluences: gravity.gravityInfluences, + bodies: gravity.bodies, + }; +} + +function delay(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} diff --git a/Aetheria.Rts.Web/Electron/aetheria-local-publication-reader.ts b/Aetheria.Rts.Web/Electron/aetheria-local-publication-reader.ts new file mode 100644 index 000000000..05f594a23 --- /dev/null +++ b/Aetheria.Rts.Web/Electron/aetheria-local-publication-reader.ts @@ -0,0 +1,36 @@ +import { decode } from "@msgpack/msgpack"; +import { SingleFileMessagePackBackingStore } from "cultcache-ts"; + +export class AetheriaLocalPublicationReader { + public constructor(private readonly statePath: string) {} + + public get statePathDescription(): string { + return this.statePath; + } + + public readDaemonFrame(): Promise { + return this.readSingleDocument(`${this.statePath}.daemon.frame.cc`, "gamecult.aetheria.daemon_frame.v1"); + } + + public readDaemonHealth(): Promise { + return this.readSingleDocument(`${this.statePath}.daemon.health.cc`, "gamecult.aetheria.daemon_health.v1"); + } + + public readAuthorityPolicy(): Promise { + return this.readSingleDocument(`${this.statePath}.authority.policy.cc`, "gamecult.aetheria.verse_authority_policy.v1"); + } + + public readStarbridgeSessionSummary(): Promise { + return this.readSingleDocument( + `${this.statePath}.daemon.starbridge.session.cc`, + "gamecult.aetheria.starbridge_session_summary.v1"); + } + + private async readSingleDocument(path: string, schemaId: string): Promise { + const records = await new SingleFileMessagePackBackingStore(path).pullAll(); + const record = records.find(candidate => candidate.schemaId === schemaId); + if (!record) + throw new Error(`Aetheria local publication ${path} did not contain schema ${schemaId}.`); + return decode(record.payload); + } +} diff --git a/Aetheria.Rts.Web/Electron/aetheria-remote-publication-reader.ts b/Aetheria.Rts.Web/Electron/aetheria-remote-publication-reader.ts new file mode 100644 index 000000000..ae9d725ad --- /dev/null +++ b/Aetheria.Rts.Web/Electron/aetheria-remote-publication-reader.ts @@ -0,0 +1,139 @@ +import { decode } from "@msgpack/msgpack"; +import { CultMesh } from "cultmesh-ts"; +import type { CultNetPeer } from "cultnet-ts"; +import type { + CultNetErrorMessage, + CultNetSnapshotResponseRawMessage, +} from "cultnet-ts"; + +const connectionId = 0x43554c54; + +export class AetheriaRemotePublicationReader { + #peer: CultNetPeer | null = null; + + public constructor( + private readonly endpoint: string, + private readonly runtimeId = "aetheria-rts-remote-reader", + private readonly timeoutMs = 2_500, + ) {} + + public get statePathDescription(): string { + return this.endpoint; + } + + public async close(): Promise { + this.#peer?.close(); + this.#peer = null; + } + + public readDaemonFrame(): Promise { + return this.readSingleDocument( + "gamecult.aetheria.daemon_frame.v1", + "daemon:aetheria.frame.latest.v1"); + } + + public readDaemonHealth(): Promise { + return this.readSingleDocument( + "gamecult.aetheria.daemon_health.v1", + "daemon:aetheria.health.v1"); + } + + public readAuthorityPolicy(): Promise { + return this.readSingleDocument( + "gamecult.aetheria.verse_authority_policy.v1", + "global:aetheria.verse_authority_policy.v1"); + } + + public readStarbridgeSessionSummary(): Promise { + return this.readSingleDocument( + "gamecult.aetheria.starbridge_session_summary.v1", + "daemon:aetheria.starbridge.session.latest.v1"); + } + + private async readSingleDocument(schemaId: string, recordKey: string): Promise { + const response = await this.requestSnapshot([recordKey]); + const document = response.documents.find(candidate => + candidate.recordKey === recordKey); + if (!document) { + throw new Error(`Aetheria remote publication ${this.endpoint} did not return ${schemaId} at ${recordKey}.`); + } + + return decode(toBytes(document.payload)); + } + + private async requestSnapshot( + recordKeys: readonly string[], + ): Promise { + const peer = await this.peer(); + const messageId = `${this.runtimeId}:snapshot:${Date.now()}:${Math.random().toString(16).slice(2)}`; + + return new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(timer); + peer.off("message", onMessage); + peer.off("invalidMessage", onInvalidMessage); + peer.off("error", onError); + peer.off("close", onClose); + }; + const rejectWith = (error: Error): void => { + cleanup(); + reject(error); + }; + const onMessage = (message: unknown): void => { + const response = message as CultNetSnapshotResponseRawMessage | CultNetErrorMessage; + if (response.schemaVersion === "cultnet.error.v0") { + cleanup(); + reject(new Error(response.error)); + return; + } + + if (response.schemaVersion !== "cultnet.snapshot_response_raw.v0" || + response.messageId !== messageId) { + return; + } + + cleanup(); + resolve(response); + }; + const onInvalidMessage = (error: Error): void => rejectWith(error); + const onError = (error: Error): void => rejectWith(error); + const onClose = (): void => rejectWith(new Error("CultMesh peer closed before snapshot response.")); + const timer = setTimeout( + () => rejectWith(new Error(`Timed out waiting for Aetheria remote snapshot ${messageId}.`)), + this.timeoutMs); + + peer.on("message", onMessage); + peer.on("invalidMessage", onInvalidMessage); + peer.on("error", onError); + peer.on("close", onClose); + peer.sendSnapshotRequest({ + schemaVersion: "cultnet.snapshot_request.v0", + messageId, + recordKeys: [...recordKeys], + }); + }); + } + + private async peer(): Promise { + if (this.#peer) + return this.#peer; + + this.#peer = await CultMesh.createRudpPeer(this.runtimeId, connectionId, this.endpoint, { + connectTimeoutMs: 2_000, + maxFragmentBytes: 1200, + maxPendingReliablePackets: 512, + }); + this.#peer.on("close", () => { + this.#peer = null; + }); + return this.#peer; + } +} + +function toBytes(value: unknown): Uint8Array { + if (value instanceof Uint8Array) + return value; + if (Array.isArray(value)) + return Uint8Array.from(value); + throw new Error("CultNet raw document payload was not binary."); +} diff --git a/Aetheria.Rts.Web/Electron/aetheria-rts-bindings.ts b/Aetheria.Rts.Web/Electron/aetheria-rts-bindings.ts new file mode 100644 index 000000000..7e1ae94f8 --- /dev/null +++ b/Aetheria.Rts.Web/Electron/aetheria-rts-bindings.ts @@ -0,0 +1,50 @@ +import type { CultMeshViewportRequest } from "cultmesh-ts"; + +export { + AetheriaRtsIpcChannels, + AetheriaRtsSchemas, + AetheriaRuntimeDaemonCommandKinds, + createAetheriaRuntimeRtsOperationHandles, + createAetheriaRuntimeRtsQueryHandles, + createAetheriaRuntimeRtsStatePointers, + createAetheriaRuntimeRtsVerseFacade, + describeAetheriaRuntimeRtsLiveFeedSurface, + describeAetheriaRuntimeRtsQueryHandles, + describeAetheriaRuntimeRtsSurfaceCatalog, +} from "./aetheria-rts-generated-bindings.js"; + +export type { + AetheriaRuntimeDaemonCommandReceipt, + AetheriaRuntimeRtsLiveFeedDiagnostic, + AetheriaRuntimeRtsOperationHandles, + AetheriaRuntimeRtsProjectionDiagnostic, + AetheriaRuntimeRtsStatePointerResolvers, + AetheriaRuntimeRtsStatePointers, + AetheriaRuntimeRtsSurfaceCatalogDiagnostic, + AetheriaRuntimeRtsVerseFacade, + AetheriaRuntimeSelectedObjectRequest as SelectedObjectRequest, + AetheriaRuntimeSetMoveVectorRequest as RtsSetMoveVectorRequest, + AetheriaRuntimeSetTargetRequest as RtsSetTargetRequest, + AetheriaRuntimeViewportFeedRequest, + AetheriaRuntimeViewportFeedSnapshot, + AuthorityRuleProjection, + AuthorityStatusProjection, + BodyView, + DaemonHealthProjection, + EntityStatus, + GravityInfluence, + GravityViewportResponse, + InventoryItem, + InventoryProjection, + ObjectsViewportResponse, + SelectedObjectProjection, + StarbridgeBaseStatusProjection, + StarbridgeRuntimeRoleProjection, + StarbridgeSessionProjection, + StarbridgeStationStockItemProjection, + StarbridgeWaveForecastProjection, + ViewObject, + ViewportResponse, +} from "./aetheria-rts-generated-bindings.js"; + +export type ViewportRequest = CultMeshViewportRequest; diff --git a/Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts b/Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts new file mode 100644 index 000000000..20f90043c --- /dev/null +++ b/Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts @@ -0,0 +1,2132 @@ +// +// Generated by scripts/generate-rts-bindings.mjs from Aetheria C# MessagePack documents. + +import { CultMesh } from "cultmesh-ts"; +import type { CultMeshLiveFeedDiagnostic, CultMeshOperationContext, CultMeshOperationReceipt, CultMeshProjectionSource, CultMeshQueryContext, CultMeshQuerySurfaceDiagnostic, CultMeshQueryWatcher, CultMeshRouteHint, CultMeshSurfaceCatalogDiagnostic, CultMeshSurfaceCatalogIndexDiagnostic, CultMeshUnsubscribe, CultMeshVerseContext, CultMeshViewportRequest } from "cultmesh-ts"; +import type { IpcMain } from "electron"; + +export const AetheriaRtsSchemas = { + daemonCommand: "gamecult.aetheria.daemon_command.v1", + eveCommand: "gamecult.eve.command.v1", + daemonFrame: "gamecult.aetheria.daemon_frame.v1", + daemonHealth: "gamecult.aetheria.daemon_health.v1", + rtsViewport: "gamecult.aetheria.rts_viewport.v1", + objectsViewport: "gamecult.aetheria.objects_viewport.v1", + gravityViewport: "gamecult.aetheria.gravity_viewport.v1", + currentZone: "gamecult.aetheria.current_zone.v1", + currentEntity: "gamecult.aetheria.current_entity.v1", + currentDocking: "gamecult.aetheria.current_docking.v1", + stationRefit: "gamecult.aetheria.station_refit.v1", + sectorMap: "gamecult.aetheria.sector_map.v1", + zoneRender: "gamecult.aetheria.zone_render.v1", + selectedObject: "gamecult.aetheria.selected_object.v1", + inventory: "gamecult.aetheria.inventory.v1", + starbridgeScenario: "gamecult.aetheria.starbridge_scenario.v1", + starbridgeSession: "gamecult.aetheria.starbridge_session.v1", + starbridgeSessionSummary: "gamecult.aetheria.starbridge_session_summary.v1", + verseAuthorityPolicy: "gamecult.aetheria.verse_authority_policy.v1", + authorityLease: "gamecult.aetheria.authority_lease.v1", +} as const; + +export const AetheriaRuntimeDaemonCommandKinds = { + none: 0, + ping: 1, + setTarget: 2, + clearTarget: 3, + targetNearest: 4, + targetNext: 5, + targetPrevious: 6, + targetReticle: 7, + setMoveVector: 8, + setLookDirection: 9, + setTractorPower: 10, + fireWeaponGroup: 11, + setWeaponGroupActive: 12, + setWeaponGroupMembership: 13, + setBehaviorActive: 14, + activateConsumable: 15, + sensorPing: 16, + setItemEnabled: 17, + setItemOverrideShutdown: 18, + setThermotoggleTargetTemperature: 19, + pickUpLoot: 20, + restoreLoadout: 21, + dock: 22, + dockNearest: 23, + undock: 24, + interact: 25, + setDockedCurrentShip: 26, + towToStation: 27, + enterWormhole: 28, + tradePurchase: 29, + transferCargoItem: 30, + equipItem: 31, + storeItem: 32, + toggleHullConductivity: 33, + setEntityName: 34, + destroyEntity: 35, + setOverrideShutdown: 36, + setHeatsinksEnabled: 37, + setShutdownPerformance: 38, + toggleShieldEnabled: 39, +} as const; + +export const AetheriaRuntimeEveCommandKinds = { + unknown: 0, + catalogRefresh: 1, + operationsRefresh: 2, + playerSettingsRefresh: 3, + setPlayerName: 4, + cycleTemperatureUnit: 5, + decrementSignificantDigits: 6, + incrementSignificantDigits: 7, + cycleNebulaQuality: 8, + toggleShowAsteroidsInMinimap: 9, + inputSettingsRefresh: 10, + beginInputCapture: 11, + cancelInputCapture: 12, + setBindingOverride: 13, + toggleActionBar: 14, + setActionBarEnabled: 15, + saveLoadoutTemplate: 16, + verseHostRefresh: 17, + cycleVerseHostVisibility: 18, + tradeValuePolicyRefresh: 19, + setTradeValueQualityMinimum: 20, + setTradeValueQualityMaximum: 21, + setTradeValueQualityExponent: 22, + setTradeValueTierQuality: 23, +} as const; + +export const AetheriaRtsIpcChannels = { + mapViewport: "aetheria-rts:map-viewport", + objectsViewport: "aetheria-rts:objects-viewport", + gravityViewport: "aetheria-rts:gravity-viewport", + viewportFeed: "aetheria-rts:viewport-feed", + viewportFeedStop: "aetheria-rts:viewport-feed-stop", + viewportFeedUpdate: "aetheria-rts:viewport-feed-update", + selectedObject: "aetheria-rts:selected-object", + inventory: "aetheria-rts:inventory", + daemonHealth: "aetheria-rts:daemon-health", + authorityStatus: "aetheria-rts:authority-status", + starbridgeSession: "aetheria-rts:starbridge-session", + setMoveVector: "aetheria-rts:set-move-vector", + setTarget: "aetheria-rts:set-target", + surfaceCatalog: "aetheria-rts:surface-catalog", + surfaceCatalogIndex: "aetheria-rts:surface-catalog-index", + health: "aetheria-rts:health", +} as const; + +export type AetheriaRuntimeDaemonCommandDocumentSlot = + | "schema" + | "commandId" + | "clientId" + | "issuedAtUtc" + | "sessionId" + | "observedFrameId" + | "kind" + | "actorEntityKey" + | "targetEntityKey" + | "targetZoneIndex" + | "equipmentIndex" + | "behaviorIndex" + | "weaponGroup" + | "positionX" + | "positionY" + | "positionZ" + | "directionX" + | "directionY" + | "scalarValue" + | "textValue" + | "cargoTransfer" + | "tradePurchase" + | "lootPickup" + | "loadoutRestore" + | "equipmentTransfer" + | "storeItem" + | "authorRuntimeId" + | "subjectKey" + | "claimKind"; + +export const aetheriaRuntimeDaemonCommandDocumentSlots: Record = { + schema: 0, + commandId: 1, + clientId: 2, + issuedAtUtc: 3, + sessionId: 4, + observedFrameId: 5, + kind: 6, + actorEntityKey: 7, + targetEntityKey: 8, + targetZoneIndex: 9, + equipmentIndex: 10, + behaviorIndex: 11, + weaponGroup: 12, + positionX: 13, + positionY: 14, + positionZ: 15, + directionX: 16, + directionY: 17, + scalarValue: 18, + textValue: 19, + cargoTransfer: 21, + tradePurchase: 22, + lootPickup: 23, + loadoutRestore: 24, + equipmentTransfer: 25, + storeItem: 26, + authorRuntimeId: 27, + subjectKey: 28, + claimKind: 29, +}; + +export type AetheriaRuntimeEveCommandDocumentSlot = + | "schema" + | "commandId" + | "providerId" + | "surfaceId" + | "command" + | "issuedAtUtc" + | "clientId" + | "playerSettings" + | "loadoutTemplate" + | "inputSettings" + | "tradeValuePolicy" + | "operationId" + | "operationSchemaId" + | "operationRouteKind" + | "operationRouteDescription" + | "operationIdempotencyKey" + | "payload" + | "operation"; + +export const aetheriaRuntimeEveCommandDocumentSlots: Record = { + schema: 0, + commandId: 1, + providerId: 2, + surfaceId: 3, + command: 4, + issuedAtUtc: 5, + clientId: 6, + playerSettings: 7, + loadoutTemplate: 8, + inputSettings: 9, + tradeValuePolicy: 10, + operationId: 11, + operationSchemaId: 12, + operationRouteKind: 13, + operationRouteDescription: 14, + operationIdempotencyKey: 15, + payload: 16, + operation: 17, +}; + +export type AetheriaRuntimeDaemonFrameDocumentSlot = + | "schema" + | "daemonId" + | "sessionId" + | "frameId" + | "publishedAtUtc" + | "simulationTimeSeconds" + | "fixedDeltaSeconds" + | "isAuthoritative" + | "stateSource" + | "run" + | "capabilities" + | "appliedCommandIds" + | "rejectedCommandIds" + | "accountedCommandIds" + | "cumulativeAppliedCommandIds" + | "cumulativeRejectedCommandIds" + | "importedFactIds" + | "rejectedImportedFactIds" + | "duplicateImportedFactIds" + | "cumulativeImportedFactIds" + | "cumulativeRejectedImportedFactIds"; + +export const aetheriaRuntimeDaemonFrameDocumentSlots: Record = { + schema: 0, + daemonId: 1, + sessionId: 2, + frameId: 3, + publishedAtUtc: 4, + simulationTimeSeconds: 5, + fixedDeltaSeconds: 6, + isAuthoritative: 7, + stateSource: 8, + run: 9, + capabilities: 10, + appliedCommandIds: 11, + rejectedCommandIds: 12, + accountedCommandIds: 13, + cumulativeAppliedCommandIds: 14, + cumulativeRejectedCommandIds: 15, + importedFactIds: 16, + rejectedImportedFactIds: 17, + duplicateImportedFactIds: 18, + cumulativeImportedFactIds: 19, + cumulativeRejectedImportedFactIds: 20, +}; + +export type AetheriaRuntimeDaemonHealthDocumentSlot = + | "schema" + | "daemonId" + | "verseId" + | "publishedAtUtc" + | "statePath" + | "frameId" + | "observedCommandCount" + | "appliedCommandCount" + | "rejectedCommandCount" + | "status" + | "publicationSource" + | "transport" + | "commandBoundaryPath"; + +export const aetheriaRuntimeDaemonHealthDocumentSlots: Record = { + schema: 0, + daemonId: 1, + verseId: 2, + publishedAtUtc: 3, + statePath: 4, + frameId: 5, + observedCommandCount: 6, + appliedCommandCount: 7, + rejectedCommandCount: 8, + status: 9, + publicationSource: 10, + transport: 11, + commandBoundaryPath: 12, +}; + +export type AetheriaRuntimeRtsViewportDocumentSlot = + | "schema" + | "frameId" + | "publishedAtUtc" + | "simulationTimeSeconds" + | "runId" + | "zoneIndex" + | "zoneName" + | "currentEntityKey" + | "viewport" + | "controlledEntityIndices" + | "objects" + | "gravityInfluences" + | "bodies"; + +export const aetheriaRuntimeRtsViewportDocumentSlots: Record = { + schema: 0, + frameId: 1, + publishedAtUtc: 2, + simulationTimeSeconds: 3, + runId: 4, + zoneIndex: 5, + zoneName: 6, + currentEntityKey: 7, + viewport: 8, + controlledEntityIndices: 9, + objects: 10, + gravityInfluences: 11, + bodies: 12, +}; + +export type AetheriaRuntimeObjectsViewportDocumentSlot = + | "schema" + | "frameId" + | "publishedAtUtc" + | "simulationTimeSeconds" + | "runId" + | "zoneIndex" + | "zoneName" + | "currentEntityKey" + | "viewport" + | "controlledEntityIndices" + | "objects"; + +export const aetheriaRuntimeObjectsViewportDocumentSlots: Record = { + schema: 0, + frameId: 1, + publishedAtUtc: 2, + simulationTimeSeconds: 3, + runId: 4, + zoneIndex: 5, + zoneName: 6, + currentEntityKey: 7, + viewport: 8, + controlledEntityIndices: 9, + objects: 10, +}; + +export type AetheriaRuntimeGravityViewportDocumentSlot = + | "schema" + | "frameId" + | "publishedAtUtc" + | "simulationTimeSeconds" + | "runId" + | "zoneIndex" + | "zoneName" + | "viewport" + | "gravityInfluences" + | "bodies" + | "terrainRadius" + | "terrainDepth" + | "terrainDepthExponent" + | "terrainWaveFrequency"; + +export const aetheriaRuntimeGravityViewportDocumentSlots: Record = { + schema: 0, + frameId: 1, + publishedAtUtc: 2, + simulationTimeSeconds: 3, + runId: 4, + zoneIndex: 5, + zoneName: 6, + viewport: 7, + gravityInfluences: 8, + bodies: 9, + terrainRadius: 10, + terrainDepth: 11, + terrainDepthExponent: 12, + terrainWaveFrequency: 13, +}; + +export type AetheriaRuntimeCurrentZoneDocumentSlot = + | "schema" + | "frameId" + | "publishedAtUtc" + | "simulationTimeSeconds" + | "runId" + | "zoneIndex" + | "zoneName" + | "positionX" + | "positionY" + | "currentEntityKey" + | "adjacentZoneIndices"; + +export const aetheriaRuntimeCurrentZoneDocumentSlots: Record = { + schema: 0, + frameId: 1, + publishedAtUtc: 2, + simulationTimeSeconds: 3, + runId: 4, + zoneIndex: 5, + zoneName: 6, + positionX: 7, + positionY: 8, + currentEntityKey: 9, + adjacentZoneIndices: 10, +}; + +export type AetheriaRuntimeCurrentEntityDocumentSlot = + | "schema" + | "frameId" + | "publishedAtUtc" + | "simulationTimeSeconds" + | "runId" + | "zoneIndex" + | "entityKey" + | "entityIndex" + | "entity" + | "status" + | "inventory" + | "equipment" + | "cargo" + | "shutdownPerformance" + | "hud"; + +export const aetheriaRuntimeCurrentEntityDocumentSlots: Record = { + schema: 0, + frameId: 1, + publishedAtUtc: 2, + simulationTimeSeconds: 3, + runId: 4, + zoneIndex: 5, + entityKey: 6, + entityIndex: 7, + entity: 8, + status: 9, + inventory: 10, + equipment: 11, + cargo: 12, + shutdownPerformance: 13, + hud: 14, +}; + +export type AetheriaRuntimeCurrentDockingDocumentSlot = + | "schema" + | "frameId" + | "publishedAtUtc" + | "simulationTimeSeconds" + | "runId" + | "zoneIndex" + | "currentEntityKey" + | "currentEntityIndex" + | "isDocked" + | "dockParentEntityKey" + | "dockParentEntityIndex" + | "dockingBayIndex" + | "dockParent" + | "dockParentOrbitKey" + | "dockParentParentOrbitKey" + | "dockParentParentBodyKey"; + +export const aetheriaRuntimeCurrentDockingDocumentSlots: Record = { + schema: 0, + frameId: 1, + publishedAtUtc: 2, + simulationTimeSeconds: 3, + runId: 4, + zoneIndex: 5, + currentEntityKey: 6, + currentEntityIndex: 7, + isDocked: 8, + dockParentEntityKey: 9, + dockParentEntityIndex: 10, + dockingBayIndex: 11, + dockParent: 12, + dockParentOrbitKey: 13, + dockParentParentOrbitKey: 14, + dockParentParentBodyKey: 15, +}; + +export type AetheriaRuntimeStationRefitDocumentSlot = + | "schema" + | "frameId" + | "publishedAtUtc" + | "simulationTimeSeconds" + | "runId" + | "zoneIndex" + | "currentEntityKey" + | "currentEntityIndex" + | "isDocked" + | "dockParentEntityKey" + | "dockParentEntityIndex" + | "dockingBayIndex" + | "dockParent" + | "availableEntities" + | "credits" + | "stationStock" + | "dockingBays" + | "loadoutRestoreOptions" + | "cargoTargets"; + +export const aetheriaRuntimeStationRefitDocumentSlots: Record = { + schema: 0, + frameId: 1, + publishedAtUtc: 2, + simulationTimeSeconds: 3, + runId: 4, + zoneIndex: 5, + currentEntityKey: 6, + currentEntityIndex: 7, + isDocked: 8, + dockParentEntityKey: 9, + dockParentEntityIndex: 10, + dockingBayIndex: 11, + dockParent: 12, + availableEntities: 13, + credits: 14, + stationStock: 15, + dockingBays: 16, + loadoutRestoreOptions: 17, + cargoTargets: 18, +}; + +export type AetheriaRuntimeStationRefitEntityOptionSlot = + | "entityKey" + | "entityIndex" + | "displayName" + | "kind" + | "isCurrentEntity" + | "isPlayerShip" + | "cargoBayCount" + | "dockingBayIndex" + | "hullItemKey" + | "cargoItems"; + +export const aetheriaRuntimeStationRefitEntityOptionSlots: Record = { + entityKey: 0, + entityIndex: 1, + displayName: 2, + kind: 3, + isCurrentEntity: 4, + isPlayerShip: 5, + cargoBayCount: 6, + dockingBayIndex: 7, + hullItemKey: 8, + cargoItems: 9, +}; + +export type AetheriaRuntimeStationStockItemSlot = + | "itemKey" + | "quantity" + | "quality" + | "durability" + | "cargoBayIndex" + | "x" + | "y" + | "price" + | "canAfford" + | "ownedQuantity"; + +export const aetheriaRuntimeStationStockItemSlots: Record = { + itemKey: 0, + quantity: 1, + quality: 2, + durability: 3, + cargoBayIndex: 4, + x: 5, + y: 6, + price: 7, + canAfford: 8, + ownedQuantity: 9, +}; + +export type AetheriaRuntimeSectorMapDocumentSlot = + | "schema" + | "frameId" + | "publishedAtUtc" + | "simulationTimeSeconds" + | "runId" + | "currentZoneIndex" + | "entranceZoneIndex" + | "exitZoneIndex" + | "discoveredZoneIndices" + | "zones" + | "links" + | "isTutorial" + | "generationSeed" + | "factionRelationships"; + +export const aetheriaRuntimeSectorMapDocumentSlots: Record = { + schema: 0, + frameId: 1, + publishedAtUtc: 2, + simulationTimeSeconds: 3, + runId: 4, + currentZoneIndex: 5, + entranceZoneIndex: 6, + exitZoneIndex: 7, + discoveredZoneIndices: 8, + zones: 9, + links: 10, + isTutorial: 11, + generationSeed: 12, + factionRelationships: 13, +}; + +export type AetheriaRuntimeSectorMapZoneSlot = + | "zoneIndex" + | "name" + | "x" + | "y" + | "ownerFactionIndex" + | "factionIndices" + | "adjacentZoneIndices" + | "discovered" + | "current" + | "entrance" + | "exit"; + +export const aetheriaRuntimeSectorMapZoneSlots: Record = { + zoneIndex: 0, + name: 1, + x: 2, + y: 3, + ownerFactionIndex: 4, + factionIndices: 5, + adjacentZoneIndices: 6, + discovered: 7, + current: 8, + entrance: 9, + exit: 10, +}; + +export type AetheriaRuntimeSectorMapLinkSlot = + | "fromZoneIndex" + | "toZoneIndex" + | "discovered"; + +export const aetheriaRuntimeSectorMapLinkSlots: Record = { + fromZoneIndex: 0, + toZoneIndex: 1, + discovered: 2, +}; + +export type AetheriaRuntimeZoneRenderDocumentSlot = + | "schema" + | "frameId" + | "publishedAtUtc" + | "simulationTimeSeconds" + | "runId" + | "zoneIndex" + | "zoneName" + | "currentEntityKey" + | "zoneRenderRadius" + | "credits" + | "adjacentZones" + | "bodyPoses" + | "asteroidBeltPoses" + | "wormholeExits" + | "droppedPickups" + | "entityFacades" + | "orbits" + | "bodies"; + +export const aetheriaRuntimeZoneRenderDocumentSlots: Record = { + schema: 0, + frameId: 1, + publishedAtUtc: 2, + simulationTimeSeconds: 3, + runId: 4, + zoneIndex: 5, + zoneName: 6, + currentEntityKey: 7, + zoneRenderRadius: 8, + credits: 9, + adjacentZones: 12, + bodyPoses: 13, + asteroidBeltPoses: 14, + wormholeExits: 15, + droppedPickups: 16, + entityFacades: 17, + orbits: 18, + bodies: 19, +}; + +export type AetheriaRuntimeZoneRenderBodyPoseSlot = + | "bodyKey" + | "orbitKey" + | "parentOrbitKey" + | "kind" + | "centerX" + | "centerZ" + | "parentCenterX" + | "parentCenterZ" + | "gravityWaveSpeed"; + +export const aetheriaRuntimeZoneRenderBodyPoseSlots: Record = { + bodyKey: 0, + orbitKey: 1, + parentOrbitKey: 2, + kind: 3, + centerX: 4, + centerZ: 5, + parentCenterX: 6, + parentCenterZ: 7, + gravityWaveSpeed: 8, +}; + +export type AetheriaRuntimeZoneRenderAsteroidBeltPoseSlot = + | "bodyKey" + | "orbitKey" + | "centerX" + | "centerZ" + | "radius" + | "asteroidCount" + | "instancePoses"; + +export const aetheriaRuntimeZoneRenderAsteroidBeltPoseSlots: Record = { + bodyKey: 0, + orbitKey: 1, + centerX: 2, + centerZ: 3, + radius: 4, + asteroidCount: 5, + instancePoses: 6, +}; + +export type AetheriaRuntimeSelectedObjectDocumentSlot = + | "schema" + | "frameId" + | "runId" + | "zoneIndex" + | "entityIndex" + | "selected"; + +export const aetheriaRuntimeSelectedObjectDocumentSlots: Record = { + schema: 0, + frameId: 1, + runId: 2, + zoneIndex: 3, + entityIndex: 4, + selected: 5, +}; + +export type AetheriaRuntimeInventoryDocumentSlot = + | "schema" + | "frameId" + | "runId" + | "zoneIndex" + | "entityIndex" + | "entityKey" + | "items" + | "equipment" + | "cargo"; + +export const aetheriaRuntimeInventoryDocumentSlots: Record = { + schema: 0, + frameId: 1, + runId: 2, + zoneIndex: 3, + entityIndex: 4, + entityKey: 5, + items: 6, + equipment: 7, + cargo: 8, +}; + +export type AetheriaRuntimeRunCheckpointCommitSlot = + | "runId" + | "isTutorial" + | "entranceZoneIndex" + | "exitZoneIndex" + | "currentZoneIndex" + | "discoveredZoneIndices" + | "zones" + | "factionRelationships" + | "generationSeed" + | "currentEntityKey" + | "credits"; + +export const aetheriaRuntimeRunCheckpointCommitSlots: Record = { + runId: 0, + isTutorial: 1, + entranceZoneIndex: 2, + exitZoneIndex: 3, + currentZoneIndex: 4, + discoveredZoneIndices: 5, + zones: 6, + factionRelationships: 8, + generationSeed: 9, + currentEntityKey: 10, + credits: 11, +}; + +export type AetheriaRuntimeZoneSnapshotCommitSlot = + | "zoneIndex" + | "name" + | "positionX" + | "positionY" + | "adjacentZoneIndices" + | "factionIndices" + | "ownerFactionIndex" + | "entities" + | "orbits" + | "bodies" + | "droppedPickups" + | "gravityTerrainRadius" + | "gravityTerrainDepth" + | "gravityTerrainDepthExponent" + | "gravityTerrainBoundaryFog" + | "gravityTerrainWaveFrequency" + | "simulationTimeSeconds"; + +export const aetheriaRuntimeZoneSnapshotCommitSlots: Record = { + zoneIndex: 0, + name: 1, + positionX: 2, + positionY: 3, + adjacentZoneIndices: 4, + factionIndices: 5, + ownerFactionIndex: 6, + entities: 7, + orbits: 8, + bodies: 9, + droppedPickups: 10, + gravityTerrainRadius: 11, + gravityTerrainDepth: 12, + gravityTerrainDepthExponent: 13, + gravityTerrainBoundaryFog: 14, + gravityTerrainWaveFrequency: 15, + simulationTimeSeconds: 16, +}; + +export type AetheriaRuntimeEntitySnapshotCommitSlot = + | "entityIndex" + | "name" + | "kind" + | "positionX" + | "positionY" + | "positionZ" + | "directionX" + | "directionY" + | "equipment" + | "cargoBays" + | "dockingBays" + | "childEntityIndices" + | "weaponGroups" + | "statGrids" + | "velocityX" + | "velocityY" + | "targetEntityIndex" + | "isActive" + | "heatsinksEnabled" + | "overrideShutdown" + | "tractorPower" + | "heatstroke" + | "hypothermia" + | "activeConsumables" + | "behaviorProgress" + | "weaponStates" + | "behaviorStates" + | "cargoContents" + | "dockingBayContents" + | "dockingBayAssignments" + | "visibility" + | "visibilitySourceCount" + | "contacts" + | "hullItemKey" + | "factionKey" + | "shutdownPerformance"; + +export const aetheriaRuntimeEntitySnapshotCommitSlots: Record = { + entityIndex: 0, + name: 1, + kind: 2, + positionX: 3, + positionY: 4, + positionZ: 5, + directionX: 6, + directionY: 7, + equipment: 10, + cargoBays: 11, + dockingBays: 12, + childEntityIndices: 13, + weaponGroups: 14, + statGrids: 15, + velocityX: 16, + velocityY: 17, + targetEntityIndex: 18, + isActive: 19, + heatsinksEnabled: 20, + overrideShutdown: 21, + tractorPower: 22, + heatstroke: 23, + hypothermia: 24, + activeConsumables: 25, + behaviorProgress: 26, + weaponStates: 27, + behaviorStates: 28, + cargoContents: 29, + dockingBayContents: 30, + dockingBayAssignments: 31, + visibility: 32, + visibilitySourceCount: 33, + contacts: 34, + hullItemKey: 35, + factionKey: 36, + shutdownPerformance: 37, +}; + +export type AetheriaRuntimeBodySnapshotCommitSlot = + | "kind" + | "name" + | "mass" + | "resources" + | "bodyRadiusMultiplier" + | "gravityRadiusMultiplier" + | "gravityDepthMultiplier" + | "gravityDepthExponent" + | "asteroids" + | "gasGiantVisual" + | "sunVisual" + | "bodyKey" + | "orbitKey" + | "gravityInfluenceCenterX" + | "gravityInfluenceCenterZ" + | "gravityInfluenceRadius" + | "gravityWellDepth" + | "gravityWaveRadius" + | "gravityWaveDepth" + | "gravityWaveSpeed"; + +export const aetheriaRuntimeBodySnapshotCommitSlots: Record = { + kind: 1, + name: 2, + mass: 4, + resources: 5, + bodyRadiusMultiplier: 6, + gravityRadiusMultiplier: 7, + gravityDepthMultiplier: 8, + gravityDepthExponent: 9, + asteroids: 10, + gasGiantVisual: 11, + sunVisual: 12, + bodyKey: 13, + orbitKey: 14, + gravityInfluenceCenterX: 15, + gravityInfluenceCenterZ: 16, + gravityInfluenceRadius: 17, + gravityWellDepth: 18, + gravityWaveRadius: 19, + gravityWaveDepth: 20, + gravityWaveSpeed: 21, +}; + +export type AetheriaRuntimeEntityStatGridCommitSlot = + | "name" + | "width" + | "height" + | "values"; + +export const aetheriaRuntimeEntityStatGridCommitSlots: Record = { + name: 0, + width: 1, + height: 2, + values: 3, +}; + +export type AetheriaRuntimeCargoBayLoadoutCommitSlot = + | "items"; + +export const aetheriaRuntimeCargoBayLoadoutCommitSlots: Record = { + items: 0, +}; + +export type AetheriaRuntimeLoadoutItemSlotCommitSlot = + | "x" + | "y" + | "item"; + +export const aetheriaRuntimeLoadoutItemSlotCommitSlots: Record = { + x: 0, + y: 1, + item: 2, +}; + +export type AetheriaRuntimeLoadoutItemCommitSlot = + | "quality" + | "durability" + | "quantity" + | "enabled" + | "itemKey" + | "overrideShutdown" + | "temperature"; + +export const aetheriaRuntimeLoadoutItemCommitSlots: Record = { + quality: 1, + durability: 2, + quantity: 3, + enabled: 4, + itemKey: 5, + overrideShutdown: 6, + temperature: 7, +}; + +export type AetheriaRuntimeRtsViewportBoundsSlot = + | "minX" + | "minY" + | "maxX" + | "maxY"; + +export const aetheriaRuntimeRtsViewportBoundsSlots: Record = { + minX: 0, + minY: 1, + maxX: 2, + maxY: 3, +}; + +export type AetheriaRuntimeRtsViewportObjectSlot = + | "entityIndex" + | "entityKey" + | "displayName" + | "kind" + | "factionKey" + | "x" + | "y" + | "z" + | "directionX" + | "directionY" + | "velocityX" + | "velocityY" + | "controlled" + | "targetEntityIndex" + | "isActive" + | "visibility" + | "status" + | "inventory"; + +export const aetheriaRuntimeRtsViewportObjectSlots: Record = { + entityIndex: 0, + entityKey: 1, + displayName: 2, + kind: 3, + factionKey: 4, + x: 5, + y: 6, + z: 7, + directionX: 8, + directionY: 9, + velocityX: 10, + velocityY: 11, + controlled: 12, + targetEntityIndex: 13, + isActive: 14, + visibility: 15, + status: 16, + inventory: 17, +}; + +export type AetheriaRuntimeRtsEntityStatusSlot = + | "hull" + | "shield" + | "heat"; + +export const aetheriaRuntimeRtsEntityStatusSlots: Record = { + hull: 0, + shield: 1, + heat: 2, +}; + +export type AetheriaRuntimeRtsInventoryItemSlot = + | "source" + | "itemKey" + | "quantity" + | "quality" + | "durability" + | "enabled" + | "sourceIndex" + | "x" + | "y"; + +export const aetheriaRuntimeRtsInventoryItemSlots: Record = { + source: 0, + itemKey: 1, + quantity: 2, + quality: 3, + durability: 4, + enabled: 5, + sourceIndex: 6, + x: 7, + y: 8, +}; + +export type AetheriaRuntimeRtsGravityInfluenceSlot = + | "bodyKey" + | "orbitKey" + | "kind" + | "x" + | "y" + | "radius" + | "gravityDepth" + | "gravityDepthExponent" + | "waveRadius" + | "waveDepth" + | "waveSpeed"; + +export const aetheriaRuntimeRtsGravityInfluenceSlots: Record = { + bodyKey: 0, + orbitKey: 1, + kind: 2, + x: 3, + y: 4, + radius: 5, + gravityDepth: 6, + gravityDepthExponent: 7, + waveRadius: 8, + waveDepth: 9, + waveSpeed: 10, +}; + +export type AetheriaRuntimeRtsBodyViewSlot = + | "bodyKey" + | "orbitKey" + | "name" + | "kind" + | "x" + | "y" + | "radius" + | "isAsteroidBelt" + | "body"; + +export const aetheriaRuntimeRtsBodyViewSlots: Record = { + bodyKey: 0, + orbitKey: 1, + name: 2, + kind: 3, + x: 4, + y: 5, + radius: 6, + isAsteroidBelt: 7, + body: 8, +}; + +export type AetheriaRuntimeStarbridgeScenarioDocumentSlot = + | "schema" + | "scenarioId" + | "displayName" + | "startingBaseKey" + | "stationStock" + | "availableShipKeys" + | "waves" + | "attackerMixKeys" + | "recoveredTechnologyPoolKeys" + | "runtimeRoles"; + +export const aetheriaRuntimeStarbridgeScenarioDocumentSlots: Record = { + schema: 0, + scenarioId: 1, + displayName: 2, + startingBaseKey: 3, + stationStock: 4, + availableShipKeys: 5, + waves: 6, + attackerMixKeys: 7, + recoveredTechnologyPoolKeys: 8, + runtimeRoles: 9, +}; + +export type AetheriaRuntimeStarbridgeSessionDocumentSlot = + | "schema" + | "sessionId" + | "scenarioId" + | "runId" + | "baseEntityKey" + | "stationEntityKey" + | "phase" + | "currentWaveIndex" + | "runtimeRoles"; + +export const aetheriaRuntimeStarbridgeSessionDocumentSlots: Record = { + schema: 0, + sessionId: 1, + scenarioId: 2, + runId: 3, + baseEntityKey: 4, + stationEntityKey: 5, + phase: 6, + currentWaveIndex: 7, + runtimeRoles: 8, +}; + +export type AetheriaRuntimeStarbridgeSessionSummaryDocumentSlot = + | "schema" + | "frameId" + | "publishedAtUtc" + | "sessionId" + | "scenarioId" + | "scenarioName" + | "runId" + | "zoneIndex" + | "zoneName" + | "phase" + | "currentWaveIndex" + | "baseStatus" + | "stationStock" + | "waveForecast" + | "runtimeRoles"; + +export const aetheriaRuntimeStarbridgeSessionSummaryDocumentSlots: Record = { + schema: 0, + frameId: 1, + publishedAtUtc: 2, + sessionId: 3, + scenarioId: 4, + scenarioName: 5, + runId: 6, + zoneIndex: 7, + zoneName: 8, + phase: 9, + currentWaveIndex: 10, + baseStatus: 11, + stationStock: 12, + waveForecast: 13, + runtimeRoles: 14, +}; + +export type AetheriaRuntimeStarbridgeStationStockItemSlot = + | "itemKey" + | "quantity" + | "quality" + | "durability" + | "source"; + +export const aetheriaRuntimeStarbridgeStationStockItemSlots: Record = { + itemKey: 0, + quantity: 1, + quality: 2, + durability: 3, + source: 4, +}; + +export type AetheriaRuntimeStarbridgeWaveDefinitionSlot = + | "waveIndex" + | "displayName" + | "attackerKeys" + | "startsAfterSeconds" + | "bossKey" + | "recoveredTechnologyKeys"; + +export const aetheriaRuntimeStarbridgeWaveDefinitionSlots: Record = { + waveIndex: 0, + displayName: 1, + attackerKeys: 2, + startsAfterSeconds: 3, + bossKey: 4, + recoveredTechnologyKeys: 5, +}; + +export type AetheriaRuntimeStarbridgeWaveForecastSlot = + | "waveIndex" + | "displayName" + | "attackerKeys" + | "bossKey" + | "recoveredTechnologyKeys"; + +export const aetheriaRuntimeStarbridgeWaveForecastSlots: Record = { + waveIndex: 0, + displayName: 1, + attackerKeys: 2, + bossKey: 3, + recoveredTechnologyKeys: 4, +}; + +export type AetheriaRuntimeStarbridgeRuntimeRoleSlot = + | "runtimeId" + | "role" + | "entityKey"; + +export const aetheriaRuntimeStarbridgeRuntimeRoleSlots: Record = { + runtimeId: 0, + role: 1, + entityKey: 2, +}; + +export type AetheriaRuntimeStarbridgeBaseStatusSlot = + | "entityKey" + | "displayName" + | "hull" + | "shield" + | "heat" + | "isActive"; + +export const aetheriaRuntimeStarbridgeBaseStatusSlots: Record = { + entityKey: 0, + displayName: 1, + hull: 2, + shield: 3, + heat: 4, + isActive: 5, +}; + +export type AetheriaRuntimeVerseAuthorityPolicyDocumentSlot = + | "schema" + | "verseId" + | "policyId" + | "ruleVersion" + | "hostRuntimeId" + | "defaultMode" + | "rules" + | "updatedAtUtc" + | "deploymentMode" + | "runtimeRoles"; + +export const aetheriaRuntimeVerseAuthorityPolicyDocumentSlots: Record = { + schema: 0, + verseId: 1, + policyId: 2, + ruleVersion: 3, + hostRuntimeId: 4, + defaultMode: 5, + rules: 6, + updatedAtUtc: 7, + deploymentMode: 8, + runtimeRoles: 9, +}; + +export type AetheriaRuntimeAuthorityRuleSlot = + | "ruleId" + | "subjectPrefix" + | "claimKinds" + | "mode" + | "runtimeIds" + | "leaseScope" + | "priority"; + +export const aetheriaRuntimeAuthorityRuleSlots: Record = { + ruleId: 0, + subjectPrefix: 1, + claimKinds: 2, + mode: 3, + runtimeIds: 4, + leaseScope: 5, + priority: 6, +}; + +export type AetheriaRuntimeAuthorityRuntimeRoleSlot = + | "runtimeId" + | "roles"; + +export const aetheriaRuntimeAuthorityRuntimeRoleSlots: Record = { + runtimeId: 0, + roles: 1, +}; + +export type AetheriaRuntimeAuthorityLeaseDocumentSlot = + | "schema" + | "leaseId" + | "verseId" + | "runtimeId" + | "subjectPrefix" + | "claimKinds" + | "validFromUtc" + | "expiresAtUtc" + | "scope"; + +export const aetheriaRuntimeAuthorityLeaseDocumentSlots: Record = { + schema: 0, + leaseId: 1, + verseId: 2, + runtimeId: 3, + subjectPrefix: 4, + claimKinds: 5, + validFromUtc: 6, + expiresAtUtc: 7, + scope: 8, +}; + +export type ViewportResponse = { + schema: string; + frameId: number; + publishedAtUtc: string; + simulationTimeSeconds: number; + runId: string; + zoneIndex: number; + zoneName: string; + currentEntityKey: string; + viewport: CultMeshViewportRequest; + controlledEntityIndices: number[]; + objects: ViewObject[]; + gravityInfluences: GravityInfluence[]; + bodies: BodyView[]; +}; + +export type ObjectsViewportResponse = { + schema: string; + frameId: number; + publishedAtUtc: string; + simulationTimeSeconds: number; + runId: string; + zoneIndex: number; + zoneName: string; + currentEntityKey: string; + viewport: CultMeshViewportRequest; + controlledEntityIndices: number[]; + objects: ViewObject[]; +}; + +export type GravityViewportResponse = { + schema: string; + frameId: number; + publishedAtUtc: string; + simulationTimeSeconds: number; + runId: string; + zoneIndex: number; + zoneName: string; + viewport: CultMeshViewportRequest; + gravityInfluences: GravityInfluence[]; + bodies: BodyView[]; +}; + +export type ViewObject = { + entityIndex: number; + entityKey: string; + displayName: string; + kind: string; + factionKey: string; + x: number; + y: number; + z: number; + directionX: number; + directionY: number; + velocityX: number; + velocityY: number; + controlled: boolean; + targetEntityIndex: number; + isActive: boolean; + visibility: number; + status: EntityStatus; + inventory: InventoryItem[]; +}; + +export type EntityStatus = { + hull: number; + shield: number; + heat: number; +}; + +export type InventoryItem = { + source: string; + itemKey: string; + quantity: number; + quality: number; + durability: number; + enabled: boolean; +}; + +export type SelectedObjectProjection = { + schema: string; + frameId: number; + runId: string; + zoneIndex: number; + entityIndex: number; + selected: ViewObject | null; +}; + +export type InventoryProjection = { + schema: string; + frameId: number; + runId: string; + zoneIndex: number; + entityIndex: number; + entityKey: string; + items: InventoryItem[]; + equipment: InventoryItem[]; + cargo: InventoryItem[]; +}; + +export type DaemonHealthProjection = { + schema: string; + daemonId: string; + verseId: string; + publishedAtUtc: string; + statePath: string; + frameId: number; + observedCommandCount: number; + appliedCommandCount: number; + rejectedCommandCount: number; + status: string; + publicationSource: string; + transport: string; + commandBoundaryPath: string; +}; + +export type AuthorityStatusProjection = { + schema: string; + verseId: string; + policyId: string; + ruleVersion: string; + hostRuntimeId: string; + defaultMode: string; + updatedAtUtc: string; + rules: AuthorityRuleProjection[]; +}; + +export type AuthorityRuleProjection = { + ruleId: string; + subjectPrefix: string; + claimKinds: string[]; + mode: string; + runtimeIds: string[]; + leaseScope: string; + priority: number; +}; + +export type StarbridgeSessionProjection = { + schema: string; + frameId: number; + publishedAtUtc: string; + sessionId: string; + scenarioId: string; + scenarioName: string; + runId: string; + zoneIndex: number; + zoneName: string; + phase: string; + currentWaveIndex: number; + baseStatus: StarbridgeBaseStatusProjection; + stationStock: StarbridgeStationStockItemProjection[]; + waveForecast: StarbridgeWaveForecastProjection[]; + runtimeRoles: StarbridgeRuntimeRoleProjection[]; +}; + +export type StarbridgeBaseStatusProjection = { + entityKey: string; + displayName: string; + hull: number; + shield: number; + heat: number; + isActive: boolean; +}; + +export type StarbridgeStationStockItemProjection = { + itemKey: string; + quantity: number; + quality: number; + durability: number; + source: string; +}; + +export type StarbridgeWaveForecastProjection = { + waveIndex: number; + displayName: string; + attackerKeys: string[]; + bossKey: string; + recoveredTechnologyKeys: string[]; +}; + +export type StarbridgeRuntimeRoleProjection = { + runtimeId: string; + role: string; + entityKey: string; +}; + +export type GravityInfluence = { + bodyKey: string; + orbitKey: string; + kind: string; + x: number; + y: number; + radius: number; + gravityDepth: number; + gravityDepthExponent: number; + waveRadius: number; + waveDepth: number; + waveSpeed: number; +}; + +export type BodyView = { + bodyKey: string; + orbitKey: string; + name: string; + kind: string; + x: number; + y: number; + radius: number; + isAsteroidBelt: boolean; +}; + + +export type AetheriaRuntimeSetMoveVectorRequest = { + actorEntityKey: string; + directionX: number; + directionY: number; + scalar: number; + observedFrameId?: number; +}; + +export type AetheriaRuntimeSetTargetRequest = { + actorEntityKey: string; + targetEntityKey: string; + observedFrameId?: number; +}; + +export type AetheriaRuntimeDaemonCommandReceipt = CultMeshOperationReceipt & { + commandId: string; +}; + +export type AetheriaRuntimeSelectedObjectRequest = { + entityIndex: number; +}; + +export type AetheriaRuntimeViewportFeedRequest = { + viewport: CultMeshViewportRequest; + selectedEntityIndex?: number; +}; + +export type AetheriaRuntimeViewportFeedSnapshot = { + viewport: ViewportResponse; + selectedObject: SelectedObjectProjection | null; + inventory: InventoryProjection | null; + daemonHealth: DaemonHealthProjection; + authorityStatus: AuthorityStatusProjection; + starbridgeSession: StarbridgeSessionProjection; + receivedAtUtc: string; + sampleMs: number; +}; + +export type AetheriaRtsMainClient = { + mapViewport(request: CultMeshViewportRequest): Promise; + objectsViewport(request: CultMeshViewportRequest): Promise; + gravityViewport(request: CultMeshViewportRequest): Promise; + selectedObject(request: AetheriaRuntimeSelectedObjectRequest): Promise; + inventory(request: AetheriaRuntimeSelectedObjectRequest): Promise; + daemonHealth(): Promise; + authorityStatus(): Promise; + starbridgeSession(): Promise; + watchViewportFeed(request: AetheriaRuntimeViewportFeedRequest, callback: (snapshot: AetheriaRuntimeViewportFeedSnapshot) => void): CultMeshUnsubscribe; + setMoveVector(request: AetheriaRuntimeSetMoveVectorRequest): Promise; + setTarget(request: AetheriaRuntimeSetTargetRequest): Promise; + surfaceCatalogDiagnostics(): CultMeshSurfaceCatalogDiagnostic; + surfaceCatalogIndexDiagnostics(): CultMeshSurfaceCatalogIndexDiagnostic; +}; + +export type AetheriaRtsHealthProvider = () => unknown; + +export function registerAetheriaRtsIpcHandlers( + ipcMain: IpcMain, + getClient: () => AetheriaRtsMainClient, + getHealth: AetheriaRtsHealthProvider, +): void { + const viewportFeedSubscriptions = new Map(); + ipcMain.handle(AetheriaRtsIpcChannels.mapViewport, async (_event, request: CultMeshViewportRequest) => + getClient().mapViewport(request)); + ipcMain.handle(AetheriaRtsIpcChannels.objectsViewport, async (_event, request: CultMeshViewportRequest) => + getClient().objectsViewport(request)); + ipcMain.handle(AetheriaRtsIpcChannels.gravityViewport, async (_event, request: CultMeshViewportRequest) => + getClient().gravityViewport(request)); + ipcMain.handle(AetheriaRtsIpcChannels.selectedObject, async (_event, request: AetheriaRuntimeSelectedObjectRequest) => + getClient().selectedObject(request)); + ipcMain.handle(AetheriaRtsIpcChannels.inventory, async (_event, request: AetheriaRuntimeSelectedObjectRequest) => + getClient().inventory(request)); + ipcMain.handle(AetheriaRtsIpcChannels.daemonHealth, async () => getClient().daemonHealth()); + ipcMain.handle(AetheriaRtsIpcChannels.authorityStatus, async () => getClient().authorityStatus()); + ipcMain.handle(AetheriaRtsIpcChannels.starbridgeSession, async () => getClient().starbridgeSession()); + ipcMain.handle(AetheriaRtsIpcChannels.viewportFeed, async (event, request: { subscriptionId: string; feed: AetheriaRuntimeViewportFeedRequest }) => { + viewportFeedSubscriptions.get(request.subscriptionId)?.(); + const unsubscribe = getClient().watchViewportFeed(request.feed, snapshot => { + event.sender.send(AetheriaRtsIpcChannels.viewportFeedUpdate, { + subscriptionId: request.subscriptionId, + snapshot, + }); + }); + viewportFeedSubscriptions.set(request.subscriptionId, unsubscribe); + }); + ipcMain.handle(AetheriaRtsIpcChannels.viewportFeedStop, (_event, subscriptionId: string) => { + viewportFeedSubscriptions.get(subscriptionId)?.(); + viewportFeedSubscriptions.delete(subscriptionId); + }); + ipcMain.handle(AetheriaRtsIpcChannels.setMoveVector, async (_event, request: AetheriaRuntimeSetMoveVectorRequest) => + getClient().setMoveVector(request)); + ipcMain.handle(AetheriaRtsIpcChannels.setTarget, async (_event, request: AetheriaRuntimeSetTargetRequest) => + getClient().setTarget(request)); + ipcMain.handle(AetheriaRtsIpcChannels.surfaceCatalog, () => getClient().surfaceCatalogDiagnostics()); + ipcMain.handle(AetheriaRtsIpcChannels.surfaceCatalogIndex, () => getClient().surfaceCatalogIndexDiagnostics()); + ipcMain.handle(AetheriaRtsIpcChannels.health, () => getHealth()); +} + +export type AetheriaRuntimeDaemonCommandSender = ( + commandId: string, + issuedAtUtc: string, + command: unknown[], + context: CultMeshOperationContext, +) => Promise; + +export type AetheriaRuntimeViewportQueryExecutor = ( + request: CultMeshViewportRequest, + context: CultMeshQueryContext, +) => Promise; + +export type AetheriaRuntimeQueryExecutor = ( + request: TParameters, + context: CultMeshQueryContext, +) => Promise; + +export type AetheriaRuntimeRtsQueryExecutors = { + mapViewport: AetheriaRuntimeViewportQueryExecutor; + objectsViewport: AetheriaRuntimeViewportQueryExecutor; + gravityViewport: AetheriaRuntimeViewportQueryExecutor; + selectedObject: AetheriaRuntimeQueryExecutor; + inventory: AetheriaRuntimeQueryExecutor; + daemonHealth: AetheriaRuntimeQueryExecutor; + authorityStatus: AetheriaRuntimeQueryExecutor; + starbridgeSession: AetheriaRuntimeQueryExecutor; +}; + +export type AetheriaRuntimeRtsQueryWatchers = Partial<{ + mapViewport: CultMeshQueryWatcher; + objectsViewport: CultMeshQueryWatcher; + gravityViewport: CultMeshQueryWatcher; + selectedObject: CultMeshQueryWatcher; + inventory: CultMeshQueryWatcher; + daemonHealth: CultMeshQueryWatcher; + authorityStatus: CultMeshQueryWatcher; + starbridgeSession: CultMeshQueryWatcher; +}>; + +export type AetheriaRuntimeRtsProjectionDiagnostic = CultMeshQuerySurfaceDiagnostic; + +export type AetheriaRuntimeRtsLiveFeedDiagnostic = CultMeshLiveFeedDiagnostic; + +export type AetheriaRuntimeRtsSurfaceCatalogDiagnostic = CultMeshSurfaceCatalogDiagnostic; + +export type AetheriaRuntimeRtsOperationHandles = ReturnType; + +export type AetheriaRuntimeRtsStatePointers = ReturnType; + +export type AetheriaRuntimeRtsStatePointerResolvers = Partial<{ + daemonFrame: (context: CultMeshQueryContext) => Promise; + daemonHealth: (context: CultMeshQueryContext) => Promise; + authorityPolicy: (context: CultMeshQueryContext) => Promise; + starbridgeSession: (context: CultMeshQueryContext) => Promise; +}>; + +export const AetheriaRuntimeRtsProjectionSources = { + daemonFrame: CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: AetheriaRtsSchemas.daemonFrame, + description: "latest daemon frame" + }), + daemonHealth: CultMesh.projectionSource("daemon:aetheria.health.latest.v1", { + schemaId: AetheriaRtsSchemas.daemonHealth, + description: "latest daemon health" + }), + authorityPolicy: CultMesh.projectionSource("daemon:aetheria.authority.policy.latest.v1", { + schemaId: AetheriaRtsSchemas.verseAuthorityPolicy, + description: "latest Verse authority policy" + }), + starbridgeSession: CultMesh.projectionSource("daemon:aetheria.starbridge.session.latest.v1", { + schemaId: AetheriaRtsSchemas.starbridgeSessionSummary, + description: "latest Starbridge session summary" + }) +} as const; + +export function createAetheriaRuntimeRtsStatePointers( + routeHint: CultMeshRouteHint = CultMesh.routeHint(), + resolvers: AetheriaRuntimeRtsStatePointerResolvers = {}, +) { + return { + daemonFrame: CultMesh.statePointer( + AetheriaRuntimeRtsProjectionSources.daemonFrame.sourceId, + async (context) => resolvers.daemonFrame?.(context), + undefined, + { + routeHint, + sources: [AetheriaRuntimeRtsProjectionSources.daemonFrame], + }, + ), + daemonHealth: CultMesh.statePointer( + AetheriaRuntimeRtsProjectionSources.daemonHealth.sourceId, + async (context) => resolvers.daemonHealth?.(context), + undefined, + { + routeHint, + sources: [AetheriaRuntimeRtsProjectionSources.daemonHealth], + }, + ), + authorityPolicy: CultMesh.statePointer( + AetheriaRuntimeRtsProjectionSources.authorityPolicy.sourceId, + async (context) => resolvers.authorityPolicy?.(context), + undefined, + { + routeHint, + sources: [AetheriaRuntimeRtsProjectionSources.authorityPolicy], + }, + ), + starbridgeSession: CultMesh.statePointer( + AetheriaRuntimeRtsProjectionSources.starbridgeSession.sourceId, + async (context) => resolvers.starbridgeSession?.(context), + undefined, + { + routeHint, + sources: [AetheriaRuntimeRtsProjectionSources.starbridgeSession], + }, + ), + } as const; +} + +export function createAetheriaRuntimeRtsQueryHandles( + executors: AetheriaRuntimeRtsQueryExecutors, + routeHint: CultMeshRouteHint = CultMesh.routeHint(), + watchers: AetheriaRuntimeRtsQueryWatchers = {}, +) { + return { + mapViewport: CultMesh.projectionRecipe( + AetheriaRtsSchemas.rtsViewport, + [AetheriaRuntimeRtsProjectionSources.daemonFrame], + executors.mapViewport, + { routeHint, watchProjection: watchers.mapViewport }, + ).asQuerySurface(), + objectsViewport: CultMesh.projectionRecipe( + AetheriaRtsSchemas.objectsViewport, + [AetheriaRuntimeRtsProjectionSources.daemonFrame], + executors.objectsViewport, + { routeHint, watchProjection: watchers.objectsViewport }, + ).asQuerySurface(), + gravityViewport: CultMesh.projectionRecipe( + AetheriaRtsSchemas.gravityViewport, + [AetheriaRuntimeRtsProjectionSources.daemonFrame], + executors.gravityViewport, + { routeHint, watchProjection: watchers.gravityViewport }, + ).asQuerySurface(), + selectedObject: CultMesh.projectionRecipe( + AetheriaRtsSchemas.selectedObject, + [AetheriaRuntimeRtsProjectionSources.daemonFrame], + executors.selectedObject, + { routeHint, watchProjection: watchers.selectedObject }, + ).asQuerySurface(), + inventory: CultMesh.projectionRecipe( + AetheriaRtsSchemas.inventory, + [AetheriaRuntimeRtsProjectionSources.daemonFrame], + executors.inventory, + { routeHint, watchProjection: watchers.inventory }, + ).asQuerySurface(), + daemonHealth: CultMesh.projectionRecipe( + AetheriaRtsSchemas.daemonHealth, + [AetheriaRuntimeRtsProjectionSources.daemonHealth], + executors.daemonHealth, + { routeHint, watchProjection: watchers.daemonHealth }, + ).asQuerySurface(), + authorityStatus: CultMesh.projectionRecipe( + AetheriaRtsSchemas.verseAuthorityPolicy, + [AetheriaRuntimeRtsProjectionSources.authorityPolicy], + executors.authorityStatus, + { routeHint, watchProjection: watchers.authorityStatus }, + ).asQuerySurface(), + starbridgeSession: CultMesh.projectionRecipe( + AetheriaRtsSchemas.starbridgeSessionSummary, + [AetheriaRuntimeRtsProjectionSources.starbridgeSession], + executors.starbridgeSession, + { routeHint, watchProjection: watchers.starbridgeSession }, + ).asQuerySurface(), + } as const; +} + +export function describeAetheriaRuntimeRtsQueryHandles( + handles: ReturnType, +) { + return { + mapViewport: describeAetheriaRuntimeRtsQuerySurface(handles.mapViewport), + objectsViewport: describeAetheriaRuntimeRtsQuerySurface(handles.objectsViewport), + gravityViewport: describeAetheriaRuntimeRtsQuerySurface(handles.gravityViewport), + selectedObject: describeAetheriaRuntimeRtsQuerySurface(handles.selectedObject), + inventory: describeAetheriaRuntimeRtsQuerySurface(handles.inventory), + daemonHealth: describeAetheriaRuntimeRtsQuerySurface(handles.daemonHealth), + authorityStatus: describeAetheriaRuntimeRtsQuerySurface(handles.authorityStatus), + starbridgeSession: describeAetheriaRuntimeRtsQuerySurface(handles.starbridgeSession), + } as const; +} + +export function describeAetheriaRuntimeRtsSurfaceCatalog( + handles: ReturnType, + operations?: AetheriaRuntimeRtsOperationHandles, + statePointers: AetheriaRuntimeRtsStatePointers = createAetheriaRuntimeRtsStatePointers(), +): AetheriaRuntimeRtsSurfaceCatalogDiagnostic { + return CultMesh.describeSurfaceCatalog( + "gamecult.aetheria.rts.surfaces.v1", + [ + CultMesh.describeSurface(statePointers.daemonFrame), + CultMesh.describeSurface(statePointers.daemonHealth), + CultMesh.describeSurface(statePointers.authorityPolicy), + CultMesh.describeSurface(statePointers.starbridgeSession), + CultMesh.describeSurface(handles.mapViewport), + CultMesh.describeSurface(handles.objectsViewport), + CultMesh.describeSurface(handles.gravityViewport), + CultMesh.describeSurface(handles.selectedObject), + CultMesh.describeSurface(handles.inventory), + CultMesh.describeSurface(handles.daemonHealth), + CultMesh.describeSurface(handles.authorityStatus), + CultMesh.describeSurface(handles.starbridgeSession), + ...(operations + ? [ + CultMesh.describeSurface(operations.setMoveVector), + CultMesh.describeSurface(operations.setTarget), + ] + : []), + ], + ); +} + +function describeAetheriaRuntimeRtsQuerySurface(surface: { + queryId: string; + routeHint: CultMeshRouteHint; + sources: readonly CultMeshProjectionSource[]; +}): AetheriaRuntimeRtsProjectionDiagnostic { + return CultMesh.describeQuerySurface(surface); +} + +export function describeAetheriaRuntimeRtsLiveFeedSurface(surface: { + feedId: string; + routeHint: CultMeshRouteHint; + sources: readonly CultMeshProjectionSource[]; +}): AetheriaRuntimeRtsLiveFeedDiagnostic { + return CultMesh.describeLiveFeed(surface); +} + +export function createAetheriaRuntimeRtsOperationHandles( + sendCommand: AetheriaRuntimeDaemonCommandSender, + createCommandId: () => string = createDefaultCommandId, +) { + return { + setMoveVector: CultMesh.operation( + "gamecult.aetheria.pilot.set_move_vector.v1", + async (request, context) => { + const commandId = context.idempotencyKey ?? createCommandId(); + const issuedAtUtc = new Date().toISOString(); + await sendCommand( + commandId, + issuedAtUtc, + encodeSetMoveVectorCommand(commandId, issuedAtUtc, context.runtimeId, request), + context, + ); + return { + commandId, + ...CultMesh.operationReceipt("gamecult.aetheria.pilot.set_move_vector.v1", true, { route: context.routeHint }), + }; + }, + ), + setTarget: CultMesh.operation( + "gamecult.aetheria.pilot.set_target.v1", + async (request, context) => { + const commandId = context.idempotencyKey ?? createCommandId(); + const issuedAtUtc = new Date().toISOString(); + await sendCommand( + commandId, + issuedAtUtc, + encodeSetTargetCommand(commandId, issuedAtUtc, context.runtimeId, request), + context, + ); + return { + commandId, + ...CultMesh.operationReceipt("gamecult.aetheria.pilot.set_target.v1", true, { route: context.routeHint }), + }; + }, + ), + } as const; +} + +export type AetheriaRuntimeRtsVerseFacade = ReturnType; + +export function createAetheriaRuntimeRtsVerseFacade( + queryVerse: CultMeshVerseContext, + commandVerse: CultMeshVerseContext, + queries: ReturnType, + operations: AetheriaRuntimeRtsOperationHandles, + statePointers: AetheriaRuntimeRtsStatePointers = createAetheriaRuntimeRtsStatePointers(), +) { + const mapViewport = CultMesh.bindQuery(queryVerse, queries.mapViewport); + const objectsViewport = CultMesh.bindQuery(queryVerse, queries.objectsViewport); + const gravityViewport = CultMesh.bindQuery(queryVerse, queries.gravityViewport); + const selectedObject = CultMesh.bindQuery(queryVerse, queries.selectedObject); + const inventory = CultMesh.bindQuery(queryVerse, queries.inventory); + const daemonHealth = CultMesh.bindQuery(queryVerse, queries.daemonHealth); + const authorityStatus = CultMesh.bindQuery(queryVerse, queries.authorityStatus); + const starbridgeSession = CultMesh.bindQuery(queryVerse, queries.starbridgeSession); + const daemonFramePointer = CultMesh.bindStatePointer(queryVerse, statePointers.daemonFrame); + const daemonHealthPointer = CultMesh.bindStatePointer(queryVerse, statePointers.daemonHealth); + const authorityPolicyPointer = CultMesh.bindStatePointer(queryVerse, statePointers.authorityPolicy); + const starbridgeSessionPointer = CultMesh.bindStatePointer(queryVerse, statePointers.starbridgeSession); + const setMoveVector = CultMesh.bindOperation(commandVerse, operations.setMoveVector); + const setTarget = CultMesh.bindOperation(commandVerse, operations.setTarget); + + return { + zone: (_zoneId = "current") => ({ + viewport: { + within: (request: CultMeshViewportRequest) => + mapViewport.execute(request), + watch: ( + request: CultMeshViewportRequest, + callback: (value: ViewportResponse) => void, + ) => mapViewport.watch(request, callback), + }, + objects: { + visibleWithin: (request: CultMeshViewportRequest) => + objectsViewport.execute(request), + watchVisibleWithin: ( + request: CultMeshViewportRequest, + callback: (value: ObjectsViewportResponse) => void, + ) => objectsViewport.watch(request, callback), + }, + gravity: { + within: (request: CultMeshViewportRequest) => + gravityViewport.execute(request), + watchWithin: ( + request: CultMeshViewportRequest, + callback: (value: GravityViewportResponse) => void, + ) => gravityViewport.watch(request, callback), + }, + }), + entity: (entityKey: string) => ({ + pilot: { + move: ( + direction: { readonly x: number; readonly y: number }, + options: { scalar?: number; observedFrameId?: number; commandId?: string } = {}, + ) => setMoveVector.invoke({ + actorEntityKey: entityKey, + directionX: direction.x, + directionY: direction.y, + scalar: options.scalar ?? 1, + observedFrameId: options.observedFrameId, + }, { idempotencyKey: options.commandId }), + target: ( + targetEntityKey: string, + options: { observedFrameId?: number; commandId?: string } = {}, + ) => setTarget.invoke({ + actorEntityKey: entityKey, + targetEntityKey, + observedFrameId: options.observedFrameId, + }, { idempotencyKey: options.commandId }), + }, + }), + selectedObject: (entityIndex: number) => + selectedObject.execute({ entityIndex }), + inventory: (entityIndex: number) => + inventory.execute({ entityIndex }), + daemon: { + state: { + frame: () => daemonFramePointer.resolve(), + health: () => daemonHealthPointer.resolve(), + authorityPolicy: () => authorityPolicyPointer.resolve(), + starbridgeSession: () => starbridgeSessionPointer.resolve(), + }, + health: () => daemonHealth.execute(undefined), + authorityStatus: () => authorityStatus.execute(undefined), + starbridgeSession: () => starbridgeSession.execute(undefined), + }, + } as const; +} + +export function encodeSetMoveVectorCommand( + commandId: string, + issuedAtUtc: string, + runtimeId: string, + request: AetheriaRuntimeSetMoveVectorRequest, +): unknown[] { + const command = createBaseCommand(commandId, issuedAtUtc, runtimeId, request.actorEntityKey, request.observedFrameId); + command[aetheriaRuntimeDaemonCommandDocumentSlots.kind] = AetheriaRuntimeDaemonCommandKinds.setMoveVector; + command[aetheriaRuntimeDaemonCommandDocumentSlots.directionX] = request.directionX; + command[aetheriaRuntimeDaemonCommandDocumentSlots.directionY] = request.directionY; + command[aetheriaRuntimeDaemonCommandDocumentSlots.scalarValue] = Math.min(1, Math.max(0, request.scalar)); + return command; +} + +export function encodeSetTargetCommand( + commandId: string, + issuedAtUtc: string, + runtimeId: string, + request: AetheriaRuntimeSetTargetRequest, +): unknown[] { + const command = createBaseCommand(commandId, issuedAtUtc, runtimeId, request.actorEntityKey, request.observedFrameId); + command[aetheriaRuntimeDaemonCommandDocumentSlots.kind] = AetheriaRuntimeDaemonCommandKinds.setTarget; + command[aetheriaRuntimeDaemonCommandDocumentSlots.targetEntityKey] = request.targetEntityKey; + return command; +} + +function createBaseCommand( + commandId: string, + issuedAtUtc: string, + runtimeId: string, + actorEntityKey: string, + observedFrameId?: number, +): unknown[] { + const command = new Array(Object.keys(aetheriaRuntimeDaemonCommandDocumentSlots).length).fill(null); + command[aetheriaRuntimeDaemonCommandDocumentSlots.schema] = AetheriaRtsSchemas.daemonCommand; + command[aetheriaRuntimeDaemonCommandDocumentSlots.commandId] = commandId; + command[aetheriaRuntimeDaemonCommandDocumentSlots.clientId] = runtimeId; + command[aetheriaRuntimeDaemonCommandDocumentSlots.issuedAtUtc] = issuedAtUtc; + command[aetheriaRuntimeDaemonCommandDocumentSlots.sessionId] = "local"; + command[aetheriaRuntimeDaemonCommandDocumentSlots.observedFrameId] = observedFrameId ?? -1; + command[aetheriaRuntimeDaemonCommandDocumentSlots.kind] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.actorEntityKey] = actorEntityKey; + command[aetheriaRuntimeDaemonCommandDocumentSlots.targetEntityKey] = ""; + command[aetheriaRuntimeDaemonCommandDocumentSlots.targetZoneIndex] = -1; + command[aetheriaRuntimeDaemonCommandDocumentSlots.equipmentIndex] = -1; + command[aetheriaRuntimeDaemonCommandDocumentSlots.behaviorIndex] = -1; + command[aetheriaRuntimeDaemonCommandDocumentSlots.weaponGroup] = -1; + command[aetheriaRuntimeDaemonCommandDocumentSlots.positionX] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.positionY] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.positionZ] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.directionX] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.directionY] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.scalarValue] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.textValue] = ""; + command[aetheriaRuntimeDaemonCommandDocumentSlots.authorRuntimeId] = ""; + command[aetheriaRuntimeDaemonCommandDocumentSlots.subjectKey] = ""; + command[aetheriaRuntimeDaemonCommandDocumentSlots.claimKind] = ""; + return command; +} + +function createDefaultCommandId(): string { + const cryptoApi = globalThis.crypto as { randomUUID?: () => string } | undefined; + return cryptoApi?.randomUUID?.() ?? `command-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} diff --git a/Aetheria.Rts.Web/Electron/aetheria-rts-local-projection.ts b/Aetheria.Rts.Web/Electron/aetheria-rts-local-projection.ts new file mode 100644 index 000000000..298e2ed7b --- /dev/null +++ b/Aetheria.Rts.Web/Electron/aetheria-rts-local-projection.ts @@ -0,0 +1,445 @@ +import { + AetheriaRtsSchemas, + aetheriaRuntimeAuthorityRuleSlots as authorityRuleSlots, + aetheriaRuntimeBodySnapshotCommitSlots as bodySlots, + aetheriaRuntimeCargoBayLoadoutCommitSlots as cargoBaySlots, + aetheriaRuntimeDaemonFrameDocumentSlots as frameSlots, + aetheriaRuntimeDaemonHealthDocumentSlots as healthSlots, + aetheriaRuntimeEntitySnapshotCommitSlots as entitySlots, + aetheriaRuntimeEntityStatGridCommitSlots as statGridSlots, + aetheriaRuntimeLoadoutItemCommitSlots as itemSlots, + aetheriaRuntimeLoadoutItemSlotCommitSlots as itemSlotSlots, + aetheriaRuntimeRunCheckpointCommitSlots as runSlots, + aetheriaRuntimeStarbridgeBaseStatusSlots as starbridgeBaseSlots, + aetheriaRuntimeStarbridgeRuntimeRoleSlots as starbridgeRoleSlots, + aetheriaRuntimeStarbridgeSessionSummaryDocumentSlots as starbridgeSummarySlots, + aetheriaRuntimeStarbridgeStationStockItemSlots as starbridgeStockSlots, + aetheriaRuntimeStarbridgeWaveForecastSlots as starbridgeWaveForecastSlots, + aetheriaRuntimeVerseAuthorityPolicyDocumentSlots as authorityPolicySlots, + aetheriaRuntimeZoneSnapshotCommitSlots as zoneSlots, +} from "./aetheria-rts-generated-bindings.js"; +import { cultMeshRectFromBounds, cultMeshViewportRequest } from "cultmesh-ts"; +import type { + AuthorityStatusProjection, + BodyView, + DaemonHealthProjection, + GravityViewportResponse, + GravityInfluence, + InventoryProjection, + InventoryItem, + ObjectsViewportResponse, + SelectedObjectProjection, + SelectedObjectRequest, + StarbridgeSessionProjection, + ViewObject, + ViewportRequest, + ViewportResponse, +} from "./aetheria-rts-bindings.js"; + +export function projectViewportFromFrame(frameDocument: unknown, request: ViewportRequest): ViewportResponse { + const objects = projectObjectsViewportFromFrame(frameDocument, request); + const gravity = projectGravityViewportFromFrame(frameDocument, request); + return { + schema: AetheriaRtsSchemas.rtsViewport, + frameId: objects.frameId, + publishedAtUtc: objects.publishedAtUtc, + simulationTimeSeconds: objects.simulationTimeSeconds, + runId: objects.runId, + zoneIndex: objects.zoneIndex, + zoneName: objects.zoneName, + currentEntityKey: objects.currentEntityKey, + viewport: objects.viewport, + controlledEntityIndices: objects.controlledEntityIndices, + objects: objects.objects, + gravityInfluences: gravity.gravityInfluences, + bodies: gravity.bodies, + }; +} + +export function projectObjectsViewportFromFrame( + frameDocument: unknown, + request: ViewportRequest, +): ObjectsViewportResponse { + const frame = arr(frameDocument); + const run = arr(frame[frameSlots.run]); + const zones = list(run[runSlots.zones]); + const currentZoneIndex = num(run[runSlots.currentZoneIndex], -1); + const zone = zones.find(candidate => num(candidate[zoneSlots.zoneIndex], -1) === currentZoneIndex) ?? + zones[0] ?? + []; + const runId = str(run[runSlots.runId]) || "local-rts"; + const viewport = normalizeViewport(request); + const entities = list(zone[zoneSlots.entities]); + const controlledEntityIndices = entities + .filter(isPlayerControlled) + .map(entity => num(entity[entitySlots.entityIndex], -1)) + .filter(index => index >= 0); + const controlled = entities.filter(entity => controlledEntityIndices.includes(num(entity[entitySlots.entityIndex], -1))); + const objects = entities + .filter(entity => entityIntersectsViewport(entity, viewport)) + .filter(entity => isPlayerControlled(entity) || + controlled.length === 0 || + controlled.some(observer => canSee(observer, entity))) + .map(entity => toViewObject(entity, runId, num(zone[zoneSlots.zoneIndex]))); + + return { + schema: AetheriaRtsSchemas.objectsViewport, + frameId: num(frame[frameSlots.frameId]), + publishedAtUtc: str(frame[frameSlots.publishedAtUtc]), + simulationTimeSeconds: num(frame[frameSlots.simulationTimeSeconds]), + runId, + zoneIndex: num(zone[zoneSlots.zoneIndex]), + zoneName: str(zone[zoneSlots.name]) || `Zone ${num(zone[zoneSlots.zoneIndex])}`, + currentEntityKey: str(run[runSlots.currentEntityKey]), + viewport, + controlledEntityIndices, + objects, + }; +} + +export function projectGravityViewportFromFrame( + frameDocument: unknown, + request: ViewportRequest, +): GravityViewportResponse { + const frame = arr(frameDocument); + const run = arr(frame[frameSlots.run]); + const zones = list(run[runSlots.zones]); + const currentZoneIndex = num(run[runSlots.currentZoneIndex], -1); + const zone = zones.find(candidate => num(candidate[zoneSlots.zoneIndex], -1) === currentZoneIndex) ?? + zones[0] ?? + []; + const runId = str(run[runSlots.runId]) || "local-rts"; + const viewport = normalizeViewport(request); + const visibleBodies = list(zone[zoneSlots.bodies]) + .filter(body => gravityInfluenceIntersectsViewport(body, viewport)); + + return { + schema: AetheriaRtsSchemas.gravityViewport, + frameId: num(frame[frameSlots.frameId]), + publishedAtUtc: str(frame[frameSlots.publishedAtUtc]), + simulationTimeSeconds: num(frame[frameSlots.simulationTimeSeconds]), + runId, + zoneIndex: num(zone[zoneSlots.zoneIndex]), + zoneName: str(zone[zoneSlots.name]) || `Zone ${num(zone[zoneSlots.zoneIndex])}`, + viewport, + gravityInfluences: visibleBodies.map(toGravityInfluence), + bodies: visibleBodies.map(toBodyView), + }; +} + +export function projectSelectedObjectFromFrame( + frameDocument: unknown, + request: SelectedObjectRequest, +): SelectedObjectProjection { + const context = frameContext(frameDocument); + const entity = context.entities.find(candidate => num(candidate[entitySlots.entityIndex], -1) === request.entityIndex); + return { + schema: AetheriaRtsSchemas.selectedObject, + frameId: context.frameId, + runId: context.runId, + zoneIndex: context.zoneIndex, + entityIndex: request.entityIndex, + selected: entity ? toViewObject(entity, context.runId, context.zoneIndex) : null, + }; +} + +export function projectInventoryFromFrame(frameDocument: unknown, request: SelectedObjectRequest): InventoryProjection { + const context = frameContext(frameDocument); + const entity = context.entities.find(candidate => num(candidate[entitySlots.entityIndex], -1) === request.entityIndex); + const allItems = entity ? inventory(entity) : []; + return { + schema: AetheriaRtsSchemas.inventory, + frameId: context.frameId, + runId: context.runId, + zoneIndex: context.zoneIndex, + entityIndex: request.entityIndex, + entityKey: entity ? entityKey(context.runId, context.zoneIndex, request.entityIndex) : "", + items: allItems, + equipment: allItems.filter(item => item.source === "equipment"), + cargo: allItems.filter(item => item.source === "cargo"), + }; +} + +export function projectDaemonHealth(healthDocument: unknown): DaemonHealthProjection { + const health = arr(healthDocument); + return { + schema: str(health[healthSlots.schema]) || AetheriaRtsSchemas.daemonHealth, + daemonId: str(health[healthSlots.daemonId]), + verseId: str(health[healthSlots.verseId]), + publishedAtUtc: str(health[healthSlots.publishedAtUtc]), + statePath: str(health[healthSlots.statePath]), + frameId: num(health[healthSlots.frameId]), + observedCommandCount: num(health[healthSlots.observedCommandCount]), + appliedCommandCount: num(health[healthSlots.appliedCommandCount]), + rejectedCommandCount: num(health[healthSlots.rejectedCommandCount]), + status: str(health[healthSlots.status]) || "unknown", + publicationSource: str(health[healthSlots.publicationSource]), + transport: str(health[healthSlots.transport]), + commandBoundaryPath: str(health[healthSlots.commandBoundaryPath]), + }; +} + +export function projectAuthorityStatus(policyDocument: unknown): AuthorityStatusProjection { + const policy = arr(policyDocument); + return { + schema: str(policy[authorityPolicySlots.schema]) || AetheriaRtsSchemas.verseAuthorityPolicy, + verseId: str(policy[authorityPolicySlots.verseId]), + policyId: str(policy[authorityPolicySlots.policyId]), + ruleVersion: str(policy[authorityPolicySlots.ruleVersion]), + hostRuntimeId: str(policy[authorityPolicySlots.hostRuntimeId]), + defaultMode: str(policy[authorityPolicySlots.defaultMode]), + updatedAtUtc: str(policy[authorityPolicySlots.updatedAtUtc]), + rules: list(policy[authorityPolicySlots.rules]).map(rule => ({ + ruleId: str(rule[authorityRuleSlots.ruleId]), + subjectPrefix: str(rule[authorityRuleSlots.subjectPrefix]), + claimKinds: stringList(rule[authorityRuleSlots.claimKinds]), + mode: str(rule[authorityRuleSlots.mode]), + runtimeIds: stringList(rule[authorityRuleSlots.runtimeIds]), + leaseScope: str(rule[authorityRuleSlots.leaseScope]), + priority: num(rule[authorityRuleSlots.priority]), + })), + }; +} + +export function projectStarbridgeSessionSummary(summaryDocument: unknown): StarbridgeSessionProjection { + const summary = arr(summaryDocument); + return { + schema: str(summary[starbridgeSummarySlots.schema]) || AetheriaRtsSchemas.starbridgeSessionSummary, + frameId: num(summary[starbridgeSummarySlots.frameId]), + publishedAtUtc: str(summary[starbridgeSummarySlots.publishedAtUtc]), + sessionId: str(summary[starbridgeSummarySlots.sessionId]) || "starbridge-session", + scenarioId: str(summary[starbridgeSummarySlots.scenarioId]) || "starbridge.local", + scenarioName: str(summary[starbridgeSummarySlots.scenarioName]) || "Starbridge", + runId: str(summary[starbridgeSummarySlots.runId]) || "local-starbridge", + zoneIndex: num(summary[starbridgeSummarySlots.zoneIndex]), + zoneName: str(summary[starbridgeSummarySlots.zoneName]), + phase: str(summary[starbridgeSummarySlots.phase]) || "setup", + currentWaveIndex: num(summary[starbridgeSummarySlots.currentWaveIndex]), + baseStatus: toStarbridgeBaseStatus(arr(summary[starbridgeSummarySlots.baseStatus])), + stationStock: list(summary[starbridgeSummarySlots.stationStock]).map(toStarbridgeStationStockItem), + waveForecast: list(summary[starbridgeSummarySlots.waveForecast]).map(toStarbridgeWaveForecast), + runtimeRoles: list(summary[starbridgeSummarySlots.runtimeRoles]).map(toStarbridgeRuntimeRole), + }; +} + +function frameContext(frameDocument: unknown): { + frameId: number; + runId: string; + zoneIndex: number; + entities: unknown[][]; +} { + const frame = arr(frameDocument); + const run = arr(frame[frameSlots.run]); + const zones = list(run[runSlots.zones]); + const currentZoneIndex = num(run[runSlots.currentZoneIndex], -1); + const zone = zones.find(candidate => num(candidate[zoneSlots.zoneIndex], -1) === currentZoneIndex) ?? + zones[0] ?? + []; + return { + frameId: num(frame[frameSlots.frameId]), + runId: str(run[runSlots.runId]) || "local-rts", + zoneIndex: num(zone[zoneSlots.zoneIndex]), + entities: list(zone[zoneSlots.entities]), + }; +} + +function normalizeViewport(request: ViewportRequest): ViewportRequest { + return cultMeshViewportRequest( + cultMeshRectFromBounds(request.minX, request.minY, request.maxX, request.maxY), + request.controlledEntityIndices, + ); +} + +function toViewObject(entity: unknown[], runId: string, zoneIndex: number): ViewObject { + return { + entityIndex: num(entity[entitySlots.entityIndex], -1), + entityKey: entityKey(runId, zoneIndex, num(entity[entitySlots.entityIndex], -1)), + displayName: str(entity[entitySlots.name]), + kind: str(entity[entitySlots.kind]), + factionKey: str(entity[entitySlots.factionKey]), + x: num(entity[entitySlots.positionX]), + y: num(entity[entitySlots.positionZ]), + z: num(entity[entitySlots.positionY]), + directionX: num(entity[entitySlots.directionX]), + directionY: num(entity[entitySlots.directionY]), + velocityX: num(entity[entitySlots.velocityX]), + velocityY: num(entity[entitySlots.velocityY]), + controlled: isPlayerControlled(entity), + targetEntityIndex: num(entity[entitySlots.targetEntityIndex], -1), + isActive: bool(entity[entitySlots.isActive]), + visibility: num(entity[entitySlots.visibility]), + status: { + hull: stat(entity, "hull"), + shield: stat(entity, "shield"), + heat: stat(entity, "heat"), + }, + inventory: inventory(entity), + }; +} + +function toStarbridgeBaseStatus(status: unknown[]): StarbridgeSessionProjection["baseStatus"] { + return { + entityKey: str(status[starbridgeBaseSlots.entityKey]), + displayName: str(status[starbridgeBaseSlots.displayName]), + hull: num(status[starbridgeBaseSlots.hull]), + shield: num(status[starbridgeBaseSlots.shield]), + heat: num(status[starbridgeBaseSlots.heat]), + isActive: bool(status[starbridgeBaseSlots.isActive]), + }; +} + +function toStarbridgeStationStockItem(item: unknown[]): StarbridgeSessionProjection["stationStock"][number] { + return { + itemKey: str(item[starbridgeStockSlots.itemKey]), + quantity: num(item[starbridgeStockSlots.quantity]), + quality: num(item[starbridgeStockSlots.quality]), + durability: num(item[starbridgeStockSlots.durability]), + source: str(item[starbridgeStockSlots.source]) || "station", + }; +} + +function toStarbridgeWaveForecast(wave: unknown[]): StarbridgeSessionProjection["waveForecast"][number] { + return { + waveIndex: num(wave[starbridgeWaveForecastSlots.waveIndex]), + displayName: str(wave[starbridgeWaveForecastSlots.displayName]), + attackerKeys: stringList(wave[starbridgeWaveForecastSlots.attackerKeys]), + bossKey: str(wave[starbridgeWaveForecastSlots.bossKey]), + recoveredTechnologyKeys: stringList(wave[starbridgeWaveForecastSlots.recoveredTechnologyKeys]), + }; +} + +function toStarbridgeRuntimeRole(role: unknown[]): StarbridgeSessionProjection["runtimeRoles"][number] { + return { + runtimeId: str(role[starbridgeRoleSlots.runtimeId]), + role: str(role[starbridgeRoleSlots.role]), + entityKey: str(role[starbridgeRoleSlots.entityKey]), + }; +} + +function toGravityInfluence(body: unknown[]): GravityInfluence { + return { + bodyKey: str(body[bodySlots.bodyKey]), + orbitKey: str(body[bodySlots.orbitKey]), + kind: str(body[bodySlots.kind]), + x: num(body[bodySlots.gravityInfluenceCenterX]), + y: num(body[bodySlots.gravityInfluenceCenterZ]), + radius: resolveGravityRadius(body), + gravityDepth: num(body[bodySlots.gravityWellDepth]), + gravityDepthExponent: num(body[bodySlots.gravityDepthExponent]), + waveRadius: num(body[bodySlots.gravityWaveRadius]), + waveDepth: num(body[bodySlots.gravityWaveDepth]), + waveSpeed: num(body[bodySlots.gravityWaveSpeed]), + }; +} + +function toBodyView(body: unknown[]): BodyView { + return { + bodyKey: str(body[bodySlots.bodyKey]), + orbitKey: str(body[bodySlots.orbitKey]), + name: str(body[bodySlots.name]), + kind: str(body[bodySlots.kind]), + x: num(body[bodySlots.gravityInfluenceCenterX]), + y: num(body[bodySlots.gravityInfluenceCenterZ]), + radius: Math.max(32, num(body[bodySlots.bodyRadiusMultiplier]) * 70), + isAsteroidBelt: str(body[bodySlots.kind]).toLowerCase().includes("asteroid"), + }; +} + +function inventory(entity: unknown[]): InventoryItem[] { + const items: InventoryItem[] = []; + for (const slot of list(entity[entitySlots.equipment])) + addSlot(items, "equipment", slot); + for (const bay of list(entity[entitySlots.cargoContents])) { + for (const slot of list(bay[cargoBaySlots.items])) + addSlot(items, "cargo", slot); + } + + return items.filter(item => item.itemKey.length > 0); +} + +function addSlot(items: InventoryItem[], source: string, slot: unknown[]): void { + const item = arr(slot[itemSlotSlots.item]); + items.push({ + source, + itemKey: str(item[itemSlots.itemKey]), + quantity: num(item[itemSlots.quantity]), + quality: num(item[itemSlots.quality]), + durability: num(item[itemSlots.durability]), + enabled: bool(item[itemSlots.enabled]), + }); +} + +function entityIntersectsViewport(entity: unknown[], viewport: ViewportRequest): boolean { + const x = num(entity[entitySlots.positionX]); + const y = num(entity[entitySlots.positionZ]); + return x >= viewport.minX && x <= viewport.maxX && y >= viewport.minY && y <= viewport.maxY; +} + +function gravityInfluenceIntersectsViewport(body: unknown[], viewport: ViewportRequest): boolean { + const x = num(body[bodySlots.gravityInfluenceCenterX]); + const y = num(body[bodySlots.gravityInfluenceCenterZ]); + const radius = resolveGravityRadius(body); + return x + radius >= viewport.minX && + x - radius <= viewport.maxX && + y + radius >= viewport.minY && + y - radius <= viewport.maxY; +} + +function canSee(observer: unknown[], target: unknown[]): boolean { + if (num(observer[entitySlots.entityIndex], -1) === num(target[entitySlots.entityIndex], -2)) + return true; + + const dx = num(observer[entitySlots.positionX]) - num(target[entitySlots.positionX]); + const dy = num(observer[entitySlots.positionZ]) - num(target[entitySlots.positionZ]); + const range = Math.max(180, num(observer[entitySlots.visibility])); + return dx * dx + dy * dy <= range * range; +} + +function isPlayerControlled(entity: unknown[]): boolean { + return str(entity[entitySlots.factionKey]).toLowerCase() === "player"; +} + +function stat(entity: unknown[], name: string): number { + const grid = list(entity[entitySlots.statGrids]) + .find(candidate => str(candidate[statGridSlots.name]).toLowerCase() === name.toLowerCase()); + return grid ? numberList(grid[statGridSlots.values])[0] ?? 0 : 0; +} + +function resolveGravityRadius(body: unknown[]): number { + const explicit = num(body[bodySlots.gravityInfluenceRadius]); + if (explicit > 0) + return explicit; + return Math.max(32, num(body[bodySlots.bodyRadiusMultiplier]) * 70); +} + +function entityKey(runId: string, zoneIndex: number, entityIndex: number): string { + return `global:aetheria.run_state.${runId}.zone.${zoneIndex}.entity.${entityIndex}.v1`; +} + +function arr(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function list(value: unknown): T[] { + return Array.isArray(value) ? value.filter(Array.isArray) as T[] : []; +} + +function numberList(value: unknown): number[] { + return Array.isArray(value) ? value.map(candidate => num(candidate)) : []; +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.map(candidate => str(candidate)).filter(candidate => candidate.length > 0) : []; +} + +function str(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function num(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function bool(value: unknown): boolean { + return value === true; +} diff --git a/Aetheria.Rts.Web/Electron/main.ts b/Aetheria.Rts.Web/Electron/main.ts new file mode 100644 index 000000000..f44576402 --- /dev/null +++ b/Aetheria.Rts.Web/Electron/main.ts @@ -0,0 +1,424 @@ +import { app, BrowserWindow, ipcMain, shell } from "electron"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createWriteStream, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + AetheriaCultMeshClient, +} from "./aetheria-cultmesh.js"; +import { registerAetheriaRtsIpcHandlers } from "./aetheria-rts-generated-bindings.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(__dirname, ".."); +const repoRoot = resolve(projectRoot, ".."); +const logsRoot = resolve(projectRoot, "logs"); +const runtimeRoot = resolve(process.env.AETHERIA_RTS_RUNTIME_ROOT ?? resolve(projectRoot, "runtime")); +const runtimeStatePath = resolve(runtimeRoot, "aetheria-rts.cc"); +const daemonDll = resolve(repoRoot, "Aetheria.State.Daemon", "bin", "Debug", "net10.0", "Aetheria.State.Daemon.dll"); +const rendererIndex = resolve(projectRoot, "wwwroot", "index.html"); +const rtsCultMeshPort = Number.parseInt(process.env.AETHERIA_RTS_CULTMESH_PORT ?? "3076", 10); +const configuredDaemonEndpoint = process.env.AETHERIA_RTS_DAEMON_ENDPOINT?.trim() ?? ""; +const launchLocalDaemon = configuredDaemonEndpoint.length === 0; +const rtsCultMeshEndpoint = configuredDaemonEndpoint || `rudp://127.0.0.1:${rtsCultMeshPort}`; +const rtsVerseId = process.env.AETHERIA_RTS_VERSE_ID?.trim() || "aetheria.local"; +const rtsDaemonId = process.env.AETHERIA_RTS_DAEMON_ID?.trim() || "starfire-rts"; +const rtsCultMeshAdvertiseHost = process.env.AETHERIA_RTS_CULTMESH_ADVERTISE_HOST?.trim() || "127.0.0.1"; +const peerCultMeshEndpoints = parseEndpointList(process.env.AETHERIA_RTS_PEER_CULTMESH_ENDPOINTS); +const electronSmoke = process.env.AETHERIA_RTS_ELECTRON_SMOKE === "1"; +const electronSmokeResultPath = process.env.AETHERIA_RTS_ELECTRON_SMOKE_RESULT; + +let daemonProcess: ChildProcessWithoutNullStreams | null = null; +let mainWindow: BrowserWindow | null = null; +let rtsClient: AetheriaCultMeshClient | null = null; +let isQuitting = false; + +app.whenReady().then(async () => { + mainWindow = createWindow(); + registerIpc(); + showStartup("Preparing Aetheria RTS", "Building daemon if needed."); + + try { + if (launchLocalDaemon) { + await ensureDotnetBuild(); + showStartup("Launching Aetheria RTS", "Starting the Aetheria daemon."); + mkdirSync(runtimeRoot, { recursive: true }); + daemonProcess = startDotnet("aetheria-daemon", daemonDll, [ + "--state", + runtimeStatePath, + "--verse-id", + rtsVerseId, + "--daemon-id", + rtsDaemonId, + "--rts-cultmesh-port", + rtsCultMeshPort.toString(), + "--rts-cultmesh-advertise-host", + rtsCultMeshAdvertiseHost, + "--tick-interval-ms", + "20", + "--fixed-delta-ms", + "20", + "--api-publication-interval-ms", + "100000", + ...peerCultMeshEndpoints.flatMap(endpoint => ["--peer-cultmesh-endpoint", endpoint]), + ]); + } else { + showStartup("Connecting Aetheria RTS", `Using daemon ${rtsCultMeshEndpoint}.`); + mkdirSync(runtimeRoot, { recursive: true }); + } + + rtsClient = new AetheriaCultMeshClient( + rtsCultMeshEndpoint, + runtimeStatePath, + "aetheria-rts-electron", + { publicationMode: launchLocalDaemon ? "local" : "remote" }); + + showStartup("Launching Aetheria RTS", "Waiting for the daemon CultMesh frame."); + await rtsClient.waitForFrame(30000); + await mainWindow.loadFile(rendererIndex); + if (electronSmoke) { + const result = await runElectronSmoke(mainWindow); + writeElectronSmokeResult({ ok: true, result }); + console.log(JSON.stringify(result, null, 2)); + exitElectronSmoke(0); + } + } catch (error) { + await showFailure(error); + if (electronSmoke) { + writeElectronSmokeResult({ + ok: false, + error: error instanceof Error ? error.stack ?? error.message : String(error), + }); + console.error(error instanceof Error ? error.stack ?? error.message : String(error)); + exitElectronSmoke(1); + } + } +}); + +app.on("window-all-closed", () => { + app.quit(); +}); + +app.on("before-quit", () => { + isQuitting = true; + void rtsClient?.close(); + stopChild(daemonProcess); +}); + +function createWindow(): BrowserWindow { + const window = new BrowserWindow({ + width: 1440, + height: 900, + minWidth: 1000, + minHeight: 640, + show: !electronSmoke, + backgroundColor: "#0b1016", + title: "Aetheria RTS", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: resolve(__dirname, "preload.cjs"), + }, + }); + + window.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: "deny" }; + }); + + return window; +} + +async function runElectronSmoke(window: BrowserWindow): Promise> { + const started = Date.now(); + let lastResult: Record | null = null; + while (Date.now() - started < 15000) { + lastResult = await window.webContents.executeJavaScript(` + (async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + const api = window.aetheriaRts; + const status = document.querySelector("#status")?.textContent ?? ""; + const selectionTitle = document.querySelector("#selection-title")?.textContent ?? ""; + const selectionDetails = document.querySelector("#selection-details")?.textContent ?? ""; + const frameDetails = document.querySelector("#frame-details")?.textContent ?? ""; + const runtimeSurfaceDetails = document.querySelector("#runtime-surface-details")?.textContent ?? ""; + const controlledText = document.querySelector("#controlled-list")?.textContent ?? ""; + const canvas = document.querySelector("#map"); + const health = api ? await api.daemonHealth() : null; + const authority = api ? await api.authorityStatus() : null; + const starbridge = api ? await api.starbridgeSession() : null; + const surfaceCatalog = api ? await api.surfaceCatalog() : null; + const surfaceCatalogIndex = api ? await api.surfaceCatalogIndex() : null; + const viewport = api ? await api.mapViewport({ minX: -5000, minY: -5000, maxX: 5000, maxY: 5000 }) : null; + const actor = viewport?.objects?.find(object => object.controlled) ?? viewport?.objects?.[0] ?? null; + const moveReceipt = api && actor ? await api.setMoveVector({ + actorEntityKey: actor.entityKey, + directionX: 1, + directionY: 0, + scalar: 0.1, + observedFrameId: viewport.frameId, + }) : null; + return { + hasApi: !!api && + typeof api.mapViewport === "function" && + typeof api.objectsViewport === "function" && + typeof api.gravityViewport === "function" && + typeof api.selectedObject === "function" && + typeof api.inventory === "function" && + typeof api.daemonHealth === "function" && + typeof api.authorityStatus === "function" && + typeof api.starbridgeSession === "function" && + typeof api.surfaceCatalog === "function" && + typeof api.surfaceCatalogIndex === "function", + status, + selectionTitle, + selectionDetails, + frameDetails, + runtimeSurfaceDetails, + controlledText, + canvasClientWidth: canvas?.clientWidth ?? 0, + canvasClientHeight: canvas?.clientHeight ?? 0, + canvasWidth: canvas?.width ?? 0, + canvasHeight: canvas?.height ?? 0, + health, + authority, + starbridge, + surfaceCatalog, + surfaceCatalogIndex, + moveReceipt + }; + })() + `, true) as Record; + + if (isElectronSmokeReady(lastResult)) + return lastResult; + await delay(250); + } + + throw new Error(`Aetheria RTS Electron smoke did not reach ready state. Last result: ${JSON.stringify(lastResult)}`); +} + +function isElectronSmokeReady(result: Record): boolean { + const status = stringValue(result.status); + const selectionTitle = stringValue(result.selectionTitle); + const selectionDetails = stringValue(result.selectionDetails); + const frameDetails = stringValue(result.frameDetails); + const runtimeSurfaceDetails = stringValue(result.runtimeSurfaceDetails); + const controlledText = stringValue(result.controlledText); + const health = objectValue(result.health); + const authority = objectValue(result.authority); + const starbridge = objectValue(result.starbridge); + const surfaceCatalog = objectValue(result.surfaceCatalog); + const surfaceCatalogIndex = objectValue(result.surfaceCatalogIndex); + const moveReceipt = objectValue(result.moveReceipt); + return result.hasApi === true && + status.includes("frame") && + selectionTitle.length > 0 && + selectionTitle !== "No Selection" && + selectionDetails.includes("Equipment") && + selectionDetails.includes("Cargo") && + frameDetails.includes("Daemon") && + frameDetails.includes("Authority") && + runtimeSurfaceDetails.includes("shared-memory") && + runtimeSurfaceDetails.includes("daemon:aetheria.frame.latest.v1") && + runtimeSurfaceDetails.includes("gamecult.aetheria.pilot.set_move_vector.v1") && + controlledText.length > 0 && + numberValue(result.canvasClientWidth) > 0 && + numberValue(result.canvasClientHeight) > 0 && + numberValue(result.canvasWidth) > 0 && + numberValue(result.canvasHeight) > 0 && + health?.status === "healthy" && + authority?.policyId === "aetheria.trusted-coop.v1" && + starbridge?.scenarioName === "Frontier Fabricator Defense" && + surfaceCatalog?.catalogId === "gamecult.aetheria.rts.surfaces.v1" && + arrayValue(surfaceCatalogIndex?.queries).length > 0 && + arrayValue(surfaceCatalogIndex?.operations).some(surface => + objectValue(surface)?.surfaceId === "gamecult.aetheria.pilot.set_move_vector.v1") && + stringValue(moveReceipt?.commandId).length > 0 && + stringValue(moveReceipt?.operationId) === "gamecult.aetheria.pilot.set_move_vector.v1" && + objectValue(moveReceipt?.route)?.kind === "network" && + moveReceipt?.accepted === true; +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function numberValue(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function objectValue(value: unknown): Record | null { + return value != null && typeof value === "object" ? value as Record : null; +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function writeElectronSmokeResult(result: Record): void { + if (!electronSmokeResultPath) + return; + + writeFileSync(electronSmokeResultPath, JSON.stringify(result, null, 2), "utf8"); +} + +function exitElectronSmoke(exitCode: number): void { + isQuitting = true; + void rtsClient?.close(); + rtsClient = null; + stopChild(daemonProcess); + daemonProcess = null; + mainWindow?.destroy(); + mainWindow = null; + setImmediate(() => app.exit(exitCode)); +} + +function registerIpc(): void { + registerAetheriaRtsIpcHandlers( + ipcMain, + requireClient, + () => ({ + status: "ok", + transport: "cultmesh-rudp", + endpoint: rtsCultMeshEndpoint, + verseId: rtsVerseId, + daemonId: rtsDaemonId, + peerEndpoints: peerCultMeshEndpoints, + daemonRunning: daemonProcess != null && !daemonProcess.killed, + daemonMode: launchLocalDaemon ? "local" : "remote", + })); +} + +function parseEndpointList(value: string | undefined): string[] { + if (!value) + return []; + + return value + .split(/[;,]/u) + .map(endpoint => endpoint.trim()) + .filter(endpoint => endpoint.length > 0); +} + +function requireClient(): AetheriaCultMeshClient { + if (!rtsClient) + throw new Error("Aetheria RTS CultMesh client is not initialized."); + return rtsClient; +} + +async function ensureDotnetBuild(): Promise { + if (existsSync(daemonDll)) + return; + + await runProcess("dotnet", ["build", resolve(repoRoot, "Aetheria.State.Daemon", "Aetheria.State.Daemon.csproj")], "Aetheria.State.Daemon.build"); +} + +function startDotnet(name: string, dllPath: string, args: string[]): ChildProcessWithoutNullStreams { + mkdirSync(logsRoot, { recursive: true }); + const child = spawn("dotnet", [dllPath, ...args], { + cwd: repoRoot, + windowsHide: true, + }); + pipeChildLogs(name, child); + child.once("exit", (code, signal) => { + if (!isQuitting) { + void showFailure(new Error(`${name} exited unexpectedly (${signal ?? code ?? "unknown"}). Check ${logsRoot}.`)); + } + }); + return child; +} + +function runProcess(command: string, args: string[], logName: string): Promise { + mkdirSync(logsRoot, { recursive: true }); + const child = spawn(command, args, { + cwd: repoRoot, + windowsHide: true, + }); + pipeChildLogs(logName, child); + return new Promise((resolvePromise, reject) => { + child.once("error", reject); + child.once("exit", code => { + if (code === 0) { + resolvePromise(); + return; + } + + reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}. Check ${logsRoot}.`)); + }); + }); +} + +function pipeChildLogs(name: string, child: ChildProcessWithoutNullStreams): void { + const stdout = createWriteStream(resolve(logsRoot, `${name}.log`), { flags: "a" }); + const stderr = createWriteStream(resolve(logsRoot, `${name}.err.log`), { flags: "a" }); + child.stdout.pipe(stdout); + child.stderr.pipe(stderr); +} + +function stopChild(child: ChildProcessWithoutNullStreams | null): void { + if (!child || child.killed) + return; + + child.kill(); +} + +function delay(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +function showStartup(title: string, detail: string): void { + void mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupHtml(title, detail))}`); +} + +async function showFailure(error: unknown): Promise { + const message = error instanceof Error ? error.message : String(error); + await mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupHtml("Aetheria RTS failed to start", message))}`); +} + +function startupHtml(title: string, detail: string): string { + return ` + + + + ${escapeHtml(title)} + + + +
+

${escapeHtml(title)}

+

${escapeHtml(detail)}

+
+ +`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/Aetheria.Rts.Web/Electron/preload.cjs b/Aetheria.Rts.Web/Electron/preload.cjs new file mode 100644 index 000000000..4a0382b59 --- /dev/null +++ b/Aetheria.Rts.Web/Electron/preload.cjs @@ -0,0 +1,53 @@ +// +// Generated by scripts/generate-rts-bindings.mjs from the RTS IPC surface contract. + +const { contextBridge, ipcRenderer } = require("electron"); + +const channels = { + mapViewport: "aetheria-rts:map-viewport", + objectsViewport: "aetheria-rts:objects-viewport", + gravityViewport: "aetheria-rts:gravity-viewport", + viewportFeed: "aetheria-rts:viewport-feed", + viewportFeedStop: "aetheria-rts:viewport-feed-stop", + viewportFeedUpdate: "aetheria-rts:viewport-feed-update", + selectedObject: "aetheria-rts:selected-object", + inventory: "aetheria-rts:inventory", + daemonHealth: "aetheria-rts:daemon-health", + authorityStatus: "aetheria-rts:authority-status", + starbridgeSession: "aetheria-rts:starbridge-session", + setMoveVector: "aetheria-rts:set-move-vector", + setTarget: "aetheria-rts:set-target", + surfaceCatalog: "aetheria-rts:surface-catalog", + surfaceCatalogIndex: "aetheria-rts:surface-catalog-index", + health: "aetheria-rts:health", +}; + +contextBridge.exposeInMainWorld("aetheriaRts", { + mapViewport: request => ipcRenderer.invoke(channels.mapViewport, request), + objectsViewport: request => ipcRenderer.invoke(channels.objectsViewport, request), + gravityViewport: request => ipcRenderer.invoke(channels.gravityViewport, request), + watchViewportFeed: (request, callback) => { + const subscriptionId = `viewport-feed-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + const listener = (_event, message) => { + if (message?.subscriptionId === subscriptionId) { + callback(message.snapshot); + } + }; + ipcRenderer.on(channels.viewportFeedUpdate, listener); + void ipcRenderer.invoke(channels.viewportFeed, { subscriptionId, feed: request }); + return () => { + ipcRenderer.off(channels.viewportFeedUpdate, listener); + void ipcRenderer.invoke(channels.viewportFeedStop, subscriptionId); + }; + }, + selectedObject: request => ipcRenderer.invoke(channels.selectedObject, request), + inventory: request => ipcRenderer.invoke(channels.inventory, request), + daemonHealth: () => ipcRenderer.invoke(channels.daemonHealth), + authorityStatus: () => ipcRenderer.invoke(channels.authorityStatus), + starbridgeSession: () => ipcRenderer.invoke(channels.starbridgeSession), + setMoveVector: request => ipcRenderer.invoke(channels.setMoveVector, request), + setTarget: request => ipcRenderer.invoke(channels.setTarget, request), + surfaceCatalog: () => ipcRenderer.invoke(channels.surfaceCatalog), + surfaceCatalogIndex: () => ipcRenderer.invoke(channels.surfaceCatalogIndex), + health: () => ipcRenderer.invoke(channels.health), +}); diff --git a/Aetheria.Rts.Web/package-lock.json b/Aetheria.Rts.Web/package-lock.json new file mode 100644 index 000000000..88fa115a6 --- /dev/null +++ b/Aetheria.Rts.Web/package-lock.json @@ -0,0 +1,275 @@ +{ + "name": "aetheria-rts-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aetheria-rts-web", + "version": "0.1.0", + "dependencies": { + "@msgpack/msgpack": "^3.1.2", + "cultcache-ts": "file:../../CultLib/packages/cultcache-ts", + "cultmesh-ts": "file:../../CultLib/packages/cultmesh-ts", + "cultnet-ts": "file:../../CultLib/packages/cultnet-ts" + }, + "devDependencies": { + "@types/node": "^26.0.0", + "electron": "^42.4.1", + "typescript": "^5.6.3" + } + }, + "../../CultLib/packages/cultcache-ts": { + "version": "0.13.5", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + } + }, + "../../CultLib/packages/cultmesh-ts": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.2", + "cultcache-ts": "file:../cultcache-ts", + "cultnet-ts": "file:../cultnet-ts" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0", + "zod": "^3.25.76" + } + }, + "../../CultLib/packages/cultnet-ts": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "ajv": "^8.17.1", + "cultcache-ts": "file:../cultcache-ts" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "json-schema-to-typescript": "^15.0.4", + "typescript": "^5.9.0", + "zod": "^3.25.76" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz", + "integrity": "sha512-OjKpjB7gohtEjZiq6nDx1egqjZJhGPN1iFOIED+NFhB/MMkXw/XRcHjh1DGXKT5z2W9eW7Jy2UKU3gpjvusFTQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@types/node": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/cultcache-ts": { + "resolved": "../../CultLib/packages/cultcache-ts", + "link": true + }, + "node_modules/cultmesh-ts": { + "resolved": "../../CultLib/packages/cultmesh-ts", + "link": true + }, + "node_modules/cultnet-ts": { + "resolved": "../../CultLib/packages/cultnet-ts", + "link": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron": { + "version": "42.4.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.4.1.tgz", + "integrity": "sha512-8CYHJP5O4wFO+ycoJR98yy907MmPeo+vWXrzjxmGGgRNKqv8pOjjm+wphO0CCgQJnBU7+QUPSJS4QXhbKrO50w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/Aetheria.Rts.Web/package.json b/Aetheria.Rts.Web/package.json new file mode 100644 index 000000000..d399daf22 --- /dev/null +++ b/Aetheria.Rts.Web/package.json @@ -0,0 +1,31 @@ +{ + "name": "aetheria-rts-web", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "electron-dist/main.js", + "scripts": { + "build": "C:\\Progra~1\\nodejs\\node.exe ./node_modules/typescript/bin/tsc -p tsconfig.json && C:\\Progra~1\\nodejs\\node.exe ./node_modules/typescript/bin/tsc -p tsconfig.electron.json && C:\\Progra~1\\nodejs\\node.exe scripts/copy-electron-preload.mjs", + "build:renderer": "C:\\Progra~1\\nodejs\\node.exe ./node_modules/typescript/bin/tsc -p tsconfig.json", + "build:electron": "C:\\Progra~1\\nodejs\\node.exe ./node_modules/typescript/bin/tsc -p tsconfig.electron.json && C:\\Progra~1\\nodejs\\node.exe scripts/copy-electron-preload.mjs", + "generate:rts-bindings": "C:\\Progra~1\\nodejs\\node.exe scripts/generate-rts-bindings.mjs", + "check:rts-bindings": "C:\\Progra~1\\nodejs\\node.exe scripts/generate-rts-bindings.mjs --check", + "verify:stage7b": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\\verify-stage7b-rts-client.ps1", + "verify:stage7c": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\\verify-stage7c-local-runtime.ps1", + "verify:stage7c:electron": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\\verify-stage7c-electron-shell.ps1", + "verify:stage7d:remote": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\\verify-stage7d-remote-cultmesh.ps1", + "electron": "npm run build && .\\node_modules\\electron\\dist\\electron.exe .", + "watch": "C:\\Progra~1\\nodejs\\node.exe ./node_modules/typescript/bin/tsc -p tsconfig.json --watch" + }, + "devDependencies": { + "@types/node": "^26.0.0", + "electron": "^42.4.1", + "typescript": "^5.6.3" + }, + "dependencies": { + "@msgpack/msgpack": "^3.1.2", + "cultcache-ts": "file:../../CultLib/packages/cultcache-ts", + "cultmesh-ts": "file:../../CultLib/packages/cultmesh-ts", + "cultnet-ts": "file:../../CultLib/packages/cultnet-ts" + } +} diff --git a/Aetheria.Rts.Web/scripts/copy-electron-preload.mjs b/Aetheria.Rts.Web/scripts/copy-electron-preload.mjs new file mode 100644 index 000000000..474429ac7 --- /dev/null +++ b/Aetheria.Rts.Web/scripts/copy-electron-preload.mjs @@ -0,0 +1,10 @@ +import { copyFileSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const source = resolve(root, "Electron", "preload.cjs"); +const legacyOutput = resolve(root, "electron-dist", "preload.js"); +const output = resolve(root, "electron-dist", "preload.cjs"); + +rmSync(legacyOutput, { force: true }); +copyFileSync(source, output); diff --git a/Aetheria.Rts.Web/scripts/generate-rts-bindings.mjs b/Aetheria.Rts.Web/scripts/generate-rts-bindings.mjs new file mode 100644 index 000000000..da47f79a9 --- /dev/null +++ b/Aetheria.Rts.Web/scripts/generate-rts-bindings.mjs @@ -0,0 +1,1273 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const root = resolve(scriptPath, "..", ".."); +const repoRoot = resolve(root, ".."); + +const sources = [ + resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs"), + resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveCommandDocument.cs"), + resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs"), + resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs"), + resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs"), + resolve(repoRoot, "Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs"), +]; +const outputPath = resolve(root, "Electron/aetheria-rts-generated-bindings.ts"); +const rendererContractPath = resolve(root, "Client/aetheria-rts-contract.ts"); +const preloadPath = resolve(root, "Electron/preload.cjs"); +const check = process.argv.includes("--check"); + +const ipcChannels = [ + ["mapViewport", "aetheria-rts:map-viewport"], + ["objectsViewport", "aetheria-rts:objects-viewport"], + ["gravityViewport", "aetheria-rts:gravity-viewport"], + ["viewportFeed", "aetheria-rts:viewport-feed"], + ["viewportFeedStop", "aetheria-rts:viewport-feed-stop"], + ["viewportFeedUpdate", "aetheria-rts:viewport-feed-update"], + ["selectedObject", "aetheria-rts:selected-object"], + ["inventory", "aetheria-rts:inventory"], + ["daemonHealth", "aetheria-rts:daemon-health"], + ["authorityStatus", "aetheria-rts:authority-status"], + ["starbridgeSession", "aetheria-rts:starbridge-session"], + ["setMoveVector", "aetheria-rts:set-move-vector"], + ["setTarget", "aetheria-rts:set-target"], + ["surfaceCatalog", "aetheria-rts:surface-catalog"], + ["surfaceCatalogIndex", "aetheria-rts:surface-catalog-index"], + ["health", "aetheria-rts:health"], +]; + +const sourceText = sources.map(path => readFileSync(path, "utf8")).join("\n"); + +const enums = [ + { + exportName: "AetheriaRuntimeDaemonCommandKinds", + entries: parseEnum(sourceText, "AetheriaRuntimeDaemonCommandKinds"), + }, + { + exportName: "AetheriaRuntimeEveCommandKinds", + entries: parseEnum(sourceText, "AetheriaRuntimeEveCommandKind"), + }, +]; +const documents = [ + { + exportName: "aetheriaRuntimeDaemonCommandDocument", + className: "AetheriaRuntimeDaemonCommandDocument", + schemaName: "daemonCommand", + }, + { + exportName: "aetheriaRuntimeEveCommandDocument", + className: "AetheriaRuntimeEveCommandDocument", + schemaName: "eveCommand", + }, + { + exportName: "aetheriaRuntimeDaemonFrameDocument", + className: "AetheriaRuntimeDaemonFrameDocument", + schemaName: "daemonFrame", + }, + { + exportName: "aetheriaRuntimeDaemonHealthDocument", + className: "AetheriaRuntimeDaemonHealthDocument", + schemaName: "daemonHealth", + }, + { + exportName: "aetheriaRuntimeRtsViewportDocument", + className: "AetheriaRuntimeRtsViewportDocument", + schemaName: "rtsViewport", + }, + { + exportName: "aetheriaRuntimeObjectsViewportDocument", + className: "AetheriaRuntimeObjectsViewportDocument", + schemaName: "objectsViewport", + }, + { + exportName: "aetheriaRuntimeGravityViewportDocument", + className: "AetheriaRuntimeGravityViewportDocument", + schemaName: "gravityViewport", + }, + { + exportName: "aetheriaRuntimeCurrentZoneDocument", + className: "AetheriaRuntimeCurrentZoneDocument", + schemaName: "currentZone", + }, + { + exportName: "aetheriaRuntimeCurrentEntityDocument", + className: "AetheriaRuntimeCurrentEntityDocument", + schemaName: "currentEntity", + }, + { + exportName: "aetheriaRuntimeCurrentDockingDocument", + className: "AetheriaRuntimeCurrentDockingDocument", + schemaName: "currentDocking", + }, + { + exportName: "aetheriaRuntimeStationRefitDocument", + className: "AetheriaRuntimeStationRefitDocument", + schemaName: "stationRefit", + }, + { + exportName: "aetheriaRuntimeStationRefitEntityOption", + className: "AetheriaRuntimeStationRefitEntityOption", + }, + { + exportName: "aetheriaRuntimeStationStockItem", + className: "AetheriaRuntimeStationStockItem", + }, + { + exportName: "aetheriaRuntimeSectorMapDocument", + className: "AetheriaRuntimeSectorMapDocument", + schemaName: "sectorMap", + }, + { + exportName: "aetheriaRuntimeSectorMapZone", + className: "AetheriaRuntimeSectorMapZone", + }, + { + exportName: "aetheriaRuntimeSectorMapLink", + className: "AetheriaRuntimeSectorMapLink", + }, + { + exportName: "aetheriaRuntimeZoneRenderDocument", + className: "AetheriaRuntimeZoneRenderDocument", + schemaName: "zoneRender", + }, + { + exportName: "aetheriaRuntimeZoneRenderBodyPose", + className: "AetheriaRuntimeZoneRenderBodyPose", + }, + { + exportName: "aetheriaRuntimeZoneRenderAsteroidBeltPose", + className: "AetheriaRuntimeZoneRenderAsteroidBeltPose", + }, + { + exportName: "aetheriaRuntimeSelectedObjectDocument", + className: "AetheriaRuntimeSelectedObjectDocument", + schemaName: "selectedObject", + }, + { + exportName: "aetheriaRuntimeInventoryDocument", + className: "AetheriaRuntimeInventoryDocument", + schemaName: "inventory", + }, + { + exportName: "aetheriaRuntimeRunCheckpointCommit", + className: "AetheriaRuntimeRunCheckpointCommit", + }, + { + exportName: "aetheriaRuntimeZoneSnapshotCommit", + className: "AetheriaRuntimeZoneSnapshotCommit", + }, + { + exportName: "aetheriaRuntimeEntitySnapshotCommit", + className: "AetheriaRuntimeEntitySnapshotCommit", + }, + { + exportName: "aetheriaRuntimeBodySnapshotCommit", + className: "AetheriaRuntimeBodySnapshotCommit", + }, + { + exportName: "aetheriaRuntimeEntityStatGridCommit", + className: "AetheriaRuntimeEntityStatGridCommit", + }, + { + exportName: "aetheriaRuntimeCargoBayLoadoutCommit", + className: "AetheriaRuntimeCargoBayLoadoutCommit", + }, + { + exportName: "aetheriaRuntimeLoadoutItemSlotCommit", + className: "AetheriaRuntimeLoadoutItemSlotCommit", + }, + { + exportName: "aetheriaRuntimeLoadoutItemCommit", + className: "AetheriaRuntimeLoadoutItemCommit", + }, + { + exportName: "aetheriaRuntimeRtsViewportBounds", + className: "AetheriaRuntimeRtsViewportBounds", + }, + { + exportName: "aetheriaRuntimeRtsViewportObject", + className: "AetheriaRuntimeRtsViewportObject", + }, + { + exportName: "aetheriaRuntimeRtsEntityStatus", + className: "AetheriaRuntimeRtsEntityStatus", + }, + { + exportName: "aetheriaRuntimeRtsInventoryItem", + className: "AetheriaRuntimeRtsInventoryItem", + }, + { + exportName: "aetheriaRuntimeRtsGravityInfluence", + className: "AetheriaRuntimeRtsGravityInfluence", + }, + { + exportName: "aetheriaRuntimeRtsBodyView", + className: "AetheriaRuntimeRtsBodyView", + }, + { + exportName: "aetheriaRuntimeStarbridgeScenarioDocument", + className: "AetheriaRuntimeStarbridgeScenarioDocument", + schemaName: "starbridgeScenario", + }, + { + exportName: "aetheriaRuntimeStarbridgeSessionDocument", + className: "AetheriaRuntimeStarbridgeSessionDocument", + schemaName: "starbridgeSession", + }, + { + exportName: "aetheriaRuntimeStarbridgeSessionSummaryDocument", + className: "AetheriaRuntimeStarbridgeSessionSummaryDocument", + schemaName: "starbridgeSessionSummary", + }, + { + exportName: "aetheriaRuntimeStarbridgeStationStockItem", + className: "AetheriaRuntimeStarbridgeStationStockItem", + }, + { + exportName: "aetheriaRuntimeStarbridgeWaveDefinition", + className: "AetheriaRuntimeStarbridgeWaveDefinition", + }, + { + exportName: "aetheriaRuntimeStarbridgeWaveForecast", + className: "AetheriaRuntimeStarbridgeWaveForecast", + }, + { + exportName: "aetheriaRuntimeStarbridgeRuntimeRole", + className: "AetheriaRuntimeStarbridgeRuntimeRole", + }, + { + exportName: "aetheriaRuntimeStarbridgeBaseStatus", + className: "AetheriaRuntimeStarbridgeBaseStatus", + }, + { + exportName: "aetheriaRuntimeVerseAuthorityPolicyDocument", + className: "AetheriaRuntimeVerseAuthorityPolicyDocument", + schemaName: "verseAuthorityPolicy", + }, + { + exportName: "aetheriaRuntimeAuthorityRule", + className: "AetheriaRuntimeAuthorityRule", + }, + { + exportName: "aetheriaRuntimeAuthorityRuntimeRole", + className: "AetheriaRuntimeAuthorityRuntimeRole", + }, + { + exportName: "aetheriaRuntimeAuthorityLeaseDocument", + className: "AetheriaRuntimeAuthorityLeaseDocument", + schemaName: "authorityLease", + }, +].map(document => ({ + ...document, + slots: parseMessagePackSlots(sourceText, document.className), + schema: parseCultDocumentSchema(sourceText, document.className), +})); + +const generated = render(enums, documents); +const rendererContract = renderRendererContract(); +const preload = renderPreload(); +const existing = readExisting(outputPath); +const existingRendererContract = readExisting(rendererContractPath); +const existingPreload = readExisting(preloadPath); + +if (check) { + if (existing !== generated || existingRendererContract !== rendererContract || existingPreload !== preload) { + console.error("RTS generated bindings are stale. Run `npm run generate:rts-bindings`."); + process.exit(1); + } +} else { + writeFileSync(outputPath, generated, "utf8"); + writeFileSync(rendererContractPath, rendererContract, "utf8"); + writeFileSync(preloadPath, preload, "utf8"); +} + +function readExisting(path) { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } +} + +function parseEnum(text, enumName) { + const body = readTypeBody(text, `enum ${enumName}`); + const entries = []; + let nextValue = 0; + for (const rawLine of body.split(/\r?\n/u)) { + const line = rawLine.replace(/\/\/.*$/u, "").trim().replace(/,$/u, ""); + if (!line) + continue; + + const match = /^([A-Za-z_][A-Za-z0-9_]*)(?:\s*=\s*(-?\d+))?$/u.exec(line); + if (!match) + continue; + + const value = match[2] === undefined ? nextValue : Number.parseInt(match[2], 10); + entries.push([toCamelCase(match[1]), value]); + nextValue = value + 1; + } + + return entries; +} + +function parseMessagePackSlots(text, className) { + const body = readTypeBody(text, `class ${className}`); + const slots = []; + const propertyPattern = /\[Key\((\d+)\)\][\s\S]*?public\s+[A-Za-z0-9_<>,.?[\]\s]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*get;\s*set;\s*\}/gu; + for (const match of body.matchAll(propertyPattern)) { + slots.push([toCamelCase(match[2]), Number.parseInt(match[1], 10)]); + } + + if (slots.length === 0) + throw new Error(`No [Key] properties found for ${className}.`); + + return slots.sort((left, right) => left[1] - right[1]); +} + +function parseCultDocumentSchema(text, className) { + const classIndex = text.indexOf(`class ${className}`); + if (classIndex < 0) + throw new Error(`Could not find ${className}.`); + + const prefix = text.slice(Math.max(0, classIndex - 500), classIndex); + const matches = [...prefix.matchAll(/\[CultDocument\("([^"]+)",\s*"([^"]+)"\)\]/gu)]; + return matches.length > 0 ? matches[matches.length - 1][2] : ""; +} + +function readTypeBody(text, declaration) { + const declarationIndex = text.indexOf(declaration); + if (declarationIndex < 0) + throw new Error(`Could not find ${declaration}.`); + + const openIndex = text.indexOf("{", declarationIndex); + if (openIndex < 0) + throw new Error(`Could not find body for ${declaration}.`); + + let depth = 0; + for (let index = openIndex; index < text.length; index += 1) { + const character = text[index]; + if (character === "{") + depth += 1; + if (character === "}") { + depth -= 1; + if (depth === 0) + return text.slice(openIndex + 1, index); + } + } + + throw new Error(`Could not read body for ${declaration}.`); +} + +function render(enums, documents) { + const schemas = documents + .filter(document => document.schema) + .map(document => ` ${document.schemaName}: "${document.schema}",`) + .join("\n"); + const enumBlocks = enums + .map(item => renderEnumBlock(item.exportName, item.entries)) + .join("\n\n"); + const slotBlocks = documents + .map(document => renderSlotBlock(document.exportName, document.slots)) + .join("\n\n"); + + return `// \n// Generated by scripts/generate-rts-bindings.mjs from Aetheria C# MessagePack documents.\n\nimport { CultMesh } from "cultmesh-ts";\nimport type { CultMeshLiveFeedDiagnostic, CultMeshOperationContext, CultMeshOperationReceipt, CultMeshProjectionSource, CultMeshQueryContext, CultMeshQuerySurfaceDiagnostic, CultMeshQueryWatcher, CultMeshRouteHint, CultMeshSurfaceCatalogDiagnostic, CultMeshSurfaceCatalogIndexDiagnostic, CultMeshUnsubscribe, CultMeshVerseContext, CultMeshViewportRequest } from "cultmesh-ts";\nimport type { IpcMain } from "electron";\n\nexport const AetheriaRtsSchemas = {\n${schemas}\n} as const;\n\n${enumBlocks}\n\nexport const AetheriaRtsIpcChannels = {\n${renderIpcChannelLines()}\n} as const;\n\n${slotBlocks}\n\n${renderProjectionTypes("CultMeshViewportRequest")}\n\n${renderRtsOperations()}\n`; +} + +function renderEnumBlock(exportName, entries) { + const lines = entries + .map(([name, value]) => ` ${name}: ${value},`) + .join("\n"); + return `export const ${exportName} = {\n${lines}\n} as const;`; +} + +function renderSlotBlock(exportName, slots) { + const slotLines = slots.map(([name, value]) => ` ${name}: ${value},`).join("\n"); + const slotNames = slots.map(([name]) => ` | "${name}"`).join("\n"); + const typeName = `${toPascalCase(exportName)}Slot`; + return `export type ${typeName} =\n${slotNames};\n\nexport const ${exportName}Slots: Record<${typeName}, number> = {\n${slotLines}\n};`; +} + +function toCamelCase(value) { + return value.slice(0, 1).toLowerCase() + value.slice(1); +} + +function toPascalCase(value) { + return value.slice(0, 1).toUpperCase() + value.slice(1); +} + +function renderRtsOperations() { + return `export type AetheriaRuntimeSetMoveVectorRequest = { + actorEntityKey: string; + directionX: number; + directionY: number; + scalar: number; + observedFrameId?: number; +}; + +export type AetheriaRuntimeSetTargetRequest = { + actorEntityKey: string; + targetEntityKey: string; + observedFrameId?: number; +}; + +export type AetheriaRuntimeDaemonCommandReceipt = CultMeshOperationReceipt & { + commandId: string; +}; + +export type AetheriaRuntimeSelectedObjectRequest = { + entityIndex: number; +}; + +export type AetheriaRuntimeViewportFeedRequest = { + viewport: CultMeshViewportRequest; + selectedEntityIndex?: number; +}; + +export type AetheriaRuntimeViewportFeedSnapshot = { + viewport: ViewportResponse; + selectedObject: SelectedObjectProjection | null; + inventory: InventoryProjection | null; + daemonHealth: DaemonHealthProjection; + authorityStatus: AuthorityStatusProjection; + starbridgeSession: StarbridgeSessionProjection; + receivedAtUtc: string; + sampleMs: number; +}; + +export type AetheriaRtsMainClient = { + mapViewport(request: CultMeshViewportRequest): Promise; + objectsViewport(request: CultMeshViewportRequest): Promise; + gravityViewport(request: CultMeshViewportRequest): Promise; + selectedObject(request: AetheriaRuntimeSelectedObjectRequest): Promise; + inventory(request: AetheriaRuntimeSelectedObjectRequest): Promise; + daemonHealth(): Promise; + authorityStatus(): Promise; + starbridgeSession(): Promise; + watchViewportFeed(request: AetheriaRuntimeViewportFeedRequest, callback: (snapshot: AetheriaRuntimeViewportFeedSnapshot) => void): CultMeshUnsubscribe; + setMoveVector(request: AetheriaRuntimeSetMoveVectorRequest): Promise; + setTarget(request: AetheriaRuntimeSetTargetRequest): Promise; + surfaceCatalogDiagnostics(): CultMeshSurfaceCatalogDiagnostic; + surfaceCatalogIndexDiagnostics(): CultMeshSurfaceCatalogIndexDiagnostic; +}; + +export type AetheriaRtsHealthProvider = () => unknown; + +export function registerAetheriaRtsIpcHandlers( + ipcMain: IpcMain, + getClient: () => AetheriaRtsMainClient, + getHealth: AetheriaRtsHealthProvider, +): void { + const viewportFeedSubscriptions = new Map(); + ipcMain.handle(AetheriaRtsIpcChannels.mapViewport, async (_event, request: CultMeshViewportRequest) => + getClient().mapViewport(request)); + ipcMain.handle(AetheriaRtsIpcChannels.objectsViewport, async (_event, request: CultMeshViewportRequest) => + getClient().objectsViewport(request)); + ipcMain.handle(AetheriaRtsIpcChannels.gravityViewport, async (_event, request: CultMeshViewportRequest) => + getClient().gravityViewport(request)); + ipcMain.handle(AetheriaRtsIpcChannels.selectedObject, async (_event, request: AetheriaRuntimeSelectedObjectRequest) => + getClient().selectedObject(request)); + ipcMain.handle(AetheriaRtsIpcChannels.inventory, async (_event, request: AetheriaRuntimeSelectedObjectRequest) => + getClient().inventory(request)); + ipcMain.handle(AetheriaRtsIpcChannels.daemonHealth, async () => getClient().daemonHealth()); + ipcMain.handle(AetheriaRtsIpcChannels.authorityStatus, async () => getClient().authorityStatus()); + ipcMain.handle(AetheriaRtsIpcChannels.starbridgeSession, async () => getClient().starbridgeSession()); + ipcMain.handle(AetheriaRtsIpcChannels.viewportFeed, async (event, request: { subscriptionId: string; feed: AetheriaRuntimeViewportFeedRequest }) => { + viewportFeedSubscriptions.get(request.subscriptionId)?.(); + const unsubscribe = getClient().watchViewportFeed(request.feed, snapshot => { + event.sender.send(AetheriaRtsIpcChannels.viewportFeedUpdate, { + subscriptionId: request.subscriptionId, + snapshot, + }); + }); + viewportFeedSubscriptions.set(request.subscriptionId, unsubscribe); + }); + ipcMain.handle(AetheriaRtsIpcChannels.viewportFeedStop, (_event, subscriptionId: string) => { + viewportFeedSubscriptions.get(subscriptionId)?.(); + viewportFeedSubscriptions.delete(subscriptionId); + }); + ipcMain.handle(AetheriaRtsIpcChannels.setMoveVector, async (_event, request: AetheriaRuntimeSetMoveVectorRequest) => + getClient().setMoveVector(request)); + ipcMain.handle(AetheriaRtsIpcChannels.setTarget, async (_event, request: AetheriaRuntimeSetTargetRequest) => + getClient().setTarget(request)); + ipcMain.handle(AetheriaRtsIpcChannels.surfaceCatalog, () => getClient().surfaceCatalogDiagnostics()); + ipcMain.handle(AetheriaRtsIpcChannels.surfaceCatalogIndex, () => getClient().surfaceCatalogIndexDiagnostics()); + ipcMain.handle(AetheriaRtsIpcChannels.health, () => getHealth()); +} + +export type AetheriaRuntimeDaemonCommandSender = ( + commandId: string, + issuedAtUtc: string, + command: unknown[], + context: CultMeshOperationContext, +) => Promise; + +export type AetheriaRuntimeViewportQueryExecutor = ( + request: CultMeshViewportRequest, + context: CultMeshQueryContext, +) => Promise; + +export type AetheriaRuntimeQueryExecutor = ( + request: TParameters, + context: CultMeshQueryContext, +) => Promise; + +export type AetheriaRuntimeRtsQueryExecutors = { + mapViewport: AetheriaRuntimeViewportQueryExecutor; + objectsViewport: AetheriaRuntimeViewportQueryExecutor; + gravityViewport: AetheriaRuntimeViewportQueryExecutor; + selectedObject: AetheriaRuntimeQueryExecutor; + inventory: AetheriaRuntimeQueryExecutor; + daemonHealth: AetheriaRuntimeQueryExecutor; + authorityStatus: AetheriaRuntimeQueryExecutor; + starbridgeSession: AetheriaRuntimeQueryExecutor; +}; + +export type AetheriaRuntimeRtsQueryWatchers = Partial<{ + mapViewport: CultMeshQueryWatcher; + objectsViewport: CultMeshQueryWatcher; + gravityViewport: CultMeshQueryWatcher; + selectedObject: CultMeshQueryWatcher; + inventory: CultMeshQueryWatcher; + daemonHealth: CultMeshQueryWatcher; + authorityStatus: CultMeshQueryWatcher; + starbridgeSession: CultMeshQueryWatcher; +}>; + +export type AetheriaRuntimeRtsProjectionDiagnostic = CultMeshQuerySurfaceDiagnostic; + +export type AetheriaRuntimeRtsLiveFeedDiagnostic = CultMeshLiveFeedDiagnostic; + +export type AetheriaRuntimeRtsSurfaceCatalogDiagnostic = CultMeshSurfaceCatalogDiagnostic; + +export type AetheriaRuntimeRtsOperationHandles = ReturnType; + +export type AetheriaRuntimeRtsStatePointers = ReturnType; + +export type AetheriaRuntimeRtsStatePointerResolvers = Partial<{ + daemonFrame: (context: CultMeshQueryContext) => Promise; + daemonHealth: (context: CultMeshQueryContext) => Promise; + authorityPolicy: (context: CultMeshQueryContext) => Promise; + starbridgeSession: (context: CultMeshQueryContext) => Promise; +}>; + +export const AetheriaRuntimeRtsProjectionSources = { + daemonFrame: CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: AetheriaRtsSchemas.daemonFrame, + description: "latest daemon frame" + }), + daemonHealth: CultMesh.projectionSource("daemon:aetheria.health.latest.v1", { + schemaId: AetheriaRtsSchemas.daemonHealth, + description: "latest daemon health" + }), + authorityPolicy: CultMesh.projectionSource("daemon:aetheria.authority.policy.latest.v1", { + schemaId: AetheriaRtsSchemas.verseAuthorityPolicy, + description: "latest Verse authority policy" + }), + starbridgeSession: CultMesh.projectionSource("daemon:aetheria.starbridge.session.latest.v1", { + schemaId: AetheriaRtsSchemas.starbridgeSessionSummary, + description: "latest Starbridge session summary" + }) +} as const; + +export function createAetheriaRuntimeRtsStatePointers( + routeHint: CultMeshRouteHint = CultMesh.routeHint(), + resolvers: AetheriaRuntimeRtsStatePointerResolvers = {}, +) { + return { + daemonFrame: CultMesh.statePointer( + AetheriaRuntimeRtsProjectionSources.daemonFrame.sourceId, + async (context) => resolvers.daemonFrame?.(context), + undefined, + { + routeHint, + sources: [AetheriaRuntimeRtsProjectionSources.daemonFrame], + }, + ), + daemonHealth: CultMesh.statePointer( + AetheriaRuntimeRtsProjectionSources.daemonHealth.sourceId, + async (context) => resolvers.daemonHealth?.(context), + undefined, + { + routeHint, + sources: [AetheriaRuntimeRtsProjectionSources.daemonHealth], + }, + ), + authorityPolicy: CultMesh.statePointer( + AetheriaRuntimeRtsProjectionSources.authorityPolicy.sourceId, + async (context) => resolvers.authorityPolicy?.(context), + undefined, + { + routeHint, + sources: [AetheriaRuntimeRtsProjectionSources.authorityPolicy], + }, + ), + starbridgeSession: CultMesh.statePointer( + AetheriaRuntimeRtsProjectionSources.starbridgeSession.sourceId, + async (context) => resolvers.starbridgeSession?.(context), + undefined, + { + routeHint, + sources: [AetheriaRuntimeRtsProjectionSources.starbridgeSession], + }, + ), + } as const; +} + +export function createAetheriaRuntimeRtsQueryHandles( + executors: AetheriaRuntimeRtsQueryExecutors, + routeHint: CultMeshRouteHint = CultMesh.routeHint(), + watchers: AetheriaRuntimeRtsQueryWatchers = {}, +) { + return { + mapViewport: CultMesh.projectionRecipe( + AetheriaRtsSchemas.rtsViewport, + [AetheriaRuntimeRtsProjectionSources.daemonFrame], + executors.mapViewport, + { routeHint, watchProjection: watchers.mapViewport }, + ).asQuerySurface(), + objectsViewport: CultMesh.projectionRecipe( + AetheriaRtsSchemas.objectsViewport, + [AetheriaRuntimeRtsProjectionSources.daemonFrame], + executors.objectsViewport, + { routeHint, watchProjection: watchers.objectsViewport }, + ).asQuerySurface(), + gravityViewport: CultMesh.projectionRecipe( + AetheriaRtsSchemas.gravityViewport, + [AetheriaRuntimeRtsProjectionSources.daemonFrame], + executors.gravityViewport, + { routeHint, watchProjection: watchers.gravityViewport }, + ).asQuerySurface(), + selectedObject: CultMesh.projectionRecipe( + AetheriaRtsSchemas.selectedObject, + [AetheriaRuntimeRtsProjectionSources.daemonFrame], + executors.selectedObject, + { routeHint, watchProjection: watchers.selectedObject }, + ).asQuerySurface(), + inventory: CultMesh.projectionRecipe( + AetheriaRtsSchemas.inventory, + [AetheriaRuntimeRtsProjectionSources.daemonFrame], + executors.inventory, + { routeHint, watchProjection: watchers.inventory }, + ).asQuerySurface(), + daemonHealth: CultMesh.projectionRecipe( + AetheriaRtsSchemas.daemonHealth, + [AetheriaRuntimeRtsProjectionSources.daemonHealth], + executors.daemonHealth, + { routeHint, watchProjection: watchers.daemonHealth }, + ).asQuerySurface(), + authorityStatus: CultMesh.projectionRecipe( + AetheriaRtsSchemas.verseAuthorityPolicy, + [AetheriaRuntimeRtsProjectionSources.authorityPolicy], + executors.authorityStatus, + { routeHint, watchProjection: watchers.authorityStatus }, + ).asQuerySurface(), + starbridgeSession: CultMesh.projectionRecipe( + AetheriaRtsSchemas.starbridgeSessionSummary, + [AetheriaRuntimeRtsProjectionSources.starbridgeSession], + executors.starbridgeSession, + { routeHint, watchProjection: watchers.starbridgeSession }, + ).asQuerySurface(), + } as const; +} + +export function describeAetheriaRuntimeRtsQueryHandles( + handles: ReturnType, +) { + return { + mapViewport: describeAetheriaRuntimeRtsQuerySurface(handles.mapViewport), + objectsViewport: describeAetheriaRuntimeRtsQuerySurface(handles.objectsViewport), + gravityViewport: describeAetheriaRuntimeRtsQuerySurface(handles.gravityViewport), + selectedObject: describeAetheriaRuntimeRtsQuerySurface(handles.selectedObject), + inventory: describeAetheriaRuntimeRtsQuerySurface(handles.inventory), + daemonHealth: describeAetheriaRuntimeRtsQuerySurface(handles.daemonHealth), + authorityStatus: describeAetheriaRuntimeRtsQuerySurface(handles.authorityStatus), + starbridgeSession: describeAetheriaRuntimeRtsQuerySurface(handles.starbridgeSession), + } as const; +} + +export function describeAetheriaRuntimeRtsSurfaceCatalog( + handles: ReturnType, + operations?: AetheriaRuntimeRtsOperationHandles, + statePointers: AetheriaRuntimeRtsStatePointers = createAetheriaRuntimeRtsStatePointers(), +): AetheriaRuntimeRtsSurfaceCatalogDiagnostic { + return CultMesh.describeSurfaceCatalog( + "gamecult.aetheria.rts.surfaces.v1", + [ + CultMesh.describeSurface(statePointers.daemonFrame), + CultMesh.describeSurface(statePointers.daemonHealth), + CultMesh.describeSurface(statePointers.authorityPolicy), + CultMesh.describeSurface(statePointers.starbridgeSession), + CultMesh.describeSurface(handles.mapViewport), + CultMesh.describeSurface(handles.objectsViewport), + CultMesh.describeSurface(handles.gravityViewport), + CultMesh.describeSurface(handles.selectedObject), + CultMesh.describeSurface(handles.inventory), + CultMesh.describeSurface(handles.daemonHealth), + CultMesh.describeSurface(handles.authorityStatus), + CultMesh.describeSurface(handles.starbridgeSession), + ...(operations + ? [ + CultMesh.describeSurface(operations.setMoveVector), + CultMesh.describeSurface(operations.setTarget), + ] + : []), + ], + ); +} + +function describeAetheriaRuntimeRtsQuerySurface(surface: { + queryId: string; + routeHint: CultMeshRouteHint; + sources: readonly CultMeshProjectionSource[]; +}): AetheriaRuntimeRtsProjectionDiagnostic { + return CultMesh.describeQuerySurface(surface); +} + +export function describeAetheriaRuntimeRtsLiveFeedSurface(surface: { + feedId: string; + routeHint: CultMeshRouteHint; + sources: readonly CultMeshProjectionSource[]; +}): AetheriaRuntimeRtsLiveFeedDiagnostic { + return CultMesh.describeLiveFeed(surface); +} + +export function createAetheriaRuntimeRtsOperationHandles( + sendCommand: AetheriaRuntimeDaemonCommandSender, + createCommandId: () => string = createDefaultCommandId, +) { + return { + setMoveVector: CultMesh.operation( + "gamecult.aetheria.pilot.set_move_vector.v1", + async (request, context) => { + const commandId = context.idempotencyKey ?? createCommandId(); + const issuedAtUtc = new Date().toISOString(); + await sendCommand( + commandId, + issuedAtUtc, + encodeSetMoveVectorCommand(commandId, issuedAtUtc, context.runtimeId, request), + context, + ); + return { + commandId, + ...CultMesh.operationReceipt("gamecult.aetheria.pilot.set_move_vector.v1", true, { route: context.routeHint }), + }; + }, + ), + setTarget: CultMesh.operation( + "gamecult.aetheria.pilot.set_target.v1", + async (request, context) => { + const commandId = context.idempotencyKey ?? createCommandId(); + const issuedAtUtc = new Date().toISOString(); + await sendCommand( + commandId, + issuedAtUtc, + encodeSetTargetCommand(commandId, issuedAtUtc, context.runtimeId, request), + context, + ); + return { + commandId, + ...CultMesh.operationReceipt("gamecult.aetheria.pilot.set_target.v1", true, { route: context.routeHint }), + }; + }, + ), + } as const; +} + +export type AetheriaRuntimeRtsVerseFacade = ReturnType; + +export function createAetheriaRuntimeRtsVerseFacade( + queryVerse: CultMeshVerseContext, + commandVerse: CultMeshVerseContext, + queries: ReturnType, + operations: AetheriaRuntimeRtsOperationHandles, + statePointers: AetheriaRuntimeRtsStatePointers = createAetheriaRuntimeRtsStatePointers(), +) { + const mapViewport = CultMesh.bindQuery(queryVerse, queries.mapViewport); + const objectsViewport = CultMesh.bindQuery(queryVerse, queries.objectsViewport); + const gravityViewport = CultMesh.bindQuery(queryVerse, queries.gravityViewport); + const selectedObject = CultMesh.bindQuery(queryVerse, queries.selectedObject); + const inventory = CultMesh.bindQuery(queryVerse, queries.inventory); + const daemonHealth = CultMesh.bindQuery(queryVerse, queries.daemonHealth); + const authorityStatus = CultMesh.bindQuery(queryVerse, queries.authorityStatus); + const starbridgeSession = CultMesh.bindQuery(queryVerse, queries.starbridgeSession); + const daemonFramePointer = CultMesh.bindStatePointer(queryVerse, statePointers.daemonFrame); + const daemonHealthPointer = CultMesh.bindStatePointer(queryVerse, statePointers.daemonHealth); + const authorityPolicyPointer = CultMesh.bindStatePointer(queryVerse, statePointers.authorityPolicy); + const starbridgeSessionPointer = CultMesh.bindStatePointer(queryVerse, statePointers.starbridgeSession); + const setMoveVector = CultMesh.bindOperation(commandVerse, operations.setMoveVector); + const setTarget = CultMesh.bindOperation(commandVerse, operations.setTarget); + + return { + zone: (_zoneId = "current") => ({ + viewport: { + within: (request: CultMeshViewportRequest) => + mapViewport.execute(request), + watch: ( + request: CultMeshViewportRequest, + callback: (value: ViewportResponse) => void, + ) => mapViewport.watch(request, callback), + }, + objects: { + visibleWithin: (request: CultMeshViewportRequest) => + objectsViewport.execute(request), + watchVisibleWithin: ( + request: CultMeshViewportRequest, + callback: (value: ObjectsViewportResponse) => void, + ) => objectsViewport.watch(request, callback), + }, + gravity: { + within: (request: CultMeshViewportRequest) => + gravityViewport.execute(request), + watchWithin: ( + request: CultMeshViewportRequest, + callback: (value: GravityViewportResponse) => void, + ) => gravityViewport.watch(request, callback), + }, + }), + entity: (entityKey: string) => ({ + pilot: { + move: ( + direction: { readonly x: number; readonly y: number }, + options: { scalar?: number; observedFrameId?: number; commandId?: string } = {}, + ) => setMoveVector.invoke({ + actorEntityKey: entityKey, + directionX: direction.x, + directionY: direction.y, + scalar: options.scalar ?? 1, + observedFrameId: options.observedFrameId, + }, { idempotencyKey: options.commandId }), + target: ( + targetEntityKey: string, + options: { observedFrameId?: number; commandId?: string } = {}, + ) => setTarget.invoke({ + actorEntityKey: entityKey, + targetEntityKey, + observedFrameId: options.observedFrameId, + }, { idempotencyKey: options.commandId }), + }, + }), + selectedObject: (entityIndex: number) => + selectedObject.execute({ entityIndex }), + inventory: (entityIndex: number) => + inventory.execute({ entityIndex }), + daemon: { + state: { + frame: () => daemonFramePointer.resolve(), + health: () => daemonHealthPointer.resolve(), + authorityPolicy: () => authorityPolicyPointer.resolve(), + starbridgeSession: () => starbridgeSessionPointer.resolve(), + }, + health: () => daemonHealth.execute(undefined), + authorityStatus: () => authorityStatus.execute(undefined), + starbridgeSession: () => starbridgeSession.execute(undefined), + }, + } as const; +} + +export function encodeSetMoveVectorCommand( + commandId: string, + issuedAtUtc: string, + runtimeId: string, + request: AetheriaRuntimeSetMoveVectorRequest, +): unknown[] { + const command = createBaseCommand(commandId, issuedAtUtc, runtimeId, request.actorEntityKey, request.observedFrameId); + command[aetheriaRuntimeDaemonCommandDocumentSlots.kind] = AetheriaRuntimeDaemonCommandKinds.setMoveVector; + command[aetheriaRuntimeDaemonCommandDocumentSlots.directionX] = request.directionX; + command[aetheriaRuntimeDaemonCommandDocumentSlots.directionY] = request.directionY; + command[aetheriaRuntimeDaemonCommandDocumentSlots.scalarValue] = Math.min(1, Math.max(0, request.scalar)); + return command; +} + +export function encodeSetTargetCommand( + commandId: string, + issuedAtUtc: string, + runtimeId: string, + request: AetheriaRuntimeSetTargetRequest, +): unknown[] { + const command = createBaseCommand(commandId, issuedAtUtc, runtimeId, request.actorEntityKey, request.observedFrameId); + command[aetheriaRuntimeDaemonCommandDocumentSlots.kind] = AetheriaRuntimeDaemonCommandKinds.setTarget; + command[aetheriaRuntimeDaemonCommandDocumentSlots.targetEntityKey] = request.targetEntityKey; + return command; +} + +function createBaseCommand( + commandId: string, + issuedAtUtc: string, + runtimeId: string, + actorEntityKey: string, + observedFrameId?: number, +): unknown[] { + const command = new Array(Object.keys(aetheriaRuntimeDaemonCommandDocumentSlots).length).fill(null); + command[aetheriaRuntimeDaemonCommandDocumentSlots.schema] = AetheriaRtsSchemas.daemonCommand; + command[aetheriaRuntimeDaemonCommandDocumentSlots.commandId] = commandId; + command[aetheriaRuntimeDaemonCommandDocumentSlots.clientId] = runtimeId; + command[aetheriaRuntimeDaemonCommandDocumentSlots.issuedAtUtc] = issuedAtUtc; + command[aetheriaRuntimeDaemonCommandDocumentSlots.sessionId] = "local"; + command[aetheriaRuntimeDaemonCommandDocumentSlots.observedFrameId] = observedFrameId ?? -1; + command[aetheriaRuntimeDaemonCommandDocumentSlots.kind] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.actorEntityKey] = actorEntityKey; + command[aetheriaRuntimeDaemonCommandDocumentSlots.targetEntityKey] = ""; + command[aetheriaRuntimeDaemonCommandDocumentSlots.targetZoneIndex] = -1; + command[aetheriaRuntimeDaemonCommandDocumentSlots.equipmentIndex] = -1; + command[aetheriaRuntimeDaemonCommandDocumentSlots.behaviorIndex] = -1; + command[aetheriaRuntimeDaemonCommandDocumentSlots.weaponGroup] = -1; + command[aetheriaRuntimeDaemonCommandDocumentSlots.positionX] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.positionY] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.positionZ] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.directionX] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.directionY] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.scalarValue] = 0; + command[aetheriaRuntimeDaemonCommandDocumentSlots.textValue] = ""; + command[aetheriaRuntimeDaemonCommandDocumentSlots.authorRuntimeId] = ""; + command[aetheriaRuntimeDaemonCommandDocumentSlots.subjectKey] = ""; + command[aetheriaRuntimeDaemonCommandDocumentSlots.claimKind] = ""; + return command; +} + +function createDefaultCommandId(): string { + const cryptoApi = globalThis.crypto as { randomUUID?: () => string } | undefined; + return cryptoApi?.randomUUID?.() ?? \`command-\${Date.now().toString(36)}-\${Math.random().toString(36).slice(2)}\`; +}`; +} + +function renderRendererContract() { + return `// +// Generated by scripts/generate-rts-bindings.mjs from the RTS CultMesh surface contract. + +import type { + CultMeshOperationReceipt, + CultMeshSurfaceCatalogDiagnostic, + CultMeshSurfaceCatalogIndexDiagnostic, + CultMeshViewportRequest, +} from "cultmesh-ts"; + +export type Viewport = CultMeshViewportRequest; + +export type AetheriaRuntimeSetMoveVectorRequest = { + actorEntityKey: string; + directionX: number; + directionY: number; + scalar: number; + observedFrameId?: number; +}; + +export type AetheriaRuntimeSetTargetRequest = { + actorEntityKey: string; + targetEntityKey: string; + observedFrameId?: number; +}; + +export type AetheriaRuntimeDaemonCommandReceipt = CultMeshOperationReceipt & { + commandId: string; +}; + +export type AetheriaRuntimeSelectedObjectRequest = { + entityIndex: number; +}; + +export type AetheriaRuntimeViewportFeedRequest = { + viewport: Viewport; + selectedEntityIndex?: number; +}; + +export type AetheriaRuntimeViewportFeedSnapshot = { + viewport: ViewportResponse; + selectedObject: SelectedObjectProjection | null; + inventory: InventoryProjection | null; + daemonHealth: DaemonHealthProjection; + authorityStatus: AuthorityStatusProjection; + starbridgeSession: StarbridgeSessionProjection; + receivedAtUtc: string; + sampleMs: number; +}; + +${renderProjectionTypes("Viewport")} + +export type AetheriaRtsApi = { + mapViewport(request: Viewport): Promise; + objectsViewport(request: Viewport): Promise; + gravityViewport(request: Viewport): Promise; + selectedObject(request: AetheriaRuntimeSelectedObjectRequest): Promise; + inventory(request: AetheriaRuntimeSelectedObjectRequest): Promise; + daemonHealth(): Promise; + authorityStatus(): Promise; + starbridgeSession(): Promise; + watchViewportFeed(request: AetheriaRuntimeViewportFeedRequest, callback: (snapshot: AetheriaRuntimeViewportFeedSnapshot) => void): () => void; + setMoveVector(request: AetheriaRuntimeSetMoveVectorRequest): Promise; + setTarget(request: AetheriaRuntimeSetTargetRequest): Promise; + surfaceCatalog(): Promise; + surfaceCatalogIndex(): Promise; + health(): Promise; +}; +`; +} + +function renderProjectionTypes(viewportTypeName) { + return `export type ViewportResponse = { + schema: string; + frameId: number; + publishedAtUtc: string; + simulationTimeSeconds: number; + runId: string; + zoneIndex: number; + zoneName: string; + currentEntityKey: string; + viewport: ${viewportTypeName}; + controlledEntityIndices: number[]; + objects: ViewObject[]; + gravityInfluences: GravityInfluence[]; + bodies: BodyView[]; +}; + +export type ObjectsViewportResponse = { + schema: string; + frameId: number; + publishedAtUtc: string; + simulationTimeSeconds: number; + runId: string; + zoneIndex: number; + zoneName: string; + currentEntityKey: string; + viewport: ${viewportTypeName}; + controlledEntityIndices: number[]; + objects: ViewObject[]; +}; + +export type GravityViewportResponse = { + schema: string; + frameId: number; + publishedAtUtc: string; + simulationTimeSeconds: number; + runId: string; + zoneIndex: number; + zoneName: string; + viewport: ${viewportTypeName}; + gravityInfluences: GravityInfluence[]; + bodies: BodyView[]; +}; + +export type ViewObject = { + entityIndex: number; + entityKey: string; + displayName: string; + kind: string; + factionKey: string; + x: number; + y: number; + z: number; + directionX: number; + directionY: number; + velocityX: number; + velocityY: number; + controlled: boolean; + targetEntityIndex: number; + isActive: boolean; + visibility: number; + status: EntityStatus; + inventory: InventoryItem[]; +}; + +export type EntityStatus = { + hull: number; + shield: number; + heat: number; +}; + +export type InventoryItem = { + source: string; + itemKey: string; + quantity: number; + quality: number; + durability: number; + enabled: boolean; +}; + +export type SelectedObjectProjection = { + schema: string; + frameId: number; + runId: string; + zoneIndex: number; + entityIndex: number; + selected: ViewObject | null; +}; + +export type InventoryProjection = { + schema: string; + frameId: number; + runId: string; + zoneIndex: number; + entityIndex: number; + entityKey: string; + items: InventoryItem[]; + equipment: InventoryItem[]; + cargo: InventoryItem[]; +}; + +export type DaemonHealthProjection = { + schema: string; + daemonId: string; + verseId: string; + publishedAtUtc: string; + statePath: string; + frameId: number; + observedCommandCount: number; + appliedCommandCount: number; + rejectedCommandCount: number; + status: string; + publicationSource: string; + transport: string; + commandBoundaryPath: string; +}; + +export type AuthorityStatusProjection = { + schema: string; + verseId: string; + policyId: string; + ruleVersion: string; + hostRuntimeId: string; + defaultMode: string; + updatedAtUtc: string; + rules: AuthorityRuleProjection[]; +}; + +export type AuthorityRuleProjection = { + ruleId: string; + subjectPrefix: string; + claimKinds: string[]; + mode: string; + runtimeIds: string[]; + leaseScope: string; + priority: number; +}; + +export type StarbridgeSessionProjection = { + schema: string; + frameId: number; + publishedAtUtc: string; + sessionId: string; + scenarioId: string; + scenarioName: string; + runId: string; + zoneIndex: number; + zoneName: string; + phase: string; + currentWaveIndex: number; + baseStatus: StarbridgeBaseStatusProjection; + stationStock: StarbridgeStationStockItemProjection[]; + waveForecast: StarbridgeWaveForecastProjection[]; + runtimeRoles: StarbridgeRuntimeRoleProjection[]; +}; + +export type StarbridgeBaseStatusProjection = { + entityKey: string; + displayName: string; + hull: number; + shield: number; + heat: number; + isActive: boolean; +}; + +export type StarbridgeStationStockItemProjection = { + itemKey: string; + quantity: number; + quality: number; + durability: number; + source: string; +}; + +export type StarbridgeWaveForecastProjection = { + waveIndex: number; + displayName: string; + attackerKeys: string[]; + bossKey: string; + recoveredTechnologyKeys: string[]; +}; + +export type StarbridgeRuntimeRoleProjection = { + runtimeId: string; + role: string; + entityKey: string; +}; + +export type GravityInfluence = { + bodyKey: string; + orbitKey: string; + kind: string; + x: number; + y: number; + radius: number; + gravityDepth: number; + gravityDepthExponent: number; + waveRadius: number; + waveDepth: number; + waveSpeed: number; +}; + +export type BodyView = { + bodyKey: string; + orbitKey: string; + name: string; + kind: string; + x: number; + y: number; + radius: number; + isAsteroidBelt: boolean; +}; +`; +} + +function renderIpcChannelLines() { + return ipcChannels + .map(([name, channel]) => ` ${name}: "${channel}",`) + .join("\n"); +} + +function renderPreload() { + const channelLines = renderIpcChannelLines(); + const apiLines = ipcChannels + .filter(([name]) => name !== "viewportFeedUpdate" && name !== "viewportFeedStop") + .map(([name]) => { + if (name === "viewportFeed") { + return ` watchViewportFeed: (request, callback) => { + const subscriptionId = \`viewport-feed-\${Date.now().toString(36)}-\${Math.random().toString(36).slice(2)}\`; + const listener = (_event, message) => { + if (message?.subscriptionId === subscriptionId) { + callback(message.snapshot); + } + }; + ipcRenderer.on(channels.viewportFeedUpdate, listener); + void ipcRenderer.invoke(channels.viewportFeed, { subscriptionId, feed: request }); + return () => { + ipcRenderer.off(channels.viewportFeedUpdate, listener); + void ipcRenderer.invoke(channels.viewportFeedStop, subscriptionId); + }; + },`; + } + const hasNoArguments = name === "health" || + name === "daemonHealth" || + name === "authorityStatus" || + name === "starbridgeSession" || + name === "surfaceCatalog" || + name === "surfaceCatalogIndex"; + const parameters = hasNoArguments ? "()" : "request"; + const invokeArguments = hasNoArguments ? `channels.${name}` : `channels.${name}, request`; + return ` ${name}: ${parameters} => ipcRenderer.invoke(${invokeArguments}),`; + }) + .join("\n"); + + return `// +// Generated by scripts/generate-rts-bindings.mjs from the RTS IPC surface contract. + +const { contextBridge, ipcRenderer } = require("electron"); + +const channels = { +${channelLines} +}; + +contextBridge.exposeInMainWorld("aetheriaRts", { +${apiLines} +}); +`; +} diff --git a/Aetheria.Rts.Web/scripts/verify-stage7b-rts-client.ps1 b/Aetheria.Rts.Web/scripts/verify-stage7b-rts-client.ps1 new file mode 100644 index 000000000..a0e25ca56 --- /dev/null +++ b/Aetheria.Rts.Web/scripts/verify-stage7b-rts-client.ps1 @@ -0,0 +1,531 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +& node scripts/generate-rts-bindings.mjs --check +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: generated RTS bindings are stale." +} + +$forbidden = @( + 'window\.aetheriaRts\.command', + 'window\.aetheriaRts\.viewport', + 'ipcMain\.handle\("aetheria-rts:command', + 'ipcMain\.handle\("aetheria-rts:viewport', + 'function createCommandDocument', + 'export type RtsCommandRequest', + 'public async command\(', + 'public async viewport\(' +) + +$forbiddenPattern = ($forbidden -join '|') +$forbiddenHits = & rg -n $forbiddenPattern Client Electron 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: public generic RTS command/viewport surface still exists.`n$forbiddenHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for forbidden public surfaces." +} + +$required = @( + 'mapViewport', + 'objectsViewport', + 'gravityViewport', + 'selectedObject', + 'inventory', + 'daemonHealth', + 'authorityStatus', + 'starbridgeSession', + 'setMoveVector', + 'setTarget', + 'surfaceCatalogIndex', + 'AetheriaRtsIpcChannels' +) + +foreach ($symbol in $required) { + & rg -q $symbol Client Electron + if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: expected typed RTS client symbol '$symbol' was not found." + } +} + +if (-not (Get-Content Client/app.ts -Raw).Contains('window.aetheriaRts.surfaceCatalogIndex()')) { + Write-Error "Stage 7B verifier failed: RTS renderer does not read the shared CultMesh surface catalog index." +} + +$rendererText = Get-Content Client/app.ts -Raw +if (-not $rendererText.Contains('surface.routeHint.kind') -or -not $rendererText.Contains('surface.sources.length')) { + Write-Error "Stage 7B verifier failed: RTS renderer does not expose CultMesh surface route and source metadata." +} + +if (-not $rendererText.Contains('latestOperationReceipt') -or + -not $rendererText.Contains('receipt.commandId') -or + -not $rendererText.Contains('receipt.operationId') -or + -not $rendererText.Contains('receipt.accepted') -or + -not $rendererText.Contains('receipt.route.kind')) { + Write-Error "Stage 7B verifier failed: RTS renderer does not expose CultMesh operation receipt metadata." +} + +if (-not (Get-Content wwwroot/index.html -Raw).Contains('runtime-surface-details')) { + Write-Error "Stage 7B verifier failed: RTS renderer does not expose runtime surface diagnostics in the shell." +} + +$transportLayoutPatterns = @( + '\[[0-9]+\]', + 'new Array', + 'command\[', + 'value\[', + 'bounds\[', + 'viewportSchema', + 'commandSchema', + 'createBaseCommandDocument', + 'decodeViewObject', + 'decodeInventoryItem', + 'decodeGravityInfluence', + 'decodeBodyView', + 'fetchViewportDocument', + 'fetchDocument\(', + 'sendSnapshotRequest', + 'schemaIds', + 'recordKeys', + 'CultNetSnapshotResponseRawMessage', + 'AetheriaRtsSchemas\.rtsViewport' +) + +$transportLayoutPattern = ($transportLayoutPatterns -join '|') +$transportLayoutHits = & rg -n $transportLayoutPattern Electron/aetheria-cultmesh.ts 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: RTS CultMesh transport wrapper still owns document layout/codec details.`n$transportLayoutHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for transport wrapper layout checks." +} + +$requiredBindingSymbols = @( + 'createAetheriaRuntimeRtsOperationHandles', + 'createAetheriaRuntimeRtsQueryHandles', + 'AetheriaRtsSchemas' +) + +foreach ($symbol in $requiredBindingSymbols) { + & rg -q $symbol Electron/aetheria-rts-bindings.ts + if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: expected RTS binding symbol '$symbol' was not found." + } +} + +$requiredGeneratedBindingSymbols = @( + 'AetheriaRtsIpcChannels', + 'registerAetheriaRtsIpcHandlers', + 'export type AetheriaRtsMainClient', + 'aetheria-rts:map-viewport', + 'aetheria-rts:objects-viewport', + 'aetheria-rts:viewport-feed', + 'aetheria-rts:viewport-feed-update', + 'aetheria-rts:set-move-vector', + 'export type AetheriaRuntimeSelectedObjectRequest', + 'export type AetheriaRuntimeViewportFeedRequest', + 'export type AetheriaRuntimeViewportFeedSnapshot', + 'aetheriaRuntimeDaemonCommandDocumentSlots', + 'encodeSetMoveVectorCommand', + 'encodeSetTargetCommand', + 'AetheriaRuntimeRtsProjectionSources', + 'AetheriaRuntimeRtsProjectionDiagnostic', + 'AetheriaRuntimeRtsLiveFeedDiagnostic', + 'AetheriaRuntimeRtsSurfaceCatalogDiagnostic', + 'AetheriaRuntimeRtsStatePointers', + 'AetheriaRuntimeRtsStatePointerResolvers', + 'CultMeshSurfaceCatalogDiagnostic', + 'CultMeshSurfaceCatalogIndexDiagnostic', + 'CultMeshOperationReceipt', + 'aetheria-rts:surface-catalog', + 'aetheria-rts:surface-catalog-index', + 'surfaceCatalogDiagnostics(): CultMeshSurfaceCatalogDiagnostic', + 'surfaceCatalogIndexDiagnostics(): CultMeshSurfaceCatalogIndexDiagnostic', + 'ipcMain.handle(AetheriaRtsIpcChannels.surfaceCatalog', + 'ipcMain.handle(AetheriaRtsIpcChannels.surfaceCatalogIndex', + 'CultMesh.projectionSource', + 'CultMesh.projectionRecipe', + 'CultMeshProjectionSource', + 'routeHint: CultMeshRouteHint', + 'CultMeshQueryWatcher', + 'AetheriaRuntimeRtsQueryWatchers', + 'createAetheriaRuntimeRtsStatePointers', + 'CultMesh.statePointer', + 'CultMesh.bindStatePointer', + 'CultMesh.describeSurface(statePointers.daemonFrame)', + 'routeHint, watchProjection', + 'watchProjection: watchers.objectsViewport', + '.asQuerySurface()', + 'describeAetheriaRuntimeRtsQueryHandles', + 'describeAetheriaRuntimeRtsLiveFeedSurface', + 'describeAetheriaRuntimeRtsSurfaceCatalog', + 'CultMesh.describeQuerySurface', + 'CultMesh.describeLiveFeed', + 'CultMesh.describeSurfaceCatalog', + 'CultMesh.describeSurface', + 'CultMesh.describeSurface(operations.setMoveVector)', + 'CultMesh.describeSurface(operations.setTarget)', + 'CultMesh.operation', + 'CultMesh.operationReceipt', + 'AetheriaRuntimeRtsOperationHandles', + 'AetheriaRuntimeRtsVerseFacade', + 'createAetheriaRuntimeRtsVerseFacade', + 'CultMesh.bindQuery', + 'CultMesh.bindOperation', + 'zone: (_zoneId =', + 'visibleWithin', + 'entity: (entityKey: string)', + 'pilot', + 'selectedObject: CultMesh.projectionRecipe', + 'inventory: CultMesh.projectionRecipe', + 'daemonHealth: CultMesh.projectionRecipe', + 'authorityStatus: CultMesh.projectionRecipe', + 'starbridgeSession: CultMesh.projectionRecipe' +) + +foreach ($symbol in $requiredGeneratedBindingSymbols) { + & rg -F -q $symbol Electron/aetheria-rts-generated-bindings.ts + if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: expected generated RTS binding symbol '$symbol' was not found." + } +} + +$forbiddenGeneratedQuerySurfaceHits = & rg -n 'CultMesh\.query\s*[<(]' Electron/aetheria-rts-generated-bindings.ts 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: generated RTS read surfaces should use CultMesh projection recipes, not raw query wrappers.`n$forbiddenGeneratedQuerySurfaceHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for generated projection recipe checks." +} + +$forbiddenGeneratedContextPlumbingHits = & rg -n 'CultMesh\.(queryContextFromVerse|operationContextFromVerse)|\.execute\([^,\n]+,\s*queryContext\(|\.invoke\([^,\n]+,\s*operationContext\(' Electron/aetheria-rts-generated-bindings.ts 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: generated RTS facade should bind surfaces to Verse contexts instead of threading context plumbing.`n$forbiddenGeneratedContextPlumbingHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for generated Verse binding checks." +} + +& rg -q 'createAetheriaRuntimeRtsQueryHandles\(' Electron/aetheria-cultmesh.ts +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: RTS client is not creating generated query handles." +} + +$clientText = Get-Content Electron/aetheria-cultmesh.ts -Raw + +if (-not $clientText.Contains('this.statePointers = createAetheriaRuntimeRtsStatePointers(') -or + -not $clientText.Contains('this.queryVerse.context.routeHint,')) { + Write-Error "Stage 7B verifier failed: RTS client is not creating generated state pointers with its shared Verse route." +} + +if (-not $clientText.Contains('daemonFrame: async () => this.fetchLatestFrameDocument()') -or + -not $clientText.Contains('authorityPolicy: async () => this.fetchAuthorityPolicyDocument()')) { + Write-Error "Stage 7B verifier failed: RTS client state pointers are not resolving through daemon publication readers." +} + +if (-not $clientText.Contains('this.verse = CultMesh.verse("aetheria.local", this.runtimeId)')) { + Write-Error "Stage 7B verifier failed: RTS client should bind to the shared CultMesh Verse primitive instead of constructing local contexts directly." +} + +if (-not $clientText.Contains('this.queryVerse = this.verse.withRoute("shared-memory"')) { + Write-Error "Stage 7B verifier failed: RTS client is not deriving a shared-memory Verse view for local projection reads." +} + +if (-not $clientText.Contains('this.commandVerse = this.verse') -or + -not $clientText.Contains('.withRoute("network", this.endpoint)') -or + -not $clientText.Contains('.withClaim("commander-control"')) { + Write-Error "Stage 7B verifier failed: RTS client is not deriving a network Verse view with an explicit command authority claim." +} + +if (-not $clientText.Contains('this.publications.statePathDescription')) { + Write-Error "Stage 7B verifier failed: RTS client projection route metadata does not include the local publication source description." +} + +if (-not $clientText.Contains('return this.queryVerse.queryContext();')) { + Write-Error "Stage 7B verifier failed: RTS query call sites should derive query contexts from the shared Verse primitive." +} + +if (-not $clientText.Contains('this.aetheria = createAetheriaRuntimeRtsVerseFacade(')) { + Write-Error "Stage 7B verifier failed: RTS client should bind generated Aetheria domain sugar to its Verse contexts." +} + +if (-not $clientText.Contains('return this.aetheria.zone().objects.visibleWithin(request);') -or + -not $clientText.Contains('return this.aetheria.entity(request.actorEntityKey).pilot.move(') -or + -not $clientText.Contains('return this.aetheria.entity(request.actorEntityKey).pilot.target(')) { + Write-Error "Stage 7B verifier failed: RTS client should route viewport and pilot operations through generated Verse domain sugar." +} + +$forbiddenRawContextHits = & rg -n 'CultMesh\.(operationContext|queryContext)\(this\.runtimeId' Electron/aetheria-cultmesh.ts 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: RTS client still constructs raw runtime-id contexts instead of using the shared Verse primitive.`n$forbiddenRawContextHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for raw context checks." +} + +if (-not $clientText.Contains('projectionDiagnostics()')) { + Write-Error "Stage 7B verifier failed: RTS client does not expose generated projection diagnostics." +} + +if (-not $clientText.Contains('describeAetheriaRuntimeRtsQueryHandles(this.queries)')) { + Write-Error "Stage 7B verifier failed: RTS projection diagnostics should be read from generated query handle metadata." +} + +if (-not $clientText.Contains('liveFeedDiagnostics()')) { + Write-Error "Stage 7B verifier failed: RTS client does not expose generated live feed diagnostics." +} + +if (-not $clientText.Contains('describeAetheriaRuntimeRtsLiveFeedSurface(this.createViewportFeed())')) { + Write-Error "Stage 7B verifier failed: RTS live feed diagnostics should be read from generated live feed metadata." +} + +if (-not $clientText.Contains('surfaceCatalogDiagnostics()')) { + Write-Error "Stage 7B verifier failed: RTS client does not expose a unified CultMesh surface catalog diagnostic." +} + +if (-not $clientText.Contains('surfaceCatalogIndexDiagnostics()')) { + Write-Error "Stage 7B verifier failed: RTS client does not expose a grouped CultMesh surface catalog diagnostic." +} + +if (-not $clientText.Contains('CultMesh.surfaceCatalogIndex(this.surfaceCatalogDiagnostics())')) { + Write-Error "Stage 7B verifier failed: RTS grouped surface catalog should use the shared CultMesh catalog index primitive." +} + +if (-not $clientText.Contains('describeAetheriaRuntimeRtsSurfaceCatalog(this.queries, this.operations, this.statePointers)')) { + Write-Error "Stage 7B verifier failed: RTS surface catalog should be read from generated query, operation, and state pointer metadata." +} + +$forbiddenDirectClientOperationHits = & rg -n 'operations\.set(MoveVector|Target)\.invoke' Electron/aetheria-cultmesh.ts 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: RTS client still invokes operation handles directly instead of generated domain sugar.`n$forbiddenDirectClientOperationHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for direct operation handle checks." +} + +if (-not $clientText.Contains('CultMesh.liveFeed')) { + Write-Error "Stage 7B verifier failed: RTS viewport feed should use the shared CultMesh live feed primitive." +} + +$requiredGeneratedPreloadSymbols = @( + '$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: Electron main still registers raw RTS IPC channel strings.`n$forbiddenMainIpcStringHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for raw Electron main IPC checks." +} + +$forbiddenMainGeneratedHandlerMirrors = @( + 'AetheriaRtsIpcChannels\.mapViewport', + 'AetheriaRtsIpcChannels\.objectsViewport', + 'AetheriaRtsIpcChannels\.setMoveVector' +) +$forbiddenMainGeneratedHandlerPattern = ($forbiddenMainGeneratedHandlerMirrors -join '|') +$forbiddenMainGeneratedHandlerHits = & rg -n $forbiddenMainGeneratedHandlerPattern Electron/main.ts 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: Electron main still mirrors generated IPC handler registration.`n$forbiddenMainGeneratedHandlerHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for generated IPC handler mirror checks." +} + +& rg -q 'registerAetheriaRtsIpcHandlers' Electron/main.ts +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: Electron main is not using generated RTS IPC handler registration." +} + +$forbiddenClientDirectProjectionReturns = @( + 'return projectSelectedObjectFromFrame', + 'return projectInventoryFromFrame', + 'return projectDaemonHealth', + 'return projectAuthorityStatus', + 'return projectStarbridgeSessionSummary' +) +$forbiddenClientDirectProjectionPattern = ($forbiddenClientDirectProjectionReturns -join '|') +$forbiddenClientDirectProjectionHits = & rg -n $forbiddenClientDirectProjectionPattern Electron/aetheria-cultmesh.ts 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: RTS client read methods bypass generated query handles.`n$forbiddenClientDirectProjectionHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for direct projection return checks." +} + +$requiredRendererContractSymbols = @( + 'export type AetheriaRtsApi', + 'export type ViewportResponse', + 'export type ObjectsViewportResponse', + 'export type GravityViewportResponse', + 'export type AetheriaRuntimeViewportFeedRequest', + 'export type AetheriaRuntimeViewportFeedSnapshot', + 'watchViewportFeed', + 'export type AetheriaRuntimeSetMoveVectorRequest', + 'export type AetheriaRuntimeSetTargetRequest' +) + +foreach ($symbol in $requiredRendererContractSymbols) { + & rg -q $symbol Client/aetheria-rts-contract.ts + if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: expected generated renderer contract symbol '$symbol' was not found." + } +} + +$forbiddenRendererAppPatterns = @( + '^type ViewportResponse =', + '^type ObjectsViewportResponse =', + '^type GravityViewportResponse =', + '^type AetheriaRtsApi =', + '^type ViewObject =', + '^type InventoryItem =' +) + +$forbiddenRendererAppPattern = ($forbiddenRendererAppPatterns -join '|') +$forbiddenRendererAppHits = & rg -n $forbiddenRendererAppPattern Client/app.ts 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: renderer still owns duplicated RTS contract types.`n$forbiddenRendererAppHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for duplicated renderer contract checks." +} + +$forbiddenRendererPollingHits = & rg -n 'setInterval|objectsViewport\(viewport\)|gravityViewport\(viewport\)' Client/app.ts 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: renderer still owns the RTS viewport polling loop instead of subscribing to the generated feed.`n$forbiddenRendererPollingHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for renderer polling checks." +} + +& rg -q 'watchViewportFeed' Client/app.ts +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: renderer is not subscribing to the generated viewport feed." +} + +$forbiddenBindingPatterns = @( + 'decodeViewportDocument', + 'viewportRecordKey', + 'aetheriaRuntimeRtsViewportDocumentSlots', + 'aetheriaRuntimeRtsViewportObjectSlots', + '^export type ViewportResponse =', + '^export type ObjectsViewportResponse =', + '^export type GravityViewportResponse =', + '^export type ViewObject =', + '^export type InventoryItem =', + '^export type SelectedObjectRequest =' +) + +$forbiddenBindingPattern = ($forbiddenBindingPatterns -join '|') +$forbiddenBindingHits = & rg -n $forbiddenBindingPattern Electron/aetheria-rts-bindings.ts 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error "Stage 7B verifier failed: stale remote viewport decoder surface still exists in RTS bindings.`n$forbiddenBindingHits" +} +if ($LASTEXITCODE -gt 1) { + Write-Error "Stage 7B verifier could not run rg for stale binding checks." +} + +& rg -q 'projectViewportFromFrame' Electron/aetheria-rts-local-projection.ts +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: local RTS frame projection entry point was not found." +} + +$requiredProjectionSymbols = @( + 'projectObjectsViewportFromFrame', + 'projectGravityViewportFromFrame', + 'AetheriaRtsSchemas.objectsViewport', + 'AetheriaRtsSchemas.gravityViewport', + 'projectSelectedObjectFromFrame', + 'projectInventoryFromFrame', + 'AetheriaRtsSchemas.selectedObject', + 'AetheriaRtsSchemas.inventory', + 'projectDaemonHealth', + 'projectAuthorityStatus', + 'projectStarbridgeSessionSummary', + 'AetheriaRtsSchemas.starbridgeSessionSummary' +) + +foreach ($symbol in $requiredProjectionSymbols) { + & rg -q $symbol Electron/aetheria-rts-local-projection.ts + if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: expected local RTS projection symbol '$symbol' was not found." + } +} + +& rg -q 'SingleFileMessagePackBackingStore' Electron/aetheria-local-publication-reader.ts +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: local CultCache publication reader was not found." +} + +& rg -q 'readStarbridgeSessionSummary' Electron/aetheria-local-publication-reader.ts +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: local Starbridge summary publication reader was not found." +} + +& rg -q '.daemon.starbridge.session.cc' Electron/aetheria-local-publication-reader.ts +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: RTS client is not reading the daemon Starbridge sidecar publication." +} + +$requiredGeneratedBindingSymbols = @( + 'aetheriaRuntimeDaemonCommandDocumentSlots', + 'aetheriaRuntimeDaemonFrameDocumentSlots', + 'aetheriaRuntimeDaemonHealthDocumentSlots', + 'aetheriaRuntimeRtsViewportDocumentSlots', + 'aetheriaRuntimeObjectsViewportDocumentSlots', + 'aetheriaRuntimeGravityViewportDocumentSlots', + 'aetheriaRuntimeSelectedObjectDocumentSlots', + 'aetheriaRuntimeInventoryDocumentSlots', + 'aetheriaRuntimeStarbridgeScenarioDocumentSlots', + 'aetheriaRuntimeStarbridgeSessionDocumentSlots', + 'aetheriaRuntimeStarbridgeSessionSummaryDocumentSlots', + 'aetheriaRuntimeStarbridgeStationStockItemSlots', + 'aetheriaRuntimeStarbridgeWaveDefinitionSlots', + 'aetheriaRuntimeStarbridgeWaveForecastSlots', + 'aetheriaRuntimeStarbridgeRuntimeRoleSlots', + 'aetheriaRuntimeStarbridgeBaseStatusSlots', + 'aetheriaRuntimeRtsViewportBoundsSlots', + 'aetheriaRuntimeRtsViewportObjectSlots', + 'aetheriaRuntimeVerseAuthorityPolicyDocumentSlots', + 'aetheriaRuntimeAuthorityRuleSlots', + 'aetheriaRuntimeAuthorityLeaseDocumentSlots', + 'aetheriaRuntimeRunCheckpointCommitSlots', + 'aetheriaRuntimeZoneSnapshotCommitSlots', + 'aetheriaRuntimeEntitySnapshotCommitSlots', + 'aetheriaRuntimeBodySnapshotCommitSlots', + 'aetheriaRuntimeEntityStatGridCommitSlots', + 'aetheriaRuntimeCargoBayLoadoutCommitSlots', + 'aetheriaRuntimeLoadoutItemSlotCommitSlots', + 'aetheriaRuntimeLoadoutItemCommitSlots', + 'AetheriaRuntimeDaemonCommandKinds', + 'AetheriaRtsSchemas' +) + +foreach ($symbol in $requiredGeneratedBindingSymbols) { + & rg -q $symbol Electron/aetheria-rts-generated-bindings.ts + if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7B verifier failed: expected generated RTS binding symbol '$symbol' was not found." + } +} + +Write-Host "Stage 7B RTS client verifier passed." diff --git a/Aetheria.Rts.Web/scripts/verify-stage7c-electron-shell.ps1 b/Aetheria.Rts.Web/scripts/verify-stage7c-electron-shell.ps1 new file mode 100644 index 000000000..0c9f00278 --- /dev/null +++ b/Aetheria.Rts.Web/scripts/verify-stage7c-electron-shell.ps1 @@ -0,0 +1,90 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +npm run build +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7C Electron shell verifier failed: RTS build failed." +} + +if (-not (Get-Content wwwroot\index.html -Raw).Contains('runtime-surface-details')) { + Write-Error "Stage 7C Electron shell verifier failed: built renderer markup does not expose runtime surface diagnostics." +} + +if (-not (Get-Content wwwroot\app.js -Raw).Contains('window.aetheriaRts.surfaceCatalogIndex()')) { + Write-Error "Stage 7C Electron shell verifier failed: built renderer script does not read the CultMesh surface catalog index." +} + +$mainText = Get-Content Electron\main.ts -Raw +if (-not $mainText.Contains('await api.surfaceCatalog()') -or -not $mainText.Contains('await api.surfaceCatalogIndex()')) { + Write-Error "Stage 7C Electron shell verifier failed: Electron smoke does not call the preload CultMesh surface catalog APIs." +} + +if (-not $mainText.Contains('runtimeSurfaceDetails.includes("shared-memory")') -or -not $mainText.Contains('daemon:aetheria.frame.latest.v1')) { + Write-Error "Stage 7C Electron shell verifier failed: Electron smoke does not verify rendered CultMesh route/source metadata." +} + +if (-not $mainText.Contains('await api.setMoveVector') -or + -not $mainText.Contains('moveReceipt?.commandId') -or + -not $mainText.Contains('moveReceipt?.operationId') -or + -not $mainText.Contains('moveReceipt?.accepted') -or + -not $mainText.Contains('moveReceipt?.route')) { + Write-Error "Stage 7C Electron shell verifier failed: Electron smoke does not verify typed operation receipts through preload." +} + +if (-not $mainText.Contains('AETHERIA_RTS_PEER_CULTMESH_ENDPOINTS') -or + -not $mainText.Contains('--peer-cultmesh-endpoint') -or + -not $mainText.Contains('AETHERIA_RTS_VERSE_ID') -or + -not $mainText.Contains('--verse-id') -or + -not $mainText.Contains('AETHERIA_RTS_DAEMON_ID') -or + -not $mainText.Contains('--daemon-id')) { + Write-Error "Stage 7C Electron shell verifier failed: Electron launcher does not pass Verse identity and peer CultMesh endpoints into the local daemon." +} + +$electron = Join-Path $root "node_modules\electron\dist\electron.exe" +if (!(Test-Path -LiteralPath $electron)) { + Write-Error "Stage 7C Electron shell verifier failed: electron.exe was not found at $electron." +} + +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("aetheria-stage7c-electron-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tempRoot | Out-Null +$resultPath = Join-Path $tempRoot "electron-smoke-result.json" +$udp = [System.Net.Sockets.UdpClient]::new(0) +$port = ([System.Net.IPEndPoint]$udp.Client.LocalEndPoint).Port +$udp.Close() + +try { + $env:AETHERIA_RTS_ELECTRON_SMOKE = "1" + $env:AETHERIA_RTS_RUNTIME_ROOT = $tempRoot + $env:AETHERIA_RTS_CULTMESH_PORT = $port.ToString() + $env:AETHERIA_RTS_VERSE_ID = "aetheria.stage7c.electron" + $env:AETHERIA_RTS_DAEMON_ID = "stage7c-starfire" + $env:AETHERIA_RTS_PEER_CULTMESH_ENDPOINTS = "rudp://127.0.0.1:1" + $env:AETHERIA_RTS_ELECTRON_SMOKE_RESULT = $resultPath + $process = Start-Process -FilePath $electron -ArgumentList "." -WorkingDirectory $root -Wait -PassThru + if ($process.ExitCode -ne 0) { + Write-Error "Stage 7C Electron shell verifier failed: Electron exited with code $($process.ExitCode)." + } + + if (!(Test-Path -LiteralPath $resultPath)) { + Write-Error "Stage 7C Electron shell verifier failed: Electron did not write a smoke result." + } + + $result = Get-Content -Raw -LiteralPath $resultPath | ConvertFrom-Json + if ($result.ok -ne $true) { + Write-Error "Stage 7C Electron shell verifier failed: $($result.error)" + } +} +finally { + Remove-Item Env:\AETHERIA_RTS_ELECTRON_SMOKE -ErrorAction SilentlyContinue + Remove-Item Env:\AETHERIA_RTS_RUNTIME_ROOT -ErrorAction SilentlyContinue + Remove-Item Env:\AETHERIA_RTS_CULTMESH_PORT -ErrorAction SilentlyContinue + Remove-Item Env:\AETHERIA_RTS_VERSE_ID -ErrorAction SilentlyContinue + Remove-Item Env:\AETHERIA_RTS_DAEMON_ID -ErrorAction SilentlyContinue + Remove-Item Env:\AETHERIA_RTS_PEER_CULTMESH_ENDPOINTS -ErrorAction SilentlyContinue + Remove-Item Env:\AETHERIA_RTS_ELECTRON_SMOKE_RESULT -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Stage 7C Electron shell verifier passed." diff --git a/Aetheria.Rts.Web/scripts/verify-stage7c-local-runtime.ps1 b/Aetheria.Rts.Web/scripts/verify-stage7c-local-runtime.ps1 new file mode 100644 index 000000000..527f2629f --- /dev/null +++ b/Aetheria.Rts.Web/scripts/verify-stage7c-local-runtime.ps1 @@ -0,0 +1,156 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$repoRoot = Split-Path -Parent $root +Set-Location $root + +npm run build +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7C verifier failed: RTS build failed." +} + +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("aetheria-stage7c-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tempRoot | Out-Null +$statePath = Join-Path $tempRoot "aetheria-rts.cc" +$endpoint = $null + +try { + $daemonOutput = dotnet run --project (Join-Path $repoRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj") -- ` + --state $statePath ` + --once ` + --rts-cultmesh-port 0 2>&1 + $daemonOutput | Out-Host + if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7C verifier failed: one-shot daemon did not complete." + } + + foreach ($line in $daemonOutput) { + if ($line -match "Aetheria RTS CultMesh endpoint: (rudp://127\.0\.0\.1:\d+)") { + $endpoint = $Matches[1] + } + } + if ([string]::IsNullOrWhiteSpace($endpoint)) { + Write-Error "Stage 7C verifier failed: one-shot daemon did not report an RTS CultMesh endpoint." + } + + $smokeScript = Join-Path $tempRoot "stage7c-smoke.mjs" + @' +import { pathToFileURL } from "node:url"; + +const statePath = process.env.AETHERIA_STAGE7C_STATE_PATH; +const endpoint = process.env.AETHERIA_STAGE7C_ENDPOINT; +const clientModulePath = process.env.AETHERIA_STAGE7C_CLIENT_MODULE; +if (!statePath || !endpoint) + throw new Error("Stage 7C verifier missing state path or endpoint."); +if (!clientModulePath) + throw new Error("Stage 7C verifier missing compiled client module path."); + +const { AetheriaCultMeshClient } = await import(pathToFileURL(clientModulePath).href); + +const client = new AetheriaCultMeshClient(endpoint, statePath, "stage7c-verifier"); +const viewport = await client.mapViewport({ minX: -5000, minY: -5000, maxX: 5000, maxY: 5000 }); +const objectsViewport = await client.objectsViewport({ minX: -5000, minY: -5000, maxX: 5000, maxY: 5000 }); +const gravityViewport = await client.gravityViewport({ minX: -5000, minY: -5000, maxX: 5000, maxY: 5000 }); +if (!Number.isFinite(viewport.frameId)) + throw new Error("Viewport projection did not produce a frame id."); +if (!Array.isArray(viewport.objects) || viewport.objects.length === 0) + throw new Error("Viewport projection did not expose visible objects."); +if (objectsViewport.schema !== "gamecult.aetheria.objects_viewport.v1") + throw new Error(`Objects viewport used unexpected schema: ${objectsViewport.schema}`); +if (gravityViewport.schema !== "gamecult.aetheria.gravity_viewport.v1") + throw new Error(`Gravity viewport used unexpected schema: ${gravityViewport.schema}`); +if (!Array.isArray(objectsViewport.objects) || objectsViewport.objects.length !== viewport.objects.length) + throw new Error("Objects viewport did not match composed map object set."); +if (!Array.isArray(gravityViewport.gravityInfluences) || gravityViewport.gravityInfluences.length !== viewport.gravityInfluences.length) + throw new Error("Gravity viewport did not match composed map gravity set."); + +const selectedIndex = viewport.controlledEntityIndices[0] ?? viewport.objects[0]?.entityIndex; +if (!Number.isFinite(selectedIndex)) + throw new Error("No entity was available for selected-object projection."); + +const selected = await client.selectedObject({ entityIndex: selectedIndex }); +if (!selected.selected) + throw new Error(`Selected-object projection did not resolve entity ${selectedIndex}.`); + +const inventory = await client.inventory({ entityIndex: selectedIndex }); +if (!Array.isArray(inventory.equipment) || !Array.isArray(inventory.cargo)) + throw new Error("Inventory projection did not return equipment and cargo arrays."); + +const health = await client.daemonHealth(); +if (health.status !== "healthy") + throw new Error(`Daemon health projection was not healthy: ${health.status}`); + +const authority = await client.authorityStatus(); +if (authority.policyId !== "aetheria.trusted-coop.v1") + throw new Error(`Unexpected authority policy id: ${authority.policyId}`); + +const starbridge = await client.starbridgeSession(); +if (starbridge.schema !== "gamecult.aetheria.starbridge_session_summary.v1") + throw new Error(`Unexpected Starbridge schema: ${starbridge.schema}`); +if (starbridge.scenarioName !== "Frontier Fabricator Defense") + throw new Error(`Unexpected Starbridge scenario: ${starbridge.scenarioName}`); +if (!Array.isArray(starbridge.stationStock) || !starbridge.stationStock.some(item => item.itemKey === "repair-parts")) + throw new Error("Starbridge session did not expose daemon-seeded station stock."); +if (!Array.isArray(starbridge.waveForecast) || !starbridge.waveForecast.some(wave => wave.displayName === "Scout Probe")) + throw new Error("Starbridge session did not expose daemon-seeded wave forecast."); + +const surfaceCatalog = client.surfaceCatalogDiagnostics(); +if (surfaceCatalog.catalogId !== "gamecult.aetheria.rts.surfaces.v1") + throw new Error(`Unexpected RTS surface catalog id: ${surfaceCatalog.catalogId}`); +if (!surfaceCatalog.surfaces.some(surface => surface.surfaceId === "gamecult.aetheria.rts.viewport_feed.v1" && surface.kind === "live-feed")) + throw new Error("RTS surface catalog did not expose the viewport live feed."); +if (!surfaceCatalog.surfaces.some(surface => surface.surfaceId === "gamecult.aetheria.pilot.set_move_vector.v1" && surface.kind === "operation")) + throw new Error("RTS surface catalog did not expose the move operation."); +if (!surfaceCatalog.surfaces.some(surface => + surface.surfaceId === "daemon:aetheria.frame.latest.v1" && + surface.kind === "state-pointer" && + surface.routeHint?.kind === "shared-memory" && + surface.sources?.some(source => source.schemaId === "gamecult.aetheria.daemon_frame.v1"))) + throw new Error("RTS surface catalog did not expose the daemon frame state pointer with shared route/source metadata."); + +const surfaceIndex = client.surfaceCatalogIndexDiagnostics(); +if (surfaceIndex.catalogId !== surfaceCatalog.catalogId) + throw new Error("RTS surface catalog index did not preserve the catalog id."); +if (!Array.isArray(surfaceIndex.operations) || surfaceIndex.operations.length < 2) + throw new Error("RTS surface catalog index did not group operation surfaces."); +if (!Array.isArray(surfaceIndex.liveFeeds) || !surfaceIndex.liveFeeds.some(surface => surface.surfaceId === "gamecult.aetheria.rts.viewport_feed.v1")) + throw new Error("RTS surface catalog index did not group the viewport live feed."); +if (!Array.isArray(surfaceIndex.queries) || !surfaceIndex.queries.some(surface => surface.surfaceId === "gamecult.aetheria.objects_viewport.v1")) + throw new Error("RTS surface catalog index did not group generated query surfaces."); +if (!Array.isArray(surfaceIndex.statePointers) || !surfaceIndex.statePointers.some(surface => surface.surfaceId === "daemon:aetheria.frame.latest.v1")) + throw new Error("RTS surface catalog index did not group generated state pointer surfaces."); + +console.log(JSON.stringify({ + frameId: viewport.frameId, + visibleObjects: viewport.objects.length, + gravityInfluences: gravityViewport.gravityInfluences.length, + selectedEntityIndex: selected.selected.entityIndex, + equipmentItems: inventory.equipment.length, + cargoItems: inventory.cargo.length, + health: health.status, + policyId: authority.policyId, + starbridgeScenario: starbridge.scenarioName, + starbridgeStock: starbridge.stationStock.length, + surfaceCatalog: { + surfaces: surfaceCatalog.surfaces.length, + operations: surfaceIndex.operations.length, + queries: surfaceIndex.queries.length, + liveFeeds: surfaceIndex.liveFeeds.length, + statePointers: surfaceIndex.statePointers.length + } +}, null, 2)); +'@ | Set-Content -LiteralPath $smokeScript -Encoding UTF8 + + $env:AETHERIA_STAGE7C_STATE_PATH = $statePath + $env:AETHERIA_STAGE7C_ENDPOINT = $endpoint + $env:AETHERIA_STAGE7C_CLIENT_MODULE = Join-Path $root "electron-dist\aetheria-cultmesh.js" + node $smokeScript + if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7C verifier failed: local Electron runtime facade could not read daemon publications." + } +} +finally { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Stage 7C local runtime verifier passed." diff --git a/Aetheria.Rts.Web/scripts/verify-stage7d-remote-cultmesh.ps1 b/Aetheria.Rts.Web/scripts/verify-stage7d-remote-cultmesh.ps1 new file mode 100644 index 000000000..deece010f --- /dev/null +++ b/Aetheria.Rts.Web/scripts/verify-stage7d-remote-cultmesh.ps1 @@ -0,0 +1,139 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$repoRoot = Split-Path -Parent $root +Set-Location $root + +npm run build +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7D remote CultMesh verifier failed: RTS build failed." +} + +$daemonProject = Join-Path $repoRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj" +dotnet build $daemonProject -v:minimal +if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7D remote CultMesh verifier failed: daemon build failed." +} + +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("aetheria-stage7d-remote-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tempRoot | Out-Null +$statePath = Join-Path $tempRoot "aetheria-raven.cc" +$daemonOut = Join-Path $tempRoot "daemon.out.log" +$daemonErr = Join-Path $tempRoot "daemon.err.log" +$port = Get-Random -Minimum 41000 -Maximum 64000 +$endpoint = "rudp://127.0.0.1:$port" +$daemon = $null + +try { + $daemon = Start-Process -FilePath "dotnet" -ArgumentList @( + "run", + "--no-build", + "--project", + $daemonProject, + "--", + "--state", + $statePath, + "--rts-cultmesh-host", + "127.0.0.1", + "--rts-cultmesh-port", + $port, + "--tick-interval-ms", + "20", + "--fixed-delta-ms", + "20", + "--api-publication-interval-ms", + "100000" + ) -RedirectStandardOutput $daemonOut -RedirectStandardError $daemonErr -WindowStyle Hidden -PassThru + + $deadline = (Get-Date).AddSeconds(40) + while ((Get-Date) -lt $deadline) { + if ($daemon.HasExited) { + Write-Error "Stage 7D remote CultMesh verifier failed: daemon exited early ($($daemon.ExitCode)).`n$(Get-Content $daemonErr -Raw)`n$(Get-Content $daemonOut -Raw)" + } + + if ((Test-Path $daemonOut) -and ((Get-Content $daemonOut -Raw) -match "Aetheria Verse daemon published frame")) { + break + } + + Start-Sleep -Milliseconds 250 + } + + if (-not (Test-Path $daemonOut) -or -not ((Get-Content $daemonOut -Raw) -match "Aetheria Verse daemon published frame")) { + Write-Error "Stage 7D remote CultMesh verifier failed: daemon did not publish a frame.`n$(if (Test-Path $daemonOut) { Get-Content $daemonOut -Raw })" + } + + $smokeScript = Join-Path $tempRoot "remote-smoke.mjs" + @' +import { pathToFileURL } from "node:url"; + +const endpoint = process.env.AETHERIA_STAGE7D_REMOTE_ENDPOINT; +const clientModulePath = process.env.AETHERIA_STAGE7D_REMOTE_CLIENT_MODULE; +if (!endpoint) + throw new Error("Stage 7D verifier missing remote endpoint."); +if (!clientModulePath) + throw new Error("Stage 7D verifier missing compiled client module path."); + +const { AetheriaCultMeshClient } = await import(pathToFileURL(clientModulePath).href); +const client = new AetheriaCultMeshClient(endpoint, "unused-local-state.cc", "stage7d-starfire", { + publicationMode: "remote", + snapshotTimeoutMs: 5000, +}); + +try { + await client.waitForFrame(15000); + const viewport = await client.mapViewport({ minX: -5000, minY: -5000, maxX: 5000, maxY: 5000 }); + const actor = viewport.objects.find(object => object.controlled); + if (!actor) + throw new Error("Remote CultMesh viewport did not expose a controlled unit."); + + const health = await client.daemonHealth(); + const authority = await client.authorityStatus(); + const starbridge = await client.starbridgeSession(); + const receipt = await client.setMoveVector({ + actorEntityKey: actor.entityKey, + directionX: 1, + directionY: 0, + scalar: 0.1, + observedFrameId: viewport.frameId, + }); + + if (health.status !== "healthy") + throw new Error(`Unexpected daemon health ${health.status}.`); + if (authority.policyId !== "aetheria.trusted-coop.v1") + throw new Error(`Unexpected authority policy ${authority.policyId}.`); + if (starbridge.scenarioName !== "Frontier Fabricator Defense") + throw new Error(`Unexpected Starbridge scenario ${starbridge.scenarioName}.`); + if (!receipt.accepted) + throw new Error(`Remote setMoveVector was rejected: ${receipt.diagnostic ?? "no diagnostic"}`); + + console.log(JSON.stringify({ + endpoint, + frameId: viewport.frameId, + visibleObjects: viewport.objects.length, + health: health.status, + policyId: authority.policyId, + starbridgeScenario: starbridge.scenarioName, + operationId: receipt.operationId, + }, null, 2)); +} finally { + await client.close(); +} +'@ | Set-Content -LiteralPath $smokeScript -Encoding UTF8 + + $env:AETHERIA_STAGE7D_REMOTE_ENDPOINT = $endpoint + $env:AETHERIA_STAGE7D_REMOTE_CLIENT_MODULE = Join-Path $root "electron-dist\aetheria-cultmesh.js" + node $smokeScript + if ($LASTEXITCODE -ne 0) { + Write-Error "Stage 7D remote CultMesh verifier failed: remote RTS client could not read or command the daemon over CultMesh." + } +} +finally { + Remove-Item Env:\AETHERIA_STAGE7D_REMOTE_ENDPOINT -ErrorAction SilentlyContinue + Remove-Item Env:\AETHERIA_STAGE7D_REMOTE_CLIENT_MODULE -ErrorAction SilentlyContinue + if ($daemon -and -not $daemon.HasExited) { + Stop-Process -Id $daemon.Id -Force + } + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Stage 7D remote CultMesh verifier passed." diff --git a/Aetheria.Rts.Web/tsconfig.electron.json b/Aetheria.Rts.Web/tsconfig.electron.json new file mode 100644 index 000000000..c3e1d0fa7 --- /dev/null +++ b/Aetheria.Rts.Web/tsconfig.electron.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmitOnError": true, + "outDir": "electron-dist", + "rootDir": "Electron", + "types": [ + "node" + ] + }, + "include": [ + "Electron/**/*.ts" + ] +} diff --git a/Aetheria.Rts.Web/tsconfig.json b/Aetheria.Rts.Web/tsconfig.json new file mode 100644 index 000000000..d6bd9c69c --- /dev/null +++ b/Aetheria.Rts.Web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "noEmitOnError": true, + "outDir": "wwwroot", + "rootDir": "Client" + }, + "include": [ + "Client/**/*.ts" + ] +} diff --git a/Aetheria.Rts.Web/wwwroot/index.html b/Aetheria.Rts.Web/wwwroot/index.html new file mode 100644 index 000000000..d7b4ed865 --- /dev/null +++ b/Aetheria.Rts.Web/wwwroot/index.html @@ -0,0 +1,51 @@ + + + + + + Aetheria RTS + + + +
+
+
+
+ Aetheria RTS + Connecting +
+
+ + 1x + + +
+
+ +
+ +
+ + + diff --git a/Aetheria.Rts.Web/wwwroot/styles.css b/Aetheria.Rts.Web/wwwroot/styles.css new file mode 100644 index 000000000..412129f4d --- /dev/null +++ b/Aetheria.Rts.Web/wwwroot/styles.css @@ -0,0 +1,172 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #111417; + color: #e8edf0; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; +} + +body { + overflow: hidden; +} + +button { + min-width: 36px; + height: 32px; + border: 1px solid #41505a; + border-radius: 6px; + background: #20282d; + color: #eef6f8; + font: inherit; +} + +button:hover { + background: #2a353b; +} + +.shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + height: 100%; + min-height: 0; +} + +.map-pane { + display: grid; + grid-template-rows: 52px minmax(0, 1fr); + min-width: 0; + min-height: 0; + background: #0f1215; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 16px; + border-bottom: 1px solid #263038; + background: #171d21; +} + +.topbar span { + display: inline-block; + margin-left: 12px; + color: #9fb0b9; + font-size: 13px; +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; +} + +#zoom-label { + min-width: 52px; + color: #c5d2d7; + text-align: center; +} + +#map { + display: block; + width: 100%; + height: 100%; + background: #0d1013; + cursor: crosshair; +} + +.side-pane { + display: grid; + grid-auto-rows: min-content; + gap: 18px; + min-height: 0; + overflow: auto; + padding: 18px; + border-left: 1px solid #263038; + background: #171d21; +} + +.side-pane section { + min-width: 0; +} + +h1, +h2 { + margin: 0 0 10px; + font-size: 15px; + line-height: 1.2; +} + +h1 { + font-size: 18px; +} + +dl { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 7px 10px; + margin: 0; + font-size: 13px; +} + +dt { + color: #91a4ad; +} + +dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +#controlled-list { + display: grid; + gap: 8px; + font-size: 13px; +} + +.controlled-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 0; + border-bottom: 1px solid #263038; +} + +.muted { + color: #91a4ad; +} + +@media (max-width: 760px) { + .shell { + grid-template-columns: 1fr; + grid-template-rows: minmax(0, 1fr) 230px; + } + + .side-pane { + border-top: 1px solid #263038; + border-left: 0; + } + + .topbar { + align-items: flex-start; + flex-direction: column; + height: auto; + padding: 10px 12px; + } + + .map-pane { + grid-template-rows: auto minmax(0, 1fr); + } +} diff --git a/Aetheria.State.AuthoritySmoke/Aetheria.State.AuthoritySmoke.csproj b/Aetheria.State.AuthoritySmoke/Aetheria.State.AuthoritySmoke.csproj new file mode 100644 index 000000000..ca788873d --- /dev/null +++ b/Aetheria.State.AuthoritySmoke/Aetheria.State.AuthoritySmoke.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + enable + latest + enable + + + + + + diff --git a/Aetheria.State.AuthoritySmoke/Program.cs b/Aetheria.State.AuthoritySmoke/Program.cs new file mode 100644 index 000000000..af78aec62 --- /dev/null +++ b/Aetheria.State.AuthoritySmoke/Program.cs @@ -0,0 +1,1797 @@ +using Aetheria.State; +using GameCult.Aetheria.State.Verse; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; + +var checks = new AuthoritySmokeChecks(); +await checks.RunAsync(); +Console.WriteLine("Authority smoke passed."); + +internal sealed class AuthoritySmokeChecks +{ + private static readonly object ProcessOutputLock = new(); + private static readonly Dictionary> ProcessOutput = new(); + + public async Task RunAsync() + { + DefaultTrustedPolicyAcceptsCommand(); + DelegatedRuntimeAcceptsOnlyListedRuntime(); + HostAuthoritativeAcceptsOnlyHostRuntime(); + InterestLeaseAcceptsOnlyActiveMatchingLease(); + UnsupportedAuthorityModesReject(); + AuthorizedCommandsReportsRejectedIds(); + PreRejectedCommandsEnterFrameReceipts(); + ClientTargetCarriesRuntimeIdentity(); + TwoLocalRuntimeDelegatedPolicyHarness(); + RtsProjectionProjectsLocalViewportFromFrame(); + StarbridgeSessionSummaryProjectsScenarioFacts(); + await AetheriaClientFacadeProjectsAndSubmitsAsync().ConfigureAwait(false); + await DaemonOncePublishesStarbridgeSessionFactsAsync().ConfigureAwait(false); + await SamePolicyDocumentCanBeLoadedByTwoNodesAsync().ConfigureAwait(false); + await TwoDaemonProcessesApplyDelegatedPolicyAsync().ConfigureAwait(false); + await ConcurrentDaemonsPublishRemoteFramesToLocalReplicasAsync().ConfigureAwait(false); + TrustedCommittedFactsConvergeLocalRuns(); + } + + private static void DefaultTrustedPolicyAcceptsCommand() + { + var command = Command("raven-unity", "entity:raven"); + var decision = AetheriaRuntimeAuthorityRouter.Authorize( + command, + policy: null, + leases: null, + localRuntimeId: "aetheria-daemon"); + + Require(decision.Authorized, "default trusted policy should accept trusted local commands"); + RequireEqual(AetheriaRuntimeAuthorityModes.AnyTrustedRuntime, decision.Mode, "default policy mode"); + RequireEqual("entity:raven", decision.SubjectKey, "default policy subject"); + RequireEqual(AetheriaRuntimeClaimKinds.Movement, decision.ClaimKind, "default policy claim"); + } + + private static void ClientTargetCarriesRuntimeIdentity() + { + var smokeId = Guid.NewGuid().ToString("N"); + var gameData = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"aetheria-client-target-{smokeId}")); + try + { + var state = AetheriaState.At(gameData); + var target = state.ClientTarget.Refresh(); + RequireEqual("raven-unity", target.RuntimeId, "client target default runtime id"); + + target = state.ClientTarget.RequestRuntimeId("raven-test-runtime"); + RequireEqual("raven-test-runtime", target.RuntimeId, "client target should persist requested runtime id"); + + var boot = AetheriaRuntimeStateBoot.Inspect(gameData); + RequireEqual("raven-test-runtime", boot.RuntimeId, "boot report should expose client target runtime id"); + + var previousRuntimeOverride = Environment.GetEnvironmentVariable(AetheriaRuntimeStateBoundary.RuntimeIdOverrideEnvironmentVariable); + var previousUnityOverride = Environment.GetEnvironmentVariable(AetheriaRuntimeStateBoundary.UnityRuntimeIdOverrideEnvironmentVariable); + try + { + Environment.SetEnvironmentVariable(AetheriaRuntimeStateBoundary.RuntimeIdOverrideEnvironmentVariable, "raven-env-runtime"); + Environment.SetEnvironmentVariable(AetheriaRuntimeStateBoundary.UnityRuntimeIdOverrideEnvironmentVariable, "ignored-unity-runtime"); + boot = AetheriaRuntimeStateBoot.Inspect(gameData); + RequireEqual("raven-env-runtime", boot.RuntimeId, "generic runtime id override should win"); + + Environment.SetEnvironmentVariable(AetheriaRuntimeStateBoundary.RuntimeIdOverrideEnvironmentVariable, ""); + boot = AetheriaRuntimeStateBoot.Inspect(gameData); + RequireEqual("ignored-unity-runtime", boot.RuntimeId, "Unity runtime id override should apply when generic override is absent"); + } + finally + { + Environment.SetEnvironmentVariable(AetheriaRuntimeStateBoundary.RuntimeIdOverrideEnvironmentVariable, previousRuntimeOverride); + Environment.SetEnvironmentVariable(AetheriaRuntimeStateBoundary.UnityRuntimeIdOverrideEnvironmentVariable, previousUnityOverride); + } + } + finally + { + try + { + gameData.Delete(recursive: true); + } + catch + { + } + } + } + + private static void DelegatedRuntimeAcceptsOnlyListedRuntime() + { + var policy = Policy( + AetheriaRuntimeAuthorityModes.AnyTrustedRuntime, + new AetheriaRuntimeAuthorityRule + { + RuleId = "raven.movement", + SubjectPrefix = "entity:raven", + ClaimKinds = [AetheriaRuntimeClaimKinds.Movement], + Mode = AetheriaRuntimeAuthorityModes.DelegatedRuntime, + RuntimeIds = ["raven-unity"], + Priority = 10 + }); + + var accepted = AetheriaRuntimeAuthorityRouter.Authorize( + Command("raven-unity", "entity:raven"), + policy, + leases: null, + localRuntimeId: "starfire-rts"); + var rejected = AetheriaRuntimeAuthorityRouter.Authorize( + Command("starfire-rts", "entity:raven"), + policy, + leases: null, + localRuntimeId: "starfire-rts"); + + Require(accepted.Authorized, "delegated runtime should accept listed runtime id"); + Require(!rejected.Authorized, "delegated runtime should reject unlisted runtime id"); + RequireEqual("delegated-runtime-required", rejected.Reason, "delegated rejection reason"); + } + + private static void HostAuthoritativeAcceptsOnlyHostRuntime() + { + var policy = Policy(AetheriaRuntimeAuthorityModes.HostAuthoritative); + + var accepted = AetheriaRuntimeAuthorityRouter.Authorize( + Command("host-daemon", "entity:raven"), + policy, + leases: null, + localRuntimeId: "host-daemon"); + var rejected = AetheriaRuntimeAuthorityRouter.Authorize( + Command("raven-unity", "entity:raven"), + policy, + leases: null, + localRuntimeId: "host-daemon"); + + Require(accepted.Authorized, "host-authoritative should accept host runtime"); + Require(!rejected.Authorized, "host-authoritative should reject non-host runtime"); + RequireEqual("host-authority-required", rejected.Reason, "host-authoritative rejection reason"); + } + + private static void InterestLeaseAcceptsOnlyActiveMatchingLease() + { + var now = DateTimeOffset.UtcNow; + var policy = Policy( + AetheriaRuntimeAuthorityModes.AnyTrustedRuntime, + new AetheriaRuntimeAuthorityRule + { + RuleId = "hostile.combat.lease", + SubjectPrefix = "entity:hostile", + ClaimKinds = [AetheriaRuntimeClaimKinds.Combat], + Mode = AetheriaRuntimeAuthorityModes.InterestLease, + LeaseScope = "combat", + Priority = 20 + }); + + var leases = new[] + { + new AetheriaRuntimeAuthorityLeaseDocument + { + LeaseId = "lease:starfire:hostile", + RuntimeId = "starfire-rts", + SubjectPrefix = "entity:hostile", + ClaimKinds = [AetheriaRuntimeClaimKinds.Combat], + Scope = "combat", + ValidFromUtc = now.AddSeconds(-5).ToString("O"), + ExpiresAtUtc = now.AddSeconds(30).ToString("O") + } + }; + + var accepted = AetheriaRuntimeAuthorityRouter.Authorize( + Command("starfire-rts", "entity:hostile:001", AetheriaRuntimeDaemonCommandKinds.FireWeaponGroup), + policy, + leases, + localRuntimeId: "raven-unity"); + var rejected = AetheriaRuntimeAuthorityRouter.Authorize( + Command("raven-unity", "entity:hostile:001", AetheriaRuntimeDaemonCommandKinds.FireWeaponGroup), + policy, + leases, + localRuntimeId: "raven-unity"); + + Require(accepted.Authorized, "interest lease should accept active matching lease"); + Require(!rejected.Authorized, "interest lease should reject missing runtime lease"); + RequireEqual("authority-lease-required", rejected.Reason, "interest lease rejection reason"); + } + + private static void UnsupportedAuthorityModesReject() + { + var policy = Policy(AetheriaRuntimeAuthorityModes.WitnessQuorum); + var decision = AetheriaRuntimeAuthorityRouter.Authorize( + Command("raven-unity", "entity:raven"), + policy, + leases: null, + localRuntimeId: "raven-unity"); + + Require(!decision.Authorized, "witness quorum should be represented but fail closed"); + RequireEqual("authority-mode-not-implemented", decision.Reason, "unsupported mode rejection reason"); + } + + private static void AuthorizedCommandsReportsRejectedIds() + { + var policy = Policy(AetheriaRuntimeAuthorityModes.HostAuthoritative); + var host = Command("host-daemon", "entity:raven"); + var client = Command("raven-unity", "entity:raven"); + var rejectedIds = new List(); + + var accepted = AetheriaRuntimeAuthorityRouter.AuthorizedCommands( + [host, client], + policy, + leases: null, + localRuntimeId: "host-daemon", + rejectedCommandIds: rejectedIds); + + RequireEqual(1, accepted.Count, "accepted command count"); + RequireEqual(host.CommandId, accepted[0].CommandId, "accepted command id"); + RequireEqual(1, rejectedIds.Count, "rejected command count"); + RequireEqual(client.CommandId, rejectedIds[0], "rejected command id"); + } + + private static void PreRejectedCommandsEnterFrameReceipts() + { + var command = Command("raven-unity", "entity:raven"); + var result = AetheriaRuntimeDaemonTickRunner.Tick( + Path.Combine(Path.GetTempPath(), "aetheria-authority-smoke.cc"), + new AetheriaRuntimeRunCheckpointCommit(), + new AetheriaRuntimeDaemonTickOptions + { + DaemonId = "host-daemon", + SessionId = "authority-smoke", + FrameId = 42, + SimulationTimeSeconds = 1, + FixedDeltaSeconds = 0.02, + ObservedCommands = Array.Empty(), + PreRejectedCommandIds = [command.CommandId], + PublishWitnesses = false + }); + + Require(result.Frame.RejectedCommandIds.Contains(command.CommandId), "pre-rejected command should enter frame rejection receipts"); + Require(result.Frame.AccountedCommandIds.Contains(command.CommandId), "pre-rejected command should enter accounted receipts"); + } + + private static void TwoLocalRuntimeDelegatedPolicyHarness() + { + const string ravenRuntime = "raven-unity"; + const string starfireRuntime = "starfire-rts"; + var ravenKey = EntityKey("coop-smoke", 0, 0); + var hostileKey = EntityKey("coop-smoke", 0, 1); + var policy = CoopPolicy(); + + var ravenByRaven = MovementCommand(ravenRuntime, ravenKey, directionX: 1, directionY: 0, magnitude: 1); + var hostileByRaven = MovementCommand(ravenRuntime, hostileKey, directionX: 0, directionY: 1, magnitude: 1); + var ravenFrame = TickWithPolicy( + "raven-local", + InitialCoopRun(), + policy, + [ravenByRaven, hostileByRaven]); + + Require(ravenFrame.AppliedCommandIds.Contains(ravenByRaven.CommandId), "raven node should apply Raven-authored Raven movement"); + Require(ravenFrame.RejectedCommandIds.Contains(hostileByRaven.CommandId), "raven node should reject Raven-authored hostile movement"); + Require(ravenFrame.AccountedCommandIds.Contains(ravenByRaven.CommandId), "raven node should account applied Raven command"); + Require(ravenFrame.AccountedCommandIds.Contains(hostileByRaven.CommandId), "raven node should account rejected hostile command"); + + var ravenByStarfire = MovementCommand(starfireRuntime, ravenKey, directionX: -1, directionY: 0, magnitude: 1); + var hostileByStarfire = MovementCommand(starfireRuntime, hostileKey, directionX: 0, directionY: -1, magnitude: 1); + var starfireFrame = TickWithPolicy( + "starfire-local", + InitialCoopRun(), + policy, + [ravenByStarfire, hostileByStarfire]); + + Require(starfireFrame.RejectedCommandIds.Contains(ravenByStarfire.CommandId), "starfire node should reject Starfire-authored Raven movement"); + Require(starfireFrame.AppliedCommandIds.Contains(hostileByStarfire.CommandId), "starfire node should apply Starfire-authored hostile movement"); + Require(starfireFrame.AccountedCommandIds.Contains(ravenByStarfire.CommandId), "starfire node should account rejected Raven command"); + Require(starfireFrame.AccountedCommandIds.Contains(hostileByStarfire.CommandId), "starfire node should account applied hostile command"); + } + + private static void RtsProjectionProjectsLocalViewportFromFrame() + { + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "rts-projection-smoke", + CurrentZoneIndex = 0, + EntranceZoneIndex = 0, + ExitZoneIndex = 1, + DiscoveredZoneIndices = [0], + CurrentEntityKey = EntityKey("rts-projection-smoke", 0, 0), + Zones = + [ + new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, + Name = "Projection Zone", + PositionX = 12, + PositionY = 34, + AdjacentZoneIndices = [1], + Entities = + [ + ProjectionEntity( + 0, + "Raven", + "player", + x: 0, + z: 0, + visibility: 80, + hull: 120, + cargoItem: "reactor-cell"), + ProjectionEntity( + 1, + "Starfire", + "player", + x: 240, + z: 0, + visibility: 160, + hull: 140, + cargoItem: "sensor-pack"), + ProjectionEntity( + 2, + "Raider In Shared Sight", + "raider", + x: 360, + z: 0, + visibility: 60, + hull: 70, + cargoItem: "scrap"), + ProjectionEntity( + 3, + "Outside Viewport", + "raider", + x: 900, + z: 0, + visibility: 60, + hull: 70, + cargoItem: "scrap") + ], + Bodies = + [ + new AetheriaRuntimeBodySnapshotCommit + { + BodyKey = "body:near", + OrbitKey = "orbit:near", + Name = "Gravity Brush", + Kind = "planet", + GravityInfluenceCenterX = 390, + GravityInfluenceCenterZ = 0, + GravityInfluenceRadius = 75, + GravityWellDepth = 3, + GravityDepthExponent = 2, + GravityWaveRadius = 32, + GravityWaveDepth = 0.5, + GravityWaveSpeed = 1 + }, + new AetheriaRuntimeBodySnapshotCommit + { + BodyKey = "body:far", + OrbitKey = "orbit:far", + Name = "Too Far", + Kind = "planet", + GravityInfluenceCenterX = 1200, + GravityInfluenceCenterZ = 0, + GravityInfluenceRadius = 50 + } + ] + }, + new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 1, + Name = "Projection Neighbor", + PositionX = 72, + PositionY = 34, + AdjacentZoneIndices = [0], + OwnerFactionIndex = 2, + FactionIndices = [2] + } + ] + }; + var frame = AetheriaRuntimeDaemonFrameDocument.Create( + run, + "projection-daemon", + "projection-session", + 12, + 0.24, + 0.02); + + var projection = AetheriaRuntimeRtsProjection.ProjectViewport( + frame, + new AetheriaRuntimeRtsViewportBounds + { + MinX = 500, + MinY = -100, + MaxX = -50, + MaxY = 100 + }); + var objectProjection = AetheriaRuntimeRtsProjection.ProjectObjectsViewport( + frame, + projection.Viewport); + var gravityProjection = AetheriaRuntimeRtsProjection.ProjectGravityViewport( + frame, + projection.Viewport); + var currentZone = AetheriaRuntimeRtsProjection.ProjectCurrentZone(frame); + var sectorMap = AetheriaRuntimeRtsProjection.ProjectSectorMap(frame); + + RequireEqual(AetheriaRuntimeDaemonSchemas.RtsViewport, projection.Schema, "projection schema"); + RequireEqual(AetheriaRuntimeDaemonSchemas.ObjectsViewport, objectProjection.Schema, "objects viewport projection schema"); + RequireEqual(AetheriaRuntimeDaemonSchemas.GravityViewport, gravityProjection.Schema, "gravity viewport projection schema"); + RequireEqual(AetheriaRuntimeDaemonSchemas.CurrentZone, currentZone.Schema, "current-zone projection schema"); + RequireEqual(AetheriaRuntimeDaemonSchemas.SectorMap, sectorMap.Schema, "sector-map projection schema"); + RequireEqual("Projection Zone", currentZone.ZoneName, "current-zone projection zone name"); + RequireEqual(12.0, currentZone.PositionX, "current-zone projection position x"); + RequireEqual(34.0, currentZone.PositionY, "current-zone projection position y"); + RequireEqual(2, sectorMap.Zones.Count, "sector-map projection zone count"); + Require(sectorMap.Zones.Any(zone => zone.ZoneIndex == 0 && zone.Current && zone.Entrance && zone.Discovered), "sector-map should mark current entrance zone"); + Require(sectorMap.Zones.Any(zone => zone.ZoneIndex == 1 && zone.Exit && !zone.Discovered), "sector-map should mark undiscovered exit zone"); + Require(sectorMap.Links.Any(link => link.FromZoneIndex == 0 && link.ToZoneIndex == 1 && !link.Discovered), "sector-map should include normalized topology link"); + RequireEqual(12L, projection.FrameId, "projection frame id"); + RequireEqual(-50.0, projection.Viewport.MinX, "normalized viewport min x"); + RequireEqual(500.0, projection.Viewport.MaxX, "normalized viewport max x"); + Require(projection.ControlledEntityIndices.SequenceEqual([0, 1]), "projection should expose controlled unit set"); + Require(projection.Objects.Any(item => item.DisplayName == "Raider In Shared Sight"), "projection should use union of controlled unit visibility"); + Require(!projection.Objects.Any(item => item.DisplayName == "Outside Viewport"), "projection should exclude objects outside viewport"); + Require(objectProjection.Objects.Select(item => item.EntityIndex).SequenceEqual(projection.Objects.Select(item => item.EntityIndex)), "objects viewport should match composed map object set"); + var raven = projection.Objects.First(item => item.DisplayName == "Raven"); + RequireEqual(120.0, raven.Status.Hull, "projection should include entity status"); + Require(raven.Inventory.Any(item => item.ItemKey == "reactor-cell" && item.Source == "cargo"), "projection should include inventory"); + Require(projection.GravityInfluences.Any(item => item.BodyKey == "body:near"), "projection should include intersecting gravity influence"); + Require(!projection.GravityInfluences.Any(item => item.BodyKey == "body:far"), "projection should exclude non-intersecting gravity influence"); + Require(gravityProjection.GravityInfluences.Select(item => item.BodyKey).SequenceEqual(projection.GravityInfluences.Select(item => item.BodyKey)), "gravity viewport should match composed map gravity set"); + } + + private static void StarbridgeSessionSummaryProjectsScenarioFacts() + { + var baseEntity = ProjectionEntity( + 4, + "Starbridge Base", + "player", + x: 0, + z: 0, + visibility: 500, + hull: 850, + cargoItem: "coolant-beam"); + baseEntity.Kind = "station"; + + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "starbridge-session-smoke", + CurrentZoneIndex = 0, + Zones = + [ + new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, + Name = "Starbridge Defense", + Entities = + [ + baseEntity, + ProjectionEntity(5, "Pilot Raven", "player", 120, 0, 300, 140, "repair-pod") + ] + } + ] + }; + var frame = AetheriaRuntimeDaemonFrameDocument.Create( + run, + "starbridge-daemon", + "starbridge-smoke-session", + 19, + 0.38, + 0.02); + var scenario = new AetheriaRuntimeStarbridgeScenarioDocument + { + ScenarioId = "starbridge.frontier-fabricator", + DisplayName = "Frontier Fabricator Defense", + StartingBaseKey = EntityKey("starbridge-session-smoke", 0, 4), + StationStock = + [ + new AetheriaRuntimeStarbridgeStationStockItem + { + ItemKey = "coolant-beam", + Quantity = 2, + Quality = 0.75, + Durability = 1 + } + ], + Waves = + [ + new AetheriaRuntimeStarbridgeWaveDefinition + { + WaveIndex = 0, + DisplayName = "Scout Probe", + AttackerKeys = ["scout"], + BossKey = "scout-captain", + RecoveredTechnologyKeys = ["sensor-calibration"] + }, + new AetheriaRuntimeStarbridgeWaveDefinition + { + WaveIndex = 1, + DisplayName = "Bomber Line", + AttackerKeys = ["bomber", "skirmisher"], + BossKey = "bomber-frame", + RecoveredTechnologyKeys = ["missile-rack-burst"] + } + ], + RuntimeRoles = + [ + new AetheriaRuntimeStarbridgeRuntimeRole + { + RuntimeId = "starfire-rts", + Role = "commander" + }, + new AetheriaRuntimeStarbridgeRuntimeRole + { + RuntimeId = "raven-unity", + Role = "pilot", + EntityKey = EntityKey("starbridge-session-smoke", 0, 5) + } + ] + }; + var session = new AetheriaRuntimeStarbridgeSessionDocument + { + SessionId = "starbridge-smoke-session", + ScenarioId = scenario.ScenarioId, + RunId = run.RunId, + BaseEntityKey = EntityKey("starbridge-session-smoke", 0, 4), + StationEntityKey = EntityKey("starbridge-session-smoke", 0, 4), + Phase = "pre-wave", + CurrentWaveIndex = 1 + }; + + var summary = AetheriaRuntimeStarbridgeProjection.ProjectSessionSummary(frame, scenario, session); + + RequireEqual(AetheriaRuntimeDaemonSchemas.StarbridgeSessionSummary, summary.Schema, "starbridge session summary schema"); + RequireEqual("starbridge.frontier-fabricator", summary.ScenarioId, "starbridge scenario id"); + RequireEqual("Frontier Fabricator Defense", summary.ScenarioName, "starbridge scenario name"); + RequireEqual("pre-wave", summary.Phase, "starbridge session phase"); + RequireEqual("Starbridge Base", summary.BaseStatus.DisplayName, "starbridge base status name"); + RequireEqual(850.0, summary.BaseStatus.Hull, "starbridge base hull"); + Require(summary.StationStock.Any(item => item.ItemKey == "coolant-beam"), "starbridge station stock should project scenario stock"); + Require(summary.WaveForecast.Length == 1 && summary.WaveForecast[0].DisplayName == "Bomber Line", "starbridge wave forecast should start at current wave"); + Require(summary.RuntimeRoles.Any(role => role.RuntimeId == "starfire-rts" && role.Role == "commander"), "starbridge runtime roles should project scenario roles"); + + var gameSurface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build( + frame, + new AetheriaRuntimeDaemonHealthDocument + { + DaemonId = "starbridge-daemon", + VerseId = "aetheria.starbridge-smoke", + Status = "healthy" + }, + AetheriaRuntimeDaemonCommandBoundaryDocument.Create("starbridge-daemon"), + summary); + RequireSurfaceMetric(gameSurface, "aetheria.daemon.game.starbridge.scenario", "Frontier Fabricator Defense", "daemon game surface starbridge scenario"); + RequireSurfaceMetric(gameSurface, "aetheria.daemon.game.starbridge.base", "Starbridge Base", "daemon game surface starbridge base"); + RequireSurfaceMetric(gameSurface, "aetheria.daemon.game.starbridge.stock.0.item", "coolant-beam", "daemon game surface starbridge stock"); + RequireSurfaceMetric(gameSurface, "aetheria.daemon.game.starbridge.wave.0.name", "Bomber Line", "daemon game surface starbridge wave forecast"); + RequireSurfaceMetric(gameSurface, "aetheria.daemon.game.starbridge.role.0.runtime", "starfire-rts", "daemon game surface starbridge runtime role"); + } + + private static async Task AetheriaClientFacadeProjectsAndSubmitsAsync() + { + var smokeId = Guid.NewGuid().ToString("N"); + var statePath = Path.Combine(Path.GetTempPath(), $"aetheria-client-facade-{smokeId}.cc"); + var frame = AetheriaRuntimeDaemonFrameDocument.Create( + new AetheriaRuntimeRunCheckpointCommit + { + RunId = "client-facade-smoke", + CurrentZoneIndex = 0, + CurrentEntityKey = EntityKey("client-facade-smoke", 0, 0), + Zones = + [ + new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, + Name = "Client Facade Zone", + Entities = + [ + ProjectionEntity(0, "Facade Raven", "player", 0, 0, 600, 140, "facade-cargo"), + ProjectionEntity(1, "Facade Raider", "raider", 100, 0, 120, 80, "raider-cargo") + ] + } + ] + }, + "facade-daemon", + "facade-session", + 7, + 0.14, + 0.02); + var health = new AetheriaRuntimeDaemonHealthDocument + { + DaemonId = "facade-daemon", + VerseId = "aetheria.facade-smoke", + FrameId = frame.FrameId, + Status = "healthy", + Transport = "cultcache-witness" + }; + var policy = AetheriaRuntimeVerseAuthorityPolicyDocument.TrustedCoop( + "aetheria.facade-smoke", + "facade-daemon"); + var scenario = new AetheriaRuntimeStarbridgeScenarioDocument + { + ScenarioId = "starbridge.facade", + DisplayName = "Facade Starbridge", + StartingBaseKey = EntityKey("client-facade-smoke", 0, 0), + StationStock = + [ + new AetheriaRuntimeStarbridgeStationStockItem + { + ItemKey = "facade-cargo", + Quantity = 3, + Quality = 1, + Durability = 1 + } + ], + Waves = + [ + new AetheriaRuntimeStarbridgeWaveDefinition + { + WaveIndex = 0, + DisplayName = "Facade Wave", + AttackerKeys = ["raider"], + BossKey = "facade-boss" + } + ] + }; + var session = new AetheriaRuntimeStarbridgeSessionDocument + { + SessionId = "facade-session", + ScenarioId = "starbridge.facade", + BaseEntityKey = EntityKey("client-facade-smoke", 0, 0), + Phase = "pre-wave" + }; + + using (var writer = await AetheriaRuntimeVerseClient + .OpenAsync(statePath, "facade-writer", startServer: false, pullOnOpen: false) + .ConfigureAwait(false)) + { + await writer.Database + .PutAsync(AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest, frame) + .ConfigureAwait(false); + await writer.Database + .PutAsync(AetheriaRuntimeVerseRecordKeys.DaemonHealth, health) + .ConfigureAwait(false); + await writer.PutStarbridgeScenarioAsync(scenario, flush: false).ConfigureAwait(false); + await writer.PutStarbridgeSessionAsync(session, flush: false).ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); + } + + AetheriaRuntimeDaemonPublicationStore.PublishVerseAuthorityPolicy(statePath, policy); + + using var client = await AetheriaClient + .OpenAsync(statePath, "raven-unity", sessionId: "facade-session", pullOnOpen: true) + .ConfigureAwait(false); + var viewport = await client + .MapViewportAsync(new AetheriaRuntimeRtsViewportBounds { MinX = -20, MinY = -20, MaxX = 150, MaxY = 20 }) + .ConfigureAwait(false); + Require(viewport.Objects.Any(item => item.DisplayName == "Facade Raven"), "facade map projection should include controlled object"); + Require(viewport.Objects.Any(item => item.DisplayName == "Facade Raider"), "facade map projection should include visible hostile object"); + var objectsViewport = await client + .ObjectsViewportAsync(new AetheriaRuntimeRtsViewportBounds { MinX = -20, MinY = -20, MaxX = 150, MaxY = 20 }) + .ConfigureAwait(false); + RequireEqual(AetheriaRuntimeDaemonSchemas.ObjectsViewport, objectsViewport.Schema, "facade objects viewport schema"); + Require(objectsViewport.Objects.Any(item => item.DisplayName == "Facade Raven"), "facade objects viewport should include controlled object"); + Require(objectsViewport.Objects.Any(item => item.DisplayName == "Facade Raider"), "facade objects viewport should include visible hostile object"); + var gravityViewport = await client + .GravityViewportAsync(new AetheriaRuntimeRtsViewportBounds { MinX = -20, MinY = -20, MaxX = 150, MaxY = 20 }) + .ConfigureAwait(false); + RequireEqual(AetheriaRuntimeDaemonSchemas.GravityViewport, gravityViewport.Schema, "facade gravity viewport schema"); + + var currentZone = await client.CurrentZoneAsync().ConfigureAwait(false); + RequireEqual(AetheriaRuntimeDaemonSchemas.CurrentZone, currentZone.Schema, "facade current-zone projection schema"); + RequireEqual("Client Facade Zone", currentZone.ZoneName, "facade current-zone projection name"); + + var sectorMap = await client.SectorMapAsync().ConfigureAwait(false); + RequireEqual(AetheriaRuntimeDaemonSchemas.SectorMap, sectorMap.Schema, "facade sector-map projection schema"); + Require(sectorMap.Zones.Any(zone => zone.ZoneIndex == currentZone.ZoneIndex && zone.Current), "facade sector-map should include current zone marker"); + + var selected = await client.SelectedObjectAsync(0).ConfigureAwait(false); + RequireEqual(AetheriaRuntimeDaemonSchemas.SelectedObject, selected.Schema, "facade selected-object projection schema"); + Require(selected.Selected?.DisplayName == "Facade Raven", "facade selected-object projection should resolve entity"); + + var inventory = await client.InventoryAsync(0).ConfigureAwait(false); + RequireEqual(AetheriaRuntimeDaemonSchemas.Inventory, inventory.Schema, "facade inventory projection schema"); + Require(inventory.Cargo.Any(item => item.ItemKey == "facade-cargo"), "facade inventory projection should expose cargo"); + + var starbridgeSummary = await client.StarbridgeSessionSummaryAsync().ConfigureAwait(false); + RequireEqual("Facade Starbridge", starbridgeSummary.ScenarioName, "facade starbridge summary should project scenario name"); + RequireEqual("pre-wave", starbridgeSummary.Phase, "facade starbridge summary should project session phase"); + Require(starbridgeSummary.StationStock.Any(item => item.ItemKey == "facade-cargo"), "facade starbridge summary should expose station stock"); + + var projectedHealth = await client.DaemonHealthAsync().ConfigureAwait(false); + Require(projectedHealth?.Status == "healthy", "facade daemon health projection should read local health"); + + var projectedPolicy = await client.AuthorityStatusAsync().ConfigureAwait(false); + RequireEqual("aetheria.trusted-coop.v1", projectedPolicy?.PolicyId, "facade authority status policy id"); + + var command = client.SetMoveVector(1, 0, 0.5); + RequireEqual(AetheriaRuntimeDaemonCommandKinds.SetMoveVector, command.Kind, "facade command kind"); + RequireEqual("raven-unity", command.ClientId, "facade command client id"); + RequireEqual(frame.FrameId, command.ObservedFrameId, "facade command observed frame id"); + + using var reader = await AetheriaRuntimeVerseClient + .OpenAsync(statePath, "facade-reader", startServer: false, pullOnOpen: true) + .ConfigureAwait(false); + var stored = await reader.Database + .GetAsync( + AetheriaRuntimeVerseRecordKeys.DaemonCommand(command.CommandId)) + .ConfigureAwait(false); + Require(stored != null, "facade command should be stored as typed daemon command document"); + RequireEqual(AetheriaRuntimeClaimKinds.Movement, stored!.ClaimKind, "facade command claim kind"); + } + + private static async Task DaemonOncePublishesStarbridgeSessionFactsAsync() + { + var smokeId = Guid.NewGuid().ToString("N"); + var statePath = Path.Combine(Path.GetTempPath(), $"aetheria-starbridge-daemon-{smokeId}.cc"); + var rtsPort = GetFreeUdpPort(); + const string runtimeId = "starbridge-daemon-smoke"; + + await RunDaemonOnceAsync(statePath, runtimeId, rtsPort).ConfigureAwait(false); + + using var client = await AetheriaClient + .OpenAsync(statePath, "starbridge-smoke-client", sessionId: "authority-smoke", pullOnOpen: true) + .ConfigureAwait(false); + var summary = await client.StarbridgeSessionSummaryAsync().ConfigureAwait(false); + + RequireEqual(AetheriaRuntimeDaemonSchemas.StarbridgeSessionSummary, summary.Schema, "daemon starbridge summary schema"); + RequireEqual("starbridge.frontier-fabricator", summary.ScenarioId, "daemon starbridge scenario id"); + RequireEqual("Frontier Fabricator Defense", summary.ScenarioName, "daemon starbridge scenario name"); + RequireEqual("authority-smoke", summary.SessionId, "daemon starbridge session id"); + RequireEqual("pre-wave", summary.Phase, "daemon starbridge phase"); + RequireEqual("Anchor Station", summary.BaseStatus.DisplayName, "daemon starbridge base name"); + Require(summary.StationStock.Any(item => item.ItemKey == "repair-parts"), "daemon starbridge summary should expose station stock"); + Require(summary.WaveForecast.Any(wave => wave.DisplayName == "Scout Probe"), "daemon starbridge summary should expose wave forecast"); + Require(summary.RuntimeRoles.Any(role => role.RuntimeId == "starfire-rts" && role.Role == "commander"), "daemon starbridge summary should expose commander role"); + Require(summary.RuntimeRoles.Any(role => role.RuntimeId == "raven-unity" && role.Role == "pilot"), "daemon starbridge summary should expose pilot role"); + } + + private static async Task SamePolicyDocumentCanBeLoadedByTwoNodesAsync() + { + var smokeId = Guid.NewGuid().ToString("N"); + var ravenPath = Path.Combine(Path.GetTempPath(), $"aetheria-authority-raven-{smokeId}.cc"); + var starfirePath = Path.Combine(Path.GetTempPath(), $"aetheria-authority-starfire-{smokeId}.cc"); + var policy = CoopPolicy(); + + await using var ravenNode = await AetheriaStateNode.OpenAsync( + ravenPath, + runtimeId: "raven-local", + startServer: false, + enableDurableShardLogs: false).ConfigureAwait(false); + await using var starfireNode = await AetheriaStateNode.OpenAsync( + starfirePath, + runtimeId: "starfire-local", + startServer: false, + enableDurableShardLogs: false).ConfigureAwait(false); + + await ravenNode.PutVerseAuthorityPolicyAsync(policy).ConfigureAwait(false); + await starfireNode.PutVerseAuthorityPolicyAsync(policy).ConfigureAwait(false); + + var ravenPolicy = await ravenNode.GetVerseAuthorityPolicyAsync().ConfigureAwait(false); + var starfirePolicy = await starfireNode.GetVerseAuthorityPolicyAsync().ConfigureAwait(false); + + Require(ravenPolicy != null, "raven node should load authority policy"); + Require(starfirePolicy != null, "starfire node should load authority policy"); + RequireEqual(policy.PolicyId, ravenPolicy!.PolicyId, "raven loaded policy id"); + RequireEqual(policy.PolicyId, starfirePolicy!.PolicyId, "starfire loaded policy id"); + RequireEqual(policy.Rules.Length, ravenPolicy.Rules.Length, "raven loaded rule count"); + RequireEqual(policy.Rules.Length, starfirePolicy.Rules.Length, "starfire loaded rule count"); + } + + private static async Task TwoDaemonProcessesApplyDelegatedPolicyAsync() + { + var smokeId = Guid.NewGuid().ToString("N"); + var policy = CoopPolicy(); + var ravenKey = EntityKey("coop-smoke", 0, 0); + var hostileKey = EntityKey("coop-smoke", 0, 1); + + var ravenByRaven = MovementCommand("raven-unity", ravenKey, directionX: 1, directionY: 0, magnitude: 1); + var hostileByRaven = MovementCommand("raven-unity", hostileKey, directionX: 0, directionY: 1, magnitude: 1); + var ravenStatePath = Path.Combine(Path.GetTempPath(), $"aetheria-authority-raven-daemon-{smokeId}.cc"); + await SeedDaemonStateAsync( + ravenStatePath, + "raven-local", + policy, + [ravenByRaven, hostileByRaven]).ConfigureAwait(false); + await RunDaemonOnceAsync(ravenStatePath, "raven-local", rtsPort: 41076).ConfigureAwait(false); + var ravenFrame = AetheriaRuntimeDaemonFrameStore.ReadFrame(ravenStatePath); + + Require(ravenFrame.AppliedCommandIds.Contains(ravenByRaven.CommandId), "raven daemon should apply Raven-authored Raven movement"); + Require(ravenFrame.RejectedCommandIds.Contains(hostileByRaven.CommandId), "raven daemon should reject Raven-authored hostile movement"); + Require(ravenFrame.AccountedCommandIds.Contains(ravenByRaven.CommandId), "raven daemon should account applied command"); + Require(ravenFrame.AccountedCommandIds.Contains(hostileByRaven.CommandId), "raven daemon should account rejected command"); + + var ravenByStarfire = MovementCommand("starfire-rts", ravenKey, directionX: -1, directionY: 0, magnitude: 1); + var hostileByStarfire = MovementCommand("starfire-rts", hostileKey, directionX: 0, directionY: -1, magnitude: 1); + var starfireStatePath = Path.Combine(Path.GetTempPath(), $"aetheria-authority-starfire-daemon-{smokeId}.cc"); + await SeedDaemonStateAsync( + starfireStatePath, + "starfire-local", + policy, + [ravenByStarfire, hostileByStarfire]).ConfigureAwait(false); + await RunDaemonOnceAsync(starfireStatePath, "starfire-local", rtsPort: 41077).ConfigureAwait(false); + var starfireFrame = AetheriaRuntimeDaemonFrameStore.ReadFrame(starfireStatePath); + + Require(starfireFrame.RejectedCommandIds.Contains(ravenByStarfire.CommandId), "starfire daemon should reject Starfire-authored Raven movement"); + Require(starfireFrame.AppliedCommandIds.Contains(hostileByStarfire.CommandId), "starfire daemon should apply Starfire-authored hostile movement"); + Require(starfireFrame.AccountedCommandIds.Contains(ravenByStarfire.CommandId), "starfire daemon should account rejected command"); + Require(starfireFrame.AccountedCommandIds.Contains(hostileByStarfire.CommandId), "starfire daemon should account applied command"); + } + + private static async Task ConcurrentDaemonsPublishRemoteFramesToLocalReplicasAsync() + { + var smokeId = Guid.NewGuid().ToString("N"); + var policy = CoopPolicy(); + var ravenKey = EntityKey("coop-smoke", 0, 0); + var hostileKey = EntityKey("coop-smoke", 0, 1); + var ravenStatePath = Path.Combine(Path.GetTempPath(), $"aetheria-authority-raven-live-{smokeId}.cc"); + var starfireStatePath = Path.Combine(Path.GetTempPath(), $"aetheria-authority-starfire-live-{smokeId}.cc"); + var ravenReplicaPath = Path.Combine(Path.GetTempPath(), $"aetheria-authority-raven-replica-{smokeId}.cc"); + var starfireReplicaPath = Path.Combine(Path.GetTempPath(), $"aetheria-authority-starfire-replica-{smokeId}.cc"); + var ravenPort = GetFreeUdpPort(); + var starfirePort = GetFreeUdpPort(); + + var ravenByRaven = MovementCommand("raven-unity", ravenKey, directionX: 1, directionY: 0, magnitude: 1); + var hostileByRaven = MovementCommand("raven-unity", hostileKey, directionX: 0, directionY: 1, magnitude: 1); + await SeedDaemonStateAsync( + ravenStatePath, + "raven-local", + policy, + [ravenByRaven, hostileByRaven]).ConfigureAwait(false); + + var ravenByStarfire = MovementCommand("starfire-rts", ravenKey, directionX: -1, directionY: 0, magnitude: 1); + var hostileByStarfire = MovementCommand("starfire-rts", hostileKey, directionX: 0, directionY: -1, magnitude: 1); + await SeedDaemonStateAsync( + starfireStatePath, + "starfire-local", + policy, + [ravenByStarfire, hostileByStarfire]).ConfigureAwait(false); + + using var ravenDaemon = StartDaemonProcess( + ravenStatePath, + "raven-local", + ravenPort, + once: false, + peerEndpoints: [$"rudp://127.0.0.1:{starfirePort}"]); + using var starfireDaemon = StartDaemonProcess( + starfireStatePath, + "starfire-local", + starfirePort, + once: false, + peerEndpoints: [$"rudp://127.0.0.1:{ravenPort}"]); + try + { + var ravenRemoteFrame = await WaitForReplicaFrameAsync( + $"rudp://127.0.0.1:{ravenPort}", + starfireReplicaPath, + "starfire-observes-raven", + [ravenDaemon, starfireDaemon], + frame => + string.Equals(frame.DaemonId, "raven-local", StringComparison.Ordinal) && + frame.CumulativeAppliedCommandIds.Contains(ravenByRaven.CommandId) && + frame.CumulativeRejectedCommandIds.Contains(hostileByRaven.CommandId), + "Raven frame with Raven movement applied and hostile movement rejected").ConfigureAwait(false); + var starfireRemoteFrame = await WaitForReplicaFrameAsync( + $"rudp://127.0.0.1:{starfirePort}", + ravenReplicaPath, + "raven-observes-starfire", + [ravenDaemon, starfireDaemon], + frame => + string.Equals(frame.DaemonId, "starfire-local", StringComparison.Ordinal) && + frame.CumulativeAppliedCommandIds.Contains(hostileByStarfire.CommandId) && + frame.CumulativeRejectedCommandIds.Contains(ravenByStarfire.CommandId), + "Starfire frame with hostile movement applied and Raven movement rejected").ConfigureAwait(false); + + RequireEqual("raven-local", ravenRemoteFrame.DaemonId, "Starfire local replica should observe Raven daemon id"); + Require(ravenRemoteFrame.CumulativeAppliedCommandIds.Contains(ravenByRaven.CommandId), "Starfire local replica should observe Raven-applied Raven command"); + Require(ravenRemoteFrame.CumulativeRejectedCommandIds.Contains(hostileByRaven.CommandId), "Starfire local replica should observe Raven rejection receipt"); + var starfireObservedRavenFacts = await WaitForReplicaFactsAsync( + $"rudp://127.0.0.1:{ravenPort}", + starfireReplicaPath, + "starfire-observes-raven-facts", + [ravenDaemon, starfireDaemon], + facts => + facts.Any(fact => + string.Equals(fact.SourceDaemonId, "raven-local", StringComparison.Ordinal) && + string.Equals(fact.CommandId, ravenByRaven.CommandId, StringComparison.Ordinal) && + string.Equals(fact.Outcome, AetheriaRuntimeCommandFactOutcomes.Applied, StringComparison.Ordinal)) && + facts.Any(fact => + string.Equals(fact.SourceDaemonId, "raven-local", StringComparison.Ordinal) && + string.Equals(fact.CommandId, hostileByRaven.CommandId, StringComparison.Ordinal) && + string.Equals(fact.Outcome, AetheriaRuntimeCommandFactOutcomes.Rejected, StringComparison.Ordinal)), + "Raven applied and rejected committed facts").ConfigureAwait(false); + Require( + starfireObservedRavenFacts.Any(fact => + string.Equals(fact.SourceDaemonId, "raven-local", StringComparison.Ordinal) && + string.Equals(fact.CommandId, ravenByRaven.CommandId, StringComparison.Ordinal) && + string.Equals(fact.Outcome, AetheriaRuntimeCommandFactOutcomes.Applied, StringComparison.Ordinal)), + "Starfire local replica should observe Raven committed movement fact"); + Require( + starfireObservedRavenFacts.Any(fact => + string.Equals(fact.SourceDaemonId, "raven-local", StringComparison.Ordinal) && + string.Equals(fact.CommandId, hostileByRaven.CommandId, StringComparison.Ordinal) && + string.Equals(fact.Outcome, AetheriaRuntimeCommandFactOutcomes.Rejected, StringComparison.Ordinal)), + "Starfire local replica should observe Raven policy rejection fact"); + + RequireEqual("starfire-local", starfireRemoteFrame.DaemonId, "Raven local replica should observe Starfire daemon id"); + Require(starfireRemoteFrame.CumulativeAppliedCommandIds.Contains(hostileByStarfire.CommandId), "Raven local replica should observe Starfire-applied hostile command"); + Require(starfireRemoteFrame.CumulativeRejectedCommandIds.Contains(ravenByStarfire.CommandId), "Raven local replica should observe Starfire rejection receipt"); + var ravenObservedStarfireFacts = await WaitForReplicaFactsAsync( + $"rudp://127.0.0.1:{starfirePort}", + ravenReplicaPath, + "raven-observes-starfire-facts", + [ravenDaemon, starfireDaemon], + facts => + facts.Any(fact => + string.Equals(fact.SourceDaemonId, "starfire-local", StringComparison.Ordinal) && + string.Equals(fact.CommandId, hostileByStarfire.CommandId, StringComparison.Ordinal) && + string.Equals(fact.Outcome, AetheriaRuntimeCommandFactOutcomes.Applied, StringComparison.Ordinal)) && + facts.Any(fact => + string.Equals(fact.SourceDaemonId, "starfire-local", StringComparison.Ordinal) && + string.Equals(fact.CommandId, ravenByStarfire.CommandId, StringComparison.Ordinal) && + string.Equals(fact.Outcome, AetheriaRuntimeCommandFactOutcomes.Rejected, StringComparison.Ordinal)), + "Starfire applied and rejected committed facts").ConfigureAwait(false); + Require( + ravenObservedStarfireFacts.Any(fact => + string.Equals(fact.SourceDaemonId, "starfire-local", StringComparison.Ordinal) && + string.Equals(fact.CommandId, hostileByStarfire.CommandId, StringComparison.Ordinal) && + string.Equals(fact.Outcome, AetheriaRuntimeCommandFactOutcomes.Applied, StringComparison.Ordinal)), + "Raven local replica should observe Starfire committed hostile fact"); + Require( + ravenObservedStarfireFacts.Any(fact => + string.Equals(fact.SourceDaemonId, "starfire-local", StringComparison.Ordinal) && + string.Equals(fact.CommandId, ravenByStarfire.CommandId, StringComparison.Ordinal) && + string.Equals(fact.Outcome, AetheriaRuntimeCommandFactOutcomes.Rejected, StringComparison.Ordinal)), + "Raven local replica should observe Starfire policy rejection fact"); + + var ravenMovementFactId = starfireObservedRavenFacts.First(fact => + string.Equals(fact.SourceDaemonId, "raven-local", StringComparison.Ordinal) && + string.Equals(fact.CommandId, ravenByRaven.CommandId, StringComparison.Ordinal) && + string.Equals(fact.Outcome, AetheriaRuntimeCommandFactOutcomes.Applied, StringComparison.Ordinal)).FactId; + var starfireHostileFactId = ravenObservedStarfireFacts.First(fact => + string.Equals(fact.SourceDaemonId, "starfire-local", StringComparison.Ordinal) && + string.Equals(fact.CommandId, hostileByStarfire.CommandId, StringComparison.Ordinal) && + string.Equals(fact.Outcome, AetheriaRuntimeCommandFactOutcomes.Applied, StringComparison.Ordinal)).FactId; + + var starfireConvergedFrame = await WaitForLocalFrameAsync( + starfireStatePath, + "starfire-local-converges-raven", + [ravenDaemon, starfireDaemon], + frame => + frame.CumulativeImportedFactIds.Contains(ravenMovementFactId) && + Entity(frame.Run, ravenKey).PositionX > 0, + "Starfire live daemon imports Raven movement fact and moves Raven locally").ConfigureAwait(false); + Require( + starfireConvergedFrame.CumulativeImportedFactIds.Any(), + "Starfire live daemon should record imported remote fact ids"); + + var ravenConvergedFrame = await WaitForLocalFrameAsync( + ravenStatePath, + "raven-local-converges-starfire", + [ravenDaemon, starfireDaemon], + frame => + frame.CumulativeImportedFactIds.Contains(starfireHostileFactId) && + Entity(frame.Run, hostileKey).PositionZ < 0, + "Raven live daemon imports Starfire hostile movement fact and moves hostile locally").ConfigureAwait(false); + Require( + ravenConvergedFrame.CumulativeImportedFactIds.Any(), + "Raven live daemon should record imported remote fact ids"); + } + finally + { + StopProcess(ravenDaemon); + StopProcess(starfireDaemon); + } + } + + private static async Task> ReadFactsAsync( + string statePath, + string runtimeId) + { + await using var node = await AetheriaStateNode.OpenAsync( + statePath, + runtimeId, + startServer: false, + enableDurableShardLogs: false).ConfigureAwait(false); + return node.ReadCommittedCommandFacts(); + } + + private static void TrustedCommittedFactsConvergeLocalRuns() + { + var smokeId = Guid.NewGuid().ToString("N"); + var policy = CoopPolicyWithMetadata(); + var ravenKey = EntityKey("coop-smoke", 0, 0); + var hostileKey = EntityKey("coop-smoke", 0, 1); + + var ravenMovement = MovementCommand("raven-unity", ravenKey, directionX: 1, directionY: 0, magnitude: 1); + var ravenSourceFrame = TickWithPolicy( + "raven-local", + InitialCoopFactRun(), + policy, + [ravenMovement]); + var ravenMovementFact = AetheriaRuntimeCommittedCommandFactDocument.FromAppliedCommand( + ravenSourceFrame, + ravenMovement, + policy.VerseId); + + var starfireImport = AetheriaRuntimeCommittedFactImporter.ImportIntoFrame( + Path.Combine(Path.GetTempPath(), $"aetheria-authority-facts-starfire-{smokeId}.cc"), + AetheriaRuntimeDaemonFrameDocument.Create( + InitialCoopFactRun(), + "starfire-local", + "authority-smoke", + frameId: 0, + simulationTimeSeconds: 0, + fixedDeltaSeconds: 0.02), + [ravenMovementFact], + policy, + leases: null, + localRuntimeId: "starfire-local", + daemonId: "starfire-local", + sessionId: "authority-smoke", + verseId: policy.VerseId); + + Require(starfireImport.AcceptedFactIds.Contains(ravenMovementFact.FactId), "Starfire should accept Raven's committed Raven movement fact"); + Require(!starfireImport.RejectedFactIds.Contains(ravenMovementFact.FactId), "Starfire should not reject Raven's authorized movement fact"); + Require(Entity(starfireImport.Run, ravenKey).PositionX > 0, "Starfire local run should converge Raven movement into local state"); + + var hostileRename = MetadataNameCommand("starfire-rts", hostileKey, "Starfire Marked Hostile"); + var starfireSourceFrame = TickWithPolicy( + "starfire-local", + InitialCoopFactRun(), + policy, + [hostileRename]); + var hostileRenameFact = AetheriaRuntimeCommittedCommandFactDocument.FromAppliedCommand( + starfireSourceFrame, + hostileRename, + policy.VerseId); + + var ravenImport = AetheriaRuntimeCommittedFactImporter.ImportIntoFrame( + Path.Combine(Path.GetTempPath(), $"aetheria-authority-facts-raven-{smokeId}.cc"), + AetheriaRuntimeDaemonFrameDocument.Create( + InitialCoopFactRun(), + "raven-local", + "authority-smoke", + frameId: 0, + simulationTimeSeconds: 0, + fixedDeltaSeconds: 0.02), + [hostileRenameFact], + policy, + leases: null, + localRuntimeId: "raven-local", + daemonId: "raven-local", + sessionId: "authority-smoke", + verseId: policy.VerseId); + + Require(ravenImport.AcceptedFactIds.Contains(hostileRenameFact.FactId), "Raven should accept Starfire's hostile metadata fact"); + RequireEqual("Starfire Marked Hostile", Entity(ravenImport.Run, hostileKey).Name, "Raven local run should converge Starfire hostile metadata into local state"); + + var unauthorizedRename = MetadataNameCommand("raven-unity", hostileKey, "Raven Should Not Own This"); + var unauthorizedFact = AetheriaRuntimeCommittedCommandFactDocument.FromAppliedCommand( + ravenSourceFrame, + unauthorizedRename, + policy.VerseId); + var rejectedImport = AetheriaRuntimeCommittedFactImporter.ImportIntoFrame( + Path.Combine(Path.GetTempPath(), $"aetheria-authority-facts-rejected-{smokeId}.cc"), + AetheriaRuntimeDaemonFrameDocument.Create( + InitialCoopFactRun(), + "starfire-local", + "authority-smoke", + frameId: 0, + simulationTimeSeconds: 0, + fixedDeltaSeconds: 0.02), + [unauthorizedFact], + policy, + leases: null, + localRuntimeId: "starfire-local", + daemonId: "starfire-local", + sessionId: "authority-smoke", + verseId: policy.VerseId); + + Require(rejectedImport.RejectedFactIds.Contains(unauthorizedFact.FactId), "Starfire should reject Raven-authored hostile metadata fact"); + RequireEqual("Starfire Hostile", Entity(rejectedImport.Run, hostileKey).Name, "Rejected facts should not mutate local state"); + } + + private static async Task SeedDaemonStateAsync( + string statePath, + string runtimeId, + AetheriaRuntimeVerseAuthorityPolicyDocument policy, + IReadOnlyList commands) + { + await using var node = await AetheriaStateNode.OpenAsync( + statePath, + runtimeId, + startServer: false, + enableDurableShardLogs: false).ConfigureAwait(false); + + await node.PutVerseAuthorityPolicyAsync(policy).ConfigureAwait(false); + foreach (var command in commands) + await node.SubmitDaemonCommandAsync(command).ConfigureAwait(false); + await node.FlushAsync().ConfigureAwait(false); + + AetheriaRuntimeDaemonFrameStore.PublishFrame( + statePath, + AetheriaRuntimeDaemonFrameDocument.Create( + InitialCoopRun(), + runtimeId, + "authority-smoke", + frameId: -1, + simulationTimeSeconds: 0, + fixedDeltaSeconds: 0.02)); + } + + private static async Task RunDaemonOnceAsync( + string statePath, + string runtimeId, + int rtsPort) + { + using var process = StartDaemonProcess(statePath, runtimeId, rtsPort, once: true); + await WaitForProcessSuccessAsync(process, runtimeId, TimeSpan.FromSeconds(90)).ConfigureAwait(false); + } + + private static Process StartDaemonProcess( + string statePath, + string runtimeId, + int rtsPort, + bool once) + { + return StartDaemonProcess(statePath, runtimeId, rtsPort, once, peerEndpoints: null); + } + + private static Process StartDaemonProcess( + string statePath, + string runtimeId, + int rtsPort, + bool once, + IReadOnlyList? peerEndpoints) + { + var repoRoot = Directory.GetCurrentDirectory(); + var executablePath = Path.Combine( + repoRoot, + "Aetheria.State.Daemon", + "bin", + "Debug", + "net10.0", + "Aetheria.State.Daemon.exe"); + var dllPath = Path.Combine( + repoRoot, + "Aetheria.State.Daemon", + "bin", + "Debug", + "net10.0", + "Aetheria.State.Daemon.dll"); + var projectPath = Path.Combine( + repoRoot, + "Aetheria.State.Daemon", + "Aetheria.State.Daemon.csproj"); + var executableExists = File.Exists(executablePath); + var dllExists = File.Exists(dllPath); + var commandPrefix = executableExists + ? Quote(executablePath) + : dllExists + ? "dotnet " + Quote(dllPath) + : "dotnet run --project " + Quote(projectPath) + " --"; + var arguments = string.Join( + " ", + "--state", + Quote(statePath), + "--daemon-id", + runtimeId, + "--verse-id", + "aetheria.coop-smoke", + "--session-id", + "authority-smoke", + "--rts-cultmesh-port", + rtsPort.ToString(), + "--api-publication-interval-ms", + "50", + string.Join(" ", (peerEndpoints ?? Array.Empty()) + .Select(endpoint => "--peer-cultmesh-endpoint " + Quote(endpoint))), + once ? "--once" : ""); + var startInfo = executableExists + ? new ProcessStartInfo(executablePath, arguments) + : dllExists + ? new ProcessStartInfo("dotnet", Quote(dllPath) + " " + arguments) + : new ProcessStartInfo("dotnet", "run --project " + Quote(projectPath) + " -- " + arguments); + startInfo.WorkingDirectory = repoRoot; + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + startInfo.UseShellExecute = false; + startInfo.CreateNoWindow = true; + + if (string.IsNullOrWhiteSpace(commandPrefix)) + { + throw new InvalidOperationException("Cannot resolve daemon command."); + } + + var process = Process.Start(startInfo) ?? + throw new InvalidOperationException("Failed to start Aetheria daemon child process."); + CaptureProcessOutput(process, runtimeId); + return process; + } + + private static async Task WaitForProcessSuccessAsync( + Process process, + string runtimeId, + TimeSpan timeout) + { + var waitTask = process.WaitForExitAsync(); + var completed = await Task.WhenAny(waitTask, Task.Delay(timeout)) + .ConfigureAwait(false); + if (completed != waitTask) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + } + + throw new TimeoutException($"Aetheria daemon child process timed out for {runtimeId}."); + } + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Aetheria daemon child process failed for {runtimeId} with exit code {process.ExitCode}.\n{GetProcessOutputTail(process)}"); + } + } + + private static void CaptureProcessOutput(Process process, string runtimeId) + { + lock (ProcessOutputLock) + ProcessOutput[process.Id] = new List(); + + process.OutputDataReceived += (_, eventArgs) => + { + if (eventArgs.Data != null) + AppendProcessOutput(process.Id, $"{runtimeId} stdout: {eventArgs.Data}"); + }; + process.ErrorDataReceived += (_, eventArgs) => + { + if (eventArgs.Data != null) + AppendProcessOutput(process.Id, $"{runtimeId} stderr: {eventArgs.Data}"); + }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + private static void AppendProcessOutput(int processId, string line) + { + lock (ProcessOutputLock) + { + if (!ProcessOutput.TryGetValue(processId, out var lines)) + { + lines = new List(); + ProcessOutput[processId] = lines; + } + + lines.Add(line); + if (lines.Count > 80) + lines.RemoveRange(0, lines.Count - 80); + } + } + + private static string GetProcessOutputTail(Process process) + { + lock (ProcessOutputLock) + { + if (!ProcessOutput.TryGetValue(process.Id, out var lines) || lines.Count == 0) + return "No child output captured."; + + var builder = new StringBuilder(); + foreach (var line in lines) + builder.AppendLine(line); + return builder.ToString(); + } + } + + private static async Task WaitForReplicaFrameAsync( + string endpoint, + string replicaPath, + string runtimeId, + IReadOnlyList childProcesses, + Func isReady, + string expectedFrame) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(45); + Exception? lastError = null; + AetheriaRuntimeDaemonFrameDocument? lastFrame = null; + while (DateTimeOffset.UtcNow < deadline) + { + try + { + await AetheriaVerseReplica.SyncScopedSnapshotAsync( + replicaPath, + endpoint, + runtimeId, + schemaIds: + [ + AetheriaRuntimeDaemonSchemas.Frame, + AetheriaRuntimeDaemonSchemas.CommittedCommandFact + ]).ConfigureAwait(false); + await using var node = await AetheriaStateNode.OpenAsync( + replicaPath, + runtimeId, + startServer: false, + enableDurableShardLogs: false).ConfigureAwait(false); + var frame = await node.GetDaemonFrameAsync().ConfigureAwait(false); + if (frame != null) + { + lastFrame = frame; + } + + if (frame != null && frame.FrameId >= 0 && isReady(frame)) + return frame; + } + catch (Exception ex) + { + lastError = ex; + } + + await Task.Delay(250).ConfigureAwait(false); + } + + throw new TimeoutException( + $"Timed out waiting for replica frame from {endpoint}. Expected {expectedFrame}." + + $" Requested schemas=[{AetheriaRuntimeDaemonSchemas.Frame},{AetheriaRuntimeDaemonSchemas.CommittedCommandFact}]." + + (lastFrame == null ? "" : $" Last frame: daemon={lastFrame.DaemonId}, frame={lastFrame.FrameId}, applied=[{string.Join(",", lastFrame.AppliedCommandIds)}], rejected=[{string.Join(",", lastFrame.RejectedCommandIds)}], cumulativeApplied=[{string.Join(",", lastFrame.CumulativeAppliedCommandIds)}], cumulativeRejected=[{string.Join(",", lastFrame.CumulativeRejectedCommandIds)}].") + + ProcessDiagnostics(childProcesses) + + (lastError == null ? "" : $" Last error: {lastError.GetType().Name}: {lastError.Message}")); + } + + private static async Task> WaitForReplicaFactsAsync( + string endpoint, + string replicaPath, + string runtimeId, + IReadOnlyList childProcesses, + Func, bool> isReady, + string expectedFacts) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(45); + Exception? lastError = null; + IReadOnlyList lastFacts = Array.Empty(); + while (DateTimeOffset.UtcNow < deadline) + { + try + { + await AetheriaVerseReplica.SyncScopedSnapshotAsync( + replicaPath, + endpoint, + runtimeId, + schemaIds: + [ + AetheriaRuntimeDaemonSchemas.Frame, + AetheriaRuntimeDaemonSchemas.CommittedCommandFact + ]).ConfigureAwait(false); + var facts = await ReadFactsAsync(replicaPath, runtimeId).ConfigureAwait(false); + lastFacts = facts; + if (isReady(facts)) + return facts; + } + catch (Exception ex) + { + lastError = ex; + } + + await Task.Delay(250).ConfigureAwait(false); + } + + throw new TimeoutException( + $"Timed out waiting for committed facts from {endpoint}. Expected {expectedFacts}." + + $" Requested schemas=[{AetheriaRuntimeDaemonSchemas.Frame},{AetheriaRuntimeDaemonSchemas.CommittedCommandFact}]." + + $" Last facts=[{string.Join(",", lastFacts.Select(fact => $"{fact.SourceDaemonId}:{fact.CommandId}:{fact.Outcome}"))}]." + + ProcessDiagnostics(childProcesses) + + (lastError == null ? "" : $" Last error: {lastError.GetType().Name}: {lastError.Message}")); + } + + private static async Task WaitForLocalFrameAsync( + string statePath, + string runtimeId, + IReadOnlyList childProcesses, + Func isReady, + string expectedFrame) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(45); + Exception? lastError = null; + AetheriaRuntimeDaemonFrameDocument? lastFrame = null; + while (DateTimeOffset.UtcNow < deadline) + { + try + { + if (AetheriaRuntimeDaemonFrameStore.TryReadFrame(statePath, out var frame)) + { + lastFrame = frame; + if (isReady(frame)) + return frame; + } + } + catch (Exception ex) + { + lastError = ex; + } + + await Task.Delay(250).ConfigureAwait(false); + } + + throw new TimeoutException( + $"Timed out waiting for local daemon frame at {statePath}. Expected {expectedFrame}." + + (lastFrame == null ? "" : $" Last frame: daemon={lastFrame.DaemonId}, frame={lastFrame.FrameId}, imported=[{string.Join(",", lastFrame.ImportedFactIds)}], cumulativeImported=[{string.Join(",", lastFrame.CumulativeImportedFactIds)}], rejectedImported=[{string.Join(",", lastFrame.CumulativeRejectedImportedFactIds)}].") + + ProcessDiagnostics(childProcesses) + + (lastError == null ? "" : $" Last error: {lastError.GetType().Name}: {lastError.Message}")); + } + + private static string ProcessDiagnostics(IReadOnlyList childProcesses) + { + var lines = new List(); + foreach (var process in childProcesses ?? Array.Empty()) + { + try + { + lines.Add(process.HasExited + ? $" Child pid={process.Id} exited code={process.ExitCode}." + : $" Child pid={process.Id} still running."); + lines.Add(Environment.NewLine); + lines.Add(GetProcessOutputTail(process)); + } + catch (Exception ex) + { + lines.Add($" Child diagnostics failed: {ex.GetType().Name}: {ex.Message}."); + } + } + + return lines.Count == 0 ? "" : string.Concat(lines); + } + + private static void StopProcess(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + } + + try + { + process.WaitForExit(5000); + } + catch + { + } + } + + private static AetheriaRuntimeDaemonFrameDocument TickWithPolicy( + string daemonId, + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeVerseAuthorityPolicyDocument policy, + IReadOnlyList observedCommands) + { + var rejectedIds = new List(); + var authorizedCommands = AetheriaRuntimeAuthorityRouter.AuthorizedCommands( + observedCommands, + policy, + leases: null, + localRuntimeId: daemonId, + rejectedCommandIds: rejectedIds); + + var result = AetheriaRuntimeDaemonTickRunner.Tick( + Path.Combine(Path.GetTempPath(), $"aetheria-authority-smoke-{daemonId}.cc"), + run, + new AetheriaRuntimeDaemonTickOptions + { + DaemonId = daemonId, + SessionId = "authority-smoke", + VerseId = policy.VerseId, + FrameId = 1, + SimulationTimeSeconds = 0.02, + FixedDeltaSeconds = 0.02, + ObservedCommands = authorizedCommands, + PreRejectedCommandIds = rejectedIds, + PublishWitnesses = false + }); + + return result.Frame; + } + + private static AetheriaRuntimeVerseAuthorityPolicyDocument Policy( + string defaultMode, + params AetheriaRuntimeAuthorityRule[] rules) + { + return new AetheriaRuntimeVerseAuthorityPolicyDocument + { + VerseId = "aetheria.test", + HostRuntimeId = "host-daemon", + DefaultMode = defaultMode, + Rules = rules + }; + } + + private static AetheriaRuntimeVerseAuthorityPolicyDocument CoopPolicy() + { + var ravenKey = EntityKey("coop-smoke", 0, 0); + var hostileKey = EntityKey("coop-smoke", 0, 1); + var policy = Policy( + AetheriaRuntimeAuthorityModes.HostAuthoritative, + new AetheriaRuntimeAuthorityRule + { + RuleId = "raven.runtime.owns.raven.movement", + SubjectPrefix = ravenKey, + ClaimKinds = [AetheriaRuntimeClaimKinds.Movement], + Mode = AetheriaRuntimeAuthorityModes.DelegatedRuntime, + RuntimeIds = ["raven-unity"], + Priority = 100 + }, + new AetheriaRuntimeAuthorityRule + { + RuleId = "starfire.runtime.owns.hostile.movement", + SubjectPrefix = hostileKey, + ClaimKinds = [AetheriaRuntimeClaimKinds.Movement], + Mode = AetheriaRuntimeAuthorityModes.DelegatedRuntime, + RuntimeIds = ["starfire-rts"], + Priority = 100 + }); + policy.VerseId = "aetheria.coop-smoke"; + policy.PolicyId = "aetheria.coop-smoke.delegated.v1"; + return policy; + } + + private static AetheriaRuntimeVerseAuthorityPolicyDocument CoopPolicyWithMetadata() + { + var policy = CoopPolicy(); + var ravenKey = EntityKey("coop-smoke", 0, 0); + var hostileKey = EntityKey("coop-smoke", 0, 1); + policy.Rules = policy.Rules + .Concat(new[] + { + new AetheriaRuntimeAuthorityRule + { + RuleId = "raven.runtime.owns.raven.metadata", + SubjectPrefix = ravenKey, + ClaimKinds = [AetheriaRuntimeClaimKinds.Metadata], + Mode = AetheriaRuntimeAuthorityModes.DelegatedRuntime, + RuntimeIds = ["raven-unity"], + Priority = 90 + }, + new AetheriaRuntimeAuthorityRule + { + RuleId = "starfire.runtime.owns.hostile.metadata", + SubjectPrefix = hostileKey, + ClaimKinds = [AetheriaRuntimeClaimKinds.Metadata], + Mode = AetheriaRuntimeAuthorityModes.DelegatedRuntime, + RuntimeIds = ["starfire-rts"], + Priority = 90 + } + }) + .ToArray(); + return policy; + } + + private static AetheriaRuntimeDaemonCommandDocument Command( + string runtimeId, + string subjectKey, + AetheriaRuntimeDaemonCommandKinds kind = AetheriaRuntimeDaemonCommandKinds.SetMoveVector) + { + var command = AetheriaRuntimeDaemonCommandDocument.Create( + kind, + runtimeId, + sessionId: "authority-smoke", + observedFrameId: 0, + actorEntityKey: subjectKey); + command.SubjectKey = subjectKey; + command.AuthorRuntimeId = runtimeId; + return command; + } + + private static AetheriaRuntimeDaemonCommandDocument MovementCommand( + string runtimeId, + string subjectKey, + double directionX, + double directionY, + double magnitude) + { + var command = Command(runtimeId, subjectKey, AetheriaRuntimeDaemonCommandKinds.SetMoveVector); + command.DirectionX = directionX; + command.DirectionY = directionY; + command.ScalarValue = magnitude; + return command; + } + + private static AetheriaRuntimeDaemonCommandDocument MetadataNameCommand( + string runtimeId, + string subjectKey, + string name) + { + var command = Command(runtimeId, subjectKey, AetheriaRuntimeDaemonCommandKinds.SetEntityName); + command.TargetEntityKey = subjectKey; + command.TextValue = name; + command.ClaimKind = AetheriaRuntimeClaimKinds.Metadata; + return command; + } + + private static AetheriaRuntimeRunCheckpointCommit InitialCoopRun() + { + return new AetheriaRuntimeRunCheckpointCommit + { + RunId = "coop-smoke", + CurrentZoneIndex = 0, + CurrentEntityKey = EntityKey("coop-smoke", 0, 0), + Zones = + [ + new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, + Name = "Co-op Smoke Zone", + Entities = + [ + new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 0, + Name = "Raven Pilot", + Kind = "ship", + FactionKey = "player", + IsActive = true + }, + new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 1, + Name = "Starfire Hostile", + Kind = "ship", + FactionKey = "player", + IsActive = true + } + ] + } + ] + }; + } + + private static AetheriaRuntimeRunCheckpointCommit InitialCoopFactRun() + { + var run = InitialCoopRun(); + var zone = run.Zones[0]; + zone.Entities[0].FactionKey = "player"; + zone.Entities[1].FactionKey = "raider"; + return run; + } + + private static AetheriaRuntimeEntitySnapshotCommit Entity( + AetheriaRuntimeRunCheckpointCommit run, + string entityKey) + { + var parts = entityKey.Split('.'); + var zoneIndex = Array.IndexOf(parts, "zone") >= 0 && int.TryParse(parts[Array.IndexOf(parts, "zone") + 1], out var parsedZone) + ? parsedZone + : 0; + var entityIndex = Array.IndexOf(parts, "entity") >= 0 && int.TryParse(parts[Array.IndexOf(parts, "entity") + 1], out var parsedEntity) + ? parsedEntity + : 0; + var zone = (run.Zones ?? Array.Empty()) + .First(candidate => candidate.ZoneIndex == zoneIndex); + return (zone.Entities ?? Array.Empty()) + .First(candidate => candidate.EntityIndex == entityIndex); + } + + private static AetheriaRuntimeEntitySnapshotCommit ProjectionEntity( + int entityIndex, + string name, + string factionKey, + double x, + double z, + double visibility, + double hull, + string cargoItem) + { + return new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = entityIndex, + Name = name, + Kind = "ship", + FactionKey = factionKey, + IsActive = true, + PositionX = x, + PositionZ = z, + DirectionX = 1, + DirectionY = 0, + Visibility = visibility, + TargetEntityIndex = -1, + StatGrids = + [ + new AetheriaRuntimeEntityStatGridCommit + { + Name = "hull", + Width = 1, + Height = 1, + Values = [hull] + }, + new AetheriaRuntimeEntityStatGridCommit + { + Name = "shield", + Width = 1, + Height = 1, + Values = [25] + }, + new AetheriaRuntimeEntityStatGridCommit + { + Name = "heat", + Width = 1, + Height = 1, + Values = [4] + } + ], + CargoContents = + [ + new AetheriaRuntimeCargoBayLoadoutCommit + { + Items = + [ + new AetheriaRuntimeLoadoutItemSlotCommit + { + Item = new AetheriaRuntimeLoadoutItemCommit + { + ItemKey = cargoItem, + Quantity = 1, + Quality = 1, + Durability = 1, + Enabled = true + } + } + ] + } + ] + }; + } + + private static string EntityKey(string runId, int zoneIndex, int entityIndex) + { + return $"global:aetheria.run_state.{runId}.zone.{zoneIndex}.entity.{entityIndex}.v1"; + } + + private static string Quote(string value) + { + return "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + } + + private static int GetFreeUdpPort() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + return ((IPEndPoint)socket.LocalEndPoint!).Port; + } + + private static void Require(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } + + private static void RequireEqual(T expected, T actual, string message) + { + if (!EqualityComparer.Default.Equals(expected, actual)) + throw new InvalidOperationException($"{message}: expected {expected}, got {actual}"); + } + + private static void RequireSurfaceMetric( + AetheriaRuntimeSurfaceDocument surface, + string componentId, + string expectedValue, + string message) + { + var component = Flatten(surface.Surface.Root) + .FirstOrDefault(candidate => string.Equals(candidate.Id, componentId, StringComparison.Ordinal)); + if (component == null) + throw new InvalidOperationException($"{message}: missing component {componentId}"); + if (!component.Props.TryGetValue("value", out var actualValue) || + !string.Equals(actualValue, expectedValue, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"{message}: expected {expectedValue}, got {actualValue ?? "(missing)"}"); + } + } + + private static IEnumerable Flatten(AetheriaRuntimeSurfaceComponent component) + { + yield return component; + foreach (var child in component.Children ?? Array.Empty()) + { + foreach (var nested in Flatten(child)) + yield return nested; + } + } +} diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 3ce25097e..f27093c79 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -1,29 +1,54 @@ using Aetheria.State; using Aetheria.State.Documents; using GameCult.Aetheria.State.Verse; +using GameCult.Caching; +using GameCult.Networking; +using MessagePack; +using System.Globalization; +using System.Net; +using System.Net.Sockets; var options = AetheriaDaemonHostOptions.Parse(args); var startedAtUtc = DateTimeOffset.UtcNow.ToString("O"); Console.WriteLine($"Aetheria Verse daemon starting: {options.StatePath}"); +Console.WriteLine($"Aetheria Verse daemon peers: {options.PeerCultMeshEndpoints.Count} [{string.Join(", ", options.PeerCultMeshEndpoints)}]"); await using var node = await AetheriaStateNode.OpenAsync( options.StatePath, runtimeId: options.DaemonId, - startServer: true).ConfigureAwait(false); + startServer: true, + enableDurableShardLogs: false).ConfigureAwait(false); using var discoveryHost = new AetheriaVerseDiscoveryHost(node); await EnsureWorldDocumentAsync(node).ConfigureAwait(false); +await EnsureTradeValuePolicyAsync(node, startedAtUtc).ConfigureAwait(false); +await EnsurePlayableRunDocumentsAsync(node, startedAtUtc).ConfigureAwait(false); +await EnsureStarbridgeSessionDocumentsAsync(node, options).ConfigureAwait(false); var verseHost = await EnsureVerseHostSettingsAsync(node, options, startedAtUtc).ConfigureAwait(false); +await EnsureVerseAuthorityPolicyAsync(node, options).ConfigureAwait(false); discoveryHost.Update(verseHost); await PublishRuntimeSessionAsync(node, options, startedAtUtc, "starting").ConfigureAwait(false); await PublishStateSurfacesAsync(node, options, startedAtUtc).ConfigureAwait(false); -var firstTick = await TickAsync(node, options).ConfigureAwait(false); +var latestFrame = AetheriaRuntimeDaemonFrameStore.TryReadFrame(node.StatePath, out var startupFrame) + ? startupFrame + : null; +using var cultMeshRudpHost = StartRtsCultMeshHost(node, options, () => latestFrame); +using var rtsPumpCancellation = new CancellationTokenSource(); +var rtsPump = RunRtsCultMeshPumpAsync(cultMeshRudpHost, rtsPumpCancellation.Token); +var nextApiPublicationUtc = DateTimeOffset.UtcNow; +var firstTick = await TickAsync(node, options, latestFrame, publishWitnesses: true).ConfigureAwait(false); +ThrowIfRtsPumpFaulted(rtsPump); +latestFrame = firstTick.Frame; +nextApiPublicationUtc = DateTimeOffset.UtcNow.Add(options.ApiPublicationInterval); Console.WriteLine($"Aetheria Verse daemon published frame {firstTick.Frame.FrameId}."); +Console.WriteLine($"Aetheria RTS CultMesh endpoint: rudp://{options.RtsCultMeshAdvertiseHost}:{cultMeshRudpHost.LocalEndPoint.Port}"); if (options.Once) { await PublishRuntimeSessionAsync(node, options, startedAtUtc, "completed").ConfigureAwait(false); + rtsPumpCancellation.Cancel(); + await rtsPump.ConfigureAwait(false); return; } @@ -37,30 +62,59 @@ Console.CancelKeyPress += (_, eventArgs) => await PublishRuntimeSessionAsync(node, options, startedAtUtc, "running").ConfigureAwait(false); Console.WriteLine("Aetheria Verse daemon is running. Press Ctrl+C to stop."); +var nextTickUtc = DateTimeOffset.UtcNow.Add(options.TickInterval); while (!stopped.Task.IsCompleted) { - var completed = await Task.WhenAny(stopped.Task, Task.Delay(options.TickInterval)).ConfigureAwait(false); - if (completed == stopped.Task) - break; + ThrowIfRtsPumpFaulted(rtsPump); + var delay = nextTickUtc - DateTimeOffset.UtcNow; + if (delay > TimeSpan.Zero) + { + var completed = await Task.WhenAny(stopped.Task, Task.Delay(delay)).ConfigureAwait(false); + if (completed == stopped.Task) + break; + } - var tick = await TickAsync(node, options).ConfigureAwait(false); - discoveryHost.Update(await node.GetVerseHostSettingsAsync().ConfigureAwait(false)); - await PublishRuntimeSessionAsync(node, options, startedAtUtc, "running").ConfigureAwait(false); - Console.WriteLine($"Aetheria Verse daemon published frame {tick.Frame.FrameId}."); + var publishWitnesses = DateTimeOffset.UtcNow >= nextApiPublicationUtc; + var tick = await TickAsync(node, options, latestFrame, publishWitnesses).ConfigureAwait(false); + ThrowIfRtsPumpFaulted(rtsPump); + latestFrame = tick.Frame; + nextTickUtc += options.TickInterval; + if (nextTickUtc < DateTimeOffset.UtcNow - options.TickInterval) + nextTickUtc = DateTimeOffset.UtcNow; + if (publishWitnesses) + { + nextApiPublicationUtc = DateTimeOffset.UtcNow.Add(options.ApiPublicationInterval); + discoveryHost.Update(await node.GetVerseHostSettingsAsync().ConfigureAwait(false)); + await PublishRuntimeSessionAsync(node, options, startedAtUtc, "running").ConfigureAwait(false); + } + if (tick.Frame.FrameId % 120 == 0) + Console.WriteLine($"Aetheria Verse daemon published frame {tick.Frame.FrameId} at {tick.Frame.SimulationTimeSeconds:0.00}s."); } await PublishRuntimeSessionAsync(node, options, startedAtUtc, "stopping").ConfigureAwait(false); +rtsPumpCancellation.Cancel(); +await rtsPump.ConfigureAwait(false); Console.WriteLine("Aetheria Verse daemon stopping."); +static void ThrowIfRtsPumpFaulted(Task rtsPump) +{ + if (!rtsPump.IsCompleted) + return; + + if (rtsPump.IsFaulted) + throw new InvalidOperationException("Aetheria RTS CultMesh pump faulted.", rtsPump.Exception); + + throw new InvalidOperationException("Aetheria RTS CultMesh pump stopped unexpectedly."); +} + static async Task TickAsync( AetheriaStateNode node, - AetheriaDaemonHostOptions options) + AetheriaDaemonHostOptions options, + AetheriaRuntimeDaemonFrameDocument? currentFrame, + bool publishWitnesses) { await AcceptEveCommandsAsync(node, options).ConfigureAwait(false); - var currentFrame = AetheriaRuntimeDaemonFrameStore.TryReadFrame(node.StatePath, out var frame) - ? frame - : null; var fixedDeltaSeconds = currentFrame?.FixedDeltaSeconds > 0 ? currentFrame.FixedDeltaSeconds : options.FixedDeltaSeconds; @@ -69,13 +123,32 @@ static async Task TickAsync( var sessionId = string.IsNullOrWhiteSpace(currentFrame?.SessionId) ? options.SessionId : currentFrame.SessionId; - var run = currentFrame?.Run ?? new AetheriaRuntimeRunCheckpointCommit(); + var run = HasPlayableRun(currentFrame?.Run) + ? currentFrame!.Run + : await ReadRuntimeRunCheckpointAsync(node).ConfigureAwait(false) ?? new AetheriaRuntimeRunCheckpointCommit(); var loadoutTemplates = node.Cache .GetAll() .Select(ToLoadoutTemplateCommit) .ToArray(); var observedCommands = node.ReadObservedDaemonCommands(); + var accountedCommandIds = new HashSet( + currentFrame?.AccountedCommandIds ?? Array.Empty(), + StringComparer.Ordinal); + var pendingObservedCommands = observedCommands + .Where(command => command != null && !accountedCommandIds.Contains(command.CommandId ?? "")) + .ToArray(); + var policyRejectedCommandIds = new List(); + var authorityPolicy = await node.GetVerseAuthorityPolicyAsync().ConfigureAwait(false); + var starbridgeScenario = await node.GetStarbridgeScenarioAsync().ConfigureAwait(false); + var starbridgeSession = await node.GetStarbridgeSessionAsync().ConfigureAwait(false); + var authorityLeases = node.ReadAuthorityLeases(); + var authorizedCommands = AetheriaRuntimeAuthorityRouter.AuthorizedCommands( + pendingObservedCommands, + authorityPolicy, + authorityLeases, + options.DaemonId, + policyRejectedCommandIds); var result = AetheriaRuntimeDaemonTickRunner.Tick( node.StatePath, @@ -89,19 +162,594 @@ static async Task TickAsync( FrameId = nextFrameId, SimulationTimeSeconds = simulationTimeSeconds, FixedDeltaSeconds = fixedDeltaSeconds, - ObservedCommands = observedCommands, - AccountedCommandIds = currentFrame?.AccountedCommandIds ?? Array.Empty(), + ObservedCommands = authorizedCommands, + AccountedCommandIds = accountedCommandIds.ToArray(), + PreRejectedCommandIds = policyRejectedCommandIds, + CumulativeAppliedCommandIds = currentFrame?.CumulativeAppliedCommandIds ?? currentFrame?.AppliedCommandIds ?? Array.Empty(), + CumulativeRejectedCommandIds = currentFrame?.CumulativeRejectedCommandIds ?? currentFrame?.RejectedCommandIds ?? Array.Empty(), + StarbridgeScenario = starbridgeScenario, + StarbridgeSession = starbridgeSession, + PublishWitnesses = publishWitnesses, OperationContext = new AetheriaRuntimeDaemonOperationContext { LoadoutTemplates = loadoutTemplates } }); + result.Frame.CumulativeImportedFactIds = + currentFrame?.CumulativeImportedFactIds ?? Array.Empty(); + result.Frame.CumulativeRejectedImportedFactIds = + currentFrame?.CumulativeRejectedImportedFactIds ?? Array.Empty(); + + await PublishCommittedCommandFactsAsync( + node, + options, + result.Frame, + pendingObservedCommands, + authorizedCommands, + policyRejectedCommandIds).ConfigureAwait(false); + + if (options.PeerCultMeshEndpoints.Count > 0) + { + result = await ImportRemoteCommittedFactsAsync( + node, + options, + result.Frame, + result, + authorityPolicy, + authorityLeases).ConfigureAwait(false); + } + + AetheriaRuntimeDaemonFrameStore.PublishFrame(node.StatePath, result.Frame); + + if (publishWitnesses) + { + await PublishDaemonApiDocumentsAsync(node, result).ConfigureAwait(false); + await PublishStateSurfacesAsync(node, options, result.Frame.PublishedAtUtc).ConfigureAwait(false); + } - await PublishDaemonApiDocumentsAsync(node, result).ConfigureAwait(false); - await PublishStateSurfacesAsync(node, options, result.Frame.PublishedAtUtc).ConfigureAwait(false); return result; } +static async Task PublishCommittedCommandFactsAsync( + AetheriaStateNode node, + AetheriaDaemonHostOptions options, + AetheriaRuntimeDaemonFrameDocument frame, + IReadOnlyList pendingObservedCommands, + IReadOnlyList authorizedCommands, + IReadOnlyList policyRejectedCommandIds) +{ + var byCommandId = (pendingObservedCommands ?? Array.Empty()) + .Where(command => command != null && !string.IsNullOrWhiteSpace(command.CommandId)) + .ToDictionary(command => command.CommandId, StringComparer.Ordinal); + + foreach (var commandId in frame.AppliedCommandIds ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(commandId) || !byCommandId.TryGetValue(commandId, out var command)) + continue; + + await node.PutCommittedCommandFactAsync( + AetheriaRuntimeCommittedCommandFactDocument.FromAppliedCommand( + frame, + command, + options.VerseId)).ConfigureAwait(false); + } + + var policyRejectedIds = new HashSet( + policyRejectedCommandIds ?? Array.Empty(), + StringComparer.Ordinal); + foreach (var commandId in frame.RejectedCommandIds ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(commandId) || + !policyRejectedIds.Contains(commandId) || + !byCommandId.TryGetValue(commandId, out var command)) + continue; + + await node.PutCommittedCommandFactAsync( + AetheriaRuntimeCommittedCommandFactDocument.FromRejectedCommand( + frame, + command, + options.VerseId)).ConfigureAwait(false); + } +} + +static async Task ImportRemoteCommittedFactsAsync( + AetheriaStateNode node, + AetheriaDaemonHostOptions options, + AetheriaRuntimeDaemonFrameDocument frame, + AetheriaRuntimeDaemonTickResult currentResult, + AetheriaRuntimeVerseAuthorityPolicyDocument? authorityPolicy, + IReadOnlyList authorityLeases) +{ + var remoteFacts = new List(); + foreach (var endpoint in options.PeerCultMeshEndpoints) + { + try + { + var endpointFacts = await AetheriaVerseReplica.FetchScopedDocumentsAsync( + endpoint, + schemaIds: + [ + AetheriaRuntimeDaemonSchemas.CommittedCommandFact + ], + connectTimeout: options.PeerSyncTimeout, + responseTimeout: options.PeerSyncTimeout).ConfigureAwait(false); + remoteFacts.AddRange(endpointFacts); + } + catch (Exception ex) when ( + ex is IOException || + ex is SocketException || + ex is TimeoutException || + ex is InvalidOperationException) + { + Console.WriteLine($"Aetheria peer fact sync skipped for {endpoint}: {ex.GetType().Name}: {ex.Message}"); + } + } + + var facts = remoteFacts + .Where(fact => fact != null) + .Where(fact => string.Equals(fact.VerseId, options.VerseId, StringComparison.Ordinal)) + .Where(fact => !string.Equals(fact.SourceDaemonId, options.DaemonId, StringComparison.Ordinal)) + .GroupBy(fact => fact.FactId ?? "", StringComparer.Ordinal) + .Where(group => !string.IsNullOrWhiteSpace(group.Key)) + .Select(group => group.First()) + .ToArray(); + if (facts.Length == 0) + return currentResult; + + var importedFactIds = (frame.CumulativeImportedFactIds ?? Array.Empty()) + .Concat(frame.CumulativeRejectedImportedFactIds ?? Array.Empty()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var import = AetheriaRuntimeCommittedFactImporter.ImportIntoFrame( + node.StatePath, + frame, + facts, + authorityPolicy, + authorityLeases, + options.DaemonId, + options.DaemonId, + frame.SessionId, + options.VerseId, + importedFactIds); + + var importedFrame = import.Frame; + importedFrame.ImportedFactIds = import.AcceptedFactIds; + importedFrame.RejectedImportedFactIds = import.RejectedFactIds; + importedFrame.DuplicateImportedFactIds = import.DuplicateFactIds; + importedFrame.CumulativeImportedFactIds = (frame.CumulativeImportedFactIds ?? Array.Empty()) + .Concat(import.AcceptedFactIds) + .Distinct(StringComparer.Ordinal) + .ToArray(); + importedFrame.CumulativeRejectedImportedFactIds = (frame.CumulativeRejectedImportedFactIds ?? Array.Empty()) + .Concat(import.RejectedFactIds) + .Distinct(StringComparer.Ordinal) + .ToArray(); + importedFrame.CumulativeAppliedCommandIds = (frame.CumulativeAppliedCommandIds ?? Array.Empty()) + .Concat(importedFrame.CumulativeAppliedCommandIds ?? Array.Empty()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + importedFrame.CumulativeRejectedCommandIds = (frame.CumulativeRejectedCommandIds ?? Array.Empty()) + .Concat(importedFrame.CumulativeRejectedCommandIds ?? Array.Empty()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (import.AcceptedFactIds.Count > 0 || import.RejectedFactIds.Count > 0) + { + Console.WriteLine( + $"Aetheria imported peer facts: accepted={import.AcceptedFactIds.Count}, rejected={import.RejectedFactIds.Count}, duplicates={import.DuplicateFactIds.Count}."); + } + + return import.Tick; +} + +static RudpCultNetSchemaServer StartRtsCultMeshHost( + AetheriaStateNode node, + AetheriaDaemonHostOptions options, + Func latestFrame) +{ + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.Bind(new IPEndPoint(ParseBindAddress(options.RtsCultMeshHost), options.RtsCultMeshPort)); + var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = $"{options.DaemonId}.rts", + Socket = socket, + ConnectionId = 0x43554c54, + TransportId = "aetheria-rts-rudp", + MaxFragmentBytes = 1200, + MaxPendingReliablePackets = 512 + }); + server.OnCultNet(async (request, peer) => + { + try + { + var response = node.Database.Documents.CreateRawSnapshotResponse( + node.Cache, + string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId, + request); + var frame = latestFrame(); + var hasFrame = frame != null; + if (hasFrame && frame != null && SnapshotWants( + request, + AetheriaRuntimeDaemonSchemas.Frame, + "daemon:aetheria.frame.latest.v1")) + { + var framePut = node.Database.Documents.CreateRawDocumentPutMessage( + response.MessageId, + new CultRecordHandle( + new CultRecordKey("daemon:aetheria.frame.latest.v1")), + frame, + new CultNetDocumentMessageOptions + { + SourceRuntimeId = options.DaemonId, + SourceRole = "aetheria-daemon" + }); + response.Documents = response.Documents + .Where(document => !string.Equals(document.SchemaId, framePut.Document.SchemaId, StringComparison.Ordinal) || + !string.Equals(document.RecordKey, framePut.Document.RecordKey, StringComparison.Ordinal)) + .Concat(new[] { framePut.Document }) + .ToArray(); + } + + var health = await node.GetDaemonHealthAsync().ConfigureAwait(false); + if (health == null && + AetheriaRuntimeDaemonPublicationStore.TryReadHealth(node.StatePath, out var publishedHealth)) + { + health = publishedHealth; + } + + if (health != null && SnapshotWants( + request, + AetheriaRuntimeDaemonSchemas.Health, + AetheriaRuntimeVerseRecordKeys.DaemonHealth.ToString())) + { + var healthPut = node.Database.Documents.CreateRawDocumentPutMessage( + response.MessageId, + new CultRecordHandle( + AetheriaRuntimeVerseRecordKeys.DaemonHealth), + health, + new CultNetDocumentMessageOptions + { + SourceRuntimeId = options.DaemonId, + SourceRole = "aetheria-daemon" + }); + response.Documents = response.Documents + .Where(document => !string.Equals(document.SchemaId, healthPut.Document.SchemaId, StringComparison.Ordinal) || + !string.Equals(document.RecordKey, healthPut.Document.RecordKey, StringComparison.Ordinal)) + .Concat(new[] { healthPut.Document }) + .ToArray(); + } + + var authorityPolicy = await node.GetVerseAuthorityPolicyAsync().ConfigureAwait(false); + if (authorityPolicy != null && SnapshotWants( + request, + AetheriaRuntimeDaemonSchemas.VerseAuthorityPolicy, + AetheriaRuntimeVerseAuthorityPolicyDocument.DocumentKey)) + { + var authorityPut = node.Database.Documents.CreateRawDocumentPutMessage( + response.MessageId, + new CultRecordHandle( + new CultRecordKey(AetheriaRuntimeVerseAuthorityPolicyDocument.DocumentKey)), + authorityPolicy, + new CultNetDocumentMessageOptions + { + SourceRuntimeId = options.DaemonId, + SourceRole = "aetheria-daemon" + }); + response.Documents = response.Documents + .Where(document => !string.Equals(document.SchemaId, authorityPut.Document.SchemaId, StringComparison.Ordinal) || + !string.Equals(document.RecordKey, authorityPut.Document.RecordKey, StringComparison.Ordinal)) + .Concat(new[] { authorityPut.Document }) + .ToArray(); + } + + var starbridgeScenario = await node.GetStarbridgeScenarioAsync().ConfigureAwait(false); + if (starbridgeScenario != null && SnapshotWants( + request, + AetheriaRuntimeDaemonSchemas.StarbridgeScenario, + AetheriaRuntimeVerseRecordKeys.StarbridgeScenarioLatest.ToString())) + { + var scenarioPut = node.Database.Documents.CreateRawDocumentPutMessage( + response.MessageId, + new CultRecordHandle( + AetheriaRuntimeVerseRecordKeys.StarbridgeScenarioLatest), + starbridgeScenario, + new CultNetDocumentMessageOptions + { + SourceRuntimeId = options.DaemonId, + SourceRole = "aetheria-daemon" + }); + response.Documents = response.Documents + .Where(document => !string.Equals(document.SchemaId, scenarioPut.Document.SchemaId, StringComparison.Ordinal) || + !string.Equals(document.RecordKey, scenarioPut.Document.RecordKey, StringComparison.Ordinal)) + .Concat(new[] { scenarioPut.Document }) + .ToArray(); + } + + InjectEveSurfaceSnapshot( + node, + options, + request, + response, + AetheriaRuntimeVerseRecordKeys.DaemonGameSurface.ToString(), + "game"); + InjectEveSurfaceSnapshot( + node, + options, + request, + response, + AetheriaRuntimeVerseRecordKeys.DaemonGameTuiSurface.ToString(), + "game-tui"); + InjectEveSurfaceSnapshot( + node, + options, + request, + response, + AetheriaRuntimeVerseRecordKeys.DaemonEditorSurface.ToString(), + "editor"); + InjectEveSurfaceSnapshot( + node, + options, + request, + response, + AetheriaRuntimeVerseRecordKeys.DaemonEditorTuiSurface.ToString(), + "editor-tui"); + + if (AetheriaRuntimeDaemonPublicationStore.TryReadStarbridgeSessionSummary( + node.StatePath, + out var starbridgeSession) && + SnapshotWants( + request, + AetheriaRuntimeDaemonSchemas.StarbridgeSessionSummary, + "daemon:aetheria.starbridge.session.latest.v1")) + { + var starbridgePut = node.Database.Documents.CreateRawDocumentPutMessage( + response.MessageId, + new CultRecordHandle( + new CultRecordKey("daemon:aetheria.starbridge.session.latest.v1")), + starbridgeSession, + new CultNetDocumentMessageOptions + { + SourceRuntimeId = options.DaemonId, + SourceRole = "aetheria-daemon" + }); + response.Documents = response.Documents + .Where(document => !string.Equals(document.SchemaId, starbridgePut.Document.SchemaId, StringComparison.Ordinal) || + !string.Equals(document.RecordKey, starbridgePut.Document.RecordKey, StringComparison.Ordinal)) + .Concat(new[] { starbridgePut.Document }) + .ToArray(); + } + + var factPuts = node.ReadCommittedCommandFacts() + .Select(fact => new + { + Fact = fact, + RecordKey = AetheriaRuntimeCommittedCommandFactDocument.CreateRecordKey(fact.FactId) + }) + .Where(item => SnapshotWants( + request, + AetheriaRuntimeDaemonSchemas.CommittedCommandFact, + item.RecordKey)) + .Select(item => node.Database.Documents.CreateRawDocumentPutMessage( + response.MessageId, + new CultRecordHandle( + new CultRecordKey(item.RecordKey)), + item.Fact, + new CultNetDocumentMessageOptions + { + SourceRuntimeId = options.DaemonId, + SourceRole = "aetheria-daemon" + }).Document) + .ToArray(); + if (factPuts.Length > 0) + { + var factKeys = factPuts + .Select(document => document.RecordKey) + .ToHashSet(StringComparer.Ordinal); + response.Documents = response.Documents + .Where(document => !string.Equals(document.SchemaId, AetheriaRuntimeDaemonSchemas.CommittedCommandFact, StringComparison.Ordinal) || + !factKeys.Contains(document.RecordKey)) + .Concat(factPuts) + .ToArray(); + } + + if (hasFrame && frame != null && TryGetRtsViewportRequest(request, out var viewportRecordKey, out var viewport)) + { + var viewportDocument = AetheriaRuntimeRtsProjection.ProjectViewport(frame, viewport); + var viewportPut = node.Database.Documents.CreateRawDocumentPutMessage( + response.MessageId, + new CultRecordHandle( + new CultRecordKey(viewportRecordKey)), + viewportDocument, + new CultNetDocumentMessageOptions + { + SourceRuntimeId = options.DaemonId, + SourceRole = "aetheria-daemon" + }); + response.Documents = response.Documents + .Where(document => !string.Equals(document.SchemaId, viewportPut.Document.SchemaId, StringComparison.Ordinal) || + !string.Equals(document.RecordKey, viewportPut.Document.RecordKey, StringComparison.Ordinal)) + .Concat(new[] { viewportPut.Document }) + .ToArray(); + } + + peer.SendCultNet(response); + } + catch (Exception ex) + { + peer.SendCultNet(new CultNetErrorMessage + { + Error = $"Aetheria snapshot response failed: {ex.GetType().Name}: {ex.Message}" + }); + } + }); + server.OnCultNet(async (message, _) => + { + if (string.Equals(message.Document.SchemaId, AetheriaRuntimeDaemonSchemas.Command, StringComparison.Ordinal)) + { + var command = MessagePackSerializer.Deserialize( + message.Document.Payload); + await node.SubmitDaemonCommandAsync(command).ConfigureAwait(false); + return; + } + + await node.Database.ApplyPutAsync(message).ConfigureAwait(false); + }); + return server; +} + +static void InjectEveSurfaceSnapshot( + AetheriaStateNode node, + AetheriaDaemonHostOptions options, + CultNetSnapshotRequestMessage request, + CultNetSnapshotResponseRawMessage response, + string recordKey, + string surfaceKind) +{ + const string EveSurfaceSchema = "gamecult.eve.surface.v1"; + if (!SnapshotWants(request, EveSurfaceSchema, recordKey)) + return; + + var surfaceDocument = ReadEveSurfacePublication(node.StatePath, surfaceKind); + var surfacePut = node.Database.Documents.CreateRawDocumentPutMessage( + response.MessageId, + new CultRecordHandle(new CultRecordKey(recordKey)), + AetheriaRuntimeEveSurfaceStateProjector.ToState(surfaceDocument), + new CultNetDocumentMessageOptions + { + SourceRuntimeId = options.DaemonId, + SourceRole = "aetheria-daemon" + }); + response.Documents = response.Documents + .Where(document => !string.Equals(document.SchemaId, surfacePut.Document.SchemaId, StringComparison.Ordinal) || + !string.Equals(document.RecordKey, surfacePut.Document.RecordKey, StringComparison.Ordinal)) + .Concat(new[] { surfacePut.Document }) + .ToArray(); +} + +static AetheriaRuntimeSurfaceDocument ReadEveSurfacePublication(string statePath, string surfaceKind) +{ + AetheriaRuntimeSurfaceDocument document; + _ = surfaceKind switch + { + "game" => AetheriaRuntimeDaemonPublicationStore.TryReadGameSurface(statePath, out document), + "game-tui" => AetheriaRuntimeDaemonPublicationStore.TryReadGameTuiSurface(statePath, out document), + "editor" => AetheriaRuntimeDaemonPublicationStore.TryReadEditorSurface(statePath, out document), + "editor-tui" => AetheriaRuntimeDaemonPublicationStore.TryReadEditorTuiSurface(statePath, out document), + _ => throw new ArgumentOutOfRangeException(nameof(surfaceKind), surfaceKind, "Unknown Eve surface publication.") + }; + return document; +} + +static async Task RunRtsCultMeshPumpAsync( + RudpCultNetSchemaServer server, + CancellationToken cancellationToken) +{ + try + { + while (!cancellationToken.IsCancellationRequested) + { + var delivered = false; + for (var i = 0; i < 64; i++) + { + delivered |= await server.PollOnceAsync().ConfigureAwait(false); + } + + server.PollResends(); + if (!delivered) + await Task.Delay(1, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } +} + +static IPAddress ParseBindAddress(string host) +{ + if (string.IsNullOrWhiteSpace(host) || + string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return IPAddress.Loopback; + } + + if (string.Equals(host, "*", StringComparison.Ordinal) || + string.Equals(host, "0.0.0.0", StringComparison.Ordinal) || + string.Equals(host, "any", StringComparison.OrdinalIgnoreCase)) + { + return IPAddress.Any; + } + + return IPAddress.Parse(host); +} + +static bool SnapshotWants( + CultNetSnapshotRequestMessage request, + string schemaId, + string recordKey) +{ + var schemaIds = request.SchemaIds ?? Array.Empty(); + var recordKeys = request.RecordKeys ?? Array.Empty(); + var schemaMatches = schemaIds.Length == 0 || schemaIds.Contains(schemaId, StringComparer.Ordinal); + var recordMatches = recordKeys.Length == 0 || recordKeys.Contains(recordKey, StringComparer.Ordinal); + return schemaMatches && recordMatches; +} + +static bool TryGetRtsViewportRequest( + CultNetSnapshotRequestMessage request, + out string recordKey, + out AetheriaRuntimeRtsViewportBounds viewport) +{ + const string prefix = "daemon:aetheria.rts.viewport.v1;"; + recordKey = ""; + viewport = new AetheriaRuntimeRtsViewportBounds(); + + var schemaIds = request.SchemaIds ?? Array.Empty(); + if (schemaIds.Length > 0 && !schemaIds.Contains(AetheriaRuntimeDaemonSchemas.RtsViewport, StringComparer.Ordinal)) + return false; + + foreach (var candidate in request.RecordKeys ?? Array.Empty()) + { + if (!candidate.StartsWith(prefix, StringComparison.Ordinal)) + continue; + + var values = candidate + .Substring(prefix.Length) + .Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Split(new[] { '=' }, 2)) + .Where(parts => parts.Length == 2) + .ToDictionary(parts => parts[0], parts => parts[1], StringComparer.Ordinal); + if (!TryGetDouble(values, "minX", out var minX) || + !TryGetDouble(values, "minY", out var minY) || + !TryGetDouble(values, "maxX", out var maxX) || + !TryGetDouble(values, "maxY", out var maxY)) + { + continue; + } + + recordKey = candidate; + viewport = new AetheriaRuntimeRtsViewportBounds + { + MinX = Math.Min(minX, maxX), + MinY = Math.Min(minY, maxY), + MaxX = Math.Max(minX, maxX), + MaxY = Math.Max(minY, maxY) + }; + return true; + } + + return false; +} + +static bool TryGetDouble( + IReadOnlyDictionary values, + string key, + out double value) +{ + value = 0; + return values.TryGetValue(key, out var text) && + double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); +} + static async Task PublishDaemonApiDocumentsAsync( AetheriaStateNode node, AetheriaRuntimeDaemonTickResult result) @@ -242,6 +890,830 @@ static async Task EnsureWorldDocumentAsync(AetheriaStateNode node) }).ConfigureAwait(false); } +static async Task EnsureTradeValuePolicyAsync(AetheriaStateNode node, string now) +{ + var existing = await node.GetTradeValuePolicyAsync().ConfigureAwait(false); + if (existing != null) + return; + + await node.PutTradeValuePolicyAsync( + AetheriaRuntimeStateMapper.ToTradeValuePolicy( + AetheriaRuntimeTradeValueSettings.Default, + now)).ConfigureAwait(false); +} + +static async Task EnsurePlayableRunDocumentsAsync(AetheriaStateNode node, string now) +{ + var settings = await node.GetPlayerSettingsAsync().ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(settings?.ActiveRunKey)) + { + var existingRun = await ReadRuntimeRunCheckpointAsync(node).ConfigureAwait(false); + if (HasPlayableRun(existingRun) && HasRtsScenario(existingRun)) + return; + } + + const string runId = "local-rts"; + var runKey = new CultRecordKey("global:aetheria.run_state.local-rts.v1"); + var zoneKey = new CultRecordKey("global:aetheria.zone_state.local-rts.0.v1"); + var entityKeys = Enumerable.Range(0, 8) + .Select(index => EntityKey(runId, 0, index)) + .ToArray(); + + settings ??= new AetheriaPlayerSettings(); + settings.ActiveRunKey = runKey.ToString(); + settings.PlayerName = string.IsNullOrWhiteSpace(settings.PlayerName) ? "Codex RTS" : settings.PlayerName; + settings.TutorialPassed = true; + settings.LastUpdatedAtUtc = now; + await node.PutPlayerSettingsAsync(settings).ConfigureAwait(false); + + await node.PutRunStateAsync(runKey, new AetheriaRunState + { + RunId = runId, + EntranceZoneIndex = 0, + ExitZoneIndex = 0, + CurrentZoneIndex = 0, + DiscoveredZoneIndices = [0], + ZoneKeys = [zoneKey.ToString()], + GenerationSeed = 0xA37E_12u, + CurrentEntityKey = entityKeys[0], + UpdatedAtUtc = now + }).ConfigureAwait(false); + + await node.PutZoneStateAsync(zoneKey, new AetheriaZoneState + { + Name = "Daemon Local Testbed", + Position = Vec2(0, 0), + EntityKeys = entityKeys, + FactionIndices = [0, 1], + OwnerFactionIndex = 0, + Orbits = + [ + new AetheriaOrbitSnapshot + { + OrbitKey = "local.sun.orbit", + FixedPosition = Vec2(0, 0) + }, + new AetheriaOrbitSnapshot + { + OrbitKey = "local.belt.orbit", + ParentOrbitKey = "local.sun.orbit", + FixedPosition = Vec2(360, -180), + Distance = 402, + Phase = 0.33 + } + ], + Bodies = + [ + new AetheriaBodySnapshot + { + BodyKey = "local.sun", + Kind = "sun", + Name = "Local Sun", + OrbitKey = "local.sun.orbit", + Mass = 1000, + BodyRadiusMultiplier = 2.2, + GravityInfluenceCenterX = 0, + GravityInfluenceCenterZ = 0, + GravityInfluenceRadius = 900, + GravityWellDepth = -80, + GravityDepthExponent = 3, + GravityWaveRadius = 450, + GravityWaveDepth = 10, + GravityWaveSpeed = 2, + SunVisual = new AetheriaSunVisualState + { + LightColor = Vec3(1, 0.86, 0.58), + FogTintColor = Vec3(0.45, 0.28, 0.16), + LightRadiusMultiplier = 1.8 + } + }, + new AetheriaBodySnapshot + { + BodyKey = "local.belt", + Kind = "asteroid_belt", + Name = "Cinder Belt", + OrbitKey = "local.belt.orbit", + Mass = 120, + BodyRadiusMultiplier = 1.0, + GravityInfluenceCenterX = 360, + GravityInfluenceCenterZ = -180, + GravityInfluenceRadius = 260, + GravityWellDepth = -22, + GravityDepthExponent = 2.2, + GravityWaveRadius = 120, + GravityWaveDepth = 4, + GravityWaveSpeed = 0.8, + Asteroids = + [ + new AetheriaAsteroidSnapshot { Distance = 42, Phase = 0.10, Size = 1.1, RotationSpeed = 0.2 }, + new AetheriaAsteroidSnapshot { Distance = 78, Phase = 1.70, Size = 0.8, RotationSpeed = -0.1 }, + new AetheriaAsteroidSnapshot { Distance = 118, Phase = 3.35, Size = 1.6, RotationSpeed = 0.08 } + ] + } + ], + GravityTerrainRadius = 1200, + GravityTerrainDepth = -8, + GravityTerrainDepthExponent = 1.2, + GravityTerrainWaveFrequency = 0.6 + }).ConfigureAwait(false); + + await node.PutEntitySnapshotAsync(new CultRecordKey(entityKeys[0]), SeedEntity( + "Anchor Station", + "station", + Vec3(-220, 0, -90), + Vec2(1, 0), + Vec2(0, 0), + "player", + "anchor-station-hull", + 720, + entityKeys[4], + [ + Contact(entityKeys[1], 1, false), + Contact(entityKeys[2], 1, false), + Contact(entityKeys[3], 1, false), + Contact(entityKeys[4], 1, true), + Contact(entityKeys[5], 0.8, true), + Contact(entityKeys[7], 0.6, false) + ], + ["repair-parts", "reactor-fuel", "drone-core"])).ConfigureAwait(false); + await node.PutEntitySnapshotAsync(new CultRecordKey(entityKeys[1]), SeedEntity( + "Vanguard One", + "ship", + Vec3(-60, 0, -40), + Vec2(1, 0.2), + Vec2(12, 4), + "player", + "ship-hull", + 520, + entityKeys[4], + [ + Contact(entityKeys[0], 1, false), + Contact(entityKeys[2], 1, false), + Contact(entityKeys[4], 1, true), + Contact(entityKeys[5], 0.85, true) + ], + ["coilgun-ammo", "field-rations"])).ConfigureAwait(false); + await node.PutEntitySnapshotAsync(new CultRecordKey(entityKeys[2]), SeedEntity( + "Wing Two", + "ship", + Vec3(180, 0, 120), + Vec2(-0.4, 0.9), + Vec2(-6, 8), + "player", + "frigate-hull", + 420, + entityKeys[4], + [ + Contact(entityKeys[0], 1, false), + Contact(entityKeys[1], 1, false), + Contact(entityKeys[4], 0.85, true), + Contact(entityKeys[6], 0.6, true) + ], + ["sensor-buoy", "shield-cell"])).ConfigureAwait(false); + await node.PutEntitySnapshotAsync(new CultRecordKey(entityKeys[3]), SeedEntity( + "Torch Three", + "ship", + Vec3(-160, 0, 210), + Vec2(0.6, -0.8), + Vec2(8, -5), + "player", + "interceptor-hull", + 480, + entityKeys[5], + [ + Contact(entityKeys[0], 1, false), + Contact(entityKeys[1], 1, false), + Contact(entityKeys[5], 0.9, true) + ], + ["micro-missile", "coolant-pack"])).ConfigureAwait(false); + await node.PutEntitySnapshotAsync(new CultRecordKey(entityKeys[4]), SeedEntity( + "Ash Raider", + "ship", + Vec3(420, 0, 180), + Vec2(-0.8, -0.1), + Vec2(-4, -2), + "raider", + "raider-hull", + 300, + entityKeys[1], + [ + Contact(entityKeys[0], 1, true), + Contact(entityKeys[1], 1, true), + Contact(entityKeys[2], 0.5, true) + ], + ["scrap-metal", "stolen-capacitor"])).ConfigureAwait(false); + await node.PutEntitySnapshotAsync(new CultRecordKey(entityKeys[5]), SeedEntity( + "Cinder Knife", + "ship", + Vec3(560, 0, -110), + Vec2(-0.9, 0.2), + Vec2(-7, 1), + "raider", + "raider-interceptor-hull", + 260, + entityKeys[3], + [ + Contact(entityKeys[0], 0.8, true), + Contact(entityKeys[3], 1, true) + ], + ["volatile-fuel"])).ConfigureAwait(false); + await node.PutEntitySnapshotAsync(new CultRecordKey(entityKeys[6]), SeedEntity( + "Blackwake", + "ship", + Vec3(680, 0, 260), + Vec2(-0.7, -0.4), + Vec2(-4, -3), + "raider", + "raider-heavy-hull", + 220, + entityKeys[2], + [ + Contact(entityKeys[2], 0.8, true) + ], + ["ore-cache", "burned-relay-core"])).ConfigureAwait(false); + await node.PutEntitySnapshotAsync(new CultRecordKey(entityKeys[7]), SeedEntity( + "Derelict Relay", + "station", + Vec3(-260, 0, 260), + Vec2(0, 1), + Vec2(0, 0), + "neutral", + "station-hull", + 140, + "", + [], + ["ancient-transponder"])).ConfigureAwait(false); + + await node.FlushAsync().ConfigureAwait(false); +} + +static async Task EnsureStarbridgeSessionDocumentsAsync( + AetheriaStateNode node, + AetheriaDaemonHostOptions options) +{ + var existingScenario = await node.GetStarbridgeScenarioAsync().ConfigureAwait(false); + var existingSession = await node.GetStarbridgeSessionAsync().ConfigureAwait(false); + if (existingScenario != null && + string.Equals(existingScenario.Schema, AetheriaRuntimeDaemonSchemas.StarbridgeScenario, StringComparison.Ordinal) && + existingSession != null && + string.Equals(existingSession.Schema, AetheriaRuntimeDaemonSchemas.StarbridgeSession, StringComparison.Ordinal)) + { + return; + } + + const string runId = "local-rts"; + var baseEntityKey = EntityKey(runId, 0, 0); + var pilotOneKey = EntityKey(runId, 0, 1); + var pilotTwoKey = EntityKey(runId, 0, 2); + var pilotThreeKey = EntityKey(runId, 0, 3); + var scenario = new AetheriaRuntimeStarbridgeScenarioDocument + { + ScenarioId = "starbridge.frontier-fabricator", + DisplayName = "Frontier Fabricator Defense", + StartingBaseKey = baseEntityKey, + StationStock = + [ + new AetheriaRuntimeStarbridgeStationStockItem + { + ItemKey = "repair-parts", + Quantity = 4, + Quality = 1, + Durability = 1 + }, + new AetheriaRuntimeStarbridgeStationStockItem + { + ItemKey = "drone-core", + Quantity = 2, + Quality = 0.9, + Durability = 1 + }, + new AetheriaRuntimeStarbridgeStationStockItem + { + ItemKey = "coolant-pack", + Quantity = 3, + Quality = 0.85, + Durability = 1 + }, + new AetheriaRuntimeStarbridgeStationStockItem + { + ItemKey = "shield-cell", + Quantity = 2, + Quality = 0.95, + Durability = 1 + } + ], + AvailableShipKeys = [pilotOneKey, pilotTwoKey, pilotThreeKey], + AttackerMixKeys = ["scout", "skirmisher", "bomber", "siege-craft", "breach-drone"], + RecoveredTechnologyPoolKeys = + [ + "sensor-calibration", + "missile-rack-burst", + "coolant-chain", + "repair-drone-armor" + ], + Waves = + [ + new AetheriaRuntimeStarbridgeWaveDefinition + { + WaveIndex = 0, + DisplayName = "Scout Probe", + AttackerKeys = ["scout"], + StartsAfterSeconds = 30, + BossKey = "scout-captain", + RecoveredTechnologyKeys = ["sensor-calibration"] + }, + new AetheriaRuntimeStarbridgeWaveDefinition + { + WaveIndex = 1, + DisplayName = "Bomber Line", + AttackerKeys = ["bomber", "skirmisher"], + StartsAfterSeconds = 150, + BossKey = "bomber-frame", + RecoveredTechnologyKeys = ["missile-rack-burst", "coolant-chain"] + }, + new AetheriaRuntimeStarbridgeWaveDefinition + { + WaveIndex = 2, + DisplayName = "Breach Mother", + AttackerKeys = ["breach-drone", "siege-craft"], + StartsAfterSeconds = 300, + BossKey = "breach-mother", + RecoveredTechnologyKeys = ["repair-drone-armor"] + } + ], + RuntimeRoles = + [ + new AetheriaRuntimeStarbridgeRuntimeRole + { + RuntimeId = "starfire-rts", + Role = "commander" + }, + new AetheriaRuntimeStarbridgeRuntimeRole + { + RuntimeId = "raven-unity", + Role = "pilot", + EntityKey = pilotOneKey + } + ] + }; + + var session = new AetheriaRuntimeStarbridgeSessionDocument + { + SessionId = options.SessionId, + ScenarioId = scenario.ScenarioId, + RunId = runId, + BaseEntityKey = baseEntityKey, + StationEntityKey = baseEntityKey, + Phase = "pre-wave", + CurrentWaveIndex = 0, + RuntimeRoles = scenario.RuntimeRoles + }; + + await node.PutStarbridgeScenarioAsync(scenario).ConfigureAwait(false); + await node.PutStarbridgeSessionAsync(session).ConfigureAwait(false); + await node.FlushAsync().ConfigureAwait(false); +} + +static async Task EnsureVerseAuthorityPolicyAsync( + AetheriaStateNode node, + AetheriaDaemonHostOptions options) +{ + var existing = await node.GetVerseAuthorityPolicyAsync().ConfigureAwait(false); + if (existing != null && string.Equals(existing.Schema, AetheriaRuntimeVerseAuthoritySchemas.Policy, StringComparison.Ordinal)) + { + AetheriaRuntimeDaemonPublicationStore.PublishVerseAuthorityPolicy(node.StatePath, existing); + return; + } + + var policy = AetheriaRuntimeVerseAuthorityPolicyDocument.TrustedCoop(options.VerseId, options.DaemonId); + await node.PutVerseAuthorityPolicyAsync(policy).ConfigureAwait(false); + AetheriaRuntimeDaemonPublicationStore.PublishVerseAuthorityPolicy(node.StatePath, policy); +} + +static async Task ReadRuntimeRunCheckpointAsync(AetheriaStateNode node) +{ + var settings = await node.GetPlayerSettingsAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(settings?.ActiveRunKey)) + return null; + + var run = await node.GetRunStateAsync(new CultRecordKey(settings.ActiveRunKey)).ConfigureAwait(false); + if (run == null) + return null; + + var zones = new List(); + var zoneKeys = run.ZoneKeys ?? Array.Empty(); + for (var zoneIndex = 0; zoneIndex < zoneKeys.Length; zoneIndex++) + { + var zone = await node.GetZoneStateAsync(new CultRecordKey(zoneKeys[zoneIndex])).ConfigureAwait(false); + if (zone == null) + continue; + + zones.Add(await ToRuntimeZoneAsync(node, zone, zoneIndex).ConfigureAwait(false)); + } + + return new AetheriaRuntimeRunCheckpointCommit + { + RunId = run.RunId ?? "", + IsTutorial = run.IsTutorial, + EntranceZoneIndex = run.EntranceZoneIndex, + ExitZoneIndex = run.ExitZoneIndex, + CurrentZoneIndex = run.CurrentZoneIndex, + DiscoveredZoneIndices = (run.DiscoveredZoneIndices ?? Array.Empty()).ToArray(), + Zones = zones, + FactionRelationships = (run.FactionRelationships ?? Array.Empty()) + .Select(relationship => new AetheriaRuntimeFactionRelationshipCommit + { + Relationship = relationship.Relationship ?? "", + Standing = relationship.Standing, + FactionKey = relationship.FactionKey ?? "" + }) + .ToArray(), + GenerationSeed = run.GenerationSeed, + CurrentEntityKey = run.CurrentEntityKey ?? "", + Credits = 1000 + }; +} + +static async Task ToRuntimeZoneAsync( + AetheriaStateNode node, + AetheriaZoneState zone, + int zoneIndex) +{ + var entityKeys = zone.EntityKeys ?? Array.Empty(); + var entityIndices = entityKeys + .Select((key, index) => new { key, index }) + .Where(pair => !string.IsNullOrWhiteSpace(pair.key)) + .ToDictionary(pair => pair.key, pair => pair.index, StringComparer.Ordinal); + var entities = new List(); + for (var entityIndex = 0; entityIndex < entityKeys.Length; entityIndex++) + { + var entity = await node.GetEntitySnapshotAsync(new CultRecordKey(entityKeys[entityIndex])).ConfigureAwait(false); + if (entity != null) + entities.Add(ToRuntimeEntity(entity, entityIndex, entityIndices)); + } + + return new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = zoneIndex, + Name = zone.Name ?? "", + PositionX = zone.Position?.X ?? 0, + PositionY = zone.Position?.Y ?? 0, + AdjacentZoneIndices = (zone.AdjacentZoneIndices ?? Array.Empty()).ToArray(), + FactionIndices = (zone.FactionIndices ?? Array.Empty()).ToArray(), + OwnerFactionIndex = zone.OwnerFactionIndex, + Entities = entities, + Orbits = (zone.Orbits ?? Array.Empty()).Select(ToRuntimeOrbit).ToArray(), + Bodies = (zone.Bodies ?? Array.Empty()).Select(body => ToRuntimeBody(body, entityIndices)).ToArray(), + DroppedPickups = (zone.DroppedPickups ?? Array.Empty()).Select(ToRuntimePickup).ToArray(), + GravityTerrainRadius = zone.GravityTerrainRadius, + GravityTerrainDepth = zone.GravityTerrainDepth, + GravityTerrainDepthExponent = zone.GravityTerrainDepthExponent, + GravityTerrainBoundaryFog = zone.GravityTerrainBoundaryFog, + GravityTerrainWaveFrequency = zone.GravityTerrainWaveFrequency, + SimulationTimeSeconds = zone.SimulationTimeSeconds + }; +} + +static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity( + AetheriaEntitySnapshot entity, + int entityIndex, + IReadOnlyDictionary entityIndices) +{ + return new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = entityIndex, + Name = entity.Name ?? "", + Kind = entity.Kind ?? "", + PositionX = entity.Position?.X ?? 0, + PositionY = entity.Position?.Y ?? 0, + PositionZ = entity.Position?.Z ?? 0, + DirectionX = entity.Direction?.X ?? 0, + DirectionY = entity.Direction?.Y ?? 1, + FactionKey = entity.FactionKey ?? "", + HullItemKey = entity.HullItemKey ?? "", + Equipment = ToEntitySlotCommits(entity.Equipment), + CargoBays = ToEntitySlotCommits(entity.CargoBays), + DockingBays = ToEntitySlotCommits(entity.DockingBays), + ChildEntityIndices = (entity.ChildEntityKeys ?? Array.Empty()) + .Select(key => entityIndices.TryGetValue(key, out var index) ? index : -1) + .Where(index => index >= 0) + .ToArray(), + WeaponGroups = (entity.WeaponGroups ?? Array.Empty()) + .Select(group => (IReadOnlyList)(group.EquipmentIndices ?? Array.Empty()).ToArray()) + .ToArray(), + StatGrids = (entity.StatGrids ?? Array.Empty()) + .Select(grid => new AetheriaRuntimeEntityStatGridCommit + { + Name = grid.Name ?? "", + Width = grid.Width, + Height = grid.Height, + Values = (grid.Values ?? Array.Empty()).ToArray() + }) + .ToArray(), + VelocityX = entity.Velocity?.X ?? 0, + VelocityY = entity.Velocity?.Y ?? 0, + TargetEntityIndex = entityIndices.TryGetValue(entity.TargetEntityKey ?? "", out var targetIndex) ? targetIndex : -1, + IsActive = entity.IsActive, + HeatsinksEnabled = entity.HeatsinksEnabled, + OverrideShutdown = entity.OverrideShutdown, + TractorPower = entity.TractorPower, + Heatstroke = entity.Heatstroke, + Hypothermia = entity.Hypothermia, + ActiveConsumables = (entity.ActiveConsumables ?? Array.Empty()) + .Select(consumable => new AetheriaRuntimeActiveConsumableCommit + { + ItemKey = consumable.ItemKey ?? "", + Quality = consumable.Quality, + RemainingDuration = consumable.RemainingDuration, + Duration = consumable.Duration + }) + .ToArray(), + BehaviorProgress = (entity.BehaviorProgress ?? Array.Empty()) + .Select(progress => new AetheriaRuntimeBehaviorProgressCommit + { + OwnerKind = progress.OwnerKind ?? "", + OwnerIndex = progress.OwnerIndex, + BehaviorIndex = progress.BehaviorIndex, + BehaviorKind = progress.BehaviorKind ?? "", + Progress = progress.Progress + }) + .ToArray(), + WeaponStates = Array.Empty(), + BehaviorStates = Array.Empty(), + CargoContents = ToCargoBayCommits(entity.CargoContents), + DockingBayContents = ToCargoBayCommits(entity.DockingBayContents), + DockingBayAssignments = (entity.DockingBayAssignments ?? Array.Empty()).ToArray(), + Visibility = entity.Visibility, + VisibilitySourceCount = entity.VisibilitySourceCount, + Contacts = (entity.Contacts ?? Array.Empty()) + .Select(contact => ToRuntimeContact(contact, entityIndices)) + .Where(contact => contact.TargetEntityIndex >= 0) + .ToArray() + }; +} + +static AetheriaRuntimeEntityContactCommit ToRuntimeContact( + AetheriaEntityContactSnapshot contact, + IReadOnlyDictionary entityIndices) +{ + return new AetheriaRuntimeEntityContactCommit + { + TargetEntityIndex = entityIndices.TryGetValue(contact.TargetEntityKey ?? "", out var index) ? index : -1, + InfoGathered = contact.InfoGathered, + Hostile = contact.Hostile, + Visible = contact.Visible + }; +} + +static AetheriaRuntimeOrbitSnapshotCommit ToRuntimeOrbit(AetheriaOrbitSnapshot orbit) +{ + return new AetheriaRuntimeOrbitSnapshotCommit + { + OrbitKey = orbit.OrbitKey ?? "", + ParentOrbitKey = orbit.ParentOrbitKey ?? "", + Distance = orbit.Distance, + Phase = orbit.Phase, + FixedPositionX = orbit.FixedPosition?.X ?? 0, + FixedPositionY = orbit.FixedPosition?.Y ?? 0 + }; +} + +static AetheriaRuntimeBodySnapshotCommit ToRuntimeBody( + AetheriaBodySnapshot body, + IReadOnlyDictionary entityIndices) +{ + return new AetheriaRuntimeBodySnapshotCommit + { + BodyKey = body.BodyKey ?? "", + Kind = body.Kind ?? "", + Name = body.Name ?? "", + OrbitKey = body.OrbitKey ?? "", + Mass = body.Mass, + Resources = (body.Resources ?? Array.Empty()) + .Select(resource => new AetheriaRuntimeBodyResourceCommit + { + ItemKey = resource.ItemKey ?? "", + Amount = resource.Amount + }) + .ToArray(), + BodyRadiusMultiplier = body.BodyRadiusMultiplier, + GravityRadiusMultiplier = body.GravityRadiusMultiplier, + GravityDepthMultiplier = body.GravityDepthMultiplier, + GravityDepthExponent = body.GravityDepthExponent, + Asteroids = (body.Asteroids ?? Array.Empty()) + .Select(asteroid => new AetheriaRuntimeAsteroidCommit + { + Distance = asteroid.Distance, + Phase = asteroid.Phase, + Size = asteroid.Size, + RotationSpeed = asteroid.RotationSpeed, + Damage = asteroid.Damage, + RespawnTimer = asteroid.RespawnTimer, + MiningAccumulators = (asteroid.MiningAccumulators ?? Array.Empty()) + .Select(accumulator => new AetheriaRuntimeAsteroidMiningAccumulatorCommit + { + MinerEntityIndex = entityIndices.TryGetValue(accumulator.MinerEntityKey ?? "", out var index) ? index : -1, + Amount = accumulator.Amount + }) + .ToArray() + }) + .ToArray(), + GasGiantVisual = new AetheriaRuntimeGasGiantVisualCommit + { + FirstOffsetDomainRotationSpeed = body.GasGiantVisual?.FirstOffsetDomainRotationSpeed ?? 1, + FirstOffsetRotationSpeed = body.GasGiantVisual?.FirstOffsetRotationSpeed ?? 1, + SecondOffsetDomainRotationSpeed = body.GasGiantVisual?.SecondOffsetDomainRotationSpeed ?? 1, + SecondOffsetRotationSpeed = body.GasGiantVisual?.SecondOffsetRotationSpeed ?? 1, + AlbedoRotationSpeed = body.GasGiantVisual?.AlbedoRotationSpeed ?? 1, + WaveRadiusMultiplier = body.GasGiantVisual?.WaveRadiusMultiplier ?? 1, + WaveDepthMultiplier = body.GasGiantVisual?.WaveDepthMultiplier ?? 1, + WaveDepthExponent = body.GasGiantVisual?.WaveDepthExponent ?? 8, + WaveSpeedMultiplier = body.GasGiantVisual?.WaveSpeedMultiplier ?? 8, + MaterialOverrides = (body.GasGiantVisual?.MaterialOverrides ?? Array.Empty()).ToArray(), + Colors = (body.GasGiantVisual?.Colors ?? Array.Empty()) + .Select(color => new AetheriaRuntimeColorCommit { X = color.X, Y = color.Y, Z = color.Z, W = color.W }) + .ToArray() + }, + SunVisual = new AetheriaRuntimeSunVisualCommit + { + LightColorX = body.SunVisual?.LightColor?.X ?? 0, + LightColorY = body.SunVisual?.LightColor?.Y ?? 0, + LightColorZ = body.SunVisual?.LightColor?.Z ?? 0, + FogTintColorX = body.SunVisual?.FogTintColor?.X ?? 0, + FogTintColorY = body.SunVisual?.FogTintColor?.Y ?? 0, + FogTintColorZ = body.SunVisual?.FogTintColor?.Z ?? 0, + LightRadiusMultiplier = body.SunVisual?.LightRadiusMultiplier ?? 1 + }, + GravityInfluenceCenterX = body.GravityInfluenceCenterX, + GravityInfluenceCenterZ = body.GravityInfluenceCenterZ, + GravityInfluenceRadius = body.GravityInfluenceRadius, + GravityWellDepth = body.GravityWellDepth, + GravityWaveRadius = body.GravityWaveRadius, + GravityWaveDepth = body.GravityWaveDepth, + GravityWaveSpeed = body.GravityWaveSpeed + }; +} + +static AetheriaRuntimeDroppedPickupCommit ToRuntimePickup(AetheriaDroppedPickupSnapshot pickup) +{ + return new AetheriaRuntimeDroppedPickupCommit + { + PickupIndex = pickup.PickupIndex, + PositionX = pickup.Position?.X ?? 0, + PositionY = pickup.Position?.Y ?? 0, + PositionZ = pickup.Position?.Z ?? 0, + VelocityX = pickup.Velocity?.X ?? 0, + VelocityY = pickup.Velocity?.Y ?? 0, + VelocityZ = pickup.Velocity?.Z ?? 0, + Item = ToLoadoutItemCommit(pickup.Item) + }; +} + +static bool HasPlayableRun(AetheriaRuntimeRunCheckpointCommit? run) +{ + return (run?.Zones ?? Array.Empty()) + .Any(zone => (zone.Entities ?? Array.Empty()).Count > 0); +} + +static bool HasRtsScenario(AetheriaRuntimeRunCheckpointCommit? run) +{ + var entities = (run?.Zones ?? Array.Empty()) + .SelectMany(zone => zone.Entities ?? Array.Empty()) + .ToArray(); + return entities.Any(entity => string.Equals(entity.Name, "Anchor Station", StringComparison.Ordinal)) && + entities.Count(entity => string.Equals(entity.FactionKey, "player", StringComparison.OrdinalIgnoreCase)) >= 4 && + entities.Count(entity => string.Equals(entity.FactionKey, "raider", StringComparison.OrdinalIgnoreCase)) >= 3; +} + +static AetheriaEntitySnapshot SeedEntity( + string name, + string kind, + AetheriaVector3 position, + AetheriaVector2 direction, + AetheriaVector2 velocity, + string factionKey, + string hullItemKey, + double visibility, + string targetEntityKey, + AetheriaEntityContactSnapshot[] contacts, + string[] cargoItems) +{ + return new AetheriaEntitySnapshot + { + Name = name, + Kind = kind, + Position = position, + Direction = direction, + Velocity = velocity, + FactionKey = factionKey, + HullItemKey = hullItemKey, + IsActive = true, + HeatsinksEnabled = true, + TractorPower = 1, + Visibility = visibility, + VisibilitySourceCount = 1, + TargetEntityKey = targetEntityKey, + Contacts = contacts, + StatGrids = + [ + StatGrid("hull", string.Equals(kind, "station", StringComparison.OrdinalIgnoreCase) ? 420 : string.Equals(factionKey, "raider", StringComparison.OrdinalIgnoreCase) ? 80 : 120), + StatGrid("shield", string.Equals(kind, "station", StringComparison.OrdinalIgnoreCase) ? 120 : 45), + StatGrid("heat", 0) + ], + Equipment = + [ + new AetheriaEntityItemSlot + { + Position = new AetheriaGridCoord { X = 0, Y = 0 }, + ItemKey = "sensor-array", + Quality = 1, + Durability = 1, + Quantity = 1, + Enabled = true + } + ], + CargoContents = + [ + new AetheriaCargoBayLoadout + { + Items = cargoItems + .Select((itemKey, index) => new AetheriaLoadoutItemSlot + { + Position = new AetheriaGridCoord { X = index % 4, Y = index / 4 }, + Item = new AetheriaLoadoutItem + { + ItemKey = itemKey, + Quality = 1, + Durability = 1, + Quantity = 1, + Enabled = true + } + }) + .ToArray() + } + ] + }; +} + +static AetheriaEntityStatGrid StatGrid(string name, double value) +{ + return new AetheriaEntityStatGrid + { + Name = name, + Width = 1, + Height = 1, + Values = [value] + }; +} + +static AetheriaEntityContactSnapshot Contact(string targetEntityKey, double infoGathered, bool hostile) +{ + return new AetheriaEntityContactSnapshot + { + TargetEntityKey = targetEntityKey, + InfoGathered = infoGathered, + Hostile = hostile, + Visible = true + }; +} + +static string EntityKey(string runId, int zoneIndex, int entityIndex) +{ + return $"global:aetheria.run_state.{runId}.zone.{zoneIndex}.entity.{entityIndex}.v1"; +} + +static AetheriaVector2 Vec2(double x, double y) +{ + return new AetheriaVector2 { X = x, Y = y }; +} + +static AetheriaVector3 Vec3(double x, double y, double z) +{ + return new AetheriaVector3 { X = x, Y = y, Z = z }; +} + +static AetheriaRuntimeLoadoutItemSlotCommit[] ToEntitySlotCommits( + IReadOnlyList? slots) +{ + return (slots ?? Array.Empty()) + .Select(slot => new AetheriaRuntimeLoadoutItemSlotCommit + { + X = slot.Position?.X ?? 0, + Y = slot.Position?.Y ?? 0, + Item = new AetheriaRuntimeLoadoutItemCommit + { + ItemKey = slot.ItemKey ?? "", + Quality = slot.Quality, + Durability = slot.Durability, + Quantity = slot.Quantity, + Enabled = slot.Enabled, + OverrideShutdown = slot.OverrideShutdown, + Temperature = slot.Temperature + } + }) + .ToArray(); +} + static async Task EnsureVerseHostSettingsAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, @@ -342,7 +1814,13 @@ internal sealed class AetheriaDaemonHostOptions public string SessionId { get; init; } = "local"; public string VerseId { get; init; } = "aetheria.local"; public string CultMeshAddress { get; init; } = "cultmesh://aetheria.local/eve/providers/aetheria.daemon"; - public TimeSpan TickInterval { get; init; } = TimeSpan.FromMilliseconds(50); + public string RtsCultMeshHost { get; init; } = "127.0.0.1"; + public string RtsCultMeshAdvertiseHost { get; init; } = "127.0.0.1"; + public int RtsCultMeshPort { get; init; } = 3076; + public IReadOnlyList PeerCultMeshEndpoints { get; init; } = Array.Empty(); + public TimeSpan PeerSyncTimeout { get; init; } = TimeSpan.FromMilliseconds(250); + public TimeSpan TickInterval { get; init; } = TimeSpan.FromMilliseconds(20); + public TimeSpan ApiPublicationInterval { get; init; } = TimeSpan.FromSeconds(1); public double FixedDeltaSeconds { get; init; } = 0.02; public bool Once { get; init; } @@ -353,11 +1831,17 @@ internal sealed class AetheriaDaemonHostOptions root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); var state = ReadOption(args, "--state"); - var intervalMs = ReadPositiveInt(args, "--tick-interval-ms") ?? 50; + var intervalMs = ReadPositiveInt(args, "--tick-interval-ms") ?? 20; var fixedDeltaMs = ReadPositiveInt(args, "--fixed-delta-ms") ?? 20; var daemonId = ReadOption(args, "--daemon-id"); var verseId = ReadOption(args, "--verse-id"); var cultMeshAddress = ReadOption(args, "--cultmesh-address"); + var rtsCultMeshHost = ReadOption(args, "--rts-cultmesh-host"); + var rtsCultMeshAdvertiseHost = ReadOption(args, "--rts-cultmesh-advertise-host"); + var rtsCultMeshPort = ReadNonNegativeInt(args, "--rts-cultmesh-port") ?? 3076; + var peerCultMeshEndpoints = ReadOptions(args, "--peer-cultmesh-endpoint"); + var peerSyncTimeoutMs = ReadPositiveInt(args, "--peer-sync-timeout-ms") ?? 250; + var apiPublicationIntervalMs = ReadPositiveInt(args, "--api-publication-interval-ms") ?? 1000; var sessionId = ReadOption(args, "--session-id"); return new AetheriaDaemonHostOptions @@ -371,7 +1855,15 @@ internal sealed class AetheriaDaemonHostOptions CultMeshAddress = string.IsNullOrWhiteSpace(cultMeshAddress) ? "cultmesh://aetheria.local/eve/providers/aetheria.daemon" : cultMeshAddress, + RtsCultMeshHost = string.IsNullOrWhiteSpace(rtsCultMeshHost) ? "127.0.0.1" : rtsCultMeshHost, + RtsCultMeshAdvertiseHost = string.IsNullOrWhiteSpace(rtsCultMeshAdvertiseHost) + ? (string.IsNullOrWhiteSpace(rtsCultMeshHost) || rtsCultMeshHost == "0.0.0.0" || rtsCultMeshHost == "*" ? "127.0.0.1" : rtsCultMeshHost) + : rtsCultMeshAdvertiseHost, + RtsCultMeshPort = rtsCultMeshPort, + PeerCultMeshEndpoints = peerCultMeshEndpoints, + PeerSyncTimeout = TimeSpan.FromMilliseconds(peerSyncTimeoutMs), TickInterval = TimeSpan.FromMilliseconds(intervalMs), + ApiPublicationInterval = TimeSpan.FromMilliseconds(apiPublicationIntervalMs), FixedDeltaSeconds = fixedDeltaMs / 1000.0, Once = HasFlag(args, "--once") }; @@ -388,6 +1880,23 @@ internal sealed class AetheriaDaemonHostOptions return ""; } + private static IReadOnlyList ReadOptions(IReadOnlyList args, string name) + { + var values = new List(); + for (var i = 0; i < args.Count - 1; i++) + { + if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(args[i + 1])) + { + values.Add(args[i + 1].Trim()); + } + } + + return values + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + private static int? ReadPositiveInt(IReadOnlyList args, string name) { return int.TryParse(ReadOption(args, name), out var value) && value > 0 @@ -395,6 +1904,13 @@ internal sealed class AetheriaDaemonHostOptions : null; } + private static int? ReadNonNegativeInt(IReadOnlyList args, string name) + { + return int.TryParse(ReadOption(args, name), out var value) && value >= 0 + ? value + : null; + } + private static bool HasFlag(IReadOnlyList args, string name) { return args.Any(arg => string.Equals(arg, name, StringComparison.OrdinalIgnoreCase)); diff --git a/Aetheria.State.Freeze/Aetheria.State.Freeze.csproj b/Aetheria.State.Freeze/Aetheria.State.Freeze.csproj new file mode 100644 index 000000000..1e5102696 --- /dev/null +++ b/Aetheria.State.Freeze/Aetheria.State.Freeze.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + enable + latest + enable + + + + + + + + diff --git a/Aetheria.State.Freeze/Program.cs b/Aetheria.State.Freeze/Program.cs new file mode 100644 index 000000000..6066cbbd0 --- /dev/null +++ b/Aetheria.State.Freeze/Program.cs @@ -0,0 +1,1356 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using Aetheria.State; +using GameCult.Geometry; +using GameCult.Aetheria.State.Verse; +using MessagePack; + +var options = FreezeOptions.Parse(args); +Directory.CreateDirectory(options.OutputDirectory); +Directory.CreateDirectory(Path.GetDirectoryName(options.StatePath) ?? "."); + +await ExportFixtureSetAsync( + options, + "baseline", + options.StatePath, + options.OutputDirectory, + Array.Empty()) + .ConfigureAwait(false); + +if (options.IncludeCommandBatches) +{ + var commandStateDirectory = Path.Combine(options.OutputDirectory, ".state"); + await ExportFixtureSetAsync( + options, + "command-batch-pilot", + Path.Combine(commandStateDirectory, "command-batch-pilot.cc"), + Path.Combine(options.OutputDirectory, "command-batch-pilot"), + BuildPilotCommands()) + .ConfigureAwait(false); + + await ExportFixtureSetAsync( + options, + "command-batch-interaction", + Path.Combine(commandStateDirectory, "command-batch-interaction.cc"), + Path.Combine(options.OutputDirectory, "command-batch-interaction"), + BuildInteractionCommands()) + .ConfigureAwait(false); + + await ExportFixtureSetAsync( + options, + "command-batch-refit", + Path.Combine(commandStateDirectory, "command-batch-refit.cc"), + Path.Combine(options.OutputDirectory, "command-batch-refit"), + BuildRefitCommands()) + .ConfigureAwait(false); +} + +ExportAuthorityDecisionFixtures(Path.Combine(options.OutputDirectory, "authority-decisions")); +ExportYmirQueryFixtures(Path.Combine(options.OutputDirectory, "ymir-queries")); + +Console.WriteLine($"Wrote Aetheria freeze fixtures to {options.OutputDirectory}"); + +static async Task ExportFixtureSetAsync( + FreezeOptions options, + string scenario, + string statePath, + string outputDirectory, + IReadOnlyList commands) +{ + Directory.CreateDirectory(outputDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(statePath) ?? "."); + if (options.CleanState) + DeleteStateFamily(statePath); + + if (commands.Count > 0) + await SeedCommandsAsync(statePath, commands).ConfigureAwait(false); + + if (options.RunDaemon) + await RunDaemonOnceAsync(options, statePath, scenario).ConfigureAwait(false); + + var manifest = new FreezeManifest + { + Scenario = scenario, + GeneratedAtUtc = DateTimeOffset.UtcNow.ToString("O"), + Root = options.Root, + StatePath = statePath, + OutputDirectory = outputDirectory, + RanDaemon = options.RunDaemon, + SeededCommandCount = commands.Count + }; + + if (commands.Count > 0) + WriteArtifact(outputDirectory, manifest, "commands.seeded", AetheriaRuntimeDaemonSchemas.Command + "[]", commands); + + var frame = AetheriaRuntimeDaemonFrameStore.ReadFrame(statePath); + WriteArtifact(outputDirectory, manifest, "daemon-frame.latest", AetheriaRuntimeDaemonSchemas.Frame, frame); + + if (AetheriaRuntimeDaemonPublicationStore.TryReadHealth(statePath, out var health)) + WriteArtifact(outputDirectory, manifest, "daemon-health.latest", AetheriaRuntimeDaemonSchemas.Health, health); + + if (AetheriaRuntimeDaemonPublicationStore.TryReadProviderAdvertisement(statePath, out var provider)) + WriteArtifact(outputDirectory, manifest, "daemon-provider.latest", AetheriaRuntimeDaemonSchemas.ProviderAdvertisement, provider); + + if (AetheriaRuntimeDaemonPublicationStore.TryReadCommandBoundary(statePath, out var commandBoundary)) + WriteArtifact(outputDirectory, manifest, "daemon-command-boundary.latest", AetheriaRuntimeDaemonSchemas.CommandBoundary, commandBoundary); + + if (AetheriaRuntimeDaemonPublicationStore.TryReadVerseAuthorityPolicy(statePath, out var authorityPolicy)) + WriteArtifact(outputDirectory, manifest, "verse-authority-policy.latest", AetheriaRuntimeVerseAuthoritySchemas.Policy, authorityPolicy); + + if (AetheriaRuntimeDaemonPublicationStore.TryReadStarbridgeSessionSummary(statePath, out var starbridgeSummary)) + WriteArtifact(outputDirectory, manifest, "starbridge-session-summary.latest", AetheriaRuntimeDaemonSchemas.StarbridgeSessionSummary, starbridgeSummary); + + if (AetheriaRuntimeDaemonSoaViewStore.TryReadView(statePath, out var soaView)) + WriteArtifact(outputDirectory, manifest, "daemon-soa-view.latest", AetheriaRuntimeDaemonSchemas.SoaView, soaView); + + var viewport = new AetheriaRuntimeRtsViewportBounds + { + MinX = options.ViewportMinX, + MinY = options.ViewportMinY, + MaxX = options.ViewportMaxX, + MaxY = options.ViewportMaxY + }; + WriteArtifact(outputDirectory, manifest, "projection.rts-viewport", AetheriaRuntimeDaemonSchemas.RtsViewport, AetheriaRuntimeRtsProjection.ProjectViewport(frame, viewport)); + WriteArtifact(outputDirectory, manifest, "projection.objects-viewport", AetheriaRuntimeDaemonSchemas.ObjectsViewport, AetheriaRuntimeRtsProjection.ProjectObjectsViewport(frame, viewport)); + WriteArtifact(outputDirectory, manifest, "projection.gravity-viewport", AetheriaRuntimeDaemonSchemas.GravityViewport, AetheriaRuntimeRtsProjection.ProjectGravityViewport(frame, viewport)); + WriteArtifact(outputDirectory, manifest, "projection.current-zone", AetheriaRuntimeDaemonSchemas.CurrentZone, AetheriaRuntimeRtsProjection.ProjectCurrentZone(frame)); + WriteArtifact(outputDirectory, manifest, "projection.current-entity", AetheriaRuntimeDaemonSchemas.CurrentEntity, AetheriaRuntimeRtsProjection.ProjectCurrentEntity(frame)); + WriteArtifact(outputDirectory, manifest, "projection.sector-map", AetheriaRuntimeDaemonSchemas.SectorMap, AetheriaRuntimeRtsProjection.ProjectSectorMap(frame)); + WriteArtifact(outputDirectory, manifest, "projection.zone-render", AetheriaRuntimeDaemonSchemas.ZoneRender, AetheriaRuntimeRtsProjection.ProjectZoneRender(frame)); + + var selectedEntityIndex = ResolveSelectedEntityIndex(frame); + WriteArtifact(outputDirectory, manifest, "projection.selected-object", AetheriaRuntimeDaemonSchemas.SelectedObject, AetheriaRuntimeRtsProjection.ProjectSelectedObject(frame, selectedEntityIndex)); + WriteArtifact(outputDirectory, manifest, "projection.inventory", AetheriaRuntimeDaemonSchemas.Inventory, AetheriaRuntimeRtsProjection.ProjectInventory(frame, selectedEntityIndex)); + + WriteJson(Path.Combine(outputDirectory, "manifest.json"), manifest); +} + +static void ExportAuthorityDecisionFixtures(string outputDirectory) +{ + Directory.CreateDirectory(outputDirectory); + var fixtures = BuildAuthorityDecisionFixtures(); + var manifest = new FreezeManifest + { + Scenario = "authority-decisions", + GeneratedAtUtc = DateTimeOffset.UtcNow.ToString("O"), + OutputDirectory = outputDirectory, + RanDaemon = false, + SeededCommandCount = fixtures.Cases.Length + }; + WriteArtifact(outputDirectory, manifest, "authority-decisions", "gamecult.aetheria.freeze.authority_decisions.v1", fixtures); + WriteJson(Path.Combine(outputDirectory, "manifest.json"), manifest); +} + +static void ExportYmirQueryFixtures(string outputDirectory) +{ + Directory.CreateDirectory(outputDirectory); + var fixtures = BuildYmirQueryFixtures(); + var manifest = new FreezeManifest + { + Scenario = "ymir-queries", + GeneratedAtUtc = DateTimeOffset.UtcNow.ToString("O"), + OutputDirectory = outputDirectory, + RanDaemon = false, + SeededCommandCount = fixtures.Cases.Length + }; + WriteArtifact(outputDirectory, manifest, "ymir-queries", "gamecult.aetheria.freeze.ymir_queries.v1", fixtures); + WriteJson(Path.Combine(outputDirectory, "manifest.json"), manifest); +} + +static async Task SeedCommandsAsync(string statePath, IReadOnlyList commands) +{ + await using var node = await AetheriaStateNode + .OpenAsync(statePath, "aetheria-freeze-command-seeder", enableDurableShardLogs: false) + .ConfigureAwait(false); + foreach (var command in commands) + await node.SubmitDaemonCommandAsync(command).ConfigureAwait(false); + await node.FlushAsync().ConfigureAwait(false); +} + +static async Task RunDaemonOnceAsync(FreezeOptions options, string statePath, string scenario) +{ + var daemonProject = Path.Combine(options.Root, "Aetheria.State.Daemon", "Aetheria.State.Daemon.csproj"); + if (!File.Exists(daemonProject)) + throw new FileNotFoundException("Could not find daemon project.", daemonProject); + + var daemonArgs = string.Join( + " ", + new[] + { + "run", + "--project", + Quote(daemonProject), + "--", + "--once", + "--root", + Quote(options.Root), + "--state", + Quote(statePath), + "--daemon-id", + $"aetheria-freeze-{scenario}", + "--session-id", + scenario, + "--rts-cultmesh-port", + "0" + }); + + var start = new ProcessStartInfo("dotnet", daemonArgs) + { + WorkingDirectory = options.Root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = Process.Start(start) ?? throw new InvalidOperationException("Failed to start daemon process."); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync().ConfigureAwait(false); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Aetheria daemon fixture run failed with exit code {process.ExitCode}.\nSTDOUT:\n{await stdout.ConfigureAwait(false)}\nSTDERR:\n{await stderr.ConfigureAwait(false)}"); + } +} + +static void DeleteStateFamily(string statePath) +{ + var directory = Path.GetDirectoryName(statePath); + var fileName = Path.GetFileName(statePath); + if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(fileName) || !Directory.Exists(directory)) + return; + + foreach (var path in Directory.EnumerateFileSystemEntries(directory, fileName + "*")) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + else + File.Delete(path); + } +} + +static IReadOnlyList BuildPilotCommands() +{ + const string actor = "global:aetheria.run_state.local-rts.zone.0.entity.1.v1"; + const string target = "global:aetheria.run_state.local-rts.zone.0.entity.4.v1"; + return new[] + { + Command("pilot-001-set-target", AetheriaRuntimeDaemonCommandKinds.SetTarget, actor, command => + { + command.TargetEntityKey = target; + }), + Command("pilot-002-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, command => + { + command.DirectionX = 1; + command.DirectionY = 0.25; + command.ScalarValue = 1; + }), + Command("pilot-003-look", AetheriaRuntimeDaemonCommandKinds.SetLookDirection, actor, command => + { + command.DirectionX = 0.25; + command.DirectionY = 1; + }), + Command("pilot-004-tractor", AetheriaRuntimeDaemonCommandKinds.SetTractorPower, actor, command => + { + command.ScalarValue = 0.5; + }), + Command("pilot-005-sensor-ping", AetheriaRuntimeDaemonCommandKinds.SensorPing, actor), + Command("pilot-006-toggle-shield", AetheriaRuntimeDaemonCommandKinds.ToggleShieldEnabled, actor), + Command("pilot-007-fire-weapon-group", AetheriaRuntimeDaemonCommandKinds.FireWeaponGroup, actor, command => + { + command.WeaponGroup = 0; + }) + }; +} + +static IReadOnlyList BuildInteractionCommands() +{ + const string actor = "global:aetheria.run_state.local-rts.zone.0.entity.1.v1"; + const string station = "global:aetheria.run_state.local-rts.zone.0.entity.0.v1"; + return new[] + { + Command("interaction-001-dock-nearest", AetheriaRuntimeDaemonCommandKinds.DockNearest, actor), + Command("interaction-002-dock", AetheriaRuntimeDaemonCommandKinds.Dock, actor, command => + { + command.TargetEntityKey = station; + }), + Command("interaction-003-undock", AetheriaRuntimeDaemonCommandKinds.Undock, actor), + Command("interaction-004-interact", AetheriaRuntimeDaemonCommandKinds.Interact, actor), + Command("interaction-005-tow", AetheriaRuntimeDaemonCommandKinds.TowToStation, actor, command => + { + command.TargetEntityKey = station; + }) + }; +} + +static IReadOnlyList BuildRefitCommands() +{ + const string actor = "global:aetheria.run_state.local-rts.zone.0.entity.1.v1"; + const string station = "global:aetheria.run_state.local-rts.zone.0.entity.0.v1"; + return new[] + { + Command("refit-001-rename", AetheriaRuntimeDaemonCommandKinds.SetEntityName, actor, command => + { + command.TargetEntityKey = actor; + command.TextValue = "Vanguard Freeze"; + }), + Command("refit-002-transfer-cargo", AetheriaRuntimeDaemonCommandKinds.TransferCargoItem, actor, command => + { + command.TargetEntityKey = actor; + command.TextValue = "repair-parts"; + command.ScalarValue = 1; + command.CargoTransfer.OriginEntityKey = station; + command.CargoTransfer.OriginCargoIndex = 0; + command.CargoTransfer.DestinationEntityKey = actor; + command.CargoTransfer.DestinationCargoIndex = 0; + command.CargoTransfer.SourceX = 0; + command.CargoTransfer.SourceY = 0; + command.CargoTransfer.DestinationX = 2; + command.CargoTransfer.DestinationY = 0; + command.CargoTransfer.HasDestinationPosition = true; + }), + Command("refit-003-trade-purchase", AetheriaRuntimeDaemonCommandKinds.TradePurchase, actor, command => + { + command.TargetEntityKey = actor; + command.EquipmentIndex = 0; + command.PositionX = 1; + command.PositionY = 0; + command.ScalarValue = 25; + command.TextValue = "reactor-fuel"; + command.TradePurchase.PurchaseKind = "cargo"; + command.TradePurchase.ItemKey = "reactor-fuel"; + command.TradePurchase.Quantity = 1; + command.TradePurchase.UnitPrice = 25; + command.TradePurchase.TotalPrice = 25; + command.TradePurchase.StationEntityKey = station; + command.TradePurchase.StationCargoIndex = 0; + command.TradePurchase.TargetEntityKey = actor; + command.TradePurchase.TargetCargoIndex = 0; + command.TradePurchase.SourceX = 1; + command.TradePurchase.SourceY = 0; + }), + Command("refit-004-equip-from-cargo", AetheriaRuntimeDaemonCommandKinds.EquipItem, actor, command => + { + command.TargetEntityKey = actor; + command.PositionX = 1; + command.PositionY = 0; + command.TextValue = "field-rations"; + command.EquipmentTransfer.SourceKind = "cargo"; + command.EquipmentTransfer.OriginEntityKey = actor; + command.EquipmentTransfer.OriginIndex = 0; + command.EquipmentTransfer.DestinationEntityKey = actor; + command.EquipmentTransfer.SourceX = 1; + command.EquipmentTransfer.SourceY = 0; + command.EquipmentTransfer.DestinationX = 1; + command.EquipmentTransfer.DestinationY = 0; + command.EquipmentTransfer.HasDestinationPosition = true; + }), + Command("refit-005-store-equipped", AetheriaRuntimeDaemonCommandKinds.StoreItem, actor, command => + { + command.TargetEntityKey = actor; + command.TextValue = "sensor-array"; + command.StoreItem.OriginEntityKey = actor; + command.StoreItem.SourceEquipmentIndex = 0; + command.StoreItem.DestinationEntityKey = actor; + command.StoreItem.DestinationCargoIndex = 0; + command.StoreItem.DestinationX = 3; + command.StoreItem.DestinationY = 0; + command.StoreItem.HasDestinationPosition = true; + }), + Command("refit-006-set-docked-current-ship", AetheriaRuntimeDaemonCommandKinds.SetDockedCurrentShip, actor, command => + { + command.TargetEntityKey = actor; + }), + Command("refit-007-pick-up-loot", AetheriaRuntimeDaemonCommandKinds.PickUpLoot, actor, command => + { + command.TargetEntityKey = actor; + command.TextValue = "loose-salvage"; + command.ScalarValue = 2; + command.PositionX = -40; + command.PositionY = 0; + command.PositionZ = -35; + command.LootPickup.ItemKey = "loose-salvage"; + command.LootPickup.Quantity = 2; + command.LootPickup.PositionX = -40; + command.LootPickup.PositionY = 0; + command.LootPickup.PositionZ = -35; + }), + Command("refit-008-restore-loadout", AetheriaRuntimeDaemonCommandKinds.RestoreLoadout, actor, command => + { + command.TargetEntityKey = actor; + command.LoadoutRestore.DockedEntityKey = actor; + command.LoadoutRestore.TemplateName = "freeze-template"; + command.LoadoutRestore.Price = 0; + }) + }; +} + +static AuthorityDecisionFixtureSet BuildAuthorityDecisionFixtures() +{ + const string verseId = "aetheria.freeze"; + const string host = "aetheria-daemon"; + const string unity = "raven-unity"; + const string rts = "starfire-rts"; + const string actor = "global:aetheria.run_state.local-rts.zone.0.entity.1.v1"; + const string raider = "global:aetheria.run_state.local-rts.zone.0.entity.4.v1"; + var validLease = new AetheriaRuntimeAuthorityLeaseDocument + { + LeaseId = "lease-rts-vanguard-movement", + VerseId = verseId, + RuntimeId = rts, + SubjectPrefix = actor, + ClaimKinds = new[] { AetheriaRuntimeClaimKinds.Movement }, + ValidFromUtc = "2026-01-01T00:00:00.0000000Z", + ExpiresAtUtc = "2100-01-01T00:00:00.0000000Z", + Scope = "nearby-interest" + }; + + var cases = new[] + { + AuthorityCase( + "trusted-coop-allows-rts-movement", + AetheriaRuntimeVerseAuthorityPolicyDocument.TrustedCoop(verseId, host), + Array.Empty(), + AuthorityCommand("auth-001-rts-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, rts), + host), + AuthorityCase( + "host-authoritative-allows-host", + HostPolicy(verseId, host), + Array.Empty(), + AuthorityCommand("auth-002-host-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, host), + host), + AuthorityCase( + "host-authoritative-rejects-rts", + HostPolicy(verseId, host), + Array.Empty(), + AuthorityCommand("auth-003-rts-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, rts), + host), + AuthorityCase( + "delegated-runtime-allows-rts-targeting", + DelegatedPolicy(verseId, host, actor, AetheriaRuntimeClaimKinds.Targeting, rts), + Array.Empty(), + AuthorityCommand("auth-004-rts-target", AetheriaRuntimeDaemonCommandKinds.SetTarget, actor, rts, command => command.TargetEntityKey = raider), + host), + AuthorityCase( + "delegated-runtime-rejects-unity-targeting", + DelegatedPolicy(verseId, host, actor, AetheriaRuntimeClaimKinds.Targeting, rts), + Array.Empty(), + AuthorityCommand("auth-005-unity-target", AetheriaRuntimeDaemonCommandKinds.SetTarget, actor, unity, command => command.TargetEntityKey = raider), + host), + AuthorityCase( + "interest-lease-allows-rts-movement", + LeasePolicy(verseId, host, actor, AetheriaRuntimeClaimKinds.Movement), + new[] { validLease }, + AuthorityCommand("auth-006-rts-lease-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, rts), + host), + AuthorityCase( + "interest-lease-rejects-rts-without-lease", + LeasePolicy(verseId, host, actor, AetheriaRuntimeClaimKinds.Movement), + Array.Empty(), + AuthorityCommand("auth-007-rts-no-lease-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, rts), + host), + AuthorityCase( + "owning-runtime-is-not-implemented", + OwningPolicy(verseId, host, actor), + Array.Empty(), + AuthorityCommand("auth-008-owning-mode-move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector, actor, unity), + host) + }; + + return new AuthorityDecisionFixtureSet + { + Schema = "gamecult.aetheria.freeze.authority_decisions.v1", + GeneratedAtUtc = DateTimeOffset.UtcNow.ToString("O"), + Cases = cases + }; +} + +static YmirQueryFixtureSet BuildYmirQueryFixtures() +{ + var stepBody = YmirBody("ship", 1, 2, 1); + stepBody.velocity = YmirVec2Of(3, -4); + var stepRequest = new YmirStepRequest + { + deltaTime = 0.5f, + world = YmirWorldOf(stepBody) + }; + + var radialBody = YmirBody("ship", 5, 0, 1); + var radialRequest = new YmirStepRequest + { + deltaTime = 1.0f, + world = YmirWorldOf(radialBody) + }; + radialRequest.world.fields = new[] + { + new YmirRadialField + { + id = "push", + position = YmirVec2Of(0, 0), + strength = 10, + radius = 10 + } + }; + + var mover = YmirBody("mover", 0, 0, 1); + mover.velocity = YmirVec2Of(2, 0); + mover.mass = 1; + var wall = YmirBody("wall", 1.5f, 0, 1); + wall.isStatic = true; + var contactRequest = new YmirStepRequest + { + deltaTime = 0.1f, + world = YmirWorldOf(mover, wall) + }; + + var overlapSphereRequest = new YmirSphereOverlapRequest + { + center = YmirVec3Of(0, 0, 0), + radius = 3, + bodies = new[] + { + YmirSphereBody("far", 4, 0, 0, 2), + YmirSphereBody("near", 1, 0, 0, 0.25f), + YmirSphereBody("miss", 10, 0, 0, 1) + } + }; + + var overlapCircleRequest = new YmirCircleOverlapRequest + { + center = YmirVec2Of(0, 0), + radius = 3, + world = YmirWorldOf( + YmirBody("far", 4, 0, 2), + YmirBody("near", 1, 0, 0.25f), + YmirBody("miss", 10, 0, 1)) + }; + + var castSphereRequest = new YmirSphereCastRequest + { + origin = YmirVec3Of(0, 0, 0), + direction = YmirVec3Of(2, 0, 0), + distance = 10, + radius = 1, + bodies = new[] { YmirSphereBody("target", 4, 0, 0, 1) } + }; + + var castCircleRequest = new YmirCircleCastRequest + { + origin = YmirVec2Of(0, 0), + direction = YmirVec2Of(1, 0), + distance = 10, + radius = 1, + world = YmirWorldOf( + YmirBody("far", 8, 0, 1), + YmirBody("near", 4, 0, 1), + YmirBody("miss", 4, 4, 1)) + }; + + var invalidCastCircleRequest = new YmirCircleCastRequest + { + origin = YmirVec2Of(0, 0), + direction = YmirVec2Of(0, 0), + distance = 1, + radius = 1, + world = YmirWorldOf(YmirBody("target", 0, 0, 1)) + }; + + return new YmirQueryFixtureSet + { + Schema = "gamecult.aetheria.freeze.ymir_queries.v1", + GeneratedAtUtc = DateTimeOffset.UtcNow.ToString("O"), + Cases = new[] + { + YmirStepCase("step-integrates-dynamic-body", "Dynamic bodies integrate velocity without mutating the input world.", stepRequest, YmirPhysicsQueries.Step(stepRequest)), + YmirStepCase("step-applies-radial-field", "Radial fields apply linear falloff acceleration before integration.", radialRequest, YmirPhysicsQueries.Step(radialRequest)), + YmirStepCase("step-reports-contact-and-separates", "Dynamic/static body overlap produces a contact and separates the dynamic body.", contactRequest, YmirPhysicsQueries.Step(contactRequest)), + YmirSphereOverlapCase("overlap-sphere-sorts-surface-distance", "Sphere overlap hits sort by surface distance, then id.", overlapSphereRequest, YmirPhysicsQueries.OverlapSphere(overlapSphereRequest)), + YmirCircleOverlapCase("overlap-circle-sorts-surface-distance", "Circle overlap hits sort by surface distance, then id.", overlapCircleRequest, YmirPhysicsQueries.OverlapCircle(overlapCircleRequest)), + YmirSphereCastCase("cast-sphere-impact-point-normal", "Sphere casts report impact distance, point, and normal.", castSphereRequest, YmirPhysicsQueries.CastSphere(castSphereRequest)), + YmirCircleCastCase("cast-circle-sorts-impact-distance", "Circle cast hits sort by impact distance, then id.", castCircleRequest, YmirPhysicsQueries.CastCircle(castCircleRequest)), + YmirCircleCastCase("cast-circle-invalid-direction-skips", "Invalid cast direction returns no hits.", invalidCastCircleRequest, YmirPhysicsQueries.CastCircle(invalidCastCircleRequest)) + } + }; +} + +static YmirQueryFixtureCase YmirStepCase( + string caseId, + string intent, + YmirStepRequest request, + YmirStepResult result) +{ + return new YmirQueryFixtureCase + { + CaseId = caseId, + QueryKind = "step", + Intent = intent, + Request = new YmirQueryFixturePayload + { + DeltaTime = request.deltaTime, + World = ToFixtureWorld(request.world) + }, + Result = new YmirQueryFixturePayload + { + World = ToFixtureWorld(result.world), + Contacts = ToFixtureContacts(result.contacts) + } + }; +} + +static YmirQueryFixtureCase YmirSphereOverlapCase( + string caseId, + string intent, + YmirSphereOverlapRequest request, + YmirSphereOverlapResult result) +{ + return new YmirQueryFixtureCase + { + CaseId = caseId, + QueryKind = "overlap-sphere", + Intent = intent, + Request = new YmirQueryFixturePayload + { + Center3 = ToFixtureVec3(request.center), + Radius = request.radius, + SphereBodies = ToFixtureSphereBodies(request.bodies) + }, + Result = new YmirQueryFixturePayload + { + SphereOverlapHits = ToFixtureSphereOverlapHits(result.hits) + } + }; +} + +static YmirQueryFixtureCase YmirCircleOverlapCase( + string caseId, + string intent, + YmirCircleOverlapRequest request, + YmirCircleOverlapResult result) +{ + return new YmirQueryFixtureCase + { + CaseId = caseId, + QueryKind = "overlap-circle", + Intent = intent, + Request = new YmirQueryFixturePayload + { + Center2 = ToFixtureVec2(request.center), + Radius = request.radius, + World = ToFixtureWorld(request.world) + }, + Result = new YmirQueryFixturePayload + { + CircleOverlapHits = ToFixtureCircleOverlapHits(result.hits) + } + }; +} + +static YmirQueryFixtureCase YmirSphereCastCase( + string caseId, + string intent, + YmirSphereCastRequest request, + YmirSphereCastResult result) +{ + return new YmirQueryFixtureCase + { + CaseId = caseId, + QueryKind = "cast-sphere", + Intent = intent, + Request = new YmirQueryFixturePayload + { + Origin3 = ToFixtureVec3(request.origin), + Direction3 = ToFixtureVec3(request.direction), + Distance = request.distance, + Radius = request.radius, + SphereBodies = ToFixtureSphereBodies(request.bodies) + }, + Result = new YmirQueryFixturePayload + { + SphereCastHits = ToFixtureSphereCastHits(result.hits) + } + }; +} + +static YmirQueryFixtureCase YmirCircleCastCase( + string caseId, + string intent, + YmirCircleCastRequest request, + YmirCircleCastResult result) +{ + return new YmirQueryFixtureCase + { + CaseId = caseId, + QueryKind = "cast-circle", + Intent = intent, + Request = new YmirQueryFixturePayload + { + Origin2 = ToFixtureVec2(request.origin), + Direction2 = ToFixtureVec2(request.direction), + Distance = request.distance, + Radius = request.radius, + World = ToFixtureWorld(request.world) + }, + Result = new YmirQueryFixturePayload + { + CircleCastHits = ToFixtureCircleCastHits(result.hits) + } + }; +} + +static YmirFixtureWorld ToFixtureWorld(YmirWorld world) +{ + return new YmirFixtureWorld + { + Time = world?.time ?? 0, + Bodies = ToFixtureBodies(world?.bodies), + Fields = ToFixtureFields(world?.fields) + }; +} + +static YmirFixtureBody[] ToFixtureBodies(IReadOnlyList? bodies) +{ + return (bodies ?? Array.Empty()) + .Select(body => new YmirFixtureBody + { + Id = body?.id ?? "", + Position = ToFixtureVec2(body?.position ?? default), + Velocity = ToFixtureVec2(body?.velocity ?? default), + Direction = ToFixtureVec2(body?.direction ?? default), + AngularVelocity = body?.angularVelocity ?? 0, + Torque = body?.torque ?? 0, + MomentOfInertia = body?.momentOfInertia ?? 0, + Radius = body?.radius ?? 0, + Mass = body?.mass ?? 0, + IsStatic = body?.isStatic ?? false, + Restitution = body?.restitution ?? 0 + }) + .ToArray(); +} + +static YmirFixtureSphereBody[] ToFixtureSphereBodies(IReadOnlyList? bodies) +{ + return (bodies ?? Array.Empty()) + .Select(body => new YmirFixtureSphereBody + { + Id = body?.id ?? "", + Position = ToFixtureVec3(body?.position ?? default), + Radius = body?.radius ?? 0 + }) + .ToArray(); +} + +static YmirFixtureField[] ToFixtureFields(IReadOnlyList? fields) +{ + return (fields ?? Array.Empty()) + .Select(field => new YmirFixtureField + { + Id = field?.id ?? "", + Position = ToFixtureVec2(field?.position ?? default), + Strength = field?.strength ?? 0, + Radius = field?.radius ?? 0 + }) + .ToArray(); +} + +static YmirFixtureContact[] ToFixtureContacts(IReadOnlyList? contacts) +{ + return (contacts ?? Array.Empty()) + .Select(contact => new YmirFixtureContact + { + BodyA = contact?.bodyA ?? "", + BodyB = contact?.bodyB ?? "", + Point = ToFixtureVec2(contact?.point ?? default), + Normal = ToFixtureVec2(contact?.normal ?? default), + Penetration = contact?.penetration ?? 0, + RelativeSpeed = contact?.relativeSpeed ?? 0 + }) + .ToArray(); +} + +static YmirFixtureCircleOverlapHit[] ToFixtureCircleOverlapHits(IReadOnlyList? hits) +{ + return (hits ?? Array.Empty()) + .Select(hit => new YmirFixtureCircleOverlapHit + { + BodyId = hit?.bodyId ?? "", + Point = ToFixtureVec2(hit?.point ?? default), + Normal = ToFixtureVec2(hit?.normal ?? default), + Penetration = hit?.penetration ?? 0, + Distance = hit?.distance ?? 0 + }) + .ToArray(); +} + +static YmirFixtureSphereOverlapHit[] ToFixtureSphereOverlapHits(IReadOnlyList? hits) +{ + return (hits ?? Array.Empty()) + .Select(hit => new YmirFixtureSphereOverlapHit + { + BodyId = hit?.bodyId ?? "", + Point = ToFixtureVec3(hit?.point ?? default), + Normal = ToFixtureVec3(hit?.normal ?? default), + Penetration = hit?.penetration ?? 0, + Distance = hit?.distance ?? 0 + }) + .ToArray(); +} + +static YmirFixtureCircleCastHit[] ToFixtureCircleCastHits(IReadOnlyList? hits) +{ + return (hits ?? Array.Empty()) + .Select(hit => new YmirFixtureCircleCastHit + { + BodyId = hit?.bodyId ?? "", + Point = ToFixtureVec2(hit?.point ?? default), + Normal = ToFixtureVec2(hit?.normal ?? default), + Distance = hit?.distance ?? 0 + }) + .ToArray(); +} + +static YmirFixtureSphereCastHit[] ToFixtureSphereCastHits(IReadOnlyList? hits) +{ + return (hits ?? Array.Empty()) + .Select(hit => new YmirFixtureSphereCastHit + { + BodyId = hit?.bodyId ?? "", + Point = ToFixtureVec3(hit?.point ?? default), + Normal = ToFixtureVec3(hit?.normal ?? default), + Distance = hit?.distance ?? 0 + }) + .ToArray(); +} + +static CultVec2 ToFixtureVec2(YmirVec2 value) +{ + return new CultVec2(value.x, value.y); +} + +static CultVec3 ToFixtureVec3(YmirVec3 value) +{ + return new CultVec3(value.x, value.y, value.z); +} + +static YmirWorld YmirWorldOf(params YmirPhysicsBody[] bodies) +{ + return new YmirWorld + { + time = 0, + bodies = bodies, + fields = Array.Empty() + }; +} + +static YmirPhysicsBody YmirBody(string id, float x, float y, float radius) +{ + return new YmirPhysicsBody + { + id = id, + position = YmirVec2Of(x, y), + direction = YmirVec2Of(1, 0), + radius = radius, + mass = 1, + momentOfInertia = 1 + }; +} + +static YmirSphereQueryBody YmirSphereBody(string id, float x, float y, float z, float radius) +{ + return new YmirSphereQueryBody + { + id = id, + position = YmirVec3Of(x, y, z), + radius = radius + }; +} + +static YmirVec2 YmirVec2Of(float x, float y) +{ + return new YmirVec2 { x = x, y = y }; +} + +static YmirVec3 YmirVec3Of(float x, float y, float z) +{ + return new YmirVec3 { x = x, y = y, z = z }; +} + +static AuthorityDecisionCase AuthorityCase( + string caseId, + AetheriaRuntimeVerseAuthorityPolicyDocument policy, + IReadOnlyList leases, + AetheriaRuntimeDaemonCommandDocument command, + string localRuntimeId) +{ + var decision = AetheriaRuntimeAuthorityRouter.Authorize(command, policy, leases, localRuntimeId); + return new AuthorityDecisionCase + { + CaseId = caseId, + LocalRuntimeId = localRuntimeId, + Policy = policy, + Leases = leases.ToArray(), + Command = command, + Decision = new AuthorityDecisionSnapshot + { + Authorized = decision.Authorized, + Reason = decision.Reason, + Mode = decision.Mode, + SubjectKey = decision.SubjectKey, + ClaimKind = decision.ClaimKind, + AuthorRuntimeId = decision.AuthorRuntimeId, + RuleId = decision.RuleId + } + }; +} + +static AetheriaRuntimeVerseAuthorityPolicyDocument HostPolicy(string verseId, string hostRuntimeId) +{ + return new AetheriaRuntimeVerseAuthorityPolicyDocument + { + VerseId = verseId, + PolicyId = "aetheria.freeze.host-authoritative.v1", + HostRuntimeId = hostRuntimeId, + DefaultMode = AetheriaRuntimeAuthorityModes.HostAuthoritative, + DeploymentMode = AetheriaRuntimeVerseDeploymentModes.DedicatedDaemon, + UpdatedAtUtc = "2026-01-01T00:00:00.0000000Z", + Rules = new[] + { + new AetheriaRuntimeAuthorityRule + { + RuleId = "freeze.host.default", + SubjectPrefix = "*", + ClaimKinds = new[] { AetheriaRuntimeClaimKinds.Any }, + Mode = AetheriaRuntimeAuthorityModes.HostAuthoritative + } + } + }; +} + +static AetheriaRuntimeVerseAuthorityPolicyDocument DelegatedPolicy( + string verseId, + string hostRuntimeId, + string subjectPrefix, + string claimKind, + string runtimeId) +{ + return new AetheriaRuntimeVerseAuthorityPolicyDocument + { + VerseId = verseId, + PolicyId = "aetheria.freeze.delegated.v1", + HostRuntimeId = hostRuntimeId, + DefaultMode = AetheriaRuntimeAuthorityModes.HostAuthoritative, + DeploymentMode = AetheriaRuntimeVerseDeploymentModes.DistributedTrusted, + UpdatedAtUtc = "2026-01-01T00:00:00.0000000Z", + Rules = new[] + { + new AetheriaRuntimeAuthorityRule + { + RuleId = "freeze.delegated.subject-claim", + SubjectPrefix = subjectPrefix, + ClaimKinds = new[] { claimKind }, + Mode = AetheriaRuntimeAuthorityModes.DelegatedRuntime, + RuntimeIds = new[] { runtimeId }, + Priority = 100 + }, + new AetheriaRuntimeAuthorityRule + { + RuleId = "freeze.host.default", + SubjectPrefix = "*", + ClaimKinds = new[] { AetheriaRuntimeClaimKinds.Any }, + Mode = AetheriaRuntimeAuthorityModes.HostAuthoritative + } + } + }; +} + +static AetheriaRuntimeVerseAuthorityPolicyDocument LeasePolicy( + string verseId, + string hostRuntimeId, + string subjectPrefix, + string claimKind) +{ + return new AetheriaRuntimeVerseAuthorityPolicyDocument + { + VerseId = verseId, + PolicyId = "aetheria.freeze.interest-lease.v1", + HostRuntimeId = hostRuntimeId, + DefaultMode = AetheriaRuntimeAuthorityModes.HostAuthoritative, + DeploymentMode = AetheriaRuntimeVerseDeploymentModes.DistributedTrusted, + UpdatedAtUtc = "2026-01-01T00:00:00.0000000Z", + Rules = new[] + { + new AetheriaRuntimeAuthorityRule + { + RuleId = "freeze.lease.subject-claim", + SubjectPrefix = subjectPrefix, + ClaimKinds = new[] { claimKind }, + Mode = AetheriaRuntimeAuthorityModes.InterestLease, + LeaseScope = "nearby-interest", + Priority = 100 + }, + new AetheriaRuntimeAuthorityRule + { + RuleId = "freeze.host.default", + SubjectPrefix = "*", + ClaimKinds = new[] { AetheriaRuntimeClaimKinds.Any }, + Mode = AetheriaRuntimeAuthorityModes.HostAuthoritative + } + } + }; +} + +static AetheriaRuntimeVerseAuthorityPolicyDocument OwningPolicy(string verseId, string hostRuntimeId, string subjectPrefix) +{ + return new AetheriaRuntimeVerseAuthorityPolicyDocument + { + VerseId = verseId, + PolicyId = "aetheria.freeze.owning-runtime.v1", + HostRuntimeId = hostRuntimeId, + DefaultMode = AetheriaRuntimeAuthorityModes.HostAuthoritative, + DeploymentMode = AetheriaRuntimeVerseDeploymentModes.DistributedTrusted, + UpdatedAtUtc = "2026-01-01T00:00:00.0000000Z", + Rules = new[] + { + new AetheriaRuntimeAuthorityRule + { + RuleId = "freeze.owning.subject", + SubjectPrefix = subjectPrefix, + ClaimKinds = new[] { AetheriaRuntimeClaimKinds.Movement }, + Mode = AetheriaRuntimeAuthorityModes.OwningRuntime, + Priority = 100 + } + } + }; +} + +static AetheriaRuntimeDaemonCommandDocument AuthorityCommand( + string commandId, + AetheriaRuntimeDaemonCommandKinds kind, + string actor, + string authorRuntimeId, + Action? configure = null) +{ + var command = Command(commandId, kind, actor, configure); + command.AuthorRuntimeId = authorRuntimeId; + command.ClientId = authorRuntimeId; + return command; +} + +static AetheriaRuntimeDaemonCommandDocument Command( + string commandId, + AetheriaRuntimeDaemonCommandKinds kind, + string actor, + Action? configure = null) +{ + var command = AetheriaRuntimeDaemonCommandDocument.Create( + kind, + "aetheria-freeze-fixture", + "freeze", + observedFrameId: -1, + actorEntityKey: actor); + command.CommandId = commandId; + command.IssuedAtUtc = "2026-01-01T00:00:00.0000000Z"; + configure?.Invoke(command); + return command; +} + +static int ResolveSelectedEntityIndex(AetheriaRuntimeDaemonFrameDocument frame) +{ + var contextRun = frame.Run ?? new AetheriaRuntimeRunCheckpointCommit(); + var current = contextRun.CurrentEntityKey ?? ""; + var marker = ".entity."; + var markerIndex = current.LastIndexOf(marker, StringComparison.Ordinal); + if (markerIndex >= 0) + { + var start = markerIndex + marker.Length; + var end = current.IndexOf('.', start); + var token = end < 0 ? current[start..] : current[start..end]; + if (int.TryParse(token, out var parsed)) + return parsed; + } + + var currentZone = (contextRun.Zones ?? Array.Empty()) + .FirstOrDefault(zone => zone != null && zone.ZoneIndex == contextRun.CurrentZoneIndex) ?? + (contextRun.Zones ?? Array.Empty()).FirstOrDefault(); + return (currentZone?.Entities ?? Array.Empty()) + .FirstOrDefault()?.EntityIndex ?? -1; +} + +static void WriteArtifact(string outputDirectory, FreezeManifest manifest, string name, string schemaId, T document) +{ + var jsonFile = name + ".json"; + var msgpackFile = name + ".msgpack"; + WriteJson(Path.Combine(outputDirectory, jsonFile), document); + File.WriteAllBytes(Path.Combine(outputDirectory, msgpackFile), MessagePackSerializer.Serialize(document)); + manifest.Artifacts.Add(new FreezeArtifact + { + Name = name, + SchemaId = schemaId, + Json = jsonFile, + MessagePack = msgpackFile, + DocumentType = typeof(T).FullName ?? typeof(T).Name + }); +} + +static void WriteJson(string path, T value) +{ + var json = JsonSerializer.Serialize(value, FreezeJson.Options); + File.WriteAllText(path, json); +} + +static string Quote(string value) +{ + return "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; +} + +internal sealed class FreezeManifest +{ + public string Scenario { get; set; } = ""; + public string GeneratedAtUtc { get; set; } = ""; + public string Root { get; set; } = ""; + public string StatePath { get; set; } = ""; + public string OutputDirectory { get; set; } = ""; + public bool RanDaemon { get; set; } + public int SeededCommandCount { get; set; } + public List Artifacts { get; } = new(); +} + +internal sealed class FreezeArtifact +{ + public string Name { get; set; } = ""; + public string SchemaId { get; set; } = ""; + public string DocumentType { get; set; } = ""; + public string Json { get; set; } = ""; + public string MessagePack { get; set; } = ""; +} + +[MessagePackObject] +internal sealed class YmirQueryFixtureSet +{ + [Key(0)] public string Schema { get; set; } = ""; + [Key(1)] public string GeneratedAtUtc { get; set; } = ""; + [Key(2)] public YmirQueryFixtureCase[] Cases { get; set; } = Array.Empty(); +} + +[MessagePackObject] +internal sealed class YmirQueryFixtureCase +{ + [Key(0)] public string CaseId { get; set; } = ""; + [Key(1)] public string QueryKind { get; set; } = ""; + [Key(2)] public string Intent { get; set; } = ""; + [Key(3)] public YmirQueryFixturePayload Request { get; set; } = new(); + [Key(4)] public YmirQueryFixturePayload Result { get; set; } = new(); +} + +[MessagePackObject] +internal sealed class YmirQueryFixturePayload +{ + [Key(0)] public float? DeltaTime { get; set; } + [Key(1)] public float? Radius { get; set; } + [Key(2)] public float? Distance { get; set; } + [Key(3)] public CultVec2? Center2 { get; set; } + [Key(4)] public CultVec3? Center3 { get; set; } + [Key(5)] public CultVec2? Origin2 { get; set; } + [Key(6)] public CultVec3? Origin3 { get; set; } + [Key(7)] public CultVec2? Direction2 { get; set; } + [Key(8)] public CultVec3? Direction3 { get; set; } + [Key(9)] public YmirFixtureWorld? World { get; set; } + [Key(10)] public YmirFixtureSphereBody[] SphereBodies { get; set; } = Array.Empty(); + [Key(11)] public YmirFixtureCircleOverlapHit[] CircleOverlapHits { get; set; } = Array.Empty(); + [Key(12)] public YmirFixtureSphereOverlapHit[] SphereOverlapHits { get; set; } = Array.Empty(); + [Key(13)] public YmirFixtureCircleCastHit[] CircleCastHits { get; set; } = Array.Empty(); + [Key(14)] public YmirFixtureSphereCastHit[] SphereCastHits { get; set; } = Array.Empty(); + [Key(15)] public YmirFixtureContact[] Contacts { get; set; } = Array.Empty(); +} + +[MessagePackObject] +internal sealed class YmirFixtureWorld +{ + [Key(0)] public float Time { get; set; } + [Key(1)] public YmirFixtureBody[] Bodies { get; set; } = Array.Empty(); + [Key(2)] public YmirFixtureField[] Fields { get; set; } = Array.Empty(); +} + +[MessagePackObject] +internal sealed class YmirFixtureBody +{ + [Key(0)] public string Id { get; set; } = ""; + [Key(1)] public CultVec2 Position { get; set; } + [Key(2)] public CultVec2 Velocity { get; set; } + [Key(3)] public CultVec2 Direction { get; set; } + [Key(4)] public float AngularVelocity { get; set; } + [Key(5)] public float Torque { get; set; } + [Key(6)] public float MomentOfInertia { get; set; } + [Key(7)] public float Radius { get; set; } + [Key(8)] public float Mass { get; set; } + [Key(9)] public bool IsStatic { get; set; } + [Key(10)] public float Restitution { get; set; } +} + +[MessagePackObject] +internal sealed class YmirFixtureSphereBody +{ + [Key(0)] public string Id { get; set; } = ""; + [Key(1)] public CultVec3 Position { get; set; } + [Key(2)] public float Radius { get; set; } +} + +[MessagePackObject] +internal sealed class YmirFixtureField +{ + [Key(0)] public string Id { get; set; } = ""; + [Key(1)] public CultVec2 Position { get; set; } + [Key(2)] public float Strength { get; set; } + [Key(3)] public float Radius { get; set; } +} + +[MessagePackObject] +internal sealed class YmirFixtureContact +{ + [Key(0)] public string BodyA { get; set; } = ""; + [Key(1)] public string BodyB { get; set; } = ""; + [Key(2)] public CultVec2 Point { get; set; } + [Key(3)] public CultVec2 Normal { get; set; } + [Key(4)] public float Penetration { get; set; } + [Key(5)] public float RelativeSpeed { get; set; } +} + +[MessagePackObject] +internal sealed class YmirFixtureCircleOverlapHit +{ + [Key(0)] public string BodyId { get; set; } = ""; + [Key(1)] public CultVec2 Point { get; set; } + [Key(2)] public CultVec2 Normal { get; set; } + [Key(3)] public float Penetration { get; set; } + [Key(4)] public float Distance { get; set; } +} + +[MessagePackObject] +internal sealed class YmirFixtureSphereOverlapHit +{ + [Key(0)] public string BodyId { get; set; } = ""; + [Key(1)] public CultVec3 Point { get; set; } + [Key(2)] public CultVec3 Normal { get; set; } + [Key(3)] public float Penetration { get; set; } + [Key(4)] public float Distance { get; set; } +} + +[MessagePackObject] +internal sealed class YmirFixtureCircleCastHit +{ + [Key(0)] public string BodyId { get; set; } = ""; + [Key(1)] public CultVec2 Point { get; set; } + [Key(2)] public CultVec2 Normal { get; set; } + [Key(3)] public float Distance { get; set; } +} + +[MessagePackObject] +internal sealed class YmirFixtureSphereCastHit +{ + [Key(0)] public string BodyId { get; set; } = ""; + [Key(1)] public CultVec3 Point { get; set; } + [Key(2)] public CultVec3 Normal { get; set; } + [Key(3)] public float Distance { get; set; } +} + +[MessagePackObject] +internal sealed class AuthorityDecisionFixtureSet +{ + [Key(0)] public string Schema { get; set; } = ""; + [Key(1)] public string GeneratedAtUtc { get; set; } = ""; + [Key(2)] public AuthorityDecisionCase[] Cases { get; set; } = Array.Empty(); +} + +[MessagePackObject] +internal sealed class AuthorityDecisionCase +{ + [Key(0)] public string CaseId { get; set; } = ""; + [Key(1)] public string LocalRuntimeId { get; set; } = ""; + [Key(2)] public AetheriaRuntimeVerseAuthorityPolicyDocument Policy { get; set; } = new(); + [Key(3)] public AetheriaRuntimeAuthorityLeaseDocument[] Leases { get; set; } = Array.Empty(); + [Key(4)] public AetheriaRuntimeDaemonCommandDocument Command { get; set; } = new(); + [Key(5)] public AuthorityDecisionSnapshot Decision { get; set; } = new(); +} + +[MessagePackObject] +internal sealed class AuthorityDecisionSnapshot +{ + [Key(0)] public bool Authorized { get; set; } + [Key(1)] public string Reason { get; set; } = ""; + [Key(2)] public string Mode { get; set; } = ""; + [Key(3)] public string SubjectKey { get; set; } = ""; + [Key(4)] public string ClaimKind { get; set; } = ""; + [Key(5)] public string AuthorRuntimeId { get; set; } = ""; + [Key(6)] public string RuleId { get; set; } = ""; +} + +internal static class FreezeJson +{ + public static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + Converters = { new JsonStringEnumConverter() } + }; +} + +internal sealed class FreezeOptions +{ + public string Root { get; init; } = ""; + public string StatePath { get; init; } = ""; + public string OutputDirectory { get; init; } = ""; + public bool RunDaemon { get; init; } = true; + public bool CleanState { get; init; } = true; + public bool IncludeCommandBatches { get; init; } = true; + public double ViewportMinX { get; init; } = -1000; + public double ViewportMinY { get; init; } = -1000; + public double ViewportMaxX { get; init; } = 1000; + public double ViewportMaxY { get; init; } = 1000; + + public static FreezeOptions Parse(IReadOnlyList args) + { + var root = FullPath(ReadOption(args, "--root"), Directory.GetCurrentDirectory()); + var defaultState = Path.Combine(root, "obj", "freeze", "aetheria-freeze-state.cc"); + var defaultOutput = Path.Combine(root, "Aetheria.State", "fixtures", "rust-rebuild-freeze"); + return new FreezeOptions + { + Root = root, + StatePath = FullPath(ReadOption(args, "--state"), defaultState), + OutputDirectory = FullPath(ReadOption(args, "--out"), defaultOutput), + RunDaemon = !HasFlag(args, "--no-run-daemon"), + CleanState = !HasFlag(args, "--no-clean-state"), + IncludeCommandBatches = !HasFlag(args, "--no-command-batches"), + ViewportMinX = ReadDouble(args, "--viewport-min-x") ?? -1000, + ViewportMinY = ReadDouble(args, "--viewport-min-y") ?? -1000, + ViewportMaxX = ReadDouble(args, "--viewport-max-x") ?? 1000, + ViewportMaxY = ReadDouble(args, "--viewport-max-y") ?? 1000 + }; + } + + private static string FullPath(string value, string fallback) + { + return Path.GetFullPath(string.IsNullOrWhiteSpace(value) ? fallback : value); + } + + private static string ReadOption(IReadOnlyList args, string name) + { + for (var i = 0; i < args.Count - 1; i++) + { + if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + } + + return ""; + } + + private static double? ReadDouble(IReadOnlyList args, string name) + { + return double.TryParse(ReadOption(args, name), out var value) ? value : null; + } + + private static bool HasFlag(IReadOnlyList args, string name) + { + return args.Any(arg => string.Equals(arg, name, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Aetheria.State.Replica/Aetheria.State.Replica.csproj b/Aetheria.State.Replica/Aetheria.State.Replica.csproj index 41b539514..85c9ae99e 100644 --- a/Aetheria.State.Replica/Aetheria.State.Replica.csproj +++ b/Aetheria.State.Replica/Aetheria.State.Replica.csproj @@ -10,6 +10,5 @@ - diff --git a/Aetheria.State.Replica/Program.cs b/Aetheria.State.Replica/Program.cs index 314dab576..30feca8fe 100644 --- a/Aetheria.State.Replica/Program.cs +++ b/Aetheria.State.Replica/Program.cs @@ -1,5 +1,4 @@ using Aetheria.State; -using GameCult.Aetheria.State.Unity; return await ProgramMainAsync(args).ConfigureAwait(false); @@ -16,6 +15,7 @@ static async Task ProgramMainAsync(string[] args) var endpoint = RequireOption(options, "endpoint"); var runtimeId = ReadOption(options, "runtime-id", "aetheria-verse-replica"); var replicaPath = ResolveReplicaPath(options); + SeedBaselineReplica(options, replicaPath); switch (mode) { @@ -59,9 +59,40 @@ static string ResolveReplicaPath(IReadOnlyDictionary options) var gameDataRoot = RequireOption(options, "game-data-root"); var verseId = RequireOption(options, "verse-id"); - return AetheriaRuntimeStateBoundary.GetReplicaStateFilePath( - new DirectoryInfo(Path.GetFullPath(gameDataRoot)), - verseId); + return Path.Combine( + Path.GetFullPath(gameDataRoot), + "Verses", + $"{SanitizeVerseId(verseId)}.cc"); +} + +static void SeedBaselineReplica(IReadOnlyDictionary options, string replicaPath) +{ + var baselinePath = ReadOption(options, "baseline-state", ""); + if (string.IsNullOrWhiteSpace(baselinePath) || + File.Exists(replicaPath) || + !File.Exists(baselinePath)) + { + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(replicaPath) ?? "."); + File.Copy(baselinePath, replicaPath, overwrite: false); +} + +static string SanitizeVerseId(string verseId) +{ + var safeVerseId = string.IsNullOrWhiteSpace(verseId) + ? "unknown-verse" + : new string((verseId ?? "") + .Select(ch => char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' || ch == '.' + ? ch + : '-') + .ToArray()) + .Trim('-'); + + return string.IsNullOrWhiteSpace(safeVerseId) + ? "unknown-verse" + : safeVerseId; } static Dictionary ParseOptions(IEnumerable args) @@ -114,7 +145,7 @@ static double ReadDoubleOption(IReadOnlyDictionary options, stri static void PrintUsage() { Console.WriteLine("Usage:"); - Console.WriteLine(" sync --endpoint cultnet://host:3075 --replica path-to-replica.cc"); - Console.WriteLine(" sync --endpoint cultnet://host:3075 --game-data-root GameData --verse-id verse.id"); - Console.WriteLine(" follow --endpoint cultnet://host:3075 --replica path-to-replica.cc [--poll-seconds 1]"); + Console.WriteLine(" sync --endpoint cultnet://host:3075 --replica path-to-replica.cc [--runtime-id aetheria-verse-replica]"); + Console.WriteLine(" sync --endpoint cultnet://host:3075 --game-data-root GameData --verse-id verse.id [--runtime-id aetheria-verse-replica]"); + Console.WriteLine(" follow --endpoint cultnet://host:3075 --replica path-to-replica.cc [--poll-seconds 1] [--runtime-id aetheria-verse-replica]"); } diff --git a/Aetheria.State.Smoke/Program.cs b/Aetheria.State.Smoke/Program.cs index 12ce3b443..3b296b514 100644 --- a/Aetheria.State.Smoke/Program.cs +++ b/Aetheria.State.Smoke/Program.cs @@ -139,25 +139,24 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s await node.PutDaemonProviderAdvertisementAsync(daemonProvider); await node.PutDaemonHealthAsync(daemonHealth); await node.PutDaemonCommandBoundaryAsync(daemonCommandBoundary); - using var daemonCommandClient = await AetheriaRuntimeVerseClient.OpenAsync( + using var daemonCommandClient = await AetheriaClient.OpenAsync( statePath, "aetheria-state-smoke-command-client", + "smoke-session", startServer: false, pullOnOpen: true); - var daemonSubmitEnvelope = await daemonCommandClient.SubmitDaemonCommandAsync( - AetheriaRuntimeDaemonCommandDocument.Create( - AetheriaRuntimeDaemonCommandKinds.SensorPing, - "aetheria-state-smoke", - "smoke-session", - daemonFrame.FrameId, - entityKey.ToString())); + daemonCommandClient.Control.SensorPing(); await using (var commandVerifyNode = await AetheriaStateNode.OpenAsync( statePath, "aetheria-state-smoke-command-check")) { - if (commandVerifyNode.ReadObservedDaemonCommands().All(command => command.CommandId != daemonSubmitEnvelope!.CommandId)) - throw new InvalidOperationException("Verse client daemon submission did not appear as a typed state record."); + if (commandVerifyNode.ReadObservedDaemonCommands().All(command => + command.Kind != AetheriaRuntimeDaemonCommandKinds.SensorPing || + command.ClientId != "aetheria-state-smoke-command-client")) + { + throw new InvalidOperationException("AetheriaClient control submission did not appear as a typed daemon state record."); + } } await node.PutDaemonFrameAsync(daemonFrame); await node.PutDaemonGameSurfaceAsync(AetheriaRuntimeEveSurfaceStateProjector.ToState(daemonGameSurface)); @@ -480,14 +479,6 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s GenerationSeed = 424242, DiscoveredZoneIndices = [0], ZoneKeys = [zoneKey.ToString()], - ActionBarBindings = - [ - new AetheriaActionBarBinding - { - Kind = "weapon-group", - WeaponGroup = 0 - } - ], UpdatedAtUtc = now }); @@ -534,23 +525,31 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s await node.GetPlayerSettingsAsync(), now)); - using var eveCommandClient = await AetheriaRuntimeVerseClient.OpenAsync( + using var eveCommandClient = await AetheriaClient.OpenAsync( statePath, "aetheria-state-smoke-eve-command-client", startServer: false, pullOnOpen: true); - var eveSubmitEnvelope = await eveCommandClient.SubmitEveCommandAsync( - AetheriaRuntimeEveCommandClient.ToDocument( - AetheriaRuntimeEveCommands.SubmitCatalogCommand( - statePath, - AetheriaRuntimeEveCommandKind.CatalogRefresh, - "aetheria-state-smoke"))); + await eveCommandClient.Ui.InputSettingsAsync( + AetheriaRuntimeEveCommandKind.SetBindingOverride, + new AetheriaRuntimeInputSettingsCommandBody + { + ActionName = "Thrust", + BindingIndex = 0, + InputSystemPath = "/w", + Enabled = true + }, + "aetheria-state-smoke-eve-command-client"); await using (var commandVerifyNode = await AetheriaStateNode.OpenAsync( statePath, "aetheria-state-smoke-eve-command-check")) { - if (commandVerifyNode.ReadObservedEveCommands().All(command => command.CommandId != eveSubmitEnvelope!.CommandId)) - throw new InvalidOperationException("Verse client Eve submission did not appear as a typed state record."); + if (commandVerifyNode.ReadObservedEveCommands().All(command => + command.Kind != AetheriaRuntimeEveCommandKind.SetBindingOverride || + command.ClientId != "aetheria-state-smoke-eve-command-client")) + { + throw new InvalidOperationException("AetheriaClient UI submission did not appear as a typed Eve state record."); + } } await node.SubmitEveCommandAsync(AetheriaRuntimeEveCommandClient.ToDocument( AetheriaRuntimeEveCommands.SubmitCatalogCommand( @@ -766,8 +765,7 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether if (runState?.RunId != "smoke" || runState.GenerationSeed != 424242 || runState.CurrentEntityKey != entityKey.ToString() || - runState.ZoneKeys.Length != 1 || - runState.ActionBarBindings.Length != 1) + runState.ZoneKeys.Length != 1) { throw new InvalidOperationException("Run state did not survive flush/reopen."); } diff --git a/Aetheria.State.Unity/AetheriaRuntimeCatalogSnapshotMapper.cs b/Aetheria.State.Unity/AetheriaRuntimeCatalogSnapshotMapper.cs index 00fd3af75..cb95ec9fa 100644 --- a/Aetheria.State.Unity/AetheriaRuntimeCatalogSnapshotMapper.cs +++ b/Aetheria.State.Unity/AetheriaRuntimeCatalogSnapshotMapper.cs @@ -10,7 +10,8 @@ internal static class AetheriaRuntimeCatalogSnapshotMapper return new AetheriaRuntimeCatalogSnapshot( catalog.Items.Select(FromState).ToArray(), catalog.Corporations.Select(FromState).ToArray(), - catalog.NameFiles.Select(FromState).ToArray()); + catalog.NameFiles.Select(FromState).ToArray(), + AetheriaRuntimeStateMapper.ToRuntimeTradeValueSettings(catalog.TradeValuePolicy)); } private static AetheriaRuntimeCatalogItem FromState(AetheriaItemDefinition item) diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 570215ca7..5729bd996 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -52,6 +52,7 @@ RequireTradeItemDetailsUseEveSurface(root); RequireTradeItemValuesUseRuntimeQueries(root); RequireItemTierProjectionUsesRuntimeQueries(root); RequireInventoryDropdownUseEveSurface(root); +RequireStationRefitDockingBaysUseTypedProjection(root); RequireNoDeadPopupShells(root); RequirePlayerSettingsEveSurface(root); RequireVerseHostSettingsAuthority(root); @@ -86,11 +87,15 @@ RequireLootPickupRequestAuthority(root); RequireEntityDestroyedRequestAuthority(root); RequireDroppedPickupCheckpointState(root); RequireTradePurchaseRequestAuthority(root); +RequireInventoryProjectionSlotIdentity(root); RequireInventoryLoadoutSaveRequestAuthority(root); RequireInventoryLoadoutRestoreRequestAuthority(root); RequireDockedCurrentShipRequestAuthority(root); -await using var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-state-verify"); +await using var node = await AetheriaStateNode.OpenAsync( + statePath, + "aetheria-state-verify", + enableDurableShardLogs: false); var ledger = await node.GetMigrationLedgerAsync() ?? throw new InvalidOperationException("Missing typed migration ledger."); @@ -104,6 +109,10 @@ var corporations = node.Cache.GetAll().ToArray(); var nameFiles = node.Cache.GetAll().ToArray(); var catalog = node.ReadCatalogSnapshot(); var surface = AetheriaCatalogSurfaceProjector.Build(catalog, DateTimeOffset.UtcNow.ToString("O")); +var tradeValuePolicy = await node.GetTradeValuePolicyAsync() + ?? throw new InvalidOperationException("Missing authored trade value policy document."); +await RequireTradeValuePolicyEveCommandPersistsAsync(); +var runtimeCatalog = AetheriaRuntimeStateReader.OpenRuntimeCatalog(statePath); RequireCount(ledger, "aetheria.item_definition.v1", items.Length); RequireCount(ledger, "aetheria.corporation.v2", corporations.Length); @@ -129,6 +138,20 @@ if (nameFiles.Any(nameFile => nameFile.Names.Length == 0 || nameFile.Names.Lengt throw new InvalidOperationException("Typed name files did not import their full name arrays."); } +if (tradeValuePolicy.QualityPriceModifier == null || + tradeValuePolicy.QualityPriceModifier.Exponent <= 0 || + tradeValuePolicy.QualityPriceModifier.Maximum <= tradeValuePolicy.QualityPriceModifier.Minimum || + tradeValuePolicy.Tiers.Length == 0) +{ + throw new InvalidOperationException("Authored trade value policy is missing quality pricing or rarity tiers."); +} + +if (runtimeCatalog.TradeValueSettings.Tiers.Count != tradeValuePolicy.Tiers.Length || + Math.Abs(runtimeCatalog.TradeValueSettings.QualityPriceModifier.Exponent - tradeValuePolicy.QualityPriceModifier.Exponent) > 0.0001) +{ + throw new InvalidOperationException("Runtime catalog did not read the authored trade value policy."); +} + var pricedItems = items.Count(item => item.Price > 0); var manufacturedItems = items.Count(item => !string.IsNullOrWhiteSpace(item.ManufacturerLegacyId)); var specificHeatItems = items.Count(item => item.SpecificHeat > 0); @@ -566,7 +589,7 @@ Console.WriteLine("Runtime simulation tuning authority: UI sends daemon operatio Console.WriteLine("Hull conductivity authority: inventory UI sends daemon operations for toggles instead of checkpoint rewrites"); Console.WriteLine("Inventory entity rename authority: UI sends daemon operations for renames instead of local graph edits"); Console.WriteLine("Weapon group authority: UI sends daemon operations for assignments instead of checkpoint rewrites"); -Console.WriteLine("Action-bar binding authority: drag/drop sends daemon operations and restores from daemon frames"); +Console.WriteLine("Action-bar binding authority: Unity owns local input bindings; activations send daemon operations"); Console.WriteLine("Inventory transfer authority: UI transfer and drag/drop send daemon operations"); Console.WriteLine("Loot pickup authority: collision pickup sends daemon operations instead of local pickup disposal"); Console.WriteLine("Entity destruction authority: hull-death observers send daemon operations instead of local graph deletion"); @@ -646,7 +669,56 @@ static void RequireSharedEvePackagesImportedFromEveRepo(string root) var localUnityUiToolkitPath = Path.Combine(root, "Packages", "org.gamecult.eve.unity-uitoolkit"); var evePackagesRoot = Path.GetFullPath(Path.Combine(root, "..", "Eve", "packages")); var upstreamSurfacePath = Path.Combine(evePackagesRoot, "org.gamecult.eve.surface", "package.json"); + var upstreamSurfaceDocumentPath = Path.Combine( + evePackagesRoot, + "org.gamecult.eve.surface", + "Runtime", + "EveSurfaceDocument.cs"); + var upstreamSurfaceAsmdefPath = Path.Combine( + evePackagesRoot, + "org.gamecult.eve.surface", + "Runtime", + "GameCult.Eve.Surface.asmdef"); var upstreamUnityUiToolkitPath = Path.Combine(evePackagesRoot, "org.gamecult.eve.unity-uitoolkit", "package.json"); + var upstreamUnityUiToolkitLowererPath = Path.Combine( + evePackagesRoot, + "org.gamecult.eve.unity-uitoolkit", + "Runtime", + "EveUiToolkitSurfaceLowerer.cs"); + var runtimeAdapterPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeEveSurfaceAdapter.cs"); + var runtimeSurfaceBuilderPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimePlayerSettingsSurfaceBuilder.cs"); + var runtimeCultCacheDocumentStorePath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeCultCacheDocumentStore.cs"); + var runtimeSurfaceStateProjectorPath = Path.Combine( + root, + "Aetheria.State", + "AetheriaRuntimeEveSurfaceStateProjector.cs"); + var playerSettingsSurfaceProjectorPath = Path.Combine( + root, + "Aetheria.State", + "AetheriaPlayerSettingsSurfaceProjector.cs"); + var catalogSurfaceProjectorPath = Path.Combine( + root, + "Aetheria.State", + "AetheriaCatalogSurfaceProjector.cs"); + var operationsSurfaceProjectorPath = Path.Combine( + root, + "Aetheria.State", + "AetheriaOperationsSurfaceProjector.cs"); var manifest = File.Exists(manifestPath) ? File.ReadAllText(manifestPath) @@ -660,6 +732,36 @@ static void RequireSharedEvePackagesImportedFromEveRepo(string root) var unityAsmdef = File.Exists(unityAsmdefPath) ? File.ReadAllText(unityAsmdefPath) : throw new InvalidOperationException("Cannot verify shared Eve package ownership; GameCult.Aetheria.State.Unity.asmdef is missing."); + var upstreamSurfaceDocument = File.Exists(upstreamSurfaceDocumentPath) + ? File.ReadAllText(upstreamSurfaceDocumentPath) + : throw new InvalidOperationException("Cannot verify shared Eve package ownership; upstream EveSurfaceDocument.cs is missing."); + var upstreamSurfaceAsmdef = File.Exists(upstreamSurfaceAsmdefPath) + ? File.ReadAllText(upstreamSurfaceAsmdefPath) + : throw new InvalidOperationException("Cannot verify shared Eve package ownership; upstream GameCult.Eve.Surface.asmdef is missing."); + var upstreamUnityUiToolkitLowerer = File.Exists(upstreamUnityUiToolkitLowererPath) + ? File.ReadAllText(upstreamUnityUiToolkitLowererPath) + : throw new InvalidOperationException("Cannot verify shared Eve package ownership; upstream EveUiToolkitSurfaceLowerer.cs is missing."); + var runtimeAdapter = File.Exists(runtimeAdapterPath) + ? File.ReadAllText(runtimeAdapterPath) + : throw new InvalidOperationException("Cannot verify shared Eve package ownership; AetheriaRuntimeEveSurfaceAdapter.cs is missing."); + var runtimeSurfaceBuilder = File.Exists(runtimeSurfaceBuilderPath) + ? File.ReadAllText(runtimeSurfaceBuilderPath) + : throw new InvalidOperationException("Cannot verify shared Eve package ownership; AetheriaRuntimePlayerSettingsSurfaceBuilder.cs is missing."); + var runtimeCultCacheDocumentStore = File.Exists(runtimeCultCacheDocumentStorePath) + ? File.ReadAllText(runtimeCultCacheDocumentStorePath) + : throw new InvalidOperationException("Cannot verify shared Eve package ownership; AetheriaRuntimeCultCacheDocumentStore.cs is missing."); + var runtimeSurfaceStateProjector = File.Exists(runtimeSurfaceStateProjectorPath) + ? File.ReadAllText(runtimeSurfaceStateProjectorPath) + : throw new InvalidOperationException("Cannot verify shared Eve package ownership; AetheriaRuntimeEveSurfaceStateProjector.cs is missing."); + var playerSettingsSurfaceProjector = File.Exists(playerSettingsSurfaceProjectorPath) + ? File.ReadAllText(playerSettingsSurfaceProjectorPath) + : throw new InvalidOperationException("Cannot verify shared Eve package ownership; AetheriaPlayerSettingsSurfaceProjector.cs is missing."); + var catalogSurfaceProjector = File.Exists(catalogSurfaceProjectorPath) + ? File.ReadAllText(catalogSurfaceProjectorPath) + : throw new InvalidOperationException("Cannot verify shared Eve package ownership; AetheriaCatalogSurfaceProjector.cs is missing."); + var operationsSurfaceProjector = File.Exists(operationsSurfaceProjectorPath) + ? File.ReadAllText(operationsSurfaceProjectorPath) + : throw new InvalidOperationException("Cannot verify shared Eve package ownership; AetheriaOperationsSurfaceProjector.cs is missing."); var requiredManifestSymbols = new[] { @@ -715,6 +817,81 @@ static void RequireSharedEvePackagesImportedFromEveRepo(string root) throw new InvalidOperationException( "Aetheria.State.Unity no longer references the shared Eve surface package assembly."); } + + if (!upstreamSurfaceAsmdef.Contains("\"GameCult.Mesh\"", StringComparison.Ordinal) || + !upstreamSurfaceDocument.Contains("using GameCult.Mesh;", StringComparison.Ordinal) || + !upstreamSurfaceDocument.Contains("public IReadOnlyList StateBindings", StringComparison.Ordinal) || + !upstreamSurfaceDocument.Contains("public CultMeshOperationBindingDescriptor Operation", StringComparison.Ordinal) || + !upstreamSurfaceDocument.Contains("public CultMeshOperationInvocationDescriptor Operation", StringComparison.Ordinal) || + !upstreamSurfaceDocument.Contains("public CultMeshOperationPayload Payload", StringComparison.Ordinal) || + !upstreamSurfaceDocument.Contains("public string Command => Operation.OperationId", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The shared Eve surface package no longer exposes first-class CultMesh state bindings and operation descriptors."); + } + + if (upstreamSurfaceDocument.Contains("EveCommandTemplate(string command", StringComparison.Ordinal) || + upstreamSurfaceDocument.Contains("string command,\r\n IReadOnlyDictionary payload", StringComparison.Ordinal) || + upstreamSurfaceDocument.Contains("CultMeshOperationInvocationDescriptor operation,\r\n IReadOnlyDictionary payload", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The shared Eve surface package has reintroduced raw command or dictionary payload request constructors."); + } + + if (!upstreamUnityUiToolkitLowerer.Contains("ResolveOperation(document, command)", StringComparison.Ordinal) || + !upstreamUnityUiToolkitLowerer.Contains("CultMesh.OperationInvocation(template.Operation)", StringComparison.Ordinal) || + !upstreamUnityUiToolkitLowerer.Contains("CultMesh.OperationPayload(component.Props)", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The shared Eve Unity UI Toolkit lowerer no longer emits CultMesh operation invocation descriptors and payloads."); + } + + if (runtimeSurfaceBuilder.Contains("public sealed class AetheriaRuntimeSurfaceStateBinding", StringComparison.Ordinal) || + !runtimeSurfaceBuilder.Contains("public IReadOnlyList StateBindings", StringComparison.Ordinal) || + !runtimeSurfaceBuilder.Contains("public static CultMeshStateBindingDescriptor ForDaemonStateRef(", StringComparison.Ordinal) || + !runtimeSurfaceBuilder.Contains("public CultMeshOperationBindingDescriptor Operation", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Aetheria runtime surfaces should use CultMesh state and operation descriptors directly instead of local live binding DTOs."); + } + + if (!runtimeAdapter.Contains("component.StateBindings.Select(ToCultMeshStateBinding).ToArray()", StringComparison.Ordinal) || + !runtimeAdapter.Contains("private static CultMeshStateBindingDescriptor ToCultMeshStateBinding(", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Aetheria runtime surfaces no longer hand CultMesh state bindings to the shared Eve component model."); + } + + if (!runtimeSurfaceBuilder.Contains("CultMesh.StateBindingRecord(binding)", StringComparison.Ordinal) || + !runtimeAdapter.Contains("CultMesh.StateBindingRecord(", StringComparison.Ordinal) || + !runtimeAdapter.Contains(".ToBinding()", StringComparison.Ordinal) || + runtimeSurfaceBuilder.Contains("CultMesh.RouteRecord(binding.RouteHint)", StringComparison.Ordinal) || + runtimeAdapter.Contains("CultMesh.RouteRecord(binding.RouteKind, binding.RouteDescription)", StringComparison.Ordinal) || + runtimeSurfaceBuilder.Contains("ParseRouteKind(", StringComparison.Ordinal) || + runtimeAdapter.Contains("ParseRouteKind(", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Aetheria runtime surfaces reintroduced local state binding flattening; state binding persistence belongs to CultMesh.StateBindingRecord."); + } + + var surfaceCommandProjectors = string.Join( + "\n", + runtimeSurfaceStateProjector, + playerSettingsSurfaceProjector, + catalogSurfaceProjector, + operationsSurfaceProjector); + if (!runtimeCultCacheDocumentStore.Contains("CultMesh.OperationBindingRecord(command.Operation)", StringComparison.Ordinal) || + !runtimeCultCacheDocumentStore.Contains("CultMesh.OperationBindingRecord(", StringComparison.Ordinal) || + !runtimeCultCacheDocumentStore.Contains("routeDescription).ToBinding()", StringComparison.Ordinal) || + !runtimeAdapter.Contains("CultMesh.OperationBindingRecord(", StringComparison.Ordinal) || + !runtimeAdapter.Contains("command.SchemaId", StringComparison.Ordinal) || + !surfaceCommandProjectors.Contains("CultMesh.OperationBindingRecord(", StringComparison.Ordinal) || + runtimeCultCacheDocumentStore.Contains("new CultMeshOperationBindingDescriptor(", StringComparison.Ordinal) || + runtimeAdapter.Contains("new CultMeshOperationBindingDescriptor(", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Aetheria runtime surfaces reintroduced local operation binding flattening; surface command persistence belongs to CultMesh.OperationBindingRecord."); + } } static void RequireSharedRuntimeSurfaceCommandsUseCultMeshTransport(string root) @@ -804,7 +981,10 @@ static void RequirePackageSerializerBoundary(string root) "AetheriaRuntimeEveCommandDocument.cs", "AetheriaRuntimeEveSurfaceState.cs", "AetheriaRuntimeDaemonDocuments.cs", - "AetheriaRuntimeDaemonSoaDocuments.cs" + "AetheriaRuntimeDaemonSoaDocuments.cs", + "AetheriaRuntimeRtsViewportDocuments.cs", + "AetheriaRuntimeStarbridgeDocuments.cs", + "AetheriaRuntimeVerseAuthorityPolicy.cs" }; var serializerSymbols = new[] @@ -1434,11 +1614,11 @@ static void RequireTypedOrbitalEntityOrbitKeys(string root) "turret.OrbitKey = turretOrbit.OrbitKey;", "station.OrbitKey = lagrangeOrbit.OrbitKey;" }, - [Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs")] = new[] + [Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityCurrentEntityPresentation.cs")] = new[] { - "FindCurrentDaemonZoneSnapshot()", - "daemonZone?.Orbits ?? Array.Empty()", - "string.Equals(orbit.OrbitKey ?? \"\", orbital.OrbitKey, StringComparison.Ordinal)", + "AetheriaRuntimeZoneRenderDocument zoneRender", + "zoneRender?.BodyPoses ?? Array.Empty()", + "string.Equals(body.OrbitKey ?? \"\", orbital.OrbitKey, StringComparison.Ordinal)", "string.Equals(body.OrbitKey ?? \"\", parentOrbitKey, StringComparison.Ordinal)" }, [Path.Combine(root, "Assets", "Scripts", "ServerShared", "Zone.cs")] = new[] @@ -1508,7 +1688,9 @@ static void RequireTypedOrbitConsumerKeys(string root) { "private readonly List _daemonBodyViews", "private void SyncDaemonBodyViews()", - "AetheriaRuntimeDaemonRenderQueries.QueryBodyViews(", + "private AetheriaRuntimeGravityViewportDocument _daemonGravityViewport;", + "GravityViewportAsync(viewport)", + "foreach (var bodyView in _daemonGravityViewport?.Bodies ?? Array.Empty())", "ResolveDaemonRenderViewport()", "private AetheriaRuntimeXzRect ResolveDaemonRenderViewport()", "private void UnloadBodyView(string bodyKey)", @@ -1813,6 +1995,31 @@ static void RequireDaemonRenderQueryAuthority(string root) var actionGameManager = File.Exists(actionGameManagerPath) ? File.ReadAllText(actionGameManagerPath) : throw new InvalidOperationException("Cannot verify daemon render query authority; ActionGameManager.cs is missing."); + var targetPresentationPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityTargetPresentation.cs"); + var targetPresentation = File.Exists(targetPresentationPath) + ? File.ReadAllText(targetPresentationPath) + : throw new InvalidOperationException("Cannot verify daemon render query authority; AetheriaUnityTargetPresentation.cs is missing."); + var pilotFrameAdapterPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityPilotFrameAdapter.cs"); + var pilotFrameAdapter = File.Exists(pilotFrameAdapterPath) + ? File.ReadAllText(pilotFrameAdapterPath) + : throw new InvalidOperationException("Cannot verify daemon render query authority; AetheriaUnityPilotFrameAdapter.cs is missing."); + var gameplayInputShellPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplayInputShell.cs"); + var gameplayInputShell = File.Exists(gameplayInputShellPath) + ? File.ReadAllText(gameplayInputShellPath) + : throw new InvalidOperationException("Cannot verify daemon render query authority; AetheriaUnityGameplayInputShell.cs is missing."); + var renderSettingsBridgePath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityRenderSettingsBridge.cs"); + var renderSettingsBridge = File.Exists(renderSettingsBridgePath) + ? File.ReadAllText(renderSettingsBridgePath) + : throw new InvalidOperationException("Cannot verify daemon render query authority; AetheriaUnityRenderSettingsBridge.cs is missing."); + var cockpitHudShellPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityCockpitHudShell.cs"); + var cockpitHudShell = File.Exists(cockpitHudShellPath) + ? File.ReadAllText(cockpitHudShellPath) + : throw new InvalidOperationException("Cannot verify daemon render query authority; AetheriaUnityCockpitHudShell.cs is missing."); + var gameplayBootShellPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplayBootShell.cs"); + var gameplayBootShell = File.Exists(gameplayBootShellPath) + ? File.ReadAllText(gameplayBootShellPath) + : throw new InvalidOperationException("Cannot verify daemon render query authority; AetheriaUnityGameplayBootShell.cs is missing."); + var unityRenderPresentation = actionGameManager + "\n" + targetPresentation + "\n" + pilotFrameAdapter + "\n" + gameplayInputShell + "\n" + renderSettingsBridge + "\n" + cockpitHudShell + "\n" + gameplayBootShell; var canonicalSnapshotPath = Path.Combine(root, "Aetheria.State", "Documents", "AetheriaRuntimeStateDocuments.cs"); var canonicalSnapshot = File.Exists(canonicalSnapshotPath) @@ -2135,16 +2342,20 @@ static void RequireDaemonRenderQueryAuthority(string root) var requiredZoneRendererSymbols = new[] { "public AetheriaRuntimeDaemonRenderSettings RenderSettings { get; set; }", - "private AetheriaRuntimeRunCheckpointCommit _daemonRunSnapshot;", - "private AetheriaRuntimeZoneSnapshotCommit _daemonZoneSnapshot;", + "private string _daemonCurrentEntityKey = \"\";", + "private double _daemonSimulationTimeSeconds;", "private readonly List _daemonBodyViews", - "private readonly List _daemonBodyPoses", - "private readonly Dictionary _daemonBodyPosesByBodyKey", + "private AetheriaRuntimeGravityViewportDocument _daemonGravityViewport;", + "private readonly List _daemonBodyPoses", + "private readonly Dictionary _daemonBodyPosesByBodyKey", "private readonly Dictionary _bodyViewsByBodyKey", "public bool TryGetBodyView(string bodyKey, out PlanetObject bodyView)", - "private readonly List _daemonAsteroidBeltPoses", + "private readonly List _daemonAsteroidBeltPoses", + "private readonly List _visibleAsteroidInstancePoses", "private readonly List _daemonCompassMarkers", "private readonly Dictionary _daemonCompassMarkersByEntityIndex", + "private readonly Dictionary _daemonTargetRowsByEntityIndex", + "private readonly List _daemonContactRows", "private IReadOnlyDictionary _observedEntityFacadesByDaemonIndex", "private readonly List _daemonPresentationEntityIndices", "private readonly HashSet _daemonPresentationEntityIndicesSet", @@ -2152,38 +2363,37 @@ static void RequireDaemonRenderQueryAuthority(string root) "private readonly HashSet _daemonVisibleEntityIndicesSet", "private readonly HashSet _visibleDaemonEntityIndices", "private readonly List _daemonWormholeExits", + "render?.WormholeExits ?? Array.Empty()", + "_daemonWormholeExits.Add(new AetheriaRuntimeDaemonWormholeExit(", "public Dictionary WormholeInstances", "public void LoadDaemonZoneView(", "IReadOnlyDictionary observedEntityFacadesByDaemonIndex,", - "AetheriaRuntimeRunCheckpointCommit daemonRun = null", + "AetheriaRuntimeZoneRenderDocument render)", "public BodySettingsCollection[] BodySettingsCollections;", - "public void ApplyDaemonFrame(", - "AetheriaRuntimeZoneSnapshotCommit daemonZone,", - "AetheriaRuntimeRunCheckpointCommit daemonRun)", - "_daemonZoneSnapshot = daemonZone;", - "_daemonRunSnapshot = daemonRun;", + "public void ApplyZoneRender(AetheriaRuntimeZoneRenderDocument render)", + "_daemonCurrentEntityKey = render?.CurrentEntityKey ?? \"\";", + "_daemonSimulationTimeSeconds = render?.SimulationTimeSeconds ?? 0;", "_observedEntityFacadesByDaemonIndex = observedEntityFacadesByDaemonIndex;", - "AetheriaRuntimeDaemonRenderQueries.EvaluateGravityTerrainHeight(", - "AetheriaRuntimeDaemonRenderQueries.ResolveZoneRenderRadius(", - " 2000);", - "AetheriaRuntimeDaemonRenderQueries.QueryGravityTerrainBand(", - "private readonly List _visibleAsteroidInstancePoses", + "render?.ZoneRenderRadius ?? 2000", "private readonly HashSet _daemonVisibleBodyKeys", "private void SyncDaemonBodyViews()", + "GravityViewportAsync(viewport)", "foreach (var bodyView in _daemonBodyViews)", "if (bodyView.IsAsteroidBelt)", - "AetheriaRuntimeDaemonRenderQueries.QueryBodyViews(", + "foreach (var bodyView in _daemonGravityViewport?.Bodies ?? Array.Empty())", "ResolveDaemonRenderViewport()", "private AetheriaRuntimeXzRect ResolveDaemonRenderViewport()", + "private static AetheriaRuntimeRtsViewportBounds ToViewportBounds(AetheriaRuntimeXzRect viewport)", "private void UnloadBodyView(string bodyKey)", "foreach (var pose in _daemonBodyPoses)", "LoadPlanet(body)", "void LoadPlanet(AetheriaRuntimeBodySnapshotCommit body)", "beltPosesByBodyKey.TryGetValue(bodyKey, out var beltPose)", "LoadAsteroidBelt(beltPose)", - "void LoadAsteroidBelt(AetheriaRuntimeDaemonAsteroidBeltPose beltPose)", + "void LoadAsteroidBelt(AetheriaRuntimeZoneRenderAsteroidBeltPose beltPose)", "private void SyncDaemonEntityInstances()", - "AetheriaRuntimeDaemonRenderQueries.QueryPresentationEntityIndices(", + "ObjectsViewportAsync(ToViewportBounds(viewport))", + "foreach (var entity in objects?.Objects ?? Array.Empty())", "_observedEntityFacadesByDaemonIndex.TryGetValue(entityIndex, out var entity)", "Loading entity {entity.Name} from daemon presentation query", "UnloadEntity(pair.Value.Entity)", @@ -2192,22 +2402,24 @@ static void RequireDaemonRenderQueryAuthority(string root) "public bool TryGetEntityInstance(int daemonEntityIndex, out EntityInstance instance)", "public bool TryGetEntityInstance(Entity entity, out EntityInstance instance)", "public bool TryGetDaemonTargetDistance(int daemonEntityIndex, out float distance)", - "AetheriaRuntimeDaemonRenderQueries.TryQueryEntityTarget(", + "_daemonTargetRowsByEntityIndex.TryGetValue(daemonEntityIndex, out var target)", "foreach (var entity in EntityInstances.Keys.ToArray())", - "AetheriaRuntimeDaemonRenderQueries.QueryBodyPoses(_daemonZoneSnapshot, _daemonBodyPoses);", - "AetheriaRuntimeDaemonRenderQueries.QueryAsteroidBeltPoses(_daemonZoneSnapshot, _daemonAsteroidBeltPoses);", + "_daemonBodyPoses.AddRange(_zoneRenderBodyPoses ?? Array.Empty());", + "_daemonAsteroidBeltPoses.AddRange(_zoneRenderAsteroidBeltPoses ?? Array.Empty());", "foreach (var beltPose in _daemonAsteroidBeltPoses)", - "AetheriaRuntimeDaemonRenderQueries.QueryAsteroidInstancePoses(", - "AetheriaRuntimeDaemonRenderQueries.QueryCompassMarkers(", - "AetheriaRuntimeDaemonRenderQueries.QueryVisibleEntityIndices(", + "beltPose.InstancePoses ?? Array.Empty()", + "private void RefreshDaemonContactRows()", + "ZoneContactsAsync()", + "private void RefreshDaemonCompassMarkers()", + "private void RefreshDaemonVisibleEntityInstances()", + "PowerPulse(", + "RadialWaves(", "RenderSettings.TargetDetectionInfoThreshold", "RenderSettings.DefaultViewDistance", "RenderSettings.MinimapAsteroidSize", "RenderSettings.ResolveDefaultMinimapDistance()", "RenderSettings.ResolveMinimapIconSize(value)", "RenderSettings.ResolveBodyIconSize(mass)", - "AetheriaRuntimeDaemonRenderQueries.QueryWormholeExits(", - "RenderSettings.WormholeDistanceRatio", "RenderSettings.MinimapZoneGravityRange", "RenderSettings.AsteroidVerticalOffset", "RenderSettings.PlanetRotationSpeed", @@ -2219,8 +2431,8 @@ static void RequireDaemonRenderQueryAuthority(string root) "RenderSettings.ResolveGravityWaveFrequency(mass)", "AddWormhole(exit)", "public void AddWormhole(AetheriaRuntimeDaemonWormholeExit exit)", - "private double DaemonSimulationTimeSeconds => _daemonZoneSnapshot?.SimulationTimeSeconds ?? 0;", - "_daemonCompassMarkersByEntityIndex.TryGetValue(entityInstance.DaemonEntityIndex, out var marker)", + "private double DaemonSimulationTimeSeconds => _daemonSimulationTimeSeconds;", + "_daemonCompassMarkersByEntityIndex.TryGetValue(entityInstance.DaemonEntityIndex", "_daemonBodyPosesByBodyKey.TryGetValue(planet.Key, out var pose)", "pose.GravityWaveSpeed" }; @@ -2292,9 +2504,60 @@ static void RequireDaemonRenderQueryAuthority(string root) "SchematicDisplay must normalize cockpit thermal risk through shared daemon render settings instead of Unity GameSettings."); } - if (!schematicDisplay.Contains("ZoneRenderer.RenderSettings.NormalizeThermalRisk(", StringComparison.Ordinal) || - !actionGameManager.Contains("Settings.GameplaySettings.HypothermiaTemperature", StringComparison.Ordinal) || - !actionGameManager.Contains("Settings.GameplaySettings.HeatstrokeTemperature", StringComparison.Ordinal)) + if (schematicDisplay.Contains("ActionGameManager.Instance.ZoneRenderer", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "SchematicDisplay must not reach through ActionGameManager.Instance.ZoneRenderer for HUD presentation settings."); + } + + var requiredSchematicHudSymbols = new[] + { + "private AetheriaRuntimeCurrentEntityDocument _currentEntityDocument;", + "ResolveCurrentEntityHudStatus()", + ".CurrentEntityAsync()", + "hud.OverrideShutdown", + "hud.HeatsinksEnabled", + "hud.Heatstroke", + "hud.Hypothermia", + "hud.Visibility", + "hud.HullDurabilityRatio" + }; + var missingSchematicHudSymbols = requiredSchematicHudSymbols + .Where(symbol => !schematicDisplay.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingSchematicHudSymbols.Length > 0) + { + throw new InvalidOperationException( + "SchematicDisplay no longer renders player HUD facts from the typed current-entity document: " + + string.Join(", ", missingSchematicHudSymbols)); + } + + var forbiddenSchematicHudSymbols = new[] + { + "_entity.OverrideShutdown", + "_entity.HeatsinksEnabled", + "_entity.Heatstroke", + "_entity.Hypothermia" + }; + var presentForbiddenSchematicHudSymbols = forbiddenSchematicHudSymbols + .Where(symbol => schematicDisplay.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (presentForbiddenSchematicHudSymbols.Length > 0) + { + throw new InvalidOperationException( + "SchematicDisplay player HUD facts must not come from the Unity entity facade: " + + string.Join(", ", presentForbiddenSchematicHudSymbols)); + } + + if (!schematicDisplay.Contains("public void SetRenderSettings(AetheriaRuntimeDaemonRenderSettings renderSettings)", StringComparison.Ordinal) || + !schematicDisplay.Contains("_renderSettings?.NormalizeThermalRisk(", StringComparison.Ordinal) || + !gameplayBootShell.Contains("CockpitHudShell.SetRenderSettings(ZoneRenderer.RenderSettings)", StringComparison.Ordinal) || + !cockpitHudShell.Contains("public sealed class AetheriaUnityCockpitHudShell", StringComparison.Ordinal) || + !cockpitHudShell.Contains("public void SetRenderSettings(AetheriaRuntimeDaemonRenderSettings renderSettings)", StringComparison.Ordinal) || + !cockpitHudShell.Contains("SchematicDisplay?.SetRenderSettings(renderSettings)", StringComparison.Ordinal) || + !cockpitHudShell.Contains("TargetSchematicDisplay?.SetRenderSettings(renderSettings)", StringComparison.Ordinal) || + !renderSettingsBridge.Contains("settings.GameplaySettings.HypothermiaTemperature", StringComparison.Ordinal) || + !renderSettingsBridge.Contains("settings.GameplaySettings.HeatstrokeTemperature", StringComparison.Ordinal)) { throw new InvalidOperationException( "HUD thermal presentation no longer flows through the shared daemon render settings bridge."); @@ -2302,6 +2565,12 @@ static void RequireDaemonRenderQueryAuthority(string root) var requiredActionGameManagerRenderSymbols = new[] { + "public static class AetheriaUnityRenderSettingsBridge", + "public static AetheriaRuntimeDaemonRenderSettings Build(", + "AetheriaUnityRenderSettingsBridge.Build(", + "public sealed class AetheriaUnityCockpitHudShell", + "public sealed class AetheriaUnityGameplayBootShell", + "CockpitHudShell.SetRenderSettings(ZoneRenderer.RenderSettings)", "renderSettings.NormalizeDetectionProgress(", "renderSettings.ResolveTargetSpottedFillEnabled(", "renderSettings.NormalizeHeatstrokePost(", @@ -2316,39 +2585,39 @@ static void RequireDaemonRenderQueryAuthority(string root) "ZoneRenderer.RenderSettings.ResolveNextMinimapZoomIndex(_zoomLevelIndex)", "ZoneRenderer.RenderSettings.ResolveMinimapDistance(_zoomLevelIndex)", "new AetheriaRuntimeExponentialLerp(", - "Settings.GameplaySettings.TargetDetectionInfoThreshold", - "Settings.GameplaySettings.SevereHeatstrokeRiskThreshold", - "Settings.GameplaySettings.LockIndicatorNoiseAmplitude", - "Settings.HeatstrokePhasingFloor", - "Settings.HeatstrokePhasingFrequency", + "settings.GameplaySettings.TargetDetectionInfoThreshold", + "settings.GameplaySettings.SevereHeatstrokeRiskThreshold", + "settings.GameplaySettings.LockIndicatorNoiseAmplitude", + "settings.HeatstrokePhasingFloor", + "settings.HeatstrokePhasingFrequency", "TargetSpottedBlinkFrequency", "TargetSpottedBlinkOffset", - "Settings.MinimapZoomLevels", - "Settings.DefaultMinimapZoom", - "Settings.WormholeDistanceRatio", - "Settings.DefaultViewDistance", - "Settings.MinimapIconSize", - "Settings.MinimapAsteroidSize", - "Settings.IconSize", - "Settings.MinimapZoneGravityRange", - "Settings.PlanetSettings.AsteroidVerticalOffset", - "Settings.PlanetRotationSpeed", - "Settings.PlanetSettings.ZoneDepthExponent", - "Settings.PlanetSettings.ZoneDepth + Settings.PlanetSettings.ZoneBoundaryFog", - "Settings.AsteroidMeshCount", - "Settings.PlanetSettings.BodyRadius", - "Settings.PlanetSettings.LightRadius", - "Settings.PlanetSettings.WaveFrequency", + "settings.MinimapZoomLevels", + "settings.DefaultMinimapZoom", + "settings.WormholeDistanceRatio", + "settings.DefaultViewDistance", + "settings.MinimapIconSize", + "settings.MinimapAsteroidSize", + "settings.IconSize", + "settings.MinimapZoneGravityRange", + "settings.PlanetSettings.AsteroidVerticalOffset", + "settings.PlanetRotationSpeed", + "settings.PlanetSettings.ZoneDepthExponent", + "settings.PlanetSettings.ZoneDepth + settings.PlanetSettings.ZoneBoundaryFog", + "settings.AsteroidMeshCount", + "settings.PlanetSettings.BodyRadius", + "settings.PlanetSettings.LightRadius", + "settings.PlanetSettings.WaveFrequency", "ZoneRenderer.BodySettingsCollections = Settings.BodySettingsCollections;" }; var missingActionGameManagerRenderSymbols = requiredActionGameManagerRenderSymbols - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => !unityRenderPresentation.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (missingActionGameManagerRenderSymbols.Length > 0) { throw new InvalidOperationException( - "ActionGameManager no longer bridges Unity render tuning through shared daemon render settings: " + + "Unity target/render presentation no longer bridges Unity render tuning through shared daemon render settings: " + string.Join(", ", missingActionGameManagerRenderSymbols)); } @@ -2720,6 +2989,7 @@ static void RequireTypedRuntimeBehaviorCoverage(string root) { var itemManagerPath = Path.Combine(root, "Assets", "Scripts", "ServerShared", "ItemManager.cs"); var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); + var observedFacadeProjectorPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedFacadeProjector.cs"); var itemManager = File.Exists(itemManagerPath) ? File.ReadAllText(itemManagerPath) @@ -2727,6 +2997,10 @@ static void RequireTypedRuntimeBehaviorCoverage(string root) var actionGameManager = File.Exists(actionGameManagerPath) ? File.ReadAllText(actionGameManagerPath) : throw new InvalidOperationException("Cannot verify typed runtime behavior coverage; ActionGameManager.cs is missing."); + var observedFacadeProjector = File.Exists(observedFacadeProjectorPath) + ? File.ReadAllText(observedFacadeProjectorPath) + : throw new InvalidOperationException("Cannot verify typed runtime behavior coverage; AetheriaUnityObservedFacadeProjector.cs is missing."); + var runtimeRestoreSource = actionGameManager + "\n" + observedFacadeProjector; var requiredFactoryMappings = new[] { @@ -2816,7 +3090,7 @@ static void RequireTypedRuntimeBehaviorCoverage(string root) }; var missingWeaponStateCoverage = requiredWeaponStateCoverage - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => !runtimeRestoreSource.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (missingWeaponStateCoverage.Length > 0) @@ -2846,7 +3120,7 @@ static void RequireTypedRuntimeBehaviorCoverage(string root) }; var missingBehaviorStateCoverage = requiredBehaviorStateCoverage - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => !runtimeRestoreSource.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (missingBehaviorStateCoverage.Length > 0) @@ -2908,14 +3182,14 @@ static void RequireEveRuntimeBootstrap(string root) var requiredPresenterSymbols = new[] { "private string surfaceId = AetheriaRuntimeDaemonGameSurfaceBuilder.SurfaceId", - "private AetheriaRuntimeVerseClient? _verseClient", + "private AetheriaClient? _client", "ReadDaemonSurface(statePath)", "ReadDaemonSurface(stateBoot.StateFilePath)", - "AetheriaRuntimeVerseClient.OpenAsync(", - "GetDaemonGameSurfaceAsync()", - "GetDaemonGameTuiSurfaceAsync()", - "GetDaemonEditorSurfaceAsync()", - "GetDaemonEditorTuiSurfaceAsync()", + "AetheriaClient.OpenAsync(", + "DaemonGameSurfaceAsync()", + "DaemonGameTuiSurfaceAsync()", + "DaemonEditorSurfaceAsync()", + "DaemonEditorTuiSurfaceAsync()", "AetheriaRuntimeEveSurfaceAdapter.ToEveSurfaceDocument(", "private bool ShouldMountSurface(", "_mountedSurfaceVersion != surface.Version", @@ -2923,8 +3197,8 @@ static void RequireEveRuntimeBootstrap(string root) "UseShell = false", "MountSurface(string statePath, EveSurfaceDocument surface)", "AetheriaEveUnitySurfaceHost.Render(", - "AetheriaRuntimeDaemonSurfaceCommands.TrySubmit(statePath, request, out var daemonEnvelope)", - "SubmitKnownSurfaceCommandAsync(" + "AetheriaRuntimeDaemonSurfaceCommands.TrySubmit(ResolveClient(statePath), request, out var daemonEnvelope)", + ".Ui.SurfaceCommandAsync(" }; var missingPresenterSymbols = requiredPresenterSymbols .Where(symbol => !presenter.Contains(symbol, StringComparison.Ordinal)) @@ -2939,7 +3213,7 @@ static void RequireEveRuntimeBootstrap(string root) if (presenter.Contains("AetheriaRuntimeStateReader.ReadEveSurface", StringComparison.Ordinal)) { throw new InvalidOperationException( - "Aetheria Eve presenter still mounts daemon surfaces through file-reader lookup instead of AetheriaRuntimeVerseClient."); + "Aetheria Eve presenter still mounts daemon surfaces through file-reader lookup instead of the shared AetheriaClient facade."); } if (presenter.Contains("new EveUiToolkitSurfaceLowerer", StringComparison.Ordinal)) @@ -2949,10 +3223,10 @@ static void RequireEveRuntimeBootstrap(string root) } var daemonSubmitIndex = presenter.IndexOf( - "AetheriaRuntimeDaemonSurfaceCommands.TrySubmit(statePath, request, out var daemonEnvelope)", + "AetheriaRuntimeDaemonSurfaceCommands.TrySubmit(ResolveClient(statePath), request, out var daemonEnvelope)", StringComparison.Ordinal); var eveSubmitIndex = presenter.IndexOf( - "SubmitKnownSurfaceCommandAsync(", + ".Ui.SurfaceCommandAsync(", StringComparison.Ordinal); if (daemonSubmitIndex < 0 || eveSubmitIndex < 0 || daemonSubmitIndex > eveSubmitIndex) { @@ -2965,9 +3239,9 @@ static void RequireEveRuntimeBootstrap(string root) "public static class AetheriaRuntimeDaemonSurfaceCommands", "EveSurfaceCommandRequest request", "request.ProviderId, \"aetheria.daemon\"", - "AetheriaRuntimeVerseClient", + "AetheriaClient", ".OpenAsync(", - "GetObservedDaemonStateAsync()", + "ObserveAsync()", "new AetheriaRuntimeDaemonOperationClient(", "AetheriaRuntimeDaemonSurfaceCommandCatalog.TrySubmitArgumentless(", "AetheriaRuntimeDaemonSurfaceCommandCatalog.CommandPrefix" @@ -3161,6 +3435,7 @@ static void RequireMainMenuSettingsCommands(string root) var actionGameManagerForbiddenSymbols = new[] { + "public static RuntimePlayerSettings RuntimePlayerSettings", "RequestRuntimePlayerSettingsCommand", "RequestRuntimePlayerName", "RequestRuntimeTemperatureUnit", @@ -3189,7 +3464,7 @@ static void RequireMainMenuSettingsCommands(string root) { "SendKnownAetheriaEveCommand(request, \"player-settings\")", "AetheriaRuntimeMainMenuCommandKind.PlayerSettingsCommand", - "SubmitKnownSurfaceCommandAsync(request, \"unity-main-menu\")", + ".Ui.SurfaceCommandAsync(request, \"unity-main-menu\")", "stateBoot.StateFilePath", "\"unity-main-menu\"" }; @@ -3511,7 +3786,7 @@ static void RequireMainMenuRootUsesEveSurface(string root) "AetheriaRuntimeMainMenuCommandKind.ShowSettings", "AetheriaRuntimeMainMenuCommandKind.Quit", "HandleMainSurfaceCommand(", - "LatestDaemonFrame(stateBoot)", + "LatestSectorMap(stateBoot)", "LatestVerseHostSettings(stateBoot)", "LatestPlayerSettings(stateBoot)", "HideMenuSurface();" @@ -3636,7 +3911,8 @@ static void RequireConfirmationDialogOwnsMinimalPromptShell(string root) "public void Clear()", "public Property AddProperty(Func read)", "public void AddField(string name, Func read, Action write)", - "public void AddField(string name, Func read, Action write)" + "public void AddField(string name, Func read, Action write)", + "public void SetInputGate(Action enableGlobalInput, Action disableGlobalInput)" }; var missingSymbols = requiredSymbols @@ -3660,7 +3936,8 @@ static void RequireConfirmationDialogOwnsMinimalPromptShell(string root) "RangedFloatField", "BoolField", "EnumField", - "StatSheet" + "StatSheet", + "ActionGameManager.Instance" }; var hits = forbiddenSymbols @@ -3780,11 +4057,13 @@ static void RequireMainMenuInputSettingsDelegateToRuntimeScreen(string root) var requiredSymbols = new[] { "AetheriaRuntimeMainMenuCommandKind.OpenRuntimeInputScreen", + "SetRuntimeInputScreenShell(Func canOpenRuntimeInputScreen, Action openRuntimeInputScreen)", "CanOpenRuntimeInputScreen()", "TryOpenRuntimeInputScreen()", "AetheriaRuntimeMainMenuSurfaceBuilder.BuildInputSettings(", "AetheriaRuntimeMainMenuSurfaceBuilder.ProjectRoot(", - "ActionGameManager.Instance.ShowInputScreenFromMenu();" + "_canOpenRuntimeInputScreen?.Invoke() == true", + "_openRuntimeInputScreen?.Invoke();" }; var missingSymbols = requiredSymbols @@ -3801,7 +4080,9 @@ static void RequireMainMenuInputSettingsDelegateToRuntimeScreen(string root) var forbiddenSymbols = new[] { "Typed input rebinding controls are not lowered through Eve yet.", - "The live remapping screen still owns drag/drop rebinding and low-level InputSystem edits." + "The live remapping screen still owns drag/drop rebinding and low-level InputSystem edits.", + "ActionGameManager.Instance.ShowInputScreenFromMenu();", + "ActionGameManager.Instance.CanShowInputScreenFromMenu()" }; var hits = forbiddenSymbols @@ -3863,8 +4144,14 @@ static void RequireRuntimeInputScreenUsesEveSurface(string root) "AetheriaEveUnitySurfaceHost.RenderRuntime(", "AetheriaEveUnitySurfaceHost.Hide(_surfaceDocument)", "UIDocument", - "ActionGameManager.RequestRuntimeInputBindingOverride", - "ActionGameManager.RequestRuntimeActionBarInput", + "private AetheriaClient _client", + ".Ui.InputSettingsAsync(command, body, \"unity-input-screen\")", + "AetheriaRuntimeInputSettingsCommandBody", + "AetheriaRuntimeEveCommandKind.SetBindingOverride", + "AetheriaRuntimeEveCommandKind.SetActionBarEnabled", + "AetheriaClient", + ".OpenAsync(", + "PlayerSettingsAsync()", "action.ApplyBindingOverride", "new InputAction(\"Aetheria Input Capture\")", "AetheriaRuntimeInputSettingsSurfaceBuilder.IsSupportedCapturePath(", @@ -3982,27 +4269,30 @@ static void RequireRuntimeInputScreenUsesEveSurface(string root) ? File.ReadAllText(actionGameManagerPath) : throw new InvalidOperationException("Cannot verify runtime input-screen authority; ActionGameManager.cs is missing."); - var requiredActionGameManagerSymbols = new[] + var requiredInputSettingsSubmitSymbols = new[] { - "SendRuntimeInputSettingsCommand(", - "SubmitInputSettingsCommandAsync(", + "SendInputSettingsCommand(", + ".Ui.InputSettingsAsync(", "AetheriaRuntimeEveCommandKind.SetBindingOverride", "AetheriaRuntimeEveCommandKind.SetActionBarEnabled" }; - var missingActionGameManagerSymbols = requiredActionGameManagerSymbols - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + var missingInputSettingsSubmitSymbols = requiredInputSettingsSubmitSymbols + .Where(symbol => !inputScreen.Contains(symbol, StringComparison.Ordinal)) .ToArray(); - if (missingActionGameManagerSymbols.Length > 0) + if (missingInputSettingsSubmitSymbols.Length > 0) { throw new InvalidOperationException( "Runtime input writes no longer send explicit Eve input-setting commands: " + - string.Join(", ", missingActionGameManagerSymbols)); + string.Join(", ", missingInputSettingsSubmitSymbols)); } var forbiddenActionGameManagerSymbols = new[] { + "RequestRuntimeInputBindingOverride", + "RequestRuntimeActionBarInput", + "SendRuntimeInputSettingsCommand(", "public static void CommitRuntimeInputBindingOverride", "public static void CommitRuntimeActionBarInput", "TrySendRuntimeInputSettingsCommand(", @@ -4026,31 +4316,127 @@ static void RequireRuntimeInputScreenUsesEveSurface(string root) static void RequireActionGameManagerInputScreenUsesSharedFullscreenPrimitive(string root) { var managerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); + var sceneWiringPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplaySceneWiring.cs"); + var menuShellPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityMenuShell.cs"); + var gameplayInputShellPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplayInputShell.cs"); if (!File.Exists(managerPath)) { throw new InvalidOperationException("Cannot verify ActionGameManager input-screen delegation; ActionGameManager.cs is missing."); } + if (!File.Exists(sceneWiringPath)) + { + throw new InvalidOperationException("Cannot verify Unity scene wiring input-screen delegation; AetheriaUnityGameplaySceneWiring.cs is missing."); + } + if (!File.Exists(menuShellPath)) + { + throw new InvalidOperationException("Cannot verify Unity menu shell input-screen delegation; AetheriaUnityMenuShell.cs is missing."); + } + if (!File.Exists(gameplayInputShellPath)) + { + throw new InvalidOperationException("Cannot verify Unity gameplay input shell delegation; AetheriaUnityGameplayInputShell.cs is missing."); + } var source = File.ReadAllText(managerPath); - var requiredSymbols = new[] + var sceneWiring = File.ReadAllText(sceneWiringPath); + var menuShell = File.ReadAllText(menuShellPath); + var gameplayInputShell = File.ReadAllText(gameplayInputShellPath); + var requiredManagerSymbols = new[] { - "public bool CanShowInputScreenFromMenu()", - "public void ShowInputScreenFromMenu()", - "ShowFullscreenMenu(HelpScreen);", - "private void ShowFullscreenMenu(GameObject menu)", - "private void HideFullscreenMenu(GameObject menu)", - "Input.Global.InputScreen.performed += context => ToggleFullscreenMenu(HelpScreen);" + "SceneWiring.ConfigureRuntimeInputScreenShell(MenuShell)", + "GameplayInputShell.Bootstrap();" }; - var missingSymbols = requiredSymbols + var missingManagerSymbols = requiredManagerSymbols .Where(symbol => !source.Contains(symbol, StringComparison.Ordinal)) .ToArray(); - if (missingSymbols.Length > 0) + if (missingManagerSymbols.Length > 0) { throw new InvalidOperationException( - "ActionGameManager no longer exposes the shared fullscreen primitive for the live input-screen owner: " + - string.Join(", ", missingSymbols)); + "ActionGameManager no longer wires the Unity menu shell as the live input-screen owner: " + + string.Join(", ", missingManagerSymbols)); + } + + var requiredSceneWiringSymbols = new[] + { + "public sealed class AetheriaUnityGameplaySceneWiring", + "public void ConfigureRuntimeInputScreenShell(AetheriaUnityMenuShell menuShell)", + "MainMenu?.SetRuntimeInputScreenShell(menuShell.CanOpenRuntimeInputScreen, menuShell.ShowRuntimeInputScreen)" + }; + + var missingSceneWiringSymbols = requiredSceneWiringSymbols + .Where(symbol => !sceneWiring.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingSceneWiringSymbols.Length > 0) + { + throw new InvalidOperationException( + "AetheriaUnityGameplaySceneWiring no longer wires the Unity menu shell as the live input-screen owner: " + + string.Join(", ", missingSceneWiringSymbols)); + } + + var requiredShellSymbols = new[] + { + "public sealed class AetheriaUnityMenuShell", + "public bool CanOpenRuntimeInputScreen()", + "public void ShowRuntimeInputScreen()", + "public void ToggleFullscreenMenu(GameObject menu)", + "private void ShowFullscreenMenu(GameObject menu)", + "private void HideFullscreenMenu(GameObject menu)" + }; + + var missingShellSymbols = requiredShellSymbols + .Where(symbol => !menuShell.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingShellSymbols.Length > 0) + { + throw new InvalidOperationException( + "AetheriaUnityMenuShell no longer owns the shared fullscreen primitive for the live input-screen owner: " + + string.Join(", ", missingShellSymbols)); + } + + var requiredGameplayInputShellSymbols = new[] + { + "public sealed class AetheriaUnityGameplayInputShell", + "public AetheriaUnityMenuShell MenuShell { get; set; }", + "Input.Global.InputScreen.performed += context => MenuShell.ToggleFullscreenMenu(MenuShell.HelpScreen);", + "Input.Global.ZoneMap.performed += context =>", + "Input.Player.TargetNearest.performed += context => PilotOperationAdapter.RequestTargetNearest();", + "private void RegisterActionBarInput()", + "DragSession.RegisterTarget(dragAction => ActionBarPresentation.RequestBinding(slot, dragAction));" + }; + + var missingGameplayInputShellSymbols = requiredGameplayInputShellSymbols + .Where(symbol => !gameplayInputShell.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingGameplayInputShellSymbols.Length > 0) + { + throw new InvalidOperationException( + "AetheriaUnityGameplayInputShell no longer owns Unity input callbacks and routes them through typed shell dependencies: " + + string.Join(", ", missingGameplayInputShellSymbols)); + } + + var forbiddenSymbols = new[] + { + "public bool CanShowInputScreenFromMenu()", + "public void ShowInputScreenFromMenu()", + "private bool CanOpenRuntimeInputScreen()", + "private void ShowRuntimeInputScreen()", + "private void ShowFullscreenMenu(GameObject menu)", + "private void HideFullscreenMenu(GameObject menu)", + "Input.Global.InputScreen.performed += context", + "Input.Global.ZoneMap.performed += context", + "Input.Player.TargetNearest.performed += context", + "new InputAction(binding: controlPath)", + "OnPointerEnterAsObservable()" + }; + var hits = forbiddenSymbols + .Where(symbol => source.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (hits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still exposes runtime input-screen menu handoff as public manager API: " + + string.Join(", ", hits)); } } @@ -4085,7 +4471,13 @@ static void RequireSectorMapZoneDetailsUseEveSurface(string root) "AetheriaRuntimeZoneDetailsSurfaceBuilder.ProjectDaemonZone(", "AetheriaRuntimeZoneDetailsSurfaceBuilder.Project(", "ProjectZoneDetailsSurfaceState(", - "GameManager.TryGetObservedZoneSnapshot(zone?.ZoneIndex ?? -1, out var daemonZone)", + "private AetheriaClient _client", + "SectorMapAsync()", + "ZoneDetailsAsync(zoneIndex)", + "OpenRuntimeCatalog()", + "PlayerSettingsAsync()", + "AetheriaClient", + ".OpenLocalAsync(", "AetheriaRuntimeZoneDetailsSurfaceCommands.TryRead(request, out var command)", "AetheriaRuntimeZoneDetailsCommandKind.Close" }; @@ -4196,6 +4588,12 @@ static void RequireRuntimeMenuTabsUseEveSurface(string root) "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeMenuTabsSurfaceBuilder.cs"); + var localStorySurfaceBuilderPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeLocalStorySurfaceBuilder.cs"); if (!File.Exists(menuPanelPath)) { throw new InvalidOperationException("Cannot verify runtime menu tab shell; MenuPanel.cs is missing."); @@ -4212,6 +4610,10 @@ static void RequireRuntimeMenuTabsUseEveSurface(string root) { throw new InvalidOperationException("Cannot verify runtime menu tab shell; AetheriaRuntimeMenuTabsSurfaceBuilder.cs is missing."); } + if (!File.Exists(localStorySurfaceBuilderPath)) + { + throw new InvalidOperationException("Cannot verify runtime local story shell; AetheriaRuntimeLocalStorySurfaceBuilder.cs is missing."); + } var legacyTabButtonPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "MenuTabButton.cs"); if (File.Exists(legacyTabButtonPath)) @@ -4229,6 +4631,7 @@ static void RequireRuntimeMenuTabsUseEveSurface(string root) } var menuTabsSurfaceBuilder = File.ReadAllText(menuTabsSurfaceBuilderPath); + var localStorySurfaceBuilder = File.ReadAllText(localStorySurfaceBuilderPath); var surfaceHost = File.ReadAllText(surfaceHostPath); var requiredSymbols = new[] { @@ -4243,8 +4646,10 @@ static void RequireRuntimeMenuTabsUseEveSurface(string root) "AetheriaRuntimeMenuTabsSurfaceBuilder.Project(", "new AetheriaRuntimeMenuTabProjectionOption(", "ResolveVisibleTabs(", - "GameManager.IsObservedDocked", - "GameManager.TryGetObservedDockedLocalStory(out _)", + "CurrentDockingAsync()", + "ResolveCurrentDocking()?.IsDocked == true", + "AetheriaClient", + "OpenLocalAsync(", "GetTabLabel(", "ToRuntimeTabKey(", "AetheriaRuntimeMenuTabsSurfaceBuilder.NormalizeTabKey(tab.ToString())", @@ -4313,6 +4718,13 @@ static void RequireRuntimeMenuTabsUseEveSurface(string root) string.Join(", ", hits)); } + if (source.Contains("GameManager.IsObservedDocked", StringComparison.Ordinal) || + source.Contains("GameManager.TryGetObservedDockedLocalStory", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "MenuPanel tab visibility must read typed current-docking state through AetheriaClient instead of ActionGameManager observed adapters."); + } + var requiredProjectionBuilderSymbols = new[] { "public sealed class AetheriaRuntimeMenuTabProjectionOption", @@ -4335,13 +4747,24 @@ static void RequireRuntimeMenuTabsUseEveSurface(string root) var requiredDockedStoryObserverSymbols = new[] { - "public bool IsObservedDocked => TryGetObservedDockingBay(out _);", - "public bool TryGetObservedDockingBay(out EquippedDockingBay dockingBay)", - "TryResolveDaemonDockingBay(CurrentEntity, out var dockParent, out var resolvedDockingBay)", - "public bool TryGetObservedDockedLocalStory(out LocationStory story)", - "TryGetObservedDockedLocalStory(out _currentLocation)" + "TryResolveDockedLocalStory(out _currentLocation)", + "private bool TryResolveCurrentDocking(out AetheriaRuntimeCurrentDockingDocument docking)", + "private bool TryResolveDockedLocalStory(out LocationStory story)", + "SetObservedFacadeIndex(AetheriaUnityObservedFacadeIndex observedFacadeIndex)", + "_observedFacadeIndex.TryResolveDockingBayByRecordKey(", + "docking.DockParentEntityKey", + "docking.DockingBayIndex", + "dockingBay?.Entity is not OrbitalEntity { Story: { } dockedStory }", + "AetheriaRuntimeLocalStorySurfaceBuilder.Build(ProjectStorySurface())", + "AetheriaRuntimeLocalStorySurfaceBuilder.Project(", + "AetheriaRuntimeLocalStorySurfaceCommands.TryRead(request, out var command)", + "AetheriaRuntimeLocalStoryCommandKind.Continue", + "AetheriaRuntimeLocalStoryCommandKind.Choose", + "new AetheriaRuntimeLocalStoryChoiceState(", + "CurrentDockingAsync()", + "unity-runtime-local-story" }; - var dockedStoryObserverCorpus = actionGameManager + "\n" + localMenu; + var dockedStoryObserverCorpus = localMenu; var missingDockedStoryObserverSymbols = requiredDockedStoryObserverSymbols .Where(symbol => !dockedStoryObserverCorpus.Contains(symbol, StringComparison.Ordinal)) .ToArray(); @@ -4354,10 +4777,39 @@ static void RequireRuntimeMenuTabsUseEveSurface(string root) if (source.Contains("GameManager.DockedEntity != null", StringComparison.Ordinal) || source.Contains("GameManager.DockedEntity as OrbitalEntity", StringComparison.Ordinal) || - localMenu.Contains("ActionGameManager.Instance.DockedEntity", StringComparison.Ordinal)) + localMenu.Contains("ActionGameManager.Instance.DockedEntity", StringComparison.Ordinal) || + localMenu.Contains("TryGetObservedDockedLocalStory", StringComparison.Ordinal) || + actionGameManager.Contains("TryGetObservedDockedLocalStory", StringComparison.Ordinal) || + actionGameManager.Contains("IsObservedDocked", StringComparison.Ordinal) || + localMenu.Contains("Instantiate(ChoicePrefab", StringComparison.Ordinal) || + localMenu.Contains("choiceInstance.Button.onClick.AddListener", StringComparison.Ordinal)) { throw new InvalidOperationException( - "Runtime menu/local UI must not inspect DockedEntity directly for dock/story visibility; ask the gameplay observer."); + "Runtime menu/local UI must not inspect DockedEntity or rebuild legacy prefab buttons; use typed docking and the Eve local-story surface."); + } + + var requiredLocalStoryBuilderSymbols = new[] + { + "public static class AetheriaRuntimeLocalStorySurfaceBuilder", + "public const string SurfaceId = \"aetheria.runtime_menu.local_story\"", + "public const string Continue = \"aetheria.runtime_menu.local_story.continue\"", + "ChoiceCommandFor(int choiceIndex)", + "public static AetheriaRuntimeLocalStorySurfaceState Project(", + "public static AetheriaRuntimeSurfaceDocument Build(", + "public enum AetheriaRuntimeLocalStoryCommandKind", + "public readonly struct AetheriaRuntimeLocalStoryCommand", + "public static class AetheriaRuntimeLocalStorySurfaceCommands", + "AetheriaRuntimeLocalStoryChoiceState", + "AetheriaRuntimeLocalStorySurfaceState" + }; + var missingLocalStoryBuilderSymbols = requiredLocalStoryBuilderSymbols + .Where(symbol => !localStorySurfaceBuilder.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingLocalStoryBuilderSymbols.Length > 0) + { + throw new InvalidOperationException( + "Shared runtime local story surface builder no longer owns the local story shell contract: " + + string.Join(", ", missingLocalStoryBuilderSymbols)); } var requiredBuilderSymbols = new[] @@ -4418,8 +4870,18 @@ static void RequireInventoryShipSettingsUseEveSurface(string root) "AetheriaRuntimeShipSettingsCommandKind.ResetShutdownThreshold", "AetheriaRuntimeShipSettingsCommandKind.Close", "AetheriaRuntimeShipSettingsSurfaceCommands.ResolveShutdownPerformance(", - "GameManager.TryGetObservedCurrentEntity(out var currentEntity)", - "GameManager.TryGetObservedCurrentEntity(out var entity)" + "ResolveDefaultShutdownPerformance()", + "ResolvePlayerSettings()?.DefaultShutdownPerformance", + "TryResolveCurrentEntityDocument(out var currentEntity)", + "TryGetTypedCurrentEntityFacade(out var currentEntity)", + "SetObservedFacadeIndex(AetheriaUnityObservedFacadeIndex observedFacadeIndex)", + "_observedFacadeIndex.TryResolveEntityByRecordKey", + "private AetheriaRuntimeCurrentEntityDocument _shipSettingsCurrentEntity;", + "!TryResolveCurrentEntityDocument(out var latestCurrentEntity)", + "RequestEntityShutdownPerformance(", + "latestCurrentEntity.EntityKey", + "(float)latestCurrentEntity.ShutdownPerformance", + "CurrentEntityAsync()" }; var missingSymbols = requiredSymbols @@ -4452,9 +4914,15 @@ static void RequireInventoryShipSettingsUseEveSurface(string root) "ShutdownThresholdStep", "Mathf.Clamp01(entity.Settings.ShutdownPerformance", "Mathf.Clamp01(GameManager.Settings.GameplaySettings.DefaultShutdownPerformance", + "GameManager.Settings.GameplaySettings.DefaultShutdownPerformance", "var entity = GameManager.CurrentEntity", "GameManager.CurrentEntity == null", + "GameManager.TryGetObservedCurrentEntity(", "RenderCurrentShipSettingsSurface(GameManager.CurrentEntity)", + "private Entity _shipSettingsEntity", + "var entity = _shipSettingsEntity;", + "entity == null || !IsCurrentEntity(entity)", + "entity.Settings.ShutdownPerformance", "new AetheriaRuntimeShipSettingsSurfaceState(" }; @@ -4641,8 +5109,7 @@ static void RequireInventoryCargoItemDetailsUseEveSurface(string root) !eveUnitySurfaceHost.Contains("ContainsStateRefs(surface)", StringComparison.Ordinal) || !eveUnitySurfaceHost.Contains("prop.Key.EndsWith(\"Ref\", StringComparison.Ordinal)", StringComparison.Ordinal) || !eveUnitySurfaceHost.Contains("CreateDefaultStateRefResolver()", StringComparison.Ordinal) || - !eveUnitySurfaceHost.Contains("AetheriaRuntimeVerseClient.CreateEveSurfaceStateRefResolver(", StringComparison.Ordinal) || - !eveUnitySurfaceHost.Contains("\"unity-eve-surface-host\"", StringComparison.Ordinal) || + !eveUnitySurfaceHost.Contains("AetheriaRuntimeStateReader.CreateEveSurfaceStateRefResolver(", StringComparison.Ordinal) || !runtimeEveSurfaceAdapter.Contains("public static EveSurfaceDocument ResolveStateRefs(", StringComparison.Ordinal) || !runtimeEveSurfaceAdapter.Contains("ResolvePropRefs(props, stateRefResolver)", StringComparison.Ordinal) || !runtimeEveSurfaceAdapter.Contains("ResolvePropRef(props, AetheriaRuntimeSurfaceStateRefs.Source, \"value\", stateRefResolver)", StringComparison.Ordinal) || @@ -4755,15 +5222,12 @@ static void RequireInventoryEquippedItemDetailsUseEveSurface(string root) "ProjectEquippedItemObservation(", "ProjectEquippedItemTemperatureControls(", "ProjectEquippedItemWeaponGroupControls(", - "ProjectEquippedItemActionBarSlots(", "AetheriaRuntimeEquippedItemDetailsSurfaceCommands.TryRead(request, out var command)", "switch (command.Kind)", "AetheriaRuntimeEquippedItemDetailsCommandKind.Close", "AetheriaRuntimeEquippedItemDetailsCommandKind.ToggleOverrideShutdown", "AetheriaRuntimeEquippedItemDetailsCommandKind.SetTargetTemperature", - "AetheriaRuntimeEquippedItemDetailsCommandKind.ToggleWeaponGroup", - "AetheriaRuntimeEquippedItemDetailsCommandKind.BindWeaponGroup", - "AetheriaRuntimeEquippedItemDetailsCommandKind.ClearActionBarBinding" + "AetheriaRuntimeEquippedItemDetailsCommandKind.ToggleWeaponGroup" }; var missingSymbols = requiredSymbols @@ -4784,6 +5248,7 @@ static void RequireInventoryEquippedItemDetailsUseEveSurface(string root) "BuildEquippedItemControlCard(", "BuildEquippedItemWeaponGroupCard(", "BuildEquippedItemActionBarCards(", + "ProjectEquippedItemActionBarSlots(", "BuildItemBehaviorCards(", "BuildItemBehaviorMetric(", "CloseEquippedItemDetailsCommand", @@ -4792,6 +5257,10 @@ static void RequireInventoryEquippedItemDetailsUseEveSurface(string root) "ToggleEquippedItemWeaponGroupCommand", "BindEquippedItemWeaponGroupCommand", "ClearEquippedItemActionBarBindingCommand", + "AetheriaRuntimeEquippedItemDetailsCommandKind.BindWeaponGroup", + "AetheriaRuntimeEquippedItemDetailsCommandKind.ClearActionBarBinding", + "RequestWeaponGroupActionBarBinding(", + "RequestClearActionBarBinding(", "ResolveEquippedItemDetailsSurfaceDocument(", "new EveUiToolkitSurfaceLowerer()", "host.AddComponent", @@ -4858,7 +5327,6 @@ static void RequireInventoryEquippedItemDetailsUseEveSurface(string root) "public sealed class AetheriaRuntimeEquippedItemMetric", "public sealed class AetheriaRuntimeEquippedItemControl", "public sealed class AetheriaRuntimeEquippedItemTemperatureControl", - "public sealed class AetheriaRuntimeEquippedItemActionBarSlot", "public enum AetheriaRuntimeEquippedItemDetailsCommandKind", "public readonly struct AetheriaRuntimeEquippedItemDetailsCommand", "public static class AetheriaRuntimeEquippedItemDetailsSurfaceCommands", @@ -4868,8 +5336,6 @@ static void RequireInventoryEquippedItemDetailsUseEveSurface(string root) "public const string ToggleOverrideShutdown = \"aetheria.inventory.equipped_item_details.override_shutdown.toggle\"", "public const string SetTargetTemperature = \"aetheria.inventory.equipped_item_details.target_temperature.set\"", "public const string ToggleWeaponGroup = \"aetheria.inventory.equipped_item_details.weapon_group.toggle\"", - "public const string BindWeaponGroup = \"aetheria.inventory.equipped_item_details.weapon_group.bind\"", - "public const string ClearActionBarBinding = \"aetheria.inventory.equipped_item_details.action_bar.clear\"", "public static AetheriaRuntimeEquippedItemDetailsSurfaceState Project(", "ProjectBehaviorSections(", "ProjectBehaviorMetric(", @@ -4879,12 +5345,9 @@ static void RequireInventoryEquippedItemDetailsUseEveSurface(string root) "new AetheriaRuntimeSurfaceCommandTemplate(ToggleOverrideShutdown", "new AetheriaRuntimeSurfaceCommandTemplate(SetTargetTemperature", "new AetheriaRuntimeSurfaceCommandTemplate(ToggleWeaponGroup", - "new AetheriaRuntimeSurfaceCommandTemplate(BindWeaponGroup", - "new AetheriaRuntimeSurfaceCommandTemplate(ClearActionBarBinding", "ReadInt(request, \"behaviorIndex\", -1)", "ReadFloat(request, \"value\", 0f)", "ReadInt(request, \"group\", -1)", - "ReadInt(request, \"slot\", -1)", "\"control.text\"", "\"inventory.menu\"" }; @@ -4898,6 +5361,25 @@ static void RequireInventoryEquippedItemDetailsUseEveSurface(string root) string.Join(", ", missingBuilderSymbols)); } + var forbiddenBuilderActionBarSymbols = new[] + { + "AetheriaRuntimeEquippedItemActionBarSlot", + "ActionBarSlots", + "BindWeaponGroup", + "ClearActionBarBinding", + "ReadInt(request, \"slot\", -1)", + "action_bar" + }; + var builderActionBarHits = forbiddenBuilderActionBarSymbols + .Where(symbol => equippedItemSurfaceBuilder.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (builderActionBarHits.Length > 0) + { + throw new InvalidOperationException( + "Shared equipped-item detail surface still exposes Unity-local action-bar binding concepts: " + + string.Join(", ", builderActionBarHits)); + } + var stateProjectIncludesRuntimePackage = stateProject.Contains(@"Runtime\*.cs", StringComparison.Ordinal); if ((!stateProjectIncludesRuntimePackage && !stateProject.Contains("AetheriaRuntimeEquippedItemDetailsSurfaceBuilder.cs", StringComparison.Ordinal)) || @@ -4908,26 +5390,164 @@ static void RequireInventoryEquippedItemDetailsUseEveSurface(string root) } var actionGameManagerSource = File.ReadAllText(actionGameManagerPath); - var requiredActionBarSymbols = new[] + var gameplaySceneWiringPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplaySceneWiring.cs"); + var gameplaySceneWiring = File.Exists(gameplaySceneWiringPath) + ? File.ReadAllText(gameplaySceneWiringPath) + : throw new InvalidOperationException("Cannot verify action-bar scene wiring; AetheriaUnityGameplaySceneWiring.cs is missing."); + var actionBarPresentationPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityActionBarPresentation.cs"); + var actionBarPresentation = File.Exists(actionBarPresentationPath) + ? File.ReadAllText(actionBarPresentationPath) + : throw new InvalidOperationException("Cannot verify equipped-item action-bar presentation; AetheriaUnityActionBarPresentation.cs is missing."); + + var requiredActionBarPresentationSymbols = new[] { - "GetActionBarSlotCount(", - "GetActionBarSlotLabel(", - "GetActionBarBindingLabel(", - "RequestWeaponGroupActionBarBinding(", - "RequestClearActionBarBinding(", - "TryRequestDaemonActionBarBinding(", - "TryRequestDaemonActionBarBindingClear(" + "public sealed class AetheriaUnityActionBarPresentation", + "public int SlotCount", + "public string SlotLabel(int slotIndex)", + "public string BindingLabel(int slotIndex)", + "public bool TryResolveControlPath(int slotIndex, out string controlPath)", + "public void RequestBinding(ActionBarSlot slot, DragObject dragAction)", + "public bool RequestWeaponGroupBinding(int slotIndex, int groupIndex)", + "public bool ClearBinding(int slotIndex)", + "public void ApplyLocalBindings()", + "public void ApplyBindings(", + "private AetheriaUnityActionBarBinding CreateBindingCommit(", + "private void SetLocalBinding(AetheriaUnityActionBarBinding binding)" }; - var missingActionBarSymbols = requiredActionBarSymbols + var missingActionBarPresentationSymbols = requiredActionBarPresentationSymbols + .Where(symbol => !actionBarPresentation.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingActionBarPresentationSymbols.Length > 0) + { + throw new InvalidOperationException( + "Equipped-item action-bar presentation reads must live outside ActionGameManager: " + + string.Join(", ", missingActionBarPresentationSymbols)); + } + + var requiredActionBarInjectionSymbols = new[] + { + "private readonly AetheriaUnityActionBarPresentation _actionBarPresentation", + "SceneWiring.ConfigureActionBarPresentation(", + "_actionBarPresentation," + }; + var missingActionBarInjectionSymbols = requiredActionBarInjectionSymbols .Where(symbol => !actionGameManagerSource.Contains(symbol, StringComparison.Ordinal)) .ToArray(); - if (missingActionBarSymbols.Length > 0) + if (missingActionBarInjectionSymbols.Length > 0) { throw new InvalidOperationException( - "ActionGameManager no longer exposes the equipped-item action-bar binding authority surface: " + - string.Join(", ", missingActionBarSymbols)); + "ActionGameManager must inject action-bar presentation into the inventory shell instead of exposing manager read APIs: " + + string.Join(", ", missingActionBarInjectionSymbols)); + } + + var requiredActionBarSceneWiringSymbols = new[] + { + "public void ConfigureActionBarPresentation(", + "actionBarPresentation?.Bind(", + "resolveActionBarClient);", + "Inventory?.SetActionBarPresentation(actionBarPresentation)" + }; + var missingActionBarSceneWiringSymbols = requiredActionBarSceneWiringSymbols + .Where(symbol => !gameplaySceneWiring.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingActionBarSceneWiringSymbols.Length > 0) + { + throw new InvalidOperationException( + "AetheriaUnityGameplaySceneWiring must bind action-bar presentation into the inventory shell instead of leaving ActionGameManager as the owner: " + + string.Join(", ", missingActionBarSceneWiringSymbols)); + } + + var forbiddenActionBarManagerReadSymbols = new[] + { + "public int GetActionBarSlotCount(", + "public string GetActionBarSlotLabel(", + "public string GetActionBarBindingLabel(", + "public bool TryResolveActionBarSlotControlPath(" + }; + var actionBarManagerReadHits = forbiddenActionBarManagerReadSymbols + .Where(symbol => actionGameManagerSource.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (actionBarManagerReadHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still exposes action-bar presentation reads: " + + string.Join(", ", actionBarManagerReadHits)); + } + + var requiredActionBarInventorySymbols = new[] + { + "SetActionBarPresentation(AetheriaUnityActionBarPresentation actionBarPresentation)", + "ResolveClient()" + }; + + var missingActionBarInventorySymbols = requiredActionBarInventorySymbols + .Where(symbol => !source.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingActionBarInventorySymbols.Length > 0) + { + throw new InvalidOperationException( + "InventoryMenu no longer submits equipped-item action-bar bindings through the typed AetheriaClient facade: " + + string.Join(", ", missingActionBarInventorySymbols)); + } + + var forbiddenInventoryActionBarDaemonSymbols = new[] + { + "operations => operations.SetActionBarBinding(", + "operations => operations.ClearActionBarBinding(controlPath)", + "SetActionBarBinding(", + "ClearActionBarBinding(controlPath)" + }; + var inventoryActionBarDaemonHits = forbiddenInventoryActionBarDaemonSymbols + .Where(symbol => source.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (inventoryActionBarDaemonHits.Length > 0) + { + throw new InvalidOperationException( + "InventoryMenu still treats local action-bar mapping as a daemon equipped-item command: " + + string.Join(", ", inventoryActionBarDaemonHits)); + } + + var forbiddenInventoryActionBarManagerReadSymbols = new[] + { + "GameManager.GetActionBarSlotCount(", + "GameManager.GetActionBarSlotLabel(", + "GameManager.GetActionBarBindingLabel(", + "GameManager.TryResolveActionBarSlotControlPath(" + }; + var inventoryActionBarManagerReadHits = forbiddenInventoryActionBarManagerReadSymbols + .Where(symbol => source.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (inventoryActionBarManagerReadHits.Length > 0) + { + throw new InvalidOperationException( + "InventoryMenu still reads action-bar presentation through ActionGameManager: " + + string.Join(", ", inventoryActionBarManagerReadHits)); + } + + var forbiddenActionBarManagerSymbols = new[] + { + "RequestWeaponGroupActionBarBinding(", + "RequestClearActionBarBinding(", + "TryRequestDaemonActionBarBinding(", + "TryRequestDaemonActionBarBindingClear(", + "RequestActionBarBinding(", + "CreateActionBarBinding(", + "CreateActionBarBindingCommit(", + "ResolveActionBarClient()?.Operations.SetActionBarBinding(" + }; + var survivingActionBarManagerSymbols = forbiddenActionBarManagerSymbols + .Where(symbol => actionGameManagerSource.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (survivingActionBarManagerSymbols.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns equipped-item action-bar binding authority instead of InventoryMenu's typed facade path: " + + string.Join(", ", survivingActionBarManagerSymbols)); } } @@ -4969,9 +5589,13 @@ static void RequireTradeCargoSelectorUseEveSurface(string root) "AetheriaRuntimeTradeCargoSelectorSurfaceCommands.TryRead(request, out var command)", "AetheriaRuntimeTradeCargoSelectorCommandKind.Close", "AetheriaRuntimeTradeCargoSelectorCommandKind.Select", - "CountAvailablePlayerShips(", - "GameManager.TryGetObservedDockingBay(out var dockingBay)", - "GameManager.ObservedAvailableEntities()" + "CurrentDockingAsync()", + "StationRefitAsync()", + "SetTargetCargo(", + "selection.EntityKey", + "OwnedQuantity", + "CargoTargets", + "AetheriaRuntimeStationCargoTargetRow" }; var missingSymbols = requiredSymbols @@ -5005,9 +5629,11 @@ static void RequireTradeCargoSelectorUseEveSurface(string root) "ProjectTradeCargoSelectorSurfaceState(", "GameManager.DockedEntity.Children", "GameManager.CurrentEntity.Parent.Children", - "GameManager.DockedEntity == null", "GameManager.DockingBay", - "GameManager.AvailableEntities()" + "GameManager.AvailableEntities()", + "_cargoSelectorStationRefitEntities", + "stationRefit.AvailableEntities ?? Array.Empty()", + "stationRefit.StationStock ?? Array.Empty()" }; var hits = forbiddenSymbols @@ -5021,6 +5647,22 @@ static void RequireTradeCargoSelectorUseEveSurface(string root) string.Join(", ", hits)); } + var forbiddenTradeCountingSymbols = new[] + { + "CountAvailablePlayerShips(", + "CountTargetCargoItems(", + "ItemsOfType" + }; + var tradeCountingHits = forbiddenTradeCountingSymbols + .Where(symbol => source.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (tradeCountingHits.Length > 0) + { + throw new InvalidOperationException( + "TradeMenu still computes trade ownership locally instead of reading typed station stock facts: " + + string.Join(", ", tradeCountingHits)); + } + var requiredBuilderSymbols = new[] { "public static class AetheriaRuntimeTradeCargoSelectorSurfaceBuilder", @@ -5327,9 +5969,9 @@ static void RequireTradeItemValuesUseRuntimeQueries(string root) var tradeQueries = File.ReadAllText(tradeQueriesPath); var requiredTradeMenuSymbols = new[] { - "ProjectTradeItem(item)", + "ProjectTradeItem(stock, typedItem)", "ProjectTradeItemCommit(", - "GameManager.ObservedTradeValueSettings()", + "ResolveCatalog()?.TradeValueSettings", "AetheriaRuntimeDaemonTradeItemQueries.ProjectTradeItem(", "AetheriaRuntimeTradeItemProjection TradeProjection", "public int Price => TradeProjection.Price", @@ -5350,6 +5992,8 @@ static void RequireTradeItemValuesUseRuntimeQueries(string root) "GameManager.ItemManager.GetTier", "GameManager.ItemManager.GameplaySettings.QualityPriceModifier", "_itemManager.GameplaySettings.QualityPriceModifier", + "GameManager.ObservedTradeValueSettings()", + "AetheriaUnityProjectionSettings.TradeValueSettings", "private readonly ItemManager _itemManager", "new TradeRow(item, FindTypedTradeItem(item), GameManager.ItemManager)" }; @@ -5497,9 +6141,15 @@ static void RequireInventoryDropdownUseEveSurface(string root) "AetheriaRuntimeInventoryDropdownSelectionKind.Loadout", "new AetheriaRuntimeInventoryDropdownEntityOption(", "new AetheriaRuntimeInventoryDropdownLoadoutOption(", - "GameManager.TryGetObservedDockingBay(out var dockingBay)", - "GameManager.ObservedAvailableEntities()", - "GameManager.ObservedLoadoutTemplates()" + "selection.EntityKey", + "_displayedEntityKey", + "_displayedCargoEntityKey", + "TryResolveStationRefitEntity(string entityKey", + "SetObservedFacadeIndex(AetheriaUnityObservedFacadeIndex observedFacadeIndex)", + "_observedFacadeIndex.TryResolveEntityByRecordKey", + "_observedFacadeIndex.TryResolveDockingBayByRecordKey", + "CurrentDockingAsync", + "LoadoutRestoreOptions" }; var missingSymbols = requiredSymbols @@ -5528,6 +6178,8 @@ static void RequireInventoryDropdownUseEveSurface(string root) "ContextMenu.AddOption(\"Save Loadout\"", "ContextMenu.AddDropdown(\"Restore Loadout\"", "ContextMenu.Show();", + "GameManager.ObservedAvailableEntities()", + "GameManager.TryGetObservedDockingBay(", "string.Equals(request.Command, AetheriaRuntimeInventoryDropdownSurfaceBuilder.Close", "_dropdownCommands.TryGetValue(request.Command", "private readonly Dictionary _dropdownCommands", @@ -5549,11 +6201,26 @@ static void RequireInventoryDropdownUseEveSurface(string root) string.Join(", ", hits)); } - if (!inventoryMenu.Contains("GameManager.TryGetObservedDockingBay(out var dockingBay)", StringComparison.Ordinal) || + if (!inventoryDropdownSurfaceBuilder.Contains("public string EntityKey { get; }", StringComparison.Ordinal) || + !inventoryDropdownSurfaceBuilder.Contains("entityKey: entity.EntityKey", StringComparison.Ordinal) || + !source.Contains("TryResolveStationRefitEntity(selection.EntityKey", StringComparison.Ordinal) || + !source.Contains("TryResolveCurrentDockingBayRow(out var currentDockingBay)", StringComparison.Ordinal) || + !source.Contains("currentDockingBay.DockingBayIndex", StringComparison.Ordinal) || + !source.Contains("ResolveStationRefit()?.DockParentEntityKey", StringComparison.Ordinal) || + source.Contains("var hasDockingBay = TryGetTypedCurrentDockingBayFacade", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "InventoryPanel dropdown must carry typed entity identity through the shared Eve surface and must project docking display state from StationRefitAsync docking-bay rows."); + } + + if (!inventoryMenu.Contains("TryGetTypedCurrentDockingBayFacade(out var dockingBay)", StringComparison.Ordinal) || + !inventoryMenu.Contains("CurrentDockingAsync()", StringComparison.Ordinal) || + !inventoryMenu.Contains("_observedFacadeIndex.TryResolveDockingBayByRecordKey", StringComparison.Ordinal) || + inventoryMenu.Contains("GameManager.TryGetObservedDockingBay(", StringComparison.Ordinal) || inventoryMenu.Contains("GameManager.DockingBay", StringComparison.Ordinal)) { throw new InvalidOperationException( - "InventoryMenu must open against daemon-observed docking bay state instead of peeking GameManager.DockingBay directly."); + "InventoryMenu must resolve typed current-docking state before adapting a key/index-scoped Unity docking bay facade."); } var requiredBuilderSymbols = new[] @@ -5575,6 +6242,8 @@ static void RequireInventoryDropdownUseEveSurface(string root) "AetheriaRuntimeInventoryDropdownLoadoutOption", "AetheriaRuntimeInventoryDropdownSelectionKind", "AetheriaRuntimeInventoryDropdownSurfaceProjection", + "public string EntityKey { get; }", + "entityKey: entity.EntityKey", "public static AetheriaRuntimeInventoryDropdownSurfaceProjection Project(", "public bool TryResolve(", "public static string EntityEquipmentCommand(", @@ -5596,6 +6265,168 @@ static void RequireInventoryDropdownUseEveSurface(string root) } } +static void RequireStationRefitDockingBaysUseTypedProjection(string root) +{ + var runtimeDocumentsPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeRtsViewportDocuments.cs"); + var runtimeProjectionPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeRtsProjection.cs"); + var inventoryPanelPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryPanel.cs"); + var inventoryMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryMenu.cs"); + + var runtimeDocuments = File.Exists(runtimeDocumentsPath) + ? File.ReadAllText(runtimeDocumentsPath) + : throw new InvalidOperationException("Cannot verify station-refit docking-bay projection; runtime viewport documents are missing."); + var runtimeProjection = File.Exists(runtimeProjectionPath) + ? File.ReadAllText(runtimeProjectionPath) + : throw new InvalidOperationException("Cannot verify station-refit docking-bay projection; runtime projection source is missing."); + var inventoryPanel = File.Exists(inventoryPanelPath) + ? File.ReadAllText(inventoryPanelPath) + : throw new InvalidOperationException("Cannot verify station-refit docking-bay projection; InventoryPanel.cs is missing."); + var inventoryMenu = File.Exists(inventoryMenuPath) + ? File.ReadAllText(inventoryMenuPath) + : throw new InvalidOperationException("Cannot verify station-refit docking-bay projection; InventoryMenu.cs is missing."); + + var requiredDocumentSymbols = new[] + { + "public IReadOnlyList DockingBays", + "public sealed class AetheriaRuntimeStationDockingBayRow", + "public int DockingBayIndex", + "public string OccupiedEntityKey", + "public int OccupiedEntityIndex", + "public string OccupiedEntityName", + "public string OccupiedHullItemKey", + "public bool OccupiedByCurrentEntity", + "public IReadOnlyList CargoItems", + "public IReadOnlyList CargoTargets", + "public sealed class AetheriaRuntimeStationCargoTargetRow", + "public AetheriaRuntimeTradeCargoTargetKind Kind", + "public int TargetIndex", + "public string EntityKey", + "public int BayIndex", + "public int Price", + "public bool CanAfford", + "public int OwnedQuantity" + }; + var missingDocumentSymbols = requiredDocumentSymbols + .Where(symbol => !runtimeDocuments.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingDocumentSymbols.Length > 0) + { + throw new InvalidOperationException( + "StationRefitAsync no longer exposes typed docking-bay rows: " + + string.Join(", ", missingDocumentSymbols)); + } + + var requiredProjectionSymbols = new[] + { + "var dockingBays = parent == null", + "DockingBays = dockingBays", + "ProjectStationDockingBays(context, parent, currentEntityIndex)", + "private static IReadOnlyList ProjectStationDockingBays(", + "dockParent.DockingBays", + "dockParent.DockingBayAssignments", + "dockParent.DockingBayContents", + "OccupiedEntityIndex = assignedEntity?.EntityIndex ?? assignedEntityIndex", + "OccupiedEntityKey = assignedEntity == null", + "BuildEntityKey(context.RunId, context.Zone.ZoneIndex, assignedEntity.EntityIndex)", + "OccupiedEntityName = assignedEntity?.Name", + "OccupiedHullItemKey = assignedEntity?.HullItemKey", + "OccupiedByCurrentEntity = assignedEntityIndex >= 0 && assignedEntityIndex == currentEntityIndex", + "CargoItems = dockingBayIndex < contents.Count", + "ProjectStationStock(contents[dockingBayIndex], dockingBayIndex)", + "var cargoTargets = parent == null", + "CargoTargets = cargoTargets", + "ProjectStationCargoTargets(parentKey, dockingBayIndex, dockingBays, availableEntities)", + "private static IReadOnlyList ProjectStationCargoTargets(", + "AetheriaRuntimeTradeCargoTargetKind.DockingBay", + "AetheriaRuntimeTradeCargoTargetKind.ShipBay", + "currentDockingBay?.CargoItems", + "entity.CargoItems ?? Array.Empty()", + "ProjectStationStockTradeFacts(", + "private static IReadOnlyList ProjectStationStockTradeFacts(", + "private static int ProjectStationStockPrice(", + "private static int CountStationOwnedQuantity(", + "AetheriaRuntimeDaemonTradeItemQueries.ProjectTradeItem(", + "CanAfford = price >= 0 && credits >= price", + "OwnedQuantity = CountStationOwnedQuantity" + }; + var missingProjectionSymbols = requiredProjectionSymbols + .Where(symbol => !runtimeProjection.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingProjectionSymbols.Length > 0) + { + throw new InvalidOperationException( + "StationRefitAsync docking-bay rows are no longer projected from daemon station state: " + + string.Join(", ", missingProjectionSymbols)); + } + + var requiredPanelSymbols = new[] + { + "TryResolveCurrentDockingBayRow(out _)", + "TryResolveCurrentDockingBayRow(out var currentDockingBay)", + "AetheriaRuntimeStationDockingBayRow dockingBay", + "stationRefit.DockingBays", + "row.DockingBayIndex == stationRefit.DockingBayIndex", + "dockingBayRow.DockingBayIndex" + }; + var missingPanelSymbols = requiredPanelSymbols + .Where(symbol => !inventoryPanel.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingPanelSymbols.Length > 0) + { + throw new InvalidOperationException( + "InventoryPanel must validate current docking-bay display through StationRefitAsync typed docking-bay rows: " + + string.Join(", ", missingPanelSymbols)); + } + + var requiredMenuSymbols = new[] + { + "TryResolveCurrentDockingBayRow(out var dockingBayRow)", + "AetheriaRuntimeStationDockingBayRow dockingBay", + "stationRefit.DockingBays", + "row.DockingBayIndex == stationRefit.DockingBayIndex", + "dockingBayRow.DockingBayIndex" + }; + var missingMenuSymbols = requiredMenuSymbols + .Where(symbol => !inventoryMenu.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingMenuSymbols.Length > 0) + { + throw new InvalidOperationException( + "InventoryMenu must validate current docking-bay display through StationRefitAsync typed docking-bay rows: " + + string.Join(", ", missingMenuSymbols)); + } + + var forbiddenMenuSymbols = new[] + { + "GameManager.TryGetObservedDockingBay(", + "GameManager.DockingBay" + }; + var forbiddenHits = forbiddenMenuSymbols + .Where(symbol => inventoryPanel.Contains(symbol, StringComparison.Ordinal) || inventoryMenu.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (forbiddenHits.Length > 0) + { + throw new InvalidOperationException( + "Inventory docking-bay display still asks ActionGameManager for broad docking state instead of typed station-refit rows: " + + string.Join(", ", forbiddenHits)); + } +} + static void RequireNoDeadPopupShells(string root) { var deletedShellPaths = new[] @@ -5878,6 +6709,74 @@ static void RequireDaemonHostDoesNotDrainRuntimeCommits(string root) } } +static void RequireInventoryProjectionSlotIdentity(string root) +{ + var projectionPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeRtsProjection.cs"); + var viewportDocumentsPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeRtsViewportDocuments.cs"); + + if (!File.Exists(projectionPath)) + { + throw new InvalidOperationException("Cannot verify inventory projection slot identity; AetheriaRuntimeRtsProjection.cs is missing."); + } + if (!File.Exists(viewportDocumentsPath)) + { + throw new InvalidOperationException("Cannot verify inventory projection slot identity; AetheriaRuntimeRtsViewportDocuments.cs is missing."); + } + + var projection = File.ReadAllText(projectionPath); + var viewportDocuments = File.ReadAllText(viewportDocumentsPath); + + var requiredProjectionSymbols = new[] + { + "AddSlot(items, \"equipment\", equipmentIndex, equipment[equipmentIndex])", + "AddSlot(items, \"cargo\", cargoBayIndex, slot)", + "SourceIndex = sourceIndex", + "X = slot.X", + "Y = slot.Y" + }; + var missingProjectionSymbols = requiredProjectionSymbols + .Where(symbol => !projection.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingProjectionSymbols.Length > 0) + { + throw new InvalidOperationException( + "Inventory projection no longer carries enough typed slot identity for cargo/equipment operations: " + + string.Join(", ", missingProjectionSymbols)); + } + + var requiredDocumentSymbols = new[] + { + "public sealed class AetheriaRuntimeRtsInventoryItem", + "[Key(6)]", + "public int SourceIndex { get; set; } = -1;", + "[Key(7)]", + "public int X { get; set; } = -1;", + "[Key(8)]", + "public int Y { get; set; } = -1;" + }; + var missingDocumentSymbols = requiredDocumentSymbols + .Where(symbol => !viewportDocuments.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingDocumentSymbols.Length > 0) + { + throw new InvalidOperationException( + "Inventory item document no longer publishes cargo/equipment slot identity to CultMesh clients: " + + string.Join(", ", missingDocumentSymbols)); + } +} + static void RequireVerseHostSettingsAuthority(string root) { var settingsPath = Path.Combine(root, "Aetheria.State", "Documents", "AetheriaVerseHostSettings.cs"); @@ -6070,6 +6969,7 @@ static void RequireClientTargetBootAuthority(string root) var bootstrapPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.eve-runtime", "Runtime", "AetheriaEveRuntimeBootstrap.cs"); var presenterPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.eve-runtime", "Runtime", "AetheriaEveSurfacePresenter.cs"); var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); + var gameplayBootShellPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplayBootShell.cs"); var mainMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "MainMenu.cs"); var requiredFiles = new[] @@ -6086,6 +6986,7 @@ static void RequireClientTargetBootAuthority(string root) bootstrapPath, presenterPath, actionGameManagerPath, + gameplayBootShellPath, mainMenuPath }; @@ -6112,6 +7013,7 @@ static void RequireClientTargetBootAuthority(string root) var bootstrap = File.ReadAllText(bootstrapPath); var presenter = File.ReadAllText(presenterPath); var actionGameManager = File.ReadAllText(actionGameManagerPath); + var gameplayBootShell = File.ReadAllText(gameplayBootShellPath); var mainMenu = File.ReadAllText(mainMenuPath); var requiredUnityPackageProjectSymbols = new[] @@ -6354,9 +7256,14 @@ static void RequireClientTargetBootAuthority(string root) var requiredActionGameManagerSymbols = new[] { - "AetheriaRuntimeStateBoot.Inspect(GameDataDirectory)", - "Aetheria runtime target: {stateBoot.TargetLabel} via {stateBoot.TargetKind} ({stateBoot.TargetSource})", - "!stateBoot.SupportsLocalStateFileRead" + "private AetheriaUnityGameplayBootShell GameplayBootShell =>", + "var boot = GameplayBootShell.Boot();", + "SceneWiring.ConfigureCurrentEntityPresentation(_currentEntityPresentation, boot.RuntimeCatalog);", + "SceneWiring.ConfigureTargetPresentation(", + "boot.RuntimeCatalog,", + "SceneWiring.ConfigureActionBarPresentation(", + "ItemManager = boot.ItemManager;", + "_loadoutItemProjector = boot.LoadoutItemProjector;" }; var missingActionGameManagerSymbols = requiredActionGameManagerSymbols .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) @@ -6364,10 +7271,61 @@ static void RequireClientTargetBootAuthority(string root) if (missingActionGameManagerSymbols.Length > 0) { throw new InvalidOperationException( - "ActionGameManager no longer boots gameplay through the shared client-target report: " + + "ActionGameManager no longer delegates gameplay boot through AetheriaUnityGameplayBootShell: " + string.Join(", ", missingActionGameManagerSymbols)); } + var forbiddenActionGameManagerBootSymbols = new[] + { + "AetheriaRuntimeStateBoot.Inspect(AetheriaUnityRuntimePaths.GameDataDirectory)", + "Aetheria runtime target: {stateBoot.TargetLabel} via {stateBoot.TargetKind} ({stateBoot.TargetSource})", + "!stateBoot.SupportsLocalStateFileRead", + "stateBoot.StateFileExists", + "private static AetheriaRuntimeCatalogSnapshot _runtimeCatalog", + "FindTypedRuntimeItem(", + "_runtimeCatalog?.FindItem(" + }; + var actionGameManagerBootHits = forbiddenActionGameManagerBootSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (actionGameManagerBootHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns runtime-target boot details instead of the Unity gameplay boot shell: " + + string.Join(", ", actionGameManagerBootHits)); + } + + var requiredGameplayBootShellSymbols = new[] + { + "public sealed class AetheriaUnityGameplayBootShell", + "public AetheriaUnityGameplayBootResult Boot()", + "AetheriaRuntimeStateBoot.Inspect(AetheriaUnityRuntimePaths.GameDataDirectory)", + "Aetheria runtime target: {stateBoot.TargetLabel} via {stateBoot.TargetKind} ({stateBoot.TargetSource})", + "Aetheria runtime id: {stateBoot.RuntimeId}", + "Aetheria runtime state file: {stateBoot.StateFilePath}", + "!stateBoot.SupportsLocalStateFileRead", + "stateBoot.StateFileExists", + "AetheriaUnityRuntimeClientProvider.ResolveClient(stateBoot.StateFilePath, stateBoot.RuntimeId)", + ".OpenRuntimeCatalog()", + "new ItemManager(", + "new AetheriaUnityLoadoutItemProjector(itemManager, runtimeCatalog)", + "ZoneRenderer.SetDroppedPickupItemProjector(loadoutItemProjector.CreateLoadoutItem)", + "ZoneRenderer.BodySettingsCollections = Settings.BodySettingsCollections", + "AetheriaUnityRenderSettingsBridge.Build(", + "CockpitHudShell.SetRenderSettings(ZoneRenderer.RenderSettings)", + "AetheriaUnityRuntimeClientProvider.PlayerSettings.GraphicsSettings.ShowAsteroidsInMinimap", + "public readonly struct AetheriaUnityGameplayBootResult" + }; + var missingGameplayBootShellSymbols = requiredGameplayBootShellSymbols + .Where(symbol => !gameplayBootShell.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingGameplayBootShellSymbols.Length > 0) + { + throw new InvalidOperationException( + "AetheriaUnityGameplayBootShell no longer boots gameplay through the shared client-target report: " + + string.Join(", ", missingGameplayBootShellSymbols)); + } + var requiredPresenterSymbols = new[] { "AetheriaRuntimeStateBoot.Inspect(gameDataDirectory, stateFilePathOverride)", @@ -6394,11 +7352,11 @@ static void RequireClientTargetBootAuthority(string root) var requiredMainMenuSymbols = new[] { "CurrentStateBoot()", - "LatestDaemonFrame(AetheriaRuntimeStateBootReport stateBoot)", - "AetheriaRuntimeVerseClient", - "GetLatestAuthoritativeRunFrameAsync()", + "LatestSectorMap(AetheriaRuntimeStateBootReport stateBoot)", + "AetheriaClient", + ".SectorMapAsync()", "LatestVerseHostSettings(AetheriaRuntimeStateBootReport stateBoot)", - "AetheriaState.At(ActionGameManager.GameDataDirectory)", + "AetheriaState.At(AetheriaUnityRuntimePaths.GameDataDirectory)", ".ClientTarget", "RequestClientTargetCommand(request)", "AetheriaRuntimeClientTargetSurfaceCommands.TryRequest(", @@ -6520,8 +7478,7 @@ static void RequireVerseReplicaTool(string root) var requiredProjectSymbols = new[] { - "", - "" + "" }; var missingProjectSymbols = requiredProjectSymbols .Where(symbol => !replicaToolProject.Contains(symbol, StringComparison.Ordinal)) @@ -6533,14 +7490,24 @@ static void RequireVerseReplicaTool(string root) string.Join(", ", missingProjectSymbols)); } + if (replicaToolProject.Contains("Aetheria.State.Unity", StringComparison.Ordinal) || + replicaToolProject.Contains("GameCult.Aetheria.State.Unity", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Verse replica tool should not depend on Unity state assemblies; replica sync must stay daemon-state native."); + } + var requiredProgramSymbols = new[] { "sync", "follow", "AetheriaVerseReplica", - "GetReplicaStateFilePath", + "ResolveReplicaPath", "--endpoint", - "--replica" + "--replica", + "--game-data-root", + "--verse-id", + "--runtime-id" }; var missingProgramSymbols = requiredProgramSymbols .Where(symbol => !replicaToolProgram.Contains(symbol, StringComparison.Ordinal)) @@ -6560,7 +7527,7 @@ static void RequireVerseReplicaTool(string root) "CultNetSchemaShardSnapshotFetcher", "CultNetSchemaShardLogFetcher", "CultNetShardReplicator", - "ApplyShardSnapshotResponseAsync" + "ApplyRawSnapshotResponseAsync" }; var missingReplicaHostSymbols = requiredReplicaHostSymbols .Where(symbol => !replicaHost.Contains(symbol, StringComparison.Ordinal)) @@ -6880,8 +7847,8 @@ static void RequireVerseSettingsShellAndBridge(string root) "AetheriaRuntimeMainMenuCommandKind.VerseHostCommand", "RequestClientTargetCommand(request)", "SendKnownAetheriaEveCommand(request, \"Verse-host\")", - "SubmitKnownSurfaceCommandAsync(request, \"unity-main-menu\")", - "AetheriaState.At(ActionGameManager.GameDataDirectory)", + ".Ui.SurfaceCommandAsync(request, \"unity-main-menu\")", + "AetheriaState.At(AetheriaUnityRuntimePaths.GameDataDirectory)", ".ClientTarget", "AetheriaRuntimeClientTargetSurfaceCommands.TryRequest(" }; @@ -7167,7 +8134,6 @@ static void RequireTypedDaemonCommandPayloads(string root) var requiredPayloadTypes = new[] { "public enum AetheriaRuntimeDaemonCommandKinds", - "public sealed class AetheriaRuntimeActionBarBindingCommand", "public sealed class AetheriaRuntimeCargoTransferCommand", "public sealed class AetheriaRuntimeTradePurchaseCommand", "public sealed class AetheriaRuntimeLootPickupCommand", @@ -7185,6 +8151,12 @@ static void RequireTypedDaemonCommandPayloads(string root) string.Join(", ", missingPayloadTypes)); } + if (daemonDocuments.Contains("public sealed class AetheriaRuntimeActionBarBindingCommand", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Action-bar input mappings are Unity-local and must not be daemon command payloads."); + } + var liveSources = new Dictionary { ["daemon documents"] = daemonDocuments, @@ -7256,10 +8228,9 @@ static void RequireTypedDaemonCommandPayloads(string root) "ClientId = string.IsNullOrWhiteSpace(clientId) ? DefaultClientId : clientId", "private AetheriaRuntimeDaemonCommandEnvelope Send(AetheriaRuntimeDaemonCommandDocument command)", "ReadObservedDaemonCommands()", - "Func submit", - "return Send((client, observed) => client.SetTarget(observed, targetEntityKey));", + "Func submit", + "return Submit((client, observed) => client.SetTarget(observed, targetEntityKey));", "return Send((client, observed) => client.TransferCargoItem(", - "command.ActionBarBinding.Kind", "command.CargoTransfer.OriginEntityKey", "command.TradePurchase.TotalPrice", "command.LootPickup.ItemKey", @@ -7271,6 +8242,42 @@ static void RequireTypedDaemonCommandPayloads(string root) var daemonOperationsClient = File.Exists(daemonOperationsClientPath) ? File.ReadAllText(daemonOperationsClientPath) : throw new InvalidOperationException("Shared daemon operations client is missing from the runtime package."); + var requiredDaemonOperationsFacadeSymbols = new[] + { + "using GameCult.Mesh;", + "public AetheriaRuntimeDaemonCommandEnvelope SetTarget(string targetEntityKey)", + "public AetheriaRuntimeDaemonCommandEnvelope SetMoveVector(", + "public CultMeshOperationReceipt TransferCargoItem(", + "private AetheriaRuntimeDaemonCommandEnvelope Submit(", + "private CultMeshOperationReceipt Send(" + }; + var missingDaemonOperationsFacadeSymbols = requiredDaemonOperationsFacadeSymbols + .Where(symbol => !daemonOperationsClient.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingDaemonOperationsFacadeSymbols.Length > 0) + { + throw new InvalidOperationException( + "Aetheria runtime operations facade no longer exposes shared CultMesh receipts: " + + string.Join(", ", missingDaemonOperationsFacadeSymbols)); + } + + var forbiddenPublicEnvelopeSymbols = new[] + { + "public AetheriaRuntimeDaemonCommandEnvelope ClearTarget(", + "public AetheriaRuntimeDaemonCommandEnvelope TransferCargoItem(", + "public AetheriaRuntimeDaemonCommandEnvelope SetLookDirection(", + "public AetheriaRuntimeDaemonCommandEnvelope SetTractorPower(" + }; + var publicEnvelopeHits = forbiddenPublicEnvelopeSymbols + .Where(symbol => daemonOperationsClient.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (publicEnvelopeHits.Length > 0) + { + throw new InvalidOperationException( + "Aetheria runtime operations facade exposes command envelopes beyond the typed identity-sensitive command path: " + + string.Join(", ", publicEnvelopeHits)); + } + var legacyUnityOperationsPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaDaemonOperations.cs"); if (File.Exists(legacyUnityOperationsPath)) { @@ -7289,6 +8296,12 @@ static void RequireTypedDaemonCommandPayloads(string root) string.Join(", ", missingClientSymbols)); } + if (clientAndObserver.Contains("ActionBarBinding", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Daemon command producers still expose Unity-local action-bar input bindings."); + } + var forbiddenQueueSymbols = new[] { "public static class AetheriaRuntimeDaemonCommandLog", @@ -7485,6 +8498,7 @@ static void RequireDaemonVersePublication(string root) var daemonStateRefsPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonStateRefs.cs"); var daemonGameSurfaceBuilderPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonGameSurfaceBuilder.cs"); var daemonEditorSurfaceBuilderPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonEditorSurfaceBuilder.cs"); + var tradeValuePolicySurfaceBuilderPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeTradeValuePolicySurfaceBuilder.cs"); var daemonSurfaceCommandCatalogPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonSurfaceCommandCatalog.cs"); var daemonHostProjectPath = Path.Combine(root, "Aetheria.State.Daemon", "Aetheria.State.Daemon.csproj"); var daemonHostProgramPath = Path.Combine(root, "Aetheria.State.Daemon", "Program.cs"); @@ -7508,6 +8522,7 @@ static void RequireDaemonVersePublication(string root) daemonStateRefsPath, daemonGameSurfaceBuilderPath, daemonEditorSurfaceBuilderPath, + tradeValuePolicySurfaceBuilderPath, daemonSurfaceCommandCatalogPath, daemonHostProjectPath, daemonHostProgramPath, @@ -7540,6 +8555,7 @@ static void RequireDaemonVersePublication(string root) var daemonStateRefs = File.ReadAllText(daemonStateRefsPath); var daemonGameSurfaceBuilder = File.ReadAllText(daemonGameSurfaceBuilderPath); var daemonEditorSurfaceBuilder = File.ReadAllText(daemonEditorSurfaceBuilderPath); + var tradeValuePolicySurfaceBuilder = File.ReadAllText(tradeValuePolicySurfaceBuilderPath); var daemonSurfaceCommandCatalog = File.ReadAllText(daemonSurfaceCommandCatalogPath); var daemonHostProject = File.ReadAllText(daemonHostProjectPath); var daemonHostProgram = File.ReadAllText(daemonHostProgramPath); @@ -7599,11 +8615,8 @@ static void RequireDaemonVersePublication(string root) "MemoryMappedFile.CreateOrOpen(", "ObserverWritable = false", "AetheriaRuntimeDaemonSoaColumnKinds.EntityIndex", - "AetheriaRuntimeDaemonSoaColumnKinds.PositionX", - "AetheriaRuntimeDaemonSoaColumnKinds.PositionY", - "AetheriaRuntimeDaemonSoaColumnKinds.PositionZ", - "AetheriaRuntimeDaemonSoaColumnKinds.VelocityX", - "AetheriaRuntimeDaemonSoaColumnKinds.VelocityZ", + "AetheriaRuntimeDaemonSoaColumnKinds.Position", + "AetheriaRuntimeDaemonSoaColumnKinds.Velocity", "AetheriaRuntimeDaemonSoaColumnKinds.PhysicsBodyRadius", "AetheriaRuntimeDaemonSoaColumnKinds.RenderVisibility", "AetheriaRuntimeDaemonSoaColumnKinds.RenderGroupId", @@ -7624,6 +8637,7 @@ static void RequireDaemonVersePublication(string root) "RuntimeDaemonProviderFileSuffix", "RuntimeDaemonHealthFileSuffix", "RuntimeDaemonCommandBoundaryFileSuffix", + "RuntimeDaemonStarbridgeSessionSummaryFileSuffix", "RuntimeDaemonGameSurfaceFileSuffix", "RuntimeDaemonGameTuiSurfaceFileSuffix", "RuntimeDaemonEditorSurfaceFileSuffix", @@ -7631,6 +8645,7 @@ static void RequireDaemonVersePublication(string root) "GetDaemonProviderPath(", "GetDaemonHealthPath(", "GetDaemonCommandBoundaryPath(", + "GetDaemonStarbridgeSessionSummaryPath(", "GetDaemonGameSurfacePath(", "GetDaemonGameTuiSurfacePath(", "GetDaemonEditorSurfacePath(", @@ -7654,6 +8669,8 @@ static void RequireDaemonVersePublication(string root) "ReadDaemonHealth(", "WriteDaemonCommandBoundary(", "ReadDaemonCommandBoundary(", + "WriteStarbridgeSessionSummary(", + "ReadStarbridgeSessionSummary(", "WriteDaemonGameSurface(", "ReadDaemonGameSurface(", "WriteDaemonEditorSurface(", @@ -7661,6 +8678,7 @@ static void RequireDaemonVersePublication(string root) "PublishProviderAdvertisement(", "PublishHealth(", "PublishCommandBoundary(", + "PublishStarbridgeSessionSummary(", "PublishGameSurface(", "PublishGameTuiSurface(", "PublishEditorSurface(", @@ -7668,6 +8686,7 @@ static void RequireDaemonVersePublication(string root) "TryReadProviderAdvertisement(", "TryReadHealth(", "TryReadCommandBoundary(", + "TryReadStarbridgeSessionSummary(", "TryReadGameSurface(", "TryReadGameTuiSurface(", "TryReadEditorSurface(", @@ -7830,8 +8849,13 @@ static void RequireDaemonVersePublication(string root) "AetheriaRuntimeDaemonPublicationStore.PublishCommandBoundary", "AetheriaRuntimeDaemonPublicationStore.PublishProviderAdvertisement", "AetheriaRuntimeDaemonPublicationStore.PublishHealth", + "AetheriaRuntimeStarbridgeProjection.ProjectSessionSummary(", + "AetheriaRuntimeDaemonPublicationStore.PublishStarbridgeSessionSummary", + "StarbridgeScenario", + "StarbridgeSession", "AetheriaRuntimeDaemonGameSurfaceBuilder.Build", "AetheriaRuntimeCatalogStore.ProjectStatRecipeSurfaceDocument(stateFilePath)", + "AetheriaRuntimeCatalogStore.ProjectTradeValuePolicySurfaceDocument(stateFilePath)", "AetheriaRuntimeDaemonPublicationStore.PublishGameSurface", "AetheriaRuntimeDaemonPublicationStore.PublishGameTuiSurface", "AetheriaRuntimeDaemonEditorSurfaceBuilder.Build", @@ -7859,6 +8883,10 @@ static void RequireDaemonVersePublication(string root) "ProjectReference Include=\"..\\Aetheria.State\\Aetheria.State.csproj\"", "AetheriaStateNode.OpenAsync(", "startServer: true", + "GetStarbridgeScenarioAsync()", + "GetStarbridgeSessionAsync()", + "StarbridgeScenario = starbridgeScenario", + "StarbridgeSession = starbridgeSession", "new AetheriaVerseDiscoveryHost(node)", "discoveryHost.Update(", "PublishDaemonApiDocumentsAsync(node, result)", @@ -7867,7 +8895,7 @@ static void RequireDaemonVersePublication(string root) "PutDaemonEditorTuiSurfaceAsync(AetheriaRuntimeEveSurfaceStateProjector.ToState(editorTuiSurface))", "AetheriaEveCommandBridge.AcceptObservedAsync(", "ReadObservedDaemonCommands()", - "AccountedCommandIds = currentFrame?.AccountedCommandIds ?? Array.Empty()", + "currentFrame?.AccountedCommandIds ?? Array.Empty()", "AetheriaRuntimeDaemonTickRunner.Tick(", "AetheriaProviderAdvertisementProjector.Build(verseHost, node.StatePath, updatedAtUtc)", "AetheriaOperationsSurfaceProjector.Build(eveStatus, verseHost, runtimeSession)", @@ -7903,6 +8931,31 @@ static void RequireDaemonVersePublication(string root) "Aetheria.State.Daemon still exposes command-retention toggles after adopting receipt-ledger command accounting."); } + var requiredTradeValuePolicySurfaceSymbols = new[] + { + "public static class AetheriaRuntimeTradeValuePolicySurfaceBuilder", + "SurfaceId = \"aetheria.tradeValuePolicy\"", + "AetheriaRuntimeTradeValuePolicySurfaceState", + "Quality Price Modifier", + "Rarity Tiers", + "AetheriaRuntimeTradeValuePolicyCommands.SetQualityMinimum", + "AetheriaRuntimeTradeValuePolicyCommands.SetTierQuality", + "BuildCommandTemplates()", + "control.text", + "curve.Evaluate(0.25)", + "ToHex(tier.Red, tier.Green, tier.Blue)" + }; + var tradeValuePolicySurfaceSource = tradeValuePolicySurfaceBuilder + "\n" + documentStore; + var missingTradeValuePolicySurfaceSymbols = requiredTradeValuePolicySurfaceSymbols + .Where(symbol => !tradeValuePolicySurfaceSource.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingTradeValuePolicySurfaceSymbols.Length > 0) + { + throw new InvalidOperationException( + "Trade value policy is no longer exposed as a designer-owned Eve surface: " + + string.Join(", ", missingTradeValuePolicySurfaceSymbols)); + } + if (stateNode.Contains("DeleteDaemonCommandAsync(", StringComparison.Ordinal) || daemonHostSource.Contains("DeleteAccountedDaemonCommandsAsync", StringComparison.Ordinal) || daemonHostSource.Contains("DeleteDaemonCommandAsync(", StringComparison.Ordinal)) @@ -7928,10 +8981,20 @@ static void RequireDaemonVersePublication(string root) "AetheriaRuntimeDaemonFrameDocument frame", "AetheriaRuntimeDaemonHealthDocument health", "AetheriaRuntimeDaemonCommandBoundaryDocument commandBoundary", + "AetheriaRuntimeStarbridgeSessionSummaryDocument? starbridge = null", + "AetheriaRuntimeStarbridgeProjection.ProjectSessionSummary(frame)", "AetheriaRuntimeSurfaceStateRefs.SourceRef(stateRef)", "AetheriaRuntimeDaemonStateRefs.CurrentEntityName", "AetheriaRuntimeDaemonStateRefs.CurrentTargetName", "\"game.daemon\"", + "\"aetheria.daemon.game.starbridge\"", + "\"Starbridge Session\"", + "\"Station Stock\"", + "\"Wave Forecast\"", + "\"Runtime Roles\"", + "BuildStarbridgeStationStockCard(starbridge)", + "BuildStarbridgeWaveForecastCard(starbridge)", + "BuildStarbridgeRuntimeRolesCard(starbridge)", "\"Typed Command Boundary\"", "\"commandBody\"", "nameof(AetheriaRuntimeDaemonCommandDocument)", @@ -7972,7 +9035,7 @@ static void RequireDaemonVersePublication(string root) "public const string CommandPrefix = \"aetheria.daemon.commands.\"", "public static IReadOnlyList ArgumentlessCommands", "public static bool IsArgumentlessCommand(", - "public static bool TrySubmitArgumentless(", + "internal static bool TrySubmitArgumentless(", "AetheriaRuntimeDaemonCommandKinds.SensorPing => client.SensorPing(observed)" }; var missingSurfaceCommandCatalogSymbols = requiredSurfaceCommandCatalogSymbols @@ -8063,9 +9126,11 @@ static void RequireDaemonVersePublication(string root) "PutDaemonProviderAdvertisementAsync(daemonProvider)", "PutDaemonHealthAsync(daemonHealth)", "PutDaemonCommandBoundaryAsync(daemonCommandBoundary)", - "AetheriaRuntimeVerseClient.OpenAsync(", - "daemonCommandClient.SubmitDaemonCommandAsync(", - "eveCommandClient.SubmitEveCommandAsync(", + "AetheriaClient.OpenAsync(", + "daemonCommandClient.Control.SensorPing();", + "eveCommandClient.Ui.InputSettingsAsync(", + "AetheriaClient control submission did not appear as a typed daemon state record.", + "AetheriaClient UI submission did not appear as a typed Eve state record.", "PutDaemonFrameAsync(daemonFrame)", "PutDaemonGameSurfaceAsync(AetheriaRuntimeEveSurfaceStateProjector.ToState(daemonGameSurface))", "GetDaemonProviderAdvertisementAsync()", @@ -8168,18 +9233,19 @@ static void RequireAetheriaRuntimeVerseClientContract(string root) "WatchLatestSoaViews()", "WatchRecord", "WatchRecord", - "AetheriaRuntimeVerseDocument", + "CultMeshMutableStatePointer", + "CultMesh.MutableStatePointer(", "CultNetDocumentBinding.ForDocument(registry)", "GetDaemonGameTuiSurfaceAsync()", "GetDaemonEditorTuiSurfaceAsync()", "WatchDaemonGameTuiSurfaces()", "WatchDaemonEditorTuiSurfaces()", - "AetheriaRuntimeVerseDocument", + "CultMeshMutableStatePointer", "GetLatestAuthoritativeRunFrameAsync()", "GetObservedDaemonStateAsync()", "OpenRuntimeCatalog()", "GetPlayerSettingsAsync()", - "GetVerseHostSettingsAsync()", + "VerseHostSettingsAsync()", "GetLoadoutTemplatesAsync()", "CreateEveSurfaceStateRefResolver(", "AetheriaRuntimeStateReader.TryResolveDaemonStateRef(", @@ -8201,6 +9267,19 @@ static void RequireAetheriaRuntimeVerseClientContract(string root) string.Join(", ", missingClientSymbols)); } + if (client.Contains("public async Task SubmitDaemonCommandAsync(", StringComparison.Ordinal) || + client.Contains("public async Task SubmitEveCommandAsync(", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "AetheriaRuntimeVerseClient exposes raw command-envelope submission publicly; clients should use AetheriaClient.Control and AetheriaClient.Ui."); + } + + if (client.Contains("AetheriaRuntimeVerseDocument", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "AetheriaRuntimeVerseClient reintroduced a private document handle; use shared CultMeshMutableStatePointer instead."); + } + var requiredSurfaceSymbols = new[] { "namespace Aetheria.State.Documents", @@ -8208,6 +9287,8 @@ static void RequireAetheriaRuntimeVerseClientContract(string root) "public sealed class EveSurfaceState", "public sealed class EveSurface", "public sealed class EveSurfaceComponent", + "public sealed class EveSurfaceStateBinding", + "public EveSurfaceStateBinding[] StateBindings", "public sealed class EveCommandTemplate" }; var missingSurfaceSymbols = requiredSurfaceSymbols @@ -8231,7 +9312,7 @@ static void RequireAetheriaRuntimeVerseClientContract(string root) { "AetheriaRuntimeVerseClient", "WatchRecord()", - "AetheriaRuntimeVerseDocument", + "CultMeshMutableStatePointer", "Unity", "Verse records", "EveSurfaceState", @@ -8253,6 +9334,8 @@ static void RequireTypedEveCommandBodies(string root) var eveCommandDocumentPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeEveCommandDocument.cs"); var eveCommandClientPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeEveCommandClient.cs"); var verseClientPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseClient.cs"); + var aetheriaClientPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaClient.cs"); + var aetheriaUiPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaUi.cs"); var runtimeCommandPortPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeCommandPort.cs"); var eveCommandBridgePath = Path.Combine(root, "Aetheria.State", "AetheriaEveCommandBridge.cs"); var stateNodePath = Path.Combine(root, "Aetheria.State", "AetheriaStateNode.cs"); @@ -8261,7 +9344,7 @@ static void RequireTypedEveCommandBodies(string root) var mainMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "MainMenu.cs"); var evePresenterPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.eve-runtime", "Runtime", "AetheriaEveSurfacePresenter.cs"); - var requiredFiles = new[] { eveCommandDocumentPath, eveCommandClientPath, verseClientPath, eveCommandBridgePath, stateNodePath, documentRegistryPath, actionGameManagerPath, mainMenuPath, evePresenterPath }; + var requiredFiles = new[] { eveCommandDocumentPath, eveCommandClientPath, verseClientPath, aetheriaClientPath, aetheriaUiPath, eveCommandBridgePath, stateNodePath, documentRegistryPath, actionGameManagerPath, mainMenuPath, evePresenterPath }; var missingFiles = requiredFiles .Where(path => !File.Exists(path)) .Select(path => Path.GetRelativePath(root, path)) @@ -8276,6 +9359,8 @@ static void RequireTypedEveCommandBodies(string root) var eveCommandDocument = File.ReadAllText(eveCommandDocumentPath); var eveCommandClient = File.ReadAllText(eveCommandClientPath); var verseClient = File.ReadAllText(verseClientPath); + var aetheriaClient = File.ReadAllText(aetheriaClientPath); + var aetheriaUi = File.ReadAllText(aetheriaUiPath); var normalizedEveCommandClient = eveCommandClient.Replace("\r\n", "\n", StringComparison.Ordinal); var eveCommandBridge = File.ReadAllText(eveCommandBridgePath); var stateNode = File.ReadAllText(stateNodePath); @@ -8288,10 +9373,23 @@ static void RequireTypedEveCommandBodies(string root) "public enum AetheriaRuntimeEveCommandKind", "public sealed class AetheriaRuntimePlayerSettingsCommandBody", "public sealed class AetheriaRuntimeInputSettingsCommandBody", + "public sealed class AetheriaRuntimeTradeValuePolicyCommandBody", "public AetheriaRuntimeEveCommandKind Kind", "public AetheriaRuntimePlayerSettingsCommandBody PlayerSettings", "public AetheriaRuntimeInputSettingsCommandBody InputSettings", "public AetheriaRuntimeLoadoutTemplateCommit? LoadoutTemplate", + "public AetheriaRuntimeTradeValuePolicyCommandBody TradeValuePolicy", + "public CultMeshOperationInvocationDescriptor Invocation", + "public CultMeshOperationPayload Payload", + "public CultMeshOperationInvocationRecord Operation", + "public string OperationId", + "public string OperationSchemaId", + "public string OperationRouteKind", + "public string OperationRouteDescription", + "public string OperationIdempotencyKey", + "public Dictionary Payload", + "SetTradeValueQualityMinimum", + "SetTradeValueTierQuality", "[CultDocument(\"gamecult.eve.command\", \"gamecult.eve.command.v1\")]" }; var missingDocumentSymbols = requiredDocumentSymbols @@ -8307,7 +9405,6 @@ static void RequireTypedEveCommandBodies(string root) var forbiddenDocumentSymbols = new[] { "IReadOnlyDictionary Payload", - "Dictionary Payload", "SubmitSurfaceRequest(", "CreateSurfaceRequestCommand(" }; @@ -8323,18 +9420,39 @@ static void RequireTypedEveCommandBodies(string root) var requiredTypedCommandSymbols = new[] { - "public static class AetheriaRuntimeEveCommands", + "internal static class AetheriaRuntimeEveCommands", "public static class AetheriaRuntimeEveCommandClient", "namespace GameCult.Aetheria.State.Verse", - "AetheriaRuntimeVerseClient.OpenAsync(", - "Task SubmitDaemonCommandAsync(", - "Task SubmitEveCommandAsync(", + "AetheriaClient.OpenAsync(", + "internal async Task SubmitDaemonCommandAsync(", + "internal async Task SubmitEveCommandAsync(", "CreatePlayerSettingsCommand(", "CreateInputSettingsCommand(", "CreateCatalogCommand(", "CreateOperationsCommand(", "CreateVerseHostCommand(", "CreateLoadoutTemplateCommand(", + "CreateTradeValuePolicyCommand(", + "ReadTradeValuePolicyBody(", + "var command = OperationIdFor(request);", + "return CommandKindForSurface(request.SurfaceId ?? \"\", OperationIdFor(request));", + "private static string OperationIdFor(EveSurfaceCommandRequest request)", + "return request?.Operation?.OperationId ?? \"\";", + "CultMeshOperationInvocationDescriptor", + "CultMeshOperationPayload", + "invocation: request.Operation", + "payload: request.Payload", + "CreateInvocation(", + "CultMesh.OperationInvocationRecord(", + "Operation = invocation", + "Operation = invocationRecord", + "NormalizeInvocationRecord(", + "ApplyInvocationCompatibilityFields(", + "Payload = envelope.Payload.ToDictionary()", + "Payload = (payload ?? CultMeshOperationPayload.Empty).ToDictionary()", + "return request.Payload.GetString(key);", + "return request.Payload.GetInt32(key, defaultValue);", + "return request.Payload.GetDouble(key, defaultValue);", "TrySendPlayerSettingsCommand(", "TrySendInputSettingsCommand(", "TrySendVerseHostCommand(", @@ -8358,12 +9476,49 @@ static void RequireTypedEveCommandBodies(string root) "command.PlayerSettings.PlayerName", "command.InputSettings.ActionName", "command.InputSettings.InputSystemPath", + "command.TradeValuePolicy.Value", + "command.TradeValuePolicy.TierIndex", + "ExecuteTradeValuePolicyCommandAsync(", + "PutTradeValuePolicyAsync(", "SubmitInputSettingsCommandAsync(", "SubmitLoadoutTemplateCommandAsync(", - "SubmitKnownSurfaceCommandAsync(" + "SubmitKnownSurfaceCommandAsync(", + "public AetheriaUi Ui => _ui", + "public sealed class AetheriaUi", + "Task InputSettingsAsync(", + "Task SaveLoadoutTemplateAsync(", + "Task SurfaceCommandAsync(" }; var mainMenu = File.ReadAllText(mainMenuPath); - var typedCommandSources = eveCommandClient + "\n" + verseClient + "\n" + eveCommandBridge + "\n" + stateNode + "\n" + documentRegistry + "\n" + actionGameManager + "\n" + mainMenu + "\n" + evePresenter; + var typedCommandSources = eveCommandClient + "\n" + verseClient + "\n" + aetheriaClient + "\n" + aetheriaUi + "\n" + eveCommandBridge + "\n" + stateNode + "\n" + documentRegistry + "\n" + actionGameManager + "\n" + mainMenu + "\n" + evePresenter; + if (aetheriaClient.Contains("public Task SubmitInputSettingsCommandAsync(", StringComparison.Ordinal) || + aetheriaClient.Contains("public Task SubmitLoadoutTemplateCommandAsync(", StringComparison.Ordinal) || + aetheriaClient.Contains("public Task SubmitKnownSurfaceCommandAsync(", StringComparison.Ordinal) || + verseClient.Contains("public Task SubmitInputSettingsCommandAsync(", StringComparison.Ordinal) || + verseClient.Contains("public Task SubmitLoadoutTemplateCommandAsync(", StringComparison.Ordinal) || + verseClient.Contains("public Task SubmitKnownSurfaceCommandAsync(", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "AetheriaClient exposes Eve command envelopes publicly; UI callers should use AetheriaClient.Ui and receive CultMeshOperationReceipt."); + } + + if (eveCommandClient.Contains("ParseRouteKind(", StringComparison.Ordinal) || + eveCommandClient.Contains("OperationId = envelope.Invocation.OperationId", StringComparison.Ordinal) || + eveCommandClient.Contains("OperationId = invocation.OperationId", StringComparison.Ordinal) || + eveCommandClient.Contains("OperationId = invocationRecord.OperationId", StringComparison.Ordinal) || + eveCommandClient.Contains("Payload = new System.Collections.Generic.Dictionary(\r\n envelope.Payload", StringComparison.Ordinal) || + eveCommandClient.Contains("Payload = new System.Collections.Generic.Dictionary(\n envelope.Payload", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Aetheria Eve command persistence reintroduced local CultMesh invocation/payload flattening; use CultMesh.OperationInvocationRecord and CultMeshOperationPayload.ToDictionary."); + } + + if (eveCommandClient.Contains("public static class AetheriaRuntimeEveCommands", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "AetheriaRuntimeEveCommands is public again; public Eve UI operations belong on AetheriaClient.Ui."); + } + if (typedCommandSources.Contains("DeleteEveCommandAsync(", StringComparison.Ordinal)) { throw new InvalidOperationException( @@ -8673,7 +9828,7 @@ static void RequireMainMenuVerseHostProjection(string root) var requiredMainMenuSymbols = new[] { "LatestVerseHostSettings(AetheriaRuntimeStateBootReport stateBoot)", - "GetVerseHostSettingsAsync()", + "VerseHostSettingsAsync()", "AetheriaRuntimeMainMenuSurfaceBuilder.ProjectRoot(", "AetheriaRuntimeMainMenuSurfaceBuilder.BuildRoot(" }; @@ -8745,14 +9900,62 @@ static void RequireMainMenuContinueRunState(string root) var packageStore = File.Exists(packageStorePath) ? File.ReadAllText(packageStorePath) : throw new InvalidOperationException("Cannot verify Continue entity payload readback; package runtime store is missing."); + var observedDockingIndexPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedDockingIndex.cs"); + var observedDockingIndex = File.Exists(observedDockingIndexPath) + ? File.ReadAllText(observedDockingIndexPath) + : throw new InvalidOperationException("Cannot verify Continue entity identity; AetheriaUnityObservedDockingIndex.cs is missing."); + var currentEntityPresentationPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityCurrentEntityPresentation.cs"); + var currentEntityPresentation = File.Exists(currentEntityPresentationPath) + ? File.ReadAllText(currentEntityPresentationPath) + : throw new InvalidOperationException("Cannot verify Continue entity presentation; AetheriaUnityCurrentEntityPresentation.cs is missing."); + var currentEntityBinderPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityCurrentEntityBinder.cs"); + var currentEntityBinder = File.Exists(currentEntityBinderPath) + ? File.ReadAllText(currentEntityBinderPath) + : throw new InvalidOperationException("Cannot verify Continue current entity binder; AetheriaUnityCurrentEntityBinder.cs is missing."); + var targetPresentationPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityTargetPresentation.cs"); + var targetPresentation = File.Exists(targetPresentationPath) + ? File.ReadAllText(targetPresentationPath) + : throw new InvalidOperationException("Cannot verify Continue target presentation; AetheriaUnityTargetPresentation.cs is missing."); + var observedTargetQueryPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedTargetQuery.cs"); + var observedTargetQuery = File.Exists(observedTargetQueryPath) + ? File.ReadAllText(observedTargetQueryPath) + : throw new InvalidOperationException("Cannot verify Continue target query; AetheriaUnityObservedTargetQuery.cs is missing."); + var observedFrameApplierPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedFrameApplier.cs"); + var observedFrameApplier = File.Exists(observedFrameApplierPath) + ? File.ReadAllText(observedFrameApplierPath) + : throw new InvalidOperationException("Cannot verify Continue frame application; AetheriaUnityObservedFrameApplier.cs is missing."); + var pilotFrameAdapterPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityPilotFrameAdapter.cs"); + var pilotFrameAdapter = File.Exists(pilotFrameAdapterPath) + ? File.ReadAllText(pilotFrameAdapterPath) + : throw new InvalidOperationException("Cannot verify Continue pilot frame adapter; AetheriaUnityPilotFrameAdapter.cs is missing."); + var daemonEntitySnapshotProjectorPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityDaemonEntitySnapshotProjector.cs"); + var daemonEntitySnapshotProjector = File.Exists(daemonEntitySnapshotProjectorPath) + ? File.ReadAllText(daemonEntitySnapshotProjectorPath) + : throw new InvalidOperationException("Cannot verify Continue entity projection; AetheriaUnityDaemonEntitySnapshotProjector.cs is missing."); + var observedFacadeProjectorPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedFacadeProjector.cs"); + var observedFacadeProjector = File.Exists(observedFacadeProjectorPath) + ? File.ReadAllText(observedFacadeProjectorPath) + : throw new InvalidOperationException("Cannot verify Continue facade projection; AetheriaUnityObservedFacadeProjector.cs is missing."); + var entityConstructionBlueprintProjectorPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityEntityConstructionBlueprintProjector.cs"); + var entityConstructionBlueprintProjector = File.Exists(entityConstructionBlueprintProjectorPath) + ? File.ReadAllText(entityConstructionBlueprintProjectorPath) + : throw new InvalidOperationException("Cannot verify Continue entity construction projection; AetheriaUnityEntityConstructionBlueprintProjector.cs is missing."); + var observedZoneContextProjectorPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedZoneContextProjector.cs"); + var observedZoneContextProjector = File.Exists(observedZoneContextProjectorPath) + ? File.ReadAllText(observedZoneContextProjectorPath) + : throw new InvalidOperationException("Cannot verify Continue zone context projection; AetheriaUnityObservedZoneContextProjector.cs is missing."); + var gameplaySceneWiringPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplaySceneWiring.cs"); + var gameplaySceneWiring = File.Exists(gameplaySceneWiringPath) + ? File.ReadAllText(gameplaySceneWiringPath) + : throw new InvalidOperationException("Cannot verify Continue scene wiring; AetheriaUnityGameplaySceneWiring.cs is missing."); var requiredMenuSymbols = new[] { - "LatestDaemonFrame", - "AetheriaRuntimeVerseClient", - "GetLatestAuthoritativeRunFrameAsync()", + "LatestSectorMap", + "AetheriaClient", + ".SectorMapAsync()", "ContinueGame()", - "ActionGameManager.ObservedGalaxy = Galaxy.ProjectObservedDaemonRun(", + "AetheriaUnityObservedRunProjection.Project(", "SceneManager.LoadScene(\"ARPG\")" }; @@ -8774,56 +9977,31 @@ static void RequireMainMenuContinueRunState(string root) var requiredGameplaySymbols = new[] { - "ApplyLatestAuthoritativeDaemonFrame()", - "TryRestoreEntityGraphFromDaemonRun(observed.Run)", - "if (string.IsNullOrWhiteSpace(run.RunId))", - "Authoritative daemon frame does not identify a run id.", - "Authoritative daemon frame has no zone snapshot", - "entity.RecordKey", - "run.CurrentEntityKey", - "CreateDaemonEntitySnapshots(runId, daemonZone)", - "CanApplyDaemonEntitySnapshotsInPlace", - "ZoneRenderer?.ApplyDaemonFrame(daemonZone, run)", - "ApplyDaemonEntitySnapshotsInPlace", - "PrepareObservedDaemonZoneContext(targetZone, daemonZone)", - "_observedZoneContextsByDaemonIndex", - "ReplaceObservedEntityFacadesFromTypedSnapshots", - "RestoreCurrentEntityBinding", - "RestoreCurrentEntityBinding(currentEntity, actionBarBindings)", - "RestoreActiveConsumablesFromTypedEntitySnapshot(entity, entitySnapshot)", - "RestoreRuntimeBehaviorStateFromTypedSnapshot(entity, entitySnapshot, restoredEntities)", - "ResolveRuntimeBehavior(entity, weaponState.OwnerKind, weaponState.OwnerIndex, weaponState.BehaviorIndex)", - "lockWeapon.RestoreRuntimeState(", - "drive.RestoreRuntimeState(", - "resourceScanner.RestoreRuntimeState(", - "AetheriaRuntimeDaemonRenderQueries.TryQueryEntityContact(", - "AetheriaRuntimeDaemonRenderQueries.QueryEntityContacts(", - "AetheriaRuntimeDaemonRenderQueries.TryQueryEntityTarget(", - "GetObservedTarget(CurrentEntity)", - "ReconcileVisibleTargetIndicators();", - "GetObservedInfoGathered(CurrentEntity, target)", - "IsObservedHostileContact(CurrentEntity, observedTarget)", - "_observedEntityFacadesByRecordKey", - "_observedEntityFacadesByDaemonIndex", - "RebuildObservedEntityFacadeIndex();", - "ZoneRenderer?.LoadDaemonZoneView(_observedEntityFacadesByDaemonIndex, daemonZone, run)", - "entity.RestoreStatGrids(entitySnapshot.StatGrids)", - "RestoreThermalExposure((float)entitySnapshot.Heatstroke, (float)entitySnapshot.Hypothermia)", - "entity.HeatsinksEnabled = entitySnapshot.HeatsinksEnabled", - "RestoreDroppedPickupsFromDaemonZoneState", - "TryGetDaemonEntitySnapshot(", - "TryGetDaemonParentSnapshot(", - "TryResolveDaemonDockingBay(", + "ObservedFrameApplier.ApplyLatestZoneRender()", + "private AetheriaUnityGameplayLoopShell GameplayLoopShell =>", + "GameplayLoopShell.Tick(Time.deltaTime, Time.time)", + "GameplayLoopShell.LateTick()", + "ApplyLatestZoneRender = () => ObservedFrameApplier.ApplyLatestZoneRender()", + "private AetheriaUnityObservedFrameApplier ObservedFrameApplier =>", + "private AetheriaUnityObservedZoneContextProjector ObservedZoneContextProjector =>", + "entity => CurrentEntityBinder.RestoreBinding(entity)", + "private AetheriaUnityObservedTargetQuery ObservedTargetQuery =>", + "ResolveObservedTarget = entity => ObservedTargetQuery.GetObservedTarget(entity)", + "TargetPresentation = _targetPresentation", + "private readonly AetheriaUnityObservedFacadeIndex _observedFacadeIndex", + "private AetheriaUnityEntityConstructionBlueprintProjector EntityConstructionBlueprintProjector =>", + "EntityConstructionBlueprintProjector.ProjectObservedEntity", + "_loadoutItemProjector.CreateLoadoutItem", + "private AetheriaUnityObservedDockingIndex ObservedDocking =>", + "ObservedDocking.IsEntityUndocked(CurrentEntity)", "IsCurrentEntityObservedUndocked(", - "parentSnapshot.DockingBayAssignments", - "ObservedAvailableEntities(", - "TryGetObservedCurrentEntity(out var currentEntity)", - "parentSnapshot.ChildEntityIndices", - "TryGetObservedEntityFacade(childEntityIndex, out var entity)", - "FindTypedRuntimeItem(", - "RuntimeCatalog?.FindItem(item?.ItemKey ?? \"\")", - "var typedItem = FindTypedRuntimeItem(item?.EquippableItem)", - "var targetHull = FindTypedRuntimeItem(target.Hull)" + "private AetheriaUnityCurrentEntityBinder CurrentEntityBinder =>", + "private readonly AetheriaUnityCurrentEntityPresentation _currentEntityPresentation", + "CurrentEntityPresentation = _currentEntityPresentation", + "private AetheriaUnityPilotFrameAdapter PilotFrameAdapter =>", + "PilotFrameAdapter = PilotFrameAdapter", + "SceneWiring.ConfigureCurrentEntityPresentation(_currentEntityPresentation, boot.RuntimeCatalog)", + "SceneWiring.ConfigureTargetPresentation(" }; if (actionGameManager.Contains("_authoritativeDaemonEntities", StringComparison.Ordinal)) @@ -8832,12 +10010,599 @@ static void RequireMainMenuContinueRunState(string root) "Unity entity wrappers are observed daemon facades, not authoritative state; keep the daemon as authority."); } + var forbiddenGameplayCatalogPolicySymbols = new[] + { + "private static AetheriaRuntimeCatalogSnapshot _runtimeCatalog", + "FindTypedRuntimeItem(", + "_runtimeCatalog?.FindItem(", + "ArticulatedWeaponBehaviorKinds", + "_targetPresentation.ResolveTypedRuntimeItem" + }; + var gameplayCatalogPolicyHits = forbiddenGameplayCatalogPolicySymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (gameplayCatalogPolicyHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns runtime catalog presentation policy instead of injecting the boot catalog into Unity presentation helpers: " + + string.Join(", ", gameplayCatalogPolicyHits)); + } + + var requiredEntitySnapshotProjectorSymbols = new[] + { + "public static class AetheriaUnityDaemonEntitySnapshotProjector", + "public static AetheriaRuntimeEntitySnapshot[] CreateSnapshots(", + "public static int EntityIndexFromRecordKey(string recordKey)", + "public static string DaemonEntityRecordKey(string runId, int zoneIndex, int entityIndex)", + "new AetheriaRuntimeEntitySnapshot(", + "CreateItemSlots(entity.Equipment)", + "CreateWeaponStates(runId, zoneIndex, entity.WeaponStates)", + "CreateBehaviorStates(entity.BehaviorStates)", + "CreateCargoBays(entity.CargoContents)", + "new AetheriaRuntimeEntityContactSnapshot(" + }; + var missingEntitySnapshotProjectorSymbols = requiredEntitySnapshotProjectorSymbols + .Where(symbol => !daemonEntitySnapshotProjector.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingEntitySnapshotProjectorSymbols.Length > 0) + { + throw new InvalidOperationException( + "Daemon entity snapshot projection must live outside ActionGameManager: " + + string.Join(", ", missingEntitySnapshotProjectorSymbols)); + } + + var forbiddenManagerSnapshotProjectionSymbols = new[] + { + "private static AetheriaRuntimeEntitySnapshot[] CreateDaemonEntitySnapshots", + "private static AetheriaRuntimeEntitySnapshot CreateDaemonEntitySnapshot", + "private static AetheriaRuntimeEntityItemSlotSnapshot[] CreateDaemonItemSlots", + "private static AetheriaRuntimeCargoBayLoadoutSnapshot[] CreateDaemonCargoBays", + "private static AetheriaRuntimeWeaponStateSnapshot[] CreateDaemonWeaponStates", + "private static AetheriaRuntimeBehaviorStateSnapshot[] CreateDaemonBehaviorStates", + "private static string DaemonEntityRecordKey(", + "private static int EntityIndexFromRecordKey(" + }; + var managerSnapshotProjectionHits = forbiddenManagerSnapshotProjectionSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerSnapshotProjectionHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns daemon entity snapshot projection internals: " + + string.Join(", ", managerSnapshotProjectionHits)); + } + + var requiredObservedFacadeProjectorSymbols = new[] + { + "public sealed class AetheriaUnityObservedFacadeProjector", + "public bool TryApplyInPlace(", + "public void Replace(", + "EntityConstructionBlueprintProjector.ProjectObservedFromBlueprint(_itemManager, zone, blueprint)", + "entity.RestoreStatGrids(entitySnapshot.StatGrids)", + "entity.RestoreThermalExposure((float)entitySnapshot.Heatstroke, (float)entitySnapshot.Hypothermia)", + "RestoreActiveConsumables(entity, entitySnapshot)", + "RestoreRuntimeBehaviorState(entity, entitySnapshot, restoredEntities)", + "_facadeIndex.EntitiesByRecordKey", + "ResolveRuntimeBehavior(entity, weaponState.OwnerKind, weaponState.OwnerIndex, weaponState.BehaviorIndex)", + "lockWeapon.RestoreRuntimeState(", + "drive.RestoreRuntimeState(", + "resourceScanner.RestoreRuntimeState(", + "_facadeIndex.RefreshDaemonIndex();" + }; + var missingObservedFacadeProjectorSymbols = requiredObservedFacadeProjectorSymbols + .Where(symbol => !observedFacadeProjector.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingObservedFacadeProjectorSymbols.Length > 0) + { + throw new InvalidOperationException( + "Observed Unity facade mutation/restoration must live in AetheriaUnityObservedFacadeProjector instead of ActionGameManager: " + + string.Join(", ", missingObservedFacadeProjectorSymbols)); + } + + var forbiddenManagerFacadeProjectionSymbols = new[] + { + "private bool CanApplyDaemonEntitySnapshotsInPlace(", + "private void ApplyDaemonEntitySnapshotsInPlace(", + "private void ReplaceObservedEntityFacadesFromTypedSnapshots(", + "private void RestoreActiveConsumablesFromTypedEntitySnapshot(", + "private void RestoreRuntimeBehaviorStateFromTypedSnapshot(", + "private static Behavior ResolveRuntimeBehavior(", + "private static IReadOnlyList ResolveRuntimeBehaviorList(", + "EntityConstructionBlueprintProjector.ProjectObservedFromBlueprint(ItemManager, Zone, blueprint)", + "entity.RestoreStatGrids(entitySnapshot.StatGrids)", + "RestoreActiveConsumablesFromTypedEntitySnapshot(entity, entitySnapshot)", + "RestoreRuntimeBehaviorStateFromTypedSnapshot(entity, entitySnapshot, restoredEntities)" + }; + var managerFacadeProjectionHits = forbiddenManagerFacadeProjectionSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerFacadeProjectionHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns observed facade mutation/restoration internals: " + + string.Join(", ", managerFacadeProjectionHits)); + } + + var requiredEntityConstructionBlueprintProjectorSymbols = new[] + { + "public sealed class AetheriaUnityEntityConstructionBlueprintProjector", + "public EntityConstructionBlueprint ProjectTemplate(AetheriaRuntimeLoadoutTemplateSnapshot template)", + "public EntityConstructionBlueprint ProjectLoadoutEntity(AetheriaRuntimeEntityLoadoutSnapshot entity)", + "public EntityConstructionBlueprint ProjectObservedEntity(", + "private static EntityConstructionBlueprint CreateBlueprint(string kind)", + "new OrbitalEntityConstructionBlueprint()", + "new ShipConstructionBlueprint()", + "shipBlueprint.Position = new float3((float)entity.PositionX", + "shipBlueprint.IsPlayerShip = isCurrentEntity", + "blueprint.Equipment = CreateEquippableSlots(entity.Equipment)", + "blueprint.CargoContents = CreateCargoBayContents(entity.CargoContents)", + "blueprint.Children = entity.Children", + "_loadoutItemProjector.CreateLoadoutItem(slot.Item)", + "_loadoutItemProjector.CreateLoadoutItem(item) as EquippableItem" + }; + var missingEntityConstructionBlueprintProjectorSymbols = requiredEntityConstructionBlueprintProjectorSymbols + .Where(symbol => !entityConstructionBlueprintProjector.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingEntityConstructionBlueprintProjectorSymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity entity construction blueprint lowering must live in AetheriaUnityEntityConstructionBlueprintProjector instead of ActionGameManager: " + + string.Join(", ", missingEntityConstructionBlueprintProjectorSymbols)); + } + + var forbiddenManagerEntityConstructionSymbols = new[] + { + "private EntityConstructionBlueprint CreateEntityConstructionBlueprint(", + "private (int2 position, EquippableItem item)[] CreateEquippableSlots(", + "private (int2 position, ItemInstance item)[][] CreateCargoBayContents(", + "private EquippableItem CreateEquippableLoadoutItem(", + "private ItemInstance CreateLoadoutItem(AetheriaRuntimeLoadoutItemSnapshot", + "new OrbitalEntityConstructionBlueprint()", + "new ShipConstructionBlueprint", + "blueprint.Children = entity.Children", + "ItemManager.CreateSimpleCommodityInstance(typedItem", + "ItemManager.CreateCraftedInstance(typedItem" + }; + var managerEntityConstructionHits = forbiddenManagerEntityConstructionSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerEntityConstructionHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns Unity entity construction blueprint lowering internals: " + + string.Join(", ", managerEntityConstructionHits)); + } + + var requiredObservedZoneContextProjectorSymbols = new[] + { + "public sealed class AetheriaUnityObservedZoneContextProjector", + "public Zone ResolveContext(", + "private readonly Dictionary _observedZoneContextsByDaemonIndex", + "private static ZoneConstructionBlueprint CreateZoneConstructionBlueprint(", + "private static BodyConstructionData CreateBodyConstructionData(", + "private static GasGiantConstructionData CreateGasGiantConstructionData(", + "private static SunConstructionData CreateSunConstructionData(", + "private static void PopulateBodyConstructionData(", + "new Zone(_itemManager, _planetSettings, constructionBlueprint, galaxyZone, _observedGalaxy)", + "_playMusic(MusicType.Overworld);" + }; + var missingObservedZoneContextProjectorSymbols = requiredObservedZoneContextProjectorSymbols + .Where(symbol => !observedZoneContextProjector.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingObservedZoneContextProjectorSymbols.Length > 0) + { + throw new InvalidOperationException( + "Observed Unity zone context lowering must live in AetheriaUnityObservedZoneContextProjector instead of ActionGameManager: " + + string.Join(", ", missingObservedZoneContextProjectorSymbols)); + } + + var forbiddenManagerZoneContextProjectionSymbols = new[] + { + "private void PrepareObservedDaemonZoneContext(", + "private static ZoneConstructionBlueprint CreateDaemonZoneConstructionBlueprint(", + "private static BodyConstructionData CreateDaemonBodyConstructionData(", + "private static GasGiantConstructionData CreateDaemonGasGiantConstructionData(", + "private static SunConstructionData CreateDaemonSunConstructionData(", + "private static void PopulateDaemonBodyConstructionData(", + "new Zone(ItemManager, Settings.PlanetSettings", + "private readonly Dictionary _observedZoneContextsByDaemonIndex" + }; + var managerZoneContextProjectionHits = forbiddenManagerZoneContextProjectionSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerZoneContextProjectionHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns observed zone/body context lowering internals: " + + string.Join(", ", managerZoneContextProjectionHits)); + } + + var requiredObservedFrameApplierSymbols = new[] + { + "public sealed class AetheriaUnityObservedFrameApplier", + "public bool ApplyLatestZoneRender()", + "ZoneRenderAsync()", + "private bool TryRestoreEntityGraphFromZoneRender(", + "if (string.IsNullOrWhiteSpace(render.RunId))", + "Aetheria zone-render feed does not identify a run id.", + "AetheriaUnityDaemonEntitySnapshotProjector.CreateSnapshots(runId, render.ZoneIndex, render.EntityFacades)", + "AetheriaUnityDaemonEntitySnapshotProjector.EntityIndexFromRecordKey(entity.RecordKey)", + "_facadeProjector.TryApplyInPlace(", + "zoneRenderer?.ApplyZoneRender(render)", + "_zoneContextProjector.ResolveContext(targetZone, render)", + "_facadeProjector.Replace(entitySnapshots, currentEntityKey, _getZone())", + "_restoreCurrentEntityBinding(currentEntity)", + "_facadeIndex.TryResolveEntityByRecordKey(currentEntityKey, out var currentEntity)", + "_facadeIndex.EntitiesByDaemonIndex", + "zoneRenderer?.LoadDaemonZoneView(_facadeIndex.EntitiesByDaemonIndex, render)", + "zoneRenderer?.RestoreDroppedPickupsFromZoneRender(render)" + }; + var missingObservedFrameApplierSymbols = requiredObservedFrameApplierSymbols + .Where(symbol => !observedFrameApplier.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingObservedFrameApplierSymbols.Length > 0) + { + throw new InvalidOperationException( + "Observed Unity frame application must live in AetheriaUnityObservedFrameApplier instead of ActionGameManager: " + + string.Join(", ", missingObservedFrameApplierSymbols)); + } + + var forbiddenManagerFrameApplicationSymbols = new[] + { + "_lastAppliedAuthoritativeDaemonFrameId", + "_lastAppliedAuthoritativeDaemonFramePath", + "_lastAppliedAuthoritativeDaemonRunId", + "_lastAppliedAuthoritativeDaemonZoneIndex", + "private bool TryRestoreEntityGraphFromZoneRender(", + "AetheriaRuntimeRtsProjection.ProjectZoneRender(observed.Frame)", + "AetheriaUnityDaemonEntitySnapshotProjector.CreateSnapshots(runId, daemonZone)", + "ObservedFacadeProjector.TryApplyInPlace(", + "ObservedFacadeProjector.Replace(entitySnapshots, currentEntityKey, Zone)", + "ZoneRenderer?.LoadDaemonZoneView(_observedFacadeIndex.EntitiesByDaemonIndex, render)", + "ZoneRenderer?.RestoreDroppedPickupsFromDaemonZoneState(daemonZone)", + "render.ActionBarBindings", + "private static AetheriaRuntimeActionBarBindingSnapshot ToActionBarBindingSnapshot(" + }; + var managerFrameApplicationHits = forbiddenManagerFrameApplicationSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerFrameApplicationHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns authoritative daemon frame application internals: " + + string.Join(", ", managerFrameApplicationHits)); + } + + var requiredObservedTargetQuerySymbols = new[] + { + "public sealed class AetheriaUnityObservedTargetQuery", + "public Entity GetObservedTarget(Entity observer)", + "public float GetObservedInfoGathered(Entity observer, Entity target)", + "public bool IsObservedHostileContact(Entity observer, Entity target)", + "public AetheriaRuntimeZoneContactRow[] GetObservedVisibleContacts(", + "private bool TryQueryEntityContact(", + "private bool TryQueryEntityTarget(", + "AetheriaRuntimeZoneContactRow", + "AetheriaRuntimeZoneTargetRow", + "ZoneContactsAsync()", + "_facadeIndex.TryResolveEntityByDaemonIndex(targetEntityIndex, out var targetEntity)" + }; + var missingObservedTargetQuerySymbols = requiredObservedTargetQuerySymbols + .Where(symbol => !observedTargetQuery.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingObservedTargetQuerySymbols.Length > 0) + { + throw new InvalidOperationException( + "Observed Unity target/contact query lowering must live in AetheriaUnityObservedTargetQuery instead of ActionGameManager: " + + string.Join(", ", missingObservedTargetQuerySymbols)); + } + + var forbiddenManagerTargetQuerySymbols = new[] + { + "private bool TryQueryDaemonEntityContact(", + "private bool TryQueryDaemonEntityTarget(", + "private Entity GetObservedTarget(", + "private float GetObservedInfoGathered(", + "private bool IsObservedHostileContact(", + "AetheriaRuntimeDaemonRenderQueries.TryQueryEntityContact(", + "AetheriaRuntimeDaemonRenderQueries.TryQueryEntityTarget(" + }; + var managerTargetQueryHits = forbiddenManagerTargetQuerySymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerTargetQueryHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns observed daemon target/contact query internals: " + + string.Join(", ", managerTargetQueryHits)); + } + + var requiredObservedDockingSymbols = new[] + { + "public sealed class AetheriaUnityObservedDockingIndex", + "CurrentDockingAsync()", + "public bool IsEntityUndocked(Entity entity)", + "public bool TryResolveDockingBay(", + "out AetheriaRuntimeCurrentDockingDocument docking", + "_observedFacadeIndex.TryResolveEntityByRecordKey(docking.DockParentEntityKey", + "docking.DockingBayIndex" + }; + var missingObservedDockingSymbols = requiredObservedDockingSymbols + .Where(symbol => !observedDockingIndex.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingObservedDockingSymbols.Length > 0) + { + throw new InvalidOperationException( + "Observed docking relationship queries must live in AetheriaUnityObservedDockingIndex instead of ActionGameManager: " + + string.Join(", ", missingObservedDockingSymbols)); + } + + var requiredCurrentEntityBinderSymbols = new[] + { + "public sealed class AetheriaUnityCurrentEntityBinder", + "public void RestoreBinding(", + "public void ClearBinding()", + "private void BindUndocked(", + "ObservedDocking.TryResolveDockingBay(currentEntity, out var dockParent, out _)", + "CurrentEntityPresentation?.BindDocked(", + "CurrentEntityPresentation?.BindUndocked(", + "TargetPresentation?.ClearIndicators();", + "CurrentEntityPresentation?.ClearBinding();", + "DisablePlayerInput?.Invoke();", + "ApplyActionBarBindings?.Invoke(Array.Empty())", + "TargetPresentation?.ReconcileVisibleTargetIndicators(GetCurrentEntity?.Invoke())", + "SetViewDirection?.Invoke((float3)AetheriaMath.ToUnityXZ(entity.CultDirection))" + }; + var missingCurrentEntityBinderSymbols = requiredCurrentEntityBinderSymbols + .Where(symbol => !currentEntityBinder.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingCurrentEntityBinderSymbols.Length > 0) + { + throw new InvalidOperationException( + "Current-entity binding orchestration must live in AetheriaUnityCurrentEntityBinder instead of ActionGameManager: " + + string.Join(", ", missingCurrentEntityBinderSymbols)); + } + + var forbiddenManagerCurrentEntityBinderSymbols = new[] + { + "if (ObservedDocking.TryResolveDockingBay(currentEntity, out var dockParent, out _))", + "_currentEntityPresentation.BindDocked(", + "_currentEntityPresentation.BindUndocked(", + "_targetPresentation.ClearIndicators();", + "_currentEntityPresentation.ClearBinding();", + "private void BindToEntity(", + "DeathPost.weight = 0;", + "ZoneRenderer.TryGetEntityInstance(entity, out var entityInstance)", + "UpdateTowingStation" + }; + var managerCurrentEntityBinderHits = forbiddenManagerCurrentEntityBinderSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerCurrentEntityBinderHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns current-entity bind orchestration instead of delegating to AetheriaUnityCurrentEntityBinder: " + + string.Join(", ", managerCurrentEntityBinderHits)); + } + + var requiredTargetPresentationSymbols = new[] + { + "public sealed class AetheriaUnityTargetPresentation", + "public Func ResolveVisibleContacts { get; set; }", + "ResolveVisibleContacts?.Invoke(", + "public Entity Tick(Entity currentEntity, float time)", + "public void ReconcileVisibleTargetIndicators(Entity currentEntity)", + "public void UpdateTargetIndicators(", + "renderSettings.NormalizeDetectionProgress(", + "renderSettings.ResolveTargetSpottedFillEnabled(", + "renderSettings.NormalizeTargetVisibilityFill(", + "renderSettings.NormalizeVisibilityToTargetFill(", + "renderSettings.NormalizeTargetStatusFill(", + "renderSettings.ResolveLockIndicatorNoiseAmplitude(", + "renderSettings.ResolveLockIndicatorNoiseFrequency(", + "renderSettings.ResolveLockSpinSpeed(", + "ResolveInfoGathered?.Invoke(currentEntity, target)", + "ResolveHostileContact?.Invoke(currentEntity, observedTarget)", + "public AetheriaRuntimeCatalogSnapshot RuntimeCatalog { get; set; }", + "RuntimeCatalog?.FindItem(target.Hull?.ItemKey ?? \"\")" + }; + var missingTargetPresentationSymbols = requiredTargetPresentationSymbols + .Where(symbol => !targetPresentation.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingTargetPresentationSymbols.Length > 0) + { + throw new InvalidOperationException( + "Daemon-observed target HUD presentation must live in AetheriaUnityTargetPresentation instead of ActionGameManager: " + + string.Join(", ", missingTargetPresentationSymbols)); + } + + var requiredPilotFrameAdapterSymbols = new[] + { + "public sealed class AetheriaUnityPilotFrameAdapter", + "public void Tick(Entity currentEntity, float deltaTime, float timeSeconds)", + "TargetPresentation?.Tick(currentEntity, timeSeconds)", + "Input.Player.Look.ReadValue()", + "PilotCommands.RequestLookDirection(viewDirection)", + "renderSettings.NormalizeHeatstrokePost(currentEntity.Heatstroke)", + "renderSettings.ResolveSevereHeatstrokePostWeight(currentEntity.Heatstroke, timeSeconds)", + "Input.Player.Move.ReadValue()", + "PilotCommands.RequestMoveVector(movement)", + "Input.Player.TractorBeam.ReadValue()", + "PilotCommands.RequestTractorPower(Saturate(" + }; + var missingPilotFrameAdapterSymbols = requiredPilotFrameAdapterSymbols + .Where(symbol => !pilotFrameAdapter.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingPilotFrameAdapterSymbols.Length > 0) + { + throw new InvalidOperationException( + "Undocked pilot-frame input/presentation policy must live in AetheriaUnityPilotFrameAdapter instead of ActionGameManager: " + + string.Join(", ", missingPilotFrameAdapterSymbols)); + } + + var forbiddenManagerPilotFrameSymbols = new[] + { + "Input.Player.Look.ReadValue()", + "Input.Player.Move.ReadValue()", + "Input.Player.TractorBeam.ReadValue()", + "PilotCommands.RequestLookDirection(_viewDirection)", + "PilotCommands.RequestMoveVector(movement)", + "PilotCommands.RequestTractorPower(Saturate(", + "HeatstrokePost.weight = (float)renderSettings.NormalizeHeatstrokePost(", + "SevereHeatstrokePost.weight =" + }; + var managerPilotFrameHits = forbiddenManagerPilotFrameSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerPilotFrameHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns undocked pilot-frame input/presentation internals instead of delegating to AetheriaUnityPilotFrameAdapter: " + + string.Join(", ", managerPilotFrameHits)); + } + + var forbiddenManagerDockingScanSymbols = new[] + { + "TryGetDaemonParentSnapshot(", + "TryResolveDaemonDockingBay(", + "parentSnapshot.DockingBayAssignments" + }; + var managerDockingScanHits = forbiddenManagerDockingScanSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerDockingScanHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still scans daemon docking relationships instead of delegating to AetheriaUnityObservedDockingIndex: " + + string.Join(", ", managerDockingScanHits)); + } + + var requiredCurrentEntityPresentationSymbols = new[] + { + "public sealed class AetheriaUnityCurrentEntityPresentation", + "public void BindUndocked(", + "public void BindDocked(", + "public void ClearBinding()", + "public void Tick(float deltaTime)", + "private readonly List _shipSubscriptions", + "private readonly List _targetSubscriptions", + "public (HardpointData[] hardpoints, Transform[] barrels, PlaceUIElementWorldspace crosshair)[] ArticulationGroups", + "public (LockWeapon targetLock, PlaceUIElementWorldspace indicator, Rotate spin)[] LockingIndicators", + "entity.TargetedByCount.Subscribe", + "entity.Target.Subscribe", + "target.IncomingHit.Where", + "_hitMarkerTime = HitMarkerDuration", + "LockIndicator.Instantiate()", + "TradeMenu.Inventory = entity.CargoBays.First()", + "DockCamera.enabled = true", + "FollowCamera.enabled = false", + "DockCamera.Follow = orbitalInstance.transform", + "ZoneRenderer.TryGetBodyView(parentOrbitPlanetBodyKey, out var parentBodyView)", + "Menu.ShowTab(MenuTab.Inventory)" + }; + var missingCurrentEntityPresentationSymbols = requiredCurrentEntityPresentationSymbols + .Where(symbol => !currentEntityPresentation.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingCurrentEntityPresentationSymbols.Length > 0) + { + throw new InvalidOperationException( + "Current-entity Unity presentation binding must live in AetheriaUnityCurrentEntityPresentation instead of ActionGameManager: " + + string.Join(", ", missingCurrentEntityPresentationSymbols)); + } + + var forbiddenManagerCurrentPresentationSymbols = new[] + { + "private List _shipSubscriptions", + "private List _targetSubscriptions", + "private (HardpointData[] hardpoints, Transform[] barrels, PlaceUIElementWorldspace crosshair)[] _articulationGroups", + "private (LockWeapon targetLock, PlaceUIElementWorldspace indicator, Rotate spin)[] _lockingIndicators", + "private float _hitMarkerTime", + "private void DoDock(", + "TradeMenu.Inventory =", + "DockCamera.Follow =", + "DockCamera.LookAt =", + "DockCamera.enabled = true", + "FollowCamera.enabled = false", + "ZoneRenderer.TryGetBodyView(parentOrbitPlanetBodyKey, out var parentBodyView)", + "Menu.ShowTab(MenuTab.Inventory)", + "CurrentEntity.TargetedByCount.Subscribe", + "CurrentEntity.Target.Subscribe", + "target.IncomingHit.Where", + "LockIndicator.Instantiate()" + }; + var managerCurrentPresentationHits = forbiddenManagerCurrentPresentationSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerCurrentPresentationHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns bind-time current-entity presentation state instead of delegating to AetheriaUnityCurrentEntityPresentation: " + + string.Join(", ", managerCurrentPresentationHits)); + } + if (actionGameManager.Contains("ItemManager.GetRuntimeItem", StringComparison.Ordinal)) { throw new InvalidOperationException( "ActionGameManager still resolves item metadata through Unity ItemManager instead of the runtime catalog."); } + if (actionGameManager.Contains("public bool TryGetObservedCurrentEntity(out Entity entity)", StringComparison.Ordinal) || + actionGameManager.Contains("public bool TryGetObservedDockingBay(out EquippedDockingBay dockingBay)", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionGameManager must not expose broad observed current-entity or docking-bay read shims; clients must use typed current projections and key-scoped facade adapters."); + } + + if (actionGameManager.Contains("public EquippedDockingBay DockingBay", StringComparison.Ordinal) || + actionGameManager.Contains("public Entity DockedEntity", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionGameManager must not expose renderer-local docked entity or docking bay state as public gameplay truth; clients must use typed current-docking projections."); + } + + if (actionGameManager.Contains("public Entity TowingStation", StringComparison.Ordinal) || + actionGameManager.Contains("AvailableCargoBays()", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionGameManager must not expose renderer-local towing context or available cargo bay enumeration as public gameplay truth; clients must use typed projections and daemon operations."); + } + + if (actionGameManager.Contains("public int Credits", StringComparison.Ordinal) || + actionGameManager.Contains("Credits = render.Credits", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionGameManager must not mirror run credits as manager-global gameplay state; clients must read credits through typed projections such as StationRefitAsync."); + } + + if (actionGameManager.Contains("public static AetheriaRuntimeCatalogSnapshot RuntimeCatalog", StringComparison.Ordinal) || + actionGameManager.Contains("public AetheriaRuntimeCatalogSnapshot RuntimeCatalog", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionGameManager must not expose the typed runtime catalog as manager-global gameplay state; clients must open typed catalog views through AetheriaClient."); + } + + if (actionGameManager.Contains("public AetheriaRuntimeDaemonRenderSettings ObservedDaemonRenderSettings()", StringComparison.Ordinal) || + actionGameManager.Contains("ObservedDaemonRenderSettings(", StringComparison.Ordinal) || + actionGameManager.Contains("new AetheriaRuntimeDaemonRenderSettings(", StringComparison.Ordinal) || + actionGameManager.Contains("ConfigureSchematicDisplayRenderSettings()", StringComparison.Ordinal) || + actionGameManager.Contains("SchematicDisplay?.SetRenderSettings(", StringComparison.Ordinal) || + actionGameManager.Contains("TargetSchematicDisplay?.SetRenderSettings(", StringComparison.Ordinal) || + actionGameManager.Contains("private void UpdatePlayerPanel()", StringComparison.Ordinal) || + actionGameManager.Contains("private void UpdateTargetPanel(Entity target)", StringComparison.Ordinal) || + actionGameManager.Contains("public EntityConstructionBlueprint CreateEntityConstructionBlueprint(AetheriaRuntimeLoadoutTemplateSnapshot template)", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionGameManager must delegate daemon render-settings and loadout-template lowering to focused renderer/bootstrap adapters, not expose them as client gameplay APIs."); + } + + if (actionGameManager.Contains("ObservedLoadoutTemplates", StringComparison.Ordinal) || + actionGameManager.Contains("_observedLoadoutTemplates", StringComparison.Ordinal) || + actionGameManager.Contains("LoadRuntimeLoadoutTemplates", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionGameManager must not cache or expose loadout templates as manager-global observed state; clients must read typed loadout templates directly."); + } + var missingGameplaySymbols = requiredGameplaySymbols .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) .ToArray(); @@ -8849,6 +10614,28 @@ static void RequireMainMenuContinueRunState(string root) string.Join(", ", missingGameplaySymbols)); } + var requiredGameplaySceneWiringSymbols = new[] + { + "public void ConfigureCurrentEntityPresentation(", + "presentation.RuntimeCatalog = runtimeCatalog", + "public void ConfigureTargetPresentation(", + "presentation.ResolveTarget = observedTargetQuery.GetObservedTarget", + "presentation.ResolveInfoGathered = observedTargetQuery.GetObservedInfoGathered", + "presentation.ResolveHostileContact = observedTargetQuery.IsObservedHostileContact", + "presentation.RuntimeCatalog = runtimeCatalog" + }; + + var missingGameplaySceneWiringSymbols = requiredGameplaySceneWiringSymbols + .Where(symbol => !gameplaySceneWiring.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingGameplaySceneWiringSymbols.Length > 0) + { + throw new InvalidOperationException( + "AetheriaUnityGameplaySceneWiring no longer has the daemon-frame Continue presentation boot path: " + + string.Join(", ", missingGameplaySceneWiringSymbols)); + } + var forbiddenGameplaySymbols = new[] { "if (run == null ||\r\n run.CurrentZoneIndex < 0 ||\r\n run.CurrentZoneEntityIndex < 0)", @@ -8861,6 +10648,9 @@ static void RequireMainMenuContinueRunState(string root) "CurrentEntity.VisibleEnemies.ObserveRemove()", "CurrentEntity.VisibleFriendlies.ObserveAdd()", "CurrentEntity.VisibleFriendlies.ObserveRemove()", + "_currentEntityPresentation.Tick(Time.deltaTime);", + "PilotFrameAdapter.Tick(CurrentEntity, Time.deltaTime, Time.time)", + "_targetPresentation.UpdateTargetIndicators(", "foreach (var hostile in CurrentEntity.VisibleEnemies)", "foreach (var friendly in CurrentEntity.VisibleFriendlies)", "dockedEntitySnapshot.ChildEntityIndices", @@ -9019,6 +10809,17 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) var daemonRuntimeOperationsPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonOperations.cs"); var daemonIntentPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonIntentState.cs"); var daemonObserverPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaDaemonObserver.cs"); + var pilotCommandSenderPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityPilotCommandSender.cs"); + var pilotFrameAdapterPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityPilotFrameAdapter.cs"); + var pilotOperationAdapterPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityPilotOperationAdapter.cs"); + var observedTargetQueryPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedTargetQuery.cs"); + var observedFrameApplierPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedFrameApplier.cs"); + var daemonEntitySnapshotProjectorPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityDaemonEntitySnapshotProjector.cs"); + var observedFacadeProjectorPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedFacadeProjector.cs"); + var entityConstructionBlueprintProjectorPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityEntityConstructionBlueprintProjector.cs"); + var observedZoneContextProjectorPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedZoneContextProjector.cs"); + var currentEntityBinderPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityCurrentEntityBinder.cs"); + var gameplaySceneWiringPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplaySceneWiring.cs"); var daemonGameplayOperationsPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonOperationsClient.cs"); var legacyDaemonGameplayOperationsPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaDaemonOperations.cs"); var actionGameManager = File.Exists(actionGameManagerPath) @@ -9045,6 +10846,39 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) var daemonObserver = File.Exists(daemonObserverPath) ? File.ReadAllText(daemonObserverPath) : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaDaemonObserver.cs is missing."); + var pilotCommandSender = File.Exists(pilotCommandSenderPath) + ? File.ReadAllText(pilotCommandSenderPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityPilotCommandSender.cs is missing."); + var pilotFrameAdapter = File.Exists(pilotFrameAdapterPath) + ? File.ReadAllText(pilotFrameAdapterPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityPilotFrameAdapter.cs is missing."); + var pilotOperationAdapter = File.Exists(pilotOperationAdapterPath) + ? File.ReadAllText(pilotOperationAdapterPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityPilotOperationAdapter.cs is missing."); + var observedTargetQuery = File.Exists(observedTargetQueryPath) + ? File.ReadAllText(observedTargetQueryPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityObservedTargetQuery.cs is missing."); + var observedFrameApplier = File.Exists(observedFrameApplierPath) + ? File.ReadAllText(observedFrameApplierPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityObservedFrameApplier.cs is missing."); + var daemonEntitySnapshotProjector = File.Exists(daemonEntitySnapshotProjectorPath) + ? File.ReadAllText(daemonEntitySnapshotProjectorPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityDaemonEntitySnapshotProjector.cs is missing."); + var observedFacadeProjector = File.Exists(observedFacadeProjectorPath) + ? File.ReadAllText(observedFacadeProjectorPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityObservedFacadeProjector.cs is missing."); + var entityConstructionBlueprintProjector = File.Exists(entityConstructionBlueprintProjectorPath) + ? File.ReadAllText(entityConstructionBlueprintProjectorPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityEntityConstructionBlueprintProjector.cs is missing."); + var observedZoneContextProjector = File.Exists(observedZoneContextProjectorPath) + ? File.ReadAllText(observedZoneContextProjectorPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityObservedZoneContextProjector.cs is missing."); + var currentEntityBinder = File.Exists(currentEntityBinderPath) + ? File.ReadAllText(currentEntityBinderPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityCurrentEntityBinder.cs is missing."); + var gameplaySceneWiring = File.Exists(gameplaySceneWiringPath) + ? File.ReadAllText(gameplaySceneWiringPath) + : throw new InvalidOperationException("Cannot verify Unity observer authority; AetheriaUnityGameplaySceneWiring.cs is missing."); var daemonOperations = File.Exists(daemonGameplayOperationsPath) ? File.ReadAllText(daemonGameplayOperationsPath) : throw new InvalidOperationException("Cannot verify Unity observer authority; shared daemon operations client is missing."); @@ -9108,33 +10942,24 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) var requiredGameplaySymbols = new[] { - "ApplyLatestAuthoritativeDaemonFrame()", + "ObservedFrameApplier.ApplyLatestZoneRender()", + "private AetheriaUnityGameplayLoopShell GameplayLoopShell =>", + "GameplayLoopShell.Tick(Time.deltaTime, Time.time)", + "GameplayLoopShell.LateTick()", + "ApplyLatestZoneRender = () => ObservedFrameApplier.ApplyLatestZoneRender()", "ResolveDaemonObserver()", - "public bool TryGetObservedZoneSnapshot(int daemonZoneIndex, out AetheriaRuntimeZoneSnapshotCommit snapshot)", - "public bool TryGetObservedRunZone(out GalaxyZone galaxyZone)", - "snapshot.ZoneIndex == daemonZoneIndex", - "FindObservedGalaxyZone(run.CurrentZoneIndex)", - "observed.IsAuthoritative", - "TryRestoreEntityGraphFromDaemonRun(observed.Run)", - "CreateDaemonZoneConstructionBlueprint(daemonZone)", - "PrepareObservedDaemonZoneContext(targetZone, daemonZone)", - "_observedZoneContextsByDaemonIndex", - "ZoneRenderer?.LoadDaemonZoneView(_observedEntityFacadesByDaemonIndex, daemonZone, run)", - "ZoneRenderer?.ApplyDaemonFrame(daemonZone, run)", - "CreateDaemonEntitySnapshots(runId, daemonZone)", - "FindCurrentDaemonZoneSnapshot()", - "daemonZone?.Orbits ?? Array.Empty()", - "ZoneRenderer.TryGetBodyView(parentOrbitPlanetBodyKey, out var parentBodyView)", - "ResolveDaemonObserver()?.LastObservedState?.Run", - "ReplaceObservedEntityFacadesFromTypedSnapshots", - "EntityConstructionBlueprintProjector.ProjectObservedFromBlueprint", - "public static Galaxy ObservedGalaxy", - "TryRequestDaemonMoveVector", - "TryRequestDaemonLookDirection", - "TryRequestDaemonTractorPower", - "observer.Operations.SetMoveVector", - "observer.Operations.SetLookDirection", - "observer.Operations.SetTractorPower" + "private AetheriaUnityObservedFrameApplier ObservedFrameApplier =>", + "private AetheriaUnityObservedZoneContextProjector ObservedZoneContextProjector =>", + "entity => CurrentEntityBinder.RestoreBinding(entity)", + "private AetheriaUnityEntityConstructionBlueprintProjector EntityConstructionBlueprintProjector =>", + "EntityConstructionBlueprintProjector.ProjectObservedEntity", + "_loadoutItemProjector.CreateLoadoutItem", + "private AetheriaUnityPilotCommandSender PilotCommands =>", + "private AetheriaUnityPilotFrameAdapter PilotFrameAdapter =>", + "PilotFrameAdapter = PilotFrameAdapter", + "private AetheriaUnityPilotOperationAdapter PilotOperationAdapter =>", + "private AetheriaUnityObservedTargetQuery ObservedTargetQuery =>", + "SceneWiring.ConfigureTargetPresentation(" }; var missingGameplaySymbols = requiredGameplaySymbols @@ -9148,6 +10973,374 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) string.Join(", ", missingGameplaySymbols)); } + var requiredSceneWiringObserverSymbols = new[] + { + "presentation.ResolveTarget = observedTargetQuery.GetObservedTarget", + "presentation.ResolveInfoGathered = observedTargetQuery.GetObservedInfoGathered", + "presentation.ResolveHostileContact = observedTargetQuery.IsObservedHostileContact" + }; + + var missingSceneWiringObserverSymbols = requiredSceneWiringObserverSymbols + .Where(symbol => !gameplaySceneWiring.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingSceneWiringObserverSymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity scene wiring no longer connects target presentation to observed daemon target queries: " + + string.Join(", ", missingSceneWiringObserverSymbols)); + } + + if (!daemonEntitySnapshotProjector.Contains("new AetheriaRuntimeEntitySnapshot(", StringComparison.Ordinal) || + !daemonEntitySnapshotProjector.Contains("CreateWeaponStates(runId, zoneIndex, entity.WeaponStates)", StringComparison.Ordinal) || + !daemonEntitySnapshotProjector.Contains("public static int EntityIndexFromRecordKey(string recordKey)", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Unity observer entity projection no longer flows through the dedicated daemon snapshot projector."); + } + + if (!observedFacadeProjector.Contains("public bool TryApplyInPlace(", StringComparison.Ordinal) || + !observedFacadeProjector.Contains("public void Replace(", StringComparison.Ordinal) || + !observedFacadeProjector.Contains("EntityConstructionBlueprintProjector.ProjectObservedFromBlueprint(_itemManager, zone, blueprint)", StringComparison.Ordinal) || + !observedFacadeProjector.Contains("RestoreRuntimeBehaviorState(entity, entitySnapshot, restoredEntities)", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Unity observer facade restoration no longer flows through the dedicated observed facade projector."); + } + + var requiredEntityConstructionBlueprintProjectorSymbols = new[] + { + "public sealed class AetheriaUnityEntityConstructionBlueprintProjector", + "public EntityConstructionBlueprint ProjectTemplate(AetheriaRuntimeLoadoutTemplateSnapshot template)", + "public EntityConstructionBlueprint ProjectLoadoutEntity(AetheriaRuntimeEntityLoadoutSnapshot entity)", + "public EntityConstructionBlueprint ProjectObservedEntity(", + "private static EntityConstructionBlueprint CreateBlueprint(string kind)", + "new OrbitalEntityConstructionBlueprint()", + "new ShipConstructionBlueprint()", + "shipBlueprint.Position = new float3((float)entity.PositionX", + "shipBlueprint.Direction = new float2((float)entity.DirectionX", + "shipBlueprint.IsPlayerShip = isCurrentEntity", + "blueprint.Equipment = CreateEquippableSlots(entity.Equipment)", + "blueprint.CargoContents = CreateCargoBayContents(entity.CargoContents)", + "blueprint.Children = entity.Children", + "_loadoutItemProjector.CreateLoadoutItem(slot.Item)", + "_loadoutItemProjector.CreateLoadoutItem(item) as EquippableItem" + }; + var missingEntityConstructionBlueprintProjectorSymbols = requiredEntityConstructionBlueprintProjectorSymbols + .Where(symbol => !entityConstructionBlueprintProjector.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingEntityConstructionBlueprintProjectorSymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity observer entity construction lowering must live in AetheriaUnityEntityConstructionBlueprintProjector instead of ActionGameManager: " + + string.Join(", ", missingEntityConstructionBlueprintProjectorSymbols)); + } + + var forbiddenManagerEntityConstructionSymbols = new[] + { + "private EntityConstructionBlueprint CreateEntityConstructionBlueprint(", + "private (int2 position, EquippableItem item)[] CreateEquippableSlots(", + "private (int2 position, ItemInstance item)[][] CreateCargoBayContents(", + "private EquippableItem CreateEquippableLoadoutItem(", + "private ItemInstance CreateLoadoutItem(AetheriaRuntimeLoadoutItemSnapshot", + "new OrbitalEntityConstructionBlueprint()", + "new ShipConstructionBlueprint", + "blueprint.Children = entity.Children", + "ItemManager.CreateSimpleCommodityInstance(typedItem", + "ItemManager.CreateCraftedInstance(typedItem" + }; + var managerEntityConstructionHits = forbiddenManagerEntityConstructionSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerEntityConstructionHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns Unity entity construction blueprint lowering internals: " + + string.Join(", ", managerEntityConstructionHits)); + } + + if (!observedZoneContextProjector.Contains("public Zone ResolveContext(", StringComparison.Ordinal) || + !observedZoneContextProjector.Contains("private static ZoneConstructionBlueprint CreateZoneConstructionBlueprint(", StringComparison.Ordinal) || + !observedZoneContextProjector.Contains("private static BodyConstructionData CreateBodyConstructionData(", StringComparison.Ordinal) || + !observedZoneContextProjector.Contains("new Zone(_itemManager, _planetSettings, constructionBlueprint, galaxyZone, _observedGalaxy)", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Unity observer zone context lowering no longer flows through the dedicated observed zone context projector."); + } + + var requiredObservedFrameApplierSymbols = new[] + { + "public sealed class AetheriaUnityObservedFrameApplier", + "public bool ApplyLatestZoneRender()", + "ZoneRenderAsync()", + "private bool TryRestoreEntityGraphFromZoneRender(", + "AetheriaUnityDaemonEntitySnapshotProjector.CreateSnapshots(runId, render.ZoneIndex, render.EntityFacades)", + "AetheriaUnityDaemonEntitySnapshotProjector.EntityIndexFromRecordKey(entity.RecordKey)", + "_facadeProjector.Replace(entitySnapshots, currentEntityKey, _getZone())", + "_facadeProjector.TryApplyInPlace(", + "_facadeIndex.TryResolveEntityByRecordKey(currentEntityKey, out var currentEntity)", + "_facadeIndex.EntitiesByDaemonIndex", + "_zoneContextProjector.ResolveContext(targetZone, render)", + "zoneRenderer?.LoadDaemonZoneView(_facadeIndex.EntitiesByDaemonIndex, render)", + "zoneRenderer?.ApplyZoneRender(render)", + "zoneRenderer?.RestoreDroppedPickupsFromZoneRender(render)", + "_restoreCurrentEntityBinding(currentEntity)" + }; + var missingObservedFrameApplierSymbols = requiredObservedFrameApplierSymbols + .Where(symbol => !observedFrameApplier.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingObservedFrameApplierSymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity observer frame restoration must live in AetheriaUnityObservedFrameApplier instead of ActionGameManager: " + + string.Join(", ", missingObservedFrameApplierSymbols)); + } + + var forbiddenManagerFrameApplicationSymbols = new[] + { + "_lastAppliedAuthoritativeDaemonFrameId", + "_lastAppliedAuthoritativeDaemonFramePath", + "_lastAppliedAuthoritativeDaemonRunId", + "_lastAppliedAuthoritativeDaemonZoneIndex", + "private bool TryRestoreEntityGraphFromZoneRender(", + "AetheriaRuntimeRtsProjection.ProjectZoneRender(observed.Frame)", + "AetheriaUnityDaemonEntitySnapshotProjector.CreateSnapshots(runId, daemonZone)", + "ObservedFacadeProjector.Replace(entitySnapshots, currentEntityKey, Zone)", + "ObservedFacadeProjector.TryApplyInPlace(", + "ZoneRenderer?.LoadDaemonZoneView(_observedFacadeIndex.EntitiesByDaemonIndex, render)", + "ZoneRenderer?.ApplyZoneRender(render)", + "ZoneRenderer?.RestoreDroppedPickupsFromDaemonZoneState(daemonZone)", + "render.ActionBarBindings", + "private static AetheriaRuntimeActionBarBindingSnapshot ToActionBarBindingSnapshot(" + }; + var managerFrameApplicationHits = forbiddenManagerFrameApplicationSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerFrameApplicationHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns authoritative daemon frame restoration instead of delegating to AetheriaUnityObservedFrameApplier: " + + string.Join(", ", managerFrameApplicationHits)); + } + + var requiredPilotFrameAdapterSymbols = new[] + { + "public sealed class AetheriaUnityPilotFrameAdapter", + "public void Tick(Entity currentEntity, float deltaTime, float timeSeconds)", + "TargetPresentation?.Tick(currentEntity, timeSeconds)", + "Input.Player.Look.ReadValue()", + "PilotCommands.RequestLookDirection(viewDirection)", + "renderSettings.NormalizeHeatstrokePost(currentEntity.Heatstroke)", + "renderSettings.ResolveSevereHeatstrokePostWeight(currentEntity.Heatstroke, timeSeconds)", + "Input.Player.Move.ReadValue()", + "PilotCommands.RequestMoveVector(movement)", + "Input.Player.TractorBeam.ReadValue()", + "PilotCommands.RequestTractorPower(Saturate(" + }; + var missingPilotFrameAdapterSymbols = requiredPilotFrameAdapterSymbols + .Where(symbol => !pilotFrameAdapter.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingPilotFrameAdapterSymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity observer pilot-frame input and presentation policy must live in AetheriaUnityPilotFrameAdapter instead of ActionGameManager: " + + string.Join(", ", missingPilotFrameAdapterSymbols)); + } + + var forbiddenManagerPilotFrameSymbols = new[] + { + "Input.Player.Look.ReadValue()", + "Input.Player.Move.ReadValue()", + "Input.Player.TractorBeam.ReadValue()", + "PilotCommands.RequestLookDirection(_viewDirection)", + "PilotCommands.RequestMoveVector(movement)", + "PilotCommands.RequestTractorPower(Saturate(", + "HeatstrokePost.weight = (float)renderSettings.NormalizeHeatstrokePost(", + "SevereHeatstrokePost.weight =" + }; + var managerPilotFrameHits = forbiddenManagerPilotFrameSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerPilotFrameHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns undocked pilot-frame input/presentation internals: " + + string.Join(", ", managerPilotFrameHits)); + } + + var requiredPilotOperationAdapterSymbols = new[] + { + "public sealed class AetheriaUnityPilotOperationAdapter", + "public void RequestInteract()", + "public void RequestTargetSelection(Entity target)", + "public void RequestTargetReticle()", + "public void RequestDock()", + "public void RequestUndock()", + "_observedFacadeIndex.TryResolveEntityRecordKey(target, out var targetEntityKey)", + "Submit(operations => operations.Interact(), \"interact\")", + "Submit(operations => operations.ClearTarget(), \"target clear\")", + "operations => operations.SetTarget(targetEntityKey)", + "operations => operations.TargetNearest()", + "operations => operations.TargetNext()", + "operations => operations.TargetPrevious()", + "operations => operations.TargetReticle(", + "operations => operations.SetOverrideShutdown(enabled)", + "operations => operations.SensorPing()", + "operations => operations.SetHeatsinksEnabled(enabled)", + "operations => operations.ToggleShieldEnabled()", + "operations => operations.DockNearest()", + "operations => operations.Undock()", + "_resolvePilotCommands()?.TrySubmit(submit, label)" + }; + var missingPilotOperationAdapterSymbols = requiredPilotOperationAdapterSymbols + .Where(symbol => !pilotOperationAdapter.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingPilotOperationAdapterSymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity pilot operation lowering must live in AetheriaUnityPilotOperationAdapter instead of ActionGameManager: " + + string.Join(", ", missingPilotOperationAdapterSymbols)); + } + + var forbiddenManagerOperationLoweringSymbols = new[] + { + "PilotCommands.TrySubmit(", + "operations => operations.Interact()", + "operations => operations.ClearTarget()", + "operations => operations.SetTarget(", + "operations => operations.TargetNearest()", + "operations => operations.TargetNext()", + "operations => operations.TargetPrevious()", + "operations => operations.TargetReticle(", + "operations => operations.SetOverrideShutdown(", + "operations => operations.SensorPing()", + "operations => operations.SetHeatsinksEnabled(", + "operations => operations.ToggleShieldEnabled()", + "operations => operations.DockNearest()", + "operations => operations.Undock()" + }; + var managerOperationLoweringHits = forbiddenManagerOperationLoweringSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerOperationLoweringHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns typed daemon operation lowering instead of delegating to AetheriaUnityPilotOperationAdapter: " + + string.Join(", ", managerOperationLoweringHits)); + } + + var requiredObservedTargetQuerySymbols = new[] + { + "public sealed class AetheriaUnityObservedTargetQuery", + "public Entity GetObservedTarget(Entity observer)", + "public float GetObservedInfoGathered(Entity observer, Entity target)", + "public bool IsObservedHostileContact(Entity observer, Entity target)", + "public AetheriaRuntimeZoneContactRow[] GetObservedVisibleContacts(", + "private bool TryQueryEntityContact(", + "private bool TryQueryEntityTarget(", + "AetheriaRuntimeZoneContactRow", + "AetheriaRuntimeZoneTargetRow", + "ZoneContactsAsync()", + "_facadeIndex.TryResolveEntityByDaemonIndex(targetEntityIndex, out var targetEntity)" + }; + var missingObservedTargetQuerySymbols = requiredObservedTargetQuerySymbols + .Where(symbol => !observedTargetQuery.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingObservedTargetQuerySymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity observer target/contact projection must live in AetheriaUnityObservedTargetQuery instead of ActionGameManager: " + + string.Join(", ", missingObservedTargetQuerySymbols)); + } + + var forbiddenManagerTargetQuerySymbols = new[] + { + "private bool TryQueryDaemonEntityContact(", + "private bool TryQueryDaemonEntityTarget(", + "private Entity GetObservedTarget(", + "private float GetObservedInfoGathered(", + "private bool IsObservedHostileContact(", + "AetheriaRuntimeDaemonRenderQueries.TryQueryEntityContact(", + "AetheriaRuntimeDaemonRenderQueries.TryQueryEntityTarget(" + }; + var managerTargetQueryHits = forbiddenManagerTargetQuerySymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerTargetQueryHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns observed daemon target/contact query internals: " + + string.Join(", ", managerTargetQueryHits)); + } + + if (!currentEntityBinder.Contains("public void RestoreBinding(", StringComparison.Ordinal) || + !currentEntityBinder.Contains("ObservedDocking.TryResolveDockingBay(currentEntity, out var dockParent, out _)", StringComparison.Ordinal) || + !currentEntityBinder.Contains("CurrentEntityPresentation?.BindDocked(", StringComparison.Ordinal) || + !currentEntityBinder.Contains("CurrentEntityPresentation?.BindUndocked(", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Unity observer current-entity binding no longer flows through the dedicated current entity binder."); + } + + var forbiddenObservedReadShims = new[] + { + "TryGetObservedGalaxy(out Galaxy galaxy)", + "TryGetObservedZoneSnapshot(", + "TryGetObservedRunZone(" + }; + + var observedReadShimHits = forbiddenObservedReadShims + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (observedReadShimHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager must not expose broad observed galaxy/zone read shims; clients read typed projections through AetheriaClient: " + + string.Join(", ", observedReadShimHits)); + } + + if (actionGameManager.Contains("public static Galaxy ObservedGalaxy", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionGameManager must not expose the raw observed galaxy projection as public gameplay state; keep it as a private renderer adapter behind typed client projections."); + } + + if (actionGameManager.Contains("public static void ProjectObservedDaemonRun(", StringComparison.Ordinal) || + mainMenu.Contains("ActionGameManager.ProjectObservedDaemonRun(", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Daemon-run projection must not be routed through ActionGameManager; use the explicit observed-run projection holder."); + } + + if (actionGameManager.Contains("public static bool IsTutorial", StringComparison.Ordinal) || + mainMenu.Contains("ActionGameManager.IsTutorial", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionGameManager must not mirror tutorial mode as public manager-global gameplay state; tutorial/run mode belongs to typed daemon run projections."); + } + + if (actionGameManager.Contains("public static DirectoryInfo GameDataDirectory", StringComparison.Ordinal) || + actionGameManager.Contains("public static string RuntimeStateFilePath", StringComparison.Ordinal) || + mainMenu.Contains("ActionGameManager.GameDataDirectory", StringComparison.Ordinal) || + mainMenu.Contains("ActionGameManager.RuntimeStateFilePath", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Unity runtime path access must live in AetheriaUnityRuntimePaths, not ActionGameManager gameplay state."); + } + + var volumeSamplingPath = Path.Combine(root, "Assets", "Scripts", "Zone Display", "VolumeSampling.cs"); + var volumeSampling = File.Exists(volumeSamplingPath) + ? File.ReadAllText(volumeSamplingPath) + : throw new InvalidOperationException("Cannot verify presentation environment ownership; VolumeSampling.cs is missing."); + if (actionGameManager.Contains("CurrentEnvironment", StringComparison.Ordinal) || + volumeSampling.Contains("ActionGameManager.Instance?.CurrentEnvironment", StringComparison.Ordinal) || + volumeSampling.Contains("ActionGameManager.Instance.CurrentEnvironment", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionGameManager must not expose presentation environment as manager-global gameplay state; visual sampling should read presentation settings directly."); + } + var mapRendererPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "MapRenderer.cs"); var mapRenderer = File.Exists(mapRendererPath) ? File.ReadAllText(mapRendererPath) @@ -9173,12 +11366,17 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) "Map and sector UI must read current-zone identity from the daemon-observed run, not Unity's mirrored Zone hierarchy."); } - if (!actionGameManager.Contains("public static bool TryGetObservedGalaxy(out Galaxy galaxy)", StringComparison.Ordinal) || - !actionGameManager.Contains("public bool TryGetObservedZoneSnapshot(int daemonZoneIndex, out AetheriaRuntimeZoneSnapshotCommit snapshot)", StringComparison.Ordinal) || - !actionGameManager.Contains("public bool TryGetObservedRunZone(out GalaxyZone galaxyZone)", StringComparison.Ordinal) || - !mapRenderer.Contains("GameManager.TryGetObservedRunZone(out var currentZone)", StringComparison.Ordinal) || - !sectorRenderer.Contains("GameManager.TryGetObservedRunZone(out var currentZone)", StringComparison.Ordinal) || - !sectorMap.Contains("ActionGameManager.TryGetObservedGalaxy(out var observedGalaxy)", StringComparison.Ordinal) || + if (!mapRenderer.Contains("private AetheriaClient _client", StringComparison.Ordinal) || + !mapRenderer.Contains("ObjectsViewportAsync", StringComparison.Ordinal) || + !mapRenderer.Contains("GravityViewportAsync", StringComparison.Ordinal) || + !mapRenderer.Contains("PlayerSettingsAsync()", StringComparison.Ordinal) || + !sectorRenderer.Contains("private AetheriaClient _client", StringComparison.Ordinal) || + !sectorRenderer.Contains("SectorMapAsync()", StringComparison.Ordinal) || + !sectorRenderer.Contains("ZoneDetailsAsync(zoneIndex)", StringComparison.Ordinal) || + !sectorRenderer.Contains("OpenRuntimeCatalog()", StringComparison.Ordinal) || + !sectorRenderer.Contains("PlayerSettingsAsync()", StringComparison.Ordinal) || + !sectorMap.Contains("private AetheriaClient _client", StringComparison.Ordinal) || + !sectorMap.Contains("SectorMapAsync()", StringComparison.Ordinal) || !sectorRenderer.Contains("AetheriaRuntimeZoneDetailsSurfaceBuilder.ProjectDaemonZone(", StringComparison.Ordinal)) { throw new InvalidOperationException( @@ -9261,7 +11459,7 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) var contextPrepRendererLoads = FindMethodScopedLineHits( actionGameManager, new[] { "LoadDaemonZoneView(", "BindToEntity(" }) - .Where(hit => hit.MethodName == "PrepareObservedDaemonZoneContext") + .Where(hit => hit.MethodName == "ResolveContext") .Select(hit => $"{hit.MethodName}:{hit.LineNumber}: {hit.Line.Trim()}") .ToArray(); if (contextPrepRendererLoads.Length > 0) @@ -9345,6 +11543,37 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) "AetheriaDaemonObserver still exposes queue-shaped public daemon APIs instead of typed Operations."); } + var forbiddenManagerFacadeSymbols = new[] + { + "public static ActionGameManager Instance", + "public Entity CurrentEntity", + "public ItemManager ItemManager", + "public Zone Zone", + "public EntitySettings NewEntitySettings", + "using Ink;", + "using Ink.Runtime;", + "private List", + "private readonly (float2 direction, string name)[] _directions", + "private static float Unlerp(", + "DeathPostTransitionTime", + "HypothermiaPost", + "SevereHypothermiaPost", + "WormholeCamera", + "public SectorMap SectorMap", + "public EventLog EventLog", + "public void EnablePlayerInput()", + "public void DisablePlayerInput()" + }; + var managerFacadeHits = forbiddenManagerFacadeSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerFacadeHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still exposes Unity gameplay facade state as public API: " + + string.Join(", ", managerFacadeHits)); + } + var requiredDaemonNavigationAuthoritySymbols = new[] { "ApplyEnterWormholeIntent(run, command, context.Intents)", @@ -9366,47 +11595,83 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) var requiredSentOperationSymbols = new[] { + "public sealed class AetheriaUnityPilotCommandSender", "_lastSentDaemonMoveVector", "_lastSentDaemonLookDirection", "_lastSentDaemonTractorPower", "_hasSentDaemonMoveVector", "_hasSentDaemonLookDirection", "_hasSentDaemonTractorPower", - "Failed to send Aetheria daemon movement operation", - "Failed to send Aetheria daemon look operation", - "Failed to send Aetheria daemon tractor-power operation" + "Func _resolveClient", + "Action submit", + "submit(client.Control)", + "Failed to send Aetheria daemon pilot {label} operation", + "operations => operations.SetMoveVector", + "operations => operations.SetLookDirection", + "operations => operations.SetTractorPower" }; var missingSentOperationSymbols = requiredSentOperationSymbols - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => !pilotCommandSender.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (missingSentOperationSymbols.Length > 0) { throw new InvalidOperationException( - "Unity gameplay no longer describes daemon input as sent typed operations: " + + "Unity pilot command sender no longer describes daemon input as sent typed operations: " + string.Join(", ", missingSentOperationSymbols)); } + var forbiddenSentOperationSymbols = new[] + { + "Func _resolveObserver", + "Action submit", + "submit(observer.Operations)" + }; + var sentOperationHits = forbiddenSentOperationSymbols + .Where(symbol => pilotCommandSender.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (sentOperationHits.Length > 0) + { + throw new InvalidOperationException( + "Unity pilot command sender still routes typed input through the daemon observer operation escape hatch: " + + string.Join(", ", sentOperationHits)); + } + + var forbiddenManagerPilotCommandSymbols = new[] + { + "private bool TrySubmitPilotOperation(", + "_lastSentDaemonMoveVector", + "_lastSentDaemonLookDirection", + "_lastSentDaemonTractorPower", + "_hasSentDaemonMoveVector", + "_hasSentDaemonLookDirection", + "_hasSentDaemonTractorPower", + "DaemonMoveCommandIntervalSeconds", + "DaemonLookCommandIntervalSeconds", + "DaemonTractorCommandIntervalSeconds" + }; + var managerPilotCommandHits = forbiddenManagerPilotCommandSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerPilotCommandHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still owns pilot command transport/throttling state instead of delegating to AetheriaUnityPilotCommandSender: " + + string.Join(", ", managerPilotCommandHits)); + } + var requiredDockingLoweringSymbols = new[] { - "public void RequestDock()", - "public void RequestUndock()", - "private void RequestInteract()", - "public void RequestTowToStation()", - "TryRequestDaemonDock()", - "TryRequestDaemonUndock()", - "TryRequestDaemonInteract()", - "TryRequestDaemonTowToStation()", - "observer.Operations.DockNearest()", - "observer.Operations.Undock()", - "observer.Operations.Interact()", - "observer.Operations.TowToStation(", - "ResolveObservedEntityZoneIndex(TowingStation)", - "TowingStation.CultPositionXZ.x", - "TowingStation.CultPositionXZ.y" + "private AetheriaUnityPilotOperationAdapter PilotOperationAdapter =>", + "PilotOperationAdapter = PilotOperationAdapter", + "public sealed class AetheriaUnityPilotOperationAdapter", + "operations => operations.DockNearest()", + "operations => operations.Undock()", + "operations => operations.Interact()" }; + var dockingLoweringSource = actionGameManager + "\n" + pilotOperationAdapter; var missingDockingLoweringSymbols = requiredDockingLoweringSymbols - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => !dockingLoweringSource.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (missingDockingLoweringSymbols.Length > 0) @@ -9421,6 +11686,19 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) "public void Dock()", "public void Undock()", "public void TowShip()", + "public ActionGameManager GameManager", + "ActionGameManager GameManager", + "private void RequestDock()", + "private void RequestUndock()", + "private void RequestInteract()", + "private void RequestTowToStation()", + "PilotOperationAdapter.RequestTowToStation(_towingStation)", + "public void RequestTowToStation(Entity towingStation)", + "_towingStation", + "UpdateTowingStation", + "ResolveObservedEntityZoneIndex(_towingStation)", + "_towingStation.CultPositionXZ.x", + "_towingStation.CultPositionXZ.y", "Missing cockpit component", "Missing thruster component", "Missing reactor component", @@ -9481,8 +11759,8 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) { "AetheriaRuntimeDaemonCommandKinds.DockNearest", "AetheriaRuntimeDaemonCommandKinds.Interact", - "public AetheriaRuntimeDaemonCommandEnvelope DockNearest(", - "public AetheriaRuntimeDaemonCommandEnvelope Interact(", + "internal AetheriaRuntimeDaemonCommandEnvelope DockNearest(", + "internal AetheriaRuntimeDaemonCommandEnvelope Interact(", "ApplyDockNearestIntent(run, command, context.Intents, context.DockingDistance)", "context.WormholeExitRadius", "AetheriaRuntimeDaemonOperationContext.DefaultDockingDistance", @@ -9529,14 +11807,19 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) var requiredShieldToggleSymbols = new[] { - "TryRequestDaemonShieldToggle", - "observer.Operations.ToggleShieldEnabled()", + "PilotOperationAdapter.RequestShieldToggle()", + "operations => operations.ToggleShieldEnabled()", "AetheriaRuntimeDaemonCommandKinds.ToggleShieldEnabled", "ApplyToggleEquipmentBehaviorItem(run, command, \"Shield\"" }; + var gameplayInputShellPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplayInputShell.cs"); + var gameplayInputShell = File.Exists(gameplayInputShellPath) + ? File.ReadAllText(gameplayInputShellPath) + : throw new InvalidOperationException("Cannot verify Unity shield input lowering; AetheriaUnityGameplayInputShell.cs is missing."); + var unityPilotOperationSources = actionGameManager + "\n" + pilotOperationAdapter + "\n" + gameplayInputShell; var missingShieldToggleSymbols = requiredShieldToggleSymbols .Where(symbol => - !actionGameManager.Contains(symbol, StringComparison.Ordinal) && + !unityPilotOperationSources.Contains(symbol, StringComparison.Ordinal) && !daemonOperationsSource.Contains(symbol, StringComparison.Ordinal) && !daemonDocuments.Contains(symbol, StringComparison.Ordinal)) .ToArray(); @@ -9607,12 +11890,12 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) "RequestTargetNext()", "RequestTargetPrevious()", "RequestTargetReticle()", - "observer.Operations.TargetNearest()", - "observer.Operations.TargetNext()", - "observer.Operations.TargetPrevious()", - "observer.Operations.TargetReticle(" + "operations => operations.TargetNearest()", + "operations => operations.TargetNext()", + "operations => operations.TargetPrevious()", + "operations => operations.TargetReticle(" }; - var unityTargetRequestSources = actionGameManager + "\n" + daemonOperations; + var unityTargetRequestSources = actionGameManager + "\n" + pilotOperationAdapter + "\n" + daemonOperations; var missingUnityTargetRequestSymbols = requiredUnityTargetRequestSymbols .Where(symbol => !unityTargetRequestSources.Contains(symbol, StringComparison.Ordinal)) .ToArray(); @@ -9685,10 +11968,14 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) "TowToStation", "AetheriaRuntimeDaemonCommandKinds.TowToStation", "ApplyTowToStationIntent", - "MoveEntityToZone(run, actor, command.TargetZoneIndex, command.PositionX, command.PositionY, out var movedEntityKey)", + "out var stationZoneIndex", + "out var station", + "var destinationX = station.PositionX;", + "var destinationY = station.PositionZ;", + "MoveEntityToZone(run, actor, stationZoneIndex, destinationX, destinationY, out var movedEntityKey)", "intents.Towing.Add", "AetheriaRuntimeDaemonTowIntent", - "public AetheriaRuntimeDaemonCommandEnvelope TowToStation(" + "public CultMeshOperationReceipt TowToStation(string stationEntityKey)" }; var missingTowDaemonSymbols = requiredTowDaemonSymbols @@ -9731,10 +12018,12 @@ static void RequireUnityObserverDoesNotTickLocalSimulation(string root) var requiredMenuSymbols = new[] { "TryStartDaemonObservedGame", - "LatestDaemonFrame(stateBoot)", - "GetLatestAuthoritativeRunFrameAsync()", - "ActionGameManager.ObservedGalaxy = Galaxy.ProjectObservedDaemonRun(", - "frame.Run", + "LatestSectorMap(stateBoot)", + ".SectorMapAsync()", + "AetheriaUnityObservedRunProjection.Project(", + "sectorMap.FrameId", + "sectorMap.IsTutorial", + "sectorMap.GenerationSeed", "SceneManager.LoadScene(\"ARPG\")" }; @@ -9874,7 +12163,12 @@ static void RequireUnityPhysicsIsNotGameplayAuthority(string root) var ymirBridgePath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Physics", "AetheriaYmirPhysicsBridge.cs"); var projectilePath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons", "Projectile.cs"); var projectileManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons", "ProjectileManager.cs"); + var laserPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons", "Laser.cs"); + var laserManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons", "LaserManager.cs"); + var constantLaserPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons", "ConstantLaser.cs"); + var constantLaserManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons", "ConstantLaserManager.cs"); var guidedProjectilePath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons", "GuidedProjectile.cs"); + var guidedProjectileManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons", "GuidedProjectileManager.cs"); var hitscanPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons", "HitscanEffect.cs"); var hitscanManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons", "HitscanManager.cs"); var clickRaycasterPath = Path.Combine(root, "Assets", "Scripts", "UI", "ClickRaycaster.cs"); @@ -9905,13 +12199,46 @@ static void RequireUnityPhysicsIsNotGameplayAuthority(string root) "AetheriaYmirPhysicsBridge.Instance.TryStepProjectile", "projectile killed instead of falling back to Unity physics." }, + [laserPath] = new[] + { + "public ZoneRenderer ZoneRenderer { get; set; }", + "AetheriaYmirPhysicsBridge.Instance.TryCastZoneHulls", + "ZoneRenderer," + }, + [laserManagerPath] = new[] + { + "p.ZoneRenderer = source.ZoneRenderer" + }, + [constantLaserPath] = new[] + { + "public ZoneRenderer ZoneRenderer { get; set; }", + "AetheriaYmirPhysicsBridge.Instance.TryCastZoneHulls", + "ZoneRenderer," + }, + [constantLaserManagerPath] = new[] + { + "p.ZoneRenderer = source.ZoneRenderer" + }, [guidedProjectilePath] = new[] { - "AetheriaYmirPhysicsBridge.Instance.TryCastZoneHulls" + "public ZoneRenderer ZoneRenderer { get; set; }", + "AetheriaYmirPhysicsBridge.Instance.TryCastZoneHulls", + "ZoneRenderer,", + "child.ZoneRenderer = ZoneRenderer" + }, + [guidedProjectileManagerPath] = new[] + { + "p.ZoneRenderer = source.ZoneRenderer" }, [hitscanPath] = new[] { - "AetheriaYmirPhysicsBridge.Instance.TryCastZoneHulls" + "public ZoneRenderer ZoneRenderer { get; set; }", + "AetheriaYmirPhysicsBridge.Instance.TryCastZoneHulls", + "ZoneRenderer," + }, + [hitscanManagerPath] = new[] + { + "p.ZoneRenderer = source.ZoneRenderer" }, [clickRaycasterPath] = new[] { @@ -9982,6 +12309,80 @@ static void RequireUnityPhysicsIsNotGameplayAuthority(string root) "Unity weapon effects must query Ymir/renderer state directly instead of carrying renderer-local Zone handles: " + string.Join("; ", weaponZoneHandleHits)); } + + var weaponEffectRoot = Path.Combine(root, "Assets", "Scripts", "Gameplay", "Weapons"); + var weaponSingletonHits = Directory.EnumerateFiles(weaponEffectRoot, "*.cs", SearchOption.TopDirectoryOnly) + .SelectMany(path => File.ReadLines(path) + .Select((line, index) => new { Path = path, LineNumber = index + 1, Line = line })) + .Where(line => line.Line.Contains("ActionGameManager.Instance", StringComparison.Ordinal)) + .Select(line => $"{Path.GetRelativePath(root, line.Path)}:{line.LineNumber}: {line.Line.Trim()}") + .ToArray(); + + if (weaponSingletonHits.Length > 0) + { + throw new InvalidOperationException( + "Unity weapon effects must receive renderer/input context explicitly instead of reaching through ActionGameManager.Instance: " + + string.Join("; ", weaponSingletonHits)); + } +} + +static async Task RequireTradeValuePolicyEveCommandPersistsAsync() +{ + var tempStatePath = Path.Combine( + Path.GetTempPath(), + $"aetheria-trade-policy-command-{Guid.NewGuid():N}.cc"); + + try + { + await using var commandNode = await AetheriaStateNode.OpenAsync( + tempStatePath, + "aetheria-state-verify-trade-policy", + enableDurableShardLogs: false); + + var policy = AetheriaRuntimeStateMapper.ToTradeValuePolicy( + AetheriaRuntimeTradeValueSettings.Default, + DateTimeOffset.UtcNow.ToString("O")); + await commandNode.PutTradeValuePolicyAsync(policy).ConfigureAwait(false); + var originalMinimum = policy.QualityPriceModifier?.Minimum ?? 0; + var editedMinimum = originalMinimum + 0.125; + + var command = AetheriaRuntimeEveCommandClient.ToDocument( + AetheriaRuntimeEveCommandClient.CreateTradeValuePolicyCommand( + AetheriaRuntimeEveCommandKind.SetTradeValueQualityMinimum, + new AetheriaRuntimeTradeValuePolicyCommandBody + { + Value = editedMinimum + }, + "aetheria-state-verify")); + await commandNode.SubmitEveCommandAsync(command).ConfigureAwait(false); + + var report = await AetheriaEveCommandBridge.AcceptObservedAsync(commandNode).ConfigureAwait(false); + if (report.AcceptedTradeValuePolicyCommands != 1) + { + throw new InvalidOperationException( + "Trade value policy Eve command was not accepted by the typed command bridge."); + } + + var editedPolicy = await commandNode.GetTradeValuePolicyAsync().ConfigureAwait(false) + ?? throw new InvalidOperationException("Trade value policy disappeared after typed Eve command."); + var actualMinimum = editedPolicy.QualityPriceModifier?.Minimum ?? 0; + if (Math.Abs(actualMinimum - editedMinimum) > 0.000001) + { + throw new InvalidOperationException( + $"Trade value policy Eve command did not persist the typed edit. Expected {editedMinimum}, got {actualMinimum}."); + } + } + finally + { + try + { + File.Delete(tempStatePath); + } + catch + { + // Best-effort cleanup for verifier scratch state. + } + } } static void RequireDeadPropertiesPanelShellDeleted(string root) @@ -10281,7 +12682,11 @@ static void RequireRuntimeStateReaderOwnsUnityStateAcquisition(string root) "ToResolvedEveSurfaceDocument(stateFilePath, surface)", "ResolveSurfaceStateRefs(stateFilePath, surface)", "public static Func CreateEveSurfaceStateRefResolver(string stateFilePath)", - "CreateStateRefResolver(string stateFilePath)", + "public static CultMeshStateRefResolver CreateEveSurfaceCultMeshStateRefResolver(", + "private static CultMeshStateRefResolver CreateStateRefResolver(string stateFilePath, string runtimeId)", + "CultMeshStateRefResolver.Empty", + "CultMesh.StateRefResolver(", + ".AsFunc()", "FindDaemonItem(", "AetheriaRuntimeEveSurfaceAdapter.ResolveStateRefs(", "AetheriaRuntimeEveSurfaceAdapter.EmptySurface(surfaceId)" @@ -10343,16 +12748,31 @@ static void RequireRuntimeStateReaderOwnsUnityStateAcquisition(string root) var daemonObserver = File.Exists(daemonObserverPath) ? File.ReadAllText(daemonObserverPath) : throw new InvalidOperationException("Cannot verify daemon state acquisition; AetheriaDaemonObserver.cs is missing."); + var observedFrameApplierPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedFrameApplier.cs"); + var observedFrameApplier = File.Exists(observedFrameApplierPath) + ? File.ReadAllText(observedFrameApplierPath) + : throw new InvalidOperationException("Cannot verify daemon state acquisition; AetheriaUnityObservedFrameApplier.cs is missing."); + var runtimeClientProviderPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityRuntimeClientProvider.cs"); + var runtimeClientProvider = File.Exists(runtimeClientProviderPath) + ? File.ReadAllText(runtimeClientProviderPath) + : throw new InvalidOperationException("Cannot verify daemon state acquisition; AetheriaUnityRuntimeClientProvider.cs is missing."); + var gameplayBootShellPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplayBootShell.cs"); + var gameplayBootShell = File.Exists(gameplayBootShellPath) + ? File.ReadAllText(gameplayBootShellPath) + : throw new InvalidOperationException("Cannot verify daemon state acquisition; AetheriaUnityGameplayBootShell.cs is missing."); var requiredActionGameManagerSymbols = new[] { - "AetheriaRuntimeVerseClient", - "ResolveRuntimeVerseClient(", - "GetPlayerSettingsAsync()", - "GetLoadoutTemplatesAsync()", - "OpenRuntimeCatalog()", + "private AetheriaUnityGameplayBootShell GameplayBootShell =>", + "var boot = GameplayBootShell.Boot();", + "SceneWiring.ConfigureCurrentEntityPresentation(_currentEntityPresentation, boot.RuntimeCatalog);", + "SceneWiring.ConfigureTargetPresentation(", + "SceneWiring.ConfigureActionBarPresentation(", + "ItemManager = boot.ItemManager;", + "_loadoutItemProjector = boot.LoadoutItemProjector;", + "AetheriaUnityRuntimeClientProvider.PlayerSettings", "ResolveDaemonObserver()", - "LastObservedState" + "private AetheriaUnityObservedFrameApplier ObservedFrameApplier =>" }; var missingActionGameManagerSymbols = requiredActionGameManagerSymbols @@ -10362,15 +12782,87 @@ static void RequireRuntimeStateReaderOwnsUnityStateAcquisition(string root) if (missingActionGameManagerSymbols.Length > 0) { throw new InvalidOperationException( - "ActionGameManager does not route typed state reads through the shared Verse client: " + + "ActionGameManager does not route typed state reads through the shared Verse client and Unity boot shell: " + string.Join(", ", missingActionGameManagerSymbols)); } + var requiredGameplayBootShellSymbols = new[] + { + "public sealed class AetheriaUnityGameplayBootShell", + "public AetheriaUnityGameplayBootResult Boot()", + "AetheriaRuntimeStateBoot.Inspect(AetheriaUnityRuntimePaths.GameDataDirectory)", + "AetheriaUnityRuntimeClientProvider.ResolveClient(stateBoot.StateFilePath, stateBoot.RuntimeId)", + "OpenRuntimeCatalog()", + "new ItemManager(", + "new AetheriaUnityLoadoutItemProjector(itemManager, runtimeCatalog)", + "ZoneRenderer.SetDroppedPickupItemProjector(loadoutItemProjector.CreateLoadoutItem)", + "ZoneRenderer.BodySettingsCollections = Settings.BodySettingsCollections", + "AetheriaUnityRenderSettingsBridge.Build(", + "CockpitHudShell.SetRenderSettings(ZoneRenderer.RenderSettings)", + "AetheriaUnityRuntimeClientProvider.PlayerSettings.GraphicsSettings.ShowAsteroidsInMinimap", + "public readonly struct AetheriaUnityGameplayBootResult" + }; + + var missingGameplayBootShellSymbols = requiredGameplayBootShellSymbols + .Where(symbol => !gameplayBootShell.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingGameplayBootShellSymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity gameplay boot shell no longer owns typed runtime boot/catalog/render setup: " + + string.Join(", ", missingGameplayBootShellSymbols)); + } + + var requiredRuntimeClientProviderSymbols = new[] + { + "public static class AetheriaUnityRuntimeClientProvider", + "public static RuntimePlayerSettings PlayerSettings =>", + "public static AetheriaClient ResolveClient(string stateFilePath, string runtimeId = \"\")", + "public static AetheriaClient CurrentClientForStateFile(string stateFilePath)", + "AetheriaClient", + "PlayerSettingsAsync()", + "OpenAsync(", + "pullOnOpen: true", + "ApplyPlayerSettings(settings, stored)" + }; + var missingRuntimeClientProviderSymbols = requiredRuntimeClientProviderSymbols + .Where(symbol => !runtimeClientProvider.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingRuntimeClientProviderSymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity runtime client/player-settings boot must live behind AetheriaUnityRuntimeClientProvider: " + + string.Join(", ", missingRuntimeClientProviderSymbols)); + } + + if (!observedFrameApplier.Contains("private AetheriaRuntimeZoneRenderDocument _lastZoneRender;", StringComparison.Ordinal) || + !observedFrameApplier.Contains("ApplyLatestZoneRender()", StringComparison.Ordinal) || + !observedFrameApplier.Contains("ZoneRenderAsync()", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Observed zone-render state acquisition must live behind AetheriaUnityObservedFrameApplier."); + } + var forbiddenActionGameManagerReaderSymbols = new[] { "AetheriaRuntimeStateReader.ReadPlayerSettings", "AetheriaRuntimeStateReader.ReadLoadoutTemplates", - "AetheriaRuntimeStateReader.OpenRuntimeCatalog" + "AetheriaRuntimeStateReader.OpenRuntimeCatalog", + "LoadoutTemplatesAsync()", + "ObservedLoadoutTemplates", + "_observedLoadoutTemplates", + "LoadRuntimeLoadoutTemplates", + "AetheriaRuntimeStateBoot.Inspect(", + "ResolveClient(stateBoot.StateFilePath).OpenRuntimeCatalog()", + "ItemManager = new ItemManager(", + "ZoneRenderer.SetDroppedPickupItemProjector(", + "ZoneRenderer.BodySettingsCollections = Settings.BodySettingsCollections", + "ResolveRuntimeVerseClient(", + "PlayerSettingsAsync()", + "ApplyRuntimePlayerSettings(", + "CreateDefaultRuntimePlayerSettings(", + "_runtimeClient" }; var actionGameManagerReaderHits = forbiddenActionGameManagerReaderSymbols .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) @@ -10378,16 +12870,17 @@ static void RequireRuntimeStateReaderOwnsUnityStateAcquisition(string root) if (actionGameManagerReaderHits.Length > 0) { throw new InvalidOperationException( - "ActionGameManager still reads typed state through the runtime file reader instead of the Verse client: " + + "ActionGameManager still reads typed state through the runtime file reader instead of the shared Aetheria client facade: " + string.Join(", ", actionGameManagerReaderHits)); } - if (!daemonObserver.Contains("AetheriaRuntimeVerseClient", StringComparison.Ordinal) || - !daemonObserver.Contains(".OpenAsync(statePath", StringComparison.Ordinal) || - !daemonObserver.Contains("GetObservedDaemonStateAsync()", StringComparison.Ordinal)) + if (!daemonObserver.Contains("AetheriaClient", StringComparison.Ordinal) || + !daemonObserver.Contains(".OpenAsync(", StringComparison.Ordinal) || + !daemonObserver.Contains("statePath", StringComparison.Ordinal) || + !daemonObserver.Contains("ObserveAsync()", StringComparison.Ordinal)) { throw new InvalidOperationException( - "AetheriaDaemonObserver no longer routes observed daemon state through the shared runtime Verse client."); + "AetheriaDaemonObserver no longer routes observed daemon state through the shared Aetheria client facade."); } if (daemonObserver.Contains("AetheriaRuntimeStateReader.TryReadObservedDaemonState", StringComparison.Ordinal)) @@ -10396,17 +12889,18 @@ static void RequireRuntimeStateReaderOwnsUnityStateAcquisition(string root) "AetheriaDaemonObserver still spelunks observed daemon state through the runtime file reader instead of the Verse client."); } - if (!mainMenu.Contains("AetheriaRuntimeVerseClient", StringComparison.Ordinal) || - !mainMenu.Contains("GetLatestAuthoritativeRunFrameAsync()", StringComparison.Ordinal)) + if (!mainMenu.Contains("AetheriaClient", StringComparison.Ordinal) || + !mainMenu.Contains("LatestSectorMap(AetheriaRuntimeStateBootReport stateBoot)", StringComparison.Ordinal) || + !mainMenu.Contains(".SectorMapAsync()", StringComparison.Ordinal)) { throw new InvalidOperationException( - "MainMenu no longer routes daemon-frame lookup through the shared runtime Verse client."); + "MainMenu no longer routes sector-map lookup through the shared Aetheria client facade."); } if (mainMenu.Contains("AetheriaRuntimeStateReader.TryReadDaemonFrame", StringComparison.Ordinal)) { throw new InvalidOperationException( - "MainMenu still reads daemon frames through the runtime file reader instead of the Verse client."); + "MainMenu still reads daemon frames through the runtime file reader instead of the shared Aetheria client facade."); } var forbiddenMainMenuReaderSymbols = new[] @@ -10420,29 +12914,28 @@ static void RequireRuntimeStateReaderOwnsUnityStateAcquisition(string root) if (mainMenuReaderHits.Length > 0) { throw new InvalidOperationException( - "MainMenu still reads typed state through the runtime file reader instead of the Verse client: " + + "MainMenu still reads typed state through the runtime file reader instead of the shared Aetheria client facade: " + string.Join(", ", mainMenuReaderHits)); } - if (!eveSurfacePresenter.Contains("AetheriaRuntimeVerseClient.OpenAsync(", StringComparison.Ordinal) || + if (!eveSurfacePresenter.Contains("AetheriaClient.OpenAsync(", StringComparison.Ordinal) || !eveSurfacePresenter.Contains("ReadDaemonSurface(statePath)", StringComparison.Ordinal)) { throw new InvalidOperationException( - "Aetheria Eve surface presenter no longer routes daemon surface lookup through the shared runtime Verse client."); + "Aetheria Eve surface presenter no longer routes daemon surface lookup through the shared AetheriaClient facade."); } if (!eveSurfacePresenter.Contains("client.CreateEveSurfaceStateRefResolver()", StringComparison.Ordinal) || - !eveSurfacePresenter.Contains("ResolveVerseClient(statePath).CreateEveSurfaceStateRefResolver()", StringComparison.Ordinal)) + !eveSurfacePresenter.Contains("ResolveClient(statePath).CreateEveSurfaceStateRefResolver()", StringComparison.Ordinal)) { throw new InvalidOperationException( - "Aetheria Eve surface presenter no longer resolves provider state refs through the shared runtime Verse client."); + "Aetheria Eve surface presenter no longer resolves provider state refs through the shared AetheriaClient facade."); } - if (eveSurfacePresenter.Contains("AetheriaRuntimeStateReader.CreateEveSurfaceStateRefResolver", StringComparison.Ordinal) || - eveUnitySurfaceHost.Contains("AetheriaRuntimeStateReader.CreateEveSurfaceStateRefResolver", StringComparison.Ordinal)) + if (eveSurfacePresenter.Contains("AetheriaRuntimeStateReader.CreateEveSurfaceStateRefResolver", StringComparison.Ordinal)) { throw new InvalidOperationException( - "Unity Eve surface lowering still resolves state refs through the file reader instead of AetheriaRuntimeVerseClient."); + "Unity Eve surface lowering still resolves state refs through the file reader instead of the shared AetheriaClient facade."); } var forbiddenDirectStoreSymbols = new Dictionary @@ -10516,24 +13009,30 @@ static void RequireRuntimeSimulationTuningRequests(string root) var actionGameManager = File.Exists(requiredActionGameManagerPath) ? File.ReadAllText(requiredActionGameManagerPath) : throw new InvalidOperationException("Cannot verify runtime simulation tuning authority; ActionGameManager.cs is missing."); + var inventoryMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryMenu.cs"); + var inventoryMenu = File.Exists(inventoryMenuPath) + ? File.ReadAllText(inventoryMenuPath) + : throw new InvalidOperationException("Cannot verify runtime simulation tuning authority; InventoryMenu.cs is missing."); - var requiredRequestMethods = new[] + var requiredInventoryRequestSymbols = new[] { - "RequestEntityOverrideShutdown", - "RequestEquippedItemOverrideShutdown", - "RequestThermotoggleTargetTemperature", - "RequestEntityShutdownPerformance" + "private void RequestEntityShutdownPerformance(string targetEntityKey, float shutdownPerformance)", + "private void RequestEquippedItemOverrideShutdown(EquippedItem item, bool enabled)", + "private void RequestThermotoggleTargetTemperature(", + "operations => operations.SetShutdownPerformance(", + "operations => operations.SetItemOverrideShutdown(", + "operations => operations.SetThermotoggleTargetTemperature(" }; - var missingMethods = requiredRequestMethods - .Where(method => !actionGameManager.Contains(method, StringComparison.Ordinal)) + var missingInventoryRequestSymbols = requiredInventoryRequestSymbols + .Where(symbol => !inventoryMenu.Contains(symbol, StringComparison.Ordinal)) .ToArray(); - if (missingMethods.Length > 0) + if (missingInventoryRequestSymbols.Length > 0) { throw new InvalidOperationException( - "Runtime simulation tuning request methods are missing from ActionGameManager: " + - string.Join(", ", missingMethods)); + "Runtime simulation tuning request methods are missing from InventoryMenu's typed daemon operation boundary: " + + string.Join(", ", missingInventoryRequestSymbols)); } var forbiddenUiWrites = new[] @@ -10560,11 +13059,7 @@ static void RequireRuntimeSimulationTuningRequests(string root) string.Join("; ", hits)); } - var inventoryMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryMenu.cs"); - var inventoryMenu = File.Exists(inventoryMenuPath) - ? File.ReadAllText(inventoryMenuPath) - : throw new InvalidOperationException("Cannot verify runtime simulation tuning authority; InventoryMenu.cs is missing."); - var shutdownRequest = inventoryMenu.IndexOf("GameManager.RequestEntityShutdownPerformance", StringComparison.Ordinal); + var shutdownRequest = inventoryMenu.IndexOf("RequestEntityShutdownPerformance(", StringComparison.Ordinal); var staleShipSettingsRender = inventoryMenu.IndexOf("RenderCurrentShipSettingsSurface(entity);", shutdownRequest, StringComparison.Ordinal); if (shutdownRequest >= 0 && staleShipSettingsRender >= 0) { @@ -10625,14 +13120,13 @@ static void RequireRuntimeSimulationTuningRequests(string root) static void RequireHullConductivityRequestAuthority(string root) { - var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); - var actionGameManager = File.Exists(actionGameManagerPath) - ? File.ReadAllText(actionGameManagerPath) - : throw new InvalidOperationException("Cannot verify hull conductivity authority; ActionGameManager.cs is missing."); + var inventoryPanelPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryPanel.cs"); + var inventoryPanel = File.Exists(inventoryPanelPath) + ? File.ReadAllText(inventoryPanelPath) + : throw new InvalidOperationException("Cannot verify hull conductivity authority; InventoryPanel.cs is missing."); - if (!actionGameManager.Contains("RequestHullConductivityToggle", StringComparison.Ordinal) || - !actionGameManager.Contains("TryRequestDaemonHullConductivityToggle", StringComparison.Ordinal) || - !actionGameManager.Contains("Operations.ToggleHullConductivity", StringComparison.Ordinal)) + if (!inventoryPanel.Contains("RequestHullConductivityToggle", StringComparison.Ordinal) || + !inventoryPanel.Contains("operations => operations.ToggleHullConductivity", StringComparison.Ordinal)) { throw new InvalidOperationException( "Hull conductivity no longer has a typed daemon request primitive."); @@ -10647,7 +13141,7 @@ static void RequireHullConductivityRequestAuthority(string root) "axis > 1" }; var unityAcceptanceHits = forbiddenUnityAcceptanceSymbols - .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => inventoryPanel.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (unityAcceptanceHits.Length > 0) { @@ -10656,7 +13150,7 @@ static void RequireHullConductivityRequestAuthority(string root) string.Join(", ", unityAcceptanceHits)); } - if (actionGameManager.Contains("public bool RequestHullConductivityToggle(", StringComparison.Ordinal)) + if (inventoryPanel.Contains("public bool RequestHullConductivityToggle(", StringComparison.Ordinal)) { throw new InvalidOperationException( "Hull conductivity request API still exposes submission as public acceptance state."); @@ -10701,36 +13195,30 @@ static void RequireHullConductivityRequestAuthority(string root) static void RequireInventoryEntityRenameRequestAuthority(string root) { - var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); - var actionGameManager = File.Exists(actionGameManagerPath) - ? File.ReadAllText(actionGameManagerPath) - : throw new InvalidOperationException("Cannot verify inventory entity rename authority; ActionGameManager.cs is missing."); - - if (!actionGameManager.Contains("RequestEntityName", StringComparison.Ordinal) || - !actionGameManager.Contains("TryRequestDaemonEntityName", StringComparison.Ordinal) || - !actionGameManager.Contains("Operations.SetEntityName", StringComparison.Ordinal)) - { - throw new InvalidOperationException( - "Entity rename no longer has a typed daemon request primitive."); - } - var inventoryPanelPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryPanel.cs"); var inventoryPanel = File.Exists(inventoryPanelPath) ? File.ReadAllText(inventoryPanelPath) : throw new InvalidOperationException("Cannot verify inventory entity rename authority; InventoryPanel.cs is missing."); + if (!inventoryPanel.Contains("RequestEntityName", StringComparison.Ordinal) || + !inventoryPanel.Contains("operations => operations.SetEntityName", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Entity rename no longer has a typed daemon request primitive."); + } + if (inventoryPanel.Contains("_displayedEntity.Name =", StringComparison.Ordinal)) { throw new InvalidOperationException( "InventoryPanel still renames entities directly instead of using the typed daemon request primitive."); } - if (!inventoryPanel.Contains("GameManager.RequestEntityName", StringComparison.Ordinal)) + if (inventoryPanel.Contains("GameManager.RequestEntityName", StringComparison.Ordinal)) { - throw new InvalidOperationException("InventoryPanel no longer routes entity rename through ActionGameManager."); + throw new InvalidOperationException("InventoryPanel still routes entity rename through ActionGameManager instead of its typed operation client."); } - var renameRequest = inventoryPanel.IndexOf("GameManager.RequestEntityName", StringComparison.Ordinal); + var renameRequest = inventoryPanel.IndexOf("RequestEntityName(_displayedEntity", StringComparison.Ordinal); var titleRefreshAfterRename = inventoryPanel.IndexOf("Title.text = _displayedEntity.Name", renameRequest, StringComparison.Ordinal); if (renameRequest >= 0 && titleRefreshAfterRename >= 0) { @@ -10741,20 +13229,19 @@ static void RequireInventoryEntityRenameRequestAuthority(string root) static void RequireWeaponGroupRequestAuthority(string root) { - var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); - var actionGameManager = File.Exists(actionGameManagerPath) - ? File.ReadAllText(actionGameManagerPath) - : throw new InvalidOperationException("Cannot verify weapon group authority; ActionGameManager.cs is missing."); + var inventoryMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryMenu.cs"); + var inventoryMenu = File.Exists(inventoryMenuPath) + ? File.ReadAllText(inventoryMenuPath) + : throw new InvalidOperationException("Cannot verify weapon group authority; InventoryMenu.cs is missing."); - if (!actionGameManager.Contains("RequestWeaponGroupMembership", StringComparison.Ordinal) || - !actionGameManager.Contains("TryRequestDaemonWeaponGroupMembership", StringComparison.Ordinal) || - !actionGameManager.Contains("Operations.SetWeaponGroupMembership", StringComparison.Ordinal)) + if (!inventoryMenu.Contains("RequestWeaponGroupMembership", StringComparison.Ordinal) || + !inventoryMenu.Contains("operations => operations.SetWeaponGroupMembership", StringComparison.Ordinal)) { throw new InvalidOperationException( "Weapon group membership no longer has a typed daemon request primitive."); } - if (actionGameManager.Contains("WeaponGroupDragObject", StringComparison.Ordinal)) + if (inventoryMenu.Contains("WeaponGroupDragObject", StringComparison.Ordinal)) { throw new InvalidOperationException( "Weapon-group action-bar binding still keeps the dead drag-object path alive instead of routing through live gameplay APIs."); @@ -10763,12 +13250,10 @@ static void RequireWeaponGroupRequestAuthority(string root) var forbiddenLocalAcceptanceSymbols = new[] { "item?.Entity?.WeaponGroups == null", - "groupIndex >= item.Entity.WeaponGroups.Length", - "groupIndex < 0", - "item.GetBehavior()" + "groupIndex >= item.Entity.WeaponGroups.Length" }; var localAcceptanceHits = forbiddenLocalAcceptanceSymbols - .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => inventoryMenu.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (localAcceptanceHits.Length > 0) { @@ -10782,7 +13267,7 @@ static void RequireWeaponGroupRequestAuthority(string root) "public bool RequestWeaponGroupMembership(" }; var publicAcceptanceApiHits = forbiddenPublicAcceptanceApis - .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => inventoryMenu.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (publicAcceptanceApiHits.Length > 0) { @@ -10791,10 +13276,6 @@ static void RequireWeaponGroupRequestAuthority(string root) string.Join(", ", publicAcceptanceApiHits)); } - var inventoryMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryMenu.cs"); - var inventoryMenu = File.Exists(inventoryMenuPath) - ? File.ReadAllText(inventoryMenuPath) - : throw new InvalidOperationException("Cannot verify weapon group authority; InventoryMenu.cs is missing."); var forbiddenUiSubmissionAcceptanceSymbols = new[] { "if (GameManager.RequestWeaponGroupMembership(", @@ -10839,29 +13320,116 @@ static void RequireActionBarBindingRequestAuthority(string root) var actionGameManager = File.Exists(actionGameManagerPath) ? File.ReadAllText(actionGameManagerPath) : throw new InvalidOperationException("Cannot verify action-bar binding authority; ActionGameManager.cs is missing."); + var actionBarPresentationPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityActionBarPresentation.cs"); + var actionBarPresentation = File.Exists(actionBarPresentationPath) + ? File.ReadAllText(actionBarPresentationPath) + : throw new InvalidOperationException("Cannot verify action-bar binding authority; AetheriaUnityActionBarPresentation.cs is missing."); + var gameplayInputShellPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplayInputShell.cs"); + var gameplayInputShell = File.Exists(gameplayInputShellPath) + ? File.ReadAllText(gameplayInputShellPath) + : throw new InvalidOperationException("Cannot verify action-bar binding authority; AetheriaUnityGameplayInputShell.cs is missing."); + var gameplaySceneWiringPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplaySceneWiring.cs"); + var gameplaySceneWiring = File.Exists(gameplaySceneWiringPath) + ? File.ReadAllText(gameplaySceneWiringPath) + : throw new InvalidOperationException("Cannot verify action-bar binding authority; AetheriaUnityGameplaySceneWiring.cs is missing."); - var requiredSymbols = new[] + var requiredManagerSymbols = new[] { - "RequestActionBarBinding(", - "TryRequestDaemonActionBarBinding(", - "TryRequestDaemonActionBarBindingClear(", - "RequestActionBarConsumable(", - "RequestActionBarBehavior(", - "RequestActionBarWeaponGroup(", - "RestoreActionBarBindingsFromTypedRun(", - "ApplyActionBarBindings(", - "RequestActionBarBinding(slot, dragAction)" + "ActionBarPresentation = _actionBarPresentation", + "SceneWiring.ConfigureActionBarPresentation(", + "private AetheriaUnityActionBarBindingAdapter ActionBarBindings =>", + "ApplyActionBarBindings = ActionBarBindings.ApplyBindings" }; - var missingSymbols = requiredSymbols + var missingManagerSymbols = requiredManagerSymbols .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) .ToArray(); - if (missingSymbols.Length > 0) + if (missingManagerSymbols.Length > 0) { throw new InvalidOperationException( - "Action-bar binding no longer has a typed daemon request and observed restore path: " + - string.Join(", ", missingSymbols)); + "ActionGameManager no longer delegates action-bar binding request/restore through the action-bar presentation owner: " + + string.Join(", ", missingManagerSymbols)); + } + + var requiredSceneWiringSymbols = new[] + { + "public void ConfigureActionBarPresentation(", + "gameplayInputShell?.ActionBarSlots ?? Array.Empty()", + "actionBarPresentation?.Bind(", + "resolveActionBarClient);" + }; + var missingSceneWiringSymbols = requiredSceneWiringSymbols + .Where(symbol => !gameplaySceneWiring.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingSceneWiringSymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity scene wiring no longer injects action-bar slots into the action-bar presentation owner: " + + string.Join(", ", missingSceneWiringSymbols)); + } + + var requiredGameplayInputShellSymbols = new[] + { + "private void RegisterActionBarInput()", + "private void CreateActionBarSlot(string controlPath)", + "var action = new InputAction(binding: controlPath);", + "_actionBarActions.Add(action);", + "_actionBarSlots.Add(slot);", + "DragSession.RegisterTarget(dragAction => ActionBarPresentation.RequestBinding(slot, dragAction));", + "slot.PointerExitTrigger.OnPointerExitAsObservable().Subscribe(_ => DragSession.UnregisterTarget());" + }; + var missingGameplayInputShellSymbols = requiredGameplayInputShellSymbols + .Where(symbol => !gameplayInputShell.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingGameplayInputShellSymbols.Length > 0) + { + throw new InvalidOperationException( + "Unity gameplay input shell no longer owns local action-bar control binding and drag target wiring: " + + string.Join(", ", missingGameplayInputShellSymbols)); + } + + var requiredPresentationSymbols = new[] + { + "public void RequestBinding(ActionBarSlot slot, DragObject dragAction)", + "public bool RequestWeaponGroupBinding(int slotIndex, int groupIndex)", + "public bool ClearBinding(int slotIndex)", + "public void ApplyLocalBindings()", + "public void ApplyBindings(", + "private AetheriaUnityActionBarBinding CreateBindingCommit(", + "private ActionBarBinding CreateBinding(", + "private void SetLocalBinding(AetheriaUnityActionBarBinding binding)", + "_localBindings = (_localBindings ?? Array.Empty())", + "SetLocalBinding(binding);", + "ApplyLocalBindings();" + }; + + var missingPresentationSymbols = requiredPresentationSymbols + .Where(symbol => !actionBarPresentation.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingPresentationSymbols.Length > 0) + { + throw new InvalidOperationException( + "Action-bar presentation no longer owns typed daemon drag/drop binding requests and observed restore lowering: " + + string.Join(", ", missingPresentationSymbols)); + } + + var forbiddenPresentationSymbols = new[] + { + "_resolveClient()?.Operations.SetActionBarBinding(", + "_resolveClient()?.Operations.ClearActionBarBinding(", + "Failed to send Aetheria daemon action-bar binding operation", + "Failed to send Aetheria daemon action-bar clear operation" + }; + var presentationHits = forbiddenPresentationSymbols + .Where(symbol => actionBarPresentation.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (presentationHits.Length > 0) + { + throw new InvalidOperationException( + "Action-bar presentation still treats local input bindings as daemon gameplay operations: " + + string.Join(", ", presentationHits)); } var forbiddenSymbols = new[] @@ -10873,7 +13441,11 @@ static void RequireActionBarBindingRequestAuthority(string root) "ProjectActionBarBindings", "ProjectActionBarBinding(slot, slot?.Binding)", "Enumerable.Range(0, CurrentEntity.WeaponGroups.Length)", - "slot == null || groupIndex < 0" + "slot == null || groupIndex < 0", + "RequestActionBarBinding(", + "CreateActionBarBinding(", + "CreateActionBarBindingCommit(", + "ResolveActionBarClient()?.Operations.SetActionBarBinding(" }; var legacyHits = forbiddenSymbols @@ -10891,6 +13463,8 @@ static void RequireActionBarBindingRequestAuthority(string root) { "public bool RequestWeaponGroupActionBarBinding(", "public bool RequestClearActionBarBinding(", + "TryRequestDaemonActionBarBinding(", + "TryRequestDaemonActionBarBindingClear(", "public bool TryRequestDaemonActionBarConsumable(", "public bool TryRequestDaemonActionBarBehavior(", "public bool TryRequestDaemonActionBarWeaponGroup(" @@ -10909,6 +13483,40 @@ static void RequireActionBarBindingRequestAuthority(string root) var inventoryMenu = File.Exists(inventoryMenuPath) ? File.ReadAllText(inventoryMenuPath) : throw new InvalidOperationException("Cannot verify action-bar binding authority; InventoryMenu.cs is missing."); + var requiredInventoryActionBarSymbols = new[] + { + "SetActionBarPresentation(AetheriaUnityActionBarPresentation actionBarPresentation)" + }; + var missingInventoryActionBarSymbols = requiredInventoryActionBarSymbols + .Where(symbol => !inventoryMenu.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingInventoryActionBarSymbols.Length > 0) + { + throw new InvalidOperationException( + "InventoryMenu no longer owns typed equipped-item action-bar binding operations: " + + string.Join(", ", missingInventoryActionBarSymbols)); + } + var forbiddenInventoryActionBarSymbols = new[] + { + "ProjectEquippedItemActionBarSlots(", + "RequestWeaponGroupActionBarBinding(", + "RequestClearActionBarBinding(", + "_actionBarPresentation?.RequestWeaponGroupBinding(slotIndex, groupIndex)", + "_actionBarPresentation?.ClearBinding(slotIndex)", + "operations => operations.SetActionBarBinding(", + "operations => operations.ClearActionBarBinding(controlPath)", + "SetActionBarBinding(", + "ClearActionBarBinding(controlPath)" + }; + var inventoryActionBarHits = forbiddenInventoryActionBarSymbols + .Where(symbol => inventoryMenu.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (inventoryActionBarHits.Length > 0) + { + throw new InvalidOperationException( + "InventoryMenu still sends local action-bar binding edits to the daemon: " + + string.Join(", ", inventoryActionBarHits)); + } var forbiddenUiAcceptanceSymbols = new[] { "GameManager.RequestWeaponGroupActionBarBinding(command.SlotIndex, command.GroupIndex))", @@ -10945,14 +13553,33 @@ static void RequireActionBarBindingRequestAuthority(string root) "ActionBarSlot still calls daemon transport helpers directly instead of submit-only request APIs: " + string.Join(", ", slotHits)); } + + if (actionBarSlot.Contains("ActionGameManager.Instance", StringComparison.Ordinal) || + !actionBarSlot.Contains("protected GameSettings Settings { get; }", StringComparison.Ordinal) || + !actionBarPresentation.Contains("new ActionBarGearBinding(entity, slot, client, _settings,", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ActionBarSlot must receive icon presentation settings explicitly instead of reaching through ActionGameManager.Instance."); + } } static void RequireInventoryDoubleClickTransferRequestAuthority(string root) { + var inventoryPanelPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryPanel.cs"); + var inventoryPanel = File.Exists(inventoryPanelPath) + ? File.ReadAllText(inventoryPanelPath) + : throw new InvalidOperationException("Cannot verify inventory transfer authority; InventoryPanel.cs is missing."); + + var inventoryMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryMenu.cs"); + var inventoryMenu = File.Exists(inventoryMenuPath) + ? File.ReadAllText(inventoryMenuPath) + : throw new InvalidOperationException("Cannot verify inventory transfer authority; InventoryMenu.cs is missing."); + var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); - var actionGameManager = File.Exists(actionGameManagerPath) - ? File.ReadAllText(actionGameManagerPath) - : throw new InvalidOperationException("Cannot verify inventory transfer authority; ActionGameManager.cs is missing."); + if (!File.Exists(actionGameManagerPath)) + { + throw new InvalidOperationException("Cannot verify inventory transfer authority; ActionGameManager.cs is missing."); + } var requiredRequests = new[] { @@ -10960,14 +13587,14 @@ static void RequireInventoryDoubleClickTransferRequestAuthority(string root) "RequestCargoItemEquip", "RequestEquippedItemStore", "RequestEquippedItemEquip", - "TryRequestDaemonCargoItemTransfer", - "TryRequestDaemonCargoItemEquip", - "TryRequestDaemonEquippedItemStore", - "TryRequestDaemonEquippedItemEquip" + "operations => operations.TransferCargoItem(", + "operations => operations.EquipItem(", + "operations => operations.StoreItem(" }; + var inventoryTransferSources = inventoryPanel + "\n" + inventoryMenu; var missingRequests = requiredRequests - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => !inventoryTransferSources.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (missingRequests.Length > 0) @@ -10988,7 +13615,7 @@ static void RequireInventoryDoubleClickTransferRequestAuthority(string root) "!origin.Cargo.TryGetValue(item, out var sourcePosition)" }; var unityInventoryAcceptanceHits = forbiddenUnityInventoryAcceptanceSymbols - .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => inventoryTransferSources.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (unityInventoryAcceptanceHits.Length > 0) { @@ -10997,16 +13624,6 @@ static void RequireInventoryDoubleClickTransferRequestAuthority(string root) string.Join(", ", unityInventoryAcceptanceHits)); } - var inventoryPanelPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryPanel.cs"); - var inventoryPanel = File.Exists(inventoryPanelPath) - ? File.ReadAllText(inventoryPanelPath) - : throw new InvalidOperationException("Cannot verify inventory transfer authority; InventoryPanel.cs is missing."); - - var inventoryMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryMenu.cs"); - var inventoryMenu = File.Exists(inventoryMenuPath) - ? File.ReadAllText(inventoryMenuPath) - : throw new InvalidOperationException("Cannot verify inventory transfer authority; InventoryMenu.cs is missing."); - var forbiddenSymbols = new[] { ".DropItem(", @@ -11071,6 +13688,66 @@ static void RequireInventoryDoubleClickTransferRequestAuthority(string root) string.Join("; ", inventorySubmissionAcceptanceHits)); } + var requiredTypedSlotValidationSymbols = new[] + { + "TryResolveTypedInventoryRows(", + "TryValidateTypedCargoSlot(", + "TryValidateTypedEquipmentSlot(", + "InventoryAsync(entityIndex)", + "origin.Cargo.TryGetValue(item, out var originPosition)", + "SourceIndex == cargoIndex", + "SourceIndex == equipmentIndex", + "row.X == originPosition.x", + "row.Y == originPosition.y", + "row.X == item.Position.x", + "row.Y == item.Position.y" + }; + var missingTypedSlotValidationSymbols = requiredTypedSlotValidationSymbols + .Where(symbol => + !inventoryMenu.Contains(symbol, StringComparison.Ordinal) || + !inventoryPanel.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingTypedSlotValidationSymbols.Length > 0) + { + throw new InvalidOperationException( + "Inventory cargo/equipment submissions must validate Unity facade adapters against typed inventory projection slot identity: " + + string.Join(", ", missingTypedSlotValidationSymbols)); + } + + var forbiddenUnknownOriginSymbols = new[] { "int.MinValue" }; + var unknownOriginHits = new[] { inventoryMenuPath, inventoryPanelPath } + .SelectMany(path => File.ReadLines(path) + .Select((line, index) => new { Path = path, LineNumber = index + 1, Line = line })) + .Where(line => forbiddenUnknownOriginSymbols.Any(symbol => line.Line.Contains(symbol, StringComparison.Ordinal))) + .Select(line => $"{Path.GetRelativePath(root, line.Path)}:{line.LineNumber}: {line.Line.Trim()}") + .ToArray(); + if (unknownOriginHits.Length > 0) + { + throw new InvalidOperationException( + "Inventory cargo/equipment submissions still send unknown origin coordinates instead of typed slot positions: " + + string.Join("; ", unknownOriginHits)); + } + + var forbiddenManagerCommandTargetSymbols = new[] + { + "TryResolveObservedCargoBayCommandTarget", + "TryResolveObservedEquippedItemCommandTarget", + "TryResolveCargoBayCommandTarget", + "TryResolveEquippedItemCommandTarget" + }; + var managerCommandTargetHits = new[] { actionGameManagerPath, inventoryMenuPath, inventoryPanelPath } + .SelectMany(path => File.ReadLines(path) + .Select((line, index) => new { Path = path, LineNumber = index + 1, Line = line })) + .Where(line => forbiddenManagerCommandTargetSymbols.Any(symbol => line.Line.Contains(symbol, StringComparison.Ordinal))) + .Select(line => $"{Path.GetRelativePath(root, line.Path)}:{line.LineNumber}: {line.Line.Trim()}") + .ToArray(); + if (managerCommandTargetHits.Length > 0) + { + throw new InvalidOperationException( + "Inventory cargo/equipment submissions must not depend on ActionGameManager command-target adapters: " + + string.Join("; ", managerCommandTargetHits)); + } + var forbiddenPublicAcceptanceApis = new[] { "public bool RequestCargoItemTransfer(", @@ -11085,7 +13762,7 @@ static void RequireInventoryDoubleClickTransferRequestAuthority(string root) "Func", "RegisterDragTarget(Func" }; - var publicAcceptanceApiHits = new[] { actionGameManagerPath, inventoryMenuPath, inventoryPanelPath } + var publicAcceptanceApiHits = new[] { inventoryMenuPath, inventoryPanelPath } .SelectMany(path => File.ReadLines(path) .Select((line, index) => new { Path = path, LineNumber = index + 1, Line = line })) .Where(line => forbiddenPublicAcceptanceApis.Any(symbol => line.Line.Contains(symbol, StringComparison.Ordinal))) @@ -11100,10 +13777,10 @@ static void RequireInventoryDoubleClickTransferRequestAuthority(string root) var requiredUiCalls = new[] { - "GameManager.RequestCargoItemTransfer", - "GameManager.RequestCargoItemEquip", - "GameManager.RequestEquippedItemStore", - "GameManager.RequestEquippedItemEquip" + "RequestCargoItemTransfer", + "RequestCargoItemEquip", + "RequestEquippedItemStore", + "RequestEquippedItemEquip" }; var missingUiCalls = requiredUiCalls @@ -11122,20 +13799,19 @@ static void RequireInventoryDoubleClickTransferRequestAuthority(string root) static void RequireTradePurchaseRequestAuthority(string root) { - var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); - var actionGameManager = File.Exists(actionGameManagerPath) - ? File.ReadAllText(actionGameManagerPath) - : throw new InvalidOperationException("Cannot verify trade purchase authority; ActionGameManager.cs is missing."); + var tradeMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "TradeMenu.cs"); + var tradeMenu = File.Exists(tradeMenuPath) + ? File.ReadAllText(tradeMenuPath) + : throw new InvalidOperationException("Cannot verify trade purchase authority; TradeMenu.cs is missing."); var requiredRequests = new[] { "RequestTradePurchase", - "TryRequestDaemonTradePurchase", - "Operations.TradePurchase" + "operations => operations.TradePurchase(" }; var missingRequests = requiredRequests - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => !tradeMenu.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (missingRequests.Length > 0) @@ -11152,7 +13828,7 @@ static void RequireTradePurchaseRequestAuthority(string root) "targetCargo.TryFindSpace(" }; var unityTradeAcceptanceHits = forbiddenUnityTradeAcceptanceSymbols - .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => tradeMenu.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (unityTradeAcceptanceHits.Length > 0) { @@ -11182,11 +13858,6 @@ static void RequireTradePurchaseRequestAuthority(string root) string.Join(", ", missingDaemonShipPurchaseSymbols)); } - var tradeMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "TradeMenu.cs"); - var tradeMenu = File.Exists(tradeMenuPath) - ? File.ReadAllText(tradeMenuPath) - : throw new InvalidOperationException("Cannot verify trade purchase authority; TradeMenu.cs is missing."); - var forbiddenSymbols = new[] { "GameManager.Credits -=", @@ -11233,9 +13904,9 @@ static void RequireTradePurchaseRequestAuthority(string root) string.Join("; ", localAcceptanceHits)); } - if (!tradeMenu.Contains("GameManager.RequestTradePurchase", StringComparison.Ordinal)) + if (tradeMenu.Contains("GameManager.RequestTradePurchase", StringComparison.Ordinal)) { - throw new InvalidOperationException("TradeMenu no longer routes purchases through ActionGameManager."); + throw new InvalidOperationException("TradeMenu still routes purchases through ActionGameManager instead of its typed operation client."); } var forbiddenSubmissionAcceptanceSymbols = new[] @@ -11244,7 +13915,7 @@ static void RequireTradePurchaseRequestAuthority(string root) "\"Purchase request rejected!\"", "public bool RequestTradePurchase(" }; - var submissionAcceptanceHits = new[] { tradeMenuPath, actionGameManagerPath } + var submissionAcceptanceHits = new[] { tradeMenuPath } .SelectMany(path => File.ReadLines(path) .Select((line, index) => new { Path = path, LineNumber = index + 1, Line = line })) .Where(line => forbiddenSubmissionAcceptanceSymbols.Any(symbol => line.Line.Contains(symbol, StringComparison.Ordinal))) @@ -11258,7 +13929,7 @@ static void RequireTradePurchaseRequestAuthority(string root) string.Join("; ", submissionAcceptanceHits)); } - var firstTradeRequest = tradeMenu.IndexOf("GameManager.RequestTradePurchase", StringComparison.Ordinal); + var firstTradeRequest = tradeMenu.IndexOf("RequestTradePurchase(", StringComparison.Ordinal); var firstCreditRefreshAfterRequest = tradeMenu.IndexOf("UpdateCreditsLabel();", firstTradeRequest, StringComparison.Ordinal); if (firstTradeRequest >= 0 && firstCreditRefreshAfterRequest >= 0) { @@ -11383,11 +14054,34 @@ static void RequireDroppedPickupCheckpointState(string root) }, [Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs")] = new[] { - "RestoreDroppedPickupsFromDaemonZoneState", - "ZoneRenderer.DropItem(" + "private AetheriaUnityLoadoutItemProjector _loadoutItemProjector", + "_loadoutItemProjector = boot.LoadoutItemProjector" + }, + [Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplayBootShell.cs")] = new[] + { + "public sealed class AetheriaUnityGameplayBootShell", + "ZoneRenderer.SetDroppedPickupItemProjector(loadoutItemProjector.CreateLoadoutItem)" + }, + [Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityObservedFrameApplier.cs")] = new[] + { + "public sealed class AetheriaUnityObservedFrameApplier", + "zoneRenderer?.RestoreDroppedPickupsFromZoneRender(render)" + }, + [Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityLoadoutItemProjector.cs")] = new[] + { + "public sealed class AetheriaUnityLoadoutItemProjector", + "public ItemInstance CreateLoadoutItem(AetheriaRuntimeLoadoutItemCommit item)", + "_itemManager.CreateSimpleCommodityInstance", + "_itemManager.CreateCraftedInstance" }, [Path.Combine(root, "Assets", "Scripts", "Zone Display", "ZoneRenderer.cs")] = new[] { + "public void SetDroppedPickupItemProjector(Func createDroppedPickupItem)", + "public void RestoreDroppedPickupsFromZoneRender(AetheriaRuntimeZoneRenderDocument render)", + "_createDroppedPickupItem?.Invoke(pickup.Item)", + "AetheriaRuntimeDroppedPickupCommit", + "DroppedPickups", + "DropItem(", "ActiveLoot" } }; @@ -11411,6 +14105,26 @@ static void RequireDroppedPickupCheckpointState(string root) "Dropped pickup checkpoint state is incomplete: " + string.Join("; ", missing)); } + + var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); + var actionGameManager = File.Exists(actionGameManagerPath) + ? File.ReadAllText(actionGameManagerPath) + : throw new InvalidOperationException("Cannot verify dropped pickup state; ActionGameManager.cs is missing."); + var forbiddenManagerSymbols = new[] + { + "ZoneRenderer.DropItem(", + "AetheriaRuntimeDroppedPickupCommit", + "ClearRenderedLoot(" + }; + var managerHits = forbiddenManagerSymbols + .Where(symbol => actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (managerHits.Length > 0) + { + throw new InvalidOperationException( + "ActionGameManager still lowers daemon dropped pickups into Unity loot instead of delegating renderer presentation: " + + string.Join(", ", managerHits)); + } } static void RequireInventoryLoadoutSaveRequestAuthority(string root) @@ -11434,8 +14148,12 @@ static void RequireInventoryLoadoutSaveRequestAuthority(string root) var eveBridgePath = Path.Combine(root, "Aetheria.State", "AetheriaEveCommandBridge.cs"); var runtimeStateMapperPath = Path.Combine(root, "Aetheria.State", "AetheriaRuntimeStateMapper.cs"); var inventoryPanelPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryPanel.cs"); + var loadoutProjectorPath = Path.Combine(root, "Assets", "Scripts", "ServerShared", "AetheriaRuntimeLoadoutProjector.cs"); + var dragSessionPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityDragSession.cs"); + var dragObjectsPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityDragObjects.cs"); + var gameplaySceneWiringPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityGameplaySceneWiring.cs"); - var requiredFiles = new[] { eveCommandDocumentPath, loadoutCommandsPath, eveBridgePath, runtimeStateMapperPath, inventoryPanelPath }; + var requiredFiles = new[] { eveCommandDocumentPath, loadoutCommandsPath, eveBridgePath, runtimeStateMapperPath, inventoryPanelPath, loadoutProjectorPath, dragSessionPath, gameplaySceneWiringPath }; var missingFiles = requiredFiles.Where(path => !File.Exists(path)).ToArray(); if (missingFiles.Length > 0) { @@ -11449,26 +14167,92 @@ static void RequireInventoryLoadoutSaveRequestAuthority(string root) var eveBridge = File.ReadAllText(eveBridgePath); var runtimeStateMapper = File.ReadAllText(runtimeStateMapperPath); var inventoryPanel = File.ReadAllText(inventoryPanelPath); + var loadoutProjector = File.ReadAllText(loadoutProjectorPath); + var dragSession = File.ReadAllText(dragSessionPath); + var gameplaySceneWiring = File.ReadAllText(gameplaySceneWiringPath); + var dragObjects = File.Exists(dragObjectsPath) + ? File.ReadAllText(dragObjectsPath) + : throw new InvalidOperationException("Cannot verify inventory drag/drop authority; AetheriaUnityDragObjects.cs is missing."); - var requiredActionSymbols = new[] + var requiredProjectorSymbols = new[] { - "RequestLoadoutTemplateSave(Entity entity)", + "public static class AetheriaRuntimeLoadoutProjector", "ProjectLoadoutTemplate(Entity entity)", - "ProjectEntityLoadout(Entity entity)", "ProjectSlots(IEnumerable slots)", - "ProjectCargoBays(IEnumerable bays)", - "SendRuntimeLoadoutTemplateCommand(loadout", - "private static void SendRuntimeLoadoutTemplateCommand(", - "SubmitLoadoutTemplateCommandAsync(loadout, clientId)", }; - var missingActionSymbols = requiredActionSymbols - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + var missingProjectorSymbols = requiredProjectorSymbols + .Where(symbol => !loadoutProjector.Contains(symbol, StringComparison.Ordinal)) .ToArray(); - if (missingActionSymbols.Length > 0) + if (missingProjectorSymbols.Length > 0) { throw new InvalidOperationException( - "Loadout save no longer sends a typed Eve loadout-template command: " + - string.Join(", ", missingActionSymbols)); + "The typed loadout-template projection used by loadout save is missing from the shared projector: " + + string.Join(", ", missingProjectorSymbols)); + } + + if (actionGameManager.Contains("ProjectLoadoutTemplate(Entity entity)", StringComparison.Ordinal) || + inventoryPanel.Contains("ActionGameManager.ProjectLoadoutTemplate", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Loadout-template projection must not live on ActionGameManager; callers should use AetheriaRuntimeLoadoutProjector."); + } + + if (actionGameManager.Contains("public DragObject DragObject", StringComparison.Ordinal) || + actionGameManager.Contains("public bool HasDragTarget", StringComparison.Ordinal) || + actionGameManager.Contains("_dragObject", StringComparison.Ordinal) || + actionGameManager.Contains("_endDragCallback", StringComparison.Ordinal) || + actionGameManager.Contains("public abstract class DragObject", StringComparison.Ordinal) || + actionGameManager.Contains("public abstract class ItemDragObject", StringComparison.Ordinal) || + actionGameManager.Contains("public class ItemInstanceDragObject", StringComparison.Ordinal) || + actionGameManager.Contains("public class EquippedItemDragObject", StringComparison.Ordinal) || + inventoryPanel.Contains("GameManager.DragObject", StringComparison.Ordinal) || + inventoryPanel.Contains("GameManager.HasDragTarget", StringComparison.Ordinal) || + inventoryPanel.Contains("GameManager.BeginDrag", StringComparison.Ordinal) || + inventoryPanel.Contains("GameManager.EndDrag", StringComparison.Ordinal) || + inventoryPanel.Contains("GameManager.TryGetDraggedItem", StringComparison.Ordinal) || + inventoryPanel.Contains("GameManager.RegisterDragTarget", StringComparison.Ordinal) || + inventoryPanel.Contains("GameManager.UnregisterDragTarget", StringComparison.Ordinal) || + actionGameManager.Contains("public void BeginDrag(", StringComparison.Ordinal) || + actionGameManager.Contains("public bool TryGetDraggedItem(", StringComparison.Ordinal) || + actionGameManager.Contains("public void RegisterDragTarget(", StringComparison.Ordinal) || + actionGameManager.Contains("public void UnregisterDragTarget(", StringComparison.Ordinal) || + actionGameManager.Contains("public bool EndDrag(", StringComparison.Ordinal) || + !actionGameManager.Contains("private readonly AetheriaUnityDragSession _dragSession", StringComparison.Ordinal) || + !actionGameManager.Contains("SceneWiring.ConfigureInventoryDragSession(_dragSession)", StringComparison.Ordinal) || + !gameplaySceneWiring.Contains("public void ConfigureInventoryDragSession(AetheriaUnityDragSession dragSession)", StringComparison.Ordinal) || + !gameplaySceneWiring.Contains("Inventory?.SetDragSession(dragSession)", StringComparison.Ordinal) || + !gameplaySceneWiring.Contains("ShipPanel?.SetDragSession(dragSession)", StringComparison.Ordinal) || + !gameplaySceneWiring.Contains("TargetShipPanel?.SetDragSession(dragSession)", StringComparison.Ordinal) || + !dragSession.Contains("public sealed class AetheriaUnityDragSession", StringComparison.Ordinal) || + !dragObjects.Contains("public abstract class DragObject", StringComparison.Ordinal) || + !dragObjects.Contains("public abstract class ItemDragObject : DragObject", StringComparison.Ordinal) || + !dragObjects.Contains("public class ItemInstanceDragObject : ItemDragObject", StringComparison.Ordinal) || + !dragObjects.Contains("public class EquippedItemDragObject : ItemDragObject", StringComparison.Ordinal) || + !dragSession.Contains("private DragObject _dragObject", StringComparison.Ordinal) || + !dragSession.Contains("private Action _endDragCallback", StringComparison.Ordinal) || + !inventoryPanel.Contains("public void SetDragSession(AetheriaUnityDragSession dragSession)", StringComparison.Ordinal) || + !inventoryPanel.Contains("private AetheriaUnityDragSession DragSession", StringComparison.Ordinal) || + !inventoryPanel.Contains("DragSession.TryGetDraggedItem(out var itemDragObject)", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Inventory drag/drop must not use ActionGameManager as a drag facade; inject the shared AetheriaUnityDragSession into inventory UI."); + } + + var requiredInventoryPanelSymbols = new[] + { + "RequestLoadoutTemplateSave(Entity entity)", + "TryResolveEntityRecordKey(entity, out var targetEntityKey)", + ".LoadoutTemplateAsync(targetEntityKey)", + ".Ui.SaveLoadoutTemplateAsync(loadout, \"unity-inventory\")" + }; + var missingInventoryPanelSymbols = requiredInventoryPanelSymbols + .Where(symbol => !inventoryPanel.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingInventoryPanelSymbols.Length > 0) + { + throw new InvalidOperationException( + "InventoryPanel no longer sends a typed Eve loadout-template command: " + + string.Join(", ", missingInventoryPanelSymbols)); } var forbiddenActionSymbols = new[] @@ -11488,7 +14272,8 @@ static void RequireInventoryLoadoutSaveRequestAuthority(string root) string.Join(", ", forbiddenActionHits)); } - if (!inventoryPanel.Contains("GameManager.RequestLoadoutTemplateSave(_displayedEntity)", StringComparison.Ordinal) || + if (!inventoryPanel.Contains("RequestLoadoutTemplateSave(_displayedEntity)", StringComparison.Ordinal) || + inventoryPanel.Contains("GameManager.RequestLoadoutTemplateSave(_displayedEntity)", StringComparison.Ordinal) || inventoryPanel.Contains("EntityConstructionBlueprintProjector.CaptureBlueprint(_displayedEntity)", StringComparison.Ordinal)) { throw new InvalidOperationException( @@ -11530,20 +14315,42 @@ static void RequireInventoryLoadoutRestoreRequestAuthority(string root) var actionGameManager = File.Exists(actionGameManagerPath) ? File.ReadAllText(actionGameManagerPath) : throw new InvalidOperationException("Cannot verify loadout restore authority; ActionGameManager.cs is missing."); + var inventoryPanelPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryPanel.cs"); + var inventoryPanel = File.Exists(inventoryPanelPath) + ? File.ReadAllText(inventoryPanelPath) + : throw new InvalidOperationException("Cannot verify loadout restore authority; InventoryPanel.cs is missing."); var tradeQueriesPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonTradeItemQueries.cs"); var tradeQueries = File.Exists(tradeQueriesPath) ? File.ReadAllText(tradeQueriesPath) : throw new InvalidOperationException("Cannot verify loadout restore authority; runtime trade item queries are missing."); + var rtsDocumentsPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeRtsViewportDocuments.cs"); + var rtsDocuments = File.Exists(rtsDocumentsPath) + ? File.ReadAllText(rtsDocumentsPath) + : throw new InvalidOperationException("Cannot verify loadout restore authority; RTS viewport documents are missing."); + var rtsProjectionPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeRtsProjection.cs"); + var rtsProjection = File.Exists(rtsProjectionPath) + ? File.ReadAllText(rtsProjectionPath) + : throw new InvalidOperationException("Cannot verify loadout restore authority; RTS projection source is missing."); + var clientPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaClient.cs"); + var client = File.Exists(clientPath) + ? File.ReadAllText(clientPath) + : throw new InvalidOperationException("Cannot verify loadout restore authority; AetheriaClient.cs is missing."); var requiredSymbols = new[] { "RequestRuntimeLoadoutRestore", - "RequestDaemonLoadoutRestore", - "Operations.RestoreLoadout" + "operations => operations.RestoreLoadout", + "AetheriaRuntimeStationLoadoutRestoreOption", + "_dropdownStationRefitLoadouts", + "stationRefit?.LoadoutRestoreOptions", + "loadout.TargetEntityKey", + "loadout.TemplateName", + "loadout.Price", + "loadout.CanRestore" }; var missingSymbols = requiredSymbols - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => !inventoryPanel.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (missingSymbols.Length > 0) @@ -11553,18 +14360,22 @@ static void RequireInventoryLoadoutRestoreRequestAuthority(string root) string.Join(", ", missingSymbols)); } - if (actionGameManager.Contains("price > Credits", StringComparison.Ordinal)) + if (actionGameManager.Contains("price > Credits", StringComparison.Ordinal) || + inventoryPanel.Contains("price > Credits", StringComparison.Ordinal)) { throw new InvalidOperationException( "Unity loadout restore still rejects requests through renderer-local credits instead of daemon acceptance."); } - if (!actionGameManager.Contains("ObservedTradeValueSettings()", StringComparison.Ordinal) || - !actionGameManager.Contains("AetheriaRuntimeDaemonTradeItemQueries.TryProjectLoadoutTemplatePrice(", StringComparison.Ordinal) || - actionGameManager.Contains("blueprint.Price(ItemManager)", StringComparison.Ordinal)) + if (actionGameManager.Contains("ObservedTradeValueSettings()", StringComparison.Ordinal) || + inventoryPanel.Contains("ObservedTradeValueSettings()", StringComparison.Ordinal) || + actionGameManager.Contains("AetheriaUnityProjectionSettings.TradeValueSettings", StringComparison.Ordinal) || + inventoryPanel.Contains("AetheriaUnityProjectionSettings.TradeValueSettings", StringComparison.Ordinal) || + actionGameManager.Contains("blueprint.Price(ItemManager)", StringComparison.Ordinal) || + inventoryPanel.Contains("blueprint.Price(ItemManager)", StringComparison.Ordinal)) { throw new InvalidOperationException( - "ActionGameManager must price loadout restore requests through shared runtime loadout queries instead of blueprint/ItemManager value authority."); + "Loadout restore must price requests through catalog-owned runtime trade policy instead of Unity settings or ItemManager value authority."); } var forbiddenActionSymbols = new[] @@ -11596,11 +14407,6 @@ static void RequireInventoryLoadoutRestoreRequestAuthority(string root) "Daemon loadout restore must reject missing templates before charging credits or mutating run state."); } - var inventoryPanelPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryPanel.cs"); - var inventoryPanel = File.Exists(inventoryPanelPath) - ? File.ReadAllText(inventoryPanelPath) - : throw new InvalidOperationException("Cannot verify loadout restore authority; InventoryPanel.cs is missing."); - var forbiddenSymbols = new[] { "EntityConstructionBlueprintProjector.InstantiateFromBlueprint", @@ -11643,16 +14449,19 @@ static void RequireInventoryLoadoutRestoreRequestAuthority(string root) string.Join("; ", localAcceptanceHits)); } - if (!inventoryPanel.Contains("GameManager.RequestRuntimeLoadoutRestore", StringComparison.Ordinal)) + if (!inventoryPanel.Contains("RequestRuntimeLoadoutRestore(_dropdownStationRefitLoadouts[selection.TemplateIndex])", StringComparison.Ordinal) || + inventoryPanel.Contains("GameManager.RequestRuntimeLoadoutRestore", StringComparison.Ordinal)) { - throw new InvalidOperationException("InventoryPanel no longer routes loadout restore through ActionGameManager."); + throw new InvalidOperationException("InventoryPanel no longer owns typed loadout restore operations directly."); } - if (!inventoryPanel.Contains("AetheriaRuntimeDaemonTradeItemQueries.TryProjectLoadoutTemplatePrice(", StringComparison.Ordinal) || - inventoryPanel.Contains("blueprint.Price(GameManager.ItemManager)", StringComparison.Ordinal)) + if (inventoryPanel.Contains("AetheriaRuntimeDaemonTradeItemQueries.TryProjectLoadoutTemplatePrice(", StringComparison.Ordinal) || + inventoryPanel.Contains("blueprint.Price(GameManager.ItemManager)", StringComparison.Ordinal) || + inventoryPanel.Contains("LoadoutTemplatesAsync()", StringComparison.Ordinal) || + inventoryPanel.Contains("ResolveLoadoutTemplates", StringComparison.Ordinal)) { throw new InvalidOperationException( - "InventoryPanel must label loadout restore options through shared runtime loadout price queries instead of Unity ItemManager pricing."); + "InventoryPanel must consume StationRefitAsync loadout restore options instead of pricing or enumerating loadouts locally."); } if (!tradeQueries.Contains("public static bool TryProjectLoadoutTemplatePrice(", StringComparison.Ordinal) || @@ -11664,19 +14473,30 @@ static void RequireInventoryLoadoutRestoreRequestAuthority(string root) "Shared runtime trade item queries must own typed loadout template price projection."); } - if (!actionGameManager.Contains("public IEnumerable ObservedLoadoutTemplates()", StringComparison.Ordinal) || - actionGameManager.Contains("public List LoadoutTemplates", StringComparison.Ordinal) || - inventoryPanel.Contains("GameManager.LoadoutTemplates", StringComparison.Ordinal)) + if (!rtsDocuments.Contains("public IReadOnlyList LoadoutRestoreOptions", StringComparison.Ordinal) || + !rtsDocuments.Contains("public sealed class AetheriaRuntimeStationLoadoutRestoreOption", StringComparison.Ordinal) || + !rtsDocuments.Contains("public string TargetEntityKey", StringComparison.Ordinal) || + !rtsDocuments.Contains("public int Price", StringComparison.Ordinal) || + !rtsDocuments.Contains("public bool CanRestore", StringComparison.Ordinal) || + !rtsProjection.Contains("ProjectLoadoutRestoreOptions(", StringComparison.Ordinal) || + !rtsProjection.Contains("AetheriaRuntimeDaemonTradeItemQueries.TryProjectLoadoutTemplatePrice(", StringComparison.Ordinal) || + !rtsProjection.Contains("credits >= price", StringComparison.Ordinal) || + !client.Contains("GetLoadoutTemplatesAsync()", StringComparison.Ordinal) || + !client.Contains("ProjectStationRefit(frame, loadoutTemplates, catalog)", StringComparison.Ordinal)) { throw new InvalidOperationException( - "InventoryPanel must enumerate daemon-observed loadout templates through ActionGameManager.ObservedLoadoutTemplates()."); + "StationRefitAsync must publish typed loadout restore options with shared runtime pricing and daemon-target identity."); } + var loadoutRestoreMatch = System.Text.RegularExpressions.Regex.Match( + inventoryPanel, + @"private void RequestRuntimeLoadoutRestore\(AetheriaRuntimeStationLoadoutRestoreOption loadout\)[\s\S]*?\n \}"); if (actionGameManager.Contains("RequestRuntimeLoadoutRestore(AetheriaRuntimeLoadoutTemplateSnapshot template, out Entity", StringComparison.Ordinal) || actionGameManager.Contains("public bool RequestRuntimeLoadoutRestore(", StringComparison.Ordinal) || inventoryPanel.Contains("RequestRuntimeLoadoutRestore(template, out", StringComparison.Ordinal) || inventoryPanel.Contains("RequestRuntimeLoadoutRestore(loadoutEntry.template, out", StringComparison.Ordinal) || - inventoryPanel.Contains("Display(entity)", StringComparison.Ordinal)) + !loadoutRestoreMatch.Success || + loadoutRestoreMatch.Value.Contains("Display(", StringComparison.Ordinal)) { throw new InvalidOperationException( "Loadout restore is still pretending daemon submission synchronously yields accepted Unity state."); @@ -11685,20 +14505,19 @@ static void RequireInventoryLoadoutRestoreRequestAuthority(string root) static void RequireDockedCurrentShipRequestAuthority(string root) { - var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); - var actionGameManager = File.Exists(actionGameManagerPath) - ? File.ReadAllText(actionGameManagerPath) - : throw new InvalidOperationException("Cannot verify docked current-ship authority; ActionGameManager.cs is missing."); + var inventoryPanelPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryPanel.cs"); + var inventoryPanel = File.Exists(inventoryPanelPath) + ? File.ReadAllText(inventoryPanelPath) + : throw new InvalidOperationException("Cannot verify docked current-ship authority; InventoryPanel.cs is missing."); var requiredSymbols = new[] { "RequestDockedCurrentShip", - "TryRequestDaemonDockedCurrentShip", - "Operations.SetDockedCurrentShip" + "operations => operations.SetDockedCurrentShip" }; var missingSymbols = requiredSymbols - .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .Where(symbol => !inventoryPanel.Contains(symbol, StringComparison.Ordinal)) .ToArray(); if (missingSymbols.Length > 0) @@ -11708,7 +14527,7 @@ static void RequireDockedCurrentShipRequestAuthority(string root) string.Join(", ", missingSymbols)); } - if (actionGameManager.Contains("DockedEntity.Children.Contains(ship)", StringComparison.Ordinal)) + if (inventoryPanel.Contains("DockedEntity.Children.Contains(ship)", StringComparison.Ordinal)) { throw new InvalidOperationException( "Unity docked current-ship selection still rejects through renderer-local child membership instead of daemon acceptance."); @@ -11720,9 +14539,9 @@ static void RequireDockedCurrentShipRequestAuthority(string root) "DockedEntity == null", "DockingBay == null" }; - var unityAcceptanceHits = FindMethodScopedLineHits(actionGameManager, forbiddenUnityAcceptanceSymbols) + var unityAcceptanceHits = FindMethodScopedLineHits(inventoryPanel, forbiddenUnityAcceptanceSymbols) .Where(hit => hit.MethodName == "RequestDockedCurrentShip") - .Select(hit => $"ActionGameManager.cs:{hit.LineNumber}: {hit.Line.Trim()}") + .Select(hit => $"InventoryPanel.cs:{hit.LineNumber}: {hit.Line.Trim()}") .ToArray(); if (unityAcceptanceHits.Length > 0) { @@ -11731,13 +14550,12 @@ static void RequireDockedCurrentShipRequestAuthority(string root) string.Join(", ", unityAcceptanceHits)); } - if (actionGameManager.Contains("public bool RequestDockedCurrentShip(", StringComparison.Ordinal)) + if (inventoryPanel.Contains("public bool RequestDockedCurrentShip(", StringComparison.Ordinal)) { throw new InvalidOperationException( "Docked current-ship request API still exposes submission as public acceptance state."); } - var inventoryPanelPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryPanel.cs"); var forbiddenSymbols = new[] { "GameManager.CurrentEntity =", @@ -11757,13 +14575,14 @@ static void RequireDockedCurrentShipRequestAuthority(string root) string.Join("; ", hits)); } - var inventoryPanel = File.ReadAllText(inventoryPanelPath); - if (!inventoryPanel.Contains("GameManager.RequestDockedCurrentShip", StringComparison.Ordinal)) + if (inventoryPanel.Contains("GameManager.RequestDockedCurrentShip", StringComparison.Ordinal) || + !inventoryPanel.Contains("RequestDockedCurrentShip(ship)", StringComparison.Ordinal)) { - throw new InvalidOperationException("InventoryPanel no longer routes current-ship selection through ActionGameManager."); + throw new InvalidOperationException("InventoryPanel no longer owns typed current-ship selection operations directly."); } - if (!inventoryPanel.Contains("GameManager.TryGetObservedCurrentEntity(out var currentEntity)", StringComparison.Ordinal) || + if (!inventoryPanel.Contains("TryResolveCurrentEntityKey(out var currentEntityKey)", StringComparison.Ordinal) || + !inventoryPanel.Contains("CurrentEntityAsync()", StringComparison.Ordinal) || inventoryPanel.Contains("GameManager.CurrentEntity", StringComparison.Ordinal)) { throw new InvalidOperationException( diff --git a/Aetheria.State/Aetheria.State.csproj b/Aetheria.State/Aetheria.State.csproj index f1bdd4c65..a071843e3 100644 --- a/Aetheria.State/Aetheria.State.csproj +++ b/Aetheria.State/Aetheria.State.csproj @@ -13,6 +13,7 @@ + diff --git a/Aetheria.State/AetheriaCatalogSnapshot.cs b/Aetheria.State/AetheriaCatalogSnapshot.cs index 9d396ada9..9169db6a3 100644 --- a/Aetheria.State/AetheriaCatalogSnapshot.cs +++ b/Aetheria.State/AetheriaCatalogSnapshot.cs @@ -16,11 +16,13 @@ public sealed class AetheriaCatalogSnapshot public AetheriaCatalogSnapshot( IEnumerable items, IEnumerable corporations, - IEnumerable nameFiles) + IEnumerable nameFiles, + AetheriaTradeValuePolicy? tradeValuePolicy = null) { Items = items.OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase).ToArray(); Corporations = corporations.OrderBy(corporation => corporation.Name, StringComparer.OrdinalIgnoreCase).ToArray(); NameFiles = nameFiles.OrderBy(nameFile => nameFile.Name, StringComparer.OrdinalIgnoreCase).ToArray(); + TradeValuePolicy = tradeValuePolicy; _itemsByLegacyId = Items .Where(item => !string.IsNullOrWhiteSpace(item.LegacyId)) @@ -45,6 +47,8 @@ public sealed class AetheriaCatalogSnapshot public IReadOnlyList NameFiles { get; } + public AetheriaTradeValuePolicy? TradeValuePolicy { get; } + public IEnumerable TradeItems => Items.Where(item => item.Price > 0).OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase); diff --git a/Aetheria.State/AetheriaCatalogSurfaceProjector.cs b/Aetheria.State/AetheriaCatalogSurfaceProjector.cs index 52eb9e28f..b99637d60 100644 --- a/Aetheria.State/AetheriaCatalogSurfaceProjector.cs +++ b/Aetheria.State/AetheriaCatalogSurfaceProjector.cs @@ -29,6 +29,13 @@ public static class AetheriaCatalogSurfaceProjector ("names", catalog.GetNameFile(corporation)?.Name ?? ""), ("influence", corporation.InfluenceDistance.ToString()))) .ToArray(); + var refreshCommand = GameCult.Mesh.CultMesh.OperationBindingRecord( + GameCult.Mesh.CultMesh.OperationBinding( + AetheriaRuntimeCatalogCommands.Refresh, + label: "Refresh", + routeHint: new GameCult.Mesh.CultMeshRouteHint( + GameCult.Mesh.CultMeshLocalityKind.Automatic, + "cultmesh"))); return new EveSurfaceState { @@ -69,9 +76,12 @@ public static class AetheriaCatalogSurfaceProjector [ new EveCommandTemplate { - Command = AetheriaRuntimeCatalogCommands.Refresh, - Label = "Refresh", - Transport = "cultmesh" + Command = refreshCommand.OperationId, + Label = refreshCommand.Label, + Transport = refreshCommand.RouteDescription, + SchemaId = refreshCommand.SchemaId, + RouteKind = refreshCommand.RouteKind, + RouteDescription = refreshCommand.RouteDescription } ] }; diff --git a/Aetheria.State/AetheriaDocumentRegistry.cs b/Aetheria.State/AetheriaDocumentRegistry.cs index 95cf33549..685b1920d 100644 --- a/Aetheria.State/AetheriaDocumentRegistry.cs +++ b/Aetheria.State/AetheriaDocumentRegistry.cs @@ -33,6 +33,7 @@ public static class AetheriaDocumentRegistry CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), @@ -44,8 +45,27 @@ public static class AetheriaDocumentRegistry CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry) @@ -62,6 +82,7 @@ public static class AetheriaDocumentRegistry typeof(AetheriaRuntimeSession), typeof(AetheriaMigrationLedger), typeof(AetheriaLegacyCatalogQuarantine), + typeof(AetheriaTradeValuePolicy), typeof(AetheriaPlayerSettings), typeof(AetheriaLoadoutTemplate), typeof(AetheriaRunState), @@ -73,8 +94,27 @@ public static class AetheriaDocumentRegistry typeof(AetheriaRuntimeDaemonHealthDocument), typeof(AetheriaRuntimeDaemonCommandBoundaryDocument), typeof(AetheriaRuntimeDaemonFrameDocument), + typeof(AetheriaRuntimeRtsViewportDocument), + typeof(AetheriaRuntimeObjectsViewportDocument), + typeof(AetheriaRuntimeGravityViewportDocument), + typeof(AetheriaRuntimeCurrentZoneDocument), + typeof(AetheriaRuntimeCurrentEntityDocument), + typeof(AetheriaRuntimeCurrentDockingDocument), + typeof(AetheriaRuntimeZoneContactsDocument), + typeof(AetheriaRuntimeStationRefitDocument), + typeof(AetheriaRuntimeSectorMapDocument), + typeof(AetheriaRuntimeZoneDetailsDocument), + typeof(AetheriaRuntimeZoneRenderDocument), + typeof(AetheriaRuntimeSelectedObjectDocument), + typeof(AetheriaRuntimeInventoryDocument), + typeof(AetheriaRuntimeStarbridgeScenarioDocument), + typeof(AetheriaRuntimeStarbridgeSessionDocument), + typeof(AetheriaRuntimeStarbridgeSessionSummaryDocument), typeof(AetheriaRuntimeDaemonSoaViewDocument), + typeof(AetheriaRuntimeVerseAuthorityPolicyDocument), + typeof(AetheriaRuntimeAuthorityLeaseDocument), typeof(AetheriaRuntimeDaemonCommandDocument), + typeof(AetheriaRuntimeCommittedCommandFactDocument), typeof(AetheriaRuntimeEveCommandDocument), typeof(EveSurfaceState), typeof(EveProviderAdvertisementState) diff --git a/Aetheria.State/AetheriaEveCommandBridge.cs b/Aetheria.State/AetheriaEveCommandBridge.cs index b48a7c567..299b32a42 100644 --- a/Aetheria.State/AetheriaEveCommandBridge.cs +++ b/Aetheria.State/AetheriaEveCommandBridge.cs @@ -11,6 +11,7 @@ public sealed class AetheriaEveCommandAcceptanceReport public int AcceptedInputSettingsCommands { get; set; } public int AcceptedLoadoutTemplateCommands { get; set; } public int AcceptedVerseHostCommands { get; set; } + public int AcceptedTradeValuePolicyCommands { get; set; } public int RejectedCommands { get; set; } public string[] AcceptedCommandIds { get; set; } = []; public string[] RejectedCommandIds { get; set; } = []; @@ -88,6 +89,14 @@ public static class AetheriaEveCommandBridge await ExecuteVerseHostCommandAsync(node, command).ConfigureAwait(false); report.AcceptedVerseHostCommands++; break; + case AetheriaRuntimeEveCommandKind.TradeValuePolicyRefresh: + case AetheriaRuntimeEveCommandKind.SetTradeValueQualityMinimum: + case AetheriaRuntimeEveCommandKind.SetTradeValueQualityMaximum: + case AetheriaRuntimeEveCommandKind.SetTradeValueQualityExponent: + case AetheriaRuntimeEveCommandKind.SetTradeValueTierQuality: + await ExecuteTradeValuePolicyCommandAsync(node, command).ConfigureAwait(false); + report.AcceptedTradeValuePolicyCommands++; + break; } accepted.Add(command.CommandId ?? ""); @@ -283,6 +292,50 @@ public static class AetheriaEveCommandBridge loadout).ConfigureAwait(false); } + private static async Task ExecuteTradeValuePolicyCommandAsync( + AetheriaStateNode node, + AetheriaRuntimeEveCommandDocument command) + { + var policy = await node.GetTradeValuePolicyAsync().ConfigureAwait(false) ?? + AetheriaRuntimeStateMapper.ToTradeValuePolicy( + AetheriaRuntimeTradeValueSettings.Default, + command.IssuedAtUtc); + policy.QualityPriceModifier ??= new AetheriaExponentialLerp(); + policy.Tiers ??= Array.Empty(); + var persistPolicy = false; + var value = command.TradeValuePolicy.Value; + + switch (command.Kind) + { + case AetheriaRuntimeEveCommandKind.SetTradeValueQualityMinimum: + policy.QualityPriceModifier.Minimum = ClampFinite(value, 0, double.MaxValue); + persistPolicy = true; + break; + case AetheriaRuntimeEveCommandKind.SetTradeValueQualityMaximum: + policy.QualityPriceModifier.Maximum = ClampFinite(value, 0, double.MaxValue); + persistPolicy = true; + break; + case AetheriaRuntimeEveCommandKind.SetTradeValueQualityExponent: + policy.QualityPriceModifier.Exponent = ClampFinite(value, 0.001, double.MaxValue); + persistPolicy = true; + break; + case AetheriaRuntimeEveCommandKind.SetTradeValueTierQuality: + var tierIndex = command.TradeValuePolicy.TierIndex; + if (tierIndex >= 0 && tierIndex < policy.Tiers.Length) + { + policy.Tiers[tierIndex].Quality = ClampFinite(value, 0, 1); + persistPolicy = true; + } + break; + } + + if (persistPolicy) + { + policy.UpdatedAtUtc = command.IssuedAtUtc; + await node.PutTradeValuePolicyAsync(policy).ConfigureAwait(false); + } + } + private static string Cycle(string current, params string[] values) { if (values.Length == 0) @@ -295,6 +348,17 @@ public static class AetheriaEveCommandBridge return values[(index + 1) % values.Length]; } + private static double ClampFinite(double value, double min, double max) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + return min; + if (value < min) + return min; + if (value > max) + return max; + return value; + } + private static void RecordRejection( AetheriaEveCommandAcceptanceReport report, AetheriaRuntimeEveCommandDocument command, diff --git a/Aetheria.State/AetheriaOperationsSurfaceProjector.cs b/Aetheria.State/AetheriaOperationsSurfaceProjector.cs index c6bd972b2..15e1bb03b 100644 --- a/Aetheria.State/AetheriaOperationsSurfaceProjector.cs +++ b/Aetheria.State/AetheriaOperationsSurfaceProjector.cs @@ -19,6 +19,13 @@ public static class AetheriaOperationsSurfaceProjector eveCommandStatus?.LastPollAtUtc, normalizedVerseHost.LastUpdatedAtUtc, runtimeSession?.LastSeenAtUtc); + var refreshCommand = GameCult.Mesh.CultMesh.OperationBindingRecord( + GameCult.Mesh.CultMesh.OperationBinding( + AetheriaRuntimeOperationsCommands.Refresh, + label: "Refresh", + routeHint: new GameCult.Mesh.CultMeshRouteHint( + GameCult.Mesh.CultMeshLocalityKind.Automatic, + "cultmesh"))); return new EveSurfaceState { ProviderId = "aetheria", @@ -85,9 +92,12 @@ public static class AetheriaOperationsSurfaceProjector [ new EveCommandTemplate { - Command = AetheriaRuntimeOperationsCommands.Refresh, - Label = "Refresh", - Transport = "cultmesh" + Command = refreshCommand.OperationId, + Label = refreshCommand.Label, + Transport = refreshCommand.RouteDescription, + SchemaId = refreshCommand.SchemaId, + RouteKind = refreshCommand.RouteKind, + RouteDescription = refreshCommand.RouteDescription } ] }; diff --git a/Aetheria.State/AetheriaPlayerSettingsSurfaceProjector.cs b/Aetheria.State/AetheriaPlayerSettingsSurfaceProjector.cs index af76f4ed0..19cdb0127 100644 --- a/Aetheria.State/AetheriaPlayerSettingsSurfaceProjector.cs +++ b/Aetheria.State/AetheriaPlayerSettingsSurfaceProjector.cs @@ -55,11 +55,18 @@ public static class AetheriaPlayerSettingsSurfaceProjector .ToArray() }, Commands = surface.Commands - .Select(command => new EveCommandTemplate + .Select(command => { - Command = command.Command, - Label = command.Label, - Transport = command.Transport + var record = GameCult.Mesh.CultMesh.OperationBindingRecord(command.Operation); + return new EveCommandTemplate + { + Command = record.OperationId, + Label = record.Label, + Transport = record.RouteDescription, + SchemaId = record.SchemaId, + RouteKind = record.RouteKind, + RouteDescription = record.RouteDescription + }; }) .ToArray() }; diff --git a/Aetheria.State/AetheriaProviderAdvertisementProjector.cs b/Aetheria.State/AetheriaProviderAdvertisementProjector.cs index a1fb1124c..98e5f6de7 100644 --- a/Aetheria.State/AetheriaProviderAdvertisementProjector.cs +++ b/Aetheria.State/AetheriaProviderAdvertisementProjector.cs @@ -44,6 +44,7 @@ public static class AetheriaProviderAdvertisementProjector "aetheria.item_definition.v1", "aetheria.corporation.v2", "aetheria.name_file.v2", + "aetheria.trade_value_policy.v1", "aetheria.player_settings.v1", "aetheria.loadout_template.v1", "aetheria.run_state.v1", diff --git a/Aetheria.State/AetheriaRuntimeEveSurfaceStateProjector.cs b/Aetheria.State/AetheriaRuntimeEveSurfaceStateProjector.cs index 3ac87d5dc..23c00a377 100644 --- a/Aetheria.State/AetheriaRuntimeEveSurfaceStateProjector.cs +++ b/Aetheria.State/AetheriaRuntimeEveSurfaceStateProjector.cs @@ -16,11 +16,18 @@ public static class AetheriaRuntimeEveSurfaceStateProjector UpdatedAtUtc = document.UpdatedAtUtc, Surface = ToSurface(document.Surface), Commands = document.Commands - .Select(command => new EveCommandTemplate + .Select(command => { - Command = command.Command, - Label = command.Label, - Transport = command.Transport + var record = GameCult.Mesh.CultMesh.OperationBindingRecord(command.Operation); + return new EveCommandTemplate + { + Command = record.OperationId, + Label = record.Label, + Transport = record.RouteDescription, + SchemaId = record.SchemaId, + RouteKind = record.RouteKind, + RouteDescription = record.RouteDescription + }; }) .ToArray() }; @@ -44,12 +51,30 @@ public static class AetheriaRuntimeEveSurfaceStateProjector private static EveSurfaceComponent ToComponent(AetheriaRuntimeSurfaceComponent component) { + var props = new Dictionary(component.Props, StringComparer.Ordinal); + AetheriaRuntimeSurfaceStateBindings.AddPointerProps(props, component.StateBindings); + return new EveSurfaceComponent { Id = component.Id, Kind = component.Kind, - Props = new Dictionary(component.Props, StringComparer.Ordinal), - Children = component.Children.Select(ToComponent).ToArray() + Props = props, + Children = component.Children.Select(ToComponent).ToArray(), + StateBindings = component.StateBindings + .Select(binding => + { + var record = GameCult.Mesh.CultMesh.StateBindingRecord(binding); + return new EveSurfaceStateBinding + { + TargetProp = record.TargetProp, + PointerId = record.PointerId, + SourceId = record.SourceId, + SchemaId = record.SchemaId, + RouteKind = record.RouteKind, + RouteDescription = record.RouteDescription + }; + }) + .ToArray() }; } } diff --git a/Aetheria.State/AetheriaRuntimeStateMapper.cs b/Aetheria.State/AetheriaRuntimeStateMapper.cs index 948ee042b..7d53750da 100644 --- a/Aetheria.State/AetheriaRuntimeStateMapper.cs +++ b/Aetheria.State/AetheriaRuntimeStateMapper.cs @@ -6,6 +6,53 @@ namespace Aetheria.State; public static class AetheriaRuntimeStateMapper { + public static AetheriaTradeValuePolicy ToTradeValuePolicy( + AetheriaRuntimeTradeValueSettings settings, + string updatedAtUtc) + { + return new AetheriaTradeValuePolicy + { + QualityPriceModifier = new AetheriaExponentialLerp + { + Exponent = settings.QualityPriceModifier.Exponent, + Minimum = settings.QualityPriceModifier.Minimum, + Maximum = settings.QualityPriceModifier.Maximum + }, + Tiers = settings.Tiers + .Select(tier => new AetheriaItemRarityTier + { + Name = tier.Name, + Quality = tier.Quality, + Red = tier.Red, + Green = tier.Green, + Blue = tier.Blue + }) + .ToArray(), + UpdatedAtUtc = updatedAtUtc + }; + } + + public static AetheriaRuntimeTradeValueSettings ToRuntimeTradeValueSettings( + AetheriaTradeValuePolicy? policy) + { + if (policy == null) + return AetheriaRuntimeTradeValueSettings.Default; + + return new AetheriaRuntimeTradeValueSettings( + new AetheriaRuntimeExponentialLerp( + policy.QualityPriceModifier?.Exponent ?? 1, + policy.QualityPriceModifier?.Minimum ?? 1, + policy.QualityPriceModifier?.Maximum ?? 1), + (policy.Tiers ?? Array.Empty()) + .Select(tier => new AetheriaRuntimeItemRarityTier( + tier.Name, + tier.Quality, + tier.Red, + tier.Green, + tier.Blue)) + .ToArray()); + } + public static AetheriaLoadoutTemplate ToLoadoutTemplate( AetheriaRuntimeLoadoutTemplateCommit loadout, string updatedAtUtc) diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs index 6e8e30ccf..f93ff8164 100644 --- a/Aetheria.State/AetheriaStateNode.cs +++ b/Aetheria.State/AetheriaStateNode.cs @@ -31,7 +31,8 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable public static async Task OpenAsync( string statePath, string runtimeId = "aetheria-local", - bool startServer = false) + bool startServer = false, + bool enableDurableShardLogs = true) { if (string.IsNullOrWhiteSpace(statePath)) { @@ -46,7 +47,7 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable new CultMeshNodeOptions { StartServer = startServer, - EnableDurableShardLogs = true, + EnableDurableShardLogs = enableDurableShardLogs, CacheOptions = new CultCacheOpenOptions { Registry = cacheRegistry, @@ -163,7 +164,20 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable return new AetheriaCatalogSnapshot( Cache.GetAll(), Cache.GetAll(), - Cache.GetAll()); + Cache.GetAll(), + Cache.GetAll().FirstOrDefault()); + } + + public Task> PutTradeValuePolicyAsync( + AetheriaTradeValuePolicy policy) + { + return Database.PutAsync(new CultRecordKey(AetheriaTradeValuePolicy.RecordKey), policy); + } + + public Task GetTradeValuePolicyAsync() + { + return Database.GetAsync( + new CultRecordKey(AetheriaTradeValuePolicy.RecordKey)); } public Task> PutCatalogSurfaceAsync(EveSurfaceState surface) @@ -266,6 +280,49 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable .ToArray(); } + public Task> PutCommittedCommandFactAsync( + AetheriaRuntimeCommittedCommandFactDocument fact) + { + if (fact == null) throw new ArgumentNullException(nameof(fact)); + fact.Schema = AetheriaRuntimeDaemonSchemas.CommittedCommandFact; + if (string.IsNullOrWhiteSpace(fact.FactId)) + fact.FactId = Guid.NewGuid().ToString("N"); + if (string.IsNullOrWhiteSpace(fact.CommittedAtUtc)) + fact.CommittedAtUtc = DateTime.UtcNow.ToString("O"); + + return Database.PutAsync( + new CultRecordKey(AetheriaRuntimeCommittedCommandFactDocument.CreateRecordKey(fact.FactId)), + fact); + } + + public IReadOnlyList ReadCommittedCommandFacts() + { + return Cache + .GetAll() + .OrderBy(fact => fact.CommittedAtUtc ?? "", StringComparer.Ordinal) + .ThenBy(fact => fact.FactId ?? "", StringComparer.Ordinal) + .ToArray(); + } + + public Task> PutVerseAuthorityPolicyAsync( + AetheriaRuntimeVerseAuthorityPolicyDocument policy) + { + return Database.PutAsync( + new CultRecordKey(AetheriaRuntimeVerseAuthorityPolicyDocument.DocumentKey), + policy); + } + + public Task GetVerseAuthorityPolicyAsync() + { + return Database.GetAsync( + new CultRecordKey(AetheriaRuntimeVerseAuthorityPolicyDocument.DocumentKey)); + } + + public IReadOnlyList ReadAuthorityLeases() + { + return Cache.GetAll().ToArray(); + } + private static CultRecordKey DaemonCommandKey(string commandId) { return new CultRecordKey($"daemon:commands:{StableToken(commandId)}:gamecult.aetheria.daemon_command.v1"); @@ -324,6 +381,30 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable new CultRecordKey("daemon:aetheria.soa_view.latest.v1")); } + public Task> PutStarbridgeScenarioAsync( + AetheriaRuntimeStarbridgeScenarioDocument scenario) + { + return Database.PutAsync(AetheriaRuntimeVerseRecordKeys.StarbridgeScenarioLatest, scenario); + } + + public Task GetStarbridgeScenarioAsync() + { + return Database.GetAsync( + AetheriaRuntimeVerseRecordKeys.StarbridgeScenarioLatest); + } + + public Task> PutStarbridgeSessionAsync( + AetheriaRuntimeStarbridgeSessionDocument session) + { + return Database.PutAsync(AetheriaRuntimeVerseRecordKeys.StarbridgeSessionLatest, session); + } + + public Task GetStarbridgeSessionAsync() + { + return Database.GetAsync( + AetheriaRuntimeVerseRecordKeys.StarbridgeSessionLatest); + } + public Task> PutDaemonGameSurfaceAsync(EveSurfaceState surface) { return Database.PutAsync(new CultRecordKey(AetheriaProviderAdvertisementProjector.DaemonGameSurfaceKey), surface); diff --git a/Aetheria.State/AetheriaVerseReplica.cs b/Aetheria.State/AetheriaVerseReplica.cs index 1069e06c4..0a3c4bd01 100644 --- a/Aetheria.State/AetheriaVerseReplica.cs +++ b/Aetheria.State/AetheriaVerseReplica.cs @@ -1,3 +1,5 @@ +using Aetheria.State.Documents; +using GameCult.Aetheria.State.Verse; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Mesh; @@ -15,13 +17,91 @@ public static class AetheriaVerseReplica string endpoint, string runtimeId = "aetheria-verse-replica") { - using var node = await OpenReplicaNodeAsync(replicaStatePath, endpoint, runtimeId).ConfigureAwait(false); - var shard = BuildReplicaShard(endpoint); - var fetcher = new CultNetSchemaShardSnapshotFetcher(); - var snapshot = await fetcher.FetchAsync(shard).ConfigureAwait(false); - var appliedSequence = await node.Database.ApplyShardSnapshotResponseAsync(shard, snapshot).ConfigureAwait(false); + using var node = await OpenReplicaNodeAsync( + replicaStatePath, + endpoint, + runtimeId, + pullOnOpen: false).ConfigureAwait(false); + return await SyncRawSnapshotChunksAsync(node, endpoint).ConfigureAwait(false); + } + + public static async Task SyncScopedSnapshotAsync( + string replicaStatePath, + string endpoint, + string runtimeId = "aetheria-verse-replica", + IReadOnlyList? schemaIds = null, + IReadOnlyList? recordKeys = null, + TimeSpan? connectTimeout = null, + TimeSpan? responseTimeout = null) + { + using var node = await OpenReplicaNodeAsync( + replicaStatePath, + endpoint, + runtimeId, + pullOnOpen: false).ConfigureAwait(false); + using var client = await ConnectedSnapshotClient.ConnectAsync( + endpoint, + connectTimeout ?? TimeSpan.FromSeconds(5)).ConfigureAwait(false); + var snapshot = await client.RequestAsync( + schemaIds, + recordKeys, + responseTimeout ?? TimeSpan.FromSeconds(5)).ConfigureAwait(false); + var applied = await node.Database.Documents + .ApplyRawSnapshotResponseAsync(node.Cache, snapshot) + .ConfigureAwait(false); await node.FlushAsync().ConfigureAwait(false); - return appliedSequence; + return applied.Count; + } + + public static async Task FetchScopedSnapshotAsync( + string endpoint, + IReadOnlyList? schemaIds = null, + IReadOnlyList? recordKeys = null, + TimeSpan? connectTimeout = null, + TimeSpan? responseTimeout = null) + { + using var client = await ConnectedSnapshotClient.ConnectAsync( + endpoint, + connectTimeout ?? TimeSpan.FromSeconds(5)).ConfigureAwait(false); + return await client.RequestAsync( + schemaIds, + recordKeys, + responseTimeout ?? TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + + public static async Task> FetchScopedDocumentsAsync( + string endpoint, + IReadOnlyList? schemaIds = null, + IReadOnlyList? recordKeys = null, + TimeSpan? connectTimeout = null, + TimeSpan? responseTimeout = null) + where T : class + { + var snapshot = await FetchScopedSnapshotAsync( + endpoint, + schemaIds, + recordKeys, + connectTimeout, + responseTimeout).ConfigureAwait(false); + var registry = AetheriaDocumentRegistry.CreateCultNetRegistry(); + var documents = new List(snapshot.Documents.Length); + foreach (var record in snapshot.Documents) + { + if (record == null) + continue; + + var binding = registry.GetBySchemaId(record.SchemaId); + if (binding == null || binding.DocumentType != typeof(T)) + continue; + + if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal)) + throw new InvalidOperationException( + $"CultNet raw document payloadEncoding must be \"messagepack\", not \"{record.PayloadEncoding}\"."); + + documents.Add((T)binding.PayloadDeserializer(record.Payload)); + } + + return documents; } public static async Task RunReplicaAsync( @@ -31,14 +111,15 @@ public static class AetheriaVerseReplica CancellationToken cancellationToken, string runtimeId = "aetheria-verse-replica") { - using var node = await OpenReplicaNodeAsync(replicaStatePath, endpoint, runtimeId).ConfigureAwait(false); - var shard = BuildReplicaShard(endpoint); + using var node = await OpenReplicaNodeAsync( + replicaStatePath, + endpoint, + runtimeId, + pullOnOpen: false).ConfigureAwait(false); var snapshotFetcher = new CultNetSchemaShardSnapshotFetcher(); var logFetcher = new CultNetSchemaShardLogFetcher(); - var snapshot = await snapshotFetcher.FetchAsync(shard).ConfigureAwait(false); - await node.Database.ApplyShardSnapshotResponseAsync(shard, snapshot).ConfigureAwait(false); - await node.FlushAsync().ConfigureAwait(false); + await SyncRawSnapshotChunksAsync(node, endpoint).ConfigureAwait(false); using var replicator = new CultNetShardReplicator( node.Database, @@ -76,10 +157,237 @@ public static class AetheriaVerseReplica primaryEndpoints: new[] { endpoint.Trim() }); } + private static async Task SyncRawSnapshotChunksAsync(CultMeshNode node, string endpoint) + { + using var client = await ConnectedSnapshotClient.ConnectAsync( + endpoint, + TimeSpan.FromSeconds(5)).ConfigureAwait(false); + var hot = await SyncHotPublicationsAsync(node, client).ConfigureAwait(false); + await node.FlushAsync().ConfigureAwait(false); + return hot; + } + + private static async Task SyncHotPublicationsAsync(CultMeshNode node, ConnectedSnapshotClient client) + { + var recordKeys = new[] + { + AetheriaRuntimeVerseRecordKeys.DaemonProviderAdvertisement.ToString(), + AetheriaRuntimeVerseRecordKeys.DaemonHealth.ToString(), + AetheriaRuntimeVerseRecordKeys.DaemonCommandBoundary.ToString(), + AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString(), + AetheriaRuntimeVerseAuthorityPolicyDocument.DocumentKey, + "daemon:aetheria.starbridge.session.latest.v1" + }; + + var sequence = 0L; + foreach (var recordKey in recordKeys) + { + var applied = await SyncScopedSnapshotAsync( + node, + client, + schemaIds: null, + recordKeys: new[] { recordKey }, + responseTimeout: TimeSpan.FromSeconds(15)).ConfigureAwait(false); + sequence = Math.Max(sequence, SnapshotSequence(applied)); + } + + return sequence; + } + + private static async Task SyncScopedSnapshotAsync( + CultMeshNode node, + ConnectedSnapshotClient client, + IReadOnlyList? schemaIds, + IReadOnlyList? recordKeys, + TimeSpan responseTimeout) + { + var snapshot = await client.RequestAsync(schemaIds, recordKeys, responseTimeout).ConfigureAwait(false); + await node.Database.Documents.ApplyRawSnapshotResponseAsync(node.Cache, snapshot).ConfigureAwait(false); + return snapshot; + } + + private static long SnapshotSequence(CultNetSnapshotResponseRawMessage snapshot) + { + return snapshot.ShardLogSequence ?? 0L; + } + + private sealed class ConnectedSnapshotClient : IDisposable + { + private const uint RtsConnectionId = 0x43554c54; + private readonly ICultNetSchemaClient _client; + private readonly string _endpoint; + private readonly Dictionary> _pending = + new(StringComparer.Ordinal); + private readonly object _pendingLock = new(); + private bool _disposed; + + private ConnectedSnapshotClient(ICultNetSchemaClient client, string endpoint) + { + _client = client; + _endpoint = endpoint; + _client.OnCultNet(OnSnapshotResponse); + _client.OnCultNet(OnError); + } + + public static async Task ConnectAsync(string endpoint, TimeSpan connectTimeout) + { + var (host, port) = ParseEndpoint(endpoint); + var client = string.Equals(new Uri(endpoint).Scheme, "rudp", StringComparison.OrdinalIgnoreCase) + ? CultNetSchemaClients.CreateRudp( + runtimeId: "aetheria-verse-replica", + connectionId: RtsConnectionId, + maxFragmentBytes: 1200) + : CultNetSchemaClients.CreateForEndpoint(endpoint); + client.Connect(host, port); + var connected = new ConnectedSnapshotClient(client, endpoint); + await connected.WaitForConnectionAsync(connectTimeout).ConfigureAwait(false); + return connected; + } + + public async Task RequestAsync( + IReadOnlyList? schemaIds, + IReadOnlyList? recordKeys, + TimeSpan responseTimeout) + { + if (_disposed) + throw new ObjectDisposedException(nameof(ConnectedSnapshotClient)); + + var messageId = $"aetheria-replica:{Guid.NewGuid():N}"; + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + lock (_pendingLock) + { + _pending.Add(messageId, completion); + } + + _client.SendCultNet(new CultNetSnapshotRequestMessage + { + MessageId = messageId, + SchemaIds = CleanFilter(schemaIds), + RecordKeys = CleanFilter(recordKeys), + ShardId = ReplicaShardId, + ShardEpoch = 1 + }); + + var timeoutTask = Task.Delay(responseTimeout); + var completed = await Task.WhenAny(completion.Task, timeoutTask).ConfigureAwait(false); + if (completed != completion.Task) + { + lock (_pendingLock) + { + _pending.Remove(messageId); + } + + throw new TimeoutException( + $"Timed out waiting for shard snapshot response from {_endpoint} " + + $"for schemas [{string.Join(", ", CleanFilter(schemaIds) ?? Array.Empty())}] " + + $"and records [{string.Join(", ", CleanFilter(recordKeys) ?? Array.Empty())}]."); + } + + return await completion.Task.ConfigureAwait(false); + } + + public void Dispose() + { + _disposed = true; + _client.Dispose(); + lock (_pendingLock) + { + foreach (var pending in _pending.Values) + { + pending.TrySetCanceled(); + } + + _pending.Clear(); + } + } + + private async Task WaitForConnectionAsync(TimeSpan connectTimeout) + { + var deadline = DateTimeOffset.UtcNow + connectTimeout; + while (!_client.Connected) + { + if (DateTimeOffset.UtcNow >= deadline) + { + throw new TimeoutException($"Timed out connecting to shard primary endpoint {_endpoint}."); + } + + await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false); + } + + await Task.Delay(TimeSpan.FromMilliseconds(75)).ConfigureAwait(false); + } + + private void OnSnapshotResponse(CultNetSnapshotResponseRawMessage response) + { + if (string.IsNullOrWhiteSpace(response.MessageId)) + return; + + TaskCompletionSource? completion; + lock (_pendingLock) + { + if (!_pending.Remove(response.MessageId, out completion)) + return; + } + + completion.TrySetResult(response); + } + + private void OnError(CultNetErrorMessage error) + { + List> pending; + lock (_pendingLock) + { + pending = _pending.Values.ToList(); + _pending.Clear(); + } + + var exception = new InvalidOperationException(error.Error); + foreach (var completion in pending) + { + completion.TrySetException(exception); + } + } + + private static string[]? CleanFilter(IReadOnlyList? values) + { + if (values is not { Count: > 0 }) + return null; + + var filtered = values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return filtered.Length == 0 ? null : filtered; + } + + private static (string Host, int Port) ParseEndpoint(string endpoint) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + { + throw new FormatException($"CultNet endpoint '{endpoint}' must be an absolute URI."); + } + + if (!string.Equals(uri.Scheme, "rudp", StringComparison.OrdinalIgnoreCase) && + !string.Equals(uri.Scheme, "cultnet", StringComparison.OrdinalIgnoreCase)) + { + throw new FormatException($"CultNet endpoint '{endpoint}' must use cultnet://host:port or rudp://host:port."); + } + + if (uri.Port <= 0 || uri.Port > 65535) + { + throw new FormatException($"CultNet endpoint '{endpoint}' must include a valid port."); + } + + return (uri.Host, uri.Port); + } + } + private static Task OpenReplicaNodeAsync( string replicaStatePath, string endpoint, - string runtimeId) + string runtimeId, + bool pullOnOpen = true) { if (string.IsNullOrWhiteSpace(replicaStatePath)) throw new ArgumentException("Replica state path must be non-empty.", nameof(replicaStatePath)); @@ -95,7 +403,7 @@ public static class AetheriaVerseReplica CacheOptions = new CultCacheOpenOptions { Registry = cacheRegistry, - PullOnOpen = true, + PullOnOpen = pullOnOpen, StoreFlushOnDispose = true, UseDirectoryStore = true }, diff --git a/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs b/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs index bf70c4e22..eff2531fd 100644 --- a/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs +++ b/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs @@ -56,6 +56,9 @@ public sealed class AetheriaPlayerGameplaySettings [Key(1)] public int SignificantDigits { get; set; } = 3; + + [Key(2)] + public float DefaultShutdownPerformance { get; set; } = 0.25f; } [MessagePackObject] @@ -223,9 +226,6 @@ public sealed class AetheriaRunState [CultReference(typeof(AetheriaZoneState), many: true)] public string[] ZoneKeys { get; set; } = []; - [Key(8)] - public AetheriaActionBarBinding[] ActionBarBindings { get; set; } = []; - [Key(9)] public AetheriaFactionRelationshipState[] FactionRelationships { get; set; } = []; @@ -936,28 +936,6 @@ public sealed class AetheriaBehaviorStateSnapshot public bool TurretControllerPredictShots { get; set; } } -[MessagePackObject] -public sealed class AetheriaActionBarBinding -{ - [Key(0)] - public string ControlPath { get; set; } = ""; - - [Key(1)] - public string Kind { get; set; } = ""; - - [Key(2)] - public string TargetKey { get; set; } = ""; - - [Key(3)] - public int EquipmentIndex { get; set; } = -1; - - [Key(4)] - public int BehaviorIndex { get; set; } = -1; - - [Key(5)] - public int WeaponGroup { get; set; } = -1; -} - [MessagePackObject] public sealed class AetheriaFactionRelationshipState { diff --git a/Aetheria.State/Documents/AetheriaTradeValuePolicy.cs b/Aetheria.State/Documents/AetheriaTradeValuePolicy.cs new file mode 100644 index 000000000..34a97c580 --- /dev/null +++ b/Aetheria.State/Documents/AetheriaTradeValuePolicy.cs @@ -0,0 +1,36 @@ +using GameCult.Caching; +using MessagePack; + +namespace Aetheria.State.Documents; + +[CultDocument("aetheria.trade_value_policy", "aetheria.trade_value_policy.v1")] +[CultGlobal] +[MessagePackObject] +public sealed class AetheriaTradeValuePolicy +{ + public const string RecordKey = "global:aetheria.trade_value_policy.v1"; + + [Key(0)] public string Schema { get; set; } = "aetheria.trade_value_policy.v1"; + [Key(1)] public string Name { get; set; } = "Aetheria trade value policy"; + [Key(2)] public AetheriaExponentialLerp QualityPriceModifier { get; set; } = new(); + [Key(3)] public AetheriaItemRarityTier[] Tiers { get; set; } = []; + [Key(4)] public string UpdatedAtUtc { get; set; } = ""; +} + +[MessagePackObject] +public sealed class AetheriaExponentialLerp +{ + [Key(0)] public double Exponent { get; set; } + [Key(1)] public double Minimum { get; set; } + [Key(2)] public double Maximum { get; set; } +} + +[MessagePackObject] +public sealed class AetheriaItemRarityTier +{ + [Key(0)] public string Name { get; set; } = ""; + [Key(1)] public double Quality { get; set; } + [Key(2)] public double Red { get; set; } + [Key(3)] public double Green { get; set; } + [Key(4)] public double Blue { get; set; } +} diff --git a/Aetheria.State/docs/cultmesh-cross-runtime-primitives.md b/Aetheria.State/docs/cultmesh-cross-runtime-primitives.md new file mode 100644 index 000000000..13c700e70 --- /dev/null +++ b/Aetheria.State/docs/cultmesh-cross-runtime-primitives.md @@ -0,0 +1,339 @@ +# CultMesh Cross-Runtime Primitives + +Aetheria is the shakedown cruise for CultMesh. The Rust rebuild should not turn every useful idea into Aetheria-only glue. If a pattern helps any typed Verse daemon expose state, operations, queries, native views, authority, or UI surfaces across runtimes, it belongs in the shared CultMesh/CultLib layer. CultMesh should be expansive enough that application code feels like it is holding managed native state, while the runtime handles locality, synchronization, authority, codecs, and cache layout. + +The bar is deliberately high: CultMesh should feel managed, cozy, and almost unfairly ergonomic to the developer, while the runtime silently chooses the fastest valid path underneath. In-process calls, shared CultCache slabs, native slices, IPC, remote transport, and browser/WASM views are locality choices, not different application APIs. The developer writes against typed Verse concepts; CultMesh supplies the plush handles, reactive wrappers, generated operations, query surfaces, native views, and diagnostics that make those concepts feel obvious. + +The promotion rule is simple: if a helper would make Aetheria, Brokkr, Odin, +Bifrost, Eve, Ymir, or VoidBot nicer in the same way, it is not an Aetheria +helper. Hoist it into the shared cross-runtime CultMesh/CultLib layer first, +then let Aetheria consume it as ordinary infrastructure. Aetheria may define +domain nouns and policies; CultMesh owns the managed-feeling primitive shape +that makes those nouns feel native from C#, Rust, TS, Unity, browser/WASM, and +tooling hosts. + +This is not a minimalist transport library. CultMesh should grow the shared +abstractions that make daemon state feel intimate: typed state pointers, +reactive wrappers, query handles, operation handles, native slice descriptors, +authority claims, locality routing, schema-generated facades, UI bindings, +diagnostics, and deterministic math/query primitives. "Primitive" does not +only mean a small value type; it means any reusable cross-runtime developer +affordance that can be hoisted into the shared library layer. The goal is a +managed, cozy developer surface that reads like ordinary domain code and +compiles down to the fastest safe path the runtime can prove. + +## Design Bar + +1. Typed state is the default. User code should hold handles, pointers, operations, and query objects, not record keys, schema ids, slot numbers, or raw payload maps. +2. The same semantic surface works in Rust, TS, Unity/C#, browser/WASM, Eve/CultUI, and native tools. +3. Locality is invisible until the developer asks for diagnostics. Co-located runtimes get shared memory or direct calls; remote runtimes get transport; browser runtimes get WASM-compatible views. +4. Authority is a first-class Verse primitive. Policies, leases, runtime roles, claims, and future quorum hooks should be reusable across daemons. +5. UI surfaces can contain typed pointers into daemon state. The UI runtime resolves, watches, invalidates, and invokes typed operations automatically. +6. Native views expose zero-copy or near-zero-copy slices when safe, with the same state semantics as remote query/subscription fallbacks. +7. Schema evolution is part of the developer experience. Bindings and migration manifests come from one source instead of hand-maintained slot maps. + +## Ownership Boundary + +| Belongs in CultMesh/CultLib | Belongs in Aetheria | +| --- | --- | +| Typed document handles, record routing, schema ids, migrations, codecs. | Aetheria world, zones, entities, inventory, stats, equipment, factions, Starbridge scenario concepts. | +| Typed operation handles, receipts, idempotency, authority metadata, prediction hooks. | Pilot, RTS, refit, trade, interaction, construction, combat, and editor operation vocabulary. | +| Typed query surfaces, query planning, locality routing, subscription lifetimes. | Object viewport, gravity viewport, selected object, inventory, stat, station, encounter, and tactical map queries. | +| Reactive state pointers and UI binding semantics. | Aetheria Eve/CultUI surface content and domain-specific panel layout. | +| Native slice descriptors, memory safety contracts, dirty ranges, runtime adapters. | Aetheria render columns, physics body columns, inventory/stat column definitions. | +| Authority primitives: policy modes, leases, claims, runtime roles, diagnostics. | Which Aetheria operation kinds require which claims and which runtime cares most about each thing. | +| CultMath primitives: `Vec2`, `Vec3`, `Rect`, `Circle`, `Sphere`, transforms, deterministic scalar helpers. | Aetheria coordinate conventions, body meanings, sensor ranges, gravity brush semantics. | +| Provider discovery and interface binding conventions for Odin/Bifrost/Eve. | Aetheria provider advertisements, operation/query catalogs, and surface ids. | + +## Primitive Set + +First implementation footholds now exist in CultLib: + +- `E:/Projects/CultLib/src/GameCult.Mesh/CultMeshPrimitives.cs` defines typed operation handles, query surfaces, state pointers, route hints, authority claims, and native slice descriptors. +- `E:/Projects/CultLib/src/GameCult.Mesh/CultMeshPrimitives.cs` also exposes fluent operation/query context builders, with `CultMesh.OperationContextFor(...)` and `CultMesh.QueryContextFor(...)` facade entrypoints. Application code should read like a typed Verse call, not like manual transport setup. +- `E:/Projects/CultLib/src/GameCult.Geometry/CultGeometryPrimitives.cs` defines shared `CultVec2`, `CultVec3`, `CultRect`, `CultCircle`, and `CultSphere` values for query and physics contracts. +- `E:/Projects/CultLib/packages/cultmesh-ts/src/index.ts` now exposes TS `CultMeshVec2`, `CultMeshRect`, `CultMeshViewportRequest`, `CultMeshQuerySurface`, `CultMeshQueryContext`, `CultMeshOperationHandle`, `CultMeshOperationContext`, `CultMeshOperationPayload`, `CultMeshStatePointer`, `CultMeshStateBindingDescriptor`, `CultMeshOperationBindingDescriptor`, `CultMeshOperationInvocationDescriptor`, native slice descriptors, authority claims, route hints, fluent context builders, and facade helpers like `CultMesh.rectFromBounds(...)`, `CultMesh.viewportRequest(...)`, `CultMesh.query(...)`, `CultMesh.operation(...)`, `CultMesh.operationPayload(...)`, `CultMesh.statePointer(...)`, `CultMesh.stateBinding(...)`, `CultMesh.operationBinding(...)`, `CultMesh.operationInvocation(...)`, `CultMesh.nativeSliceView(...)`, `CultMesh.operationContextFor(...)`, and `CultMesh.queryContextFor(...)`. +- `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePlayerSettingsSurfaceBuilder.cs` and `E:/Projects/Eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs` now expose component `StateBindings` as `IReadOnlyList`. Eve no longer owns a parallel live binding DTO, and Aetheria no longer keeps one in its live runtime surface model. Aetheria only keeps a MessagePack DTO at the persisted document boundary and converts to the shared CultMesh primitive before handing surfaces to renderers. +- Aetheria and Eve live command templates now carry `CultMeshOperationBindingDescriptor`. Eve command requests carry `CultMeshOperationInvocationDescriptor` plus `CultMeshOperationPayload`, so renderer click/change events preserve operation id, schema, route hint, idempotency, and scalar field reads as shared CultMesh metadata. Legacy `command`, `label`, `transport`, and string payload fields remain compatibility projections and persisted DTO fields, but the live API no longer exposes raw command-string/dictionary constructors; controls point at typed CultMesh operations and renderers build requests from shared CultMesh primitives. +- `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationsClient.cs` is now a typed operation facade that returns `CultMeshOperationReceipt` from semantic verbs such as `SetMoveVector`, `SetTarget`, `DockNearest`, and inventory transfers. The lower-level daemon command envelope remains an internal transport wrapper, but public client code should receive shared CultMesh receipts, not Aetheria-specific command envelopes. +- `Packages/org.gamecult.aetheria.state/Runtime/AetheriaUi.cs` applies the same rule to Eve/CultUI commands. Unity UI and Eve presenter code should call `AetheriaClient.Ui` and receive `CultMeshOperationReceipt`; `AetheriaRuntimeEveCommandEnvelope` remains a bridge/document persistence detail for the Verse command boundary, not the shape application code leans on. +- `AetheriaRuntimeEveCommands` is internal now. It may help smoke tests and internal bridge code manufacture persisted command documents, but it is not the public renderer/client API. Public callers use `AetheriaClient.Ui`, and the shared receipt is the public outcome type. +- `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonRenderQueries.cs` now exposes daemon render gravity/body query overloads around `CultMath.rect` in Aetheria XY space. `AetheriaRuntimeXzRect` remains only as a Unity legacy adapter. +- `E:/Projects/CultLib/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs` now recovers readable schema-stamped cold records when the hot directory manifest is missing the record's catalog entry. Aetheria hit this with persisted Verse authority policy state; the fix belongs in CultCache because durable Verse state should feel managed and resilient across runtime/schema refreshes, not like hand-maintained manifest bookkeeping. +- `E:/Projects/CultLib/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts` and `E:/Projects/CultLib/packages/cultcache-rs/src/lib.rs` now mirror the same schema-stamped recovery for single-file MessagePack snapshots. Their cache registries resolve recovered schema names and normalize envelopes back to registered public document types, so browser/Electron/Rust clients can share durable Verse state without depending on a stale hot catalog. +- `E:/Projects/CultLib/packages/cultcache-py/src/cultcache_py/stores.py`, `E:/Projects/CultLib/packages/cultcache-py/src/cultnet_py/replication.py`, and `E:/Projects/CultLib/packages/cultcache-ts/src/cult-cache-inspector.ts` now carry the same primitive into Python stores, raw CultNet replication helpers, and tooling inspection. A stale schema id should be a recoverable runtime detail when the cold payload is schema-stamped, not a client-visible failure mode. + +### Typed Document Handles + +Current leak: + +```ts +await node.putRaw(recordKey, schemaId, msgpackBytes); +const frame = decodeFrame(await node.get("daemon:aetheria.frame.latest.v1")); +``` + +Desired shape: + +```ts +const run = await verse.aetheria().currentRun().watch(); +const frame = await run.frame().latest(); +``` + +The handle owns record keys, schema ids, codec choice, migration readers, cache hydration, and subscription invalidation. The application code sees the typed value and its stable semantic location in the Verse. + +### Typed Operation Handles + +Current leak: + +```ts +await client.setMoveVector({ + actorEntityKey, + directionX: input.x, + directionY: input.y, + scalar: 1, +}); +``` + +Desired shape: + +```ts +await verse + .aetheria() + .entity(actorEntityId) + .pilot() + .move({ x: input.x, y: input.y }); +``` + +CultMesh should generate operation handles from schema. The handle carries payload shape, authority claim metadata, routing, idempotency, receipts, diagnostics, and optional prediction hooks. The runtime decides whether this is a local direct call, a shared-slab write, a network operation, or a WASM host call. The RTS binding generator now emits `createAetheriaRuntimeRtsOperationHandles(...)` for `setMoveVector` and `setTarget`; the Electron client consumes those generated TS `CultMesh.operation(...)` handles while preserving the current daemon command document as an internal transport detail. + +### Typed Query Surfaces + +Current leak: + +```ts +const frame = await client.latestFrame(); +const objects = projectObjectsViewport(frame, rect, controlledEntityIds); +``` + +Desired shape: + +```ts +const objects = await verse + .aetheria() + .zone(zoneId) + .objects() + .visibleTo(controlledEntityIds) + .within(rect) + .watch(); +``` + +Queries are derived-state surfaces, not bespoke HTTP endpoints or local projection helpers. CultMesh should own query identity, parameters, caching, invalidation, locality routing, and remote execution. A co-located daemon can execute directly over slabs; a remote peer can execute through the Verse; a browser can consume the same query shape through generated TS/WASM bindings. The RTS binding generator now emits projection response types, `createAetheriaRuntimeRtsQueryHandles(...)` for every RTS read surface, renderer `aetheria-rts-contract.ts`, `AetheriaRtsIpcChannels`, the preload bridge, and `registerAetheriaRtsIpcHandlers(...)`. Those generated read handles are backed by `CultMesh.projectionRecipe(...).asQuerySurface()` with explicit daemon frame, health, authority, and Starbridge source descriptors. The RTS client passes its local publication route into the generated handles once, then uses automatic query contexts so the recipe owns locality defaults. Because `CultMeshQuerySurface` preserves `sources` and `routeHint`, the RTS client can expose `projectionDiagnostics()` directly from generated handle metadata instead of maintaining a parallel diagnostic table. Electron and browser clients consume generated contracts instead of duplicating query/response shapes, IPC channel names, handler registration, or viewport methods as the primary abstraction. + +### Reactive State Pointers + +Current leak: + +```csharp +var resolver = AetheriaRuntimeVerseClient.CreateEveSurfaceStateRefResolver(statePath, runtimeId); +var selected = await resolver.ResolveAsync(surface.StateRef); +``` + +Desired shape: + +```ts +surface.panel("Selection", { + entity: verse.aetheria().selection().entity().pointer(), + actions: verse.aetheria().selection().entity().operations(), +}); +``` + +Eve/CultUI props should be allowed to hold typed state pointers and operation handles. The UI runtime resolves them, subscribes to them, handles unavailable state, invalidates stale values, and invokes typed operations without custom per-daemon adapters. + +### Native Slice Views + +Current leak: + +```csharp +var descriptor = await client.GetSoaViewAsync(); +var map = MemoryMappedFile.OpenExisting(descriptor.MemoryMapName); +``` + +Desired shape: + +```csharp +using var view = await verse.Aetheria() + .Zone(zoneId) + .RenderView() + .AsNativeArrays(); +``` + +CultMesh/CultCache should define native view descriptors, dirty ranges, schema fingerprints, lifetimes, and safety rules. Unity gets `NativeArray`-friendly views. Rust gets slices. Browser/WASM gets typed array views when co-located or a compatible copied view when remote. The semantic surface stays the same. + +### Authority Primitives + +Current leak: + +```csharp +var decision = AetheriaRuntimeAuthorityRouter.Authorize(policy, command, runtimeId, leases); +``` + +Desired shape: + +```rust +let receipt = verse + .aetheria() + .entity(actor) + .pilot() + .move(vec2) + .with_authority(runtime.claims().local_responsiveness()) + .await?; +``` + +Near-term shared-library sugar already exists in C# and TS: + +```csharp +var context = CultMesh.OperationContextFor("unity-raven") + .Claim("pilot-control", shardId: "zone:raven") + .Route(CultMeshLocalityKind.SharedMemory, "co-located Verse") + .Idempotency("move:raven:1") + .Build(); +``` + +```ts +const context = CultMesh.operationContextFor("browser-starfire") + .claim("simulation-authority", { shardId: "zone:starfire" }) + .route("wasm", "browser-local projection") + .idempotency("move:starfire:1") + .build(); +``` + +Authority is not topology. CultMesh should provide reusable policy modes, claim kinds, leases, runtime roles, denial reasons, receipts, and diagnostics. Aetheria configures which operation families require which claims. Future server-authoritative, trusted distributed, lease, browser-host, and quorum modes should use the same primitive vocabulary. + +### Geometry And Math Values + +Current leak: + +```ts +viewport({ minX, minY, maxX, maxY }); +``` + +Desired shape: + +```ts +const rect = CultMesh.rect(CultMesh.vec2(x0, y0), CultMesh.vec2(x1, y1)); +await zone.gravity().influences().intersecting(rect); +``` + +CultMath/CultMesh should provide deterministic cross-runtime primitives for vectors, rects, circles, spheres, transforms, scalar tolerances, and spatial query inputs. In CultLib C# this starts as `CultVec2`, `CultVec3`, `CultRect`, `CultCircle`, and `CultSphere` under `GameCult.Geometry`; in TS this starts as `CultMeshVec2`, `CultMeshRect`, and `CultMeshViewportRequest` in `cultmesh-ts`. SoA does not require splitting a vector into unrelated conceptual values; the layout can still be cache-efficient while the API treats position, velocity, and acceleration as meaningful vector values. + +### Locality-Aware Routing + +Current leak: + +```ts +await fetch("http://127.0.0.1:39217/ymir/overlap-sphere", ...); +``` + +Desired shape: + +```ts +const hits = await verse + .ymir() + .world(worldId) + .overlapSphere({ center, radius }) + .layers("gameplay") + .all(); +``` + +The caller asks for a typed operation or query. CultMesh chooses in-process, shared slab, IPC, network, RUDP, browser/WASM bridge, or remote Verse execution. The choice is observable for diagnostics, but it is not encoded into application logic. + +### Schema Evolution + +Current leak: + +```ts +const entityId = payload[ENTITY_ID_SLOT]; +``` + +Desired shape: + +```ts +const entity = frame.entities.byId(entityId); +``` + +Bindings should be generated from one schema source into Rust, TS, Unity/C#, browser/WASM, Eve/CultUI metadata, and migration manifests. MessagePack key order, schema ids, deprecated fields, compatibility readers, and version negotiation are shared machinery. + +### Surface Bindings + +Current leak: + +```json +{ "kind": "button", "command": "dock", "payload": { "entityId": 42 } } +``` + +Desired shape: + +```ts +button({ + icon: "anchor", + enabled: selected.docking().canDock(), + onPress: selected.docking().dock(), +}); +``` + +CultUI should bind to typed state and operation handles. A surface can be rendered in Unity, browser, terminal, or an MCP host without rewriting command payloads or state-ref resolution. Bifrost exposes daemon Verse tools through these provider-owned interfaces; it is not a Brokkr-specific socket wrapper. + +The TS side now has the primitive shape too: + +```ts +const selected = CultMesh.statePointer( + "aetheria.selection.current", + () => selection.resolve(), + (emit) => selection.watch(emit), +); +``` + +That pointer is still only a primitive, not the final generated Verse facade. +The next step is generator-owned state paths, codecs, unavailable-state +diagnostics, and UI binding metadata. + +The RTS generated bindings now create state pointer handles for the daemon +frame, health, authority policy, and Starbridge session documents, and the RTS +surface catalog advertises them with the same shared-memory route and schema +source metadata as the query handles. This is the proof shape Eve/CultUI and +Bifrost should consume: inspect typed pointer surfaces, resolve/watch them +through the local Verse, and leave record keys and daemon-specific resolver +rules below the shared primitive layer. + +Those generated pointers are now Verse-bound in the RTS facade as well. The +Electron client supplies publication-reader resolvers once, generated code binds +the pointers with `CultMesh.bindStatePointer(queryVerse, pointer)`, and callers +can resolve the underlying daemon documents through the facade without manually +constructing query contexts or knowing where the local publication files live. + +## Aetheria Proofs + +Aetheria should prove these primitives by forcing each client to use the same shared shape: + +1. Rust Aetheria loads the measured fixture world and publishes typed state through CultMesh. +2. Rust Ymir answers step, overlap, cast, contact, broadphase, sparse-cluster, and viewport-intersection queries through typed query surfaces. +3. The browser RTS client renders object and gravity viewports through query handles and submits operations through typed handles. +4. Unity consumes render and physics views as native slices while keeping gameplay authority out of the scene hierarchy. +5. Eve/CultUI panels bind to state pointers and operation handles with no daemon-specific resolver. +6. Bifrost MCP tools discover provider interfaces through Odin and invoke typed Verse operations/queries without knowing whether the live daemon is Brokkr, Aetheria, or something else. +7. Authority diagnostics explain which runtime may author which claim without changing operation schemas. + +## Performance Contract + +The cozy API must not be a slow API. + +- Handles should be allocation-light after binding. +- Query parameter objects should be value-shaped and cacheable. +- Native views should expose contiguous slabs and dirty ranges where co-location allows it. +- Remote fallbacks should keep the same semantic API but may return copied values or streamed deltas. +- Hot paths must support generated code, stable layout, pooled codecs, and deterministic math. +- The runtime should expose diagnostics for route choice, copy count, slab identity, schema version, authority decision, and subscription churn. + +## Build Implication + +When the Rust rebuild needs a new adapter, ask whether that adapter is actually a missing CultMesh primitive. Aetheria should contain the game. CultMesh/CultLib should contain the magic that makes the game feel native from every runtime. diff --git a/Aetheria.State/docs/cultmesh-ergonomics-staged-migration-plan.md b/Aetheria.State/docs/cultmesh-ergonomics-staged-migration-plan.md new file mode 100644 index 000000000..44924ccc5 --- /dev/null +++ b/Aetheria.State/docs/cultmesh-ergonomics-staged-migration-plan.md @@ -0,0 +1,484 @@ +# CultMesh Ergonomics Staged Migration Plan + +This is the staged plan for getting CultMesh ergonomics from "usable transport and replicated records" to the ideal developer experience: typed state that feels local, reactive UI surfaces that resolve daemon pointers automatically, native slab views for fast clients, and authority semantics that are explicit without making every client hand-roll routing code. + +The Rust/WASM rebuild remains valuable, but it is not the MVP path. The near-term goal is to build the perfect machine in C# first, freeze the API shape we actually want, then let any later Rust body implement that shape rather than inherit the current leaks. + +## North Star + +C# clients should read like domain code: + +```csharp +var aetheria = verse.Aetheria(); + +await aetheria.Entity(actorEntityId) + .Pilot + .MoveAsync(CultMesh.Vec2(input.X, input.Y)); + +var objects = await aetheria.Zone(zoneId) + .Objects + .VisibleTo(controlledEntityIds) + .Within(rect) + .LatestAsync(); + +using var renderView = await aetheria.Zone(zoneId) + .RenderView + .AsNativeArraysAsync(); +``` + +TypeScript clients should have the same semantic shape: + +```ts +const aetheria = verse.aetheria(); + +await aetheria.entity(actorEntityId) + .pilot.move(CultMesh.vec2(input.x, input.y)); + +const objects = await aetheria.zone(zoneId) + .objects.visibleTo(controlledEntityIds) + .within(rect) + .latest(); + +const gravity = await aetheria.zone(zoneId) + .gravity.influences() + .intersecting(rect) + .latest(); +``` + +Eve/CultUI surfaces should carry typed state pointers and operation handles, not stringly command documents: + +```ts +surface.panel("Selection", { + entity: verse.aetheria().selection().entity().pointer(), + stats: verse.aetheria().selection().entity().stats().pointer(), + actions: verse.aetheria().selection().entity().operations(), +}); +``` + +Ymir should be reachable through the same typed query surface pattern: + +```ts +const hits = await verse.ymir() + .world(worldId) + .overlapSphere({ center, radius }) + .layers("gameplay") + .all(); +``` + +Authority should be claimed through typed operation context sugar, with CultMesh owning the generic primitive and Aetheria owning the policy: + +```csharp +await aetheria.Entity(actorEntityId) + .Pilot + .WithAuthority("pilot-control", shardId: $"entity:{actorEntityId}") + .MoveAsync(CultMesh.Vec2(input.X, input.Y)); +``` + +## Current Baseline + +The project already has the first layer of primitive sugar: + +- `CultMeshOperationHandle`, `CultMeshOperationBindingDescriptor`, `CultMeshOperationBindingRecord`, `CultMeshOperationInvocationDescriptor`, `CultMeshOperationInvocationRecord`, and `CultMeshOperationPayload` exist in CultLib. +- `CultMeshStatePointer`, state binding descriptors/records, route hints, authority claims, query surfaces, viewport requests, native slice descriptors, and context builders exist in C# and TypeScript. +- CultMath/CultGeometry now has vector and rect primitives, including XY viewport vocabulary. +- RTS bindings are generated from the Aetheria schema and expose a typed-ish Verse facade. +- Aetheria Eve surfaces persist CultMesh binding/invocation records and keep legacy fields as compatibility mirrors. +- `AetheriaClient`, `AetheriaControl`, and `AetheriaUi` expose ergonomic C# entry points, but the domain facade is still incomplete. + +The remaining problem is that the sugar is not yet the only obvious path. There are still public or semi-public seams where clients can fall back into raw envelopes, record keys, ad hoc frame projection, manual state resolution, and locality-specific wiring. + +## Migration Rules + +1. Repeated Aetheria-specific glue becomes a generated Aetheria facade. +2. Repeated cross-runtime glue becomes CultMesh/CultLib. +3. Public client APIs must expose typed domain operations, typed query surfaces, typed pointers, or native views. +4. Raw payload dictionaries, command names, schema ids, record keys, slot ids, and transport-local route details are allowed inside generated/runtime internals only. +5. Compatibility mirrors are temporary scaffolding. They must have a named removal stage. +6. Unity is a renderer/input client. It should not own gameplay state, zone hierarchy, simulation cadence, physics, or entity authority by accident. +7. The browser/RTS client and Unity client must use the same semantic Verse facade, even when their locality/runtime routes differ. + +## Stage 0: Freeze The Vocabulary + +Status: mostly done, keep tightening. + +Goal: make the desired surface explicit enough that every later stage can delete ad hoc code instead of inventing new abstractions in place. + +Targets: + +- Keep `Aetheria.State/docs/cultmesh-cross-runtime-primitives.md` as the primitive ownership boundary. +- Keep `Aetheria.State/docs/daemon-code-map-for-rust-port.md` as the current daemon/control-flow map. +- Treat this document as the staged migration checklist. +- Define canonical names for: + - document handles + - operation handles + - operation invocation records + - query surfaces + - state pointers + - native slice views + - authority claims + - route hints + - geometry values + +Gates: + +- No new public API may introduce `Apply(command, payload)` style call sites. +- No new client API may require a caller to know transport record keys or raw schema slots. +- All new examples in docs must use the North Star shape. + +## Stage 1: Harden Shared CultMesh Primitives + +Status: started. + +Goal: make CultMesh primitives boring, portable, serializable, and pleasant enough that Aetheria does not need local substitutes. + +Targets: + +- C# primitives in `E:/Projects/CultLib/src/GameCult.Mesh/CultMeshPrimitives.cs`. +- Geometry primitives in `E:/Projects/CultLib/src/GameCult.Geometry/CultGeometryPrimitives.cs`. +- TS primitives in `E:/Projects/Aetheria/packages/cultmesh-ts/src/index.ts`. +- MessagePack/JSON-compatible record forms for operation/state/query/native/authority values. +- Typed payload readers and builders so domain facades do not pass `Dictionary` in public. +- Stable route and authority record forms that can cross process/runtime boundaries. + +Work: + +- Add typed scalar/vector/list readers to operation payloads. +- Add generated or handwritten conversion helpers between descriptor objects and record objects. +- Add invariant tests for C#/TS round trips. +- Keep primitive names runtime-neutral. Aetheria, Brokkr, Eve, Odin, VoidBot, and Ymir should all be able to use the same vocabulary. + +Gates: + +```powershell +dotnet test E:\Projects\CultLib\tests\GameCult.Mesh.Tests\GameCult.Mesh.Tests.csproj --no-restore -v:minimal +npm test --workspace packages/cultmesh-ts +``` + +## Stage 2: Generate Domain Facades + +Status: started in RTS bindings; incomplete across C#/Unity/Eve. + +Goal: make the typed facade the public shape in every runtime. + +Targets: + +- C# facade: `verse.Aetheria().Entity(id).Pilot.MoveAsync(...)`. +- TS facade: `verse.aetheria().entity(id).pilot.move(...)`. +- Eve/CultUI bindings: surface actions are operation handles, not command strings. +- Unity facade: Unity gameplay/input shell calls typed operations and typed queries only. + +Work: + +- Promote the current RTS binding generator into a general Aetheria CultMesh facade generator. +- Generate C# and TS operation handles from the same schema. +- Generate query builders for objects, gravity, inventory, selection, stats, action availability, and map/RTS visibility. +- Generate state pointer builders for selected object, selected inventory, selected stats, UI panels, and live frame metadata. +- Add a verifier that fails if public client code exposes command-name/payload pairs. + +Desired C# shape: + +```csharp +await client.Aetheria() + .Entity(actorEntityId) + .Equipment(slot) + .ActivateAsync(target); +``` + +Desired TS shape: + +```ts +await client.aetheria() + .entity(actorEntityId) + .equipment(slot) + .activate(target); +``` + +Gates: + +```powershell +dotnet run --project E:\Projects\Aetheria\Aetheria.State.Verify\Aetheria.State.Verify.csproj --no-restore +npm run generate:rts-bindings +npm run check:rts-bindings +``` + +## Stage 3: Make Query Surfaces The Client Contract + +Status: partially started; local projection still leaks. + +Goal: clients ask the daemon for typed projections. They do not reconstruct world truth from frames unless they are inside a generated/internal query executor. + +Targets: + +- Objects viewport. +- Gravity viewport. +- Visible set for RTS: union of entities visible to controlled units. +- Selected object. +- Current stats. +- Inventory. +- Equipment/action availability. +- Station/service status. +- Ymir overlap/cast queries. + +Work: + +- Move remaining browser-local projection paths behind generated query executors or daemon query endpoints. +- Make viewport queries use `CultRect`/XY everywhere outside Unity adapter boundaries. +- Give each query a stable typed request and typed response. +- Add watch/latest semantics consistently: + - `latest()` for one-shot reads. + - `watch()` for reactive subscriptions. + - `asNativeArrays()` where co-located slab views are available. + +Gates: + +- RTS client rendering uses query surfaces, not frame spelunking. +- Unity rendering asks for render/query views, not a mirrored zone hierarchy. +- `ZoneRenderer.LoadZone` and ActionGameManager-owned zone state become vestigial or disappear. +- Public client code has no direct dependency on current daemon frame layout except through generated/internal projection code. + +## Stage 4: Resolve Eve State Pointers Automatically + +Status: primitive records exist; runtime resolution is not yet the whole story. + +Goal: an Eve surface can contain a typed pointer into daemon state and the UI runtime resolves it, watches it, and binds it without custom per-panel plumbing. + +Targets: + +- Eve surface documents carry `CultMeshStateBindingRecord`. +- Operation buttons carry `CultMeshOperationBindingRecord`. +- User actions emit `CultMeshOperationInvocationRecord`. +- CultUI runtime resolves pointers via the current Verse. +- Unity UIToolkit lowerer consumes the same surface semantics as browser/Electron. + +Work: + +- Add a pointer resolver interface to CultMesh/CultUI rather than Aetheria-specific code. +- Add watch lifetime management to UI runtime. +- Add loading/error/stale states as generic pointer states. +- Remove manual state-ref resolver glue from Aetheria UI code once the generic runtime path exists. + +Gates: + +- Selection panel can bind selected entity/stats/inventory from pointers only. +- No Aetheria panel needs to manually decode a state reference. +- Surface command persistence contains CultMesh invocation records as the authoritative form. + +## Stage 5: Native View Sugar For Unity And Co-Located Clients + +Status: designed; implementation still thin. + +Goal: Unity becomes a thin, brutally fast renderer/input client over Aetheria/Ymir state. + +Targets: + +- `AsNativeArraysAsync()` over CultCache slabs. +- Unity-safe lifetime wrappers around shared memory/native slice descriptors. +- Burst-readable views for render columns and physics state. +- Dirty ranges/version stamps so clients can skip unchanged slabs. +- Render-oriented projections generated by Aetheria, not maintained as a Unity hierarchy. + +Work: + +- Define native slice descriptors for Aetheria render views and Ymir physics views. +- Wrap descriptors in Unity `NativeArray`/`NativeSlice` adapters with explicit lifetime ownership. +- Add schema/version validation before exposing a slab as a native view. +- Move camera/frustum queries to daemon query surfaces. +- Keep Unity-specific transform/material/sprite concerns in Unity, but pull entity membership, object location, gravity influence, physics state, inventory, and stats from Aetheria/Ymir. + +Desired Unity shape: + +```csharp +using var view = await verse.Aetheria() + .Zone(zoneId) + .RenderView + .ForCamera(cameraQuery) + .AsNativeArraysAsync(); + +RenderJobs.Schedule(view.Objects, view.Sprites, view.Gravity); +``` + +Gates: + +```powershell +dotnet build E:\Projects\Aetheria\GameCult.Aetheria.State.Unity.csproj --no-restore -v:minimal +dotnet build E:\Projects\Aetheria\Aetheria.State.Unity.Smoke\Aetheria.State.Unity.Smoke.csproj --no-restore -v:minimal +``` + +## Stage 6: Authority Sugar And Locality Policies + +Status: primitive claims exist; gameplay policy needs first-class wiring. + +Goal: authority is explicit and configurable without leaking into every gameplay call. + +Targets: + +- Server-authoritative mode. +- Trusted co-op distributed simulation mode. +- Future quorum/re-sim mode, without blocking MVP. +- Per-shard/per-entity authority claims. +- Route hints chosen by Verse/runtime policy. + +Work: + +- Add Aetheria authority policy config as data, not hardcoded branch logic. +- Add facade sugar: + - `WithAuthority(...)` + - `PreferLocality(...)` + - `AsNode(...)` + - `WithIdempotency(...)` +- Attach operation context automatically where a facade knows the actor/entity/shard. +- Add diagnostics that explain why an operation routed to local/shared-memory/network/host. +- Keep CultMesh generic: it owns claims, leases, routes, receipts, and diagnostics; Aetheria owns which operations require which claims. + +Gates: + +- Unity player can own simulation authority for its own pilot state in trusted co-op config. +- RTS player can own hostile pawns or commanded pawns in trusted co-op config. +- Server-authoritative config can force daemon/host-only simulation authority. +- No public gameplay API requires manually constructing a command port. + +## Stage 7: Ymir Query Facade And Physics Ownership + +Status: Ymir owns more physics directionally, but query sugar and benchmark discipline need to catch up. + +Goal: physics is a typed service with the same CultMesh ergonomics as Aetheria state. + +Targets: + +- `OverlapSphere`. +- Brush/viewport intersection for gravity influence queries. +- Collision/cast queries needed by abilities and movement. +- Gravity terrain influence queries. +- Physics benchmark suite with deterministic sparse clustered scenes. + +Work: + +- Add Ymir typed query handles using CultMesh query surface primitives. +- Add C# and TS facade generation for Ymir. +- Add Aetheria-to-Ymir state references rather than duplicating physics ownership in Unity. +- Add benchmarks that compare vector SoA, scalar SoA, and spatial acceleration structures. +- Keep Unity physics out of gameplay state. Unity may render/debug physics but not authoritatively simulate it. + +Gates: + +- Gameplay queries use Ymir facade operations. +- Gravity viewport queries can ask for every body whose influence brush intersects an XY viewport. +- Benchmarks are deterministic and run in CI or a documented local perf lane. + +## Stage 8: One Schema Pipeline + +Status: pieces exist; still too hand-maintained. + +Goal: one schema source generates cross-runtime facades, record serializers, pointer/query/operation metadata, and docs. + +Targets: + +- C# facade generation. +- TS facade generation. +- Eve/CultUI binding metadata. +- Unity native view adapters. +- Query request/response records. +- Operation payload records. +- Schema evolution manifests. +- Legacy readers only where explicitly needed. + +Work: + +- Replace one-off RTS binding generation with a CultMesh schema generator. +- Generate operation payload types instead of exposing generic payload maps. +- Generate query result types and pointer target types. +- Generate docs/snippets from schema metadata so examples cannot rot. +- Add schema version compatibility checks to native view and network decode paths. + +Gates: + +- Adding a new Aetheria operation once creates C#, TS, Eve operation binding, verifier metadata, and docs. +- Adding a new query once creates C#, TS, Eve pointer/query metadata, and query executor stubs. +- No runtime has a hand-authored duplicate of another runtime's binding vocabulary. + +## Stage 9: Delete Compatibility Shells + +Status: not started. + +Goal: remove scaffolding once the generated typed path is the only supported path. + +Remove: + +- Public command-name/payload APIs. +- Cached command port concepts. +- Public raw frame spelunking for client projections. +- Public record-key/schema-slot access in client code. +- Manual state-ref resolution in UI panels. +- Legacy XZ vocabulary outside Unity adapter names. +- Unity-owned zone hierarchy/gameplay simulation state. +- Browser-local bespoke gameplay behavior. + +Keep only as internals where required: + +- Wire-level records. +- Legacy readers for persisted documents that predate the migration. +- Diagnostic tools that intentionally expose raw records. +- Tests that assert legacy decode behavior until the migration window closes. + +Gates: + +- Verifier blocks new public stringly operation surfaces. +- `rg` sweeps show no client-facing `Apply(command`, `commandName`, `payload`, `recordKey`, or raw slot access outside generated/runtime internals. +- Unity, RTS/Electron, and Eve all consume the same semantic facade. + +## Stage 10: MVP Acceptance + +The migration is MVP-complete when: + +- Aetheria daemon owns gameplay simulation state. +- Ymir owns physics state and gameplay physics queries. +- Unity is rendering/input focused. +- RTS/Electron is a thin CultMesh client, not a parallel gameplay implementation. +- Eve/CultUI surfaces use typed state pointers and typed operation handles. +- Aetheria exposes current stats, inventory, equipment, selection, objects viewport, gravity viewport, and authority-aware operations as typed surfaces. +- Unity and browser clients can point at the same Verse and observe/interact with the same simulation according to authority policy. +- Co-located clients can use native slab views where available. +- Remote clients use the same facade shape over network routes. +- Locality is visible in diagnostics, not application logic. + +## Non-Goals For This MVP + +- Rebuilding the daemon in Rust. +- Making the browser host the full simulation in WASM. +- Quorum/re-sim consensus implementation. +- Perfect zero-copy support across every runtime. +- Exact parity with every historical Unity behavior. + +These are future expansion paths. The migration must not close the door on them, but it should not block the MVP on them. + +## Verification Lane + +Use this lane when changing the sugar surface: + +```powershell +dotnet test E:\Projects\CultLib\tests\GameCult.Mesh.Tests\GameCult.Mesh.Tests.csproj --no-restore -v:minimal +dotnet run --project E:\Projects\Aetheria\Aetheria.State.Verify\Aetheria.State.Verify.csproj --no-restore +dotnet build E:\Projects\Aetheria\GameCult.Aetheria.State.Unity.csproj --no-restore -v:minimal +dotnet build E:\Projects\Aetheria\Aetheria.State.Unity.Smoke\Aetheria.State.Unity.Smoke.csproj --no-restore -v:minimal +npm run generate:rts-bindings +npm run check:rts-bindings +npm run build +``` + +Add Ymir benchmark and authority smoke commands to this lane as those stages land. + +## Sugar Targets Checklist + +- [ ] Typed Aetheria C# domain facade is complete. +- [ ] Typed Aetheria TS domain facade is complete. +- [ ] Typed Ymir C# and TS query facades exist. +- [ ] Eve/CultUI resolves CultMesh state pointers automatically. +- [ ] Eve/CultUI invokes operation records without Aetheria-specific command glue. +- [ ] Query surfaces cover objects viewport, gravity viewport, selection, stats, inventory, equipment, and services. +- [ ] Unity render path consumes native views or query results, not gameplay-owned mirrored state. +- [ ] RTS render path consumes query surfaces, not bespoke frame projection as public client behavior. +- [ ] Authority policy is data-driven per Verse. +- [ ] Operation context sugar attaches route, claim, and idempotency without caller boilerplate. +- [ ] Schema generator emits C#, TS, Eve metadata, query records, operation payload records, and docs. +- [ ] Public APIs do not expose stringly command names or raw payload dictionaries. +- [ ] Compatibility mirrors are internal, named, and scheduled for deletion. diff --git a/Aetheria.State/docs/daemon-code-map-for-rust-port.md b/Aetheria.State/docs/daemon-code-map-for-rust-port.md new file mode 100644 index 000000000..1da3244b7 --- /dev/null +++ b/Aetheria.State/docs/daemon-code-map-for-rust-port.md @@ -0,0 +1,551 @@ +# Aetheria Daemon Code Map for Rust Rebuild + +This is a map of the current C# daemon as a reference API to document, measure, and deprecate before the rebuild. This is not an Agile migration plan and not a request to preserve the current architecture. The C# code is the shakedown cruise: it taught us where Unity, TS, browser, Eve/CultUI, Rust, and native clients need clean CultMesh ergonomics, where the daemon leaked local-process assumptions, and where the API wants to become magical instead of merely serviceable. + +The rebuild target is a clean cross-runtime Aetheria surface: a Rust-native daemon and Ymir body, with useful abstractions hoisted into CultMesh/CultLib so typed state, operations, queries, native slices, authority, and UI state pointers are shared library primitives. Clients should feel like they are holding reactive, native Aetheria state, not hand-pulling snapshots, decoding array slots, reading local publication files, or building bespoke command packets. + +This also means Ymir needs a Rust body. Aetheria cannot become a Rust-native simulation daemon while physics remains a C# or Unity-shaped side service. The C# Ymir contracts and Unity bridge are useful reference material, but the rebuilt daemon needs Rust Ymir beside it as the authoritative physics library/service. + +## Rebuild Doctrine + +1. Document the current C# daemon API and client behavior. +2. Measure it as the deprecated compatibility reference. +3. Build the daemon and client semantics in the shape we actually want, then hoist reusable abstractions into CultMesh/CultLib instead of trapping them in Aetheria glue. +4. Let Unity, TS, browser, native, Eve/CultUI, Rust, and future runtimes consume the same typed Verse semantics. +5. Use this rebuild to make CultMesh expansive: cozy managed developer experience on top, very fast cross-runtime primitives underneath. + +Compatibility means the new cross-runtime surface can understand enough of the old contract to migrate clients and compare intentional behavior. It does not mean the new architecture inherits old seams, and it does not mean client APIs keep their current awkward shape. + +The test for a good abstraction is whether it belongs to Aetheria or to CultMesh. If a pattern is useful to any daemon with typed state, reactive UI surfaces, native view sharing, authority routing, or colocated query execution, it should move up into the shared cross-runtime library layer. + +The concrete shared-layer target is tracked in `Aetheria.State/docs/cultmesh-cross-runtime-primitives.md`. Treat that note as the CultMesh/CultLib primitive checklist for this rebuild: Aetheria proves the shape, but the reusable machinery belongs in the cross-runtime library layer. + +## Current C# Shape + +```mermaid +flowchart TD + Host["Program.cs\nprocess host, CLI, tick loop, seeding"] + Node["AetheriaStateNode\nCultMesh/CultCache/CultNet wrapper"] + Commands["Daemon command docs\nEve command docs"] + Authority["Authority router\npolicy + leases"] + Tick["Tick runner"] + Ops["Operations executor\nmutates run checkpoint"] + Sim["RTS sim\nmovement, AI, combat, contacts"] + Frame["Frame document\nrun snapshot + facts"] + Projection["Projection helpers\nviewport, gravity, inventory"] + Publications["Publications\nCultNet records, .cc files, RUDP snapshots, Eve surfaces"] + Unity["Unity client\nobserver + SoA mmap"] + TS["Electron/TS client\nCultMesh send, local reads"] + + Host --> Node + Node --> Commands + Commands --> Authority + Authority --> Tick + Tick --> Ops + Ops --> Sim + Sim --> Frame + Frame --> Projection + Frame --> Publications + Projection --> Publications + Publications --> Unity + Publications --> TS +``` + +The current daemon is one C# executable around a CultMesh node. It ensures seed documents, reads typed command documents, authorizes them, applies operations, steps a compact RTS sim, publishes a new frame, and emits several secondary publications for clients. + +That shape proved the core idea, but it also leaked implementation details into every edge: + +- Unity observes daemon state through a C# facade and memory-mapped SoA view. +- Electron sends CultMesh commands but still reads local `.cc` publications. +- TS duplicates projection logic by decoding MessagePack arrays and slot constants. +- RUDP snapshot handling injects ad hoc viewport projection records. +- Eve/CultUI surface generation is mixed into daemon publication. +- The host loop seeds scenario content and durable docs directly. +- The API is typed in places, but not magical enough: clients still know too much about transport, record keys, snapshots, and projection plumbing. + +## Measured C# API Surface + +These files define the deprecated reference surface. They should be treated as API documentation and shakedown evidence first, not as design inspiration or strict parity law. + +| Area | Source | What to freeze | +| --- | --- | --- | +| Host behavior | `Aetheria.State.Daemon/Program.cs` | CLI options, startup behavior, seed behavior, tick cadence, RUDP endpoint behavior, remote fact import behavior. | +| CultMesh node wrapper | `Aetheria.State/AetheriaStateNode.cs` | Current record reads/writes, observed command query behavior, flush semantics. | +| Registry | `Aetheria.State/AetheriaDocumentRegistry.cs` | C# document set currently exposed through CultCache/CultNet. | +| Replica sync | `Aetheria.State/AetheriaVerseReplica.cs` | Scoped snapshot fetch, document fetch, remote committed fact import dependency. | +| Local publications | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCultCacheDocumentStore.cs` | `.cc` compatibility files and their payload layout. | +| Daemon documents | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs` | Schema ids, command enum, frame, command, committed fact, viewport, health, provider, command boundary, surface docs. | +| Snapshot documents | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs` | Run, zone, entity, body, equipment, cargo, loadout, stat grids. | +| Authority documents | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs` | Policy, rules, leases, modes, roles, deployment vocabulary, current authorization decisions. | +| SoA documents | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs` | SoA backends, sync modes, column ids, dirty ranges, render groups. | +| Command application | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs` | Current meaning of every gameplay command and rejection. | +| Tick composition | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs` | Current order of command filtering, operation execution, sim step, frame creation, publication payloads. | +| Compact sim | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsSimulation.cs` | Existing movement, hostile AI, combat, heat, and contact rules. | +| Projections | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs` | Existing map, object, gravity, selected object, docking, refit, sector, and inventory projections. | +| Client facade | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs` | C# client observation and typed operation ergonomics. | +| Verse client | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs` | Current lower-level typed document reads/watches and command submission. | +| Operation builders | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationClient.cs` and `AetheriaRuntimeDaemonOperationsClient.cs` | Existing typed operation vocabulary. | +| Eve bridge | `Aetheria.State/AetheriaEveCommandBridge.cs` | Existing Eve command acceptance behavior for settings, catalog, policies, and surfaces. | +| Unity consumption | `Assets/Scripts/Gameplay/AetheriaDaemonObserver.cs` | Current Unity daemon observation, SoA remapping, and render native view handoff. | +| TS consumption | `Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts`, `aetheria-rts-local-projection.ts`, `aetheria-local-publication-reader.ts` | Current Electron command sending, local publication reading, and duplicated projection behavior. | +| Ymir C# contracts | `Assets/Scripts/ServerShared/YmirPhysicsContracts.cs` | Current body/world/query DTOs plus reference implementation for step, overlap, and cast queries. | +| Ymir Unity bridge | `Assets/Scripts/Gameplay/Physics/AetheriaYmirPhysicsBridge.cs` | Current Unity presentation adapter that posts JSON to Ymir endpoints and maps daemon SoA bodies into Ymir query worlds. | +| Ymir query tests | `Assets/Scripts/Tests/YmirPhysicsQueryTests.cs` | Current expectations for integration, radial fields, contacts, overlap sphere/circle, and cast sphere/circle; useful semantics should survive, DTO/endpoint shape should not. | + +## Current Control Flow + +The C# host does this today: + +1. Parse `AetheriaDaemonHostOptions`. +2. Open `AetheriaStateNode` with a CultMesh node, CultCache registry, and CultNet registry. +3. Ensure baseline world, trade policy, playable run, Starbridge scenario/session, verse host settings, and authority policy. +4. Publish runtime session and Eve surfaces. +5. Start a loopback CultNet RUDP server for RTS clients. +6. Tick immediately, then tick by interval: + - Accept Eve commands. + - Choose next frame id and simulation time. + - Resolve current run from previous frame or durable documents. + - Read loadout templates, daemon commands, authority policy, Starbridge state, and leases. + - Filter commands through authority rules. + - Execute daemon operations. + - Step the compact RTS sim. + - Build a daemon frame. + - Publish committed command facts. + - Import remote committed command facts. + - Publish frame, health, command boundary, provider docs, Starbridge summary, SoA view, and Eve surfaces. + +This is behavior to measure and learn from. It is not the desired architecture. + +## Current Public Data Contract + +The Rust daemon must be able to read or bridge these old records during deprecation: + +| Record | Current key | +| --- | --- | +| Provider advertisement | `daemon:aetheria.provider_advertisement.v1` | +| Health | `daemon:aetheria.health.v1` | +| Command boundary | `daemon:aetheria.command_boundary.v1` | +| Latest frame | `daemon:aetheria.frame.latest.v1` | +| Latest SoA view | `daemon:aetheria.soa_view.latest.v1` | +| Starbridge scenario | `starbridge:aetheria.scenario.latest.v1` | +| Starbridge session | `starbridge:aetheria.session.latest.v1` | +| Game Eve surface | `eve:surface:aetheria.daemon.game` | +| Game Eve TUI surface | `eve:surface:aetheria.daemon.game.tui` | +| Editor Eve surface | `eve:surface:aetheria.daemon.editor` | +| Editor Eve TUI surface | `eve:surface:aetheria.daemon.editor.tui` | +| Daemon command | `daemon:commands:{stable-command-id}:gamecult.aetheria.daemon_command.v1` | +| Eve command | `eve:commands:{stable-command-id}:gamecult.eve.command.v1` | + +Current schema roots: + +| Contract | Source | +| --- | --- | +| Daemon command/frame/fact/projection/provider/health/boundary/surface docs | `AetheriaRuntimeDaemonDocuments.cs` | +| Runtime run/zone/entity/body/equipment/cargo/loadout docs | `AetheriaRuntimeSnapshotDocuments.cs` | +| SoA view docs and columns | `AetheriaRuntimeDaemonSoaDocuments.cs` | +| Authority policies and leases | `AetheriaRuntimeVerseAuthorityPolicy.cs` | +| Eve command docs | `AetheriaRuntimeEveCommandDocument.cs` | + +## Current Gameplay Command Vocabulary + +These are the gameplay operations exposed by the C# command document. The Rust rebuild should re-express them as typed native operations, not as public stringly transport calls. + +| Area | Commands | +| --- | --- | +| Targeting | `SetTarget`, `ClearTarget`, `TargetNearest`, `TargetNext`, `TargetPrevious`, `TargetReticle` | +| Pilot control | `SetMoveVector`, `SetLookDirection`, `SetTractorPower` | +| Combat and equipment | `FireWeaponGroup`, `SetWeaponGroupActive`, `SetWeaponGroupMembership`, `SetBehaviorActive`, `ActivateConsumable`, `SensorPing`, `SetHeatsinksEnabled`, `SetOverrideShutdown`, `SetShutdownPerformance`, `SetItemEnabled`, `ToggleShieldEnabled`, `SetItemOverrideShutdown`, `SetThermotoggleTargetTemperature` | +| Inventory, refit, economy | `TransferCargoItem`, `EquipItem`, `StoreItem`, `PickUpLoot`, `TradePurchase`, `RestoreLoadout`, `SetDockedCurrentShip` | +| Interaction and travel | `Dock`, `DockNearest`, `Undock`, `Interact`, `EnterWormhole`, `TowToStation` | +| Metadata and destruction | `SetEntityName`, `DestroyEntity`, `ToggleHullConductivity` | + +## Rust Daemon Perfect Shape + +The Rust daemon should be designed around typed state, typed operations, reactive subscriptions, and a Rust-native Ymir physics body. + +```mermaid +flowchart TD + Verse["CultMesh Verse\nschema, authority, replication"] + State["Aetheria State\nWorld, zones, entities, inventory, stats"] + Ops["Typed Operations\npilot, RTS, inventory, economy, editor"] + Ymir["Ymir Rust\nphysics body, queries, spatial acceleration"] + Sim["Aetheria Simulation\ncombat, heat, AI, sensors"] + Queries["Typed Queries\nviewports, gravity, selection, inventory, stats"] + Views["Native Views\nSoA slabs, WASM views, render views"] + Surfaces["Eve/CultUI Surfaces\nstate pointers + typed commands"] + Unity["Unity renderer/input shell"] + Browser["Browser/RTS client"] + Native["Native tools and daemons"] + + Verse --> State + Ops --> State + State --> Ymir + Ymir --> Sim + Sim --> State + State --> Queries + Queries --> Views + State --> Surfaces + Views --> Unity + Views --> Browser + Views --> Native + Surfaces --> Unity + Surfaces --> Browser + Surfaces --> Native +``` + +Desired properties: + +- A client can ask for typed state or a typed projection without knowing record-key plumbing. +- A client can submit a typed operation without constructing transport payloads. +- A UI surface can point at daemon state and let the CultUI runtime resolve it automatically. +- Unity receives native render views and typed state handles, not gameplay ownership. +- Browser clients can run as observers, controllers, or simulation hosts depending on Verse authority configuration. +- The daemon owns simulation and projections as state-native behavior, not as publication side effects. +- Ymir owns physical truth in Rust: stepping, overlaps, casts, contacts, broadphase, and spatial query acceleration. +- CultMesh authority is explicit per Verse, with server-authoritative, trusted distributed, lease, quorum, and browser/WASM host structures remaining open. + +## Ymir Rust Body + +Ymir should be rebuilt as a Rust-native physics foundation used by Aetheria, Unity, browser/WASM, and future runtimes. Its current C# implementation is behavior evidence, not the long-term body and not strict parity law. + +Current Ymir shape: + +| Area | Current source | Notes | +| --- | --- | --- | +| Contracts and reference implementation | `Assets/Scripts/ServerShared/YmirPhysicsContracts.cs` | Defines `YmirWorld`, `YmirPhysicsBody`, radial fields, contacts, 2D circle queries, 3D sphere queries, and reference step/query math. | +| Unity adapter | `Assets/Scripts/Gameplay/Physics/AetheriaYmirPhysicsBridge.cs` | Converts Unity/daemon render views into Ymir request DTOs and posts JSON to local query endpoints. This is presentation glue, not future architecture. | +| Tests | `Assets/Scripts/Tests/YmirPhysicsQueryTests.cs` | Current checks for stepping, radial fields, overlap/cast ordering, contact output, and invalid input handling. | + +The initial freeze exporter now emits `ymir-queries` probes from the current C# implementation: step integration, radial field acceleration, contact separation, overlap circle/sphere sorting, cast circle/sphere hits, and invalid-input behavior. These are typed measurement probes with explicit vector, world, body, field, hit, and contact records in JSON plus MessagePack. The next version should expand them with CultMath-shaped rect, circle, sphere, broadphase, sparse-cluster, and viewport-intersection fixtures. + +Rust Ymir should provide: + +- canonical `Vec2`, `Vec3`, `Rect`, `Circle`, `Sphere`, and body handles from CultMath-compatible primitives +- deterministic step for Aetheria body motion +- contacts and collision response for gameplay authority +- `overlap_circle`, `cast_circle`, `overlap_sphere`, `cast_sphere` +- viewport/intersection queries for gravity brushes and render/query visibility +- sparse-cluster-friendly broadphase and spatial acceleration +- SoA-friendly body storage that can be viewed by Aetheria and render clients +- WASM-compatible query and step surface +- CultMesh-friendly typed query/operation bindings where a runtime can ask for physical facts without knowing transport details + +The C# JSON endpoint shape should be understood only for migration and comparison. The Rust API should feel native: + +```rust +let hits = ymir + .world(zone_id) + .overlap_sphere(Sphere::new(center, radius)) + .filter(FactionMask::hostile_to(actor)) + .collect(); +``` + +That is the bar. Unity can keep a bridge for click affordances and presentation, but physical truth belongs to Rust Ymir and is consumed by Aetheria through typed state/query handles. + +## CultMesh Ergonomic Lessons + +See `Aetheria.State/docs/cultmesh-cross-runtime-primitives.md` for the expanded primitive design, ownership boundary, code-shape examples, and performance contract. This section is the short form kept inside the daemon map. + +Aetheria exposed where CultMesh should become more magical: + +| Current leak | Better CultMesh shape | +| --- | --- | +| Clients manually know record keys. | CultMesh provides generated typed document handles and query handles. | +| TS decodes MessagePack arrays by slot constants. | CultMesh provides generated typed TS/Rust/C#/WASM bindings with transparent encode/decode. | +| Commands are documents pushed to known records. | CultMesh provides typed Verse operations with transport chosen by runtime. | +| Local `.cc` files are used as client read path. | CultMesh subscriptions and scoped queries are the read path. | +| Viewport projections are injected into snapshot responses. | CultMesh supports first-class typed query surfaces for derived state. | +| Eve surfaces require manual state-ref resolution. | CultUI/CultMesh surface props can hold typed state pointers resolved by the runtime. | +| Unity needs special observer glue. | CultMesh gives Unity the same typed state handles plus native render views. | +| SoA view sharing is daemon-specific. | CultCache/CultMesh exposes typed native slices where co-location allows it. | +| Authority logic is app-specific glue. | CultMesh exposes reusable authority policy, lease, claim, and runtime-role primitives. | +| Colocated services still use bespoke endpoints. | CultMesh routes typed local calls, remote calls, WASM calls, and shared-memory/native-slice access behind one semantic API. | + +This rebuild should feed back into CultMesh itself. If Aetheria needs a hand-written adapter to do an obvious typed-state thing, that is probably a missing CultMesh primitive. The target is managed and cozy for the developer, but not soft underneath: the runtime should still pick the fastest available path, whether that is an in-process view, a shared CultCache slab, a WASM view, or a remote Verse route. + +### CultMesh Primitives To Hoist + +These are not Aetheria conveniences. They are shared library capabilities that should make future daemons feel native from every runtime: + +| Primitive | Shared capability | +| --- | --- | +| Typed document handles | Generated handles for records, schema ids, keys, subscriptions, and mutations. | +| Typed operation handles | Method-shaped operations with generated payloads, authority metadata, routing, idempotency, and acknowledgements. | +| Typed query surfaces | Composable derived-state queries that can run colocated, remote, or WASM without changing client code. | +| Reactive state pointers | Values that UI surfaces can hold directly, with automatic resolution, invalidation, and subscription lifetimes. | +| Native slice views | Safe typed views over CultCache/SoA slabs for Unity jobs, Rust, native clients, and WASM memory where possible. | +| Authority primitives | Reusable policy modes, leases, claims, runtime roles, and future quorum/consensus hooks. | +| Geometry/math values | Shared `Vec2`, `Vec3`, `Rect`, `Circle`, `Sphere`, transforms, and deterministic scalar helpers. | +| Locality-aware routing | Runtime chooses shared-memory, in-process, IPC, network, or WASM transport while the API remains semantic. | +| Schema evolution | Generated migration manifests, compatibility readers, and versioned operation/query contracts. | +| Surface bindings | Eve/CultUI components bind to typed state handles and operation handles instead of string refs and command names. | + +### Sugar Targets + +These examples are aspirational API targets. They are not promises about exact names; they show how little transport code a client should need to express ordinary Aetheria work. + +Current TS RTS client command path: + +```ts +const commandId = crypto.randomUUID(); +const issuedAtUtc = new Date().toISOString(); +await client.sendCommandDocument( + commandId, + issuedAtUtc, + encodeSetMoveVectorCommand(commandId, issuedAtUtc, runtimeId, { + actorEntityKey, + directionX, + directionY, + scalarValue, + })); +``` + +Desired TS CultMesh operation sugar: + +```ts +await verse + .aetheria() + .entity(actor) + .pilot() + .move({ direction: vec2(x, y), throttle }); +``` + +Current TS projection path: + +```ts +const frame = await publications.readDaemonFrame(); +const objects = projectObjectsViewportFromFrame(frame, { + minX, + minY, + maxX, + maxY, + controlledEntityIndices, +}); +``` + +Desired TS query sugar: + +```ts +const objects = await verse + .aetheria() + .zone(zoneId) + .objects() + .visibleTo(controlledUnits) + .within(rect.xy(min, max)) + .watch(); +``` + +Current Unity observation path: + +```csharp +var observed = ResolveClient().ObserveAsync().GetAwaiter().GetResult(); +if (observed != null && observed.HasSoaView) +{ + AetheriaDaemonSoaMemoryMap.TryOpen(observed.SoaIndex, out var map, out _); + AetheriaDaemonRenderNativeView.TryCreate(observed.SoaIndex, map, out var view); +} +``` + +Desired Unity renderer sugar: + +```csharp +await foreach (var view in verse + .Aetheria() + .CurrentZone() + .RenderView() + .AsNativeArrays()) +{ + ShipRenderJobs.Schedule(view.Entities); +} +``` + +Current Ymir query bridge shape: + +```csharp +var request = new YmirCircleOverlapRequest +{ + world = world, + center = ToVec2(center), + radius = radius +}; +var hits = PostJson(OverlapCircleUrl, request).hits; +``` + +Desired Rust Ymir/Aetheria query sugar: + +```rust +let hits = verse + .aetheria() + .zone(zone) + .physics() + .overlap_circle(Circle::new(center, radius)) + .exclude(actor) + .collect() + .await?; +``` + +Current Eve surface state-ref resolution: + +```csharp +var resolver = AetheriaRuntimeVerseClient.CreateEveSurfaceStateRefResolver(statePath, runtimeId); +var label = resolver("aetheria.daemon/frame/currentEntity/name"); +``` + +Desired CultUI state pointer sugar: + +```ts +surface.label({ + text: verse.aetheria().currentEntity().name, +}); + +surface.button({ + icon: "crosshair", + command: verse.aetheria().currentEntity().targetNearest, +}); +``` + +Current C# operation facade is close, but still hides snapshot/transport setup behind a Unity-owned observer: + +```csharp +observer.Operations.SetTarget(targetEntityKey); +observer.Operations.FireWeaponGroup(0); +``` + +Desired cross-runtime typed operation sugar: + +```csharp +await verse.Aetheria() + .Entity(actor) + .Combat + .Target(target) + .Fire(group: 0); +``` + +Current refit/economy fixture shape: + +```csharp +command.TextValue = "repair-parts"; +command.CargoTransfer.OriginEntityKey = station; +command.CargoTransfer.OriginCargoIndex = 0; +command.CargoTransfer.DestinationEntityKey = actor; +command.CargoTransfer.DestinationCargoIndex = 0; +command.CargoTransfer.SourceX = 0; +command.CargoTransfer.SourceY = 0; +command.CargoTransfer.DestinationX = 2; +command.CargoTransfer.DestinationY = 0; +command.CargoTransfer.HasDestinationPosition = true; +``` + +Desired inventory operation sugar: + +```ts +await verse + .aetheria() + .entity(actor) + .inventory() + .cargo() + .takeFrom(station.inventory().cargo()) + .item("repair-parts") + .placeAt(grid(2, 0)); +``` + +Desired trade sugar: + +```rust +verse + .aetheria() + .entity(actor) + .inventory() + .buy_from(station) + .item("reactor-fuel") + .quantity(1) + .place_in(CargoBay::primary()) + .await?; +``` + +The common pattern: state access starts from a typed Verse handle, operations hang from typed state objects, queries compose like native collections, and subscriptions return reactive state or native views. Record keys, schema ids, endpoint selection, MessagePack slots, command ids, and local/remote routing are CultMesh runtime concerns. + +## Deprecation Boundary + +The C# daemon becomes deprecated when these artifacts exist: + +1. A written measurement snapshot of current schemas, record keys, operation meanings, client semantics, and projection outputs. +2. Migration probe fixtures for: + - seeded run state + - command batches + - authority acceptance/rejection + - post-tick frames + - objects viewport + - gravity viewport + - selected object + - inventory + - SoA view descriptor +3. A Rust daemon design that does not inherit local publication files, TS projection duplication, Unity coordinate leakage, or host-loop scenario seeding. +4. A CultMesh ergonomic checklist identifying shared cross-runtime primitives to hoist before the daemon and clients harden around local adapters. The detailed checklist lives in `Aetheria.State/docs/cultmesh-cross-runtime-primitives.md`. + +After that, the C# daemon should be deprecated except for critical migration fixes. + +The concrete artifact and fixture checklist lives in `Aetheria.State/docs/rust-rebuild-freeze-checklist.md`. + +## Rust Rebuild Modules + +These are product-shape modules, not incremental migration slices: + +| Module | Responsibility | +| --- | --- | +| `aetheria_schema` | Canonical schemas, generated bindings, record/query/operation identities, and legacy MessagePack migration support. | +| `aetheria_world` | World, zones, entities, bodies, inventory, loadouts, stats, keys, deterministic state layout. | +| `aetheria_authority` | Verse authority policies, leases, roles, claims, and runtime participation rules. | +| `aetheria_ops` | Typed gameplay operations and validation. | +| `ymir_math` | CultMath-compatible vectors, rects, circles, spheres, transforms, deterministic scalar helpers. | +| `ymir_physics` | Rust-native bodies, fields, broadphase, stepping, contacts, overlap/cast queries, spatial acceleration. | +| `aetheria_sim` | Aetheria rules over Ymir physics: combat, heat, AI, sensors, stat evaluation, deterministic tick logic. | +| `aetheria_query` | First-class typed queries for objects viewport, gravity viewport, current object, inventory, stats, Starbridge state, and client-visible sets. | +| `aetheria_views` | SoA/native/WASM render views, dirty ranges, and cross-runtime native slice semantics. | +| `aetheria_surfaces` | Eve/CultUI state-backed surfaces, state pointer resolution, and operation bindings. | +| `aetheria_host` | Native daemon process, CultMesh runtime, persistence, authority mode configuration. | +| `aetheria_wasm` | Browser simulation host and query runtime. | + +The module boundary should stay honest: Aetheria modules define Aetheria concepts, while CultMesh/CultLib absorbs the general machinery for typed state, generated handles, authority routing, native views, geometry values, reactive surfaces, transport selection, and schema evolution. + +## Design Constraints For The New Daemon + +- Gameplay plane is XY. Unity XZ conversion belongs in Unity adapters. +- State schema should model vectors and rects as first-class values. +- Ymir primitives should be Rust-first and CultMath-compatible; C# DTOs are compatibility mirrors. +- SoA columns may store vector values as vector columns; they do not need to split every component unless the query actually benefits. +- The simulation receives explicit tick time. Wall clock is publication metadata only. +- Queries are derived state. Clients should ask for what they need: visible objects, gravity influences, stats, inventory, render views. +- Rendering views are not authority. Unity, browser, and native clients consume views and submit typed operations. +- The browser can be a simulation host when authority policy grants it that role. +- Local co-deployment can expose native slices, but remote runtimes must see the same semantic state through CultMesh. +- The new API should make the common path short enough that client code reads like native Aetheria code. + +## Things To Bury With The C# Daemon + +- Local `.cc` publication files as normal client API. +- C# or Unity-shaped Ymir as the authority path. +- JSON HTTP Ymir calls as the normal in-process/co-deployed path. +- TS projection copies that parse raw MessagePack slots. +- RUDP snapshot projection injection as the query model. +- Unity coordinate debt in shared schema. +- Host-loop seeding as world definition. +- UI surfaces that require external manual state-ref resolution. +- Client code that knows transport boundaries for ordinary state access. +- Any public `Apply(command, payload)` style operation surface. + +## First Rebuild Milestone + +The first Rust milestone should demonstrate the final shape, not a half-port: + +1. Define canonical schema and hoisted CultMesh primitives for Rust, TS, Unity/C#, browser/WASM, and Eve/CultUI, using `Aetheria.State/docs/cultmesh-cross-runtime-primitives.md` as the shared-layer target. +2. Launch a Rust Aetheria Verse node. +3. Load the measured C# fixture world as a migration sample. +4. Accept typed operations from a TS client through CultMesh. +5. Run simulation authority in Rust. +6. Answer typed viewport and gravity queries. +7. Publish a native/WASM-friendly render view. +8. Let a browser client render and command the world without local `.cc` reads or duplicated projection code. +9. Keep Unity as an optional renderer/input shell over the same typed state and render view semantics. + +That is the bar: C# is documented, measured, and deprecated; the rebuild is not the old daemon in a new language, it is the daemon, client API, and CultMesh surface Aetheria was trying to become. diff --git a/Aetheria.State/docs/rust-rebuild-freeze-checklist.md b/Aetheria.State/docs/rust-rebuild-freeze-checklist.md new file mode 100644 index 000000000..a7a16ae50 --- /dev/null +++ b/Aetheria.State/docs/rust-rebuild-freeze-checklist.md @@ -0,0 +1,105 @@ +# Aetheria Rust Rebuild Freeze Checklist + +This checklist turns the current C# daemon and client integrations into a measured, deprecated reference before the rebuild begins in earnest. The goal is not to migrate the C# shape piece by piece, and it is not strict parity with every current wart. The goal is to make current behavior visible enough that the new daemon, client APIs, and shared CultMesh/CultLib primitives can deliberately preserve, improve, or discard it while building the clean Aetheria/Ymir/CultMesh shape. + +## Freeze Artifacts + +| Artifact | Source of truth | Output | +| --- | --- | --- | +| Daemon schema catalog | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs` | Generated schema manifest with schema ids, document names, MessagePack key order, enum ids, and record key conventions. | +| Runtime snapshot schema catalog | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs` | Generated schema manifest for run, zone, entity, body, inventory, loadout, cargo, equipment, and stat grids. | +| Authority schema catalog | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs` | Generated schema manifest plus policy-mode behavior table. | +| Ymir C# reference sample | `Assets/Scripts/ServerShared/YmirPhysicsContracts.cs` | Current DTO and query behavior evidence for Rust Ymir design. Preserve only semantics we still want. | +| Current TS generated contract | `Aetheria.Rts.Web/scripts/generate-rts-bindings.mjs` and `Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts` | Evidence that schema extraction exists today; replace it with shared CultMesh/CultLib generation for typed handles, operations, queries, state pointers, native views, authority, and migration manifests. | +| Shared CultMesh/CultLib primitive target | `Aetheria.State/docs/cultmesh-cross-runtime-primitives.md` | Design bar and acceptance vocabulary for hoisting typed documents, operations, queries, state pointers, native views, authority, geometry, locality routing, schema evolution, and surface bindings out of Aetheria glue. | +| Initial daemon fixture exporter | `Aetheria.State.Freeze/Program.cs` | Runs the C# daemon once, reads current daemon publications, emits normalized JSON plus MessagePack bytes for frame, public docs, SoA descriptor, projection fixtures, authority decisions, Ymir query probes, and seeded pilot/interaction/refit command-batch fixtures. | +| Current client verifier gates | `Aetheria.Rts.Web/scripts/verify-stage7b-rts-client.ps1`, `verify-stage7c-local-runtime.ps1`, `verify-stage7c-electron-shell.ps1`, `Aetheria.State/scripts/verify-stage7d-unity-parity.ps1` | Current smoke gates while C# is still live. They prove today still runs; they do not define the future daemon or client API surface. | + +## Golden Fixtures + +The C# daemon and clients are measured when these fixtures can be generated from the current implementation. The rebuild should consume them as migration probes: green where current behavior is intentional, red or replaced where current behavior is accidental, and redesigned wherever the client semantics should become simpler. + +| Fixture | Must contain | Proves | +| --- | --- | --- | +| `seeded-local-rts-world` | The daemon-seeded `local-rts` run with zones, entities, bodies, inventory, loadouts, stat grids, current entity, and authority policy. | Rust can load current world semantics without inheriting the host seeding code. | +| `command-batch-pilot` | Targeting, movement, look, tractor, sensor ping, shield toggle, weapon group fire. Initial exporter emits this batch and captures applied/rejected command accounting. | Rust operations cover the same gameplay intents through typed operations. | +| `command-batch-refit` | Rename, transfer cargo, trade purchase, equip from cargo, store equipped item, set docked current ship, pick up loot, restore loadout. Initial exporter emits this batch and captures applied/rejected command accounting: rename/transfer/trade/equip/store currently apply; current-ship selection, pickup without a seeded drop, and restore without a template currently reject. | Rust operations cover inventory/economy/refit intent without inheriting command-document ergonomics or accidental rejection behavior. | +| `command-batch-interaction` | Dock, dock nearest, undock, interact, tow to station. Initial exporter emits this batch and captures applied/rejected command accounting. | Rust operations cover interaction/travel intent and may simplify ambiguous current behavior. | +| `authority-decisions` | Allowed and rejected commands for host-authoritative, delegated runtime, owning runtime, and lease cases. | Rust authority has explicit behavior for currently implemented modes and explicit design choices for modes C# only stubs. | +| `post-tick-frame` | Frame after a fixed command batch and fixed delta, including command accounting and committed facts. | Rust tick composition can be compared to current output where useful, then diverge where the new simulation model is better. | +| `objects-viewport` | XY rect, controlled unit set, visible objects, statuses, inventory summaries. | Rust query surface can replace TS local projection copies. | +| `gravity-viewport` | XY rect, intersecting gravity brushes, body views. | Rust query surface can replace Unity/TS gravity reconstruction. | +| `selected-object` | Entity selection, status, target, faction, inventory identity. | Rust selected-object query supports current UI expectations through cleaner typed state. | +| `inventory` | Equipment and cargo rows, source identity, item stats, quantity, quality, durability. | Rust inventory projection can drive Unity and RTS panels. | +| `soa-view` | Current-zone render columns, column kinds, vector columns, dirty ranges, native view descriptor. | Rust view publication can feed Unity and native clients. | +| `ymir-queries` | Step integration, radial field acceleration, contact separation, overlap circle, cast circle, overlap sphere, cast sphere, and invalid-input cases. Initial exporter emits typed request/result probes with explicit vector, world, body, field, hit, and contact records in JSON plus MessagePack. Vector fields now use shared CultLib `GameCult.Geometry` primitives (`CultVec2`/`CultVec3`) instead of fixture-local DTOs. | Rust Ymir preserves useful physical semantics while replacing C# endpoint shape with Rust/CultMath primitives. | + +## Rust Rebuild Acceptance Gates + +The first rebuild milestone is accepted only when the new architecture demonstrates the intended cross-runtime shape. + +1. A Rust Verse node can load the measured fixture world and publish typed Aetheria state. +2. A TS client can submit typed operations through shared CultMesh operation handles without constructing command documents, record keys, or raw MessagePack payloads. +3. A browser client can render map state by watching shared typed object/gravity query surfaces, not by reading local `.cc` files or duplicating projection code. +4. A Unity client can consume a shared typed render view as native arrays without owning gameplay state or running Unity physics for authority. +5. Rust Ymir owns physical truth for step, overlap, cast, contact, and broadphase queries. +6. Eve/CultUI surfaces can bind to shared CultMesh state pointers and typed operations without manual state-ref resolver glue in client code. +7. Authority policy is configured through shared CultMesh authority primitives and changes who may author claims without changing operation schemas. +8. The API can run in native daemon mode and expose a WASM-compatible surface for browser simulation host mode. +9. The old C# daemon can be turned off for the browser RTS proof and the world still runs, renders, and accepts commands. + +## CultMesh Sugar Gates + +These are not cosmetic. If these fail, the architecture is still leaking. + +The expanded primitive spec is `Aetheria.State/docs/cultmesh-cross-runtime-primitives.md`. Use it as the shared-library ownership boundary whenever a new adapter starts appearing in Aetheria-specific code. + +| Gate | Bad smell | Desired shape | +| --- | --- | --- | +| Typed operations | Client code constructs `CommandId`, `recordKey`, `schemaId`, or `document_put_raw`. | CultMesh operation handles expose `verse.aetheria().entity(actor).pilot().move(...)`. | +| Typed queries | Client code fetches a frame and calls local projection helpers. | CultMesh query surfaces expose `verse.aetheria().zone(zone).objects().visibleTo(units).within(rect)`. | +| Typed state pointers | UI code resolves string state refs manually. | CultMesh state pointers are first-class values resolved by CultUI/CultMesh runtime. | +| Native views | Unity code manually opens memory maps by daemon-specific paths. | CultMesh native slice views expose `RenderView().AsNativeArrays()` from the typed Verse handle. | +| Authority | Each daemon invents policy, lease, claim, and runtime-role glue. | CultMesh authority primitives carry policy modes, claims, leases, runtime roles, and future quorum hooks. | +| Geometry | Each runtime reinvents vectors, rects, circles, spheres, and coordinate conversions. | CultMath/CultMesh provide shared geometry values and deterministic scalar helpers. | +| Locality | Co-deployed services still talk through bespoke URLs or files. | CultMesh chooses in-process, shared slab, IPC, network, or WASM transport behind one semantic API. | +| Ymir queries | Unity or TS posts JSON to a hard-coded local URL for normal co-deployed queries. | Aetheria/Ymir queries are typed, colocated when possible, and remotely routable when needed. | +| Schema generation | TS, C#, and Rust each hand-maintain slot maps. | One schema source generates cross-runtime CultMesh bindings and legacy migration manifests. | +| Client semantics | Each runtime gets bespoke helper layers and transport assumptions. | Unity, TS, browser, Rust, native, and Eve/CultUI share the same operation/query/state semantics with runtime-specific ergonomic sugar. | + +## Current Verification Commands + +These are current-state gates, not proof that the Rust rebuild is complete. They are useful while freezing the C# contract. + +```powershell +dotnet build .\GameCult.Aetheria.State.Unity.csproj /clp:ErrorsOnly /p:UseSharedCompilation=false +dotnet build .\Aetheria.State.Daemon\Aetheria.State.Daemon.csproj /clp:ErrorsOnly /p:UseSharedCompilation=false +dotnet run --project .\Aetheria.State.Verify\Aetheria.State.Verify.csproj +.\Aetheria.State\scripts\verify-stage7d-unity-parity.ps1 +cd .\Aetheria.Rts.Web +npm run check:rts-bindings +npm run build +npm run verify:stage7b +npm run verify:stage7c +npm run verify:stage7c:electron +``` + +## Immediate Build Work + +1. Expand `Aetheria.State.Freeze` beyond baseline plus pilot/interaction/refit/authority/Ymir probes into post-tick, richer authority, and richer Ymir fixture families. +2. Expand `generate-rts-bindings.mjs` or replace it with shared CultMesh/CultLib schema and binding generation for Rust, TS, Unity/C#, browser/WASM, and Eve/CultUI, including legacy migration manifests. +3. Expand the initial CultMath-shaped Ymir fixture cases with explicit rect, circle, sphere, broadphase, sparse-cluster, and viewport-intersection schemas. +4. Begin the shared primitive implementation from `cultmesh-cross-runtime-primitives.md` before the Rust daemon hardens around one-off Aetheria adapters. +5. Create an initial Rust workspace with `ymir_math`, `ymir_physics`, `aetheria_schema`, and `aetheria_world`. +6. Make Rust Ymir satisfy the intentional Ymir fixture cases before wiring it into Aetheria; document any deliberate improvements over C# behavior. +7. Make Rust Aetheria load the measured world fixture and answer object/gravity viewport queries through hoisted CultMesh query/state primitives shared by TS, Unity, browser, and Eve/CultUI. + +Generate the initial daemon fixture set with: + +```powershell +dotnet run --project .\Aetheria.State.Freeze\Aetheria.State.Freeze.csproj -- --root . --state .\obj\freeze\aetheria-freeze-state.cc --out .\Aetheria.State\fixtures\rust-rebuild-freeze +``` + +For smoke runs that should not touch checked-in fixtures, write to `.\obj\freeze-smoke\fixtures`. + +The rebuild begins once the C# behavior is measured enough to learn from it without copying its leaks. diff --git a/Aetheria.State/docs/stage-7-client-surface-inventory.md b/Aetheria.State/docs/stage-7-client-surface-inventory.md new file mode 100644 index 000000000..092324671 --- /dev/null +++ b/Aetheria.State/docs/stage-7-client-surface-inventory.md @@ -0,0 +1,298 @@ +# Stage 7A Client Surface Inventory + +Date: 2026-06-24 + +Stage 7A exists to stop the client migration from turning into Jenga. This is +the reviewed inventory for Unity, Electron, and daemon client-facing surfaces. +Stage 7D.1 refreshed the Unity half of this ledger before Unity parity code +movement began. + +## Gate + +Current gate: Stage 7D.4 Unity projection reroute. + +Implementation runbook: + +- `Aetheria.State/docs/stage-7-thin-client-staged-implementation-plan.md` + +Stage 7A is complete. Stage 7D.1 reuses this artifact as the edit queue: every +remaining Unity-facing mutation and gameplay read/projection path must be +assigned to `7D.2 facade`, `7D.3 commands`, `7D.4 reads`, `7D.5 renderer`, or +`8 demolition`. Stage 8 Unity gameplay shell demolition may not start until +Stage 7E passes. + +## Target Shape + +```text +client input -> local typed Verse command document -> local daemon +local daemon state -> local typed projection/read -> renderer or UI panel +peer daemon facts -> local authority import -> local state +``` + +Forbidden as public client shape: + +- `command(kind, payload)`; +- browser or Unity code constructing transport payloads directly; +- hand-encoded MessagePack array layouts in client code; +- remote gameplay viewport reads used as state authority; +- cached command-port facades or ad-hoc buses between input and the local Verse + command document. + +## Electron Mutation Surfaces + +| Surface | Evidence | Current Shape | Decision | Stage | +| --- | --- | --- | --- | --- | +| Browser right-click move/target | `Aetheria.Rts.Web/Client/app.ts:478`, `:490` | Browser now calls typed `window.aetheriaRts.setTarget(...)` and `setMoveVector(...)`; it no longer constructs `{ kind: ... }` transport payloads. | Keep as temporary typed facade. Replace with shared generated TS Verse bindings when available. | 7B/7C | +| Preload command bridge | `Aetheria.Rts.Web/Electron/preload.cjs:5`, `:6` | Exposes typed `setMoveVector` and `setTarget` IPC calls. | Keep until the broader typed runtime facade replaces preload method wiring. | 7B | +| Electron IPC command handler | `Aetheria.Rts.Web/Electron/main.ts:98`, `:101` | Accepts `RtsSetMoveVectorRequest` and `RtsSetTargetRequest` and forwards to typed client methods. | Keep as Stage 7B shim. | 7B | +| TypeScript CultMesh command writer | `Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts`; `Aetheria.Rts.Web/Electron/aetheria-rts-bindings.ts`; `Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts` | Transport wrapper exposes typed methods; generated TS contract metadata owns schema ids, command kind ids, and MessagePack slot maps derived from the C# `[Key]` declarations, including the current 30-slot daemon command document. | Keep the semantic encode helpers here until the broader CultMesh TS typed document writer lands; do not reintroduce manual slot maps. | 7B | +| Daemon RTS command receive hook | `Aetheria.State.Daemon/Program.cs:454` | RUDP handler accepts raw command documents and submits them to the local node. | Quarantine for 7B, then replace or narrow. It can remain as an internal local typed document transport endpoint, but it must not define the public client API. | 7B | + +## Electron Read And Projection Surfaces + +| Surface | Evidence | Current Shape | Decision | Stage | +| --- | --- | --- | --- | --- | +| Browser viewport polling | `Aetheria.Rts.Web/Client/app.ts:166` | Browser requests `mapViewport` every 50 ms and treats the returned document as the main map projection. | Keep as named projection shim, but replace underlying remote viewport request with local typed projection reads. | 7B/7C | +| Preload viewport bridge | `Aetheria.Rts.Web/Electron/preload.cjs:4` | Exposes typed `mapViewport` projection request. | Keep until the broader typed runtime facade replaces preload method wiring. | 7B | +| Electron IPC viewport handler | `Aetheria.Rts.Web/Electron/main.ts:95` | Accepts typed viewport bounds and forwards to `AetheriaCultMeshClient.mapViewport`. | Keep as Stage 7B shim. | 7B | +| TypeScript CultMesh viewport reader | `Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts`; `Aetheria.Rts.Web/Electron/aetheria-rts-bindings.ts`; `Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts` | Transport wrapper asks for the named RTS viewport schema; generated TS contract metadata owns viewport slot maps while semantic decode helpers produce ergonomic UI types. | Replace the remote viewport request with local typed projection reads. | 7B/7C | +| Daemon RTS viewport hook | `Aetheria.State.Daemon/Program.cs:420`, `:501`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs` | Daemon snapshot hook now delegates viewport construction to the shared typed `AetheriaRuntimeRtsProjection` API. The hook still serves it over RUDP for Electron. | Keep only as temporary transport plumbing. Replace Electron's RUDP viewport read with local projection reads, then delete or narrow this hook. | 7B/7C | + +## Unity Mutation Surfaces + +| Surface | Evidence | Current Shape | Decision | Stage | +| --- | --- | --- | --- | --- | +| Runtime input settings commands | `Assets/Scripts/UI/InputScreen/InputDisplayLayout.cs` | `InputDisplayLayout` now owns an explicit `AetheriaClient` and submits typed input settings commands through the shared facade. The old static `ActionGameManager` input-settings ingress has been deleted. | Keep as the Stage 7D direct-panel pattern: panel owns presentation/input state, facade owns typed Verse submission. | 7D | +| Loadout restore | `Assets/Scripts/Gameplay/ActionGameManager.cs:1110`; caller at `Assets/Scripts/UI/Menu/InventoryPanel.cs:258` | Unity UI calls manager method for daemon-owned loadout operation. | Replace with typed client facade method. | 7D | +| Daemon operation submitter | `Assets/Scripts/Gameplay/AetheriaDaemonObserver.cs:40`, `:44`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationClient.cs:49`, `:54` | Unity uses typed operation wrappers that open `AetheriaRuntimeVerseClient` and submit `AetheriaRuntimeDaemonCommandDocument`. | Keep as the semantic source for Stage 7B typed commands, but remove per-call client open churn when the shared runtime wrapper lands. | 7B/7D | +| Surface command helpers | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSurfaceCommands.cs:23`, `:37` | Eve/CultUI surface commands submit typed daemon operations. | Keep, but ensure surface command templates point at typed documents or typed state refs rather than generic payload maps. | 7D | + +## Unity Read And Projection Surfaces + +| Surface | Evidence | Current Shape | Decision | Stage | +| --- | --- | --- | --- | --- | +| Daemon observer polling | `Assets/Scripts/Gameplay/AetheriaDaemonObserver.cs:130`, `:139` | Opens `AetheriaRuntimeVerseClient` and reads observed daemon state. | Keep. This is the closest current Unity surface to the target contract. | 7D | +| SoA native view mapping | `Assets/Scripts/Gameplay/AetheriaDaemonObserver.cs:83`, `:153` | Maps daemon SoA view into `AetheriaDaemonRenderNativeView`. | Keep. This remains the rendering fast path. | 7D/8 | +| `ActionGameManager` frame lowering | `Assets/Scripts/Gameplay/ActionGameManager.cs`; `Assets/Scripts/Gameplay/AetheriaUnityObservedFrameApplier.cs` | Unity loop delegates `ApplyLatestZoneRender` to the observed frame applier, which reads the typed `ZoneRenderAsync()` facade. | Quarantine. Stage 7D may keep it as a shell; Stage 8 should delete or reduce it once Unity renders from projection/native views. | 7D/8 | +| Observed Galaxy facade | `Assets/Scripts/Gameplay/AetheriaUnityObservedRunProjection.cs`; created from typed sector-map boot in `Assets/Scripts/UI/MainMenu.cs` | Unity maintains an observed facade graph only as a legacy scene-construction adapter. `ActionGameManager` no longer exposes or stores it. | Quarantine for Stage 7D, demolish in Stage 8 where clients can use equivalent typed projections. | 7D/8 | +| `ZoneRenderer` frame projection | `Assets/Scripts/Zone Display/ZoneRenderer.cs:239` | Persistent GameObject projection cache lowers typed `zone_render`, viewport, and contact feeds. | Keep as presentation cache only until Stage 8. It must not become an authority or mirrored hierarchy owner. | 7D/8 | +| Menu reads from manager singletons | Examples: `Assets/Scripts/UI/Menu/SectorMap.cs:94`, `Assets/Scripts/UI/Menu/InventoryMenu.cs:411`, `Assets/Scripts/UI/Menu/TradeMenu.cs:530` | UI panels read catalog/settings/state through `ActionGameManager`. | Replace with shared client projection/state refs as panels are migrated. | 7D/8 | + +## Shared Runtime Contract Surfaces To Keep + +| Surface | Evidence | Decision | +| --- | --- | --- | +| `AetheriaRuntimeVerseClient` | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs:153`, `:177`, `:508` | Keep and make it the shape both Unity and Electron mirror. TS needs generated/manual bindings for the same typed records. | +| Typed operation wrappers | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationClient.cs:7`; `AetheriaRuntimeDaemonOperationsClient` wrappers | Keep as semantic command catalog. Stage 7B should expose equivalent TS typed operations. | +| Render queries | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonRenderQueries.cs:645`, `:974`, `:1079`, `:1165` | Keep as projection internals. Unity consumes typed zone/body/contact/viewport rows; asteroid instance poses are now carried by `zone_render` instead of recomputed from snapshots. | +| Authority policy and health docs | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs:45`; `AetheriaRuntimeVerseClient.GetHealthAsync` at `:237`; generated TS metadata in `Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts` | Keep. Stage 7 client startup must show policy id, peer health, daemon endpoint, frame id/time, and import counters. | + +## Ad-Hoc Bus Search Results + +Reviewed search terms: + +```text +command, viewport, Apply(command, payload, Queue, ConcurrentQueue, Channel, +Bus, EventBus, CommandPort, cached command, port +``` + +Actionable hits now: + +- TypeScript raw CultMesh document put and snapshot request helpers. +- Daemon RTS command/viewport request handlers. + +Retired hits: + +- Electron IPC `aetheria-rts:command` and `aetheria-rts:viewport`; +- browser `window.aetheriaRts.command` and `window.aetheriaRts.viewport`; +- public `RtsCommandRequest` command union; +- `createCommandDocument`. + +Non-actionable or already-quarantined hits: + +- shader/material `Queue` metadata; +- ordinary import/export/transport wording; +- daemon peer endpoint option parsing; +- command receipt/import bookkeeping from Stage 6. + +No current `CommandPort`, `CachedCommandPort`, `EventBus`, or public +`Apply(command, payload)` implementation was found in the scoped search. + +## Stage 7D.1 Unity Edit Queue + +Date: 2026-06-24 + +Stage 7D.0 verifier baseline passed after the Electron app-shell smoke was +hardened. Unity parity may now begin, but only through the following queue. + +Reviewed source search: + +```powershell +rg -n "ActionGameManager|AetheriaDaemonObserver|AetheriaRuntimeDaemonOperationClient|AetheriaRuntimeDaemonSurfaceCommands|ZoneRenderer|Rigidbody|Collider|Physics\.|SubmitDaemonCommand|CommandPort|CachedCommandPort|EventBus|Apply\(" Assets/Scripts Packages/org.gamecult.aetheria.state/Runtime -g "*.cs" -S +rg -n "ObservedGalaxy|LoadZone|ApplyDaemonFrame|GameObject\.Find|FindObjects|GetComponent<" Assets/Scripts -g "*.cs" -S +rg -n "Queue|Channel|ConcurrentQueue|Bus" Assets/Scripts Packages/org.gamecult.aetheria.state/Runtime -g "*.cs" -S +``` + +### 7D.2 Facade Candidates + +Status on 2026-06-24: initial shared facade is in place. +`AetheriaClient` owns the local Verse client lifetime, exposes typed daemon +operations, and provides named local reads for map viewports, selected object, +inventory, daemon health, authority policy, and SoA view data. +`AetheriaDaemonObserver` now exposes that shared client instead of keeping a +separate operation client and Verse client. The remaining rows are either +carried into 7D.3 command reroute or 7D.4 projection reroute. + +| Surface | Evidence | Current Shape | Stage 7D Action | +| --- | --- | --- | --- | +| Unity local daemon observer | `Assets/Scripts/Gameplay/AetheriaDaemonObserver.cs` | Now owns a shared `AetheriaClient`, local observation cursor, and SoA render mapping. | Keep as Unity presentation shell. 7D.3 should move remaining command ingress through `observer.Client`/`observer.Operations`; 7D.4 should move read sites to named facade projections. | +| Runtime daemon operation client | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationClient.cs` | Typed command helper. The shared facade can now inject a submit delegate so sends reuse its long-lived Verse client instead of opening a new client per command. | Keep as typed command document builder behind `AetheriaClient`. Do not expose generic command APIs above it. | +| Generated operation catalog | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationsClient.cs` | Ergonomic typed wrappers over `AetheriaRuntimeDaemonOperationClient`; nullable observed state is now explicit for pre-frame command issue. | Keep as public typed operation vocabulary exposed through `AetheriaClient.Operations`. | +| Eve/CultUI surface commands | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSurfaceCommands.cs:8`, `:37`; catalog at `AetheriaRuntimeDaemonSurfaceCommandCatalog.cs:58` | Surface command ingress now routes through `AetheriaClient` and facade-backed daemon command document submission. | Keep as typed Eve ingress behind the facade. Do not let UI callers submit daemon documents directly. | +| Runtime Verse client submit | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs:508`, `:528` | Canonical typed command document submission. | Keep as the low-level primitive used by the facade. Do not expose stringly command APIs above it. | + +### 7D.3 Command Reroute Queue + +| Surface | Evidence | Current Shape | Stage 7D Action | +| --- | --- | --- | --- | +| Input settings commands | `Assets/Scripts/UI/InputScreen/InputDisplayLayout.cs` | Panel now resolves and owns an `AetheriaClient`, then submits typed input settings values through `AetheriaClient.SubmitInputSettingsCommandAsync`. | Done for 7D.3. Use this as the pattern for remaining direct UI command reroutes. | +| Loadout template save/delete | `Assets/Scripts/UI/Menu/InventoryPanel.cs`; projection helper at `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeLoadoutSnapshotProjector.cs` | `InventoryPanel` owns loadout-template save submission through `AetheriaClient.SubmitLoadoutTemplateCommandAsync` and now asks `AetheriaClient.LoadoutTemplateAsync(entityKey)` to project the target daemon entity into a template. Save no longer serializes the Unity `Entity` facade. | Done for save. The remaining Unity loadout projector is renderer/bootstrap adapter code for constructing Unity presentation objects from saved templates, not the save path. | +| Loadout restore/current ship/name/hull toggles | `Assets/Scripts/UI/Menu/InventoryPanel.cs` | `InventoryPanel` owns an explicit `AetheriaClient` and submits `RestoreLoadout`, `SetDockedCurrentShip`, `SetEntityName`, and `ToggleHullConductivity` through `AetheriaClient.Operations`. The old manager request shims are gone; the panel uses the manager only to resolve observed Unity facade entities to daemon record keys and local price context. | Done for this dropdown/current-ship slice. Price/context logic that another runtime needs should move into daemon/state code before Stage 8. | +| Inventory drag/drop and double-click item transfer | `Assets/Scripts/UI/Menu/InventoryPanel.cs`; `Assets/Scripts/UI/Menu/InventoryMenu.cs` | Inventory panels/menus own explicit `AetheriaClient` instances and submit `TransferCargoItem`, `EquipItem`, and `StoreItem` through typed facade operations. `ActionGameManager` no longer exposes cargo/equipment transfer request shims; it only resolves observed Unity facade cargo/equipment objects to daemon record keys and indices. | Done for this transfer slice. | +| Trade purchases | `Assets/Scripts/UI/Menu/TradeMenu.cs` | `TradeMenu` owns an explicit `AetheriaClient` and submits typed `TradePurchase` operations for station stock, cargo purchases, commodity quantity purchases, and docked ship hull purchases. `ActionGameManager` no longer exposes trade purchase request shims. | Done for current trade purchase surface. Station-stock authoring/refit verbs still need first-class typed surfaces where they are not already represented by trade commands. | +| Equipped-item details and action-bar binding commands | `Assets/Scripts/UI/Menu/InventoryMenu.cs`; `Assets/Scripts/Gameplay/ActionBarSlot.cs` | `InventoryMenu` submits equipped-item override shutdown, thermotoggle target temperature, weapon-group membership, action-bar bind, and action-bar clear through its explicit `AetheriaClient`. Restored action-bar bindings hold an explicit `AetheriaClient` and submit consumable, behavior-active, and weapon-group-active typed operations. `ActionGameManager` no longer exposes public request shims for these menu commands; it only resolves observed item identity and action-bar control paths. | Done for current detailed item/action-bar surface. Drag-to-action-bar still originates in the manager because drag registration currently lives there, but it submits through the shared facade rather than a daemon shim. | +| Runtime target/control commands | `Assets/Scripts/Gameplay/ActionGameManager.cs` plus `AetheriaDaemonObserver.Operations` | `ActionGameManager` remains the local input gesture router for movement, look, tractor power, target selection/cycling, reticle targeting, override shutdown, sensor ping, heatsinks, shields, interact, tow, dock, and undock. It now submits those through a shared typed facade operation helper instead of private daemon shim methods. | Done for current pilot input ingress. Stage 8 should contract this to input orchestration only after equivalent projections replace manager-owned reads. | +| Starbridge commander and pilot verbs | Starbridge design target in `E:/Projects/AetheriaLore/Aetheria/Game Design/Aetheria Starbridge.md` | Station stock, docking/refit, salvage, construction anchoring, target marks, cooling, repair, drone/turret orders, infrastructure placement, fabrication, wave/hostile control, and survival-pod recovery are not all first-class command surfaces yet. | Add them only as typed daemon/state operations or typed projections. Unity may issue pilot-local intent; RTS may issue commander intent; neither client may own bespoke gameplay behavior for these verbs. | +| Menu startup and Eve commands | `Assets/Scripts/UI/MainMenu.cs:20`, `:461` | Main menu now owns an `AetheriaClient`; frame/settings reads and known Eve command submission route through the facade. Client-target file edits still use the typed local target utility. | Keep as facade-backed UI shell. Stage 8 should delete the menu-owned compatibility shims once panels can hold explicit client references. | +| Eve surface presenter | `Packages/org.gamecult.aetheria.eve-runtime/Runtime/AetheriaEveSurfacePresenter.cs` | Presenter now owns an `AetheriaClient`; daemon Eve surface reads, state-ref resolution, and surface command submission use the facade or file-backed state reader. | Keep as Unity presentation infrastructure. It must remain render/input plumbing, not gameplay authority. | + +### 7D.4 Projection And Read Queue + +| Surface | Evidence | Current Shape | Stage 7D Action | +| --- | --- | --- | --- | +| Observed daemon state polling | `Assets/Scripts/Gameplay/AetheriaDaemonObserver.cs:74`, `:120` | Polls local Verse state and emits changed observations. | Keep as local read primitive, but expose named typed projections through 7D.2 facade rather than leaking raw observed state to gameplay shell callers. | +| Observed galaxy facade | `Assets/Scripts/Gameplay/AetheriaUnityObservedRunProjection.cs`; booted from typed sector-map state in `Assets/Scripts/UI/MainMenu.cs` | Unity builds a projected Galaxy facade only as a legacy scene-construction adapter. `ActionGameManager` no longer stores or exposes it. | Quarantine for Stage 7D. Replace reads with local projection/state access where possible. Delete or collapse in Stage 8 after equivalent projections exist. | +| Runtime catalog/settings reads | scoped search across `Assets/Scripts` and `Packages/org.gamecult.aetheria.state` | No direct `ActionGameManager.RuntimeCatalog` or `ActionGameManager.RuntimePlayerSettings` reads remain. `InventoryMenu`, `InventoryPanel`, `TradeMenu`, `ActionBarSlot`, `ZoneRenderer`, `EntityInstance`, `ShipInstance`, `InputDisplayLayout`, `VolumeCloudRenderer`, `MainMenu`, and `SchematicDisplay` have moved their local catalog/formatting/settings reads to explicit `AetheriaClient` instances or a renderer-owned client cache. | Done for direct manager-global catalog/settings cleanup. Continue replacing observed-galaxy/entity facade reads with portable typed projections. | +| Map screen zone/settings reads | `Assets/Scripts/UI/Menu/MapRenderer.cs` | `MapRenderer` now owns an explicit `AetheriaClient` and reads zone title from `ObjectsViewportAsync` plus minimap asteroid visibility from `PlayerSettingsAsync`. The unused `ActionGameManager` reference was removed. | Done for this focused 7D.4 read slice. Continue moving map/sector reads off the temporary observed galaxy and global runtime settings where typed projections exist. | +| Sector zone details reads | `Assets/Scripts/UI/Menu/SectorRenderer.cs` | The zone-details Eve surface now reads sector topology from `SectorMapAsync`, daemon zone facts from `ZoneDetailsAsync`, hull type lookups from `OpenRuntimeCatalog`, and formatting settings from `PlayerSettingsAsync` through an explicit `AetheriaClient`. It no longer asks `ActionGameManager` for zone snapshots, runtime catalog, or player formatting, and no longer reads the whole authoritative frame for this surface. | Done for zone-details surface. The sector map layout/reveal path still uses the legacy `GalaxyZone` facade until every remaining renderer signal is represented by portable typed projections. | +| Target/contact presentation reads | `Assets/Scripts/Gameplay/AetheriaUnityObservedTargetQuery.cs`; `Assets/Scripts/Gameplay/AetheriaUnityTargetPresentation.cs`; projection in `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs` | Unity target presentation now reads `gamecult.aetheria.zone_contacts.v1` through `AetheriaClient.ZoneContactsAsync()`. The feed carries typed target/contact identity, visibility, hostility, target position, deltas, and distance for the current zone. Target HUD, target-distance renderer cache, minimap compass markers, and renderer visibility fades no longer call snapshot render-query helpers for those facts. | Done for current target HUD indicators, target-distance renderer cache, compass markers, and visibility fade state. Remaining target presentation objects are Unity affordances over typed contact rows. | +| Docked current-entity binding | `Assets/Scripts/Gameplay/AetheriaUnityObservedDockingIndex.cs`; `Assets/Scripts/Gameplay/AetheriaUnityCurrentEntityBinder.cs`; `Assets/Scripts/Gameplay/AetheriaUnityCurrentEntityPresentation.cs` | Docking state now comes from `AetheriaClient.CurrentDockingAsync()` and resolves the Unity docking-bay facade by typed dock parent key plus bay index. Dock camera body focus uses `AetheriaClient.ZoneRenderAsync()` body poses rather than passing a raw current-zone snapshot into presentation. | Done for current docked binding path. The remaining Unity facade entity/docking-bay objects are presentation adapters, not portable gameplay state. | +| Inventory/trade item detail reads | `Assets/Scripts/UI/Menu/InventoryMenu.cs`; `Assets/Scripts/UI/Menu/InventoryPanel.cs`; `Assets/Scripts/UI/Menu/TradeMenu.cs` | These panels already own explicit `AetheriaClient` instances for typed command submission. They now also read runtime catalog/manufacturer data and player formatting settings through those local clients for ship settings, cargo item details, equipped item details, trade table columns, trade behavior fields, trade item details, dropdown loadout pricing, inventory hull/item geometry, hardpoint typing, durability lookup, and temperature labels. | Done for current detail/dropdown/panel slice. Remaining HUD schematic, entity prefab presentation, and action-bar icon/name reads still use manager globals until migrated. | +| Current ship settings facts | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs`; `Assets/Scripts/UI/Menu/InventoryMenu.cs` | `CurrentEntityAsync` now publishes the current entity shutdown-performance threshold. `InventoryMenu` renders the ship-settings Eve surface from the typed current-entity document and submits shutdown changes by daemon entity key instead of storing a Unity `Entity` facade as the settings source. | Done for current ship-settings read slice. The daemon still validates range and applies authority; Unity only renders the typed row and submits intent. | +| Current cockpit HUD facts | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs`; `Assets/Scripts/UI/HUD/SchematicDisplay.cs` | `CurrentEntityAsync` now publishes a typed current-entity HUD status row for override shutdown, shield activity, heatsinks, heat/hypothermia exposure, visibility, hull ratio, radiator range, sensor cooldown, reactor draw, capacitor charge, and Aether-drive RPM. The player schematic HUD reads that row through its local `AetheriaClient` instead of pulling those facts from the Unity `Entity` facade. | Done for the current player HUD status slice. Target/enemy schematic rows still adapt Unity presentation objects until a target-entity projection replaces that display path. | +| Station refit docking-bay rows | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs`; `Assets/Scripts/UI/Menu/InventoryPanel.cs`; `Assets/Scripts/UI/Menu/InventoryMenu.cs` | `StationRefitAsync` now publishes typed docking-bay rows with slot identity, occupied entity identity, current-entity match, hull key, and cargo items. Inventory menu/panel docking-bay display validates the current row before adapting the Unity docking-bay facade. | Done for the current docking-bay display slice. Continue promoting station stock/refit eligibility and remaining cargo presentation facts into typed station/refit projections before Stage 8. | +| Loadout restore options | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs`; `Assets/Scripts/UI/Menu/InventoryPanel.cs` | `StationRefitAsync` now composes daemon frame state, Verse loadout templates, and runtime catalog policy into typed restore options. Each row carries template name, daemon target entity key, price, and `CanRestore`. InventoryPanel renders and submits those rows instead of reading `LoadoutTemplatesAsync` or pricing templates locally. | Done for current restore dropdown slice. The daemon still validates restore acceptance on command apply; clients only display the projected row and submit intent. | +| Trade cargo target rows | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs`; `Assets/Scripts/UI/Menu/TradeMenu.cs` | `StationRefitAsync` now publishes typed cargo target rows for the current docking bay and player ship bays. Each row carries target kind, label, entity key, bay index, hull key, and cargo items. TradeMenu builds the target selector and owned counts from those rows instead of reconstructing targets from available entities or treating station stock as docking-bay cargo. | Done for current target cargo/count slice. Remaining trade row filtering and purchase submission stay typed; Stage 8 can delete more Unity presentation adapters once row rendering is fully projection-native. | +| Station stock trade facts | `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs`; `Assets/Scripts/UI/Menu/TradeMenu.cs` | `StationRefitAsync` enriches each station stock row with shared-policy price, affordability, and owned quantity. Hull ownership is counted from typed available entities; item ownership is counted from typed cargo target rows. TradeMenu renders the `OwnedQuantity` row fact instead of counting Unity ships or cargo dictionaries locally. | Done for current stock row fact slice. The daemon projection owns trade row display facts; command apply still revalidates purchase acceptance. | +| Action-bar and renderer catalog reads | `Assets/Scripts/Gameplay/ActionBarSlot.cs`; `Assets/Scripts/Zone Display/ZoneRenderer.cs`; `Assets/Scripts/Gameplay/EntityInstance.cs`; `Assets/Scripts/Gameplay/ShipInstance.cs` | Action-bar bindings resolve gear icon catalog data through their explicit `AetheriaClient`. `ZoneRenderer` owns a local `AetheriaClient` catalog cache used for entity hull prefabs, pickup labels, pickup tier color, entity hull binding, and ship durability normalization. | Done for current presentation catalog slice. Remaining HUD schematic catalog/formatting and main-menu observed-galaxy boot still need separate treatment. | +| Input and nebula settings reads | `Assets/Scripts/UI/InputScreen/InputDisplayLayout.cs`; `Assets/Scripts/Zone Display/VolumeCloudRenderer.cs` | The input screen reads action-bar input visibility from `AetheriaClient.PlayerSettingsAsync()`. The volume cloud renderer reads nebula quality from `AetheriaClient.PlayerSettingsAsync()` and falls back to its serialized quality if local state cannot be read. | Done for current settings slice. | +| Main-menu and HUD catalog/settings reads | `Assets/Scripts/UI/MainMenu.cs`; `Assets/Scripts/UI/HUD/SchematicDisplay.cs` | Main menu reads the runtime catalog and typed sector-map boot state through its local `AetheriaClient`; the legacy observed-galaxy boot projection now lowers from `SectorMapAsync` rather than a whole authoritative daemon frame. Schematic HUD reads item catalog data and player formatting settings through its local `AetheriaClient`. | Done for direct manager-global read cleanup and whole-frame boot read removal. `SchematicDisplay.cs` was transcoded to UTF-8 before editing because it contained legacy non-UTF8 degree-symbol bytes. | +| Trade value settings projection | `Aetheria.State/Documents/AetheriaTradeValuePolicy.cs`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs`; `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogStore.cs`; `InventoryMenu.cs`; `InventoryPanel.cs`; `TradeMenu.cs`; `ZoneRenderer.cs` | Trade value projection is now authored typed state. The daemon seeds `global:aetheria.trade_value_policy.v1`, provider advertisements include `aetheria.trade_value_policy.v1`, and runtime catalog snapshots read that policy into `AetheriaRuntimeCatalogSnapshot.TradeValueSettings`. Unity panels/renderers consume it through explicit `AetheriaClient` catalog caches. The Unity `GameSettings` projection helper and `ActionGameManager.ObservedTradeValueSettings` are gone. | Done for manager, Unity-settings source, and code-only default cleanup. Next policy work is designer-facing Eve/CultUI authoring, not another Unity settings bridge. | +| Sector map reads | `Assets/Scripts/UI/Menu/SectorMap.cs`; `Assets/Scripts/UI/Menu/SectorRenderer.cs`; `Assets/Scripts/UI/MainMenu.cs` | Sector map renders from `AetheriaClient.SectorMapAsync`, emits clicked zone indices, and resolves details from `SectorMapAsync` plus daemon zone contents. Main-menu observed-game boot also uses `SectorMapAsync` for run id, frame id, tutorial mode, generation seed, zones, links, discovery, and faction relationships. It no longer reads `ActionGameManager.TryGetObservedGalaxy`, passes `GalaxyZone` objects through UI events, or reads whole daemon frames for Unity observed-galaxy boot. | Done for the current topology/detail/boot surface. Remaining work is richer faction/home/boss icon metadata as typed sector-map fields if designers still need those signals. | +| Frame lowering | `Assets/Scripts/Gameplay/AetheriaUnityObservedFrameApplier.cs`; `Assets/Scripts/Gameplay/AetheriaUnityObservedRunProjection.cs`; `Assets/Scripts/Gameplay/AetheriaUnityObservedZoneContextProjector.cs`; `Assets/Scripts/Gameplay/ActionGameManager.cs` | Frame handoff is now named as `ApplyLatestZoneRender`: it reads `ZoneRenderAsync()` and adapts only the target zone needed for the Unity presentation shell. The frame applier no longer receives the whole observed galaxy facade, the temporary projected-zone lookup lives beside the observed-run projection holder, and `ActionGameManager` no longer keeps an observed-galaxy property. The remaining whole-graph dependency is lazily resolved inside the quarantined `Zone` construction adapter because the legacy `Zone` constructor still requires it. | Keep narrowing toward typed projection/native render consumption. Persistent gameplay mirror belongs to Stage 8 demolition. | + +### 7D.5 Renderer/Input Shell Queue + +| Surface | Evidence | Current Shape | Stage 7D Action | +| --- | --- | --- | --- | +| Zone renderer frame application | `Assets/Scripts/Zone Display/ZoneRenderer.cs:220`, `:239` | `LoadDaemonZoneView` clears/rebuilds a Unity hierarchy; `ApplyZoneRender` updates render-time zone data, caches target/contact rows from `ZoneContactsAsync()`, loads presentation entities from `ObjectsViewportAsync(...)`, and discovers body views plus terrain sampling facts from `GravityViewportAsync(...)` for the current XY bounds. Unity no longer recomputes target, compass, visibility, presentation entity, visible body-view, terrain-height, terrain-band, asteroid-instance, dropped-pickup, entity-facade, orbit, or body facts from raw zone snapshot render queries. | Keep only as render cache. `ApplyZoneRender` is acceptable as per-frame projection consumption; `LoadDaemonZoneView` is now the remaining Stage 8 hierarchy rebuild shim once renderer data is native/projection-driven. | +| Entity instance cache | `ZoneRenderer.cs`; `AetheriaUnityObservedFacadeIndex.cs`; target presentation wiring in `AetheriaUnityGameplaySceneWiring.cs` | Unity keeps daemon-indexed presentation objects for rendering, docking, target HUD, and camera binding. Daemon-index entity lookup lives in the explicit facade index/scene wiring instead of an `ActionGameManager` shim. | Classify as temporary renderer shell. It must not become gameplay authority; replace with render/native views as Stage 8 proceeds. | +| SoA render fast path | `Assets/Scripts/Gameplay/AetheriaDaemonRenderBuffer.cs:50`, `AetheriaDaemonIndirectRenderer.cs:40`, `AetheriaDaemonObserver.LastRenderNativeView` | Reads observer SoA/native view for rendering. | Keep. This is aligned with thin renderer direction. Attach it to the 7D.2 facade instead of direct observer singleton lookup. | +| Ymir click/physics bridge | `Assets/Scripts/Gameplay/Physics/AetheriaYmirPhysicsBridge.cs` | Uses Ymir/clickable/hull mappings, not `UnityEngine.Physics` as simulation authority. | Keep as presentation/query bridge, then narrow behind facade/render shell. Any remaining Unity collider components are click/render affordances, not gameplay physics authority. | +| `GetComponent` and prefab collider hits | many source/prefab hits | Mostly Unity presentation wiring, object pooling, click affordances, and imported asset metadata. | Non-actionable for 7D unless the component participates in gameplay authority. Recheck during Stage 8 demolition. | + +### Non-Actionable Queue Hits + +| Hit | Evidence | Decision | +| --- | --- | --- | +| Event log queue | `Assets/Scripts/EventLog.cs:13` | UI log buffer; not a command bus. Keep. | +| Sector reveal queue | `Assets/Scripts/UI/Menu/SectorMap.cs:54`, `:79` | Presentation animation queue; not a gameplay command bus. Keep until the sector map projection is replaced. | +| KD-tree query queue | `Assets/Scripts/ServerShared/NIH/KDTree/KDQuery/*.cs` | Internal spatial query implementation. Keep unless replaced by CultMath/Ymir spatial structures in a later performance pass. | +| `System.Threading.Channels.dll` asmdef reference | `Packages/org.gamecult.aetheria.state/Runtime/GameCult.Aetheria.State.Unity.asmdef` | Assembly reference only; no ad hoc runtime channel found in scoped source. | +| `Physics.` source search | scoped C# search | No actionable `UnityEngine.Physics.*` simulation authority call found in `Assets/Scripts` or state runtime during this pass. | + +## Stage 7B Build Order + +Done: + +- public renderer API no longer exposes `window.aetheriaRts.command` or + `window.aetheriaRts.viewport`; +- Electron IPC no longer exposes `aetheria-rts:command` or + `aetheria-rts:viewport`; +- browser code now calls typed methods `setMoveVector`, `setTarget`, and + `mapViewport`; +- `aetheria-cultmesh.ts` is now transport-focused and no longer owns + MessagePack slot layout; +- local generated-style bindings in `aetheria-rts-bindings.ts` encode/decode + named slots for command documents, including the 30-slot daemon command + layout; +- `scripts/generate-rts-bindings.mjs` derives schema ids, command kind ids, and + slot maps from the C# MessagePack document declarations into + `aetheria-rts-generated-bindings.ts` for daemon command, daemon frame, daemon + health, RTS viewport payloads, nested daemon snapshot payloads, Verse + authority policy, authority rule, and authority lease; +- `AetheriaRuntimeRtsProjection` owns typed viewport projection over local + daemon frame state, including controlled-unit visibility union, status, + inventory, and gravity influence intersections; +- daemon RUDP snapshot handling delegates RTS viewport construction to + `AetheriaRuntimeRtsProjection` instead of carrying map projection logic + inline; +- `Aetheria.State.AuthoritySmoke` verifies the projection behavior against a + local daemon frame; +- Electron map reads now fetch the latest daemon frame and project it locally in + `aetheria-rts-local-projection.ts` instead of requesting the remote RTS + viewport document; +- Electron selected-object, inventory/cargo, daemon health, and authority status + panels now read through typed local projection facade methods instead of + deriving panel state sideways from the map payload; +- Electron map/panel projection reads now use local CultCache publication files + through `aetheria-local-publication-reader.ts` instead of loopback + `sendSnapshotRequest`; +- the daemon publishes the Verse authority policy to a local `.authority.policy.cc` + sidecar so Electron can read authority status locally alongside frame and + health; +- `Aetheria.Rts.Web/scripts/verify-stage7c-local-runtime.ps1` starts a one-shot + daemon and proves the compiled Electron runtime facade can read map, + selected-object, inventory, health, and authority projections from local + CultCache publications; +- `Aetheria.Rts.Web/scripts/verify-stage7c-electron-shell.ps1` launches the real + Electron app shell against a disposable runtime directory and proves the + renderer can refresh through preload IPC using the typed projection facade; +- Electron preload/main expose typed projection channels: + `aetheria-rts:selected-object`, `aetheria-rts:inventory`, + `aetheria-rts:daemon-health`, and `aetheria-rts:authority-status`; +- `Aetheria.Rts.Web/scripts/verify-stage7b-rts-client.ps1` fails if the old + public generic API returns or if the transport wrapper regains document + layout/codec ownership, fails if stale remote viewport decoder helpers return, + and also fails if the generated bindings are stale. + +Remaining: + +1. Move Unity command/read paths onto the same typed local client shape proven by + Electron. +2. Add a separate peer sync health projection if daemon health stops being the + canonical peer health surface. +3. Replace the remaining semantic command construction arrays with a CultMesh + TS typed document writer once that primitive is available locally. +4. Narrow Electron preload/main IPC behind a typed runtime facade. +5. Keep the daemon RTS viewport handler only as temporary compatibility plumbing + until no client or diagnostic path depends on it. +6. Keep the verifier checking for old public generic surfaces and transport + wrapper layout leaks: + - `window.aetheriaRts.command`; + - `window.aetheriaRts.viewport`; + - `ipcMain.handle("aetheria-rts:command"`; + - `ipcMain.handle("aetheria-rts:viewport"`; + - `function createCommandDocument`; + - `decodeViewportDocument`; + - `viewportRecordKey`; + - public TS command union `{ kind: ... }` used as transport payload; + - hand-indexed MessagePack document layout in `aetheria-cultmesh.ts`. + +## Stage 7A Exit Check + +This inventory is the Stage 7A artifact. Stage 7A has exited. Electron's Stage +7B/7C proving surface now has generated binding metadata, typed command facade +methods, local map/panel projections, local CultCache publication reads, and +runtime/app-shell verifiers. The next use of this file is Stage 7D.1: refresh +the Unity entries into a concrete edit queue before changing Unity behavior. diff --git a/Aetheria.State/docs/stage-7-thin-client-staged-implementation-plan.md b/Aetheria.State/docs/stage-7-thin-client-staged-implementation-plan.md new file mode 100644 index 000000000..6ae7be919 --- /dev/null +++ b/Aetheria.State/docs/stage-7-thin-client-staged-implementation-plan.md @@ -0,0 +1,1298 @@ +# Stage 7 Thin Client Staged Implementation Plan + +This is the build map for turning Unity and Electron into thin CultMesh clients +without building Jenga. The broader Verse authority plan explains why the +architecture exists; this document explains the exact order to build it in. + +Current gate: Stage 7D.4. Stage 7C must be re-verified after any local runtime +script or daemon launch change, but no new architecture is allowed there unless +the verifier fails. + +Stage rule: a stage may consume only the artifacts listed as inputs, may create +only the artifacts listed as outputs, and must remove or quarantine its +demolition target before the next stage starts. + +## How To Use This Plan + +This is the migration runbook. When working on the thin-client split, pick the +first incomplete slice in the current stage and do only that slice. A slice is +complete only when its proof passes and its demolition target is gone, +quarantined, or explicitly carried forward by the next slice. + +Every implementation move must answer five questions before code changes: + +1. Which stage and slice does this advance? +2. Which existing surface is being replaced or narrowed? +3. Which typed document, projection, or facade owns the new shape? +4. Which verifier proves the old path did not grow back? +5. Which later slice is now unlocked? + +If an edit cannot answer those questions, it is not Stage 7 work. Put it in a +parking note or defer it. + +## Build Tracks + +Stage 7 has four tracks. Only the active track may create new runtime shape. + +| Track | Purpose | Current state | Owner | +| --- | --- | --- | --- | +| Contract | Define typed command and projection vocabulary shared by clients. | Electron slice exists; Unity parity next. | `AetheriaRuntimeVerseClient`, generated TS bindings, projection modules | +| Publication | Make daemon state visible as typed local state, not remote viewport fetches. | Electron frame/health/policy sidecars exist. | daemon publication store and local readers | +| Client shell | Bind UI/input/rendering to the typed facade. | Electron proven; Unity next. | Electron main/preload/client and Unity presentation shell | +| Demolition | Delete or quarantine legacy gameplay ownership. | Waiting for Unity parity and cross-runtime smoke. | Stage 8 only | + +Do not mix tracks inside one slice unless the slice explicitly says so. + +## Target Shape + +Every client runtime is a local Verse participant: + +1. The UI/runtime process owns input, rendering, and local presentation state. +2. The local Verse node owns typed document exchange with the local daemon and + peers. +3. The daemon owns simulation and publishes committed typed facts. +4. Reads are local projections over local Verse state. +5. Writes are typed command/fact documents. +6. No public client API accepts `command(kind, payload)`, raw document bags, or + viewport strings as gameplay semantics. + +The ergonomic layer for that shape must graduate into CultMesh/CultLib when it +is reusable. Aetheria may prove the API with domain sugar, but shared concepts +belong in the cross-runtime primitive layer: typed state pointers, typed +operation handles, typed query surfaces, projection recipes, authority claims, +reactive watches, and native slab descriptors. The current shared roadmap is +`E:/Projects/CultLib/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md`. + +Electron and Unity must converge on this shape: + +```text +UI input + -> typed client facade + -> typed command document + -> local Verse node + -> local daemon command gate + -> simulation + -> committed fact/frame documents + -> local Verse replica + -> typed local projection + -> renderer/UI panels +``` + +The map viewport is just one projection. Selected object state, inventory, +cargo, authority status, and peer health must follow the same pattern. + +Starbridge is the current design pressure for this shape. One RTS commander and +one to four Unity pilots must share the same Verse facts while each runtime +authors only the typed claims it should own. + +The product source for that pressure is +`E:/Projects/AetheriaLore/Aetheria/Game Design/Aetheria Starbridge.md`. Stage 7 +does not own the whole release, but every client-parity slice should make one +of Starbridge's shared surfaces more portable: session/base facts, tactical map +projections, station/refit state, commander verbs, pilot field verbs, recovery, +waves, or episode/progression data. + +The staged Starbridge implementation slices live in +`Aetheria.State/docs/verse-authority-implementation-plan.md` under +`Starbridge Slice Map`. Treat that map as the product-facing build order for +new co-op verbs. This Stage 7 runbook remains the client-parity/demolition +runbook: it decides when Unity and Electron are thin enough to consume those +verbs without creating client-owned gameplay branches. + +Commander-facing Starbridge surfaces: + +- map, fog, wave, hostile, and base-system projections; +- infrastructure placement, power routing, fabrication, drone/turret orders, + station-stock, recovered-technology, construction-ghost, target-mark, and + commander support operations; +- authority diagnostics for which runtime owns each claim kind. + +Pilot-facing Starbridge surfaces: + +- responsive ship movement, local combat, salvage, docking, refit, anchoring, + cooling, repair, target-mark, survival-pod, cargo, equipment, and status + operations/projections; +- support-gear validity derived from equipped daemon state, not from client + role assumptions. + +None of those are allowed to become client-local gameplay branches. If the RTS +client can do it, Unity or another runtime must be able to inspect the same +typed state and issue the same typed operation when policy allows it. If Unity +can do it, the RTS client must be able to observe the committed result through +local Verse state rather than through a peer viewport. + +## Current Baseline + +Completed and usable: + +- Stage 6 live committed fact import passes. +- Electron public API exposes typed methods: `mapViewport`, `setMoveVector`, + and `setTarget`. +- Electron IPC exposes typed channels: + `aetheria-rts:map-viewport`, `aetheria-rts:set-move-vector`, and + `aetheria-rts:set-target`. +- `Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts` is transport-focused. +- `Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts` is generated + from C# `[Key]` declarations. +- `Aetheria.Rts.Web/Electron/aetheria-rts-bindings.ts` keeps ergonomic command + encoders and public TS request/response types. +- `Aetheria.Rts.Web/Electron/aetheria-rts-local-projection.ts` projects the + latest daemon frame into a local map viewport. +- Electron no longer requests the remote RTS viewport snapshot document. +- `AetheriaRuntimeRtsProjection` owns the matching C# map projection and the + daemon delegates the temporary RUDP compatibility hook to it. +- `AetheriaRuntimeStarbridgeProjection` owns the first S0 session-summary + projection, and `AetheriaClient.StarbridgeSessionSummaryAsync` exposes it as + a named facade read without routing through map viewport state. Scenario and + session facts now have latest-record keys in local Verse, so clients can ask + the facade for the active Starbridge summary without passing documents around. +- The daemon boot path seeds the first Starbridge scenario/session facts from + daemon-owned local state. A fresh once-mode daemon publishes `Frontier + Fabricator Defense` as typed scenario/session documents, and the authority + smoke reads the resulting summary through `AetheriaClient` instead of Unity + scene authoring. +- Selected-object and inventory reads are now named projection documents rather + than hidden whole-zone viewport reads. The map viewport remains a map + projection; entity inspection and inventory panels have their own typed + facade results. +- Stage 7D.4/S1 has started splitting the map projection itself. Objects visible + to the union of controlled units and XY gravity influences now have separate + typed documents and facade reads: `ObjectsViewportAsync` and + `GravityViewportAsync`. `MapViewportAsync` remains as a compatibility + composition over those two projections while Unity/Electron callers migrate. +- The Electron renderer now asks for `objectsViewport` and `gravityViewport` + through preload IPC and composes its local draw model from those typed + projections. `mapViewport` remains available for compatibility and simple + diagnostics, but the app-shell path exercises the split projection contract. +- Unity's `MapRenderer` now refreshes its map header through the same + `ObjectsViewportAsync` and `GravityViewportAsync` facade reads instead of + reaching straight for the whole authoritative frame. This is only the first + Unity parity cut: the legacy scene renderer still consumes whole-zone daemon + snapshots until Stage 8 demolition replaces that shell. +- `Aetheria.State.AuthoritySmoke` verifies the C# projection. +- `npm run verify:stage7b` fails if public generic APIs return, if the transport + wrapper regains layout ownership, if stale viewport decoders return, or if the + generated binding metadata is stale. + +Remaining risk: + +- Electron command writes still use CultMesh RUDP document puts. That is + acceptable for the command lane, but replacing the semantic command arrays + with a typed TS document writer remains open. +- Unity does not yet consume the same thin client facade. +- Peer health is currently represented by daemon health; a divergent peer sync + health document can be added to the projection catalog when the daemon + publishes it. + +## Stage Graph + +```text +7B.0 inventory freeze + -> 7B.1 generated contract metadata + -> 7B.2 typed command facade + -> 7B.3 local map projection + -> 7B.4 local projection catalog + -> 7B.5 local replica read path + -> 7C Electron thin client + -> 7D Unity thin client parity + -> 7E cross-runtime co-op smoke + -> 8 Unity gameplay shell demolition +``` + +Do not skip 7B.5. If Unity parity is built on top of loopback viewport/frame +requests, the bridge becomes the new legacy shell. + +## 7B.0 Inventory Freeze + +Status: complete. + +Inputs: + +- Stage 6 live committed fact import. +- Existing Unity shell. +- Existing Electron RTS shell. + +Build: + +- Inventory every client mutation path. +- Inventory every gameplay read/projection path. +- Classify each path as keep, replace, or delete. + +Outputs: + +- `Aetheria.State/docs/stage-7-client-surface-inventory.md` + +Demolition target: + +- Unknown public command/read surfaces. + +Verifier: + +```powershell +rg -n "command\(|viewport\(|Apply\(command|CommandPort|CachedCommandPort|EventBus|Bus" Assets Packages Aetheria.Rts.Web +``` + +Every hit must be listed in the inventory before implementation continues. + +Stop line: + +- No new runtime abstraction can be added until this inventory names the surface + it replaces. + +## 7B.1 Generated Contract Metadata + +Status: complete for current command/frame/health/authority/snapshot slice. + +Inputs: + +- C# MessagePack document declarations in + `Packages/org.gamecult.aetheria.state/Runtime`. + +Build: + +- Generate TS schema ids, enum ids, and slot maps from C# `[Key]` + declarations. +- Treat generated metadata as read-only. +- Expand source coverage whenever a projection consumes a new nested document. + +Outputs: + +- `Aetheria.Rts.Web/scripts/generate-rts-bindings.mjs` +- `Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts` + +Demolition target: + +- Hand-maintained TS copies of C# document layout. + +Verifier: + +```powershell +cd Aetheria.Rts.Web +npm run check:rts-bindings +``` + +Stop line: + +- No TS projection or command writer may index a MessagePack array without slot + metadata from this generator. + +## 7B.2 Typed Command Facade + +Status: complete for movement and target commands. + +Inputs: + +- Generated command metadata from 7B.1. +- Existing daemon typed command document. + +Build: + +- Expose typed TS request objects for command semantics. +- Keep command id, runtime id, issue time, and local session wiring inside the + client runtime wrapper. +- Keep UI components unaware of transport payload shape. + +Outputs: + +- `Aetheria.Rts.Web/Electron/aetheria-rts-bindings.ts` +- `Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts` +- `Aetheria.Rts.Web/Electron/main.ts` +- `Aetheria.Rts.Web/Electron/preload.cjs` +- `Aetheria.Rts.Web/Client/app.ts` + +Demolition target: + +- Public `command(kind, payload)` and generic command IPC. + +Verifier: + +```powershell +cd Aetheria.Rts.Web +npm run verify:stage7b +``` + +Stop line: + +- Do not add station, inventory, or combat commands until the command catalog is + typed from UI to daemon gate. + +## 7B.3 Local Map Projection + +Status: complete for Electron map viewport shape; direct local replica reads +remain 7B.5. + +Inputs: + +- Generated daemon frame and nested snapshot metadata from 7B.1. +- Typed map request/response model from 7B.2. +- C# projection semantics in `AetheriaRuntimeRtsProjection`. + +Build: + +- Fetch the latest local daemon frame document. +- Project the map viewport in TS from the frame document. +- Use controlled-unit visibility union for fog of war. +- Include objects, status, inventory, gravity influences, and body views. +- Keep remote RTS viewport document handling as daemon compatibility only. + +Outputs: + +- `Aetheria.Rts.Web/Electron/aetheria-rts-local-projection.ts` +- `Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts` +- `Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs` +- `Aetheria.State.AuthoritySmoke/Program.cs` + +Demolition target: + +- Electron requesting `gamecult.aetheria.rts_viewport.v1` from the daemon. +- Stale `decodeViewportDocument` and `viewportRecordKey` TS binding helpers. + +Verifier: + +```powershell +cd Aetheria.Rts.Web +npm run verify:stage7b +npm run build + +cd .. +dotnet run --project .\Aetheria.State.AuthoritySmoke\Aetheria.State.AuthoritySmoke.csproj +``` + +Stop line: + +- Do not add selected panels on top of the old viewport document. They must use + the projection catalog in 7B.4. + +## 7B.4 Local Projection Catalog + +Status: complete for Electron selected object, inventory/cargo, daemon health, +and authority status. + +Inputs: + +- Local frame projection module from 7B.3. +- Generated snapshot metadata from 7B.1. + +Build: + +- Add typed local projections for: + - selected object summary; + - selected object stats/status; + - equipment loadout; + - cargo inventory; + - peer sync health; + - authority policy/status. +- Keep projection functions pure over local frame/health/policy documents. +- Give Electron UI panels typed facade methods instead of reading map payloads + sideways. + +Outputs: + +- `Aetheria.Rts.Web/Electron/aetheria-rts-local-projection.ts` +- `Aetheria.Rts.Web/Electron/aetheria-rts-bindings.ts` +- `Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts` +- `Aetheria.Rts.Web/Client/app.ts` +- `Aetheria.Rts.Web/Electron/main.ts` +- `Aetheria.Rts.Web/Electron/preload.cjs` +- optional C# parity projection smoke additions in + `Aetheria.State.AuthoritySmoke/Program.cs` + +Demolition target: + +- UI panels deriving selected-object details from ad hoc map object shape. +- Any new bespoke RTS gameplay behavior in the browser client. + +Verifier: + +```powershell +cd Aetheria.Rts.Web +npm run verify:stage7b +npm run build +``` + +Additional acceptance: + +- Selecting an owned pawn or station shows status and inventory from typed local + projections. +- The browser client still contains no simulation rules beyond presentation, + selection, and command intent creation. +- `npm run verify:stage7b` requires the typed projection symbols and IPC + channels. + +Stop line: + +- Do not move Unity onto the facade until 7B.5 replaces Electron's loopback + frame/health/policy fetches with local replica reads. + +## 7B.5 Local Replica Read Path + +Status: complete for Electron frame, health, and authority-policy reads. + +Inputs: + +- Projection catalog from 7B.4. +- CultMesh/CultCache local replica/runtime APIs. + +Build: + +- Replace loopback frame, health, and authority-policy fetches with direct local + CultCache publication reads. +- Keep the projection function signatures stable. +- Leave transport code responsible only for sync, not projection semantics. + +Outputs: + +- `Aetheria.Rts.Web/Electron/aetheria-local-publication-reader.ts`; +- updated `AetheriaCultMeshClient` read path; +- verifier proving map reads no longer issue snapshot requests for local frame + data. +- authority policy sidecar publication through + `AetheriaRuntimeDaemonPublicationStore`. + +Demolition target: + +- Electron map/panel reads that depend on loopback daemon snapshot requests. +- Missing local authority policy publication. + +Verifier: + +```powershell +rg -n "sendSnapshotRequest|recordKeys|schemaIds" Aetheria.Rts.Web/Electron +cd Aetheria.Rts.Web +npm run verify:stage7b +npm run build +``` + +Allowed remaining hits must be sync/bootstrap plumbing outside the Electron +client runtime. `aetheria-cultmesh.ts` must not contain snapshot request code. + +Stop line: + +- Do not begin Unity parity until Electron launch proves the local publication + files exist and can drive map/panel projections. + +## 7C Electron Thin Client + +Status: complete for automated local-runtime and Electron app-shell smoke. + +Inputs: + +- Typed command facade from 7B.2. +- Local projection catalog from 7B.4. +- Local replica read path from 7B.5. + +Build: + +- Electron launches or attaches to the local daemon. +- Browser UI renders map, selected panels, health, and authority status from + typed facade methods. +- Browser UI submits only typed command intents. +- Enemy/pawn/station behavior remains daemon simulation, not RTS client code. + +Demolition target: + +- Browser-side gameplay simulation. +- Renderer access to raw CultMesh transport calls. + +Verifier: + +```powershell +cd Aetheria.Rts.Web +npm run build +npm run verify:stage7b +npm run verify:stage7c +npm run verify:stage7c:electron +``` + +Automated smoke: + +- builds the RTS renderer and Electron main process; +- starts the Aetheria daemon once with a disposable state file; +- reads frame, selected object, inventory, daemon health, and authority policy + through the compiled Electron runtime facade; +- fails if map/panel projections cannot be driven from local CultCache + publication files. + +Electron shell smoke: + +- launches the real Electron app with a disposable runtime directory; +- starts the daemon through the app's normal launch path; +- waits for the renderer to refresh through preload IPC; +- checks status, selected object details, equipment/cargo, daemon health, + authority policy, controlled list, and canvas sizing; +- exits non-zero if the app shell cannot drive the same typed projection path. + +Optional manual smoke: + +- Launch Electron. +- Select owned pawn. +- Observe status/inventory. +- Issue movement/target command. +- Confirm daemon frame changes and UI updates from local projection reads. + +Stop line: + +- Do not add RTS features that require browser-owned gameplay state. +- Unity parity may begin only by reusing the typed local client shape proven by + Electron. + +## 7D Unity Thin Client Parity + +Status: next. + +Inputs: + +- Electron-proven typed client facade. +- Unity shell inventory from 7B.0. + +Build principle: + +Unity does not get a bespoke bridge. Unity gets the same local-client shape +Electron proved: typed command submission, typed local projection reads, peer +health, and authority status. The Unity-specific code may adapt data into +render buffers and input gestures, but it must not define new gameplay +semantics. + +### 7D.0 Re-verify Electron Baseline + +Status: complete. + +Purpose: + +- Make sure Stage 7D starts from a working thin-client proof rather than a + remembered one. + +Allowed edits: + +- Verifier flake fixes only. +- No new runtime abstraction. + +Proof: + +```powershell +cd Aetheria.Rts.Web +npm run verify:stage7b +npm run verify:stage7c +npm run verify:stage7c:electron + +cd .. +dotnet run --project .\Aetheria.State.AuthoritySmoke\Aetheria.State.AuthoritySmoke.csproj +``` + +Demolition target: + +- Any Stage 7C verifier that can pass while the renderer uses generic + command/viewport APIs or loopback viewport snapshots. + +Unlocks: + +- 7D.1. + +### 7D.1 Unity Surface Triage + +Status: complete. + +Purpose: + +- Turn the Unity inventory into an edit queue with owners. This is not another + broad modeling pass; it is the last stop before code movement. + +Allowed edits: + +- Documentation and search harness updates. +- No runtime behavior changes except verifier-only assertions. + +Map these surfaces into keep/replace/delete: + +- `ActionGameManager` input methods; +- `AetheriaDaemonObserver` command and read paths; +- `AetheriaRuntimeDaemonOperationClient`; +- `AetheriaRuntimeDaemonSurfaceCommands`; +- `ZoneRenderer` zone/frame loading; +- Unity physics entry points: `Rigidbody`, `Collider`, `Physics.`; +- UI panel reads that go through manager singletons. + +Output: + +- Update `Aetheria.State/docs/stage-7-client-surface-inventory.md` with a + slice assignment for each still-actionable Unity hit: + `7D.2 facade`, `7D.3 commands`, `7D.4 reads`, `7D.5 renderer`, or + `8 demolition`. + +Proof: + +```powershell +rg -n "ActionGameManager|AetheriaDaemonObserver|AetheriaRuntimeDaemonOperationClient|AetheriaRuntimeDaemonSurfaceCommands|ZoneRenderer|Rigidbody|Collider|Physics\\.|SubmitDaemonCommand|Queue|Channel|ConcurrentQueue|Bus|CommandPort|Apply\\(" Assets Packages/org.gamecult.aetheria.state +``` + +Every actionable hit must have an owner in the inventory. + +Demolition target: + +- Unknown Unity gameplay ownership. + +Unlocks: + +- 7D.2. + +### 7D.2 Shared Client Facade Shape + +Status: complete for initial facade. Stage 7D.3 may now reroute Unity command +callers through this facade; Stage 7D.4 may add narrower projections as specific +read sites move. + +Purpose: + +- Give C#/Unity an ergonomic facade matching the Electron-facing shape without + copying Electron's IPC shell. + +Allowed edits: + +- Add or narrow C# facade types in + `Packages/org.gamecult.aetheria.state/Runtime`. +- Keep facade methods typed and named by game intent or projection: + `SetMoveVector`, `SetTarget`, `MapViewport`, `SelectedObject`, `Inventory`, + `DaemonHealth`, `AuthorityStatus`. +- The facade may wrap `AetheriaRuntimeVerseClient` and publication stores. + +Forbidden edits: + +- No `Apply(command, payload)`. +- No public `Command(string kind, object payload)`. +- No cached command port. +- No queue between input and the authority gate. +- No Unity-only command vocabulary. + +Output: + +- `AetheriaClient` in + `Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs`. +- The facade owns a long-lived `AetheriaRuntimeVerseClient`, exposes the typed + `AetheriaRuntimeDaemonOperationsClient`, and reads map viewport, selected + object, inventory, daemon health, authority policy, and SoA view data through + named methods. +- `AetheriaDaemonObserver` now resolves an `AetheriaClient` and exposes + `Client`/`Operations` from that shared facade instead of owning separate + operation and Verse clients. +- Unity package project/asmdef now includes the new facade/projection files and + the CultMath dependency required by render/object viewport queries. + +Proof: + +```powershell +rg -n "Apply\\(command|Command\\(string|payload|CachedCommandPort|CommandPort|Queue|Channel|ConcurrentQueue|EventBus|Bus" Assets Packages/org.gamecult.aetheria.state +dotnet run --project .\Aetheria.State.AuthoritySmoke\Aetheria.State.AuthoritySmoke.csproj +dotnet build .\GameCult.Aetheria.State.Unity.csproj +``` + +Proof status on 2026-06-24: authority smoke passed; Unity package build passed. +`Aetheria.State.Unity.Smoke` is not a clean 7D.2 verifier yet because the +default workspace state has a corrupt CultNet shard log, while a fresh state +copy lacks the expected typed Eve surface fixture. + +Demolition target: + +- Unity-facing generic command wrappers. + +Unlocks: + +- 7D.3 and 7D.4. + +### 7D.3 Unity Command Reroute + +Status: complete for the currently known Unity command ingress. + +Completed slice: + +- `ActionGameManager` no longer owns a cached runtime + `AetheriaRuntimeVerseClient` for boot/catalog/settings/loadout template + paths. +- Runtime catalog opening, player settings reads, loadout template reads, input + settings commands, and loadout template save/delete commands now route through + the shared `AetheriaClient` facade. +- `InputDisplayLayout` now owns an explicit `AetheriaClient` for input settings + changes and the old `ActionGameManager` input-settings ingress has been + removed. +- `MainMenu` daemon-frame reads, player-settings reads, Verse-host settings + reads, and known Eve surface command submission now route through + `AetheriaClient` instead of a menu-owned raw `AetheriaRuntimeVerseClient`. +- `AetheriaEveSurfacePresenter` now reads daemon Eve surfaces and submits Eve + surface commands through `AetheriaClient`. +- `AetheriaEveUnitySurfaceHost` uses the file-backed runtime state reader for + default state-ref resolution instead of constructing a raw Verse client. +- Low-level operation/Eve command compatibility fallbacks now open + `AetheriaClient`; `AetheriaRuntimeVerseClient` is confined to the facade + implementation, its own type, and tests. +- `AetheriaDaemonObserver` already exposes facade-backed typed operations, so + existing observer command calls reuse the shared local Verse client instead of + opening per-command runtime clients. +- Action-bar activation bindings now receive an explicit `AetheriaClient` when + restored, and submit consumable, behavior-active, and weapon-group-active + typed operations directly through the facade. The old public + `ActionGameManager.RequestActionBar...` activation shims are gone. +- `InventoryPanel` now owns an explicit `AetheriaClient` for the dropdown/current + ship slice: loadout restore, set docked current ship, entity rename, and hull + conductivity toggles submit typed daemon operations directly from the panel. + The matching `ActionGameManager` request shims were deleted; the manager only + exposes observed entity record-key resolution for the panel's Unity facade + entities. +- Inventory drag/drop and double-click transfers now submit typed + `TransferCargoItem`, `EquipItem`, and `StoreItem` operations directly from + `InventoryPanel`/`InventoryMenu` through explicit `AetheriaClient` instances. + `ActionGameManager` no longer exposes cargo/equipment transfer request shims; + it only resolves observed Unity facade cargo/equipment objects to daemon + record keys and indices. +- `TradeMenu` now submits typed `TradePurchase` operations through its own + `AetheriaClient` for station stock, commodity quantity purchases, cargo + purchases, and docked ship hull purchases. The old + `ActionGameManager.RequestTradePurchase` shim is gone. +- `InventoryMenu` now submits detailed equipped-item controls through its own + `AetheriaClient`: item override shutdown, thermotoggle target temperature, + weapon-group membership, action-bar binding, and action-bar clearing. The old + public manager request shims for those menu commands are gone; the manager + only resolves observed item identity and action-bar control paths. +- `InventoryPanel` now owns loadout-template save submission through + `AetheriaClient.SubmitLoadoutTemplateCommandAsync`; `ActionGameManager` only + exposes the temporary Unity-facade loadout projection helper. +- Remaining pilot input ingress in `ActionGameManager` now submits through a + shared typed facade operation helper: movement, look direction, tractor power, + targeting, reticle targeting, override shutdown, sensor ping, heatsinks, + shields, interact, tow, dock, and undock. The private `TryRequestDaemon...` + pilot command shims are gone; the manager remains the input gesture router + until Stage 7D.4/7D.5 replace its read/projection dependencies. + +Carried forward: + +- New Starbridge verbs may expose new command surfaces only as typed + daemon/state operations. The next command-expansion slice should cover + station-stock authoring/refit, construction anchoring, target marks, + support-gear cooling/repair, fabrication, drone/turret orders, and commander + support. If a verb is not yet represented in daemon/state code, build it there + first; do not hang it from Unity or Electron UI code. + +Purpose: + +- Move Unity input/UI mutations onto the typed local facade. + +Allowed edits: + +- Change `ActionGameManager` and UI callers so they create typed intent values + and call the shared facade. +- Keep runtime id, command id, issue time, and endpoint wiring inside the + facade/runtime wrapper. +- Leave old methods only as thin forwarding shims if removing them would + explode call-site churn; mark each shim for Stage 8 deletion in the + inventory. + +Forbidden edits: + +- Do not add gameplay logic to Unity to make rerouting easier. +- Do not make UI panels construct daemon command documents directly. + +Output: + +- Unity movement, target, loadout, and surface commands route through typed + local command submission. + +Proof: + +```powershell +rg -n "SubmitDaemonCommand|RestoreLoadout|SetTarget|SetMove|Apply\\(|commandKind|payload" Assets Packages/org.gamecult.aetheria.state +dotnet run --project .\Aetheria.State.AuthoritySmoke\Aetheria.State.AuthoritySmoke.csproj +``` + +Search hits must show either typed facade usage or an inventory-listed Stage 8 +shim. + +Demolition target: + +- Unity command paths that bypass the local daemon authority gate. + +Unlocks: + +- 7D.5 command input side. + +### 7D.4 Unity Projection Reroute + +Status: active. The first focused read reroute is complete for the map screen's +zone title and minimap-asteroid setting reads. + +Purpose: + +- Move Unity gameplay reads onto local typed projections/state. Unity can cache + render data, but the cache must be presentation-only. + +Allowed edits: + +- Read daemon frame, health, authority policy, render SoA, selected object, and + inventory through the shared C# client facade or existing typed state readers. +- Add projection helpers only if they are pure over local state/publications. +- Keep projection results portable enough for another runtime to ask for the + same semantic data. + +Forbidden edits: + +- No remote viewport as game truth. +- No persistent Unity hierarchy as game truth. +- No `ZoneRenderer.LoadZone` ownership of level contents. + +Output: + +- Unity read sites stop depending on manager singleton gameplay state where a + typed local projection exists. +- `ZoneRenderer` consumes per-frame projection/render data only. + +Completed slice: + +- `Assets/Scripts/UI/Menu/MapRenderer.cs` now owns an explicit `AetheriaClient` + and refreshes through `ObjectsViewportAsync` and `GravityViewportAsync` for + its current XY bounds, with minimap asteroid visibility still read from + `PlayerSettingsAsync`. Its unused `ActionGameManager` dependency was removed. +- `Assets/Scripts/UI/Menu/SectorRenderer.cs` now owns an explicit + `AetheriaClient` for the zone-details Eve surface. It reads daemon zone + facts from `ZoneDetailsAsync`, sector topology from `SectorMapAsync`, hull + type lookups from `OpenRuntimeCatalog`, and formatting settings from + `PlayerSettingsAsync` instead of asking `ActionGameManager` for zone + snapshots, runtime catalog, or player formatting. It also centers the sector + view from the typed + `CurrentZoneAsync` projection instead of carrying a serialized + `ActionGameManager` dependency for `TryGetObservedRunZone`. +- `gamecult.aetheria.sector_map.v1` now defines a portable sector topology + projection over local daemon frame state: current/entrance/exit zones, + discovered zones, zone XY positions, adjacency links, and faction index + markers. `AetheriaClient.SectorMapAsync` exposes this as the next replacement + for `SectorMap`'s legacy `ObservedGalaxy` graph reads. +- `Assets/Scripts/UI/Menu/SectorMap.cs` now renders from + `AetheriaClient.SectorMapAsync` and emits clicked zone indices instead of + `GalaxyZone` objects. `SectorRenderer` resolves its zone details from the + same typed sector projection plus daemon zone contents, so the sector menu no + longer calls `ActionGameManager.TryGetObservedGalaxy`. +- `Assets/Scripts/UI/MainMenu.cs` now boots the observed Unity galaxy from the + typed `SectorMapAsync` projection instead of `LatestAuthoritativeRunFrameAsync`. + The sector-map feed carries tutorial mode, generation seed, and faction + relationships so Unity no longer needs a whole daemon frame just to enter the + observer scene. +- `gamecult.aetheria.zone_render.v1` now names the transitional Unity zone + renderer feed. `AetheriaRuntimeRtsProjection.ProjectZoneRender` owns the + projection, `AetheriaClient.ZoneRenderAsync` exposes it as a facade read, and + `ActionGameManager` now restores the Unity shell from + `AetheriaRuntimeZoneRenderDocument` rather than directly from a raw daemon + run snapshot. The feed no longer carries the whole run or current zone + snapshot into Unity; + it carries explicit run-level render facts such as current entity key, + credits, action-bar bindings, simulation time, render radius, adjacent-zone + summaries, body poses, asteroid-belt poses, asteroid-instance poses, dropped + pickups, entity facade rows, orbits, and bodies. `ZoneRenderer` no longer + derives render radius from a zone snapshot, stores simulation time inside it, + or recomputes render facts from it. This is still a Stage 8 shim because + Unity rebuilds a GameObject hierarchy from typed facade rows; the value is + making the boundary explicit and shared before replacing that payload with + native/projection slabs. +- `gamecult.aetheria.current_entity.v1` now names the portable current-subject + feed. `AetheriaRuntimeRtsProjection.ProjectCurrentEntity` derives the active + entity from the typed run current entity key and returns entity identity, + status, inventory, equipment, and cargo without requiring callers to know the + entity index. `AetheriaClient.CurrentEntityAsync` exposes the feed for Unity + and Electron parity work. +- `Assets/Scripts/UI/Menu/TradeMenu.cs` now uses its explicit `AetheriaClient` + for trade item catalog/manufacturer lookups and player formatting settings. + Its spreadsheet behavior columns and trade item details Eve surface no longer + read `ActionGameManager.RuntimeCatalog` or `RuntimePlayerSettings`. +- `Assets/Scripts/UI/Menu/InventoryMenu.cs` now uses its explicit + `AetheriaClient` for item catalog/manufacturer lookups and player formatting + settings. Ship settings, cargo item details, equipped item details, and typed + shape-cell lookups no longer read `ActionGameManager.RuntimeCatalog` or + `RuntimePlayerSettings`. +- `Assets/Scripts/UI/Menu/InventoryPanel.cs` now uses its explicit + `AetheriaClient` for loadout restore pricing, hull/item shape and durability + lookups, hardpoint color typing, and temperature label formatting. The panel + no longer reads `ActionGameManager.RuntimeCatalog` or + `RuntimePlayerSettings` for those surfaces. +- `Assets/Scripts/Gameplay/ActionBarSlot.cs` now uses the binding's explicit + `AetheriaClient` for gear action-bar icon catalog lookup instead of reading + `ActionGameManager.RuntimeCatalog`. +- `Assets/Scripts/Zone Display/ZoneRenderer.cs`, + `Assets/Scripts/Gameplay/EntityInstance.cs`, and + `Assets/Scripts/Gameplay/ShipInstance.cs` now resolve hull, pickup, and + durability catalog data through a `ZoneRenderer`-owned `AetheriaClient` + catalog cache instead of `ActionGameManager.RuntimeCatalog`. +- `Assets/Scripts/UI/InputScreen/InputDisplayLayout.cs` now reads action-bar + input visibility from `AetheriaClient.PlayerSettingsAsync()` instead of + `ActionGameManager.RuntimePlayerSettings`. +- `Assets/Scripts/Zone Display/VolumeCloudRenderer.cs` now reads nebula quality + from `AetheriaClient.PlayerSettingsAsync()` instead of + `ActionGameManager.RuntimePlayerSettings`, while preserving the serialized + renderer quality as fallback if local state is unavailable. +- `Assets/Scripts/UI/MainMenu.cs` now reads the runtime catalog through its + local `AetheriaClient` when bootstrapping the legacy observed-galaxy + projection. This does not make the observed-galaxy projection a desired final + shape; it removes the direct manager-global catalog dependency while that + projection remains quarantined. +- `Assets/Scripts/UI/HUD/SchematicDisplay.cs` now reads item catalog data and + player formatting settings through a local `AetheriaClient`. The direct + `ActionGameManager.RuntimeCatalog` and `RuntimePlayerSettings` reads are gone + from the Unity codebase. +- `global:aetheria.trade_value_policy.v1` now owns the authored typed trade + value policy used by inventory, trade, loadout pricing, and pickup rendering. + `AetheriaRuntimeCatalogSnapshot.TradeValueSettings` reads that policy through + the runtime catalog store. The old `ActionGameManager.ObservedTradeValueSettings` + service-locator method and Unity `AetheriaUnityProjectionSettings` helper have + both been deleted. +- Inventory and trade docking/refit affordances now validate Unity facade cargo + objects against `gamecult.aetheria.current_docking.v1` before presenting or + selecting the current docking bay. `InventoryMenu`, `InventoryPanel`, and + `TradeMenu` all read `CurrentDockingAsync`. `InventoryPanel` loadout restore + targets and `TradeMenu` docked-ship purchase targets now resolve from + `DockParentEntityKey` instead of `ActionGameManager.DockedEntity`. +- `gamecult.aetheria.station_refit.v1` now owns the typed station/refit selector + model for currently docked play: dock parent, docking bay, available docked + entities, player-ship flags, cargo bay counts, and hull item keys. + `InventoryPanel` and `TradeMenu` build their entity/cargo selector options + from `AetheriaClient.StationRefitAsync`; `TradeMenu` also derives available + ship purchase counts from the same typed projection. Their remaining Unity + facade lookups are key-validated adapters used only to call existing + `Display(...)` methods. +- `ActionGameManager.ObservedAvailableEntities()` has been deleted. Inventory + dropdown selection now resolves a single typed station/refit entity key + through `TryResolveObservedEntityFacadeByRecordKey`, keeping the Unity facade + bridge key-scoped while the old `Display(Entity)` UI remains a Stage 8 shim. +- Inventory menu/panel docking-bay display now reads + `gamecult.aetheria.current_docking.v1` first and adapts the Unity docking bay + facade by `DockParentEntityKey` plus `DockingBayIndex` through + `TryResolveObservedDockingBayFacadeByRecordKey`. The menu code no longer asks + `ActionGameManager` for a broad current docking bay before validation. +- `gamecult.aetheria.station_refit.v1` now also publishes typed docking-bay + rows: slot identity, occupied entity key/name/hull, current-entity match, and + bay cargo items. `InventoryPanel` and `InventoryMenu` must resolve the current + `AetheriaRuntimeStationDockingBayRow` before adapting the legacy Unity docking + bay facade for display, so station/refit truth lives in the daemon projection + instead of the facade graph. +- `gamecult.aetheria.station_refit.v1` now publishes loadout restore options + with template identity, daemon target entity key, shared trade-policy price, + and restore eligibility. `InventoryPanel` no longer enumerates loadout + templates or computes restore prices locally; it renders and submits the typed + row that came from `StationRefitAsync`. +- `gamecult.aetheria.station_refit.v1` now publishes trade cargo target rows for + the current docking bay and each player ship cargo bay, including the target + label, entity key, bay index, hull key, and cargo items. `TradeMenu` no longer + reconstructs cargo selector targets from available entity options or reads the + docking bay's cargo from station stock; target counts use the explicit row. +- `gamecult.aetheria.station_refit.v1` now enriches station stock rows with + shared-policy price, affordability, and owned quantity. `TradeMenu` renders + ownership from `OwnedQuantity`; it no longer counts player ships or target + cargo locally. +- Inventory menu current-entity display now reads + `gamecult.aetheria.current_entity.v1` first and adapts the Unity entity facade + by daemon record key through `TryResolveObservedEntityFacadeByRecordKey`. The + menu code no longer asks `ActionGameManager` for a broad current entity before + validation. +- `gamecult.aetheria.current_entity.v1` now carries the current entity shutdown + threshold. The inventory ship-settings Eve surface renders and updates that + typed fact by daemon entity key instead of storing a Unity `Entity` facade as + settings authority. +- `gamecult.aetheria.current_entity.v1` now carries typed player HUD status: + override shutdown, shield activity, heatsinks, heat exposure, visibility, + hull ratio, radiator range, sensor cooldown, reactor draw, capacitor charge, + and Aether-drive RPM. `SchematicDisplay` reads those facts from + `AetheriaClient.CurrentEntityAsync()` for the player schematic instead of + reading those gameplay facts from the Unity `Entity` facade. +- Shared RTS/current-entity inventory rows now publish `SourceIndex`, `X`, and + `Y` beside the source kind and item key. Cargo and equipment operations can + validate against typed projection slot identity instead of searching Unity + facade graphs for the command target. +- `InventoryPanel` and `InventoryMenu` now validate cargo/equipment submissions + against those typed inventory rows before sending daemon operations. The + remaining Unity facade adapter only supplies the presentation object's entity + key/source index, and cargo submissions now include the origin grid cell + instead of sentinel coordinates. +- `ActionGameManager` no longer exposes cargo/equipment command-target adapter + methods. Inventory UI derives the presentation object's owner/index locally, + validates that identity against typed projection rows, and only then submits + through `AetheriaClient.Operations`. +- `LocalMenu` no longer asks `ActionGameManager` for docked local-story state. + It reads `gamecult.aetheria.current_docking.v1`, adapts the story-bearing + docking parent by typed parent key and bay index, and then renders the shared + local-story Eve surface. +- `ActionGameManager.TryGetObservedCurrentEntity` and + `TryGetObservedDockingBay` have been deleted. Current subject and docking + state must be read through typed projections first; Unity facade access is + limited to key-scoped adapters. +- `ActionGameManager.ObservedLoadoutTemplates` and its preload cache have been + deleted. Loadout templates are read through `AetheriaClient.LoadoutTemplatesAsync` + by the UI that needs them instead of being copied into manager-global state. +- `ActionGameManager.TryGetObservedGalaxy`, `TryGetObservedZoneSnapshot`, and + `TryGetObservedRunZone` have been deleted as public read shims. Sector/map + callers must use typed projections; the remaining observed galaxy object is a + quarantined scene bootstrap adapter and Stage 8 renderer adapter, not a + portable client API. +- `AetheriaUnityObservedFrameApplier` now consumes + `AetheriaClient.ZoneRenderAsync()` for the per-frame Unity shell handoff + instead of rebuilding `gamecult.aetheria.zone_render.v1` from + `LastObservedState.Frame`. Its current-zone lookup also reads the typed + zone-render feed, and the applier no longer receives the whole observed + galaxy facade just to apply a frame. The temporary projected-zone lookup now + lives beside `AetheriaUnityObservedRunProjection`, and `ActionGameManager` + no longer keeps an observed-galaxy property. The remaining whole-graph + dependency is lazily resolved inside the quarantined Unity `Zone` + construction adapter until Stage 8 replaces the persistent scene graph with + projection/native render consumption. The handoff is named + `ApplyLatestZoneRender` so Unity code no longer describes this path as whole + authoritative daemon frame application. +- `gamecult.aetheria.zone_details.v1` now owns the portable sector-zone details + feed used by Unity's sector details panel: zone identity, mass, radius, body + kinds, entity hull item keys, and contents availability. `SectorRenderer` + reads it through `AetheriaClient.ZoneDetailsAsync(zoneIndex)` instead of + pulling `LatestAuthoritativeRunFrameAsync()` and searching the whole run for + a raw `AetheriaRuntimeZoneSnapshotCommit`. +- `gamecult.aetheria.zone_contacts.v1` now owns the portable current-zone + target/contact feed used by Unity target presentation. `AetheriaUnityObservedTargetQuery` + reads it through `AetheriaClient.ZoneContactsAsync()` and + `AetheriaUnityTargetPresentation` consumes typed contact rows instead of + asking raw zone snapshots or `AetheriaRuntimeDaemonRenderQueries` for target + facts. +- `gamecult.aetheria.zone_contacts.v1` also carries target/contact position, + delta, and distance facts. `ZoneRenderer.TryGetDaemonTargetDistance` now + refreshes a typed target-row cache from `ZoneContactsAsync()` instead of + querying `_daemonZoneSnapshot` through `TryQueryEntityTarget`, so weapon + convergence no longer depends on a raw snapshot read. `ZoneRenderer` also + derives minimap compass markers and visible-entity fade state from cached + typed contact rows instead of `QueryCompassMarkers` or + `QueryVisibleEntityIndices`, and it loads presentation entities through + `ObjectsViewportAsync(...)` for the current XY bounds instead of + `QueryPresentationEntityIndices`. `GravityViewportAsync(...)` now also + carries viewport-scoped body rows with the body payload Unity needs for + prefab/material lowering, so `ZoneRenderer` no longer calls `QueryBodyViews` + over the raw zone snapshot. The gravity viewport also carries the terrain + radius/depth/exponent/wave-frequency scalars used by the local render sampler, + so terrain heights and minimap gravity bands no longer ask raw zone snapshots. +- `ActionGameManager.DockedEntity` and `DockingBay` have been removed as public + properties. Docking presentation now sets the renderer perspective directly, + while menu/refit/local-story callers read typed current-docking state and use + only key-scoped facade adapters when they still need Unity objects. +- Docked current-entity binding now reads `CurrentDockingAsync()` through + `AetheriaUnityObservedDockingIndex` instead of scanning the current zone + snapshot for docking-bay parent relationships. The dock camera receives body + look-at context from the typed `ZoneRenderAsync()` body-pose feed, so docked + presentation no longer accepts a raw `AetheriaRuntimeZoneSnapshotCommit`. +- `ActionGameManager.TowingStation` is now private renderer/input context, and + the unused `AvailableCargoBays` public enumeration has been deleted. Tow + requests still lower through typed daemon operations; available cargo choices + belong to typed station/refit projections, not manager-local enumerators. +- `ActionGameManager.Credits` has been deleted. Credits are projected through + typed client state such as `StationRefitAsync`; Unity must not mirror run + currency into manager-global state. +- `ActionGameManager.RuntimeCatalog` is now a private boot cache for + manager-internal adapter construction only. Client UI must open typed catalog + views through `AetheriaClient`, not through manager-global Unity state. +- `ActionGameManager` no longer keeps an `ObservedGalaxy` property. The + remaining galaxy projection lives in `AetheriaUnityObservedRunProjection`, + is populated from typed `SectorMapAsync` state, and exists only as a + quarantined renderer/legacy `Zone` construction adapter. Portable clients + must use typed daemon projections rather than raw Unity galaxy state. +- `ActionGameManager.IsTutorial` has been deleted. Tutorial/run mode is read + from typed daemon run/frame projections at the call site instead of mirrored + into public manager-global Unity state. +- `ActionGameManager.CurrentEnvironment` has been deleted. The volume renderer + reads its own presentation settings directly; environment visuals must not be + exposed as manager-global gameplay state. +- Save-loadout projection now runs through + `AetheriaClient.LoadoutTemplateAsync(entityKey)` and + `AetheriaRuntimeLoadoutSnapshotProjector`. Unity submits the typed template + command after resolving the displayed facade to a daemon entity key; it no + longer serializes the Unity `Entity` facade as the source of save truth. +- `ActionGameManager.DragObject` and `HasDragTarget` are no longer public. + Inventory drag/drop uses `TryGetDraggedItem` and `EndDrag`'s consumed result + instead of peeking raw manager gesture state. +- Raw inventory drag gesture state now lives in `AetheriaUnityDragSession`. + `ActionGameManager` keeps only a narrow facade for scene UI callbacks instead + of owning the drag object and target callback fields directly. +- `ActionGameManager.RuntimePlayerSettings` is now private boot/input context. + UI and render surfaces must read player settings through their own + `AetheriaClient` projections instead of the gameplay manager. +- `ActionGameManager.GameDataDirectory` and `RuntimeStateFilePath` have moved + to `AetheriaUnityRuntimePaths`. Unity runtime path discovery is boot plumbing, + not gameplay-manager state. +- `AetheriaRuntimeTradeValuePolicySurfaceBuilder` now exposes the authored + `aetheria.trade_value_policy.v1` state as a provider-owned Eve/CultUI + designer surface. The daemon editor publishes it beside the stat recipe + surface, so trade value, loadout pricing, station stock, and future RTS + economy tooling inspect the same typed policy instead of reviving Unity + settings as gameplay truth. +- The trade value policy designer surface now has typed Eve/CultUI edit + commands for the quality price curve and rarity tier thresholds. The command + body is `AetheriaRuntimeTradeValuePolicyCommandBody`, and the daemon bridge + persists edits through `AetheriaTradeValuePolicy` instead of a string payload + or Unity settings object. + +Next slice: + +- Continue with station/refit read surfaces that still depend on the legacy + Unity facade graph rather than manager-global catalog/settings. The direct + `ActionGameManager.RuntimeCatalog` and `RuntimePlayerSettings` cleanup is + complete, trade/pricing policy is now authored and edited as daemon state, + and the remaining 7D.4 work is replacing station stock, cargo, inventory, and + loadout reads with portable typed projections. + +Active 7D.4 implementation queue: + +This queue is ordered. Do not pick a later row because it is easier. Each row +must either delete its legacy read path or name the exact Stage 8 shim that +still owns the remaining Unity object adaptation. Starbridge-facing reads that +appear in Unity must become named `AetheriaClient` facade reads before they are +allowed to become Electron/RTS features. + +| Order | Slice | Build | Delete or quarantine | Proof | +| --- | --- | --- | --- | --- | +| 0 | Policy/editor typed state | Trade value policy is authored and edited through `aetheria.trade_value_policy.v1`, `AetheriaRuntimeTradeValuePolicySurfaceBuilder`, typed Eve command bodies, and `AetheriaEveCommandBridge` persistence. | No Unity `GameSettings` or public string command/payload path may own pricing policy. | Unity compile, `verify-stage7d-unity-parity.ps1`, and `Aetheria.State.Verify` require typed commands, editable controls, and `PutTradeValuePolicyAsync`. | +| 1 | Sector topology | `SectorMap`, `SectorRenderer`, and main-menu observed-game boot consume `SectorMapAsync`, `CurrentZoneAsync`, daemon zone contents, catalog, and player settings through explicit clients. `SectorMapAsync` carries tutorial mode, generation seed, and faction relationships so Unity can lower the temporary observed galaxy without a whole daemon frame. | `SectorMap` no longer reads `ActionGameManager.TryGetObservedGalaxy`, passes `GalaxyZone`, or owns a legacy faction graph. `MainMenu` may not call `LatestAuthoritativeRunFrameAsync` for observed-game boot. | `verify-stage7d-unity-parity.ps1`; scoped search for `TryGetObservedGalaxy`, `GalaxyZone`, `Dictionary