797 lines
28 KiB
TypeScript
797 lines
28 KiB
TypeScript
import { encode } from "@msgpack/msgpack";
|
|
import { performance } from "node:perf_hooks";
|
|
import { CultMesh } from "cultmesh-ts";
|
|
import type {
|
|
CultMeshDocumentCatalog,
|
|
CultMeshDocumentPublicationSource,
|
|
CultMeshPublicationDocumentBinding,
|
|
CultMeshOperationContext,
|
|
CultMeshQueryContext,
|
|
CultMeshSurfaceCatalogIndexDiagnostic,
|
|
CultMeshVerse,
|
|
} from "cultmesh-ts";
|
|
import type {
|
|
CultNetDocumentPutRawMessage,
|
|
CultNetPeer,
|
|
} from "cultnet-ts";
|
|
import {
|
|
AetheriaRtsSchemas,
|
|
createAetheriaRuntimeGameDocuments,
|
|
createAetheriaRuntimeGameOperationHandles,
|
|
createAetheriaRuntimeGameVerseHandles,
|
|
createAetheriaRuntimeGameQueryHandles,
|
|
describeAetheriaRuntimeGameLiveFeedSurface,
|
|
describeAetheriaRuntimeGameQueryHandles,
|
|
describeAetheriaRuntimeGameSurfaceCatalog,
|
|
type AetheriaRuntimeDaemonCommandReceipt,
|
|
type AssetManifestDocument,
|
|
type AuthorityStatusDocument,
|
|
type DaemonHealthDocument,
|
|
type GravityViewportResponse,
|
|
type InventoryDocument,
|
|
type ObjectsViewportResponse,
|
|
type RenderSplatsViewportResponse,
|
|
type AetheriaRuntimeGameLiveFeedDiagnostic,
|
|
type AetheriaRuntimeGameQueryDiagnostic,
|
|
type AetheriaRuntimeGameSurfaceCatalogDiagnostic,
|
|
type AetheriaRuntimeGameOperationHandles,
|
|
type AetheriaRuntimeGameVerseHandles,
|
|
type AetheriaRuntimeGameDocuments,
|
|
type AetheriaRuntimeViewportFeedRequest,
|
|
type AetheriaRuntimeViewportFeedSnapshot,
|
|
type RtsSetMoveVectorRequest,
|
|
type RtsSetTargetRequest,
|
|
type SelectedObjectDocument,
|
|
type SelectedObjectRequest,
|
|
type StarbridgeSessionDocument,
|
|
type ViewportRequest,
|
|
type ViewportResponse,
|
|
} from "./aetheria-rts-bindings.js";
|
|
import {
|
|
readAuthorityStatusDocument,
|
|
readAssetManifestDocument,
|
|
readDaemonHealthDocument,
|
|
readGravityViewportDocument,
|
|
readInventoryDocument,
|
|
readObjectsViewportDocument,
|
|
readRenderSplatsViewportDocument,
|
|
readSelectedObjectDocument,
|
|
readStarbridgeSessionSummaryDocument,
|
|
readViewportDocument,
|
|
} from "./aetheria-rts-local-documents.js";
|
|
|
|
const connectionId = 0x43554c54;
|
|
|
|
type AetheriaPublicationDocumentSpec = CultMeshPublicationDocumentBinding & {
|
|
readonly localPath: string;
|
|
readonly remoteRecordKey?: string;
|
|
};
|
|
|
|
type AetheriaRuntimeGameQueryExecutors = {
|
|
mapViewport: (request: ViewportRequest) => Promise<ViewportResponse>;
|
|
objectsViewport: (request: ViewportRequest) => Promise<ObjectsViewportResponse>;
|
|
gravityViewport: (request: ViewportRequest) => Promise<GravityViewportResponse>;
|
|
renderSplatsViewport: (request: ViewportRequest) => Promise<RenderSplatsViewportResponse>;
|
|
selectedObject: (request: SelectedObjectRequest) => Promise<SelectedObjectDocument>;
|
|
inventory: (request: SelectedObjectRequest) => Promise<InventoryDocument>;
|
|
daemonHealth: () => Promise<DaemonHealthDocument>;
|
|
authorityStatus: () => Promise<AuthorityStatusDocument>;
|
|
starbridgeSession: () => Promise<StarbridgeSessionDocument>;
|
|
assetManifest: () => Promise<AssetManifestDocument>;
|
|
};
|
|
|
|
export type AetheriaCultMeshClientOptions = {
|
|
publicationMode?: "local" | "remote";
|
|
snapshotTimeoutMs?: number;
|
|
};
|
|
|
|
export type AetheriaCultMeshDaemonTarget = {
|
|
readonly uri: string;
|
|
readonly peerId?: string;
|
|
readonly verseId?: string;
|
|
readonly role?: string;
|
|
readonly endpoints?: readonly string[];
|
|
};
|
|
|
|
export type {
|
|
BodyView,
|
|
EntityStatus,
|
|
GravityViewportResponse,
|
|
GravityInfluence,
|
|
InventoryItem,
|
|
ObjectsViewportResponse,
|
|
AetheriaRuntimeViewportFeedRequest,
|
|
AetheriaRuntimeViewportFeedSnapshot,
|
|
RtsSetMoveVectorRequest,
|
|
RtsSetTargetRequest,
|
|
SelectedObjectDocument,
|
|
SelectedObjectRequest,
|
|
StarbridgeSessionDocument,
|
|
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;
|
|
private readonly daemonTarget: Required<AetheriaCultMeshDaemonTarget>;
|
|
|
|
public constructor(
|
|
daemonTarget: string | AetheriaCultMeshDaemonTarget,
|
|
statePath: string,
|
|
private readonly runtimeId = "aetheria-electron-client",
|
|
options: AetheriaCultMeshClientOptions = {},
|
|
) {
|
|
this.daemonTarget = normalizeDaemonTarget(daemonTarget);
|
|
const publicationMode = options.publicationMode ?? "local";
|
|
this.publicationDescription = publicationMode === "remote" ? this.daemonTarget.uri : statePath;
|
|
this.verse = CultMesh.verse("aetheria.local", this.runtimeId);
|
|
this.queryVerse = publicationMode === "remote"
|
|
? this.verse.withRoute("network", this.publicationDescription)
|
|
: this.verse.withRoute("shared-memory", this.publicationDescription);
|
|
this.commandVerse = this.verse
|
|
.withRoute("network", this.daemonTarget.uri)
|
|
.withClaim("commander-control", { shardId: "aetheria.local" });
|
|
this.publications = CultMesh.documentsFromPublication(
|
|
this.createPublicationSource(publicationMode),
|
|
createAetheriaPublicationDocuments(statePath, publicationMode),
|
|
{
|
|
routeHint: this.queryVerse.context.routeHint,
|
|
timeoutMs: options.snapshotTimeoutMs,
|
|
pollMs: 50,
|
|
messageIdPrefix: `${this.runtimeId}:snapshot`,
|
|
},
|
|
);
|
|
const executors = {
|
|
mapViewport: async (request: ViewportRequest) =>
|
|
readViewportDocument(await this.fetchViewportDocument(AetheriaRtsSchemas.gameViewport, "aetheria.viewport.map", request)),
|
|
objectsViewport: async (request: ViewportRequest) =>
|
|
readObjectsViewportDocument(await this.fetchViewportDocument(AetheriaRtsSchemas.objectsViewport, "aetheria.viewport.objects", request)),
|
|
gravityViewport: async (request: ViewportRequest) =>
|
|
readGravityViewportDocument(await this.fetchViewportDocument(AetheriaRtsSchemas.gravityViewport, "aetheria.viewport.gravity", request)),
|
|
renderSplatsViewport: async (request: ViewportRequest) =>
|
|
readRenderSplatsViewportDocument(await this.fetchViewportDocument(AetheriaRtsSchemas.renderSplatsViewport, "aetheria.viewport.render_splats", request)),
|
|
selectedObject: async (request: SelectedObjectRequest) =>
|
|
readSelectedObjectDocument(await this.fetchIndexedDocument(AetheriaRtsSchemas.selectedObject, "aetheria.object.selected", request.entityIndex)),
|
|
inventory: async (request: SelectedObjectRequest) =>
|
|
readInventoryDocument(await this.fetchIndexedDocument(AetheriaRtsSchemas.inventory, "aetheria.inventory", request.entityIndex)),
|
|
daemonHealth: async () => readDaemonHealthDocument(await this.fetchDaemonHealthDocument()),
|
|
authorityStatus: async () => readAuthorityStatusDocument(await this.fetchAuthorityPolicyDocument()),
|
|
starbridgeSession: async () => readStarbridgeSessionSummaryDocument(await this.fetchStarbridgeSessionSummaryDocument()),
|
|
assetManifest: async () => readAssetManifestDocument(await this.fetchAssetManifestDocument()),
|
|
};
|
|
this.queryExecutors = executors;
|
|
this.queries = createAetheriaRuntimeGameQueryHandles(
|
|
executors,
|
|
this.queryVerse.context.routeHint,
|
|
{
|
|
objectsViewport: CultMesh.pollingQueryWatcher(executors.objectsViewport, { intervalMs: 50 }),
|
|
gravityViewport: CultMesh.pollingQueryWatcher(executors.gravityViewport, { intervalMs: 50 }),
|
|
renderSplatsViewport: CultMesh.pollingQueryWatcher(executors.renderSplatsViewport, { 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 }),
|
|
assetManifest: CultMesh.pollingQueryWatcher(executors.assetManifest, { intervalMs: 1000 }),
|
|
},
|
|
);
|
|
this.documents = createAetheriaRuntimeGameDocuments(
|
|
this.queryVerse.context.routeHint,
|
|
{
|
|
daemonFrame: async () => this.fetchLatestFrameDocument(),
|
|
daemonHealth: async () => this.fetchDaemonHealthDocument(),
|
|
authorityPolicy: async () => this.fetchAuthorityPolicyDocument(),
|
|
starbridgeSession: async () => this.fetchStarbridgeSessionSummaryDocument(),
|
|
assetManifest: async () => this.fetchAssetManifestDocument(),
|
|
},
|
|
);
|
|
this.operations = createAetheriaRuntimeGameOperationHandles(
|
|
(commandId, issuedAtUtc, command, context) =>
|
|
this.sendCommandDocument(commandId, issuedAtUtc, command, context),
|
|
);
|
|
this.aetheria = createAetheriaRuntimeGameVerseHandles(
|
|
this.queryVerse.context,
|
|
this.commandVerse.context,
|
|
this.queries,
|
|
this.operations,
|
|
this.documents,
|
|
);
|
|
}
|
|
|
|
private readonly publicationDescription: string;
|
|
private readonly publications: CultMeshDocumentCatalog;
|
|
private readonly queryExecutors: AetheriaRuntimeGameQueryExecutors;
|
|
private readonly queries: ReturnType<typeof createAetheriaRuntimeGameQueryHandles>;
|
|
private readonly documents: AetheriaRuntimeGameDocuments;
|
|
private readonly operations: AetheriaRuntimeGameOperationHandles;
|
|
private readonly aetheria: AetheriaRuntimeGameVerseHandles;
|
|
|
|
public async close(): Promise<void> {
|
|
this.#peer?.close();
|
|
this.#peer = null;
|
|
}
|
|
|
|
public async waitForFrame(timeoutMs: number): Promise<void> {
|
|
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.daemonTarget.uri}. ${lastError}`);
|
|
}
|
|
|
|
public async mapViewport(request: ViewportRequest): Promise<ViewportResponse> {
|
|
return this.aetheria.zone().viewport.within(request);
|
|
}
|
|
|
|
public async objectsViewport(request: ViewportRequest): Promise<ObjectsViewportResponse> {
|
|
return this.aetheria.zone().objects.visibleWithin(request);
|
|
}
|
|
|
|
public async gravityViewport(request: ViewportRequest): Promise<GravityViewportResponse> {
|
|
return this.aetheria.zone().gravity.within(request);
|
|
}
|
|
|
|
public async renderSplatsViewport(request: ViewportRequest): Promise<RenderSplatsViewportResponse> {
|
|
return this.aetheria.zone().renderSplats.within(request);
|
|
}
|
|
|
|
public async selectedObject(request: SelectedObjectRequest): Promise<SelectedObjectDocument> {
|
|
return this.aetheria.selectedObject(request.entityIndex);
|
|
}
|
|
|
|
public async inventory(request: SelectedObjectRequest): Promise<InventoryDocument> {
|
|
return this.aetheria.inventory(request.entityIndex);
|
|
}
|
|
|
|
public async daemonHealth(): Promise<DaemonHealthDocument> {
|
|
return this.aetheria.daemon.health();
|
|
}
|
|
|
|
public async authorityStatus(): Promise<AuthorityStatusDocument> {
|
|
return this.aetheria.daemon.authorityStatus();
|
|
}
|
|
|
|
public async starbridgeSession(): Promise<StarbridgeSessionDocument> {
|
|
return this.aetheria.daemon.starbridgeSession();
|
|
}
|
|
|
|
public async assetManifest(): Promise<AssetManifestDocument> {
|
|
return this.aetheria.daemon.assetManifest();
|
|
}
|
|
|
|
public queryDiagnostics(): Readonly<Record<string, AetheriaRuntimeGameQueryDiagnostic>> {
|
|
return describeAetheriaRuntimeGameQueryHandles(this.queries);
|
|
}
|
|
|
|
public liveFeedDiagnostics(): Readonly<Record<string, AetheriaRuntimeGameLiveFeedDiagnostic>> {
|
|
return {
|
|
viewportFeed: describeAetheriaRuntimeGameLiveFeedSurface(this.createViewportFeed()),
|
|
};
|
|
}
|
|
|
|
public surfaceCatalogDiagnostics(): AetheriaRuntimeGameSurfaceCatalogDiagnostic {
|
|
const catalog = describeAetheriaRuntimeGameSurfaceCatalog(this.queries, this.operations, this.documents);
|
|
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<AetheriaRuntimeDaemonCommandReceipt> {
|
|
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<AetheriaRuntimeDaemonCommandReceipt> {
|
|
return this.aetheria.entity(request.actorEntityKey).pilot.target(request.targetEntityKey, {
|
|
observedFrameId: request.observedFrameId,
|
|
});
|
|
}
|
|
|
|
private async fetchLatestFrameDocument(): Promise<unknown> {
|
|
return this.fetchPublicationDocument(AetheriaRtsSchemas.daemonFrame);
|
|
}
|
|
|
|
private async fetchDaemonHealthDocument(): Promise<unknown> {
|
|
return this.fetchPublicationDocument(AetheriaRtsSchemas.daemonHealth);
|
|
}
|
|
|
|
private async fetchAuthorityPolicyDocument(): Promise<unknown> {
|
|
return this.fetchPublicationDocument(AetheriaRtsSchemas.verseAuthorityPolicy);
|
|
}
|
|
|
|
private async fetchStarbridgeSessionSummaryDocument(): Promise<unknown> {
|
|
return this.fetchPublicationDocument(AetheriaRtsSchemas.starbridgeSessionSummary);
|
|
}
|
|
|
|
private async fetchAssetManifestDocument(): Promise<unknown> {
|
|
return this.fetchPublicationDocument(AetheriaRtsSchemas.assetManifest);
|
|
}
|
|
|
|
private async fetchViewportDocument(
|
|
schemaId: string,
|
|
recordPrefix: string,
|
|
request: ViewportRequest,
|
|
): Promise<unknown> {
|
|
const recordKey = managedViewportRecordKey(recordPrefix, request);
|
|
const document = CultMesh.documentFromPublication(
|
|
{
|
|
kind: "peer-snapshot",
|
|
peer: () => this.peer(),
|
|
endpoint: this.resolvedRudpEndpoint(),
|
|
},
|
|
schemaId,
|
|
recordKey,
|
|
{
|
|
documentId: recordKey,
|
|
routeHint: this.queryVerse.context.routeHint,
|
|
sourceId: recordKey,
|
|
timeoutMs: 1500,
|
|
pollMs: 50,
|
|
messageIdPrefix: `${this.runtimeId}:viewport`,
|
|
},
|
|
);
|
|
|
|
return retryTransientPublicationRead(() => document.latest(this.queryContext()));
|
|
}
|
|
|
|
private async fetchIndexedDocument(
|
|
schemaId: string,
|
|
recordPrefix: string,
|
|
entityIndex: number,
|
|
): Promise<unknown> {
|
|
const recordKey = `${recordPrefix}.${Math.trunc(entityIndex)}`;
|
|
const document = CultMesh.documentFromPublication(
|
|
{
|
|
kind: "peer-snapshot",
|
|
peer: () => this.peer(),
|
|
endpoint: this.resolvedRudpEndpoint(),
|
|
},
|
|
schemaId,
|
|
recordKey,
|
|
{
|
|
documentId: recordKey,
|
|
routeHint: this.queryVerse.context.routeHint,
|
|
sourceId: recordKey,
|
|
timeoutMs: 1500,
|
|
pollMs: 50,
|
|
messageIdPrefix: `${this.runtimeId}:indexed-document`,
|
|
},
|
|
);
|
|
|
|
return retryTransientPublicationRead(() => document.latest(this.queryContext()));
|
|
}
|
|
|
|
private fetchPublicationDocument(schemaId: string): Promise<unknown> {
|
|
return retryTransientPublicationRead(() => this.publications.latest({ schemaId }, this.queryContext()));
|
|
}
|
|
|
|
private createPublicationSource(
|
|
mode: "local" | "remote",
|
|
): (binding: CultMeshPublicationDocumentBinding) => CultMeshDocumentPublicationSource {
|
|
if (mode === "remote") {
|
|
return () => ({
|
|
kind: "peer-snapshot",
|
|
peer: () => this.peer(),
|
|
endpoint: this.resolvedRudpEndpoint(),
|
|
});
|
|
}
|
|
|
|
return binding => ({
|
|
kind: "single-file",
|
|
path: (binding as AetheriaPublicationDocumentSpec).localPath,
|
|
});
|
|
}
|
|
|
|
private createViewportFeed() {
|
|
return CultMesh.liveFeed<AetheriaRuntimeViewportFeedRequest, AetheriaRuntimeViewportFeedSnapshot>(
|
|
"gamecult.aetheria.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,
|
|
...this.queries.assetManifest.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<AetheriaRuntimeViewportFeedSnapshot> {
|
|
const startedAt = performance.now();
|
|
const selectedEntityIndex = request.selectedEntityIndex ?? -1;
|
|
const [
|
|
gravity,
|
|
daemonHealth,
|
|
authorityStatus,
|
|
starbridgeSession,
|
|
assetManifest,
|
|
selectedObject,
|
|
inventory,
|
|
] = await Promise.all([
|
|
this.queryExecutors.gravityViewport(request.viewport),
|
|
this.queryExecutors.daemonHealth(),
|
|
this.queryExecutors.authorityStatus(),
|
|
this.queryExecutors.starbridgeSession(),
|
|
this.queryExecutors.assetManifest(),
|
|
selectedEntityIndex >= 0
|
|
? this.queryExecutors.selectedObject({ entityIndex: selectedEntityIndex })
|
|
: Promise.resolve(null),
|
|
selectedEntityIndex >= 0
|
|
? this.queryExecutors.inventory({ entityIndex: selectedEntityIndex })
|
|
: Promise.resolve(null),
|
|
]);
|
|
|
|
return {
|
|
viewport: composeViewport(objects, gravity),
|
|
selectedObject,
|
|
inventory,
|
|
daemonHealth,
|
|
authorityStatus,
|
|
starbridgeSession,
|
|
assetManifest,
|
|
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<void> {
|
|
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: "aetheria-client",
|
|
tags: ["aetheria"],
|
|
},
|
|
};
|
|
const peer = await this.peer();
|
|
peer.send(message);
|
|
await delay(80);
|
|
}
|
|
|
|
private async peer(): Promise<CultNetPeer> {
|
|
if (this.#peer)
|
|
return this.#peer;
|
|
const peers = CultMesh.createPeerCatalog();
|
|
const leases = CultMesh.createAuthorityLeaseCatalog();
|
|
const leaseId = `${this.daemonTarget.peerId}:authority`;
|
|
peers.upsert({
|
|
peerId: this.daemonTarget.peerId,
|
|
verseId: this.daemonTarget.verseId,
|
|
endpoints: this.daemonTarget.endpoints,
|
|
roles: [this.daemonTarget.role],
|
|
authorityLeaseId: leaseId,
|
|
});
|
|
leases.upsert({
|
|
leaseId,
|
|
verseId: this.daemonTarget.verseId,
|
|
peerId: this.daemonTarget.peerId,
|
|
roles: [this.daemonTarget.role],
|
|
validFrom: new Date(Date.now() - 60_000),
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
this.#peer = await CultMesh.createRudpPeerForAuthorizedPeer(
|
|
this.runtimeId,
|
|
connectionId,
|
|
peers,
|
|
leases,
|
|
this.daemonTarget.verseId,
|
|
this.daemonTarget.role,
|
|
{
|
|
connectTimeoutMs: 2000,
|
|
maxFragmentBytes: 1200,
|
|
maxPendingReliablePackets: 512,
|
|
},
|
|
);
|
|
this.#peer.on("close", () => {
|
|
this.#peer = null;
|
|
});
|
|
return this.#peer;
|
|
}
|
|
|
|
private resolvedRudpEndpoint(): string {
|
|
const endpoint = this.daemonTarget.endpoints.find(value => value.toLowerCase().startsWith("rudp://"));
|
|
if (!endpoint) {
|
|
throw new Error(`Daemon ${this.daemonTarget.uri} has no resolved RUDP transport endpoint. Resolve it through Odin/CultMesh before opening a transport peer.`);
|
|
}
|
|
return endpoint;
|
|
}
|
|
}
|
|
|
|
function normalizeDaemonTarget(target: string | AetheriaCultMeshDaemonTarget): Required<AetheriaCultMeshDaemonTarget> {
|
|
const value = typeof target === "string"
|
|
? { uri: target }
|
|
: target;
|
|
if (!value.uri.trim()) {
|
|
throw new Error("Aetheria CultMesh daemon target URI is required.");
|
|
}
|
|
if (value.uri.toLowerCase().startsWith("rudp://")) {
|
|
throw new Error("Aetheria daemon targets must be CultMesh URIs; raw RUDP endpoints belong in resolved peer-card endpoints.");
|
|
}
|
|
return {
|
|
uri: value.uri,
|
|
peerId: value.peerId?.trim() || "aetheria-daemon",
|
|
verseId: value.verseId?.trim() || "aetheria.local",
|
|
role: value.role?.trim() || "aetheria-daemon",
|
|
endpoints: value.endpoints ?? [],
|
|
};
|
|
}
|
|
|
|
function managedViewportRecordKey(prefix: string, request: ViewportRequest): string {
|
|
const minX = Math.min(finiteNumber(request.minX), finiteNumber(request.maxX));
|
|
const minY = Math.min(finiteNumber(request.minY), finiteNumber(request.maxY));
|
|
const maxX = Math.max(finiteNumber(request.minX), finiteNumber(request.maxX));
|
|
const maxY = Math.max(finiteNumber(request.minY), finiteNumber(request.maxY));
|
|
return `${prefix}.${viewportToken(minX)}.${viewportToken(minY)}.${viewportToken(maxX)}.${viewportToken(maxY)}`;
|
|
}
|
|
|
|
function viewportToken(value: number): string {
|
|
const rounded = Math.round(value * 1000) / 1000;
|
|
return rounded
|
|
.toString()
|
|
.replace("-", "n")
|
|
.replace(".", "p");
|
|
}
|
|
|
|
function finiteNumber(value: unknown): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
async function retryTransientPublicationRead<T>(read: () => Promise<T>): Promise<T> {
|
|
let lastError: unknown;
|
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
try {
|
|
return await read();
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (!isTransientPublicationReadError(error))
|
|
throw error;
|
|
await delay(25 + attempt * 8);
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
function isTransientPublicationReadError(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return message.includes("EBUSY") ||
|
|
message.includes("EPERM") ||
|
|
message.includes("ENOENT") ||
|
|
message.includes("did not contain schema");
|
|
}
|
|
|
|
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.game_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 createAetheriaPublicationDocuments(
|
|
statePath: string,
|
|
mode: "local" | "remote",
|
|
): readonly AetheriaPublicationDocumentSpec[] {
|
|
const documents: readonly AetheriaPublicationDocumentSpec[] = [
|
|
{
|
|
...CultMesh.publicationDocument(
|
|
AetheriaRtsSchemas.daemonFrame,
|
|
"daemon:aetheria.frame.latest.v1",
|
|
{
|
|
documentId: "daemon:aetheria.frame.latest.v1",
|
|
sourceId: "daemon:aetheria.frame.latest.v1",
|
|
},
|
|
),
|
|
localPath: statePath,
|
|
},
|
|
{
|
|
...CultMesh.publicationDocument(
|
|
AetheriaRtsSchemas.daemonHealth,
|
|
"daemon:aetheria.health.latest.v1",
|
|
{
|
|
documentId: "daemon:aetheria.health.latest.v1",
|
|
sourceId: "daemon:aetheria.health.latest.v1",
|
|
},
|
|
),
|
|
localPath: statePath,
|
|
remoteRecordKey: "daemon:aetheria.health.v1",
|
|
},
|
|
{
|
|
...CultMesh.publicationDocument(
|
|
AetheriaRtsSchemas.verseAuthorityPolicy,
|
|
"daemon:aetheria.authority.policy.latest.v1",
|
|
{
|
|
documentId: "daemon:aetheria.authority.policy.latest.v1",
|
|
sourceId: "daemon:aetheria.authority.policy.latest.v1",
|
|
},
|
|
),
|
|
localPath: statePath,
|
|
remoteRecordKey: "global:aetheria.verse_authority_policy.v1",
|
|
},
|
|
{
|
|
...CultMesh.publicationDocument(
|
|
AetheriaRtsSchemas.starbridgeSessionSummary,
|
|
"daemon:aetheria.starbridge.session.latest.v1",
|
|
{
|
|
documentId: "daemon:aetheria.starbridge.session.latest.v1",
|
|
sourceId: "daemon:aetheria.starbridge.session.latest.v1",
|
|
},
|
|
),
|
|
localPath: statePath,
|
|
},
|
|
{
|
|
...CultMesh.publicationDocument(
|
|
AetheriaRtsSchemas.assetManifest,
|
|
"daemon:aetheria.asset_manifest.latest.v1",
|
|
{
|
|
documentId: "daemon:aetheria.asset_manifest.latest.v1",
|
|
sourceId: "daemon:aetheria.asset_manifest.latest.v1",
|
|
},
|
|
),
|
|
localPath: statePath,
|
|
},
|
|
];
|
|
|
|
return documents.map(document => mode === "remote" && document.remoteRecordKey
|
|
? {
|
|
...document,
|
|
recordKey: document.remoteRecordKey,
|
|
}
|
|
: document);
|
|
}
|
|
|
|
function readField(value: unknown, property: string, index: number): unknown {
|
|
if (Array.isArray(value))
|
|
return value[index];
|
|
if (value && typeof value === "object") {
|
|
const record = value as Record<string, unknown>;
|
|
if (property in record)
|
|
return record[property];
|
|
const pascal = property.charAt(0).toUpperCase() + property.slice(1);
|
|
if (pascal in record)
|
|
return record[pascal];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function arrayValue(value: unknown): unknown[] {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function normalizeCommandPayload(payload: Record<string, unknown> | undefined): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(payload ?? {})) {
|
|
if (value == null)
|
|
continue;
|
|
result[key] = typeof value === "string" ? value : String(value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function objectValue(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
function stringOr(value: unknown, fallback: string): string {
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function numberOr(value: unknown, fallback: number): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function stringRecord(value: unknown): Record<string, string> {
|
|
const source = objectValue(value);
|
|
const result: Record<string, string> = {};
|
|
for (const [key, entry] of Object.entries(source)) {
|
|
if (entry == null)
|
|
continue;
|
|
result[key] = typeof entry === "string" ? entry : String(entry);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function delay(milliseconds: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
}
|